Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion docs/model_reference/model_core.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
7 changes: 6 additions & 1 deletion src/supervaizer/access/api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,19 @@ 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)],
request: Request,
) -> 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")
Expand Down
20 changes: 3 additions & 17 deletions src/supervaizer/access/tailscale.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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"
)
2 changes: 1 addition & 1 deletion src/supervaizer/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/supervaizer/routers/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
10 changes: 10 additions & 0 deletions tests/test_access_api_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import os
from unittest.mock import patch

import pytest
from fastapi import Depends, FastAPI
from fastapi.testclient import TestClient

Expand Down Expand Up @@ -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")
Expand Down
Loading