diff --git a/CLAUDE.md b/CLAUDE.md index 88dbdc7..53fbaac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -49,7 +49,11 @@ scripts/smoke.sh web/express-openid my-example It uses throwaway credentials and needs no Vouch account. Probes are chosen per category: web apps must render `/`; static SPAs must render `/` **and** have their `__VOUCH_*` placeholders substituted into the built bundle (`entrypoint.sh` exits 0 even when its `sed` glob matches nothing, so this is the only thing catching a bundler output-layout change); MCP and A2A servers must serve their well-known metadata and reject unauthenticated calls with 401; native CLIs must get past module loading. -A Playwright end-to-end suite lives in `tests/` (specs for web, spa, native, mcp, a2a) and is driven by the root `Makefile` (`make test`, `make test-mcp`, …). It is **not** wired into CI: it shells out to the macOS Keychain for a DPoP signing key, needs a live hardware-key-backed Vouch session (`~/.vouch/cookie.txt`), and creates/destroys real OAuth applications. Run it locally before merging anything non-trivial. +A Playwright end-to-end suite lives in `tests/` (specs for web, spa, native, mcp, a2a, claims) and is driven by the root `Makefile` (`make test`, `make test-mcp`, …). It is **not** wired into CI: it shells out to the macOS Keychain for a DPoP signing key, needs a live hardware-key-backed Vouch session, and creates/destroys real OAuth applications. Run it locally before merging anything non-trivial. + +Credentials come from the Vouch CLI's XDG locations — `$XDG_CONFIG_HOME/vouch/config.json` and `$XDG_STATE_HOME/vouch/cookie.txt`, defaulting to `~/.config` and `~/.local/state`. Run `vouch login` first. + +`make test-claims` is a regression guard on the shape of a Vouch access token (ES256, `typ: at+jwt`, JWKS-verifiable, `aud` = the requesting client unless narrowed by an RFC 8707 `resource` parameter). Every example's token verification depends on those facts, so if Vouch ever changes them this fails first. ## Adding a New Example diff --git a/a2a/python-agent/README.md b/a2a/python-agent/README.md index b4f321d..90b5e6d 100644 --- a/a2a/python-agent/README.md +++ b/a2a/python-agent/README.md @@ -9,7 +9,7 @@ This example demonstrates: ## How It Works -1. A client agent fetches `/.well-known/agent.json` to discover this agent's capabilities +1. A client agent fetches `/.well-known/agent-card.json` to discover this agent's capabilities 2. The Agent Card declares `openIdConnect` security pointing at your Vouch issuer 3. The client obtains an access token from Vouch (via any OAuth flow) 4. The client calls the agent with `Authorization: Bearer ` @@ -35,21 +35,43 @@ docker run -p 3000:3000 \ | Path | Auth Required | Description | |------|---------------|-------------| -| `GET /.well-known/agent.json` | No | Agent Card (discovery) | +| `GET /.well-known/agent-card.json` | No | Agent Card (discovery) | | `POST /` | Yes (Bearer) | A2A JSON-RPC endpoint | ## Agent Card -The agent card at `/.well-known/agent.json` includes: +The agent card at `/.well-known/agent-card.json` includes: ```json { "securitySchemes": { "vouch_oidc": { + "openIdConnectSecurityScheme": { + "openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration" + }, "type": "openIdConnect", "openIdConnectUrl": "https://us.vouch.sh/.well-known/openid-configuration" } }, - "security": [{ "vouch_oidc": [] }] + "securityRequirements": [{ "schemes": { "vouch_oidc": {} } }] } ``` + +The card's types are protobuf-backed since a2a-sdk 1.0, so the scheme is emitted in +its ProtoJSON form. The SDK also flattens `type` and `openIdConnectUrl` alongside it +for clients written against the older schema. + +## Protocol Version + +Built on a2a-sdk 1.x, which speaks protocol `1.0`. The JSON-RPC method is +`SendMessage`; this agent also enables v0.3 compatibility on the same endpoint, so +`message/send` works too. The older `tasks/send` is not served. + +```bash +curl -X POST http://localhost:3000/ \ + -H "Authorization: Bearer $ACCESS_TOKEN" \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":1,"method":"message/send","params":{"message":{ + "role":"user","parts":[{"kind":"text","text":"Who am I?"}], + "messageId":"1","kind":"message"}}}' +``` diff --git a/a2a/python-agent/agent.py b/a2a/python-agent/agent.py index 4b3a46f..67edf90 100644 --- a/a2a/python-agent/agent.py +++ b/a2a/python-agent/agent.py @@ -4,15 +4,25 @@ import jwt from jwt import PyJWKClient import uvicorn +from a2a.helpers import new_text_message from a2a.server.agent_execution import AgentExecutor -from a2a.server.apps import A2AStarletteApplication from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.routes import create_agent_card_routes, create_jsonrpc_routes from a2a.server.tasks import InMemoryTaskStore from a2a.types import ( - AgentCard, AgentCapabilities, + AgentCard, + AgentInterface, AgentSkill, + OpenIdConnectSecurityScheme, + Role, + SecurityRequirement, + SecurityScheme, ) +from a2a.utils.constants import AGENT_CARD_WELL_KNOWN_PATH, DEFAULT_RPC_URL +from starlette.applications import Starlette +from starlette.middleware import Middleware +from starlette.middleware.base import BaseHTTPMiddleware from starlette.requests import Request from starlette.responses import JSONResponse @@ -56,13 +66,14 @@ class IdentityAgentExecutor(AgentExecutor): """A simple agent that returns the caller's verified identity.""" async def execute(self, context, event_queue): - # Return the caller's identity info result = { 'message': 'Identity verified via Vouch OIDC', 'note': 'The caller was authenticated with a hardware security key', } + # A message rather than an artifact: v1.0 enforces the streaming rules, so + # emitting an artifact with no preceding Task event is now an error. await event_queue.enqueue_event( - context.create_text_artifact(json.dumps(result, indent=2)) + new_text_message(json.dumps(result, indent=2), role=Role.ROLE_AGENT) ) async def cancel(self, context, event_queue): @@ -73,10 +84,16 @@ async def cancel(self, context, event_queue): agent_card = AgentCard( name='Vouch Identity Agent', description='An A2A agent secured with Vouch OIDC. Demonstrates hardware-backed authentication for agent-to-agent communication.', - url=f'http://localhost:{PORT}', + # `url` was replaced by supported_interfaces in v1.0. + supported_interfaces=[ + AgentInterface( + url=f'http://localhost:{PORT}{DEFAULT_RPC_URL}', + protocol_binding='JSONRPC', + ), + ], version='1.0.0', - defaultInputModes=['text/plain'], - defaultOutputModes=['text/plain'], + default_input_modes=['text/plain'], + default_output_modes=['text/plain'], capabilities=AgentCapabilities(streaming=False), skills=[ AgentSkill( @@ -87,33 +104,29 @@ async def cancel(self, context, event_queue): examples=['Who am I?', 'Verify my identity'], ), ], - securitySchemes={ - 'vouch_oidc': { - 'type': 'openIdConnect', - 'openIdConnectUrl': f'{VOUCH_ISSUER}/.well-known/openid-configuration', - }, + # The card's types are protobuf-backed in v1.0, so the security scheme is a typed + # message rather than the plain dict the 0.2 SDK accepted. + security_schemes={ + 'vouch_oidc': SecurityScheme( + open_id_connect_security_scheme=OpenIdConnectSecurityScheme( + open_id_connect_url=f'{VOUCH_ISSUER}/.well-known/openid-configuration', + ), + ), }, - security=[{'vouch_oidc': []}], + security_requirements=[SecurityRequirement(schemes={'vouch_oidc': {'list': []}})], ) -# Create the A2A application task_store = InMemoryTaskStore() handler = DefaultRequestHandler( agent_executor=IdentityAgentExecutor(), task_store=task_store, -) -a2a_app = A2AStarletteApplication( agent_card=agent_card, - http_handler=handler, ) -# Wrap with auth middleware -from starlette.middleware.base import BaseHTTPMiddleware - async def auth_middleware(request: Request, call_next): # Allow unauthenticated access to agent card discovery - if request.url.path == '/.well-known/agent.json': + if request.url.path == AGENT_CARD_WELL_KNOWN_PATH: return await call_next(request) claims = verify_bearer_token(request) @@ -127,12 +140,17 @@ async def auth_middleware(request: Request, call_next): return await call_next(request) -app = a2a_app.build() - -# Add middleware -app.add_middleware(BaseHTTPMiddleware, dispatch=auth_middleware) +# A2AStarletteApplication was removed in v1.0; compose the routes directly. Middleware +# now goes in the Starlette constructor rather than being added after build(). +app = Starlette( + routes=[ + *create_agent_card_routes(agent_card), + *create_jsonrpc_routes(handler, rpc_url=DEFAULT_RPC_URL, enable_v0_3_compat=True), + ], + middleware=[Middleware(BaseHTTPMiddleware, dispatch=auth_middleware)], +) if __name__ == '__main__': print(f'A2A agent running on http://localhost:{PORT}') - print(f'Agent Card: http://localhost:{PORT}/.well-known/agent.json') + print(f'Agent Card: http://localhost:{PORT}{AGENT_CARD_WELL_KNOWN_PATH}') uvicorn.run(app, host='0.0.0.0', port=PORT) diff --git a/a2a/python-agent/requirements.in b/a2a/python-agent/requirements.in index 8257391..a77afef 100644 --- a/a2a/python-agent/requirements.in +++ b/a2a/python-agent/requirements.in @@ -1,5 +1,6 @@ -a2a-sdk[http-server]>=0.2.0,<0.3 +a2a-sdk[http-server]>=1.1,<2 uvicorn>=0.51 PyJWT>=2.13 cryptography>=49.0 httpx>=0.28 +starlette>=1.3 diff --git a/a2a/python-agent/requirements.txt b/a2a/python-agent/requirements.txt index c0c1877..7c08377 100644 --- a/a2a/python-agent/requirements.txt +++ b/a2a/python-agent/requirements.txt @@ -1,9 +1,7 @@ # This file was autogenerated by uv via the following command: # uv pip compile a2a/python-agent/requirements.in --universal --python-version 3.14 -o a2a/python-agent/requirements.txt -a2a-sdk==0.2.16 +a2a-sdk==1.1.2 # via -r a2a/python-agent/requirements.in -annotated-doc==0.0.5 - # via fastapi annotated-types==0.8.0 # via pydantic anyio==4.14.2 @@ -15,16 +13,27 @@ certifi==2026.7.22 # via # httpcore # httpx + # requests cffi==2.1.0 ; platform_python_implementation != 'PyPy' # via cryptography +charset-normalizer==3.4.9 + # via requests click==8.4.2 # via uvicorn colorama==0.4.6 ; sys_platform == 'win32' # via click cryptography==49.0.0 - # via -r a2a/python-agent/requirements.in -fastapi==0.140.13 + # via + # -r a2a/python-agent/requirements.in + # google-auth +google-api-core==2.33.0 # via a2a-sdk +google-auth==2.56.2 + # via google-api-core +googleapis-common-protos==1.75.0 + # via + # a2a-sdk + # google-api-core h11==0.16.0 # via # httpcore @@ -35,50 +44,52 @@ httpx==0.28.1 # via # -r a2a/python-agent/requirements.in # a2a-sdk -httpx-sse==0.4.3 - # via a2a-sdk idna==3.18 # via # anyio # httpx -opentelemetry-api==1.44.0 + # requests +json-rpc==1.15.0 + # via a2a-sdk +packaging==26.2 + # via a2a-sdk +proto-plus==1.28.2 + # via google-api-core +protobuf==6.33.6 # via # a2a-sdk - # opentelemetry-sdk - # opentelemetry-semantic-conventions -opentelemetry-sdk==1.44.0 - # via a2a-sdk -opentelemetry-semantic-conventions==0.65b0 - # via opentelemetry-sdk + # google-api-core + # googleapis-common-protos + # proto-plus +pyasn1==0.6.4 + # via pyasn1-modules +pyasn1-modules==0.4.2 + # via google-auth pycparser==3.0 ; implementation_name != 'PyPy' and platform_python_implementation != 'PyPy' # via cffi pydantic==2.13.4 - # via - # a2a-sdk - # fastapi + # via a2a-sdk pydantic-core==2.46.4 # via pydantic pyjwt==2.13.0 # via -r a2a/python-agent/requirements.in +requests==2.34.2 + # via google-api-core sse-starlette==3.4.6 # via a2a-sdk starlette==1.3.1 # via + # -r a2a/python-agent/requirements.in # a2a-sdk - # fastapi # sse-starlette typing-extensions==4.16.0 # via - # fastapi - # opentelemetry-api - # opentelemetry-sdk - # opentelemetry-semantic-conventions # pydantic # pydantic-core # typing-inspection typing-inspection==0.4.2 - # via - # fastapi - # pydantic + # via pydantic +urllib3==2.7.0 + # via requests uvicorn==0.51.0 # via -r a2a/python-agent/requirements.in diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 9f5a235..70d38c1 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -112,19 +112,8 @@ mcp/*) a2a/*) start - # Path moved to agent-card.json in a2a-sdk 0.3.0; accept either until we migrate. wait_http "/.well-known/agent-card.json" >/dev/null - port="$(port_of)" - card_ok=0 - for p in /.well-known/agent-card.json /.well-known/agent.json; do - code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "http://localhost:${port}${p}" 2>/dev/null || true) - if [ "$code" = "200" ]; then - echo " GET $p -> 200" - card_ok=1 - break - fi - done - [ "$card_ok" = "1" ] || fail "agent card not served at either well-known path" + expect_status "/.well-known/agent-card.json" '^200$' expect_post_status "/" '^401$' ;; diff --git a/tests/tests/a2a.spec.js b/tests/tests/a2a.spec.js index 873d106..7c0582a 100644 --- a/tests/tests/a2a.spec.js +++ b/tests/tests/a2a.spec.js @@ -74,7 +74,7 @@ for (const example of A2A_EXAMPLES) { test("agent card has OIDC security scheme", async () => { const res = await fetch( - `${baseUrl}/.well-known/agent.json`, + `${baseUrl}/.well-known/agent-card.json`, ); expect(res.status).toBe(200); @@ -142,12 +142,15 @@ for (const example of A2A_EXAMPLES) { body: JSON.stringify({ jsonrpc: "2.0", id: 1, - method: "tasks/send", + // `message/send` is the v0.3 method name, served here because the agent + // enables v0_3 compat. The native 1.0 name is `SendMessage`. + method: "message/send", params: { - id: "test-task-1", message: { role: "user", - parts: [{ type: "text", text: "Who am I?" }], + parts: [{ kind: "text", text: "Who am I?" }], + messageId: "test-message-1", + kind: "message", }, }, }), @@ -156,6 +159,11 @@ for (const example of A2A_EXAMPLES) { expect(res.status).toBe(200); const body = await res.json(); expect(body).toHaveProperty("jsonrpc", "2.0"); + + // A JSON-RPC error is also 200 with jsonrpc:"2.0", so assert the executor + // actually ran and produced its identity message. + expect(body.error, `JSON-RPC error: ${JSON.stringify(body.error)}`).toBeUndefined(); + expect(JSON.stringify(body.result)).toContain("Identity verified via Vouch OIDC"); }); }); }