Files
turnstone/turnstone/console/server.py
T
Patrick Buckley d675b237a3 feat(mcp): oauth schema + minimum admin form
Adds the data model and admin UI surface required by the OAuth-MCP flow.
Phase 2 of the per-user delegation initiative.

Schema:
- migration 049 creates mcp_user_tokens (PK user_id, server_name) and
  mcp_oauth_pending (PK state, indexed by created_at)
- eight new columns on mcp_servers: auth_type ('none' / 'static' /
  'oauth_user', NOT NULL DEFAULT 'static') plus six oauth_* config
  fields and oauth_as_issuer_cached
- post-upgrade UPDATE normalises auth_type to 'none' for streamable-http
  rows whose headers are NULL/empty/'{}'; stdio rows are left at the
  'static' default (auth_type is HTTP-auth-only)
- _schema.py kept in lockstep with the migration so metadata.create_all
  and alembic upgrade produce identical shapes
- mcp_user_tokens / mcp_oauth_pending TypedDicts in _protocol.py for
  Phase 3/4 use (no CRUD methods yet)

Storage / API:
- create_mcp_server gains the eight kwargs across protocol + sqlite +
  postgresql
- MCP_SERVER_MUTABLE picks up auth_type and the six text oauth_* fields;
  oauth_client_secret_ct is intentionally NOT in the whitelist — Phase 3
  will own ciphertext writes via a dedicated method
- McpServerInfo + Create/Update Pydantic schemas extended; oauth_client_secret
  accepted as plaintext input but discarded (Phase 3 wires encryption)

Admin handlers:
- _parse_auth_type validates against {'none', 'static', 'oauth_user'} and
  rejects empty / unknown values; shared between create and update
- when auth_type changes away from 'oauth_user', the oauth_* config
  columns are explicitly nulled in the same UPDATE so the row stays
  consistent
- _clean_oauth_text caps text fields at 512 chars (URLs at 2048) to bound
  admin write surface
- _mask_mcp_secrets now masks oauth_client_secret_ct to '***' regardless
  of reveal=true (write-only field)
- audit detail dict redacts oauth_client_secret if present

Frontend:
- new "Multitenant Authorization" fieldset on the MCP-server modal with
  three radio buttons (None / Shared / Per-user OAuth 2.1)
- conditional OAuth subform: AS URL, registration mode (preregistered /
  dcr; cimd is future), client ID, client secret, scopes, audience
- secret input is autocomplete=off and never round-trips on edit
- audience auto-populates from the MCP server URL on blur
- headers textarea hidden and submitted as {} when auth_type is 'none' or
  'oauth_user' so flipping the radio cleans up server-side state

Tests: storage round-trip for the new columns, oauth_pending table smoke,
migration 049 upgrade/downgrade with stdio-vs-http normalisation, four
admin-API tests for auth_type validation and oauth_*-clear-on-flip-away.
Suite passes 5284 (matched pre-Phase-2 baseline 5267 + 17 new).

Stacks on Phase 0; no behavioural change for existing rows.
2026-05-04 22:00:23 -07:00

11884 lines
441 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Cluster dashboard HTTP server for turnstone.
Serves the cluster-level dashboard UI and provides REST/SSE APIs
backed by the ClusterCollector. Uses Starlette/ASGI with uvicorn.
Also provides:
- Workstream creation via HTTP dispatch to target server nodes
- Reverse proxy for server UIs so users only need console port access
"""
from __future__ import annotations
import argparse
import asyncio
import contextlib
import functools
import json
import logging
import math
import os
import queue
import re
import secrets
import textwrap
import threading
import time
import urllib.parse
from concurrent.futures import ThreadPoolExecutor
from contextlib import asynccontextmanager
from pathlib import Path
from typing import TYPE_CHECKING, Any
import httpx
from sse_starlette import EventSourceResponse
from starlette.applications import Starlette
from starlette.middleware import Middleware
from starlette.responses import HTMLResponse, JSONResponse, Response, StreamingResponse
from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles
from turnstone.api.console_spec import build_console_spec
from turnstone.api.docs import make_docs_handler, make_openapi_handler
from turnstone.console.collector import ClusterCollector
from turnstone.console.coordinator_client import load_task_envelope
from turnstone.console.metrics import ConsoleMetrics
from turnstone.console.router import ConsoleRouter
from turnstone.core.audit import record_audit
from turnstone.core.auth import (
JWT_AUD_CONSOLE,
JWT_AUD_SERVER,
AuthMiddleware,
create_jwt,
jwt_version_slot,
require_permission,
)
from turnstone.core.rendezvous import NoAvailableNodeError
from turnstone.core.session_replay import session_replay_preamble
from turnstone.core.session_routes import (
AttachmentUploadHelpers,
CoordOnlyVerbHandlers,
SessionEndpointConfig,
SharedSessionVerbHandlers,
make_approve_handler,
make_attachment_handlers,
make_cancel_handler,
make_close_handler,
make_create_handler,
make_dequeue_handler,
make_detail_handler,
make_events_handler,
make_history_handler,
make_list_handler,
make_open_handler,
make_saved_handler,
make_send_handler,
register_coord_verbs,
register_session_routes,
)
from turnstone.core.skill_kind import SkillKind
from turnstone.core.web_helpers import (
read_json_or_400,
require_storage_or_503,
)
from turnstone.core.workstream import Workstream, WorkstreamKind
if TYPE_CHECKING:
from collections.abc import AsyncGenerator, Callable, Iterable
from starlette.requests import Request
from turnstone.core.session import ChatSession
from turnstone.core.session_manager import SessionManager
from turnstone.core.storage._protocol import StorageBackend
log = logging.getLogger("turnstone.console.server")
# ---------------------------------------------------------------------------
# Static assets — loaded once at startup
# ---------------------------------------------------------------------------
_STATIC_DIR = Path(__file__).parent / "static"
_SHARED_DIR = Path(__file__).parent.parent / "shared_static"
_HTML = ""
_HTML_ETAG = ""
def _load_static() -> None:
import hashlib
from turnstone.core.web_helpers import version_html
global _HTML, _HTML_ETAG
_HTML = version_html((_STATIC_DIR / "index.html").read_text(encoding="utf-8"))
_HTML_ETAG = '"' + hashlib.md5(_HTML.encode()).hexdigest()[:16] + '"' # noqa: S324
# ---------------------------------------------------------------------------
# Query parameter helpers
# ---------------------------------------------------------------------------
def _parse_int(
params: dict[str, str],
name: str,
default: int,
minimum: int = 0,
maximum: int = 10000,
) -> int:
try:
val = int(params.get(name, str(default)))
except (ValueError, IndexError):
val = default
return max(minimum, min(val, maximum))
# ---------------------------------------------------------------------------
# Pure ASGI middleware
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Proxy helpers
# ---------------------------------------------------------------------------
# Inline JS injected into proxied server-UI pages. Two responsibilities,
# kept in one IIFE so the original window.fetch closure variable is
# available to the picker (which has to bypass the prefix shim):
#
# 1. Prefix shim \u2014 rewrites root-relative fetch() and EventSource()
# URLs to /node/{id}/... so the proxied page's API calls land
# at the console (which forwards them to the right server node).
#
# 2. Node picker \u2014 on DOMContentLoaded, prepends a node-id pill into
# the server UI's #ui-header (.appbar). Click \u2192 dropdown with
# \u2190 Console + the other healthy nodes. Replaces the earlier
# 32px back-to-console banner that used to live above the appbar.
# Lazy-fetches /api/cluster/nodes the first time the menu opens
# (cheap when the user never clicks; fresh when they do).
_JS_PROXY_SHIM = """\
(function(){
var _pfx = "PREFIX_PLACEHOLDER";
var _nodeId = "NODE_ID_PLACEHOLDER";
var _oF = window.fetch;
window.fetch = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return _oF.call(this, u, o);
};
var _oE = window.EventSource;
window.EventSource = function(u, o){
if (typeof u === "string" && u.startsWith("/")) u = _pfx + u;
return new _oE(u, o);
};
window.EventSource.prototype = _oE.prototype;
window.EventSource.CONNECTING = _oE.CONNECTING;
window.EventSource.OPEN = _oE.OPEN;
window.EventSource.CLOSED = _oE.CLOSED;
function el(tag, cls, text){
var n = document.createElement(tag);
if (cls) n.className = cls;
if (text != null) n.textContent = text;
return n;
}
function buildPicker(){
var header = document.getElementById("ui-header");
if (!header) return;
// Trigger pill \u2014 prepended into #ui-header (the server UI's appbar).
var pill = document.createElement("button");
pill.type = "button";
pill.className = "console-node-pill";
pill.setAttribute("aria-haspopup", "menu");
pill.setAttribute("aria-expanded", "false");
pill.setAttribute("aria-label", "Switch node, currently " + _nodeId);
// title gives sighted users the full id when it ellipsizes
// \u2014 see the max-width + text-overflow rules in _CONSOLE_PROXY_STYLE.
pill.setAttribute("title", _nodeId);
pill.appendChild(el("span", "console-node-pill-dot"));
pill.appendChild(el("span", "console-node-pill-id", _nodeId));
pill.appendChild(el("span", "console-node-pill-caret", "\u25be"));
header.insertBefore(pill, header.firstChild);
// Menu state lives at the picker level, not on the menu DOM, so a
// close-then-reopen reuses the cached node list (no stale spinner).
var menu = null;
var loaded = false;
var loading = false;
var lastNodes = [];
var closeHandler = null;
function closeMenu(){
if (menu){ menu.remove(); menu = null; }
if (closeHandler){
document.removeEventListener("mousedown", closeHandler);
document.removeEventListener("keydown", closeHandler);
closeHandler = null;
}
pill.setAttribute("aria-expanded", "false");
}
function openMenu(){
if (menu) return;
// Reuse the workstream-tab dropdown shell for visual + behavioural
// consistency with the chevron menu next to it in the same toolbar.
menu = document.createElement("div");
menu.className = "ws-tab-dropdown console-node-menu";
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Switch node");
menu.addEventListener("contextmenu", function(e){ e.preventDefault(); });
document.body.appendChild(menu);
pill.setAttribute("aria-expanded", "true");
if (loaded){
renderMenu(lastNodes);
} else if (loading){
menu.appendChild(skeleton());
positionMenu();
} else {
menu.appendChild(skeleton());
positionMenu();
loadNodes();
}
// Keyboard handler kept in lockstep with the workstream-tab dropdown
// in turnstone/ui/static/app.js (search for _tabDropdownCloseHandler).
// If you change the keys here, change them there. The only intentional
// divergence is the :not([aria-disabled='true']) filter — the picker
// skips disabled rows (current + unreachable) during arrow-key cycling.
closeHandler = function(e){
if (e.type === "keydown"){
if (e.key === "Escape"){
e.preventDefault();
closeMenu();
pill.focus();
} else if (e.key === "Tab"){
// Per ARIA APG menu pattern: Tab closes the menu AND moves
// focus to the next focusable element. Don't preventDefault —
// let the browser do its native Tab traversal.
closeMenu();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp"
|| e.key === "Home" || e.key === "End"){
e.preventDefault();
if (!menu) return;
var btns = Array.from(
menu.querySelectorAll(".ws-tab-dropdown-item:not([aria-disabled='true'])")
);
if (!btns.length) return;
var idx = btns.indexOf(document.activeElement);
if (e.key === "ArrowDown") btns[(idx + 1) % btns.length].focus();
// idx <= 0 covers both "first item" (wrap to last) and "no
// current focus" (idx === -1, which would otherwise yield N-2
// via the modulo). Same shape worth backporting to app.js.
else if (e.key === "ArrowUp") btns[idx <= 0 ? btns.length - 1 : idx - 1].focus();
else if (e.key === "Home") btns[0].focus();
else if (e.key === "End") btns[btns.length - 1].focus();
}
} else if (e.type === "mousedown"
&& menu && !menu.contains(e.target)
&& e.target !== pill && !pill.contains(e.target)){
closeMenu();
}
};
// Defer listener wiring + initial focus so the click that opened
// the menu doesn't immediately trigger the mousedown-close path.
var activeMenu = menu;
var activeHandler = closeHandler;
setTimeout(function(){
if (menu !== activeMenu || !activeHandler) return;
document.addEventListener("mousedown", activeHandler);
document.addEventListener("keydown", activeHandler);
var first = activeMenu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}, 0);
}
function positionMenu(){
if (!menu) return;
var pr = pill.getBoundingClientRect();
var mr = menu.getBoundingClientRect();
var mx = pr.left;
var my = pr.bottom + 4;
if (my + mr.height > window.innerHeight) my = pr.top - mr.height - 4;
if (mx + mr.width > window.innerWidth) mx = window.innerWidth - mr.width - 4;
if (mx < 4) mx = 4;
menu.style.left = mx + "px";
menu.style.top = my + "px";
}
function skeleton(){
var box = el("div", "console-node-skeleton");
box.setAttribute("role", "status");
box.setAttribute("aria-label", "Loading nodes");
// Three rows: roughly the typical small-cluster size. CSS fades
// opacity per :nth-child (1.0 / 0.7 / 0.5) — adding a fourth would
// need a fourth opacity stop to avoid visual repetition.
for (var i = 0; i < 3; i++) box.appendChild(el("div", "console-node-skeleton-row"));
return box;
}
function loadNodes(){
loading = true;
// Saved original fetch \u2014 the prefix shim above would otherwise
// rewrite this to /node/{id}/v1/api/cluster/nodes, which the node
// doesn't serve (it's a console-only endpoint mounted at /v1).
// limit=1000 requests the collector's hard maximum in one round-trip;
// beyond 1000 nodes the picker UI is no longer the right shape (it'd
// need a search box) so we don't try to paginate.
_oF.call(window, "/v1/api/cluster/nodes?limit=1000", { credentials: "same-origin" })
.then(function(r){ if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function(data){
loaded = true; loading = false;
lastNodes = Array.isArray(data && data.nodes) ? data.nodes : [];
if (menu) renderMenu(lastNodes);
})
.catch(function(){
loading = false;
if (menu) renderError();
});
}
function renderError(){
var status = el("div", "console-node-menu-status", "Failed to load nodes");
var retry = document.createElement("button");
retry.type = "button";
retry.className = "ws-tab-dropdown-item console-node-menu-item";
retry.setAttribute("role", "menuitem");
retry.setAttribute("tabindex", "-1");
retry.appendChild(el("span", "ws-tab-dropdown-label", "Retry"));
retry.addEventListener("click", function(e){
e.stopPropagation();
loaded = false;
if (menu){ menu.replaceChildren(skeleton()); positionMenu(); }
loadNodes();
});
menu.replaceChildren(status, retry);
positionMenu();
setTimeout(function(){ retry.focus(); }, 0);
}
function buildBackItem(){
var back = document.createElement("a");
back.href = "/";
back.className = "ws-tab-dropdown-item console-node-menu-item console-node-menu-back";
back.setAttribute("role", "menuitem");
back.setAttribute("tabindex", "-1");
back.setAttribute("aria-label", "Back to console");
back.appendChild(el("span", "console-node-menu-arrow", "\u2190"));
back.appendChild(el("span", "ws-tab-dropdown-label", "Console"));
return back;
}
function buildNodeItem(n){
var nid = n.node_id || "";
if (!nid) return null;
var isCurrent = nid === _nodeId;
var reachable = n.reachable !== false;
var hStatus = (n.health && n.health.status) || "";
var status = !reachable ? "unreachable"
: (hStatus && hStatus !== "ok" ? "degraded" : "healthy");
var dotMod = status === "healthy" ? "" : status;
var wsTotal = n.ws_total != null ? n.ws_total : 0;
// Current + unreachable rows are non-interactive: rendered as <div>
// with aria-disabled so the keyboard-nav filter skips them and
// mouse clicks land on dead text. A clickable <a> for an
// unreachable node would route the user to a 502 page.
var nonInteractive = isCurrent || !reachable;
var item;
if (nonInteractive){
item = document.createElement("div");
} else {
item = document.createElement("a");
item.href = "/node/" + encodeURIComponent(nid) + "/";
}
item.className = "ws-tab-dropdown-item console-node-menu-item"
+ (isCurrent ? " is-current" : "")
+ (!reachable && !isCurrent ? " is-unreachable" : "");
item.setAttribute("role", "menuitem");
item.setAttribute("tabindex", "-1");
if (isCurrent) item.setAttribute("aria-current", "true");
if (nonInteractive) item.setAttribute("aria-disabled", "true");
item.setAttribute(
"aria-label",
nid + ", " + wsTotal + " workstream" + (wsTotal === 1 ? "" : "s")
+ ", " + status + (isCurrent ? ", current node" : "")
);
var dot = el("span",
"console-node-menu-item-dot"
+ (dotMod ? " console-node-menu-item-dot--" + dotMod : ""));
dot.setAttribute("aria-hidden", "true");
item.appendChild(dot);
item.appendChild(el("span", "ws-tab-dropdown-label console-node-menu-item-id", nid));
// Meta carries ws-count + status text \u2014 the text suffix doubles as
// a colorblind-safe encoding of the dot color. aria-hidden because
// the menuitem aria-label already says it.
var metaText = wsTotal + " ws" + (status !== "healthy" ? " \u00b7 " + status : "");
var meta = el("span", "ws-tab-dropdown-key", metaText);
meta.setAttribute("aria-hidden", "true");
item.appendChild(meta);
if (isCurrent){
var check = el("span", "console-node-menu-item-check", "\u2713");
check.setAttribute("aria-hidden", "true");
item.appendChild(check);
}
return item;
}
function renderMenu(nodes){
var children = [buildBackItem()];
var nodeItems = [];
nodes.forEach(function(n){
var it = buildNodeItem(n);
if (it) nodeItems.push(it);
});
if (nodeItems.length){
var sep = el("div", "ws-tab-dropdown-sep");
sep.setAttribute("role", "separator");
children.push(sep);
children = children.concat(nodeItems);
}
menu.replaceChildren(...children);
positionMenu();
// First-open path: openMenu()'s deferred focus hook ran before the
// async fetch resolved, so it found only the skeleton and left
// focus on the pill. If focus is still on the pill (i.e. the user
// didn't navigate away while the skeleton was up), grab it now.
if (document.activeElement === pill){
var first = menu.querySelector(
".ws-tab-dropdown-item:not([aria-disabled='true'])"
);
if (first) first.focus();
}
}
pill.addEventListener("click", function(e){
e.stopPropagation();
if (menu) closeMenu(); else openMenu();
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", buildPicker);
} else {
buildPicker();
}
})();
"""
# Inline <style> injected into proxied server-UI pages. The dropdown
# panel itself reuses .ws-tab-dropdown* (defined in ui/static/style.css,
# which the proxied page already loads) for animation, shadow, theme
# override, and item layout. This sheet adds:
# - the trigger pill (no analogue exists in the server UI),
# - the inline health-dot in menu items (mirrors --green / --accent /
# --red from the cluster-overview node table \u2014 see
# console/static/style.css:535-549),
# - the "you are here" tint + cursor:default for the current node row,
# - a 3-row pulse skeleton for the loading state.
_CONSOLE_PROXY_STYLE = (
"<style>"
# --- Trigger pill \u2014 sits at the start of #ui-header (.appbar).
# Height 24px passes WCAG 2.5.8 (24px min target) and harmonises
# with .btn (28px) and .appbar-back (~20px) without looking stunted.
# max-width caps the pill against pathologically long node ids
# (validated up to 256 chars upstream); the id span ellipsizes
# inside. min-width:0 lets it shrink under appbar pressure.
".console-node-pill{display:inline-flex;align-items:center;gap:6px;"
"height:24px;padding:0 10px;max-width:240px;min-width:0;"
"font-family:var(--font-mono);font-size:12px;color:var(--fg-dim);"
"background:transparent;border:1px solid var(--border-strong);"
"border-radius:var(--radius-sm);cursor:pointer;line-height:1;"
"transition:background .12s,color .12s}"
".console-node-pill:hover{background:var(--bg-highlight);color:var(--fg)}"
'.console-node-pill[aria-expanded="true"]{background:var(--bg-highlight);'
"color:var(--fg);border-color:var(--accent-dim)}"
".console-node-pill:focus-visible{outline:2px solid var(--accent);"
"outline-offset:2px}"
".console-node-pill-dot{width:6px;height:6px;border-radius:50%;"
"background:var(--green);box-shadow:0 0 4px var(--green-glow);"
"flex-shrink:0}"
".console-node-pill-id{font-weight:500;overflow:hidden;"
"text-overflow:ellipsis;white-space:nowrap;min-width:0}"
".console-node-pill-caret{font-size:10px;color:var(--fg-dim);opacity:.7;"
"display:inline-block;transition:transform .12s}"
'.console-node-pill[aria-expanded="true"] .console-node-pill-caret'
"{transform:rotate(180deg)}"
# --- Menu shell uses .ws-tab-dropdown directly; no CSS needed here.
# Constrain the picker's width so node ids + meta have room.
".console-node-menu{min-width:240px;max-width:360px}"
# --- Menu items reuse .ws-tab-dropdown-item \u2014 we only override
# font (mono, for hostname-like ids) and add the dot column.
".console-node-menu-item{font-family:var(--font-mono);font-size:12px;"
"padding:6px 12px;gap:8px;color:var(--fg-dim);text-decoration:none}"
# Current row: keep the accent-tint visible. The shared
# .ws-tab-dropdown-item[aria-disabled="true"] rule applies opacity:.55
# which would otherwise wash out the "you are here" tint \u2014 restore
# full opacity here. Same restore for the unreachable row's red dot
# so its color signal stays legible against the dim row background.
".console-node-menu-item.is-current{background:var(--accent-dim);"
"color:var(--fg);cursor:default;opacity:1}"
".console-node-menu-item.is-current:hover{background:var(--accent-dim);"
"color:var(--fg)}"
# Unreachable row: dim the text but leave the dot at full saturation
# so the red signal reads against the dim row. cursor:not-allowed
# comes from the shared aria-disabled rule.
".console-node-menu-item.is-unreachable{color:var(--fg-dim)}"
".console-node-menu-item.is-unreachable .console-node-menu-item-dot{opacity:1}"
".console-node-menu-back{color:var(--accent)}"
".console-node-menu-back:hover{color:var(--accent)}"
".console-node-menu-arrow{font-family:var(--font-mono);font-size:13px}"
# Health dots in menu items \u2014 match cluster-overview canonical colors:
# reachable + ok \u2192 --green (style.css:539)
# reachable + !ok \u2192 --accent (style.css:548 \u2014 was --yellow)
# unreachable \u2192 --red (style.css:544)
".console-node-menu-item-dot{width:6px;height:6px;border-radius:50%;"
"flex-shrink:0;background:var(--green);box-shadow:0 0 4px var(--green-glow)}"
".console-node-menu-item-dot--unreachable{background:var(--red);"
"box-shadow:0 0 4px var(--red-glow)}"
".console-node-menu-item-dot--degraded{background:var(--accent);"
"box-shadow:0 0 4px var(--accent-glow-strong)}"
".console-node-menu-item-id{flex:1}"
".console-node-menu-item-check{color:var(--accent);font-size:11px}"
# --- Loading state: 3-row pulsing skeleton. Reuses --border-strong
# for the row tint and a dedicated keyframe so we can guard it under
# prefers-reduced-motion in step.
".console-node-skeleton{padding:6px 0}"
".console-node-skeleton-row{height:14px;margin:6px 12px;"
"background:var(--border-strong);border-radius:var(--radius-sm);"
"animation:console-node-skel-pulse 1.4s ease-in-out infinite}"
".console-node-skeleton-row:nth-child(2){opacity:.7;animation-delay:.15s}"
".console-node-skeleton-row:nth-child(3){opacity:.5;animation-delay:.3s}"
"@keyframes console-node-skel-pulse{"
"0%,100%{opacity:.4}50%{opacity:.8}}"
"@media (prefers-reduced-motion:reduce){"
".console-node-skeleton-row{animation:none}}"
# --- Status text fallback (only used by the error path now).
".console-node-menu-status{padding:8px 12px;font-family:var(--font-mono);"
"font-size:11px;color:var(--fg-dim);text-align:center}"
"</style>"
)
_VALID_NODE_ID = re.compile(r"^[a-zA-Z0-9._-]+$")
_VALID_WS_ID_RE = re.compile(r"^[a-f0-9]{1,64}$")
_PROXY_JWT_EXPIRY_SECONDS = 300 # 5 min — ample for any request round-trip
_CONTROL_CHAR_RE = re.compile(r"[\x00-\x1f\x7f]")
async def _bounded_stream_preview(response: httpx.Response, cap: int = 200) -> str:
"""Streaming counterpart of :func:`_bounded_body_preview`.
Iterates ``response.aiter_bytes`` up to ``cap * 1.3`` bytes (enough
for ``cap`` chars post-UTF-8 decode) so a compromised / oversized
upstream can't force the proxy to buffer an arbitrary error page
just to populate a preview. Shares the control-char scrub with
the non-streaming helper so the output shape is identical across
sites.
"""
preview_chunks: list[bytes] = []
read = 0
byte_cap = int(cap * 1.3) + 1
try:
async for chunk in response.aiter_bytes():
if not chunk:
continue
remaining = byte_cap - read
if remaining <= 0:
break
preview_chunks.append(chunk[:remaining])
read += len(preview_chunks[-1])
if read >= byte_cap:
break
except Exception:
return "<unreadable>"
decoded = b"".join(preview_chunks).decode("utf-8", "replace")
return _CONTROL_CHAR_RE.sub(" ", decoded)[:cap]
def _bounded_body_preview(text: str | bytes, cap: int = 200) -> str:
"""Return a body preview for 4xx logs capped at ``cap`` chars.
Body is already in memory on a non-streaming httpx response; this
helper exists so every call-site produces the same shape.
Control characters (CR, LF, NUL, ...) are replaced with spaces
before the cap: the preview flows into both ``log.warning`` records
and the operator-facing 503 ``collector_scope_error`` body, and
upstream-controlled newlines in either surface would let a
compromised node forge additional log lines or masquerade as
embedded remediation text.
"""
if not text:
return ""
if isinstance(text, bytes):
text = text.decode("utf-8", "replace")
return _CONTROL_CHAR_RE.sub(" ", text)[:cap]
def _proxy_auth_headers(request: Request) -> dict[str, str]:
"""Build auth headers for proxied requests to upstream servers.
Mints a short-lived JWT carrying the real user's identity and scopes
so the upstream server records correct audit attribution and enforces
scope narrowing. Falls back to the ServiceTokenManager when no user
context is available.
When the inbound request authenticated with a coordinator-minted JWT
(``auth_result.token_source == "coordinator"``), the re-mint
preserves that source AND the ``coord_ws_id`` custom claim so
upstream audit rows retain coordinator-origin visibility. For all
other inbound sources the re-mint uses ``"console-proxy"`` as before.
"""
auth_result = getattr(getattr(request, "state", None), "auth_result", None)
jwt_secret: str = getattr(request.app.state, "jwt_secret", "")
if auth_result is not None and auth_result.user_id and jwt_secret:
# Preserve coordinator-origin source on re-mint — otherwise
# every upstream call from a coordinator session would be
# indistinguishable from a human-originated console proxy call.
is_coord = auth_result.token_source == "coordinator"
source = "coordinator" if is_coord else "console-proxy"
extra: dict[str, Any] = {}
if is_coord:
coord_ws_id = auth_result.extra_claims.get("coord_ws_id")
if coord_ws_id:
extra["coord_ws_id"] = coord_ws_id
token = create_jwt(
user_id=auth_result.user_id,
scopes=auth_result.scopes,
source=source,
secret=jwt_secret,
audience=JWT_AUD_SERVER,
permissions=auth_result.permissions,
expiry_seconds=_PROXY_JWT_EXPIRY_SECONDS,
extra_claims=extra or None,
)
return {"Authorization": f"Bearer {token}"}
# Fallback: service identity via ServiceTokenManager.
mgr = getattr(request.app.state, "proxy_token_mgr", None)
if mgr is not None:
return dict(mgr.bearer_header)
return {}
# Action-name map for the routing proxy. See ``turnstone/core/audit.py``
# module docstring for the canonical action-namespace registry.
_ROUTE_PROXY_AUDIT_ACTIONS: dict[str, str] = {
"send": "route.workstream.send",
"dequeue": "route.workstream.dequeue",
"approve": "route.approve",
"cancel": "route.cancel",
"command": "route.command",
"plan": "route.plan",
"close": "route.workstream.close",
}
def _emit_route_audit(
request: Request,
action: str,
ws_id: str,
node_id: str,
) -> None:
"""Record an audit event for a successful routing-proxy hop.
Caller must ensure the upstream response was 2xx; auditing failures
is deferred (4xx/5xx are observable via ``_record_route``'s metrics
path). Reads the inbound ``auth_result`` directly so that
coordinator-origin attribution lands in ``detail.src`` without
relying on the ``_proxy_auth_headers`` re-mint.
``detail`` carries ``{src, node_id, coord_ws_id?}`` — ``coord_ws_id``
only when the inbound JWT carried it (i.e. the call originated from
a coordinator session). Failures are swallowed; the proxied
response must never break because of an audit-emission bug.
"""
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
auth = getattr(getattr(request, "state", None), "auth_result", None)
user_id: str = (getattr(auth, "user_id", "") or "") if auth is not None else ""
src: str = (getattr(auth, "token_source", "") or "") if auth is not None else ""
coord_ws_id: str = ""
if auth is not None:
coord_ws_id = (getattr(auth, "extra_claims", None) or {}).get("coord_ws_id", "") or ""
detail: dict[str, Any] = {"src": src, "node_id": node_id}
if coord_ws_id:
detail["coord_ws_id"] = coord_ws_id
try:
from turnstone.core.audit import record_audit
record_audit(
storage,
user_id,
action,
"workstream",
ws_id,
detail,
request.client.host if request.client else "",
)
except Exception:
log.debug("route.audit_failed action=%s", action, exc_info=True)
def _get_server_url(request: Request, node_id: str) -> str | None:
"""Resolve node_id to its server_url via the collector."""
if not node_id or not _VALID_NODE_ID.match(node_id) or len(node_id) > 256:
return None
collector: ClusterCollector = request.app.state.collector
detail = collector.get_node_detail(node_id)
if detail and detail.get("server_url"):
url: str = detail["server_url"]
return url.rstrip("/")
return None
def _pick_best_node(collector: ClusterCollector) -> str:
"""Select the reachable node with the most available capacity."""
nodes = collector.get_all_nodes()
best_id = ""
best_headroom = -1
for n in nodes:
if not n.get("reachable", False):
continue
headroom = n.get("max_ws", 10) - n.get("ws_total", 0)
if headroom > best_headroom:
best_headroom = headroom
best_id = n["node_id"]
return best_id
# ---------------------------------------------------------------------------
# Route handlers — dashboard
# ---------------------------------------------------------------------------
async def index(request: Request) -> Response:
if request.headers.get("If-None-Match") == _HTML_ETAG:
return Response(status_code=304, headers={"ETag": _HTML_ETAG, "Cache-Control": "no-cache"})
resp = HTMLResponse(_HTML)
resp.headers["Cache-Control"] = "no-cache"
resp.headers["ETag"] = _HTML_ETAG
return resp
async def cluster_overview(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
return JSONResponse(collector.get_overview())
async def cluster_nodes(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
params = dict(request.query_params)
sort_by = params.get("sort", "activity")
limit = _parse_int(params, "limit", 100, minimum=1, maximum=1000)
offset = _parse_int(params, "offset", 0)
# Extract meta.* filters for node metadata filtering
meta_filters = {k[5:]: v for k, v in params.items() if k.startswith("meta.") and k[5:]}
node_ids: set[str] | None = None
if meta_filters:
import json as _mf_json
storage = getattr(request.app.state, "auth_storage", None)
if storage is not None:
# Values in the DB are JSON-encoded. Try to use raw value if it is
# already valid JSON (e.g. meta.cpu_count=4), otherwise wrap as string.
encoded = {}
for mk, mv in meta_filters.items():
try:
_mf_json.loads(mv)
encoded[mk] = mv
except (ValueError, TypeError):
encoded[mk] = _mf_json.dumps(mv)
try:
node_ids = storage.filter_nodes_by_metadata(encoded)
except Exception:
log.warning("cluster.metadata_filter_failed", exc_info=True)
node_ids = None # fall back to unfiltered
if node_ids is not None and not node_ids:
return JSONResponse({"nodes": [], "total": 0})
nodes, total = collector.get_nodes(
sort_by=sort_by, limit=limit, offset=offset, node_ids=node_ids
)
return JSONResponse({"nodes": nodes, "total": total})
def _coordinator_rows(request: Request) -> list[dict[str, Any]]:
"""Build per-coordinator dashboard rows for cluster_workstreams.
Coordinators live on the console process, not on a cluster node, so
they aren't represented in the collector's node SSE streams. Merge
them into the cluster view so the dashboard tree grouping can nest
spawned children under their coordinator parent.
Sources two lanes and merges by ws_id:
- **In-memory** via :meth:`SessionManager.list_all` — carries live
session state (model / model_alias / current workstream state)
for currently-loaded coordinators.
- **Persisted** via ``storage.list_workstreams(kind=COORDINATOR)``
— includes closed / error / soft-deleted rows the manager has
evicted from memory. Without this, closed coordinators
disappeared from the landing page the moment ``close`` fired.
In-memory wins on ws_id conflict so live state stays authoritative
for active sessions.
Trusted-team visibility (post-#400): the cluster dashboard shows
every coordinator regardless of caller identity; ``user_id`` is
surfaced on each row as display metadata.
"""
coord_mgr = getattr(request.app.state, "coord_mgr", None)
if coord_mgr is None:
return []
try:
wss = coord_mgr.list_all()
except Exception:
log.debug("cluster_workstreams.coord_list_failed", exc_info=True)
return []
def _str_sess_attr(sess: Any, name: str) -> str:
val = getattr(sess, name, "") if sess else ""
return val if isinstance(val, str) else ""
rows: list[dict[str, Any]] = []
seen: set[str] = set()
for ws in wss:
sess = getattr(ws, "session", None)
rows.append(
{
"id": ws.id,
"name": ws.name,
"state": ws.state.value,
"title": "",
"node": "console",
"server_url": "",
"model": _str_sess_attr(sess, "model"),
"model_alias": _str_sess_attr(sess, "model_alias"),
"tokens": 0,
"context_ratio": 0.0,
"activity": "",
"activity_state": "",
"tool_calls": 0,
"kind": WorkstreamKind.COORDINATOR.value,
"parent_ws_id": None,
"user_id": ws.user_id or "",
}
)
seen.add(ws.id)
# Second lane — persisted coordinator rows, used to surface
# closed / error / deleted coordinators the manager has already
# evicted from ``self._workstreams``. Cluster-wide (trusted-team
# visibility).
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return rows
try:
persisted = storage.list_workstreams(
kind=WorkstreamKind.COORDINATOR,
user_id=None,
limit=200,
)
except Exception:
log.debug("cluster_workstreams.coord_persisted_failed", exc_info=True)
return rows
for row in persisted:
# SQLAlchemy Row — access via _mapping so future SELECT reorders
# / new columns don't silently corrupt the projection (per the
# storage-protocol guidance on list_workstreams). Test doubles
# must expose the same ._mapping attribute; positional indexing
# was removed because it hard-coded column offsets that drift
# with migrations.
m = row._mapping
row_id = m.get("ws_id") or ""
if not row_id or row_id in seen:
continue
row_owner = m.get("user_id") or ""
rows.append(
{
"id": row_id,
"name": m.get("name") or f"coord-{row_id[:4]}",
"state": str(m.get("state") or "idle"),
"title": "",
"node": "console",
"server_url": "",
"model": "",
"model_alias": "",
"tokens": 0,
"context_ratio": 0.0,
"activity": "",
"activity_state": "",
"tool_calls": 0,
"kind": WorkstreamKind.COORDINATOR.value,
"parent_ws_id": None,
"user_id": row_owner,
}
)
return rows
async def cluster_workstreams(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
params = dict(request.query_params)
state = params.get("state")
node = params.get("node")
search = params.get("search")
sort_by = params.get("sort", "state")
page = _parse_int(params, "page", 1, minimum=1)
per_page = _parse_int(params, "per_page", 50, minimum=1, maximum=200)
extra_rows = _coordinator_rows(request)
ws_list, total = collector.get_workstreams(
state=state,
node=node,
search=search,
sort_by=sort_by,
page=page,
per_page=per_page,
extra_rows=extra_rows,
)
pages = math.ceil(total / per_page) if per_page > 0 else 0
return JSONResponse(
{
"workstreams": ws_list,
"total": total,
"page": page,
"per_page": per_page,
"pages": pages,
}
)
_CLUSTER_WS_LIVE_KEYS = (
"state",
"tokens",
"context_ratio",
"activity",
"activity_state",
"tool_calls",
"model",
"model_alias",
"title",
"name",
# Carries the inline approve/deny payload (items + judge_verdict)
# so coord live-bulk callers can render row-level UI without a
# per-child round-trip. ``None`` when no approval is pending.
# Cross-tenant exposure follows the trusted-team posture documented
# on ``SessionUIBase.serialize_pending_approval_detail``.
"pending_approval_detail",
# Ring buffer of the child's recent auto-approves (last 10) for
# the coord-tree's "auto-approved by skill X" pill. Without this
# the operator has no surface to see WHICH tool calls bypassed
# the gate or WHY (skill allowlist / blanket / admin policy /
# explicit "Always" click) — the SSE ``tool_info`` event fires
# only on the per-ws stream the coord doesn't subscribe to.
"recent_auto_approvals",
)
class _NodeDashboardCache:
"""Short-TTL per-node ``/v1/api/dashboard`` response cache.
Prevents the O(N·M) fan-in that cluster_ws_detail would otherwise
produce when a coordinator inspects N children hosted on a handful
of nodes each serving M workstreams: one concurrent fetch per node
per TTL window, de-duplicated via a per-node asyncio.Lock so a
burst of concurrent requests collapses into a single upstream call.
TTL is intentionally short (2s) — dashboard data is used for
live-badge rendering where a 12s lag is acceptable. Bounded cache
size is unnecessary in practice: node_id count scales with cluster
size (typically O(10100)), not with request volume.
"""
_TTL_SECONDS = 2.0
def __init__(self) -> None:
self._cache: dict[str, tuple[float, dict[str, Any] | None]] = {}
self._locks: dict[str, asyncio.Lock] = {}
self._locks_lock = asyncio.Lock()
async def get(
self,
node_id: str,
server_url: str,
client: httpx.AsyncClient,
headers: dict[str, str],
) -> dict[str, Any] | None:
now = time.monotonic()
cached = self._cache.get(node_id)
if cached is not None and now - cached[0] < self._TTL_SECONDS:
return cached[1]
async with self._locks_lock:
lock = self._locks.get(node_id)
if lock is None:
lock = asyncio.Lock()
self._locks[node_id] = lock
async with lock:
# Re-check — a coalesced peer may have populated while we
# waited on the per-node lock.
cached = self._cache.get(node_id)
if cached is not None and time.monotonic() - cached[0] < self._TTL_SECONDS:
return cached[1]
payload: dict[str, Any] | None = None
try:
resp = await client.get(
f"{server_url}/v1/api/dashboard",
headers=headers,
timeout=2.0,
)
except (httpx.HTTPError, TimeoutError):
resp = None
if resp is not None:
status = resp.status_code
if 200 <= status < 300:
try:
raw = resp.json()
except (ValueError, json.JSONDecodeError):
raw = None
if isinstance(raw, dict):
payload = raw
elif 400 <= status < 500:
# 4xx on the /dashboard fetch means the caller's
# JWT (user or service-token fallback) lacks the
# required scopes — surface at WARNING so the
# drift doesn't hide behind a silent empty
# dashboard. Return without caching so an
# operator scope fix is visible on the next
# request instead of after the TTL window; the
# per-node lock above is the hot-loop guard.
log.warning(
"proxy.dashboard_cache.4xx node=%s status=%d url=%s body=%s",
node_id,
status,
server_url,
_bounded_body_preview(resp.text),
)
return None
self._cache[node_id] = (time.monotonic(), payload)
return payload
def _coordinator_live_snapshot(ws: Any) -> dict[str, Any]:
"""Build a ``live`` block for an in-process coordinator workstream.
Mirrors the shape a node's ``/v1/api/dashboard`` would produce for a
workstream entry so the cluster-inspect merge is source-independent
(the UI can't tell a coordinator's live block from a node's). The
``pending_approval`` derived field is set to match the node branch
(see :func:`_fetch_live_block`) — both origins must produce the same
keys or the UI can't reliably read the flag.
"""
sess = getattr(ws, "session", None)
ui = getattr(ws, "ui", None)
# `_pending_approval` is set precisely when an approval is actively
# being awaited (populated by approve_tools, cleared by the resolve
# path). A freshly-constructed UI has `_pending_approval=None` and
# `_approval_event` in its default unset state — the earlier
# implementation read `not _approval_event.is_set()` as the signal
# which fired True on every new coordinator, making the flag useless.
pending_approval = ui is not None and getattr(ui, "_pending_approval", None) is not None
def _str_attr(obj: Any, name: str) -> str:
val = getattr(obj, name, "") if obj else ""
return val if isinstance(val, str) else ""
# Coord rows synthesize the same ``pending_approval_detail`` shape
# the node-side dashboard produces — single source of truth via
# ``SessionUIBase.serialize_pending_approval_detail``. The console
# coord LLM judge isn't wired today (``coordinator_ui.py:138``
# hardcodes ``judge_pending=False``), so ``judge_verdict`` will
# always be ``None`` for these rows; the coord-self stretch in
# the plan covers that follow-up. ``ui`` may be ``None`` in
# transient states (newly-created ws before activation); every
# active coord UI is a ``SessionUIBase`` and supports the method.
pending_approval_detail = ui.serialize_pending_approval_detail() if ui is not None else None
recent_auto_approvals = ui.serialize_recent_auto_approvals() if ui is not None else []
return {
"state": ws.state.value if hasattr(ws.state, "value") else str(ws.state),
"tokens": 0,
"context_ratio": 0.0,
"activity": "",
"activity_state": "approval" if pending_approval else "",
"tool_calls": 0,
"model": _str_attr(sess, "model"),
"model_alias": _str_attr(sess, "model_alias"),
"title": "",
"name": getattr(ws, "name", "") or "",
"pending_approval": pending_approval,
"pending_approval_detail": pending_approval_detail,
"recent_auto_approvals": recent_auto_approvals,
}
async def _fetch_live_block(
request: Request, row: dict[str, Any], ws_id: str
) -> dict[str, Any] | None:
"""Fetch the per-workstream ``live`` block for cluster-inspect.
For coordinator-hosted workstreams, read live state from the
in-process :class:`SessionManager` (coordinator kind). For
node-backed workstreams, issue a short-timeout HTTP GET against
the owning node's ``/v1/api/dashboard`` and project the matching
entry.
Always returns ``None`` on coordinator-not-loaded, node unreachable,
wrong status, un-parseable payload, or no matching entry — the
caller surfaces this as ``live=null`` with a 200 response. Any
unexpected exception propagates to the caller's correlation-id
handler; internal degradations stay silent.
"""
row_node_id = row.get("node_id") or ""
row_kind = WorkstreamKind.from_raw(row.get("kind"))
# Kind is the authoritative discriminator; the `"console"` node_id
# sentinel is paired with coordinator rows only (see
# ``ClusterCollector.CONSOLE_PSEUDO_NODE_ID``). Branching purely
# on kind avoids a subtle collision if a real node ever registers
# with ``node_id="console"``.
if row_kind == WorkstreamKind.COORDINATOR:
coord_mgr = getattr(request.app.state, "coord_mgr", None)
if coord_mgr is None:
return None
ws = coord_mgr.get(ws_id)
if ws is None:
return None
return _coordinator_live_snapshot(ws)
server_url = _get_server_url(request, row_node_id)
if not server_url:
return None
client: httpx.AsyncClient = request.app.state.proxy_client
# Route through the per-node dashboard cache — N concurrent
# cluster_ws_detail calls to children on the same node collapse
# to one upstream GET per 2s TTL window instead of N full
# /dashboard fetches per call.
cache = getattr(request.app.state, "dashboard_cache", None)
if cache is not None:
payload = await cache.get(row_node_id, server_url, client, _proxy_auth_headers(request))
else:
# Test harnesses / legacy embeddings may skip the cache; fall
# back to the direct fetch so this function still works.
try:
resp = await client.get(
f"{server_url}/v1/api/dashboard",
headers=_proxy_auth_headers(request),
timeout=2.0,
)
except (httpx.HTTPError, TimeoutError):
return None
status = resp.status_code
if 400 <= status < 500:
# 4xx on the direct /dashboard fetch is the same class
# of auth/scope drift as the cached path above — surface
# at WARNING so operators see it in ops logs.
log.warning(
"proxy.live_block.4xx node=%s status=%d url=%s body=%s",
row_node_id,
status,
server_url,
_bounded_body_preview(resp.text),
)
return None
if not (200 <= status < 300):
return None
try:
raw = resp.json()
except (ValueError, httpx.HTTPError):
return None
payload = raw if isinstance(raw, dict) else None
if payload is None:
return None
for entry in payload.get("workstreams", []) or []:
if isinstance(entry, dict) and entry.get("ws_id") == ws_id:
live = {k: entry.get(k) for k in _CLUSTER_WS_LIVE_KEYS if k in entry}
# Derived field — kept in lockstep with
# _coordinator_live_snapshot so both origins produce the
# same keys. ``state="attention"`` is the canonical signal;
# ``activity_state="approval"`` is set inside approve_tools
# AFTER the state transition fires, so a bulk fetch that
# races with that window can see state=attention and
# activity_state="" simultaneously. A non-null
# ``pending_approval_detail`` is also a definitive signal
# (the serializer only emits non-None when ``_pending_approval``
# is set on the UI). Any of the three flips this true; the
# frontend reducer mirrors the same disjunction.
live["pending_approval"] = (
live.get("activity_state") == "approval"
or entry.get("state") == "attention"
or live.get("pending_approval_detail") is not None
)
return live
return None
async def cluster_ws_detail(request: Request) -> JSONResponse:
"""GET /v1/api/cluster/ws/{ws_id}/detail — persisted row + live merge.
Gated on ``admin.cluster.inspect``. Aggregates the workstream's stored
row with its live state from the owning node's ``/v1/api/dashboard``
(for node-backed workstreams) or the in-process :class:`SessionManager`
(for ``kind="coordinator"`` rows).
Behavior:
- ``persisted`` — the full ``get_workstream`` row (never ``None``).
- ``live`` — live fields merged from the owning node, or ``null`` when
the node is unreachable / the workstream isn't on its roster / the
coordinator isn't in memory. The caller always sees ``persisted``
populated and a 200 response; node unreachability is signalled by
``live=null`` rather than an error status.
- ``messages`` — tail-N conversation messages, default 20, capped at 200.
404 masks ownership failures (match :func:`make_detail_handler`).
Correlation-id masks unexpected exceptions in the merge path.
"""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.cluster.inspect")
if err is not None:
return err
storage, err503 = require_storage_or_503(request)
if err503 is not None:
return err503
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
# Accept either ``?limit=`` (the canonical name used by
# the lifted history factory and the list_workstreams tool) or the
# transitional ``?message_limit=`` from earlier phase-3 drafts.
# ``?limit`` wins when both are set so callers migrating from the
# older name can overlap without surprise.
try:
raw_limit = request.query_params.get("limit")
if raw_limit is None:
raw_limit = request.query_params.get("message_limit", "20")
limit = int(raw_limit)
except (TypeError, ValueError):
limit = 20
limit = max(0, min(limit, 200))
# Offload the sync DB fetch to the default executor so the SSE event
# loop doesn't stall on a slow query. This handler is hit once per
# child-row state tick in the tree UI (debounced 250ms, cached 5s
# client-side) and cluster_ws_detail shares the event loop with
# every coordinator's SSE stream.
try:
row = await asyncio.to_thread(storage.get_workstream, ws_id)
except Exception:
correlation_id = secrets.token_hex(4)
log.warning(
"cluster_ws_detail.storage_failed correlation_id=%s ws_id=%s",
correlation_id,
ws_id[:8],
exc_info=True,
)
return JSONResponse(
{
"error": (
f"failed to read workstream (internal error). correlation_id={correlation_id}"
)
},
status_code=500,
)
if row is None:
return JSONResponse({"error": "workstream not found"}, status_code=404)
try:
live = await _fetch_live_block(request, row, ws_id)
except Exception:
correlation_id = secrets.token_hex(4)
log.warning(
"cluster_ws_detail.live_merge_failed correlation_id=%s ws_id=%s",
correlation_id,
ws_id[:8],
exc_info=True,
)
live = None
messages: list[dict[str, Any]] = []
if limit > 0:
try:
# Tail-N bound pushed into SQL (load_messages supports limit
# on both backends). Offloaded to the default executor so
# the async SSE loop stays unblocked under rapid fan-out.
messages = await asyncio.to_thread(storage.load_messages, ws_id, limit=limit)
except Exception:
log.debug("cluster_ws_detail.load_messages_failed", exc_info=True)
return JSONResponse(
{
"persisted": row,
"live": live,
"messages": messages,
}
)
# Upper bound on ids per bulk request. Matches the per-coordinator
# fanout cap + leaves headroom; larger batches would defeat the per-node
# /v1/api/dashboard cache's batching benefit once the id set spans many
# nodes, at which point the caller should paginate client-side.
_CLUSTER_WS_LIVE_BULK_CAP = 50
async def cluster_ws_live_bulk(request: Request) -> JSONResponse:
"""GET /v1/api/cluster/ws/live?ids=a,b,c — bulk live-block fetch.
Returns ``{results: {ws_id: live | null}, denied: [ws_id, ...],
truncated: bool}``.
Collapses the per-row fan-out that tree UIs with 30+ visible
children produce — one HTTP round-trip per TTL window instead of
one-per-row. Reuses the same ``_fetch_live_block`` path as
``cluster_ws_detail`` so node-dashboard cache behaviour, coordinator
in-process snapshots, and ownership masking stay consistent.
Permission + ownership semantics match ``cluster_ws_detail``:
gated on ``admin.cluster.inspect`` and rows the caller doesn't
own surface in ``denied`` rather than ``results`` (so the endpoint
can't be used as an existence oracle). Missing ids also route to
``denied`` for the same reason. ``ids`` over the cap is truncated
with ``truncated=true`` so the model / frontend knows to paginate.
"""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.cluster.inspect")
if err is not None:
return err
storage, err503 = require_storage_or_503(request)
if err503 is not None:
return err503
raw_ids = request.query_params.get("ids", "") or ""
# Split on comma; strip whitespace; drop empty / invalid entries.
# Dedupe while preserving order so a caller passing the same id
# twice doesn't double-bill the round-trip budget.
seen: set[str] = set()
cleaned: list[str] = []
for chunk in raw_ids.split(","):
wid = chunk.strip()
if not wid or not _VALID_WS_ID_RE.match(wid):
continue
if wid in seen:
continue
seen.add(wid)
cleaned.append(wid)
truncated = False
if len(cleaned) > _CLUSTER_WS_LIVE_BULK_CAP:
truncated = True
cleaned = cleaned[:_CLUSTER_WS_LIVE_BULK_CAP]
if not cleaned:
return JSONResponse({"results": {}, "denied": [], "truncated": False})
try:
rows = await asyncio.to_thread(storage.get_workstreams_batch, cleaned)
except Exception:
correlation_id = secrets.token_hex(4)
log.warning(
"cluster_ws_live_bulk.storage_failed correlation_id=%s count=%d",
correlation_id,
len(cleaned),
exc_info=True,
)
return JSONResponse(
{"error": f"storage error (internal). correlation_id={correlation_id}"},
status_code=500,
)
results: dict[str, dict[str, Any] | None] = {}
denied: list[str] = []
owned_rows: list[tuple[str, dict[str, Any]]] = []
for wid in cleaned:
row = rows.get(wid)
if row is None:
# Missing rows route to ``denied`` rather than ``results``
# so the endpoint can't be used as an existence oracle for
# ids outside the caller's knowledge.
denied.append(wid)
continue
owned_rows.append((wid, row))
# Fetch live blocks concurrently — ``_fetch_live_block`` already
# routes node-backed reads through the per-node dashboard cache,
# so N concurrent fetches against the same node collapse to a
# single upstream call per TTL window.
async def _one(wid: str, row: dict[str, Any]) -> tuple[str, dict[str, Any] | None]:
try:
live = await _fetch_live_block(request, row, wid)
except Exception:
log.debug(
"cluster_ws_live_bulk.one_failed ws=%s",
wid[:8],
exc_info=True,
)
live = None
return wid, live
gathered = await asyncio.gather(*(_one(wid, row) for wid, row in owned_rows))
for wid, live in gathered:
results[wid] = live
return JSONResponse({"results": results, "denied": denied, "truncated": truncated})
async def cluster_node_detail(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
detail = collector.get_node_detail(node_id)
if not detail:
return JSONResponse({"error": "Node not found"}, status_code=404)
# Attach metadata if available
import json as _nd_json
storage = getattr(request.app.state, "auth_storage", None)
if storage is not None:
try:
raw = storage.get_node_metadata(node_id)
entries = []
for r in raw:
try:
val = _nd_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
entries.append({"key": r["key"], "value": val, "source": r["source"]})
detail["metadata"] = entries
except Exception:
log.warning("cluster.node_metadata_load_failed node_id=%s", node_id, exc_info=True)
detail["metadata"] = []
else:
detail["metadata"] = []
return JSONResponse(detail)
def _collector_scope_error(request: Request) -> JSONResponse | None:
"""Return a 503 if the boot self-check detected collector scope drift.
Used by cluster-wide data endpoints so they refuse to serve an
empty dashboard when the operator's configuration is broken —
a clear 503 with remediation text is better than rendering a
blank table full of "missing data" bugs.
"""
err = getattr(request.app.state, "collector_scope_error", "") or ""
if err:
return JSONResponse(
{"error": err, "reason": "collector_scope_drift"},
status_code=503,
)
return None
async def cluster_snapshot(request: Request) -> JSONResponse:
err = _collector_scope_error(request)
if err is not None:
return err
collector: ClusterCollector = request.app.state.collector
return JSONResponse(collector.get_snapshot())
async def cluster_events_sse(request: Request) -> Response:
err = _collector_scope_error(request)
if err is not None:
return err
collector: ClusterCollector = request.app.state.collector
client_queue: queue.Queue[dict[str, Any]] = queue.Queue(maxsize=2000)
async def event_generator() -> AsyncGenerator[dict[str, str], None]:
loop = asyncio.get_running_loop()
try:
# Atomic snapshot+register — no event gap possible.
snap = await loop.run_in_executor(
None, collector.get_snapshot_and_register, client_queue
)
snap["type"] = "snapshot"
yield {"data": json.dumps(snap)}
while True:
try:
event = await loop.run_in_executor(
None, functools.partial(client_queue.get, timeout=5)
)
yield {"data": json.dumps(event)}
except queue.Empty:
pass # poll timeout, retry
if await request.is_disconnected():
break
finally:
collector.unregister_listener(client_queue)
return EventSourceResponse(event_generator(), ping=5)
async def health(request: Request) -> JSONResponse:
collector: ClusterCollector = request.app.state.collector
overview = collector.get_overview()
return JSONResponse(
{
"status": "ok",
"service": "turnstone-console",
"nodes": overview["nodes"],
"workstreams": overview["workstreams"],
"version_drift": overview.get("version_drift", False),
"versions": overview.get("versions", []),
}
)
async def console_metrics_endpoint(request: Request) -> Response:
"""GET /metrics — Prometheus text exposition format for console metrics."""
cm: ConsoleMetrics = request.app.state.console_metrics
text = cm.generate_text()
return Response(text, media_type="text/plain; version=0.0.4; charset=utf-8")
async def auth_login(request: Request) -> Response:
"""Authenticate via username:password or legacy token, return JWT."""
from turnstone.core.auth import handle_auth_login
return await handle_auth_login(request, JWT_AUD_CONSOLE)
async def auth_logout(request: Request) -> Response:
"""POST /v1/api/auth/logout — clear auth cookie."""
from turnstone.core.auth import handle_auth_logout
return await handle_auth_logout(request)
async def auth_status(request: Request) -> Response:
"""GET /v1/api/auth/status — public endpoint for login UI state detection."""
from turnstone.core.auth import handle_auth_status
return await handle_auth_status(request)
async def auth_setup(request: Request) -> Response:
"""POST /v1/api/auth/setup — create first admin user (public, one-time only)."""
from turnstone.core.auth import handle_auth_setup
return await handle_auth_setup(request, JWT_AUD_CONSOLE)
async def auth_whoami(request: Request) -> Response:
"""GET /v1/api/auth/whoami — return authenticated user info."""
from turnstone.core.auth import handle_auth_whoami
return await handle_auth_whoami(request)
async def auth_refresh(request: Request) -> Response:
"""POST /v1/api/auth/refresh — extend the auth cookie's expiry.
Requires a currently-valid cookie (auth middleware enforces). Re-
resolves user permissions from storage so role changes propagate.
"""
from turnstone.core.auth import handle_auth_refresh
return await handle_auth_refresh(request, JWT_AUD_CONSOLE)
async def oidc_authorize(request: Request) -> Response:
"""GET /v1/api/auth/oidc/authorize — redirect to OIDC provider."""
from turnstone.core.auth import handle_oidc_authorize
return await handle_oidc_authorize(request, JWT_AUD_CONSOLE)
async def oidc_callback(request: Request) -> Response:
"""GET /v1/api/auth/oidc/callback — OIDC callback, exchange code for JWT."""
from turnstone.core.auth import handle_oidc_callback
return await handle_oidc_callback(request, JWT_AUD_CONSOLE)
# ---------------------------------------------------------------------------
# Route handlers — available models (lightweight, no admin permission)
# ---------------------------------------------------------------------------
async def list_available_models(request: Request) -> JSONResponse:
"""GET /v1/api/models — enabled model aliases for workstream creation."""
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
rows = storage.list_model_definitions(enabled_only=True)
# Only expose alias/model/provider — rows also contain api_key, base_url, etc.
models = [{"alias": r["alias"], "model": r["model"], "provider": r["provider"]} for r in rows]
# Include effective defaults for clients (web UI, channel gateway).
default_alias = ""
channel_default_alias = ""
cs = getattr(request.app.state, "config_store", None)
if cs is not None:
default_alias = cs.get("model.default_alias") or ""
channel_default_alias = cs.get("channels.default_model_alias") or ""
enabled_aliases = {r["alias"] for r in rows}
if default_alias and default_alias not in enabled_aliases:
default_alias = ""
if channel_default_alias and channel_default_alias not in enabled_aliases:
channel_default_alias = ""
return JSONResponse(
{
"models": models,
"default_alias": default_alias,
"channel_default_alias": channel_default_alias,
}
)
# ---------------------------------------------------------------------------
# Route handlers — workstream creation
# ---------------------------------------------------------------------------
async def create_workstream(request: Request) -> JSONResponse:
"""POST /v1/api/cluster/workstreams/new — create a workstream via HTTP.
Three targeting modes:
- ``node_id`` set to a specific node ID → POST to that node
- ``node_id`` omitted or ``"auto"`` → console picks the node with most headroom
- ``node_id`` set to ``"pool"`` → console picks any available node
"""
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
collector: ClusterCollector = request.app.state.collector
raw_node_id = body.get("node_id", "")
raw_name = body.get("name", "")
raw_model = body.get("model", "")
raw_judge_model = body.get("judge_model", "")
raw_initial_message = body.get("initial_message", "")
raw_skill = body.get("skill", "")
raw_resume_ws = body.get("resume_ws", "")
if not isinstance(raw_node_id, str):
raw_node_id = "" if raw_node_id is None else None
if not isinstance(raw_name, str):
raw_name = "" if raw_name is None else None
if not isinstance(raw_model, str):
raw_model = "" if raw_model is None else None
if not isinstance(raw_judge_model, str):
raw_judge_model = "" if raw_judge_model is None else None
if not isinstance(raw_initial_message, str):
raw_initial_message = "" if raw_initial_message is None else None
if not isinstance(raw_skill, str):
raw_skill = "" if raw_skill is None else None
if not isinstance(raw_resume_ws, str):
raw_resume_ws = "" if raw_resume_ws is None else None
if (
raw_node_id is None
or raw_name is None
or raw_model is None
or raw_judge_model is None
or raw_initial_message is None
or raw_skill is None
or raw_resume_ws is None
):
return JSONResponse(
{
"error": "node_id, name, model, judge_model, initial_message, skill, and resume_ws must be strings"
},
status_code=400,
)
node_id = raw_node_id
name = raw_name[:256]
model = raw_model[:128]
judge_model = raw_judge_model[:128]
initial_message = raw_initial_message[:4096]
skill = raw_skill[:256]
resume_ws = raw_resume_ws[:64]
auth = getattr(getattr(request, "state", None), "auth_result", None)
uid: str = getattr(auth, "user_id", "") or ""
# Pool — pick any available node
if node_id == "pool":
node_id = _pick_best_node(collector)
if not node_id:
return JSONResponse({"error": "No reachable nodes available"}, status_code=503)
# Auto-select node by most available capacity
if not node_id or node_id == "auto":
node_id = _pick_best_node(collector)
if not node_id:
return JSONResponse({"error": "No reachable nodes available"}, status_code=503)
# Validate node exists and get its URL
detail = collector.get_node_detail(node_id)
if not detail:
return JSONResponse({"error": "Node not found"}, status_code=404)
server_url = detail.get("server_url", "")
if not server_url:
return JSONResponse({"error": "Node has no URL"}, status_code=502)
ws_body = {
"name": name,
"model": model,
"judge_model": judge_model,
"initial_message": initial_message,
"skill": skill,
"resume_ws": resume_ws,
"user_id": uid,
}
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
try:
resp = await client.post(
f"{server_url.rstrip('/')}/v1/api/workstreams/new",
json=ws_body,
headers=headers,
)
resp.raise_for_status()
except httpx.HTTPError as exc:
log.warning("Workstream dispatch to %s failed: %s", node_id, exc)
return JSONResponse({"error": f"Dispatch to node {node_id} failed"}, status_code=502)
return JSONResponse(
{
"status": "ok",
"correlation_id": resp.json().get("ws_id", ""),
"target_node": node_id,
}
)
# ---------------------------------------------------------------------------
# Route handlers — workstream routing proxy (rendezvous)
# ---------------------------------------------------------------------------
def _record_route(
request: Request, method: str, status: int, t0: float, resp: Response
) -> Response:
"""Record routing metrics and return the response unchanged."""
cm: ConsoleMetrics | None = getattr(request.app.state, "console_metrics", None)
if cm is not None:
cm.record_route(method, status, time.monotonic() - t0)
return resp
async def route_create(request: Request) -> Response:
"""POST /v1/api/route/workstreams/new — create via rendezvous routing.
Accepts both `application/json` and `multipart/form-data`. Multipart
callers must include ``?ws_id=<hex>`` in the URL query string so the
console can hash to the owning node before the multipart body lands —
we do not parse the body just to peek at the metadata.
"""
t0 = time.monotonic()
router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready()
if not ring_ready:
# Router cache empty — the collector hasn't published a
# services list yet. One-shot refresh off the event loop
# before giving up.
if router is not None:
await asyncio.to_thread(router.refresh_cache)
ring_ready = router.is_ready()
if not ring_ready:
return _record_route(
request,
"create",
503,
t0,
JSONResponse(
{"error": "Cluster routing not initialized"},
status_code=503,
),
)
assert router is not None
raw_content_type = request.headers.get("content-type") or ""
is_multipart = raw_content_type.lower().startswith("multipart/form-data")
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
pin = False
body: dict[str, Any] = {}
raw_body: bytes = b""
# Routing strategy is surfaced on the response so callers (the
# coordinator's spawn_workstream tool especially) can explain why a
# given node was chosen. Set on every branch below.
routing_strategy = "rendezvous"
if is_multipart:
# Multipart: caller must pass ws_id as a query param so we can
# route without parsing the body. Stream the raw bytes through
# to the upstream so we don't lose the multipart framing.
ws_id = request.query_params.get("ws_id", "").strip()
if not ws_id:
return _record_route(
request,
"create",
400,
t0,
JSONResponse(
{"error": "ws_id query parameter required for multipart create"},
status_code=400,
),
)
try:
ref = router.route(ws_id)
except NoAvailableNodeError:
return _record_route(
request,
"create",
503,
t0,
JSONResponse(
{"error": "No available node for routing"},
status_code=503,
),
)
# Multipart callers pre-allocate ws_id (typically an attachment
# follow-up against an existing workstream) — same hash-of-known-id
# path resume_ws takes on the JSON branch.
routing_strategy = "resume"
raw_body = await request.body()
# Forward the raw header verbatim — the multipart `boundary=` parameter
# is case-sensitive and must match the bytes in the body exactly.
upstream_headers = {**headers, "Content-Type": raw_content_type}
try:
resp = await client.post(
f"{ref.url}/v1/api/workstreams/new",
content=raw_body,
headers=upstream_headers,
)
except httpx.HTTPError:
return _record_route(
request,
"create",
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
else:
try:
body = await request.json()
except Exception:
return _record_route(
request,
"create",
400,
t0,
JSONResponse(
{"error": "Invalid JSON body"},
status_code=400,
),
)
try:
if body.get("resume_ws"):
ref = router.route(body["resume_ws"])
routing_strategy = "resume"
elif body.get("target_node"):
# Brute-force HRW search can take up to _GENERATE_ATTEMPT_CAP
# iterations for skewed weights; off the event loop.
ws_id = await asyncio.to_thread(router.generate_ws_id_for_node, body["target_node"])
body["ws_id"] = ws_id
ref = router.route(ws_id)
pin = True
routing_strategy = "target_node"
else:
ws_id = secrets.token_hex(16)
body["ws_id"] = ws_id
ref = router.route(ws_id)
except NoAvailableNodeError:
return _record_route(
request,
"create",
503,
t0,
JSONResponse(
{"error": "No available node for routing"},
status_code=503,
),
)
try:
resp = await client.post(
f"{ref.url}/v1/api/workstreams/new", json=body, headers=headers
)
except httpx.HTTPError:
return _record_route(
request,
"create",
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
# 503 retry with a new ws_id that hashes to a different node.
# Multipart variant skips this branch — the body is bound to the
# ws_id the caller chose, so re-routing would mean re-uploading.
if resp.status_code == 503 and not pin and not body.get("resume_ws"):
failed_node = ref.node_id
found_alt = False
for _ in range(10):
ws_id = secrets.token_hex(16)
try:
ref = router.route(ws_id)
except NoAvailableNodeError:
break
if ref.node_id != failed_node:
found_alt = True
break
if not found_alt:
return _record_route(
request,
"create",
resp.status_code,
t0,
Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
),
)
body["ws_id"] = ws_id
try:
resp = await client.post(
f"{ref.url}/v1/api/workstreams/new", json=body, headers=headers
)
except httpx.HTTPError:
return _record_route(
request,
"create",
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
if resp.status_code == 200:
data = resp.json()
data["node_url"] = ref.url
# Audit attribution — multipart sets ``ws_id`` from the query
# string; JSON sets it on the body (or carries ``resume_ws``
# for a rehydrate). Either way, this is the workstream the
# caller actually landed on.
if is_multipart:
audit_ws_id = ws_id
else:
audit_ws_id = body.get("ws_id") or body.get("resume_ws", "") or ""
# Return the storage-authoritative node_id so subsequent
# inspect / list calls agree on the binding. ``ref.node_id`` is
# the rendezvous target AT SPAWN TIME — stale once membership
# changes — and the node's own create handler is the source of
# truth for what node_id got persisted on the workstream row.
# Fall back to ref.node_id only when the storage lookup fails,
# matching the previous behaviour so this change is strictly
# additive.
bound_node_id = ref.node_id
storage = getattr(request.app.state, "auth_storage", None)
if storage is not None and audit_ws_id:
try:
row = storage.get_workstream(audit_ws_id)
stored_node = row.get("node_id") if isinstance(row, dict) else None
if isinstance(stored_node, str) and stored_node:
bound_node_id = stored_node
except Exception:
log.debug(
"route_create.node_id_lookup_failed ws=%s",
audit_ws_id[:8] if audit_ws_id else "",
exc_info=True,
)
data["node_id"] = bound_node_id
data["routing_strategy"] = routing_strategy
_emit_route_audit(request, "route.workstream.create", audit_ws_id, bound_node_id)
return _record_route(request, "create", 200, t0, JSONResponse(data))
return _record_route(
request,
"create",
resp.status_code,
t0,
Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
),
)
async def route_attachment_proxy(request: Request) -> Response:
"""Proxy ws-id-keyed attachment endpoints through the router.
Handles all four shapes mounted under
``/v1/api/route/workstreams/{ws_id}/attachments[/...]``:
- ``POST .../attachments`` — multipart upload (raw-body forward)
- ``GET .../attachments`` — list pending (JSON pass-through)
- ``GET .../attachments/{attachment_id}/content`` — raw bytes
- ``DELETE .../attachments/{attachment_id}`` — JSON pass-through
All variants forward ``Content-Type`` + auth headers so multipart
framing survives, and propagate upstream response headers so the
``Content-Disposition`` / ``X-Content-Type-Options`` set by
``get_attachment_content`` reach the original caller intact.
"""
method = "attach"
t0 = time.monotonic()
router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready()
if not ring_ready:
if router is not None:
await asyncio.to_thread(router.refresh_cache)
ring_ready = router.is_ready()
if not ring_ready:
return _record_route(
request,
method,
503,
t0,
JSONResponse(
{"error": "Cluster routing not initialized"},
status_code=503,
),
)
assert router is not None
ws_id = request.path_params.get("ws_id", "").strip()
if not ws_id:
return _record_route(
request,
method,
400,
t0,
JSONResponse({"error": "ws_id required"}, status_code=400),
)
try:
ref = router.route(ws_id)
except (NoAvailableNodeError, ValueError):
return _record_route(
request,
method,
503,
t0,
JSONResponse({"error": "routing failed"}, status_code=503),
)
upstream_path = request.url.path.replace("/api/route/", "/api/", 1)
if request.url.query:
upstream_path += f"?{request.url.query}"
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
upstream_headers: dict[str, str] = dict(headers)
if request.method in ("POST", "PUT", "DELETE"):
upstream_headers["Content-Type"] = request.headers.get(
"content-type", "application/octet-stream"
)
body = await request.body()
try:
resp = await client.request(
request.method,
f"{ref.url}{upstream_path}",
content=body,
headers=upstream_headers,
)
except httpx.HTTPError:
return _record_route(
request,
method,
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
else:
try:
resp = await client.get(f"{ref.url}{upstream_path}", headers=upstream_headers)
except httpx.HTTPError:
return _record_route(
request,
method,
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
# Preserve upstream headers — Content-Disposition + CSP set by the
# /content handler must reach the original caller, and the upstream
# already produced the correct Content-Type for both JSON and binary
# payloads. Drop hop-by-hop headers that the underlying transport
# will manage itself.
response_headers = {
k: v
for k, v in resp.headers.items()
if k.lower()
not in {"transfer-encoding", "content-encoding", "connection", "content-length"}
}
return _record_route(
request,
method,
resp.status_code,
t0,
Response(
content=resp.content,
status_code=resp.status_code,
headers=response_headers,
),
)
async def route_proxy(request: Request) -> Response:
"""Generic routing proxy for send/approve/cancel/command/close.
Path-keyed shape: ``POST/DELETE /v1/api/route/workstreams/{ws_id}/<verb>``
(or ``POST /v1/api/route/<verb>`` for the body-keyed plan/command
legacies still in scope). ``verb`` drives the audit action lookup;
DELETE on ``/send`` is treated as dequeue for audit attribution.
"""
t0 = time.monotonic()
# Extract verb name from URL tail: /v1/api/route/.../send -> "send".
# DELETE on /send is the dequeue path — audit attribution diverges.
verb = request.url.path.rsplit("/", 1)[-1]
if verb == "send" and request.method == "DELETE":
verb = "dequeue"
router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready()
if not ring_ready:
if router is not None:
await asyncio.to_thread(router.refresh_cache)
ring_ready = router.is_ready()
if not ring_ready:
return _record_route(
request,
verb,
503,
t0,
JSONResponse(
{"error": "Cluster routing not initialized"},
status_code=503,
),
)
assert router is not None
try:
body = await request.json()
except Exception:
return _record_route(
request,
verb,
400,
t0,
JSONResponse(
{"error": "Invalid JSON body"},
status_code=400,
),
)
# Path-keyed shape (post-1.5) carries ws_id in the URL; the
# legacy plan/command routes still mount at body-keyed URLs and
# supply ws_id via the JSON body. Try path first, fall back to body.
ws_id = request.path_params.get("ws_id", "") or str(body.get("ws_id") or "")
if not ws_id:
return _record_route(
request,
verb,
400,
t0,
JSONResponse(
{"error": "ws_id required"},
status_code=400,
),
)
try:
ref = router.route(ws_id)
except (NoAvailableNodeError, ValueError):
return _record_route(
request,
verb,
503,
t0,
JSONResponse(
{"error": "routing failed"},
status_code=503,
),
)
# Map /v1/api/route/... → /v1/api/... on the upstream server
path = request.url.path
upstream_path = path.replace("/api/route/", "/api/", 1)
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
http_method = request.method
try:
resp = await client.request(
http_method, f"{ref.url}{upstream_path}", json=body, headers=headers
)
except httpx.HTTPError:
return _record_route(
request,
verb,
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
# Transparent retry on 404 (at most once):
#
# The rendezvous-selected node doesn't have the workstream. Refresh
# membership + overrides and re-route. If the route changed (e.g., a
# local-create override was added since the last cache load, or a
# node has joined / dropped), retry on the new node. If the route
# is the same, return the 404 as-is — no loop, no scan.
if resp.status_code == 404:
# Off the event loop — force_refresh takes a blocking lock and
# issues two storage queries. Coalesces internally so a 404
# stampede after a node churn doesn't N×-multiply DB reads.
await asyncio.to_thread(router.force_refresh)
try:
new_ref = router.route(ws_id)
except (NoAvailableNodeError, ValueError):
new_ref = ref
if new_ref.node_id != ref.node_id:
try:
resp = await client.request(
http_method,
f"{new_ref.url}{upstream_path}",
json=body,
headers=headers,
)
except httpx.HTTPError:
return _record_route(
request,
verb,
502,
t0,
JSONResponse(
{"error": f"retry node {new_ref.node_id} unreachable"},
status_code=502,
),
)
ref = new_ref # retried node — used for audit attribution (only emits on 2xx via the next block).
if 200 <= resp.status_code < 300:
action = _ROUTE_PROXY_AUDIT_ACTIONS.get(verb)
if action:
_emit_route_audit(request, action, ws_id, ref.node_id)
return _record_route(
request,
verb,
resp.status_code,
t0,
Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
),
)
async def route_workstream_delete(request: Request) -> Response:
"""POST /v1/api/route/workstreams/delete — proxy to the upstream delete endpoint.
The upstream server exposes delete at ``POST /v1/api/workstreams/{ws_id}/delete``
(path parameter), so ``route_proxy``'s ``/api/route/... → /api/...``
rewrite doesn't apply. This dedicated handler reads ``ws_id`` from the
request body, routes to the owning node, and forwards to the path-parameter
form. Used by the coordinator's ``delete_workstream`` tool.
"""
t0 = time.monotonic()
router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready()
if not ring_ready:
if router is not None:
await asyncio.to_thread(router.refresh_cache)
ring_ready = router.is_ready()
if not ring_ready:
return _record_route(
request,
"delete",
503,
t0,
JSONResponse(
{"error": "Cluster routing not initialized"},
status_code=503,
),
)
assert router is not None
try:
body = await request.json()
except Exception:
return _record_route(
request,
"delete",
400,
t0,
JSONResponse({"error": "Invalid JSON body"}, status_code=400),
)
ws_id = body.get("ws_id", "")
if not ws_id:
return _record_route(
request,
"delete",
400,
t0,
JSONResponse({"error": "ws_id required"}, status_code=400),
)
try:
ref = router.route(ws_id)
except (NoAvailableNodeError, ValueError):
return _record_route(
request,
"delete",
503,
t0,
JSONResponse({"error": "routing failed"}, status_code=503),
)
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
upstream_url = f"{ref.url}/v1/api/workstreams/{ws_id}/delete"
try:
resp = await client.post(upstream_url, json=body, headers=headers)
except httpx.HTTPError:
return _record_route(
request,
"delete",
502,
t0,
JSONResponse(
{"error": f"upstream node {ref.node_id} unreachable"},
status_code=502,
),
)
if 200 <= resp.status_code < 300:
_emit_route_audit(request, "route.workstream.delete", ws_id, ref.node_id)
return _record_route(
request,
"delete",
resp.status_code,
t0,
Response(
content=resp.content,
status_code=resp.status_code,
headers=dict(resp.headers),
),
)
async def route_lookup(request: Request) -> JSONResponse:
"""GET /v1/api/route — look up which node owns a workstream."""
t0 = time.monotonic()
router: ConsoleRouter | None = request.app.state.router
ring_ready = router is not None and router.is_ready()
if not ring_ready:
if router is not None:
await asyncio.to_thread(router.refresh_cache)
ring_ready = router.is_ready()
if not ring_ready:
return _record_route(
request,
"route",
503,
t0,
JSONResponse(
{"error": "Cluster routing not initialized"},
status_code=503,
),
) # type: ignore[return-value]
assert router is not None
ws_id = request.query_params.get("ws_id", "")
if not ws_id:
return _record_route(
request,
"route",
400,
t0,
JSONResponse(
{"error": "ws_id required"},
status_code=400,
),
) # type: ignore[return-value]
try:
ref = router.route(ws_id)
except NoAvailableNodeError:
return _record_route(
request,
"route",
503,
t0,
JSONResponse(
{"error": "No available node for routing"},
status_code=503,
),
) # type: ignore[return-value]
return _record_route(
request,
"route",
200,
t0,
JSONResponse(
{"node_url": ref.url, "node_id": ref.node_id},
),
) # type: ignore[return-value]
# ---------------------------------------------------------------------------
# Route handlers — reverse proxy
# ---------------------------------------------------------------------------
async def proxy_index(request: Request) -> Response:
"""GET /node/{node_id}/ — serve proxied server UI with URL rewriting."""
node_id = request.path_params["node_id"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
safe_node = urllib.parse.quote(node_id, safe="")
prefix = f"/node/{safe_node}"
try:
resp = await client.get(f"{server_url}/", headers=_proxy_auth_headers(request))
if resp.status_code < 200 or resp.status_code >= 300:
log.debug("Upstream %s returned status %s", node_id, resp.status_code)
return JSONResponse(
{"error": "Upstream server error", "status_code": resp.status_code},
status_code=resp.status_code,
)
page = resp.text
# Rewrite static asset paths
page = page.replace('href="/static/', f'href="{prefix}/static/')
page = page.replace('src="/static/', f'src="{prefix}/static/')
page = page.replace('href="/shared/', f'href="{prefix}/shared/')
page = page.replace('src="/shared/', f'src="{prefix}/shared/')
# Inject the proxy shim (prefix rewriting + node-picker) after <body>.
# The picker self-attaches to #ui-header on DOMContentLoaded; the
# banner that used to live above the appbar is gone. node_id is
# validated against _VALID_NODE_ID upstream, so json.dumps is the
# only escaping the JS literal needs.
shim_js = _JS_PROXY_SHIM.replace('"PREFIX_PLACEHOLDER"', json.dumps(prefix)).replace(
'"NODE_ID_PLACEHOLDER"', json.dumps(node_id)
)
shim = "<script>" + shim_js + "</script>"
page = page.replace("<body>", "<body>" + _CONSOLE_PROXY_STYLE + shim, 1)
html_resp = HTMLResponse(page)
html_resp.headers["Cache-Control"] = "no-cache"
return html_resp
except httpx.HTTPError as exc:
log.debug("Proxy index error for %s: %s", node_id, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_static(request: Request) -> Response:
"""GET /node/{node_id}/static/{path} — proxy static files."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
try:
resp = await client.get(
f"{server_url}/static/{path}",
headers=_proxy_auth_headers(request),
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/octet-stream"),
)
except httpx.HTTPError as exc:
log.debug("Proxy static error for %s/%s: %s", node_id, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_shared_static(request: Request) -> Response:
"""GET /node/{node_id}/shared/{path} — proxy shared static files."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
try:
resp = await client.get(
f"{server_url}/shared/{path}",
headers=_proxy_auth_headers(request),
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/octet-stream"),
)
except httpx.HTTPError as exc:
log.debug("Proxy shared static error for %s/%s: %s", node_id, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def proxy_api(request: Request) -> Response:
"""Proxy API requests to target node. Detects SSE vs regular."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
# Detect if this came through the /v1/ proxy route
api_prefix = "api"
safe_node = urllib.parse.quote(node_id, safe="")
if request.url.path.startswith(f"/node/{safe_node}/v1/api/"):
api_prefix = "v1/api"
# SSE detection: GET requests to events endpoints. The bare
# ``events`` / ``events/global`` paths are the legacy / global
# streams; ``workstreams/{ws_id}/events`` is the per-workstream
# stream the interactive WebUI subscribes to. Without matching
# the per-ws shape, the EventSource API can't consume the
# response (gets a one-shot GET instead of text/event-stream)
# and Firefox surfaces it as "can't establish a connection".
is_sse = path in ("events", "events/global") or (
path.startswith("workstreams/") and path.endswith("/events")
)
if request.method == "GET" and is_sse:
# ``events/global`` requires service scope on the upstream
# (carries cluster-wide cross-tenant inventory by design);
# end-user JWTs don't have it, so proxy as the console's
# service identity instead. Per-ws + bare events stay on
# the user's identity for upstream audit attribution.
use_service = path == "events/global"
return await _proxy_sse(
request,
server_url,
path,
api_prefix=api_prefix,
use_service_auth=use_service,
)
if request.method in ("POST", "PUT", "DELETE"):
return await _proxy_post(request, server_url, path, api_prefix=api_prefix)
return await _proxy_get(request, server_url, f"{api_prefix}/{path}")
async def proxy_non_api(request: Request) -> Response:
"""Proxy non-API GET endpoints (health, metrics) to target node."""
node_id = request.path_params["node_id"]
path = request.path_params["path"]
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
return await _proxy_get(request, server_url, path)
async def _proxy_get(request: Request, server_url: str, path: str) -> Response:
"""Forward a GET request to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
target = f"{server_url}/{path}"
if request.url.query:
target += f"?{request.url.query}"
try:
resp = await client.get(target, headers=_proxy_auth_headers(request))
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy GET error for %s: %s", target, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def _proxy_post(
request: Request, server_url: str, path: str, *, api_prefix: str = "api"
) -> Response:
"""Forward a non-GET request (POST/PUT/DELETE) to the target server."""
client: httpx.AsyncClient = request.app.state.proxy_client
body = await request.body()
content_type = request.headers.get("content-type", "application/json")
target = f"{server_url}/{api_prefix}/{path}"
if request.url.query:
target += f"?{request.url.query}"
try:
headers = {"Content-Type": content_type}
headers.update(_proxy_auth_headers(request))
resp = await client.request(
request.method,
target,
content=body,
headers=headers,
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError as exc:
log.debug("Proxy %s error for %s/%s: %s", request.method, api_prefix, path, exc)
return JSONResponse({"error": "Node unreachable"}, status_code=502)
async def _proxy_sse(
request: Request,
server_url: str,
path: str,
*,
api_prefix: str = "api",
use_service_auth: bool = False,
) -> Response:
"""Proxy an SSE stream from the target server to the browser.
Relays raw bytes verbatim so server-side ping comments, event framing,
and keepalives all pass through unchanged.
``use_service_auth=True`` swaps the user's re-minted JWT for the
console's service token. The ``events/global`` upstream
(``server.py``'s ``global_events_sse``) requires ``service`` scope
by design — end-user JWTs lack it, so a user-scoped proxy call
would 403-loop forever as the browser's EventSource auto-retries.
Treating it as a service-to-service call mirrors how the cluster
collector itself subscribes; the user identity stays with the
console-side gate (the ``/node/{node_id}/v1/api/`` route is
already gated by the console's ``AuthMiddleware``).
"""
target = f"{server_url}/{api_prefix}/{path}"
if request.url.query:
target += f"?{request.url.query}"
sse_client: httpx.AsyncClient = request.app.state.proxy_sse_client
sse_auth: dict[str, str]
if use_service_auth:
proxy_token_mgr = getattr(request.app.state, "proxy_token_mgr", None)
if proxy_token_mgr is None:
# Fail fast on misconfig — without a service token the
# upstream 401/403s on every request and the browser's
# EventSource auto-retries forever, filling logs.
# Surface as 503 so the operator sees a single clear
# signal instead of a retry storm.
log.error(
"proxy.sse.no_service_token url=%s — proxy_token_mgr "
"not configured; events/global proxy unavailable",
target,
)
return JSONResponse(
{"error": "console service token unavailable"},
status_code=503,
)
sse_auth = dict(proxy_token_mgr.bearer_header)
else:
sse_auth = _proxy_auth_headers(request)
async def raw_stream() -> AsyncGenerator[bytes, None]:
try:
async with sse_client.stream(
"GET",
target,
headers={**sse_auth, "Accept": "text/event-stream", "Cache-Control": "no-store"},
timeout=httpx.Timeout(connect=10, read=None, write=5, pool=None),
) as response:
if response.status_code != 200:
status = response.status_code
body_preview = await _bounded_stream_preview(response)
# Non-200 from a service-auth-backed SSE path is
# operator-actionable: 4xx = scope/tenant drift,
# 5xx = upstream outage. Raise the log floor to
# WARNING so it surfaces in ops logs; the browser
# also receives the error event for UX.
log.warning(
"proxy.sse.non_200 status=%d url=%s body=%s",
status,
target,
body_preview,
)
yield f"event: error\ndata: Upstream returned status {status}\n\n".encode()
return
async for chunk in response.aiter_bytes():
if await request.is_disconnected():
return
yield chunk
except httpx.HTTPError:
log.debug("SSE proxy stream ended for %s", target)
return StreamingResponse(
raw_stream(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-store",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
# ---------------------------------------------------------------------------
# Coordinator workstream endpoints — mounted under /v1/api/workstreams/*
# via register_session_routes + register_coord_verbs. Handler bodies stay
# named ``coordinator_*`` for now; the body-convergence follow-on lifts
# them into turnstone.core.session_routes with kind branching.
# ---------------------------------------------------------------------------
def _require_coord_mgr(request: Request) -> tuple[Any, JSONResponse | None]:
"""Resolve the coordinator manager or return a 503 with remediation.
Returns ``(coord_mgr, None)`` on success, ``(None, JSONResponse)``
when the coordinator subsystem isn't configured.
"""
coord_mgr = getattr(request.app.state, "coord_mgr", None)
config_store = getattr(request.app.state, "config_store", None)
if coord_mgr is None:
registry_err = getattr(request.app.state, "coord_registry_error", "") or ""
msg = "Coordinator subsystem not initialized. " + (
registry_err or "Add a model definition in the admin Models tab."
)
return None, JSONResponse({"error": msg}, status_code=503)
if config_store is None:
return None, JSONResponse({"error": "ConfigStore unavailable"}, status_code=503)
alias = (config_store.get("coordinator.model_alias") or "").strip()
# Empty alias falls back to the registry's default model — operators
# get a working coordinator on a freshly-provisioned console without
# an extra manual setting.
registry = getattr(request.app.state, "coord_registry", None)
if registry is None:
return None, JSONResponse(
{
"error": (
"ModelRegistry unavailable for coordinator sessions. "
"Restart the console after adding a model definition."
)
},
status_code=503,
)
try:
# ``resolve(None)`` uses ``registry.default``; the registry
# raises when neither the explicit alias nor the default is
# configured, which we translate to a 503 with remediation.
registry.resolve(alias or None)
except Exception as exc:
hint = f"coordinator.model_alias '{alias}'" if alias else "the registry default alias"
return None, JSONResponse(
{
"error": (
f"{hint} does not resolve: {exc}. "
"Add or enable a model in the admin Models tab."
)
},
status_code=503,
)
return coord_mgr, None
def _require_admin_coordinator(
request: Request, *, allow_service_bypass: bool = True
) -> JSONResponse | None:
"""Gate a coordinator endpoint on the ``admin.coordinator`` permission.
Destructive endpoints (/restrict, /stop_cascade) pass
``allow_service_bypass=False`` so a service-scoped caller whose
``user_id`` matches the coord owner still needs an explicit grant.
"""
return require_permission(
request, "admin.coordinator", allow_service_bypass=allow_service_bypass
)
async def _resolve_coordinator_or_404(
request: Request,
coord_mgr: Any,
storage: Any,
ws_id: str,
user_id: str,
) -> tuple[Any, JSONResponse | None]:
"""Resolve a coordinator workstream by id.
Returns ``(ws, None)`` on success — ``ws`` is the in-memory
``Workstream`` when present, ``None`` when the coordinator is
persisted but not loaded (callers may then fall through to
``storage`` directly or trigger lazy rehydration). Returns
``(None, 404)`` on missing row / wrong kind / storage unavailable.
Centralises the manager-first, storage-fallback, 404-mask ladder
used by the coord-only verbs (``coordinator_children`` /
``coordinator_tasks``). The shared verbs (history, detail, ...)
inline the same ladder via :func:`make_history_handler` /
:func:`make_detail_handler`. Turnstone is a
trusted-team tool — ``user_id`` is metadata, not an access
boundary, so this helper no longer gates on row ownership; scope
auth (``admin.coordinator``) upstream is the gate.
"""
del user_id # retained in signature for caller-site clarity; not consulted here
miss = JSONResponse({"error": "coordinator not found"}, status_code=404)
ws = coord_mgr.get(ws_id) if coord_mgr is not None else None
if ws is None:
if storage is None:
return None, miss
try:
# Cold-cache path (every console restart, eviction, console
# proxy hop) — offload the sync DB call so the coord
# children/tasks handlers don't block the event loop on the
# same DB the rest of the handler is unblocking.
row = await asyncio.to_thread(storage.get_workstream, ws_id)
except Exception:
log.debug("resolve_coordinator.storage_failed ws=%s", ws_id[:8], exc_info=True)
return None, miss
if row is None or row.get("kind") != WorkstreamKind.COORDINATOR:
return None, miss
return None, None
return ws, None
def _auth_user_id(request: Request) -> str:
"""Thin shim over :func:`turnstone.core.web_helpers.auth_user_id`.
Kept as a module-level alias so existing call sites don't need a
sweeping rename; the lifted helper is the canonical version
(shared with the node side since P1.5).
"""
from turnstone.core.web_helpers import auth_user_id
return auth_user_id(request)
def _auth_scopes(request: Request) -> set[str]:
auth = getattr(getattr(request, "state", None), "auth_result", None)
return set(getattr(auth, "scopes", []) or [])
def _audit_close_coordinator(
request: Request,
ws_id: str,
ws_before: Workstream, # noqa: ARG001 — coord audit detail doesn't use it yet
reason: str, # noqa: ARG001 — coord doesn't expose close_reason yet
) -> None:
"""Record the ``coordinator.close`` audit event.
Passed to :func:`make_close_handler` as the ``audit_emit``
callable. ``storage`` is guaranteed non-``None`` by the lifted
handler's upstream gate; the ``getattr`` fallback is defensive
consistency with the rest of the storage access pattern.
"""
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
record_audit(
storage,
_auth_user_id(request),
"coordinator.close",
"workstream",
ws_id,
{"coord_ws_id": ws_id, "src": "coordinator"},
request.client.host if request.client else "",
)
def _audit_cancel_coordinator(
request: Request,
ws_id: str,
ws_before: Workstream, # noqa: ARG001 — coord audit detail doesn't use it yet
force: bool,
) -> None:
"""Record the ``coordinator.cancel`` audit event.
Passed to :func:`make_cancel_handler` as the ``audit_emit``
callable. Mirrors :func:`_audit_close_coordinator`. The ``force``
flag rides into the audit detail so an operator-driven recovery
is distinguishable from a routine cancel.
"""
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
record_audit(
storage,
_auth_user_id(request),
"coordinator.cancel",
"workstream",
ws_id,
{"coord_ws_id": ws_id, "src": "coordinator", "force": force},
request.client.host if request.client else "",
)
def _coord_events_replay(
ws: Workstream,
ui: Any,
request: Request, # noqa: ARG001 — coord replay doesn't need request context
) -> Iterable[dict[str, Any]]:
"""Initial SSE replay payload for coord ``events`` connections.
Yields, in order:
1. ``connected`` + optional ``status`` via the shared
:func:`turnstone.core.session_replay.session_replay_preamble`
so the dashboard's status bar populates before any live tick.
Same payload shape interactive uses.
2. Pending approval prompt (if any) and the cached LLM verdicts
that fired since it surfaced. Without this replay a refresh
loses the judge chip on the pending approval until the
operator re-invokes the action.
3. Pending plan-review (if any).
Coord still skips conversation history — the dashboard fetches it
via a separate ``GET /history`` endpoint and doesn't want a
multi-MB inline replay on every reconnect.
Pure read — never mutates ``ui`` / ``ws`` / ``session``.
"""
yield from session_replay_preamble(ws.session, ui)
pending_approval = getattr(ui, "_pending_approval", None)
if pending_approval is not None:
yield pending_approval
# Cached LLM verdicts that fired since the approval prompt
# — without this replay, a reconnecting / refreshing tab
# sees the approve_request prompt but no judge chip, and
# since intent_verdict only fires once per call_id (no
# push to a late subscriber), the chip would never appear
# until the operator re-invokes the action. Mirrors the
# interactive path at ``turnstone/server.py:875-878``.
llm_verdicts = getattr(ui, "_llm_verdicts", None)
ws_lock = getattr(ui, "_ws_lock", None)
if llm_verdicts and ws_lock is not None:
with ws_lock:
cached_verdicts = list(llm_verdicts.values())
for v in cached_verdicts:
yield {"type": "intent_verdict", **v}
pending_plan = getattr(ui, "_pending_plan_review", None)
if pending_plan is not None:
yield pending_plan
async def _coord_create_validate_request(
request: Request,
body: dict[str, Any],
uid: str,
uploaded_files: list[tuple[str, str, bytes]],
) -> JSONResponse | None:
"""Per-kind pre-create gate for coord.
Wired onto :attr:`SessionEndpointConfig.create_validate_request`
and called by :func:`make_create_handler` after body parsing
but before skill resolution / ``mgr.create``. The single gate is
a 401 when the auth result resolved to an empty user id —
coord's ``admin.coordinator`` scope check at the
``permission_gate`` is the primary access boundary, but a token
that passes the scope check with ``sub=""`` would still land
here, and ``mgr.create`` requires a non-empty ``user_id``.
"""
if not uid:
return JSONResponse({"error": "authentication required"}, status_code=401)
return None
def _coord_create_build_kwargs(
request: Request,
body: dict[str, Any],
uid: str,
skill_data: dict[str, Any] | None,
skill_id: str,
applied_skill_version: int,
) -> dict[str, Any]:
"""Build kwargs for ``coord_mgr.create`` from a parsed coord create body.
Coord's create still takes a smaller set than interactive's
(no ``client_type`` / ``parent_ws_id`` / ``ws_id`` — coord ws_id
is always server-generated and coord has no parent), but
per-call ``model`` and ``judge_model`` overrides flow through
here onto the coord session factory the same way they flow
through interactive's: ConfigStore (``coordinator.model_alias``
/ ``judge.model``) sets the default; this body field overrides
for one session.
"""
# Use the canonical skill name from the resolved row when one was
# found; falls back to the stripped body value (which is what the
# validator already normalised) so the persisted skill name stays
# whitespace-clean regardless of how the request shape changes.
canonical_skill: str | None
if skill_data and skill_data.get("name"):
canonical_skill = str(skill_data["name"])
else:
canonical_skill = (body.get("skill") or "").strip() or None
name = (body.get("name") or "").strip()
# Empty / non-string / whitespace-only body fields collapse to None
# so the factory falls back to ConfigStore defaults rather than
# treating "" (or a hostile dict / list) as a request to override
# with the empty alias. The isinstance guard also keeps a
# truthy-non-string body (e.g. ``{"model": {"url": "x"}}``) from
# reaching ``.strip()`` and crashing into the lifted handler's
# generic 500 path.
model_raw = body.get("model")
judge_raw = body.get("judge_model")
model = (model_raw.strip() if isinstance(model_raw, str) else "") or None
judge_model = (judge_raw.strip() if isinstance(judge_raw, str) else "") or None
return {
"user_id": uid,
"name": name,
"skill": canonical_skill,
"skill_id": skill_id,
"skill_version": applied_skill_version,
"model": model,
"judge_model": judge_model,
}
async def _coord_create_post_install(
request: Request,
ws: Workstream,
body: dict[str, Any],
uid: str,
skill_data: dict[str, Any] | None,
applied_skill_version: int,
attachment_ids: list[str],
) -> dict[str, Any]:
"""Tail end of coord create: dispatch the initial message.
Wired onto :attr:`SessionEndpointConfig.create_post_install`. When
an ``initial_message`` is provided, dispatches via
:meth:`CoordinatorAdapter.send`; any uploaded ``attachment_ids``
are reserved onto the same ``send_id`` token so the worker's
first turn picks them up exactly the way interactive's
``post_install`` worker thread does.
Returns ``{}`` — coord's response carries only the always-include
parity fields populated by the factory.
"""
import uuid as _uuid
from turnstone.core.attachments import reserve_and_resolve_attachments
initial_message = (body.get("initial_message") or "").strip()
if not initial_message:
return {}
coord_adapter = getattr(request.app.state, "coord_adapter", None)
if coord_adapter is None:
return {}
# Mirror interactive's reservation pattern: same send_id token
# scopes the soft-lock and the eventual consume. Coord's
# ``CoordinatorAdapter.send`` worker passes both through to
# ``ChatSession.send(..., send_id=...)``; on worker failure the
# adapter's exception path unreserves so the rows return to
# pending.
send_id = _uuid.uuid4().hex
resolved_atts: list[Any] = []
if attachment_ids:
resolved_atts, _ord, _drop = reserve_and_resolve_attachments(
attachment_ids, send_id, ws.id, uid
)
coord_adapter.send(
ws.id,
initial_message,
attachments=resolved_atts or None,
send_id=send_id if resolved_atts else None,
)
return {}
def _audit_coordinator_create(
request: Request,
ws: Workstream,
body: dict[str, Any],
uid: str,
) -> None:
"""Audit emitter for the coord ``coordinator.create`` event.
Wired onto :func:`make_create_handler` as ``audit_emit``. Failures
are caught + logged at ``warning`` by the factory.
"""
from turnstone.core.audit import record_audit
storage = getattr(request.app.state, "auth_storage", None)
if storage is None:
return
record_audit(
storage,
uid,
"coordinator.create",
"workstream",
ws.id,
{"coord_ws_id": ws.id, "src": "coordinator", "name": ws.name},
request.client.host if request.client else "",
)
def _coord_spawn_metrics(_request: Request, ui: Any) -> None:
"""Per-spawn counter writes for coord — mirrors interactive's pattern.
Wired onto :attr:`SessionEndpointConfig.spawn_metrics`. Increments
``_ws_messages`` and resets ``_ws_turn_tool_calls`` so the rich
``ws_state`` cluster broadcast renders the same per-turn shape
coord rows on the dashboard need. Console-side Prometheus runs
through :class:`ConsoleMetrics` (lighter than the per-node collector
— judge verdicts and routing/membership only); the interactive
analog ``_metrics.record_message_sent()`` has no console counterpart
yet, so this hook only owns the per-UI counter writes.
"""
if (
hasattr(ui, "_ws_lock")
and hasattr(ui, "_ws_messages")
and hasattr(ui, "_ws_turn_tool_calls")
):
with ui._ws_lock:
ui._ws_messages += 1
ui._ws_turn_tool_calls = 0
async def _coord_saved_loaded_lookup(request: Request) -> set[str]:
"""Return ws_ids currently held in ``coord_mgr``'s warm pool.
Wired onto :attr:`SessionEndpointConfig.saved_loaded_lookup`.
Defence-in-depth filter for the saved-coordinators list — a row
can be ``state='closed'`` on disk for a few seconds while the
close-emit sequence races the in-memory pop, and we don't want
the saved card grid showing a coord that's still loaded.
Empty set when ``coord_mgr`` isn't attached (subsystem unavailable)
or empty (zero-element snapshot — skip the executor hop too).
Errors are swallowed by the lifted body's outer ``try/except``;
returning ``set()`` here on a missing manager keeps the caller
happy without trampling the lifted body's error log.
"""
coord_mgr = getattr(request.app.state, "coord_mgr", None)
if coord_mgr is None:
return set()
# Cheap probe: an empty pool can answer without paying the
# ``asyncio.to_thread`` round-trip. ``count`` reads under the
# manager lock but doesn't block; if the manager isn't empty we
# still need ``list_all`` under to_thread because the snapshot
# itself acquires the same lock.
if coord_mgr.count == 0:
return set()
return await asyncio.to_thread(
lambda: {ws.id for ws in coord_mgr.list_all()},
)
async def coordinator_page(request: Request) -> Response:
"""GET /coordinator/{ws_id} — serve the one-pane coordinator HTML.
The handler injects ``data-ws-id`` on the <html> tag so
``coordinator.js`` can read it without a separate API round-trip.
Auth gating happens on the API endpoints the page calls — this
handler simply serves the static template (same model as /static).
"""
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
template_path = _STATIC_DIR / "coordinator" / "index.html"
if not template_path.is_file():
return JSONResponse({"error": "coordinator UI template missing"}, status_code=500)
try:
body = template_path.read_text(encoding="utf-8")
except OSError:
return JSONResponse({"error": "failed to read coordinator UI template"}, status_code=500)
# Inject the ws_id as an HTML attribute. ws_id passed the
# ``_VALID_WS_ID_RE`` gate above (hex only) so there's nothing
# to HTML-escape; leave the replacement simple.
body = body.replace("{{WS_ID}}", ws_id)
return Response(body, media_type="text/html; charset=utf-8")
_CHILDREN_PAGE_LIMIT = 200
def _coord_children_row(row: Any) -> dict[str, Any]:
"""Serialize a ``list_workstreams`` row for the /children response.
Matches the ``list_children`` tool output shape so the tool and the UI
endpoint agree: ``ws_id / node_id / name / state / created / updated
/ kind / parent_ws_id / skill_id / skill_version``.
"""
try:
m = row._mapping # SQLAlchemy Row
except AttributeError:
m = {
"ws_id": row[0],
"node_id": row[1],
"name": row[2],
"state": row[3],
"created": row[4],
"updated": row[5],
"kind": WorkstreamKind.from_raw(row[6] if len(row) > 6 else None),
"parent_ws_id": row[7] if len(row) > 7 else None,
"skill_id": row[8] if len(row) > 8 else None,
"skill_version": row[9] if len(row) > 9 else None,
}
return {
"ws_id": m["ws_id"],
"node_id": m["node_id"],
"name": m["name"],
"state": m["state"],
"created": m["created"],
"updated": m["updated"],
"kind": m["kind"],
"parent_ws_id": m["parent_ws_id"],
"skill_id": m["skill_id"],
"skill_version": m["skill_version"],
}
async def coordinator_children(request: Request) -> JSONResponse:
"""GET /v1/api/workstreams/{ws_id}/children — list direct children.
Returns ``{items, truncated}`` with one row per interactive workstream
whose ``parent_ws_id`` is the coordinator. Matches the shape of the
``list_children`` tool so the tree UI and the model-facing tool see
the same rows.
Same ownership / 404-on-mismatch / admin-bypass semantics as
:func:`make_detail_handler`. Reads don't audit.
"""
from turnstone.core.web_helpers import require_storage_or_503
err = _require_admin_coordinator(request)
if err is not None:
return err
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503
storage, err503s = require_storage_or_503(request)
if err503s is not None:
return err503s
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
user_id = _auth_user_id(request)
_ws, err404 = await _resolve_coordinator_or_404(request, coord_mgr, storage, ws_id, user_id)
if err404 is not None:
return err404
# Trusted-team visibility: any caller with admin.coordinator sees
# the full child subtree. ``user_id`` stays on each row as
# metadata, not a filter.
try:
raw = await asyncio.to_thread(
storage.list_workstreams,
limit=_CHILDREN_PAGE_LIMIT + 1,
parent_ws_id=ws_id,
kind=None,
user_id=None,
)
except Exception:
correlation_id = secrets.token_hex(4)
log.warning(
"coordinator_children.list_failed correlation_id=%s ws_id=%s",
correlation_id,
ws_id[:8],
exc_info=True,
)
return JSONResponse(
{"error": f"failed to list children. correlation_id={correlation_id}"},
status_code=500,
)
# Compute truncated BEFORE the coordinator-filter pass so a
# filtered-out sentinel row doesn't mask "there's more data".
# Fetched `_CHILDREN_PAGE_LIMIT + 1` rows above as the sentinel;
# if the DB returned that many, there's at least one more page.
truncated = len(raw) > _CHILDREN_PAGE_LIMIT
items: list[dict[str, Any]] = []
for row in raw:
serialized = _coord_children_row(row)
# Drop nested coordinators defensively — current schema can't
# produce them (the interactive-side SessionManager rejects
# kind!=interactive), but the filter keeps the tree UI contract
# stable across schema changes.
if serialized.get("kind") == WorkstreamKind.COORDINATOR:
continue
items.append(serialized)
if len(items) >= _CHILDREN_PAGE_LIMIT:
break
return JSONResponse({"items": items, "truncated": truncated})
def _coordinator_metrics_payload(
*,
ws_id: str,
spawns_total: int = 0,
spawns_last_hour: int = 0,
child_state_counts: dict[str, int] | None = None,
judge_fallback_rate: float = 0.0,
intent_verdicts_sample: int = 0,
) -> dict[str, Any]:
"""Build the ``coordinator_metrics`` response dict.
One source of truth for the response shape — the DENY short-circuit
and the happy path both call this so a new field added tomorrow
can't appear in one branch and not the other. Wait-tool metrics
are always zero placeholders; the harness doesn't persist them
yet but scrapers key on the keys being present.
"""
return {
"ws_id": ws_id,
"spawns_total": spawns_total,
"spawns_last_hour": spawns_last_hour,
"child_state_counts": child_state_counts or {},
"judge_fallback_rate": judge_fallback_rate,
"intent_verdicts_sample": intent_verdicts_sample,
"wait_completions": 0,
"wait_timeouts": 0,
"wait_avg_elapsed": 0.0,
}
async def coordinator_metrics(request: Request) -> JSONResponse:
"""GET /v1/api/workstreams/{ws_id}/metrics — per-coordinator health snapshot.
Aggregates cheap, already-persisted signals into a one-shot "is
this coordinator healthy?" answer for operators (#16). No new
persistence — everything derives from ``list_workstreams``
(children) and ``list_intent_verdicts`` (judge telemetry).
Fields:
- ``spawns_total`` — children ever created under this coordinator
(closed / deleted children included; the row persists through
close and hard-delete cascades the row out but is rare).
- ``spawns_last_hour`` — subset of the above whose ``created``
timestamp is within the last 3600s.
- ``child_state_counts`` — ``{state: count}`` grouped on the live
children's state column. Useful for spotting a coordinator
whose children are all stuck in ``attention`` (approval queue).
- ``judge_fallback_rate`` — fraction of recent intent verdicts
whose ``tier`` contained ``fallback`` — indicates the judge's
primary path is unavailable or misconfigured. 0.0 when no
verdicts have been recorded.
- ``wait_completions`` / ``wait_timeouts`` / ``wait_avg_elapsed``
— placeholders returning 0 / 0 / 0.0; these require explicit
wait-tool instrumentation (future work — the SSE events from
#14 carry the data live but aren't persisted yet).
Ownership / authz: same gate as :func:`make_detail_handler` —
404-mask rows the caller doesn't own (no existence-oracle leak).
"""
from turnstone.core.web_helpers import require_storage_or_503
err = _require_admin_coordinator(request)
if err is not None:
return err
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503
storage, err503s = require_storage_or_503(request)
if err503s is not None:
return err503s
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
user_id = _auth_user_id(request)
_ws, err404 = await _resolve_coordinator_or_404(request, coord_mgr, storage, ws_id, user_id)
if err404 is not None:
return err404
# Children metrics derived from aggregate SQL — avoids pulling
# every hydrated row just to group by state and filter on created
# (#perf-1). Two cheap queries instead of a ``list_workstreams``
# scan up to 10k rows.
#
# Trusted-team visibility: aggregates run cluster-wide per the
# unified ownership model; ``user_id`` is not a filter here.
from datetime import UTC, datetime
now_epoch = time.time()
hour_ago_iso = datetime.fromtimestamp(now_epoch - 3600, tz=UTC).strftime("%Y-%m-%dT%H:%M:%S")
try:
state_counts = await asyncio.to_thread(
storage.count_workstreams_by_state,
parent_ws_id=ws_id,
user_id=None,
)
except Exception:
log.debug("coordinator_metrics.state_counts_failed ws=%s", ws_id[:8], exc_info=True)
state_counts = {}
spawns_total = sum(state_counts.values())
try:
spawns_last_hour = await asyncio.to_thread(
storage.count_workstreams_since,
hour_ago_iso,
parent_ws_id=ws_id,
user_id=None,
)
except Exception:
log.debug(
"coordinator_metrics.spawns_last_hour_failed ws=%s",
ws_id[:8],
exc_info=True,
)
spawns_last_hour = 0
# Intent verdicts — scoped to the coordinator itself (child verdicts
# would require iterating every child's verdicts; deferred). Small
# cap (last 200) keeps this a cheap query; enough to compute a
# meaningful rate for a busy coordinator.
try:
verdicts = await asyncio.to_thread(storage.list_intent_verdicts, ws_id=ws_id, limit=200)
except Exception:
log.debug("coordinator_metrics.list_verdicts_failed ws=%s", ws_id[:8], exc_info=True)
verdicts = []
total_verdicts = len(verdicts)
fallback_count = 0
for v in verdicts:
tier = ""
if isinstance(v, dict):
tier = str(v.get("tier") or "")
else:
try:
tier = str(v._mapping.get("tier") or "")
except AttributeError:
tier = ""
if "fallback" in tier.lower():
fallback_count += 1
judge_fallback_rate = round(fallback_count / total_verdicts, 3) if total_verdicts > 0 else 0.0
return JSONResponse(
_coordinator_metrics_payload(
ws_id=ws_id,
spawns_total=spawns_total,
spawns_last_hour=spawns_last_hour,
child_state_counts=state_counts,
judge_fallback_rate=judge_fallback_rate,
intent_verdicts_sample=total_verdicts,
)
)
_RESTRICT_MAX_TOOLS = 256
_RESTRICT_MAX_TOOL_NAME_LEN = 128
# Bounded concurrency on bulk coordinator fan-out (stop_cascade,
# close_all_children). Upstream coord_client calls have a 30s timeout;
# a 100-child cascade at this cap finishes in ~200s worst case,
# comfortably inside typical 300s proxy limits.
_COORD_FANOUT_MAX_CONCURRENCY = 16
async def _fanout_on_children(
child_ids: list[str],
coord_client: Any,
action: Callable[[str], Any],
*,
log_tag: str,
concurrency: int = _COORD_FANOUT_MAX_CONCURRENCY,
) -> tuple[list[str], list[str], list[str]]:
"""Bounded-concurrency fan-out over a coordinator's children.
Returns ``(ok, failed, skipped)`` — ``ok`` = action succeeded,
``skipped`` = upstream 404 (already gone), ``failed`` = everything
else (dispatch errors, exceptions, non-dict returns). Routes all
ids to ``failed`` when ``coord_client`` is None so the operator
sees the unexpected state rather than a silent no-op. Callers map
the three buckets to endpoint-specific response keys (cancelled /
closed / ...).
"""
ok: list[str] = []
failed: list[str] = []
skipped: list[str] = []
if not child_ids:
return ok, failed, skipped
if coord_client is None:
return ok, list(child_ids), skipped
sem = asyncio.Semaphore(concurrency)
async def _one(cid: str) -> tuple[str, str]:
async with sem:
try:
result = await asyncio.to_thread(action, cid)
if not isinstance(result, dict):
return cid, "failed"
if not result.get("error"):
return cid, "ok"
# Stale registry entry (child row deleted) or upstream
# 404 both mean "already gone". Route to skipped so
# operators can distinguish from dispatch failures.
if result.get("status") == 404:
return cid, "skipped"
# Lifted ``cancel`` returns 400 with "No session" for
# placeholder / build-failed workstreams (the in-memory
# row exists but its ChatSession was never constructed,
# so there's nothing to cancel). Treat the same as
# 404 — the child has no work to stop, not a dispatch
# failure that should fire alerts. Pre-lift coord
# silently no-op'd on placeholders; this keeps cascade
# behaviour parity with the pre-lift outcome.
# NOTE: this branch is reachable from the cancel-cascade
# caller (``stop_cascade``) but unreachable from the
# close-cascade caller (``close_all_children``); the
# close handler at ``session_routes.py:852-854`` 404s
# for both missing and already-closed-evicted rows and
# never emits a 400 "No session". Kept as shared code
# rather than gated by caller — the branch is cheap and
# the symmetry makes future cascade verbs easier to add.
if result.get("status") == 400 and result.get("error") == "No session":
return cid, "skipped"
return cid, "failed"
except Exception:
log.debug("%s.child_failed ws=%s", log_tag, cid[:8], exc_info=True)
return cid, "failed"
outcomes = await asyncio.gather(*(_one(cid) for cid in child_ids), return_exceptions=False)
for cid, bucket in outcomes:
if bucket == "ok":
ok.append(cid)
elif bucket == "skipped":
skipped.append(cid)
else:
failed.append(cid)
return ok, failed, skipped
async def _require_json_object(request: Request) -> dict[str, Any] | JSONResponse:
"""Parse the request body and require a JSON object.
``read_json_or_400`` only validates that the body parses as JSON,
not that it's an object. A ``null``/list/scalar body otherwise
reaches ``body.get(...)`` and raises ``AttributeError`` → 500.
"""
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
if not isinstance(body, dict):
return JSONResponse({"error": "body must be a JSON object"}, status_code=400)
return body
async def _resolve_coord_session(
request: Request,
*,
allow_service_bypass: bool = True,
) -> tuple[ChatSession, StorageBackend, str, str] | JSONResponse:
"""Return ``(session, storage, user_id, ws_id)`` or a gate-failure response.
Destructive / capability-escalating handlers pass
``allow_service_bypass=False`` so a service token whose ``user_id``
matches the coord owner still needs an explicit ``admin.coordinator``
grant — matching the treatment ``/trust`` gives its perm gate.
"""
err = _require_admin_coordinator(request, allow_service_bypass=allow_service_bypass)
if err is not None:
return err
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503
storage, err503s = require_storage_or_503(request)
if err503s is not None:
return err503s
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
user_id = _auth_user_id(request)
ws, err404 = await _resolve_coordinator_or_404(request, coord_mgr, storage, ws_id, user_id)
if err404 is not None:
return err404
if ws is None or ws.session is None:
return JSONResponse({"error": "coordinator not found"}, status_code=404)
return ws.session, storage, user_id, ws_id
async def _emit_coord_audit(
storage: StorageBackend,
user_id: str,
action: str,
ws_id: str,
detail: dict[str, Any],
client_host: str,
) -> None:
"""Record a coordinator governance audit event off the event loop.
Uses the console's dedicated audit executor when available so
cancel-cascade bursts can't starve audit writes (and vice versa).
Falls back to the default executor for test harnesses that don't
wire one.
"""
audit_exec = _audit_executor()
loop = asyncio.get_running_loop()
try:
if audit_exec is not None:
await loop.run_in_executor(
audit_exec,
record_audit,
storage,
user_id,
action,
"coordinator",
ws_id,
detail,
client_host,
)
else:
await asyncio.to_thread(
record_audit,
storage,
user_id,
action,
"coordinator",
ws_id,
detail,
client_host,
)
except Exception:
log.debug("coord.audit.dispatch_failed", exc_info=True)
_audit_executor_ref: ThreadPoolExecutor | None = None
def _audit_executor() -> ThreadPoolExecutor | None:
"""Return the shared audit-writes executor, if the lifespan built one."""
return _audit_executor_ref
def _set_audit_executor(executor: ThreadPoolExecutor | None) -> None:
"""Install or clear the process-wide audit executor (lifespan-owned)."""
global _audit_executor_ref
_audit_executor_ref = executor
async def coordinator_trust(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/trust — toggle trusted-session mode."""
trust_err = require_permission(request, "coordinator.trust.send", allow_service_bypass=False)
if trust_err is not None:
return trust_err
resolved = await _resolve_coord_session(request)
if isinstance(resolved, JSONResponse):
return resolved
session, storage, user_id, ws_id = resolved
body = await _require_json_object(request)
if isinstance(body, JSONResponse):
return body
raw_send = body.get("send")
if not isinstance(raw_send, bool):
return JSONResponse({"error": "body must carry {'send': bool}"}, status_code=400)
before = session.get_trust_send()
session.set_trust_send(raw_send)
await _emit_coord_audit(
storage,
user_id,
"coordinator.trust.toggled",
ws_id,
{"src": "coordinator", "send_before": before, "send_after": raw_send},
request.client.host if request.client else "",
)
return JSONResponse({"status": "ok", "trust_send": raw_send})
async def coordinator_restrict(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/restrict — revoke tool access mid-session."""
resolved = await _resolve_coord_session(request, allow_service_bypass=False)
if isinstance(resolved, JSONResponse):
return resolved
session, storage, user_id, ws_id = resolved
body = await _require_json_object(request)
if isinstance(body, JSONResponse):
return body
raw_revoke = body.get("revoke")
if not isinstance(raw_revoke, list) or not all(isinstance(t, str) and t for t in raw_revoke):
return JSONResponse(
{"error": "body must carry {'revoke': [<tool_name>, ...]}"},
status_code=400,
)
if len(raw_revoke) > _RESTRICT_MAX_TOOLS:
return JSONResponse(
{"error": f"revoke list exceeds {_RESTRICT_MAX_TOOLS} entries"},
status_code=400,
)
if any(len(t) > _RESTRICT_MAX_TOOL_NAME_LEN for t in raw_revoke):
return JSONResponse(
{"error": f"tool names must be <= {_RESTRICT_MAX_TOOL_NAME_LEN} chars"},
status_code=400,
)
additions = frozenset(raw_revoke)
after = session.revoke_tools(additions)
await _emit_coord_audit(
storage,
user_id,
"coordinator.restricted",
ws_id,
{
"src": "coordinator",
"revoked": sorted(additions),
"revoked_total": sorted(after),
},
request.client.host if request.client else "",
)
return JSONResponse({"status": "ok", "revoked_tools": sorted(after)})
async def coordinator_stop_cascade(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/stop_cascade — cancel the subtree."""
resolved = await _resolve_coord_session(request, allow_service_bypass=False)
if isinstance(resolved, JSONResponse):
return resolved
session, storage, user_id, ws_id = resolved
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503 # pragma: no cover — _resolve_coord_session already gated this
coord_adapter = getattr(request.app.state, "coord_adapter", None)
child_ids = list(coord_adapter.children_snapshot(ws_id)) if coord_adapter is not None else []
coord_mgr.cancel(ws_id)
coord_client: Any = getattr(session, "_coord_client", None)
# ``action`` is only called when coord_client is live — the helper
# short-circuits on None before invoking it.
cancelled, failed, skipped = await _fanout_on_children(
child_ids,
coord_client,
lambda cid: coord_client.cancel(cid),
log_tag="coordinator_stop_cascade",
)
await _emit_coord_audit(
storage,
user_id,
"coordinator.stopped_cascade",
ws_id,
{
"src": "coordinator",
"cancelled": cancelled,
"failed": failed,
"skipped": skipped,
},
request.client.host if request.client else "",
)
return JSONResponse(
{
"status": "ok",
"cancelled": cancelled,
"failed": failed,
"skipped": skipped,
}
)
_CLOSE_ALL_CHILDREN_MAX_REASON_LEN = 512
async def coordinator_close_all_children(request: Request) -> JSONResponse:
"""POST /v1/api/workstreams/{ws_id}/close_all_children — soft-close the direct children.
Near-twin of ``coordinator_stop_cascade`` — both fan out over
``children_snapshot`` via ``_fanout_on_children``. Returns
``{closed, failed, skipped}``. Unlike ``stop_cascade``, this does
NOT recurse into grandchildren (the coordinator's model tool asks
for a bounded teardown of its own fan-out; operator-level cascade
stays behind ``stop_cascade``).
"""
resolved = await _resolve_coord_session(request, allow_service_bypass=False)
if isinstance(resolved, JSONResponse):
return resolved
session, storage, user_id, ws_id = resolved
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503 # pragma: no cover — _resolve_coord_session already gated this
del coord_mgr # children_snapshot moved to the adapter
coord_adapter = getattr(request.app.state, "coord_adapter", None)
body = await _require_json_object(request)
if isinstance(body, JSONResponse):
return body
raw_reason = body.get("reason", "")
if raw_reason is not None and not isinstance(raw_reason, str):
return JSONResponse(
{"error": "reason must be a string"},
status_code=400,
)
reason = (raw_reason or "").strip()
if len(reason) > _CLOSE_ALL_CHILDREN_MAX_REASON_LEN:
return JSONResponse(
{"error": f"reason exceeds {_CLOSE_ALL_CHILDREN_MAX_REASON_LEN} chars"},
status_code=400,
)
child_ids = list(coord_adapter.children_snapshot(ws_id)) if coord_adapter is not None else []
coord_client: Any = getattr(session, "_coord_client", None)
closed, failed, skipped = await _fanout_on_children(
child_ids,
coord_client,
lambda cid: coord_client.close_workstream(cid, reason),
log_tag="coordinator_close_all_children",
)
await _emit_coord_audit(
storage,
user_id,
"coordinator.closed_all_children",
ws_id,
{
"src": "coordinator",
"reason": reason,
"closed": closed,
"failed": failed,
"skipped": skipped,
},
request.client.host if request.client else "",
)
return JSONResponse(
{
"status": "ok",
"closed": closed,
"failed": failed,
"skipped": skipped,
}
)
async def coordinator_tasks(request: Request) -> JSONResponse:
"""GET /v1/api/workstreams/{ws_id}/tasks — read task list envelope.
Returns ``{"version": 1, "tasks": [...]}`` — the same shape the
``tasks(action="list")`` tool returns (less the list-tool's
client-side 200-row slice; the UI handles its own pagination).
Corrupt envelopes return an empty list for UI resilience. The
``tasks`` tool remains the authoritative write path and surfaces
corruption errors to the model on mutation attempts.
"""
from turnstone.core.web_helpers import require_storage_or_503
err = _require_admin_coordinator(request)
if err is not None:
return err
coord_mgr, err503 = _require_coord_mgr(request)
if err503 is not None:
return err503
storage, err503s = require_storage_or_503(request)
if err503s is not None:
return err503s
ws_id = request.path_params.get("ws_id", "")
if not _VALID_WS_ID_RE.match(ws_id):
return JSONResponse({"error": "invalid ws_id"}, status_code=400)
user_id = _auth_user_id(request)
_ws, err404 = await _resolve_coordinator_or_404(request, coord_mgr, storage, ws_id, user_id)
if err404 is not None:
return err404
envelope, _corrupt = await asyncio.to_thread(load_task_envelope, storage, ws_id)
return JSONResponse(envelope)
# ---------------------------------------------------------------------------
# Lifespan
# ---------------------------------------------------------------------------
_PROBE_ALLOWED_SCHEMES: frozenset[str] = frozenset({"http", "https"})
_PROBE_TIMEOUT_SECONDS = 2.0
def _probe_candidate_url(services: list[dict[str, Any]] | None) -> tuple[str, str]:
"""Pick the first service-registry entry usable as a scope-probe target.
Returns ``(url, service_id)`` or ``("", "")`` when nothing is usable.
The URL must carry an ``http``/``https`` scheme and a non-link-local
host; the service-registry integrity is the primary defense, but
the scheme + host filter here is defense-in-depth so a poisoned
entry can't redirect the probe to a cloud metadata endpoint.
"""
for svc in services or []:
raw_url = (svc.get("url") or "").rstrip("/")
nid = svc.get("service_id") or ""
if not raw_url or not nid:
continue
try:
parsed = urllib.parse.urlparse(raw_url)
except ValueError:
continue
if parsed.scheme not in _PROBE_ALLOWED_SCHEMES:
continue
host = (parsed.hostname or "").lower()
# 169.254.0.0/16 is the AWS / GCP instance metadata range;
# an http target there would turn a compromised registry into
# an SSRF to IMDS. Loopback is retained for single-box dev.
if host.startswith("169.254."):
continue
return raw_url, nid
return "", ""
async def _verify_collector_service_scope(app: Starlette, client: httpx.AsyncClient) -> None:
"""Probe one upstream node to confirm the collector token's scopes.
The console's :class:`ClusterCollector` authenticates to upstream
nodes' ``/v1/api/events/global`` SSE endpoint, which is hard-gated
on the ``service`` scope. A collector token missing that scope
silently 403s on every SSE connect and the cluster dashboard
renders empty; this probe surfaces the drift at boot.
Probes with ``expected_node_id=_scope-probe_`` so the upstream
returns 409 (identity mismatch) immediately — a 409 means the
scope gate was passed. Any other 4xx = configuration drift:
``app.state.collector_scope_error`` is set non-empty, ``log.error``
fires, and the cluster-snapshot endpoints return 503 with a
remediation hint so the operator sees the problem in the first
failing UI load instead of days later.
Transient failures (network errors, 5xx, no nodes discovered yet)
are logged at info/warning and do NOT refuse to serve — they
can't be distinguished from legitimate "cluster is coming up"
states.
"""
storage = getattr(app.state, "auth_storage", None)
token_mgr = getattr(app.state, "collector_token_mgr", None)
if storage is None or token_mgr is None:
log.info("collector_scope_probe.skipped reason=storage_or_token_missing")
return
# list_services is blocking DB I/O — offload so a slow / Postgres
# backend doesn't stall the event loop during the probe window.
try:
services = await asyncio.to_thread(storage.list_services, "server", max_age_seconds=120)
except Exception:
# Storage-backend drift is itself a class of configuration
# error worth surfacing — the original silent-skip here hid
# exactly the kind of failure the probe was added to catch.
log.warning(
"collector_scope_probe.service_registry_unavailable",
exc_info=True,
)
return
probe_url, probe_node = _probe_candidate_url(services)
if not probe_url:
# Distinguish "registry empty" (normal pre-discovery) from
# "registry populated but every entry malformed" (operator-
# actionable drift) so the two aren't both logged as INFO
# silent-skips.
if services:
log.warning(
"collector_scope_probe.registry_malformed count=%d",
len(services),
)
else:
log.info("collector_scope_probe.skipped reason=no_nodes_registered")
return
headers = {"Authorization": f"Bearer {token_mgr.token}"}
probe_target = f"{probe_url}/v1/api/events/global"
try:
resp = await client.get(
probe_target,
params={"expected_node_id": "_scope-probe_"},
headers=headers,
timeout=_PROBE_TIMEOUT_SECONDS,
)
except (httpx.HTTPError, TimeoutError) as exc:
log.warning(
"collector_scope_probe.transient_error node=%s url=%s%s",
probe_node,
probe_target,
exc,
)
return
status = resp.status_code
if status == 409:
# Identity mismatch is expected — it means the scope gate was
# accepted and the handler got as far as the node_id check.
log.info("collector_scope_probe.ok node=%s url=%s", probe_node, probe_target)
return
body_preview = _bounded_body_preview(resp.text)
if 400 <= status < 500:
# 403 = missing ``service`` scope on the collector token; 401 =
# JWT rejected outright (secret mismatch / audience drift).
# Either way refuse to serve the dashboard until the operator
# fixes it. Upstream body is attacker-controllable so we
# delimit it explicitly in the 503 error text; the preview
# already passed through the control-char scrub in
# _bounded_body_preview.
app.state.collector_scope_error = (
f"collector token rejected by {probe_node} ({probe_target}): "
f"HTTP {status} — upstream_body=<<<{body_preview}>>>. "
"The collector's ServiceTokenManager scopes must include "
"'service' and the JWT audience must match what upstream "
"enforces."
)
log.error(
"collector_scope_probe.drift node=%s url=%s status=%d%s",
probe_node,
probe_target,
status,
body_preview,
)
return
# 5xx / unexpected: likely a transient upstream problem rather
# than our configuration — warn but don't refuse to serve.
log.warning(
"collector_scope_probe.unexpected_status node=%s url=%s status=%d%s",
probe_node,
probe_target,
status,
body_preview,
)
def _coord_idle_cleanup_thread(
mgr: SessionManager,
timeout_sec: float,
stop_event: threading.Event | None = None,
) -> None:
"""Periodically reap idle + DB-orphan coordinator workstreams.
Mirrors the regular server's ``_idle_cleanup_thread`` (turnstone/server.py)
but skips the rate-limiter / global-queue arms — the console doesn't have
those. ``mgr.close_idle`` does the work: closes loaded IDLE rows AND
bulk-closes DB rows of this kind whose ``updated`` is past the cutoff
and which aren't currently loaded. The latter pass catches coords left
behind by prior console process incarnations.
Runs an initial sweep BEFORE the first sleep so cold-start orphans are
reaped immediately rather than waiting one ``check_every`` interval (~30
min on default 2h timeout). This intentionally diverges from the regular
server pattern, which has no initial sweep — the regular server runs
inside a normal request-handling lifecycle, the console-side coord pool
is a small fixed-size cache where orphans dominate the row count after
a cold boot.
``stop_event`` is for tests — when set, the thread exits cleanly after
the next loop check. Production callers pass ``None`` (the daemon is
process-lifetime).
"""
check_every = min(300.0, timeout_sec / 4)
# Initial sweep — runs once before entering the sleep loop.
try:
mgr.close_idle(timeout_sec)
except Exception:
log.debug("console.coord_idle_cleanup_initial_failed", exc_info=True)
while True:
if stop_event is not None and stop_event.is_set():
return
time.sleep(check_every)
if stop_event is not None and stop_event.is_set():
return
try:
mgr.close_idle(timeout_sec)
except Exception:
log.debug("console.coord_idle_cleanup_failed", exc_info=True)
@asynccontextmanager
async def _lifespan(app: Starlette) -> AsyncGenerator[None, None]:
# Create async HTTP clients for proxy routes. Auth headers are NOT baked
# in — _proxy_auth_headers() injects a fresh token per-request so JWTs
# auto-rotate via ServiceTokenManager instead of expiring after 1 hour.
# Size the pool above the fan-out limit to leave headroom for non-fan-out
# proxy traffic (UI proxying, SSE streams, etc.).
#
# Build a ConfigStore so console settings reads get type validation and
# caching instead of raw storage.get_system_setting() calls.
storage = getattr(app.state, "auth_storage", None)
config_store = None
if storage:
try:
from turnstone.core.config_store import ConfigStore
config_store = ConfigStore(storage)
except Exception:
log.warning("Failed to initialise ConfigStore", exc_info=True)
app.state.config_store = config_store
# Initialize rule registry for configurable judge rules
app.state.rule_registry = None
if config_store is not None:
try:
from turnstone.core.rule_registry import RuleRegistry
app.state.rule_registry = RuleRegistry(storage=config_store.storage)
except Exception:
log.warning("Failed to initialise RuleRegistry", exc_info=True)
fan_out = (
config_store.get("cluster.node_fan_out_limit") if config_store else _NODE_FAN_OUT_LIMIT
)
app.state.fan_out_limit = fan_out
# Build mTLS context for proxy clients if TLS is enabled
_tls_mgr = getattr(app.state, "tls_manager", None)
_proxy_ssl = _tls_mgr.get_client_ssl_context() if _tls_mgr and _tls_mgr.ca_initialized else None
_proxy_verify: Any = _proxy_ssl if _proxy_ssl else True
app.state.proxy_client = httpx.AsyncClient(
timeout=30,
limits=httpx.Limits(
max_connections=fan_out + 50,
max_keepalive_connections=min(fan_out // 4, 100),
),
verify=_proxy_verify,
)
app.state.proxy_sse_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
limits=httpx.Limits(
max_connections=1100, max_keepalive_connections=100, keepalive_expiry=30
),
verify=_proxy_verify,
)
# Short-TTL per-node /v1/api/dashboard cache — coalesces the
# cluster_ws_detail fan-in so N concurrent child-inspect calls to
# the same node collapse to one upstream GET per TTL window.
app.state.dashboard_cache = _NodeDashboardCache()
# Dedicated small executor for governance audit writes. Without
# this, audit dispatches share the default thread pool with
# ``coord_client.cancel`` calls from ``stop_cascade`` and any
# other ``asyncio.to_thread`` caller — a burst on one path can
# starve the other. 4 workers is ample headroom for
# admin-driven audit traffic.
audit_exec = ThreadPoolExecutor(max_workers=4, thread_name_prefix="coord-audit")
app.state.audit_executor = audit_exec
_set_audit_executor(audit_exec)
# Dedicated executor for coord SSE queue polling, mirroring the
# interactive-side ``sse_executor`` in ``turnstone/server.py``.
# Each coord ``events`` SSE listener parks a thread on
# ``client_queue.get(timeout=5)`` for the connection lifetime.
# Without this pool, those parks land on Python's default
# ThreadPoolExecutor (~min(32, cpu_count+4)) and compete with
# every other ``asyncio.to_thread`` caller — a few coord tabs
# against a multi-child workstream are enough to stall new
# request handlers.
app.state.coord_sse_executor = ThreadPoolExecutor(
max_workers=200, thread_name_prefix="coord-sse"
)
# Populate the router's services cache if a router is configured
_router: ConsoleRouter | None = getattr(app.state, "router", None)
if _router is not None:
_router.refresh_cache()
if not _router.is_ready():
log.warning("Router cache is empty after refresh — no nodes assigned")
# Prove the collector's service-auth token is accepted by an
# upstream node before the lifespan yields — a scope mismatch
# otherwise only surfaces once an operator notices missing
# dashboard rows. See :func:`_verify_collector_service_scope`.
await _verify_collector_service_scope(app, app.state.proxy_client)
# Start scheduler if configured
scheduler = getattr(app.state, "scheduler", None)
if scheduler is not None:
scheduler.start()
from turnstone.core.oidc import initialize_oidc_state
await initialize_oidc_state(app.state)
# Register console in service registry so other services can discover it
console_url = getattr(app.state, "console_url", "")
_console_heartbeat_task: Any = None
if console_url and storage:
try:
storage.register_service("console", "console", console_url)
# Periodic heartbeat to keep the registration alive
import asyncio
async def _console_heartbeat() -> None:
from turnstone.core.storage._registry import StorageUnavailableError
while True:
await asyncio.sleep(30)
try:
storage.heartbeat_service("console", "console")
except StorageUnavailableError:
pass # already logged by storage layer
except Exception:
log.warning("console.heartbeat_failed", exc_info=True)
_console_heartbeat_task = asyncio.create_task(_console_heartbeat())
except Exception:
log.warning("Failed to register console service", exc_info=True)
# TLS: init CA, issue console certs, start renewal
tls_mgr = getattr(app.state, "tls_manager", None)
if tls_mgr is not None:
import socket
try:
if not tls_mgr.ca_initialized:
await tls_mgr.init_ca()
hostname = socket.gethostname()
fqdn = socket.getfqdn()
cert_hostnames = [hostname, "localhost", "127.0.0.1"]
if fqdn != hostname:
cert_hostnames.append(fqdn)
extra_sans = os.environ.get("TURNSTONE_TLS_SANS", "")
if extra_sans:
cert_hostnames.extend(s.strip() for s in extra_sans.split(",") if s.strip())
await tls_mgr.issue_console_certs(cert_hostnames)
await tls_mgr.start_renewal()
# Re-create proxy clients with mTLS context now that certs are ready
client_ctx = tls_mgr.get_client_ssl_context()
if client_ctx:
await app.state.proxy_client.aclose()
await app.state.proxy_sse_client.aclose()
app.state.proxy_client = httpx.AsyncClient(
timeout=30,
limits=httpx.Limits(
max_connections=app.state.fan_out_limit + 50,
max_keepalive_connections=min(app.state.fan_out_limit // 4, 100),
),
verify=client_ctx,
)
app.state.proxy_sse_client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=5, read=30, write=5, pool=5),
limits=httpx.Limits(
max_connections=1100,
max_keepalive_connections=100,
keepalive_expiry=30,
),
verify=client_ctx,
)
# Upgrade collector httpx client for mTLS node polling
app.state.collector.upgrade_tls(tls_verify=client_ctx)
log.info("tls.proxy_clients.upgraded")
except Exception:
log.warning("TLS initialization failed — continuing without TLS", exc_info=True)
# Coordinator workstream plumbing. Lazy — failure here is non-fatal;
# the coordinator endpoints return 503 with a remediation message
# when coord_mgr is None, so the rest of the console still works.
app.state.coord_mgr = None
app.state.coord_adapter = None
app.state.coord_registry = None
app.state.coord_registry_error = ""
if storage and config_store:
try:
from turnstone.core.model_registry import load_model_registry
try:
coord_registry = load_model_registry(storage=storage)
app.state.coord_registry = coord_registry
except ValueError as exc:
# No model rows configured. Endpoint returns 503 with
# the error text so admin sees remediation in the UI.
app.state.coord_registry_error = str(exc)
coord_registry = None
if coord_registry is not None:
from turnstone.console.collector import ClusterCollector
from turnstone.console.coordinator_adapter import CoordinatorAdapter
from turnstone.console.coordinator_client import (
CoordinatorClient,
CoordinatorTokenManager,
)
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
from turnstone.console.session_factory import (
build_console_session_factory,
)
from turnstone.core.session_manager import SessionManager
jwt_secret: str = getattr(app.state, "jwt_secret", "")
console_bind_url: str = getattr(app.state, "console_url", "") or (
"http://127.0.0.1:8001"
)
def _ui_factory(ws: Workstream) -> ConsoleCoordinatorUI:
return ConsoleCoordinatorUI(ws_id=ws.id, user_id=ws.user_id or "")
def _coord_client_factory(ws_id: str, user_id: str) -> CoordinatorClient:
ttl = int(config_store.get("coordinator.session_jwt_ttl_seconds"))
tm = CoordinatorTokenManager(
user_id=user_id or "system",
scopes=frozenset({"read", "write", "approve"}),
permissions=frozenset({"admin.coordinator"}),
secret=jwt_secret,
coord_ws_id=ws_id,
ttl_seconds=ttl,
)
def _token_factory() -> str:
return tm.token
return CoordinatorClient(
console_base_url=console_bind_url,
storage=storage,
token_factory=_token_factory,
coord_ws_id=ws_id,
user_id=user_id,
)
coord_factory = build_console_session_factory(
registry=coord_registry,
config_store=config_store,
node_id="console",
coord_client_factory=_coord_client_factory,
)
coord_adapter = CoordinatorAdapter(
collector=app.state.collector,
ui_factory=_ui_factory,
session_factory=coord_factory,
)
from turnstone.core.state_writer import StateWriter
coord_state_writer = StateWriter(storage)
coord_state_writer.start()
coord_mgr = SessionManager(
coord_adapter,
storage=storage,
max_active=int(config_store.get("coordinator.max_active")),
node_id=ClusterCollector.CONSOLE_PSEUDO_NODE_ID,
state_writer=coord_state_writer,
# CoordinatorAdapter implements SessionEventEmitter
# in full — every lifecycle transition fans out to
# the cluster collector's pseudo-node so the
# dashboard tree mirrors child state.
event_emitter=coord_adapter,
# Filter out persisted aliases that no longer resolve
# so a coordinator pinned to a since-removed alias
# still rehydrates (on the registry default) instead
# of 500-ing on every reopen.
model_validator=coord_registry.has_alias,
)
# Late-bind the manager onto the adapter so
# ``_rebuild_children_registry`` / ``send`` /
# fan-out dispatch can call ``mgr.get(ws_id)``.
coord_adapter.attach(coord_mgr)
app.state.coord_state_writer = coord_state_writer
# Shared refs so ConsoleCoordinatorUI.on_state_change
# flows state transitions through the unified manager,
# on_rename fans out to the cluster dashboard, and
# _record_judge_metric / on_intent_verdict feed the
# console's /metrics endpoint with coord verdicts.
ConsoleCoordinatorUI._coord_mgr = coord_mgr
ConsoleCoordinatorUI._collector = app.state.collector
ConsoleCoordinatorUI._console_metrics = app.state.console_metrics
app.state.coord_mgr = coord_mgr
app.state.coord_adapter = coord_adapter
# Wire the cluster-event subscription so the coordinator's
# SSE stream fans out filtered child_ws_* events. Safe to
# call even when the collector has no nodes yet — the
# subscription just sits idle until the first node event.
try:
coord_adapter.start_child_event_fanout(app.state.collector)
except Exception:
log.warning("console.coordinator_child_fanout_init_failed", exc_info=True)
# Idle cleanup: closes loaded-but-stale coords AND DB orphans
# left behind by prior console processes. The thread runs an
# initial sweep on entry (no synchronous lifespan call needed —
# see ``_coord_idle_cleanup_thread``) so cold-start cleanup
# doesn't block startup. Reuses the regular-server
# ``server.workstream_idle_timeout`` setting — the same cadence
# makes sense for both kinds and avoids a redundant config knob.
try:
idle_minutes = int(config_store.get("server.workstream_idle_timeout"))
except Exception:
idle_minutes = 0
if idle_minutes > 0:
timeout_sec = float(idle_minutes * 60)
cleanup_thread = threading.Thread(
target=_coord_idle_cleanup_thread,
args=(coord_mgr, timeout_sec),
name="coord-idle-cleanup",
daemon=True,
)
cleanup_thread.start()
app.state.coord_idle_cleanup_thread = cleanup_thread
log.info(
"console.coordinator_mgr_ready max_active=%s",
config_store.get("coordinator.max_active"),
)
except Exception:
log.warning("console.coordinator_init_failed", exc_info=True)
yield
# Shutdown
if _console_heartbeat_task is not None:
_console_heartbeat_task.cancel()
# Deregister console from services table
if console_url and storage:
try:
storage.deregister_service("console", "console")
except Exception:
log.debug("console.deregister_failed", exc_info=True)
tls_mgr = getattr(app.state, "tls_manager", None)
if tls_mgr is not None:
await tls_mgr.stop_renewal()
if scheduler is not None:
scheduler.stop()
coord_adapter_shutdown = getattr(app.state, "coord_adapter", None)
if coord_adapter_shutdown is not None:
try:
coord_adapter_shutdown.shutdown()
except Exception:
log.debug("console.coord_adapter_shutdown_failed", exc_info=True)
coord_state_writer_shutdown = getattr(app.state, "coord_state_writer", None)
if coord_state_writer_shutdown is not None:
try:
# shutdown() joins a daemon thread + runs sync DB writes;
# offload to keep the console lifespan event loop moving.
await asyncio.to_thread(coord_state_writer_shutdown.shutdown)
except Exception:
log.debug("console.coord_state_writer_shutdown_failed", exc_info=True)
# Drop the shared ConsoleCoordinatorUI refs on teardown so tests
# that spin up multiple lifespan instances don't carry stale
# manager/collector references across them.
try:
from turnstone.console.coordinator_ui import ConsoleCoordinatorUI
ConsoleCoordinatorUI._coord_mgr = None
ConsoleCoordinatorUI._collector = None
ConsoleCoordinatorUI._console_metrics = None
except Exception:
log.debug("console.coord_ui_refs_reset_failed", exc_info=True)
await app.state.proxy_sse_client.aclose()
await app.state.proxy_client.aclose()
from turnstone.core.oidc import close_oidc_state
await close_oidc_state(app.state)
app.state.collector.stop()
audit_exec_shutdown = getattr(app.state, "audit_executor", None)
if audit_exec_shutdown is not None:
_set_audit_executor(None)
audit_exec_shutdown.shutdown(wait=True)
# Drain the coord SSE pool AFTER ``coord_adapter.shutdown()`` above:
# adapter shutdown deregisters listeners so no new events handlers
# arrive at this pool; in-flight ``client_queue.get`` futures
# already running are bounded by their 5s timeout and finish
# naturally. ``cancel_futures=True`` discards any queued-but-not-
# started futures so we don't block lifespan teardown on them.
coord_sse_exec_shutdown = getattr(app.state, "coord_sse_executor", None)
if coord_sse_exec_shutdown is not None:
coord_sse_exec_shutdown.shutdown(wait=True, cancel_futures=True)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
# Admin API endpoints — user + token management
# ---------------------------------------------------------------------------
async def admin_list_users(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users — list all users."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
return JSONResponse({"users": storage.list_users()})
async def admin_create_user(request: Request) -> JSONResponse:
"""POST /v1/api/admin/users — create a new user."""
import uuid
from turnstone.core.auth import hash_password, require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
username = body.get("username", "").strip()
display_name = body.get("display_name", "").strip()
password = body.get("password", "")
from turnstone.core.auth import is_valid_username
if not is_valid_username(username):
return JSONResponse(
{"error": "Invalid username (1-64 chars: letters, digits, . _ -)"},
status_code=400,
)
if not display_name:
return JSONResponse({"error": "display_name is required"}, status_code=400)
if not password or len(password) < 8:
return JSONResponse({"error": "Password must be at least 8 characters"}, status_code=400)
# Check username uniqueness
if storage.get_user_by_username(username) is not None:
return JSONResponse({"error": "Username already taken"}, status_code=409)
user_id = uuid.uuid4().hex
pw_hash = hash_password(password)
storage.create_user(user_id, username, display_name, pw_hash)
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"user.create",
"user",
user_id,
{"username": username},
ip,
)
# Read back to get the storage-canonical created timestamp
user = storage.get_user(user_id)
return JSONResponse(
{
"user_id": user["user_id"],
"username": user["username"],
"display_name": user["display_name"],
"created": user["created"],
}
)
async def admin_delete_user(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/users/{user_id} — delete user + cascade tokens."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
# Prevent self-deletion
auth_result = getattr(request.state, "auth_result", None)
if auth_result and auth_result.user_id == user_id:
return JSONResponse({"error": "Cannot delete your own account"}, status_code=400)
# Look up username for the audit trail before deleting
target_user = storage.get_user(user_id)
if storage.delete_user(user_id):
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"user.delete",
"user",
user_id,
{"username": target_user.get("username", "") if target_user else ""},
ip,
)
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "User not found"}, status_code=404)
async def admin_list_tokens(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/tokens — list tokens for a user."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
return JSONResponse({"tokens": storage.list_api_tokens(user_id)})
async def admin_create_token(request: Request) -> JSONResponse:
"""POST /v1/api/admin/users/{user_id}/tokens — create API token."""
import uuid
from turnstone.core.auth import generate_token, hash_token, require_permission, token_prefix
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
# Verify user exists
if storage.get_user(user_id) is None:
return JSONResponse({"error": "User not found"}, status_code=404)
try:
body: dict[str, Any] = await request.json()
except (ValueError, json.JSONDecodeError):
body = {}
name = body.get("name", "")
scopes = body.get("scopes", "read,write,approve")
expires_days = body.get("expires_days")
# Validate scopes
from turnstone.core.auth import VALID_SCOPES
requested = {s.strip() for s in scopes.split(",") if s.strip()}
if not requested or not requested.issubset(VALID_SCOPES):
return JSONResponse(
{"error": "Invalid scopes (allowed: read, write, approve)"}, status_code=400
)
expires: str | None = None
if expires_days is not None:
from datetime import UTC, datetime, timedelta
expires = (datetime.now(UTC) + timedelta(days=int(expires_days))).strftime(
"%Y-%m-%dT%H:%M:%S"
)
raw = generate_token()
tid = uuid.uuid4().hex
storage.create_api_token(
token_id=tid,
token_hash=hash_token(raw),
token_prefix=token_prefix(raw),
user_id=user_id,
name=name,
scopes=scopes,
expires=expires,
)
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"token.create",
"token",
tid,
{"name": name},
ip,
)
return JSONResponse(
{
"token": raw,
"token_id": tid,
"token_prefix": token_prefix(raw),
"scopes": scopes,
}
)
async def admin_revoke_token(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/tokens/{token_id} — revoke an API token."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
token_id = request.path_params["token_id"]
if storage.delete_api_token(token_id):
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"token.revoke",
"token",
token_id,
{},
ip,
)
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "Token not found"}, status_code=404)
# ---------------------------------------------------------------------------
# Admin: Channel user mapping
# ---------------------------------------------------------------------------
async def admin_list_channels(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/channels — list channel links for a user."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
channels = storage.list_channel_users_by_user(user_id)
return JSONResponse({"channels": channels})
async def admin_create_channel(request: Request) -> JSONResponse:
"""POST /v1/api/admin/users/{user_id}/channels — link a channel account."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
from turnstone.core.web_helpers import read_json_or_400
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
channel_type = body.get("channel_type", "").strip().lower()
channel_user_id = body.get("channel_user_id", "").strip()
if not channel_type:
return JSONResponse({"error": "channel_type is required"}, status_code=400)
if not channel_user_id:
return JSONResponse({"error": "channel_user_id is required"}, status_code=400)
if len(channel_type) > 64 or len(channel_user_id) > 256:
return JSONResponse({"error": "Value too long"}, status_code=400)
# Verify user exists
if storage.get_user(user_id) is None:
return JSONResponse({"error": "User not found"}, status_code=404)
# Check for existing mapping
existing = storage.get_channel_user(channel_type, channel_user_id)
if existing is not None:
return JSONResponse(
{"error": f"Channel user already linked to user {existing['user_id']}"},
status_code=409,
)
storage.create_channel_user(channel_type, channel_user_id, user_id)
result = storage.get_channel_user(channel_type, channel_user_id)
if result is None:
return JSONResponse({"error": "Failed to create channel mapping"}, status_code=500)
# Guard against race: another request may have claimed this channel_user_id.
if result.get("user_id") != user_id:
return JSONResponse(
{"error": f"Channel user already linked to user {result['user_id']}"},
status_code=409,
)
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"channel.link",
"channel",
channel_user_id,
{"channel_type": channel_type, "user_id": user_id},
ip,
)
return JSONResponse(result)
async def admin_delete_channel(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/channels/{channel_type}/{channel_user_id} — unlink."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
channel_type = request.path_params["channel_type"]
channel_user_id = request.path_params["channel_user_id"]
if storage.delete_channel_user(channel_type, channel_user_id):
from turnstone.core.audit import record_audit
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"channel.unlink",
"channel",
channel_user_id,
{"channel_type": channel_type},
ip,
)
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "Channel link not found"}, status_code=404)
# ---------------------------------------------------------------------------
# Admin API endpoints — OIDC identities
# ---------------------------------------------------------------------------
async def admin_list_oidc_identities(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/oidc-identities — list OIDC links for a user."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
identities = storage.list_oidc_identities_for_user(user_id)
return JSONResponse({"oidc_identities": identities})
async def admin_delete_oidc_identity(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/oidc-identities?issuer=...&subject=... — unlink OIDC identity."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
issuer = request.query_params.get("issuer", "")
subject = request.query_params.get("subject", "")
if not issuer or not subject:
return JSONResponse({"error": "issuer and subject required"}, status_code=400)
# Look up before delete so audit captures which user was affected
identity = storage.get_oidc_identity(issuer, subject)
if not identity:
return JSONResponse({"error": "Identity not found"}, status_code=404)
storage.delete_oidc_identity(issuer, subject)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"oidc_identity.delete",
"oidc_identity",
f"{issuer}:{subject}",
{"user_id": identity["user_id"]},
ip,
)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin API endpoints — scheduled tasks
# ---------------------------------------------------------------------------
def _normalize_task_dict(task: dict[str, Any]) -> dict[str, Any]:
"""Convert DB row ints/csv to JSON-friendly bools/lists."""
tools_str = task.get("auto_approve_tools", "")
task["auto_approve_tools"] = [s.strip() for s in tools_str.split(",") if s.strip()]
task["auto_approve"] = bool(task.get("auto_approve", 0))
task["enabled"] = bool(task.get("enabled", 1))
# Normalize notify_targets from JSON string to list
import json as _json
raw_nt = task.get("notify_targets", "[]")
try:
task["notify_targets"] = _json.loads(raw_nt) if isinstance(raw_nt, str) else raw_nt
except (_json.JSONDecodeError, TypeError):
task["notify_targets"] = []
return task
def _compute_next_run(schedule_type: str, cron_expr: str, at_time: str) -> str:
"""Compute the next run time for a schedule. Empty string if invalid."""
if schedule_type == "at":
return at_time
if schedule_type == "cron" and cron_expr:
from datetime import UTC, datetime
from croniter import croniter
cron = croniter(cron_expr, datetime.now(UTC))
next_dt = cron.get_next(datetime)
return str(next_dt.strftime("%Y-%m-%dT%H:%M:%S"))
return ""
def _validate_schedule_fields(schedule_type: str, cron_expr: str, at_time: str) -> str | None:
"""Validate schedule type/expression. Returns error string or None."""
if schedule_type not in ("cron", "at"):
return "schedule_type must be 'cron' or 'at'"
if schedule_type == "cron":
if not cron_expr:
return "cron_expr is required when schedule_type is 'cron'"
from croniter import croniter
if not croniter.is_valid(cron_expr):
return f"Invalid cron expression: {cron_expr}"
if schedule_type == "at":
if not at_time:
return "at_time is required when schedule_type is 'at'"
from datetime import UTC, datetime
try:
dt = datetime.fromisoformat(at_time)
if dt.tzinfo is None:
return (
"at_time must include a timezone offset (e.g. 2024-01-01T12:00:00Z or +00:00)"
)
if dt <= datetime.now(UTC):
return "at_time must be in the future"
except ValueError:
return "at_time must be a valid ISO8601 timestamp with timezone"
return None
async def admin_list_schedules(request: Request) -> JSONResponse:
"""GET /v1/api/admin/schedules — list all scheduled tasks."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
tasks = storage.list_scheduled_tasks()
for t in tasks:
_normalize_task_dict(t)
return JSONResponse({"schedules": tasks})
async def admin_create_schedule(request: Request) -> JSONResponse:
"""POST /v1/api/admin/schedules — create a scheduled task."""
import uuid
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:256]
description = str(body.get("description", "")).strip()[:1024]
schedule_type = str(body.get("schedule_type", "")).strip()
cron_expr = str(body.get("cron_expr", "")).strip()[:256]
at_time = str(body.get("at_time", "")).strip()[:64]
target_mode = str(body.get("target_mode", "auto")).strip()[:256]
model = str(body.get("model", "")).strip()[:128]
initial_message = str(body.get("initial_message", "")).strip()[:4096]
auto_approve = bool(body.get("auto_approve", False))
raw_tools = body.get("auto_approve_tools", [])
auto_approve_tools = raw_tools if isinstance(raw_tools, list) else []
skill_name = str(body.get("skill", "")).strip()[:256]
enabled = bool(body.get("enabled", True))
# Validate notify_targets
from turnstone.server import _validate_notify_targets
raw_nt = body.get("notify_targets", "[]")
if isinstance(raw_nt, list):
import json as _json
raw_nt = _json.dumps(raw_nt)
notify_targets, nt_err = _validate_notify_targets(raw_nt)
if nt_err:
return JSONResponse({"error": nt_err}, status_code=400)
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not initial_message:
return JSONResponse({"error": "initial_message is required"}, status_code=400)
if skill_name and not storage.get_prompt_template_by_name(skill_name):
return JSONResponse({"error": f"Skill not found: {skill_name}"}, status_code=400)
validation_err = _validate_schedule_fields(schedule_type, cron_expr, at_time)
if validation_err:
return JSONResponse({"error": validation_err}, status_code=400)
if not target_mode:
return JSONResponse({"error": "target_mode is required"}, status_code=400)
# Cap total schedule count to prevent unbounded growth
max_schedules = 200
existing = storage.list_scheduled_tasks()
if len(existing) >= max_schedules:
return JSONResponse(
{"error": f"Maximum of {max_schedules} schedules reached"}, status_code=409
)
next_run = _compute_next_run(schedule_type, cron_expr, at_time)
task_id = uuid.uuid4().hex
created_by = getattr(getattr(request, "state", None), "user_id", "")
storage.create_scheduled_task(
task_id=task_id,
name=name,
description=description,
schedule_type=schedule_type,
cron_expr=cron_expr,
at_time=at_time,
target_mode=target_mode,
model=model,
initial_message=initial_message,
auto_approve=auto_approve,
auto_approve_tools=auto_approve_tools,
created_by=created_by,
next_run=next_run if enabled else "",
skill=skill_name,
notify_targets=notify_targets,
)
if not enabled:
# Storage backends default enabled=1 on create; persist user's choice
storage.update_scheduled_task(task_id, enabled=False)
task = storage.get_scheduled_task(task_id)
if task:
_normalize_task_dict(task)
return JSONResponse(task)
async def admin_get_schedule(request: Request) -> JSONResponse:
"""GET /v1/api/admin/schedules/{task_id} — get single task."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
task_id = request.path_params["task_id"]
task = storage.get_scheduled_task(task_id)
if task is None:
return JSONResponse({"error": "Schedule not found"}, status_code=404)
_normalize_task_dict(task)
return JSONResponse(task)
async def admin_update_schedule(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/schedules/{task_id} — partial update."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
task_id = request.path_params["task_id"]
existing = storage.get_scheduled_task(task_id)
if existing is None:
return JSONResponse({"error": "Schedule not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "name" in body:
updates["name"] = str(body["name"]).strip()[:256]
if "description" in body:
updates["description"] = str(body["description"]).strip()[:1024]
if "schedule_type" in body:
updates["schedule_type"] = str(body["schedule_type"]).strip()
if "cron_expr" in body:
updates["cron_expr"] = str(body["cron_expr"]).strip()[:256]
if "at_time" in body:
updates["at_time"] = str(body["at_time"]).strip()[:64]
if "target_mode" in body:
updates["target_mode"] = str(body["target_mode"]).strip()[:256]
if "model" in body:
updates["model"] = str(body["model"]).strip()[:128]
if "initial_message" in body:
updates["initial_message"] = str(body["initial_message"]).strip()[:4096]
if "auto_approve" in body:
updates["auto_approve"] = bool(body["auto_approve"])
if "auto_approve_tools" in body:
raw = body["auto_approve_tools"]
updates["auto_approve_tools"] = raw if isinstance(raw, list) else []
if "skill" in body:
skill_val = str(body["skill"]).strip()[:256]
if skill_val and not storage.get_prompt_template_by_name(skill_val):
return JSONResponse({"error": f"Skill not found: {skill_val}"}, status_code=400)
updates["skill"] = skill_val
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
if "notify_targets" in body:
from turnstone.server import _validate_notify_targets
raw_nt = body["notify_targets"]
if isinstance(raw_nt, list):
import json as _json
raw_nt = _json.dumps(raw_nt)
nt_str, nt_err = _validate_notify_targets(raw_nt)
if nt_err:
return JSONResponse({"error": nt_err}, status_code=400)
updates["notify_targets"] = nt_str
# Validate schedule fields if changed
stype = updates.get("schedule_type", existing["schedule_type"])
cexpr = updates.get("cron_expr", existing["cron_expr"])
atime = updates.get("at_time", existing["at_time"])
schedule_fields_changed = (
"schedule_type" in updates or "cron_expr" in updates or "at_time" in updates
)
if schedule_fields_changed:
validation_err = _validate_schedule_fields(stype, cexpr, atime)
if validation_err:
return JSONResponse({"error": validation_err}, status_code=400)
# Recompute next_run if schedule changed or enabled toggled
if schedule_fields_changed or "enabled" in updates:
enabled = updates.get("enabled", bool(existing.get("enabled", 1)))
if enabled:
# Re-validate at_time when re-enabling a one-shot task
if stype == "at" and not schedule_fields_changed:
validation_err = _validate_schedule_fields(stype, cexpr, atime)
if validation_err:
return JSONResponse({"error": validation_err}, status_code=400)
updates["next_run"] = _compute_next_run(stype, cexpr, atime)
else:
updates["next_run"] = ""
storage.update_scheduled_task(task_id, **updates)
task = storage.get_scheduled_task(task_id)
if task:
_normalize_task_dict(task)
return JSONResponse(task)
async def admin_delete_schedule(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/schedules/{task_id} — delete task + runs."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
task_id = request.path_params["task_id"]
if storage.delete_scheduled_task(task_id):
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "Schedule not found"}, status_code=404)
async def admin_list_schedule_runs(request: Request) -> JSONResponse:
"""GET /v1/api/admin/schedules/{task_id}/runs — run history."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.schedules")
if err:
return err
task_id = request.path_params["task_id"]
# Verify task exists
if storage.get_scheduled_task(task_id) is None:
return JSONResponse({"error": "Schedule not found"}, status_code=404)
try:
limit = max(1, min(int(request.query_params.get("limit", "50")), 200))
except (ValueError, TypeError):
limit = 50
runs = storage.list_task_runs(task_id, limit=limit)
return JSONResponse({"runs": runs})
# ---------------------------------------------------------------------------
# Admin API endpoints — watches (aggregated from nodes)
# ---------------------------------------------------------------------------
async def admin_list_watches(request: Request) -> JSONResponse:
"""GET /v1/api/admin/watches — aggregate watches from all nodes."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.watches")
if err:
return err
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _fetch_node(node: dict[str, Any]) -> list[dict[str, Any]]:
server_url = (node.get("server_url") or "").rstrip("/")
if not server_url:
return []
async with sem:
try:
resp = await client.get(f"{server_url}/v1/api/watches", headers=headers)
if resp.status_code == 200:
data = resp.json()
watches: list[dict[str, Any]] = data.get("watches", [])
# Tag each watch with node_id in case the server omits it
for w in watches:
if not w.get("node_id"):
w["node_id"] = node["node_id"]
return watches
except Exception:
log.debug(
"Failed to fetch watches from node %s",
node.get("node_id"),
exc_info=True,
)
return []
tasks = [_fetch_node(n) for n in nodes]
results = await asyncio.gather(*tasks)
all_watches: list[dict[str, Any]] = []
for batch in results:
all_watches.extend(batch)
# Sort: active first, then by created descending (stable sort trick)
all_watches.sort(key=lambda w: w.get("created", ""), reverse=True)
all_watches.sort(key=lambda w: not w.get("active", False))
return JSONResponse({"watches": all_watches})
_VALID_WATCH_ID = re.compile(r"^[a-fA-F0-9]+$")
# Max concurrent outbound requests when fanning out to cluster nodes.
# Must stay below the httpx pool limit (set in _lifespan) to leave
# headroom for non-fan-out proxy traffic (UI proxying, SSE streams).
_NODE_FAN_OUT_LIMIT = 200 # fallback; prefer cluster.node_fan_out_limit from storage
def _get_fan_out_limit(request: Request) -> int:
"""Return the fan-out limit cached at startup on app.state."""
return int(getattr(request.app.state, "fan_out_limit", _NODE_FAN_OUT_LIMIT))
async def admin_cancel_watch(request: Request) -> Response:
"""POST /v1/api/admin/watches/{watch_id}/cancel — proxy cancel to the owning node."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "admin.watches")
if err:
return err
watch_id = request.path_params["watch_id"]
if not watch_id or not _VALID_WATCH_ID.match(watch_id) or len(watch_id) > 128:
return JSONResponse({"error": "Invalid watch_id"}, status_code=400)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
node_id = str(body.get("node_id", "") or request.query_params.get("node_id", "")).strip()
if not node_id:
return JSONResponse({"error": "node_id is required"}, status_code=400)
server_url = _get_server_url(request, node_id)
if not server_url:
return JSONResponse({"error": "Node not found"}, status_code=404)
client: httpx.AsyncClient = request.app.state.proxy_client
headers = {"Content-Type": "application/json"}
headers.update(_proxy_auth_headers(request))
try:
resp = await client.post(
f"{server_url}/v1/api/watches/{watch_id}/cancel",
content=b"{}",
headers=headers,
)
return Response(
content=resp.content,
status_code=resp.status_code,
media_type=resp.headers.get("content-type", "application/json"),
)
except httpx.HTTPError:
return JSONResponse({"error": "Node unreachable"}, status_code=502)
# ---------------------------------------------------------------------------
# Admin API endpoints — governance (roles, orgs, policies, templates, usage, audit)
# ---------------------------------------------------------------------------
def _audit_context(request: Request) -> tuple[str, str]:
"""Extract (user_id, ip_address) from request for audit logging.
Honors ``X-Forwarded-For`` only when the request appears to come
through a trusted proxy (``X-Forwarded-Proto`` is set), matching the
existing ``is_secure_request()`` trust model. Falls back to
``request.client.host`` otherwise.
"""
from turnstone.core.auth import is_secure_request
auth_result = getattr(request.state, "auth_result", None)
user_id = auth_result.user_id if auth_result else ""
ip = ""
# Only trust X-Forwarded-For when behind a proxy that sets X-Forwarded-Proto
if is_secure_request(dict(request.headers), request.url.scheme):
forwarded = request.headers.get("x-forwarded-for", "")
if forwarded:
ip = forwarded.split(",")[0].strip()
if not ip:
ip = request.client.host if request.client else ""
return user_id, ip
_VALID_PERMISSIONS = frozenset(
{
"read",
"write",
"approve",
"admin.users",
"admin.roles",
"admin.orgs",
"admin.policies",
"admin.prompt_policies",
"admin.skills",
"admin.audit",
"admin.usage",
"admin.schedules",
"admin.watches",
"admin.judge",
"admin.memories",
"admin.nodes",
"admin.settings",
"admin.mcp",
"admin.models",
# Coordinator workstream kind — granted to builtin-admin via
# migration 040 so admins can create / manage coordinator
# sessions out of the box. Also grantable to non-admin users
# via a custom role when per-user opt-in is desired.
"admin.coordinator",
# Cluster-wide live workstream inspect — troubleshooting
# surface for GET /v1/api/cluster/ws/{ws_id}/detail. Granted
# to builtin-admin via migration 040.
"admin.cluster.inspect",
"tools.approve",
"workstreams.create",
"workstreams.close",
"conversation.modify",
}
)
async def admin_list_roles(request: Request) -> JSONResponse:
"""GET /v1/api/admin/roles — list all roles."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.roles")
if err:
return err
return JSONResponse({"roles": storage.list_roles()})
async def admin_create_role(request: Request) -> JSONResponse:
"""POST /v1/api/admin/roles — create a new role."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import is_valid_username, require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.roles")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:128]
display_name = str(body.get("display_name", "")).strip()[:256]
permissions = str(body.get("permissions", "")).strip()
if not is_valid_username(name):
return JSONResponse(
{"error": "Invalid name (1-64 chars: letters, digits, . _ -)"},
status_code=400,
)
if not display_name:
display_name = name
# Validate permissions against the allowed set
if permissions:
perm_list = [p.strip() for p in permissions.split(",") if p.strip()]
invalid = [p for p in perm_list if p not in _VALID_PERMISSIONS]
if invalid:
return JSONResponse(
{"error": f"Invalid permissions: {', '.join(invalid)}"},
status_code=400,
)
# Check for duplicate name
if storage.get_role_by_name(name) is not None:
return JSONResponse({"error": f"Role '{name}' already exists"}, status_code=409)
role_id = uuid.uuid4().hex
storage.create_role(
role_id=role_id,
name=name,
display_name=display_name,
permissions=permissions,
builtin=False,
org_id="",
)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "role.create", "role", role_id, {"name": name}, ip)
role = storage.get_role(role_id)
if role is None:
return JSONResponse({"error": "Role creation failed"}, status_code=500)
return JSONResponse(role)
async def admin_update_role(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/roles/{role_id} — update a custom role."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.roles")
if err:
return err
role_id = request.path_params["role_id"]
existing = storage.get_role(role_id)
if existing is None:
return JSONResponse({"error": "Role not found"}, status_code=404)
if existing.get("builtin"):
return JSONResponse({"error": "Cannot modify builtin role"}, status_code=400)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "display_name" in body:
updates["display_name"] = str(body["display_name"]).strip()[:256]
if "permissions" in body:
raw_perms = str(body["permissions"]).strip()
if raw_perms:
perm_list = [p.strip() for p in raw_perms.split(",") if p.strip()]
invalid = [p for p in perm_list if p not in _VALID_PERMISSIONS]
if invalid:
return JSONResponse(
{"error": f"Invalid permissions: {', '.join(invalid)}"},
status_code=400,
)
updates["permissions"] = raw_perms
storage.update_role(role_id, **updates)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "role.update", "role", role_id, updates, ip)
role = storage.get_role(role_id)
return JSONResponse(role)
async def admin_delete_role(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/roles/{role_id} — delete a custom role."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.roles")
if err:
return err
role_id = request.path_params["role_id"]
existing = storage.get_role(role_id)
if existing is None:
return JSONResponse({"error": "Role not found"}, status_code=404)
if existing.get("builtin"):
return JSONResponse({"error": "Cannot delete builtin role"}, status_code=400)
storage.delete_role(role_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"role.delete",
"role",
role_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok"})
async def admin_list_user_roles(request: Request) -> JSONResponse:
"""GET /v1/api/admin/users/{user_id}/roles — list roles assigned to a user."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
return JSONResponse({"roles": storage.list_user_roles(user_id)})
async def admin_assign_role(request: Request) -> JSONResponse:
"""POST /v1/api/admin/users/{user_id}/roles — assign a role to a user."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
role_id = str(body.get("role_id", "")).strip()
if not role_id:
return JSONResponse({"error": "role_id is required"}, status_code=400)
audit_uid, ip = _audit_context(request)
# Validate that user exists
if storage.get_user(user_id) is None:
return JSONResponse({"error": "User not found"}, status_code=404)
# Validate that role exists
target_role = storage.get_role(role_id)
if target_role is None:
return JSONResponse({"error": "Role not found"}, status_code=404)
# Prevent self-assignment
auth_result = getattr(request.state, "auth_result", None)
if auth_result and auth_result.user_id == user_id:
return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403)
# Ensure caller holds all permissions present in the target role
target_perms = set(
p.strip() for p in target_role.get("permissions", "").split(",") if p.strip()
)
if (
auth_result
and auth_result.permissions
and not target_perms.issubset(auth_result.permissions)
):
return JSONResponse(
{"error": "Cannot assign role with permissions you do not hold"},
status_code=403,
)
storage.assign_role(user_id, role_id, assigned_by=audit_uid)
record_audit(
storage,
audit_uid,
"role.assign",
"user",
user_id,
{"role_id": role_id},
ip,
)
return JSONResponse({"status": "ok"})
async def admin_unassign_role(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/users/{user_id}/roles/{role_id} — unassign a role."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.users")
if err:
return err
user_id = request.path_params["user_id"]
role_id = request.path_params["role_id"]
audit_uid, ip = _audit_context(request)
# Prevent self-modification
if audit_uid and audit_uid == user_id:
return JSONResponse({"error": "Cannot modify own role assignments"}, status_code=403)
if storage.unassign_role(user_id, role_id):
record_audit(
storage,
audit_uid,
"role.unassign",
"user",
user_id,
{"role_id": role_id},
ip,
)
return JSONResponse({"status": "ok"})
return JSONResponse({"error": "Role assignment not found"}, status_code=404)
async def admin_list_orgs(request: Request) -> JSONResponse:
"""GET /v1/api/admin/orgs — list all organizations."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.orgs")
if err:
return err
return JSONResponse({"orgs": storage.list_orgs()})
async def admin_get_org(request: Request) -> JSONResponse:
"""GET /v1/api/admin/orgs/{org_id} — get a single organization."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.orgs")
if err:
return err
org_id = request.path_params["org_id"]
org = storage.get_org(org_id)
if org is None:
return JSONResponse({"error": "Organization not found"}, status_code=404)
return JSONResponse(org)
async def admin_update_org(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/orgs/{org_id} — update an organization."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.orgs")
if err:
return err
org_id = request.path_params["org_id"]
existing = storage.get_org(org_id)
if existing is None:
return JSONResponse({"error": "Organization not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "display_name" in body:
updates["display_name"] = str(body["display_name"]).strip()[:256]
if "settings" in body:
settings_str = str(body["settings"]).strip()
try:
json.loads(settings_str)
except (json.JSONDecodeError, TypeError):
return JSONResponse({"error": "settings must be valid JSON"}, status_code=400)
updates["settings"] = settings_str
storage.update_org(org_id, **updates)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "org.update", "org", org_id, updates, ip)
org = storage.get_org(org_id)
return JSONResponse(org)
async def admin_list_policies(request: Request) -> JSONResponse:
"""GET /v1/api/admin/policies — list all tool policies."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
return JSONResponse({"policies": storage.list_tool_policies()})
async def admin_create_policy(request: Request) -> JSONResponse:
"""POST /v1/api/admin/policies — create a tool policy."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:256]
tool_pattern = str(body.get("tool_pattern", "")).strip()[:256]
action = str(body.get("action", "")).strip().lower()
priority = int(body.get("priority", 0)) if isinstance(body.get("priority"), (int, float)) else 0
org_id = str(body.get("org_id", "")).strip()[:64]
enabled = bool(body.get("enabled", True))
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not tool_pattern:
return JSONResponse({"error": "tool_pattern is required"}, status_code=400)
if action not in ("allow", "deny", "ask"):
return JSONResponse(
{"error": "action must be one of: allow, deny, ask"},
status_code=400,
)
audit_uid, ip = _audit_context(request)
policy_id = uuid.uuid4().hex
storage.create_tool_policy(
policy_id=policy_id,
name=name,
tool_pattern=tool_pattern,
action=action,
priority=priority,
org_id=org_id,
enabled=enabled,
created_by=audit_uid,
)
# Drop the cached policy snapshot so the next ``approve_tools`` read
# picks up this rule without waiting for the TTL window to expire.
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache(org_id)
record_audit(
storage,
audit_uid,
"policy.create",
"policy",
policy_id,
{"name": name},
ip,
)
policy = storage.get_tool_policy(policy_id)
return JSONResponse(policy)
async def admin_update_policy(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/policies/{policy_id} — update a tool policy."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_tool_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Policy not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "name" in body:
updates["name"] = str(body["name"]).strip()[:256]
if "tool_pattern" in body:
updates["tool_pattern"] = str(body["tool_pattern"]).strip()[:256]
if "action" in body:
act = str(body["action"]).strip().lower()
if act not in ("allow", "deny", "ask"):
return JSONResponse(
{"error": "action must be one of: allow, deny, ask"},
status_code=400,
)
updates["action"] = act
if "priority" in body:
updates["priority"] = (
int(body["priority"]) if isinstance(body["priority"], (int, float)) else 0
)
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
storage.update_tool_policy(policy_id, **updates)
# Drop the cached policy snapshot so the next ``approve_tools`` read
# picks up this update without waiting for the TTL window. Use the
# existing row's org_id so the right slot is invalidated.
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache(existing.get("org_id", "") if isinstance(existing, dict) else None)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "policy.update", "policy", policy_id, updates, ip)
policy = storage.get_tool_policy(policy_id)
return JSONResponse(policy)
async def admin_delete_policy(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/policies/{policy_id} — delete a tool policy."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_tool_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Policy not found"}, status_code=404)
storage.delete_tool_policy(policy_id)
# Drop the cached policy snapshot so the next ``approve_tools`` read
# stops applying the deleted rule. Use the existing row's org_id.
from turnstone.core.policy import invalidate_policy_cache
invalidate_policy_cache(existing.get("org_id", "") if isinstance(existing, dict) else None)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"policy.delete",
"policy",
policy_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin: Skills (thin layer over prompt templates with extended fields)
# ---------------------------------------------------------------------------
_VALID_ACTIVATIONS = {"named", "default", "search"}
# Fields that may be updated on installed (readonly) skills.
# These are local runtime configuration — not part of the SKILL.md spec —
# so they don't compromise the fidelity of an externally-sourced skill.
_SKILL_RUNTIME_CONFIG_FIELDS = frozenset(
{
"model",
"temperature",
"reasoning_effort",
"max_tokens",
"token_budget",
"agent_max_turns",
"auto_approve",
"allowed_tools",
"enabled",
"notify_on_complete",
"priority",
}
)
def _parse_skill_session_config(body: dict[str, Any]) -> tuple[dict[str, Any], JSONResponse | None]:
"""Parse and validate session config fields from a skill request body.
Returns (fields_dict, error_response). error_response is None on success.
Only includes fields that are present in the body (for partial updates).
"""
import json as _json
fields: dict[str, Any] = {}
if "model" in body:
fields["model"] = str(body["model"] or "").strip()
if "temperature" in body:
temp = body["temperature"]
if temp is not None and temp != "":
try:
temp = float(temp)
if not (0.0 <= temp <= 2.0):
return {}, JSONResponse(
{"error": "temperature must be between 0 and 2"}, status_code=400
)
fields["temperature"] = temp
except (ValueError, TypeError):
fields["temperature"] = None
else:
fields["temperature"] = None
if "token_budget" in body:
try:
tb = int(body.get("token_budget", 0) or 0)
except (ValueError, TypeError):
return {}, JSONResponse({"error": "token_budget must be an integer"}, status_code=400)
if tb < 0:
return {}, JSONResponse({"error": "token_budget must be non-negative"}, status_code=400)
fields["token_budget"] = tb
if "max_tokens" in body:
mt = body["max_tokens"]
if mt is not None and mt != "":
try:
mt = int(mt)
except (ValueError, TypeError):
return {}, JSONResponse({"error": "max_tokens must be an integer"}, status_code=400)
if mt < 1:
return {}, JSONResponse({"error": "max_tokens must be positive"}, status_code=400)
fields["max_tokens"] = mt
else:
fields["max_tokens"] = None
if "agent_max_turns" in body:
amt = body["agent_max_turns"]
if amt is not None and amt != "":
try:
amt = int(amt)
except (ValueError, TypeError):
return {}, JSONResponse(
{"error": "agent_max_turns must be an integer"}, status_code=400
)
if amt < 1:
return {}, JSONResponse(
{"error": "agent_max_turns must be positive"}, status_code=400
)
fields["agent_max_turns"] = amt
else:
fields["agent_max_turns"] = None
if "reasoning_effort" in body:
fields["reasoning_effort"] = str(body["reasoning_effort"] or "").strip()
if "auto_approve" in body:
fields["auto_approve"] = bool(body.get("auto_approve", False))
if "enabled" in body:
fields["enabled"] = bool(body.get("enabled", True))
if "activation" in body:
activation = str(body["activation"] or "named").strip()
if activation not in _VALID_ACTIVATIONS:
return {}, JSONResponse(
{"error": f"activation must be one of: {', '.join(sorted(_VALID_ACTIVATIONS))}"},
status_code=400,
)
fields["activation"] = activation
if "notify_on_complete" in body:
nc = str(body.get("notify_on_complete", "{}")).strip()
if nc and nc != "{}":
try:
_json.loads(nc)
except (_json.JSONDecodeError, TypeError):
return {}, JSONResponse(
{"error": "notify_on_complete must be valid JSON"}, status_code=400
)
fields["notify_on_complete"] = nc
if "allowed_tools" in body:
at_raw = body.get("allowed_tools", "[]")
if isinstance(at_raw, list):
fields["allowed_tools"] = _json.dumps(at_raw)
else:
at_str = str(at_raw).strip()
if at_str and not at_str.startswith("["):
at_str = _json.dumps([t.strip() for t in at_str.split(",") if t.strip()])
try:
_json.loads(at_str or "[]")
except (ValueError, TypeError):
at_str = "[]"
fields["allowed_tools"] = at_str or "[]"
return fields, None
def _skill_to_response(r: dict[str, Any], resource_count: int = 0) -> dict[str, Any]:
"""Convert a storage skill dict to a JSON-safe response dict."""
import json as _json
tags: list[str] = []
with contextlib.suppress(ValueError, TypeError):
tags = _json.loads(r.get("tags", "[]"))
return {
"template_id": r.get("template_id", ""),
"name": r.get("name", ""),
"category": r.get("category", ""),
"description": r.get("description", ""),
"content": r.get("content", ""),
"tags": tags,
"is_default": r.get("is_default", False),
"activation": r.get("activation", "named"),
"origin": r.get("origin", "manual"),
"mcp_server": r.get("mcp_server", ""),
"readonly": r.get("readonly", False),
"author": r.get("author", ""),
"version": r.get("version", "1.0.0"),
"variables": r.get("variables", "[]"),
"token_estimate": r.get("token_estimate", 0),
"source_url": r.get("source_url", ""),
"org_id": r.get("org_id", ""),
"created_by": r.get("created_by", ""),
# Session config fields
"model": r.get("model", ""),
"auto_approve": r.get("auto_approve", False),
"temperature": r.get("temperature"),
"reasoning_effort": r.get("reasoning_effort", ""),
"max_tokens": r.get("max_tokens"),
"token_budget": r.get("token_budget", 0),
"agent_max_turns": r.get("agent_max_turns"),
"notify_on_complete": r.get("notify_on_complete", "{}"),
"enabled": r.get("enabled", True),
"priority": r.get("priority", 0),
"allowed_tools": r.get("allowed_tools", "[]"),
"license": r.get("license", ""),
"compatibility": r.get("compatibility", ""),
"kind": r.get("kind", "any"),
"risk_level": r.get("risk_level", ""),
"scan_report": r.get("scan_report", "{}"),
"scan_version": r.get("scan_version", ""),
"resource_count": resource_count,
"created": r.get("created", ""),
"updated": r.get("updated", ""),
}
async def admin_list_skills(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills — list all skills."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
params = dict(request.query_params)
limit = _parse_int(params, "limit", 0, minimum=0, maximum=10000)
offset = _parse_int(params, "offset", 0, minimum=0, maximum=100000)
rows = storage.list_prompt_templates(limit=limit, offset=offset)
total = storage.count_prompt_templates()
skill_ids = [r["template_id"] for r in rows]
rc_map = storage.count_skill_resources_bulk(skill_ids) if skill_ids else {}
skills = [_skill_to_response(r, resource_count=rc_map.get(r["template_id"], 0)) for r in rows]
return JSONResponse({"skills": skills, "total": total})
async def admin_get_skill(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id} — get a single skill."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
rc_map = storage.count_skill_resources_bulk([skill_id])
return JSONResponse(_skill_to_response(skill, resource_count=rc_map.get(skill_id, 0)))
async def admin_create_skill(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills — create a skill."""
import json as _json
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
# Treat an explicit JSON null the same as a missing key — ``.get(k, "")``
# only falls back when the key is absent, so ``str(None)`` would otherwise
# yield the literal string "None" and slip past the non-empty guards.
name = str(body.get("name") or "").strip()[:256]
content = str(body.get("content") or "").strip()[:32768]
category = str(body.get("category") or "general").strip()[:64]
description = str(body.get("description") or "").strip()[:1024]
try:
kind = SkillKind(str(body.get("kind") or "any").strip().lower()).value
except ValueError:
return JSONResponse(
{"error": "kind must be one of: " + ", ".join(sorted(k.value for k in SkillKind))},
status_code=400,
)
variables = str(body.get("variables", "[]")).strip()
try:
_json.loads(variables)
except (_json.JSONDecodeError, TypeError):
return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400)
is_default = bool(body.get("is_default", False))
org_id = str(body.get("org_id", "")).strip()[:64]
author = str(body.get("author", "")).strip()[:256]
version = str(body.get("version", "1.0.0")).strip()[:64]
license_val = str(body.get("license", "")).strip()[:128]
compatibility = str(body.get("compatibility", "")).strip()[:500]
raw_tags = body.get("tags", [])
if isinstance(raw_tags, list):
tags_str = _json.dumps(raw_tags)
else:
tags_str = str(raw_tags).strip()
try:
_json.loads(tags_str)
except (ValueError, TypeError):
tags_str = "[]"
token_estimate = len(content) // 4 if content else 0
# Session config fields via shared helper
session_fields, session_err = _parse_skill_session_config(body)
if session_err:
return session_err
# Resolve activation / is_default sync
activation = session_fields.pop("activation", "")
if not activation:
activation = "default" if is_default else "named"
if activation == "default":
is_default = True
try:
priority = max(-1000, min(1000, int(body.get("priority", 0) or 0)))
except (ValueError, TypeError):
priority = 0
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not content:
return JSONResponse({"error": "content is required"}, status_code=400)
if not description:
return JSONResponse({"error": "description is required"}, status_code=400)
if storage.get_prompt_template_by_name(name):
return JSONResponse({"error": "Skill name already exists"}, status_code=409)
audit_uid, ip = _audit_context(request)
skill_id = uuid.uuid4().hex
storage.create_prompt_template(
template_id=skill_id,
name=name,
category=category,
content=content,
variables=variables,
is_default=is_default,
org_id=org_id,
created_by=audit_uid,
description=description,
tags=tags_str,
version=version,
author=author,
skill_license=license_val,
compatibility=compatibility,
activation=activation,
token_estimate=token_estimate,
priority=priority,
kind=kind,
**session_fields,
)
record_audit(
storage,
audit_uid,
"skill.create",
"skill",
skill_id,
{"name": name},
ip,
)
skill = storage.get_prompt_template(skill_id)
return JSONResponse(_skill_to_response(skill))
async def admin_update_skill(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/skills/{skill_id} — update a skill."""
import json as _json
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
existing = storage.get_prompt_template(skill_id)
if existing is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
is_readonly = bool(existing.get("readonly"))
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
# Session config fields via shared helper
session_fields, session_err = _parse_skill_session_config(body)
if session_err:
return session_err
updates: dict[str, Any] = dict(session_fields)
if "name" in body:
updates["name"] = str(body["name"]).strip()[:256]
existing_by_name = storage.get_prompt_template_by_name(updates["name"])
if existing_by_name and existing_by_name["template_id"] != skill_id:
return JSONResponse({"error": "Skill name already exists"}, status_code=409)
if "content" in body:
content = str(body["content"]).strip()[:32768]
updates["content"] = content
updates["token_estimate"] = len(content) // 4 if content else 0
if "category" in body:
updates["category"] = str(body["category"]).strip()[:64]
if "description" in body:
# Match create: an operator can rewrite the description but
# cannot blank it out — and ``null`` is treated the same as
# blank so it can't coerce to the literal string "None".
raw_description = body["description"]
new_description = str(raw_description or "").strip()[:1024]
if not new_description:
return JSONResponse({"error": "description must not be empty"}, status_code=400)
updates["description"] = new_description
if "kind" in body:
try:
updates["kind"] = SkillKind(str(body["kind"] or "").strip().lower()).value
except ValueError:
return JSONResponse(
{"error": "kind must be one of: " + ", ".join(sorted(k.value for k in SkillKind))},
status_code=400,
)
if "variables" in body:
var_str = str(body["variables"]).strip()
try:
_json.loads(var_str)
except (_json.JSONDecodeError, TypeError):
return JSONResponse({"error": "variables must be a valid JSON array"}, status_code=400)
updates["variables"] = var_str
if "is_default" in body:
updates["is_default"] = bool(body["is_default"])
if "activation" in updates and updates["activation"] == "default":
updates["is_default"] = True
if "author" in body:
updates["author"] = str(body["author"]).strip()[:256]
if "version" in body:
updates["version"] = str(body["version"]).strip()[:64]
if "license" in body:
updates["license"] = str(body["license"]).strip()[:128]
if "compatibility" in body:
updates["compatibility"] = str(body["compatibility"]).strip()[:500]
if "tags" in body:
raw_tags = body["tags"]
if isinstance(raw_tags, list):
updates["tags"] = _json.dumps(raw_tags)
else:
tag_str = str(raw_tags).strip()
try:
_json.loads(tag_str)
except (ValueError, TypeError):
tag_str = "[]"
updates["tags"] = tag_str
if "priority" in body:
try:
updates["priority"] = max(-1000, min(1000, int(body["priority"] or 0)))
except (ValueError, TypeError):
updates["priority"] = 0
# Installed (readonly) skills: restrict updates to runtime config only.
# Spec/content fields are locked to preserve external-source fidelity.
if is_readonly:
updates = {k: v for k, v in updates.items() if k in _SKILL_RUNTIME_CONFIG_FIELDS}
if not updates:
return JSONResponse({"error": "No runtime config fields to update"}, status_code=400)
# Snapshot current state for version history before applying update
existing_versions = storage.list_skill_versions(skill_id)
version_int = len(existing_versions) + 1
audit_uid_pre, _ = _audit_context(request)
storage.create_skill_version(
skill_id=skill_id,
version=version_int,
snapshot=_json.dumps(existing, default=str),
changed_by=audit_uid_pre,
)
storage.update_prompt_template(skill_id, **updates)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"skill.update.config" if is_readonly else "skill.update",
"skill",
skill_id,
updates,
ip,
)
updated_skill = storage.get_prompt_template(skill_id)
rc_map = storage.count_skill_resources_bulk([skill_id])
return JSONResponse(_skill_to_response(updated_skill, resource_count=rc_map.get(skill_id, 0)))
async def admin_delete_skill(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/skills/{skill_id} — delete a skill."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
existing = storage.get_prompt_template(skill_id)
if existing is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
storage.delete_skill_resources(skill_id)
storage.delete_skill_versions(skill_id)
storage.delete_prompt_template(skill_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"skill.delete",
"skill",
skill_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok"})
async def admin_list_skill_versions(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id}/versions — version history."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
versions = storage.list_skill_versions(skill_id)
return JSONResponse({"versions": versions})
async def list_skills_summary(request: Request) -> JSONResponse:
"""GET /v1/api/skills — list available skills (summary)."""
import json as _json
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
rows = storage.list_prompt_templates()
skills = []
for r in rows:
if not r.get("enabled", True):
continue
tags: list[str] = []
with contextlib.suppress(ValueError, TypeError):
tags = _json.loads(r.get("tags", "[]"))
skills.append(
{
"name": r["name"],
"category": r.get("category", ""),
"description": r.get("description", ""),
"tags": tags,
"is_default": r.get("is_default", False),
"activation": r.get("activation", "named"),
"origin": r.get("origin", "manual"),
"author": r.get("author", ""),
"version": r.get("version", "1.0.0"),
}
)
return JSONResponse({"skills": skills})
async def admin_usage(request: Request) -> JSONResponse:
"""GET /v1/api/admin/usage — query usage data."""
from datetime import UTC, datetime, timedelta
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.usage")
if err:
return err
params = dict(request.query_params)
since = params.get("since", "")
until = params.get("until", "")
user_id = params.get("user_id", "")
model = params.get("model", "")
group_by = params.get("group_by", "day")
if group_by not in ("day", "hour", "model", "user"):
return JSONResponse(
{"error": "group_by must be one of: day, hour, model, user"},
status_code=400,
)
if not since:
since = (datetime.now(UTC) - timedelta(days=7)).strftime("%Y-%m-%dT%H:%M:%S")
summary = storage.query_usage(since=since, until=until, user_id=user_id, model=model)
breakdown = storage.query_usage(
since=since,
until=until,
user_id=user_id,
model=model,
group_by=group_by,
)
# Resolve user_id hex → username for display when grouped by user
if group_by == "user" and breakdown:
uid_to_name: dict[str, str] = {}
for u in storage.list_users():
uid_to_name[u["user_id"]] = u.get("username") or u["user_id"]
for row in breakdown:
raw_key = row.get("key", "")
if raw_key and raw_key in uid_to_name:
row["key"] = uid_to_name[raw_key]
return JSONResponse({"summary": summary, "breakdown": breakdown})
async def admin_audit(request: Request) -> JSONResponse:
"""GET /v1/api/admin/audit — query audit events."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.audit")
if err:
return err
params = dict(request.query_params)
action = params.get("action", "")
user_id = params.get("user_id", "")
since = params.get("since", "")
until = params.get("until", "")
try:
limit = max(1, min(int(params.get("limit", "50")), 200))
except (ValueError, TypeError):
limit = 50
try:
offset = max(int(params.get("offset", "0")), 0)
except (ValueError, TypeError):
offset = 0
events = storage.list_audit_events(
action=action,
user_id=user_id,
since=since,
until=until,
limit=limit,
offset=offset,
)
total = storage.count_audit_events(
action=action,
user_id=user_id,
since=since,
until=until,
)
# Resolve user_id hex → username for display
if events:
uid_to_name: dict[str, str] = {}
for u in storage.list_users():
uid_to_name[u["user_id"]] = u.get("username") or u["user_id"]
for ev in events:
raw_uid = ev.get("user_id", "")
if raw_uid and raw_uid in uid_to_name:
ev["username"] = uid_to_name[raw_uid]
return JSONResponse({"events": events, "total": total})
async def admin_list_verdicts(request: Request) -> JSONResponse:
"""GET /v1/api/admin/verdicts — list intent verdicts."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
params = dict(request.query_params)
ws_id = params.get("ws_id", "")
since = params.get("since", "")
until = params.get("until", "")
risk_level = params.get("risk_level", "")
try:
limit = max(1, min(int(params.get("limit", "100")), 500))
except (ValueError, TypeError):
limit = 100
try:
offset = max(int(params.get("offset", "0")), 0)
except (ValueError, TypeError):
offset = 0
verdicts = storage.list_intent_verdicts(
ws_id=ws_id,
since=since,
until=until,
risk_level=risk_level,
limit=limit,
offset=offset,
)
total = storage.count_intent_verdicts(
ws_id=ws_id,
since=since,
until=until,
risk_level=risk_level,
)
return JSONResponse({"verdicts": verdicts, "total": total})
async def admin_list_output_assessments(request: Request) -> JSONResponse:
"""GET /v1/api/admin/output-assessments — list output guard assessments."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
params = dict(request.query_params)
ws_id = params.get("ws_id", "")
risk_level = params.get("risk_level", "")
since = params.get("since", "")
until = params.get("until", "")
try:
limit = max(1, min(int(params.get("limit", "100")), 500))
except (ValueError, TypeError):
limit = 100
try:
offset = max(int(params.get("offset", "0")), 0)
except (ValueError, TypeError):
offset = 0
assessments = storage.list_output_assessments(
ws_id=ws_id,
risk_level=risk_level,
since=since,
until=until,
limit=limit,
offset=offset,
)
total = storage.count_output_assessments(
ws_id=ws_id, risk_level=risk_level, since=since, until=until
)
return JSONResponse({"assessments": assessments, "total": total})
async def admin_rescan_skill(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/{skill_id}/rescan — re-scan skill security."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if not skill:
return JSONResponse({"error": "Skill not found"}, status_code=404)
from turnstone.core.storage._utils import scan_skill_content
content = skill.get("content", "")
allowed_tools = skill.get("allowed_tools", "[]")
risk_level, scan_report, scan_version = scan_skill_content(content, allowed_tools)
storage.update_prompt_template(
skill_id,
risk_level=risk_level,
scan_report=scan_report,
scan_version=scan_version,
)
return JSONResponse(
{
"risk_level": risk_level,
"scan_report": scan_report,
"scan_version": scan_version,
}
)
# ---------------------------------------------------------------------------
# Admin: Skill Resources
# ---------------------------------------------------------------------------
_ALLOWED_RESOURCE_DIRS = ("scripts/", "references/", "assets/")
_MAX_RESOURCE_SIZE = 100 * 1024 # 100KB
_MAX_RESOURCES_PER_SKILL = 10
async def admin_list_skill_resources(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id}/resources — list resources."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
rows = storage.list_skill_resources(skill_id)
resources = [
{
"resource_id": r.get("resource_id", ""),
"skill_id": r.get("skill_id", ""),
"path": r.get("path", ""),
"content_type": r.get("content_type", "text/plain"),
"size": len(r.get("content", "")),
"created": r.get("created", ""),
}
for r in rows
]
return JSONResponse({"resources": resources})
async def admin_get_skill_resource(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/{skill_id}/resources/{path:path} — get one resource."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
path = request.path_params["path"]
resource = storage.get_skill_resource(skill_id, path)
if resource is None:
return JSONResponse({"error": "Resource not found"}, status_code=404)
return JSONResponse(
{
"resource_id": resource.get("resource_id", ""),
"skill_id": resource.get("skill_id", ""),
"path": resource.get("path", ""),
"content": resource.get("content", ""),
"content_type": resource.get("content_type", "text/plain"),
"size": len(resource.get("content", "")),
"created": resource.get("created", ""),
}
)
async def admin_create_skill_resource(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/{skill_id}/resources — upload resource."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if skill.get("readonly"):
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
path = str(body.get("path", "")).strip()
content = str(body.get("content", ""))
content_type = str(body.get("content_type", "text/plain")).strip()[:64]
if not path:
return JSONResponse({"error": "path is required"}, status_code=400)
# Normalize and reject path traversal
import posixpath
path = posixpath.normpath(path)
if ".." in path.split("/") or "\x00" in path:
return JSONResponse({"error": "Invalid path"}, status_code=400)
if not any(path.startswith(d) for d in _ALLOWED_RESOURCE_DIRS):
return JSONResponse(
{"error": "path must start with scripts/, references/, or assets/"},
status_code=400,
)
if len(content) > _MAX_RESOURCE_SIZE:
return JSONResponse(
{"error": f"Resource exceeds {_MAX_RESOURCE_SIZE // 1024}KB limit"},
status_code=400,
)
existing = storage.list_skill_resources(skill_id)
if len(existing) >= _MAX_RESOURCES_PER_SKILL:
return JSONResponse(
{"error": f"Maximum {_MAX_RESOURCES_PER_SKILL} resources per skill"},
status_code=400,
)
if storage.get_skill_resource(skill_id, path) is not None:
return JSONResponse({"error": "Resource path already exists"}, status_code=409)
resource_id = uuid.uuid4().hex
storage.create_skill_resource(
resource_id=resource_id,
skill_id=skill_id,
path=path,
content=content,
content_type=content_type,
)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "skill_resource.create", "skill", skill_id, {"path": path}, ip)
created = storage.get_skill_resource(skill_id, path)
return JSONResponse(
{
"resource_id": resource_id,
"skill_id": skill_id,
"path": path,
"content_type": content_type,
"size": len(content),
"created": (created or {}).get("created", ""),
},
status_code=201,
)
async def admin_delete_skill_resource(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/skills/{skill_id}/resources/{path:path} — delete resource."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
skill_id = request.path_params["skill_id"]
skill = storage.get_prompt_template(skill_id)
if skill is None:
return JSONResponse({"error": "Skill not found"}, status_code=404)
if skill.get("readonly"):
return JSONResponse({"error": "Installed skills are read-only"}, status_code=403)
path = request.path_params["path"]
deleted = storage.delete_skill_resource_by_path(skill_id, path)
if not deleted:
return JSONResponse({"error": "Resource not found"}, status_code=404)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "skill_resource.delete", "skill", skill_id, {"path": path}, ip)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin: Skill Discovery
# ---------------------------------------------------------------------------
def _get_discovery_url(request: Request) -> str:
"""Get skills discovery URL via ConfigStore, config.toml, or default."""
from turnstone.core.config import load_config
from turnstone.core.skill_sources import DEFAULT_DISCOVERY_URL
# ConfigStore: validated + cached
config_store = getattr(request.app.state, "config_store", None)
if config_store:
val = config_store.get("skills.discovery_url")
if val:
return str(val)
# Fall back to config.toml [skills] section
skills_cfg = load_config("skills")
url = skills_cfg.get("discovery_url", "")
if url:
return str(url)
return DEFAULT_DISCOVERY_URL
# Authoritative cap on ``raw`` itself — code points, matching the Pydantic
# ``max_length`` on ``ParseSkillRequest.raw`` and the ``content[:32768]``
# truncation in ``admin_create_skill``.
_PARSE_SKILL_MAX_CHARS = 32_768
# Generous coarse Content-Length pre-check. The HTTP body carries the JSON
# wrapper (``{"raw":"..."}`` + escaping) plus any UTF-8 multi-byte expansion,
# so a legitimate max-length ``raw`` produces a body well above the char cap
# — rejecting at exactly 32 KiB would 413 valid near-max requests. This
# threshold only needs to refuse obviously oversized payloads before
# ``request.json()`` buffers them; the per-string ``len(raw)`` check below
# is the authoritative limit.
_PARSE_SKILL_MAX_BODY_BYTES = _PARSE_SKILL_MAX_CHARS * 4
async def admin_parse_skill(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/parse — parse SKILL.md frontmatter + body.
Used by the admin UI's create/edit skill modals: when a user pastes a
full SKILL.md document, the frontend posts the raw text here and uses
the returned fields to populate the form, then drops the body into
the content textarea. Reusing the Python parser keeps admin imports
and external skill installs in lockstep on edge cases (Hermes/nested
metadata layouts, malformed-YAML recovery, length caps).
Hardening: size cap + threadpool offload guard against deeply-nested
YAML; alias amplification is not a concern with ``safe_load``.
"""
from turnstone.core.auth import require_permission
from turnstone.core.skill_parser import parse_skill_md
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "admin.skills")
if err:
return err
# Reject oversized bodies before buffering — protects worker memory from
# an admin token spraying multi-GB JSON. Threshold is generous enough
# to admit a max-length ``raw`` plus its JSON wrapper and escaping; the
# per-string check below enforces the exact 32 KiB rule.
cl = request.headers.get("content-length")
if cl and cl.isdigit() and int(cl) > _PARSE_SKILL_MAX_BODY_BYTES:
return JSONResponse({"error": "request body too large"}, status_code=413)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
raw = body.get("raw")
if not isinstance(raw, str) or not raw.strip():
return JSONResponse({"error": "raw is required"}, status_code=400)
if len(raw) > _PARSE_SKILL_MAX_CHARS:
return JSONResponse({"error": "raw exceeds 32 KiB"}, status_code=413)
try:
parsed = await asyncio.to_thread(parse_skill_md, raw)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
return JSONResponse(
{
"name": parsed.name,
"description": parsed.description,
"content": parsed.content,
"tags": list(parsed.tags),
"author": parsed.author,
"version": parsed.version,
"allowed_tools": list(parsed.allowed_tools),
"license": parsed.license,
"compatibility": parsed.compatibility,
}
)
async def admin_skill_discover(request: Request) -> JSONResponse:
"""GET /v1/api/admin/skills/discover — search external skill registries."""
from turnstone.core.auth import require_permission
from turnstone.core.skill_sources import SkillSourceError, SkillsShClient
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
q = str(request.query_params.get("q", "")).strip()
if not q:
return JSONResponse({"error": "Search query is required"}, status_code=400)
try:
limit = max(1, min(int(request.query_params.get("limit", "20")), 100))
except (ValueError, TypeError):
limit = 20
discovery_url = _get_discovery_url(request)
client = SkillsShClient(base_url=discovery_url)
try:
listings = await client.search(query=q, limit=limit)
except SkillSourceError as exc:
return JSONResponse({"error": f"Discovery error: {exc}"}, status_code=502)
# Mark which skills are already installed (by source_url match)
installed_map: dict[str, dict[str, str]] = {}
for row in storage.list_installed_skill_urls():
installed_map[row["source_url"]] = {
"risk_level": row.get("risk_level", ""),
"template_id": row.get("template_id", ""),
}
skills_out = []
for listing in listings:
is_installed = listing.source_url in installed_map if listing.source_url else False
entry: dict[str, Any] = {
"id": listing.id,
"name": listing.name,
"description": listing.description,
"author": listing.author,
"source": listing.source,
"source_url": listing.source_url,
"install_count": listing.install_count,
"tags": listing.tags,
"installed": is_installed,
}
if is_installed and listing.source_url:
info = installed_map[listing.source_url]
entry["risk_level"] = info["risk_level"]
entry["template_id"] = info["template_id"]
skills_out.append(entry)
return JSONResponse({"skills": skills_out})
async def admin_skill_install(request: Request) -> JSONResponse:
"""POST /v1/api/admin/skills/install — install a skill from external source."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.skill_sources import (
SkillNotFoundError,
SkillSourceError,
SkillsShClient,
fetch_skill_from_github,
fetch_skills_from_github_repo,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.skills")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
source = str(body.get("source", "")).strip()
if source not in ("skills.sh", "github"):
return JSONResponse({"error": "source must be 'skills.sh' or 'github'"}, status_code=400)
try:
if source == "skills.sh":
skill_id_param = str(body.get("skill_id", "")).strip()
if not skill_id_param:
return JSONResponse({"error": "skill_id is required"}, status_code=400)
discovery_url = _get_discovery_url(request)
client = SkillsShClient(base_url=discovery_url)
github_url = await client.resolve_github_url(skill_id_param)
packages = [await fetch_skill_from_github(github_url)]
else:
url = str(body.get("url", "")).strip()
if not url:
return JSONResponse({"error": "url is required"}, status_code=400)
try:
packages = [await fetch_skill_from_github(url)]
except SkillNotFoundError:
# No root SKILL.md — try scanning for a multi-skill repo
packages = await fetch_skills_from_github_repo(url)
except SkillNotFoundError as exc:
return JSONResponse({"error": str(exc)}, status_code=404)
except SkillSourceError as exc:
return JSONResponse({"error": str(exc)}, status_code=502)
except ValueError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
import json as _json
audit_uid, ip = _audit_context(request)
installed: list[dict[str, Any]] = []
skipped: list[dict[str, str]] = []
for package in packages:
pkg_source_url = package.listing.source_url
# Check for duplicate by source_url
if pkg_source_url and storage.get_skill_by_source_url(pkg_source_url):
skipped.append({"name": package.parsed.name, "reason": "already installed"})
continue
# Check for duplicate by name
if storage.get_prompt_template_by_name(package.parsed.name):
skipped.append({"name": package.parsed.name, "reason": "name exists"})
continue
skill_id = uuid.uuid4().hex
parsed = package.parsed
tags_str = _json.dumps(parsed.tags)
allowed_tools_str = _json.dumps(parsed.allowed_tools)
content = parsed.content[:32768]
token_estimate = len(content) // 4 if content else 0
# Installer mirrors migration 043's placeholder — a SKILL.md
# with no description otherwise fails the new non-empty
# invariant, blocking installs from upstream catalogs the
# operator doesn't control.
skill_description = parsed.description.strip() or f"Skill: {parsed.name}"
try:
storage.create_prompt_template(
template_id=skill_id,
name=parsed.name,
category="general",
content=content,
variables="[]",
is_default=False,
org_id="",
created_by=audit_uid,
origin="source",
readonly=True,
description=skill_description,
tags=tags_str,
source_url=pkg_source_url,
version=parsed.version,
author=parsed.author,
skill_license=parsed.license,
compatibility=parsed.compatibility,
activation="named",
token_estimate=token_estimate,
allowed_tools=allowed_tools_str,
)
except Exception:
skipped.append({"name": parsed.name, "reason": "conflict"})
continue
# Store bundled resources
for res_path, res_content in package.resources.items():
storage.create_skill_resource(
resource_id=uuid.uuid4().hex,
skill_id=skill_id,
path=res_path,
content=res_content,
)
record_audit(
storage,
audit_uid,
"skill.install",
"skill",
skill_id,
{"name": parsed.name, "source": source, "source_url": pkg_source_url},
ip,
)
skill = storage.get_prompt_template(skill_id)
if skill:
installed.append(_skill_to_response(skill, resource_count=len(package.resources)))
if not installed and skipped:
# All skills were duplicates
return JSONResponse(
{
"error": "All skills already installed",
"installed": [],
"skipped": skipped,
"total": len(packages),
},
status_code=409,
)
# Consistent envelope for both single and batch installs
return JSONResponse(
{
"installed": installed,
"skipped": skipped,
"total": len(packages),
}
)
# ---------------------------------------------------------------------------
# Admin: Memories
# ---------------------------------------------------------------------------
def _validate_memory_scope_filter(scope: str, scope_id: str) -> JSONResponse | None:
"""Validate scope/scope_id consistency for memory queries."""
scope = scope.strip()
scope_id = scope_id.strip()
if scope == "global" and scope_id:
return JSONResponse({"error": "scope_id is not allowed with global scope"}, status_code=400)
if scope_id and not scope:
return JSONResponse(
{"error": "scope is required when scope_id is provided"}, status_code=400
)
return None
async def admin_list_memories(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories — list structured memories with filters."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
err = _validate_memory_scope_filter(scope, scope_id)
if err:
return err
try:
limit = max(1, min(int(request.query_params.get("limit", "100")), 200))
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
rows = storage.list_structured_memories(
mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
total = storage.count_structured_memories(mem_type=mem_type, scope=scope, scope_id=scope_id)
return JSONResponse({"memories": rows, "total": total})
async def admin_search_memories(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories/search — search memories by query."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
query = request.query_params.get("q", "").strip()
if not query:
return JSONResponse({"error": "q is required"}, status_code=400)
mem_type = request.query_params.get("type", "")
scope = request.query_params.get("scope", "")
scope_id = request.query_params.get("scope_id", "")
err = _validate_memory_scope_filter(scope, scope_id)
if err:
return err
try:
limit = max(1, min(int(request.query_params.get("limit", "20")), 50))
except (ValueError, TypeError):
return JSONResponse({"error": "limit must be an integer"}, status_code=400)
rows = storage.search_structured_memories(
query, mem_type=mem_type, scope=scope, scope_id=scope_id, limit=limit
)
return JSONResponse({"memories": rows, "total": len(rows)})
async def admin_get_memory(request: Request) -> JSONResponse:
"""GET /v1/api/admin/memories/{memory_id} — get a single memory."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
memory_id = request.path_params["memory_id"]
mem = storage.get_structured_memory(memory_id)
if not mem:
return JSONResponse({"error": "Memory not found"}, status_code=404)
return JSONResponse(mem)
async def admin_delete_memory(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/memories/{memory_id} — delete a memory by ID."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.memories")
if err:
return err
memory_id = request.path_params["memory_id"]
existing = storage.get_structured_memory(memory_id)
if not existing:
return JSONResponse({"error": "Memory not found"}, status_code=404)
storage.delete_structured_memory_by_id(memory_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"memory.delete",
"memory",
memory_id,
{"name": existing.get("name", ""), "scope": existing.get("scope", "")},
ip,
)
return JSONResponse({"status": "ok"})
# ---------------------------------------------------------------------------
# Admin: System Settings
# ---------------------------------------------------------------------------
def _emit_models_changed(request: Request) -> None:
"""Fan a ``models_changed`` SSE notice to connected browsers, if any.
Best-effort: silently no-ops when the collector isn't attached
(e.g. test fixtures that bypass the cluster collector).
"""
collector = getattr(request.app.state, "collector", None)
if collector is not None:
collector.emit_models_changed()
# Settings whose change should refresh the model dropdown / Roles UI in
# every connected browser — covers the global default plus the per-role
# overrides surfaced in the admin Models → Roles sub-tab. Additions
# here are purely additive (e.g. future ``perception.*.model`` keys).
_MODEL_AFFECTING_SETTING_KEYS: frozenset[str] = frozenset(
{
"model.default_alias",
"model.plan_alias",
"model.plan_effort",
"model.task_alias",
"model.task_effort",
"coordinator.model_alias",
"coordinator.reasoning_effort",
"judge.model",
}
)
async def _publish_config_change(request: Request) -> None:
"""Fan out config-reload to all known server nodes (best-effort, async).
Uses the collector's node registry, the shared async proxy client,
and bounded concurrency via the fan-out semaphore.
"""
# Reload the console's own ConfigStore so cached values stay fresh
# (must happen even when collector is absent — e.g. standalone console)
config_store = getattr(request.app.state, "config_store", None)
if config_store:
config_store.reload()
collector = getattr(request.app.state, "collector", None)
if not collector:
return
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _notify(url: str) -> None:
async with sem:
try:
await client.post(
f"{url.rstrip('/')}/v1/api/_internal/config-reload",
headers=headers,
timeout=5.0,
)
except Exception:
log.warning("Config reload failed for %s", url, exc_info=True)
nodes = collector.get_all_nodes()
tasks = [_notify(n["server_url"]) for n in nodes if n.get("server_url")]
if tasks:
await asyncio.gather(*tasks, return_exceptions=True)
async def admin_list_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings — list all settings with effective values."""
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS, deserialize_value
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
stored = {r["key"]: r for r in storage.list_system_settings() if r.get("node_id", "") == ""}
settings: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
row = stored.get(key)
if row:
try:
val = deserialize_value(key, row["value"])
except (ValueError, KeyError):
val = row["value"]
info = {
"key": key,
"value": "***" if defn.is_secret else val,
"source": "storage",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": row.get("node_id", ""),
"changed_by": row.get("changed_by", ""),
"updated": row.get("updated", ""),
"restart_required": defn.restart_required,
}
else:
info = {
"key": key,
"value": "***" if defn.is_secret else defn.default,
"source": "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": "",
"changed_by": "",
"updated": "",
"restart_required": defn.restart_required,
}
settings.append(info)
return JSONResponse({"settings": settings})
async def admin_settings_schema(request: Request) -> JSONResponse:
"""GET /v1/api/admin/settings/schema — return the full settings registry."""
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS
err = require_permission(request, "admin.settings")
if err:
return err
schema: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
schema.append(
{
"key": key,
"type": defn.type,
"default": defn.default,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"min_value": defn.min_value,
"max_value": defn.max_value,
"choices": defn.choices,
"restart_required": defn.restart_required,
"help": defn.help,
"reference_url": defn.reference_url,
}
)
return JSONResponse({"schema": schema})
async def admin_update_setting(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/settings/{key} — set a setting value."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import (
serialize_value,
validate_key,
validate_value,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
key = request.path_params["key"]
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
try:
defn = validate_key(key)
except ValueError:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
if "value" not in body:
return JSONResponse({"error": "value is required"}, status_code=400)
raw_value = body.get("value")
# Secret sentinel: "***" means "keep existing value"
if defn.is_secret and raw_value == "***":
existing = storage.get_system_setting(key)
return JSONResponse(
{
"key": key,
"value": "***",
"source": "storage" if existing else "default",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": True,
"node_id": existing.get("node_id", "") if existing else "",
"changed_by": existing.get("changed_by", "") if existing else "",
"updated": existing.get("updated", "") if existing else "",
"restart_required": defn.restart_required,
"unchanged": True,
}
)
try:
typed_value = validate_value(key, raw_value)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=400)
node_id = str(body.get("node_id", ""))
audit_uid, ip = _audit_context(request)
storage.upsert_system_setting(
key=key,
value=serialize_value(typed_value),
node_id=node_id,
is_secret=defn.is_secret,
changed_by=audit_uid,
)
record_audit(
storage,
audit_uid,
"setting.update",
"setting",
key,
{"value": "***" if defn.is_secret else typed_value, "node_id": node_id},
ip,
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse(
{
"key": key,
"value": "***" if defn.is_secret else typed_value,
"source": "storage",
"type": defn.type,
"description": defn.description,
"section": defn.section,
"is_secret": defn.is_secret,
"node_id": node_id,
"changed_by": audit_uid,
"updated": "",
"restart_required": defn.restart_required,
}
)
async def admin_delete_setting(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/settings/{key} — reset a setting to default."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import validate_key
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.settings")
if err:
return err
key = request.path_params["key"]
try:
defn = validate_key(key)
except ValueError:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=400)
node_id = request.query_params.get("node_id", "")
deleted = storage.delete_system_setting(key, node_id=node_id)
if not deleted:
return JSONResponse({"error": f"Setting '{key}' not found in storage"}, status_code=404)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"setting.delete",
"setting",
key,
{"node_id": node_id},
ip,
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse({"status": "ok", "key": key, "default": defn.default})
# ---------------------------------------------------------------------------
# Admin: MCP Registry
# ---------------------------------------------------------------------------
def _get_registry_url(request: Request) -> str:
"""Get the MCP Registry URL via ConfigStore, config.toml, or default."""
from turnstone.core.config import load_config
from turnstone.core.mcp_registry import DEFAULT_REGISTRY_URL
# ConfigStore: validated + cached
config_store = getattr(request.app.state, "config_store", None)
if config_store:
val = config_store.get("mcp.registry_url")
if val:
return str(val)
# Fall back to config.toml [mcp] section
mcp_cfg = load_config("mcp")
url = mcp_cfg.get("registry_url", "")
if url:
return str(url)
return DEFAULT_REGISTRY_URL
async def admin_registry_search(request: Request) -> JSONResponse:
"""GET /v1/api/admin/mcp-registry/search — search the MCP Registry."""
from turnstone.core.auth import require_permission
from turnstone.core.mcp_registry import (
MCPRegistryClient,
MCPRegistryError,
RegistryServer,
registry_server_to_dict,
)
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
q = str(request.query_params.get("search", "")).strip()
try:
limit = max(1, min(int(request.query_params.get("limit", "20")), 100))
except (ValueError, TypeError):
limit = 20
cursor = request.query_params.get("cursor") or None
registry_url = _get_registry_url(request)
async with MCPRegistryClient(base_url=registry_url) as client:
try:
result = await client.search(q=q, limit=limit, cursor=cursor)
except MCPRegistryError as exc:
return JSONResponse({"error": f"Registry error: {exc}"}, status_code=502)
# Deduplicate: keep only isLatest entries, first occurrence per name wins.
# Skip servers with no install source (no remotes and no packages).
seen: dict[str, RegistryServer] = {}
for srv in result.servers:
if srv.meta and not srv.meta.is_latest:
continue
if not srv.remotes and not srv.packages:
continue
if srv.name not in seen:
seen[srv.name] = srv
deduped = list(seen.values())
# Mark which servers are already installed
installed: dict[str, dict[str, Any]] = {}
for s in storage.list_mcp_servers():
rn = s.get("registry_name")
if rn:
installed[rn] = s
servers_out = []
for srv in deduped:
d = registry_server_to_dict(srv)
existing = installed.get(srv.name)
if existing:
d["installed"] = True
d["installed_server_id"] = existing["server_id"]
d["installed_version"] = existing.get("registry_version", "")
d["update_available"] = existing.get("registry_version", "") != srv.version
else:
d["installed"] = False
d["installed_server_id"] = ""
d["installed_version"] = ""
d["update_available"] = False
servers_out.append(d)
return JSONResponse(
{
"servers": servers_out,
"total": result.total_count,
"next_cursor": result.next_cursor,
}
)
async def admin_registry_install(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-registry/install — install a server from the registry."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.mcp_registry import (
MCPRegistryClient,
MCPRegistryError,
resolve_install_config,
sanitize_registry_name,
)
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
registry_name = str(body.get("registry_name", "")).strip()
if not registry_name:
return JSONResponse({"error": "registry_name is required"}, status_code=400)
source = str(body.get("source", "")).strip()
if source not in ("remote", "package"):
return JSONResponse({"error": "source must be 'remote' or 'package'"}, status_code=400)
try:
index = int(body.get("index", 0))
except (ValueError, TypeError):
return JSONResponse({"error": "index must be an integer"}, status_code=400)
variables = body.get("variables") or {}
env_values = body.get("env") or {}
header_values = body.get("headers") or {}
if not isinstance(variables, dict):
return JSONResponse({"error": "variables must be an object"}, status_code=400)
if not isinstance(env_values, dict):
return JSONResponse({"error": "env must be an object"}, status_code=400)
if not isinstance(header_values, dict):
return JSONResponse({"error": "headers must be an object"}, status_code=400)
custom_name = str(body.get("name", "")).strip()
# Check for duplicates
existing = storage.get_mcp_server_by_registry_name(registry_name)
if existing:
return JSONResponse(
{
"error": (
f"Registry server '{registry_name}' is already installed "
f"as '{existing['name']}'"
)
},
status_code=409,
)
# Check max servers
current = storage.list_mcp_servers()
max_servers = _get_mcp_max_servers(request)
if len(current) >= max_servers:
return JSONResponse({"error": f"Maximum {max_servers} servers"}, status_code=400)
# Fetch the specific server from the registry
registry_url = _get_registry_url(request)
async with MCPRegistryClient(base_url=registry_url) as client:
try:
result = await client.search(q=registry_name, limit=100)
except MCPRegistryError as exc:
return JSONResponse({"error": f"Registry error: {exc}"}, status_code=502)
# Find the exact server by name
server = None
for s in result.servers:
if s.name == registry_name:
server = s
break
if server is None:
return JSONResponse(
{"error": f"Server '{registry_name}' not found in registry"},
status_code=404,
)
# Resolve install configuration
try:
config = resolve_install_config(server, source, index, variables)
except MCPRegistryError as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
except (IndexError, ValueError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
# Determine server name
try:
name = custom_name or sanitize_registry_name(registry_name)
except MCPRegistryError as exc:
return JSONResponse({"error": f"{exc}; provide a custom 'name'"}, status_code=400)
if not name or not _MCP_NAME_RE.match(name) or "__" in name:
return JSONResponse(
{"error": f"Name '{name}' is invalid; provide a custom 'name'"},
status_code=400,
)
if storage.get_mcp_server_by_name(name):
return JSONResponse(
{"error": f"Server name '{name}' already exists; provide a custom 'name'"},
status_code=409,
)
# Merge user-provided env and header values
merged_env = config.get("env", {})
if isinstance(env_values, dict):
merged_env.update(env_values)
merged_headers = config.get("headers", {})
if isinstance(header_values, dict):
merged_headers.update(header_values)
server_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
storage.create_mcp_server(
server_id=server_id,
name=name,
transport=config["transport"],
command=config.get("command", ""),
args=json.dumps(config.get("args", [])),
url=config.get("url", ""),
headers=json.dumps(merged_headers),
env=json.dumps(merged_env),
auto_approve=False,
enabled=True,
created_by=audit_uid,
registry_name=registry_name,
registry_version=config["registry_version"],
registry_meta=json.dumps(config["registry_meta"]),
)
record_audit(
storage,
audit_uid,
"mcp_server.registry_install",
"mcp_server",
server_id,
{"name": name, "registry_name": registry_name, "source": source},
ip,
)
# Auto-reload nodes for one-click UX
await _notify_nodes_mcp_reload(request)
server_row = storage.get_mcp_server(server_id)
return JSONResponse(_mcp_server_to_detail(_mask_mcp_secrets(server_row or {})))
# ---------------------------------------------------------------------------
# Admin: MCP Servers
# ---------------------------------------------------------------------------
_MCP_NAME_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MCP_MAX_SERVERS = 200 # fallback; prefer cluster.mcp_max_servers from storage
_MCP_AUTH_TYPES = frozenset({"none", "static", "oauth_user"})
def _clean_oauth_text(value: Any, *, max_length: int = 512) -> str | None:
"""Normalize an admin form OAuth text field — empty string -> None.
Caps the input to ``max_length`` characters to bound DB row size on
the admin.mcp write path. Pass a larger ``max_length`` (e.g. 2048)
for URL fields where the default would otherwise truncate valid
long URLs.
"""
if value is None:
return None
text = str(value).strip()
if not text:
return None
return text[:max_length]
def _parse_auth_type(body: dict[str, Any]) -> tuple[str | None, JSONResponse | None]:
"""Validate ``auth_type`` from a request body.
Returns ``(value, None)`` for a valid value, ``(None, error)`` for
a present-but-invalid value (caller returns ``error``), or
``(None, None)`` when ``auth_type`` is absent (caller skips the
update / falls back to a default).
"""
if "auth_type" not in body:
return None, None
auth_type = str(body["auth_type"]).strip()
if auth_type not in _MCP_AUTH_TYPES:
return None, JSONResponse(
{"error": "auth_type must be 'none', 'static', or 'oauth_user'"},
status_code=400,
)
return auth_type, None
def _get_mcp_max_servers(request: Request) -> int:
"""Read cluster.mcp_max_servers via ConfigStore (validated + cached)."""
config_store = getattr(request.app.state, "config_store", None)
if config_store:
return int(config_store.get("cluster.mcp_max_servers"))
return _MCP_MAX_SERVERS
def _mask_mcp_secrets(server: dict[str, Any], reveal: bool = False) -> dict[str, Any]:
"""Mask secret fields on an MCP server response dict.
``env`` and ``headers`` are masked only when ``reveal`` is False.
``oauth_client_secret_ct`` is always masked regardless of ``reveal``
— it's a write-only field (the admin form accepts plaintext but
the response just signals presence-or-absence as ``"***"`` /
``None``).
"""
s = dict(server)
# OAuth client secret ciphertext is write-only at every read path.
raw_secret = s.get("oauth_client_secret_ct")
s["oauth_client_secret_ct"] = "***" if raw_secret is not None else None
if reveal:
return s
if s.get("env") and s["env"] != "{}":
try:
env_dict = json.loads(s["env"]) if isinstance(s["env"], str) else s["env"]
s["env"] = json.dumps({k: "***" for k in env_dict})
except (json.JSONDecodeError, TypeError):
s["env"] = "{}"
if s.get("headers") and s["headers"] != "{}":
try:
hdr_dict = json.loads(s["headers"]) if isinstance(s["headers"], str) else s["headers"]
s["headers"] = json.dumps({k: "***" for k in hdr_dict})
except (json.JSONDecodeError, TypeError):
s["headers"] = "{}"
return s
def _mcp_server_to_detail(
server: dict[str, Any],
node_statuses: dict[str, dict[str, Any]] | None = None,
) -> dict[str, Any]:
"""Convert a storage dict to a McpServerDetail-shaped dict."""
d = dict(server)
d["status"] = node_statuses or {}
return d
async def _collect_mcp_status(
request: Request,
) -> dict[str, dict[str, dict[str, Any]]]:
"""Query all nodes for MCP status. Returns {node_id: {server_name: status}}."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.get(
f"{url.rstrip('/')}/v1/api/_internal/mcp-status",
headers=headers,
timeout=10,
)
if resp.status_code == 200:
return node_id, resp.json().get("servers", {})
except Exception:
log.debug("Failed to fetch MCP status from node %s", node_id, exc_info=True)
return node_id, None
results = await asyncio.gather(*[_fetch(n) for n in nodes])
return {nid: servers for nid, servers in results if servers is not None}
async def admin_list_mcp_servers(request: Request) -> JSONResponse:
"""GET /v1/api/admin/mcp-servers — list all MCP server definitions."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
reveal = str(request.query_params.get("reveal", "")).lower() in ("true", "1")
servers = storage.list_mcp_servers()
# Collect live status from all nodes
node_statuses = await _collect_mcp_status(request)
db_names: set[str] = set()
result = []
for s in servers:
db_names.add(s["name"])
# Build per-node status for this server
per_node: dict[str, dict[str, Any]] = {}
for node_id, node_servers in node_statuses.items():
status = node_servers.get(s["name"])
if status:
per_node[node_id] = status
s = _mask_mcp_secrets(s, reveal)
result.append(_mcp_server_to_detail(s, per_node))
# Merge config-sourced servers visible on nodes but not in DB
config_names: set[str] = set()
for node_servers in node_statuses.values():
for name in node_servers:
if name not in db_names:
config_names.add(name)
for name in sorted(config_names):
# Build a synthetic read-only entry from node-reported data
per_node = {}
transport = "stdio"
command = ""
url = ""
for node_id, node_servers in node_statuses.items():
ns = node_servers.get(name)
if ns:
per_node[node_id] = ns
transport = ns.get("transport", "stdio")
command = ns.get("command", "")
url = ns.get("url", "")
result.append(
{
"server_id": "",
"name": name,
"transport": transport,
"command": command,
"args": "[]",
"url": url,
"headers": "{}",
"env": "{}",
"auto_approve": False,
"enabled": True,
"created_by": "",
"auth_type": "static",
"created": "",
"updated": "",
"source": "config",
"status": per_node,
}
)
return JSONResponse({"servers": result})
async def admin_create_mcp_server(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers — create an MCP server definition."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:64]
transport = str(body.get("transport", "")).strip()
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not _MCP_NAME_RE.match(name):
return JSONResponse(
{"error": "name must match [a-zA-Z0-9._-]+"},
status_code=400,
)
if "__" in name:
return JSONResponse(
{"error": "name must not contain '__' (reserved delimiter)"},
status_code=400,
)
if transport not in ("stdio", "streamable-http"):
return JSONResponse(
{"error": "transport must be 'stdio' or 'streamable-http'"},
status_code=400,
)
if transport == "stdio" and not str(body.get("command", "")).strip():
return JSONResponse({"error": "command is required for stdio transport"}, status_code=400)
if transport == "streamable-http" and not str(body.get("url", "")).strip():
return JSONResponse(
{"error": "url is required for streamable-http transport"}, status_code=400
)
auth_type_value, err_resp = _parse_auth_type(body)
if err_resp is not None:
return err_resp
# Helper returns None when key is absent — fall back to the default.
auth_type = auth_type_value if auth_type_value is not None else "static"
# Check max servers
existing = storage.list_mcp_servers()
max_servers = _get_mcp_max_servers(request)
if len(existing) >= max_servers:
return JSONResponse(
{"error": f"Maximum {max_servers} servers"},
status_code=400,
)
# Check name uniqueness
if storage.get_mcp_server_by_name(name):
return JSONResponse(
{"error": f"Server '{name}' already exists"},
status_code=409,
)
server_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
args_list = body.get("args", [])
headers_dict = body.get("headers", {})
env_dict = body.get("env", {})
storage.create_mcp_server(
server_id=server_id,
name=name,
transport=transport,
command=str(body.get("command", "")).strip(),
args=json.dumps(args_list) if isinstance(args_list, list) else "[]",
url=str(body.get("url", "")).strip(),
headers=json.dumps(headers_dict) if isinstance(headers_dict, dict) else "{}",
env=json.dumps(env_dict) if isinstance(env_dict, dict) else "{}",
auto_approve=bool(body.get("auto_approve", False)),
enabled=bool(body.get("enabled", True)),
created_by=audit_uid,
auth_type=auth_type,
oauth_client_id=_clean_oauth_text(body.get("oauth_client_id")),
oauth_scopes=_clean_oauth_text(body.get("oauth_scopes")),
oauth_audience=_clean_oauth_text(body.get("oauth_audience"), max_length=2048),
oauth_registration_mode=_clean_oauth_text(body.get("oauth_registration_mode")),
oauth_authorization_server_url=_clean_oauth_text(
body.get("oauth_authorization_server_url"), max_length=2048
),
)
audit_detail: dict[str, Any] = {"name": name, "auth_type": auth_type}
if "oauth_client_secret" in body:
audit_detail["oauth_client_secret"] = "(redacted)"
record_audit(
storage,
audit_uid,
"mcp_server.create",
"mcp_server",
server_id,
audit_detail,
ip,
)
server = storage.get_mcp_server(server_id)
return JSONResponse(_mcp_server_to_detail(_mask_mcp_secrets(server or {})))
async def admin_get_mcp_server(request: Request) -> JSONResponse:
"""GET /v1/api/admin/mcp-servers/{server_id} — get single MCP server."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
server_id = request.path_params["server_id"]
server = storage.get_mcp_server(server_id)
if server is None:
return JSONResponse({"error": "MCP server not found"}, status_code=404)
node_statuses = await _collect_mcp_status(request)
per_node: dict[str, dict[str, Any]] = {}
for node_id, node_servers in node_statuses.items():
status = node_servers.get(server["name"])
if status:
per_node[node_id] = status
reveal = str(request.query_params.get("reveal", "")).lower() in ("true", "1")
server = _mask_mcp_secrets(server, reveal)
return JSONResponse(_mcp_server_to_detail(server, per_node))
async def admin_update_mcp_server(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/mcp-servers/{server_id} — update an MCP server."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
server_id = request.path_params["server_id"]
existing = storage.get_mcp_server(server_id)
if existing is None:
return JSONResponse({"error": "MCP server not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "name" in body:
name = str(body["name"]).strip()[:64]
if not name:
return JSONResponse({"error": "name cannot be empty"}, status_code=400)
if not _MCP_NAME_RE.match(name):
return JSONResponse(
{"error": "name must match [a-zA-Z0-9._-]+"},
status_code=400,
)
if "__" in name:
return JSONResponse(
{"error": "name must not contain '__'"},
status_code=400,
)
if name != existing["name"] and storage.get_mcp_server_by_name(name):
return JSONResponse(
{"error": f"Server '{name}' already exists"},
status_code=409,
)
updates["name"] = name
if "transport" in body:
transport = str(body["transport"]).strip()
if transport not in ("stdio", "streamable-http"):
return JSONResponse(
{"error": "transport must be 'stdio' or 'streamable-http'"},
status_code=400,
)
updates["transport"] = transport
if "command" in body:
updates["command"] = str(body["command"]).strip()
if "args" in body:
updates["args"] = json.dumps(body["args"]) if isinstance(body["args"], list) else "[]"
if "url" in body:
updates["url"] = str(body["url"]).strip()
if "headers" in body:
updates["headers"] = (
json.dumps(body["headers"]) if isinstance(body["headers"], dict) else "{}"
)
if "env" in body:
updates["env"] = json.dumps(body["env"]) if isinstance(body["env"], dict) else "{}"
if "auto_approve" in body:
updates["auto_approve"] = bool(body["auto_approve"])
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
auth_type_value, err_resp = _parse_auth_type(body)
if err_resp is not None:
return err_resp
if auth_type_value is not None:
updates["auth_type"] = auth_type_value
for _oauth_key in (
"oauth_client_id",
"oauth_scopes",
"oauth_registration_mode",
):
if _oauth_key in body:
updates[_oauth_key] = _clean_oauth_text(body[_oauth_key])
for _oauth_url_key in (
"oauth_audience",
"oauth_authorization_server_url",
):
if _oauth_url_key in body:
updates[_oauth_url_key] = _clean_oauth_text(body[_oauth_url_key], max_length=2048)
# When auth_type is changed away from oauth_user, clear the OAuth
# columns so a stale client_id / audience can't leak back if the
# row is later flipped to a different oauth_user provider.
# ``oauth_client_secret_ct`` is owned by a dedicated write path
# (not the generic update); its clear-on-change lives there.
if updates.get("auth_type") and updates["auth_type"] != "oauth_user":
updates.update(
{
"oauth_client_id": None,
"oauth_scopes": None,
"oauth_audience": None,
"oauth_registration_mode": None,
"oauth_authorization_server_url": None,
"oauth_as_issuer_cached": None,
}
)
if updates:
storage.update_mcp_server(server_id, **updates)
audit_uid, ip = _audit_context(request)
audit_detail = dict(updates)
for _secret_key in ("env", "headers"):
if _secret_key in audit_detail:
audit_detail[_secret_key] = "(updated)"
if "oauth_client_secret" in body:
audit_detail["oauth_client_secret"] = "(redacted)"
record_audit(
storage,
audit_uid,
"mcp_server.update",
"mcp_server",
server_id,
audit_detail,
ip,
)
server = storage.get_mcp_server(server_id)
return JSONResponse(_mcp_server_to_detail(_mask_mcp_secrets(server or {})))
async def admin_delete_mcp_server(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/mcp-servers/{server_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
server_id = request.path_params["server_id"]
existing = storage.get_mcp_server(server_id)
if existing is None:
return JSONResponse({"error": "MCP server not found"}, status_code=404)
storage.delete_mcp_server(server_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"mcp_server.delete",
"mcp_server",
server_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok"})
async def _notify_nodes_mcp_reload(request: Request) -> dict[str, Any]:
"""Tell all nodes to re-read the mcp_servers DB table and reconcile."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.post(
f"{url.rstrip('/')}/v1/api/_internal/mcp-reload",
headers=headers,
timeout=30,
)
return node_id, resp.json()
except Exception as exc:
log.debug("Failed to notify node %s for MCP reload", node_id, exc_info=True)
return node_id, {"error": str(exc)}
results = await asyncio.gather(*[_notify(n) for n in nodes])
return {nid: data for nid, data in results if data is not None}
async def _notify_nodes_mcp_action(request: Request, action: str, name: str) -> dict[str, Any]:
"""Tell all nodes to perform a per-server MCP action.
*action* is the suffix of the internal endpoint — currently
``"refresh"`` or ``"reconnect"``. Each node is hit at
``/v1/api/_internal/mcp-{action}/{name}``.
"""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
safe_name = urllib.parse.quote(name, safe="")
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.post(
f"{url.rstrip('/')}/v1/api/_internal/mcp-{action}/{safe_name}",
headers=headers,
timeout=30,
)
return node_id, resp.json()
except Exception as exc:
log.debug(
"Failed to notify node %s for MCP %s of %s",
node_id,
action,
name,
exc_info=True,
)
return node_id, {"error": str(exc)}
results = await asyncio.gather(*[_notify(n) for n in nodes])
return {nid: data for nid, data in results if data is not None}
async def _notify_nodes_mcp_refresh_one(request: Request, name: str) -> dict[str, Any]:
"""Tell all nodes to refresh a single MCP server's catalog."""
return await _notify_nodes_mcp_action(request, "refresh", name)
async def _notify_nodes_mcp_reconnect_one(request: Request, name: str) -> dict[str, Any]:
"""Tell all nodes to force-reconnect a single MCP server."""
return await _notify_nodes_mcp_action(request, "reconnect", name)
async def admin_mcp_reload(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/reload — tell nodes to re-read DB."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
results = await _notify_nodes_mcp_reload(request)
return JSONResponse({"status": "ok", "results": results})
async def _admin_mcp_action(request: Request, action: str) -> JSONResponse:
"""Shared body for the per-server MCP admin actions.
*action* is the verb suffix — ``"refresh"`` or ``"reconnect"``.
Auth, name validation, audit, and per-node fan-out are identical
across the two; only the audit action string and the notify call
vary, both keyed on *action*.
"""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
name = request.path_params["name"]
if "__" in name:
return JSONResponse({"error": "invalid server name"}, status_code=400)
existing = storage.get_mcp_server_by_name(name)
target_id = existing.get("id", name) if existing else name
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
f"mcp_server.{action}",
"mcp_server",
target_id,
{"name": name},
ip,
)
results = await _notify_nodes_mcp_action(request, action, name)
return JSONResponse({"status": "ok", "results": results})
async def admin_mcp_refresh_one(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/{name}/refresh — refresh one server's catalog."""
return await _admin_mcp_action(request, "refresh")
async def admin_mcp_reconnect_one(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/{name}/reconnect — force-reconnect one server."""
return await _admin_mcp_action(request, "reconnect")
async def admin_import_mcp_config(request: Request) -> JSONResponse:
"""POST /v1/api/admin/mcp-servers/import — import from pasted JSON config."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.mcp")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
data = body.get("config")
if not isinstance(data, dict):
return JSONResponse(
{"error": "config is required (JSON object with mcpServers key)"}, status_code=400
)
servers = data.get("mcpServers", {})
if not isinstance(servers, dict) or not servers:
return JSONResponse(
{"error": "No mcpServers found in config"},
status_code=400,
)
imported: list[str] = []
skipped: list[str] = []
errors: list[str] = []
audit_uid, ip = _audit_context(request)
current_count = len(storage.list_mcp_servers())
max_servers = _get_mcp_max_servers(request)
for srv_name, cfg in servers.items():
srv_name = str(srv_name).strip()[:64]
if not srv_name or not _MCP_NAME_RE.match(srv_name) or "__" in srv_name:
errors.append(f"{srv_name}: invalid server name")
continue
if storage.get_mcp_server_by_name(srv_name):
skipped.append(srv_name)
continue
if current_count >= max_servers:
errors.append(f"{srv_name}: max servers reached")
break
transport = "stdio"
if "url" in cfg or cfg.get("type") in ("http", "streamable-http"):
transport = "streamable-http"
# Coerce fields to expected types
raw_args = cfg.get("args", [])
raw_headers = cfg.get("headers", {})
raw_env = cfg.get("env", {})
if not isinstance(raw_args, list):
errors.append(f"{srv_name}: args must be a list")
continue
if not isinstance(raw_headers, dict):
errors.append(f"{srv_name}: headers must be an object")
continue
if not isinstance(raw_env, dict):
errors.append(f"{srv_name}: env must be an object")
continue
server_id = uuid.uuid4().hex
try:
storage.create_mcp_server(
server_id=server_id,
name=srv_name,
transport=transport,
command=str(cfg.get("command", "")),
args=json.dumps(raw_args),
url=str(cfg.get("url", "")),
headers=json.dumps(raw_headers),
env=json.dumps(raw_env),
auto_approve=False,
enabled=True,
created_by=audit_uid,
)
imported.append(srv_name)
current_count += 1
except Exception as exc:
errors.append(f"{srv_name}: {exc}")
if imported:
record_audit(
storage,
audit_uid,
"mcp_server.import",
"mcp_server",
"",
{"imported": imported, "skipped": skipped},
ip,
)
return JSONResponse({"imported": imported, "skipped": skipped, "errors": errors})
# ---------------------------------------------------------------------------
# Admin: Model Definitions
# ---------------------------------------------------------------------------
_MODEL_ALIAS_RE = re.compile(r"^[a-zA-Z0-9._-]+$")
_MODEL_PROVIDERS = frozenset({"openai", "anthropic", "openai-compatible", "google"})
_REASONING_EFFORT_CHOICES = frozenset(
{"", "none", "minimal", "low", "medium", "high", "xhigh", "max"}
)
# Keep in sync with turnstone.core.providers._VALID_API_SURFACES.
_API_SURFACE_CHOICES = frozenset({"chat", "responses"})
def _validate_api_surface(caps: Any) -> str | None:
"""Return an error message if ``caps["server_compat"]["api_surface"]`` is invalid.
Strict equality match (no strip/lower normalisation): the persisted value
is bound directly to the admin ``<select>`` whose options are the canonical
``"chat"`` / ``"responses"`` strings, so anything else fails to round-trip
through edit/save. The provider factory raises ``ValueError`` at request
time for an unknown surface; validating here turns that into a 400 at
write time so an admin can't poison a model alias via direct API calls.
"""
if not isinstance(caps, dict):
return None
sc = caps.get("server_compat")
if not isinstance(sc, dict):
return None
raw = sc.get("api_surface")
if raw is None or raw == "":
return None
if not isinstance(raw, str) or raw not in _API_SURFACE_CHOICES:
return f"Invalid server_compat.api_surface: {raw!r}"
return None
# Keep in sync with turnstone.core.providers._google.GOOGLE_DEFAULT_BASE_URL
_PROVIDER_DEFAULT_URLS: dict[str, str] = {
"openai": "https://api.openai.com/v1",
"anthropic": "https://api.anthropic.com",
"google": "https://generativelanguage.googleapis.com/v1beta/openai/",
}
def _mask_model_secrets(model: dict[str, Any]) -> dict[str, Any]:
"""Replace api_key with '***' (unconditional, write-only)."""
m = dict(model)
if m.get("api_key"):
m["api_key"] = "***"
return m
async def _collect_model_status(
request: Request,
) -> dict[str, dict[str, dict[str, Any]]]:
"""Query all nodes for model status. Returns {node_id: {alias: info}}."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _fetch(node: dict[str, Any]) -> tuple[str, dict[str, dict[str, Any]] | None]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.get(
f"{url.rstrip('/')}/v1/api/_internal/model-status",
headers=headers,
timeout=10,
)
if resp.status_code == 200:
return node_id, resp.json().get("models", {})
except Exception:
log.debug("Failed to fetch model status from node %s", node_id, exc_info=True)
return node_id, None
results = await asyncio.gather(*[_fetch(n) for n in nodes])
return {nid: models for nid, models in results if models is not None}
def _refresh_coord_registry(app_state: Any, storage: Any) -> None:
"""Rebuild ``app_state.coord_registry`` in place from DB model definitions.
The console-side coordinator session factory closes over the
``coord_registry`` instance built at lifespan startup
(see this module's lifespan setup and ``console/session_factory.py``).
Replacing the attribute would orphan the closure — new sessions would
still resolve through the stale object. Mutating in place via
``ModelRegistry.reload()`` preserves identity, so:
- new coordinator sessions see the new state at create-time;
- active coordinator sessions auto-pick up the swap at next ``send()``
via ``ChatSession._refresh_model_from_registry`` (the per-send
check compares ``cfg.model`` against ``self.model`` and re-resolves
on mismatch).
Errors are logged + swallowed. The DB write that triggered this
refresh has already succeeded, and the explicit reload button
remains the user-facing recovery path. Validation failures
(e.g. admin deleted the alias that ``registry.default`` points at)
leave the existing registry intact rather than tearing down a
working coordinator.
"""
from turnstone.core.model_registry import load_model_registry
existing = getattr(app_state, "coord_registry", None)
if existing is None:
# Lifespan didn't build a coord_registry (no DB model rows at boot)
# — the entire coord subsystem stayed uninitialized, so a console
# restart is required after the operator adds the first row.
return
try:
# ``strict=True`` so a transient DB read error surfaces here.
# Without it, the loader degrades to a config.toml-only registry
# and ``existing.reload()`` would silently drop every DB-sourced
# alias.
new_registry = load_model_registry(storage=storage, strict=True)
except ValueError as exc:
# ModelRegistry.__init__ raises ValueError for several distinct
# config issues — empty models, default/fallback/agent/plan/task
# alias not present in the loaded set. Log the actual reason so
# operators can tell "no enabled rows" from "default alias typo
# in config.toml". Existing registry stays in place either way.
log.warning("console.coord_registry_refresh_skipped reason=%s", exc)
return
except Exception:
log.warning("console.coord_registry_refresh_load_failed", exc_info=True)
return
try:
existing.reload(
new_registry.models,
new_registry.default,
new_registry.fallback,
new_registry.agent_model,
plan_model=new_registry.plan_model,
task_model=new_registry.task_model,
plan_effort=new_registry.plan_effort,
task_effort=new_registry.task_effort,
)
except Exception:
log.warning("console.coord_registry_refresh_reload_failed", exc_info=True)
finally:
# Defensive — load_model_registry doesn't eagerly create clients
# (ModelRegistry.__init__ leaves _clients/_providers empty; they
# populate lazily on first resolve), so shutdown() iterates empty
# dicts in practice. Kept against the day the loader grows
# eager-init or a future caller pre-warms the throwaway, and
# wrapped because shutdown() in finally would otherwise escape
# after a successful in-place reload — surfacing as 500 with the
# registry actually mutated and the audit row recording success.
try:
new_registry.shutdown()
except Exception:
log.warning("console.coord_registry_refresh_shutdown_failed", exc_info=True)
async def _notify_nodes_model_reload(request: Request) -> dict[str, Any]:
"""Tell all nodes to re-read model definitions from DB and rebuild registry."""
collector: ClusterCollector = request.app.state.collector
nodes = collector.get_all_nodes()
client: httpx.AsyncClient = request.app.state.proxy_client
headers = _proxy_auth_headers(request)
sem = asyncio.Semaphore(_get_fan_out_limit(request))
async def _notify(node: dict[str, Any]) -> tuple[str, Any]:
node_id = node.get("node_id", "")
url = node.get("server_url", "")
if not url:
return node_id, None
async with sem:
try:
resp = await client.post(
f"{url.rstrip('/')}/v1/api/_internal/model-reload",
headers=headers,
timeout=30,
)
return node_id, resp.json()
except Exception as exc:
log.debug("Failed to notify node %s for model reload", node_id, exc_info=True)
return node_id, {"error": str(exc)}
results = await asyncio.gather(*[_notify(n) for n in nodes])
return {nid: data for nid, data in results if data is not None}
async def admin_list_model_definitions(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-definitions — list all model definitions."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
db_models = storage.list_model_definitions()
# Collect live status from all nodes
node_statuses = await _collect_model_status(request)
db_aliases: set[str] = set()
result = []
for m in db_models:
db_aliases.add(m["alias"])
m["source"] = "db"
result.append(_mask_model_secrets(m))
# Merge config-sourced models visible on nodes but not in DB
config_aliases: set[str] = set()
for node_models in node_statuses.values():
for alias in node_models:
if alias not in db_aliases:
config_aliases.add(alias)
for alias in sorted(config_aliases):
# Build a synthetic read-only entry from node-reported data
model_name = ""
provider = "openai"
context_window = 0
cfg_temperature = None
cfg_max_tokens = None
cfg_reasoning_effort = None
for node_models in node_statuses.values():
nm = node_models.get(alias)
if nm:
model_name = nm.get("model", "")
provider = nm.get("provider", "openai")
context_window = nm.get("context_window", 0)
cfg_temperature = nm.get("temperature")
cfg_max_tokens = nm.get("max_tokens")
cfg_reasoning_effort = nm.get("reasoning_effort")
break
result.append(
{
"definition_id": "",
"alias": alias,
"model": model_name,
"provider": provider,
"base_url": "",
"api_key": "",
"context_window": context_window,
"capabilities": "{}",
"enabled": True,
"temperature": cfg_temperature,
"max_tokens": cfg_max_tokens,
"reasoning_effort": cfg_reasoning_effort,
"source": "config",
"created_by": "",
"created": "",
"updated": "",
}
)
# Include the effective default alias so the UI can highlight it.
# Prefer ConfigStore override, fall back to config.toml [model].default,
# then validate against the actual enabled model list (same fallback
# rules as load_model_registry).
configured_default = ""
cs = getattr(request.app.state, "config_store", None)
if cs:
configured_default = cs.get("model.default_alias") or ""
if not configured_default:
from turnstone.core.config import load_config as _load_cfg
configured_default = _load_cfg().get("model", {}).get("default", "default")
enabled_aliases = [m["alias"] for m in result if m.get("alias") and m.get("enabled", True)]
enabled_set = set(enabled_aliases)
if configured_default in enabled_set:
default_alias = configured_default
elif "default" in enabled_set:
default_alias = "default"
elif enabled_aliases:
default_alias = enabled_aliases[0]
else:
default_alias = ""
return JSONResponse({"models": result, "default_alias": default_alias})
async def admin_create_model_definition(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions — create a model definition."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
alias = str(body.get("alias", "")).strip()[:64]
model_name = str(body.get("model", "")).strip()[:128]
if not alias:
return JSONResponse({"error": "alias is required"}, status_code=400)
if not model_name:
return JSONResponse({"error": "model is required"}, status_code=400)
if not _MODEL_ALIAS_RE.match(alias):
return JSONResponse(
{"error": "alias must match [a-zA-Z0-9._-]+"},
status_code=400,
)
# Check alias uniqueness
if storage.get_model_definition_by_alias(alias):
return JSONResponse(
{"error": f"Model alias '{alias}' already exists"},
status_code=409,
)
definition_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
provider = str(body.get("provider", "openai")).strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse(
{"error": f"Unknown provider: {provider!r}"},
status_code=400,
)
base_url = str(body.get("base_url", "")).strip()
api_key = str(body.get("api_key", "")).strip()
ctx_raw = body.get("context_window", 32768)
context_window = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
caps = body.get("capabilities", {})
err_msg = _validate_api_surface(caps)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
capabilities = json.dumps(caps) if isinstance(caps, dict) else "{}"
enabled = bool(body.get("enabled", True))
# Per-model sampling overrides (None = use global default)
temperature: float | None = None
if body.get("temperature") is not None:
try:
temperature = float(body["temperature"])
except (ValueError, TypeError):
return JSONResponse({"error": "temperature must be a number"}, status_code=400)
if not 0.0 <= temperature <= 2.0:
return JSONResponse(
{"error": "temperature must be between 0.0 and 2.0"}, status_code=400
)
max_tokens: int | None = None
if body.get("max_tokens") is not None:
try:
max_tokens = int(body["max_tokens"])
except (ValueError, TypeError):
return JSONResponse({"error": "max_tokens must be an integer"}, status_code=400)
if max_tokens < 1:
return JSONResponse({"error": "max_tokens must be >= 1"}, status_code=400)
reasoning_effort: str | None = None
if body.get("reasoning_effort") is not None:
reasoning_effort = str(body["reasoning_effort"]).strip()
if reasoning_effort and reasoning_effort not in _REASONING_EFFORT_CHOICES:
return JSONResponse(
{"error": f"Invalid reasoning_effort: {reasoning_effort!r}"},
status_code=400,
)
if not reasoning_effort:
reasoning_effort = None
storage.create_model_definition(
definition_id=definition_id,
alias=alias,
model=model_name,
provider=provider,
base_url=base_url,
api_key=api_key,
context_window=context_window,
capabilities=capabilities,
enabled=enabled,
created_by=audit_uid,
temperature=temperature,
max_tokens=max_tokens,
reasoning_effort=reasoning_effort,
)
record_audit(
storage,
audit_uid,
"model_definition.create",
"model_definition",
definition_id,
{"alias": alias},
ip,
)
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
created = storage.get_model_definition(definition_id)
if created is None:
return JSONResponse(
{"error": f"Model alias '{alias}' already exists (concurrent insert)"},
status_code=409,
)
return JSONResponse(_mask_model_secrets(created))
async def admin_get_model_definition(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
model_def = storage.get_model_definition(definition_id)
if model_def is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
return JSONResponse(_mask_model_secrets(model_def))
async def admin_update_model_definition(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
existing = storage.get_model_definition(definition_id)
if existing is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
updates: dict[str, Any] = {}
if "alias" in body:
alias = str(body["alias"]).strip()[:64]
if not alias:
return JSONResponse({"error": "alias cannot be empty"}, status_code=400)
if not _MODEL_ALIAS_RE.match(alias):
return JSONResponse(
{"error": "alias must match [a-zA-Z0-9._-]+"},
status_code=400,
)
if alias != existing["alias"] and storage.get_model_definition_by_alias(alias):
return JSONResponse(
{"error": f"Model alias '{alias}' already exists"},
status_code=409,
)
updates["alias"] = alias
if "model" in body:
model_val = str(body["model"]).strip()[:128]
if not model_val:
return JSONResponse({"error": "model cannot be empty"}, status_code=400)
updates["model"] = model_val
if "provider" in body:
prov = str(body["provider"]).strip()
if prov not in _MODEL_PROVIDERS:
return JSONResponse(
{"error": f"Unknown provider: {prov!r}"},
status_code=400,
)
updates["provider"] = prov
if "base_url" in body:
updates["base_url"] = str(body["base_url"]).strip()
if "api_key" in body:
api_key = str(body["api_key"]).strip()
# Sentinel "***" or empty string means "keep existing"
if api_key and api_key != "***":
updates["api_key"] = api_key
if "context_window" in body:
ctx_raw = body["context_window"]
updates["context_window"] = max(0, int(ctx_raw)) if isinstance(ctx_raw, (int, float)) else 0
if "capabilities" in body:
caps = body["capabilities"]
err_msg = _validate_api_surface(caps)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
updates["capabilities"] = json.dumps(caps) if isinstance(caps, dict) else "{}"
if "enabled" in body:
updates["enabled"] = bool(body["enabled"])
# Per-model sampling overrides — explicit null clears to "use global default"
if "temperature" in body:
raw_temp = body["temperature"]
if raw_temp is None:
updates["temperature"] = None
else:
try:
temp_val = float(raw_temp)
except (ValueError, TypeError):
return JSONResponse({"error": "temperature must be a number"}, status_code=400)
if not 0.0 <= temp_val <= 2.0:
return JSONResponse(
{"error": "temperature must be between 0.0 and 2.0"},
status_code=400,
)
updates["temperature"] = temp_val
if "max_tokens" in body:
raw_mt = body["max_tokens"]
if raw_mt is None:
updates["max_tokens"] = None
else:
try:
mt_val = int(raw_mt)
except (ValueError, TypeError):
return JSONResponse({"error": "max_tokens must be an integer"}, status_code=400)
if mt_val < 1:
return JSONResponse({"error": "max_tokens must be >= 1"}, status_code=400)
updates["max_tokens"] = mt_val
if "reasoning_effort" in body:
raw_re = body["reasoning_effort"]
if raw_re is None:
updates["reasoning_effort"] = None
else:
re_val = str(raw_re).strip()
if not re_val:
updates["reasoning_effort"] = None
elif re_val not in _REASONING_EFFORT_CHOICES:
return JSONResponse(
{"error": f"Invalid reasoning_effort: {re_val!r}"},
status_code=400,
)
else:
updates["reasoning_effort"] = re_val
if updates:
storage.update_model_definition(definition_id, **updates)
audit_uid, ip = _audit_context(request)
audit_detail = dict(updates)
if "api_key" in audit_detail:
audit_detail["api_key"] = "(updated)"
record_audit(
storage,
audit_uid,
"model_definition.update",
"model_definition",
definition_id,
audit_detail,
ip,
)
if updates:
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
model_def = storage.get_model_definition(definition_id)
return JSONResponse(_mask_model_secrets(model_def or {}))
async def admin_delete_model_definition(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/model-definitions/{definition_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
definition_id = request.path_params["definition_id"]
existing = storage.get_model_definition(definition_id)
if existing is None:
return JSONResponse({"error": "Model definition not found"}, status_code=404)
storage.delete_model_definition(definition_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"model_definition.delete",
"model_definition",
definition_id,
{"alias": existing.get("alias", "")},
ip,
)
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
return JSONResponse({"status": "ok", "definition_id": definition_id})
async def admin_model_reload(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions/reload — tell nodes to re-read DB."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
# Ensure config (including model.default_alias) is fresh on all nodes
# before they rebuild their model registries.
await _publish_config_change(request)
# Refresh the console's own coord_registry first. The node fan-out
# below carries DB→nodes propagation; the console hosts coordinator
# sessions itself and must mutate its in-process registry too —
# otherwise the coord LLM keeps calling the prior model name even
# after a successful reload.
await asyncio.to_thread(_refresh_coord_registry, request.app.state, storage)
_emit_models_changed(request)
results = await _notify_nodes_model_reload(request)
return JSONResponse({"status": "ok", "results": results})
async def admin_detect_model(request: Request) -> JSONResponse:
"""POST /v1/api/admin/model-definitions/detect — stateless endpoint probe."""
import asyncio
from turnstone.core.auth import require_permission
from turnstone.core.model_registry import probe_model_endpoint
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.models")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
provider = str(body.get("provider", "openai")).strip()
base_url = str(body.get("base_url", "")).strip()
api_key = str(body.get("api_key", "")).strip()
model = str(body.get("model", "")).strip()
definition_id = str(body.get("definition_id", "")).strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
# Resolve api_key from DB when the UI sends the masked sentinel
if (not api_key or api_key == "***") and definition_id:
row = storage.get_model_definition(definition_id)
if row:
api_key = row.get("api_key", "")
if not base_url:
base_url = row.get("base_url", "")
# Apply provider default URL if still empty
if not base_url:
base_url = _PROVIDER_DEFAULT_URLS.get(provider, "")
# For commercial endpoints an api_key is required
_normalized = (base_url if "://" in base_url else f"https://{base_url}") if base_url else ""
_hostname = (urllib.parse.urlparse(_normalized).hostname or "") if _normalized else ""
if not api_key and (
not base_url
or _hostname == "api.openai.com"
or _hostname.endswith(".openai.com")
or _hostname == "api.anthropic.com"
or _hostname.endswith(".anthropic.com")
or _hostname.endswith(".googleapis.com")
):
return JSONResponse({"error": "api_key is required"}, status_code=400)
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(
None, probe_model_endpoint, provider, base_url, api_key, model
)
return JSONResponse(result)
async def admin_model_capabilities(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-capabilities — static capability lookup."""
from turnstone.core.auth import require_permission
from turnstone.core.providers import lookup_model_capabilities
err = require_permission(request, "admin.models")
if err:
return err
provider = request.query_params.get("provider", "").strip()
model = request.query_params.get("model", "").strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
if not model:
return JSONResponse({"error": "model is required"}, status_code=400)
caps = lookup_model_capabilities(provider, model)
return JSONResponse(
{
"model": model,
"provider": provider,
"known": caps is not None,
"capabilities": caps or {},
}
)
async def admin_known_models(request: Request) -> JSONResponse:
"""GET /v1/api/admin/model-capabilities/known — list known model name prefixes."""
from turnstone.core.auth import require_permission
from turnstone.core.providers import list_known_models
err = require_permission(request, "admin.models")
if err:
return err
provider = request.query_params.get("provider", "").strip()
if provider not in _MODEL_PROVIDERS:
return JSONResponse({"error": f"Unknown provider: {provider!r}"}, status_code=400)
return JSONResponse({"provider": provider, "models": list_known_models(provider)})
# ---------------------------------------------------------------------------
# TLS endpoints
# ---------------------------------------------------------------------------
async def tls_ca_cert(request: Request) -> Response:
"""GET /v1/api/admin/tls/ca.pem — Download CA root certificate."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.settings")
if err:
return err
mgr = getattr(request.app.state, "tls_manager", None)
if mgr is None or not mgr.ca_initialized:
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
return Response(
content=mgr.get_root_cert_pem(),
media_type="application/x-pem-file",
headers={"Content-Disposition": "attachment; filename=turnstone-ca.pem"},
)
# ---------------------------------------------------------------------------
# Admin: Prompt Policies (system message composition)
# ---------------------------------------------------------------------------
async def admin_list_prompt_policies(request: Request) -> JSONResponse:
"""GET /v1/api/admin/prompt-policies — list all prompt policies."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
if err:
return err
policies = storage.list_prompt_policies()
return JSONResponse({"policies": policies})
async def admin_create_prompt_policy(request: Request) -> JSONResponse:
"""POST /v1/api/admin/prompt-policies — create a prompt policy."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:64]
content = str(body.get("content", "")).strip()[:32768]
if not name:
return JSONResponse({"error": "name is required"}, status_code=400)
if not content:
return JSONResponse({"error": "content is required"}, status_code=400)
try:
priority = int(body.get("priority", 0))
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
policy_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
storage.upsert_prompt_policy(
{
"policy_id": policy_id,
"name": name,
"content": content,
"tool_gate": str(body.get("tool_gate", "")).strip(),
"priority": priority,
"enabled": bool(body.get("enabled", True)),
"org_id": str(body.get("org_id", "")).strip(),
"created_by": audit_uid,
}
)
record_audit(
storage,
audit_uid,
"prompt_policy.create",
"prompt_policy",
policy_id,
{"name": name},
ip,
)
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
async def admin_get_prompt_policy(request: Request) -> JSONResponse:
"""GET /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
if err:
return err
policy_id = request.path_params["policy_id"]
policy = storage.get_prompt_policy(policy_id)
if policy is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
return JSONResponse(policy)
async def admin_update_prompt_policy(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_prompt_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
update = dict(body)
update["policy_id"] = policy_id
if "name" in update:
update["name"] = str(update["name"]).strip()[:64]
if "priority" in update:
try:
update["priority"] = int(update["priority"])
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
if "content" in update:
update["content"] = str(update["content"]).strip()[:32768]
if "tool_gate" in update:
update["tool_gate"] = str(update["tool_gate"] or "").strip()
if "enabled" in update:
update["enabled"] = bool(update["enabled"])
storage.upsert_prompt_policy(update)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"prompt_policy.update",
"prompt_policy",
policy_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse(storage.get_prompt_policy(policy_id) or {})
async def admin_delete_prompt_policy(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/prompt-policies/{policy_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.prompt_policies")
if err:
return err
policy_id = request.path_params["policy_id"]
existing = storage.get_prompt_policy(policy_id)
if existing is None:
return JSONResponse({"error": "Prompt policy not found"}, status_code=404)
storage.delete_prompt_policy(policy_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"prompt_policy.delete",
"prompt_policy",
policy_id,
{"name": existing.get("name", "")},
ip,
)
return JSONResponse({"status": "ok", "policy_id": policy_id})
# ---------------------------------------------------------------------------
# Admin: Judge (heuristic rules, output guard patterns, settings)
# ---------------------------------------------------------------------------
_JUDGE_RULE_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
_VALID_RISK_LEVELS = frozenset({"critical", "high", "medium", "low"})
_VALID_OG_RISK_LEVELS = frozenset({"high", "medium", "low"}) # no "critical" in output guard
_VALID_RECOMMENDATIONS = frozenset({"approve", "review", "deny"})
_VALID_TIERS = frozenset({"critical", "high", "medium", "low"})
_VALID_CATEGORIES = frozenset(
{
"prompt_injection",
"credentials",
"encoded_payloads",
"adversarial_urls",
"info_disclosure",
}
)
_VALID_PATTERN_FLAGS = frozenset({"IGNORECASE", "MULTILINE", "DOTALL"})
_FLAG_NAME_RE = re.compile(r"^[a-z][a-z_]*$")
def _validate_regex_pattern(pattern: str, flags: int = 0) -> str | None:
"""Validate a regex pattern. Returns error message or None if valid.
Compiles the pattern with the given flags, then probes against several
test strings with a timeout to detect catastrophic backtracking.
"""
try:
compiled = re.compile(pattern, flags)
except re.error as exc:
return f"Invalid regex: {exc}"
# Probe against several string shapes to detect catastrophic backtracking.
test_strings = ["a" * 1000, "b" * 30 + "!", "A1b2C3" * 100]
def _probe() -> None:
for s in test_strings:
compiled.search(s)
try:
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError as FuturesTimeout
pool = ThreadPoolExecutor(max_workers=1)
try:
pool.submit(_probe).result(timeout=0.5)
except FuturesTimeout:
return "Regex appears to have catastrophic backtracking"
finally:
pool.shutdown(wait=False, cancel_futures=True)
except Exception:
return "Regex caused an error during test"
return None
# -- Judge settings ---------------------------------------------------------
async def admin_list_judge_settings(request: Request) -> JSONResponse:
"""GET /v1/api/admin/judge/settings — list judge settings with schema."""
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS, deserialize_value
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
stored = {r["key"]: r for r in storage.list_system_settings() if r.get("node_id", "") == ""}
result: list[dict[str, Any]] = []
for key, defn in sorted(SETTINGS.items()):
if not key.startswith("judge."):
continue
row = stored.get(key)
if row:
try:
val = deserialize_value(key, row["value"])
except (ValueError, KeyError):
val = row["value"]
entry = {
"key": key,
"type": defn.type,
"default": defn.default,
"description": defn.description,
"help": defn.help,
"value": "***" if defn.is_secret else val,
"source": "storage",
"is_secret": defn.is_secret,
"min_value": defn.min_value,
"max_value": defn.max_value,
"choices": defn.choices,
"restart_required": defn.restart_required,
}
else:
entry = {
"key": key,
"type": defn.type,
"default": defn.default,
"description": defn.description,
"help": defn.help,
"value": "***" if defn.is_secret else defn.default,
"source": "default",
"is_secret": defn.is_secret,
"min_value": defn.min_value,
"max_value": defn.max_value,
"choices": defn.choices,
"restart_required": defn.restart_required,
}
result.append(entry)
return JSONResponse({"settings": result})
async def admin_update_judge_setting(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/judge/settings/{key} — update a judge setting."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
key = request.path_params["key"]
if not key.startswith("judge."):
return JSONResponse({"error": "Only judge.* settings allowed"}, status_code=400)
defn = SETTINGS.get(key)
if defn is None:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
value = body.get("value")
if value is None:
return JSONResponse({"error": "value is required"}, status_code=400)
# Use ConfigStore for validation and persistence
config_store = getattr(request.app.state, "config_store", None)
if config_store is None:
return JSONResponse({"error": "ConfigStore not available"}, status_code=503)
# Handle secret sentinel
if defn.is_secret and value == "***":
return JSONResponse({"status": "ok", "key": key, "value": "***"})
audit_uid, ip = _audit_context(request)
try:
config_store.set(key, value, changed_by=audit_uid)
except (ValueError, TypeError) as exc:
return JSONResponse({"error": str(exc)}, status_code=400)
record_audit(
storage,
audit_uid,
"setting.update",
"setting",
key,
{"value": "***" if defn.is_secret else value},
ip,
)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
effective = config_store.get(key, defn.default)
return JSONResponse(
{"status": "ok", "key": key, "value": "***" if defn.is_secret else effective}
)
async def admin_delete_judge_setting(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/judge/settings/{key} — reset a judge setting to default."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.settings_registry import SETTINGS
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
key = request.path_params["key"]
if not key.startswith("judge."):
return JSONResponse({"error": "Only judge.* settings allowed"}, status_code=400)
defn = SETTINGS.get(key)
if defn is None:
return JSONResponse({"error": f"Unknown setting: {key}"}, status_code=404)
config_store = getattr(request.app.state, "config_store", None)
if config_store:
config_store.delete(key)
audit_uid, ip = _audit_context(request)
record_audit(storage, audit_uid, "setting.delete", "setting", key, {}, ip)
await _publish_config_change(request)
if key in _MODEL_AFFECTING_SETTING_KEYS:
_emit_models_changed(request)
return JSONResponse({"status": "ok", "key": key, "default": defn.default})
# -- Heuristic rules -------------------------------------------------------
async def admin_list_heuristic_rules(request: Request) -> JSONResponse:
"""GET /v1/api/admin/judge/heuristic-rules — list merged heuristic rules."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
# Get DB rules
db_rules = storage.list_heuristic_rules()
# Get built-in rules
from turnstone.core.judge import _HEURISTIC_RULES
# Build merged list
result: list[dict[str, Any]] = []
# Start with DB rules
seen_names: set[str] = set()
for row in db_rules:
name = row["name"]
seen_names.add(name)
entry = dict(row)
if row.get("builtin"):
entry["source"] = "builtin-overridden" if row.get("enabled") else "builtin-disabled"
else:
entry["source"] = "db"
result.append(entry)
# Add built-ins not overridden in DB
import json as _json
for rule in _HEURISTIC_RULES:
if rule.name not in seen_names:
result.append(
{
"rule_id": "",
"name": rule.name,
"risk_level": rule.risk_level,
"confidence": rule.confidence,
"recommendation": rule.recommendation,
"tool_pattern": rule.tool_pattern,
"arg_patterns": _json.dumps(rule.arg_patterns),
"intent_template": rule.intent_template,
"reasoning_template": rule.reasoning_template,
"tier": rule.risk_level,
"priority": 0,
"builtin": True,
"enabled": True,
"source": "builtin",
"created_by": "",
"created": "",
"updated": "",
}
)
return JSONResponse({"rules": result})
async def admin_create_heuristic_rule(request: Request) -> JSONResponse:
"""POST /v1/api/admin/judge/heuristic-rules — create a heuristic rule."""
import json as _json
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:64]
if not name or not _JUDGE_RULE_NAME_RE.match(name):
return JSONResponse(
{"error": "name must match [a-z][a-z0-9_-]* (max 64 chars)"},
status_code=400,
)
# Check uniqueness
if storage.get_heuristic_rule_by_name(name):
return JSONResponse({"error": f"Rule with name '{name}' already exists"}, status_code=409)
risk_level = str(body.get("risk_level", "medium"))
if risk_level not in _VALID_RISK_LEVELS:
return JSONResponse(
{"error": f"risk_level must be one of {sorted(_VALID_RISK_LEVELS)}"}, status_code=400
)
recommendation = str(body.get("recommendation", "review"))
if recommendation not in _VALID_RECOMMENDATIONS:
return JSONResponse(
{"error": f"recommendation must be one of {sorted(_VALID_RECOMMENDATIONS)}"},
status_code=400,
)
tier = str(body.get("tier", risk_level))
if tier not in _VALID_TIERS:
return JSONResponse(
{"error": f"tier must be one of {sorted(_VALID_TIERS)}"}, status_code=400
)
try:
confidence = float(body.get("confidence", 0.7))
if not 0.0 <= confidence <= 1.0:
raise ValueError
except (ValueError, TypeError):
return JSONResponse(
{"error": "confidence must be a float between 0.0 and 1.0"}, status_code=400
)
tool_pattern = str(body.get("tool_pattern", "*"))
if not tool_pattern:
return JSONResponse({"error": "tool_pattern is required"}, status_code=400)
# Validate arg_patterns
arg_patterns = body.get("arg_patterns", [])
if isinstance(arg_patterns, str):
try:
arg_patterns = _json.loads(arg_patterns)
except _json.JSONDecodeError:
return JSONResponse({"error": "arg_patterns must be a JSON array"}, status_code=400)
if not isinstance(arg_patterns, list):
return JSONResponse({"error": "arg_patterns must be a list"}, status_code=400)
for i, pat in enumerate(arg_patterns):
err_msg = _validate_regex_pattern(str(pat))
if err_msg:
return JSONResponse({"error": f"arg_patterns[{i}]: {err_msg}"}, status_code=400)
try:
priority = int(body.get("priority", 0))
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
rule_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
storage.create_heuristic_rule(
rule_id=rule_id,
name=name,
risk_level=risk_level,
confidence=confidence,
recommendation=recommendation,
tool_pattern=tool_pattern,
arg_patterns=_json.dumps(arg_patterns),
intent_template=str(body.get("intent_template", "")),
reasoning_template=str(body.get("reasoning_template", "")),
tier=tier,
priority=priority,
builtin=bool(body.get("builtin", False)),
enabled=bool(body.get("enabled", True)),
created_by=audit_uid,
)
record_audit(
storage, audit_uid, "heuristic_rule.create", "heuristic_rule", rule_id, {"name": name}, ip
)
# Reload rule registry
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse(storage.get_heuristic_rule(rule_id) or {}, status_code=201)
async def admin_get_heuristic_rule(request: Request) -> JSONResponse:
"""GET /v1/api/admin/judge/heuristic-rules/{rule_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
rule_id = request.path_params["rule_id"]
rule = storage.get_heuristic_rule(rule_id)
if rule is None:
return JSONResponse({"error": "Heuristic rule not found"}, status_code=404)
return JSONResponse(rule)
async def admin_update_heuristic_rule(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/judge/heuristic-rules/{rule_id}."""
import json as _json
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
rule_id = request.path_params["rule_id"]
existing = storage.get_heuristic_rule(rule_id)
if existing is None:
return JSONResponse({"error": "Heuristic rule not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
fields: dict[str, Any] = {}
if "name" in body:
name = str(body["name"]).strip()[:64]
if not _JUDGE_RULE_NAME_RE.match(name):
return JSONResponse({"error": "name must match [a-z][a-z0-9_-]*"}, status_code=400)
existing_by_name = storage.get_heuristic_rule_by_name(name)
if existing_by_name and existing_by_name.get("rule_id") != rule_id:
return JSONResponse(
{"error": f"Rule with name '{name}' already exists"}, status_code=409
)
fields["name"] = name
if "risk_level" in body:
if body["risk_level"] not in _VALID_RISK_LEVELS:
return JSONResponse(
{"error": f"risk_level must be one of {sorted(_VALID_RISK_LEVELS)}"},
status_code=400,
)
fields["risk_level"] = body["risk_level"]
if "recommendation" in body:
if body["recommendation"] not in _VALID_RECOMMENDATIONS:
return JSONResponse(
{"error": f"recommendation must be one of {sorted(_VALID_RECOMMENDATIONS)}"},
status_code=400,
)
fields["recommendation"] = body["recommendation"]
if "tier" in body:
if body["tier"] not in _VALID_TIERS:
return JSONResponse(
{"error": f"tier must be one of {sorted(_VALID_TIERS)}"}, status_code=400
)
fields["tier"] = body["tier"]
if "confidence" in body:
try:
conf = float(body["confidence"])
if not 0.0 <= conf <= 1.0:
raise ValueError
fields["confidence"] = conf
except (ValueError, TypeError):
return JSONResponse({"error": "confidence must be 0.0-1.0"}, status_code=400)
if "tool_pattern" in body:
fields["tool_pattern"] = str(body["tool_pattern"])
if "arg_patterns" in body:
ap = body["arg_patterns"]
if isinstance(ap, str):
try:
ap = _json.loads(ap)
except _json.JSONDecodeError:
return JSONResponse({"error": "arg_patterns must be a JSON array"}, status_code=400)
if not isinstance(ap, list):
return JSONResponse({"error": "arg_patterns must be a list"}, status_code=400)
for i, pat in enumerate(ap):
err_msg = _validate_regex_pattern(str(pat))
if err_msg:
return JSONResponse({"error": f"arg_patterns[{i}]: {err_msg}"}, status_code=400)
fields["arg_patterns"] = _json.dumps(ap)
if "intent_template" in body:
fields["intent_template"] = str(body["intent_template"])
if "reasoning_template" in body:
fields["reasoning_template"] = str(body["reasoning_template"])
if "priority" in body:
try:
fields["priority"] = int(body["priority"])
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
if "builtin" in body:
fields["builtin"] = bool(body["builtin"])
if "enabled" in body:
fields["enabled"] = bool(body["enabled"])
if fields:
storage.update_heuristic_rule(rule_id, **fields)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"heuristic_rule.update",
"heuristic_rule",
rule_id,
{"name": existing.get("name", "")},
ip,
)
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse(storage.get_heuristic_rule(rule_id) or {})
async def admin_delete_heuristic_rule(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/judge/heuristic-rules/{rule_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
rule_id = request.path_params["rule_id"]
existing = storage.get_heuristic_rule(rule_id)
if existing is None:
return JSONResponse({"error": "Heuristic rule not found"}, status_code=404)
storage.delete_heuristic_rule(rule_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"heuristic_rule.delete",
"heuristic_rule",
rule_id,
{"name": existing.get("name", "")},
ip,
)
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse({"status": "ok", "rule_id": rule_id})
# -- Output guard patterns --------------------------------------------------
async def admin_list_output_guard_patterns(request: Request) -> JSONResponse:
"""GET /v1/api/admin/judge/output-guard-patterns — list merged output guard patterns."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
# Get DB patterns
db_patterns = storage.list_output_guard_patterns()
# Get built-in patterns
from turnstone.core.output_guard import _BUILTIN_OG_PATTERNS
# Build merged list
result: list[dict[str, Any]] = []
seen_names: set[str] = set()
for row in db_patterns:
name = row["name"]
seen_names.add(name)
entry = dict(row)
if row.get("builtin"):
entry["source"] = "builtin-overridden" if row.get("enabled") else "builtin-disabled"
else:
entry["source"] = "db"
result.append(entry)
# Add built-ins not overridden in DB
import re as _re
_flags_reverse = {
_re.IGNORECASE: "IGNORECASE",
_re.MULTILINE: "MULTILINE",
_re.DOTALL: "DOTALL",
}
for pat in _BUILTIN_OG_PATTERNS:
if pat.name not in seen_names:
# Derive pattern_flags from compiled regex so overrides preserve them
pf = ",".join(n for f, n in _flags_reverse.items() if pat.compiled.flags & f)
result.append(
{
"pattern_id": "",
"name": pat.name,
"category": pat.category,
"risk_level": pat.risk_level,
"pattern": pat.compiled.pattern,
"pattern_flags": pf,
"flag_name": pat.flag_name,
"annotation": pat.annotation,
"is_credential": pat.is_credential,
"redact_label": pat.redact_label,
"priority": pat.priority,
"builtin": True,
"enabled": True,
"source": "builtin",
"created_by": "",
"created": "",
"updated": "",
}
)
return JSONResponse({"patterns": result})
async def admin_create_output_guard_pattern(request: Request) -> JSONResponse:
"""POST /v1/api/admin/judge/output-guard-patterns — create an output guard pattern."""
import uuid
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
name = str(body.get("name", "")).strip()[:64]
if not name or not _JUDGE_RULE_NAME_RE.match(name):
return JSONResponse(
{"error": "name must match [a-z][a-z0-9_-]* (max 64 chars)"},
status_code=400,
)
# Check uniqueness
if storage.get_output_guard_pattern_by_name(name):
return JSONResponse(
{"error": f"Pattern with name '{name}' already exists"}, status_code=409
)
# Validate pattern_flags first (needed for pattern validation)
pattern_flags_raw = body.get("pattern_flags", "")
if isinstance(pattern_flags_raw, list):
pattern_flags_list = pattern_flags_raw
elif isinstance(pattern_flags_raw, str) and pattern_flags_raw:
pattern_flags_list = [f.strip() for f in pattern_flags_raw.split(",") if f.strip()]
else:
pattern_flags_list = []
re_flags = 0
for flag in pattern_flags_list:
if flag not in _VALID_PATTERN_FLAGS:
return JSONResponse(
{
"error": f"Invalid pattern_flag '{flag}'; must be one of {sorted(_VALID_PATTERN_FLAGS)}"
},
status_code=400,
)
re_flags |= {"IGNORECASE": re.IGNORECASE, "MULTILINE": re.MULTILINE, "DOTALL": re.DOTALL}[
flag
]
pattern_flags = ",".join(pattern_flags_list)
pattern = str(body.get("pattern", ""))
if not pattern:
return JSONResponse({"error": "pattern is required"}, status_code=400)
err_msg = _validate_regex_pattern(pattern, re_flags)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
category = str(body.get("category", ""))
if category not in _VALID_CATEGORIES:
return JSONResponse(
{"error": f"category must be one of {sorted(_VALID_CATEGORIES)}"}, status_code=400
)
risk_level = str(body.get("risk_level", "medium"))
if risk_level not in _VALID_OG_RISK_LEVELS:
return JSONResponse(
{"error": f"risk_level must be one of {sorted(_VALID_OG_RISK_LEVELS)}"},
status_code=400,
)
flag_name = str(body.get("flag_name", ""))
if not flag_name or not _FLAG_NAME_RE.match(flag_name):
return JSONResponse({"error": "flag_name must match [a-z][a-z_]*"}, status_code=400)
annotation = str(body.get("annotation", ""))
is_credential = bool(body.get("is_credential", False))
redact_label = str(body.get("redact_label", ""))
if is_credential and not redact_label:
return JSONResponse(
{"error": "redact_label is required when is_credential is true"}, status_code=400
)
try:
priority = int(body.get("priority", 0))
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
pattern_id = uuid.uuid4().hex
audit_uid, ip = _audit_context(request)
storage.create_output_guard_pattern(
pattern_id=pattern_id,
name=name,
category=category,
risk_level=risk_level,
pattern=pattern,
flag_name=flag_name,
annotation=annotation,
pattern_flags=pattern_flags,
is_credential=is_credential,
redact_label=redact_label,
priority=priority,
builtin=bool(body.get("builtin", False)),
enabled=bool(body.get("enabled", True)),
created_by=audit_uid,
)
record_audit(
storage,
audit_uid,
"output_guard_pattern.create",
"output_guard_pattern",
pattern_id,
{"name": name},
ip,
)
# Reload rule registry
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse(storage.get_output_guard_pattern(pattern_id) or {}, status_code=201)
async def admin_get_output_guard_pattern(request: Request) -> JSONResponse:
"""GET /v1/api/admin/judge/output-guard-patterns/{pattern_id}."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
pattern_id = request.path_params["pattern_id"]
pattern = storage.get_output_guard_pattern(pattern_id)
if pattern is None:
return JSONResponse({"error": "Output guard pattern not found"}, status_code=404)
return JSONResponse(pattern)
async def admin_update_output_guard_pattern(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/judge/output-guard-patterns/{pattern_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
pattern_id = request.path_params["pattern_id"]
existing = storage.get_output_guard_pattern(pattern_id)
if existing is None:
return JSONResponse({"error": "Output guard pattern not found"}, status_code=404)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
fields: dict[str, Any] = {}
if "name" in body:
name = str(body["name"]).strip()[:64]
if not _JUDGE_RULE_NAME_RE.match(name):
return JSONResponse({"error": "name must match [a-z][a-z0-9_-]*"}, status_code=400)
existing_by_name = storage.get_output_guard_pattern_by_name(name)
if existing_by_name and existing_by_name.get("pattern_id") != pattern_id:
return JSONResponse(
{"error": f"Pattern with name '{name}' already exists"}, status_code=409
)
fields["name"] = name
# Resolve pattern_flags first (needed for pattern validation)
re_flags = 0
if "pattern_flags" in body:
pf_raw = body["pattern_flags"]
if isinstance(pf_raw, list):
pf_list = pf_raw
elif isinstance(pf_raw, str) and pf_raw:
pf_list = [f.strip() for f in pf_raw.split(",") if f.strip()]
else:
pf_list = []
for flag in pf_list:
if flag not in _VALID_PATTERN_FLAGS:
return JSONResponse(
{
"error": f"Invalid pattern_flag '{flag}'; must be one of {sorted(_VALID_PATTERN_FLAGS)}"
},
status_code=400,
)
for flag in pf_list:
re_flags |= {
"IGNORECASE": re.IGNORECASE,
"MULTILINE": re.MULTILINE,
"DOTALL": re.DOTALL,
}[flag]
fields["pattern_flags"] = ",".join(pf_list)
if "pattern" in body:
pattern = str(body["pattern"])
err_msg = _validate_regex_pattern(pattern, re_flags)
if err_msg:
return JSONResponse({"error": err_msg}, status_code=400)
fields["pattern"] = pattern
if "category" in body:
if body["category"] not in _VALID_CATEGORIES:
return JSONResponse(
{"error": f"category must be one of {sorted(_VALID_CATEGORIES)}"}, status_code=400
)
fields["category"] = body["category"]
if "risk_level" in body:
if body["risk_level"] not in _VALID_OG_RISK_LEVELS:
return JSONResponse(
{"error": f"risk_level must be one of {sorted(_VALID_OG_RISK_LEVELS)}"},
status_code=400,
)
fields["risk_level"] = body["risk_level"]
if "flag_name" in body:
fn = str(body["flag_name"])
if not _FLAG_NAME_RE.match(fn):
return JSONResponse({"error": "flag_name must match [a-z][a-z_]*"}, status_code=400)
fields["flag_name"] = fn
if "annotation" in body:
fields["annotation"] = str(body["annotation"])
if "is_credential" in body:
fields["is_credential"] = bool(body["is_credential"])
if "redact_label" in body:
fields["redact_label"] = str(body["redact_label"])
# Cross-field validation: is_credential requires redact_label
final_is_cred = fields.get("is_credential", existing.get("is_credential", False))
final_redact = fields.get("redact_label", existing.get("redact_label", ""))
if final_is_cred and not final_redact:
return JSONResponse(
{"error": "redact_label is required when is_credential is true"}, status_code=400
)
if "priority" in body:
try:
fields["priority"] = int(body["priority"])
except (ValueError, TypeError):
return JSONResponse({"error": "priority must be an integer"}, status_code=400)
if "builtin" in body:
fields["builtin"] = bool(body["builtin"])
if "enabled" in body:
fields["enabled"] = bool(body["enabled"])
if fields:
storage.update_output_guard_pattern(pattern_id, **fields)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"output_guard_pattern.update",
"output_guard_pattern",
pattern_id,
{"name": existing.get("name", "")},
ip,
)
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse(storage.get_output_guard_pattern(pattern_id) or {})
async def admin_delete_output_guard_pattern(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/judge/output-guard-patterns/{pattern_id}."""
from turnstone.core.audit import record_audit
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
storage, err = require_storage_or_503(request)
if err:
return err
err = require_permission(request, "admin.judge")
if err:
return err
pattern_id = request.path_params["pattern_id"]
existing = storage.get_output_guard_pattern(pattern_id)
if existing is None:
return JSONResponse({"error": "Output guard pattern not found"}, status_code=404)
storage.delete_output_guard_pattern(pattern_id)
audit_uid, ip = _audit_context(request)
record_audit(
storage,
audit_uid,
"output_guard_pattern.delete",
"output_guard_pattern",
pattern_id,
{"name": existing.get("name", "")},
ip,
)
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse({"status": "ok", "pattern_id": pattern_id})
# -- Judge utility endpoints ------------------------------------------------
async def admin_judge_reload(request: Request) -> JSONResponse:
"""POST /v1/api/admin/judge/reload — reload rule registry on all nodes."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.judge")
if err:
return err
rule_registry = getattr(request.app.state, "rule_registry", None)
if rule_registry:
rule_registry.reload()
await _publish_config_change(request)
return JSONResponse({"status": "ok"})
async def admin_validate_regex(request: Request) -> JSONResponse:
"""POST /v1/api/admin/judge/validate-regex — test-compile a regex."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400
err = require_permission(request, "admin.judge")
if err:
return err
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
pattern = str(body.get("pattern", ""))
if not pattern:
return JSONResponse({"error": "pattern is required"}, status_code=400)
err_msg = _validate_regex_pattern(pattern)
if err_msg:
return JSONResponse({"valid": False, "error": err_msg})
return JSONResponse({"valid": True})
def _validate_node_id(node_id: str) -> JSONResponse | None:
"""Return an error response if node_id is invalid, else None."""
if not node_id or len(node_id) > 256 or not _VALID_NODE_ID.match(node_id):
return JSONResponse({"error": "Invalid node ID"}, status_code=400)
return None
async def admin_get_all_node_metadata(request: Request) -> JSONResponse:
"""GET /v1/api/admin/node-metadata — metadata for all nodes."""
import json as _anm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
storage, serr = require_storage_or_503(request)
if serr:
return serr
all_meta = storage.get_all_node_metadata()
result: dict[str, list[dict[str, Any]]] = {}
for nid, rows in all_meta.items():
entries = []
for r in rows:
try:
val = _anm_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
entries.append({"key": r["key"], "value": val, "source": r["source"]})
result[nid] = entries
return JSONResponse({"nodes": result})
async def admin_get_node_metadata(request: Request) -> JSONResponse:
"""GET /v1/api/admin/nodes/{node_id}/metadata — all metadata for a node."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
storage, serr = require_storage_or_503(request)
if serr:
return serr
rows = storage.get_node_metadata(node_id)
metadata = []
for r in rows:
try:
val = _nm_json.loads(r["value"])
except (ValueError, TypeError):
val = r["value"]
metadata.append({"key": r["key"], "value": val, "source": r["source"]})
return JSONResponse({"node_id": node_id, "metadata": metadata})
async def admin_set_node_metadata(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/nodes/{node_id}/metadata — bulk set user metadata."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
entries = body.get("entries", [])
if not entries:
return JSONResponse({"error": "No entries provided"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
# Validate entries
existing = {r["key"]: r["source"] for r in storage.get_node_metadata(node_id)}
for e in entries:
key = e.get("key", "")
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
if len(key) > 128:
return JSONResponse(
{"error": f"Key too long (max 128): {key[:32]}..."}, status_code=400
)
if "value" not in e:
return JSONResponse({"error": f"Missing value for key: {key}"}, status_code=400)
if existing.get(key) == "auto":
return JSONResponse(
{"error": f"Cannot overwrite auto-populated key: {key}"},
status_code=400,
)
bulk = [(e["key"], _nm_json.dumps(e["value"]), "user") for e in entries]
storage.set_node_metadata_bulk(node_id, bulk)
return JSONResponse({"ok": True, "count": len(bulk)})
async def admin_set_node_metadata_key(request: Request) -> JSONResponse:
"""PUT /v1/api/admin/nodes/{node_id}/metadata/{key} — set single key."""
import json as _nm_json
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import read_json_or_400, require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
key = request.path_params["key"]
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
if len(key) > 128:
return JSONResponse({"error": "Key too long (max 128)"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
existing = storage.get_node_metadata(node_id)
for r in existing:
if r["key"] == key and r["source"] == "auto":
return JSONResponse(
{"error": f"Cannot overwrite auto-populated key: {key}"},
status_code=400,
)
body = await read_json_or_400(request)
if isinstance(body, JSONResponse):
return body
if "value" not in body:
return JSONResponse({"error": "Missing value"}, status_code=400)
storage.set_node_metadata(node_id, key, _nm_json.dumps(body["value"]), source="user")
return JSONResponse({"ok": True})
async def admin_delete_node_metadata_key(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/nodes/{node_id}/metadata/{key} — delete single key."""
from turnstone.core.auth import require_permission
from turnstone.core.web_helpers import require_storage_or_503
err = require_permission(request, "admin.nodes")
if err:
return err
node_id = request.path_params["node_id"]
nv = _validate_node_id(node_id)
if nv:
return nv
key = request.path_params["key"]
if not key:
return JSONResponse({"error": "Empty key"}, status_code=400)
storage, serr = require_storage_or_503(request)
if serr:
return serr
existing = storage.get_node_metadata(node_id)
for r in existing:
if r["key"] == key and r["source"] == "auto":
return JSONResponse(
{"error": f"Cannot delete auto-populated key: {key}"},
status_code=400,
)
deleted = storage.delete_node_metadata(node_id, key)
if not deleted:
return JSONResponse({"error": "Key not found"}, status_code=404)
return JSONResponse({"ok": True})
async def tls_ca_status(request: Request) -> JSONResponse:
"""GET /v1/api/admin/tls/ca — CA status."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.settings")
if err:
return err
mgr = getattr(request.app.state, "tls_manager", None)
if mgr is None or not mgr.ca_initialized:
return JSONResponse({"enabled": False})
from turnstone.console.tls import _CA_CN
certs = mgr.list_certs()
return JSONResponse(
{
"enabled": True,
"ca_cn": _CA_CN,
"cert_count": len(certs),
"certs": [
{
"domain": c.domain,
"issued_at": c.issued_at.isoformat(),
"expires_at": c.expires_at.isoformat(),
}
for c in certs
],
},
)
async def tls_list_certs(request: Request) -> JSONResponse:
"""GET /v1/api/admin/tls/certs — List issued certificates."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.settings")
if err:
return err
mgr = getattr(request.app.state, "tls_manager", None)
if mgr is None or not mgr.ca_initialized:
return JSONResponse({"certs": []})
certs = mgr.list_certs()
return JSONResponse(
{
"certs": [
{
"domain": c.domain,
"domains": list(c.domains),
"issued_at": c.issued_at.isoformat(),
"expires_at": c.expires_at.isoformat(),
}
for c in certs
],
},
)
async def tls_renew_cert(request: Request) -> JSONResponse:
"""POST /v1/api/admin/tls/certs/{domain}/renew — Force cert renewal."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.settings")
if err:
return err
mgr = getattr(request.app.state, "tls_manager", None)
if mgr is None or not mgr.ca_initialized:
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
domain = request.path_params["domain"]
try:
bundle = mgr.renew_cert(domain)
return JSONResponse(
{
"domain": bundle.domain,
"issued_at": bundle.issued_at.isoformat(),
"expires_at": bundle.expires_at.isoformat(),
},
)
except ValueError as e:
return JSONResponse({"error": str(e)}, status_code=404)
except Exception as e:
return JSONResponse({"error": str(e)}, status_code=500)
async def tls_delete_cert(request: Request) -> JSONResponse:
"""DELETE /v1/api/admin/tls/certs/{domain} — Delete a certificate."""
from turnstone.core.auth import require_permission
err = require_permission(request, "admin.settings")
if err:
return err
mgr = getattr(request.app.state, "tls_manager", None)
if mgr is None or not mgr.ca_initialized:
return JSONResponse({"error": "TLS not enabled"}, status_code=404)
domain = request.path_params["domain"]
if not mgr.delete_cert(domain):
return JSONResponse({"error": f"No cert for {domain}"}, status_code=404)
return JSONResponse({"deleted": domain})
# ---------------------------------------------------------------------------
# ConfigStore env seeding
# ---------------------------------------------------------------------------
def _seed_config_from_env(config_store: Any, storage: Any) -> None:
"""Seed ConfigStore settings from environment variables.
Checks for ``TURNSTONE_{SECTION}_{KEY}`` env vars and writes them
to ConfigStore if they aren't already set. This allows container
deployments to configure settings before the admin UI is available.
Only seeds known settings from the registry to avoid storing garbage.
Uses config_store.set() for proper validation, serialization, and
cache invalidation.
"""
from turnstone.core.settings_registry import SETTINGS
for key in SETTINGS:
env_name = "TURNSTONE_" + key.replace(".", "_").upper()
env_val = os.environ.get(env_name)
if env_val is None:
continue
# Only seed if not already stored (check raw storage to avoid
# config_store cache, which may not reflect DB state yet)
existing = storage.get_system_setting(key)
if existing is not None:
continue
try:
config_store.set(key, env_val, changed_by="env")
log.info("config.seeded_from_env: %s from %s", key, env_name)
except Exception:
log.warning("config.seed_failed: %s from %s", key, env_name, exc_info=True)
# ---------------------------------------------------------------------------
# App factory
# ---------------------------------------------------------------------------
def create_app(
*,
collector: ClusterCollector,
jwt_secret: str = "",
auth_storage: Any = None,
proxy_token_mgr: Any = None,
cors_origins: list[str] | None = None,
tls_manager: Any = None,
console_url: str = "",
router: ConsoleRouter | None = None,
console_metrics: ConsoleMetrics | None = None,
) -> Starlette:
"""Build the Starlette ASGI application for the console dashboard."""
_spec = build_console_spec()
_openapi_handler = make_openapi_handler(_spec)
_docs_handler = make_docs_handler()
# Coord workstream HTTP tree mounts under the unified
# ``/api/workstreams/`` shape. Lifted handlers (e.g. ``approve``,
# ``close``) capture the kind-specific ``SessionEndpointConfig``
# via the factory closure. Per-kind handlers (``coordinator_*``)
# still look the coord manager up via ``request.app.state.coord_mgr``
# at request time because the manager is built in the lifespan,
# after this app construction; future verb lifts carry that lookup
# into the config callable.
def _coord_attachment_owner(
request: Request, ws_id: str, mgr: Any
) -> tuple[str, JSONResponse | None]:
"""Resolve the attachment owner for a coord ws_id.
Kind-strict — only resolves through ``coord_mgr.get(ws_id)``
and DOES NOT fall back to storage. This keeps an
``admin.coordinator``-scoped caller from reading or mutating
attachments on **interactive** workstreams via the coord
attachment endpoints: the storage row for an interactive ws
would otherwise resolve cleanly through the generic
``get_workstream_owner`` storage call (which doesn't filter
by kind), granting cross-kind access. Persisted-but-not-loaded
coordinators must be ``open``ed before they can accept
attachment operations.
"""
from turnstone.core.web_helpers import auth_user_id
ws = mgr.get(ws_id)
if ws is None:
return "", JSONResponse({"error": "coordinator not found"}, status_code=404)
return ws.user_id or auth_user_id(request), None
from turnstone.core.attachments import (
classify_text_attachment as _coord_classify_text,
)
from turnstone.core.attachments import (
sniff_image_mime as _coord_sniff_image,
)
from turnstone.core.attachments import (
upload_lock as _coord_upload_lock,
)
coord_attachment_helpers = AttachmentUploadHelpers(
sniff_image_mime=_coord_sniff_image,
classify_text_attachment=_coord_classify_text,
upload_lock=_coord_upload_lock,
)
coord_endpoint_config = SessionEndpointConfig(
permission_gate=_require_admin_coordinator,
manager_lookup=_require_coord_mgr,
tenant_check=None, # cluster-wide admin.coordinator gate covers it
not_found_label="coordinator not found",
audit_action_prefix="coordinator",
supports_attachments=True,
attachment_owner_resolver=_coord_attachment_owner,
attachment_helpers=coord_attachment_helpers,
# Per-spawn counter writes — the rich ``ws_state`` payload
# cluster broadcast (PR #420) reads ``_ws_messages`` and
# resets ``_ws_turn_tool_calls`` per turn so coord rows render
# the same activity / per-turn counts interactive rows do.
# Judge verdicts on coord feed the console's /metrics endpoint
# via :class:`ConsoleCoordinatorUI._record_judge_metric` /
# ``on_intent_verdict``; this hook only owns the per-UI counter
# writes that match interactive's pattern.
spawn_metrics=_coord_spawn_metrics,
emit_message_queued=True,
events_replay=_coord_events_replay,
create_supports_attachments=True,
create_supports_user_id_override=False,
create_validate_request=_coord_create_validate_request,
create_build_kwargs=_coord_create_build_kwargs,
create_post_install=_coord_create_post_install,
# No alias surface on coord today — the lifted body falls
# back to ``ws.name`` when ``list_resolve_titles`` is None.
list_resolve_titles=None,
# Explicit kind classifier for the lifted list/saved factory's
# storage filter (drops the pre-fix ``audit_action_prefix``
# string compare that would have silently leaked interactive
# rows for any future kind).
list_kind=WorkstreamKind.COORDINATOR,
# Coord saved cards show only explicitly-closed coordinators —
# active / in-flight rows live in the active list and
# tombstones are non-resurrectable.
saved_state_filter="closed",
saved_loaded_lookup=_coord_saved_loaded_lookup,
# Isolate coord SSE polling on its own 200-thread pool so a
# handful of coord tabs (each parking a thread on
# ``client_queue.get``) can't starve the default executor and
# stall every other ``asyncio.to_thread`` caller (storage,
# router, audit). Mirrors the interactive endpoint's
# ``sse_executor_lookup`` wiring on ``interactive_endpoint_config``
# in ``turnstone/server.py``.
sse_executor_lookup=lambda request: request.app.state.coord_sse_executor,
)
coord_workstream_routes: list[Any] = []
register_session_routes(
coord_workstream_routes,
prefix="/api/workstreams",
handlers=SharedSessionVerbHandlers(
list_workstreams=make_list_handler(coord_endpoint_config), # lifted: shared body
list_saved=make_saved_handler(coord_endpoint_config), # lifted: shared body
create=make_create_handler( # lifted: shared body
coord_endpoint_config,
audit_emit=_audit_coordinator_create,
),
detail=make_detail_handler(coord_endpoint_config), # lifted: shared body
open=make_open_handler(coord_endpoint_config), # lifted: shared body
close=make_close_handler( # lifted: shared body
coord_endpoint_config,
audit_emit=_audit_close_coordinator,
supports_close_reason=False,
),
send=make_send_handler(coord_endpoint_config), # lifted: shared body (P1.5)
dequeue=make_dequeue_handler(coord_endpoint_config), # lifted: shared body
approve=make_approve_handler(coord_endpoint_config), # lifted: shared body
cancel=make_cancel_handler( # lifted: shared body
coord_endpoint_config,
audit_emit=_audit_cancel_coordinator,
),
events=make_events_handler(coord_endpoint_config), # lifted: shared body
history=make_history_handler(coord_endpoint_config), # lifted: shared body
attachments=make_attachment_handlers(
coord_endpoint_config
), # lifted: shared body (P1.5)
),
)
register_coord_verbs(
coord_workstream_routes,
prefix="/api/workstreams",
handlers=CoordOnlyVerbHandlers(
children=coordinator_children,
tasks=coordinator_tasks,
metrics=coordinator_metrics,
trust=coordinator_trust,
restrict=coordinator_restrict,
stop_cascade=coordinator_stop_cascade,
close_all_children=coordinator_close_all_children,
),
)
app = Starlette(
routes=[
Route("/", index),
Mount(
"/v1",
routes=[
*coord_workstream_routes,
Route("/api/cluster/overview", cluster_overview),
Route("/api/cluster/nodes", cluster_nodes),
Route("/api/cluster/workstreams", cluster_workstreams),
Route("/api/cluster/workstreams/new", create_workstream, methods=["POST"]),
Route("/api/cluster/ws/live", cluster_ws_live_bulk),
Route("/api/cluster/ws/{ws_id}/detail", cluster_ws_detail),
Route("/api/cluster/node/{node_id}", cluster_node_detail),
Route("/api/cluster/snapshot", cluster_snapshot),
Route("/api/cluster/events", cluster_events_sse),
# Workstream routing (rendezvous proxy to server nodes)
Route("/api/route/workstreams/new", route_create, methods=["POST"]),
Route(
"/api/route/workstreams/{ws_id}/send",
route_proxy,
methods=["POST", "DELETE"],
),
Route(
"/api/route/workstreams/{ws_id}/approve",
route_proxy,
methods=["POST"],
),
Route(
"/api/route/workstreams/{ws_id}/cancel",
route_proxy,
methods=["POST"],
),
Route("/api/route/command", route_proxy, methods=["POST"]),
Route("/api/route/plan", route_proxy, methods=["POST"]),
Route(
"/api/route/workstreams/{ws_id}/close",
route_proxy,
methods=["POST"],
),
# Coordinator-only hard delete — forwards to the server's
# path-parameter form at /v1/api/workstreams/{ws_id}/delete.
Route(
"/api/route/workstreams/delete",
route_workstream_delete,
methods=["POST"],
),
Route(
"/api/route/workstreams/{ws_id}/attachments",
route_attachment_proxy,
methods=["POST", "GET"],
),
Route(
"/api/route/workstreams/{ws_id}/attachments/{attachment_id}",
route_attachment_proxy,
methods=["DELETE"],
),
Route(
"/api/route/workstreams/{ws_id}/attachments/{attachment_id}/content",
route_attachment_proxy,
methods=["GET"],
),
Route("/api/route", route_lookup, methods=["GET"]),
Route("/api/models", list_available_models),
Route("/api/skills", list_skills_summary),
Route("/api/auth/login", auth_login, methods=["POST"]),
Route("/api/auth/logout", auth_logout, methods=["POST"]),
Route("/api/auth/status", auth_status),
Route("/api/auth/setup", auth_setup, methods=["POST"]),
Route("/api/auth/whoami", auth_whoami),
Route("/api/auth/refresh", auth_refresh, methods=["POST"]),
Route("/api/auth/oidc/authorize", oidc_authorize),
Route("/api/auth/oidc/callback", oidc_callback),
Route("/api/admin/users", admin_list_users),
Route("/api/admin/users", admin_create_user, methods=["POST"]),
Route("/api/admin/users/{user_id}", admin_delete_user, methods=["DELETE"]),
Route("/api/admin/users/{user_id}/tokens", admin_list_tokens),
Route(
"/api/admin/users/{user_id}/tokens", admin_create_token, methods=["POST"]
),
Route("/api/admin/tokens/{token_id}", admin_revoke_token, methods=["DELETE"]),
Route(
"/api/admin/users/{user_id}/channels",
admin_list_channels,
),
Route(
"/api/admin/users/{user_id}/channels",
admin_create_channel,
methods=["POST"],
),
Route(
"/api/admin/channels/{channel_type}/{channel_user_id}",
admin_delete_channel,
methods=["DELETE"],
),
Route(
"/api/admin/users/{user_id}/oidc-identities",
admin_list_oidc_identities,
),
Route(
"/api/admin/oidc-identities",
admin_delete_oidc_identity,
methods=["DELETE"],
),
Route("/api/admin/schedules", admin_list_schedules),
Route("/api/admin/schedules", admin_create_schedule, methods=["POST"]),
Route("/api/admin/schedules/{task_id}", admin_get_schedule),
Route("/api/admin/schedules/{task_id}", admin_update_schedule, methods=["PUT"]),
Route(
"/api/admin/schedules/{task_id}",
admin_delete_schedule,
methods=["DELETE"],
),
Route("/api/admin/schedules/{task_id}/runs", admin_list_schedule_runs),
Route("/api/admin/watches", admin_list_watches),
Route(
"/api/admin/watches/{watch_id}/cancel",
admin_cancel_watch,
methods=["POST"],
),
# Governance: Roles
Route("/api/admin/roles", admin_list_roles),
Route("/api/admin/roles", admin_create_role, methods=["POST"]),
Route("/api/admin/roles/{role_id}", admin_update_role, methods=["PUT"]),
Route("/api/admin/roles/{role_id}", admin_delete_role, methods=["DELETE"]),
Route("/api/admin/users/{user_id}/roles", admin_list_user_roles),
Route(
"/api/admin/users/{user_id}/roles",
admin_assign_role,
methods=["POST"],
),
Route(
"/api/admin/users/{user_id}/roles/{role_id}",
admin_unassign_role,
methods=["DELETE"],
),
# Governance: Orgs
Route("/api/admin/orgs", admin_list_orgs),
Route("/api/admin/orgs/{org_id}", admin_get_org),
Route("/api/admin/orgs/{org_id}", admin_update_org, methods=["PUT"]),
# Governance: Tool policies
Route("/api/admin/policies", admin_list_policies),
Route("/api/admin/policies", admin_create_policy, methods=["POST"]),
Route(
"/api/admin/policies/{policy_id}",
admin_update_policy,
methods=["PUT"],
),
Route(
"/api/admin/policies/{policy_id}",
admin_delete_policy,
methods=["DELETE"],
),
# Governance: Skill Discovery
Route("/api/admin/skills/discover", admin_skill_discover),
Route(
"/api/admin/skills/install",
admin_skill_install,
methods=["POST"],
),
Route(
"/api/admin/skills/parse",
admin_parse_skill,
methods=["POST"],
),
# Governance: Skills
Route("/api/admin/skills", admin_list_skills),
Route("/api/admin/skills", admin_create_skill, methods=["POST"]),
Route("/api/admin/skills/{skill_id}", admin_get_skill),
Route(
"/api/admin/skills/{skill_id}",
admin_update_skill,
methods=["PUT"],
),
Route(
"/api/admin/skills/{skill_id}",
admin_delete_skill,
methods=["DELETE"],
),
Route(
"/api/admin/skills/{skill_id}/versions",
admin_list_skill_versions,
),
# Governance: Skill Resources
Route(
"/api/admin/skills/{skill_id}/resources",
admin_list_skill_resources,
),
Route(
"/api/admin/skills/{skill_id}/resources",
admin_create_skill_resource,
methods=["POST"],
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_get_skill_resource,
),
Route(
"/api/admin/skills/{skill_id}/resources/{path:path}",
admin_delete_skill_resource,
methods=["DELETE"],
),
# Governance: Memories
Route("/api/admin/memories", admin_list_memories),
Route("/api/admin/memories/search", admin_search_memories),
Route("/api/admin/memories/{memory_id}", admin_get_memory),
Route(
"/api/admin/memories/{memory_id}",
admin_delete_memory,
methods=["DELETE"],
),
# System: Settings
Route("/api/admin/settings", admin_list_settings),
Route("/api/admin/settings/schema", admin_settings_schema),
Route(
"/api/admin/settings/{key:path}",
admin_update_setting,
methods=["PUT"],
),
Route(
"/api/admin/settings/{key:path}",
admin_delete_setting,
methods=["DELETE"],
),
# System: MCP Registry
Route("/api/admin/mcp-registry/search", admin_registry_search),
Route(
"/api/admin/mcp-registry/install",
admin_registry_install,
methods=["POST"],
),
# System: MCP Servers
Route("/api/admin/mcp-servers", admin_list_mcp_servers),
Route(
"/api/admin/mcp-servers",
admin_create_mcp_server,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/import",
admin_import_mcp_config,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/reload",
admin_mcp_reload,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/refresh",
admin_mcp_refresh_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{name}/reconnect",
admin_mcp_reconnect_one,
methods=["POST"],
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_get_mcp_server,
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_update_mcp_server,
methods=["PUT"],
),
Route(
"/api/admin/mcp-servers/{server_id}",
admin_delete_mcp_server,
methods=["DELETE"],
),
# System: Model Definitions
Route("/api/admin/model-definitions", admin_list_model_definitions),
Route(
"/api/admin/model-definitions",
admin_create_model_definition,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/reload",
admin_model_reload,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/detect",
admin_detect_model,
methods=["POST"],
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_get_model_definition,
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_update_model_definition,
methods=["PUT"],
),
Route(
"/api/admin/model-definitions/{definition_id}",
admin_delete_model_definition,
methods=["DELETE"],
),
Route("/api/admin/model-capabilities", admin_model_capabilities),
Route(
"/api/admin/model-capabilities/known",
admin_known_models,
),
# Governance: Prompt Policies
Route("/api/admin/prompt-policies", admin_list_prompt_policies),
Route(
"/api/admin/prompt-policies",
admin_create_prompt_policy,
methods=["POST"],
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_get_prompt_policy,
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_update_prompt_policy,
methods=["PUT"],
),
Route(
"/api/admin/prompt-policies/{policy_id}",
admin_delete_prompt_policy,
methods=["DELETE"],
),
# Governance: Judge Rules
Route("/api/admin/judge/settings", admin_list_judge_settings),
Route(
"/api/admin/judge/settings/{key:path}",
admin_update_judge_setting,
methods=["PUT"],
),
Route(
"/api/admin/judge/settings/{key:path}",
admin_delete_judge_setting,
methods=["DELETE"],
),
Route("/api/admin/judge/heuristic-rules", admin_list_heuristic_rules),
Route(
"/api/admin/judge/heuristic-rules",
admin_create_heuristic_rule,
methods=["POST"],
),
Route(
"/api/admin/judge/heuristic-rules/{rule_id}",
admin_get_heuristic_rule,
),
Route(
"/api/admin/judge/heuristic-rules/{rule_id}",
admin_update_heuristic_rule,
methods=["PUT"],
),
Route(
"/api/admin/judge/heuristic-rules/{rule_id}",
admin_delete_heuristic_rule,
methods=["DELETE"],
),
Route(
"/api/admin/judge/output-guard-patterns", admin_list_output_guard_patterns
),
Route(
"/api/admin/judge/output-guard-patterns",
admin_create_output_guard_pattern,
methods=["POST"],
),
Route(
"/api/admin/judge/output-guard-patterns/{pattern_id}",
admin_get_output_guard_pattern,
),
Route(
"/api/admin/judge/output-guard-patterns/{pattern_id}",
admin_update_output_guard_pattern,
methods=["PUT"],
),
Route(
"/api/admin/judge/output-guard-patterns/{pattern_id}",
admin_delete_output_guard_pattern,
methods=["DELETE"],
),
Route(
"/api/admin/judge/reload",
admin_judge_reload,
methods=["POST"],
),
Route(
"/api/admin/judge/validate-regex",
admin_validate_regex,
methods=["POST"],
),
# Governance: Usage & Audit
Route("/api/admin/usage", admin_usage),
Route("/api/admin/audit", admin_audit),
# Governance: Intent Verdicts
Route("/api/admin/verdicts", admin_list_verdicts),
Route("/api/admin/output-assessments", admin_list_output_assessments),
Route(
"/api/admin/skills/{skill_id}/rescan",
admin_rescan_skill,
methods=["POST"],
),
# Node metadata
Route("/api/admin/node-metadata", admin_get_all_node_metadata),
Route(
"/api/admin/nodes/{node_id}/metadata/{key}",
admin_set_node_metadata_key,
methods=["PUT"],
),
Route(
"/api/admin/nodes/{node_id}/metadata/{key}",
admin_delete_node_metadata_key,
methods=["DELETE"],
),
Route("/api/admin/nodes/{node_id}/metadata", admin_get_node_metadata),
Route(
"/api/admin/nodes/{node_id}/metadata",
admin_set_node_metadata,
methods=["PUT"],
),
# TLS / ACME
Route("/api/admin/tls/ca", tls_ca_status),
Route("/api/admin/tls/ca.pem", tls_ca_cert),
Route("/api/admin/tls/certs", tls_list_certs),
Route(
"/api/admin/tls/certs/{domain}/renew",
tls_renew_cert,
methods=["POST"],
),
Route(
"/api/admin/tls/certs/{domain}",
tls_delete_cert,
methods=["DELETE"],
),
],
),
Route("/health", health),
Route("/metrics", console_metrics_endpoint),
Route("/openapi.json", _openapi_handler),
Route("/docs", _docs_handler),
Mount("/static", app=StaticFiles(directory=str(_STATIC_DIR)), name="static"),
Mount("/shared", app=StaticFiles(directory=str(_SHARED_DIR)), name="shared"),
# Coordinator one-pane UI — the route serves a single
# index.html template with the ws_id injected via data-ws-id
# so coordinator.js can pull it without an extra round-trip.
Route("/coordinator/{ws_id}", coordinator_page),
# Proxy routes — serve server UI through console port
Route("/node/{node_id}/", proxy_index),
Route("/node/{node_id}/static/{path:path}", proxy_static),
Route("/node/{node_id}/shared/{path:path}", proxy_shared_static),
Route(
"/node/{node_id}/v1/api/{path:path}",
proxy_api,
methods=["GET", "POST", "PUT", "DELETE"],
),
Route(
"/node/{node_id}/api/{path:path}",
proxy_api,
methods=["GET", "POST", "PUT", "DELETE"],
),
Route("/node/{node_id}/{path:path}", proxy_non_api),
],
middleware=_build_console_middleware(cors_origins),
lifespan=_lifespan,
)
app.state.collector = collector
app.state.jwt_secret = jwt_secret
app.state.auth_storage = auth_storage
app.state.proxy_token_mgr = proxy_token_mgr
# Used by the boot scope probe to mint a token with the same
# scopes the collector's SSE path uses.
app.state.collector_token_mgr = getattr(collector, "_token_manager", None)
# Set non-empty by the boot probe when upstream rejects the
# collector token; cluster-dashboard endpoints then 503 with the
# remediation hint until the operator fixes the scopes.
app.state.collector_scope_error = ""
app.state.console_url = console_url
app.state.tls_manager = tls_manager
app.state.router = router
app.state.console_metrics = console_metrics or ConsoleMetrics()
# Mount ACME responder whenever a TLS manager is configured.
# ACMEResponder (lacme 1.0.2+) serves /ca.pem natively.
if tls_manager is not None:
from starlette.routing import Mount as RouteMount
app.routes.insert(0, RouteMount("/acme", app=tls_manager.get_responder()))
from turnstone.core.auth import LoginRateLimiter
app.state.login_limiter = LoginRateLimiter()
# OIDC configuration (opt-in via env vars)
from turnstone.core.oidc import load_oidc_config
oidc_config = load_oidc_config()
app.state.oidc_config = oidc_config
app.state.jwks_data = None # populated after async discovery
# Scheduler — start background thread if storage is available
if auth_storage is not None:
from turnstone.console.scheduler import TaskScheduler
scheduler = TaskScheduler(
collector=collector,
storage=auth_storage,
api_token="",
token_manager=proxy_token_mgr,
)
app.state.scheduler = scheduler
else:
app.state.scheduler = None
return app
def _build_console_middleware(cors_origins: list[str] | None = None) -> list[Middleware]:
"""Build the middleware stack with optional CORS."""
stack: list[Middleware] = []
if cors_origins:
from turnstone.core.web_helpers import cors_middleware
stack.append(cors_middleware(cors_origins))
stack.append(
Middleware(AuthMiddleware, jwt_audience=JWT_AUD_CONSOLE, jwt_version=jwt_version_slot())
)
return stack
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="turnstone console — cluster dashboard service.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=textwrap.dedent("""\
Examples:
turnstone-console # default settings
turnstone-console --port 9090 # custom port
"""),
)
parser.add_argument(
"--host",
default="0.0.0.0",
help="Host to bind to (default: 0.0.0.0)",
)
parser.add_argument(
"--port",
type=int,
default=8090,
help="Port to listen on (default: 8090)",
)
from turnstone.core.log import add_log_args
add_log_args(parser)
from turnstone.core.config import add_config_arg, apply_config
add_config_arg(parser)
apply_config(parser, ["console", "auth"])
args = parser.parse_args()
from turnstone.core.log import configure_logging_from_args
configure_logging_from_args(args, "console")
from turnstone.core.auth import load_jwt_secret
jwt_secret = load_jwt_secret()
# Initialize storage early — the collector needs it for service discovery.
auth_storage = None
try:
from turnstone.core.storage import init_storage
db_backend = os.environ.get("TURNSTONE_DB_BACKEND", "sqlite")
db_url = os.environ.get("TURNSTONE_DB_URL", "")
db_path = os.environ.get("TURNSTONE_DB_PATH", "")
auth_storage = init_storage(
db_backend,
path=db_path,
url=db_url,
sslmode=os.environ.get("TURNSTONE_DB_SSLMODE", ""),
sslrootcert=os.environ.get("TURNSTONE_DB_SSLROOTCERT", ""),
sslcert=os.environ.get("TURNSTONE_DB_SSLCERT", ""),
sslkey=os.environ.get("TURNSTONE_DB_SSLKEY", ""),
)
except Exception:
log.info("Console storage not available — admin API disabled, JWT-only auth")
if auth_storage is None:
log.error(
"Storage backend is required for the console (service discovery). "
"Set TURNSTONE_DB_PATH or TURNSTONE_DB_URL."
)
raise SystemExit(1)
from turnstone.core.auth import JWT_AUD_SERVER, ServiceTokenManager
# ``service`` scope is REQUIRED — ``/v1/api/events/global`` on every
# upstream node hard-gates on it (server.py global_events_sse).
# ``read`` is kept for legacy read-path compatibility.
collector_token_mgr = ServiceTokenManager(
user_id="console-collector",
scopes=frozenset({"read", "service"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.collector_token_manager_created")
router = ConsoleRouter(storage=auth_storage)
console_metrics = ConsoleMetrics()
collector = ClusterCollector(
storage=auth_storage,
token_manager=collector_token_mgr,
router=router,
console_metrics=console_metrics,
)
collector.start()
_load_static()
proxy_token_mgr = ServiceTokenManager(
user_id="console-proxy",
scopes=frozenset({"read", "write", "approve", "service"}),
source="console",
secret=jwt_secret,
audience=JWT_AUD_SERVER,
expiry_hours=1,
)
log.info("console.proxy_token_manager_created")
from turnstone.core.web_helpers import parse_cors_origins
cors_origins = parse_cors_origins()
# TLS: initialize manager if enabled
tls_mgr = None
# Console URL for service registration — other services use this to discover the console.
# Precedence: TURNSTONE_CONSOLE_URL env > auto-detect from bind address.
# In Docker Compose, set TURNSTONE_CONSOLE_URL to the service name (e.g. http://console:8090).
import socket as _socket
_console_url_env = os.environ.get("TURNSTONE_CONSOLE_URL", "")
if _console_url_env:
console_url = _console_url_env
else:
_advertise_host = args.host
if _advertise_host in ("0.0.0.0", "::", ""):
_advertise_host = _socket.gethostname()
console_url = f"http://{_advertise_host}:{args.port}"
if auth_storage:
try:
from turnstone.core.config_store import ConfigStore
_cs = ConfigStore(auth_storage)
# Seed ConfigStore from env vars (TURNSTONE_{SECTION}_{KEY})
_seed_config_from_env(_cs, auth_storage)
if _cs.get("tls.enabled"):
from turnstone.console.tls import TLSManager
tls_mgr = TLSManager(auth_storage, config_store=_cs)
# Init CA before create_app so ACME responder can be mounted
import asyncio
asyncio.run(tls_mgr.init_ca())
# Upgrade scheme to https if no explicit URL was provided
if not _console_url_env:
console_url = console_url.replace("http://", "https://")
log.info("TLS enabled")
except ImportError:
log.warning("TLS enabled but lacme not installed — pip install turnstone[tls]")
tls_mgr = None
except Exception:
log.warning("TLS initialization failed", exc_info=True)
tls_mgr = None
# Sync TLS state to ConfigStore so server nodes see the correct value.
# Three cases:
# 1. TLS succeeded → write true
# 2. TLS not configured (DB false/unset) → write false (definitive)
# 3. TLS configured (DB true) but init failed → don't overwrite
# (transient failure shouldn't permanently disable TLS)
try:
db_enabled = _cs.get("tls.enabled")
if tls_mgr is not None:
if not db_enabled:
_cs.set("tls.enabled", True, changed_by="console-startup")
elif db_enabled:
log.warning(
"tls.enabled is true in ConfigStore but TLS init failed — "
"server nodes will attempt TLS and fall back to plain HTTP"
)
else:
_cs.set("tls.enabled", False, changed_by="console-startup")
except Exception:
log.debug("Failed to sync TLS state to ConfigStore", exc_info=True)
app = create_app(
collector=collector,
jwt_secret=jwt_secret,
auth_storage=auth_storage,
proxy_token_mgr=proxy_token_mgr,
cors_origins=cors_origins,
tls_manager=tls_mgr,
console_url=console_url,
router=router,
console_metrics=console_metrics,
)
log.info("Console starting on %s", console_url)
log.info("Auth: enabled (JWT)")
print("Press Ctrl+C to stop.")
import uvicorn
uvicorn.run(app, host=args.host, port=args.port, log_level="warning")
if __name__ == "__main__":
main()