diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cf8cd2f..d9846617 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 61cf0856..6c7eebe9 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/openapi.yaml b/openapi.yaml index a89abce7..9ae560ce 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -4801,7 +4801,28 @@ 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 @@ -4809,6 +4830,49 @@ paths: 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: diff --git a/packages/engine/CHANGELOG.md b/packages/engine/CHANGELOG.md index d7f1ab15..d825ed38 100644 --- a/packages/engine/CHANGELOG.md +++ b/packages/engine/CHANGELOG.md @@ -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 diff --git a/packages/engine/src/engine/__tests__/a2a.test.ts b/packages/engine/src/engine/__tests__/a2a.test.ts index ec2e5ec5..badacc7f 100644 --- a/packages/engine/src/engine/__tests__/a2a.test.ts +++ b/packages/engine/src/engine/__tests__/a2a.test.ts @@ -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 }; + 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 }; + 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 }; + 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' }, + }); + }); +}); diff --git a/packages/engine/src/routes/a2a.ts b/packages/engine/src/routes/a2a.ts index 2cf9a8fc..6dc11b32 100644 --- a/packages/engine/src/routes/a2a.ts +++ b/packages/engine/src/routes/a2a.ts @@ -62,6 +62,13 @@ function extractWorkspaceHint(c: Context): 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); @@ -70,7 +77,7 @@ function extractWorkspaceHint(c: Context): string | null { return hostSegments[0]!; } - return c.req.param('workspace') || null; + return null; } function extractTargetAgentName(params: Record | undefined, fallbackContextId?: string): string | null { @@ -228,6 +235,38 @@ async function handleWorkspaceAgentCard(c: Context) { } } + // 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') + && !c.req.param('workspace') + ) { + const candidates = await db.select().from(workspaces).limit(2); + if (candidates.length === 1) { + workspace = candidates[0]!; + } + } + if (!workspace) { return jsonNotFound(c, 'workspace_not_found', 'Workspace could not be inferred from request. Provide an Authorization header or ?workspace= query param.'); } diff --git a/packages/types/src/__tests__/sdk-openapi-sync.test.ts b/packages/types/src/__tests__/sdk-openapi-sync.test.ts index 46e0e24c..08807ab7 100644 --- a/packages/types/src/__tests__/sdk-openapi-sync.test.ts +++ b/packages/types/src/__tests__/sdk-openapi-sync.test.ts @@ -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,