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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
56 changes: 56 additions & 0 deletions docs/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Original file line number Diff line number Diff line change
@@ -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

85 changes: 85 additions & 0 deletions docs/reviews/pr387-followup-issues.md
Original file line number Diff line number Diff line change
@@ -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=<id>` instead of `?access_token=<jwt>`.
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.
62 changes: 62 additions & 0 deletions docs/reviews/pr387-phase1-verification-2026-07-07.md
Original file line number Diff line number Diff line change
@@ -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`.
Loading
Loading