Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,11 @@ This project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

Packages without a separate changelog are covered by the cross-package notes below.

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- A2A counterparties can discover a self-hosted deployment at the standard `GET /.well-known/agent-card.json`, and `/:workspace/.well-known/agent-card.json` now resolves. Deployments holding more than one workspace still require a selector.

## [7.0.0] - 2026-08-07

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -649,7 +649,8 @@ POST /v1/a2a/register Register an external A2A agent
GET /v1/a2a/agents List registered A2A agents
DELETE /v1/a2a/agents/:name Remove an A2A agent
GET /v1/a2a/agents/:name/card Get agent card for a registered agent
GET /.well-known/agent-card.json A2A agent card (root-level)
GET /.well-known/agent-card.json A2A agent card (root-level; ?workspace= selects on multi-tenant)
GET /:workspace/.well-known/agent-card.json A2A agent card for a named workspace
POST /a2a/rpc A2A JSON-RPC gateway (root-level)
POST /a2a/webhook/:ws/:name Inbound webhook for relay agents
```
Expand Down
64 changes: 64 additions & 0 deletions openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -4801,14 +4801,78 @@ paths:
description: Local development server
get:
summary: Workspace A2A agent card
description: >
Unauthenticated A2A discovery. The workspace is resolved in order:
Authorization header, `?workspace=` query parameter, an explicit
`/:workspace/` path segment, then the first host label (the hosted
workspace-per-subdomain convention). A deployment holding exactly one
workspace answers this bare path with that workspace; a deployment
holding more than one resolves only if one of those mechanisms names a
workspace — a valid workspace subdomain is sufficient on its own — and
otherwise returns 404. An explicit query or path selector that does not
resolve always returns 404 rather than falling back to any other
workspace.
tags: [A2A]
parameters:
- in: query
name: workspace
required: false
schema:
type: string
description: >
Workspace name. Needed on a deployment holding more than one
workspace unless the request host already identifies one — a valid
workspace subdomain resolves without it.
responses:
'200':
description: Agent card JSON
content:
application/json:
schema:
type: object
'404':
description: >
`workspace_not_found` — no selector resolved a workspace, or an
explicit selector named one that does not exist.
content:
application/json:
schema:
type: object

/{workspace}/.well-known/agent-card.json:
servers:
- url: https://cast.agentrelay.com
description: Production server (root, no /v1 prefix)
- url: http://localhost:8787
description: Local development server
get:
summary: Workspace A2A agent card, path-scoped
description: >
Path-scoped form of A2A discovery. The path segment takes precedence
over host-label inference, so this route resolves on multi-label
authorities. A segment naming a workspace that does not exist returns
404 rather than falling back to any other workspace.
tags: [A2A]
parameters:
- in: path
name: workspace
required: true
schema:
type: string
description: Workspace name.
responses:
'200':
description: Agent card JSON
content:
application/json:
schema:
type: object
'404':
description: '`workspace_not_found` — the named workspace does not exist.'
content:
application/json:
schema:
type: object

/a2a/register:
post:
Expand Down
6 changes: 5 additions & 1 deletion packages/engine/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,11 @@ See the [root changelog](../../CHANGELOG.md) for cross-package release highlight
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]
## [Unreleased - Patch]

### Fixed

- Agent-card discovery resolves the workspace from an explicit `/:workspace/` path segment before host-label inference, and serves the sole workspace when a deployment has exactly one and no selector was given. Deployments with more than one workspace, and unresolved explicit selectors, return `workspace_not_found`.

## [7.0.0] - 2026-08-07

Expand Down
114 changes: 114 additions & 0 deletions packages/engine/src/engine/__tests__/a2a.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,3 +470,117 @@ describe('getWorkspaceAgentCard', () => {
]);
});
});

describe('workspace agent-card discovery', () => {
let stack: TestStack;

beforeEach(() => {
stack = makeNodeStack();
});

afterEach(() => stack.close());

it('serves the standard bare well-known path for a sole self-hosted workspace', async () => {
const ws = await createWorkspace(stack.app, 'borealis');
await registerAgent(stack.app, ws.workspaceKey, 'worker');

const response = await stack.app.request(
'https://relay.borealis.example/.well-known/agent-card.json',
);

expect(response.status).toBe(200);
const card = await response.json() as { provider?: Record<string, unknown> };
expect(card.provider).toMatchObject({
workspace_id: ws.workspaceId,
workspace_name: 'borealis',
});
});

it('keeps a valid hosted workspace subdomain authoritative', async () => {
const selected = await createWorkspace(stack.app, 'northwind');
await createWorkspace(stack.app, 'borealis');

const response = await stack.app.request(
'https://northwind.cast.example/.well-known/agent-card.json',
);

expect(response.status).toBe(200);
const card = await response.json() as { provider?: Record<string, unknown> };
expect(card.provider).toMatchObject({
workspace_id: selected.workspaceId,
workspace_name: 'northwind',
});
});

it('makes an explicit path workspace beat host inference on a multi-label authority', async () => {
const selected = await createWorkspace(stack.app, 'borealis');
await createWorkspace(stack.app, 'cast');

const response = await stack.app.request(
'https://cast.agentrelay.com/borealis/.well-known/agent-card.json',
);

expect(response.status).toBe(200);
const card = await response.json() as { provider?: Record<string, unknown> };
expect(card.provider).toMatchObject({
workspace_id: selected.workspaceId,
workspace_name: 'borealis',
});
});

it('does not replace an invalid path workspace with a valid host workspace', async () => {
await createWorkspace(stack.app, 'cast');

const response = await stack.app.request(
'https://cast.agentrelay.com/misspelled/.well-known/agent-card.json',
);

expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'workspace_not_found' },
});
});

it('fails closed on a bare multi-tenant deployment instead of selecting a workspace', async () => {
await createWorkspace(stack.app, 'northwind');
await createWorkspace(stack.app, 'borealis');

const response = await stack.app.request(
'https://cast.agentrelay.com/.well-known/agent-card.json',
);

expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'workspace_not_found' },
});
});

it('serves the sole workspace even when an unresolved host label was inferred', async () => {
// Host-label inference is a hosted convention, not caller intent, so a
// label that names no workspace does not suppress the single-tenant
// fallback — otherwise a self-host could never answer the standard path,
// since a conformant authority always yields some first label. The row cap
// is the boundary: the multi-tenant case above 404s on this same request.
await createWorkspace(stack.app, 'ratify-protocol');

const response = await stack.app.request(
'https://relay.ratifyprotocol.com/.well-known/agent-card.json',
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ name: 'ratify-protocol' });
});

it('does not hide an invalid explicit workspace selector behind the sole-workspace fallback', async () => {
await createWorkspace(stack.app, 'borealis');

const response = await stack.app.request(
'https://relay.borealis.example/.well-known/agent-card.json?workspace=misspelled',
);

expect(response.status).toBe(404);
await expect(response.json()).resolves.toMatchObject({
error: { code: 'workspace_not_found' },
});
});
});
41 changes: 40 additions & 1 deletion packages/engine/src/routes/a2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,13 @@ function extractWorkspaceHint(c: Context<AppEnv>): string | null {
const explicit = c.req.query('workspace');
if (explicit) return explicit;

// An explicit path selector must beat host inference. Previously the host
// branch was consulted first, so on any authority with three or more labels
// — which the Relay identifier profile requires — the documented
// `/:workspace/.well-known/agent-card.json` route could never take effect.
const pathWorkspace = c.req.param('workspace');
if (pathWorkspace) return pathWorkspace;

const host = c.req.header('Host') ?? new URL(c.req.url).host;
const hostname = host.split(':')[0] ?? '';
const hostSegments = hostname.split('.').filter(Boolean);
Expand All @@ -70,7 +77,7 @@ function extractWorkspaceHint(c: Context<AppEnv>): string | null {
return hostSegments[0]!;
}

return c.req.param('workspace') || null;
return null;
}

function extractTargetAgentName(params: Record<string, unknown> | undefined, fallbackContextId?: string): string | null {
Expand Down Expand Up @@ -228,6 +235,38 @@ async function handleWorkspaceAgentCard(c: Context<AppEnv>) {
}
}

// A self-hosted, single-tenant deployment must answer the standard bare
// well-known URL without requiring a Relaycast-specific query parameter.
//
// Two guards, and the distinction between them is deliberate:
//
// 1. An *explicit* selector is caller intent, so a misspelled `?workspace=`
// or `/:workspace/` must 404 rather than silently resolving to a
// different tenant. Host-label inference is not caller intent — it is a
// hosted workspace-per-subdomain convention, and on any authority with
// three or more labels it always produces a candidate. Treating it as an
// explicit selector would mean the fallback never fires on exactly the
// deployments it exists for.
// 2. The row cap is what makes that safe: with two or more workspaces the
// fallback declines rather than guessing, so there is no tenant boundary
// to cross. It is `limit(2)` rather than a count so a large table is
// never scanned.
//
// Net effect: on a multi-tenant deployment an unresolved host label 404s;
// on a single-tenant one it serves the only workspace there is. The card is
// unauthenticated by design (A2A discovery), so this exposes nothing that
// the standard well-known path is not already meant to publish.
if (
!workspace
&& !c.req.query('workspace')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: An empty ?workspace= selector is treated as absent and returns the sole workspace card rather than failing closed. Check query-key presence in the fallback guard so supplied-but-empty selectors produce workspace_not_found.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/engine/src/routes/a2a.ts, line 246:

<comment>An empty `?workspace=` selector is treated as absent and returns the sole workspace card rather than failing closed. Check query-key presence in the fallback guard so supplied-but-empty selectors produce `workspace_not_found`.</comment>

<file context>
@@ -228,6 +235,23 @@ async function handleWorkspaceAgentCard(c: Context<AppEnv>) {
+    // closed without scanning the workspace table.
+    if (
+      !workspace
+      && !c.req.query('workspace')
+      && !c.req.param('workspace')
+    ) {
</file context>
Suggested change
&& !c.req.query('workspace')
&& !new URL(c.req.url).searchParams.has('workspace')

&& !c.req.param('workspace')
Comment on lines +260 to +262

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Treat an empty workspace query as explicit

When a deployment has exactly one workspace and a caller sends ?workspace=, the query value is an empty string, so both extraction and this truthiness guard treat the selector as absent and return the sole workspace's card. This silently replaces a malformed explicit selector despite the new fail-loud contract; test parameter presence separately from whether its value is nonempty so this request returns workspace_not_found.

Useful? React with 👍 / 👎.

) {
const candidates = await db.select().from(workspaces).limit(2);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Update README and OpenAPI with the discovery contract

This changes the public agent-card resolution contract by adding sole-workspace fallback and making the path selector authoritative, but README.md still lists only the root route and openapi.yaml documents neither selector nor fallback/error semantics. Consumers therefore cannot discover the supported /:workspace/ form or determine when the bare URL returns 404; update both sources alongside this behavior change.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

if (candidates.length === 1) {
workspace = candidates[0]!;
}
}
Comment on lines +259 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Discovery endpoint behaviour changed without matching API documentation updates

The public agent-card discovery endpoint changed how it picks a workspace (new sole-workspace fallback added at packages/engine/src/routes/a2a.ts:244-253 and reordered selection at packages/engine/src/routes/a2a.ts:65-80) without the required matching updates to README.md and openapi.yaml, so the published API description no longer matches the server.
Impact: Integrators reading the docs cannot tell how discovery resolves a workspace, including the /:workspace/ path form and the ?workspace= selector.

Repository documentation rule and what is missing

AGENTS.md ("Docs Hygiene") mandates: "Update README.md and openapi.yaml together when API behavior changes." This PR changes observable behaviour of GET /.well-known/agent-card.json (a bare request on a single-workspace deployment now returns 200 instead of 404) and makes the /:workspace/.well-known/agent-card.json route functional for the first time on multi-label authorities. README.md:652 lists only the bare route, and openapi.yaml:4796-4810 documents no workspace query parameter, no path-scoped variant, and no 404 workspace_not_found response. Neither file is touched by this PR.

Prompt for agents
AGENTS.md requires README.md and openapi.yaml to be updated together whenever API behaviour changes. This PR changes the behaviour of GET /.well-known/agent-card.json (single-workspace deployments now answer the bare path; an explicit /:workspace/ path selector now takes precedence over host inference) but does not touch the docs. Update openapi.yaml's /.well-known/agent-card.json entry to document the optional ?workspace= query parameter, the /:workspace/.well-known/agent-card.json path variant, and the 404 workspace_not_found response, and add the path-scoped route to the A2A route list in README.md.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +259 to +268

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟨 Unauthenticated agent-card discovery can leak a workspace's agent roster when host selector is wrong

The unauthenticated discovery handler now falls back to the only workspace in the database whenever no query/path selector is present (packages/engine/src/routes/a2a.ts:244-253). This fallback also triggers when a host-label selector was supplied but did not resolve, so any request to any hostname on a deployment holding exactly one workspace returns that workspace's card, which enumerates every agent name in the workspace (getWorkspaceAgentCard). On the hosted gateway this makes an internal workspace's agent roster reachable without credentials at a time when the table holds a single workspace.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


if (!workspace) {
return jsonNotFound(c, 'workspace_not_found', 'Workspace could not be inferred from request. Provide an Authorization header or ?workspace= query param.');
}
Expand Down
4 changes: 4 additions & 0 deletions packages/types/src/__tests__/sdk-openapi-sync.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ const IGNORED_SEGMENTS = new Set([
const NON_SDK_OPENAPI_PATHS = new Set([
'/v1/health',
'/v1/.well-known/agent-card.json',
// Path-scoped form of the same unauthenticated A2A discovery endpoint. Served
// for counterparties that name the workspace explicitly; like the bare form
// it is not an agent-SDK surface.
'/v1/{param}/.well-known/agent-card.json',
'/v1/a2a/rpc',
'/v1/a2a/webhook/{param}/{param}',
// Provider integration endpoints are provisioned/called by relayfile-cloud,
Expand Down
Loading