mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
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.
107 lines
4.1 KiB
Python
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())}
|