From 53391f22487c5870ca9c8ef8fefb0f56248cace5 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 6 Jul 2026 04:38:35 +0200 Subject: [PATCH] fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prior fix (6ed4e139) covered the flow/do MCP servers but missed four other agent->orchestrator call sites that built the header dict by hand and omitted X-Agent-Token and/or X-Agent-Team. With ROBOCO_AGENT_AUTH_REQUIRED armed on the NAS, every one 401s: - agent_sdk/server.py: the session-end post-mortem flush (/api/journals/me/entries), A2A persistence + offline fallback (/api/a2a/*), and the stopped-without-transition auto-substitute (/api/tasks/auto-substitute) — all sent only X-Agent-ID/Role, so each 401'd 'Missing X-Agent-Token'. Add a shared _agent_headers() helper (mirroring flow_server._build_headers) and route all four through it. - agent_sdk/secretary_driver.py: _headers() sent the token but not the team, so the HMAC gate 401'd with signature mismatch (secretary is board-team; token signed with team='board', verified with team=''). Add the team header. - mcp/git_readonly.py: the read-only git MCP sent only X-Agent-ID/Role — no token, no team — so /api/git/* 401'd once auth was armed. Convert the static _HEADERS to a _headers() helper with team + token. - runtime/orchestrator.py: the cell-PM auto-submit self-API call acted as a PM with a hand-built {X-Agent-ID, X-Agent-Role} dict — no token, no team — 401ing under auth-required. Add _agent_api_headers(uuid, role) mirroring _system_api_headers, and use it. Tests: _agent_headers round-trip (token + team, team-omitted when None), _agent_api_headers carries a signed PM token + team. --- roboco/agent_sdk/secretary_driver.py | 8 ++- roboco/agent_sdk/server.py | 38 ++++++++++---- roboco/mcp/git_readonly.py | 24 ++++++++- roboco/runtime/orchestrator.py | 20 +++++++- tests/unit/agent_sdk/test_agent_headers.py | 51 +++++++++++++++++++ tests/unit/runtime/test_system_api_headers.py | 29 ++++++++++- 6 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 tests/unit/agent_sdk/test_agent_headers.py diff --git a/roboco/agent_sdk/secretary_driver.py b/roboco/agent_sdk/secretary_driver.py index 498345ac..d908223d 100644 --- a/roboco/agent_sdk/secretary_driver.py +++ b/roboco/agent_sdk/secretary_driver.py @@ -25,6 +25,8 @@ from typing import Any import httpx +from roboco.agents_config import get_agent_team + _TIMEOUT = 30.0 _SECRETARY_BASE_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob") @@ -36,10 +38,14 @@ def _api_base() -> str: def _headers() -> dict[str, str]: + agent_id = os.environ.get("ROBOCO_AGENT_ID", "") headers = { - "X-Agent-ID": os.environ.get("ROBOCO_AGENT_ID", ""), + "X-Agent-ID": agent_id, "X-Agent-Role": os.environ.get("ROBOCO_AGENT_ROLE", "secretary"), } + team = get_agent_team(agent_id) + if team: + headers["X-Agent-Team"] = team token = os.environ.get("ROBOCO_AGENT_TOKEN") if token: headers["X-Agent-Token"] = token diff --git a/roboco/agent_sdk/server.py b/roboco/agent_sdk/server.py index c3ae7caa..a2efd5c9 100644 --- a/roboco/agent_sdk/server.py +++ b/roboco/agent_sdk/server.py @@ -44,6 +44,7 @@ from roboco.agent_sdk.models import ( from roboco.agent_sdk.transcript_usage import ( sum_transcript_usage as _sum_transcript_usage, ) +from roboco.agents_config import get_agent_team from roboco.foundation.policy.agent_loop import DEFAULT_BUDGET as _BUDGET from roboco.foundation.policy.agent_loop import retry_limit_for from roboco.services.gateway.envelope import Envelope @@ -52,10 +53,32 @@ logger = structlog.get_logger() # Environment configuration AGENT_ID = os.environ.get("ROBOCO_AGENT_ID", "unknown") +AGENT_ROLE = os.environ.get("ROBOCO_AGENT_ROLE", "developer") MAIN_API_URL = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000") SDK_PORT = int(os.environ.get("ROBOCO_SDK_PORT", "9000")) +def _agent_headers() -> dict[str, str]: + """Headers for the SDK server's direct calls to the orchestrator API. + + Mirrors flow_server/do_server ``_build_headers``: ``X-Agent-Token`` (HMAC + over id:role:team, injected by the orchestrator at spawn) and + ``X-Agent-Team`` must travel with every call, or the API's + ``ROBOCO_AGENT_AUTH_REQUIRED`` gate 401s with "Missing X-Agent-Token" / + signature mismatch. Without this the session-end post-mortem flush, the + A2A persistence/fallback, and the auto-substitute call all fail when auth + is armed. + """ + headers = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE} + team = get_agent_team(AGENT_ID) + if team: + headers["X-Agent-Team"] = team + token = os.environ.get("ROBOCO_AGENT_TOKEN") + if token: + headers["X-Agent-Token"] = token + return headers + + # ============================================================================= # TOOL MANIFEST (gateway-enabled path) # ============================================================================= @@ -171,7 +194,7 @@ async def _persist_received_message(msg: A2AMessage) -> None: "initial_message": msg.content, "requires_response": False, }, - headers={"X-Agent-ID": AGENT_ID}, + headers=_agent_headers(), timeout=5.0, ) # Note: 409 conflict is ok - conversation already exists @@ -269,10 +292,7 @@ async def _create_notification_fallback(req: SendRequest) -> None: "urgent": req.urgent, }, }, - headers={ - "X-Agent-ID": AGENT_ID, - "X-Agent-Role": "developer", # SDK doesn't know role - }, + headers=_agent_headers(), timeout=10.0, ) logger.info( @@ -672,13 +692,12 @@ async def terminal_force_substitute() -> dict[str, str]: current task on behalf of the agent when Stop is allowed despite no terminal tool having been called. """ - role = os.environ.get("ROBOCO_AGENT_ROLE", "developer") try: async with httpx.AsyncClient() as client: await client.post( f"{MAIN_API_URL}/api/tasks/auto-substitute", json={"reason": "stopped_without_transition"}, - headers={"X-Agent-ID": AGENT_ID, "X-Agent-Role": role}, + headers=_agent_headers(), timeout=5.0, ) logger.warning( @@ -855,10 +874,7 @@ async def journal_post_mortem(req: PostMortemRequest) -> dict[str, str]: await client.post( f"{MAIN_API_URL}/api/journals/me/entries", json=payload, - headers={ - "X-Agent-ID": AGENT_ID, - "X-Agent-Role": os.environ.get("ROBOCO_AGENT_ROLE", "developer"), - }, + headers=_agent_headers(), timeout=5.0, ) except Exception as e: diff --git a/roboco/mcp/git_readonly.py b/roboco/mcp/git_readonly.py index 64398541..fe61eb8a 100644 --- a/roboco/mcp/git_readonly.py +++ b/roboco/mcp/git_readonly.py @@ -18,6 +18,8 @@ from typing import Any import httpx from mcp.server.fastmcp import FastMCP +from roboco.agents_config import get_agent_team + ORCHESTRATOR_URL = os.environ.get( "ROBOCO_ORCHESTRATOR_URL", "http://roboco-orchestrator:8000", @@ -25,7 +27,25 @@ ORCHESTRATOR_URL = os.environ.get( AGENT_ID = os.environ["ROBOCO_AGENT_ID"] AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"] -_HEADERS = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE} + +def _headers() -> dict[str, str]: + """Identity + HMAC token headers for the orchestrator git reads. + + The git routes sit behind the same ``ROBOCO_AGENT_AUTH_REQUIRED`` gate as + the rest of ``/api/`` — a static ``{X-Agent-ID, X-Agent-Role}`` dict 401s + with "Missing X-Agent-Token" once auth is armed. Built per call (token is + stable per container, but mirroring flow/do/server keeps the pattern). + """ + headers = {"X-Agent-ID": AGENT_ID, "X-Agent-Role": AGENT_ROLE} + team = get_agent_team(AGENT_ID) + if team: + headers["X-Agent-Team"] = team + token = os.environ.get("ROBOCO_AGENT_TOKEN") + if token: + headers["X-Agent-Token"] = token + return headers + + _TIMEOUT = 15 mcp = FastMCP("roboco-git-readonly") @@ -41,7 +61,7 @@ def _get(path: str, params: dict[str, Any]) -> dict[str, Any]: """GET against the orchestrator with the agent's identity headers.""" with httpx.Client(timeout=_TIMEOUT) as client: response = client.get( - f"{ORCHESTRATOR_URL}{path}", headers=_HEADERS, params=params + f"{ORCHESTRATOR_URL}{path}", headers=_headers(), params=params ) response.raise_for_status() result: dict[str, Any] = response.json() diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index 1dd81da1..cedce507 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -162,6 +162,24 @@ def _system_api_headers() -> dict[str, str]: } +def _agent_api_headers(agent_uuid: str, role: str) -> dict[str, str]: + """Headers for the orchestrator's internal self-API calls acting as a + specific agent (the cell-PM auto-submit). Adds the signed ``X-Agent-Token`` + + ``X-Agent-Team`` so the call passes the ``ROBOCO_AGENT_AUTH_REQUIRED`` + gate — a hand-built ``{X-Agent-ID, X-Agent-Role}`` dict 401s with + "Missing X-Agent-Token" under auth-required (F038/F039 — the same gap the + system-headers helper closes for the system identity). + """ + from roboco.agents_config import issue_agent_token + + team = get_agent_team(agent_uuid) or "" + headers = {"X-Agent-ID": agent_uuid, "X-Agent-Role": role} + if team: + headers["X-Agent-Team"] = team + headers["X-Agent-Token"] = issue_agent_token(agent_uuid, role, team) + return headers + + # Consecutive failed recovery probes before the CEO is notified once per episode. _CEO_NOTIFY_THRESHOLD = 10 # Consecutive strategy-engine cycle failures before the CEO is notified once @@ -10986,7 +11004,7 @@ Start now: evidence(task_id="{task_id}") try: resp = await client.post( f"{self._api_url}/v1/flow/{role_path}/{verb}", - headers={"X-Agent-ID": pm_uuid, "X-Agent-Role": role}, + headers=_agent_api_headers(pm_uuid, role), json={"task_id": task_id, "notes": notes}, ) body = resp.json() diff --git a/tests/unit/agent_sdk/test_agent_headers.py b/tests/unit/agent_sdk/test_agent_headers.py new file mode 100644 index 00000000..fa200eec --- /dev/null +++ b/tests/unit/agent_sdk/test_agent_headers.py @@ -0,0 +1,51 @@ +"""The SDK server's direct orchestrator calls must carry the agent HMAC +token + team, or the API's ``ROBOCO_AGENT_AUTH_REQUIRED`` gate 401s with +"Missing X-Agent-Token" — regression: the session-end post-mortem flush +(``/api/journals/me/entries``), A2A persistence/fallback, and +auto-substitute call all built the header dict by hand and omitted both, +latent until auth was armed on the NAS deploy. +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING + +import roboco.agent_sdk.server as srv + +if TYPE_CHECKING: + import pytest + + +def test_agent_headers_carries_token_and_team( + monkeypatch: pytest.MonkeyPatch, +) -> None: + be_dev_1 = "00000000-0000-0000-0001-000000000001" # role=developer, team=backend + monkeypatch.setenv("ROBOCO_AGENT_ID", be_dev_1) + monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer") + monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "test-hmac-token") + importlib.reload(srv) + + headers = srv._agent_headers() + + assert headers["X-Agent-ID"] == be_dev_1 + assert headers["X-Agent-Role"] == "developer" + assert headers["X-Agent-Team"] == "backend" + assert headers["X-Agent-Token"] == "test-hmac-token" + + +def test_agent_headers_omits_team_when_none( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A team-less agent (the `system` sentinel) confirms the team header is + # omitted, not sent empty — so the middleware passes "" and matches a + # token signed with team="". + monkeypatch.setenv("ROBOCO_AGENT_ID", "00000000-0000-0000-0000-000000000000") + monkeypatch.setenv("ROBOCO_AGENT_ROLE", "system") + monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "test-hmac-token") + importlib.reload(srv) + + headers = srv._agent_headers() + + assert "X-Agent-Team" not in headers + assert headers["X-Agent-Token"] == "test-hmac-token" diff --git a/tests/unit/runtime/test_system_api_headers.py b/tests/unit/runtime/test_system_api_headers.py index 0fb70d74..51da4c48 100644 --- a/tests/unit/runtime/test_system_api_headers.py +++ b/tests/unit/runtime/test_system_api_headers.py @@ -16,7 +16,11 @@ from roboco.agents_config import verify_agent_token from roboco.foundation import identity as _foundation from roboco.models import AgentRole from roboco.models.permissions import TASK_PERMISSIONS, TaskAction -from roboco.runtime.orchestrator import _SYSTEM_API_HEADERS, _system_api_headers +from roboco.runtime.orchestrator import ( + _SYSTEM_API_HEADERS, + _agent_api_headers, + _system_api_headers, +) def test_system_api_headers_match_the_system_identity() -> None: @@ -55,3 +59,26 @@ def test_system_api_headers_unsigned_when_secret_unset( monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False) headers = _system_api_headers() assert headers["X-Agent-Token"] == "UNSIGNED" + + +def test_agent_api_headers_carry_signed_token_and_team( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # The cell-PM auto-submit self-API call acts as a specific PM. A hand-built + # {X-Agent-ID, X-Agent-Role} dict 401s under ROBOCO_AGENT_AUTH_REQUIRED — + # same F038/F039 gap as the system self-call. _agent_api_headers must carry + # a token signed for that PM's (id, role, team) plus the team header. + monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", secrets.token_hex(32)) + be_pm = _foundation.AGENTS["be-pm"] + be_pm_uuid = str(be_pm.uuid) + role = be_pm.role.value # "cell_pm" + team = be_pm.team.value # "backend" + + headers = _agent_api_headers(be_pm_uuid, role) + + assert headers["X-Agent-ID"] == be_pm_uuid + assert headers["X-Agent-Role"] == role + assert headers["X-Agent-Team"] == team + token = headers["X-Agent-Token"] + assert token and token != "UNSIGNED" + assert verify_agent_token(token, be_pm_uuid, role, team)