diff --git a/roboco/agent_sdk/secretary_driver.py b/roboco/agent_sdk/secretary_driver.py index d908223d..693ee6e3 100644 --- a/roboco/agent_sdk/secretary_driver.py +++ b/roboco/agent_sdk/secretary_driver.py @@ -47,7 +47,9 @@ def _headers() -> dict[str, str]: if team: headers["X-Agent-Team"] = team 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 return headers diff --git a/roboco/agent_sdk/server.py b/roboco/agent_sdk/server.py index a2efd5c9..5bcb8676 100644 --- a/roboco/agent_sdk/server.py +++ b/roboco/agent_sdk/server.py @@ -74,7 +74,9 @@ def _agent_headers() -> dict[str, str]: if team: headers["X-Agent-Team"] = team 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 return headers diff --git a/roboco/api/deps.py b/roboco/api/deps.py index 3d4afaa8..789a5908 100644 --- a/roboco/api/deps.py +++ b/roboco/api/deps.py @@ -11,6 +11,7 @@ import os from typing import TYPE_CHECKING, Annotated, Any from uuid import UUID +import structlog from fastapi import Cookie, Depends, Header, HTTPException, Response, status from sqlalchemy import select 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.workspace import WorkspaceService +logger = structlog.get_logger() + if TYPE_CHECKING: from collections.abc import Callable, Coroutine @@ -245,6 +248,19 @@ def _check_agent_auth_token( x_agent_role, 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( status_code=status.HTTP_401_UNAUTHORIZED, detail=( diff --git a/roboco/mcp/do_server.py b/roboco/mcp/do_server.py index db304991..a55512e0 100644 --- a/roboco/mcp/do_server.py +++ b/roboco/mcp/do_server.py @@ -240,7 +240,9 @@ def _build_headers() -> dict[str, str]: if team: headers["X-Agent-Team"] = team 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 return headers diff --git a/roboco/mcp/flow_server.py b/roboco/mcp/flow_server.py index 1660c351..c0866e5d 100644 --- a/roboco/mcp/flow_server.py +++ b/roboco/mcp/flow_server.py @@ -274,7 +274,13 @@ def _build_headers() -> dict[str, str]: if team: headers["X-Agent-Team"] = team 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 return headers diff --git a/roboco/mcp/git_readonly.py b/roboco/mcp/git_readonly.py index fe61eb8a..ecfc1830 100644 --- a/roboco/mcp/git_readonly.py +++ b/roboco/mcp/git_readonly.py @@ -41,7 +41,9 @@ def _headers() -> dict[str, str]: if team: headers["X-Agent-Team"] = team 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 return headers diff --git a/tests/unit/mcp_servers/test_do_server.py b/tests/unit/mcp_servers/test_do_server.py index 98b37ed9..add22c82 100644 --- a/tests/unit/mcp_servers/test_do_server.py +++ b/tests/unit/mcp_servers/test_do_server.py @@ -119,6 +119,31 @@ def test_build_headers_carries_auth_token_and_team( 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: fake_client = MagicMock() fake_client.__enter__.return_value = fake_client diff --git a/tests/unit/mcp_servers/test_flow_server.py b/tests/unit/mcp_servers/test_flow_server.py index 15a0daa4..7b2e6ea9 100644 --- a/tests/unit/mcp_servers/test_flow_server.py +++ b/tests/unit/mcp_servers/test_flow_server.py @@ -162,6 +162,32 @@ def test_build_headers_carries_auth_token_and_team( 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: fake_client = _make_fake_client({"status": "in_progress"})