2026-06-29 05:38:21 +02:00
|
|
|
"""Orchestrator control routes (/api/orchestrator/*) are gated to the
|
|
|
|
|
CEO/operator identity: the presented ``X-Agent-ID`` is bound to a verified
|
|
|
|
|
HMAC token (DB-free panel-token guard) and the role asserted as CEO. In dev
|
|
|
|
|
(header-trust) mode a missing token is a no-op; a presented-but-forged token
|
|
|
|
|
is still rejected.
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from typing import TYPE_CHECKING
|
2026-07-07 10:09:23 +02:00
|
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
2026-06-29 05:38:21 +02:00
|
|
|
from uuid import uuid4
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
import pytest_asyncio
|
|
|
|
|
from fastapi import FastAPI
|
|
|
|
|
from httpx import ASGITransport, AsyncClient
|
|
|
|
|
from roboco.agents_config import issue_agent_token
|
2026-07-07 10:09:23 +02:00
|
|
|
from roboco.api import deps as _deps
|
|
|
|
|
from roboco.api.auth.backend import SESSION_COOKIE_NAME
|
2026-06-29 05:38:21 +02:00
|
|
|
from roboco.api.deps import _ServiceHolder, set_orchestrator
|
|
|
|
|
from roboco.api.routes.orchestrator import router as orch_router
|
|
|
|
|
|
|
|
|
|
if TYPE_CHECKING:
|
|
|
|
|
from collections.abc import AsyncIterator
|
|
|
|
|
|
|
|
|
|
_SECRET = "test-secret-for-orch-auth"
|
|
|
|
|
_AGENT_ID = "00000000-0000-0000-0000-000000000001"
|
|
|
|
|
_HTTP_201 = 201
|
|
|
|
|
_HTTP_204 = 204
|
|
|
|
|
_HTTP_401 = 401
|
|
|
|
|
_HTTP_403 = 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _mock_orchestrator() -> MagicMock:
|
|
|
|
|
orch = MagicMock()
|
|
|
|
|
orch.spawn_agent = AsyncMock(
|
|
|
|
|
return_value=MagicMock(
|
|
|
|
|
agent_id=_AGENT_ID,
|
|
|
|
|
state=MagicMock(value="starting"),
|
|
|
|
|
current_task_id=None,
|
|
|
|
|
error_count=0,
|
|
|
|
|
started_at=None,
|
|
|
|
|
waiting_for=None,
|
|
|
|
|
)
|
|
|
|
|
)
|
|
|
|
|
orch.stop_agent = AsyncMock(return_value=None)
|
|
|
|
|
return orch
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest_asyncio.fixture
|
|
|
|
|
async def orch_client() -> AsyncIterator[tuple[AsyncClient, MagicMock]]:
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(orch_router, prefix="/api/orchestrator")
|
|
|
|
|
orch = _mock_orchestrator()
|
|
|
|
|
set_orchestrator(orch)
|
|
|
|
|
async with AsyncClient(
|
|
|
|
|
transport=ASGITransport(app=app), base_url="http://test"
|
|
|
|
|
) as client:
|
|
|
|
|
yield client, orch
|
|
|
|
|
_ServiceHolder.orchestrator = None
|
|
|
|
|
app.dependency_overrides.clear()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Strict mode: token required
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_rejects_missing_token_when_required(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Strict mode + no X-Agent-Token => 401, never reaches the orchestrator."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "ceo"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
orch.spawn_agent.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Dev mode: forged token rejected, missing token is a no-op
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_rejects_forged_token_even_in_dev(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A presented-but-forged token is rejected even in header-trust mode."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
"X-Agent-Token": "forged-not-a-real-hmac",
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
orch.spawn_agent.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_rejects_non_ceo_role(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A developer (even with a validly-issued token) must not spawn/stop agents."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
dev_id = str(uuid4())
|
|
|
|
|
token = issue_agent_token(dev_id, "developer")
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": dev_id,
|
|
|
|
|
"X-Agent-Role": "developer",
|
|
|
|
|
"X-Agent-Token": token,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_403
|
|
|
|
|
orch.spawn_agent.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# Legitimate CEO caller succeeds
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_spawn_accepts_valid_ceo_token(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""A valid CEO token passes the gate and reaches the orchestrator."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
token = issue_agent_token(_AGENT_ID, "ceo")
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
"X-Agent-Token": token,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_201
|
|
|
|
|
orch.spawn_agent.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_stop_accepts_valid_ceo_token(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""The gate is wired into stop_agent too."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_REQUIRED", "true")
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
token = issue_agent_token(_AGENT_ID, "ceo")
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/stop",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
"X-Agent-Token": token,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_204
|
|
|
|
|
orch.stop_agent.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_dev_mode_missing_token_still_succeeds(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Dev mode (no ROBOCO_AGENT_AUTH_REQUIRED) + no token => no-op, route runs.
|
|
|
|
|
Preserves the panel/operator flow in dev exactly as F003/F004 did."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
2026-07-07 10:09:23 +02:00
|
|
|
|
|
|
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", False)
|
2026-06-29 05:38:21 +02:00
|
|
|
client, orch = orch_client
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "ceo"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_201
|
|
|
|
|
orch.spawn_agent.assert_awaited_once()
|
2026-07-07 10:09:23 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
# cloud_auth on: cookie dual-path (panel reaches /api/orchestrator/* via cookie)
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cloud_auth_forged_ceo_header_no_token_no_cookie_rejected(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""cloud_auth on: bare X-Agent-Role: ceo with no token/cookie is a spoof."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={"X-Agent-ID": _AGENT_ID, "X-Agent-Role": "ceo"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
orch.spawn_agent.assert_not_awaited()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cloud_auth_valid_ceo_token_passes(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
token = issue_agent_token(_AGENT_ID, "ceo")
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
"X-Agent-Token": token,
|
|
|
|
|
},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_201
|
|
|
|
|
orch.spawn_agent.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cloud_auth_valid_session_cookie_passes(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
"""Panel path: a valid CEO session cookie reaches the orchestrator."""
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
fake_user = MagicMock()
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.api.routes.orchestrator.resolve_session_user",
|
|
|
|
|
new=AsyncMock(return_value=fake_user),
|
|
|
|
|
):
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
},
|
|
|
|
|
cookies={SESSION_COOKIE_NAME: "valid-session-cookie"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_201
|
|
|
|
|
orch.spawn_agent.assert_awaited_once()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
|
|
|
async def test_cloud_auth_invalid_session_cookie_rejected(
|
|
|
|
|
orch_client: tuple[AsyncClient, MagicMock],
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("ROBOCO_AGENT_AUTH_SECRET", _SECRET)
|
|
|
|
|
monkeypatch.delenv("ROBOCO_AGENT_AUTH_REQUIRED", raising=False)
|
|
|
|
|
|
|
|
|
|
monkeypatch.setattr(_deps.settings, "cloud_auth_enabled", True)
|
|
|
|
|
client, orch = orch_client
|
|
|
|
|
with patch(
|
|
|
|
|
"roboco.api.routes.orchestrator.resolve_session_user",
|
|
|
|
|
new=AsyncMock(return_value=None),
|
|
|
|
|
):
|
|
|
|
|
r = await client.post(
|
|
|
|
|
f"/api/orchestrator/agents/{_AGENT_ID}/spawn",
|
|
|
|
|
headers={
|
|
|
|
|
"X-Agent-ID": _AGENT_ID,
|
|
|
|
|
"X-Agent-Role": "ceo",
|
|
|
|
|
},
|
|
|
|
|
cookies={SESSION_COOKIE_NAME: "bogus"},
|
|
|
|
|
)
|
|
|
|
|
assert r.status_code == _HTTP_401
|
|
|
|
|
orch.spawn_agent.assert_not_awaited()
|