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 000000000..0a1a323a7 --- /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/src/backend/db/migrations.py b/src/backend/db/migrations.py index 2efe496aa..683c0cb26 100644 --- a/src/backend/db/migrations.py +++ b/src/backend/db/migrations.py @@ -5690,6 +5690,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 0103_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), @@ -5862,4 +5873,5 @@ def _migrate_agent_skill_gates(cursor, conn): ("admin_email_not_username", _migrate_admin_email_not_username), ("held_gate_turns", _migrate_held_gate_turns), ("nevermined_delegations", _migrate_nevermined_delegations), + ("a2a_caller_identity", _migrate_a2a_caller_identity), ] diff --git a/src/backend/db/schedules/executions.py b/src/backend/db/schedules/executions.py index 9a1582b33..cbcbd3343 100644 --- a/src/backend/db/schedules/executions.py +++ b/src/backend/db/schedules/executions.py @@ -105,6 +105,9 @@ def _row_to_schedule_execution(row) -> ScheduleExecution: open_canvas_id=row["open_canvas_id"] if "open_canvas_id" in row_keys else None, # Inter-agent chain depth (#2806); NULL = root. chain_depth=row["chain_depth"] if "chain_depth" in row_keys else None, + # Verified AAuth peer of an inbound A2A call (ent#862); NULL otherwise. + a2a_caller_identity=row["a2a_caller_identity"] + if "a2a_caller_identity" in row_keys else None, ) # ========================================================================= diff --git a/src/backend/db/schema.py b/src/backend/db/schema.py index 1b55deb07..8952bc622 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 fcd8436a7..50b71e2a3 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 e8c9094ce..d5f3e57b4 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/db_models.py b/src/backend/db_models.py index d55debd8f..6fb735397 100644 --- a/src/backend/db_models.py +++ b/src/backend/db_models.py @@ -313,6 +313,9 @@ class ScheduleExecution(BaseModel): open_canvas_id: Optional[str] = None # #2806 — agent-to-agent hops from a non-agent root (NULL/None = root). chain_depth: Optional[int] = None + # ent#862 — verified AAuth peer identity of an inbound A2A caller + # (`aauth:@`); None for bearer/non-A2A rows. + a2a_caller_identity: Optional[str] = None # ========================================================================= diff --git a/src/backend/migrations/versions/0103_a2a_caller_identity.py b/src/backend/migrations/versions/0103_a2a_caller_identity.py new file mode 100644 index 000000000..a3b3583ac --- /dev/null +++ b/src/backend/migrations/versions/0103_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: 0103_a2a_caller_identity +Revises: 0102_nevermined_delegations +""" +revision = "0103_a2a_caller_identity" +down_revision = "0102_nevermined_delegations" +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 c877b81e2..7894147f2 100644 --- a/src/backend/models.py +++ b/src/backend/models.py @@ -4209,6 +4209,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 60d0da20c..92820b55f 100644 --- a/src/backend/routers/a2a.py +++ b/src/backend/routers/a2a.py @@ -599,7 +599,8 @@ def _task_from_row(exec_id: str, row: Any) -> Dict[str, Any]: def _mint_a2a_execution(agent_name: str, text: str, current_user: Optional[User], - *, model: Optional[str] = None) -> str: + *, model: Optional[str] = None, + aauth_identity: Optional[str] = None) -> str: """Create the execution row BEFORE dispatch, so the router holds the id while the turn runs (#3749). `execute_task(execution_id=...)` then skips its own row creation — the pre-created-row contract `/chat` has used since #96. @@ -630,6 +631,8 @@ def _mint_a2a_execution(agent_name: str, text: str, current_user: Optional[User] source_mcp_key_name=getattr(current_user, "mcp_key_name", None), model_used=model, subscription_id=subscription_id, + # ent#862: the AAuth peer identity that triggered this execution. + a2a_caller_identity=aauth_identity, ), ) if execution is None or not getattr(execution, "id", None): @@ -1690,7 +1693,10 @@ async def _dispatch_jsonrpc( # runs; the claim names it at once (a concurrent duplicate inside the # budget then gets a pollable `working` instead of "retry"). try: - exec_id = _mint_a2a_execution(agent_name, text, current_user) + exec_id = _mint_a2a_execution( + agent_name, text, current_user, + aauth_identity=getattr(aauth, "identity", None), + ) except Exception as exc: # noqa: BLE001 idempotency_service.fail(decision) logger.warning("a2a message/send could not mint a row for %s: %s", agent_name, exc) diff --git a/src/frontend/src/components/A2aPanel.vue b/src/frontend/src/components/A2aPanel.vue index a2af4b155..cc35f8d10 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 }} +

+
@@ -236,6 +250,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue' import { useAgentsStore } from '../stores/agents' import { useRole } from '../composables/useRole' import { copyToClipboard } from '../utils/clipboard' +import BaseBadge from './base/BaseBadge.vue' const props = defineProps({ agentName: { type: String, required: true }, @@ -269,6 +284,21 @@ const skillDraft = ref([]) const newIdentity = ref('') +// 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/frontend/src/views/ExecutionDetail.vue b/src/frontend/src/views/ExecutionDetail.vue index a003b765a..eaa801d0a 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(() => { diff --git a/src/mcp-server/src/tools/a2a.ts b/src/mcp-server/src/tools/a2a.ts index 63646d503..1ad7176c8 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."), diff --git a/tests/unit/test_1482_write_params.py b/tests/unit/test_1482_write_params.py index a1b2a45d8..d81bc3e23 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)} @@ -160,6 +161,22 @@ def test_every_task_field_is_persisted(self, schedule_ops): "__manual__", "agent-a", "do it", "mcp") assert ex.chain_depth == 2 and ex.source_channel_client == "client@example.com" + def test_a2a_caller_identity_reads_back_through_the_execution_response(self, schedule_ops): + # ent#862: the column was written but never read back, so the execution + # detail route (`ExecutionResponse(**execution.model_dump())`) always + # returned null. Read it back the way that route does. + from db.write_params import TaskExecutionFields + from models import ExecutionResponse + ex = schedule_ops.create_task_execution( + "agent-a", "ping", "a2a", + TaskExecutionFields(a2a_caller_identity="aauth:echo@a.example")) + fetched = schedule_ops.get_execution(ex.id) + assert fetched.a2a_caller_identity == "aauth:echo@a.example" + assert ExecutionResponse(**fetched.model_dump()).a2a_caller_identity == ( + "aauth:echo@a.example") + bare = schedule_ops.get_execution(schedule_ops.create_task_execution("agent-a", "hi").id) + assert ExecutionResponse(**bare.model_dump()).a2a_caller_identity is None + def test_no_fields_is_a_bare_manual_row(self, schedule_ops): ex = schedule_ops.create_task_execution("agent-a", "hi") row = _row(ex.id) diff --git a/tests/unit/test_ent623_aauth_a2a.py b/tests/unit/test_ent623_aauth_a2a.py index a8fa3fa9e..c6218e7e4 100644 --- a/tests/unit/test_ent623_aauth_a2a.py +++ b/tests/unit/test_ent623_aauth_a2a.py @@ -77,7 +77,7 @@ def b(monkeypatch): from fastapi.testclient import TestClient state = {"issuer": B_ISS, "exposed": {"bot"}, "dispatched": [], "audit": [], "activity": [], - "owners": {}, "executions": {}} + "owners": {}, "executions": {}, "minted": []} monkeypatch.setattr(a2a, "db", types.SimpleNamespace( get_a2a_exposed=lambda name: name in state["exposed"], @@ -89,7 +89,8 @@ def b(monkeypatch): get_execution=lambda eid: state["executions"].get(eid), cancel_queued_execution=lambda eid, reason=None: False, # #3749: message/send mints the row before dispatch. - create_task_execution=lambda **kw: types.SimpleNamespace(id="exec-minted-623"), + create_task_execution=lambda **kw: ( + state["minted"].append(kw) or types.SimpleNamespace(id="exec-minted-623")), get_agent_subscription_id=lambda name: None, )) monkeypatch.setattr(a2a, "get_agent_container", @@ -258,6 +259,21 @@ def test_listed_identity_runs_task_without_a_trinity_user(b): assert b.state["scopes"] == [f"a2a:bot:aauth|{ECHO}"] +def test_minted_row_records_the_aauth_peer_identity(b): + # ent#862 PR3: the execution row names the peer that triggered it. Since + # #3749 the row is minted before dispatch, so the stamp lives on the mint. + r = b.sign_as("echo", _send()) + assert r.status_code == 200, r.text + assert [kw["fields"].a2a_caller_identity for kw in b.state["minted"]] == [ECHO] + + +def test_bearer_minted_row_records_no_aauth_identity(b): + _bearer(b) + r = b.http.post("/a2a/bot", json=_send(), headers={"Authorization": "Bearer x"}) + assert r.status_code == 200, r.text + assert [kw["fields"].a2a_caller_identity for kw in b.state["minted"]] == [None] + + def test_unlisted_identity_of_a_trusted_issuer_is_403(b): r = b.sign_as("counter", _send()) assert r.status_code == 403 diff --git a/tests/unit/test_ent966_delegation_schema.py b/tests/unit/test_ent966_delegation_schema.py index 47cdba5c9..17530989f 100644 --- a/tests/unit/test_ent966_delegation_schema.py +++ b/tests/unit/test_ent966_delegation_schema.py @@ -54,7 +54,7 @@ def test_registered_in_the_runner(self): from db import migrations names = [name for name, _ in migrations.MIGRATIONS] - assert names[-1] == "nevermined_delegations" + assert names.count("nevermined_delegations") == 1 def test_second_active_row_for_one_agent_is_rejected(self, tmp_path): conn = _migrated(tmp_path)