mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[F087,F088] enforce panel token on live-chat bridges (Phase 5)
Add a CEO-bound, header-token-only gate (require_panel_token) at the route level of the prompter_live + secretary_live bridges, which were the only panel-facing API surface that ran unauthenticated. It mirrors the WS _require_panel_token and _check_agent_auth_token contracts: in dev (ROBOCO_AGENT_AUTH_REQUIRED unset) a missing token is allowed; a presented-but-forged token is rejected even in dev; in prod nginx already injects the CEO-signed X-Agent-Token on /api/ for GET + POST, so the SSE stream (EventSource can't set headers) and the POSTs are now checked instead of anonymous. Applied to start/stream/status/messages/stop on both routers; preview_live_batch switched from CurrentAgentContext+noqa to the route-level gate (genuinely auth-only). confirm/confirm-batch/re-interview keep CurrentAgentContext (they use agent.identity). The container->relay /events callback is intentionally left ungated (internal Docker network, opaque session id) — gated by a test sentinel so Option B (spawn+SDK token wiring) is a deliberate future decision. No panel/nginx/spawn/SDK changes; master merge invariant untouched. 22 new TDD auth tests, 492 api tests green.
This commit is contained in:
+27
-1
@@ -15,7 +15,7 @@ from fastapi import Depends, Header, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from roboco.agents_config import verify_agent_token
|
||||
from roboco.agents_config import CEO_AGENT_ID, verify_agent_token
|
||||
from roboco.api.schemas.optimal import PaginationParams
|
||||
from roboco.db.base import get_db
|
||||
from roboco.db.tables import AgentTable
|
||||
@@ -232,6 +232,32 @@ def _check_agent_auth_token(
|
||||
)
|
||||
|
||||
|
||||
def require_panel_token(
|
||||
x_agent_token: Annotated[str | None, Header(alias="X-Agent-Token")] = None,
|
||||
) -> None:
|
||||
"""Panel (CEO) HMAC gate for the live-chat bridges.
|
||||
|
||||
The HTTP analog of the WS ``_require_panel_token``: the panel is the only
|
||||
caller of the live intake/secretary chat, nginx injects the CEO-signed
|
||||
``X-Agent-Token`` on ``/api/`` in prod, and browser ``EventSource`` cannot
|
||||
set headers — so the gate is token-only (no ``X-Agent-ID``; the stream is
|
||||
session-keyed and the panel is the sole client). In dev
|
||||
(``ROBOCO_AGENT_AUTH_REQUIRED`` unset) a missing token is allowed; a
|
||||
presented-but-forged token is still rejected, matching
|
||||
``_check_agent_auth_token`` and the WS gate.
|
||||
"""
|
||||
if _auth_required() and not x_agent_token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing X-Agent-Token header (auth required)",
|
||||
)
|
||||
if x_agent_token and not verify_agent_token(x_agent_token, CEO_AGENT_ID, "ceo", ""):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid X-Agent-Token — signature mismatch.",
|
||||
)
|
||||
|
||||
|
||||
async def _resolve_agent_identity(
|
||||
db: DbSession, x_agent_id: str, x_agent_role: str
|
||||
) -> tuple[UUID, str]:
|
||||
|
||||
@@ -20,13 +20,14 @@ import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sse_starlette import EventSourceResponse
|
||||
|
||||
from roboco.api.deps import (
|
||||
CurrentAgentContext,
|
||||
DbSession,
|
||||
get_orchestrator,
|
||||
require_panel_token,
|
||||
require_pm_or_above,
|
||||
)
|
||||
from roboco.api.schemas.prompter_live import (
|
||||
@@ -74,6 +75,7 @@ def _translate_service_error(e: ServiceError) -> HTTPException:
|
||||
"/live/start",
|
||||
response_model=StartLiveResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_panel_token)],
|
||||
)
|
||||
async def start_live(body: StartLiveRequest, db: DbSession) -> StartLiveResponse:
|
||||
"""Spawn the intake agent for a new chat and return its session id.
|
||||
@@ -117,7 +119,7 @@ async def start_live(body: StartLiveRequest, db: DbSession) -> StartLiveResponse
|
||||
return StartLiveResponse(session_id=session_id)
|
||||
|
||||
|
||||
@router.get("/live/{session_id}/stream")
|
||||
@router.get("/live/{session_id}/stream", dependencies=[Depends(require_panel_token)])
|
||||
async def stream(session_id: str, request: Request) -> EventSourceResponse:
|
||||
"""Stream the agent's live events (token deltas, tool calls) to the panel."""
|
||||
registry = get_live_registry()
|
||||
@@ -131,7 +133,7 @@ async def stream(session_id: str, request: Request) -> EventSourceResponse:
|
||||
return EventSourceResponse(events(), ping=15)
|
||||
|
||||
|
||||
@router.get("/live/{session_id}/status")
|
||||
@router.get("/live/{session_id}/status", dependencies=[Depends(require_panel_token)])
|
||||
async def session_status(session_id: str) -> dict[str, bool]:
|
||||
"""Report whether a live intake session is still running.
|
||||
|
||||
@@ -142,7 +144,7 @@ async def session_status(session_id: str) -> dict[str, bool]:
|
||||
return {"alive": get_live_registry().is_alive(session_id)}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/messages")
|
||||
@router.post("/live/{session_id}/messages", dependencies=[Depends(require_panel_token)])
|
||||
async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, bool]:
|
||||
"""Deliver the human's message to the running intake agent."""
|
||||
delivered = await get_live_registry().deliver(session_id, body.text)
|
||||
@@ -157,7 +159,7 @@ async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, b
|
||||
return {"delivered": True}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/stop")
|
||||
@router.post("/live/{session_id}/stop", dependencies=[Depends(require_panel_token)])
|
||||
async def stop_live(session_id: str) -> dict[str, bool]:
|
||||
"""Reap the live intake session (panel close, or draft confirmed)."""
|
||||
await get_orchestrator().reap_intake_session(session_id)
|
||||
@@ -215,12 +217,13 @@ async def confirm_live(
|
||||
return {"task_id": str(task_id)}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/preview-batch")
|
||||
@router.post(
|
||||
"/live/{session_id}/preview-batch", dependencies=[Depends(require_panel_token)]
|
||||
)
|
||||
async def preview_live_batch(
|
||||
session_id: str, # noqa: ARG001 — kept for route symmetry; preview is pure
|
||||
body: BatchPreviewRequest,
|
||||
db: DbSession,
|
||||
agent: CurrentAgentContext, # noqa: ARG001 — auth context only
|
||||
) -> dict[str, Any]:
|
||||
"""Compute a MegaTask's waves from the proposed drafts WITHOUT creating it.
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, status
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||
from sse_starlette import EventSourceResponse
|
||||
|
||||
from roboco.api.deps import get_orchestrator
|
||||
from roboco.api.deps import get_orchestrator, require_panel_token
|
||||
from roboco.api.schemas.secretary_live import (
|
||||
AgentEvent,
|
||||
LiveMessageRequest,
|
||||
@@ -41,6 +41,7 @@ router = APIRouter()
|
||||
"/live/start",
|
||||
response_model=StartSecretaryResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(require_panel_token)],
|
||||
)
|
||||
async def start_live(body: StartSecretaryRequest) -> StartSecretaryResponse:
|
||||
"""Spawn the Secretary agent for a new chat and return its session id."""
|
||||
@@ -57,7 +58,7 @@ async def start_live(body: StartSecretaryRequest) -> StartSecretaryResponse:
|
||||
return StartSecretaryResponse(session_id=session_id)
|
||||
|
||||
|
||||
@router.get("/live/{session_id}/stream")
|
||||
@router.get("/live/{session_id}/stream", dependencies=[Depends(require_panel_token)])
|
||||
async def stream(session_id: str, request: Request) -> EventSourceResponse:
|
||||
"""Stream the Secretary's live events (token deltas, tool calls) to the panel."""
|
||||
registry = get_live_registry()
|
||||
@@ -71,13 +72,13 @@ async def stream(session_id: str, request: Request) -> EventSourceResponse:
|
||||
return EventSourceResponse(events(), ping=15)
|
||||
|
||||
|
||||
@router.get("/live/{session_id}/status")
|
||||
@router.get("/live/{session_id}/status", dependencies=[Depends(require_panel_token)])
|
||||
async def session_status(session_id: str) -> dict[str, bool]:
|
||||
"""Report whether a live Secretary session is still running."""
|
||||
return {"alive": get_live_registry().is_alive(session_id)}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/messages")
|
||||
@router.post("/live/{session_id}/messages", dependencies=[Depends(require_panel_token)])
|
||||
async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, bool]:
|
||||
"""Deliver the CEO's message to the running Secretary agent."""
|
||||
delivered = await get_live_registry().deliver(session_id, body.text)
|
||||
@@ -92,7 +93,7 @@ async def send_message(session_id: str, body: LiveMessageRequest) -> dict[str, b
|
||||
return {"delivered": True}
|
||||
|
||||
|
||||
@router.post("/live/{session_id}/stop")
|
||||
@router.post("/live/{session_id}/stop", dependencies=[Depends(require_panel_token)])
|
||||
async def stop_live(session_id: str) -> dict[str, bool]:
|
||||
"""Reap the live Secretary session."""
|
||||
await get_orchestrator().reap_secretary_session(session_id)
|
||||
|
||||
Reference in New Issue
Block a user