From 00aaf4e2a2c6f0f0f45f53804e6b3040178f7005 Mon Sep 17 00:00:00 2001 From: Eugene Vyborov <1073874+vybe@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:31:28 -0700 Subject: [PATCH 1/4] =?UTF-8?q?feat(aauth):=20graduate=20the=20agent=20ide?= =?UTF-8?q?ntity=20for=20Trinity-to-Trinity=20calls=20=E2=80=94=20flag=20s?= =?UTF-8?q?plit,=20Settings=20surface,=20outbound=20endpoint=20UI=20(abili?= =?UTF-8?q?tyai/trinity-enterprise#862,=20PR1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two switches now: `aauth_enabled` governs Flows 1–4 (well-knowns, signing, inbound verification, card) and is the single input of `active_issuer()`; `aauth_prototype_enabled` is narrowed to the person-token and resource-calling prototypes, gated once at `require_live` on BOTH switches. Forwarded in every backend compose file with an empty default so the Settings row stays the observable source; `aauth_available` on the feature-flags surface. `GET/PUT /api/settings/aauth`: admin on read, admin AND interactive on write (no MCP key of any scope), diff-only body, named 422 for an invalid issuer, person server or resource allow-list, audited `aauth_config_change`. The generic `PUT/DELETE /api/settings/{key}` refuses all five `aauth_*` keys — the two Flow 5–6 keys were the one MEDIUM `/cso --diff` found (pre-existing; cso-diff-2026-10-13-ent862-aauth-settings). Settings → Integrations gains an AAuth panel (honest "On, inactive" state, env-sourced values read-only, confirm before changing a live issuer, a federation-readiness checklist, the stated limits) and an outbound A2A endpoints panel over the OSS registry with `auth_scheme`; MCP `register_a2a_endpoint` takes `auth_scheme`. The two-instance demo script moves its generic settings writes to the dedicated route. Tests: tests/unit/test_ent862_aauth_settings.py (flag matrix, route principal matrix, generic-door refusals, compose wiring); mounted vitest specs for both panels (each proven red by mutation); MCP a2a.test.ts auth_scheme cases. Part 1 of 3 for abilityai/trinity-enterprise#862. Co-Authored-By: Claude Fable 5.1 --- .env.example | 12 +- docker-compose.hosted.yml | 3 + docker-compose.prod.yml | 3 + docker-compose.yml | 3 + docs/memory/architecture/backend.md | 10 +- docs/memory/architecture/frontend.md | 1 + docs/memory/feature-flows.md | 2 +- .../feature-flows/a2a-aauth-agent-identity.md | 18 +- ...-refusal-reprices-every-scripted-writer.md | 3 + docs/memory/requirements/mcp.md | 44 +- ...diff-2026-10-13-ent862-aauth-settings.json | 41 ++ ...o-diff-2026-10-13-ent862-aauth-settings.md | 47 ++ scripts/dev/aauth_prototype/compose.b.yml | 1 + scripts/dev/aauth_prototype/demo.sh | 14 +- src/backend/models.py | 22 +- src/backend/routers/aauth_person.py | 7 +- src/backend/routers/settings/__init__.py | 2 + src/backend/routers/settings/aauth.py | 61 +++ src/backend/routers/settings/flags.py | 5 + src/backend/routers/settings/generic.py | 29 ++ src/backend/services/aauth/config.py | 65 ++- .../services/aauth/resource_allowlist.py | 4 +- src/backend/services/aauth/settings.py | 185 ++++++++ src/backend/services/aauth/whoami.py | 2 +- .../components/settings/A2AEndpointsPanel.vue | 297 +++++++++++++ .../src/components/settings/AAuthPanel.vue | 238 +++++++++++ src/frontend/src/stores/a2aFederation.js | 112 +++++ src/frontend/src/utils/aauthSettings.js | 77 ++++ src/frontend/src/views/Settings.vue | 7 + .../unit/a2aEndpointsPanel.mount.spec.js | 134 ++++++ .../tests/unit/aauthPanel.mount.spec.js | 122 ++++++ src/frontend/tests/unit/aauthSettings.spec.js | 72 ++++ src/mcp-server/src/client.ts | 1 + src/mcp-server/src/tools/a2a.test.ts | 39 ++ src/mcp-server/src/tools/a2a.ts | 22 + src/mcp-server/src/tools/aauth.ts | 5 +- tests/registry.json | 12 + tests/unit/test_1028_settings_package.py | 4 + tests/unit/test_ent862_aauth_settings.py | 403 ++++++++++++++++++ tests/unit/test_ent878_aauth_person_token.py | 2 + .../unit/test_ent879_aauth_resource_client.py | 2 + 41 files changed, 2088 insertions(+), 45 deletions(-) create mode 100644 docs/memory/learnings/2026-10-13-a-catch-all-refusal-reprices-every-scripted-writer.md create mode 100644 docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.json create mode 100644 docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.md create mode 100644 src/backend/routers/settings/aauth.py create mode 100644 src/backend/services/aauth/settings.py create mode 100644 src/frontend/src/components/settings/A2AEndpointsPanel.vue create mode 100644 src/frontend/src/components/settings/AAuthPanel.vue create mode 100644 src/frontend/src/stores/a2aFederation.js create mode 100644 src/frontend/src/utils/aauthSettings.js create mode 100644 src/frontend/tests/unit/a2aEndpointsPanel.mount.spec.js create mode 100644 src/frontend/tests/unit/aauthPanel.mount.spec.js create mode 100644 src/frontend/tests/unit/aauthSettings.spec.js create mode 100644 tests/unit/test_ent862_aauth_settings.py diff --git a/.env.example b/.env.example index 931a510282..20d1df826f 100644 --- a/.env.example +++ b/.env.example @@ -274,9 +274,15 @@ VOIP_ENABLED=false # sanitiser can stop a transformed secret. An admin can flip this at runtime # (system_settings wins over this variable, no restart needed). A2A_OUTBOUND_ENABLED=false -# AAuth prototype (trinity-enterprise#623 / #878 / #879). All four resolve -# system_settings → env → default, so an admin can flip them at runtime. -# AAUTH_PROTOTYPE_ENABLED=false — nothing is published, signed or accepted until ON. +# AAuth agent identity (trinity-enterprise#623, graduated by #862). Every key +# resolves system_settings → env → default, and Settings → AAuth edits the first +# two, so an admin can flip them at runtime. +# AAUTH_ENABLED= — agents on this instance sign their calls to other +# instances, and verified callers from instances an +# agent lists are accepted. Off until set or switched +# on in Settings; left unset here on purpose. +# AAUTH_PROTOTYPE_ENABLED=false — the person-token and resource-calling prototypes +# (also need AAUTH_ENABLED). # AAUTH_ISSUER= — this instance's AAuth server identifier (https://). # AAUTH_PERSON_SERVER= — the person server agents bind through (e.g. # https://person.hello.coop); unset → person-token diff --git a/docker-compose.hosted.yml b/docker-compose.hosted.yml index 69e699fbd4..052953fb4c 100644 --- a/docker-compose.hosted.yml +++ b/docker-compose.hosted.yml @@ -203,6 +203,9 @@ services: # AAuth prototype (trinity-enterprise#623 / #878 / #879). Default OFF and # fail-closed: system_settings wins, but the env leg must be forwarded or # the .env levers silently do nothing (#1056 class). + # ent#862: the graduated agent identity (Flows 1-4). Empty default, so an + # unset variable reads as unset and the Settings switch stays authoritative. + - AAUTH_ENABLED=${AAUTH_ENABLED:-} - AAUTH_PROTOTYPE_ENABLED=${AAUTH_PROTOTYPE_ENABLED:-false} - AAUTH_ISSUER=${AAUTH_ISSUER:-} - AAUTH_PERSON_SERVER=${AAUTH_PERSON_SERVER:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4cceb611e6..50ebdf7056 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -140,6 +140,9 @@ services: # AAuth prototype (trinity-enterprise#623 / #878 / #879). Default OFF and # fail-closed: system_settings wins, but the env leg must be forwarded or # the .env levers silently do nothing (#1056 class). + # ent#862: the graduated agent identity (Flows 1-4). Empty default, so an + # unset variable reads as unset and the Settings switch stays authoritative. + - AAUTH_ENABLED=${AAUTH_ENABLED:-} - AAUTH_PROTOTYPE_ENABLED=${AAUTH_PROTOTYPE_ENABLED:-false} - AAUTH_ISSUER=${AAUTH_ISSUER:-} - AAUTH_PERSON_SERVER=${AAUTH_PERSON_SERVER:-} diff --git a/docker-compose.yml b/docker-compose.yml index b0cfd82524..a149f9455b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -79,6 +79,9 @@ services: # AAuth prototype (trinity-enterprise#623 / #878 / #879). Default OFF and # fail-closed: system_settings wins, but the env leg must be forwarded or # the .env levers silently do nothing (#1056 class). + # ent#862: the graduated agent identity (Flows 1-4). Empty default, so an + # unset variable reads as unset and the Settings switch stays authoritative. + - AAUTH_ENABLED=${AAUTH_ENABLED:-} - AAUTH_PROTOTYPE_ENABLED=${AAUTH_PROTOTYPE_ENABLED:-false} - AAUTH_ISSUER=${AAUTH_ISSUER:-} - AAUTH_PERSON_SERVER=${AAUTH_PERSON_SERVER:-} diff --git a/docs/memory/architecture/backend.md b/docs/memory/architecture/backend.md index d30931f92c..7a5b1187e8 100644 --- a/docs/memory/architecture/backend.md +++ b/docs/memory/architecture/backend.md @@ -45,9 +45,9 @@ - `files.py` - Public download endpoint for outbound agent file sharing (FILES-001) - `agent_rename.py` - Rename endpoint (RENAME-001) - `a2a.py` - A2A protocol: the authenticated per-agent card (#737) plus the **inbound server** on a separate prefix-less `a2a_server_router` — public `GET /a2a/{name}/.well-known/agent-card.json` (per-IP rate limited) + `POST /a2a/{name}` JSON-RPC (message/send, message/stream SSE, tasks/get, tasks/cancel). Exposure is opt-in per agent (`agent_ownership.a2a_exposed`, default OFF); non-exposed/inaccessible → uniform 404 (Invariant #8). `messageId` dedup is scoped per (agent, caller principal) — the field is peer-controlled and only unique per-client (ent#157). **Also hosts the OUTBOUND client (#736):** `POST /{name}/a2a/call` + `POST /{name}/a2a/task`, a Trinity agent tasking an EXTERNAL A2A agent. The target is never caller-supplied — it is a name resolved through `services/a2a_outbound.py` — and the routes are thin (auth + HTTP error map + audit); orchestration lives in `services/a2a_outbound_service.py`. Default OFF (`A2A_OUTBOUND_ENABLED`), both routes 404 when off. **The inbound door also takes payment (ent#679):** its principal is OPTIONAL (`get_user_or_anonymous`, which degrades on a 401 but RE-RAISES a 403 so a fenced key cannot become a payer), and the handler branches once — a resolved principal gets today's free path byte-identically, an anonymous caller gets today's 401 unless the agent is BOTH exposed and Nevermined-enabled, in which case it gets the paid door's own 402 with `resource.url` on THIS door. The card producer `_card_with_exposed_skills` also declares the price (`a2a_card_service.with_payment_extension`), so both card surfaces state it and an unpriced agent's card is byte-identical. **#3215:** `_base_url_from_request` now delegates to `utils/public_url.py::public_base_url` — one module, one `X-Forwarded-Proto` upgrade rule — and the anonymous JSON-RPC door uses it too, so neither 402 door can advertise an origin the caller did not call; it also honours the Settings `public_chat_url` row, not just the env var. **The two CARD routes take `_card_base_url` (configured-first, review I3)**, which is the card's pre-#3215 behaviour and not a second copy of the logic: a card is republished to buyers elsewhere and the `get_agent_a2a_card` MCP tool reads it from `backend:8000`, so a same-host card advertises an internal host to every external buyer. A buyer that follows the card lands on the configured host, where the door's own same-host rule mints the 402 for that very origin. `_task_object` MIRRORS the x402 metadata onto the Task's top-level `metadata` from the same assignment as `status.message.metadata` (the spec location, which the provider SDK reads) — without it a typed client renders a settled, charged turn as `metadata: null`; the free path emits no `metadata` key, so unpaid Task bytes and the idempotency snapshots over them are unchanged -- `aauth.py` - AAuth discovery documents (ent#623, prototype, flag OFF): `GET /.well-known/aauth-{agent,jwks,resource}.json`, per-IP limited, uniform 404 unless the prototype is live. The AAuth path on `POST /a2a/{name}` lives in `a2a.py` (`_A2AServerRoute` routes a `Signature-Key` request around the bearer dependency) — see [requirements mcp.md §32.8](../requirements/mcp.md) -- `aauth_person.py` - AAuth person token (ent#878, prototype, flag OFF): `POST /api/agents/{name}/aauth/person-token` — the flag gate as a `Depends` (uniform 404 before body validation), the agent's OWN key only, two per-agent rate budgets + a fleet budget, the audit row (`aauth_person_token_{issued,pending,denied,refused}`, `token_jti` never the token), and the HTTP mapping of the service outcome — see [requirements mcp.md §32.9](../requirements/mcp.md) -- `aauth_resource.py` - AAuth resource call (ent#879, prototype, flag OFF): `POST /api/agents/{name}/aauth/resource-call` — ent#878's gates reused (`aauth_person.require_live` / `assert_own_key`, public since ent#879) before the allow-list, three rate budgets, the discriminated call-XOR-resume body, the audit row (`aauth_resource_call` after egress / `aauth_resource_refused` before it; never a token, body, justification, handle, url or code) and the HTTP mapping of the ladder's outcome (protocol outcomes 200 with `outcome`, refusals 4xx/5xx with `possibly_executed` on a POST timeout) — see [requirements mcp.md §32.10](../requirements/mcp.md) +- `aauth.py` - AAuth discovery documents (ent#623; behind the graduated `aauth_enabled` switch since ent#862): `GET /.well-known/aauth-{agent,jwks,resource}.json`, per-IP limited, uniform 404 unless AAuth is live. The AAuth path on `POST /a2a/{name}` lives in `a2a.py` (`_A2AServerRoute` routes a `Signature-Key` request around the bearer dependency) — see [requirements mcp.md §32.8](../requirements/mcp.md) +- `aauth_person.py` - AAuth person token (ent#878, prototype; `require_live` needs BOTH `aauth_enabled` and `aauth_prototype_enabled` since ent#862, and is the only reader of the prototype switch): `POST /api/agents/{name}/aauth/person-token` — the flag gate as a `Depends` (uniform 404 before body validation), the agent's OWN key only, two per-agent rate budgets + a fleet budget, the audit row (`aauth_person_token_{issued,pending,denied,refused}`, `token_jti` never the token), and the HTTP mapping of the service outcome — see [requirements mcp.md §32.9](../requirements/mcp.md) +- `aauth_resource.py` - AAuth resource call (ent#879, prototype, both switches): `POST /api/agents/{name}/aauth/resource-call` — ent#878's gates reused (`aauth_person.require_live` / `assert_own_key`, public since ent#879) before the allow-list, three rate budgets, the discriminated call-XOR-resume body, the audit row (`aauth_resource_call` after egress / `aauth_resource_refused` before it; never a token, body, justification, handle, url or code) and the HTTP mapping of the ladder's outcome (protocol outcomes 200 with `outcome`, refusals 4xx/5xx with `possibly_executed` on a POST timeout) — see [requirements mcp.md §32.10](../requirements/mcp.md) - `agent_ssh.py` - SSH access endpoint - `credentials.py` - Credential injection/export/import (CRED-002) - `chat.py` / `chat/` - Agent chat/activity monitoring @@ -109,7 +109,7 @@ - `subscriptions.py` - Subscription management (SUB-002). **#2572:** `POST` now also runs the credential-less adoption sweep (`subscription_service.adopt_for_credentialless_agents`) beside the #1089 rollover fan-out, in its own swallow-everything `try/except` — the sweep must never fail a registration whose credential is already stored, which is why the body param is `payload` and the injected one `http_request` (two things called `request` is how a log line inside an `except` raises `AttributeError` into a 500). The manual assign/clear routes now write the `subscription_assign` / `subscription_clear` audit rows this router had never carried (#2421 owns the remaining three actions); id and name only, never a token. #471 usage observability — extended `GET /{id}/usage` (failure counters + one-gate `rate_limited_now` + `headroom` block, `source: anthropic|observed`), `GET /{id}/usage/breakdown` (per-agent, cost-ranked), `POST /{id}/usage/refresh` (click probe), `GET/PUT /settings/headroom-auto-refresh` — all `assert_admin`; ent#433 adds `GET /{id}/headroom/history?window=24h|7d|30d` (bounded `last`-per-bucket series, `bucket_start` + real `fetched_at`, `coverage_pct`; 422 on an unknown window, id-OR-name + 404 parity with `/usage`; read-only, never probes); ent#434 adds `PUT /settings/headroom-alert-threshold` (`0` disables — the `operator_queue_retention_days` idiom — else 50–99 with a named 422; the escalation tier is DERIVED `max(threshold, 90)`, never a second knob, because two settable thresholds are an oscillator and `validate_ops_setting` is per-key so it cannot express the cross-field invariant) and extends the auto-refresh GET with a `weekly_alert` block carrying `active` + an `inactive_reason` of `no_subscriptions` / `threshold_disabled` / `auto_refresh_off` / `redis_unavailable` / `count_unavailable`, so "no alerts" is distinguishable from "not checking" (#2217) — an unreadable subscription list is its OWN reason rather than falling through to `active: true`, which it did while `None == 0` decided the first arm. Rendered by the threshold control in `SubscriptionsPanel.vue`, whose decidable rules live in `utils/headroomAlertSettings.js` (vitest runs `environment: 'node'` with no mount harness, so a rule inside the SFC is untestable — the ent#392 precedent). The key is 422-blocked on the generic `PUT /api/settings/{key}` - `skills.py` - Skill CRUD and assignment. **#2703:** assignment DELIVERS — `POST`/`PUT` call `skill_service.deliver_assigned` (the start-path injection, all assigned + `force=False`; a subset would rewrite CLAUDE.md's section) and return a `delivery` block (`injected|partial|pending_start|in_progress|not_delivered+reason`) on a 200 the row never rolls back; bounded by `SKILL_DELIVERY_BUDGET_SECONDS` with the work kept alive past it. **ent#672:** `delivery.skills[name].warnings` names a deprecated skill on every outcome (read from the library entry, so a stopped agent is told too); `GET /skills/library` carries `deprecated` / `superseded_by`. All four routes fire the thin `agent_skills_changed` WS trigger (`skill_service.broadcast_skills_changed`, identifiers only — #918 rule) — see [skill-injection.md](../feature-flows/skill-injection.md) - `skill_gate.py` - The in-container skill-gate hook's check, `POST /api/skill-gate/check` (trinity-enterprise#752): the agent from its key (`get_self_agent`), every verdict a 200; and the per-agent gate map, `/api/agents/{agent_name}/skill-gates` (`agent_router`, trinity-enterprise#753) — see [skill-gate.md](../feature-flows/skill-gate.md) -- `settings/` (package, #1028 — eleven domain modules, `provider_keys` carrying the #2715 Resend/Gemini routes split out of `credentials`; `generic.py`'s `/{key}` catch-all is included LAST, Invariant #4) - Platform admin settings (incl. Slack transport connect/disconnect/install). **#2572:** both Anthropic-key clear paths fire the credential-less adoption sweep, each gated on the route's existing `deleted` truthiness — the dedicated `DELETE /api-keys/anthropic` **and** the generic `DELETE /{key}` keyed on the two key aliases, because `db.delete_setting` has no delete-side twin of ent#435's `db.set_setting` sink guard and so reaches the key without ever touching `clear_secret_setting`. The hook sits on the ROUTES, not that leaf, which also serves `github_pat` and the Slack keys. Hosts the Tier-2 usage-sharing family (ent#12/ent#437): `GET /telemetry-sharing` (admin + human-only; `?preview=0` skips the aggregate build), `PUT /telemetry-sharing` (consent; mints/discards the share id), `POST /telemetry-sharing/ask/dismiss` (the once-per-install "don't ask again" marker) — see [Opt-in Instance Telemetry](#opt-in-instance-telemetry-ent437) +- `settings/` (package, #1028 — twelve domain modules, `provider_keys` carrying the #2715 Resend/Gemini routes split out of `credentials`, `aauth` carrying ent#862's `GET/PUT /aauth` — the AAuth switches + issuer, admin AND `require_interactive` on write because turning it on publishes the instance key and accepts signed callers; the generic catch-all refuses the three keys on PUT and DELETE; `generic.py`'s `/{key}` catch-all is included LAST, Invariant #4) - Platform admin settings (incl. Slack transport connect/disconnect/install). **#2572:** both Anthropic-key clear paths fire the credential-less adoption sweep, each gated on the route's existing `deleted` truthiness — the dedicated `DELETE /api-keys/anthropic` **and** the generic `DELETE /{key}` keyed on the two key aliases, because `db.delete_setting` has no delete-side twin of ent#435's `db.set_setting` sink guard and so reaches the key without ever touching `clear_secret_setting`. The hook sits on the ROUTES, not that leaf, which also serves `github_pat` and the Slack keys. Hosts the Tier-2 usage-sharing family (ent#12/ent#437): `GET /telemetry-sharing` (admin + human-only; `?preview=0` skips the aggregate build), `PUT /telemetry-sharing` (consent; mints/discards the share id), `POST /telemetry-sharing/ask/dismiss` (the once-per-install "don't ask again" marker) — see [Opt-in Instance Telemetry](#opt-in-instance-telemetry-ent437) *Content & Files:* - `image_generation.py` - Image generation REST endpoints (IMG-001) @@ -149,7 +149,7 @@ - `role_addressing.py` - The ONE rule that turns an outbound object's `to:` role (`primary | approver | viewer | operator`) into people (trinity-enterprise#606): a provider's `people_for` answer first, else primary → owner, operator → nobody, approver/viewer refused (`RoleRefused`, named codes). Shared by asks (`ask_service._address` — a role several people fill delivers the one ask to each of them, trinity-enterprise#816), reports (`routers/reports._report_audience`) and messages (`routers/messages._recipient`); agent-supplied emails are still honoured and logged as deprecated (`log_email_addressing`). - `a2a_outbound_service.py` - Outbound orchestration: kill switch → rate bounds (per-agent **and** a fleet Redis key — a per-agent limit bounds one agent, the fleet is the exhaustion path) → resolve → validate → `effect_guard` → call → `agent_activities` row. Ordering is load-bearing: bounds before resolution so a flood can't become a DNS amplifier, and validation before the guard so a refused URL never burns an effect claim (#736) - `a2a_client.py` - The protocol client, FastAPI-free (raises `A2ACallError`, mapped 1:1 at the router). One resolution → one validated IP → **pinned for both hops**, with the registered hostname carried for `Host` + SNI + cert verification, so DNS rebinding is *closed* rather than accepted as a residual (the template-registry validator can accept it; this request carries a credential). `trust_env=False` — every other control reasons about the target IP and a proxy makes the target irrelevant — with the CA context rebuilt explicitly, since that flag also disables httpx's `SSL_CERT_FILE`/`SSL_CERT_DIR`. Wire-byte ceilings over `aiter_raw()`; any `Content-Encoding` refused, not decoded; a 3xx is a failure on both hops; a wall-clock deadline over cancellable awaits, because httpx's `read` timeout is per-read and a trickling tarpit resets it forever. The card is a **hint**: its `url` must be same-origin (default-port-equivalent — Trinity's own card emits no port) and `securitySchemes` never selects the credential, which is why #736 ships while ent#159 is blocked. Errors ride the body on **HTTP 200**, so the body is parsed for `error` even on 200 (#736). **402/403 on the RPC hop are classified BEFORE the encoding and length guards** (#3185): a CDN-gzipped or oversized "pay me" previously reported `rpc_encoding`/`rpc_too_large` — an outage, for an endpoint working perfectly — so the header is read first and the body only when identity-encoded and under a ceiling 16× tighter than the answer cap, with the outcome surviving from the status alone when it cannot be read. The in-band `x402.payment.status` check runs **before `_parse_task`** on both the send and the poll path, or a priced `input-required` reaches the agent as a prompt it polls forever; the `payment` block handed back is allowlisted, leaf-capped and scrubbed against the token **and its base64 forms and the decoded payload's long leaves** -- `aauth/` (package, ent#623 prototype, flag OFF) - AAuth agent-identity-only mode, self-hosted bootstrap: `config` (flag + issuer), `jose` (Ed25519 JWS — PyJWT only knows `EdDSA`), `httpsig` (RFC 9421 profile + strict RFC 8941 subset), `keys` (instance signing key, AES-256-GCM in `system_settings`, insert-if-absent), `signer` (per-agent `aa-agent+jwt` + request signing), `discovery` (issuer metadata/JWKS through the pinned `a2a_client` egress, same-origin `jwks_uri`, 8 s deadline), `verifier` (FastAPI-free; trusted-issuer pre-gate before any fetch; replay fail-closed), `inbound` (audit/activity attribution + identity-scoped `tasks/*`), `whoami` (operator CLI); ent#878 adds `person_server` (PS metadata: four required fields, token endpoints same-origin, `jwks_uri` may be off-host, 60 s negative cache), `requirement` (the strict `AAuth-Requirement` / `Retry-After` grammar), `egress` (every status with headers + capped body under the `a2a_client` guards), `person_token` (the binding state machine: worker-memory held token stamped with the key thumbprint, Redis pending records `aauth:pt:*`, fail-closed `state_unavailable`), and makes `signer`'s per-agent key HKDF-derived from the instance seed per hour window — both workers hold the same `cnf`, and a cache hit honours `min_remaining` against the window end so a whole challenge chain signs with one key. ent#879 adds `resource_allowlist` (the SINK between an LLM-supplied URL and a signed request: `aauth_resource_allowlist`, origin equality, EMPTY on any unreadable state), `resource_meta` (resource metadata + control paths + tolerant `AAuth-Budget`, cached per `(issuer, document)`), `resource_token` (the `aa-resource+jwt` verification sink — one red test per check), `auth_token` (inspection-only `aa-auth+jwt`, T5), `ps_client` (the auth-token exchange, the Redis auth-token cache `aauth:at:*` with TTL `exp − 300`, pending records in ent#878's handle namespace with kinds `ps_interaction` / `ps_approval` / `resource_polling`), `resource_client` (the bounded challenge ladder: ≤ 6 resource sends, ≤ 3 PS exchanges, one 25 s budget, no inline polling, every held token scrubbed from bodies and errors, `resume` re-sends the STORED request) and `resource_call` (the route's seam: the ladder under `effect_guard("aauth_resource")` — only result/denied complete, every other outcome releases — plus the audit + activity rows). `a2a_gate.explicit_inbound_identities` is its **fail-closed** allow-list accessor +- `aauth/` (package, ent#623; Flows 1–4 behind `aauth_enabled` since ent#862, Flows 5–6 additionally behind `aauth_prototype_enabled`, both OFF) - AAuth agent-identity-only mode, self-hosted bootstrap: `config` (the two switches + issuer: `active_issuer()` for Flows 1–4, `prototype_active_issuer()` read only at `require_live`), `settings` (the admin read/write rules behind `/api/settings/aauth`: sources, `live`, diff-only writes, `invalid_issuer`, the readiness checklist), `jose` (Ed25519 JWS — PyJWT only knows `EdDSA`), `httpsig` (RFC 9421 profile + strict RFC 8941 subset), `keys` (instance signing key, AES-256-GCM in `system_settings`, insert-if-absent), `signer` (per-agent `aa-agent+jwt` + request signing), `discovery` (issuer metadata/JWKS through the pinned `a2a_client` egress, same-origin `jwks_uri`, 8 s deadline), `verifier` (FastAPI-free; trusted-issuer pre-gate before any fetch; replay fail-closed), `inbound` (audit/activity attribution + identity-scoped `tasks/*`), `whoami` (operator CLI); ent#878 adds `person_server` (PS metadata: four required fields, token endpoints same-origin, `jwks_uri` may be off-host, 60 s negative cache), `requirement` (the strict `AAuth-Requirement` / `Retry-After` grammar), `egress` (every status with headers + capped body under the `a2a_client` guards), `person_token` (the binding state machine: worker-memory held token stamped with the key thumbprint, Redis pending records `aauth:pt:*`, fail-closed `state_unavailable`), and makes `signer`'s per-agent key HKDF-derived from the instance seed per hour window — both workers hold the same `cnf`, and a cache hit honours `min_remaining` against the window end so a whole challenge chain signs with one key. ent#879 adds `resource_allowlist` (the SINK between an LLM-supplied URL and a signed request: `aauth_resource_allowlist`, origin equality, EMPTY on any unreadable state), `resource_meta` (resource metadata + control paths + tolerant `AAuth-Budget`, cached per `(issuer, document)`), `resource_token` (the `aa-resource+jwt` verification sink — one red test per check), `auth_token` (inspection-only `aa-auth+jwt`, T5), `ps_client` (the auth-token exchange, the Redis auth-token cache `aauth:at:*` with TTL `exp − 300`, pending records in ent#878's handle namespace with kinds `ps_interaction` / `ps_approval` / `resource_polling`), `resource_client` (the bounded challenge ladder: ≤ 6 resource sends, ≤ 3 PS exchanges, one 25 s budget, no inline polling, every held token scrubbed from bodies and errors, `resume` re-sends the STORED request) and `resource_call` (the route's seam: the ladder under `effect_guard("aauth_resource")` — only result/denied complete, every other outcome releases — plus the audit + activity rows). `a2a_gate.explicit_inbound_identities` is its **fail-closed** allow-list accessor - `a2a_protocol.py` - Shared JSON-RPC/A2A vocabulary — error codes, the dialect table, envelope helpers — imported by **both** `routers/a2a.py` (inbound) and `a2a_client.py` (outbound) so the two cannot drift. Dialect defaults to **v0.3** (an absent version is the spec's back-compat rule); `1.x` is defined and deliberately **refused**, since no peer exists to verify it against. "Target v1.0 only" was rejected on evidence: Trinity's own card pins `0.3.0` and its server dispatches slash names, so a v1.0-only client cannot talk to Trinity and #738 federation would be dead on arrival (#736). Also owns the **x402 payment vocabulary** (#3185) — the dotted metadata keys, the status values, the two HTTP header names, and the stdlib base64/JSON token codec `decode_payment_token`, which lives here rather than in the client because the endpoint store reads it too (one answer to "is this credential a payment token?") and the inbound side will read the same names - `operator_intake_service.py` - Fire-and-forget, once-per-install opt-in operator intake POST at first-run; owns `installation_id` (trinity-enterprise#38). ent#463 adds a Settings-home surface (`GET`/`PUT /api/settings/operator-intake`) so an admin can opt in / opt out after first-run — necessary since abilityai/trinity#2385 stops rendering the welcome form on any install with a pre-provisioned admin. Both surfaces converge on `submit_operator_intake` and preserve the at-most-once marker (no second intake client, no forked payload); opt-out is a durable decline and does NOT roll back the marker. Two accessors over the id (ent#545): `get_or_create_installation_id` is the **writers'** accessor (the consent POST, the product-event emit, the #1987 canary label) and `get_installation_id` is the non-minting read for every GET-shaped path — a `get_or_create_*` on a read path is a durable write with a race (learnings 2026-08-05); the enterprise funnel read was the last such caller. The mint is a write-once claim through `insert_setting_if_absent` (a losing worker reads the winner's id back), closing the SELECT-then-upsert race #1987 had recorded as pre-existing - `mcp_auth_service.py` - #848 inline-login issue/verify + the per-call access gate behind `routers/mcp_auth.py`. Owns the **enumeration-safety contract** (#186): `/request` answers ONE constant 202 on every path — known, unknown, malformed, rate-limited, backend-threw — with **no audit row**, and does no branch-dependent work on the request path at all (the known-check, the cap read and the code INSERT all run in a Starlette `BackgroundTasks` task after the response flushes, because a committing write on only one branch measured ~1.9× even on in-process SQLite). `_email_is_known` **fails closed**, so a lookup error reads as "unknown" and mails nothing. Rate limits are **account-scoped, never per-IP** — `client_ip` is always the MCP server, so a per-IP bucket collapses every user into one and 30 wrong codes would lock inline login out fleet-wide (the #591 DoS); a global ceiling checked *before* the known-branch bounds the unknown side without becoming its own differential. `assert_email_may_reach_agent` is the load-bearing gate: the internal secret authenticates the caller, the asserted email's own standing authorizes the action diff --git a/docs/memory/architecture/frontend.md b/docs/memory/architecture/frontend.md index 549a3211a6..7c5a9cf517 100644 --- a/docs/memory/architecture/frontend.md +++ b/docs/memory/architecture/frontend.md @@ -18,6 +18,7 @@ **Stores (domain-scoped, Invariant #6):** - `stores/agents.js` - Agent CRUD, chat, activity +- `stores/a2aFederation.js` - Calls between Trinity instances (ent#862): the AAuth switch + issuer (`/api/settings/aauth`) and the OSS outbound endpoint registry (`/api/settings/a2a-endpoints`) with the outbound kill switch; each half keeps its own loaded latch + error so a failed refresh keeps the data on screen. Rendered by `components/settings/AAuthPanel.vue` (readiness checklist, asks before changing a live issuer) and `A2AEndpointsPanel.vue` (register/update by name with `auth_scheme`, bounded table, remove asks first); the decidable rules are in `utils/aauthSettings.js` - `stores/auth.js` - Email/admin authentication + JWT; `principalId` getter = the JWT `sub` (the username on every login path, available synchronously from the stored token — `user.username` is NOT, it lands with `/api/users/me`), the identity every per-user browser cache is namespaced by (trinity-enterprise#413) - `stores/userPreferences.js` - Per-user UI preference sync engine over `GET|PUT|DELETE /api/users/me/preferences` (trinity-enterprise#413): one load, trailing-debounced conditional PUT per key (`base_updated_at`: `null` insert-only / string compare-and-set — never unconditional, nothing sent before the load settles), origin-aware 409 (gesture retries once, reconcile adopts), identity-tagged queue dropped synchronously on identity change, `keepalive` flush on `pagehide`. `stores/fleetGrid.js` is the first consumer; every other per-user localStorage key migrates by consuming it, never by re-implementing it — see [dashboard-grid-view.md](../feature-flows/dashboard-grid-view.md#layout-model) - `stores/collaborations.js` - Collaboration graph state, WebSocket integration diff --git a/docs/memory/feature-flows.md b/docs/memory/feature-flows.md index a69c2147b7..a3a304e160 100644 --- a/docs/memory/feature-flows.md +++ b/docs/memory/feature-flows.md @@ -240,7 +240,7 @@ | Agent MCP Key | [agent-mcp-key.md](feature-flows/agent-mcp-key.md) | The agent's own `scope='agent'` key — container config-truth probe, start-time drift self-heal, owner-driven rotation (#1854) | | A2A Inbound Server | [a2a-inbound-server.md](feature-flows/a2a-inbound-server.md) | Opt-in public Agent Card + JSON-RPC/SSE task endpoint so external orchestrators discover and task an agent; per-caller `messageId` dedup, rate-limited public route, allow-list seam (ent#157/#160) | | A2A Outbound Calls | [a2a-outbound-call.md](feature-flows/a2a-outbound-call.md) | The calling half: a Trinity agent tasks an external A2A agent through an operator-registered endpoint (never a caller-supplied URL); call-time SSRF re-validation, connect-time IP pinning, same-origin card pin, `effect_guard` keyed on the conversation, default-OFF kill switch (#736). A priced remote answers **402** with a bounded, scrubbed `payment` block and `do_not_retry`; the endpoint credential carries a kind (`api_key` / `payment_token`, inferred when omitted) — Trinity reads the price and attaches a stored token, never buys (#3185) | -| A2A AAuth Agent Identity | [a2a-aauth-agent-identity.md](feature-flows/a2a-aauth-agent-identity.md) | Prototype (flag OFF): a caller is authenticated as its own agent identity (`aauth:@`) by an RFC 9421 signature plus a self-issued `aa-agent+jwt`, verified against the caller's published key set — no bearer secret shared between instances; trusted-issuer pre-gate before any fetch, fail-closed allow-list, `external_agent` attribution (ent#623); ent#878 adds the person-server leg — an agent obtains a person token for a resource as itself after its owner binds it once via link + code (Flow 5) | +| A2A AAuth Agent Identity | [a2a-aauth-agent-identity.md](feature-flows/a2a-aauth-agent-identity.md) | Flows 1–4 behind `aauth_enabled` (Settings → Integrations, ent#862), Flows 5–6 prototype behind a second switch: a caller is authenticated as its own agent identity (`aauth:@`) by an RFC 9421 signature plus a self-issued `aa-agent+jwt`, verified against the caller's published key set — no bearer secret shared between instances; trusted-issuer pre-gate before any fetch, fail-closed allow-list, `external_agent` attribution (ent#623); ent#878 adds the person-server leg — an agent obtains a person token for a resource as itself after its owner binds it once via link + code (Flow 5) | | Trinity CLI | [cli-tool.md](feature-flows/cli-tool.md) | Python Click CLI with multi-instance profiles, mirroring core MCP tools as shell commands | | Trinity Connect | [trinity-connect.md](feature-flows/trinity-connect.md) | Local-remote agent sync via WebSocket | | Write User Memory | [write-user-memory.md](feature-flows/write-user-memory.md) | Per-user memory write MCP tool (MEM-001, #888) | diff --git a/docs/memory/feature-flows/a2a-aauth-agent-identity.md b/docs/memory/feature-flows/a2a-aauth-agent-identity.md index 5a9b87dae1..2936a01a9c 100644 --- a/docs/memory/feature-flows/a2a-aauth-agent-identity.md +++ b/docs/memory/feature-flows/a2a-aauth-agent-identity.md @@ -11,9 +11,12 @@ bootstrap: no Person Server, no Access Server, no missions) applied to the A2A seam Trinity already ships. It is an **authentication** change, not a new transport: the wire is still A2A JSON-RPC 2.0 over `POST /a2a/{name}`. -> **Status:** prototype behind `aauth_prototype_enabled`, **OFF by default**. -> With the flag off, inbound, outbound and the served card behave exactly as -> they did before (§32.8 in `docs/memory/requirements/mcp.md`). +> **Status:** Flows 1–4 graduated for Trinity-to-Trinity calls behind +> `aauth_enabled`, **OFF by default**, switchable from Settings → Integrations +> (ent#862 PR1). Flows 5–6 stay prototype behind a second switch, +> `aauth_prototype_enabled`, and need both. With the switch off, inbound, +> outbound and the served card behave exactly as they did before (§32.8 in +> `docs/memory/requirements/mcp.md`). ## Why it exists @@ -48,7 +51,8 @@ key. The token says *who this is*; the signature proves *this request is theirs* | Layer | File | Notes | |---|---|---| -| Flag + issuer | `services/aauth/config.py` | `aauth_prototype_enabled`, `aauth_issuer` — both `system_settings` → env → default. Issuer must be `https://`: no port, no path, lowercase | +| Switches + issuer | `services/aauth/config.py` | `aauth_enabled` (Flows 1–4, the single input of `active_issuer()`), `aauth_prototype_enabled` (Flows 5–6, read once at `require_live` via `prototype_active_issuer()`), `aauth_issuer` — all `system_settings` → env → default. Issuer must be `https://`: no port, no path, lowercase | +| Settings surface (ent#862) | `services/aauth/settings.py`, `routers/settings/aauth.py`, `components/settings/AAuthPanel.vue`, `stores/a2aFederation.js`, `utils/aauthSettings.js` | `GET/PUT /api/settings/aauth`: each value with its source, `live` = switch AND valid issuer, the readiness checklist; the write is admin + interactive, diff-only, `invalid_issuer` by name; the panel asks before changing a live issuer | | JOSE | `services/aauth/jose.py` | Compact JWS with the fully-specified `Ed25519` alg (PyJWT only registers `EdDSA`, which the draft forbids verifiers to accept), JWK ⇄ key, RFC 7638 thumbprints | | RFC 9421 profile | `services/aauth/httpsig.py` | Signature base, `@authority` normalisation, Content-Digest, and a **strict** RFC 8941 subset (anything unexpected is refused, not interpreted) | | Instance key | `services/aauth/keys.py` | One Ed25519 key per instance, AES-256-GCM in `system_settings.aauth_signing_key_encrypted` (Invariant #12), created on first use via insert-if-absent | @@ -79,6 +83,7 @@ key. The token says *who this is*; the signature proves *this request is theirs* | POST | `/a2a/{name}` | **AAuth** *or* bearer | The existing JSON-RPC task endpoint. A request carrying `Signature-Key` takes the AAuth path; everything else is unchanged | | POST | `/api/agents/{name}/aauth/person-token` | the agent's **own** key only | ent#878: obtain a person token for `resource` as the agent, or resume a pending binding by `pending_handle` (Flow 5). Uniform 404 while inert — before body validation | | POST | `/api/agents/{name}/aauth/resource-call` | the agent's **own** key only | ent#879: call an allow-listed AAuth resource (`{resource}{path}`) as the agent — the whole requirement ladder, every token held by the platform — or resume a pending call by `pending_handle` (Flow 6). Same gates as the person-token route, consulted before the allow-list | +| GET/PUT | `/api/settings/aauth` | admin; the write **interactive-only** | ent#862: the switches, issuer, person server and resource allow-list with their sources, `live`, the identity example and the readiness checklist. PUT writes only the fields sent; `422 invalid_{issuer,person_server,resource_allowlist}`; audited. The generic catch-all refuses all five keys | All three documents answer a uniform **404** unless AAuth is live (flag on **and** a valid issuer), so a default install publishes nothing. They are per-IP rate @@ -88,7 +93,8 @@ limited like the public card route. | Key | Where | Meaning | |---|---|---| -| `aauth_prototype_enabled` | `system_settings` → `AAUTH_PROTOTYPE_ENABLED` | Master switch, default **off** | +| `aauth_enabled` | `system_settings` → `AAUTH_ENABLED` | The switch for Flows 1–4, default **off**; Settings → Integrations → AAuth (ent#862) | +| `aauth_prototype_enabled` | `system_settings` → `AAUTH_PROTOTYPE_ENABLED` | The Flow 5–6 prototypes, default **off**; inert without `aauth_enabled` | | `aauth_issuer` | `system_settings` → `AAUTH_ISSUER` | This instance's AAuth identity domain, `https://` | | `aauth_signing_key_encrypted` | `system_settings` | The instance key envelope. Blocked on the generic settings PUT/DELETE; included in the credential-rotation script | | `aauth_person_server` | `system_settings` → `AAUTH_PERSON_SERVER` | ent#878: the person server agents bind through, `https://` (same strict rule as the issuer, never repaired). Unset → `person_server_unconfigured` | @@ -103,7 +109,7 @@ the ordinary bearer 401. ## Flow 1 — Enablement (per instance) -1. Operator sets `aauth_issuer` to the instance's public origin and turns the flag on. +1. An admin sets the issuer to the instance's public origin and turns the switch on — in Settings → Integrations → "Calls between Trinity instances" (or `AAUTH_ISSUER` / `AAUTH_ENABLED` for bootstrap; a stored row wins over the environment in both directions). The panel reports "On, inactive" until the issuer is valid, and asks before changing a live issuer, since that re-keys every agent's identity for every peer. 2. First use of the key (a signature, or a JWKS fetch) creates one Ed25519 key, encrypts it, and writes it **insert-if-absent**, then re-reads the row — so two uvicorn workers racing on first use converge on one key rather than forking. diff --git a/docs/memory/learnings/2026-10-13-a-catch-all-refusal-reprices-every-scripted-writer.md b/docs/memory/learnings/2026-10-13-a-catch-all-refusal-reprices-every-scripted-writer.md new file mode 100644 index 0000000000..0a1a323a71 --- /dev/null +++ b/docs/memory/learnings/2026-10-13-a-catch-all-refusal-reprices-every-scripted-writer.md @@ -0,0 +1,3 @@ +## 2026-10-13 — pitfall — Refusing a key on the generic settings catch-all re-prices every scripted writer of that key, and they live outside the backend tree +**Context**: trinity-enterprise#862 PR1. The AAuth switches + issuer gained a dedicated `PUT /api/settings/aauth` and the generic `PUT/DELETE /api/settings/{key}` learned to refuse those keys (the #506 / ent#14 / ent#435 shape). Every backend test was green. `scripts/dev/aauth_prototype/demo.sh` — the two-instance harness the live acceptance step depends on — still wrote `aauth_issuer` and `aauth_prototype_enabled` through the catch-all, so it would have 422'd on first run; and because the flag it wrote had been narrowed to Flows 5–6 in the same diff, even a successful write would have turned nothing on. Found only by grepping the key names across the whole tree during `/review`. +**Lesson**: (1) Blocking a key on the catch-all is a contract change for every writer of that key, and the writers that matter are the ones the test suite never runs: `scripts/**/*.sh`, ops runbooks, the trinity-ops agent, docs that quote a `curl`. Before adding a `SETTINGS_ROUTE_KEYS`-style refusal, `grep -rn '' --include='*.sh' --include='*.md' --include='*.py' .` and move each hit to the dedicated route in the same PR. (2) Narrowing what a flag governs is the same class one step removed: a writer that still flips the old flag "succeeds" and gets nothing, which is worse than a 422 — grep the flag name with the same sweep. diff --git a/docs/memory/requirements/mcp.md b/docs/memory/requirements/mcp.md index 089d12957b..86e10d6bbd 100644 --- a/docs/memory/requirements/mcp.md +++ b/docs/memory/requirements/mcp.md @@ -1030,8 +1030,10 @@ revision — the kind is a label inside the existing envelope. - **Flow**: `docs/memory/feature-flows/a2a-inbound-server.md` → Outbound endpoint allowlist ### 32.8 AAuth Agent Identity on the A2A Seam — prototype (ent#623) -- **Status**: 🧪 Prototype on a branch, flag OFF by default — not a platform - promise. Productization is decided after the 2026-09-18 call. +- **Status**: ✅ Flows 1–4 graduated for Trinity-to-Trinity calls behind + `aauth_enabled` (OFF by default; Settings → Integrations) — ent#862 PR1 (flag + split, Settings surface, outbound endpoint UI). The core inbound allow-list and + the execution-record attribution follow in ent#862 PR2/PR3. - **Implements**: trinity-enterprise#623 (evaluation home: ent#558) - **Description**: A Trinity agent calls an agent on a *different* Trinity instance and is authenticated as **itself** under AAuth's @@ -1040,11 +1042,25 @@ revision — the kind is a label inside the existing envelope. bootstrap): it publishes `/.well-known/aauth-agent.json` + a JWKS and self-issues `aa-agent+jwt` agent tokens; requests are signed per RFC 9421 with the key conveyed by `Signature-Key: sig=jwt;jwt="…"`. -- **FR-1 — Flag + issuer**: `aauth_prototype_enabled` (`system_settings` → - `AAUTH_PROTOTYPE_ENABLED` → OFF) and `aauth_issuer` (`system_settings` → - `AAUTH_ISSUER`; `https://`, no port/path, lowercase). Flag OFF, or no - valid issuer, leaves inbound, outbound and the card byte-identical to §32.2 / - §32.5. +- **FR-1 — Switches + issuer (ent#862 N3)**: `aauth_enabled` (`system_settings` + → `AAUTH_ENABLED` → OFF) governs Flows 1–4 and is the single input of + `config.active_issuer()`; `aauth_prototype_enabled` (`AAUTH_PROTOTYPE_ENABLED` + → OFF) is narrowed to the §32.9/§32.10 prototypes, which need BOTH + (`config.prototype_active_issuer()`, read only at `aauth_person.require_live`). + `aauth_issuer` (`system_settings` → `AAUTH_ISSUER`; `https://`, no + port/path, lowercase, never repaired). Switch OFF, or no valid issuer, leaves + inbound, outbound and the card byte-identical to §32.2 / §32.5 — "on, inactive" + is reported as such, never as live. **Settings surface**: `GET/PUT + /api/settings/aauth` — admin on read; admin **and interactive** (`require_interactive`, + no MCP key of any scope) on write; the body carries only the fields changed; + an invalid issuer is `422 {reason: invalid_issuer}`; audited as + `aauth_config_change`; the route also carries the §32.9 person server and + the §32.10 resource allow-list (validated, empty clears), and the generic + `PUT/DELETE /api/settings/{key}` refuses all five `aauth_*` keys + (`cso-diff-2026-10-13-ent862` F1). `GET /api/settings/feature-flags` reports `aauth_available` + (switch on AND valid issuer). Every backend compose file forwards + `AAUTH_ENABLED=${AAUTH_ENABLED:-}` (empty default, so the row stays the + observable source) — `tests/unit/test_ent862_aauth_settings.py`. - **FR-2 — Identity**: `aauth:@` — per agent, never per owner; no owner role travels with it. One instance signing key (Ed25519, AES-256-GCM at rest in `system_settings`, Invariant #12); each @@ -1077,14 +1093,22 @@ revision — the kind is a label inside the existing envelope. principal cannot make the backend sign as an agent. The request is signed over the final bytes sent. For a path-bearing `aauth` endpoint the card is read from `/.well-known/agent-card.json` first. +- **FR-8 — Outbound endpoint UI (ent#862)**: Settings → Integrations carries + the OSS registry (`GET/PUT/DELETE /api/settings/a2a-endpoints`): list, + register/update by name with `auth_scheme` (bearer | aauth — the aauth choice + hides the credential field and says a stored one is dropped), remove (asks + first), and the outbound kill switch; `register_a2a_endpoint` over MCP takes + the same `auth_scheme`. The AAuth panel shows a federation-readiness checklist + (issuer valid · switched on · outbound allowed · an aauth endpoint registered), + each unmet item naming its next step. - **Out of scope**: Person Server, missions, budgets, sub-agents, key - rotation, hardware-backed keys, UI. Stated limits: software key in the + rotation, hardware-backed keys. Stated limits: software key in the backend; identity keyed by agent name (a recreated name inherits it); the backend signs on the agent's behalf; inbound AAuth is inert without the private allow-list module. ### 32.9 AAuth Person Token — binding an agent to its owner (ent#878) -- **Status**: 🧪 Prototype on the §32.8 stack, flag OFF by default — not a +- **Status**: 🧪 Prototype on the §32.8 stack, behind BOTH `aauth_enabled` and `aauth_prototype_enabled` (ent#862), OFF by default — not a platform promise. Chained after §32.8; the resource/auth-token leg (ent#879, `call_aauth_resource`) is §32.10. - **Implements**: trinity-enterprise#878 (evaluation home: ent#558) @@ -1191,7 +1215,7 @@ revision — the kind is a label inside the existing envelope. is advertised but inert while the flag is off. ### 32.10 AAuth Resource Call — `call_aauth_resource` (ent#879) -- **Status**: 🧪 Prototype on the §32.9 stack, flag OFF by default — not a +- **Status**: 🧪 Prototype on the §32.9 stack, behind BOTH switches like §32.9 (ent#862), OFF by default — not a platform promise. Chained after §32.9 (the person token is one rung of this ladder). - **Implements**: trinity-enterprise#879 (evaluation home: ent#558) diff --git a/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.json b/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.json new file mode 100644 index 0000000000..cae2f76592 --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.json @@ -0,0 +1,41 @@ +{ + "version": "1.2.3", + "date": "2026-10-13", + "mode": "diff", + "scope": "feature/ent862-aauth-graduate vs origin/dev (merge-base 984f8f4c8)", + "issue": "abilityai/trinity-enterprise#862", + "pr": null, + "counts_as_full_run": false, + "phases_run": ["0", "1", "2-diff", "5-compose", "6-mcp", "7-ASI03", "9-A01", "9-A10", "12", "13", "14"], + "findings": [ + { + "id": 1, + "severity": "MEDIUM", + "confidence": 9, + "status": "VERIFIED", + "evidence": "SUPPORTED", + "category": "settings-generic-door", + "owasp_2025": "A01", + "asi": "ASI03", + "llm": null, + "phase": "9", + "file": "src/backend/routers/settings/generic.py", + "line": 183, + "title": "aauth_person_server / aauth_resource_allowlist still writable via the admin-only generic settings catch-all", + "pre_existing": true, + "verifier": "independent agent — CONFIRMED, MEDIUM", + "remediation": "applied in-branch after the audit: both keys join SETTINGS_ROUTE_KEYS and PUT /api/settings/aauth takes person_server + resource_allowlist (validated, empty clears); demo.sh moved to the route", + "closure_evidence": "tests/unit/test_ent862_aauth_settings.py generic-door parametrisation over five keys" + } + ], + "hypotheses": [], + "coverage_gaps": [ + "full-history secrets archaeology not run (diff mode)", + "dependency scanners not run (no dependency change in diff)", + "CI/CD workflow audit not run (no workflow change in diff)", + "image internals / Docker pass not run (no Dockerfile change in diff)", + "frontend panels not exercised by a human in both themes" + ], + "supply_chain_summary": {"scanner": "none (no dependency change)", "new_install_scripts": 0}, + "totals": {"critical": 0, "high": 0, "medium": 1, "low": 0} +} diff --git a/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.md b/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.md new file mode 100644 index 0000000000..aa884aba40 --- /dev/null +++ b/docs/security-reports/cso-diff-2026-10-13-ent862-aauth-settings.md @@ -0,0 +1,47 @@ +# CSO Diff Audit — trinity-enterprise#862 PR1: AAuth flag split + Settings surface + +- **Date**: 2026-10-13 · **Mode**: `--diff` (daily gate 8/10) · **Branch**: `feature/ent862-aauth-graduate` vs `origin/dev` (merge-base `984f8f4c8`, working tree audited pre-commit) +- **Scope**: `src/backend/services/aauth/{config,settings,whoami}.py`, `routers/settings/{aauth,generic,flags,__init__}.py`, `routers/aauth_person.py`, `models.py`, `src/mcp-server/src/{client.ts,tools/a2a.ts,tools/aauth.ts}`, `src/frontend/src/{components/settings/AAuthPanel.vue,components/settings/A2AEndpointsPanel.vue,stores/a2aFederation.js,utils/aauthSettings.js,views/Settings.vue}`, three `docker-compose*.yml` + `scripts/dev/aauth_prototype/{compose.b.yml,demo.sh}`, `.env.example`, docs, tests + +## Verdict: 1 finding (MEDIUM, pre-existing, remediated in-branch after the audit). No new door opened. + +The diff adds one grant surface and closes a generic door around it. The grant surface — `PUT /api/settings/aauth` — is gated the way Invariant #8 asks of a grant (`require_interactive` + `assert_admin`: no MCP key of any scope), writes only the fields sent, validates the issuer by name, and audits `{from, to}`. The generic `PUT/DELETE /api/settings/{key}` now refuses the three keys that route owns. The finding is that it stopped at three. + +``` +SECURITY FINDINGS +═════════════════ +# Sev Conf Status Evidence Category Finding Phase File:Line +── ──── ──── ────── ──────── ──────── ─────── ───── ───────── +1 MED 9/10 VERIFIED SUPPORTED settings-generic-door aauth_person_server / aauth_resource_allowlist still P9 routers/settings/generic.py:183, :628 + writable via the admin-only generic catch-all +``` + +### F1 — the two Flow 5–6 keys stayed on the generic door (MEDIUM · 9/10 · VERIFIED · SUPPORTED · OWASP A01:2025 · ASI03) +- **Evidence**: `services/aauth/config.py:43` `SETTINGS_ROUTE_KEYS = frozenset({FLAG_SETTING, PROTOTYPE_FLAG_SETTING, ISSUER_SETTING})`; `routers/settings/generic.py:183` and `:628` gate `update_setting` / `delete_setting` with `assert_admin(current_user)` alone, which admits `ADMIN_GATE_SCOPES = {None, "user", "system"}` (`dependencies.py`). Neither key is credential-shaped for the ent#435 sink guard (`_allowlist`, `_server` match no suffix), so nothing else refuses the write. The branch's own comment on the refusal block — *"an MCP key carrying an admin owner's role would otherwise flip them"* — applies verbatim to the two keys it left out. +- **Exploit scenario**: (1) an attacker holds an admin's user- or system-scoped MCP key (a leaked `trinity-ops-agent` key, a key in a shared `.mcp.json`); (2) `PUT /api/settings/aauth_resource_allowlist {"value": "https://attacker.example"}` passes `assert_admin`; (3) any agent later induced to name that resource (the LLM-supplied-URL path the allowlist exists to fence) sends it a request signed as the agent and carries the person-server ladder to it. Repointing `aauth_person_server` lets an attacker-run person server mediate owner binding — only while the prototype switch is on, which this diff made human-only. +- **Prerequisites that keep it MEDIUM**: an already-compromised admin-role key; a second actor (an agent's own key) to realise egress; and the same key can already register a credentialed outbound A2A endpoint (`PUT /api/settings/a2a-endpoints` is `assert_admin` + `reject_agent_principal`, not interactive) — key-driven egress grants are the existing baseline. +- **Pre-existing**: yes — `origin/dev` has both keys with no catch-all guard; the diff narrowed the door and did not widen it. +- **Verifier**: independent fresh-context agent — CONFIRMED, MEDIUM, "pre-existing; the branch narrows it but does not close it". +- **Remediation (applied in-branch after this audit, per approved plan decision #16 "refuse ALL `aauth_*` keys")**: `PUT /api/settings/aauth` takes `person_server` (strict server-identifier rule, empty clears) and `resource_allowlist` (must parse under `resource_allowlist.parse_allowlist`, empty clears); both keys join `SETTINGS_ROUTE_KEYS`; `demo.sh` moves its two remaining generic writes to the route. Closure evidence: the parametrised generic-door tests in `tests/unit/test_ent862_aauth_settings.py` cover five keys. + +## Checks run (diff mode; phases 0, 1, 12–14 + diff-relevant rows) + +| Check | Result | +|---|---| +| Attack-surface delta | +2 routes (`GET/PUT /api/settings/aauth`: admin; write interactive-only), +1 MCP parameter (`auth_scheme` on `register_a2a_endpoint`, same human-only backend route), +4 compose env forwards (a boolean, empty default), +2 Settings panels. No new agent-context ingress, WebSocket channel, webhook, or Docker permission | +| Grant-vs-use (ASI03, Invariant #8) | `update_aauth_settings` = `Depends(require_interactive)` + `assert_admin`; tested against agent-, user- and system-scoped keys (403) and a non-admin JWT (403). Guard suites green: `test_293_admin_gate_rejects_agent_keys`, `test_2996_human_only_routes`, `test_1310_auth_wiring`, `test_186_enumeration_uniformity`, `test_2094_dependency_path_param_pairing`, `test_ent435_settings_sink_guard` (144 pass) | +| Flag split (A10 failure direction) | Both switches resolve through `settings_service._resolve_bool_flag` (row wins both ways; unreadable row → env → **off**) and state fail-closed in their docstrings; Flows 5–6 gated once at `aauth_person.require_live` on BOTH switches (4-arm test); internal readers unchanged so one issuer is compared throughout | +| Generic catch-all | Refuses `aauth_enabled`, `aauth_prototype_enabled`, `aauth_issuer` on PUT and DELETE (6 tests) — F1 for the two keys it missed | +| Input validation | Issuer never repaired: `normalize_issuer` refuses http, port, path, mixed case, IP literal → `422 {reason: invalid_issuer}` (5 cases); booleans are `StrictBool` (`1` refused); `extra="forbid"`; `nothing_to_change` → 400 | +| Credential exposure | Nothing new stored; the audit row carries switch/issuer `{from,to}` only; the MCP tool refuses `credentials` together with `auth_scheme: aauth` before any round trip; `A2AEndpointsPanel` hides the credential field for aauth and sends no `credentials` key on a blank bearer update (mounted tests) | +| Secrets in diff | Known-prefix scan over added lines: none; no tracked `.env`; hidden-Unicode grep over changed files: none | +| LLM05 output handling | No `v-html`/`innerHTML` in the new panels; dialog copy interpolates through Vue text binding | +| Enterprise-docs guard | `enterprise-docs-guard.yml` pattern (ugrep `-P`) over added `docs/` + seam lines: no hits | +| Compose wiring | `AAUTH_ENABLED=${AAUTH_ENABLED:-}` in `docker-compose.yml`, `.prod.yml`, `.hosted.yml` (empty default keeps the row the observable source); `compose.b.yml` sets it outright like the prototype flag; YAML-parse test pins all four | +| Writers outside the backend | `scripts/dev/aauth_prototype/demo.sh` moved to the dedicated route for the three blocked keys (found in `/review`); F1 remediation moves the remaining two | + +## Hypotheses +(none — daily mode) + +## Coverage gaps +Diff mode: secrets archaeology over full history, dependency scanners, CI/CD workflow audit, image internals and the Docker pass were not run — the diff changes no dependency, workflow, or Dockerfile. The frontend panels were exercised by mounted vitest specs, not by a human in a browser in both themes (the design-contract check the PR self-check still owes). diff --git a/scripts/dev/aauth_prototype/compose.b.yml b/scripts/dev/aauth_prototype/compose.b.yml index dd421bb15c..9f7131787c 100644 --- a/scripts/dev/aauth_prototype/compose.b.yml +++ b/scripts/dev/aauth_prototype/compose.b.yml @@ -7,6 +7,7 @@ services: # edited during the demo. command: uvicorn main:app --host 0.0.0.0 --port 8000 --no-server-header environment: + - AAUTH_ENABLED=true - AAUTH_PROTOTYPE_ENABLED=true - AAUTH_ISSUER=${AAUTH_ISSUER:?AAUTH_ISSUER must be set} # The served agent card's `url` must be the public origin, or A's client diff --git a/scripts/dev/aauth_prototype/demo.sh b/scripts/dev/aauth_prototype/demo.sh index a3bdd352df..a407ce21e5 100755 --- a/scripts/dev/aauth_prototype/demo.sh +++ b/scripts/dev/aauth_prototype/demo.sh @@ -195,10 +195,11 @@ wire() { b_tok=$(token_for "$B_API" "$(envval "$STATE/b.env" ADMIN_PASSWORD)") say "A: issuer $a_url, person server $PERSON_SERVER, resources $SENZING + $WHOAMI, outbound on, endpoint bravo-remote → $b_url/a2a/$B_AGENT (aauth, no credential)" - setting "$A_API" "$a_tok" aauth_issuer "$a_url" - setting "$A_API" "$a_tok" aauth_person_server "$PERSON_SERVER" # ent#878 (bind.sh needs it) - setting "$A_API" "$a_tok" aauth_resource_allowlist "$SENZING,$WHOAMI" # ent#879 (origin equality, fail-closed) - setting "$A_API" "$a_tok" aauth_prototype_enabled true + # ent#862: the switches + issuer go through their own route (the generic + # settings PUT refuses those keys); Flows 5-6 need BOTH switches. + api "$A_API" "$a_tok" PUT /api/settings/aauth \ + "{\"issuer\": \"$a_url\", \"enabled\": true, \"prototype_enabled\": true, + \"person_server\": \"$PERSON_SERVER\", \"resource_allowlist\": \"$SENZING,$WHOAMI\"}" >/dev/null setting "$A_API" "$a_tok" a2a_outbound_enabled true api "$A_API" "$a_tok" PUT /api/settings/a2a-endpoints \ "{\"name\": \"bravo-remote\", \"url\": \"$b_url/a2a/$B_AGENT\", \"auth_scheme\": \"aauth\"}"; echo @@ -352,11 +353,10 @@ down() { done docker rm -f aauth-front-a aauth-front-b "$DIND" >/dev/null 2>&1 || true local a_tok; a_tok=$(token_for "$A_API" "$(envval "$REPO/.env" ADMIN_PASSWORD)" 2>/dev/null) || return 0 - setting "$A_API" "$a_tok" aauth_prototype_enabled false + api "$A_API" "$a_tok" PUT /api/settings/aauth \ + '{"enabled": false, "prototype_enabled": false, "person_server": "", "resource_allowlist": ""}' >/dev/null || true setting "$A_API" "$a_tok" a2a_outbound_enabled false api "$A_API" "$a_tok" DELETE /api/settings/a2a-endpoints/bravo-remote >/dev/null || true - api "$A_API" "$a_tok" DELETE /api/settings/aauth_resource_allowlist >/dev/null || true # ent#879 - api "$A_API" "$a_tok" DELETE /api/settings/aauth_person_server >/dev/null || true # ent#878 echo " A: AAuth + outbound switched off, demo endpoint, resource allow-list and person server removed" echo " (B's data volume trinity-b-dind-data kept; 'docker volume rm trinity-b-dind-data' resets B)" } diff --git a/src/backend/models.py b/src/backend/models.py index faabe388d2..299796b6d3 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -7,7 +7,7 @@ import re import unicodedata -from pydantic import BaseModel, ConfigDict, Discriminator, EmailStr, Field, PrivateAttr, SecretStr, StrictInt, Tag, field_validator, model_validator +from pydantic import BaseModel, ConfigDict, Discriminator, EmailStr, Field, PrivateAttr, SecretStr, StrictBool, StrictInt, Tag, field_validator, model_validator from typing import Annotated, Any, Dict, List, Literal, Optional, Union from datetime import datetime from enum import Enum @@ -4320,6 +4320,26 @@ class TemplateRegistryUpdate(BaseModel): enabled: Optional[bool] = None +class AAuthSettingsUpdate(BaseModel): + """PUT body for `/api/settings/aauth` (abilityai/trinity-enterprise#862). + + Partial: an omitted field is left untouched, so the panel sends only what + the admin changed and a save never freezes an untouched value into a row + (learnings 2026-09-29). `issuer` is validated at the service by the strict + server-identifier rule; an empty string clears the stored one. Booleans are + strict — `1` is refused, not stored as `true`. + """ + model_config = ConfigDict(extra="forbid") + + enabled: Optional[StrictBool] = None + issuer: Optional[str] = Field(default=None, max_length=260) + prototype_enabled: Optional[StrictBool] = None + # The Flow 5–6 knobs (ent#878 / ent#879), on this route rather than the + # generic catch-all for the same reason as the issuer (cso F1). + person_server: Optional[str] = Field(default=None, max_length=260) + resource_allowlist: Optional[str] = Field(default=None, max_length=16 * 1024) + + class McpUrlUpdate(BaseModel): """Request body for updating the MCP server URL.""" url: str diff --git a/src/backend/routers/aauth_person.py b/src/backend/routers/aauth_person.py index 429a9e62e1..ffc4c4a541 100644 --- a/src/backend/routers/aauth_person.py +++ b/src/backend/routers/aauth_person.py @@ -126,8 +126,13 @@ def require_live() -> None: Public: `routers/aauth_resource.py` (ent#879) takes the same gate — one mechanism for every route that signs as the agent, never a second copy. + + ent#862: these are the Flow 5–6 prototypes, so the gate needs BOTH the + graduated AAuth switch and the prototype switch. This is the only place the + prototype switch is read; the services behind the routes keep reading + `active_issuer()`. """ - if not aauth_config.active_issuer(): + if not aauth_config.prototype_active_issuer(): raise HTTPException(status_code=404, detail="Not found") diff --git a/src/backend/routers/settings/__init__.py b/src/backend/routers/settings/__init__.py index 23e78c4019..282fdbaf0c 100644 --- a/src/backend/routers/settings/__init__.py +++ b/src/backend/routers/settings/__init__.py @@ -25,6 +25,7 @@ from database import SystemSetting from . import ( + aauth, agent_defaults, credentials, provider_keys, @@ -70,6 +71,7 @@ router.include_router(mcp_url.router) router.include_router(agent_defaults.router) router.include_router(integrations.router) +router.include_router(aauth.router) # ent#862 the AAuth switch + issuer router.include_router(ops.router) # … and the `/{key}` catch-all LAST. See the module docstring. router.include_router(generic.router) diff --git a/src/backend/routers/settings/aauth.py b/src/backend/routers/settings/aauth.py new file mode 100644 index 0000000000..2a05de07c5 --- /dev/null +++ b/src/backend/routers/settings/aauth.py @@ -0,0 +1,61 @@ +"""AAuth agent identity — the switch and the issuer (abilityai/trinity-enterprise#862). + +`GET/PUT /api/settings/aauth`. Turning AAuth on publishes this instance's key +and starts accepting signed calls from agents on other instances that an agent +here has listed; changing the issuer changes the identity of every agent on the +instance. Both are grants, so the write is admin AND interactive: an MCP key of +any scope (agent, user, system) is refused — `assert_admin` alone admits a +user- or system-scoped key carrying an admin owner's role. + +The rules live in `services.aauth.settings`; this module holds the gates and +the audit row (Invariant #1). +""" +from fastapi import APIRouter, Depends, HTTPException, Request + +from dependencies import assert_admin, get_current_user, require_interactive +from models import AAuthSettingsUpdate, User +from services.aauth import settings as aauth_settings +from services.platform_audit_service import AuditEventType, platform_audit_service + +router = APIRouter() + + +@router.get("/aauth") +async def get_aauth_settings(current_user: User = Depends(get_current_user)): + """The switches, the issuer, where each value came from, and readiness. Admin-only.""" + assert_admin(current_user) + return aauth_settings.get_state() + + +@router.put("/aauth") +async def update_aauth_settings( + body: AAuthSettingsUpdate, + request: Request, + current_user: User = Depends(require_interactive), +): + """Change the switches and/or the issuer. Admin and interactive only. Audited. + + Only the fields sent are written. Nothing is repaired: an invalid issuer, + person server or resource allow-list is `422 {reason: invalid_}`. + """ + assert_admin(current_user) + try: + changes = aauth_settings.apply_update( + enabled=body.enabled, issuer=body.issuer, prototype_enabled=body.prototype_enabled, + person_server=body.person_server, resource_allowlist_raw=body.resource_allowlist, + ) + except aauth_settings.AAuthSettingsError as e: + status = 400 if e.reason == "nothing_to_change" else 422 + raise HTTPException(status_code=status, detail={"reason": e.reason, "message": e.message}) + + await platform_audit_service.log( + event_type=AuditEventType.CONFIGURATION, + event_action="aauth_config_change", + source="api", + actor_user=current_user, + actor_ip=request.client.host if request.client else None, + endpoint=request.scope["path"], + request_id=getattr(request.state, "request_id", None), + details={"setting": "aauth", "changes": changes}, + ) + return aauth_settings.get_state() diff --git a/src/backend/routers/settings/flags.py b/src/backend/routers/settings/flags.py index ddcb64d5a8..cfc2c63ece 100644 --- a/src/backend/routers/settings/flags.py +++ b/src/backend/routers/settings/flags.py @@ -146,6 +146,7 @@ async def get_public_feature_flags( ) from services.entitlement_service import entitlement_service from services import a2a_outbound_service + from services.aauth import config as aauth_config from models import CANVAS_MAX_PER_AGENT # Function-local (#2217): a top-level import would pull the whole canary # package into the settings-router load; the handler pattern here is @@ -218,6 +219,10 @@ async def get_public_feature_flags( # both call routes 404 when off. Observability + UI gating only — the # routes enforce it themselves. "a2a_outbound_available": a2a_outbound_service.is_outbound_enabled(), + # AAuth agent identity (ent#862) — true when it is LIVE (switch on AND a + # valid issuer), not merely switched on: "on but inert" is off in effect. + # Observability + UI gating only; every AAuth route enforces it itself. + "aauth_available": aauth_config.active_issuer() is not None, "platform_default_model": settings_service.get_platform_default_model(), # ent#553: the per-agent canvas ceiling, so the UI can WARN before the # agent meets the refusal instead of only reporting it afterwards. An diff --git a/src/backend/routers/settings/generic.py b/src/backend/routers/settings/generic.py index 07caf6f857..e35c549c27 100644 --- a/src/backend/routers/settings/generic.py +++ b/src/backend/routers/settings/generic.py @@ -422,6 +422,22 @@ async def update_setting( detail=f"{key} is managed by the AAuth key service and cannot be set through the API", ) + # ent#862: the AAuth switches and the issuer are grants (publish this + # instance's key, accept signed callers, change every agent's identity), so + # they go through the dedicated route, which is admin AND interactive and + # validates the issuer. This catch-all is admin-only: an MCP key carrying an + # admin owner's role would otherwise flip them. + from services.aauth import config as aauth_config + + if key in aauth_config.SETTINGS_ROUTE_KEYS: + raise HTTPException( + status_code=422, + detail=( + f"{key} must be set via PUT /api/settings/aauth " + "(admin + interactive-only, issuer validated, audit-logged)" + ), + ) + if key == "a2a_trusted_networks": raise HTTPException( status_code=422, @@ -638,6 +654,19 @@ async def delete_setting( detail=f"{key} is managed by the AAuth key service and cannot be cleared through the API", ) + # ent#862: same reason as on PUT — clearing the issuer or a switch is a change + # to this instance's AAuth posture, made through the dedicated route. + from services.aauth import config as aauth_config + + if key in aauth_config.SETTINGS_ROUTE_KEYS: + raise HTTPException( + status_code=422, + detail=( + f"{key} must be changed via PUT /api/settings/aauth " + "(admin + interactive-only, audit-logged)" + ), + ) + # ent#14: blocked here as well as on PUT, unlike the #1644 retention acks. # Deleting an ack re-arms a guard and therefore fails safe; deleting # `template_registry_enabled` reverts it to its default of ON, which diff --git a/src/backend/services/aauth/config.py b/src/backend/services/aauth/config.py index 217f0d6f89..7715fd9b86 100644 --- a/src/backend/services/aauth/config.py +++ b/src/backend/services/aauth/config.py @@ -1,6 +1,16 @@ -"""Flag + issuer resolution for the AAuth prototype (ent#623). +"""Flags + issuer resolution for AAuth (ent#623, graduated by ent#862). -Both are runtime-resolved (``system_settings`` → env → default), the +Two switches (ent#862, decision N3): + +* ``aauth_enabled`` — the graduated agent identity, Flows 1–4 (well-known + documents, outbound signing, inbound verification, card / 401 + advertisement). :func:`active_issuer` is its single reader. +* ``aauth_prototype_enabled`` — the person-token and resource-calling + prototypes (Flows 5–6) only. They require BOTH switches: + :func:`prototype_active_issuer`, consulted once, at + ``routers.aauth_person.require_live``. + +Every value is runtime-resolved (``system_settings`` → env → default), the ``a2a_outbound_enabled`` shape, so an admin flip needs no restart. The issuer is this instance's AAuth server identifier and therefore the domain of every agent identity it asserts: ``https://`` — https, host only, no port, no path, no @@ -16,8 +26,10 @@ from typing import Optional from urllib.parse import urlsplit -FLAG_SETTING = "aauth_prototype_enabled" -FLAG_ENV = "AAUTH_PROTOTYPE_ENABLED" +FLAG_SETTING = "aauth_enabled" +FLAG_ENV = "AAUTH_ENABLED" +PROTOTYPE_FLAG_SETTING = "aauth_prototype_enabled" +PROTOTYPE_FLAG_ENV = "AAUTH_PROTOTYPE_ENABLED" ISSUER_SETTING = "aauth_issuer" ISSUER_ENV = "AAUTH_ISSUER" #: ent#878: the person server this instance's agents bind through (one HTTPS @@ -25,6 +37,20 @@ #: requests fail closed with ``person_server_unconfigured``. PERSON_SERVER_SETTING = "aauth_person_server" PERSON_SERVER_ENV = "AAUTH_PERSON_SERVER" +#: ent#879: the AAuth resources agents may call — parsed by +#: ``services.aauth.resource_allowlist`` (which imports this name, not a twin). +RESOURCE_ALLOWLIST_SETTING = "aauth_resource_allowlist" +RESOURCE_ALLOWLIST_ENV = "AAUTH_RESOURCE_ALLOWLIST" + +#: The keys only `PUT /api/settings/aauth` may write (ent#862); the generic +#: settings catch-all refuses them on PUT and DELETE. The person server and the +#: resource allow-list are here too (cso-diff-2026-10-13-ent862 F1): they decide +#: which host mediates owner binding and where a signed agent request may go — +#: the same grant class as the issuer, so the same human-only door. +SETTINGS_ROUTE_KEYS = frozenset({ + FLAG_SETTING, PROTOTYPE_FLAG_SETTING, ISSUER_SETTING, + PERSON_SERVER_SETTING, RESOURCE_ALLOWLIST_SETTING, +}) #: A DNS host: labels of letters/digits/hyphens, lowercase only (the issuer #: rule), at least one dot. IP literals are not server identifiers here. @@ -36,7 +62,7 @@ def is_enabled() -> bool: - """The prototype flag. Default OFF. + """The graduated AAuth switch (Flows 1–4). Default OFF. Fail-closed: a flag that cannot be read is OFF, the same direction `get_issuer` takes on an unreadable issuer. The card builder (ent#180, the @@ -53,6 +79,21 @@ def is_enabled() -> bool: return False +def is_prototype_enabled() -> bool: + """The prototype switch, narrowed to Flows 5–6 by ent#862. Default OFF. + + On its own it turns nothing on: :func:`prototype_active_issuer` also needs + :func:`is_enabled`. Fail-closed exactly like the graduated switch. + """ + from services.settings_service import settings_service + + try: + return bool(settings_service._resolve_bool_flag( + PROTOTYPE_FLAG_SETTING, PROTOTYPE_FLAG_ENV, default=False)) + except Exception: # noqa: BLE001 — an unreadable flag means "not enabled" + return False + + def normalize_issuer(value: Optional[str]) -> Optional[str]: """Return ``value`` if it is a valid AAuth server identifier, else None. @@ -110,12 +151,24 @@ def get_person_server() -> Optional[str]: def active_issuer() -> Optional[str]: - """The issuer when AAuth is live (flag ON and a valid issuer), else None.""" + """The issuer when AAuth is live (graduated flag ON and a valid issuer), else None.""" if not is_enabled(): return None return get_issuer() +def prototype_active_issuer() -> Optional[str]: + """The issuer when the Flow 5–6 prototypes are live: BOTH switches and an issuer. + + Read only by the routes' single gate (``aauth_person.require_live``). The + services behind those routes keep reading :func:`active_issuer`, so a call + that got past the gate compares one issuer throughout (ent#862 review). + """ + if not is_prototype_enabled(): + return None + return active_issuer() + + def agent_identity(agent_name: str, issuer: str) -> str: """``aauth:@`` (protocol draft, Agent Identifiers).""" return f"aauth:{agent_name}@{issuer_host(issuer)}" diff --git a/src/backend/services/aauth/resource_allowlist.py b/src/backend/services/aauth/resource_allowlist.py index d27850715a..3ea9f5f3b2 100644 --- a/src/backend/services/aauth/resource_allowlist.py +++ b/src/backend/services/aauth/resource_allowlist.py @@ -27,8 +27,8 @@ from services.aauth import config -SETTING = "aauth_resource_allowlist" -ENV = "AAUTH_RESOURCE_ALLOWLIST" +SETTING = config.RESOURCE_ALLOWLIST_SETTING +ENV = config.RESOURCE_ALLOWLIST_ENV MAX_RAW_CHARS = 16 * 1024 MAX_ENTRIES = 100 diff --git a/src/backend/services/aauth/settings.py b/src/backend/services/aauth/settings.py new file mode 100644 index 0000000000..c692b2c287 --- /dev/null +++ b/src/backend/services/aauth/settings.py @@ -0,0 +1,185 @@ +"""The admin settings surface for AAuth (abilityai/trinity-enterprise#862). + +Reads and writes the switches and the issuer behind `GET/PUT +/api/settings/aauth`. The router holds the gates and the audit row; this module +holds the rules (Invariant #1): + +* every value reports WHERE it came from (`settings` row, `env`, or the + `default`), because the row wins in both directions and an operator looking + at "on" needs to know whether turning it off here will stick; +* `live` is the honest answer — switched on without a valid issuer is OFF in + effect, and the panel says so instead of showing a green toggle; +* the issuer is never repaired: a value that needs fixing is refused by name + (`invalid_issuer`), the same strict rule the resolver applies; +* writes touch only the fields the request carried. +""" +from __future__ import annotations + +import os +from typing import Any, Dict, Optional + +from services.aauth import config, resource_allowlist + +class AAuthSettingsError(ValueError): + """A refused update, with a stable reason code for the response.""" + + def __init__(self, reason: str, message: str): + super().__init__(message) + self.reason = reason + self.message = message + + +def _db(): + from database import db + + return db + + +def _flag(setting: str, env: str) -> Dict[str, Any]: + """The value through the ONE resolver every gate uses, plus which tier answered. + + The value is never re-derived here: a second truthy set would let the panel + show "on" for a row the gate reads as off. + """ + from services.settings_service import settings_service + + value = bool(settings_service._resolve_bool_flag(setting, env, default=False)) + if _db().get_setting_value(setting, None) is not None: + source = "settings" + elif os.getenv(env, "").strip(): + source = "env" + else: + source = "default" + return {"value": value, "source": source} + + +def _text(setting: str, env: str) -> Dict[str, Any]: + """A string knob with its source (row → env → unset), never re-validated here.""" + stored = _db().get_setting_value(setting, None) + if stored: + return {"value": stored, "source": "settings"} + value = os.getenv(env, "").strip() + if value: + return {"value": value, "source": "env"} + return {"value": "", "source": "default"} + + +def _readiness(issuer_valid: bool) -> Dict[str, Any]: + """What else a call to another instance needs, beyond this switch. + + Read-only facts, each of which an operator otherwise discovers one refusal + at a time: outbound calling has its own kill switch, and an agent can only + call a peer through an endpoint registered with the `aauth` scheme. + """ + from services import a2a_outbound, a2a_outbound_service + + try: + endpoints = a2a_outbound.list_oss_endpoints() + except Exception: # noqa: BLE001 — a readiness hint never fails the read + endpoints = [] + return { + "issuer_valid": issuer_valid, + "outbound_enabled": bool(a2a_outbound_service.is_outbound_enabled()), + "endpoints": len(endpoints), + "aauth_endpoints": sum(1 for e in endpoints if e.get("auth_scheme") == "aauth"), + } + + +def get_state() -> Dict[str, Any]: + enabled = _flag(config.FLAG_SETTING, config.FLAG_ENV) + prototype = _flag(config.PROTOTYPE_FLAG_SETTING, config.PROTOTYPE_FLAG_ENV) + issuer = _text(config.ISSUER_SETTING, config.ISSUER_ENV) + valid = config.normalize_issuer(issuer["value"]) if issuer["value"] else None + host = config.issuer_host(valid) if valid else "" + person_server = _text(config.PERSON_SERVER_SETTING, config.PERSON_SERVER_ENV) + allowlist = _text(config.RESOURCE_ALLOWLIST_SETTING, config.RESOURCE_ALLOWLIST_ENV) + return { + "enabled": enabled["value"], + "enabled_source": enabled["source"], + "issuer": issuer["value"], + "issuer_source": issuer["source"], + "issuer_valid": valid is not None, + "live": bool(enabled["value"] and valid), + "identity_example": f"aauth:@{host}" if host else "", + "prototype_enabled": prototype["value"], + "person_server": person_server["value"], + "person_server_source": person_server["source"], + "resource_allowlist": allowlist["value"], + "resource_allowlist_source": allowlist["source"], + "resource_allowlist_count": len(resource_allowlist.parse_allowlist(allowlist["value"])), + "readiness": _readiness(valid is not None), + } + + +def apply_update(*, enabled: Optional[bool] = None, issuer: Optional[str] = None, + prototype_enabled: Optional[bool] = None, + person_server: Optional[str] = None, + resource_allowlist_raw: Optional[str] = None) -> Dict[str, Dict[str, Any]]: + """Write the fields that were sent; return ``{field: {from, to}}`` for the audit row. + + Validates everything before writing anything, so a refused value never + leaves a half-applied update behind. + """ + sent = (enabled, issuer, prototype_enabled, person_server, resource_allowlist_raw) + if all(v is None for v in sent): + raise AAuthSettingsError( + "nothing_to_change", + "Send at least one of `enabled`, `issuer`, `prototype_enabled`, " + "`person_server`, `resource_allowlist`.") + + if issuer is not None: + issuer = issuer.strip() + if issuer and config.normalize_issuer(issuer) is None: + raise AAuthSettingsError( + "invalid_issuer", + "The issuer must be https:// — lowercase, no port, no path, " + "not an IP address (e.g. https://trinity.example.com).", + ) + if person_server is not None: + person_server = person_server.strip() + if person_server and config.normalize_issuer(person_server) is None: + raise AAuthSettingsError( + "invalid_person_server", + "The person server must be https:// — lowercase, no port, no " + "path (e.g. https://person.example.com).", + ) + if resource_allowlist_raw is not None: + resource_allowlist_raw = resource_allowlist_raw.strip() + # The sink reads a list it cannot parse as NOTHING; storing one would + # look configured while allowing no resource at all. + if resource_allowlist_raw and not resource_allowlist.parse_allowlist(resource_allowlist_raw): + raise AAuthSettingsError( + "invalid_resource_allowlist", + "Every entry must be an https:// origin — lowercase, no port, " + "no path — as a comma-separated list or a JSON array, at most " + f"{resource_allowlist.MAX_ENTRIES} entries (e.g. https://senzing.example.com).", + ) + + db = _db() + changes: Dict[str, Dict[str, Any]] = {} + + def _set_flag(field: str, setting: str, value: Optional[bool]) -> None: + if value is None: + return + before = db.get_setting_value(setting, None) + db.set_setting(setting, "true" if value else "false") + changes[field] = {"from": before, "to": "true" if value else "false"} + + _set_flag("enabled", config.FLAG_SETTING, enabled) + _set_flag("prototype_enabled", config.PROTOTYPE_FLAG_SETTING, prototype_enabled) + + def _set_text(field: str, setting: str, value: Optional[str]) -> None: + if value is None: + return + before = db.get_setting_value(setting, None) + if value: + db.set_setting(setting, value) + else: + db.delete_setting(setting) + changes[field] = {"from": before, "to": value or None} + + _set_text("issuer", config.ISSUER_SETTING, issuer) + _set_text("person_server", config.PERSON_SERVER_SETTING, person_server) + _set_text("resource_allowlist", config.RESOURCE_ALLOWLIST_SETTING, resource_allowlist_raw) + + return changes diff --git a/src/backend/services/aauth/whoami.py b/src/backend/services/aauth/whoami.py index 8819e51000..f811ceed80 100644 --- a/src/backend/services/aauth/whoami.py +++ b/src/backend/services/aauth/whoami.py @@ -5,7 +5,7 @@ Proof against a verifier that is not ours: whoami.aauth.dev fetches this instance's ``/.well-known/aauth-agent.json`` + JWKS over the public internet, verifies the agent token and the RFC 9421 signature, and echoes the identity. -Requires the prototype flag, a public ``aauth_issuer``, and the issuer's +Requires ``aauth_enabled`` (ent#862), a public ``aauth_issuer``, and the issuer's well-known documents reachable from the internet. Not an endpoint on purpose: signing as an agent is only placed on the A2A call diff --git a/src/frontend/src/components/settings/A2AEndpointsPanel.vue b/src/frontend/src/components/settings/A2AEndpointsPanel.vue new file mode 100644 index 0000000000..f832044a0b --- /dev/null +++ b/src/frontend/src/components/settings/A2AEndpointsPanel.vue @@ -0,0 +1,297 @@ + + + diff --git a/src/frontend/src/components/settings/AAuthPanel.vue b/src/frontend/src/components/settings/AAuthPanel.vue new file mode 100644 index 0000000000..91db1a32a1 --- /dev/null +++ b/src/frontend/src/components/settings/AAuthPanel.vue @@ -0,0 +1,238 @@ + + + diff --git a/src/frontend/src/stores/a2aFederation.js b/src/frontend/src/stores/a2aFederation.js new file mode 100644 index 0000000000..c06b46344b --- /dev/null +++ b/src/frontend/src/stores/a2aFederation.js @@ -0,0 +1,112 @@ +import { defineStore } from 'pinia' +import api from '../api' + +/** + * Calling agents on other Trinity instances (abilityai/trinity-enterprise#862): + * this instance's AAuth switch + issuer, and the platform-wide outbound A2A + * endpoint registry `call_a2a_agent` resolves against (#736). + * + * Admin-only surfaces; callers gate on admin before `load*()`. Each half keeps + * its own `*Loaded` latch and error, so a failed fetch renders LoadFailed and a + * failed refresh keeps the data on screen (loading means "no data yet"). + */ +function detailOf(e, fallback) { + const d = e?.response?.data?.detail + if (typeof d === 'string') return d + if (d?.message) return d.message + if (d?.error) return d.error + return fallback +} + +export const useA2aFederationStore = defineStore('a2aFederation', { + state: () => ({ + aauth: null, + aauthLoaded: false, + aauthLoading: false, + aauthError: '', + endpoints: [], + outboundEnabled: false, + endpointsLoaded: false, + endpointsLoading: false, + endpointsError: '', + }), + + actions: { + async loadAAuth() { + this.aauthLoading = true + try { + const { data } = await api.get('/api/settings/aauth') + this.aauth = data + this.aauthError = '' + this.aauthLoaded = true + } catch (e) { + this.aauthError = detailOf(e, "Couldn't load the AAuth settings.") + } finally { + this.aauthLoading = false + } + }, + + /** The AAuth checklist reads the endpoint registry; refresh it in place. */ + async refreshReadiness() { + if (this.aauthLoaded) await this.loadAAuth() + }, + + /** PUT only the changed fields; throws with a readable message on refusal. */ + async saveAAuth(body) { + try { + const { data } = await api.put('/api/settings/aauth', body) + this.aauth = data + return data + } catch (e) { + throw new Error(detailOf(e, "Couldn't save the AAuth settings.")) + } + }, + + async loadEndpoints() { + this.endpointsLoading = true + try { + const { data } = await api.get('/api/settings/a2a-endpoints') + this.endpoints = Array.isArray(data?.endpoints) ? data.endpoints : [] + this.outboundEnabled = Boolean(data?.enabled) + this.endpointsError = '' + this.endpointsLoaded = true + } catch (e) { + this.endpointsError = detailOf(e, "Couldn't load the outbound endpoints.") + } finally { + this.endpointsLoading = false + } + }, + + async upsertEndpoint(body) { + try { + const { data } = await api.put('/api/settings/a2a-endpoints', body) + await this.loadEndpoints() + await this.refreshReadiness() + return data + } catch (e) { + throw new Error(detailOf(e, "Couldn't save the endpoint.")) + } + }, + + async removeEndpoint(ref) { + try { + await api.delete(`/api/settings/a2a-endpoints/${encodeURIComponent(ref)}`) + await this.loadEndpoints() + await this.refreshReadiness() + } catch (e) { + throw new Error(detailOf(e, "Couldn't remove the endpoint.")) + } + }, + + /** The outbound kill switch (`a2a_outbound_enabled`, §32.5 FR-11). */ + async setOutboundEnabled(enabled) { + try { + await api.put('/api/settings/a2a_outbound_enabled', { value: enabled ? 'true' : 'false' }) + this.outboundEnabled = Boolean(enabled) + await this.refreshReadiness() + } catch (e) { + throw new Error(detailOf(e, "Couldn't change outbound calling.")) + } + }, + }, +}) diff --git a/src/frontend/src/utils/aauthSettings.js b/src/frontend/src/utils/aauthSettings.js new file mode 100644 index 0000000000..f5e80e16e5 --- /dev/null +++ b/src/frontend/src/utils/aauthSettings.js @@ -0,0 +1,77 @@ +/** + * Settings → AAuth: the panel's rules, kept out of the SFC so they are tested + * directly (abilityai/trinity-enterprise#862). + */ + +/** + * The PUT body: only the fields the admin changed (learnings 2026-09-29). + * Sending an untouched value would write it as a row, and a row beats the + * environment from then on. + * + * @param {{enabled: boolean, issuer: string}} loaded what GET returned + * @param {{enabled: boolean, issuer: string}} draft what the form holds + */ +export function buildAAuthUpdate(loaded, draft) { + const body = {} + if (Boolean(draft.enabled) !== Boolean(loaded.enabled)) body.enabled = Boolean(draft.enabled) + const issuer = (draft.issuer || '').trim() + if (issuer !== (loaded.issuer || '').trim()) body.issuer = issuer + return body +} + +const HOST_RE = /^(?=.{1,253}$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*\.(?![0-9]+$)[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/ + +/** + * Pre-validate the issuer client-side (principle 17). The server applies the + * same rule and is the authority; this only lets the admin see the problem + * before saving. Returns '' when acceptable (an empty value clears it). + */ +export function issuerProblem(value) { + const v = (value || '').trim() + if (!v) return '' + const example = ' Example: https://trinity.example.com' + if (!v.startsWith('https://')) return `The issuer must start with https://.${example}` + const rest = v.slice('https://'.length) + if (rest.includes('/') || rest.includes('?') || rest.includes('#')) { + return `No path — the issuer is the host only.${example}` + } + if (rest.includes(':')) return `No port — AAuth identities are host names on the default HTTPS port.${example}` + if (rest !== rest.toLowerCase()) return `Use lowercase — the issuer is compared exactly.${example}` + if (!HOST_RE.test(rest)) return `Use a DNS host name with a dot, not an IP address.${example}` + return '' +} + +/** + * The federation-readiness checklist: what else a call to another instance + * needs. Each item names its next step, so the panel never shows a red mark + * without saying what to do about it (principle 16). + */ +export function readinessItems(state) { + const r = state?.readiness || {} + return [ + { + key: 'issuer', + ok: Boolean(r.issuer_valid), + label: 'Issuer set to this instance\'s public HTTPS host', + next: 'Enter the host other instances reach this one on, e.g. https://trinity.example.com.', + }, + { + key: 'live', + ok: Boolean(state?.live), + label: 'AAuth switched on', + next: 'Turn on the switch above once the issuer is set.', + }, + { + key: 'outbound', + ok: Boolean(r.outbound_enabled), + label: 'Outbound A2A calls allowed', + next: 'Turn on outbound calls in the A2A endpoints panel below.', + }, + { + key: 'endpoint', + ok: (r.aauth_endpoints || 0) > 0, + label: `An endpoint registered with the AAuth scheme (${r.aauth_endpoints || 0} of ${r.endpoints || 0})`, + next: 'Register the peer agent\'s A2A URL below with Authentication set to AAuth.', + }, + ] +} diff --git a/src/frontend/src/views/Settings.vue b/src/frontend/src/views/Settings.vue index ba8f1eb688..91a5d05ac4 100644 --- a/src/frontend/src/views/Settings.vue +++ b/src/frontend/src/views/Settings.vue @@ -1295,6 +1295,11 @@ paydown of #717/#1030; the section owns its own data loads). --> + + + +
@@ -2350,6 +2355,8 @@ import { } from '../components/onboarding/hardeningGuide' import { MODEL_CATALOG } from '../constants/modelCatalog' import TemplateRegistryPanel from '../components/settings/TemplateRegistryPanel.vue' +import AAuthPanel from '../components/settings/AAuthPanel.vue' +import A2AEndpointsPanel from '../components/settings/A2AEndpointsPanel.vue' import PlatformKeyField from '../components/settings/PlatformKeyField.vue' import ConfirmDialog from '../components/ConfirmDialog.vue' import BaseBadge from '../components/base/BaseBadge.vue' diff --git a/src/frontend/tests/unit/a2aEndpointsPanel.mount.spec.js b/src/frontend/tests/unit/a2aEndpointsPanel.mount.spec.js new file mode 100644 index 0000000000..de993b85b9 --- /dev/null +++ b/src/frontend/tests/unit/a2aEndpointsPanel.mount.spec.js @@ -0,0 +1,134 @@ +// @vitest-environment jsdom +/** + * Settings → Outbound A2A endpoints (abilityai/trinity-enterprise#862), mounted. + * + * Registering an endpoint decides where a credentialed server-side request may + * go, so the tests drive the rendered panel against a stubbed client and read + * what it sends: + * - an AAuth registration carries `auth_scheme: 'aauth'` and no credential key; + * - switching a stored-credential bearer row to AAuth warns that the secret is + * dropped, and sends the scheme; + * - remove asks first; a cancel sends nothing; a confirm DELETEs by id; + * - the outbound switch writes the kill-switch setting; + * - zero endpoints after a SUCCEEDED fetch is the empty copy; a failed fetch is LoadFailed. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() })) +vi.mock('@/api', () => ({ default: api })) + +import A2AEndpointsPanel from '@/components/settings/A2AEndpointsPanel.vue' + +const VENDOR = { id: 'ep-1', name: 'vendor', url: 'https://v.example.com/a2a', has_credentials: true, auth_scheme: 'bearer' } + +let w +async function render(endpoints = [], enabled = true) { + api.get.mockImplementation(async () => ({ data: { endpoints, enabled } })) + api.put.mockImplementation(async () => ({ data: { success: true, enabled } })) + api.delete.mockImplementation(async () => ({ data: { success: true } })) + w = mount(A2AEndpointsPanel, { attachTo: document.body }) + await flushPromises() + return w +} +// The dialog's own buttons — the row carries a 'Remove' of its own. +const button = (label) => [...document.querySelectorAll('[data-testid="confirm-dialog"] button')].find((b) => b.textContent.trim() === label) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) +afterEach(() => { w?.unmount(); w = null; document.body.innerHTML = '' }) + +describe('A2AEndpointsPanel', () => { + it('registers an AAuth endpoint with no credential key', async () => { + await render() + await w.find('#a2a-endpoint-name').setValue('cornelius-architect') + await w.find('#a2a-endpoint-url').setValue('https://a.example.com/a2a/cornelius-architect') + await w.find('#a2a-endpoint-scheme').setValue('aauth') + expect(w.find('#a2a-endpoint-credential').exists()).toBe(false) + await w.find('[data-testid="a2a-endpoint-form"]').trigger('submit') + await flushPromises() + + expect(api.put).toHaveBeenCalledWith('/api/settings/a2a-endpoints', { + name: 'cornelius-architect', + url: 'https://a.example.com/a2a/cornelius-architect', + auth_scheme: 'aauth', + }) + }) + + it('refuses a non-default port on an AAuth endpoint before sending', async () => { + await render() + await w.find('#a2a-endpoint-name').setValue('peer') + await w.find('#a2a-endpoint-url').setValue('https://a.example.com:8443/a2a/x') + await w.find('#a2a-endpoint-scheme').setValue('aauth') + await w.find('[data-testid="a2a-endpoint-form"]').trigger('submit') + await flushPromises() + + expect(w.text()).toContain('default HTTPS port') + expect(api.put).not.toHaveBeenCalled() + }) + + it('switching a credentialed bearer row to AAuth warns and sends the scheme', async () => { + await render([VENDOR]) + await w.find('[data-testid="a2a-endpoint-vendor"]').findAll('button')[0].trigger('click') + await w.find('#a2a-endpoint-scheme').setValue('aauth') + + expect(w.find('[data-testid="a2a-endpoint-drop-note"]').exists()).toBe(true) + await w.find('[data-testid="a2a-endpoint-form"]').trigger('submit') + await flushPromises() + expect(api.put).toHaveBeenCalledWith('/api/settings/a2a-endpoints', { + name: 'vendor', url: 'https://v.example.com/a2a', auth_scheme: 'aauth', + }) + }) + + it('a blank credential on a bearer update keeps the stored one (no key sent)', async () => { + await render([VENDOR]) + await w.find('[data-testid="a2a-endpoint-vendor"]').findAll('button')[0].trigger('click') + await w.find('#a2a-endpoint-url').setValue('https://v2.example.com/a2a') + await w.find('[data-testid="a2a-endpoint-form"]').trigger('submit') + await flushPromises() + + expect(api.put.mock.calls[0][1]).toEqual({ name: 'vendor', url: 'https://v2.example.com/a2a', auth_scheme: 'bearer' }) + }) + + it('remove asks first; cancel sends nothing; confirm deletes by id', async () => { + await render([VENDOR]) + await w.find('[data-testid="a2a-remove-vendor"]').trigger('click') + await flushPromises() + expect(api.delete).not.toHaveBeenCalled() + button('Cancel').click() + await flushPromises() + expect(api.delete).not.toHaveBeenCalled() + + await w.find('[data-testid="a2a-remove-vendor"]').trigger('click') + await flushPromises() + button('Remove').click() + await flushPromises() + expect(api.delete).toHaveBeenCalledWith('/api/settings/a2a-endpoints/ep-1') + }) + + it('the switch writes the outbound kill-switch setting', async () => { + await render([], false) + expect(w.find('[data-testid="a2a-outbound-off"]').exists()).toBe(true) + await w.find('[data-testid="a2a-outbound-toggle"]').trigger('click') + await flushPromises() + + expect(api.put).toHaveBeenCalledWith('/api/settings/a2a_outbound_enabled', { value: 'true' }) + }) + + it('zero endpoints after a succeeded fetch is the empty copy', async () => { + await render([]) + expect(w.find('[data-testid="a2a-endpoints-empty"]').exists()).toBe(true) + }) + + it('a failed fetch is LoadFailed, never the empty copy', async () => { + api.get.mockRejectedValue({ response: { data: { detail: 'boom' } } }) + w = mount(A2AEndpointsPanel, { attachTo: document.body }) + await flushPromises() + + expect(w.text()).toContain("Couldn't load the outbound endpoints") + expect(w.find('[data-testid="a2a-endpoints-empty"]').exists()).toBe(false) + }) +}) diff --git a/src/frontend/tests/unit/aauthPanel.mount.spec.js b/src/frontend/tests/unit/aauthPanel.mount.spec.js new file mode 100644 index 0000000000..d22529aa3c --- /dev/null +++ b/src/frontend/tests/unit/aauthPanel.mount.spec.js @@ -0,0 +1,122 @@ +// @vitest-environment jsdom +/** + * Settings → AAuth panel (abilityai/trinity-enterprise#862), mounted. + * + * Every control here is a store write that changes this instance's identity + * posture, so the tests drive the rendered panel against a stubbed client and + * read the requests it sends: + * - the switch sends ONLY `enabled` (never the env-sourced issuer it did not touch); + * - "on but no valid issuer" is shown as inactive, never as a green switch; + * - an invalid issuer is named before saving and cannot be saved; + * - changing a LIVE issuer asks first, and a cancel sends nothing; + * - a failed first load is LoadFailed, not an empty panel. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { mount, flushPromises } from '@vue/test-utils' +import { setActivePinia, createPinia } from 'pinia' + +const api = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() })) +vi.mock('@/api', () => ({ default: api })) + +import AAuthPanel from '@/components/settings/AAuthPanel.vue' + +const STATE = (over = {}) => ({ + enabled: false, enabled_source: 'default', + issuer: 'https://a.example.com', issuer_source: 'env', issuer_valid: true, + live: false, identity_example: 'aauth:@a.example.com', + prototype_enabled: false, + readiness: { issuer_valid: true, outbound_enabled: false, endpoints: 0, aauth_endpoints: 0 }, + ...over, +}) + +let w +async function render(state) { + api.get.mockImplementation(async () => ({ data: state })) + api.put.mockImplementation(async (_url, body) => ({ data: { ...state, ...body } })) + w = mount(AAuthPanel, { attachTo: document.body }) + await flushPromises() + return w +} + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) +afterEach(() => { w?.unmount(); w = null; document.body.innerHTML = '' }) + +describe('AAuthPanel', () => { + it('the switch sends only `enabled`, never the env-sourced issuer', async () => { + await render(STATE()) + await w.find('[data-testid="aauth-toggle"] button, [data-testid="aauth-toggle"]').trigger('click') + await flushPromises() + + expect(api.put).toHaveBeenCalledTimes(1) + expect(api.put).toHaveBeenCalledWith('/api/settings/aauth', { enabled: true }) + }) + + it('shows "on but no valid issuer" as inactive', async () => { + await render(STATE({ enabled: true, issuer: '', issuer_valid: false, live: false })) + + expect(w.find('[data-testid="aauth-inert"]').exists()).toBe(true) + expect(w.find('[data-testid="aauth-status"]').text()).toContain('On, inactive') + }) + + it('names a bad issuer and will not save it', async () => { + await render(STATE({ issuer: '', issuer_source: 'default', issuer_valid: false })) + await w.find('#aauth-issuer').setValue('https://trinity.example.com:8443') + + expect(w.text()).toContain('No port') + expect(w.find('[data-testid="aauth-issuer-save"]').attributes('disabled')).toBeDefined() + await w.find('#aauth-issuer').trigger('keyup.enter') + await flushPromises() + expect(api.put).not.toHaveBeenCalled() + }) + + it('saves a new issuer on an instance that is not live without asking', async () => { + await render(STATE({ issuer: '', issuer_source: 'default', issuer_valid: false })) + await w.find('#aauth-issuer').setValue('https://b.example.com') + await w.find('[data-testid="aauth-issuer-save"]').trigger('click') + await flushPromises() + + expect(api.put).toHaveBeenCalledWith('/api/settings/aauth', { issuer: 'https://b.example.com' }) + }) + + it('asks before changing a live issuer, and a cancel sends nothing', async () => { + await render(STATE({ enabled: true, live: true, issuer_source: 'settings' })) + await w.find('#aauth-issuer').setValue('https://b.example.com') + await w.find('[data-testid="aauth-issuer-save"]').trigger('click') + await flushPromises() + + expect(api.put).not.toHaveBeenCalled() + expect(document.body.textContent).toContain('Peers that listed the old identities') + const cancel = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Cancel') + cancel.click() + await flushPromises() + expect(api.put).not.toHaveBeenCalled() + + await w.find('[data-testid="aauth-issuer-save"]').trigger('click') + await flushPromises() + const confirm = [...document.querySelectorAll('button')].find((b) => b.textContent.trim() === 'Change issuer') + confirm.click() + await flushPromises() + expect(api.put).toHaveBeenCalledWith('/api/settings/aauth', { issuer: 'https://b.example.com' }) + }) + + it('a failed first load renders LoadFailed, not the settings', async () => { + api.get.mockRejectedValue({ response: { data: { detail: 'boom' } } }) + w = mount(AAuthPanel, { attachTo: document.body }) + await flushPromises() + + expect(w.text()).toContain("Couldn't load the AAuth settings") + expect(w.find('[data-testid="aauth-toggle"]').exists()).toBe(false) + }) + + it('the readiness checklist names the next step for what is missing', async () => { + await render(STATE()) + const items = w.findAll('[data-testid="aauth-readiness"] li') + + expect(items).toHaveLength(4) + expect(items[2].attributes('data-ok')).toBe('false') + expect(items[2].text()).toContain('Turn on outbound calls') + }) +}) diff --git a/src/frontend/tests/unit/aauthSettings.spec.js b/src/frontend/tests/unit/aauthSettings.spec.js new file mode 100644 index 0000000000..42def40c98 --- /dev/null +++ b/src/frontend/tests/unit/aauthSettings.spec.js @@ -0,0 +1,72 @@ +/** + * Settings → AAuth: the rules the panel runs on (abilityai/trinity-enterprise#862). + * + * The save body is the part that bites: a settings form that sends every field + * freezes untouched values into rows, and for a value sourced from the + * environment that silently ends the variable's authority (learnings + * 2026-09-29). So the body carries only what the admin changed. + */ +import { describe, it, expect } from 'vitest' +import { buildAAuthUpdate, issuerProblem, readinessItems } from '@/utils/aauthSettings' + +const loaded = { + enabled: false, enabled_source: 'default', + issuer: 'https://a.example.com', issuer_source: 'env', issuer_valid: true, + prototype_enabled: false, +} + +describe('buildAAuthUpdate', () => { + it('sends nothing when nothing changed', () => { + expect(buildAAuthUpdate(loaded, { enabled: false, issuer: 'https://a.example.com' })).toEqual({}) + }) + + it('sends only the toggled switch, never the env-sourced issuer it did not touch', () => { + expect(buildAAuthUpdate(loaded, { enabled: true, issuer: 'https://a.example.com' })).toEqual({ enabled: true }) + }) + + it('sends a changed issuer trimmed', () => { + expect(buildAAuthUpdate(loaded, { enabled: false, issuer: ' https://b.example.com ' })) + .toEqual({ issuer: 'https://b.example.com' }) + }) + + it('sends an empty issuer to clear a stored one', () => { + const stored = { ...loaded, issuer_source: 'settings' } + expect(buildAAuthUpdate(stored, { enabled: false, issuer: '' })).toEqual({ issuer: '' }) + }) +}) + +describe('issuerProblem', () => { + it.each([ + ['https://trinity.example.com', ''], + ['', ''], + ])('accepts %s', (v, want) => expect(issuerProblem(v)).toBe(want)) + + it.each([ + ['http://trinity.example.com', /https/], + ['https://trinity.example.com:8443', /port/], + ['https://trinity.example.com/a2a', /path/], + ['https://Trinity.example.com', /lowercase/], + ['https://10.0.0.5', /host name/], + ['trinity.example.com', /https/], + ])('names what is wrong with %s', (v, re) => expect(issuerProblem(v)).toMatch(re)) +}) + +describe('readinessItems', () => { + const base = { + live: true, + readiness: { issuer_valid: true, outbound_enabled: true, endpoints: 2, aauth_endpoints: 1 }, + } + + it('is all done for a ready instance', () => { + expect(readinessItems(base).every((i) => i.ok)).toBe(true) + }) + + it('names the next step for each missing piece', () => { + const items = readinessItems({ + live: false, + readiness: { issuer_valid: false, outbound_enabled: false, endpoints: 0, aauth_endpoints: 0 }, + }) + expect(items.map((i) => i.key)).toEqual(['issuer', 'live', 'outbound', 'endpoint']) + expect(items.every((i) => !i.ok && i.next.length > 0)).toBe(true) + }) +}) diff --git a/src/mcp-server/src/client.ts b/src/mcp-server/src/client.ts index 76cb54867a..e40d9251d9 100644 --- a/src/mcp-server/src/client.ts +++ b/src/mcp-server/src/client.ts @@ -3932,6 +3932,7 @@ export class TrinityClient { clear_credentials?: boolean; /** `payment_token` makes the credential ride as x402 payment (#3185). */ credential_kind?: "api_key" | "payment_token"; + auth_scheme?: "bearer" | "aauth"; // ent#862 }, ): Promise<{ endpoint?: unknown; enabled?: boolean; hint?: string }> { return this.request<{ endpoint?: unknown; enabled?: boolean; hint?: string }>( diff --git a/src/mcp-server/src/tools/a2a.test.ts b/src/mcp-server/src/tools/a2a.test.ts index cc76ebac95..9f747aba48 100644 --- a/src/mcp-server/src/tools/a2a.test.ts +++ b/src/mcp-server/src/tools/a2a.test.ts @@ -131,6 +131,8 @@ describe("ent#761 — the three outbound control tools target the OSS endpoint s // #3185: absent, not defaulted — the store infers the kind from the value, // and an `api_key` sent here would override that inference with a guess. credential_kind: undefined, + // ent#862: absent keeps the stored scheme (a new endpoint is bearer). + auth_scheme: undefined, }); assert.equal(out.success, true); assert.equal(out.endpoint.name, "partner"); @@ -146,9 +148,46 @@ describe("ent#761 — the three outbound control tools target the OSS endpoint s credentials: undefined, clear_credentials: undefined, credential_kind: undefined, + auth_scheme: undefined, }); }); + it("ent#862: register forwards auth_scheme aauth with no credential", async () => { + const calls: Recorded[] = []; + const tools = makeTools(calls); + await tools.register_a2a_endpoint.execute( + { name: "peer", url: "https://peer.example.com/a2a/bot", auth_scheme: "aauth" }, + {}, + ); + assert.equal(calls.length, 1); + assert.equal((calls[0].args[0] as { auth_scheme?: string }).auth_scheme, "aauth"); + }); + + it("ent#862: register refuses a credential on an aauth endpoint (no backend call)", async () => { + // An aauth endpoint stores no secret: the store refuses one, and the caller + // learns it here without a round trip. + const calls: Recorded[] = []; + const tools = makeTools(calls); + const out = JSON.parse( + await tools.register_a2a_endpoint.execute( + { name: "peer", url: "https://peer.example.com/a2a/bot", auth_scheme: "aauth", credentials: "TOK" }, + {}, + ), + ); + assert.equal(out.success, false); + assert.equal(out.invalid, true); + assert.match(out.error, /aauth/); + assert.equal(calls.length, 0); + }); + + it("ent#862: the schema takes only bearer or aauth", () => { + const tools = makeTools([]); + const schema = tools.register_a2a_endpoint.parameters; + assert.equal(schema.safeParse({ name: "p", url: "https://x", auth_scheme: "aauth" }).success, true); + assert.equal(schema.safeParse({ name: "p", url: "https://x", auth_scheme: "bearer" }).success, true); + assert.equal(schema.safeParse({ name: "p", url: "https://x", auth_scheme: "oauth" }).success, false); + }); + it("register forwards clear_credentials", async () => { const calls: Recorded[] = []; const tools = makeTools(calls); diff --git a/src/mcp-server/src/tools/a2a.ts b/src/mcp-server/src/tools/a2a.ts index d00d271653..63646d503f 100644 --- a/src/mcp-server/src/tools/a2a.ts +++ b/src/mcp-server/src/tools/a2a.ts @@ -284,6 +284,12 @@ export function createA2ATools(client: TrinityClient, requireApiKey: boolean) { + "only). Omit to let the platform infer " + "it from the value. Send it alone to re-label a credential already stored.", ), + auth_scheme: z.enum(["bearer", "aauth"]).optional().describe( + "How calls authenticate. 'bearer' (the default for a new endpoint) sends the stored " + + "credential. 'aauth' signs each call as the calling agent's own AAuth identity " + + "(aauth:@) — no credential is stored, switching to it clears one, " + + "and the peer must list that identity. Omit to keep the stored scheme.", + ), }), execute: async ( params: { @@ -293,9 +299,24 @@ export function createA2ATools(client: TrinityClient, requireApiKey: boolean) { credentials?: string; clear_credentials?: boolean; credential_kind?: "api_key" | "payment_token"; + auth_scheme?: "bearer" | "aauth"; }, context?: { session?: McpAuthContext }, ) => { + if (params.auth_scheme === "aauth" && params.credentials) { + // ent#862: an aauth endpoint holds no secret — the store refuses one. + // Refused here too, so the caller learns it without a round trip. + return JSON.stringify( + { + success: false, + error: + "An auth_scheme 'aauth' endpoint stores no credential: each call is signed " + + "as the calling agent. Drop `credentials`, or use auth_scheme 'bearer'.", + invalid: true, + }, + null, 2, + ); + } if (params.clear_credentials && params.credential_kind) { // Contradictory instructions about one slot (#3185). Refused here as // well as at the route, so the caller learns it without spending a @@ -333,6 +354,7 @@ export function createA2ATools(client: TrinityClient, requireApiKey: boolean) { credentials: params.credentials, clear_credentials: params.clear_credentials, credential_kind: params.credential_kind, + auth_scheme: params.auth_scheme, }); return ok({ endpoint: result?.endpoint, diff --git a/src/mcp-server/src/tools/aauth.ts b/src/mcp-server/src/tools/aauth.ts index e7c67857f5..50d22d686e 100644 --- a/src/mcp-server/src/tools/aauth.ts +++ b/src/mcp-server/src/tools/aauth.ts @@ -23,8 +23,9 @@ * ── Gating (plan taste T3 / ent#879 T4) ────────────────────────────────── * Registered in `server.ts`'s `toolGroups` → the `operatorOnly` allow-list, * ALWAYS. There is deliberately no env gate and no dynamic registration: the - * demo flips `aauth_prototype_enabled` in `system_settings` at runtime, which - * the MCP server cannot see. With the flag off the backend answers 404 and + * demo flips the AAuth switches (`aauth_enabled` + `aauth_prototype_enabled`, + * ent#862) in `system_settings` at runtime, which the MCP server cannot see. + * With a switch off the backend answers 404 and * both tools report `aauth_disabled: true` — advertised but inert, the * `call_a2a_agent` precedent. The backend 404 is the authority. * diff --git a/tests/registry.json b/tests/registry.json index 19e95713b6..034d4d8e09 100644 --- a/tests/registry.json +++ b/tests/registry.json @@ -1,5 +1,17 @@ { "test_files": [ + { + "file": "unit/test_ent862_aauth_settings.py", + "feature": "abilityai/trinity-enterprise#862", + "added": "2026-10-13", + "categories": [ + "backend", + "settings", + "aauth", + "a2a", + "unit" + ] + }, { "file": "unit/test_3524_gate_approval_card.py", "feature": "abilityai/trinity#3524 #3525 #3527", diff --git a/tests/unit/test_1028_settings_package.py b/tests/unit/test_1028_settings_package.py index 6412e88da2..3d848ba4ea 100644 --- a/tests/unit/test_1028_settings_package.py +++ b/tests/unit/test_1028_settings_package.py @@ -178,6 +178,10 @@ def _sig(route): # logged a failed request on every Settings tab. Lives in `mcp_url` beside # its precedent `/mcp-url`. ("/api/settings/public-chat-url", ("GET",), "get_public_chat_url"), + # trinity-enterprise#862 — the AAuth switch and issuer, with a human-only + # write; the generic catch-all refuses those keys. + ("/api/settings/aauth", ("GET",), "get_aauth_settings"), + ("/api/settings/aauth", ("PUT",), "update_aauth_settings"), } diff --git a/tests/unit/test_ent862_aauth_settings.py b/tests/unit/test_ent862_aauth_settings.py new file mode 100644 index 0000000000..7bab2f9d36 --- /dev/null +++ b/tests/unit/test_ent862_aauth_settings.py @@ -0,0 +1,403 @@ +"""AAuth graduation, PR1 — the flag split and the admin settings surface +(abilityai/trinity-enterprise#862). + +Two switches now: + +* ``aauth_enabled`` (``AAUTH_ENABLED``) — the graduated agent identity, Flows + 1–4: the well-known documents, outbound signing, inbound verification and the + card / 401 advertisement. ``config.active_issuer()`` is its single reader. +* ``aauth_prototype_enabled`` (``AAUTH_PROTOTYPE_ENABLED``) — narrowed to the + person-token and resource-calling prototypes (Flows 5–6), which require BOTH + switches, gated once at ``aauth_person.require_live``. + +The settings route is a grant surface (turning it on publishes this instance's +key and accepts signed callers from other instances), so its write is admin AND +interactive: an MCP key of any scope — agent, user, system — is refused. + +Sync throughout (`tests/unit/pytest.ini` overrides `asyncio_mode = auto`). +""" +import types +from unittest.mock import AsyncMock + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient + +import database +import routers.settings as rs +from dependencies import get_current_user +from services.aauth import config + +ISSUER = "https://trinity-a.example.com" + + +class _Store: + """`system_settings` in memory, installed on the shared `db` facade.""" + + def __init__(self): + self.rows = {} + + def get_setting_value(self, key, default=None): + return self.rows.get(key, default) + + def set_setting(self, key, value): + self.rows[key] = value + + def delete_setting(self, key): + return self.rows.pop(key, None) is not None + + +@pytest.fixture +def store(monkeypatch): + s = _Store() + for name in ("get_setting_value", "set_setting", "delete_setting"): + monkeypatch.setattr(database.db, name, getattr(s, name)) + for var in ("AAUTH_ENABLED", "AAUTH_PROTOTYPE_ENABLED", "AAUTH_ISSUER", + "AAUTH_PERSON_SERVER", "AAUTH_RESOURCE_ALLOWLIST"): + monkeypatch.delenv(var, raising=False) + return s + + +# --------------------------------------------------------------------------- +# Flag split (N3) +# --------------------------------------------------------------------------- + +def test_aauth_is_off_by_default(store): + assert config.is_enabled() is False + assert config.active_issuer() is None + + +def test_the_prototype_flag_alone_no_longer_turns_on_flows_1_to_4(store): + store.rows.update({"aauth_prototype_enabled": "true", "aauth_issuer": ISSUER}) + assert config.active_issuer() is None + assert config.prototype_active_issuer() is None + + +def test_the_graduated_flag_turns_on_flows_1_to_4_only(store): + store.rows.update({"aauth_enabled": "true", "aauth_issuer": ISSUER}) + assert config.active_issuer() == ISSUER + assert config.prototype_active_issuer() is None + + +def test_flows_5_and_6_need_both_switches(store): + store.rows.update({ + "aauth_enabled": "true", "aauth_prototype_enabled": "true", "aauth_issuer": ISSUER, + }) + assert config.prototype_active_issuer() == ISSUER + + +def test_the_env_var_turns_it_on_and_a_stored_row_overrides_it(store, monkeypatch): + monkeypatch.setenv("AAUTH_ENABLED", "true") + monkeypatch.setenv("AAUTH_ISSUER", ISSUER) + assert config.active_issuer() == ISSUER + store.rows["aauth_enabled"] = "false" # the admin's kill switch beats env + assert config.active_issuer() is None + + +@pytest.mark.parametrize("graduated,prototype,live", [ + (False, False, False), (True, False, False), (False, True, False), (True, True, True), +]) +def test_require_live_gates_the_prototype_routes_on_both_flags(store, graduated, prototype, live): + """Every Flow 5/6 route depends on `require_live`; it is the one gate.""" + from routers import aauth_person + + store.rows["aauth_issuer"] = ISSUER + store.rows["aauth_enabled"] = "true" if graduated else "false" + store.rows["aauth_prototype_enabled"] = "true" if prototype else "false" + if live: + aauth_person.require_live() + else: + with pytest.raises(HTTPException) as exc: + aauth_person.require_live() + assert exc.value.status_code == 404 + + +def test_the_resource_call_route_takes_the_same_gate(): + from routers import aauth_person, aauth_resource + + route = next(r for r in aauth_resource.router.routes if r.path.endswith("/aauth/resource-call")) + assert aauth_person.require_live in [d.call for d in route.dependant.dependencies] + + +# --------------------------------------------------------------------------- +# GET/PUT /api/settings/aauth +# --------------------------------------------------------------------------- + +def _principal(role="admin", agent_name=None, connector_agent=None, mcp_scope=None): + return types.SimpleNamespace( + id=1, username="admin", email="admin@example.com", role=role, + agent_name=agent_name, connector_agent=connector_agent, mcp_scope=mcp_scope, + vouched_source_agent=None, + ) + + +@pytest.fixture +def api(store, monkeypatch): + import routers.settings.aauth as rs_aauth + from services import a2a_outbound, a2a_outbound_service + + app = FastAPI() + app.include_router(rs.router) + who = {"user": _principal()} + app.dependency_overrides[get_current_user] = lambda: who["user"] + + audit = AsyncMock(return_value=None) + monkeypatch.setattr(rs_aauth.platform_audit_service, "log", audit) + endpoints = [ + {"id": "1", "name": "peer-a", "url": "https://a.example.com/a2a/x", + "has_credentials": False, "auth_scheme": "aauth"}, + {"id": "2", "name": "vendor", "url": "https://v.example.com/a2a", + "has_credentials": True, "auth_scheme": "bearer"}, + ] + monkeypatch.setattr(a2a_outbound, "list_oss_endpoints", lambda: endpoints) + monkeypatch.setattr(a2a_outbound_service, "is_outbound_enabled", lambda: True) + + e = types.SimpleNamespace() + e.client = TestClient(app, raise_server_exceptions=False) + e.store = store + e.audit = audit + e.as_ = lambda p: who.__setitem__("user", p) + return e + + +def test_get_reports_state_sources_and_readiness(api, monkeypatch): + monkeypatch.setenv("AAUTH_ISSUER", ISSUER) + api.store.rows["aauth_enabled"] = "true" + + body = api.client.get("/api/settings/aauth").json() + + assert body["enabled"] is True and body["enabled_source"] == "settings" + assert body["issuer"] == ISSUER and body["issuer_source"] == "env" + assert body["issuer_valid"] is True + assert body["live"] is True + assert body["identity_example"] == "aauth:@trinity-a.example.com" + assert body["prototype_enabled"] is False + assert body["readiness"] == { + "issuer_valid": True, + "outbound_enabled": True, + "endpoints": 2, + "aauth_endpoints": 1, + } + + +def test_get_is_honest_about_an_inert_switch(api): + """On without a valid issuer is OFF in effect — the panel must say so.""" + api.store.rows.update({"aauth_enabled": "true", "aauth_issuer": "http://nope"}) + + body = api.client.get("/api/settings/aauth").json() + + assert body["enabled"] is True + assert body["issuer_valid"] is False + assert body["live"] is False + + +def test_put_writes_only_what_it_was_sent(api): + resp = api.client.put("/api/settings/aauth", json={"enabled": True}) + + assert resp.status_code == 200, resp.text + assert api.store.rows == {"aauth_enabled": "true"} + assert resp.json()["enabled"] is True + + +def test_put_sets_a_valid_issuer_and_audits_the_change(api): + resp = api.client.put("/api/settings/aauth", json={"issuer": ISSUER}) + + assert resp.status_code == 200, resp.text + assert api.store.rows == {"aauth_issuer": ISSUER} + api.audit.assert_awaited_once() + kwargs = api.audit.await_args.kwargs + assert kwargs["event_action"] == "aauth_config_change" + assert kwargs["details"]["changes"] == {"issuer": {"from": None, "to": ISSUER}} + + +@pytest.mark.parametrize("bad", [ + "http://trinity-a.example.com", # not https + "https://trinity-a.example.com:8443", # port + "https://trinity-a.example.com/x", # path + "https://Trinity-A.example.com", # not lowercase — never repaired + "https://10.0.0.5", # IP literal +]) +def test_put_refuses_an_invalid_issuer_by_name(api, bad): + resp = api.client.put("/api/settings/aauth", json={"issuer": bad}) + + assert resp.status_code == 422 + assert resp.json()["detail"]["reason"] == "invalid_issuer" + assert api.store.rows == {} + + +def test_put_with_an_empty_issuer_clears_the_stored_one(api): + api.store.rows["aauth_issuer"] = ISSUER + resp = api.client.put("/api/settings/aauth", json={"issuer": ""}) + + assert resp.status_code == 200, resp.text + assert "aauth_issuer" not in api.store.rows + + +def test_put_toggles_the_prototype_switch(api): + resp = api.client.put("/api/settings/aauth", json={"prototype_enabled": True}) + + assert resp.status_code == 200, resp.text + assert api.store.rows == {"aauth_prototype_enabled": "true"} + + +def test_put_sets_and_clears_the_person_server(api): + resp = api.client.put("/api/settings/aauth", json={"person_server": "https://person.example.com"}) + assert resp.status_code == 200, resp.text + assert api.store.rows == {"aauth_person_server": "https://person.example.com"} + assert resp.json()["person_server"] == "https://person.example.com" + assert resp.json()["person_server_source"] == "settings" + + resp = api.client.put("/api/settings/aauth", json={"person_server": ""}) + assert resp.status_code == 200, resp.text + assert api.store.rows == {} + + +def test_put_refuses_an_invalid_person_server_by_name(api): + resp = api.client.put("/api/settings/aauth", json={"person_server": "http://person.example.com"}) + assert resp.status_code == 422 + assert resp.json()["detail"]["reason"] == "invalid_person_server" + assert api.store.rows == {} + + +def test_put_sets_a_resource_allowlist_that_parses(api): + raw = "https://senzing.example.com, https://whoami.example.com" + resp = api.client.put("/api/settings/aauth", json={"resource_allowlist": raw}) + assert resp.status_code == 200, resp.text + assert api.store.rows == {"aauth_resource_allowlist": raw} + body = resp.json() + assert body["resource_allowlist"] == raw + assert body["resource_allowlist_count"] == 2 + assert body["resource_allowlist_source"] == "settings" + + +@pytest.mark.parametrize("raw", [ + "https://ok.example.com, http://bad.example.com", # one bad entry empties the list + "[not json", + "https://ok.example.com:8443", +]) +def test_put_refuses_a_resource_allowlist_the_sink_would_read_as_nothing(api, raw): + """`parse_allowlist` fails closed to () — storing such a value would silently + allow nothing while the row looks configured.""" + resp = api.client.put("/api/settings/aauth", json={"resource_allowlist": raw}) + assert resp.status_code == 422 + assert resp.json()["detail"]["reason"] == "invalid_resource_allowlist" + assert api.store.rows == {} + + +def test_put_with_an_empty_resource_allowlist_clears_it(api): + api.store.rows["aauth_resource_allowlist"] = "https://ok.example.com" + resp = api.client.put("/api/settings/aauth", json={"resource_allowlist": ""}) + assert resp.status_code == 200, resp.text + assert api.store.rows == {} + + +def test_get_reports_the_prototype_knobs_with_their_sources(api, monkeypatch): + monkeypatch.setenv("AAUTH_PERSON_SERVER", "https://person.example.com") + body = api.client.get("/api/settings/aauth").json() + assert body["person_server"] == "https://person.example.com" + assert body["person_server_source"] == "env" + assert body["resource_allowlist"] == "" and body["resource_allowlist_source"] == "default" + assert body["resource_allowlist_count"] == 0 + + +def test_put_with_nothing_to_change_is_refused(api): + assert api.client.put("/api/settings/aauth", json={}).status_code == 400 + + +def test_put_refuses_a_number_for_a_boolean(api): + """StrictBool: `1` is not `true` (lax ints would store it).""" + assert api.client.put("/api/settings/aauth", json={"enabled": 1}).status_code == 422 + assert api.store.rows == {} + + +@pytest.mark.parametrize("method", ["get", "put"]) +def test_a_non_admin_is_refused(api, method): + api.as_(_principal(role="user")) + kw = {"json": {"enabled": True}} if method == "put" else {} + assert getattr(api.client, method)("/api/settings/aauth", **kw).status_code == 403 + + +@pytest.mark.parametrize("principal", [ + _principal(agent_name="scout", mcp_scope="agent"), # admin-owned agent key + _principal(mcp_scope="user"), # admin's own user-scoped key + _principal(mcp_scope="system"), # trinity-system +]) +def test_no_mcp_key_of_any_scope_may_change_it(api, principal): + api.as_(principal) + + resp = api.client.put("/api/settings/aauth", json={"enabled": True}) + + assert resp.status_code == 403 + assert api.store.rows == {} + + +# --------------------------------------------------------------------------- +# The generic catch-all is not a way around the dedicated route +# --------------------------------------------------------------------------- + +# cso-diff-2026-10-13-ent862 F1: the two Flow 5–6 keys decide where a signed +# agent request may go and which person server mediates binding — the same +# grant class as the issuer, so the same door closes on them. +AAUTH_ROUTE_KEYS = [ + "aauth_enabled", "aauth_issuer", "aauth_prototype_enabled", + "aauth_person_server", "aauth_resource_allowlist", +] + + +@pytest.mark.parametrize("key", AAUTH_ROUTE_KEYS) +def test_the_generic_put_refuses_the_aauth_switches(api, key): + resp = api.client.put(f"/api/settings/{key}", json={"value": "true"}) + + assert resp.status_code == 422 + assert "/api/settings/aauth" in resp.text + assert api.store.rows == {} + + +@pytest.mark.parametrize("key", AAUTH_ROUTE_KEYS) +def test_the_generic_delete_refuses_the_aauth_switches(api, key): + api.store.rows[key] = "true" + + resp = api.client.delete(f"/api/settings/{key}") + + assert resp.status_code == 422 + assert api.store.rows == {key: "true"} + + +# --------------------------------------------------------------------------- +# Feature flags and deployment wiring +# --------------------------------------------------------------------------- + +def test_feature_flags_reports_whether_aauth_is_live(store, monkeypatch): + import asyncio + + from routers.settings import flags + + monkeypatch.setattr(config, "active_issuer", lambda: ISSUER) + assert asyncio.run(flags.get_public_feature_flags(current_user=None))["aauth_available"] is True + monkeypatch.setattr(config, "active_issuer", lambda: None) + assert asyncio.run(flags.get_public_feature_flags(current_user=None))["aauth_available"] is False + + +@pytest.mark.parametrize("compose_file,expected", [ + ("docker-compose.yml", "AAUTH_ENABLED=${AAUTH_ENABLED:-}"), + ("docker-compose.prod.yml", "AAUTH_ENABLED=${AAUTH_ENABLED:-}"), + ("docker-compose.hosted.yml", "AAUTH_ENABLED=${AAUTH_ENABLED:-}"), + # The two-instance harness turns it on outright, as it does the prototype flag. + ("scripts/dev/aauth_prototype/compose.b.yml", "AAUTH_ENABLED=true"), +]) +def test_every_backend_compose_file_forwards_the_flag_with_no_default(compose_file, expected): + """A flag absent from `environment:` is un-switchable (learnings 2026-07-30), + and a compose default would make "env" the reported source on every install + (learnings 2026-09-29). The live consumer of these files is compose itself.""" + from pathlib import Path + + import yaml + + root = Path(__file__).resolve().parents[2] + doc = yaml.safe_load((root / compose_file).read_text()) + forwarded = [] + for svc in doc.get("services", {}).values(): + env = svc.get("environment") or [] + items = env if isinstance(env, list) else [f"{k}={v}" for k, v in env.items()] + forwarded += [i for i in items if str(i).startswith("AAUTH_ENABLED=")] + assert forwarded == [expected] diff --git a/tests/unit/test_ent878_aauth_person_token.py b/tests/unit/test_ent878_aauth_person_token.py index 1ad8bf7974..c259c7c178 100644 --- a/tests/unit/test_ent878_aauth_person_token.py +++ b/tests/unit/test_ent878_aauth_person_token.py @@ -181,6 +181,8 @@ def harness(monkeypatch): state = {"issuer": OUR, "ps": PS, "ps_fake": ps, "redis": redis, "kid": kid, "jwk": {**jwk, "kid": kid}} monkeypatch.setattr(config, "active_issuer", lambda: state["issuer"]) + # ent#862: Flows 5–6 also need the prototype switch (gated at require_live). + monkeypatch.setattr(config, "is_prototype_enabled", lambda: True) monkeypatch.setattr(config, "get_person_server", lambda: state["ps"]) monkeypatch.setattr(keys, "get_instance_key", lambda: keys.InstanceKey(kid=kid, public_jwk=state["jwk"], private_key=instance_key)) diff --git a/tests/unit/test_ent879_aauth_resource_client.py b/tests/unit/test_ent879_aauth_resource_client.py index 8b69057078..4d5dc25024 100644 --- a/tests/unit/test_ent879_aauth_resource_client.py +++ b/tests/unit/test_ent879_aauth_resource_client.py @@ -223,6 +223,8 @@ def harness(monkeypatch): state = {"issuer": OUR, "ps": PS, "res": res, "ps_fake": ps, "redis": redis, "kid": kid} monkeypatch.setattr(config, "active_issuer", lambda: state["issuer"]) + # ent#862: Flows 5–6 also need the prototype switch (gated at require_live). + monkeypatch.setattr(config, "is_prototype_enabled", lambda: True) monkeypatch.setattr(config, "get_person_server", lambda: state["ps"]) monkeypatch.setattr(keys, "get_instance_key", lambda: keys.InstanceKey(kid=kid, public_jwk={**jwk, "kid": kid}, private_key=instance_key)) From 54a3f023a3e7352a7b2ebc60a9de9f1e8651ea0a Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 22 Oct 2026 15:18:44 -0400 Subject: [PATCH 2/4] feat(a2a): AAuth identity badge and format validation in inbound allow-list UI (ent#862 PR2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A2aPanel.vue — inbound allow-list section: - Updated description to document both entry formats: email/username for local Trinity accounts and `aauth:@` for AAuth peers - Added AAuth/Local badge (BaseBadge) to visually distinguish the two kinds of listed identity at a glance - Updated input placeholder to show both formats - Added client-side format warning when the entered value starts with `aauth:` but does not match `aauth:@` — fires before submit MCP set_a2a_inbound_allowlist description updated to document the `aauth:@` format alongside email/username. Co-Authored-By: Claude Sonnet 4.6 --- src/frontend/src/components/A2aPanel.vue | 54 ++++++++++++++++++------ src/mcp-server/src/tools/a2a.ts | 4 +- 2 files changed, 45 insertions(+), 13 deletions(-) diff --git a/src/frontend/src/components/A2aPanel.vue b/src/frontend/src/components/A2aPanel.vue index 7bfa0c38b9..56f3f0c9f0 100644 --- a/src/frontend/src/components/A2aPanel.vue +++ b/src/frontend/src/components/A2aPanel.vue @@ -146,9 +146,11 @@

Inbound allow-list

- Trinity accounts permitted to task this agent inbound — the caller's account - email (or username, when the account has no email). Empty = - any authenticated owner/shared caller. Non-empty = only these identities. + Callers permitted to task this agent inbound. Empty = any authenticated owner/shared caller. + Non-empty = only these identities. Two formats are accepted: + email or username for Trinity accounts on this instance, or + aauth:<agent>@<peer-host> + for an agent on a peer Trinity instance over AAuth.

- {{ id }} +
+ {{ isAAuthIdentity(id) ? 'AAuth' : 'Local' }} + {{ id }} +
@@ -169,16 +178,21 @@ No restriction — any authenticated owner/shared caller may task the agent.
- +
+ +

+ {{ aAuthIdentityFormatWarning }} +

+
@@ -252,6 +266,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useAgentsStore } from '../stores/agents' import { copyToClipboard } from '../utils/clipboard' +import BaseBadge from './base/BaseBadge.vue' const props = defineProps({ agentName: { type: String, required: true }, @@ -284,6 +299,21 @@ const skillDraft = ref([]) const newIdentity = ref('') const newEndpoint = ref({ name: '', url: '', credentials: '' }) +// aauth:@ — the format AAuth peer identities must use +const AAUTH_IDENTITY_RE = /^aauth:[^@]+@[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z]{2,})+$/ + +function isAAuthIdentity(id) { + return typeof id === 'string' && id.toLowerCase().startsWith('aauth:') +} + +const aAuthIdentityFormatWarning = computed(() => { + const v = newIdentity.value + if (!v || !isAAuthIdentity(v)) return '' + return AAUTH_IDENTITY_RE.test(v) + ? '' + : 'Expected format: aauth:@ — e.g. aauth:my-agent@peer.example.com' +}) + // The canonical A2A discovery URL an external orchestrator fetches (the public, // unauthenticated well-known route served by the inbound server, ent#157). const cardUrl = computed(() => diff --git a/src/mcp-server/src/tools/a2a.ts b/src/mcp-server/src/tools/a2a.ts index 63646d503f..1ad7176c8d 100644 --- a/src/mcp-server/src/tools/a2a.ts +++ b/src/mcp-server/src/tools/a2a.ts @@ -221,7 +221,9 @@ export function createA2ATools(client: TrinityClient, requireApiKey: boolean) { name: "set_a2a_inbound_allowlist", description: "Manage which external identities may task an agent over A2A inbound. Provide `add` and/or " + - "`remove` (identity strings — e.g. a caller URL, client id, or key id). Owner/admin and human-only. " + + "`remove` (identity strings). Two formats: an email or username for a Trinity account on this " + + "instance, or `aauth:@` for an agent on a peer Trinity instance over AAuth " + + "(e.g. `aauth:my-agent@peer.example.com`). Owner/admin and human-only. " + "Enterprise feature; returns { not_entitled: true } if A2A is not licensed.", parameters: z.object({ agent_name: z.string().describe("The agent whose inbound allow-list to modify."), From e846faf2dbb6778736af52b354fc844478d178ce Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Thu, 22 Oct 2026 15:25:32 -0400 Subject: [PATCH 3/4] feat(a2a): record AAuth peer identity on execution rows (ent#862 PR3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Store the `aauth:@` identity on `schedule_executions` so inbound AAuth-authenticated A2A calls are attributed end-to-end. - `db/schema.py` + `db/tables.py`: add `a2a_caller_identity TEXT` column - `db/migrations.py`: SQLite migration `a2a_caller_identity` - `migrations/versions/0099_a2a_caller_identity.py`: Alembic mirror - `db/write_params.py`: `TaskExecutionFields.a2a_caller_identity` - `models.py`: `ExecutionResponse.a2a_caller_identity` - `services/task_execution_service.py`: thread parameter through `execute_task` - `routers/a2a.py`: pass `aauth.identity` into `_run_a2a_task` → `execute_task` - `views/ExecutionDetail.vue`: show AAuth Caller in Execution Origin card Co-Authored-By: Claude Sonnet 4.6 --- src/backend/db/migrations.py | 12 +++++++++ src/backend/db/schema.py | 3 +++ src/backend/db/tables.py | 1 + src/backend/db/write_params.py | 1 + .../versions/0099_a2a_caller_identity.py | 25 +++++++++++++++++++ src/backend/models.py | 2 ++ src/backend/routers/a2a.py | 9 +++++-- .../services/task_execution_service.py | 3 +++ src/frontend/src/views/ExecutionDetail.vue | 8 +++++- 9 files changed, 61 insertions(+), 3 deletions(-) create mode 100644 src/backend/migrations/versions/0099_a2a_caller_identity.py diff --git a/src/backend/db/migrations.py b/src/backend/db/migrations.py index c49b251ded..98aeccf803 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -5540,6 +5540,17 @@ def _migrate_agent_skill_gates(cursor, conn): conn.commit() +def _migrate_a2a_caller_identity(cursor, conn): + """abilityai/trinity-enterprise#862 — store the AAuth peer identity that + triggered an inbound A2A execution so it is visible on the execution row. + Additive: NULL for all existing rows. Mirrored by Alembic 0099_a2a_caller_identity.""" + _safe_add_column( + cursor, "schedule_executions", "a2a_caller_identity", + "ALTER TABLE schedule_executions ADD COLUMN a2a_caller_identity TEXT", + ) + conn.commit() + + MIGRATIONS = [ ("agent_sharing", _migrate_agent_sharing_table), ("schedule_executions_observability", _migrate_schedule_executions_observability), @@ -5708,4 +5719,5 @@ def _migrate_agent_skill_gates(cursor, conn): ("a2a_internal_scope", _migrate_a2a_internal_scope), ("operator_queue_replace", _migrate_operator_queue_replace), ("proactive_consent_not_asked", _migrate_proactive_consent_not_asked), + ("a2a_caller_identity", _migrate_a2a_caller_identity), ] diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 6782bd215a..92eb0cc906 100644 --- a/src/backend/db/schema.py +++ b/src/backend/db/schema.py @@ -302,6 +302,9 @@ -- #2843: the conversation this turn continues; at most one running -- row per (agent, key). NULL = no conversation guard. conversation_key TEXT, + -- ent#862: AAuth peer identity that triggered this execution, + -- e.g. aauth:agent@peer.example.com. NULL for non-AAuth callers. + a2a_caller_identity TEXT, FOREIGN KEY (schedule_id) REFERENCES agent_schedules(id) ) """, diff --git a/src/backend/db/tables.py b/src/backend/db/tables.py index b83250efb6..703b174f81 100644 --- a/src/backend/db/tables.py +++ b/src/backend/db/tables.py @@ -313,6 +313,7 @@ def process_bind_param(self, value, dialect): # (the pull claim relies on its IntegrityError), so autogenerate must not # propose dropping it. Column("conversation_key", Text), + Column("a2a_caller_identity", Text), Index( "idx_executions_one_running_turn", "agent_name", "conversation_key", diff --git a/src/backend/db/write_params.py b/src/backend/db/write_params.py index e8c9094ceb..d5f3e57b48 100644 --- a/src/backend/db/write_params.py +++ b/src/backend/db/write_params.py @@ -49,6 +49,7 @@ class TaskExecutionFields(ExecutionSource): source_channel_client: Optional[str] = None open_canvas_id: Optional[str] = None chain_depth: Optional[int] = None + a2a_caller_identity: Optional[str] = None @dataclass(frozen=True, kw_only=True) diff --git a/src/backend/migrations/versions/0099_a2a_caller_identity.py b/src/backend/migrations/versions/0099_a2a_caller_identity.py new file mode 100644 index 0000000000..225476d461 --- /dev/null +++ b/src/backend/migrations/versions/0099_a2a_caller_identity.py @@ -0,0 +1,25 @@ +"""a2a_caller_identity (abilityai/trinity-enterprise#862). + +PostgreSQL half of the dual-track pair; SQLite half is db/migrations.py::a2a_caller_identity. + +Revision ID: 0099_a2a_caller_identity +Revises: 0098_proactive_consent_not_asked +""" +revision = "0099_a2a_caller_identity" +down_revision = "0098_proactive_consent_not_asked" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + from alembic import op + op.execute( + "ALTER TABLE schedule_executions ADD COLUMN IF NOT EXISTS a2a_caller_identity TEXT" + ) + + +def downgrade() -> None: + from alembic import op + op.execute( + "ALTER TABLE schedule_executions DROP COLUMN IF EXISTS a2a_caller_identity" + ) diff --git a/src/backend/models.py b/src/backend/models.py index 299796b6d3..f8eb212249 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -4176,6 +4176,8 @@ class ExecutionResponse(BaseModel): compact_metadata: Optional[str] = None # Turn-integrity flags (#2467) - small JSON object; NULL = no evidence turn_integrity: Optional[str] = None + # ent#862: AAuth peer identity that triggered this execution (NULL for non-AAuth callers) + a2a_caller_identity: Optional[str] = None # trinity-enterprise#754 — as on ExecutionSummary. gate_self_approved: bool = False gate_self_approved_by_viewer: bool = False diff --git a/src/backend/routers/a2a.py b/src/backend/routers/a2a.py index 7886fdf79a..4726c7c61f 100644 --- a/src/backend/routers/a2a.py +++ b/src/backend/routers/a2a.py @@ -637,7 +637,8 @@ def _authorize_inbound(current_user: User, agent_name: str) -> None: raise HTTPException(status_code=403, detail="Caller not on the agent's A2A inbound allow-list") -async def _run_a2a_task(agent_name: str, text: str, current_user: Optional[User]): +async def _run_a2a_task(agent_name: str, text: str, current_user: Optional[User], + *, aauth_identity: Optional[str] = None): """Bridge one inbound A2A message into the Trinity execution stack. #2524: goes through `dispatch_and_await_terminal`, not `execute_task` @@ -658,6 +659,7 @@ async def _run_a2a_task(agent_name: str, text: str, current_user: Optional[User] source_user_id=getattr(current_user, "id", None), source_user_email=getattr(current_user, "email", None), source_mcp_key_id=getattr(current_user, "mcp_key_id", None), + a2a_caller_identity=aauth_identity, ) @@ -1360,7 +1362,10 @@ async def _dispatch_jsonrpc( return await _stream_task(agent_name, text, current_user, rpc_id, decision, caller_ip) try: - result = await _run_a2a_task(agent_name, text, current_user) + result = await _run_a2a_task( + agent_name, text, current_user, + aauth_identity=getattr(aauth, "identity", None), + ) except SkillGateError as exc: task = _gate_task(exc) if isinstance(exc, SkillApprovalRequired): diff --git a/src/backend/services/task_execution_service.py b/src/backend/services/task_execution_service.py index 6ec1847faa..5173471372 100644 --- a/src/backend/services/task_execution_service.py +++ b/src/backend/services/task_execution_service.py @@ -1549,6 +1549,8 @@ async def execute_task( # #2973: inter-agent chain depth (#2806) the caller already admitted; # stamped on the row created here. Ignored when `execution_id` is given. chain_depth: Optional[int] = None, + # ent#862: AAuth peer identity that triggered this execution. + a2a_caller_identity: Optional[str] = None, # trinity-enterprise#751: the requester's OWN words, when `message` is a # composed prompt (history, sender context, a template). The skill gate # scans this, never the composition. None ⇒ `message` is the requester's. @@ -1659,6 +1661,7 @@ async def execute_task( source_channel_client=source_channel_client, source_channel_agent=source_channel_agent, chain_depth=chain_depth, + a2a_caller_identity=a2a_caller_identity, ), ) execution_id = execution.id if execution else None diff --git a/src/frontend/src/views/ExecutionDetail.vue b/src/frontend/src/views/ExecutionDetail.vue index a003b765a9..eaa801d0a9 100644 --- a/src/frontend/src/views/ExecutionDetail.vue +++ b/src/frontend/src/views/ExecutionDetail.vue @@ -201,6 +201,11 @@ {{ execution.source_mcp_key_name }} ({{ execution.source_mcp_key_id.substring(0, 8) }}...)
+ +
+ AAuth Caller: + {{ execution.a2a_caller_identity }} +
{ if (!execution.value) return false return execution.value.source_user_email || execution.value.source_agent_name || - execution.value.source_mcp_key_name + execution.value.source_mcp_key_name || + execution.value.a2a_caller_identity }) const logEntries = computed(() => { From 8f244e2b006db279143e4387044c7b9496d99099 Mon Sep 17 00:00:00 2001 From: "Trinity Agent (trinity)" Date: Fri, 23 Oct 2026 03:31:34 -0400 Subject: [PATCH 4/4] test(ent862): cover a2a_caller_identity in the every-task-field persistence guard Co-Authored-By: Claude Opus 5.5 --- tests/unit/test_1482_write_params.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_1482_write_params.py b/tests/unit/test_1482_write_params.py index a1b2a45d81..a44756528c 100644 --- a/tests/unit/test_1482_write_params.py +++ b/tests/unit/test_1482_write_params.py @@ -148,6 +148,7 @@ def test_every_task_field_is_persisted(self, schedule_ops): "source_channel_chat_id": "C1", "source_channel_thread": "T1", "source_channel_agent": "binder", "source_channel_client": "client@example.com", "open_canvas_id": "canvas-1", "chain_depth": 2, + "a2a_caller_identity": "aauth:echo@a.example", } # Guard the guard: this test covers every field the object has. assert set(values) == {f.name for f in dataclasses.fields(TaskExecutionFields)}