Files
roboco/roboco/api/routes/secretary_live.py
T
Renn F 27870e7953 [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.
2026-06-28 19:46:13 +02:00

107 lines
4.1 KiB
Python

"""Live Secretary chat — the panel <-> Secretary container bridge.
Mirrors the intake live bridge over the shared ``PrompterLiveRegistry``, scoped
to the Secretary session (no project/product). Auth is intentionally light here
(opaque session id on a trusted network); the Secretary's *authority* is gated
at ``/api/secretary/directives``.
- ``POST /live/start`` — spawn the Secretary container.
- ``GET /live/{session_id}/stream`` — SSE: the agent's live events to the panel.
- ``GET /live/{session_id}/status`` — is the session still alive?
- ``POST /live/{session_id}/messages`` — the CEO's message in (panel -> agent).
- ``POST /live/{session_id}/stop`` — reap the session.
- ``POST /live/{session_id}/events`` — the agent's events in (container -> relay).
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from uuid import uuid4
from fastapi import APIRouter, Depends, HTTPException, Request, status
from sse_starlette import EventSourceResponse
from roboco.api.deps import get_orchestrator, require_panel_token
from roboco.api.schemas.secretary_live import (
AgentEvent,
LiveMessageRequest,
StartSecretaryRequest,
StartSecretaryResponse,
)
from roboco.services.prompter_live import get_live_registry
if TYPE_CHECKING:
from collections.abc import AsyncGenerator
router = APIRouter()
@router.post(
"/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."""
session_id = uuid4().hex
try:
await get_orchestrator().start_secretary_session(
session_id, initial_message=body.initial_message
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail={"error": "spawn_failed", "message": str(exc)},
) from exc
return StartSecretaryResponse(session_id=session_id)
@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()
async def events() -> AsyncGenerator[dict[str, Any]]:
async for event in registry.stream(session_id):
if await request.is_disconnected():
break
yield {"event": event.get("kind", "message"), "data": json.dumps(event)}
return EventSourceResponse(events(), ping=15)
@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", 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)
if not delivered:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail={
"error": "not_found",
"message": f"No live secretary session {session_id} (start it first).",
},
)
return {"delivered": True}
@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)
return {"stopped": True}
@router.post("/live/{session_id}/events")
async def relay_event(session_id: str, event: AgentEvent) -> dict[str, bool]:
"""Relay one agent event from the container onto the session's stream."""
return {"pushed": get_live_registry().push(session_id, event.model_dump())}