refactor(web): extract sessions/mcp/skills/tools routes to APIRouter modules (wave 2; route-table equality verified)

- hermes_cli/web_routers/sessions.py: 14 routes across 3 routers
  (list_router, search_router, manage_router) mounted at the three original
  registration points so global route order is preserved exactly.
- hermes_cli/web_routers/mcp.py: 11 routes; OAuth flow registry
  (_mcp_oauth_flows/lock/cap) stays in web_server, reached via new
  web_deps.LateState live proxies so tests mutating web_server._mcp_oauth_flows
  keep working.
- hermes_cli/web_routers/skills.py: 12 routes across hub_router + router
  (two original registration points straddle the profiles router include).
- hermes_cli/web_routers/tools.py: 12 routes; toolset/terminal catalogs stay
  in web_server (some are defined after the mount point), reached via LateState.
- web_deps.py: add LateState — operation-time proxy for web_server-owned
  module state (getattr/item/iter/len/contains/context-manager/comparisons).
- Handler bodies byte-identical; legacy re-exports keep
  web_server.<handler> importable for tests.
- Verified: ordered route table (method, path) identical to pre-refactor app
  (291 routes); import smoke; ruff; windows-footguns clean.
- test_web_server_sessiondb_eventloop.py: structural AST scan now reads both
  web_server.py and web_routers/sessions.py (handlers moved; helpers stayed).
This commit is contained in:
teknium1 2026-07-29 15:28:23 -07:00 committed by Teknium
parent 1a3a9de630
commit 011ec4513e
7 changed files with 2578 additions and 2116 deletions

View file

@ -56,6 +56,85 @@ def late_attr(name: str) -> Any:
return getattr(_server(), name)
class LateState:
"""Live proxy for module-level state owned by ``web_server``.
Extracted routers can't ``from web_server import _mcp_oauth_flows`` —
that would freeze the object at import time (breaking tests that mutate
or replace it on ``web_server``) and be a circular import besides. Some
of the state is also defined *after* the router's ``include_router``
point in web_server's body, so even a late module-import wouldn't see it
yet. This proxy forwards every operation the extracted handlers actually
perform attribute access, item get/set/del, iteration, membership,
``len``/truthiness, ``with``-blocks (locks), and rich comparisons
(numeric limits) to ``web_server.<name>`` resolved at operation time,
so mutating or monkeypatching the attribute on ``web_server`` stays
authoritative.
"""
__slots__ = ("_name",)
def __init__(self, name: str) -> None:
object.__setattr__(self, "_name", name)
def _target(self) -> Any:
return getattr(_server(), object.__getattribute__(self, "_name"))
def __getattr__(self, attr: str) -> Any:
return getattr(self._target(), attr)
def __getitem__(self, key: Any) -> Any:
return self._target()[key]
def __setitem__(self, key: Any, value: Any) -> None:
self._target()[key] = value
def __delitem__(self, key: Any) -> None:
del self._target()[key]
def __contains__(self, item: Any) -> bool:
return item in self._target()
def __iter__(self):
return iter(self._target())
def __len__(self) -> int:
return len(self._target())
def __bool__(self) -> bool:
return bool(self._target())
def __enter__(self):
return self._target().__enter__()
def __exit__(self, *exc):
return self._target().__exit__(*exc)
def __eq__(self, other: Any) -> bool:
return self._target() == other
def __ne__(self, other: Any) -> bool:
return self._target() != other
def __lt__(self, other: Any) -> bool:
return self._target() < other
def __le__(self, other: Any) -> bool:
return self._target() <= other
def __gt__(self, other: Any) -> bool:
return self._target() > other
def __ge__(self, other: Any) -> bool:
return self._target() >= other
def __hash__(self) -> int:
return hash(self._target())
def __repr__(self) -> str: # pragma: no cover - debugging aid
return f"<LateState {object.__getattribute__(self, '_name')} -> {self._target()!r}>"
# --- Named accessors for the shared server state (call-time reads) ---------

View file

@ -0,0 +1,478 @@
"""MCP dashboard routes (extracted verbatim from web_server.py).
Handler bodies are byte-identical. The OAuth flow registry
(``_mcp_oauth_flows`` + lock + pending cap) and the worker/helpers stay in
web_server - reached via the late-binding seam in :mod:`hermes_cli.web_deps`
(``late`` for callables, ``LateState`` for the mutable registry/lock/limit) so
tests that mutate ``web_server._mcp_oauth_flows`` or
``monkeypatch.setattr(web_server, "_run_dashboard_mcp_oauth", ...)`` keep
working unchanged.
"""
import asyncio # noqa: F401 — used by handlers
import logging
import secrets # noqa: F401
import threading # noqa: F401
from typing import Any, Dict, Optional # noqa: F401
from fastapi import APIRouter, HTTPException, Request # noqa: F401
from fastapi.responses import HTMLResponse # noqa: F401
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
MCPCatalogInstall,
MCPEnabledToggle,
MCPServerCreate,
MCPServersReplace,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_config_profile_scope = late("_config_profile_scope")
_gc_mcp_oauth_flows = late("_gc_mcp_oauth_flows")
_mcp_install_action_name = late("_mcp_install_action_name")
_mcp_oauth_callback_url = late("_mcp_oauth_callback_url")
_mcp_server_summary = late("_mcp_server_summary")
_normalize_mcp_server_create = late("_normalize_mcp_server_create")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_require_token = late("_require_token")
_run_dashboard_mcp_oauth = late("_run_dashboard_mcp_oauth")
_spawn_hermes_action = late("_spawn_hermes_action")
load_config = late("load_config")
save_config = late("save_config")
save_env_value = late("save_env_value")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_mcp_oauth_flows = LateState("_mcp_oauth_flows")
_mcp_oauth_flows_lock = LateState("_mcp_oauth_flows_lock")
_MAX_PENDING_MCP_OAUTH_FLOWS = LateState("_MAX_PENDING_MCP_OAUTH_FLOWS")
@router.get("/api/mcp/servers")
async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
with _profile_scope(profile):
servers = _get_mcp_servers()
return {
"servers": [
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
]
}
@router.post("/api/mcp/servers")
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
from hermes_cli.mcp_config import (
_get_mcp_servers,
_save_bearer_auth_token,
_save_mcp_server,
)
try:
name, server_config, bearer_token = _normalize_mcp_server_create(body)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
with _profile_scope(body.profile or profile):
existing = _get_mcp_servers()
if name in existing:
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
try:
with _profile_scope(body.profile or profile):
if bearer_token is not None:
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
if not _save_mcp_server(name, server_config):
raise HTTPException(
status_code=400,
detail=f"Server '{name}' rejected: suspicious command/args configuration",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("POST /api/mcp/servers failed")
raise HTTPException(status_code=400, detail=str(exc)) from exc
return _mcp_server_summary(name, server_config)
@router.put("/api/mcp/servers")
async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None):
"""Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save).
The generic ``/api/config`` endpoint deep-merges maps, so it can never
delete a server key, drop an ``enabled: false`` flag, or remove a nested
field edits looked saved but the stale entry survived on disk. This
endpoint sets the whole map so removals actually persist. Storage stays
the config.yaml ``mcp_servers`` key the CLI/TUI already read.
"""
from hermes_cli.mcp_config import _replace_mcp_servers
with _profile_scope(body.profile or profile):
ok, issues = _replace_mcp_servers(body.servers)
if not ok:
raise HTTPException(status_code=400, detail="; ".join(issues))
return {"ok": True}
@router.delete("/api/mcp/servers/{name}")
async def remove_mcp_server(name: str, profile: Optional[str] = None):
from hermes_cli.mcp_config import _remove_mcp_server
with _profile_scope(profile):
removed = _remove_mcp_server(name)
if not removed:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
return {"ok": True}
@router.post("/api/mcp/servers/{name}/test")
async def test_mcp_server(name: str, profile: Optional[str] = None):
"""Connect to the server, list its tools, disconnect. Returns tool list."""
from hermes_cli.mcp_config import (
_get_mcp_servers,
_oauth_tokens_present,
_probe_single_server,
)
with _profile_scope(profile):
servers = _get_mcp_servers()
if name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
details: Dict[str, Any] = {}
# An `auth: oauth` server that serves tools/list anonymously would probe OK
# with no token — a false green. Require a token on disk for it, matching the
# /auth verification (some providers don't enforce auth on tools/list).
needs_oauth_token = servers[name].get("auth") == "oauth"
def _probe_scoped():
# Home-only scope (contextvar), NOT _profile_scope. A probe blocks for
# as long as the server takes to spawn/connect — a stdio `npx` cold
# start is many seconds — and _profile_scope holds a process-global
# skills lock for its ENTIRE body. Holding that across the probe
# serialized every other endpoint (config/skills/toolsets all take the
# same lock), so a slow server made unrelated requests time out at 15s.
# The probe touches no skills globals; it only needs the HERMES_HOME
# override for .env interpolation + OAuth token resolution, which the
# contextvar provides (copied into this to_thread worker; and
# _run_on_mcp_loop re-wraps it onto the MCP event-loop thread).
with _config_profile_scope(profile):
tools = _probe_single_server(name, servers[name], details=details)
token_present = _oauth_tokens_present(name) if needs_oauth_token else True
return tools, token_present
try:
# Probe blocks on a dedicated MCP event loop — run in a thread so the
# FastAPI event loop is never blocked.
tools, token_present = await asyncio.to_thread(_probe_scoped)
except Exception as exc:
return {
"ok": False,
"error": str(exc),
"tools": [],
}
if not token_present:
return {
"ok": False,
"error": "OAuth authentication required — no token found.",
"tools": [],
}
return {
"ok": True,
"tools": [{"name": t, "description": d} for t, d in tools],
"prompts": details.get("prompts", 0),
"resources": details.get("resources", 0),
}
@router.post("/api/mcp/servers/{name}/auth")
async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = None):
"""Start MCP OAuth and hand the authorization URL to the dashboard browser."""
from hermes_cli.mcp_config import _get_mcp_servers
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
_require_token(request)
_gc_mcp_oauth_flows()
from hermes_constants import get_hermes_home
process_home = str(get_hermes_home().expanduser().resolve(strict=False))
with _profile_scope(profile):
servers = _get_mcp_servers()
flow_home = str(get_hermes_home().expanduser().resolve(strict=False))
if name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
cfg = dict(servers[name])
if not cfg.get("url"):
raise HTTPException(status_code=400, detail="stdio servers authenticate via env keys, not OAuth")
if cfg.get("headers") and cfg.get("auth") != "oauth":
raise HTTPException(status_code=400, detail="This server uses header/API-key auth, not OAuth")
cfg["auth"] = "oauth"
flow_id = secrets.token_urlsafe(24)
flow = DashboardOAuthFlow(
flow_id=flow_id,
server_name=name,
profile=profile,
hermes_home=flow_home,
redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri")
or _mcp_oauth_callback_url(request, name),
reconnect_live=flow_home == process_home,
)
with _mcp_oauth_flows_lock:
pending = sum(
not flow.worker_done
for flow in _mcp_oauth_flows.values()
)
if pending >= _MAX_PENDING_MCP_OAUTH_FLOWS:
raise HTTPException(
status_code=429,
detail="Too many MCP OAuth flows are already in progress",
)
if any(
flow.server_name == name
and flow.hermes_home == flow_home
and not flow.worker_done
for flow in _mcp_oauth_flows.values()
):
raise HTTPException(
status_code=409,
detail=f"MCP OAuth for '{name}' is already in progress",
)
_mcp_oauth_flows[flow_id] = flow
threading.Thread(
target=_run_dashboard_mcp_oauth,
args=(flow, cfg),
daemon=True,
name=f"mcp-oauth-{name}",
).start()
try:
await flow.wait_for_authorization_url(timeout=30)
except Exception as exc:
flow.mark_error(str(exc))
return flow.snapshot()
@router.get("/api/mcp/oauth/flows/{flow_id}")
async def mcp_oauth_flow_status(flow_id: str, request: Request):
_require_token(request)
_gc_mcp_oauth_flows()
flow = _mcp_oauth_flows.get(flow_id)
if flow is None:
raise HTTPException(status_code=404, detail="OAuth flow not found or expired")
snapshot = flow.snapshot()
snapshot["tools"] = flow.tools
return snapshot
@router.get("/api/mcp/oauth/callback/{server_name:path}")
async def mcp_oauth_callback(
server_name: str,
code: Optional[str] = None,
state: Optional[str] = None,
error: Optional[str] = None,
):
_gc_mcp_oauth_flows()
with _mcp_oauth_flows_lock:
candidates = [
flow
for flow in _mcp_oauth_flows.values()
if flow.server_name == server_name
and flow.status == "authorization_required"
]
flow = next(
(
candidate
for candidate in candidates
if candidate.expected_state is not None
and state is not None
and secrets.compare_digest(candidate.expected_state, state)
),
None,
)
if flow is None:
return HTMLResponse("<h1>OAuth flow expired</h1><p>Return to Hermes and try again.</p>", status_code=404)
try:
flow.deliver_callback(code=code, state=state, error=error)
except ValueError as exc:
reason = str(exc)
status_code = 409 if "already received" in reason else 400
return HTMLResponse(
"<h1>OAuth callback rejected</h1>"
"<p>The callback was invalid or already used.</p>",
status_code=status_code,
)
if error:
return HTMLResponse("<h1>Authorization failed</h1><p>Return to Hermes for details.</p>", status_code=400)
return HTMLResponse("<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>")
@router.put("/api/mcp/servers/{name}/enabled")
async def set_mcp_server_enabled(
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
):
"""Enable or disable an MCP server (takes effect on next session/gateway).
Toggles the ``enabled`` key on the server's config.yaml entry — the same
flag the agent reads at startup. Disabled servers stay in config so they
can be re-enabled without re-entering their settings.
"""
with _profile_scope(body.profile or profile):
cfg = load_config()
servers = cfg.get("mcp_servers")
if not isinstance(servers, dict) or name not in servers:
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
if not isinstance(servers[name], dict):
raise HTTPException(status_code=400, detail="Malformed server config")
servers[name]["enabled"] = bool(body.enabled)
save_config(cfg)
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
@router.get("/api/mcp/catalog")
async def list_mcp_catalog(profile: Optional[str] = None):
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
Each entry reports whether it's already installed and enabled so the UI
can show install / enabled state inline. This is the same catalog
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
the installed/enabled annotations (the catalog itself is repo-shipped
and identical for every profile).
"""
try:
from hermes_cli import mcp_catalog
except Exception as exc:
_log.exception("mcp_catalog import failed")
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
entries = []
try:
with _profile_scope(profile):
catalog_entries = list(mcp_catalog.list_catalog())
installed_state = {
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
for e in catalog_entries
}
for entry in catalog_entries:
auth = entry.auth
transport = entry.transport
install = entry.install
entries.append({
"name": entry.name,
"description": entry.description,
"source": entry.source,
"transport": transport.type,
"auth_type": getattr(auth, "type", "none"),
# Env vars the user must supply (names + prompts only, never values).
"required_env": [
{"name": e.name, "prompt": e.prompt, "required": e.required}
for e in getattr(auth, "env", []) or []
],
# Transport details so the UI can show exactly what connects/runs.
# The trust model (docs: user-guide/features/mcp) tells users to
# inspect command/args/url and the install bootstrap before
# installing — surface them rather than hiding them in the repo.
"command": transport.command,
"args": list(transport.args or []),
"url": transport.url,
# Git bootstrap (present only for entries that clone + build).
"install_url": install.url if install else None,
"install_ref": install.ref if install else None,
"bootstrap": list(install.bootstrap) if install else [],
# Default tool pre-selection hint and post-install guidance.
"default_enabled": list(entry.tools.default_enabled)
if entry.tools.default_enabled is not None
else None,
"post_install": entry.post_install or "",
"needs_install": entry.install is not None,
"installed": installed_state.get(entry.name, (False, False))[0],
"enabled": installed_state.get(entry.name, (False, False))[1],
})
except HTTPException:
# Unknown/invalid profile → 404, not a silently-empty catalog.
raise
except Exception:
_log.exception("list_mcp_catalog failed")
diagnostics = []
try:
diagnostics = [
{"name": n, "kind": k, "message": m}
for (n, k, m) in mcp_catalog.catalog_diagnostics()
]
except Exception:
pass
return {"entries": entries, "diagnostics": diagnostics}
@router.post("/api/mcp/catalog/install")
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
"""Install a catalog MCP into config.yaml.
For HTTP/stdio entries with required env vars, those are written to .env
via the standard env path so the agent can read them at session start.
Entries that need a git bootstrap (``needs_install``) are installed via
the CLI action path because the clone can take time.
"""
from hermes_cli import mcp_catalog
name = (body.name or "").strip()
entry = mcp_catalog.get_entry(name)
if entry is None:
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
# Persist any supplied env vars first (catalog entries declare which names
# they need; we only write the ones the user provided).
effective_profile = body.profile or profile
if body.env:
with _profile_scope(effective_profile):
for k, v in body.env.items():
if v:
save_env_value(k, v)
# Git-bootstrap entries can take a while to clone — run via the background
# action path so the request returns immediately and the UI can tail logs.
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
if entry.install is not None:
# Unique per-entry action name: a shared "mcp-install" would let a
# re-click (or a second entry) overwrite the tracked process/log while
# the first clone is still running.
action = _mcp_install_action_name(name)
try:
_spawn_hermes_action(
_profile_cli_args(effective_profile) + ["mcp", "install", name],
action,
)
except HTTPException:
raise
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
return {"ok": True, "name": name, "background": True, "action": action}
# No git step — install synchronously via the catalog API. install_entry
# routes through load_config/save_config + save_env_value, all call-time
# resolvers, so the context override scopes it. Wrap the to_thread body
# in the scope INSIDE the thread (contextvars don't propagate into
# to_thread the other way around — asyncio.to_thread copies context, so
# setting it here works; keep it explicit for clarity).
def _install_scoped():
with _profile_scope(effective_profile):
mcp_catalog.install_entry(entry, enable=body.enable)
try:
await asyncio.to_thread(_install_scoped)
except HTTPException:
raise
except Exception as exc:
_log.exception("install_mcp_catalog_entry failed")
raise HTTPException(status_code=400, detail=str(exc))
return {"ok": True, "name": name, "background": False}

View file

@ -0,0 +1,709 @@
"""Session dashboard routes (extracted verbatim from web_server.py).
Three routers because the original registration points are far apart and
global route order matters: ``list_router`` (GET /api/sessions) was registered
before the profiles ``sessions_router`` include, ``search_router``
(GET /api/sessions/search) right after it, and ``manage_router`` (the
mutation/detail endpoints) thousands of lines later - each is mounted at its
original registration point so the app's route table is byte-identical.
Handler bodies are byte-identical; web_server-owned helpers are reached via
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
``monkeypatch.setattr(web_server, "_helper", ...)`` keep working.
"""
import asyncio # noqa: F401 — used by handlers
import logging
import time # noqa: F401
from typing import Any, Dict, List, Optional # noqa: F401
from fastapi import APIRouter, HTTPException, Request # noqa: F401
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
BulkDeleteSessions,
SessionImport,
SessionPrune,
SessionRename,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
list_router = APIRouter()
search_router = APIRouter()
manage_router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_cron_default_profile = late("_cron_default_profile")
_cron_profile_home = late("_cron_profile_home")
_import_sessions_for_profile = late("_import_sessions_for_profile")
_maybe_auto_archive_for_profile = late("_maybe_auto_archive_for_profile")
_open_session_db_for_profile = late("_open_session_db_for_profile")
_prune_sessions = late("_prune_sessions")
_read_session_import_body = late("_read_session_import_body")
_session_latest_descendant = late("_session_latest_descendant")
_strip_session_list_rows = late("_strip_session_list_rows")
@list_router.get("/api/sessions")
def get_sessions(
limit: int = 20,
offset: int = 0,
min_messages: int = 0,
archived: str = "exclude",
order: str = "created",
source: str = None,
sources: str = None,
exclude_sources: str = None,
cwd_prefix: str = None,
full: bool = False,
profile: Optional[str] = None,
):
"""List sessions.
``archived`` controls how soft-archived sessions are treated:
``exclude`` (default) hides them, ``only`` returns just the archived ones
(used by the desktop "Archived sessions" settings panel), and ``include``
returns both.
``order`` controls pagination order: ``created`` (default, by original
start time) or ``recent`` (by latest activity across the compression
chain). ``recent`` keeps a long-running conversation on the first page
after it auto-compresses into a fresh continuation id.
Rows omit ``system_prompt``/``model_config`` (the payload-dominating
fields no list UI reads) unless ``full=1`` is passed.
"""
if archived not in ("exclude", "only", "include"):
raise HTTPException(
status_code=400,
detail="archived must be one of: exclude, only, include",
)
if order not in ("created", "recent"):
raise HTTPException(
status_code=400,
detail="order must be one of: created, recent",
)
profile_name: Optional[str] = None
if profile:
profile_name, _ = _cron_profile_home(profile)
try:
db = _open_session_db_for_profile(profile)
try:
# Opportunistic, config-gated, double-throttled stale-session
# sweep — the only auto_archive hook that fires for Desktop's
# `hermes serve` backend. No-op when disabled or run recently.
_maybe_auto_archive_for_profile(db, profile)
min_message_count = max(0, min_messages)
archived_only = archived == "only"
include_archived = archived == "include"
# Optional source scoping: ``source`` includes a single class,
# ``sources`` includes any of several comma-separated classes, and
# ``exclude_sources`` (comma-separated) drops classes. The desktop
# uses these to split recents (exclude=cron) from the cron-jobs
# section (source=cron) into two independent lists.
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
sessions = db.list_sessions_rich(
source=source or None,
sources=source_list or None,
exclude_sources=exclude_list or None,
cwd_prefix=(cwd_prefix or None),
limit=limit,
offset=offset,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
order_by_last_active=order == "recent",
# SQL-level projection: when the caller didn't ask for full
# rows, skip the system_prompt blob inside SQLite too (pairs
# with the API-level _strip_session_list_rows below).
compact_rows=not full,
include_pinned=True,
)
total = db.session_count(
source=source or None,
sources=source_list or None,
cwd_prefix=(cwd_prefix or None),
exclude_sources=exclude_list or None,
min_message_count=min_message_count,
include_archived=include_archived,
archived_only=archived_only,
exclude_children=True,
)
now = time.time()
# Same ownership contract as get_session_detail: rows are stamped
# with the serving profile even when the request wasn't explicitly
# scoped, so default-profile rows never circulate unowned.
row_profile = profile_name or _cron_default_profile()
for s in sessions:
s["is_active"] = (
s.get("ended_at") is None
and (now - s.get("last_active", s.get("started_at", 0))) < 300
)
s["profile"] = row_profile
s["is_default_profile"] = row_profile == "default"
# SQLite stores the flag as 0/1; expose a real JSON boolean.
s["archived"] = bool(s.get("archived"))
s["pinned"] = bool(s.get("pinned"))
if not full:
_strip_session_list_rows(sessions)
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions failed")
raise HTTPException(status_code=500, detail="Internal server error")
@search_router.get("/api/sessions/search")
async def search_sessions(
q: str = "",
limit: int = 20,
profile: Optional[str] = None,
source: str = None,
sources: str = None,
exclude_sources: str = None,
):
"""Search sessions by ID plus full-text message content using FTS5.
Direct session-id matches are surfaced first, then FTS message-content
matches. Results are deduped by compression lineage, not by raw
``session_id``. Auto-compression rotates a conversation onto a fresh
session id (and leaves the old segment's messages in the FTS index), so one
logical chat can own many ``sessions`` rows that all match the same query.
Branches also use ``parent_session_id``, but they are real alternate
conversations; don't collapse branch-specific hits back into the parent.
"""
if not q or not q.strip():
return {"results": []}
try:
db = _open_session_db_for_profile(profile)
try:
safe_limit = max(1, min(int(limit or 20), 100))
source_filter = source or None
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
include_sources = [source_filter] if source_filter else (source_list or None)
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
now = time.time()
# Walk parent_session_id to the compression root, memoized so a
# chain of compression segments only costs one walk. We deliberately
# stop at branch/delegate edges: those sessions may diverge from the
# parent and should remain searchable on their own.
root_cache: dict = {}
def compression_root(session_id: str) -> str:
if not session_id:
return session_id
if session_id in root_cache:
return root_cache[session_id]
chain = []
cur = session_id
visited = set()
root = session_id
while cur and cur not in visited:
visited.add(cur)
chain.append(cur)
if cur in root_cache:
root = root_cache[cur]
break
try:
s = db.get_session(cur)
except Exception:
s = None
if not s:
root = cur
break
parent = s.get("parent_session_id") if isinstance(s, dict) else None
if not parent:
root = cur
break
try:
parent_session = db.get_session(parent)
except Exception:
parent_session = None
if not parent_session:
root = cur
break
parent_ended_at = parent_session.get("ended_at")
started_at = s.get("started_at")
is_compression_edge = (
parent_session.get("end_reason") == "compression"
and parent_ended_at is not None
and started_at is not None
and started_at >= parent_ended_at
)
if not is_compression_edge:
root = cur
break
cur = parent
for node in chain:
root_cache[node] = root
return root
tip_cache: dict = {}
def lineage_tip(root_id: str) -> str:
if root_id in tip_cache:
return tip_cache[root_id]
tip = root_id
try:
resolved = db.get_compression_tip(root_id)
if resolved:
tip = resolved
except Exception:
pass
tip_cache[root_id] = tip
return tip
# Both ID matches and content matches share one keyspace, keyed by
# compression lineage root, so an id-hit and a content-hit on the
# same logical conversation collapse to a single result. The first
# hit for a lineage wins; ID matches run first and take priority.
seen: dict = {}
def add_lineage_result(raw_sid: str, payload: dict) -> None:
if not raw_sid:
return
root = compression_root(raw_sid)
if root in seen or len(seen) >= safe_limit:
return
payload = dict(payload)
sid = lineage_tip(root)
payload["session_id"] = sid
payload["lineage_root"] = root
try:
row = db.get_session_rich_row(sid)
except Exception:
row = None
if row:
payload.update(
{
"id": row.get("id") or sid,
"source": row.get("source"),
"model": row.get("model"),
"title": row.get("title"),
"started_at": row.get("started_at"),
"ended_at": row.get("ended_at"),
"last_active": row.get("last_active") or row.get("started_at"),
"is_active": (
row.get("ended_at") is None
and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300
),
"message_count": row.get("message_count") or 0,
"tool_call_count": row.get("tool_call_count") or 0,
"input_tokens": row.get("input_tokens") or 0,
"output_tokens": row.get("output_tokens") or 0,
"preview": row.get("preview"),
"parent_session_id": row.get("parent_session_id"),
"archived": bool(row.get("archived")),
}
)
else:
payload["id"] = sid
seen[root] = payload
# Direct ID matches first: users often paste a session id from CLI,
# logs, or another Hermes surface. FTS can't find those unless the
# id happens to appear in message text. search_sessions_by_id is
# SQL-bounded, so this stays cheap even with thousands of sessions.
for row in db.search_sessions_by_id(
q,
limit=safe_limit,
include_archived=True,
source=source_filter,
sources=source_list or None,
exclude_sources=exclude_list or None,
):
sid = row.get("id")
preview = (row.get("preview") or "").strip()
snippet = preview or f"Session ID: {sid}"
add_lineage_result(
sid,
{
"snippet": snippet,
"role": None,
"source": row.get("source"),
"model": row.get("model"),
"session_started": row.get("started_at"),
},
)
# Auto-add prefix wildcards so partial words match
# e.g. "nimb" → "nimb*" matches "nimby"
# Preserve quoted phrases and existing wildcards as-is
import re
terms = []
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
if token.startswith('"') or token.endswith("*"):
terms.append(token)
else:
terms.append(token + "*")
prefix_query = " ".join(terms)
# Over-fetch so lineage dedup can still surface `limit` distinct
# conversations even when several hits collapse onto one root.
fetch_limit = max(safe_limit * 5, 50)
matches = db.search_messages(
query=prefix_query,
source_filter=include_sources,
exclude_sources=exclude_list or None,
limit=fetch_limit,
)
for m in matches:
if len(seen) >= safe_limit:
break
add_lineage_result(
m["session_id"],
{
"snippet": m.get("snippet", ""),
"role": m.get("role"),
"source": m.get("source"),
"model": m.get("model"),
"session_started": m.get("session_started"),
},
)
return {"results": list(seen.values())}
finally:
db.close()
except HTTPException:
raise
except Exception:
_log.exception("GET /api/sessions/search failed")
raise HTTPException(status_code=500, detail="Search failed")
@manage_router.post("/api/sessions/bulk-delete")
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
"""Delete every session in ``body.ids`` in a single DB transaction.
Backs the dashboard's bulk-select-and-delete flow on the sessions
page. POST (not DELETE) because most HTTP clients refuse to send a
request body on DELETE and a body is the natural shape for a list
of IDs Starlette accepts both, but POSTing a list keeps proxies,
curl, and the browser ``fetch`` API consistent.
Per-row contract matches :meth:`SessionDB.delete_sessions`:
* Unknown IDs are silently skipped (the response ``deleted`` count
reflects what really happened, not the input length). This is
deliberate UI selection state can race against another tab's
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
batch.
* Children of every deleted parent are orphaned, not cascade-
deleted.
* Active and archived sessions ARE deleted when explicitly
selected unlike ``DELETE /api/sessions/empty``, the user
hand-picked the rows so we trust the selection.
* Like the other session-delete endpoints, this does NOT pass a
``sessions_dir`` through; on-disk transcript / request-dump
cleanup runs at the CLI/agent layer on the next prune pass.
The response carries the actual deleted count, so the dashboard
can surface it in a toast. The IDs that were removed are not
echoed back because the client already knows what it asked to
delete (unknown IDs are silently skipped see contract above)
and can prune its in-memory list directly from the request.
"""
# Enforce a hard cap so a runaway/typo'd selection can't lock the
# DB writer for an extended window. The dashboard pages 20 rows
# at a time; 500 covers a "select all on every page in a
# reasonable scrollback" worst case without opening the door to
# multi-thousand-row transactions.
if len(body.ids) > 500:
raise HTTPException(
status_code=400,
detail="ids must contain at most 500 entries",
)
def _delete() -> int:
db = _open_session_db_for_profile(body.profile)
try:
return db.delete_sessions(body.ids)
finally:
db.close()
deleted = await asyncio.to_thread(_delete)
return {"ok": True, "deleted": deleted}
@manage_router.post("/api/sessions/import")
async def import_sessions_endpoint(request: Request):
"""Import one or more sessions exported from the dashboard or CLI.
This is intentionally separate from ``/api/ops/import``: that endpoint
restores a whole Hermes backup archive, while this endpoint is scoped to
session rows/messages and is safe to use from the Sessions page.
"""
try:
raw_body = await _read_session_import_body(request)
body = SessionImport.model_validate_json(raw_body)
except HTTPException:
raise
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid session import payload") from exc
try:
result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if not result.get("ok", False):
raise HTTPException(status_code=400, detail=result)
return result
@manage_router.get("/api/sessions/empty/count")
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
"""Return the number of empty, ended, non-archived sessions.
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
UI hides the affordance so users aren't presented with a button
that does nothing. Cheap, single-COUNT query.
"""
def _count() -> int:
db = _open_session_db_for_profile(profile)
try:
return db.count_empty_sessions()
finally:
db.close()
return {"count": await asyncio.to_thread(_count)}
@manage_router.delete("/api/sessions/empty")
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
"""Delete every empty (``message_count == 0``), ended,
non-archived session in a single transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* Active sessions are skipped (``ended_at IS NULL``) so a live
agent isn't yanked mid-handshake.
* Archived sessions are skipped the user explicitly chose to
keep those rows.
* Children of deleted parents are orphaned, not cascade-deleted.
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
below, this doesn't pass a ``sessions_dir`` through — the on-disk
transcript / request-dump cleanup is wired at the CLI/agent layer
but the web server historically leaves file cleanup to the next
prune-on-startup pass. Matching that pre-existing trade-off keeps
the two delete endpoints' DB-vs-disk behaviour consistent.
"""
def _delete() -> int:
db = _open_session_db_for_profile(profile)
try:
return db.delete_empty_sessions()
finally:
db.close()
deleted = await asyncio.to_thread(_delete)
return {"ok": True, "deleted": deleted}
@manage_router.get("/api/sessions/stats")
async def get_session_stats(profile: Optional[str] = None):
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
path isn't captured as a session id by the parameterized route.
"""
db = _open_session_db_for_profile(profile)
try:
total = db.session_count(include_archived=True)
active_store = db.session_count(include_archived=False)
archived = db.session_count(archived_only=True)
messages = db.message_count()
by_source: Dict[str, int] = {}
try:
by_source = db.session_count_by_source(
include_archived=True,
exclude_children=True,
)
except Exception:
pass
return {
"total": total,
"active_store": active_store,
"archived": archived,
"messages": messages,
"by_source": by_source,
}
finally:
db.close()
@manage_router.get("/api/sessions/{session_id}")
async def get_session_detail(session_id: str, profile: Optional[str] = None):
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
session = db.get_session(sid) if sid else None
if not session:
raise HTTPException(status_code=404, detail="Session not found")
# Always stamp the owning profile — the serving profile is known even
# when the request carries no ``?profile=`` (it's this process's own
# profile). Stamping only on explicit ``?profile=`` left rows for the
# default/primary profile systematically unowned, so multi-profile
# clients resolved them to whichever gateway happened to be active
# (cross-profile open asymmetry, #67603 family).
session["profile"] = (
_cron_profile_home(profile)[0] if profile else _cron_default_profile()
)
session["is_default_profile"] = session["profile"] == "default"
return session
finally:
db.close()
@manage_router.get("/api/sessions/{session_id}/latest-descendant")
async def get_session_latest_descendant(
session_id: str,
profile: Optional[str] = None,
):
def _lookup():
db = _open_session_db_for_profile(profile)
try:
return _session_latest_descendant(session_id, db)
finally:
db.close()
latest, path = await asyncio.to_thread(_lookup)
if not latest:
raise HTTPException(status_code=404, detail="Session not found")
return {
"requested_session_id": path[0] if path else session_id,
"session_id": latest,
"path": path,
"changed": bool(path and latest != path[0]),
}
@manage_router.get("/api/sessions/{session_id}/messages")
async def get_session_messages(
session_id: str,
profile: Optional[str] = None,
limit: Optional[int] = None,
offset: int = 0,
):
def _read():
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
return None
sid = db.resolve_resume_session_id(sid)
# Clamp limit to prevent abuse (max 500 per page)
_limit = min(limit, 500) if limit is not None else None
return sid, _limit, db.get_messages(sid, limit=_limit, offset=offset)
finally:
db.close()
result = await asyncio.to_thread(_read)
if result is None:
raise HTTPException(status_code=404, detail="Session not found")
sid, _limit, messages = result
return {
"session_id": sid,
"messages": messages,
"pagination": {
"limit": _limit,
"offset": offset,
"returned": len(messages),
},
}
@manage_router.delete("/api/sessions/{session_id}")
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
# ``profile`` deletes a session belonging to another (local) profile by
# opening its state.db directly. Remote profiles never reach here — the
# desktop routes their DELETE to the remote backend. Omit for current/default.
def _delete():
db = _open_session_db_for_profile(profile)
try:
# Resolve exact ids / unique prefixes like every other session endpoint
# (detail, messages, rename, export all do). A session that no longer
# exists is an idempotent success: DELETE's contract is "ensure it's
# gone", and the desktop optimistically removes the row then RESTORES it
# on any error — so a 404 on an already-absent row resurrected a ghost
# row and surfaced "session not found". /goal + auto-compression churn
# leaves transient empty rows (reaped by empty-session hygiene) that
# race the sidebar snapshot, which is exactly when this fired. Mirrors
# the bulk-delete endpoint, which already treats ghost ids as success.
sid = db.resolve_session_id(session_id)
if not sid:
return {"ok": True, "already_absent": True}
db.delete_session(sid)
return {"ok": True}
finally:
db.close()
return await asyncio.to_thread(_delete)
@manage_router.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename, archive, and/or pin it.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session; ``pinned`` sets the durable keep flag (exempts the
session from the auto-archive sweep). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile)
try:
sid = db.resolve_session_id(session_id)
if not sid:
raise HTTPException(status_code=404, detail="Session not found")
if body.title is None and body.archived is None and body.pinned is None:
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.",
)
if body.title is not None:
try:
db.set_session_title(sid, body.title or "")
except ValueError as e:
# Title too long, invalid characters, or already in use.
raise HTTPException(status_code=400, detail=str(e))
if body.archived is not None:
db.set_session_archived(sid, body.archived)
if body.pinned is not None:
db.set_session_pinned(sid, body.pinned)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.pinned is not None:
result["pinned"] = bool(body.pinned)
return result
finally:
db.close()
@manage_router.get("/api/sessions/{session_id}/export")
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
"""Export a single session (metadata + messages) as JSON."""
def _export():
db = _open_session_db_for_profile(profile)
try:
sid = db.resolve_session_id(session_id)
return db.export_session(sid) if sid else None
finally:
db.close()
data = await asyncio.to_thread(_export)
if data is None:
raise HTTPException(status_code=404, detail="Session not found")
return data
@manage_router.post("/api/sessions/prune")
async def prune_sessions_endpoint(body: SessionPrune):
"""Delete ended sessions matching filters without blocking the event loop."""
return await asyncio.to_thread(_prune_sessions, body)

View file

@ -0,0 +1,490 @@
"""Skills dashboard routes (extracted verbatim from web_server.py).
Two routers because the original registration points are far apart and global
route order matters: ``hub_router`` (the skills-hub install/search/scan
endpoints) was registered before the profiles ``router`` include in
web_server, the plain skills CRUD ``router`` after it - each is mounted at
its original registration point.
Handler bodies are byte-identical; web_server-owned helpers are reached via
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
``monkeypatch.setattr(web_server, "_spawn_hermes_action", ...)`` keep
working.
"""
import asyncio # noqa: F401 — used by handlers
import logging
from typing import Optional # noqa: F401
from fastapi import APIRouter, HTTPException # noqa: F401
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
SkillContentUpdate,
SkillCreate,
SkillInstallRequest,
SkillToggle,
SkillUninstallRequest,
SkillsUpdateRequest,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
hub_router = APIRouter()
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_clear_skills_prompt_cache = late("_clear_skills_prompt_cache")
_config_profile_scope = late("_config_profile_scope")
_hub_action_name = late("_hub_action_name")
_installed_hub_identifiers = late("_installed_hub_identifiers")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_skill_meta_to_payload = late("_skill_meta_to_payload")
_spawn_hermes_action = late("_spawn_hermes_action")
load_config = late("load_config")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_SKILL_HUB_SOURCE_LABELS = LateState("_SKILL_HUB_SOURCE_LABELS")
@hub_router.post("/api/skills/hub/install")
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
identifier = (body.identifier or "").strip()
if not identifier:
raise HTTPException(status_code=400, detail="identifier is required")
name = _hub_action_name("install", identifier)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["skills", "install", identifier, "--yes"],
name,
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills install")
raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}")
return {"ok": True, "pid": proc.pid, "name": name}
@hub_router.post("/api/skills/hub/uninstall")
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
name = (body.name or "").strip()
if not name:
raise HTTPException(status_code=400, detail="name is required")
action = _hub_action_name("uninstall", name)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"],
action,
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills uninstall")
raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}")
return {"ok": True, "pid": proc.pid, "name": action}
@hub_router.post("/api/skills/hub/update")
async def update_skills_hub(
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
):
try:
effective = (body.profile if body else None) or profile
proc = _spawn_hermes_action(
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn skills update")
raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}")
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
@hub_router.get("/api/skills/hub/sources")
async def list_skills_hub_sources(profile: Optional[str] = None):
"""List the configured skill-hub sources and installed-skill provenance.
Gives the dashboard something to show BEFORE a search runs which hubs
are wired up, their trust tier, and a set of featured skills pulled from
the centralized index (zero extra API calls). Without this the Browse-hub
tab is a blank page with no indication it's even connected to anything.
``profile`` scopes the installed-skill provenance to that profile.
"""
def _run():
from tools.skills_hub import create_source_router
with _config_profile_scope(profile):
sources = create_source_router()
out = []
index_available = False
featured = []
for src in sources:
sid = src.source_id()
entry = {
"id": sid,
"label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid),
}
# GitHub exposes a rate-limit flag; the index an availability flag.
if sid == "github":
try:
entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False))
except Exception:
entry["rate_limited"] = False
if sid == "hermes-index":
try:
index_available = bool(getattr(src, "is_available", False))
except Exception:
index_available = False
entry["available"] = index_available
# Empty-query search on the index returns featured/popular skills.
if index_available:
try:
featured = [
_skill_meta_to_payload(m) for m in src.search("", limit=12)
]
except Exception:
featured = []
out.append(entry)
# Tell the UI which sources are worth searching individually (for its
# progressive per-source fan-out). Mirror parallel_search_sources: when
# the centralized index is available it already subsumes the external
# API sources, so they're redundant — skipping them avoids ~70 GitHub
# calls per keystroke. Keep this set in sync with that function's
# ``_api_source_ids``.
_api_source_ids = frozenset(
{"github", "skills-sh", "clawhub", "lobehub", "well-known"}
)
for entry in out:
entry["searchable"] = not (index_available and entry["id"] in _api_source_ids)
return {
"sources": out,
"index_available": index_available,
"featured": featured,
"installed": _installed_hub_identifiers(profile),
}
try:
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("skills hub sources listing failed")
raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}")
@hub_router.get("/api/skills/hub/search")
async def search_skills_hub(
q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None
):
"""Search the skill hub across all configured sources.
Network-bound (parallel source search); runs in a thread so the FastAPI
loop isn't blocked. Returns structured results the UI installs by
identifier via POST /api/skills/hub/install, previews via
/api/skills/hub/preview, and scans via /api/skills/hub/scan.
"""
query = (q or "").strip()
if not query:
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
def _run():
from tools.skills_hub import create_source_router, parallel_search_sources
with _config_profile_scope(profile):
sources = create_source_router()
capped = min(max(limit, 1), 50)
all_results, source_counts, timed_out = parallel_search_sources(
sources, query=query, source_filter=source or "all", overall_timeout=30
)
# Dedupe by identifier, preferring higher trust (mirrors unified_search).
_rank = {"builtin": 2, "trusted": 1, "community": 0}
seen = {}
for r in all_results:
if r.identifier not in seen:
seen[r.identifier] = r
elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0):
seen[r.identifier] = r
deduped = list(seen.values())[:capped]
return {
"results": [_skill_meta_to_payload(m) for m in deduped],
"source_counts": source_counts,
"timed_out": timed_out,
"installed": _installed_hub_identifiers(profile),
}
try:
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("skills hub search failed")
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
@hub_router.get("/api/skills/hub/preview")
async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading.
Resolves the identifier across configured sources (same path the CLI
installer uses), then returns the rendered SKILL.md text and the file
manifest WITHOUT installing anything. This is the 'read the actual skill
before installing' affordance the Browse-hub tab was missing.
Scoped to ``profile`` so a non-default profile with different hub taps
resolves against ITS source router, not the default profile's.
"""
ident = (identifier or "").strip()
if not ident:
raise HTTPException(status_code=400, detail="identifier is required")
def _run():
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
from tools.skills_hub import create_source_router
with _config_profile_scope(profile):
sources = create_source_router()
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
if not bundle and not meta:
return None
files = {}
skill_md = ""
if bundle:
for rel, content in (bundle.files or {}).items():
if isinstance(content, bytes):
# Some sources (e.g. official optional skills) store every
# file as bytes. Decode text so SKILL.md / docs render;
# only fall back to a placeholder for genuinely-binary data.
try:
files[rel] = content.decode("utf-8")
except UnicodeDecodeError:
files[rel] = "(binary file)"
else:
files[rel] = content
skill_md = files.get("SKILL.md", "") or ""
m = meta or bundle
return {
"name": getattr(m, "name", ident),
"description": getattr(m, "description", "") or "",
"source": getattr(m, "source", "") or "",
"identifier": getattr(m, "identifier", ident) or ident,
"trust_level": getattr(m, "trust_level", "community") or "community",
"repo": getattr(m, "repo", None),
"tags": list(getattr(m, "tags", None) or []),
"skill_md": skill_md,
"files": sorted(files.keys()),
}
try:
result = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub preview failed")
raise HTTPException(status_code=502, detail=f"Hub preview failed: {exc}")
if result is None:
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
return result
@hub_router.get("/api/skills/hub/scan")
async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
"""Run the install-time security scan on a hub skill WITHOUT installing it.
Fetches the bundle, quarantines it, and runs the same `scan_skill` /
`should_allow_install` pipeline the CLI installer uses then cleans up the
quarantine. Returns the verdict, per-finding detail, trust tier, and the
install-policy decision so the dashboard can show a visual safety result
on demand (the 'scan' button the Browse-hub tab was missing).
Scoped to ``profile`` so the bundle resolves against that profile's hub
source router, matching where an install would pull it from.
"""
ident = (identifier or "").strip()
if not ident:
raise HTTPException(status_code=400, detail="identifier is required")
def _run():
import shutil as _shutil
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
from tools.skills_hub import create_source_router, quarantine_bundle
from tools.skills_guard import scan_skill, should_allow_install
with _config_profile_scope(profile):
sources = create_source_router()
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
if not bundle:
return None
if bundle.source == "official":
scan_source = "official"
else:
scan_source = (
getattr(bundle, "identifier", "")
or getattr(meta, "identifier", "")
or ident
)
q_path = None
try:
q_path = quarantine_bundle(bundle)
result = scan_skill(q_path, source=scan_source)
finally:
if q_path is not None:
_shutil.rmtree(q_path, ignore_errors=True)
allowed, reason = should_allow_install(result, force=False)
# `allowed` may be None ("ask") for agent-created/dangerous gates.
if allowed is True:
policy = "allow"
elif allowed is None:
policy = "ask"
else:
policy = "block"
findings = [
{
"severity": f.severity,
"category": f.category,
"file": f.file,
"line": f.line,
"description": f.description,
}
for f in result.findings
]
# Per-severity tally for an at-a-glance summary.
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for f in result.findings:
if f.severity in counts:
counts[f.severity] += 1
return {
"name": result.skill_name,
"identifier": ident,
"source": result.source,
"trust_level": result.trust_level,
"verdict": result.verdict,
"summary": result.summary,
"policy": policy,
"policy_reason": reason,
"findings": findings,
"severity_counts": counts,
}
try:
result = await asyncio.to_thread(_run)
except Exception as exc:
_log.exception("skills hub scan failed")
raise HTTPException(status_code=502, detail=f"Hub scan failed: {exc}")
if result is None:
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
return result
@router.get("/api/skills")
async def get_skills(profile: Optional[str] = None):
from tools.skills_tool import _find_all_skills
from hermes_cli.skills_config import get_disabled_skills
from tools.skill_usage import (
_read_bundled_manifest_names,
_read_hub_installed_names,
activity_count,
load_usage,
)
with _profile_scope(profile):
config = load_config()
disabled = get_disabled_skills(config)
skills = _find_all_skills(skip_disabled=True)
usage = load_usage()
# Set-based provenance (same classification as skill_usage.provenance,
# without a per-skill manifest read): hub > bundled > agent, where
# "agent" covers agent-authored AND local hand-made skills — the ones
# the user may edit/delete from the UI.
bundled_names = _read_bundled_manifest_names()
hub_names = _read_hub_installed_names()
for s in skills:
s["enabled"] = s["name"] not in disabled
s["usage"] = activity_count(usage.get(s["name"], {}))
s["provenance"] = (
"hub" if s["name"] in hub_names
else "bundled" if s["name"] in bundled_names
else "agent"
)
return skills
@router.put("/api/skills/toggle")
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
with _profile_scope(body.profile or profile):
config = load_config()
disabled = get_disabled_skills(config)
if body.enabled:
disabled.discard(body.name)
else:
disabled.add(body.name)
save_disabled_skills(config, disabled)
return {"ok": True, "name": body.name, "enabled": body.enabled}
@router.get("/api/skills/content")
async def get_skill_content(name: str, profile: Optional[str] = None):
"""Return the raw SKILL.md text for a skill, for the dashboard editor."""
from tools.skill_manager_tool import _find_skill
with _profile_scope(profile):
found = _find_skill(name)
if not found:
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
skill_md = found["path"] / "SKILL.md"
if not skill_md.exists():
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
try:
content = skill_md.read_text(encoding="utf-8")
except OSError as exc:
raise HTTPException(status_code=500, detail=str(exc)) from exc
return {"name": name, "content": content, "path": str(skill_md)}
@router.post("/api/skills")
async def create_skill(body: SkillCreate):
"""Create a new custom skill (SKILL.md) from the dashboard editor.
Calls the same validated write path as the agent's ``skill_manage``
tool (frontmatter validation, name/category validation, size limit,
optional security scan) but bypasses the agent write-approval gate:
a write from the authenticated dashboard IS the user acting directly.
"""
from tools.skill_manager_tool import _create_skill
with _profile_scope(body.profile):
result = _create_skill(body.name, body.content, body.category or None)
if not result.get("success"):
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
_clear_skills_prompt_cache()
return result
@router.put("/api/skills/content")
async def update_skill_content(body: SkillContentUpdate):
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
from tools.skill_manager_tool import _edit_skill
with _profile_scope(body.profile):
result = _edit_skill(body.name, body.content)
if not result.get("success"):
err = result.get("error", "Failed to update skill.")
status = 404 if "not found" in str(err).lower() else 400
raise HTTPException(status_code=status, detail=err)
_clear_skills_prompt_cache()
return result

View file

@ -0,0 +1,734 @@
"""Toolset / terminal-backend dashboard routes (extracted verbatim from
web_server.py).
Handler bodies are byte-identical. The toolset/terminal catalogs
(``_MODEL_CATALOG_TOOLSETS``, ``_TERMINAL_BACKENDS``,
``_TERMINAL_BACKEND_NAMES`` - some defined *after* this router's mount point
in web_server's body) and all helpers stay in web_server - reached via the
late-binding seam in :mod:`hermes_cli.web_deps` (``late`` for callables,
``LateState`` for the catalog constants) so monkeypatching on web_server
stays authoritative.
"""
import logging
import sys # noqa: F401 — used by handlers
from typing import Any, Dict, List, Optional # noqa: F401
from fastapi import APIRouter, HTTPException # noqa: F401
from hermes_cli.web_deps import late, LateState
from hermes_cli.web_models import (
TerminalBackendSelect,
ToolsetEnvUpdate,
ToolsetModelSelect,
ToolsetPostSetup,
ToolsetProviderSelect,
ToolsetToggle,
)
# Same logger the handlers used before extraction (identical logger object).
_log = logging.getLogger("hermes_cli.web_server")
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_find_toolset_provider_row = late("_find_toolset_provider_row")
_probe_terminal_backend = late("_probe_terminal_backend")
_profile_cli_args = late("_profile_cli_args")
_profile_scope = late("_profile_scope")
_resolve_toolset_model_plugin = late("_resolve_toolset_model_plugin")
_spawn_hermes_action = late("_spawn_hermes_action")
_toolset_model_catalog = late("_toolset_model_catalog")
load_config = late("load_config")
save_config = late("save_config")
# Live proxies for web_server-owned module state (mutations/monkeypatches
# on web_server remain authoritative; resolved at operation time).
_MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS")
_TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS")
_TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES")
@router.get("/api/tools/toolsets")
async def get_toolsets(profile: Optional[str] = None):
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_get_effective_configurable_toolsets,
_get_platform_tools,
_toolset_configuration_platform,
_toolset_has_keys,
gui_toolset_label,
)
from hermes_cli.platforms import platform_label
from toolsets import resolve_toolset
with _profile_scope(profile):
config = load_config()
toolset_rows = _get_effective_configurable_toolsets()
target_platforms = {
_toolset_configuration_platform(name) for name, _, _ in toolset_rows
}
enabled_by_platform = {
platform: _get_platform_tools(
config,
platform,
include_default_mcp_servers=False,
)
for platform in target_platforms
}
result = []
for name, label, desc in toolset_rows:
try:
tools = sorted(set(resolve_toolset(name)))
except Exception:
tools = []
target_platform = _toolset_configuration_platform(name)
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) have no per-platform toolset —
# their switch is their own config section (e.g. stt.enabled).
from utils import is_truthy_value
section = config.get(name)
section = section if isinstance(section, dict) else {}
is_enabled = is_truthy_value(section.get("enabled", True), default=True)
else:
is_enabled = name in enabled_by_platform[target_platform]
result.append({
"name": name,
"label": gui_toolset_label(label),
"description": desc,
"platform": target_platform,
"platform_label": gui_toolset_label(
platform_label(target_platform, target_platform)
),
"enabled": is_enabled,
"available": is_enabled,
"configured": _toolset_has_keys(name, config),
"tools": tools,
})
return result
@router.put("/api/tools/toolsets/{name}")
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
"""Enable/disable a configurable toolset for its configuration platform.
Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted
toolsets instead target their supported platform (for example, Discord's
native toolsets persist to ``platform_toolsets.discord``). The shared
``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped
to ``body.profile`` when provided. Returns 400 for unknown toolset keys.
"""
from hermes_cli.tools_config import (
_CONFIG_ONLY_TOOLSETS,
_get_effective_configurable_toolsets,
_get_platform_tools,
_save_platform_tools,
_toolset_configuration_platform,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
target_platform = _toolset_configuration_platform(name)
if name in _CONFIG_ONLY_TOOLSETS:
# Config-only capabilities (stt) toggle their own config section's
# ``enabled`` flag — there is no platform_toolsets entry to write.
with _profile_scope(body.profile or profile):
config = load_config()
section = config.setdefault(name, {})
if not isinstance(section, dict):
section = {}
config[name] = section
section["enabled"] = bool(body.enabled)
save_config(config)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
}
with _profile_scope(body.profile or profile):
config = load_config()
enabled = set(
_get_platform_tools(
config,
target_platform,
include_default_mcp_servers=False,
)
)
if body.enabled:
enabled.add(name)
else:
enabled.discard(name)
_save_platform_tools(config, target_platform, enabled)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
}
@router.get("/api/tools/toolsets/{name}/config")
async def get_toolset_config(name: str, profile: Optional[str] = None):
"""Return the provider matrix + key status for a toolset's config panel.
Surfaces the same provider rows the CLI ``hermes tools`` picker shows
(via ``_visible_providers``), each with its ``env_vars`` annotated with
current ``is_set`` state so the GUI can render provider selection + key
entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty
provider list and ``has_category: false``. Returns 400 for unknown keys.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_is_provider_active,
_visible_providers,
provider_readiness_status,
web_provider_capabilities,
)
from hermes_cli.config import get_env_value
from hermes_cli.nous_subscription import get_nous_subscription_features
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
providers = []
active_provider = None
active_search_backend = None
active_extract_backend = None
if cat:
# Fetch portal/entitlement state once for the whole matrix — the
# per-provider readiness computation below reuses it instead of
# re-probing per row.
features = get_nous_subscription_features(config, force_fresh=True)
for prov in _visible_providers(cat, config, force_fresh=True):
env_vars = [
{
"key": e["key"],
"prompt": e.get("prompt", e["key"]),
"url": e.get("url"),
"default": e.get("default"),
"is_set": bool(get_env_value(e["key"])),
}
for e in prov.get("env_vars", [])
]
# Surface the same active-provider determination the CLI picker
# uses (``_is_provider_active``) so the GUI highlights the provider
# actually written to config (e.g. web.backend), not just the first
# keyless one in the list.
is_active = _is_provider_active(prov, config, force_fresh=True)
if is_active and active_provider is None:
active_provider = prov["name"]
row = {
"name": prov["name"],
"badge": prov.get("badge", ""),
"tag": prov.get("tag", ""),
"env_vars": env_vars,
"post_setup": prov.get("post_setup"),
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
"is_active": is_active,
# Honest server-side readiness. The GUI's old client-side
# heuristic showed "Ready" for every zero-env-var row —
# including logged-out Nous Subscription rows and never-run
# post_setup installs (see provider_readiness_status).
"status": provider_readiness_status(
prov, config, features=features, is_active=is_active
),
}
if name == "web" and prov.get("web_backend"):
# The runtime split web into two capabilities long ago
# (web.search_backend / web.extract_backend); surface each
# row's backend key and which capabilities it can serve so
# the GUI can offer per-capability selection.
row["web_backend"] = prov["web_backend"]
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
if name == "tts" and prov.get("tts_provider"):
# The provider key written to tts.provider on selection.
# Doubles as the config section holding the provider's
# voice/model settings (tts.<key>.*) so the GUI can render
# those fields inline in the Capabilities panel.
row["tts_provider"] = prov["tts_provider"]
providers.append(row)
if name == "web":
# Resolve the per-capability active backends exactly the way the
# web_search / web_extract dispatchers do (per-capability key →
# shared web.backend → credential auto-detect), so the GUI badges
# reflect what a tool call would actually hit right now.
try:
from tools.web_tools import _get_extract_backend, _get_search_backend
active_search_backend = _get_search_backend()
active_extract_backend = _get_extract_backend()
except Exception:
active_search_backend = None
active_extract_backend = None
payload = {
"name": name,
"has_category": cat is not None,
"providers": providers,
"active_provider": active_provider,
}
if name == "web":
payload["active_search_backend"] = active_search_backend
payload["active_extract_backend"] = active_extract_backend
return payload
@router.get("/api/tools/toolsets/{name}/models")
async def get_toolset_models(
name: str, provider: Optional[str] = None, profile: Optional[str] = None
):
"""Return the model catalog for a toolset backend (image/video gen).
The GUI counterpart of the model picker `hermes tools` runs after a
backend is selected e.g. FAL's multi-model catalog (speed / strengths /
price per model). ``provider`` names a picker row; omitted, the currently
active provider is used. Toolsets without model catalogs return
``has_models: false``.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
with _profile_scope(profile):
config = load_config()
row = _find_toolset_provider_row(name, config, provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
return {
"name": name,
"has_models": False,
"models": [],
"current": None,
"default": None,
}
catalog, default_model = _toolset_model_catalog(name, plugin)
section_cfg = config.get(section)
current = None
if isinstance(section_cfg, dict):
raw = section_cfg.get("model")
if isinstance(raw, str) and raw.strip():
current = raw.strip()
if current not in catalog:
current = default_model if default_model in catalog else None
models = [
{
"id": model_id,
"display": meta.get("display", model_id),
"speed": meta.get("speed", ""),
"strengths": meta.get("strengths", ""),
"price": meta.get("price", ""),
}
for model_id, meta in catalog.items()
]
return {
"name": name,
"has_models": bool(models),
"provider": row.get("name") if row else None,
"plugin": plugin,
"models": models,
"current": current,
"default": default_model,
}
@router.put("/api/tools/toolsets/{name}/model")
async def select_toolset_model(
name: str, body: ToolsetModelSelect, profile: Optional[str] = None
):
"""Persist a backend model selection (``image_gen.model`` / ``video_gen.model``).
Validates the model against the resolved backend's catalog — the same
write the CLI's post-selection model picker performs. Returns 400 for
toolsets without model catalogs or unknown model ids.
"""
section = _MODEL_CATALOG_TOOLSETS.get(name)
if section is None:
raise HTTPException(
status_code=400, detail=f"Toolset has no model catalog: {name}"
)
model_id = (body.model or "").strip()
if not model_id:
raise HTTPException(status_code=400, detail="model is required")
with _profile_scope(body.profile or profile):
config = load_config()
row = _find_toolset_provider_row(name, config, body.provider)
plugin = _resolve_toolset_model_plugin(name, row) if row else None
if not plugin:
raise HTTPException(
status_code=400,
detail=f"No model-capable backend is active for {name}",
)
catalog, _default = _toolset_model_catalog(name, plugin)
if model_id not in catalog:
raise HTTPException(
status_code=400,
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
)
section_cfg = config.setdefault(section, {})
if not isinstance(section_cfg, dict):
section_cfg = {}
config[section] = section_cfg
section_cfg["model"] = model_id
save_config(config)
return {"ok": True, "name": name, "model": model_id, "plugin": plugin}
@router.put("/api/tools/toolsets/{name}/provider")
async def select_toolset_provider(
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
):
"""Persist a provider selection for a toolset (no key prompting).
Delegates to ``apply_provider_selection`` the shared, non-interactive
core extracted from the CLI configurator so the GUI and ``hermes tools``
write identical config keys (``web.backend``, ``tts.provider``, etc.).
API keys and post-setup flows are handled by separate endpoints. Returns
400 for unknown toolset or provider names.
For the ``web`` toolset only, an optional ``capability`` ('search' |
'extract') scopes the selection to ``web.search_backend`` /
``web.extract_backend`` the same per-capability overrides the runtime
dispatchers (``tools.web_tools._get_search_backend`` /
``_get_extract_backend``) resolve first. The provider must actually
support the requested capability (a search-only backend can't be the
extract backend). Omitting ``capability`` keeps the legacy whole-provider
behavior (writes ``web.backend``).
Managed Nous rows (``managed_nous_feature``) additionally report the
Portal entitlement state: the CLI flow gates these selections on
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
prompt, so selecting one while logged out / unentitled used to write the
config keys and then never activate (``_is_provider_active`` requires
``managed_by_nous``). The response now carries an additive
``needs_nous_auth: true`` + ``feature`` so the client can drive the
existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``)
and refetch.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
apply_provider_selection,
web_provider_capabilities,
_get_effective_configurable_toolsets,
_visible_providers,
)
from hermes_cli.nous_subscription import (
MANAGED_FEATURE_COVERAGE_CATEGORY,
get_nous_subscription_features,
)
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
if body.capability is not None:
if name != "web":
raise HTTPException(
status_code=400,
detail="capability selection is only supported for the web toolset",
)
if body.capability not in ("search", "extract"):
raise HTTPException(
status_code=400,
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
)
with _profile_scope(body.profile or profile):
config = load_config()
if body.capability is not None:
# Per-capability path: resolve the picker row to its backend key
# and write web.<capability>_backend. Does NOT touch web.backend,
# so the other capability keeps resolving through the shared
# fallback chain.
cat = TOOL_CATEGORIES.get(name)
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
prov = next((p for p in providers if p.get("name") == body.provider), None)
if prov is None:
raise HTTPException(
status_code=400,
detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
)
backend = prov.get("web_backend")
if not backend:
raise HTTPException(
status_code=400,
detail=f"Provider {body.provider!r} has no web backend key",
)
if body.capability not in web_provider_capabilities(backend):
raise HTTPException(
status_code=400,
detail=f"{body.provider} does not support {body.capability}",
)
web_cfg = config.setdefault("web", {})
if not isinstance(web_cfg, dict):
web_cfg = {}
config["web"] = web_cfg
web_cfg[f"{body.capability}_backend"] = backend
else:
try:
apply_provider_selection(name, body.provider, config)
except KeyError as exc:
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
save_config(config)
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
if body.capability is not None:
response["capability"] = body.capability
# Entitlement check for managed Nous rows — mirrors the gate the CLI
# applies via ensure_nous_portal_access at selection time.
cat = TOOL_CATEGORIES.get(name)
row = None
if cat:
row = next(
(
p
for p in _visible_providers(cat, config, force_fresh=True)
if p.get("name") == body.provider
),
None,
)
managed_feature = (row or {}).get("managed_nous_feature")
if managed_feature:
features = get_nous_subscription_features(config, force_fresh=True)
acct = features.account_info
category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
entitled = bool(
acct
and acct.logged_in
and (
acct.tool_gateway_entitled_for(category)
if category
else acct.tool_gateway_entitled
)
)
if not entitled:
response["needs_nous_auth"] = True
response["feature"] = managed_feature
return response
@router.put("/api/tools/toolsets/{name}/env")
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
"""Persist API keys for a toolset's provider env vars.
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value``
the same store ``hermes tools`` writes when it prompts for keys. Keys are
validated against the env-var allowlist for the toolset's category (the
union of every visible provider's ``env_vars``), so the GUI can't write an
arbitrary env var through this endpoint. A blank value is treated as
"leave unchanged" and skipped. Returns the saved/skipped key lists and the
refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
"""
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_get_effective_configurable_toolsets,
_visible_providers,
)
from hermes_cli.config import get_env_value, save_env_value
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
with _profile_scope(body.profile or profile):
config = load_config()
cat = TOOL_CATEGORIES.get(name)
allowed: set[str] = set()
if cat:
for prov in _visible_providers(cat, config, force_fresh=True):
for e in prov.get("env_vars", []):
allowed.add(e["key"])
unknown = [k for k in body.env if k not in allowed]
if unknown:
raise HTTPException(
status_code=400,
detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}",
)
saved: List[str] = []
skipped: List[str] = []
for key, value in body.env.items():
if value and value.strip():
try:
save_env_value(key, value.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc))
saved.append(key)
else:
skipped.append(key)
status = {k: bool(get_env_value(k)) for k in allowed}
return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
@router.post("/api/tools/toolsets/{name}/post-setup")
async def run_toolset_post_setup(
name: str, body: ToolsetPostSetup, profile: Optional[str] = None
):
"""Spawn a provider's post-setup install hook as a background action.
Post-setup hooks (npm install for browser/Camofox, pip install for
KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
text-output, so this follows the spawn-action pattern: it launches
``hermes tools post-setup <key>`` and the frontend tails the log via
``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
against the declared post-setup allowlist before spawning. Returns 400
for unknown toolset or post-setup key.
``profile`` spawns the hook as ``hermes -p <profile> tools post-setup``.
Most hooks install machine-level artifacts (repo node_modules, shared
pip packages) where the scope is inert, but hooks that read config or
write per-profile state must see the same HERMES_HOME the rest of the
drawer's writes targeted — so the scope is threaded for consistency.
"""
from hermes_cli.tools_config import (
_get_effective_configurable_toolsets,
valid_post_setup_keys,
)
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
if name not in valid_ts:
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
if body.key not in valid_post_setup_keys():
raise HTTPException(
status_code=400, detail=f"Unknown post-setup key: {body.key}"
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["tools", "post-setup", body.key],
"tools-post-setup",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn tools post-setup")
raise HTTPException(
status_code=500, detail=f"Failed to run post-setup: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
@router.get("/api/tools/terminal/backends")
async def get_terminal_backends(profile: Optional[str] = None):
"""Terminal execution backend rows with health probes for the picker panel.
Returns ``{active, backends: [{name, label, description, active, status,
detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` /
``unavailable`` and ``detail`` carries setup guidance for non-ready rows.
Probes are fast (<~2s each) and defensive a probe failure surfaces as a
status, never an error response.
"""
with _profile_scope(profile):
config = load_config()
terminal_cfg = config.get("terminal")
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
active = str(terminal_cfg.get("backend") or "local").strip().lower()
if active not in _TERMINAL_BACKEND_NAMES:
active = "local"
backends = []
for row in _TERMINAL_BACKENDS:
status, detail = _probe_terminal_backend(row["name"], terminal_cfg)
backends.append({
"name": row["name"],
"label": row["label"],
"description": row["description"],
"active": row["name"] == active,
"status": status,
"detail": detail,
})
return {"active": active, "backends": backends}
@router.put("/api/tools/terminal/backend")
async def select_terminal_backend(
body: TerminalBackendSelect, profile: Optional[str] = None
):
"""Persist ``terminal.backend`` in config.yaml.
Validates against the known backend set (the same enum the raw-config
settings row exposes). Selecting a backend that still needs setup is
allowed the picker shows guidance instead of blocking, matching the CLI.
"""
backend = (body.backend or "").strip().lower()
if backend not in _TERMINAL_BACKEND_NAMES:
raise HTTPException(
status_code=400,
detail=f"Unknown terminal backend: {body.backend!r}. "
f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}",
)
with _profile_scope(body.profile or profile):
config = load_config()
terminal_cfg = config.setdefault("terminal", {})
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
config["terminal"] = terminal_cfg
terminal_cfg["backend"] = backend
save_config(config)
return {"ok": True, "backend": backend}
@router.get("/api/tools/computer-use/status")
async def get_computer_use_status(profile: Optional[str] = None):
"""Cross-platform Computer Use readiness for the desktop card.
See ``tools.computer_use.permissions.computer_use_status`` for the payload
shape. Read-only and fast (shells ``cua-driver doctor`` + macOS
``permissions status``).
"""
from tools.computer_use.permissions import computer_use_status
with _profile_scope(profile):
return computer_use_status()
@router.post("/api/tools/computer-use/permissions/grant")
async def grant_computer_use_permissions(profile: Optional[str] = None):
"""Spawn ``hermes computer-use permissions grant`` as a background action.
macOS-only: ``cua-driver permissions grant`` launches CuaDriver via
LaunchServices so the TCC dialog is attributed to com.trycua.driver, then
waits for approval. The frontend polls ``GET /api/actions/computer-use-
grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have
no TCC toggles to grant, so this returns 400 there.
"""
if sys.platform != "darwin":
raise HTTPException(
status_code=400,
detail="Computer Use permission grants are a macOS concept.",
)
try:
proc = _spawn_hermes_action(
_profile_cli_args(profile)
+ ["computer-use", "permissions", "grant"],
"computer-use-grant",
)
except HTTPException:
raise
except Exception as exc:
_log.exception("Failed to spawn computer-use permissions grant")
raise HTTPException(
status_code=500, detail=f"Failed to request permissions: {exc}"
)
return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"}

File diff suppressed because it is too large Load diff

View file

@ -4,6 +4,7 @@ import threading
from pathlib import Path
from hermes_cli import web_server
from hermes_cli.web_routers import sessions as web_sessions
TARGET_HANDLERS = {
@ -29,15 +30,18 @@ def _call_name(call: ast.Call) -> str | None:
def test_sessiondb_handlers_open_connections_inside_executor_helpers():
tree = ast.parse(Path(web_server.__file__).read_text(encoding="utf-8"))
handlers = {
node.name: node
for node in tree.body
if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS
}
top_level_helpers = {
node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)
}
# The session route handlers were extracted to web_routers/sessions.py
# (wave 2); the analytics handlers and the executor helpers still live in
# web_server.py — scan both modules' top-level bodies.
handlers: dict[str, ast.AsyncFunctionDef] = {}
top_level_helpers: dict[str, ast.FunctionDef] = {}
for mod in (web_server, web_sessions):
tree = ast.parse(Path(mod.__file__).read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS:
handlers[node.name] = node
elif isinstance(node, ast.FunctionDef):
top_level_helpers[node.name] = node
assert handlers.keys() == TARGET_HANDLERS
for name, handler in handlers.items():