diff --git a/README.md b/README.md index 87d84ae..c5a9e2f 100644 --- a/README.md +++ b/README.md @@ -156,7 +156,7 @@ For detailed instructions on customizing your controller, see the [Controller Se - **Agent Management**: Register, update, and control agents - **Job Control**: Create, track, and manage jobs - **Event Handling**: Process and respond to system events -- **Custom Routes**: Agents can mount their own FastAPI routers at `/agents/{slug}/api/` for tool endpoints, webhooks, or custom APIs +- **Custom Routes**: Agents can mount their own FastAPI routers under `/api/agents/{slug}/...` for tool endpoints, webhooks, or custom APIs - **Scheduled Steps**: Defer step execution to a future time with automatic background polling and workbench controls (execute now, cancel, reschedule) - **Human-in-the-Loop (HITL)**: Form-based and dialog-based interactive content review with chat interface - **Agent Workbench**: Built-in testing interface with real-time monitoring, job control, HITL forms, and live console diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index ed557a2..b562f6a 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -60,10 +60,10 @@ All notable changes to this project will be documented in this file. | Status | Count | | ---------- | ----- | -| ✅ Passed | 502 | +| ✅ Passed | 505 | | 🤔 Skipped | 0 | | 🔴 Failed | 0 | -| ⏱️ in | ~54s | +| ⏱️ in | 01:07 | ## [0.14.2] - 2026-04-16 @@ -170,7 +170,7 @@ All notable changes to this project will be documented in this file. ### Added -- **Custom routes** — Agents can mount their own FastAPI `APIRouter` via the new `custom_routes` field on `Agent`. Supervaizer mounts them at `/agents/{slug}/api/` without inspecting or managing the routes. Enables agents to expose tool endpoints, webhooks, or any HTTP API alongside the workbench. +- **Custom routes** — Agents can mount their own FastAPI `APIRouter` via the new `custom_routes` field on `Agent`. Supervaizer mounts them under the API router at `/api/agents/{slug}/...` (paths defined on the nested router append after that prefix) without inspecting or managing the routes. Enables agents to expose tool endpoints, webhooks, or any HTTP API alongside the workbench. - **Scheduled steps** — `CaseNodeUpdate` gains `scheduled_at`, `scheduled_method`, `scheduled_params`, `scheduled_status` fields. Steps with `scheduled_at` are deferred until the scheduled time. A background executor polls every 60 seconds and calls the agent method automatically. The workbench shows countdown, "Execute now", and "Cancel" controls on pending scheduled steps. Enables time-based orchestration (call scheduling, retries with backoff, follow-up actions). - Model: `CaseNodeUpdate.scheduled_at / scheduled_method / scheduled_params / scheduled_status` diff --git a/docs/model_reference/model_core.md b/docs/model_reference/model_core.md index 9842d9c..68b82d4 100644 --- a/docs/model_reference/model_core.md +++ b/docs/model_reference/model_core.md @@ -127,7 +127,7 @@ _No additional fields beyond parent class._ | `max_execution_time` | `int` | 3600 | Maximum execution time in seconds, defaults to 1 hour | | `supervaize_instructions_template_path` | `str` | `None` | Optional path to a custom template file for supervaize_instructions.html page | | `instructions_path` | `str` | 'supervaize_instructions.html' | Path where the supervaize instructions page is served (relative to agent path) | -| `custom_routes` | `Any` | `None` | Optional FastAPI APIRouter with custom routes for this agent | +| `custom_routes` | `Any` | `None` | Optional FastAPI APIRouter; mounted at `/api/agents/{slug}/...` on the API surface | | `dynamic_choices_callback` | `Any` | `None` | Callable that returns dynamic choices for method fields. Signature: (method_name: str, context: dict) -> dict[str, list[tuple[str, str]]] | ### `agent.AgentMethod` diff --git a/src/supervaizer/access/api_auth.py b/src/supervaizer/access/api_auth.py index 6d1b77e..af1ad47 100644 --- a/src/supervaizer/access/api_auth.py +++ b/src/supervaizer/access/api_auth.py @@ -72,6 +72,11 @@ def require_scope(required_scope: str) -> Callable[..., dict[str, str]]: # <-- Scope is hierarchical: 'write' satisfies 'read' (but not the reverse). """ + if required_scope not in _SCOPE_RANK: + raise ValueError( + f"Unknown required_scope {required_scope!r}; " + f"must be one of {tuple(_SCOPE_RANK)}" + ) def _check( meta: Annotated[dict[str, str], Depends(require_api_key)], @@ -79,7 +84,7 @@ def _check( ) -> dict[str, str]: key_scope = meta.get("scope", "") key_rank = _SCOPE_RANK.get(key_scope, -1) - req_rank = _SCOPE_RANK.get(required_scope, 0) + req_rank = _SCOPE_RANK[required_scope] if key_rank < req_rank: path = request.scope.get("path", "") log_access_denied_api(None, path, "insufficient scope") diff --git a/src/supervaizer/access/tailscale.py b/src/supervaizer/access/tailscale.py index 8a5f391..48ecd3f 100644 --- a/src/supervaizer/access/tailscale.py +++ b/src/supervaizer/access/tailscale.py @@ -19,7 +19,6 @@ from fastapi import HTTPException from starlette.requests import HTTPConnection -from starlette.websockets import WebSocketState from supervaizer.access.client_ip import _extract_client_ip from supervaizer.common import log_access_denied_tailscale @@ -36,8 +35,9 @@ def require_tailscale(conn: HTTPConnection) -> None: # <-- ADDED In local mode (SUPERVAIZER_LOCAL_MODE=true), loopback addresses are also allowed so the admin UI works without a Tailscale connection. - Raises HTTP 403 for plain HTTP connections and closes WebSocket connections - with code 1008 when the client IP is outside 100.64.0.0/10. + Raises HTTP 403 when the client IP is outside 100.64.0.0/10, including for + WebSocket upgrade requests (the handshake is rejected before the connection + is established). """ path = conn.scope.get("path", "") ip = _extract_client_ip(conn.scope) @@ -53,20 +53,6 @@ def require_tailscale(conn: HTTPConnection) -> None: # <-- ADDED if not allowed: log_access_denied_tailscale(ip, path, "not in tailscale range") - if conn.scope.get("type") == "websocket": - # For WebSocket connections, close with policy violation code - # We need to check if the connection is still in a connectable state - ws = conn # conn IS the WebSocket for ws scope - if ( - hasattr(ws, "client_state") - and ws.client_state == WebSocketState.CONNECTING - ): - raise HTTPException( - status_code=403, detail="Forbidden: Tailscale network required" - ) - raise HTTPException( - status_code=403, detail="Forbidden: Tailscale network required" - ) raise HTTPException( status_code=403, detail="Forbidden: Tailscale network required" ) diff --git a/src/supervaizer/agent.py b/src/supervaizer/agent.py index a9168de..1fd8bc7 100644 --- a/src/supervaizer/agent.py +++ b/src/supervaizer/agent.py @@ -629,7 +629,7 @@ class AgentAbstract(SvBaseModel): ) custom_routes: Any | None = Field( default=None, - description="Optional FastAPI APIRouter with custom routes for this agent", + description="Optional FastAPI APIRouter; mounted on the API app at /api/agents/{slug}/...", exclude=True, ) dynamic_choices_callback: Any | None = Field( diff --git a/src/supervaizer/routers/api.py b/src/supervaizer/routers/api.py index bb0c7ba..38d1de3 100644 --- a/src/supervaizer/routers/api.py +++ b/src/supervaizer/routers/api.py @@ -52,7 +52,7 @@ def create_api_router(server: "Server") -> APIRouter: # <-- ADDED if agent.data_resources: api_router.include_router(create_agent_data_routes(server, agent)) - # Agent custom routes + # Agent custom routes (full path: /api/agents/{slug}/... plus each route on the nested router) for agent in server.agents: if agent.custom_routes: api_router.include_router( diff --git a/tests/test_access_api_auth.py b/tests/test_access_api_auth.py index 353ab1f..f360d6a 100644 --- a/tests/test_access_api_auth.py +++ b/tests/test_access_api_auth.py @@ -15,6 +15,7 @@ import os from unittest.mock import patch +import pytest from fastapi import Depends, FastAPI from fastapi.testclient import TestClient @@ -102,6 +103,15 @@ def guarded() -> dict: class TestRequireScope: """Tests for require_scope — hierarchical scope model.""" + def test_unknown_required_scope_raises_at_creation( + self: "TestRequireScope", + ) -> None: + """Typos or invalid scope names must fail when wiring the dependency, not at runtime as 'read'.""" + from supervaizer.access.api_auth import require_scope + + with pytest.raises(ValueError, match="Unknown required_scope"): + require_scope("admin") + def test_read_key_on_read_scope_passes(self: "TestRequireScope") -> None: with patch.dict("supervaizer.access.api_auth.API_KEYS", _TEST_KEYS): app = _make_app(required_scope="read")