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