[auth] Omit UNSIGNED agent token at every agent->API call site

The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC secret
is unset at spawn. The API middleware rejects a presented-but-unverifiable
token with 401 'signature mismatch' even in dev mode (auth not required),
so forwarding UNSIGNED turned every flow/do/SDK/secretary/git verb into a
401 — the live pr_reviewer/i_am_idle signature-mismatch loop. Omit the
header when the token is the UNSIGNED sentinel at all five agent-side
header builders; dev accepts a missing token, prod 401s with 'Missing
X-Agent-Token' (the clear respawn-with-secret signal). Add a structlog
diagnostic on the middleware reject path so the next mismatch logs the
exact (id, role, team, token_unsigned, auth_required) inputs.
This commit is contained in:
Renn F
2026-07-06 05:43:02 +02:00
parent 2e938d20d6
commit 2b8bc10d8d
8 changed files with 86 additions and 5 deletions
+3 -1
View File
@@ -47,7 +47,9 @@ def _headers() -> dict[str, str]:
if team: if team:
headers["X-Agent-Team"] = team headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: # See flow_server._build_headers: forwarding the "UNSIGNED" sentinel 401s
# even in dev mode; omit so a missing token is accepted in dev.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
return headers return headers
+3 -1
View File
@@ -74,7 +74,9 @@ def _agent_headers() -> dict[str, str]:
if team: if team:
headers["X-Agent-Team"] = team headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: # See flow_server._build_headers: forwarding the "UNSIGNED" sentinel 401s
# even in dev mode; omit so a missing token is accepted in dev.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
return headers return headers
+16
View File
@@ -11,6 +11,7 @@ import os
from typing import TYPE_CHECKING, Annotated, Any from typing import TYPE_CHECKING, Annotated, Any
from uuid import UUID from uuid import UUID
import structlog
from fastapi import Cookie, Depends, Header, HTTPException, Response, status from fastapi import Cookie, Depends, Header, HTTPException, Response, status
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -45,6 +46,8 @@ from roboco.services.task import TaskService
from roboco.services.work_session import WorkSessionService from roboco.services.work_session import WorkSessionService
from roboco.services.workspace import WorkspaceService from roboco.services.workspace import WorkspaceService
logger = structlog.get_logger()
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable, Coroutine from collections.abc import Callable, Coroutine
@@ -245,6 +248,19 @@ def _check_agent_auth_token(
x_agent_role, x_agent_role,
x_agent_team or "", x_agent_team or "",
): ):
# Diagnose the mismatch: a stale token (secret rotated since spawn),
# an UNSIGNED sentinel forwarded by an older agent, or a genuine
# (id, role, team) drift all surface as the same "signature mismatch"
# 401. Log the inputs so the next failure pinpoints which.
logger.warning(
"Agent token rejected",
agent_id=x_agent_id,
agent_role=x_agent_role,
agent_team=x_agent_team,
token_present=bool(x_agent_token),
token_unsigned=x_agent_token == "UNSIGNED",
auth_required=_auth_required(),
)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=( detail=(
+3 -1
View File
@@ -240,7 +240,9 @@ def _build_headers() -> dict[str, str]:
if team: if team:
headers["X-Agent-Team"] = team headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: # See flow_server._build_headers: forwarding the "UNSIGNED" sentinel 401s
# even in dev mode; omit so a missing token is accepted in dev.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
return headers return headers
+7 -1
View File
@@ -274,7 +274,13 @@ def _build_headers() -> dict[str, str]:
if team: if team:
headers["X-Agent-Team"] = team headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: # The orchestrator injects "UNSIGNED" when ROBOCO_AGENT_AUTH_SECRET is
# unset at spawn. The middleware rejects a presented-but-unverifiable
# token with 401 "signature mismatch" even in dev mode, so forwarding
# UNSIGNED turns every flow verb into a 401. Omit the header instead —
# dev (auth not required) accepts a missing token; prod (auth required)
# 401s with "Missing X-Agent-Token", the clear respawn-with-secret signal.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
return headers return headers
+3 -1
View File
@@ -41,7 +41,9 @@ def _headers() -> dict[str, str]:
if team: if team:
headers["X-Agent-Team"] = team headers["X-Agent-Team"] = team
token = os.environ.get("ROBOCO_AGENT_TOKEN") token = os.environ.get("ROBOCO_AGENT_TOKEN")
if token: # See flow_server._build_headers: forwarding the "UNSIGNED" sentinel 401s
# even in dev mode; omit so a missing token is accepted in dev.
if token and token != "UNSIGNED":
headers["X-Agent-Token"] = token headers["X-Agent-Token"] = token
return headers return headers
+25
View File
@@ -119,6 +119,31 @@ def test_build_headers_carries_auth_token_and_team(
assert "X-Correlation-ID" in headers assert "X-Correlation-ID" in headers
def test_build_headers_omits_unsigned_token(monkeypatch: pytest.MonkeyPatch) -> None:
"""The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC
secret is unset at spawn. The middleware rejects a presented-but-unverifiable
token with 401 "signature mismatch" even in dev mode, so forwarding UNSIGNED
turns every do verb into a 401. Omit the header so dev (auth not required)
accepts the call; prod 401s with "Missing X-Agent-Token" instead."""
import importlib
be_dev_1 = "00000000-0000-0000-0001-000000000001"
manifest = Path(tempfile.mkdtemp()) / "tool-manifest.json"
manifest.write_text(json.dumps({**_DO_TEST_MANIFEST, "agent_id": be_dev_1}))
monkeypatch.setenv("ROBOCO_AGENT_ID", be_dev_1)
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "UNSIGNED")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest))
import roboco.mcp.do_server as srv
importlib.reload(srv)
headers = srv._build_headers()
assert "X-Agent-Token" not in headers
def test_dm_posts_all_fields(do_module: Any) -> None: def test_dm_posts_all_fields(do_module: Any) -> None:
fake_client = MagicMock() fake_client = MagicMock()
fake_client.__enter__.return_value = fake_client fake_client.__enter__.return_value = fake_client
@@ -162,6 +162,32 @@ def test_build_headers_carries_auth_token_and_team(
assert "X-Correlation-ID" in headers assert "X-Correlation-ID" in headers
def test_build_headers_omits_unsigned_token(
monkeypatch: pytest.MonkeyPatch, tmp_path: Path
) -> None:
"""The orchestrator injects ROBOCO_AGENT_TOKEN=UNSIGNED when the HMAC
secret is unset at spawn. The middleware rejects a presented-but-unverifiable
token with 401 "signature mismatch" even in dev mode, so forwarding UNSIGNED
turns every flow verb (give_me_work / i_am_idle / ...) into a 401 the live
pr_reviewer/i_am_idle signature-mismatch loop. Omit the header so dev (auth
not required) accepts the call."""
be_dev_1 = "00000000-0000-0000-0001-000000000001"
manifest = tmp_path / "tool-manifest.json"
manifest.write_text(json.dumps({**_FULL_MANIFEST, "agent_id": be_dev_1}))
monkeypatch.setenv("ROBOCO_AGENT_ID", be_dev_1)
monkeypatch.setenv("ROBOCO_AGENT_ROLE", "developer")
monkeypatch.setenv("ROBOCO_AGENT_TOKEN", "UNSIGNED")
monkeypatch.setenv("ROBOCO_ORCHESTRATOR_URL", "http://test-orchestrator:8000")
monkeypatch.setenv("ROBOCO_TOOL_MANIFEST_PATH", str(manifest))
import roboco.mcp.flow_server as srv
importlib.reload(srv)
headers = srv._build_headers()
assert "X-Agent-Token" not in headers
def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None: def test_i_will_work_on_passes_plan(flow_module: types.ModuleType) -> None:
fake_client = _make_fake_client({"status": "in_progress"}) fake_client = _make_fake_client({"status": "in_progress"})