diff --git a/CHANGELOG.md b/CHANGELOG.md index 705e77f603..7f86e8bfd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Standalone dashboard PWA** (`cao_pwa/`) — a multi-instance fleet dashboard that consumes the AG-UI stream from any browser (no MCP host required), with automatic reconnection that resumes via `?since=`. See [docs/pwa.md](docs/pwa.md). +- **A2A v1.0 transport + signed Agent Card** — JSON-RPC 2.0 (`task.send`/`get`/`cancel`), SSE streaming, and REST polling for agent-to-agent interop, plus an Ed25519-signed Agent Card + JWKS published on a dedicated listener. The listener is default-off (`CAO_AGENT_CARD_ENABLED`) and binds loopback (127.0.0.1) unless an operator opts into external discoverability with `CAO_AGENT_CARD_HOST`. When the auth layer is enabled, the transport enforces **per-method scope** (`task.send`/`task.cancel` → `cao:write`, `task.get` and the stream/REST reads → `cao:read`; `cao:admin` satisfies any), returning `401`/`403` with JSON-RPC error bodies; the listener **refuses to mount the transport on a non-loopback bind while auth is disabled** (the Agent Card doc still publishes). Task ids are **create-only** (re-sending an existing id → `INVALID_PARAMS`; a peer can never overwrite another peer's task), and the in-memory task store is **bounded** (`CAO_A2A_MAX_TASKS`, default 1000) and **TTL-evicting** (`CAO_A2A_TASK_TTL`, default 3600s) — a full store of live tasks refuses new `task.send` with `RESOURCE_EXHAUSTED` (HTTP 429 + `Retry-After`) rather than growing without limit. See [docs/auth.md](docs/auth.md#a2a-transport-auth-the-9890-listener). + - **OpenTelemetry GenAI instrumentation** — opt-in traces/metrics over OTLP following the GenAI semantic conventions, shipped as the `[otel]` optional extra (`pip install cli-agent-orchestrator[otel]`); the base install degrades to no-ops. See [docs/otel-deployment.md](docs/otel-deployment.md). - **Native multi-agent workflow spec** — a trusted-author YAML workflow grammar with authoring/validation endpoints, a run-engine seam, and `workflow_run` / `workflow_return` / `workflow_cancel` MCP tools (#312). diff --git a/docs/auth.md b/docs/auth.md index 609ea1a1de..5954d6c86c 100644 --- a/docs/auth.md +++ b/docs/auth.md @@ -192,6 +192,62 @@ token is a full operator. is byte-identical to prior releases — loopback peer check only, no token required, no subprotocol echoed. +## A2A transport auth (the :9890 listener) + +The A2A task routes (`POST /a2a/v1/rpc`, `GET /a2a/v1/tasks/{id}`, +`GET /a2a/v1/stream/{id}`) enforce the same JWT scheme **per method**, +verified against the same JWKS used everywhere else: + +| Method | Required scope | +|---|---| +| `task.send`, `task.cancel` | `cao:write` | +| `task.get`, `GET /stream/{id}`, `GET /tasks/{id}` | `cao:read` | + +`cao:admin` satisfies any method; a `cao:write` grant implies read. Failures +return 401 (missing / malformed / expired bearer) or 403 (insufficient +scope), carried both as the HTTP status and as a JSON-RPC error body — never +tunnelled through 200. Task ids are **create-only**: re-sending an existing +id is refused with `INVALID_PARAMS` (a peer can never overwrite another +peer's in-flight task). + +Two hard safety properties: + +- **Fail-closed mount**: with `CAO_AGENT_CARD_HOST` set to a non-loopback + address and auth *not* configured, CAO refuses to mount the task routes + (discovery — Agent Card + JWKS — still serves). An external-facing, + unauthenticated task API is never a reachable configuration. +- **Bounded task store**: the in-memory store is capped + (`CAO_A2A_MAX_TASKS`, default 1000) and time-windowed + (`CAO_A2A_TASK_TTL`, default 3600 s; malformed or non-positive values + fall back to the defaults with a warning — the bound cannot be disabled). + On overflow the oldest terminal tasks are evicted; when full of live + tasks, `task.send` is refused with `RESOURCE_EXHAUSTED` at **HTTP 429** + (+ `Retry-After: 30`), so both JSON-RPC clients and HTTP-native retry + middleware back off. + +Walkthrough (auth enabled): + +```sh +# 1. No token -> 401 with a JSON-RPC UNAUTHENTICATED error +curl -si http://localhost:9890/a2a/v1/rpc \ + -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"task.send","params":{"task":{"id":"t1","messages":[]}}}' +# HTTP/1.1 401 Unauthorized +# {"jsonrpc":"2.0","id":null,"error":{"code":3,"message":"missing bearer token"}} + +# 2. cao:read token cannot submit work -> 403 PERMISSION_DENIED +curl -si http://localhost:9890/a2a/v1/rpc \ + -H "Authorization: Bearer $READ_TOKEN" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"task.send","params":{"task":{"id":"t1","messages":[]}}}' +# HTTP/1.1 403 Forbidden + +# 3. cao:write token -> 200 with the task envelope +curl -s http://localhost:9890/a2a/v1/rpc \ + -H "Authorization: Bearer $WRITE_TOKEN" -H 'Content-Type: application/json' \ + -d '{"jsonrpc":"2.0","id":"1","method":"task.send","params":{"task":{"id":"t1","messages":[]}}}' | jq .result.task.state +# "submitted" +``` + ## Gaps (deferred to follow-up sibling-PRs) - **OBO token exchange** (RFC 8693) — if your MCP host wants to swap diff --git a/docs/reviews/pr387-a2a-fixes/0001-fix-agent-card-release-the-9890-listener-socket-dete.patch b/docs/reviews/pr387-a2a-fixes/0001-fix-agent-card-release-the-9890-listener-socket-dete.patch new file mode 100644 index 0000000000..a176260ab1 --- /dev/null +++ b/docs/reviews/pr387-a2a-fixes/0001-fix-agent-card-release-the-9890-listener-socket-dete.patch @@ -0,0 +1,51 @@ +From aa8b51e84a88a80361d962d6775695b75226744c Mon Sep 17 00:00:00 2001 +From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> +Date: Tue, 7 Jul 2026 04:36:08 +0000 +Subject: [PATCH 1/2] fix(agent-card): release the :9890 listener socket + deterministically on stop +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +uvicorn's Server.serve() returns immediately after startup() (which binds +the listening socket) when should_exit is already set, skipping the +shutdown() that closes it. AgentCardListener.stop() only set should_exit, +so a fast start->stop could leave :9890 bound until GC — surfacing as an +EADDRINUSE flake when consecutive lifespans start the listener (e.g. the +A2A mount-guard tests). Close any bound sockets explicitly after the serve +task ends, and log (not raise) a start-up failure so shutdown still runs. +--- + .../agent_card/listener.py | 17 +++++++++++++++++ + 1 file changed, 17 insertions(+) + +diff --git a/src/cli_agent_orchestrator/agent_card/listener.py b/src/cli_agent_orchestrator/agent_card/listener.py +index 8010bb4..91f048c 100644 +--- a/src/cli_agent_orchestrator/agent_card/listener.py ++++ b/src/cli_agent_orchestrator/agent_card/listener.py +@@ -74,6 +74,23 @@ class AgentCardListener: + await asyncio.wait_for(self.task, timeout=5.0) + except asyncio.TimeoutError: + logger.warning("Agent Card listener did not stop within 5s") ++ except Exception: # pragma: no cover - defensive ++ # A start-up failure (e.g. the port was already bound) surfaces ++ # when the serve task is awaited. Log and continue so shutdown ++ # still releases whatever sockets did open. ++ logger.warning("Agent Card listener task ended with an error", exc_info=True) ++ # uvicorn's Server.serve() returns right after startup() (which binds ++ # the listening socket) when should_exit is already set, skipping the ++ # shutdown() that would close it. Setting should_exit alone therefore ++ # races: a quick start->stop can leave :9890 bound until GC. Close any ++ # listening sockets explicitly so the port is released deterministically. ++ for server in getattr(self.server, "servers", None) or []: ++ server.close() ++ for server in getattr(self.server, "servers", None) or []: ++ try: ++ await server.wait_closed() ++ except Exception: # pragma: no cover - defensive ++ pass + + + async def start_agent_card_listener( +-- +2.50.1 + diff --git a/docs/reviews/pr387-a2a-fixes/0002-test-a2a-run-the-in-process-A2A-round-trip-without-t.patch b/docs/reviews/pr387-a2a-fixes/0002-test-a2a-run-the-in-process-A2A-round-trip-without-t.patch new file mode 100644 index 0000000000..849491a4cc --- /dev/null +++ b/docs/reviews/pr387-a2a-fixes/0002-test-a2a-run-the-in-process-A2A-round-trip-without-t.patch @@ -0,0 +1,45 @@ +From d033cc4fa60ecbb20c98994b3766da0b9d2112fe Mon Sep 17 00:00:00 2001 +From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> +Date: Tue, 7 Jul 2026 04:36:08 +0000 +Subject: [PATCH 2/2] test(a2a): run the in-process A2A round-trip without tmux + +The round-trip drives two peers as httpx.ASGITransport apps and needs no +terminal-backed agent, but the session-autouse require_tmux fixture in +test/e2e/conftest.py skipped it wholesale on tmux-less hosts. Override +require_tmux locally (matching the existing require_cao_server / +warmup_mcp_server_cache overrides) so the real-JWT round-trip actually +executes in minimal CI/sandboxes instead of silently skipping. +--- + test/e2e/test_a2a_roundtrip.py | 9 ++++++++- + 1 file changed, 8 insertions(+), 1 deletion(-) + +diff --git a/test/e2e/test_a2a_roundtrip.py b/test/e2e/test_a2a_roundtrip.py +index d360f7d..50abdd4 100644 +--- a/test/e2e/test_a2a_roundtrip.py ++++ b/test/e2e/test_a2a_roundtrip.py +@@ -50,7 +50,9 @@ pytestmark = [pytest.mark.e2e, pytest.mark.slow] + + # Override the test/e2e/conftest.py autouse fixtures — this test runs the + # A2A surfaces in-process via ``httpx.ASGITransport`` and doesn't need a +-# live CAO server or warmed uvx cache. ++# live CAO server, a warmed uvx cache, or tmux (there is no terminal-backed ++# agent here — both peers are ASGI apps). Overriding ``require_tmux`` keeps ++# the round-trip runnable in tmux-less environments (e.g. minimal CI jobs). + @pytest.fixture(autouse=True) + def require_cao_server(): + yield +@@ -61,6 +63,11 @@ def warmup_mcp_server_cache(): + yield + + ++@pytest.fixture(autouse=True) ++def require_tmux(): ++ yield ++ ++ + # --------------------------------------------------------------------------- + # Peer factory — one per CAO instance. + # --------------------------------------------------------------------------- +-- +2.50.1 + diff --git a/docs/reviews/pr387-followup-issues.md b/docs/reviews/pr387-followup-issues.md new file mode 100644 index 0000000000..b0c5b0f1bb --- /dev/null +++ b/docs/reviews/pr387-followup-issues.md @@ -0,0 +1,85 @@ +# PR #387 — follow-up issues to file + +The upstream reply (`pr387-agui-response-draft-v2.md`) commits to four +follow-ups. Ready-to-file issue bodies below; open them against +`awslabs/cli-agent-orchestrator` after PR-A merges and reference #386 / #387. + +--- + +## 1. AG-UI stream: short-lived single-use ticket handshake + +**Labels:** `enhancement`, `security`, `agui` + +**Context.** `GET /agui/v1/stream` accepts the bearer token as a query parameter +because browser `EventSource` cannot set an `Authorization` header. PR-A scrubs +the token from access logs and recommends short TTLs, but the token still +appears in the URL. Reviewer @gutosantos82 (review 4632216702, item I2) flagged +this; we agreed the query-param pattern is the standard SSE workaround and +committed to a follow-up rather than blocking the core. + +**Proposal.** Add a `POST /agui/v1/ticket` endpoint that authenticates with a +normal `Authorization` header and returns a single-use, short-lived (~30 s) +ticket. The stream then accepts `?ticket=` instead of `?access_token=`. +The ticket is consumed on first `EventSource` connect and cannot be replayed. + +**Acceptance.** Ticket mints only with a valid bearer + `cao:read`; is single-use +and TTL-bounded; `?access_token=` remains supported (deprecated) for one release; +tests cover mint → connect → reuse-rejected and expiry. + +--- + +## 2. AG-UI: `STATE_DELTA` debounce + +**Labels:** `enhancement`, `performance`, `agui` + +**Context.** Rapid backend state changes currently emit one `STATE_DELTA` per +change. Under bursty activity (many agents, fast tool loops) this can flood the +SSE stream and the client. + +**Proposal.** Coalesce `STATE_DELTA` events within a small window (e.g. 50–100 ms) +into a single merged delta before emission, configurable via env. Preserve +ordering and last-writer-wins semantics per key. + +**Acceptance.** A burst of N deltas within the window emits ≤1 merged event; the +merged delta is equivalent to applying the N deltas in order; debounce window is +configurable; a test asserts the coalescing boundary. + +--- + +## 3. Generative UI: `emit_ui` rate limiting + +**Labels:** `enhancement`, `security`, `agui` + +**Context.** `emit_ui` (MCP tool + `POST /agui/v1/emit_ui`) lets a producer push +UI components onto the stream. There is no per-producer rate limit, so a +misbehaving or hostile producer could flood connected clients. + +**Proposal.** Add a per-producer / per-connection token-bucket rate limit on +`emit_ui`, returning a clear throttling error (HTTP 429 for the REST path) when +exceeded. Defaults tunable via env. + +**Acceptance.** Emits above the configured rate are rejected with 429 + +`Retry-After`; within-limit emits are unaffected; the allow-list refusal path is +unchanged; tests cover the throttle boundary and the 429 body. + +--- + +## 4. Test hygiene: fixed-path `term-42.mcp.json` race + +**Labels:** `test`, `flaky`, `tech-debt` + +**Context.** A test writes/reads a fixed-path `term-42.mcp.json`. On parallel or +repeated runs this shared path can race, producing intermittent failures. Noted +during the #387 review cycle; deferred out of the core PR. + +**Proposal.** Use a per-test temporary path (`tmp_path` fixture) or a unique +terminal id per test instead of the fixed `term-42` name, so runs are isolated. + +**Acceptance.** No test depends on a fixed shared MCP-config path; the affected +test passes under `pytest -p xdist -n auto` / repeated runs without flakiness. + +--- + +> Note: the two defects found during Phase-1 verification (the `:9890` listener +> socket leak and the tmux-gated in-process round-trip) are **already fixed** on +> `reconcile/pr387-a2a-hardened` — they do not need issues. diff --git a/docs/reviews/pr387-phase1-verification-2026-07-07.md b/docs/reviews/pr387-phase1-verification-2026-07-07.md new file mode 100644 index 0000000000..8c14a875ac --- /dev/null +++ b/docs/reviews/pr387-phase1-verification-2026-07-07.md @@ -0,0 +1,62 @@ +# PR #387 — Phase-1 independent verification record (2026-07-07) + +Independent re-run of every gate the handoff (`pr387-kiro-handoff.md`) claims, +executed by the finishing author on a clean checkout (uv-managed **Python +3.10.20**, Node **v22.22.3**). Numbers here are the ones any PR body or the +upstream reply may cite; anything not reproduced locally is marked as such. + +## `reconcile/pr387-agui-core` (#19) — head `3712571` + +| Gate | Result | Handoff claim | Match | +|---|---|---|---| +| `pytest` | 3537 passed / 22 skipped / 0 failed (87% cov) | 3,537 P / 22 S / 0 F | ✅ | +| `mypy src/` | no issues, 132 files | mypy 132 | ✅ | +| `black --check` / `isort --check-only` | clean | — | ✅ | +| `cao_pwa` `tsc --noEmit` | clean | — | ✅ | +| `cao_pwa` `vitest run` | 24/24 passed | vitest 24 | ✅ | +| `cao_pwa` `vite build` | OK (154 KB js / 49.7 KB gz) | — | ✅ | +| Live Playwright e2e + `showcase.sh` 6-frame gate | **not run** — no Chromium, Playwright CDN blocked | live spec green | ⏸ rely on CI `Build, test & record` | + +Load-bearing properties (code-level): `import cli_agent_orchestrator.a2a` and +`.agent_card` both raise `ImportError` (split is clean); `cao_pwa/src/api.ts` +closes and reopens `EventSource` with the `?since=` cursor ("Always take over +reconnection"). + +## `reconcile/pr387-a2a-hardened` (#20) — head `2d6a550` + 2 verification fixes + +| Gate | Result | Handoff claim | Match | +|---|---|---|---| +| `pytest` | 3609 passed / 21 skipped / 0 failed | 3,609 P / 21 S / 0 F | ✅ (after fix — see below) | +| `mypy src/` | no issues, 142 files | mypy 142 | ✅ | +| `black --check` / `isort --check-only` | clean | — | ✅ | +| A2A e2e round-trip (`-m e2e`) | 2 passed (real JWTs, in-process) | 2 passed | ✅ (after fix — see below) | + +Load-bearing properties (code-level): `rpc.py` authenticates before body parse; +store-full → `RESOURCE_EXHAUSTED` @ HTTP 429 + `Retry-After`; `task.send` +refuses an existing id. + +### Two defects found and fixed on this branch (focused commits) + +1. **`:9890` listener socket leak.** `AgentCardListener.stop()` set uvicorn's + `should_exit` only; `Server.serve()` early-returns after `startup()` (which + binds the socket) when `should_exit` is already set, skipping `shutdown()`. + Result: a fast start→stop leaks the port until GC — a deterministic + `EADDRINUSE` failure of + `test_a2a_mount_guard::test_loopback_bind_without_auth_still_mounts` in the + full run. Fix: close the bound sockets explicitly in `stop()`. Restores the + claimed **0 failures** (before: 1 failed / 3608 passed). +2. **Spurious tmux skip on the in-process round-trip.** `test_a2a_roundtrip.py` + overrode `require_cao_server` / `warmup_mcp_server_cache` but not the + session-autouse `require_tmux`, so a pure-ASGI test skipped on tmux-less + hosts. Fix: add the matching `require_tmux` override → 2 skipped becomes 2 + passed. + +Patches: `docs/reviews/pr387-a2a-fixes/000{1,2}-*.patch` (apply with `git am` +onto `reconcile/pr387-a2a-hardened` to preserve the Claude-authored, +`f40933d`-stacked lineage). + +## Scope / scale (re-confirmed for the reply) + +157 changed files (+16,288 / −119); 0 deletions in the diff (117 A / 40 M on the +combined bundle); `q_cli` / `gemini_cli` were removed upstream in #353 (in the +merge base), not by this PR. Consistent with `pr387-agui-response-draft-v2.md`. diff --git a/docs/reviews/pr387-pr-body-A-agui-core.md b/docs/reviews/pr387-pr-body-A-agui-core.md new file mode 100644 index 0000000000..b332b20e79 --- /dev/null +++ b/docs/reviews/pr387-pr-body-A-agui-core.md @@ -0,0 +1,65 @@ +# PR-A — AG-UI protocol adapter + generative UI + standalone PWA (#386) + +> Draft PR body for `reconcile/pr387-agui-core` (#19), and for upstream #387 at +> retarget time. Reconciles the two independent remediations of the #387 review +> (`claude/pr387-agui-core` and `kiro/pr387-agui-core`); see +> `docs/reviews/reconciliation-notes.md` for the per-change source map. Both +> source implementations contributed — credited neutrally there. + +## What this PR does + +This is the AG-UI **core** — the verified subset the collaborator asked to land +first (@fanhongy), with the A2A transport split out to a follow-up (PR-B, +`reconcile/pr387-a2a-hardened`). + +- **AG-UI typed-event stream** — `GET /agui/v1/stream` (SSE) normalizes every + CLI agent into the six-event AG-UI vocabulary. Metadata-only on the wire: + message bodies and tool args are redacted by construction (asserted with a + canary-secret test through both event shapes). +- **Generative UI** — the `emit_ui` MCP tool and `POST /agui/v1/emit_ui`, gated + by a frozen component allow-list enforced at both the producer and adapter + layers. +- **Standalone PWA** (`cao_pwa/`) — a stock `EventSource` client rendering the + six components, with client-owned reconnection that resumes via `?since=` + across dropped connections (native `EventSource` cannot resume, so `api.ts` + closes and reopens with the cursor). +- **`mock_cli` provider** — deterministic provider used by the demo and CI. +- **Opt-in OpenTelemetry** — GenAI instrumentation behind the `[otel]` optional + extra; absent the extra it degrades with an actionable warning (never a hard + import error). +- **WebSocket auth** — `cao.bearer.` subprotocol; close codes 4401 + (unauthenticated) / 4403 (insufficient scope). Token-parse failures map to + 401 (SSE) / 4401 (WS), and access logging is scrubbed of query-string + credentials. +- **Review nits folded in** — the Copilot five (stray `tests/__init__.py` + removed, mypy `python_version = "3.10"`, W3C Trace Context citation, + `InstancePicker` nested-button restructure, `test_headless_ci` docstring), the + dated generative-UI design log folded into `docs/pwa.md`, and the dead + `data_analyst_gemini_cli.md` example retargeted to `antigravity_cli` (the + provider `gemini_cli` was removed upstream in #353 and does not exist). + +## Explicitly NOT in this PR + +- **A2A v1.0 transport + signed Agent Card** — moved to PR-B, held until its + auth gating, store bounds, and per-method 401/403 matrix land. No + `src/.../a2a/` or `src/.../agent_card/` module is present here; importing + either raises `ImportError`. +- No `authlib` / `python-multipart` dependency (those leave with PR-B). + +## Default-off posture + +With no env flags set, `GET /agui/v1/stream` and `POST /agui/v1/emit_ui` return +404, no extra listener binds, and the route table is byte-identical to `main`. +The stream is enabled by `CAO_AGUI_ENABLED` or, intentionally, by +`CAO_MCP_APPS_ENABLED` (the AG-UI SSE surface and the already-upstream MCP Apps +surface share one in-process event source — documented in `docs/pwa.md`). + +## Gates (independently re-run on this branch, Python 3.10) + +- `pytest`: **3537 passed, 22 skipped, 0 failed** (87% total coverage) +- `mypy src/`: **no issues in 132 source files** +- `black --check .` / `isort --check-only .`: clean +- `cao_pwa`: `tsc --noEmit` clean, **vitest 24/24**, `vite build` OK +- Live Playwright e2e (`npm run test:e2e:live`) + `showcase.sh` six-frame + `GENERATIVE_UI` gate: green via the CI `Build, test & record` job (require a + browser; not run in the reviewer sandbox). diff --git a/docs/reviews/pr387-pr-body-B-a2a-hardened.md b/docs/reviews/pr387-pr-body-B-a2a-hardened.md new file mode 100644 index 0000000000..6169b3ffd3 --- /dev/null +++ b/docs/reviews/pr387-pr-body-B-a2a-hardened.md @@ -0,0 +1,65 @@ +# PR-B — A2A v1.0 transport + signed Agent Card, hardened (follow-up to PR-A) + +> Draft PR body for `reconcile/pr387-a2a-hardened` (#20). Reconciles +> `claude/pr387-a2a-hardened` and `kiro/pr387-a2a-hardened`; both source +> implementations contributed. Stacked on `f40933d`; carries **no** `pyproject` +> / `uv.lock` diff by design (dependency + doc hygiene rides PR-A). Open after +> PR-A merges, then rebase onto the new head (mechanical — no dep diff). + +## What this PR does + +Returns the A2A transport that was split out of the AG-UI core, with every +blocking review item from 4632216702 / @fanhongy / 4638092590 addressed. + +- **Per-method auth on the JSON-RPC route** — scopes are resolved *before* the + body is parsed, inside the `rpc()` handler (JSON-RPC multiplexes methods over + one route, so auth must be per-method after parse). `task.send` / `task.cancel` + require `cao:write`; `task.get` requires `cao:read`. Failures return JSON-RPC + error objects with matching HTTP status: **401** (missing/invalid token), + **403** (insufficient scope) — no auth failure tunneled through a 200. Stream + and REST reads use FastAPI `Depends` scope guards. +- **Fail-closed mount guard** — if auth is disabled *and* the effective bind + host is non-loopback, the A2A routers are refused (logged); loopback + no-auth + stays allowed for dev ergonomics. +- **Bounded task store** — `InMemoryTaskStore` is capped (`max_tasks`, default + 1000, `CAO_A2A_MAX_TASKS`) and TTL-swept (`ttl_seconds`, default 3600, + `CAO_A2A_TASK_TTL`). On overflow the oldest *terminal* tasks are evicted + first; when every stored task is still live, a new insert raises + `TaskStoreFull` → **`RESOURCE_EXHAUSTED` at HTTP 429 + `Retry-After`**. +- **Idempotent-create task ids** — `task.send` with an existing id is refused + (`already exists`) rather than silently upserting; an omitted id no longer + raises. Closes the task-id upsert-injection must-fix and the `Task.from_dict` + `KeyError` nit from review 4638092590. +- **Truthful docstrings** — `rpc.py` / `stream.py` now describe the enforcement + that actually exists (the prior "authentication is enforced via the JWKS" + claim was false on unguarded routes). + +## Two fixes from independent verification (this branch) + +Found while re-running the gates; committed here as focused commits: + +1. **`fix(agent-card)`** — `AgentCardListener.stop()` only set uvicorn's + `should_exit`, but `Server.serve()` returns right after `startup()` (which + binds `:9890`) when `should_exit` is already set, *skipping* `shutdown()`. A + fast start→stop therefore leaked the listening socket until GC — surfacing as + a deterministic `EADDRINUSE` in the mount-guard tests. `stop()` now closes the + bound sockets explicitly so the port is released deterministically. +2. **`test(a2a)`** — the in-process round-trip drives two ASGI peers and needs no + tmux, but the session-autouse `require_tmux` fixture skipped it wholesale on + tmux-less hosts. Overriding `require_tmux` locally keeps the real-JWT + round-trip actually executing (2 passed) instead of silently skipping. + +## Gates (independently re-run on this branch, Python 3.10) + +- `pytest`: **3609 passed, 21 skipped, 0 failed** (after fix #1; before it, one + mount-guard test failed on the port leak) +- `mypy src/`: **no issues in 142 source files** +- `black --check .` / `isort --check-only .`: clean +- A2A e2e round-trip (`pytest test/e2e/test_a2a_roundtrip.py -m e2e`): **2 + passed** with real JWTs, in-process ASGI (no tmux required after fix #2) + +## Load-bearing properties (verified in code, not just tests) + +- `rpc.py` authenticates before body parse. +- Store-full → 429 + JSON-RPC `RESOURCE_EXHAUSTED` body + `Retry-After`. +- `task.send` refuses an existing id. diff --git a/src/cli_agent_orchestrator/a2a/__init__.py b/src/cli_agent_orchestrator/a2a/__init__.py new file mode 100644 index 0000000000..c846054b2b --- /dev/null +++ b/src/cli_agent_orchestrator/a2a/__init__.py @@ -0,0 +1,40 @@ +"""A2A v1.0 transport endpoints. + +Layered on top of the Agent Card listener (`:9890`). Implements the +JSON-RPC 2.0 RPC method surface (`task.send`, `task.get`, `task.cancel`) +alongside SSE streaming and a REST polling fallback. +""" + +from cli_agent_orchestrator.a2a.rpc import TaskExecutor, build_a2a_router +from cli_agent_orchestrator.a2a.store import InMemoryTaskStore, TaskStore +from cli_agent_orchestrator.a2a.stream import ( + InMemoryTaskEventBus, + NullEventBus, + TaskEventBus, + build_stream_router, +) +from cli_agent_orchestrator.a2a.types import ( + A2AErrorCode, + JsonRpcError, + JsonRpcRequest, + JsonRpcResponse, + Task, + TaskState, +) + +__all__ = [ + "A2AErrorCode", + "InMemoryTaskEventBus", + "InMemoryTaskStore", + "JsonRpcError", + "JsonRpcRequest", + "JsonRpcResponse", + "NullEventBus", + "Task", + "TaskEventBus", + "TaskExecutor", + "TaskState", + "TaskStore", + "build_a2a_router", + "build_stream_router", +] diff --git a/src/cli_agent_orchestrator/a2a/rpc.py b/src/cli_agent_orchestrator/a2a/rpc.py new file mode 100644 index 0000000000..4734bb3bc2 --- /dev/null +++ b/src/cli_agent_orchestrator/a2a/rpc.py @@ -0,0 +1,454 @@ +"""A2A v1.0 JSON-RPC 2.0 endpoint. + +Mounted on the Agent Card listener (:9890) so external A2A peers discover +and call CAO over the same boundary. The listener is otherwise read-only +(Agent Card + JWKS); this adds ``POST /a2a/v1/rpc`` for JSON-RPC 2.0 task +operations. + +Methods: + + * ``task.send`` — peer submits a task. CAO assigns it the + ``submitted`` state and returns the task envelope. Subsequent + work transitions through ``working`` → terminal state. Binding + accepted tasks to CAO's internal task dispatch is a follow-up; + this ships the protocol surface only. + + * ``task.get`` — peer polls the current state of a previously sent + task. Returns the full envelope including state + messages + + artifacts. + + * ``task.cancel`` — peer requests cancellation. CAO transitions + the task to ``canceled`` if it isn't terminal yet; otherwise + returns ``TASK_ALREADY_TERMINAL``. + +Authentication: when CAO auth is enabled (``AUTH0_DOMAIN`` / +``CAO_AUTH_JWKS_URI``), every request must carry an ``Authorization: Bearer`` +RS256 JWT validated against the configured JWKS, and each method is gated on +scope: ``task.send`` / ``task.cancel`` require ``cao:write``; ``task.get`` +requires ``cao:read``. Failures are returned as JSON-RPC errors +(``UNAUTHENTICATED`` / ``PERMISSION_DENIED``) with matching HTTP 401/403. +When auth is disabled (the default-off loopback posture) no token is +required and behavior is unchanged. + +Per the JSON-RPC 2.0 spec: + * Parse errors return id=null with code -32700. + * Invalid request shapes return whatever id we managed to parse + (or null) with code -32600. + * Unknown methods return code -32601. + * Application errors (task not found, etc.) use the A2A-specific + positive codes from ``A2AErrorCode``. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import time +import uuid +from typing import Any, Awaitable, Callable, List, Optional + +from fastapi import APIRouter, Request +from fastapi.responses import JSONResponse + +from cli_agent_orchestrator.a2a.store import InMemoryTaskStore, TaskStore, TaskStoreFull +from cli_agent_orchestrator.a2a.stream import NullEventBus, TaskEventBus +from cli_agent_orchestrator.a2a.types import ( + A2AErrorCode, + JsonRpcError, + JsonRpcId, + JsonRpcRequest, + JsonRpcResponse, + Task, + TaskState, +) +from cli_agent_orchestrator.security.auth import ( + FULL_SCOPE_SET, + SCOPE_ADMIN, + SCOPE_READ, + SCOPE_WRITE, + extract_scopes_from_token, + is_auth_enabled, +) + +logger = logging.getLogger(__name__) + + +# A peer-supplied task → final-state task. The executor is responsible +# for running the work and returning the task with its terminal state +# set. Errors should not raise — the framework catches them and +# transitions to FAILED. The executor receives the *submitted* task +# and may mutate the messages / artifacts / metadata fields freely. +TaskExecutor = Callable[[Task], Awaitable[Task]] + + +# Per-method scope requirements (any one of the listed scopes suffices). +# Mutating methods need cao:write; reads need cao:read. cao:admin implies both. +_METHOD_SCOPES: dict[str, tuple[str, ...]] = { + "task.send": (SCOPE_WRITE, SCOPE_ADMIN), + "task.cancel": (SCOPE_WRITE, SCOPE_ADMIN), + "task.get": (SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN), +} + + +class _AuthFailure(Exception): + """Authentication/authorization failure carrying both wire encodings.""" + + def __init__(self, http_status: int, code: A2AErrorCode, message: str) -> None: + super().__init__(message) + self.http_status = http_status + self.code = code + self.message = message + + +def _resolve_request_scopes(request: Request) -> List[str]: + """Return the caller's scopes, or raise ``_AuthFailure`` (→ 401). + + Default-off: with auth disabled the full scope set is granted and the + request is never inspected — identical to the pre-auth behavior. + """ + if not is_auth_enabled(): + return list(FULL_SCOPE_SET) + + authorization = request.headers.get("authorization", "") + parts = authorization.split() + token = parts[1].strip() if len(parts) == 2 and parts[0].lower() == "bearer" else "" + if not token: + raise _AuthFailure(401, A2AErrorCode.UNAUTHENTICATED, "missing bearer token") + try: + return extract_scopes_from_token(token) + except Exception: + # PyJWTError subclasses (malformed/expired/bad signature) or a JWKS + # fetch failure — fail closed with a clean 401, never a 500. + logger.info("a2a rpc: token validation failed", exc_info=True) + raise _AuthFailure(401, A2AErrorCode.UNAUTHENTICATED, "invalid or expired token") + + +def _require_method_scope(method: str, scopes: List[str]) -> None: + """Raise ``_AuthFailure`` (→ 403) unless ``scopes`` authorizes ``method``.""" + if not is_auth_enabled(): + return + required = _METHOD_SCOPES.get(method) + if required is None: + # Unknown methods fall through to METHOD_NOT_FOUND after auth. + return + if not any(scope in scopes for scope in required): + raise _AuthFailure( + 403, + A2AErrorCode.PERMISSION_DENIED, + f"method {method!r} requires one of {list(required)}", + ) + + +# --------------------------------------------------------------------------- +# Method handlers +# --------------------------------------------------------------------------- + + +async def _run_task_in_background( + task: Task, + executor: TaskExecutor, + store: TaskStore, + bus: TaskEventBus, +) -> None: + """Run the executor and publish state transitions to the bus. + + Always finishes with the task in a terminal state — COMPLETED if + the executor returned cleanly, FAILED if it raised. Callers + register this via ``asyncio.create_task`` so the HTTP response + for ``task.send`` returns immediately with state=WORKING. + """ + # Transition submitted → working before invoking the executor so + # SSE consumers see the work start. + task.state = TaskState.WORKING + task.updated_at = time.time() + await store.upsert(task) + await bus.publish(task.id, {"task": task.to_dict()}) + try: + result = await executor(task) + # Honor whatever terminal state the executor set; default to + # COMPLETED if the executor returned without setting one. + if not TaskState.is_terminal(result.state): + result.state = TaskState.COMPLETED + result.updated_at = time.time() + await store.upsert(result) + await bus.publish(result.id, {"task": result.to_dict()}) + except Exception as exc: + logger.warning("A2A task executor failed for id=%s", task.id, exc_info=True) + task.state = TaskState.FAILED + task.updated_at = time.time() + # Surface the error in metadata so the peer can see why. + task.metadata = {**task.metadata, "error": str(exc)} + await store.upsert(task) + await bus.publish(task.id, {"task": task.to_dict()}) + + +async def _handle_task_send( + store: TaskStore, + bus: TaskEventBus, + params: dict[str, Any], + executor: Optional[TaskExecutor] = None, +) -> dict[str, Any]: + """Accept a new task. Generates an id if the peer didn't provide one. + + When an ``executor`` is registered on the router, the task is + scheduled in a background asyncio task — the HTTP response returns + immediately with state=SUBMITTED, the SSE stream sees + SUBMITTED → WORKING → terminal. Without an executor, the task + stays in SUBMITTED until a peer or operator transitions it + explicitly. + """ + task_payload = params.get("task") + if not isinstance(task_payload, dict): + raise _invalid_params("'task' must be an object") + + task = Task.from_dict(task_payload) + if not task.id: + task.id = str(uuid.uuid4()) + elif await store.get(task.id) is not None: + # Idempotent-create, not upsert: accepting a resubmitted id verbatim + # would let any peer overwrite/cancel another peer's in-flight task + # (review 4638092590 must-fix). Internal state transitions go through + # store.upsert/transition directly and are unaffected. + raise _invalid_params(f"task id {task.id!r} already exists") + if not task.state: + task.state = TaskState.SUBMITTED + if task.created_at is None: + task.created_at = time.time() + await store.upsert(task) + await bus.publish(task.id, {"task": task.to_dict()}) + + if executor is not None: + # Fire-and-forget. The background task handles state + # transitions + bus publishing; this handler returns quickly + # so the peer isn't blocked on a long-running execution. + asyncio.create_task( + _run_task_in_background(task, executor, store, bus), + name=f"a2a-task-{task.id}", + ) + + return {"task": task.to_dict()} + + +async def _handle_task_get( + store: TaskStore, bus: TaskEventBus, params: dict[str, Any] +) -> dict[str, Any]: + task_id = params.get("id") or params.get("taskId") + if not isinstance(task_id, str) or not task_id: + raise _invalid_params("'id' must be a non-empty string") + task = await store.get(task_id) + if task is None: + raise _app_error(A2AErrorCode.TASK_NOT_FOUND, f"task {task_id!r} not found") + return {"task": task.to_dict()} + + +async def _handle_task_cancel( + store: TaskStore, bus: TaskEventBus, params: dict[str, Any] +) -> dict[str, Any]: + task_id = params.get("id") or params.get("taskId") + if not isinstance(task_id, str) or not task_id: + raise _invalid_params("'id' must be a non-empty string") + task = await store.get(task_id) + if task is None: + raise _app_error(A2AErrorCode.TASK_NOT_FOUND, f"task {task_id!r} not found") + if TaskState.is_terminal(task.state): + raise _app_error( + A2AErrorCode.TASK_ALREADY_TERMINAL, + f"task {task_id!r} is already {task.state}; cannot cancel", + ) + task.state = TaskState.CANCELED + task.updated_at = time.time() + await store.upsert(task) + await bus.publish(task.id, {"task": task.to_dict()}) + return {"task": task.to_dict()} + + +_METHODS: dict[ + str, + Callable[ + [TaskStore, TaskEventBus, dict[str, Any], Optional[TaskExecutor]], + Awaitable[dict[str, Any]], + ], +] = { + "task.send": _handle_task_send, + # task.get / task.cancel ignore the executor — they're synchronous + # state queries, not work dispatchers. Wrappers below. + "task.get": lambda store, bus, params, _exec: _handle_task_get(store, bus, params), + "task.cancel": lambda store, bus, params, _exec: _handle_task_cancel(store, bus, params), +} + + +# --------------------------------------------------------------------------- +# Error helpers +# --------------------------------------------------------------------------- + + +class _RpcException(Exception): + """Raised by handlers to signal a JSON-RPC error response.""" + + def __init__(self, code: int, message: str, data: Any = None) -> None: + super().__init__(message) + self.code = code + self.message = message + self.data = data + + +def _invalid_params(message: str) -> _RpcException: + return _RpcException(int(A2AErrorCode.INVALID_PARAMS), message) + + +def _app_error(code: A2AErrorCode, message: str) -> _RpcException: + return _RpcException(int(code), message) + + +def _error_response(req_id: JsonRpcId, code: int, message: str, data: Any = None) -> dict[str, Any]: + return JsonRpcResponse( + id=req_id, error=JsonRpcError(code=code, message=message, data=data) + ).to_dict() + + +def _success_response(req_id: JsonRpcId, result: Any) -> dict[str, Any]: + return JsonRpcResponse(id=req_id, result=result).to_dict() + + +# --------------------------------------------------------------------------- +# Router +# --------------------------------------------------------------------------- + + +def build_a2a_router( + store: TaskStore | None = None, + *, + bus: TaskEventBus | None = None, + executor: Optional[TaskExecutor] = None, +) -> APIRouter: + """Construct an APIRouter that mounts the A2A v1.0 JSON-RPC endpoint. + + * ``store`` — task persistence. Defaults to an in-process + ``InMemoryTaskStore``. Operators who want durable A2A task state + can implement the ``TaskStore`` Protocol against their own + backend (libsql, postgres, etc.) and pass it in. + * ``bus`` — event bus that the SSE stream endpoint consumes + from. RPC handlers publish state transitions on this + bus so SSE subscribers see live updates. Defaults to + ``NullEventBus`` (no streaming). + * ``executor`` — async callable invoked when a peer ``task.send``s + a new task. Receives the task with state=SUBMITTED, returns + it with state set to a terminal value. Without an executor, + tasks are stored but never dispatched — useful for testing + the protocol surface in isolation. The CAO production wiring + passes an executor that bridges to ``dispatch_task``. + """ + task_store = store or InMemoryTaskStore() + event_bus = bus or NullEventBus() + router = APIRouter(prefix="/a2a/v1", tags=["a2a"]) + + @router.post("/rpc") + async def rpc(request: Request) -> JSONResponse: + # Authentication first — before any parsing or method dispatch, so an + # unauthenticated peer learns nothing about the method surface. 401 is + # carried both as the HTTP status and as a JSON-RPC error body. + try: + scopes = _resolve_request_scopes(request) + except _AuthFailure as exc: + return JSONResponse( + _error_response(None, int(exc.code), exc.message), + status_code=exc.http_status, + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Body parse — bare 400 with id=null per JSON-RPC 2.0 §5. + try: + raw = await request.body() + payload = json.loads(raw) if raw else None + except json.JSONDecodeError as exc: + return JSONResponse( + _error_response(None, int(A2AErrorCode.PARSE_ERROR), str(exc)), + status_code=400, + ) + + # Single request only — batch support is a future commit. + if not isinstance(payload, dict): + return JSONResponse( + _error_response( + None, + int(A2AErrorCode.INVALID_REQUEST), + "request body must be a JSON object", + ), + status_code=400, + ) + + req = JsonRpcRequest.from_dict(payload) + if req.jsonrpc != "2.0": + return JSONResponse( + _error_response( + req.id, + int(A2AErrorCode.INVALID_REQUEST), + "jsonrpc must be '2.0'", + ), + status_code=400, + ) + if not req.method: + return JSONResponse( + _error_response( + req.id, + int(A2AErrorCode.INVALID_REQUEST), + "method is required", + ), + status_code=400, + ) + + # Authorization: per-method scope gate (403) after authentication. + try: + _require_method_scope(req.method, scopes) + except _AuthFailure as exc: + return JSONResponse( + _error_response(req.id, int(exc.code), exc.message), + status_code=exc.http_status, + ) + + handler = _METHODS.get(req.method) + if handler is None: + return JSONResponse( + _error_response( + req.id, + int(A2AErrorCode.METHOD_NOT_FOUND), + f"unknown method {req.method!r}", + ), + status_code=404, + ) + + try: + result = await handler(task_store, event_bus, req.params, executor) + return JSONResponse(_success_response(req.id, result)) + except _RpcException as exc: + # Application-level errors are still 200 OK per JSON-RPC. + return JSONResponse(_error_response(req.id, exc.code, exc.message, exc.data)) + except TaskStoreFull as exc: + # Bounded store at capacity with live tasks — refuse the new task + # (never grow unbounded). Carried as HTTP 429 + Retry-After so + # HTTP-native retry middleware backs off without understanding + # A2A error codes, with the JSON-RPC error body for clients that + # ignore transport status. Domain errors still ride 200; capacity + # is a transport/backoff condition (matching how 401/403 already + # carry their HTTP statuses here). + return JSONResponse( + _error_response(req.id, int(A2AErrorCode.RESOURCE_EXHAUSTED), str(exc)), + status_code=429, + headers={"Retry-After": "30"}, + ) + except Exception as exc: # pragma: no cover - defensive, internal bug + logger.exception("A2A RPC handler crashed: method=%s", req.method) + return JSONResponse( + _error_response( + req.id, + int(A2AErrorCode.INTERNAL_ERROR), + f"internal error: {exc}", + ), + status_code=500, + ) + + # Stash the store + bus on the router for tests + downstream wiring. + router.task_store = task_store # type: ignore[attr-defined] + router.event_bus = event_bus # type: ignore[attr-defined] + return router diff --git a/src/cli_agent_orchestrator/a2a/store.py b/src/cli_agent_orchestrator/a2a/store.py new file mode 100644 index 0000000000..f3c3e71041 --- /dev/null +++ b/src/cli_agent_orchestrator/a2a/store.py @@ -0,0 +1,183 @@ +"""Task store for A2A v1.0 endpoints. + +The A2A spec mandates a task lifecycle: peers ``send`` a task, ``get`` +its current state, and ``cancel`` it before it terminates. The lifecycle +requires a store that survives at least the lifetime of one HTTP request +→ next. + +This module ships an in-memory store as the default backend. A SQLite +or libsql-backed store would be a drop-in replacement (the ``TaskStore`` +Protocol is the seam) but is deferred — A2A peers typically poll on +short timeframes. + +Bounds: the in-memory store is capped (``max_tasks``, default 1000, +``CAO_A2A_MAX_TASKS``) and time-windowed (``ttl_seconds``, default 3600, +``CAO_A2A_TASK_TTL``). Tasks idle past the TTL are lazily swept on access; +on overflow the oldest *terminal* tasks are evicted first, and when every +stored task is still live a new insert raises ``TaskStoreFull`` (the +RPC layer maps it to ``RESOURCE_EXHAUSTED`` at HTTP 429) instead of growing without +bound — a network-reachable ``task.send`` must never be a remote +memory-exhaustion vector. + +Concurrency: ``InMemoryTaskStore`` uses an ``asyncio.Lock`` because +the A2A endpoints are async. Sync callers should not share an +instance across threads without external synchronization. +""" + +from __future__ import annotations + +import asyncio +import logging +import os +import time +from typing import Optional, Protocol, runtime_checkable + +from cli_agent_orchestrator.a2a.types import Task, TaskState + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_TASKS = 1000 +DEFAULT_TTL_SECONDS = 3600.0 + + +class TaskStoreFull(Exception): + """The store is at capacity with live (non-terminal) tasks only. + + The RPC layer maps this to an ``A2AErrorCode.RESOURCE_EXHAUSTED`` JSON-RPC + error carried on HTTP 429 (+ ``Retry-After``) so a peer flooding + ``task.send`` gets a bounded, backoff-signaling rejection instead of + driving the process out of memory. + """ + + +@runtime_checkable +class TaskStore(Protocol): + """Async storage contract for A2A tasks.""" + + async def get(self, task_id: str) -> Optional[Task]: ... + + async def upsert(self, task: Task) -> None: ... + + async def delete(self, task_id: str) -> None: ... + + +class InMemoryTaskStore: + """Default in-memory store. One instance per CAO process. + + Bounded by ``max_tasks`` + ``ttl_seconds`` (see module docstring); + peers that track their own task ids should poll within the TTL. + """ + + def __init__( + self, + max_tasks: int = DEFAULT_MAX_TASKS, + ttl_seconds: float = DEFAULT_TTL_SECONDS, + ) -> None: + self.max_tasks = max_tasks + self.ttl_seconds = ttl_seconds + self._tasks: dict[str, Task] = {} + self._lock = asyncio.Lock() + + @classmethod + def from_env(cls) -> "InMemoryTaskStore": + """Build a store with ``CAO_A2A_MAX_TASKS`` / ``CAO_A2A_TASK_TTL`` applied. + + A malformed or non-positive value falls back to the default with a + warning — never a startup crash, and never "disable the bound" (a + zero/negative cap would silently reopen the unbounded-store DoS this + store exists to close). + """ + + def _positive(name: str, default: float) -> float: + raw = os.environ.get(name) + if raw is None or not raw.strip(): + return default + try: + value = float(raw) + except ValueError: + logger.warning("%s=%r is not a number; using default %s", name, raw, default) + return default + if value <= 0: + logger.warning( + "%s=%r is not positive; using default %s (the bound cannot be disabled)", + name, + raw, + default, + ) + return default + return value + + return cls( + max_tasks=int(_positive("CAO_A2A_MAX_TASKS", DEFAULT_MAX_TASKS)), + ttl_seconds=_positive("CAO_A2A_TASK_TTL", DEFAULT_TTL_SECONDS), + ) + + def _sweep_locked(self, now: float) -> None: + """Drop tasks idle past the TTL. Caller holds the lock.""" + if self.ttl_seconds <= 0: + return + cutoff = now - self.ttl_seconds + stale = [ + task_id + for task_id, task in self._tasks.items() + if (task.updated_at or task.created_at or now) < cutoff + ] + for task_id in stale: + del self._tasks[task_id] + + def _evict_for_capacity_locked(self) -> None: + """Make room for one insert. Caller holds the lock. + + Oldest terminal tasks go first (their lifecycle is finished; peers + re-polling them would see TASK_NOT_FOUND, same as after a TTL sweep). + Live tasks are never dropped — when the store is full of them the + insert is refused instead. + """ + while len(self._tasks) >= self.max_tasks: + terminal = [ + (task.updated_at or task.created_at or 0.0, task_id) + for task_id, task in self._tasks.items() + if TaskState.is_terminal(task.state) + ] + if not terminal: + raise TaskStoreFull( + f"store holds {len(self._tasks)} live tasks (max_tasks={self.max_tasks})" + ) + _, oldest_id = min(terminal) + del self._tasks[oldest_id] + + async def get(self, task_id: str) -> Optional[Task]: + async with self._lock: + self._sweep_locked(time.time()) + return self._tasks.get(task_id) + + async def upsert(self, task: Task) -> None: + async with self._lock: + now = time.time() + self._sweep_locked(now) + if task.id not in self._tasks: + self._evict_for_capacity_locked() + if task.created_at is None: + task.created_at = now + task.updated_at = now + self._tasks[task.id] = task + + async def delete(self, task_id: str) -> None: + async with self._lock: + self._tasks.pop(task_id, None) + + async def list_ids(self) -> list[str]: + async with self._lock: + self._sweep_locked(time.time()) + return list(self._tasks.keys()) + + async def transition(self, task_id: str, new_state: str) -> Optional[Task]: + """Atomic state transition. Returns the updated task, or + ``None`` if the task didn't exist or was already terminal.""" + async with self._lock: + task = self._tasks.get(task_id) + if task is None or TaskState.is_terminal(task.state): + return None + task.state = new_state + task.updated_at = time.time() + return task diff --git a/src/cli_agent_orchestrator/a2a/stream.py b/src/cli_agent_orchestrator/a2a/stream.py new file mode 100644 index 0000000000..f1b92b3ac2 --- /dev/null +++ b/src/cli_agent_orchestrator/a2a/stream.py @@ -0,0 +1,229 @@ +"""A2A v1.0 streaming + REST polling endpoints. + +Layered on top of the JSON-RPC endpoint. Two routes: + + * ``GET /a2a/v1/stream/{taskId}`` — Server-Sent Events stream of + task lifecycle updates. Each event has ``event: task.update`` with + a JSON ``data:`` payload containing the current task envelope. + The stream stays open until the task reaches a terminal state, + then sends a final ``event: task.terminal`` and closes. + + * ``GET /a2a/v1/tasks/{taskId}`` — REST polling fallback. Returns + the current task envelope as plain JSON. Peers without SSE + support poll this endpoint instead. + +The stream consumes from a per-task event queue. ``TaskEventBus`` +ships an in-process pub-sub primitive: callers post state transitions, +the SSE handler forwards them. A ``NullBus`` is provided for tests + +deployments where streaming isn't wired. + +The bus + store + transport endpoints together form the full A2A +v1.0 server side. Authentication: when CAO auth is enabled, both routes +require an ``Authorization: Bearer`` JWT carrying ``cao:read`` (validated +against the configured JWKS via ``require_any_scope``); when auth is +disabled (default-off) no token is required. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from collections import defaultdict +from typing import Any, AsyncIterator, Optional, Protocol, runtime_checkable + +from fastapi import APIRouter, Depends +from fastapi.responses import JSONResponse, StreamingResponse + +from cli_agent_orchestrator.a2a.store import TaskStore +from cli_agent_orchestrator.a2a.types import A2AErrorCode, TaskState +from cli_agent_orchestrator.security.auth import ( + SCOPE_ADMIN, + SCOPE_READ, + SCOPE_WRITE, + require_any_scope, +) + +logger = logging.getLogger(__name__) + + +# Cap per-task subscriber queue size so a slow consumer can't OOM CAO. +# When the queue is full, oldest events are dropped — task.update is +# idempotent so a client missing an intermediate update isn't fatal as +# long as terminal-state events get through. +_DEFAULT_QUEUE_MAX = 64 + + +# --------------------------------------------------------------------------- +# Event bus +# --------------------------------------------------------------------------- + + +@runtime_checkable +class TaskEventBus(Protocol): + """Async pub-sub for task lifecycle events. + + Producers call ``publish(task_id, payload)`` whenever a task state + transitions. Consumers (the SSE handler) call ``subscribe(task_id)`` + to get an async generator of payloads. + """ + + async def publish(self, task_id: str, payload: dict[str, Any]) -> None: ... + + def subscribe(self, task_id: str) -> AsyncIterator[dict[str, Any]]: ... + + +class InMemoryTaskEventBus: + """Per-process bus. One queue per (task_id, subscriber) pair. + + Concurrency: a per-task list of subscriber queues, guarded by a + single lock. Publish is fire-and-forget — slow subscribers drop + old events rather than blocking the producer. + """ + + def __init__(self, *, queue_max: int = _DEFAULT_QUEUE_MAX) -> None: + self._subscribers: dict[str, list[asyncio.Queue]] = defaultdict(list) + self._lock = asyncio.Lock() + self._queue_max = queue_max + + async def publish(self, task_id: str, payload: dict[str, Any]) -> None: + async with self._lock: + queues = list(self._subscribers.get(task_id, ())) + for q in queues: + try: + q.put_nowait(payload) + except asyncio.QueueFull: + # Drop oldest, push newest — keeps the stream alive + # under back-pressure without blocking the producer. + try: + q.get_nowait() + except asyncio.QueueEmpty: + pass + try: + q.put_nowait(payload) + except asyncio.QueueFull: # pragma: no cover - vanishingly rare + pass + + async def subscribe(self, task_id: str) -> AsyncIterator[dict[str, Any]]: + queue: asyncio.Queue = asyncio.Queue(maxsize=self._queue_max) + async with self._lock: + self._subscribers[task_id].append(queue) + try: + while True: + payload = await queue.get() + yield payload + finally: + async with self._lock: + if queue in self._subscribers.get(task_id, []): + self._subscribers[task_id].remove(queue) + if not self._subscribers[task_id]: + self._subscribers.pop(task_id, None) + + +class NullEventBus: + """No-op bus for tests + deployments where streaming isn't wired.""" + + async def publish(self, task_id: str, payload: dict[str, Any]) -> None: + return None + + async def subscribe(self, task_id: str) -> AsyncIterator[dict[str, Any]]: + # Yields nothing — the consumer's outer ``async for`` exits + # immediately. Yields once unreachable to satisfy the + # async-generator runtime. + if False: # pragma: no cover + yield {} + + +# --------------------------------------------------------------------------- +# Stream + REST router +# --------------------------------------------------------------------------- + + +def _sse_event(event: str, payload: dict[str, Any]) -> bytes: + """Format one Server-Sent Event frame. The blank line is the + record terminator per the SSE spec.""" + return f"event: {event}\ndata: {json.dumps(payload)}\n\n".encode("utf-8") + + +def build_stream_router( + *, + store: TaskStore, + bus: Optional[TaskEventBus] = None, + poll_initial: bool = True, +) -> APIRouter: + """Construct a router exposing the SSE + REST polling endpoints. + + * ``store`` — resolves task ids on REST polling + sends the + initial state on SSE connect. + * ``bus`` — produces the SSE stream's update events. Defaults to + ``NullEventBus`` (the stream sends only the initial state + + terminal frame and closes). + * ``poll_initial`` — when True (default), the SSE handler emits + one ``task.update`` event with the current state right after + the connect handshake. Disable for tests that want to verify + bus-driven events in isolation. + """ + event_bus = bus or NullEventBus() + # Read-only routes: require cao:read (write/admin also satisfy). No-op when + # the auth layer is default-off. ROUTER-level so both routes are covered — + # and any future route added here inherits the gate instead of shipping + # unauthenticated by omission. + router = APIRouter( + prefix="/a2a/v1", + tags=["a2a"], + dependencies=[Depends(require_any_scope(SCOPE_READ, SCOPE_WRITE, SCOPE_ADMIN))], + ) + + @router.get("/tasks/{task_id}") + async def get_task(task_id: str) -> JSONResponse: + task = await store.get(task_id) + if task is None: + return JSONResponse( + { + "error": { + "code": int(A2AErrorCode.TASK_NOT_FOUND), + "message": f"task {task_id!r} not found", + } + }, + status_code=404, + ) + return JSONResponse({"task": task.to_dict()}) + + @router.get("/stream/{task_id}") + async def stream(task_id: str) -> StreamingResponse: + async def gen() -> AsyncIterator[bytes]: + # 1. Initial state (or 404-equivalent + close). + task = await store.get(task_id) + if task is None: + yield _sse_event( + "task.error", + { + "code": int(A2AErrorCode.TASK_NOT_FOUND), + "message": f"task {task_id!r} not found", + }, + ) + return + if poll_initial: + yield _sse_event("task.update", {"task": task.to_dict()}) + if TaskState.is_terminal(task.state): + yield _sse_event("task.terminal", {"task": task.to_dict()}) + return + + # 2. Subscribe to bus + relay events until terminal. + async for payload in event_bus.subscribe(task_id): + event_name = "task.update" + task_payload = payload.get("task") + if isinstance(task_payload, dict) and TaskState.is_terminal( + task_payload.get("state", "") + ): + event_name = "task.terminal" + yield _sse_event(event_name, payload) + if event_name == "task.terminal": + return + + return StreamingResponse(gen(), media_type="text/event-stream") + + # Stash the bus on the router so callers can publish events without + # holding a separate reference. + router.event_bus = event_bus # type: ignore[attr-defined] + return router diff --git a/src/cli_agent_orchestrator/a2a/types.py b/src/cli_agent_orchestrator/a2a/types.py new file mode 100644 index 0000000000..97caca4b9d --- /dev/null +++ b/src/cli_agent_orchestrator/a2a/types.py @@ -0,0 +1,179 @@ +"""A2A v1.0 wire types + JSON-RPC envelope. + +The A2A spec is JSON-RPC 2.0 over HTTP with task state machine +semantics. We model only what CAO needs to round-trip a task +end-to-end: + + * ``Task`` — opaque wrapper around the message thread. The state + machine has five terminal/non-terminal states (per the A2A spec): + ``submitted``, ``working``, ``input-required``, ``completed``, + ``canceled``, ``failed``. + * ``JsonRpcRequest`` / ``JsonRpcResponse`` — the canonical JSON-RPC + 2.0 envelope. We accept both string and integer ids per the spec. + * ``A2AErrorCode`` — error codes. Negative numbers in the JSON-RPC + reserved range (-32xxx) for protocol errors; positive task-state + errors layered on top. + +The fields here are deliberately permissive (`extra: dict[str, Any]` +on Task) so we can round-trip novel A2A extensions a peer might send +without losing them. CAO doesn't interpret extension fields itself. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import IntEnum +from typing import Any, Optional, Union + +# JSON-RPC ids are str | int | None per the spec. +JsonRpcId = Union[str, int, None] + + +class TaskState(str): + """A2A task state machine values. Strings, not an Enum, so they + serialize directly to JSON without a converter step.""" + + SUBMITTED = "submitted" + WORKING = "working" + INPUT_REQUIRED = "input-required" + COMPLETED = "completed" + CANCELED = "canceled" + FAILED = "failed" + + @classmethod + def all(cls) -> tuple[str, ...]: + return ( + cls.SUBMITTED, + cls.WORKING, + cls.INPUT_REQUIRED, + cls.COMPLETED, + cls.CANCELED, + cls.FAILED, + ) + + @classmethod + def is_terminal(cls, state: str) -> bool: + return state in (cls.COMPLETED, cls.CANCELED, cls.FAILED) + + +class A2AErrorCode(IntEnum): + """Error codes for JSON-RPC responses + A2A task semantics.""" + + # JSON-RPC 2.0 reserved range. + PARSE_ERROR = -32700 + INVALID_REQUEST = -32600 + METHOD_NOT_FOUND = -32601 + INVALID_PARAMS = -32602 + INTERNAL_ERROR = -32603 + + # A2A-specific. Per the v1.0 spec these are application-defined + # codes outside the reserved range. + TASK_NOT_FOUND = 1 + TASK_ALREADY_TERMINAL = 2 + UNAUTHENTICATED = 3 + UNSUPPORTED_OPERATION = 4 + # Caller authenticated but lacks the scope the method requires (HTTP 403). + PERMISSION_DENIED = 5 + # Task store is at capacity and cannot accept a new task (HTTP 429). + RESOURCE_EXHAUSTED = 6 + + +@dataclass +class Task: + """A2A task. Mostly opaque to CAO — the state machine + id are + load-bearing, the rest is round-tripped to the peer.""" + + id: str + state: str = TaskState.SUBMITTED + messages: list[dict[str, Any]] = field(default_factory=list) + metadata: dict[str, Any] = field(default_factory=dict) + artifacts: list[dict[str, Any]] = field(default_factory=list) + created_at: Optional[float] = None + updated_at: Optional[float] = None + extra: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = { + "id": self.id, + "state": self.state, + "messages": self.messages, + "metadata": self.metadata, + "artifacts": self.artifacts, + } + if self.created_at is not None: + d["created_at"] = self.created_at + if self.updated_at is not None: + d["updated_at"] = self.updated_at + # Round-trip unknown fields verbatim. + d.update(self.extra) + return d + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "Task": + known = {"id", "state", "messages", "metadata", "artifacts", "created_at", "updated_at"} + extra = {k: v for k, v in data.items() if k not in known} + return cls( + # data.get, not data["id"]: an omitted id is legal — task.send + # generates a server-side UUID for falsy ids (review 4638092590). + id=str(data.get("id", "")), + state=data.get("state", TaskState.SUBMITTED), + messages=list(data.get("messages", [])), + metadata=dict(data.get("metadata", {})), + artifacts=list(data.get("artifacts", [])), + created_at=data.get("created_at"), + updated_at=data.get("updated_at"), + extra=extra, + ) + + +@dataclass +class JsonRpcRequest: + """JSON-RPC 2.0 request envelope. + + The ``id`` field distinguishes requests (id present) from + notifications (id missing). CAO's A2A implementation does not + yet support notifications; missing-id requests get a + ``INVALID_REQUEST`` response. + """ + + jsonrpc: str + method: str + params: dict[str, Any] = field(default_factory=dict) + id: JsonRpcId = None + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "JsonRpcRequest": + return cls( + jsonrpc=data.get("jsonrpc", "2.0"), + method=data.get("method", ""), + params=data.get("params") or {}, + id=data.get("id"), + ) + + +@dataclass +class JsonRpcError: + code: int + message: str + data: Optional[Any] = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"code": int(self.code), "message": self.message} + if self.data is not None: + d["data"] = self.data + return d + + +@dataclass +class JsonRpcResponse: + id: JsonRpcId + result: Optional[Any] = None + error: Optional[JsonRpcError] = None + + def to_dict(self) -> dict[str, Any]: + d: dict[str, Any] = {"jsonrpc": "2.0", "id": self.id} + if self.error is not None: + d["error"] = self.error.to_dict() + else: + d["result"] = self.result + return d diff --git a/src/cli_agent_orchestrator/agent_card/__init__.py b/src/cli_agent_orchestrator/agent_card/__init__.py new file mode 100644 index 0000000000..59a1bf1ee3 --- /dev/null +++ b/src/cli_agent_orchestrator/agent_card/__init__.py @@ -0,0 +1,29 @@ +"""Signed A2A v1.0 Agent Card publication. + +Surface: + * ``Signer`` — Ed25519 sign/verify, lazily-generated key on disk. + * ``build_agent_card`` — assembles the unsigned Agent Card from + settings + provider list + MCP tool surface. + * ``router`` — APIRouter exposing /.well-known/agent-card.json and + /.well-known/jwks.json. + * ``start_agent_card_listener`` — dedicated :9890 uvicorn server + (no TrustedHostMiddleware) so external A2A peers can discover CAO. +""" + +from cli_agent_orchestrator.agent_card.builder import build_agent_card +from cli_agent_orchestrator.agent_card.listener import ( + AgentCardListener, + build_listener_app, + start_agent_card_listener, +) +from cli_agent_orchestrator.agent_card.signing import JWS, Signer, verify_compact_jws + +__all__ = [ + "AgentCardListener", + "JWS", + "Signer", + "build_agent_card", + "build_listener_app", + "start_agent_card_listener", + "verify_compact_jws", +] diff --git a/src/cli_agent_orchestrator/agent_card/builder.py b/src/cli_agent_orchestrator/agent_card/builder.py new file mode 100644 index 0000000000..6fde542126 --- /dev/null +++ b/src/cli_agent_orchestrator/agent_card/builder.py @@ -0,0 +1,109 @@ +"""Agent Card builder. + +Produces an A2A v1.0-conformant ``agent-card.json`` document from CAO's +runtime state — provider list, MCP tool surface, and operator-supplied +metadata stored in ``settings.json``. + +The card is signed by ``signing.Signer`` and served at +``/.well-known/agent-card.json`` from the dedicated :9890 listener. +""" + +from __future__ import annotations + +import shutil +from importlib import metadata as _md +from typing import Any + +# A2A v1.0 declared MCP-tool surface for CAO. These names are the same +# tools the FastMCP server exposes — see mcp_server/server.py. +_DECLARED_MCP_TOOLS = ["assign", "handoff", "send_message", "load_skill"] + + +# Provider binary lookup, mirroring api/main.py:list_providers_endpoint. +_PROVIDER_BINARIES = { + "kiro_cli": "kiro-cli", + "claude_code": "claude", + "q_cli": "q", + "codex": "codex", + "gemini_cli": "gemini", + "kimi_cli": "kimi", + "copilot_cli": "copilot", + "opencode_cli": "opencode", +} + + +def _package_version() -> str: + try: + return _md.version("cli-agent-orchestrator") + except _md.PackageNotFoundError: # pragma: no cover - install-time only + return "0.0.0" + + +def _detect_installed_providers() -> list[dict[str, Any]]: + """Mirror api/main.py:list_providers_endpoint without importing FastAPI.""" + return [ + {"name": p, "binary": b, "installed": shutil.which(b) is not None} + for p, b in _PROVIDER_BINARIES.items() + ] + + +def build_agent_card( + metadata: dict[str, Any] | None = None, + *, + rpc_endpoint: str | None = None, + stream_endpoint: str | None = None, + tasks_endpoint: str | None = None, +) -> dict[str, Any]: + """Build the unsigned Agent Card. + + ``metadata`` is operator-supplied configuration (``description``, + ``contact``, ``vendor``, ``agent_id``) typically read from + ``settings.json`` via ``settings_service``. + + Endpoints default to ``None`` because the full A2A transport surface + arrives in Phase 5 (commit see plan §12). In Phase 1 the card declares + skills and capabilities only — peers wishing to actually invoke tools + must wait until the transport layer is published. + """ + metadata = metadata or {} + + skills = [ + {"id": "orchestrate", "description": "Decompose tasks and dispatch to specialists."}, + {"id": "plan", "description": "Single-threaded planning over a write spine."}, + {"id": "code", "description": "Code generation via a serialized write queue."}, + {"id": "review", "description": "Independent N=3 majority-vote review."}, + {"id": "search", "description": "Breadth-first read across parallel workers."}, + ] + + return { + "$schema": "https://a2aproject.org/schemas/v1.0/agent-card.json", + "agentId": metadata.get("agent_id", "cao-mayor-local"), + "name": metadata.get("name", "CAO Mayor"), + "description": metadata.get( + "description", + "CLI Agent Orchestrator — Tier-1 orchestrator for hierarchical multi-agent workflows.", + ), + "organization": metadata.get("organization", "local"), + "vendor": metadata.get("vendor", "AWS Labs"), + "contact": metadata.get("contact"), + "version": _package_version(), + "capabilities": { + "skills": skills, + "supportedModalities": ["text", "structured-data"], + "tools": _DECLARED_MCP_TOOLS, + }, + "providers": _detect_installed_providers(), + "protocols": { + "transport": ["json-rpc-2.0"], + "streaming": "sse", + "security": ["oauth2.1", "mtls"], + }, + "endpoints": { + "rpc": rpc_endpoint, + "stream": stream_endpoint, + "tasks": tasks_endpoint, + }, + # AgentCardSignature is filled in by router.py when the card is + # actually served — separating "build" from "sign" lets the tests + # exercise each in isolation. + } diff --git a/src/cli_agent_orchestrator/agent_card/listener.py b/src/cli_agent_orchestrator/agent_card/listener.py new file mode 100644 index 0000000000..91f048cb55 --- /dev/null +++ b/src/cli_agent_orchestrator/agent_card/listener.py @@ -0,0 +1,135 @@ +"""Dedicated read-only listener for /.well-known/agent-card.json. + +The Agent Card needs to be discoverable by external A2A peers, but the +main :9889 API is locked behind ``TrustedHostMiddleware`` to preserve +CAO's localhost-only security stance for arbitrary requests. The design +is a second uvicorn server bound to :9890 that hosts only the agent_card +router — no other routes, no Host restriction, read-only. + +The listener is **default-off** (it only starts when ``CAO_AGENT_CARD_ENABLED`` +is set) and binds **loopback (127.0.0.1) by default**, matching CAO's +localhost-only stance. Operators who explicitly want the Agent Card to be +discoverable by external A2A peers opt in with ``CAO_AGENT_CARD_HOST=0.0.0.0`` +(and are responsible for the network exposure that implies). +""" + +from __future__ import annotations + +import asyncio +import logging +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +import uvicorn +from fastapi import FastAPI + +from cli_agent_orchestrator.agent_card import router as card_router +from cli_agent_orchestrator.agent_card.signing import Signer + +logger = logging.getLogger(__name__) + + +def build_listener_app( + signer: Signer, + metadata_provider: Callable[[], dict[str, Any]], + *, + a2a_router: Any = None, + a2a_stream_router: Any = None, +) -> FastAPI: + """Construct the dedicated FastAPI app served by the :9890 listener. + + Exposes ``/.well-known/agent-card.json`` and ``/.well-known/jwks.json`` + by default. When ``a2a_router`` and/or ``a2a_stream_router`` are + supplied (Phase 5 commits 26 + 27), the A2A v1.0 JSON-RPC and + streaming/REST endpoints are mounted alongside the discovery routes + on the same external-facing boundary. + """ + card_router.configure(signer, metadata_provider) + app = FastAPI( + title="CAO Agent Card", + description="A2A v1.0 Agent Card + transport endpoints.", + version="1.0.0", + docs_url=None, # No interactive docs on the public listener. + redoc_url=None, + openapi_url=None, + ) + app.include_router(card_router.router) + if a2a_router is not None: + app.include_router(a2a_router) + if a2a_stream_router is not None: + app.include_router(a2a_stream_router) + return app + + +@dataclass +class AgentCardListener: + server: uvicorn.Server + task: asyncio.Task[None] + + async def stop(self) -> None: + self.server.should_exit = True + try: + await asyncio.wait_for(self.task, timeout=5.0) + except asyncio.TimeoutError: + logger.warning("Agent Card listener did not stop within 5s") + except Exception: # pragma: no cover - defensive + # A start-up failure (e.g. the port was already bound) surfaces + # when the serve task is awaited. Log and continue so shutdown + # still releases whatever sockets did open. + logger.warning("Agent Card listener task ended with an error", exc_info=True) + # uvicorn's Server.serve() returns right after startup() (which binds + # the listening socket) when should_exit is already set, skipping the + # shutdown() that would close it. Setting should_exit alone therefore + # races: a quick start->stop can leave :9890 bound until GC. Close any + # listening sockets explicitly so the port is released deterministically. + for server in getattr(self.server, "servers", None) or []: + server.close() + for server in getattr(self.server, "servers", None) or []: + try: + await server.wait_closed() + except Exception: # pragma: no cover - defensive + pass + + +async def start_agent_card_listener( + signer_dir: Path, + metadata_provider: Callable[[], dict[str, Any]], + *, + host: str | None = None, + port: int = 9890, + a2a_router: Any = None, + a2a_stream_router: Any = None, +) -> AgentCardListener: + """Start the :9890 uvicorn server in a background task. + + When ``a2a_router`` and/or ``a2a_stream_router`` are supplied + (Phase 5 commits 26 + 27), the A2A v1.0 transport endpoints are + mounted on the listener alongside the Agent Card discovery routes. + + Returns an ``AgentCardListener`` whose ``.stop()`` cleanly tears down + the server. Failures during startup are logged and re-raised — the + operator should know if the second listener can't bind. + """ + bind_host = host or os.environ.get("CAO_AGENT_CARD_HOST", "127.0.0.1") + signer = Signer(signer_dir) + app = build_listener_app( + signer, + metadata_provider, + a2a_router=a2a_router, + a2a_stream_router=a2a_stream_router, + ) + config = uvicorn.Config( + app, + host=bind_host, + port=port, + log_level="warning", + # No access log — too noisy for a discovery endpoint that gets + # polled. Errors still surface via the application logger. + access_log=False, + ) + server = uvicorn.Server(config) + task = asyncio.create_task(server.serve(), name="agent-card-listener") + logger.info("Agent Card listener starting on %s:%d", bind_host, port) + return AgentCardListener(server=server, task=task) diff --git a/src/cli_agent_orchestrator/agent_card/router.py b/src/cli_agent_orchestrator/agent_card/router.py new file mode 100644 index 0000000000..65b2ac97bd --- /dev/null +++ b/src/cli_agent_orchestrator/agent_card/router.py @@ -0,0 +1,96 @@ +"""APIRouter exposing the Agent Card and JWKS at well-known URIs. + +Mounted on the dedicated :9890 listener (no TrustedHostMiddleware) so +external A2A peers can discover CAO. Read-only — no other routes. +""" + +from __future__ import annotations + +from typing import Any, Callable, Optional + +from fastapi import APIRouter + +from cli_agent_orchestrator.agent_card.builder import build_agent_card +from cli_agent_orchestrator.agent_card.signing import Signer + +MetadataProvider = Callable[[], dict[str, Any]] + +_signer: Optional[Signer] = None +_metadata_provider: Optional[MetadataProvider] = None + + +def configure(signer: Signer, metadata_provider: MetadataProvider) -> None: + """Inject the dependencies the router needs to sign and assemble cards. + + Called once from the lifespan before the router starts serving. Keeping + these as module-level injectables avoids forcing the FastAPI app to + carry app.state.* through to a sub-application. + """ + global _signer, _metadata_provider + _signer = signer + _metadata_provider = metadata_provider + + +def reset() -> None: + """Forget injected state. Used in tests to start clean.""" + global _signer, _metadata_provider + _signer = None + _metadata_provider = None + + +router = APIRouter() + + +@router.get("/.well-known/agent-card.json") +def well_known_agent_card() -> dict[str, Any]: + if _signer is None: + # Defensive — should never happen if lifespan wired correctly. + return {"error": "agent card listener not initialized"} + metadata = _metadata_provider() if _metadata_provider else {} + card = build_agent_card(metadata) + card["AgentCardSignature"] = _signer.sign_card(card) + return card + + +@router.get("/.well-known/jwks.json") +def well_known_jwks() -> dict[str, Any]: + if _signer is None: + return {"keys": []} + return {"keys": [_signer.public_jwk()]} + + +@router.get("/.well-known/oauth-protected-resource") +def well_known_oauth_protected_resource() -> Any: + """Protected Resource Metadata per RFC 9728. + + Returns a JSON document letting MCP + clients discover which authorization server issues tokens for this + CAO instance and which scopes the API requires. When ``AUTH0_DOMAIN`` + is unset, the route returns 404 — discovery correctly signals "no + auth required, treat as localhost-only". + """ + from fastapi import HTTPException, Request + from fastapi.responses import JSONResponse + + from cli_agent_orchestrator.security import SCOPES_SUPPORTED as ALL_SCOPES + from cli_agent_orchestrator.security import is_auth_enabled as auth_enabled + + if not auth_enabled(): + raise HTTPException(status_code=404, detail="OAuth not configured") + + import os + + domain = os.environ.get("AUTH0_DOMAIN", "") + audience = os.environ.get("AUTH0_AUDIENCE", "") + + return JSONResponse( + { + "resource": audience, + "authorization_servers": [f"https://{domain}/"], + "bearer_methods_supported": ["header"], + "scopes_supported": list(ALL_SCOPES), + "resource_documentation": ( + "https://github.com/awslabs/cli-agent-orchestrator/blob/main/docs/auth.md" + ), + } + ) diff --git a/src/cli_agent_orchestrator/agent_card/signing.py b/src/cli_agent_orchestrator/agent_card/signing.py new file mode 100644 index 0000000000..e68d33b947 --- /dev/null +++ b/src/cli_agent_orchestrator/agent_card/signing.py @@ -0,0 +1,174 @@ +"""Ed25519 signing for the CAO Agent Card. + +Produces an A2A v1.0 ``AgentCardSignature`` field — a JWS in compact +form (header.payload.signature) using Ed25519 (alg=EdDSA). + +Conventions: + * Private key file lives at ``CAO_HOME_DIR/agent_card/key.ed25519``, + generated lazily with mode 0600 the first time ``Signer`` is constructed. + * Signed payload is the canonical JSON of the card *without* its + ``AgentCardSignature`` field — standard "self-referential signature" + handling so the signed bytes are stable. + * Public key is exported as a JWK (RFC 8037 OKP/Ed25519) at + ``/.well-known/jwks.json`` so external A2A peers can verify. + +We rely only on ``cryptography`` (already in tree) — no JWT/JWS library +dependency. The JWS compact form is short and well-defined. +""" + +from __future__ import annotations + +import base64 +import json +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, + Ed25519PublicKey, +) + + +def _b64u(data: bytes) -> str: + """Base64url without padding (RFC 7515 §2).""" + return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") + + +def _b64u_decode(s: str) -> bytes: + pad = "=" * (-len(s) % 4) + return base64.urlsafe_b64decode(s + pad) + + +def _canonical_json(obj: dict[str, Any]) -> bytes: + """Stable serialization for signing (sorted keys, no spaces).""" + return json.dumps(obj, sort_keys=True, separators=(",", ":")).encode("utf-8") + + +@dataclass(frozen=True) +class JWS: + """Parsed JWS compact form: header.payload.signature (all base64url).""" + + header_b64: str + payload_b64: str + signature_b64: str + + def to_compact(self) -> str: + return f"{self.header_b64}.{self.payload_b64}.{self.signature_b64}" + + +class Signer: + """Ed25519 signer with a lazily-generated on-disk key.""" + + KEY_FILENAME = "key.ed25519" + KID = "cao-agent-card-v1" + ALG = "EdDSA" + + def __init__(self, key_dir: Path) -> None: + self._key_dir = key_dir + self._key_dir.mkdir(parents=True, exist_ok=True, mode=0o700) + self._key_path = key_dir / self.KEY_FILENAME + self._private = self._load_or_generate() + self._public = self._private.public_key() + + # -- key management ----------------------------------------------------- + + def _load_or_generate(self) -> Ed25519PrivateKey: + if self._key_path.exists(): + data = self._key_path.read_bytes() + return serialization.load_pem_private_key(data, password=None) # type: ignore[return-value] + # Generate fresh key with mode 0600. + key = Ed25519PrivateKey.generate() + pem = key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + # Write atomically with restrictive permissions. + tmp = self._key_path.with_suffix(self._key_path.suffix + ".tmp") + tmp.write_bytes(pem) + os.chmod(tmp, 0o600) + tmp.replace(self._key_path) + return key + + def public_jwk(self) -> dict[str, Any]: + """Return the public key as a JWK (RFC 8037 OKP, Ed25519).""" + raw = self._public.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + return { + "kty": "OKP", + "crv": "Ed25519", + "kid": self.KID, + "alg": self.ALG, + "use": "sig", + "x": _b64u(raw), + } + + # -- signing / verification ------------------------------------------- + + def sign(self, payload: bytes) -> JWS: + """Produce a JWS compact-form signature over ``payload``.""" + header = {"alg": self.ALG, "kid": self.KID, "typ": "JWS"} + header_b64 = _b64u(_canonical_json(header)) + payload_b64 = _b64u(payload) + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + signature = self._private.sign(signing_input) + return JWS(header_b64, payload_b64, _b64u(signature)) + + def sign_card(self, card: dict[str, Any]) -> str: + """Sign an Agent Card dict and return the compact JWS string. + + The ``AgentCardSignature`` field, if present on input, is stripped + before signing so the signed bytes are stable across re-issues. + """ + body = {k: v for k, v in card.items() if k != "AgentCardSignature"} + jws = self.sign(_canonical_json(body)) + return jws.to_compact() + + def verify_card(self, card: dict[str, Any]) -> bool: + """Verify a signed Agent Card. Returns True if the signature is valid.""" + signature = card.get("AgentCardSignature") + if not isinstance(signature, str): + return False + return verify_compact_jws(self._public, signature, omit_field=True, card=card) + + +def verify_compact_jws( + public: Ed25519PublicKey, + compact: str, + *, + omit_field: bool = False, + card: dict[str, Any] | None = None, +) -> bool: + """Verify a compact JWS against a public key. + + When ``omit_field`` is True, the verifier rebuilds the signed payload + from ``card`` minus its ``AgentCardSignature`` field rather than from + the JWS payload segment. This is what we need to verify a card that + embeds its own signature. + """ + parts = compact.split(".") + if len(parts) != 3: + return False + header_b64, payload_b64, signature_b64 = parts + try: + signature = _b64u_decode(signature_b64) + except Exception: + return False + + if omit_field and card is not None: + body = {k: v for k, v in card.items() if k != "AgentCardSignature"} + expected_payload_b64 = _b64u(_canonical_json(body)) + if expected_payload_b64 != payload_b64: + return False + + signing_input = f"{header_b64}.{payload_b64}".encode("ascii") + try: + public.verify(signature, signing_input) + return True + except Exception: + return False diff --git a/src/cli_agent_orchestrator/api/main.py b/src/cli_agent_orchestrator/api/main.py index 34b674e38f..0d8ba5e9c6 100644 --- a/src/cli_agent_orchestrator/api/main.py +++ b/src/cli_agent_orchestrator/api/main.py @@ -35,6 +35,13 @@ from fastapi.responses import JSONResponse from pydantic import BaseModel, Field, field_validator, model_validator +from cli_agent_orchestrator.a2a import ( + InMemoryTaskEventBus, + InMemoryTaskStore, + build_a2a_router, + build_stream_router, +) +from cli_agent_orchestrator.agent_card import start_agent_card_listener from cli_agent_orchestrator.backends import TerminalNotFoundError from cli_agent_orchestrator.backends.herdr_backend import HerdrBackend from cli_agent_orchestrator.backends.registry import get_backend @@ -45,6 +52,8 @@ init_db, ) from cli_agent_orchestrator.constants import ( + AGENT_CARD_KEY_DIR, + AGENT_CARD_PORT, ALLOWED_HOSTS, API_BASE_URL, CAO_HOME_DIR, @@ -420,6 +429,50 @@ def validate_name(cls, v: str) -> str: return v +# Hosts treated as loopback for the A2A mount guard. +_A2A_LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost") + + +def _should_mount_a2a(*, bind_host: str, a2a_disabled: bool, auth_enabled: bool) -> bool: + """Decide whether to mount the A2A transport routers on the listener. + + Fail-closed: the A2A JSON-RPC surface is a task *write* API, so it must not + be mounted on a non-loopback bind while the auth layer is disabled — that + combination would expose an unauthenticated, peer-controllable task API + (the exact finding the review reproduced). Loopback + no-auth stays allowed + for local dev; any bind with auth enabled is allowed; ``CAO_A2A_DISABLED`` + always wins. Pure function so the decision table is unit-testable without + binding a socket (the lifespan integration tests cover the wiring). + """ + if a2a_disabled: + return False + if bind_host not in _A2A_LOOPBACK_HOSTS and not auth_enabled: + return False + return True + + +# Hosts treated as loopback for the A2A mount guard. +_A2A_LOOPBACK_HOSTS = ("127.0.0.1", "::1", "localhost") + + +def _should_mount_a2a(*, bind_host: str, a2a_disabled: bool, auth_enabled: bool) -> bool: + """Decide whether to mount the A2A transport routers on the listener. + + Fail-closed: the A2A JSON-RPC surface is a task *write* API, so it must not + be mounted on a non-loopback bind while the auth layer is disabled — that + combination would expose an unauthenticated, peer-controllable task API + (the exact finding the review reproduced). Loopback + no-auth stays allowed + for local dev; any bind with auth enabled is allowed; ``CAO_A2A_DISABLED`` + always wins. Pure function so the decision table is unit-testable without + binding a socket (the lifespan integration tests cover the wiring). + """ + if a2a_disabled: + return False + if bind_host not in _A2A_LOOPBACK_HOSTS and not auth_enabled: + return False + return True + + @asynccontextmanager async def lifespan(app: FastAPI): """Application lifespan events.""" @@ -480,6 +533,85 @@ def deliver_inbox(terminal_id: str) -> None: herdr_inbox_task = asyncio.create_task(svc.start()) logger.info("Herdr inbox service started") + # Agent Card / A2A listener (DEFAULT-OFF). + # + # Optionally starts a dedicated read-only Agent Card listener on its own + # port (AGENT_CARD_PORT, default :9890). It is off unless explicitly + # enabled, binds loopback (127.0.0.1) by default, and is failure-isolated. + # - CAO_AGENT_CARD_ENABLED=true starts the listener (off by default). It + # does NOT inherit the main app's TrustedHostMiddleware, so operators + # opt in knowingly; it binds 127.0.0.1 unless CAO_AGENT_CARD_HOST is + # set (e.g. 0.0.0.0 for external A2A discoverability — a deliberate + # network-exposure choice). + # - CAO_A2A_DISABLED=true publishes the Agent Card but skips mounting the + # A2A v1.0 JSON-RPC + stream routers on the listener. + agent_card_listener = None + if os.environ.get("CAO_AGENT_CARD_ENABLED", "false").lower() == "true": + try: + from cli_agent_orchestrator.services import settings_service + + def _agent_card_metadata() -> Dict: + """Operator-supplied Agent Card metadata (best-effort). + + Prefers a ``get_agent_card_metadata`` on settings_service if the + (ported) implementation is present; otherwise falls back to the + unified config ``agent_card`` key, then an empty dict (the + builder produces a valid default card from an empty mapping). + """ + getter = getattr(settings_service, "get_agent_card_metadata", None) + if callable(getter): + try: + return getter() or {} + except Exception: + return {} + try: + return ConfigService.get("agent_card", default={}) or {} + except Exception: + return {} + + a2a_router = None + a2a_stream_router = None + a2a_disabled_flag = os.environ.get("CAO_A2A_DISABLED", "false").lower() == "true" + a2a_bind_host = os.environ.get("CAO_AGENT_CARD_HOST", "127.0.0.1") + a2a_wanted = _should_mount_a2a( + bind_host=a2a_bind_host, + a2a_disabled=a2a_disabled_flag, + auth_enabled=is_auth_enabled(), + ) + if not a2a_disabled_flag and not a2a_wanted: + # Only the fail-closed guard refuses when the flag is on: + # non-loopback bind with no auth configured would be an + # unauthenticated write API for any network peer. + logger.error( + "Refusing to mount the A2A task routes: CAO_AGENT_CARD_HOST=%s is " + "non-loopback and auth is not configured (set AUTH0_DOMAIN / " + "CAO_AUTH_JWKS_URI, or bind loopback). The Agent Card discovery " + "routes are still served.", + a2a_bind_host, + ) + if a2a_wanted: + a2a_store = InMemoryTaskStore.from_env() + a2a_bus = InMemoryTaskEventBus() + a2a_router = build_a2a_router(store=a2a_store, bus=a2a_bus) + a2a_stream_router = build_stream_router(store=a2a_store, bus=a2a_bus) + app.state.a2a_store = a2a_store + app.state.a2a_bus = a2a_bus + + agent_card_listener = await start_agent_card_listener( + AGENT_CARD_KEY_DIR, + _agent_card_metadata, + port=AGENT_CARD_PORT, + a2a_router=a2a_router, + a2a_stream_router=a2a_stream_router, + ) + logger.info("Agent Card / A2A listener started on :%s", AGENT_CARD_PORT) + except Exception: + logger.warning("Agent Card listener failed to start; continuing", exc_info=True) + + # Expose the listener handle (None when default-off) so tests can assert no + # extra listener is opened without the explicit enable flag. + app.state.agent_card_listener = agent_card_listener + yield # Stop herdr inbox service on shutdown diff --git a/src/cli_agent_orchestrator/constants.py b/src/cli_agent_orchestrator/constants.py index 01f2e54a6a..cd85dbfa41 100644 --- a/src/cli_agent_orchestrator/constants.py +++ b/src/cli_agent_orchestrator/constants.py @@ -164,6 +164,13 @@ def _env_int(name: str, default: int) -> int: # OpenTelemetry service.name for CAO's spans/metrics. OTEL_SERVICE_NAME = "cao" +# Dedicated read-only Agent Card / A2A listener. +# Runs on its own port (default 9890), intentionally discoverable by external +# A2A peers, separate from the localhost-only main API. Signing keys live under +# AGENT_CARD_KEY_DIR. +AGENT_CARD_PORT = int(os.environ.get("CAO_AGENT_CARD_PORT", "9890")) +AGENT_CARD_KEY_DIR = CAO_HOME_DIR / "agent_card" + # Provider-specific agent directories KIRO_AGENTS_DIR = Path(os.environ.get("CAO_AGENTS_DIR", str(Path.home() / ".kiro" / "agents"))) COPILOT_AGENTS_DIR = Path.home() / ".copilot" / "agents" # Copilot custom agents diff --git a/src/cli_agent_orchestrator/security/auth.py b/src/cli_agent_orchestrator/security/auth.py index 03047a5609..419c660fd4 100644 --- a/src/cli_agent_orchestrator/security/auth.py +++ b/src/cli_agent_orchestrator/security/auth.py @@ -207,6 +207,16 @@ def clear(self) -> None: _jwks_cache = _JWKSCache() +def reset_jwks_cache() -> None: + """Drop any cached JWKS client (tests switch key material between cases). + + ``test/conftest.py::auth_enabled_env`` has referenced this since the + fixture landed; the function itself was missing — a latent break for any + in-process test enabling auth. + """ + _jwks_cache.clear() + + def get_jwks_cache() -> _JWKSCache: """Return the process-wide JWKS cache (exposed for tests).""" diff --git a/test/a2a/__init__.py b/test/a2a/__init__.py new file mode 100644 index 0000000000..c42b66c373 --- /dev/null +++ b/test/a2a/__init__.py @@ -0,0 +1 @@ +"""Tests for the Phase 5 A2A v1.0 transport endpoints.""" diff --git a/test/a2a/test_auth_enforcement.py b/test/a2a/test_auth_enforcement.py new file mode 100644 index 0000000000..17efd0cd2f --- /dev/null +++ b/test/a2a/test_auth_enforcement.py @@ -0,0 +1,259 @@ +"""A2A transport auth enforcement — the blocking finding from the #387 review. + +The A2A JSON-RPC endpoint (``POST /a2a/v1/rpc``: task.send/get/cancel) and the +stream/REST routes were mounted with **no authentication at all** while their +docstrings claimed JWKS enforcement. This suite pins the fixed contract: + +* auth disabled (default-off, loopback dev) → everything works untokened, + byte-for-byte as before; +* auth enabled → per-method scope enforcement: + - ``task.send`` / ``task.cancel`` → ``cao:write`` + - ``task.get`` and the stream/REST reads → ``cao:read`` + with 401 (missing/malformed/expired bearer) and 403 (insufficient scope) + carried BOTH as the HTTP status and as a JSON-RPC error body + (``UNAUTHENTICATED`` / ``PERMISSION_DENIED``) so JSON-RPC clients that + ignore transport status still see the failure. + +Tokens are real RS256 JWTs validated against a live in-process JWKS endpoint +(``jwt_factory`` + ``jwks_server`` fixtures) — nothing in the auth path is +stubbed. +""" + +from __future__ import annotations + +from typing import Iterator + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.a2a import ( + A2AErrorCode, + InMemoryTaskStore, + build_a2a_router, + build_stream_router, +) +from cli_agent_orchestrator.security import auth as auth_mod + + +@pytest.fixture +def store() -> InMemoryTaskStore: + return InMemoryTaskStore() + + +@pytest.fixture +def client(store: InMemoryTaskStore) -> TestClient: + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + app.include_router(build_stream_router(store=store)) + return TestClient(app) + + +@pytest.fixture +def auth_on(monkeypatch, jwt_factory, jwks_server) -> Iterator[None]: + """Enable real JWT validation against the in-process JWKS server.""" + monkeypatch.setenv("AUTH0_DOMAIN", jwt_factory.domain) + monkeypatch.setenv("AUTH0_AUDIENCE", jwt_factory.audience) + monkeypatch.setenv("CAO_AUTH_JWKS_URI", jwks_server.url) + auth_mod.reset_jwks_cache() + yield + auth_mod.reset_jwks_cache() + + +def _rpc(method: str, params: dict, *, req_id: str | int | None = "1") -> dict: + return {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + + +def _bearer(token: str) -> dict: + return {"Authorization": f"Bearer {token}"} + + +def _send(client: TestClient, headers: dict | None = None): + return client.post( + "/a2a/v1/rpc", + json=_rpc( + "task.send", {"task": {"id": "", "messages": [{"role": "user", "content": "hi"}]}} + ), + headers=headers or {}, + ) + + +class TestAuthDisabledIsUnchanged: + """Default-off (no AUTH0_DOMAIN / CAO_AUTH_JWKS_URI): no token required.""" + + def test_send_get_cancel_work_untokened(self, client: TestClient): + resp = _send(client) + assert resp.status_code == 200 + task_id = resp.json()["result"]["task"]["id"] + + got = client.post("/a2a/v1/rpc", json=_rpc("task.get", {"id": task_id})) + assert got.status_code == 200 + assert got.json()["result"]["task"]["id"] == task_id + + rest = client.get(f"/a2a/v1/tasks/{task_id}") + assert rest.status_code == 200 + + +class TestRpcAuthEnabled: + def test_missing_token_is_401_unauthenticated(self, client: TestClient, auth_on): + resp = _send(client) + assert resp.status_code == 401 + body = resp.json() + assert body["error"]["code"] == int(A2AErrorCode.UNAUTHENTICATED) + + def test_malformed_token_is_401(self, client: TestClient, auth_on): + resp = _send(client, headers=_bearer("not-a-jwt")) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == int(A2AErrorCode.UNAUTHENTICATED) + + def test_expired_token_is_401(self, client: TestClient, auth_on, jwt_factory): + resp = _send(client, headers=_bearer(jwt_factory.mint_expired())) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == int(A2AErrorCode.UNAUTHENTICATED) + + def test_read_scope_cannot_send_or_cancel(self, client: TestClient, auth_on, jwt_factory): + read_token = jwt_factory.mint(scopes="cao:read") + resp = _send(client, headers=_bearer(read_token)) + assert resp.status_code == 403 + assert resp.json()["error"]["code"] == int(A2AErrorCode.PERMISSION_DENIED) + + cancel = client.post( + "/a2a/v1/rpc", + json=_rpc("task.cancel", {"id": "whatever"}), + headers=_bearer(read_token), + ) + assert cancel.status_code == 403 + + def test_write_scope_sends_and_read_scope_gets(self, client: TestClient, auth_on, jwt_factory): + write_token = jwt_factory.mint(scopes="cao:write") + sent = _send(client, headers=_bearer(write_token)) + assert sent.status_code == 200 + task_id = sent.json()["result"]["task"]["id"] + + read_token = jwt_factory.mint(scopes="cao:read") + got = client.post( + "/a2a/v1/rpc", + json=_rpc("task.get", {"id": task_id}), + headers=_bearer(read_token), + ) + assert got.status_code == 200 + assert got.json()["result"]["task"]["id"] == task_id + + def test_write_scope_implies_read_like_the_rest_of_cao( + self, client: TestClient, auth_on, jwt_factory + ): + # Repo-wide convention (see require_any_scope call sites in api/main.py): + # read endpoints accept any of read/write/admin — write implies read. + write_only = jwt_factory.mint(scopes="cao:write") + sent = _send(client, headers=_bearer(write_only)) + task_id = sent.json()["result"]["task"]["id"] + got = client.post( + "/a2a/v1/rpc", + json=_rpc("task.get", {"id": task_id}), + headers=_bearer(write_only), + ) + assert got.status_code == 200 + + def test_auth_failure_beats_method_dispatch(self, client: TestClient, auth_on): + # No credential leakage about which methods exist: unauthenticated + # requests get 401 even for unknown methods. + resp = client.post("/a2a/v1/rpc", json=_rpc("task.exfiltrate", {})) + assert resp.status_code == 401 + + +class TestStreamRoutesAuthEnabled: + def test_rest_poll_requires_read(self, client: TestClient, auth_on, jwt_factory): + assert client.get("/a2a/v1/tasks/some-id").status_code == 401 + wrong = jwt_factory.mint(scopes="some:other") + assert client.get("/a2a/v1/tasks/some-id", headers=_bearer(wrong)).status_code == 403 + read = jwt_factory.mint(scopes="cao:read") + # Authorized but unknown id → 404 (auth cleared, resource missing). + assert client.get("/a2a/v1/tasks/some-id", headers=_bearer(read)).status_code == 404 + + def test_sse_stream_requires_read(self, client: TestClient, auth_on, jwt_factory): + assert client.get("/a2a/v1/stream/some-id").status_code == 401 + wrong = jwt_factory.mint(scopes="some:other") + assert client.get("/a2a/v1/stream/some-id", headers=_bearer(wrong)).status_code == 403 + + +class TestDocstringsTellTheTruth: + def test_rpc_module_documents_actual_enforcement(self): + import cli_agent_orchestrator.a2a.rpc as rpc_mod + + doc = rpc_mod.__doc__ or "" + assert "once auth is enabled" not in doc # the old false claim + assert "cao:write" in doc and "cao:read" in doc + + def test_stream_module_documents_actual_enforcement(self): + import cli_agent_orchestrator.a2a.stream as stream_mod + + doc = stream_mod.__doc__ or "" + assert "is enforced once auth is enabled" not in doc + assert "cao:read" in doc + + +class TestScopeMatrixCompletion: + """Cases ported from the sibling remediation's 28-case matrix, re-minted + as real RS256 JWTs (that matrix ran on a stubbed token layer), plus the + bearer-parsing edges neither suite covered.""" + + def test_admin_scope_satisfies_every_method(self, client: TestClient, auth_on, jwt_factory): + admin = _bearer(jwt_factory.mint(scopes="cao:admin")) + sent = _send(client, headers=admin) + assert sent.status_code == 200 and "error" not in sent.json() + task_id = sent.json()["result"]["task"]["id"] + + got = client.post( + "/a2a/v1/rpc", + json=_rpc("task.get", {"id": task_id}, req_id="2"), + headers=admin, + ) + assert got.status_code == 200 and "error" not in got.json() + + cancelled = client.post( + "/a2a/v1/rpc", + json=_rpc("task.cancel", {"id": task_id}, req_id="3"), + headers=admin, + ) + assert cancelled.status_code == 200 and "error" not in cancelled.json() + + def test_unknown_method_with_valid_token_is_method_not_found( + self, client: TestClient, auth_on, jwt_factory + ): + # Auth first, then dispatch: an AUTHENTICATED caller learns the method + # is unknown; an anonymous one gets 401 (pinned elsewhere) and learns + # nothing about the method surface. + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.evil", {}), + headers=_bearer(jwt_factory.mint(scopes="cao:read")), + ) + assert resp.status_code == 404 # matching HTTP status, like 401/403/429 + assert resp.json()["error"]["code"] == int(A2AErrorCode.METHOD_NOT_FOUND) + + def test_bearer_scheme_is_case_insensitive(self, client: TestClient, auth_on, jwt_factory): + token = jwt_factory.mint(scopes="cao:write") + resp = _send(client, headers={"Authorization": f"BEARER {token}"}) + assert resp.status_code == 200 and "error" not in resp.json() + + def test_empty_bearer_is_401(self, client: TestClient, auth_on): + resp = _send(client, headers={"Authorization": "Bearer "}) + assert resp.status_code == 401 + assert resp.json()["error"]["code"] == int(A2AErrorCode.UNAUTHENTICATED) + + def test_duplicate_authorization_headers_use_the_first( + self, client: TestClient, auth_on, jwt_factory + ): + # Starlette resolves duplicate headers to the first occurrence; pin it + # so header-smuggling a second (valid) credential cannot upgrade a + # rejected first one. + good = jwt_factory.mint(scopes="cao:write") + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.send", {"task": {"id": "", "messages": []}}), + headers=[ + ("Authorization", "Bearer not-a-jwt"), + ("Authorization", f"Bearer {good}"), + ], + ) + assert resp.status_code == 401 diff --git a/test/a2a/test_rpc.py b/test/a2a/test_rpc.py new file mode 100644 index 0000000000..fe14a06620 --- /dev/null +++ b/test/a2a/test_rpc.py @@ -0,0 +1,396 @@ +"""Tests for the A2A v1.0 JSON-RPC endpoint. + +Coverage matrix: + * task.send: assigns id when missing; round-trips messages + metadata; + starts in ``submitted`` state + * task.get: returns the task envelope; ``TASK_NOT_FOUND`` when missing + * task.cancel: transitions non-terminal tasks; ``TASK_ALREADY_TERMINAL`` + on already-canceled / completed tasks + * Protocol errors: + - parse error → -32700, id=null, 400 status + - non-object body → -32600 + - missing method → -32600 + - unknown method → -32601, 404 status + - wrong jsonrpc version → -32600 + * Application errors: TASK_NOT_FOUND (1) is returned as 200 OK with + error in body, per JSON-RPC 2.0 + * Round-trip: a peer's ``extra`` fields on Task survive send → get +""" + +from __future__ import annotations + +import asyncio + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.a2a import ( + A2AErrorCode, + InMemoryTaskStore, + TaskState, + build_a2a_router, +) + + +@pytest.fixture +def store() -> InMemoryTaskStore: + return InMemoryTaskStore() + + +@pytest.fixture +def client(store: InMemoryTaskStore) -> TestClient: + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + return TestClient(app) + + +def _rpc(method: str, params: dict, *, req_id: str | int | None = "1") -> dict: + return {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params} + + +# --------------------------------------------------------------------------- +# task.send +# --------------------------------------------------------------------------- + + +class TestTaskSend: + def test_assigns_id_when_missing(self, client: TestClient): + resp = client.post( + "/a2a/v1/rpc", + json=_rpc( + "task.send", {"task": {"id": "", "messages": [{"role": "user", "content": "hi"}]}} + ), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["id"] == "1" + assert "error" not in body + task = body["result"]["task"] + assert task["id"] # Auto-generated. + assert task["state"] == TaskState.SUBMITTED + assert task["messages"] == [{"role": "user", "content": "hi"}] + + def test_preserves_caller_supplied_id(self, client: TestClient): + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.send", {"task": {"id": "task-abc", "messages": []}}), + ) + assert resp.status_code == 200 + body = resp.json() + assert body["result"]["task"]["id"] == "task-abc" + + def test_default_state_is_submitted(self, client: TestClient): + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.send", {"task": {"id": "t1"}}), + ) + assert resp.json()["result"]["task"]["state"] == TaskState.SUBMITTED + + def test_metadata_round_trips(self, client: TestClient): + resp = client.post( + "/a2a/v1/rpc", + json=_rpc( + "task.send", + {"task": {"id": "t1", "metadata": {"priority": "high", "tenant": "x"}}}, + ), + ) + assert resp.json()["result"]["task"]["metadata"] == {"priority": "high", "tenant": "x"} + + def test_extra_fields_round_trip(self, client: TestClient): + # A2A peers may send fields CAO doesn't recognize. They should + # round-trip on get. + resp = client.post( + "/a2a/v1/rpc", + json=_rpc( + "task.send", + {"task": {"id": "t1", "customField": "preserved"}}, + ), + ) + assert resp.json()["result"]["task"]["customField"] == "preserved" + + def test_missing_task_field_is_invalid_params(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=_rpc("task.send", {})) + body = resp.json() + assert body["error"]["code"] == int(A2AErrorCode.INVALID_PARAMS) + + +# --------------------------------------------------------------------------- +# task.get +# --------------------------------------------------------------------------- + + +class TestTaskGet: + def test_returns_existing_task(self, client: TestClient): + client.post("/a2a/v1/rpc", json=_rpc("task.send", {"task": {"id": "t1"}})) + resp = client.post("/a2a/v1/rpc", json=_rpc("task.get", {"id": "t1"})) + assert resp.json()["result"]["task"]["id"] == "t1" + + def test_unknown_task_returns_task_not_found(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=_rpc("task.get", {"id": "nope"})) + body = resp.json() + # Application-level errors are 200 OK with error in the body. + assert resp.status_code == 200 + assert body["error"]["code"] == int(A2AErrorCode.TASK_NOT_FOUND) + + def test_missing_id_is_invalid_params(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=_rpc("task.get", {})) + assert resp.json()["error"]["code"] == int(A2AErrorCode.INVALID_PARAMS) + + def test_taskId_alias_works(self, client: TestClient): + # Some peers use the camelCase form; accept both. + client.post("/a2a/v1/rpc", json=_rpc("task.send", {"task": {"id": "t1"}})) + resp = client.post("/a2a/v1/rpc", json=_rpc("task.get", {"taskId": "t1"})) + assert resp.json()["result"]["task"]["id"] == "t1" + + +# --------------------------------------------------------------------------- +# task.cancel +# --------------------------------------------------------------------------- + + +class TestTaskCancel: + def test_cancels_non_terminal_task(self, client: TestClient): + client.post("/a2a/v1/rpc", json=_rpc("task.send", {"task": {"id": "t1"}})) + resp = client.post("/a2a/v1/rpc", json=_rpc("task.cancel", {"id": "t1"})) + assert resp.json()["result"]["task"]["state"] == TaskState.CANCELED + + def test_cannot_cancel_already_canceled(self, client: TestClient): + client.post("/a2a/v1/rpc", json=_rpc("task.send", {"task": {"id": "t1"}})) + client.post("/a2a/v1/rpc", json=_rpc("task.cancel", {"id": "t1"})) + resp = client.post("/a2a/v1/rpc", json=_rpc("task.cancel", {"id": "t1"})) + assert resp.json()["error"]["code"] == int(A2AErrorCode.TASK_ALREADY_TERMINAL) + + def test_cancel_unknown_returns_task_not_found(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=_rpc("task.cancel", {"id": "nope"})) + assert resp.json()["error"]["code"] == int(A2AErrorCode.TASK_NOT_FOUND) + + +# --------------------------------------------------------------------------- +# Protocol errors +# --------------------------------------------------------------------------- + + +class TestProtocolErrors: + def test_parse_error_returns_neg_32700_with_null_id(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", content=b"this is not json") + body = resp.json() + assert resp.status_code == 400 + assert body["id"] is None + assert body["error"]["code"] == int(A2AErrorCode.PARSE_ERROR) + + def test_non_object_body_returns_invalid_request(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=[1, 2, 3]) + assert resp.json()["error"]["code"] == int(A2AErrorCode.INVALID_REQUEST) + + def test_missing_method_returns_invalid_request(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json={"jsonrpc": "2.0", "id": "1"}) + assert resp.json()["error"]["code"] == int(A2AErrorCode.INVALID_REQUEST) + + def test_wrong_jsonrpc_version_returns_invalid_request(self, client: TestClient): + resp = client.post( + "/a2a/v1/rpc", + json={"jsonrpc": "1.0", "id": "1", "method": "task.get", "params": {"id": "x"}}, + ) + assert resp.json()["error"]["code"] == int(A2AErrorCode.INVALID_REQUEST) + + def test_unknown_method_returns_method_not_found(self, client: TestClient): + resp = client.post("/a2a/v1/rpc", json=_rpc("totally.fake", {})) + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == int(A2AErrorCode.METHOD_NOT_FOUND) + + +# --------------------------------------------------------------------------- +# Listener mount integration +# --------------------------------------------------------------------------- + + +class TestListenerMount: + def test_a2a_router_mounts_alongside_agent_card(self, tmp_path): + # Smoke test: build_listener_app with the A2A router mounted + # serves both /.well-known/agent-card.json AND /a2a/v1/rpc + # off the same FastAPI app. + from cli_agent_orchestrator.agent_card.listener import build_listener_app + from cli_agent_orchestrator.agent_card.signing import Signer + + signer = Signer(tmp_path) + store = InMemoryTaskStore() + router = build_a2a_router(store=store) + + def metadata_provider() -> dict: + return {"description": "test"} + + app = build_listener_app(signer, metadata_provider, a2a_router=router) + client = TestClient(app) + + # Agent Card still works. + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + + # A2A RPC works. + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.send", {"task": {"id": "t1"}}), + ) + assert resp.status_code == 200 + assert resp.json()["result"]["task"]["id"] == "t1" + + +# --------------------------------------------------------------------------- +# Executor bridge (Phase 5 follow-up) +# --------------------------------------------------------------------------- + + +class TestExecutorBridge: + """task.send with a registered executor schedules the work in a + background asyncio task and publishes state transitions to the + bus. Without an executor, tasks stay in SUBMITTED — current + behavior preserved.""" + + @pytest.mark.asyncio + async def test_executor_runs_in_background_and_publishes_terminal(self): + from cli_agent_orchestrator.a2a import ( + InMemoryTaskEventBus, + InMemoryTaskStore, + Task, + TaskState, + build_a2a_router, + ) + + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + executed: list[Task] = [] + + async def executor(task: Task) -> Task: + executed.append(task) + task.artifacts = [{"kind": "result", "value": "ok"}] + task.state = TaskState.COMPLETED + return task + + # Subscribe before submitting so we capture all events. + events: list[dict] = [] + + async def reader() -> None: + async for payload in bus.subscribe("t1"): + events.append(payload) + state = payload.get("task", {}).get("state") + if state in (TaskState.COMPLETED, TaskState.FAILED, TaskState.CANCELED): + return + + reader_task = asyncio.create_task(reader()) + await asyncio.sleep(0.01) + + # Submit via the RPC endpoint. + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + app.include_router(build_a2a_router(store=store, bus=bus, executor=executor)) + + # TestClient is sync; run it in a thread so we don't block + # the event loop. + def post() -> dict: + client = TestClient(app) + resp = client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": { + "task": {"id": "t1", "messages": [{"role": "user", "content": "hi"}]} + }, + }, + ) + return resp.json() + + body = await asyncio.to_thread(post) + # The HTTP response returns immediately with the submitted task. + assert body["result"]["task"]["id"] == "t1" + + # Wait for the background executor to complete. + await asyncio.wait_for(reader_task, timeout=2.0) + + # Final stored state is COMPLETED. + final = await store.get("t1") + assert final is not None + assert final.state == TaskState.COMPLETED + assert final.artifacts == [{"kind": "result", "value": "ok"}] + + # Bus saw the SUBMITTED→WORKING→COMPLETED progression. + states = [e["task"]["state"] for e in events] + assert TaskState.WORKING in states + assert TaskState.COMPLETED in states + + # Executor was called once. + assert len(executed) == 1 + + @pytest.mark.asyncio + async def test_executor_failure_transitions_to_failed(self): + from cli_agent_orchestrator.a2a import ( + InMemoryTaskEventBus, + InMemoryTaskStore, + Task, + TaskState, + build_a2a_router, + ) + + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + + async def executor(task: Task) -> Task: + raise RuntimeError("boom") + + events: list[dict] = [] + + async def reader() -> None: + async for payload in bus.subscribe("t-fail"): + events.append(payload) + if payload["task"]["state"] in (TaskState.FAILED, TaskState.COMPLETED): + return + + reader_task = asyncio.create_task(reader()) + await asyncio.sleep(0.01) + + from fastapi import FastAPI + from fastapi.testclient import TestClient + + app = FastAPI() + app.include_router(build_a2a_router(store=store, bus=bus, executor=executor)) + + def post() -> dict: + client = TestClient(app) + resp = client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": {"task": {"id": "t-fail"}}, + }, + ) + return resp.json() + + await asyncio.to_thread(post) + await asyncio.wait_for(reader_task, timeout=2.0) + + final = await store.get("t-fail") + assert final is not None + assert final.state == TaskState.FAILED + # Error captured in metadata so the peer can read it back. + assert "boom" in final.metadata.get("error", "") + + def test_no_executor_keeps_task_submitted(self, client: TestClient): + # With no executor wired, task.send returns the task in + # SUBMITTED and it stays that way (existing behavior). + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.send", {"task": {"id": "t-no-exec"}}), + ) + assert resp.json()["result"]["task"]["state"] == "submitted" + + resp = client.post( + "/a2a/v1/rpc", + json=_rpc("task.get", {"id": "t-no-exec"}), + ) + assert resp.json()["result"]["task"]["state"] == "submitted" diff --git a/test/a2a/test_rpc_background_coverage.py b/test/a2a/test_rpc_background_coverage.py new file mode 100644 index 0000000000..72c093600b --- /dev/null +++ b/test/a2a/test_rpc_background_coverage.py @@ -0,0 +1,95 @@ +"""Coverage for the A2A background executor + type helpers. + +Targets the branches in ``a2a/rpc.py`` and ``a2a/types.py`` not hit by +the HTTP-level tests: ``_run_task_in_background`` (executor sets terminal +state, executor omits terminal state → COMPLETED, executor raises → +FAILED with error metadata), and the ``TaskState.all`` / +``JsonRpcError.to_dict`` helpers. +""" + +from __future__ import annotations + +import pytest + +from cli_agent_orchestrator.a2a.rpc import _run_task_in_background +from cli_agent_orchestrator.a2a.store import InMemoryTaskStore +from cli_agent_orchestrator.a2a.stream import InMemoryTaskEventBus +from cli_agent_orchestrator.a2a.types import ( + A2AErrorCode, + JsonRpcError, + Task, + TaskState, +) + + +@pytest.mark.asyncio +async def test_background_defaults_to_completed_when_executor_omits_terminal() -> None: + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + task = Task(id="t1", state=TaskState.SUBMITTED) + await store.upsert(task) + + async def executor(t: Task) -> Task: + # Returns without setting a terminal state. + return t + + await _run_task_in_background(task, executor, store, bus) + result = await store.get("t1") + assert result is not None + assert result.state == TaskState.COMPLETED + + +@pytest.mark.asyncio +async def test_background_honors_executor_terminal_state() -> None: + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + task = Task(id="t2", state=TaskState.SUBMITTED) + await store.upsert(task) + + async def executor(t: Task) -> Task: + t.state = TaskState.CANCELED + return t + + await _run_task_in_background(task, executor, store, bus) + result = await store.get("t2") + assert result is not None + assert result.state == TaskState.CANCELED + + +@pytest.mark.asyncio +async def test_background_marks_failed_on_executor_exception() -> None: + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + task = Task(id="t3", state=TaskState.SUBMITTED) + await store.upsert(task) + + async def executor(t: Task) -> Task: + raise RuntimeError("boom") + + await _run_task_in_background(task, executor, store, bus) + result = await store.get("t3") + assert result is not None + assert result.state == TaskState.FAILED + assert result.metadata.get("error") == "boom" + + +def test_task_state_all_lists_every_state() -> None: + states = TaskState.all() + assert set(states) == { + TaskState.SUBMITTED, + TaskState.WORKING, + TaskState.INPUT_REQUIRED, + TaskState.COMPLETED, + TaskState.CANCELED, + TaskState.FAILED, + } + + +def test_jsonrpc_error_to_dict_includes_data_when_present() -> None: + err = JsonRpcError(code=A2AErrorCode.TASK_NOT_FOUND, message="missing", data={"id": "x"}) + d = err.to_dict() + assert d == {"code": 1, "message": "missing", "data": {"id": "x"}} + + # Omits data when None. + err2 = JsonRpcError(code=A2AErrorCode.INVALID_PARAMS, message="bad") + assert "data" not in err2.to_dict() diff --git a/test/a2a/test_store_bounds.py b/test/a2a/test_store_bounds.py new file mode 100644 index 0000000000..19cded0c6a --- /dev/null +++ b/test/a2a/test_store_bounds.py @@ -0,0 +1,125 @@ +"""InMemoryTaskStore bounds — the second blocking finding from the #387 review. + +The store was an unbounded dict ("tasks live until explicitly deleted or +process restart"), which combined with a network-reachable ``task.send`` +meant a remote peer could drive unbounded memory growth. The store is now +bounded two ways, both env-tunable: + +* ``max_tasks`` (``CAO_A2A_MAX_TASKS``, default 1000) — on overflow the + oldest *terminal* tasks are evicted first; if every stored task is still + live, new inserts are refused (``TaskStoreFull`` → the RPC layer maps it to + ``RESOURCE_EXHAUSTED`` at HTTP 429 + Retry-After) rather than silently + dropping active work. +* ``ttl_seconds`` (``CAO_A2A_TASK_TTL``, default 3600) — tasks idle past the + TTL are lazily swept on access. +""" + +from __future__ import annotations + +import pytest + +from cli_agent_orchestrator.a2a import A2AErrorCode, InMemoryTaskStore, build_a2a_router +from cli_agent_orchestrator.a2a.store import TaskStoreFull +from cli_agent_orchestrator.a2a.types import Task, TaskState + + +def _task(task_id: str, state: str = TaskState.SUBMITTED) -> Task: + return Task(id=task_id, state=state) + + +@pytest.mark.asyncio +class TestCapacity: + async def test_evicts_oldest_terminal_first_on_overflow(self): + store = InMemoryTaskStore(max_tasks=3) + await store.upsert(_task("t1", TaskState.COMPLETED)) + await store.upsert(_task("t2", TaskState.SUBMITTED)) + await store.upsert(_task("t3", TaskState.CANCELED)) + + await store.upsert(_task("t4")) # over capacity → evict t1 (oldest terminal) + assert await store.get("t1") is None + assert await store.get("t2") is not None + assert await store.get("t3") is not None + assert await store.get("t4") is not None + + async def test_refuses_new_tasks_when_full_of_live_work(self): + store = InMemoryTaskStore(max_tasks=2) + await store.upsert(_task("t1")) + await store.upsert(_task("t2")) + with pytest.raises(TaskStoreFull): + await store.upsert(_task("t3")) + # Existing live tasks were not dropped. + assert await store.get("t1") is not None + assert await store.get("t2") is not None + + async def test_updating_an_existing_task_never_hits_the_cap(self): + store = InMemoryTaskStore(max_tasks=1) + await store.upsert(_task("t1")) + updated = _task("t1", TaskState.WORKING) + await store.upsert(updated) # same id: update in place, no capacity check + stored = await store.get("t1") + assert stored is not None and stored.state == TaskState.WORKING + + +@pytest.mark.asyncio +class TestTTL: + async def test_idle_tasks_are_swept_after_ttl(self, monkeypatch): + store = InMemoryTaskStore(max_tasks=10, ttl_seconds=100) + await store.upsert(_task("old")) + # Age the task past the TTL by rewinding its updated_at. + async with store._lock: # test-only reach-in; the sweep is lazy-on-access + store._tasks["old"].updated_at -= 101 + + assert await store.get("old") is None + assert await store.list_ids() == [] + + async def test_fresh_tasks_survive_the_sweep(self): + store = InMemoryTaskStore(max_tasks=10, ttl_seconds=100) + await store.upsert(_task("fresh")) + assert await store.get("fresh") is not None + + +class TestEnvConfig: + def test_env_overrides(self, monkeypatch): + monkeypatch.setenv("CAO_A2A_MAX_TASKS", "7") + monkeypatch.setenv("CAO_A2A_TASK_TTL", "42.5") + store = InMemoryTaskStore.from_env() + assert store.max_tasks == 7 + assert store.ttl_seconds == 42.5 + + def test_defaults(self, monkeypatch): + monkeypatch.delenv("CAO_A2A_MAX_TASKS", raising=False) + monkeypatch.delenv("CAO_A2A_TASK_TTL", raising=False) + store = InMemoryTaskStore.from_env() + assert store.max_tasks == 1000 + assert store.ttl_seconds == 3600.0 + + +class TestRpcMapping: + def test_task_send_when_full_returns_task_limit_exceeded(self): + from fastapi import FastAPI + from fastapi.testclient import TestClient + + store = InMemoryTaskStore(max_tasks=1) + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + client = TestClient(app) + + def send(task_id: str): + return client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": {"task": {"id": task_id, "messages": []}}, + }, + ) + + assert send("t1").status_code == 200 + resp = send("t2") + # Capacity refusal is a transport/backoff condition: HTTP 429 with a + # Retry-After hint (so HTTP-native retry middleware backs off), plus + # the JSON-RPC error body for clients that ignore transport status. + assert resp.status_code == 429 + assert resp.headers.get("Retry-After") == "30" + assert resp.json()["error"]["code"] == int(A2AErrorCode.RESOURCE_EXHAUSTED) diff --git a/test/a2a/test_store_coverage.py b/test/a2a/test_store_coverage.py new file mode 100644 index 0000000000..450a09a5f3 --- /dev/null +++ b/test/a2a/test_store_coverage.py @@ -0,0 +1,74 @@ +"""Coverage for the in-memory A2A task store lifecycle helpers. + +Exercises the branches in ``a2a/store.py`` that the RPC tests don't hit +directly: ``list_ids``, ``delete``, ``upsert`` timestamp assignment, and +the atomic ``transition`` guard (normal, missing task, already-terminal). +""" + +from __future__ import annotations + +import pytest + +from cli_agent_orchestrator.a2a.store import InMemoryTaskStore +from cli_agent_orchestrator.a2a.types import Task, TaskState + + +@pytest.mark.asyncio +async def test_upsert_sets_created_and_updated_at() -> None: + store = InMemoryTaskStore() + task = Task(id="t1") + assert task.created_at is None + await store.upsert(task) + fetched = await store.get("t1") + assert fetched is not None + assert fetched.created_at is not None + assert fetched.updated_at is not None + first_created = fetched.created_at + + # Re-upsert preserves created_at, refreshes updated_at. + await store.upsert(task) + again = await store.get("t1") + assert again is not None + assert again.created_at == first_created + + +@pytest.mark.asyncio +async def test_list_ids_and_delete() -> None: + store = InMemoryTaskStore() + await store.upsert(Task(id="a")) + await store.upsert(Task(id="b")) + ids = await store.list_ids() + assert set(ids) == {"a", "b"} + + await store.delete("a") + assert await store.get("a") is None + # Deleting a missing id is a no-op. + await store.delete("does-not-exist") + assert set(await store.list_ids()) == {"b"} + + +@pytest.mark.asyncio +async def test_transition_normal() -> None: + store = InMemoryTaskStore() + await store.upsert(Task(id="t", state=TaskState.SUBMITTED)) + updated = await store.transition("t", TaskState.WORKING) + assert updated is not None + assert updated.state == TaskState.WORKING + assert updated.updated_at is not None + + +@pytest.mark.asyncio +async def test_transition_missing_returns_none() -> None: + store = InMemoryTaskStore() + assert await store.transition("nope", TaskState.WORKING) is None + + +@pytest.mark.asyncio +async def test_transition_terminal_is_rejected() -> None: + store = InMemoryTaskStore() + await store.upsert(Task(id="done", state=TaskState.COMPLETED)) + # Already terminal → transition refuses and returns None. + assert await store.transition("done", TaskState.WORKING) is None + unchanged = await store.get("done") + assert unchanged is not None + assert unchanged.state == TaskState.COMPLETED diff --git a/test/a2a/test_stream.py b/test/a2a/test_stream.py new file mode 100644 index 0000000000..2f0955cf3f --- /dev/null +++ b/test/a2a/test_stream.py @@ -0,0 +1,385 @@ +"""Tests for the A2A v1.0 SSE stream + REST polling endpoints. + +Coverage matrix: + * REST polling (GET /a2a/v1/tasks/{id}): + - returns the current task envelope as JSON + - 404 with TASK_NOT_FOUND error body for unknown ids + * SSE stream (GET /a2a/v1/stream/{id}): + - emits an initial task.update with the current state + - emits task.terminal + closes when the task is already terminal + - relays bus events as task.update + - bus event with terminal state emits task.terminal + closes + - unknown task id emits a single task.error frame and closes + * InMemoryTaskEventBus: + - publish/subscribe round-trips events + - multiple subscribers each see published events + - subscriber teardown removes the queue + - publish to no subscribers is a no-op (no error) + - back-pressure: full queue drops oldest, accepts newest + * RPC integration: task.send + task.cancel publish to the bus + so SSE consumers see live updates +""" + +from __future__ import annotations + +import asyncio +import json + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.a2a import ( + A2AErrorCode, + InMemoryTaskEventBus, + InMemoryTaskStore, + NullEventBus, + Task, + TaskState, + build_a2a_router, + build_stream_router, +) + +# --------------------------------------------------------------------------- +# REST polling +# --------------------------------------------------------------------------- + + +@pytest.fixture +def store() -> InMemoryTaskStore: + return InMemoryTaskStore() + + +@pytest.fixture +def stream_client(store: InMemoryTaskStore) -> TestClient: + app = FastAPI() + app.include_router(build_stream_router(store=store)) + return TestClient(app) + + +class TestRestPolling: + def test_returns_existing_task(self, store: InMemoryTaskStore): + # Seed via the RPC endpoint so we don't have to juggle async + # contexts inside a sync test. + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + app.include_router(build_stream_router(store=store)) + client = TestClient(app) + client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": {"task": {"id": "t1", "state": TaskState.WORKING}}, + }, + ) + resp = client.get("/a2a/v1/tasks/t1") + assert resp.status_code == 200 + body = resp.json() + assert body["task"]["id"] == "t1" + assert body["task"]["state"] == TaskState.WORKING + + def test_unknown_task_is_404(self, stream_client: TestClient): + resp = stream_client.get("/a2a/v1/tasks/nope") + assert resp.status_code == 404 + assert resp.json()["error"]["code"] == int(A2AErrorCode.TASK_NOT_FOUND) + + +def _app(*, store: InMemoryTaskStore, bus: InMemoryTaskEventBus | None = None) -> FastAPI: + app = FastAPI() + app.include_router(build_stream_router(store=store, bus=bus)) + return app + + +def _parse_sse(content: str) -> list[dict]: + """Turn raw SSE bytes into a list of {event, data} dicts.""" + events: list[dict] = [] + current: dict[str, str] = {} + for line in content.splitlines(): + if line.startswith("event: "): + current["event"] = line[len("event: ") :] + elif line.startswith("data: "): + current["data"] = json.loads(line[len("data: ") :]) + elif line == "": + if current: + events.append(current) + current = {} + if current: + events.append(current) + return events + + +# --------------------------------------------------------------------------- +# SSE stream +# --------------------------------------------------------------------------- + + +def _seed_terminal_task(store: InMemoryTaskStore, task_id: str, state: str) -> TestClient: + """Set up a TestClient with a pre-seeded terminal task. + + Goes through the RPC endpoint to avoid having to manage an async + context outside a TestClient call. task.send creates with the + given state, then task.cancel transitions to CANCELED if needed. + """ + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + app.include_router(build_stream_router(store=store)) + client = TestClient(app) + if state == TaskState.COMPLETED: + # task.send doesn't allow caller-supplied state to be terminal, + # so seed via store directly through an event loop bridge. + import anyio + + anyio.run(store.upsert, Task(id=task_id, state=state)) + else: + client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": {"task": {"id": task_id}}, + }, + ) + client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "2", + "method": "task.cancel", + "params": {"id": task_id}, + }, + ) + return client + + +class TestSseStream: + def test_terminal_task_emits_update_and_terminal_then_closes(self, store: InMemoryTaskStore): + client = _seed_terminal_task(store, "t1", TaskState.COMPLETED) + with client.stream("GET", "/a2a/v1/stream/t1") as resp: + content = b"".join(resp.iter_bytes()) + events = _parse_sse(content.decode("utf-8")) + # Initial state + terminal frame, then close. + names = [e["event"] for e in events] + assert "task.update" in names + assert "task.terminal" in names + + def test_canceled_state_treated_as_terminal(self, store: InMemoryTaskStore): + client = _seed_terminal_task(store, "t1", TaskState.CANCELED) + with client.stream("GET", "/a2a/v1/stream/t1") as resp: + content = b"".join(resp.iter_bytes()) + events = _parse_sse(content.decode("utf-8")) + assert any(e["event"] == "task.terminal" for e in events) + + def test_unknown_task_emits_task_error(self, stream_client: TestClient): + with stream_client.stream("GET", "/a2a/v1/stream/nope") as resp: + content = b"".join(resp.iter_bytes()).decode("utf-8") + events = _parse_sse(content) + assert len(events) == 1 + assert events[0]["event"] == "task.error" + assert events[0]["data"]["code"] == int(A2AErrorCode.TASK_NOT_FOUND) + + +# --------------------------------------------------------------------------- +# Direct generator test (no HTTP) — exercises the non-terminal flow +# without needing async/sync glue around TestClient. +# --------------------------------------------------------------------------- + + +class TestStreamGeneratorDirect: + @pytest.mark.asyncio + async def test_non_terminal_then_terminal_via_bus(self): + """Subscribe to the bus directly, publish a working update, + then a completed update, and assert the consumer sees both + with the right SSE event names.""" + bus = InMemoryTaskEventBus() + received: list[tuple[str, dict]] = [] + + async def consumer() -> None: + async for payload in bus.subscribe("t1"): + task = payload.get("task", {}) + if TaskState.is_terminal(task.get("state", "")): + received.append(("task.terminal", payload)) + return + received.append(("task.update", payload)) + + task = asyncio.create_task(consumer()) + await asyncio.sleep(0.01) + await bus.publish("t1", {"task": {"id": "t1", "state": TaskState.WORKING}}) + await bus.publish("t1", {"task": {"id": "t1", "state": TaskState.COMPLETED}}) + await asyncio.wait_for(task, timeout=1.0) + assert [name for name, _ in received] == ["task.update", "task.terminal"] + + +# --------------------------------------------------------------------------- +# InMemoryTaskEventBus +# --------------------------------------------------------------------------- + + +class TestEventBus: + @pytest.mark.asyncio + async def test_publish_subscribe_roundtrip(self): + bus = InMemoryTaskEventBus() + + received: list[dict] = [] + + async def reader() -> None: + async for payload in bus.subscribe("t1"): + received.append(payload) + if len(received) >= 2: + return + + task = asyncio.create_task(reader()) + # Give the subscriber a chance to register. + await asyncio.sleep(0.01) + await bus.publish("t1", {"event": 1}) + await bus.publish("t1", {"event": 2}) + await asyncio.wait_for(task, timeout=1.0) + assert received == [{"event": 1}, {"event": 2}] + + @pytest.mark.asyncio + async def test_publish_with_no_subscribers_is_noop(self): + bus = InMemoryTaskEventBus() + await bus.publish("never-subscribed", {"x": 1}) # Must not raise. + + @pytest.mark.asyncio + async def test_full_queue_drops_oldest(self): + # Direct semantics check: fill the queue without a consumer, + # then drain. With queue_max=2 and 3 publishes, the consumer + # sees exactly 2 events (the oldest was dropped to make room). + bus = InMemoryTaskEventBus(queue_max=2) + + # Pre-register a subscriber by getting the async iterator. + sub_iter = bus.subscribe("t1").__aiter__() + # First __anext__ to attach the queue; then immediately schedule. + first = asyncio.create_task(sub_iter.__anext__()) + # Publish before the subscriber is fully bound — fall back to a + # short sleep so the queue registration completes. + await asyncio.sleep(0.01) + for i in range(3): + await bus.publish("t1", {"event": i}) + + # Drain whatever is in the queue. + results: list[dict] = [] + results.append(await asyncio.wait_for(first, timeout=1.0)) + try: + results.append(await asyncio.wait_for(sub_iter.__anext__(), timeout=0.1)) + except asyncio.TimeoutError: + pass + try: + results.append(await asyncio.wait_for(sub_iter.__anext__(), timeout=0.1)) + except asyncio.TimeoutError: + pass + + # queue_max=2 + 3 publishes → at most 2 events delivered. + assert len(results) <= 2 + + @pytest.mark.asyncio + async def test_subscriber_teardown_clears_queue(self): + # Explicit aclose() of the async generator triggers the + # finally block deterministically across Python versions + # (3.11 ran it on coroutine return; 3.12 defers to GC). + bus = InMemoryTaskEventBus() + sub = bus.subscribe("t1") + + # Bind the subscription via __anext__ so the queue is registered. + first = asyncio.create_task(sub.__anext__()) + await asyncio.sleep(0.01) + assert len(bus._subscribers.get("t1", [])) == 1 + + await bus.publish("t1", {"x": 1}) + await asyncio.wait_for(first, timeout=1.0) + + # Explicit close → finally block runs, queue is removed. + await sub.aclose() + assert bus._subscribers.get("t1") in (None, []) + + +# --------------------------------------------------------------------------- +# Null bus +# --------------------------------------------------------------------------- + + +class TestNullEventBus: + @pytest.mark.asyncio + async def test_publish_is_noop(self): + bus = NullEventBus() + await bus.publish("t1", {"x": 1}) # No-op. + + @pytest.mark.asyncio + async def test_subscribe_yields_nothing(self): + bus = NullEventBus() + items = [] + async for payload in bus.subscribe("t1"): + items.append(payload) + assert items == [] + + +# --------------------------------------------------------------------------- +# RPC + bus integration +# --------------------------------------------------------------------------- + + +class TestRpcBusIntegration: + @pytest.mark.asyncio + async def test_task_send_publishes_to_bus(self): + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + + received: list[dict] = [] + + async def reader() -> None: + async for payload in bus.subscribe("t1"): + received.append(payload) + return + + task = asyncio.create_task(reader()) + await asyncio.sleep(0.01) + + app = FastAPI() + app.include_router(build_a2a_router(store=store, bus=bus)) + client = TestClient(app) + + client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.send", + "params": {"task": {"id": "t1"}}, + }, + ) + await asyncio.wait_for(task, timeout=1.0) + assert received[0]["task"]["id"] == "t1" + + @pytest.mark.asyncio + async def test_task_cancel_publishes_terminal_state(self): + store = InMemoryTaskStore() + await store.upsert(Task(id="t1", state=TaskState.WORKING)) + bus = InMemoryTaskEventBus() + + received: list[dict] = [] + + async def reader() -> None: + async for payload in bus.subscribe("t1"): + received.append(payload) + return + + task = asyncio.create_task(reader()) + await asyncio.sleep(0.01) + + app = FastAPI() + app.include_router(build_a2a_router(store=store, bus=bus)) + client = TestClient(app) + client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "1", + "method": "task.cancel", + "params": {"id": "t1"}, + }, + ) + await asyncio.wait_for(task, timeout=1.0) + assert received[0]["task"]["state"] == TaskState.CANCELED diff --git a/test/a2a/test_stream_coverage.py b/test/a2a/test_stream_coverage.py new file mode 100644 index 0000000000..40b01ce56f --- /dev/null +++ b/test/a2a/test_stream_coverage.py @@ -0,0 +1,92 @@ +"""Coverage for the A2A streaming/REST router + event bus back-pressure. + +Targets ``a2a/stream.py`` branches: the REST 404 + hit paths, the SSE +``task.error`` (missing task) and ``task.terminal`` (already-terminal on +connect) frames, the ``InMemoryTaskEventBus`` drop-oldest behaviour when a +slow subscriber's queue is full, and the ``NullEventBus`` no-op. +""" + +from __future__ import annotations + +import asyncio + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.a2a.store import InMemoryTaskStore +from cli_agent_orchestrator.a2a.stream import ( + InMemoryTaskEventBus, + NullEventBus, + build_stream_router, +) +from cli_agent_orchestrator.a2a.types import Task, TaskState + + +def _app_with(store: InMemoryTaskStore, **kwargs) -> TestClient: + app = FastAPI() + app.include_router(build_stream_router(store=store, **kwargs)) + return TestClient(app) + + +@pytest.mark.asyncio +async def test_rest_poll_found_and_missing() -> None: + store = InMemoryTaskStore() + await store.upsert(Task(id="present", state=TaskState.WORKING)) + client = _app_with(store) + + ok = client.get("/a2a/v1/tasks/present") + assert ok.status_code == 200 + assert ok.json()["task"]["id"] == "present" + + missing = client.get("/a2a/v1/tasks/absent") + assert missing.status_code == 404 + assert missing.json()["error"]["message"].startswith("task 'absent'") + + +@pytest.mark.asyncio +async def test_stream_missing_task_emits_error_frame() -> None: + store = InMemoryTaskStore() + client = _app_with(store) + body = client.get("/a2a/v1/stream/ghost").text + assert "event: task.error" in body + assert "not found" in body + + +@pytest.mark.asyncio +async def test_stream_terminal_task_emits_update_then_terminal() -> None: + store = InMemoryTaskStore() + await store.upsert(Task(id="done", state=TaskState.COMPLETED)) + client = _app_with(store) + body = client.get("/a2a/v1/stream/done").text + assert "event: task.update" in body + assert "event: task.terminal" in body + + +@pytest.mark.asyncio +async def test_event_bus_drops_oldest_when_subscriber_queue_full() -> None: + bus = InMemoryTaskEventBus(queue_max=1) + # Register a full subscriber queue directly so publish must evict. + q: asyncio.Queue = asyncio.Queue(maxsize=1) + q.put_nowait({"seq": 0}) + bus._subscribers["t"].append(q) + + await bus.publish("t", {"seq": 1}) + + # Oldest ({"seq": 0}) was dropped; newest survives. + assert q.get_nowait() == {"seq": 1} + assert q.empty() + + +@pytest.mark.asyncio +async def test_event_bus_publish_to_no_subscribers_is_noop() -> None: + bus = InMemoryTaskEventBus() + await bus.publish("nobody", {"x": 1}) # should not raise + + +@pytest.mark.asyncio +async def test_null_event_bus_publish_and_subscribe() -> None: + bus = NullEventBus() + await bus.publish("t", {"x": 1}) # no-op + seen = [payload async for payload in bus.subscribe("t")] + assert seen == [] diff --git a/test/a2a/test_task_id_integrity.py b/test/a2a/test_task_id_integrity.py new file mode 100644 index 0000000000..ce2e471c86 --- /dev/null +++ b/test/a2a/test_task_id_integrity.py @@ -0,0 +1,93 @@ +"""Peer-supplied task ids must never overwrite existing tasks. + +Review 4638092590 (upstream #387): ``task.send`` accepted a peer-supplied +``id`` verbatim and the store upserted it — any peer could replace or +effectively cancel another peer's in-flight task by resubmitting its id +(state-integrity injection), and an *omitted* id crashed ``Task.from_dict`` +with a ``KeyError``. This suite pins the fixed contract: + +* ``task.send`` is idempotent-create — an existing id is refused with + ``INVALID_PARAMS`` and the stored task is untouched; +* an omitted or empty id takes the server-generated-UUID path. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.a2a import InMemoryTaskStore, build_a2a_router +from cli_agent_orchestrator.a2a.types import A2AErrorCode + + +@pytest.fixture +def store() -> InMemoryTaskStore: + return InMemoryTaskStore() + + +@pytest.fixture +def client(store: InMemoryTaskStore) -> TestClient: + app = FastAPI() + app.include_router(build_a2a_router(store=store)) + return TestClient(app) + + +def _send(client: TestClient, task: dict, req_id: str = "1"): + return client.post( + "/a2a/v1/rpc", + json={"jsonrpc": "2.0", "id": req_id, "method": "task.send", "params": {"task": task}}, + ) + + +def test_resubmitted_id_is_refused_and_original_task_survives(client: TestClient): + first = _send(client, {"id": "peer-task-1", "messages": [{"role": "user", "content": "a"}]}) + assert first.status_code == 200 + assert "error" not in first.json() + + # A second send with the same id — as another peer would do to stomp the + # first — must be refused, not upserted. + attack = _send( + client, + {"id": "peer-task-1", "messages": [{"role": "user", "content": "overwritten"}]}, + req_id="2", + ) + assert attack.status_code == 200 # JSON-RPC application error rides 200 + assert attack.json()["error"]["code"] == int(A2AErrorCode.INVALID_PARAMS) + assert "already exists" in attack.json()["error"]["message"] + + # The original task is untouched. + got = client.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "3", + "method": "task.get", + "params": {"id": "peer-task-1"}, + }, + ) + task = got.json()["result"]["task"] + assert task["messages"][0]["content"] == "a" + + +def test_omitted_id_gets_a_server_uuid(client: TestClient): + # No "id" key at all: previously a KeyError inside Task.from_dict. + resp = _send(client, {"messages": [{"role": "user", "content": "hi"}]}) + assert resp.status_code == 200 + body = resp.json() + assert "error" not in body + assert body["result"]["task"]["id"] # server generated + + +def test_empty_id_gets_a_server_uuid(client: TestClient): + resp = _send(client, {"id": "", "messages": [{"role": "user", "content": "hi"}]}) + assert resp.status_code == 200 + body = resp.json() + assert "error" not in body + assert body["result"]["task"]["id"] + + +def test_distinct_ids_do_not_collide(client: TestClient): + a = _send(client, {"id": "task-a", "messages": []}) + b = _send(client, {"id": "task-b", "messages": []}, req_id="2") + assert "error" not in a.json() and "error" not in b.json() diff --git a/test/agent_card/__init__.py b/test/agent_card/__init__.py new file mode 100644 index 0000000000..58d06c62a5 --- /dev/null +++ b/test/agent_card/__init__.py @@ -0,0 +1 @@ +"""Tests for the Agent Card publishing layer.""" diff --git a/test/agent_card/test_builder.py b/test/agent_card/test_builder.py new file mode 100644 index 0000000000..f1008bdd62 --- /dev/null +++ b/test/agent_card/test_builder.py @@ -0,0 +1,77 @@ +"""Tests for the Agent Card builder.""" + +from __future__ import annotations + +from cli_agent_orchestrator.agent_card.builder import ( + _DECLARED_MCP_TOOLS, + _PROVIDER_BINARIES, + build_agent_card, +) + + +class TestBuilderDefaults: + def test_returns_a2a_v1_schema_url(self): + card = build_agent_card() + assert card["$schema"] == "https://a2aproject.org/schemas/v1.0/agent-card.json" + + def test_includes_all_four_mcp_tools(self): + card = build_agent_card() + assert card["capabilities"]["tools"] == _DECLARED_MCP_TOOLS + + def test_includes_every_known_provider(self): + card = build_agent_card() + names = {p["name"] for p in card["providers"]} + assert names == set(_PROVIDER_BINARIES.keys()) + + def test_provider_entries_have_installed_flag(self): + card = build_agent_card() + for entry in card["providers"]: + assert "installed" in entry + assert isinstance(entry["installed"], bool) + + def test_default_endpoints_are_none_in_phase_1(self): + # Full A2A transport surface arrives in Phase 5. + card = build_agent_card() + assert card["endpoints"]["rpc"] is None + assert card["endpoints"]["stream"] is None + assert card["endpoints"]["tasks"] is None + + +class TestBuilderMetadataMerge: + def test_operator_metadata_overrides_defaults(self): + card = build_agent_card( + { + "agent_id": "cao-prod-01", + "name": "Production CAO", + "description": "Customer-facing orchestrator", + "organization": "flowclaw.local", + "vendor": "Flowclaw", + "contact": "ops@flowclaw.local", + } + ) + assert card["agentId"] == "cao-prod-01" + assert card["name"] == "Production CAO" + assert card["description"] == "Customer-facing orchestrator" + assert card["organization"] == "flowclaw.local" + assert card["vendor"] == "Flowclaw" + assert card["contact"] == "ops@flowclaw.local" + + def test_explicit_endpoint_overrides(self): + card = build_agent_card( + None, + rpc_endpoint="https://cao.example/a2a/v1/rpc", + stream_endpoint="https://cao.example/a2a/v1/stream", + tasks_endpoint="https://cao.example/a2a/v1/tasks", + ) + assert card["endpoints"]["rpc"] == "https://cao.example/a2a/v1/rpc" + assert card["endpoints"]["stream"] == "https://cao.example/a2a/v1/stream" + assert card["endpoints"]["tasks"] == "https://cao.example/a2a/v1/tasks" + + +class TestBuilderHasNoSignatureField: + def test_unsigned_card_does_not_carry_signature(self): + # Signing is the router's responsibility — the builder must produce + # a card with no AgentCardSignature so signing remains stable + # (no risk of accidentally signing over a stale signature). + card = build_agent_card() + assert "AgentCardSignature" not in card diff --git a/test/agent_card/test_listener_coverage.py b/test/agent_card/test_listener_coverage.py new file mode 100644 index 0000000000..92bd7cbcf2 --- /dev/null +++ b/test/agent_card/test_listener_coverage.py @@ -0,0 +1,74 @@ +"""Coverage for the dedicated Agent Card listener (:9890) assembly + lifecycle. + +Targets ``agent_card/listener.py``: mounting the optional A2A JSON-RPC and +streaming routers onto the listener app, and the real +``start_agent_card_listener`` → ``AgentCardListener.stop()`` round-trip on a +loopback test port. +""" + +from __future__ import annotations + +import socket + +import pytest +from fastapi import APIRouter + +from cli_agent_orchestrator.agent_card.listener import ( + build_listener_app, + start_agent_card_listener, +) +from cli_agent_orchestrator.agent_card.signing import Signer + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def test_build_listener_app_mounts_optional_a2a_routers(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + a2a = APIRouter() + + @a2a.post("/a2a/v1/rpc") + async def rpc() -> dict: # pragma: no cover - only route registration matters + return {} + + stream = APIRouter() + + @stream.get("/a2a/v1/stream/{task_id}") + async def stream_route(task_id: str) -> dict: # pragma: no cover + return {} + + app = build_listener_app( + signer, lambda: {"name": "cao"}, a2a_router=a2a, a2a_stream_router=stream + ) + paths = {getattr(r, "path", None) for r in app.routes} + assert "/.well-known/agent-card.json" in paths + assert "/a2a/v1/rpc" in paths + assert "/a2a/v1/stream/{task_id}" in paths + + +def test_build_listener_app_without_a2a_routers(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + app = build_listener_app(signer, lambda: {"name": "cao"}) + paths = {getattr(r, "path", None) for r in app.routes} + assert "/.well-known/agent-card.json" in paths + assert "/.well-known/jwks.json" in paths + + +@pytest.mark.asyncio +async def test_start_and_stop_listener(tmp_path) -> None: + port = _free_port() + listener = await start_agent_card_listener( + tmp_path / "kd", + lambda: {"name": "cao"}, + host="127.0.0.1", + port=port, + ) + try: + assert listener.task is not None + assert not listener.task.done() + finally: + await listener.stop() + assert listener.server.should_exit is True diff --git a/test/agent_card/test_router.py b/test/agent_card/test_router.py new file mode 100644 index 0000000000..ec4bf70130 --- /dev/null +++ b/test/agent_card/test_router.py @@ -0,0 +1,89 @@ +"""Integration tests for the /.well-known endpoints.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.agent_card import build_listener_app +from cli_agent_orchestrator.agent_card import router as card_router +from cli_agent_orchestrator.agent_card.signing import Signer, verify_compact_jws + + +@pytest.fixture +def app(tmp_path: Path): + """Spin up the dedicated card-listener FastAPI app on a temp key dir.""" + signer = Signer(tmp_path / "agent_card") + metadata = lambda: {"agent_id": "cao-test-01", "name": "Test CAO"} + app = build_listener_app(signer, metadata) + yield app, signer + card_router.reset() + + +@pytest.fixture +def client(app): + fastapi_app, _signer = app + return TestClient(fastapi_app) + + +# --------------------------------------------------------------------------- +# /.well-known/agent-card.json +# --------------------------------------------------------------------------- + + +class TestAgentCardEndpoint: + def test_returns_200_with_signed_card(self, client): + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + body = resp.json() + assert body["agentId"] == "cao-test-01" + assert body["name"] == "Test CAO" + assert "AgentCardSignature" in body + + def test_signature_verifies_against_jwks(self, client, app): + _, signer = app + body = client.get("/.well-known/agent-card.json").json() + # Verify with the signer's public key directly. + assert signer.verify_card(body) is True + + def test_signature_verifies_against_published_jwks(self, client, app): + # Stronger end-to-end test: pull the public key from the JWKS + # endpoint, then verify the card against it. This is what an + # external A2A peer would do. + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + + from cli_agent_orchestrator.agent_card.signing import _b64u_decode + + body = client.get("/.well-known/agent-card.json").json() + jwks = client.get("/.well-known/jwks.json").json() + keys = jwks["keys"] + assert len(keys) == 1 + pub_raw = _b64u_decode(keys[0]["x"]) + public = Ed25519PublicKey.from_public_bytes(pub_raw) + + signature = body["AgentCardSignature"] + assert verify_compact_jws(public, signature, omit_field=True, card=body) + + +# --------------------------------------------------------------------------- +# /.well-known/jwks.json +# --------------------------------------------------------------------------- + + +class TestJWKSEndpoint: + def test_returns_200_with_one_key(self, client): + resp = client.get("/.well-known/jwks.json") + assert resp.status_code == 200 + body = resp.json() + assert isinstance(body["keys"], list) + assert len(body["keys"]) == 1 + + def test_jwk_has_required_fields(self, client): + body = client.get("/.well-known/jwks.json").json() + key = body["keys"][0] + assert key["kty"] == "OKP" + assert key["crv"] == "Ed25519" + assert key["alg"] == "EdDSA" + assert "x" in key diff --git a/test/agent_card/test_router_coverage.py b/test/agent_card/test_router_coverage.py new file mode 100644 index 0000000000..d5084dcfd1 --- /dev/null +++ b/test/agent_card/test_router_coverage.py @@ -0,0 +1,70 @@ +"""Coverage for the Agent Card discovery router. + +Targets ``agent_card/router.py`` branches: the uninitialized-signer +defensive returns for the agent-card + JWKS routes, and the +OAuth Protected-Resource-Metadata route in both its 404 (auth disabled) +and populated (auth enabled) forms. +""" + +from __future__ import annotations + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import cli_agent_orchestrator.security as security_mod +from cli_agent_orchestrator.agent_card import router as card_router +from cli_agent_orchestrator.agent_card.signing import Signer + + +@pytest.fixture +def client() -> TestClient: + app = FastAPI() + app.include_router(card_router.router) + return TestClient(app, raise_server_exceptions=False) + + +def test_agent_card_uninitialized_returns_error(client: TestClient) -> None: + card_router.reset() + resp = client.get("/.well-known/agent-card.json") + assert resp.status_code == 200 + assert resp.json() == {"error": "agent card listener not initialized"} + + +def test_jwks_uninitialized_returns_empty_keys(client: TestClient) -> None: + card_router.reset() + resp = client.get("/.well-known/jwks.json") + assert resp.json() == {"keys": []} + + +def test_agent_card_and_jwks_when_configured(tmp_path, client: TestClient) -> None: + signer = Signer(tmp_path / "kd") + card_router.configure(signer, lambda: {"name": "cao"}) + try: + card = client.get("/.well-known/agent-card.json").json() + assert "AgentCardSignature" in card + jwks = client.get("/.well-known/jwks.json").json() + assert jwks["keys"][0]["kty"] == "OKP" + finally: + card_router.reset() + + +def test_oauth_protected_resource_404_when_auth_disabled(client: TestClient, monkeypatch) -> None: + monkeypatch.setattr(security_mod, "is_auth_enabled", lambda: False) + resp = client.get("/.well-known/oauth-protected-resource") + assert resp.status_code == 404 + + +def test_oauth_protected_resource_document_when_auth_enabled( + client: TestClient, monkeypatch +) -> None: + monkeypatch.setattr(security_mod, "is_auth_enabled", lambda: True) + monkeypatch.setenv("AUTH0_DOMAIN", "example.auth0.com") + monkeypatch.setenv("AUTH0_AUDIENCE", "https://cao.example/api") + resp = client.get("/.well-known/oauth-protected-resource") + assert resp.status_code == 200 + body = resp.json() + assert body["resource"] == "https://cao.example/api" + assert body["authorization_servers"] == ["https://example.auth0.com/"] + assert body["bearer_methods_supported"] == ["header"] + assert isinstance(body["scopes_supported"], list) diff --git a/test/agent_card/test_signing.py b/test/agent_card/test_signing.py new file mode 100644 index 0000000000..e00f8c86be --- /dev/null +++ b/test/agent_card/test_signing.py @@ -0,0 +1,149 @@ +"""Tests for Ed25519 Agent Card signing. + +Pinned invariants: + * The signer creates the key file lazily with mode 0600. + * sign_card() / verify_card() round-trip a card. + * Tampering with the card invalidates the signature. + * The signature is over the card *minus* its AgentCardSignature field + (otherwise the signature would be self-referential). + * The public JWK uses RFC 8037 OKP / Ed25519 conventions. +""" + +from __future__ import annotations + +import json +import os +import stat +from pathlib import Path + +import pytest + +from cli_agent_orchestrator.agent_card.signing import ( + Signer, + _b64u, + _b64u_decode, + _canonical_json, + verify_compact_jws, +) + + +@pytest.fixture +def signer(tmp_path: Path) -> Signer: + return Signer(tmp_path / "agent_card") + + +# --------------------------------------------------------------------------- +# Key generation + on-disk format +# --------------------------------------------------------------------------- + + +class TestKeyManagement: + def test_key_is_generated_lazily(self, tmp_path: Path): + key_dir = tmp_path / "agent_card" + assert not key_dir.exists() + Signer(key_dir) + assert (key_dir / Signer.KEY_FILENAME).exists() + + def test_key_file_is_mode_0600(self, tmp_path: Path): + key_dir = tmp_path / "agent_card" + Signer(key_dir) + mode = (key_dir / Signer.KEY_FILENAME).stat().st_mode + # Owner read/write only — group + other have no access. + assert stat.S_IMODE(mode) == 0o600 + + def test_second_construction_loads_existing_key(self, tmp_path: Path): + key_dir = tmp_path / "agent_card" + s1 = Signer(key_dir) + first_jwk = s1.public_jwk() + s2 = Signer(key_dir) + # Same key on disk → same JWK fingerprint. + assert s2.public_jwk() == first_jwk + + +# --------------------------------------------------------------------------- +# Public JWK +# --------------------------------------------------------------------------- + + +class TestPublicJwk: + def test_jwk_uses_rfc8037_okp_ed25519(self, signer: Signer): + jwk = signer.public_jwk() + assert jwk["kty"] == "OKP" + assert jwk["crv"] == "Ed25519" + assert jwk["alg"] == "EdDSA" + assert jwk["kid"] == Signer.KID + assert jwk["use"] == "sig" + assert "x" in jwk + + def test_jwk_x_decodes_to_32_bytes(self, signer: Signer): + jwk = signer.public_jwk() + raw = _b64u_decode(jwk["x"]) + assert len(raw) == 32 # Ed25519 public key length + + +# --------------------------------------------------------------------------- +# sign_card / verify_card round-trip +# --------------------------------------------------------------------------- + + +class TestSignCardRoundTrip: + def test_signed_card_verifies(self, signer: Signer): + card = {"name": "CAO Mayor", "version": "1.0.0"} + signature = signer.sign_card(card) + signed = {**card, "AgentCardSignature": signature} + assert signer.verify_card(signed) is True + + def test_signature_does_not_cover_itself(self, signer: Signer): + """Signing twice with and without an existing AgentCardSignature + field must yield the same result — otherwise the signature would be + self-referential and re-issuing would invalidate the previous one. + """ + card = {"name": "CAO", "version": "1.0.0"} + sig1 = signer.sign_card(card) + sig2 = signer.sign_card({**card, "AgentCardSignature": "stale-value"}) + assert sig1 == sig2 + + def test_tampered_card_does_not_verify(self, signer: Signer): + card = {"name": "CAO Mayor", "version": "1.0.0"} + signed = {**card, "AgentCardSignature": signer.sign_card(card)} + signed["name"] = "Imposter" + assert signer.verify_card(signed) is False + + def test_missing_signature_does_not_verify(self, signer: Signer): + card = {"name": "CAO Mayor"} + assert signer.verify_card(card) is False + + def test_garbled_signature_does_not_raise(self, signer: Signer): + card = {"name": "CAO Mayor", "AgentCardSignature": "not.a.real.jws"} + # Must return False rather than raise — a malformed signature is + # an attacker, not a programming error. + assert signer.verify_card(card) is False + + +# --------------------------------------------------------------------------- +# Compact JWS structural checks +# --------------------------------------------------------------------------- + + +class TestCompactJWS: + def test_signature_is_three_dot_separated_segments(self, signer: Signer): + compact = signer.sign_card({"name": "x"}) + parts = compact.split(".") + assert len(parts) == 3 + # All three parts decode as base64url. + for p in parts: + _b64u_decode(p) + + def test_header_declares_eddsa_alg_and_kid(self, signer: Signer): + compact = signer.sign_card({"name": "x"}) + header_b64 = compact.split(".")[0] + header = json.loads(_b64u_decode(header_b64)) + assert header["alg"] == "EdDSA" + assert header["kid"] == Signer.KID + + def test_canonical_json_is_stable(self): + # Sorted keys + compact separators → byte-identical regardless of + # input ordering. + a = _canonical_json({"b": 2, "a": 1}) + b = _canonical_json({"a": 1, "b": 2}) + assert a == b diff --git a/test/agent_card/test_signing_coverage.py b/test/agent_card/test_signing_coverage.py new file mode 100644 index 0000000000..d3535fc39b --- /dev/null +++ b/test/agent_card/test_signing_coverage.py @@ -0,0 +1,63 @@ +"""Coverage for the Ed25519 Agent Card signer verification branches. + +Targets the failure paths in ``agent_card/signing.verify_compact_jws`` and +``Signer.verify_card``: wrong segment count, undecodable signature base64, +``omit_field`` payload mismatch, and a cryptographically-invalid signature +(the ``public.verify`` exception branch). Also round-trips a real sign → +verify to keep the happy path anchored. +""" + +from __future__ import annotations + +from cli_agent_orchestrator.agent_card.signing import ( + JWS, + Signer, + _b64u, + verify_compact_jws, +) + + +def test_sign_and_verify_card_roundtrip(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + card = {"name": "cao", "version": "1.0.0"} + card["AgentCardSignature"] = signer.sign_card(card) + assert signer.verify_card(card) is True + + +def test_verify_card_rejects_non_string_signature(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + assert signer.verify_card({"name": "cao"}) is False # no signature field + assert signer.verify_card({"AgentCardSignature": 123}) is False # wrong type + + +def test_verify_compact_jws_wrong_segment_count(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + assert verify_compact_jws(signer._public, "onlyonepart") is False + assert verify_compact_jws(signer._public, "a.b") is False + + +def test_verify_compact_jws_undecodable_signature(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + # A signature segment that base64url cannot decode raises → False. + bad = "aGVhZGVy.cGF5bG9hZA.@@@not-base64@@@" + assert verify_compact_jws(signer._public, bad) is False + + +def test_verify_compact_jws_invalid_signature_bytes(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + jws = signer.sign(b"hello world") + # Replace the signature with a valid-base64 but wrong-length/content blob + # so cryptography's verify() raises InvalidSignature → caught → False. + tampered = JWS(jws.header_b64, jws.payload_b64, _b64u(b"\x00" * 64)) + assert verify_compact_jws(signer._public, tampered.to_compact()) is False + + +def test_verify_compact_jws_omit_field_payload_mismatch(tmp_path) -> None: + signer = Signer(tmp_path / "kd") + card = {"name": "cao"} + compact = signer.sign_card(card) + # Verify against a DIFFERENT card body → rebuilt payload won't match. + assert ( + verify_compact_jws(signer._public, compact, omit_field=True, card={"name": "other"}) + is False + ) diff --git a/test/api/test_a2a_mount_guard.py b/test/api/test_a2a_mount_guard.py new file mode 100644 index 0000000000..1501ba057f --- /dev/null +++ b/test/api/test_a2a_mount_guard.py @@ -0,0 +1,80 @@ +"""Fail-closed guard for the A2A task routes (#387 review, blocking finding). + +``CAO_AGENT_CARD_HOST=0.0.0.0`` (the documented opt-in for external A2A +discoverability) combined with auth-off used to expose an unauthenticated +``task.send``/``task.cancel`` write API to any network peer. The lifespan now +refuses to mount the A2A routers on a non-loopback bind unless auth is +configured — discovery (Agent Card + JWKS) still serves, the task API doesn't. +""" + +from __future__ import annotations + +import pytest +from fastapi.testclient import TestClient + +from cli_agent_orchestrator.api.main import _should_mount_a2a, app + + +@pytest.fixture(autouse=True) +def _clean_env(monkeypatch): + for var in ("AUTH0_DOMAIN", "AUTH0_AUDIENCE", "CAO_AUTH_JWKS_URI", "CAO_A2A_DISABLED"): + monkeypatch.delenv(var, raising=False) + + +def test_non_loopback_bind_without_auth_refuses_a2a_mount(monkeypatch, caplog): + monkeypatch.setenv("CAO_AGENT_CARD_ENABLED", "true") + monkeypatch.setenv("CAO_AGENT_CARD_HOST", "0.0.0.0") + with TestClient(app, base_url="http://localhost"): + listener = app.state.agent_card_listener + # The discovery listener itself may start (that's read-only), but the + # A2A task store/bus must not be wired. + assert getattr(app.state, "a2a_store", None) is None + if listener is not None: + paths = {r.path for r in listener.server.config.app.routes} + assert not any(p.startswith("/a2a") for p in paths) + assert "Refusing to mount the A2A task routes" in caplog.text + + +def test_loopback_bind_without_auth_still_mounts(monkeypatch): + monkeypatch.setenv("CAO_AGENT_CARD_ENABLED", "true") + monkeypatch.delenv("CAO_AGENT_CARD_HOST", raising=False) + with TestClient(app, base_url="http://localhost"): + listener = app.state.agent_card_listener + assert listener is not None + paths = {r.path for r in listener.server.config.app.routes} + assert any(p.startswith("/a2a") for p in paths) + # The bounded store is the env-configured one. + assert getattr(app.state, "a2a_store", None) is not None + + +def test_default_off_no_listener(monkeypatch): + monkeypatch.delenv("CAO_AGENT_CARD_ENABLED", raising=False) + with TestClient(app, base_url="http://localhost"): + assert app.state.agent_card_listener is None + + +# --------------------------------------------------------------------------- +# Decision table on the extracted pure function (ported from the Kiro +# remediation) — the lifespan integration tests above cover the wiring; this +# covers the full matrix without binding a socket. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "bind_host, a2a_disabled, auth_enabled, expected", + [ + ("127.0.0.1", False, False, True), # loopback + no auth: OK (dev) + ("localhost", False, False, True), # loopback alias + ("::1", False, False, True), # ipv6 loopback + ("0.0.0.0", False, False, False), # non-loopback + no auth: REFUSED + ("0.0.0.0", False, True, True), # non-loopback + auth: OK + ("10.0.0.5", False, False, False), # arbitrary external + no auth: REFUSED + ("0.0.0.0", True, True, False), # explicitly disabled always wins + ("127.0.0.1", True, False, False), # disabled wins on loopback too + ], +) +def test_mount_decision(bind_host, a2a_disabled, auth_enabled, expected): + assert ( + _should_mount_a2a(bind_host=bind_host, a2a_disabled=a2a_disabled, auth_enabled=auth_enabled) + is expected + ) diff --git a/test/api/test_default_off_listeners.py b/test/api/test_default_off_listeners.py index 2689afe275..0df97f23f2 100644 --- a/test/api/test_default_off_listeners.py +++ b/test/api/test_default_off_listeners.py @@ -1,10 +1,13 @@ """Regression: `cao-server` with no flags opens no listener beyond :9889. The A2A / Agent Card transport was split out of the AG-UI core PR (review -feedback on awslabs/cli-agent-orchestrator#387): it must not exist on this -branch at all — no module, no lifespan wiring, no ``/a2a`` routes. This -asserts that stronger contract, plus the AG-UI surface's own default-off -behavior (no flags => 404). +feedback on awslabs/cli-agent-orchestrator#387) and lands hardened in Phase-B. +It is present in this build but **default-off**: with no flags set there is no +lifespan wiring, no listener handle, and no ``/a2a`` routes on the main :9889 +app (the transport only mounts on the dedicated :9890 listener when +``CAO_AGENT_CARD_ENABLED`` is set — see test_a2a_mount_guard.py). This asserts +that default-off contract, plus the AG-UI surface's own default-off behavior +(no flags => 404). """ from __future__ import annotations @@ -27,12 +30,12 @@ def _no_surface_env(monkeypatch): monkeypatch.delenv(var, raising=False) -def test_a2a_surface_absent_from_this_build(): - """The A2A/Agent Card transport is not part of this branch.""" - with pytest.raises(ImportError): - import cli_agent_orchestrator.a2a # noqa: F401 - with pytest.raises(ImportError): - import cli_agent_orchestrator.agent_card # noqa: F401 +def test_a2a_surface_importable_but_not_wired_by_default(): + """The A2A/Agent Card modules exist (Phase-B) but importing them has no + side effects: no listener is started and no routes mount without the flag. + """ + import cli_agent_orchestrator.a2a # noqa: F401 + import cli_agent_orchestrator.agent_card # noqa: F401 def test_no_agent_card_listener_state_without_flag(): diff --git a/test/e2e/test_a2a_roundtrip.py b/test/e2e/test_a2a_roundtrip.py new file mode 100644 index 0000000000..50abdd4b42 --- /dev/null +++ b/test/e2e/test_a2a_roundtrip.py @@ -0,0 +1,286 @@ +"""A2A v1.0 round-trip end-to-end test (v2.5 close-out, item 7). + +Spins up two CAO A2A peers in-process and exercises the full +peer-to-peer task lifecycle: + + Peer A ──HTTP POST──▶ Peer B's /a2a/v1/rpc (task.send) + Peer A ──HTTP GET ──▶ Peer B's /a2a/v1/stream/{id} (SSE) + Peer A ──HTTP POST──▶ Peer B's /a2a/v1/rpc (task.get) + +Peer B has a stub :class:`TaskExecutor` that transitions the task +``submitted → working → completed`` with a result message. Peer A +verifies: + + * ``task.send`` returns 200 OK with state ``submitted`` or ``working`` + * ``task.get`` (polled) eventually reports ``completed`` + * SSE stream emits at least one ``task.update`` and one + terminal-state event before closing + +Marker: ``e2e`` + ``slow`` so this is auto-deselected from the default +``pytest -m 'not e2e' --no-cov`` invocation. Run manually with:: + + pytest -m e2e test/e2e/test_a2a_roundtrip.py + +The runbook also documents a true-network variant (two CAO instances +on different ports rather than ASGI in-process). For CI we use the +in-process variant — same code path, no flaky port binding. +""" + +from __future__ import annotations + +import asyncio +import json +from typing import Any, AsyncIterator + +import httpx +import pytest +from fastapi import FastAPI + +from cli_agent_orchestrator.a2a import ( + InMemoryTaskEventBus, + InMemoryTaskStore, + Task, + TaskState, + build_a2a_router, + build_stream_router, +) + +pytestmark = [pytest.mark.e2e, pytest.mark.slow] + + +# Override the test/e2e/conftest.py autouse fixtures — this test runs the +# A2A surfaces in-process via ``httpx.ASGITransport`` and doesn't need a +# live CAO server, a warmed uvx cache, or tmux (there is no terminal-backed +# agent here — both peers are ASGI apps). Overriding ``require_tmux`` keeps +# the round-trip runnable in tmux-less environments (e.g. minimal CI jobs). +@pytest.fixture(autouse=True) +def require_cao_server(): + yield + + +@pytest.fixture(autouse=True) +def warmup_mcp_server_cache(): + yield + + +@pytest.fixture(autouse=True) +def require_tmux(): + yield + + +# --------------------------------------------------------------------------- +# Peer factory — one per CAO instance. +# --------------------------------------------------------------------------- + + +def _build_peer(*, with_executor: bool) -> tuple[FastAPI, InMemoryTaskStore, InMemoryTaskEventBus]: + store = InMemoryTaskStore() + bus = InMemoryTaskEventBus() + + async def _executor(task: Task) -> Task: + # Trivial executor — pretend the worker did some work and produced + # a single response message. The framework transitions WORKING → + # COMPLETED automatically when the executor returns without + # setting a terminal state. + await asyncio.sleep(0) + task.messages = list(task.messages) + [ + {"role": "assistant", "content": "round-trip complete"} + ] + return task + + app = FastAPI() + app.include_router( + build_a2a_router( + store=store, + bus=bus, + executor=_executor if with_executor else None, + ) + ) + app.include_router(build_stream_router(store=store, bus=bus)) + return app, store, bus + + +# --------------------------------------------------------------------------- +# Utility: SSE line parser +# --------------------------------------------------------------------------- + + +async def _read_sse_events( + stream: AsyncIterator[bytes], *, max_events: int = 5 +) -> list[dict[str, Any]]: + """Pull at most ``max_events`` parsed SSE frames from an async iterator.""" + events: list[dict[str, Any]] = [] + buffer = "" + async for chunk in stream: + buffer += chunk.decode("utf-8") + while "\n\n" in buffer: + block, _, buffer = buffer.partition("\n\n") + event_line = next((ln for ln in block.splitlines() if ln.startswith("event:")), "") + data_line = next((ln for ln in block.splitlines() if ln.startswith("data:")), "") + if not event_line: + continue + event_name = event_line.split(":", 1)[1].strip() + payload = data_line.split(":", 1)[1].strip() if data_line else "" + try: + data = json.loads(payload) if payload else {} + except json.JSONDecodeError: + data = {"raw": payload} + events.append({"event": event_name, "data": data}) + if len(events) >= max_events: + return events + return events + + +# --------------------------------------------------------------------------- +# Round-trip +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_a2a_round_trip_two_peers(): + """End-to-end: peer A sends a task to peer B, B executes, A polls + streams.""" + # Peer A: vanilla — used only as the client; no executor. + app_a, _, _ = _build_peer(with_executor=False) + # Peer B: has the executor that completes the task. + app_b, store_b, _ = _build_peer(with_executor=True) + + # Drive peer A as an HTTP client against peer B's ASGI app. + transport_b = httpx.ASGITransport(app=app_b) + async with httpx.AsyncClient(transport=transport_b, base_url="http://peer-b") as client_b: + # 1. Peer A submits a task to peer B's RPC endpoint. + send_resp = await client_b.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "rt-1", + "method": "task.send", + "params": { + "task": { + "id": "rt-task-1", + "messages": [{"role": "user", "content": "ping"}], + "metadata": {"sender": "peer-a"}, + } + }, + }, + ) + assert send_resp.status_code == 200, send_resp.text + send_body = send_resp.json() + assert "error" not in send_body, send_body + send_state = send_body["result"]["task"]["state"] + # Either SUBMITTED (executor scheduled but hasn't run yet) or + # WORKING (executor already started). + assert send_state in (TaskState.SUBMITTED, TaskState.WORKING) + + # 2. Poll peer B's task.get until the task reaches COMPLETED. + deadline = asyncio.get_event_loop().time() + 5.0 + final_state = None + while asyncio.get_event_loop().time() < deadline: + get_resp = await client_b.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "rt-get", + "method": "task.get", + "params": {"id": "rt-task-1"}, + }, + ) + assert get_resp.status_code == 200 + task_payload = get_resp.json()["result"]["task"] + if TaskState.is_terminal(task_payload["state"]): + final_state = task_payload["state"] + break + await asyncio.sleep(0.05) + + assert final_state == TaskState.COMPLETED, ( + f"task did not reach COMPLETED within deadline (final_state={final_state}, " + f"store={await store_b.get('rt-task-1')})" + ) + + # 3. Pull peer B's SSE stream — for an already-terminal task we + # expect a single ``task.terminal`` frame and the stream to close. + async with client_b.stream( + "GET", + "/a2a/v1/stream/rt-task-1", + timeout=5.0, + ) as stream_resp: + assert stream_resp.status_code == 200 + events = await _read_sse_events(stream_resp.aiter_bytes(), max_events=3) + + assert events, "stream produced no events for terminal task" + # First (and likely only) event must be a terminal carrying the + # final task envelope. The Phase 5 stream router collapses an + # already-terminal subscription into one frame. + terminal_events = [e for e in events if e["event"] in ("task.terminal", "task.update")] + assert terminal_events, f"no task.update/terminal events seen: {events}" + terminal_payload = terminal_events[0]["data"] + assert terminal_payload.get("task", {}).get("id") == "rt-task-1" + assert terminal_payload.get("task", {}).get("state") == TaskState.COMPLETED + + +@pytest.mark.asyncio +async def test_a2a_round_trip_authenticated(monkeypatch, jwt_factory, jwks_server): + """Same round-trip with auth enforced: 401 untokened, full flow with JWTs. + + Added with the #387 review remediation — the transport now gates + task.send/cancel on ``cao:write`` and task.get/stream on ``cao:read`` + against a live JWKS, so the e2e proves the authenticated path end-to-end. + """ + from cli_agent_orchestrator.security import auth as auth_mod + + monkeypatch.setenv("AUTH0_DOMAIN", jwt_factory.domain) + monkeypatch.setenv("AUTH0_AUDIENCE", jwt_factory.audience) + monkeypatch.setenv("CAO_AUTH_JWKS_URI", jwks_server.url) + auth_mod.reset_jwks_cache() + try: + app_b, _, _ = _build_peer(with_executor=True) + transport_b = httpx.ASGITransport(app=app_b) + write_h = {"Authorization": f"Bearer {jwt_factory.mint(scopes='cao:write')}"} + read_h = {"Authorization": f"Bearer {jwt_factory.mint(scopes='cao:read')}"} + send_req = { + "jsonrpc": "2.0", + "id": "rt-auth-1", + "method": "task.send", + "params": {"task": {"id": "rt-auth-task", "messages": []}}, + } + async with httpx.AsyncClient(transport=transport_b, base_url="http://peer-b") as client_b: + # Untokened peer is rejected before any task work happens. + anon = await client_b.post("/a2a/v1/rpc", json=send_req) + assert anon.status_code == 401 + + # Authorized peer runs the full lifecycle. + sent = await client_b.post("/a2a/v1/rpc", json=send_req, headers=write_h) + assert sent.status_code == 200, sent.text + assert "error" not in sent.json() + + deadline = asyncio.get_event_loop().time() + 5.0 + final_state = None + while asyncio.get_event_loop().time() < deadline: + got = await client_b.post( + "/a2a/v1/rpc", + json={ + "jsonrpc": "2.0", + "id": "rt-auth-get", + "method": "task.get", + "params": {"id": "rt-auth-task"}, + }, + headers=read_h, + ) + assert got.status_code == 200 + task_payload = got.json()["result"]["task"] + if TaskState.is_terminal(task_payload["state"]): + final_state = task_payload["state"] + break + await asyncio.sleep(0.05) + assert final_state == TaskState.COMPLETED + + # Stream also requires the read scope. + anon_stream = await client_b.get("/a2a/v1/stream/rt-auth-task") + assert anon_stream.status_code == 401 + async with client_b.stream( + "GET", "/a2a/v1/stream/rt-auth-task", headers=read_h, timeout=5.0 + ) as stream_resp: + assert stream_resp.status_code == 200 + events = await _read_sse_events(stream_resp.aiter_bytes(), max_events=2) + assert any(e["event"] in ("task.terminal", "task.update") for e in events) + finally: + auth_mod.reset_jwks_cache()