From 2e938d20d63cf8eba10fcd805cf41aecb91e8cd6 Mon Sep 17 00:00:00 2001 From: Renn F Date: Mon, 6 Jul 2026 05:42:45 +0200 Subject: [PATCH] [auth] Omit UNSIGNED self-call token in dev mode + video-engine test mypy fix _agent_api_headers sent the UNSIGNED sentinel when ROBOCO_AGENT_AUTH_SECRET was unset, but the dev-mode middleware rejects a presented-but-unverifiable token with 401 signature mismatch (while accepting a missing one). The cell-PM auto-submit self-call 401'd in every dev run, regressing test_auto_submit_cuts_the_pm_turn. Attach the token only when a secret is set. Also fix the FromClause.update mypy error in the per-project video-engine opt-out test (ORM row load + flush). --- roboco/runtime/orchestrator.py | 11 +++++++++-- tests/unit/runtime/test_system_api_headers.py | 15 +++++++++++++++ tests/unit/services/test_video_engine.py | 9 ++++----- 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/roboco/runtime/orchestrator.py b/roboco/runtime/orchestrator.py index cedce507..9b75dc38 100644 --- a/roboco/runtime/orchestrator.py +++ b/roboco/runtime/orchestrator.py @@ -169,14 +169,21 @@ def _agent_api_headers(agent_uuid: str, role: str) -> dict[str, str]: 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). + + The token is attached only when ``ROBOCO_AGENT_AUTH_SECRET`` is set: the + dev-mode middleware rejects a presented-but-unverifiable token (the + ``UNSIGNED`` sentinel) with 401 "signature mismatch" while accepting a + missing token, so sending ``UNSIGNED`` would turn a clean dev self-call + into a 401. With the secret armed the token is signed and verifies. """ - from roboco.agents_config import issue_agent_token + from roboco.agents_config import _auth_secret, 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) + if _auth_secret(): + headers["X-Agent-Token"] = issue_agent_token(agent_uuid, role, team) return headers diff --git a/tests/unit/runtime/test_system_api_headers.py b/tests/unit/runtime/test_system_api_headers.py index 51da4c48..e846b9f9 100644 --- a/tests/unit/runtime/test_system_api_headers.py +++ b/tests/unit/runtime/test_system_api_headers.py @@ -82,3 +82,18 @@ def test_agent_api_headers_carry_signed_token_and_team( token = headers["X-Agent-Token"] assert token and token != "UNSIGNED" assert verify_agent_token(token, be_pm_uuid, role, team) + + +def test_agent_api_headers_omit_token_when_secret_unset( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Dev mode: with no secret set, issue_agent_token returns the UNSIGNED + # sentinel, but the dev-mode middleware rejects a presented-but-unverifiable + # token with 401 "signature mismatch" while accepting a missing token. + # Sending UNSIGNED would 401 the cell-PM auto-submit self-call in every dev + # run (the e2e test_auto_submit_cuts_the_pm_turn regression), so the token + # header is omitted entirely when the secret is unset. + monkeypatch.delenv("ROBOCO_AGENT_AUTH_SECRET", raising=False) + be_pm = _foundation.AGENTS["be-pm"] + headers = _agent_api_headers(str(be_pm.uuid), be_pm.role.value) + assert "X-Agent-Token" not in headers diff --git a/tests/unit/services/test_video_engine.py b/tests/unit/services/test_video_engine.py index 3f6d9d25..16875f8c 100644 --- a/tests/unit/services/test_video_engine.py +++ b/tests/unit/services/test_video_engine.py @@ -221,11 +221,10 @@ async def test_open_video_task_no_op_when_project_not_opted_in( ) -> None: await _seed(db_session) # Flip the per-project opt-in back off — the global flag stays on. - await db_session.execute( - ProjectTable.__table__.update() - .where(ProjectTable.__table__.c.slug == SLUG) - .values(video_engine_enabled=False) - ) + project = ( + await db_session.execute(select(ProjectTable).where(ProjectTable.slug == SLUG)) + ).scalar_one() + project.video_engine_enabled = False await db_session.flush() _enable(monkeypatch) engine = video_engine_module.VideoEngine(db_session)