fix(auth): send X-Agent-Token + X-Agent-Team from all agent->API call sites

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.
This commit is contained in:
Renn F
2026-07-06 04:38:35 +02:00
parent ba646da07a
commit 53391f2248
6 changed files with 154 additions and 16 deletions
+7 -1
View File
@@ -25,6 +25,8 @@ from typing import Any
import httpx import httpx
from roboco.agents_config import get_agent_team
_TIMEOUT = 30.0 _TIMEOUT = 30.0
_SECRETARY_BASE_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob") _SECRETARY_BASE_TOOLS: tuple[str, ...] = ("Read", "Grep", "Glob")
@@ -36,10 +38,14 @@ def _api_base() -> str:
def _headers() -> dict[str, str]: def _headers() -> dict[str, str]:
agent_id = os.environ.get("ROBOCO_AGENT_ID", "")
headers = { 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"), "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") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: if token:
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
+27 -11
View File
@@ -44,6 +44,7 @@ from roboco.agent_sdk.models import (
from roboco.agent_sdk.transcript_usage import ( from roboco.agent_sdk.transcript_usage import (
sum_transcript_usage as _sum_transcript_usage, 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 DEFAULT_BUDGET as _BUDGET
from roboco.foundation.policy.agent_loop import retry_limit_for from roboco.foundation.policy.agent_loop import retry_limit_for
from roboco.services.gateway.envelope import Envelope from roboco.services.gateway.envelope import Envelope
@@ -52,10 +53,32 @@ logger = structlog.get_logger()
# Environment configuration # Environment configuration
AGENT_ID = os.environ.get("ROBOCO_AGENT_ID", "unknown") 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") MAIN_API_URL = os.environ.get("ROBOCO_API_URL", "http://roboco-orchestrator:8000")
SDK_PORT = int(os.environ.get("ROBOCO_SDK_PORT", "9000")) 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) # TOOL MANIFEST (gateway-enabled path)
# ============================================================================= # =============================================================================
@@ -171,7 +194,7 @@ async def _persist_received_message(msg: A2AMessage) -> None:
"initial_message": msg.content, "initial_message": msg.content,
"requires_response": False, "requires_response": False,
}, },
headers={"X-Agent-ID": AGENT_ID}, headers=_agent_headers(),
timeout=5.0, timeout=5.0,
) )
# Note: 409 conflict is ok - conversation already exists # Note: 409 conflict is ok - conversation already exists
@@ -269,10 +292,7 @@ async def _create_notification_fallback(req: SendRequest) -> None:
"urgent": req.urgent, "urgent": req.urgent,
}, },
}, },
headers={ headers=_agent_headers(),
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": "developer", # SDK doesn't know role
},
timeout=10.0, timeout=10.0,
) )
logger.info( 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 current task on behalf of the agent when Stop is allowed despite no
terminal tool having been called. terminal tool having been called.
""" """
role = os.environ.get("ROBOCO_AGENT_ROLE", "developer")
try: try:
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
await client.post( await client.post(
f"{MAIN_API_URL}/api/tasks/auto-substitute", f"{MAIN_API_URL}/api/tasks/auto-substitute",
json={"reason": "stopped_without_transition"}, json={"reason": "stopped_without_transition"},
headers={"X-Agent-ID": AGENT_ID, "X-Agent-Role": role}, headers=_agent_headers(),
timeout=5.0, timeout=5.0,
) )
logger.warning( logger.warning(
@@ -855,10 +874,7 @@ async def journal_post_mortem(req: PostMortemRequest) -> dict[str, str]:
await client.post( await client.post(
f"{MAIN_API_URL}/api/journals/me/entries", f"{MAIN_API_URL}/api/journals/me/entries",
json=payload, json=payload,
headers={ headers=_agent_headers(),
"X-Agent-ID": AGENT_ID,
"X-Agent-Role": os.environ.get("ROBOCO_AGENT_ROLE", "developer"),
},
timeout=5.0, timeout=5.0,
) )
except Exception as e: except Exception as e:
+22 -2
View File
@@ -18,6 +18,8 @@ from typing import Any
import httpx import httpx
from mcp.server.fastmcp import FastMCP from mcp.server.fastmcp import FastMCP
from roboco.agents_config import get_agent_team
ORCHESTRATOR_URL = os.environ.get( ORCHESTRATOR_URL = os.environ.get(
"ROBOCO_ORCHESTRATOR_URL", "ROBOCO_ORCHESTRATOR_URL",
"http://roboco-orchestrator:8000", "http://roboco-orchestrator:8000",
@@ -25,7 +27,25 @@ ORCHESTRATOR_URL = os.environ.get(
AGENT_ID = os.environ["ROBOCO_AGENT_ID"] AGENT_ID = os.environ["ROBOCO_AGENT_ID"]
AGENT_ROLE = os.environ["ROBOCO_AGENT_ROLE"] 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 _TIMEOUT = 15
mcp = FastMCP("roboco-git-readonly") 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.""" """GET against the orchestrator with the agent's identity headers."""
with httpx.Client(timeout=_TIMEOUT) as client: with httpx.Client(timeout=_TIMEOUT) as client:
response = client.get( response = client.get(
f"{ORCHESTRATOR_URL}{path}", headers=_HEADERS, params=params f"{ORCHESTRATOR_URL}{path}", headers=_headers(), params=params
) )
response.raise_for_status() response.raise_for_status()
result: dict[str, Any] = response.json() result: dict[str, Any] = response.json()
+19 -1
View File
@@ -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. # Consecutive failed recovery probes before the CEO is notified once per episode.
_CEO_NOTIFY_THRESHOLD = 10 _CEO_NOTIFY_THRESHOLD = 10
# Consecutive strategy-engine cycle failures before the CEO is notified once # Consecutive strategy-engine cycle failures before the CEO is notified once
@@ -10986,7 +11004,7 @@ Start now: evidence(task_id="{task_id}")
try: try:
resp = await client.post( resp = await client.post(
f"{self._api_url}/v1/flow/{role_path}/{verb}", 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}, json={"task_id": task_id, "notes": notes},
) )
body = resp.json() body = resp.json()
@@ -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"
+28 -1
View File
@@ -16,7 +16,11 @@ from roboco.agents_config import verify_agent_token
from roboco.foundation import identity as _foundation from roboco.foundation import identity as _foundation
from roboco.models import AgentRole from roboco.models import AgentRole
from roboco.models.permissions import TASK_PERMISSIONS, TaskAction 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: 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) monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False)
headers = _system_api_headers() headers = _system_api_headers()
assert headers["X-Agent-Token"] == "UNSIGNED" 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)