mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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:
@@ -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
|
||||
|
||||
+27
-11
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user