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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 '<key>' --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.
12 changes: 12 additions & 0 deletions src/backend/db/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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),
]
3 changes: 3 additions & 0 deletions src/backend/db/schedules/executions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

# =========================================================================
Expand Down
3 changes: 3 additions & 0 deletions src/backend/db/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
)
""",
Expand Down
1 change: 1 addition & 0 deletions src/backend/db/tables.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions src/backend/db/write_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions src/backend/db_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:<local>@<host>`); None for bearer/non-A2A rows.
a2a_caller_identity: Optional[str] = None


# =========================================================================
Expand Down
25 changes: 25 additions & 0 deletions src/backend/migrations/versions/0103_a2a_caller_identity.py
Original file line number Diff line number Diff line change
@@ -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"
)
2 changes: 2 additions & 0 deletions src/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 8 additions & 2 deletions src/backend/routers/a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
54 changes: 42 additions & 12 deletions src/frontend/src/components/A2aPanel.vue
Original file line number Diff line number Diff line change
Expand Up @@ -146,39 +146,53 @@
<div class="mt-5 pt-5 border-t border-gray-200 dark:border-gray-700">
<h4 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-1">Inbound allow-list</h4>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
Trinity accounts permitted to task this agent inbound — the caller's account
<strong>email</strong> (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:
<strong>email or username</strong> for Trinity accounts on this instance, or
<strong><code class="font-mono">aauth:&lt;agent&gt;@&lt;peer-host&gt;</code></strong>
for an agent on a peer Trinity instance over AAuth.
</p>
<div v-if="config.inbound_allowlist.length" class="space-y-2 mb-3">
<div
v-for="id in config.inbound_allowlist"
:key="id"
class="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-gray-50 dark:bg-gray-900/50 border border-gray-200 dark:border-gray-700"
>
<code class="font-mono text-xs text-gray-800 dark:text-gray-200 overflow-x-auto">{{ id }}</code>
<div class="flex items-center gap-2 min-w-0">
<BaseBadge
:variant="isAAuthIdentity(id) ? 'info' : 'neutral'"
size="sm"
class="shrink-0"
>{{ isAAuthIdentity(id) ? 'AAuth' : 'Local' }}</BaseBadge>
<code class="font-mono text-xs text-gray-800 dark:text-gray-200 overflow-x-auto">{{ id }}</code>
</div>
<button
type="button"
@click="removeIdentity(id)"
:disabled="busy"
class="text-status-danger-600 hover:text-status-danger-700 text-xs font-medium disabled:opacity-50"
class="text-status-danger-600 hover:text-status-danger-700 text-xs font-medium disabled:opacity-50 shrink-0"
>Remove</button>
</div>
</div>
<div v-else class="text-sm text-gray-500 dark:text-gray-400 mb-3">
No restriction — any authenticated owner/shared caller may task the agent.
</div>
<form class="flex items-center gap-2" @submit.prevent="addIdentity">
<input
v-model.trim="newIdentity"
type="text"
placeholder="caller@example.com"
class="flex-1 px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-action-primary-500 focus:outline-none"
/>
<div class="flex-1 flex flex-col gap-1">
<input
v-model.trim="newIdentity"
type="text"
placeholder="caller@example.com or aauth:agent@peer-host"
class="w-full px-3 py-1.5 text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-action-primary-500 focus:outline-none"
/>
<p v-if="aAuthIdentityFormatWarning" class="text-xs text-status-warning-600 dark:text-status-warning-400">
{{ aAuthIdentityFormatWarning }}
</p>
</div>
<button
type="submit"
:disabled="busy || !newIdentity"
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-action-primary-600 hover:bg-action-primary-700 disabled:opacity-50"
class="px-3 py-1.5 text-sm font-medium rounded-md text-white bg-action-primary-600 hover:bg-action-primary-700 disabled:opacity-50 self-start"
>Add</button>
</form>
</div>
Expand Down Expand Up @@ -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 },
Expand Down Expand Up @@ -269,6 +284,21 @@ const skillDraft = ref([])

const newIdentity = ref('')

// aauth:<local>@<host> — 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:<agent-name>@<peer-host> — 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(() =>
Expand Down
8 changes: 7 additions & 1 deletion src/frontend/src/views/ExecutionDetail.vue
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,11 @@
<span class="ml-2 text-gray-900 dark:text-white font-medium">{{ execution.source_mcp_key_name }}</span>
<span v-if="execution.source_mcp_key_id" class="ml-1 text-xs text-gray-500 dark:text-gray-400 font-mono">({{ execution.source_mcp_key_id.substring(0, 8) }}...)</span>
</div>
<!-- AAuth peer caller (ent#862) -->
<div v-if="execution.a2a_caller_identity" class="md:col-span-full">
<span class="font-medium">AAuth Caller:</span>
<code class="ml-2 font-mono text-xs">{{ execution.a2a_caller_identity }}</code>
</div>
</div>
<ExecutionGateMarker
class="mt-3"
Expand Down Expand Up @@ -481,7 +486,8 @@ const hasOriginInfo = computed(() => {
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(() => {
Expand Down
4 changes: 3 additions & 1 deletion src/mcp-server/src/tools/a2a.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:<agent>@<peer-host>` 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."),
Expand Down
17 changes: 17 additions & 0 deletions tests/unit/test_1482_write_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand All @@ -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)
Expand Down
20 changes: 18 additions & 2 deletions tests/unit/test_ent623_aauth_a2a.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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",
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion tests/unit/test_ent966_delegation_schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading