Repository navigation
feat(a2a): AAuth agent identity across instances, behind an off-by-default flag (abilityai/trinity-enterprise#623) - #2956
Conversation
…fault flag (Abilityai/trinity-enterprise#623) An agent on one Trinity calls an agent on another and is authenticated as ITSELF — by its own key under AAuth's agent-identity-only mode — with no bearer secret of either instance configured on the other. Prototype for the 2026-09-18 call with the protocol's author; `aauth_prototype_enabled` is OFF by default and flag-off behaviour is unchanged. Each instance is its own Agent Provider (AAuth self-hosted bootstrap): it publishes `/.well-known/aauth-{agent,jwks,resource}.json`, self-issues `aa-agent+jwt` tokens binding a per-agent key via `cnf.jwk`, and signs requests per RFC 9421 with the key conveyed by `Signature-Key: sig=jwt;jwt="…"`. Inbound (`POST /a2a/{name}`): a request carrying `Signature-Key` while AAuth is live takes a separate entry point, so the bearer dependency — which 401s a request with no token before any handler runs — is not made conditional and the bearer path stays byte-identical. Verification runs before the body is parsed as JSON-RPC, cheapest-and-local first: header shape → token claims → exposure + trusted-issuer pre-gate → issuer metadata/JWKS (pinned public-HTTPS egress, same-origin `jwks_uri`, one 8 s deadline) → token signature → HTTP signature (which covers the Content-Digest HEADER) → body digest → replay claim. Only then is the body read. An AAuth caller never becomes a Trinity `User`: access is exposure plus an EXPLICIT allow-list entry, fail-closed, and no owner role travels with it. Refusals answer RFC 9457 problem details with `Signature-Error`; a verified-but-unlisted caller gets 403. Two properties are load-bearing and easy to lose: * B never fetches metadata for an issuer host no operator listed, so an unauthenticated caller cannot use this backend as a fetcher; and * a refusal is only auditable once the token's own signature verifies — before that `sub` is a string the caller typed, and attributing an audit row to it would hand any stranger the audit table. Outbound: a registered endpoint may be marked `auth_scheme: aauth` (no credential — switching to it clears one, non-default ports refused). Only the agent's OWN key may place such a call: `AuthorizedAgentByName` also admits the owner, shared users and admins, and for AAuth that would let a human make the backend sign as the agent. The request is signed over the exact bytes sent, to the registered host rather than the pinned IP. Also here: the served card declares `aauth` AFTER `bearerAuth` (an AAuth-unaware client that takes the first entry is unaffected); a new `external_agent` audit actor type, so a remote caller is not recorded as the platform itself; and a path-relative card fetch for aauth endpoints, because a Trinity peer serves its card under `/a2a/{name}/` while the client only ever looked at the origin root — which is why Trinity-to-Trinity A2A did not work at all. The general fix for that wants its own public issue. Verified live between two independent instances (the second in its own Docker daemon — see the learnings entry on why a shared daemon eats the first one's agent volumes): `whoami.aauth.dev`, a verifier that is not ours, echoes the calling agent's identity; the remote task runs; an unlisted identity is refused 403; a tampered body is refused 401; a bearer call to the same endpoint still works. A request signed by the JS reference `@hellocoop/httpsig` verifies here (checked-in vector), and ours verifies there. Stated prototype limits: one software-held instance key (AES-256-GCM at rest, no rotation), the backend signs on the agent's behalf, identity is keyed by agent name, `message/stream` is refused for AAuth callers, and inbound AAuth is inert without the private allow-list module. Requirement: docs/memory/requirements/mcp.md §32.6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…trinity-enterprise#623) The end-to-end flow, written to be read by someone who did not build it: roles, layers, public surface, config state, and the four flows (enablement, outbound sign, inbound verify-and-decide, served card) with the ordering constraints spelled out — why the trusted-issuer pre-gate runs before any network I/O, why a refusal only becomes auditable once the token verifies, and why the body is read after the signature that covers its digest rather than before. Also states the trust model plainly: what the callee actually verifies (key possession + this exact request), what it does not (who runs the peer), that the unit of trust is the peer instance rather than one agent, and that authorization stays coarse — an allow-list entry means the peer may ask this agent for anything it can do. Closes with the prototype's limits and the diagrams worth drawing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
✅ Nightly unit-suite clean when this PR is merged into |
|
Resolve by merging |
…orable secret value is a named 422 (#3325) (#3417) Fixes #3325 — independent fix, one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. Base is `dev`. **Draft until the operator merges.** ## What - `src/backend/services/credential_encryption.py::decrypt`: every malformed envelope shape raises `ValueError`, as the function promises — JSON that is not an object (`[]`, `null`), and a non-string nonce or ciphertext. Importing such a `.credentials.enc` now returns **400** instead of a 500 with a stack trace in the log. The envelope format and every successful decrypt are unchanged. - `src/backend/services/secret_settings.py::encrypt_secret_setting`: the key is checked first, in its own `try`, and only that check can raise `MissingEncryptionKeyError`. A value that cannot be encoded (for example a lone surrogate) raises the new `SecretSettingValueError(ValueError)`. Its message names the setting, never the value. - `src/backend/error_handlers.py` + `src/backend/main.py`: one app-level handler maps `SecretSettingValueError` to **422**. `src/backend/routers/settings/credentials.py`: three pass-through lines in the routes whose catch-all would have turned it into a 500 (Anthropic, GitHub PAT, `PUT /slack`). - Tests: the three strict-xfail markers in `test_ec_credential_crypto_edges.py` are removed; new `tests/unit/test_3325_credential_error_types.py` (registered in `tests/registry.json`) asserts no value in the message or the exception chain, a missing key reported before a bad value, `PUT /api/settings/api-keys/anthropic` → 422, and that `main.py` registers the handler. ## Rulings carried (orchestrator, on the operator's behalf — plan file) - TD-1: one app-level handler plus the three pass-through lines — as recommended. - TD-2: a `.credentials.enc` that decrypts correctly but whose contents are not an object is deferred — as recommended; forging one needs the platform key. - TD-3: 422, matching the existing 422 for refused cleartext writes — as recommended. ## Review + security `/review` reading pass (claude-fable-5-1, report-only): **MERGEABLE**, no critical findings. The secret cannot leave through the new error: it is raised after the `except` block, so `__context__` is cleared, not merely hidden; the handler returns only the message; nothing logs it; audit rows are written only after a successful write. The only residual carrier is traceback frame locals, which nothing in the backend renders. The handler is keyed on the subclass, so no other `ValueError` is swallowed or relabelled. `/cso --diff`: no findings. Evidence sweep (claude-opus-5-5): **GREEN**. 23 unit files import the touched modules; the 16 most direct ran shuffled (seed 12345), one file per process, all passed. All 7 tests in the new file ran, 0 skipped. With both service files taken from `dev`, exactly the three formerly-xfail tests fail (8 cases). Seven less direct files are left to CI and named in the review file. `git merge-tree` against `dev` f6bedcd is clean. ## Tests Targeted counts above. Not run here: the full unit island (CI). ## Before merge - Startup auto-import (`lifecycle.py`) on a malformed envelope now lands in its `ValueError` retry arm: it retries without a warning per attempt and ends in the same "failed" result, with the existing error log after the last attempt. - Enterprise callers of `decrypt` get `ValueError` where they got `AttributeError` / `TypeError` on a malformed envelope. Two catch broadly, three propagate as before; no change needed there. - The new test file has a module-level `importorskip` for `sqlalchemy` and `cryptography`: it skips silently where those are missing. They are present on the engineer and in CI. - Ten open PRs share `tests/registry.json` or `src/backend/main.py` with this branch (#3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2713, #2709). A test-merge of this head against each adds no conflict beyond what that PR already has with `dev`. ## Handoffs (not in this diff) - TD-2 above: the decrypted-but-not-an-object case can still 500 on import. - `PUT /api/settings/slack` saves the client ID before a later secret write fails (partial update, pre-existing). - The GitHub PAT, Resend and Gemini routes are covered by the app-level handler but not exercised by the new route test. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…L is refused, not stored (#3323) (#3420) Fixes #3323 — independent fix, one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. Base is `dev`. **Draft until the operator merges.** ## What - `src/backend/utils/url_validation.py::reject_embedded_credentials`: strips its input and treats a leading `//` as already having a host, the same rule `strip_url_credentials` uses. `//<token>@github.com/o/r` is now refused like the `https://` form, including with leading whitespace or mixed case. All three write paths that store a skills-library URL go through this one function (`routers/skills.py` create and update, legacy adoption in `services/skill_service.py`), so the token is no longer saved in plaintext to `skill_sources.url` or the audit details. - If `urlparse` refuses an input that starts with `//`, the function falls back to the module's existing userinfo regex instead of raising, so this fix adds no new 500. - Tests: the D3 strict-xfail marker is removed; four more refused shapes and a four-case "must not raise / must not over-reach" test are added. ## Rulings carried (orchestrator, on the operator's behalf — plan file) - TD-1: the fallback applies only to inputs starting with `//`; the bare `ValueError` for every other malformed input is #3322's and is unchanged (D4 stays a strict xfail) — as recommended. - TD-2: keep the username/password check; `//@host` carries no secret and is not refused — as recommended. - TD-3, TD-4: deferred, see Handoffs — as recommended. ## Review + security `/review` reading pass (claude-fable-5-1, report-only): **MERGEABLE**, zero critical, five informational. The pre-fix and post-fix modules were executed side by side over forty inputs: nothing that was refused before now passes, credential-free inputs are unchanged, the fallback regex is anchored and linear. `/cso --diff`: no findings. Fixed after review: - `546f329a` — the `tests/registry.json` descriptions no longer call #3323 a strict xfail. - `a75a5e04` — the reject stub in `test_ent183_skill_packages.py` mirrors the fixed `//` rule. It is deliberately stricter than production on a malformed bracket (`//[oops/x` raises). Evidence sweep after the fix (claude-opus-5-5): **GREEN**. 28 unit files import the module; the 14 most direct ran shuffled (seed 12345), one file per process. Three did not come back clean, none because of this branch: - `test_ent236_skills_lifecycle.py::TestNonRepoDirectoryRecovers::test_real_repo_still_pulls` and `test_skill_service_user_agent.py` (collection error) fail the same way on `dev` f6bedcd in the engineer's environment. - `test_ent237_skill_sources.py` hit the 240 s per-file limit at about 72 % with no failure printed — left to CI. `git merge-tree` against `dev` f6bedcd is clean. ## Tests 895 passed across the 11 files that completed cleanly. Not run here: the 14 less direct importer files, `test_ent237_skill_sources.py` to completion, and the full unit island (CI). ## Before merge - Ten open PRs share `tests/registry.json` with this branch (#3417, #3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2709). A test-merge of this head against each adds no conflict beyond what that PR already has with `dev`. - One stale docstring remains at `tests/unit/test_ec_url_validation_properties.py:36` (still calls D3 a strict xfail). ## Handoffs (not in this diff) - TD-3 (P3): shapes that still pass both reject and strip — `/\t/tok@`, `/\n/tok@`, `///tok@`, `https:/tok@` (`urlparse` removes tab and newline after the check). Fixing it needs the same change in `strip_url_credentials` and cases in both test families. - TD-4: a property test that reject and strip always agree over the shared `test_2052` corpus; it needs exceptions for empty userinfo. - #3322 (bare `ValueError` / `UnicodeError` → 500) is the sibling issue in the same file and is untouched. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…t the database (#3385) (#3421) Fixes #3385 — one of ten small bug fixes in the trinity-pm `chain-easy-1008` run. **Stacked on #3414** (the fix for #3314): base is `feature/3314-queue-fingerprint-falsy`, because both issues edit `_clamp_ingested_item` and the fingerprint helpers. Merge #3414 first, then retarget this to `dev`. **Draft until the operator merges.** ## What - `src/backend/services/operator_queue_service.py`: new `OPERATOR_QUEUE_TYPE_MAX = 64` and `_bounded_type`. An agent-written `type` longer than 64 characters is shortened with the existing `…[truncated]` marker at ingest (`_clamp_ingested_item`); the ask is never refused or lost. `_comparable_type` applies the same bound, so a shortened row does not read as a rewrite (#2915) and a row stored before this fix at full length still compares equal. - `src/backend/db/operator_queue.py::_insert_values`: `_DB_BELT_TYPE_MAX_BYTES = 1024`; above it the insert raises `ValueError`, like the id / title / question / context belts. All three create paths go through it. - Docs: five lines in `architecture/api-endpoints.md`, `feature-flows/operating-room.md` and `requirements/security.md` that list the clamp and belt fields now name `type`. - Tests in `tests/unit/test_1632_operator_queue_caps.py`: an oversized type is shortened with the marker; every platform-emitted type and every type `ask_operator` accepts is unchanged; the belt rejects an oversized type and passes 64 four-byte characters; `test_3385_clamped_type_is_not_a_rewrite` (clamped row vs raw entry, legacy full-length row, a real rewrite inside the first 52 characters). ## Rulings carried (orchestrator, on the operator's behalf — plan file) - T1: a fixed constant, no environment variable — as recommended. - T2: 64 characters — as recommended. The longest type the platform emits is `workspace_problem_report`, 24. - T3: the DB belt raises, like its siblings — as recommended. - Orchestrator: stacked on #3314 instead of branching from `dev`, so the two fixes do not hand the operator a conflict in the same two functions. ## Review + security `/review` reading pass (claude-fable-5-1, report-only, against the stacked base): **MERGEABLE**, zero critical. Census of every literal `type` the platform and the enterprise submodule pass to the insert paths: longest is 24 characters, none is altered. Every truncated value ends in the marker, which no set member can match, so truncation cannot move a value into or out of a set that decides control flow (`_BUDGETED_ALERT_TYPES` and neighbours). The new `ValueError` is reachable only from the file sync loop, which quarantines it under its existing handler. `/cso --diff`: no findings; the error text carries the constant, never the value. Fixed after review: `eab91c10` — I1, the five doc lines. Noted, not changed: I2, a non-string `type` over 1 KiB now raises the named `ValueError` where the base failed at the bind — same quarantine outcome. Evidence sweep after the fix (claude-opus-5-5): **GREEN**. 13 operator-queue files ran shuffled (seed 12345), one file per process: 836 passed, 7 xfailed. With both production files taken from the #3314 branch, the two `TestClampType` cases, the belt test and the clamped-row fingerprint case go red. `git merge-tree` against `dev` f6bedcd is clean; the base branch has not moved (`923b1edc`). ## Tests Counts above. Not run here: `test_ent815_queue_walk`, `test_ent815_broad_list_agent_scope`, `test_ent751_gate_entries` (2–4 minutes each; they ran green on the #3314 base in that PR's sweep) and the full unit island. ## Before merge - **This PR's checks are weaker than a dev-based PR's:** a PR whose base is a feature branch runs a reduced CI set and `backend-unit-test` does not run. The first full run happens when it retargets to `dev` after #3414 merges — read that run before merging. - Accepted trade-off: an agent that rewrites an oversized type only after character 52 is not detected as a change. Titles and questions already behave this way. - Thirteen open PRs share a file with this branch (mostly `tests/registry.json` and `docs/memory/architecture/api-endpoints.md`; #3256 also `db/operator_queue.py` and the three docs). A test-merge of this head against each of #3420, #3417, #3412, #3411, #3407, #3304, #3271, #3256, #2984, #2956, #2713 and #2709 adds no conflict beyond what that PR already has with `dev`. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…prototype onto current dev (#3644)
…pricing reads after the dev merge (#3644) dev's inbound door now asks db.get_a2a_scope (trinity-enterprise#838) and, for a bearer-less caller, db.get_nevermined_config_with_key (ent#679), and it resolves its principal through get_user_or_anonymous — which calls get_current_user instead of depending on it, so the fixture's override has to sit on the optional dependency. 17 of 30 tests in this file failed on the merged tree for exactly those three reasons; no product code changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e dev merge (#3644) - integrations.py: the settings-change audit row carried `credential_cleared` twice after the dev merge; Python keeps the last key, so switching a bearer endpoint to `aauth` recorded `false` while the store dropped the secret. The ent#623 line stays; the stale dev line goes. Router-level test proves the row says `true` on the switch (red against the duplicate). - a2a.py: the three `request.url.path` reads become `request.scope["path"]` (#3102 fence); the signature base keeps `raw_path` first. - .gitleaksignore: fingerprint for the `private_key: Ed25519PrivateKey = field(repr=False)` dataclass field at keys.py:43 (generic-api-key false positive). The range scan re-flags the adding commit, so an inline `gitleaks:allow` on the head cannot clear it; the fingerprint can. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…on forwards the mapped scheme (#3644) - _route_census.py: the three ent#623 discovery documents (/.well-known/aauth-agent.json, -jwks.json, -resource.json) are OWN_AUTH — anonymous by design, public material only, uniform 404 while the prototype flag is off. The AAuth route class on POST /a2a/{name} is the same registered route the census already lists; nothing new to classify there. - nginx.conf: the /.well-known/aauth- location sent `X-Forwarded-Proto $scheme`, which is always http behind the TLS terminator (#3215). It now mirrors the /a2a/ block's headers (mapped $fwd_proto); the sibling count moves 3 → 4. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ettings are unreadable (#3644) (d) `test_3215_public_base_url` drives the real card route with a settings stand-in that knows only the public URL. The branch made dev's single card builder (ent#180) consult `aauth_config.active_issuer()`, whose flag read called `settings_service._resolve_bool_flag` unguarded, so the card 500ed and the four card/door tests that follow it failed. `is_enabled()` now treats an unreadable flag as OFF (AAuth inert, dev's bytes), the direction `get_issuer` already takes; regression test on the real card route. I1: two tests execute the merge-added ent#838 gate on the AAuth door — an internal-scope agent answers an outside AAuth caller with the keyed caller's uniform 404 (no fetch, no audit row), and admits a trusted one as its verified identity, never its address. I2: comment on `fetch_card_for` naming its overlap with ent#838's own-path-then-origin card order; code unchanged. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…it allowlists (#3644) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…range scan still sees (#3644) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
trinity-ability
left a comment
There was a problem hiding this comment.
/validate-pr — #2956 at 33a22bddb (chain-aauth-demo, trinity#3644)
Verdict: APPROVE. The code is ready to merge. This verdict covers the code only; the draft status and the gating decision (trinity-enterprise#558 task G) are the operator's call.
Lane B (by path: no file under docker/, auth, credentials or CI; no schema). 42 files, +4208/−28. Targets dev. Merges cleanly with the live dev tip, which is 25 commits ahead (git merge-tree clean).
Summary
| Category | Status | Notes |
|---|---|---|
| Commit messages | ✅ | conventional prefixes, and each one says why |
| Base / size | ✅ | dev; 42 files |
| Status auto-promotion | Refs abilityai/trinity-enterprise#623 with no closing keyword. Deliberate for a stacked prototype; trinity#3644 needs a manual status change at merge |
|
| Requirements / architecture | ✅ | requirements/mcp.md §32.8; api-endpoints.md and backend.md catalog rows |
| Feature flow | a2a-aauth-agent-identity.md is detailed but does not use the template sections (Overview / User Story / Error Handling / Testing). Index row is present |
|
| Security §4.1–4.10 | ✅ | no keys; emails are on the reserved .example domain; 93.184.216.34 is a fixture, 1.1.1.1 is a dev-script resolver, 100.70.1.2 is test CGNAT; no symlinks or host paths; enterprise-docs grep is clean; no new os.getenv |
| Infrastructure | ✅ | one location in src/frontend/nginx.conf (now forwards $fwd_proto, #3215); scripts/dev/aauth_prototype/* is dev-only |
| Build packaging | ✅ | services/aauth/ sits under services/, so no new top-level directory |
| Structural review | ✅ | merge-scoped /review plus an inline STRIDE pass (3644-aauth-refresh-review.md). The prototype had its own review on 2026-09-18 |
| Merge gate | ✅ | Tier 1 green (regression diff, pytest head, schema-parity, CodeQL ×2, verify-non-root, gitleaks); journey-smoke and e2e also green on the PR. dev-ci: success; no open dev-ci red issue |
| Test adequacy | ✅ | non-happy paths covered: unlisted identity → 403, tampered digest → 401, replay, flag off, provider failure, internal scope |
Execution coverage (§5.4): mutations run in the PR worktree
Baseline: the AAuth, ent#838, #3215, ent#761, #1920 and card suites give 234 passed; the #2996 census gives 69 passed.
| Fix / wiring | Mutation | Result |
|---|---|---|
C1 credential_cleared on an aauth switch (integrations.py:679) |
restore dev's value, and separately put the duplicate key back | ❌ red: test_ent761_outbound_control_oss.py, 1 failure each |
I1 ent#838 internal gate on the AAuth door (a2a.py:1341) |
if False: |
❌ red: test_ent623_aauth_a2a.py |
a1c551a8 flag read fails closed (aauth/config.py) |
re-raise instead of returning False | ❌ red: 1 failure across test_ent623_aauth_a2a + test_3215 |
Production wiring app.include_router(aauth_router) (main.py:1426) |
comment it out | ❌ red: #2996 route census |
Every change from the review has a test that goes red when the change is reverted, and the router registration in main.py is pinned by the census.
Warnings (do not block merge)
- I2 is still open by choice.
fetch_card_foroverlaps ent#838's own-path-then-origin order: an aauth endpoint can fetch the own-path card twice, and it falls back on any status, not just 404. The code now has a comment saying so. Worth opening a follow-up issue before the stack merges. /verify-localand/cso --diffover the whole prototype are still unchecked in the Test Plan. This lane does not require them, but a P1theme-securityfeature does under the pipeline table. Run them before the operator merges the stack.- The flag is off by default, so all of this ships inert until the gating decision. Inbound AAuth also stays inert without the private allow-list provider (trinity-enterprise#677).
Report-only run: nothing was committed and the branch was not edited.
…st split (ent#623) (#3748) Pulls in Abilityai/trinity-enterprise#677. Without it the public a2a_gate seam (#2956) finds no inbound_identities method on the allow-list provider and refuses every AAuth caller, and an aauth: allow-list entry wrongly gates bearer callers. Co-authored-by: trinity-ability <309458136+trinity-ability@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Ready for review since 2026-10-13 (engineer
/validate-pr: APPROVE at33a22bdd) — it merges together with its stack (#3659 → the ent#879 PR) on the operator's call; the gating decision (OSS-core seam vs entitlement-gated module) is still task G on trinity-enterprise#558.Summary
An agent on one Trinity instance calls an agent on another and is authenticated as itself — by a key only it holds — instead of by a shared bearer secret. This is AAuth's smallest access mode (agent-identity-only, self-hosted bootstrap: no Person Server, no Access Server, no missions) applied to the A2A seam we already ship. The transport is unchanged: still A2A JSON-RPC over
POST /a2a/{name}.Everything is behind
aauth_prototype_enabled, off by default. With the flag off, inbound, outbound and the served card behave as before.What it replaces. Federation previously meant instance B minting a Trinity MCP API key and instance A storing it. That credential resolves to its owner, carrying the owner's role — the class behind six prior incidents — so the callee's answer to "who is calling?" was "somebody holding a key owned by a user here". Now it is
aauth:<agent>@<instance-host>: per agent, no role attached, proved per request, revoked by deleting one line on the callee.Changes
services/aauth/—config(flag + issuer),jose(Ed25519 JWS; PyJWT only registers the polymorphicEdDSA, which the draft forbids verifiers to accept),httpsig(RFC 9421 profile + a strict RFC 8941 subset),keys(one instance key, AES-256-GCM insystem_settings, insert-if-absent so two workers cannot fork it),signer,discovery,verifier,inbound,whoami(operator CLI).routers/aauth.py— the three/.well-known/aauth-*.jsondocuments; uniform 404 unless AAuth is live; per-IP limited.routers/a2a.py— a route class sends a request carryingSignature-Keyto a separate entry point, so the bearer dependency is not made conditional and the bearer path, its 401s and its dependency overrides are untouched. Both entry points share one JSON-RPC method table.auth_schemeon the endpoint registry (bearerdefault |aauth, which carries no credential), signer threaded to the RPC, and a path-relative card fetch foraauthendpoints.a2a_gate.explicit_inbound_identities— the fail-closed allow-list accessor (inverse of the bearer gate beside it). Its provider half is abilityai/trinity-enterprise#677.platform_audit_service— newexternal_agentactor type, so a remote caller is not recorded as the platform itself.Ordering decisions worth reviewing, all with tests: the trusted-issuer pre-gate runs before any network I/O; a refusal is auditable only after the token's signature verifies; the body is read only after the signature covering its digest verifies; replay and the allow-list fail closed.
Test Plan
tests/unit/test_ent623_aauth_{core,a2a}.py— 136 new tests; mutation-checked (breaking the pre-gate, digest, replay, flag gate, allow-list membership or task scoping each fails a test)test_157_*,test_736_*,test_180_*,test_a2a_card_service) — 493 passingdev— pre-existing)@hellocoop/httpsig2.6.0 verifies here (checked-in vector), and ours verifies therewhoami.aauth.dev(a non-Trinity verifier) echoed the calling agent's identity; the remote task ran; an unlisted identity got 403; a tampered body got 401 before the envelope was parsed; a bearer call on the same endpoint still worked/verify-local,/cso --diff— not runOpen before this can merge
/a2a/{name}/, so bearer federation fails at the card fetch too. Fixed here foraauthendpoints only; the general fix wants its own issue.src/frontend/nginx.confgains onelocationfor the discovery documents — add theuilabel if the e2e gate is wanted before merge.Docs:
docs/memory/feature-flows/a2a-aauth-agent-identity.md, requirementsdocs/memory/requirements/mcp.md§32.6, architecture catalogs, and alearnings.mdentry (a second Trinity backend on the same Docker daemon deletes the first one's agent volumes).Refs abilityai/trinity-enterprise#623
🤖 Generated with Claude Code
Refresh 2026-10-13 (#3644) — the branch now contains current
devBrought up to
devd427814aby merge commit06866d2e(no rewrite: this PR stayed open). The real merge conflicted in 13 files, not the 7merge-treepredicted; every resolution keeps both parents' behaviour. Follow-up commits on the branch:3cb3e9c1391fef34credential_clearedkey in the endpoint audit row (merge kept both lines);request.url.path→scope["path"]×3 (#3102 fence); gitleaks allowlist fingerprint for a false positive on a dataclass attribute name3f045bbd$fwd_protolike/a2a/(#3215)a1c551a8services/aauth/configraised under an unreadable settings service and 500'd the card route (4 of the #3215 card/door tests); an unreadable flag now reads OFF, so the card and the bearer-less 401 keepdev's bytes; regression test + two tests for the merge-added internal-scope gate on AAuth callersRouting after the merge.
routers/a2a.pynow has one shared_dispatch_jsonrpcreached by the bearer path, the keyless (ent#838) path and the AAuth route class. Two behaviours were added to keepdev's intent: an AAuth caller passes the ent#838 internal-scope gate exactly as a bearer caller does, and the AAuth challenge headers also ridedev's uniform returned 401 (flag-on only; a module constant, so nothing enumerates agents). The requirement moved to §32.8 ofdocs/memory/requirements/mcp.md(devtook 32.6 and 32.7).Review + security (orchestrator, trinity-pm).
/review(Fable, report-only, merge-scoped): 19 both-sides files set-compared, 12 byte-identical, 5 differ only by the expected adaptations; one medium (C1, fixed), two lows (I1 fixed with tests, I2 noted in code). Inline STRIDE pass over the six resolved service/router diffs: no findings. Spot checks:test_ent623_aauth_a2a33,test_ent838_a2a_trusted_networks37,test_ent679_*178 passed.Tests. On
a1c551a8:regression diffgreen (the seven post-09-18 guards that were red on3cb3e9c1now pass),pytest (head),frontend-e2e,journey-smoke,schema-parity, CodeQL green.secret-scanred on the allowlist comment quoting the pattern — reworded in the next commit.Stack. This branch is the base of the AAuth Night chain:
feature/878-aauth-person-token(trinity-enterprise#878, person-token binding via Hellō) andfeature/879-aauth-resource-client(trinity-enterprise#879, outbound resource client + Senzing sanctions check) open as stacked draft PRs on it. The stack merges together on the operator's call; gating stays task G on trinity-enterprise#558. The enterprise half remains trinity-enterprise#677.