feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160)#1628
feat(a2a): A2A interoperability — inbound server (well-known + JSON-RPC/SSE) + control MCP tools (ent#157/#160)#1628dolho wants to merge 15 commits into
Conversation
📖 A2A user guide + screenshotsFull guide added at Trinity speaks the open A2A protocol — an exposed agent is reachable by any A2A orchestrator (Google ADK, LangChain, Bedrock, another Trinity), and can call external A2A agents in turn.
Part 1 — Expose an agent (the new A2A tab)
Same controls are available as MCP tools ( Part 2 — Consume an exposed agent (external orchestrator)1. Discover — fetch the card: curl -s http://localhost:8001/a2a/new_cool_agent/.well-known/agent-card.json | jq
# → { "protocolVersion":"0.3.0", "url":".../a2a/new_cool_agent",
# "preferredTransport":"JSONRPC", "securitySchemes":{"bearerAuth":{...}} }2. Authenticate — a Trinity MCP API key (Settings → MCP Keys) as 3. Task — JSON-RPC 2.0: curl -s -X POST http://localhost:8001/a2a/new_cool_agent \
-H "Authorization: Bearer trinity_mcp_YOUR_KEY" \
-d '{"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"messageId":"req-1",
"parts":[{"kind":"text","text":"Summarize today's sales."}]}}}'
# → Task { status:{state:"completed"}, artifacts:[{parts:[{text:"…"}]}] }
Notes
JSON-RPC error codes: |
vybe
left a comment
There was a problem hiding this comment.
Validated via /validate-pr. Impressive scope and the fundamentals are solid — dual-track migrations are textbook (all four surfaces present, 0024 chains to the verified sole head 0023, matches the 0010_mcp_exposed house pattern byte-for-byte), Invariants #4/#14 clean, SQL is all bound-param Core, and the JSON-RPC path is fail-closed 401 with uniform 404s. The negative-path test coverage is genuinely good.
Three findings I'd want resolved before this lands. All verified against the code, not inferred.
1. CRITICAL — cross-caller response disclosure via messageId idempotency
routers/a2a.py:
message_id = message.get("messageId")
decision = idempotency_service.begin(f"a2a:{agent_name}", message_id)
if decision.replay and not decision.in_flight and decision.snapshot:
return _rpc_result(rpc_id, decision.snapshot)The scope carries no caller identity, the key is a peer-controlled protocol field, and the stored snapshot contains the agent's full response text (artifacts[0].parts[0].text).
Failure: users A and B both have shared access to agent bot. A sends messageId: "req-1". B sends messageId: "req-1" → B receives A's agent output, and B's own task silently never executes.
This isn't hypothetical: A2A messageId is mandatory, auto-generated by SDKs, and the spec only requires per-client uniqueness. This PR's own guide uses "messageId": "req-1" as the worked example.
I know make_agent_scope(name) + caller-supplied key is the house shape (chat.py:205, fan_out.py:63) — the difference is Idempotency-Key there is opt-in and deliberately chosen, while messageId is automatic and collision-prone. Suggest folding the caller principal into the scope.
2. CRITICAL — unauthenticated well-known route has no rate limit
routers/a2a.py — a2a_well_known_card → _serve_card
Per request against an exposed agent: DB read → live uncached Docker API call (docker_service.get_agent_container) → HTTP call into the agent container (_fetch_template_data, 5s timeout). No throttle at either layer — services/rate_limiter isn't imported in the diff, and nginx.conf has no limit_req/limit_conn.
Every other unauthenticated route in the repo carries a per-IP limit: public.py:50 (PUBLIC_LINK_LOOKUP_RATE_LIMIT = 60, annotated "pentest 3.3.2"), files.py:44 (_DOWNLOAD_RATE_LIMIT = 60), webhooks.py:99 (added by #1424 for exactly this endpoint shape).
The card URL is published by design, so the agent name is public. An unauthenticated flood saturates the Docker API (called synchronously inside async def, so it stalls the event loop) and hammers the agent container → fleet-wide degradation from one URL.
Worth noting this PR is what makes the route public — the docs it replaces said "Trinity does not serve that route yet — it requires a separate access-policy decision per agent." That decision seems to have been made implicitly; the per-IP limit is the piece that didn't come with it.
3. CRITICAL — public seam file names a private enterprise table (standing rule; CI green by accident)
services/a2a_gate.py:13:
* Enterprise build → the provider consults ``enterprise_a2a_inbound_allowlist``:
enterprise_a2a_inbound_allowlist matches the guard's own pattern and is not allowlisted. The guard won't catch it — .github/workflows/enterprise-docs-guard.yml greps docs/** CLAUDE.md plus SEAM_FILES='src/backend/main.py src/backend/services/entitlement_service.py'. a2a_gate.py is a new open-core seam the stale list doesn't cover.
This is exactly the #1461 class the guard's own header calls out: "a comment there is unguarded prose that can name the paid catalog just as a doc can." Fix is one comment edit (describe the mechanism, not the table) — and please add a2a_gate.py to SEAM_FILES so the next one is caught.
The docs/ side is clean.
Warnings
-
tasks/cancelreports success unconditionally.terminate_execution_on_agent(...)returns aboolthat's discarded; the handler always returns"canceled". Nostatus=CANCELLEDwrite, no slot release, no CAS precondition (contrast #1082 status-as-projection, andchat.py:2408's dedicateddb.cancel_queued_executionbranch). Cancelling a queued execution → registry 404s → helper returns True → client told "canceled" → the task drains minutes later, runs, bills, and performs side effects the caller believed cancelled. -
SSE disconnect wedges the
messageIdfor 24h._gen()wraps_run_a2a_taskinexcept Exception, but a client disconnect raisesasyncio.CancelledError— aBaseExceptionsince 3.8, so uncaught. Neithercomplete()norfail()runs → row staysin_flight→ every retry with thatmessageIdgets-32603 "already in progress"for 24h. -
Allow-list identity contract mismatch.
caller_identity = getattr(current_user, "email", None) or current_user.username— always email/username. ButA2aPanel.vueplaceholders"did:example, https://caller/…, or key id"and the guide says "a caller URL, client id, or key id". An operator entering a DID makes the list non-empty → every real caller (an email) is denied, or they think they've restricted access when they haven't. -
The access gate has zero coverage.
test_157_a2a_inbound_server.pydefinesstate["access"] = {"bot"}but no test ever varies it — the exposed-but-inaccessible → uniform 404 branch is never exercised. -
Allow-list fails open; docs present it as a hard boundary.
check_inbound_allowedreturnsTrueon provider exception (deliberate, documented in code) — but the guide says "only listed identities are accepted; everyone else gets 403" with no mention that a policy-backend outage admits every owner/shared caller.
Suggestions
await request.json()is uncapped (contrast #1083's callback and #1424's webhook, which caps before parsing). Mitigated by nginxclient_max_body_size 25m, but not if:8000is reachable directly.message/streamreplay returns JSON, not SSE — a streaming SDK breaks on replay._a2a_state_formaps everything non-success→failed, so a CANCELLED execution reportsfailed, notcanceled.architecture.mdnot updated: line 784 still says "A2A v1.0" (now0.3.0); theagent_ownershipblock listsmcp_exposedbut nota2a_exposed; the two new public routes,services/a2a_gate.py, andtriggered_by="a2a"are undocumented. Onlydocs/user-docs/was touched.
Merge ordering
Companion enterprise PR abilityai/trinity-enterprise#161 imports from services import a2a_gate and calls get_a2a_exposed/set_a2a_exposed — none of which exist in OSS until this PR lands. I verified the API contracts match exactly on both sides, so it's purely sequencing: #1628 → dev first, then #161, and any submodule-pointer bump must land on a branch that already contains this PR.
…ic card, degeneric the seam Resolves the three criticals and the warnings from the #1628 review. Criticals: - messageId dedup was scoped per agent only, but messageId is a peer-controlled field that SDKs auto-generate and the spec only requires to be unique per client. Two callers colliding on "req-1" meant caller B received caller A's stored snapshot — the agent's full response text — and B's own task silently never ran. The scope now carries the caller principal, preferring mcp_key_id so agent-scoped keys resolving to one owner stay distinct. - The unauthenticated well-known card route had no throttle, while each hit costs a DB read, a live Docker API call, and an HTTP call into the agent container. Since the URL is published by design, one address could stall the event loop and hammer the fleet. Adds a per-IP limit ahead of all that work, matching public.py/files.py/webhooks.py. - services/a2a_gate.py named a private enterprise table in a comment. The comment now describes the mechanism, and the file joins SEAM_FILES so the guard covers this new seam (the #1461 class it was blind to). Warnings: - tasks/cancel discarded the bool from terminate_execution_on_agent and always reported "canceled". A queued task would drain later, run, and bill against a caller who believed it cancelled. Queued rows now cancel through the backlog CAS, terminal rows return TaskNotCancelable, and a failed terminate is reported honestly. - An SSE client disconnect raises CancelledError, a BaseException that the generator's `except Exception` never caught, so the row stayed in_flight and wedged the messageId for the full 24h TTL. - The allow-list UI and guide advertised DIDs and caller URLs while the code compares email-or-username, so a DID-only list denies everyone. Both now say email, and the guide states the gate fails open rather than presenting it as a hard boundary. - The access gate had no coverage; adds cases for exposed-but- inaccessible, 404 indistinguishability, and the admit path. Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC body is capped before parsing, stream replay returns SSE instead of JSON, and architecture.md records the new routes, the a2a_exposed column, the 0.3.0 protocol version, and the seam service. Every new test was verified to fail against the unfixed code. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Re-validated via
The three criticals are resolved — verified live, not just by testsThe backend bind-mounts 1. Cross-caller disclosure via 2. Unauthenticated well-known route had no rate limit — added per-IP (60/min), placed ahead of the DB/Docker/agent work, trusted-proxy aware via 3. Seam file named a private table — comment now describes the mechanism, and Warnings 4–8 addressed: Other live checks: non-exposed and nonexistent agents both return a uniform 404; JSON-RPC without auth is fail-closed 401; the card serves Validation findings
❌ Requirements not updated for a new capability
|
…t stale claims Closes the documentation gaps from the /validate-pr re-review. Requirements (requirements/mcp.md): the inbound server is a new capability — two public routes, a new column, an exposure flag, the allow-list seam, MCP control tools — but §32 covered only the #737 authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound server, ent#157) and §32.3 (control over MCP, ent#160), each recording the decisions a future reader would otherwise have to reverse-engineer: why the dedup scope carries the caller, why the public route is limited before its own work, why cancel reports what happened, and that the allow-list fails open. Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it. A vertical slice across router, seam, MCP tools, Vue panel, and a private module is the shape feature-flows exists for (cf. mcp-connector.md). Stale claims — two user-facing docs asserted the opposite of what this PR ships, and docs/user-docs/** auto-publishes to a public search index: - faq/collaboration.md said "There is no public unauthenticated /.well-known route yet" - faq/advanced-features.md said "the public /.well-known/agent-card.json route is not served yet" Both now describe exposure, the two public routes, and that tasking still requires a key. "A2A v1.0" is corrected to 0.3.0 in eight places, including four in a2a_card_service.py — the file that emits "0.3.0". The router docstrings still described a single-endpoint Phase 1 surface and called public serving a follow-up needing "a separate access-policy decision"; that decision is the per-agent exposure flag, and it shipped here. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Requested changes applied in
One finding I missed in the review above: Also refreshed the router + card-service docstrings, which still described a single-endpoint Phase 1 surface and called public serving a follow-up needing "a separate access-policy decision" — that decision is the per-agent exposure flag, and it shipped in this PR. Verification: 44 tests still green; The blocking documentation item is cleared from my side. The code findings were resolved in Worth an independent pass before merge — two of the three criticals here were fixed and then validated by the same author, and the two false-doc findings show that a fresh reader looking at a different corner keeps finding things. |
…dex row Two nits caught re-validating my own commit: - §32.1 used "✅ Shipped", a label the legend doesn't define. The four canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented / ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped" appeared exactly once, introduced by me. (#737 is closed, so ✅ itself is right.) - The requirements index row for mcp.md still said "A2A discoverability", which no longer routes a reader to the inbound server now living in the same area file. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Re-validated via
Status
Found in this pass (both mine, both fixed in
|
|
Resolve by running |
Unblocks the PR (#1628 was CONFLICTING) and re-bases the A2A work on current dev before the skills-exposure feature lands on top. Sole conflict: `tests/registry.json` — both sides appended entries at the same array position. Resolved as a union (the a2a entry plus dev's #1600/#1649/#1615/ ssh entries); verified programmatically that the merged set is exactly |HEAD ∪ dev| = 82 with nothing dropped and no duplicates.
Two additions on this branch1. Merge conflict resolved ( Also worth noting for whoever re-reviews: @vybe's 2. Configurable exposed skills (trinity-enterprise#180) — An exposed agent's card advertises every It extends this PR's own seam rather than adding a module: It is a disclosure control, and every layer says so. Requirements §32.4 landed before the code; public docs describe the seam mechanism only, per the standing enterprise-docs rule. 17 new OSS tests (110 green across the A2A suites), 6 enterprise tests (module suite 24). Worth a look in review: writing the tests surfaced that a malformed provider return would have failed closed and invisibly — a |
ef6b8b8 to
05fa025
Compare
…ic card, degeneric the seam Resolves the three criticals and the warnings from the #1628 review. Criticals: - messageId dedup was scoped per agent only, but messageId is a peer-controlled field that SDKs auto-generate and the spec only requires to be unique per client. Two callers colliding on "req-1" meant caller B received caller A's stored snapshot — the agent's full response text — and B's own task silently never ran. The scope now carries the caller principal, preferring mcp_key_id so agent-scoped keys resolving to one owner stay distinct. - The unauthenticated well-known card route had no throttle, while each hit costs a DB read, a live Docker API call, and an HTTP call into the agent container. Since the URL is published by design, one address could stall the event loop and hammer the fleet. Adds a per-IP limit ahead of all that work, matching public.py/files.py/webhooks.py. - services/a2a_gate.py named a private enterprise table in a comment. The comment now describes the mechanism, and the file joins SEAM_FILES so the guard covers this new seam (the #1461 class it was blind to). Warnings: - tasks/cancel discarded the bool from terminate_execution_on_agent and always reported "canceled". A queued task would drain later, run, and bill against a caller who believed it cancelled. Queued rows now cancel through the backlog CAS, terminal rows return TaskNotCancelable, and a failed terminate is reported honestly. - An SSE client disconnect raises CancelledError, a BaseException that the generator's `except Exception` never caught, so the row stayed in_flight and wedged the messageId for the full 24h TTL. - The allow-list UI and guide advertised DIDs and caller URLs while the code compares email-or-username, so a DID-only list denies everyone. Both now say email, and the guide states the gate fails open rather than presenting it as a hard boundary. - The access gate had no coverage; adds cases for exposed-but- inaccessible, 404 indistinguishability, and the admit path. Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC body is capped before parsing, stream replay returns SSE instead of JSON, and architecture.md records the new routes, the a2a_exposed column, the 0.3.0 protocol version, and the seam service. Every new test was verified to fail against the unfixed code. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t stale claims Closes the documentation gaps from the /validate-pr re-review. Requirements (requirements/mcp.md): the inbound server is a new capability — two public routes, a new column, an exposure flag, the allow-list seam, MCP control tools — but §32 covered only the #737 authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound server, ent#157) and §32.3 (control over MCP, ent#160), each recording the decisions a future reader would otherwise have to reverse-engineer: why the dedup scope carries the caller, why the public route is limited before its own work, why cancel reports what happened, and that the allow-list fails open. Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it. A vertical slice across router, seam, MCP tools, Vue panel, and a private module is the shape feature-flows exists for (cf. mcp-connector.md). Stale claims — two user-facing docs asserted the opposite of what this PR ships, and docs/user-docs/** auto-publishes to a public search index: - faq/collaboration.md said "There is no public unauthenticated /.well-known route yet" - faq/advanced-features.md said "the public /.well-known/agent-card.json route is not served yet" Both now describe exposure, the two public routes, and that tasking still requires a key. "A2A v1.0" is corrected to 0.3.0 in eight places, including four in a2a_card_service.py — the file that emits "0.3.0". The router docstrings still described a single-endpoint Phase 1 surface and called public serving a follow-up needing "a separate access-policy decision"; that decision is the per-agent exposure flag, and it shipped here. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex row Two nits caught re-validating my own commit: - §32.1 used "✅ Shipped", a label the legend doesn't define. The four canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented / ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped" appeared exactly once, introduced by me. (#737 is closed, so ✅ itself is right.) - The requirements index row for mcp.md still said "A2A discoverability", which no longer routes a reader to the inbound server now living in the same area file. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…k endpoint (ent#157)
Make an exposed Trinity agent reachable + taskable over the open A2A protocol by
an external orchestrator (Google ADK, LangChain, Bedrock, another Trinity).
OSS `a2a_exposed` primitive (edition-agnostic, default OFF):
- agent_ownership.a2a_exposed column (schema.py + tables.py + two-track
migration: db/migrations.py + Alembic 0024) + A2AExposureMixin accessors +
surfacing on GET /api/agents (mirrors mcp_exposed). OSS owns the column + the
read/enforcement; the WRITE is entitlement-gated by the enterprise A2A setter
(core-primitive + enterprise-knob, like users.suspended_at).
Public A2A server (routers/a2a.py, new a2a_server_router mounted in main.py):
- GET /a2a/{name}/.well-known/agent-card.json — unauthenticated discovery;
uniform 404 for non-exposed/non-existent (no enumeration oracle; safe by
default since the flag can't be set without the entitled setter).
- POST /a2a/{name} — JSON-RPC 2.0: message/send (sync bridge to
execute_task(triggered_by="a2a")), message/stream (SSE), tasks/get,
tasks/cancel; proper JSON-RPC error codes (-32700/-32600/-32601/-32602) +
A2A task-not-found (-32001). Bearer = a Trinity MCP key, validated per call by
get_current_user (fail-closed 401). Owner/shared access + per-agent inbound
allow-list via the new services/a2a_gate seam (OSS no-op; enterprise provider,
fail-open). Trigger-boundary idempotency on messageId (Invariant #18) so
at-least-once re-delivery can't double-execute. Every task audit-logged
(source="a2a"). taskId == Trinity execution_id.
- Card honesty: protocolVersion pinned "0.3.0", url → the JSON-RPC endpoint
(not the old chat placeholder), preferredTransport=JSONRPC, Bearer scheme.
Phased (seam ready): tasks/resubscribe → -32004; message/stream is
non-incremental (the agent turn is atomic) but spec-shaped so a streaming client
attaches and receives the terminal task.
Tests: tests/unit/test_157_a2a_inbound_server.py (16) — card honesty, well-known
gating, JSON-RPC dispatch + error codes, message/send bridge (triggered_by=a2a),
tasks/get + tasks/cancel, idempotent replay, allow-list allow/deny + fail-open.
Schema + Alembic parity guards green.
Related to trinity-enterprise#157
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…160) The MCP surface for the A2A interoperability management plane — the third surface (Invariant #13) over the entitlement-gated enterprise backend (trinity-enterprise#160, /api/enterprise/a2a/*). Distinct from the runtime call_a2a_agent (#736). New src/mcp-server/src/tools/a2a.ts (7 tools): - get_agent_a2a_config, set_agent_a2a_exposure, get_agent_a2a_card (proxies the OSS #737 served-card endpoint), set_a2a_inbound_allowlist, register_a2a_endpoint, list_a2a_endpoints, remove_a2a_endpoint. - Honest gating: an unentitled 403 ("not licensed") / OSS-only 404 return a structured { not_entitled | not_found } — never a silent success. Mutations are owner/admin + human-only, enforced at the backend (agent-scoped key → 403 human_only). Outbound credentials are write-only — the backend returns only has_credentials, so no tool echoes a secret. client.ts: 8 A2A methods (getA2AExposedMap swallows OSS-404/unentitled-403 → {}). agents.ts: list_agents/get_agent best-effort merge a2a_exposed (mirrors mcp_exposed, #846) — omitted in editions without A2A, no OSS↔enterprise coupling. server.ts: register the tool group (connector-denied visibility, like the rest). Tests: src/mcp-server/src/tools/a2a.test.ts (10) — proxy contract, credentials never echoed, entitlement/human-only/404 gating. Full suite 100 pass; tsc clean. Related to #160 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…/api/agents (ent#157) With ent#157 surfacing agent_ownership.a2a_exposed natively on GET /api/agents, list_agents/get_agent already carry the field. Remove the belt-and-suspenders enrichA2AExposed helper + getA2AExposedMap client method (both read the same OSS column). Fewer round-trips, no behaviour change. MCP suite 100 pass, tsc clean.
The card generator now pins protocolVersion "0.3.0", points url at the real
JSON-RPC endpoint (/a2a/{name}) with a documentationUrl, and declares
preferredTransport=JSONRPC. Update the two #737 shape assertions
(test_card_full_template_shape, test_card_label_fallback_shape) to match —
fixes the regression-diff CI failure.
…uth, soft-delete guard Broaden the A2A inbound-server suite (16→28) and add the OSS exposure-mixin DB suite (11), plus registry entries: - message/stream SSE (working → final completed task) - in-flight messageId idempotency → retryable -32603 - tasks/get state mapping across all Trinity statuses; multipart concat; empty-text invalid - exposed-but-stopped agent still serves the card (label fallback) - unauthenticated JSON-RPC POST → 401 (fail-closed, before dispatch) - A2AExposureMixin: default/set/get, per-agent isolation, missing-agent, and the deleted_at guard (soft-deleted agent can never be flipped/read exposed) Backend a2a+adjacent 142 pass, enterprise 21, MCP 100.
…ot a dict (live-caught) Live end-to-end testing surfaced a 500 on tasks/get: db.get_execution returns a ScheduleExecution MODEL, but the handler used row.get(...) (dict API) → AttributeError. Add _exec_field(row, name) that works for both a model (attribute) and a dict, and use it in tasks/get + tasks/cancel. Regression test returns an object-shaped row so CI catches this shape mismatch. Verified live against a running instance: expose → well-known 200 (honest card), message/send → real agent execution (state=completed), tasks/get → 200, messageId idempotency replay (no re-exec), triggered_by="a2a" on the row, allow-list enforcement → 403, credentials never echoed, unexpose → 404.
…ist, endpoints (ent#158) Adds an owner-only "A2A" tab on Agent Detail (components/A2aPanel.vue), gated on the enterprise `a2a` entitlement (sessionsStore.a2aAvailable ← enterprise_features) so it never renders in OSS/unentitled builds — no blank tab. Enterprise Vue ships in the OSS bundle, gated by the feature flag (standard open-core seam). The panel: - Expose-over-A2A toggle (default OFF) → the enterprise setter that write-throughs to the OSS a2a_exposed column; not-exposed shows an explainer + CTA (no dead state). - One-click-copy public Agent Card URL (the ent#157 well-known discovery route). - Advertised skills (read-only, from the #737 served card). - Inbound allow-list add/remove. - Outbound endpoint registry add/remove; credentials are write-only (password input, never echoed back — the API returns has_credentials only). Store methods in stores/agents.js (getA2aConfig/setA2aExposure/updateA2aAllowlist/ registerA2aEndpoint/removeA2aEndpoint/getA2aCard); feature flag in stores/sessions.js; tab wired through OverflowTabs so overflow + ?tab= deep-linking keep working. No new backend endpoints — proxies the ent#157/#160 surface (all live-verified). Related to trinity-enterprise#158 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (ent#157) The A2A public routes (well-known Agent Card + JSON-RPC/SSE task endpoint) live on the backend under /a2a/, but neither the prod nginx nor the vite dev proxy forwarded /a2a/ — so hitting the card URL on the frontend origin fell through try_files → index.html (prod) / the SPA (dev) and redirected. External A2A orchestrators reach Trinity through this front door, not :8000 directly, so the card was unreachable. - nginx.conf: add `location /a2a/` proxying to trinity-backend:8000 (SSE-friendly: proxy_buffering off, mirrors /api/). - vite.config.js: add `/a2a/` to the dev proxy. changeOrigin:false preserves the browser Host so the backend renders the card's `url`/documentationUrl against the real front-door origin (dev has no PUBLIC_CHAT_URL; prod nginx uses `Host $host`). Verified live on :8001: card served (200 JSON, no redirect) with a self-consistent url (http://localhost:8001/a2a/new_cool_agent).
…enshots (ent#157/#158/#160) Rewrite the stale #737 A2A doc into a full how-to for the shipped feature: - Enable exposure via the new A2A tab (with screenshots) or the MCP tools. - Consume an exposed agent as an external orchestrator: discover the well-known card → authenticate with a Trinity MCP key → task over JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel), using the real live-verified curl flow. - Inbound allow-list, outbound endpoint registry, security/behavior notes, and a reference (public routes, JSON-RPC methods, error codes). Screenshots (headless-captured against the live UI): the full A2A config panel and the Agent Card URL section. Describes only the generic A2A capability + entitlement seam — no enterprise schema/module internals (enterprise-docs guard clean). Related to trinity-enterprise#158 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ic card, degeneric the seam Resolves the three criticals and the warnings from the #1628 review. Criticals: - messageId dedup was scoped per agent only, but messageId is a peer-controlled field that SDKs auto-generate and the spec only requires to be unique per client. Two callers colliding on "req-1" meant caller B received caller A's stored snapshot — the agent's full response text — and B's own task silently never ran. The scope now carries the caller principal, preferring mcp_key_id so agent-scoped keys resolving to one owner stay distinct. - The unauthenticated well-known card route had no throttle, while each hit costs a DB read, a live Docker API call, and an HTTP call into the agent container. Since the URL is published by design, one address could stall the event loop and hammer the fleet. Adds a per-IP limit ahead of all that work, matching public.py/files.py/webhooks.py. - services/a2a_gate.py named a private enterprise table in a comment. The comment now describes the mechanism, and the file joins SEAM_FILES so the guard covers this new seam (the #1461 class it was blind to). Warnings: - tasks/cancel discarded the bool from terminate_execution_on_agent and always reported "canceled". A queued task would drain later, run, and bill against a caller who believed it cancelled. Queued rows now cancel through the backlog CAS, terminal rows return TaskNotCancelable, and a failed terminate is reported honestly. - An SSE client disconnect raises CancelledError, a BaseException that the generator's `except Exception` never caught, so the row stayed in_flight and wedged the messageId for the full 24h TTL. - The allow-list UI and guide advertised DIDs and caller URLs while the code compares email-or-username, so a DID-only list denies everyone. Both now say email, and the guide states the gate fails open rather than presenting it as a hard boundary. - The access gate had no coverage; adds cases for exposed-but- inaccessible, 404 indistinguishability, and the admit path. Also: cancelled now maps to `canceled` rather than `failed`, the JSON-RPC body is capped before parsing, stream replay returns SSE instead of JSON, and architecture.md records the new routes, the a2a_exposed column, the 0.3.0 protocol version, and the seam service. Every new test was verified to fail against the unfixed code. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t stale claims Closes the documentation gaps from the /validate-pr re-review. Requirements (requirements/mcp.md): the inbound server is a new capability — two public routes, a new column, an exposure flag, the allow-list seam, MCP control tools — but §32 covered only the #737 authenticated card, explicitly scoped "Phase 1". Adds §32.2 (inbound server, ent#157) and §32.3 (control over MCP, ent#160), each recording the decisions a future reader would otherwise have to reverse-engineer: why the dedup scope carries the caller, why the public route is limited before its own work, why cancel reports what happened, and that the allow-list fails open. Feature flow: adds feature-flows/a2a-inbound-server.md and indexes it. A vertical slice across router, seam, MCP tools, Vue panel, and a private module is the shape feature-flows exists for (cf. mcp-connector.md). Stale claims — two user-facing docs asserted the opposite of what this PR ships, and docs/user-docs/** auto-publishes to a public search index: - faq/collaboration.md said "There is no public unauthenticated /.well-known route yet" - faq/advanced-features.md said "the public /.well-known/agent-card.json route is not served yet" Both now describe exposure, the two public routes, and that tasking still requires a key. "A2A v1.0" is corrected to 0.3.0 in eight places, including four in a2a_card_service.py — the file that emits "0.3.0". The router docstrings still described a single-endpoint Phase 1 surface and called public serving a follow-up needing "a separate access-policy decision"; that decision is the per-agent exposure flag, and it shipped here. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dex row Two nits caught re-validating my own commit: - §32.1 used "✅ Shipped", a label the legend doesn't define. The four canonical labels are ⏳ Not Started / 🚧 In Progress / ✅ Implemented / ❌ Removed, and "Implemented" is used 179 times elsewhere; "Shipped" appeared exactly once, introduced by me. (#737 is closed, so ✅ itself is right.) - The requirements index row for mcp.md still said "A2A discoverability", which no longer routes a reader to the inbound server now living in the same area file. Related to #1628 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ent#180)
An exposed agent's card advertises every `template.yaml capabilities[]` tag, and
the well-known discovery route is unauthenticated by design — so today that full
capability list is world-readable for any exposed agent. This lets an operator
choose what the outside is told.
**A disclosure control, and the code says so.** The card's `skills[]` is
advertisement: inbound `message/send` dispatches free-form text via
`execute_task(triggered_by="a2a")`, with no per-skill routing. Filtering changes
what an orchestrator SEES, never what it may ASK for. That's stated in the
requirement, the seam docstring and the filter itself, because a filter
operators mistake for an invocation gate is a control that looks like security
and isn't. A real boundary (constraining a2a-triggered runs via
allowed_tools/guardrails) is separate work with its own threat model.
Extends the existing ent#157 seam rather than adding a module — same shape as
the inbound allow-list, second provider:
provider.exposed_skills(agent_name) -> Optional[List[str]]
- No provider (OSS) → identity function; the card is byte-identical to before,
by construction. The enterprise module owns the config, storage and UI.
- `None` = no opinion = advertise all: the unconfigured default, so exposure
(already opt-in, default OFF) keeps every existing card unchanged on upgrade.
- `[]` ≠ `None`: an explicit "advertise nothing".
- Stale ids are inert — the selection only subtracts; `template.yaml` stays the
source of truth for what exists.
- Fail-open on provider error (advertise all + WARNING): consistent with the
seam's availability bias and the advertise-all default. Honest only *because*
this isn't a security boundary — failing closed would silently empty a card
and break discovery invisibly.
Both card surfaces (public well-known + authenticated per-agent) go through one
router helper, so they can't disagree and a future third surface gets the filter
by default rather than by remembering. `generate_a2a_card` stays pure — the
provider lookup lives in the helper.
Writing the tests found a real gap: a provider returning a str (a defect) would
iterate into single characters, match no id, and silently empty the card —
fail-CLOSED, the opposite of the contract, and invisible. Malformed returns now
take the same fail-open path as a raised error.
Requirements §32.4 written before the code (CLAUDE.md rule #1); public docs
describe the generic seam only, per the standing enterprise-docs rule.
Related to trinity-enterprise#180
The UI half. The panel showed the advertised skills read-only; it now lets an
owner choose them (the panel is already entitlement-gated as a whole, so no
extra gating here).
- Checkbox list over the agent's FULL capability set with the current selection
applied; Save / Cancel / "Reset to all".
- The selectable list comes from `/api/agents/{name}/info`, NOT the card: the
card now returns only the curated subset (that's the point), so it can no
longer tell the UI what is available to pick.
- Ticking everything stores `null`, not the full list — "no opinion" keeps the
agent advertising whatever its template declares as the template evolves,
instead of freezing today's tags into a selection that silently goes stale on
the next repull. Un-ticking everything stores `[]` (advertise nothing), which
the read view names explicitly so it can't be confused with "no capabilities".
- The copy says what the control does: hiding a skill stops it being
*advertised*, not asked for.
Verified by compiling the SFC (script + template) and asserting every new
binding is exposed. `npm run build` fails on this machine for an unrelated,
pre-existing reason — `mermaid` is declared in package.json but not installed
locally, and AgentWorkspace.vue fails to resolve it identically with my changes
stashed.
Public feature-flow documents the seam mechanism only, per the standing
enterprise-docs rule.
Related to trinity-enterprise#180
…ead (ent#157) The rebase surfaced a collision the SQLite track absorbed silently and the PostgreSQL track could not: #1666 landed `0024_agent_ownership_volume_base_name` on dev while this branch carried `0024_agent_ownership_a2a_exposed`, both chained to `0023`. Two revisions, one parent — a branched history, so `alembic upgrade head` has multiple heads and the pg-migrations job fails at the real PG boot path. The two tracks fail differently, which is worth remembering: `db/migrations.py` is a LIST, so its conflict was a union and both entries just run. Alembic is a LINKED LIST, so the same "both sides added a migration" merges cleanly at the text level and breaks at runtime. A green SQLite parity check says nothing about the chain. Renamed to `0025_agent_ownership_a2a_exposed` with `down_revision = 0024_agent_ownership_volume_base_name`. Verified by walking the graph: 26 revisions, exactly one head, no parent with more than one child, no dangling down_revision, and an unbroken chain head→`0001_baseline`. Related to trinity-enterprise#157
a3118dc to
6e150b3
Compare


Summary
The complete public surface for A2A (agent-to-agent) protocol interoperability — one feature, one PR. Two halves:
The entitlement-gated enterprise backend ships in a separate repo (the private submodule): trinity-enterprise#161. It's the writer/management half; this PR is the reader/server + MCP half. (The submodule is a distinct git repo, so it can't share a PR with the public code.)
A2A inbound server (ent#157)
OSS
a2a_exposedprimitive (edition-agnostic, default OFF — safe by default):agent_ownership.a2a_exposedcolumn (schema.py + tables.py + two-track migration:db/migrations.py+ Alembic0024) +A2AExposureMixinaccessors + surfaced onGET /api/agents(mirrorsmcp_exposed). OSS owns the column + read/enforcement; the write is entitlement-gated by the enterprise setter (core-primitive + enterprise-knob, likeusers.suspended_at).Public server (
routers/a2a.py→ newa2a_server_routerinmain.py):GET /a2a/{name}/.well-known/agent-card.json— unauthenticated discovery; uniform 404 for non-exposed/non-existent (no enumeration oracle). Safe by default: the flag can't be set without the entitled setter → OSS-only builds always 404.POST /a2a/{name}— JSON-RPC 2.0:message/send(sync →execute_task(triggered_by="a2a")),message/stream(SSE),tasks/get,tasks/cancel. JSON-RPC error codes (-32700/-32600/-32601/-32602) + A2A task-not-found (-32001). Bearer = a Trinity MCP key, per-callget_current_user(fail-closed 401). Owner/shared + per-agent inbound allow-list via the newservices/a2a_gateseam (OSS no-op; enterprise provider; fail-open). Trigger-boundary idempotency onmessageId(Invariant Unified Executions Dashboard (EXEC-022) #18). Every task audit-logged (source="a2a").taskId == execution_id.protocolVersion"0.3.0",url→ the JSON-RPC endpoint,preferredTransport=JSONRPC, Bearer scheme.tasks/resubscribe(-32004), incremental token streaming.A2A control MCP tools (ent#160)
src/mcp-server/src/tools/a2a.ts— 7 tools:get_agent_a2a_config,set_agent_a2a_exposure,get_agent_a2a_card(proxies the OSS #737 served card),set_a2a_inbound_allowlist,register_a2a_endpoint,list_a2a_endpoints,remove_a2a_endpoint. Honest gating (unentitled-403 / OSS-404 → structurednot_entitled/not_found, never silent success); mutations owner/admin + human-only; outbound credentials write-only (never echoed).a2a_exposedflows natively onlist_agents/get_agentvia the OSS column (no extra fetch).Test Plan
tests/unit/test_157_a2a_inbound_server.py(16) +test_a2a_card_service.pyupdated for the honest card. Schema/Alembic parity guards green; pg-migrations, backend-boots-without-enterprise, non-root all green.src/mcp-server/src/tools/a2a.test.ts(10); full suite 100 pass,tscclean.Companion PR
trinity-enterprise#161 — the entitlement-gated
enterprise.backend.a2amodule: the exposure setter (write-throughs to this OSS column — without it the flag stays OFF and every public route 404s, the intended safe default), curated-skills + inbound allow-list + outbound endpoint registry (AES-256-GCM creds), and the allow-list provider it plugs intoa2a_gate.Related to trinity-enterprise#157, trinity-enterprise#160
🤖 Generated with Claude Code
Update — A2A config UI (ent#158) added. An owner-only A2A tab on Agent Detail (
components/A2aPanel.vue), gated on thea2aentitlement (never a blank tab in OSS): exposure toggle (→ the OSSa2a_exposedwrite-through), one-click-copy Agent Card URL, advertised skills (read-only), inbound allow-list add/remove, outbound endpoint registry (credentials write-only, never echoed). No new backend endpoints — proxies the surface above. This PR now covers the full public feature: inbound server (#157) + control MCP tools (#160) + config UI (#158).