Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license

This commit is contained in:
2026-09-05 13:26:46 +03:00
commit 03634b1ca3
11340 changed files with 3442369 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
"""Extracted APIRouter modules for the dashboard web server.
Each module exposes ``router = APIRouter()`` (profiles additionally exposes
``sessions_router``) and is mounted by ``hermes_cli.web_server`` at the exact
point in module execution where the routes were originally registered, so
route-matching order is unchanged. Shared web_server helpers/state are
reached through the late-binding seam in ``hermes_cli.web_deps``.
"""
+339
View File
@@ -0,0 +1,339 @@
"""Cron dashboard routes (extracted verbatim from web_server.py).
Handler bodies are byte-identical. The ``*_sync`` workers, profile resolution
and the threadpool wrapper (``_run_cron_dashboard_io``) still live in
web_server — reached via the late-binding seam in :mod:`hermes_cli.web_deps`
so ``monkeypatch.setattr(web_server, ...)`` keeps working (several cron tests
rely on exactly that).
"""
import asyncio # noqa: F401 — used by handlers
import functools # noqa: F401
import logging
from typing import Optional # noqa: F401
from fastapi import APIRouter, HTTPException, Request # noqa: F401
from fastapi.responses import JSONResponse # noqa: F401
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
CronJobCreate,
CronJobUpdate,
AutomationBlueprintInstantiate,
)
# 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 — includes config readers so existing
# ``monkeypatch.setattr(web_server, "load_config", ...)`` idioms behave
# identically for these routes).
_run_cron_dashboard_io = late("_run_cron_dashboard_io")
_list_cron_jobs_sync = late("_list_cron_jobs_sync")
_get_cron_job_sync = late("_get_cron_job_sync")
_list_cron_job_runs_sync = late("_list_cron_job_runs_sync")
_create_cron_job_sync = late("_create_cron_job_sync")
_update_cron_job_sync = late("_update_cron_job_sync")
_pause_cron_job_sync = late("_pause_cron_job_sync")
_resume_cron_job_sync = late("_resume_cron_job_sync")
_trigger_cron_job_sync = late("_trigger_cron_job_sync")
_delete_cron_job_sync = late("_delete_cron_job_sync")
_find_cron_job_profile = late("_find_cron_job_profile")
_fire_cron_job_for_profile = late("_fire_cron_job_for_profile")
_forward_cron_fire_to_gateway = late("_forward_cron_fire_to_gateway")
_gateway_intentionally_stopped = late("_gateway_intentionally_stopped")
_notify_cron_provider_for_profile = late("_notify_cron_provider_for_profile")
_call_cron_for_profile = late("_call_cron_for_profile")
_raise_if_cron_registration_error = late("_raise_if_cron_registration_error")
load_config = late("load_config")
cfg_get = late("cfg_get")
# Retry-After hint (seconds) stamped on retryable cron-fire 503s. Sized to
# clear the common transient windows — a scale-to-zero wake or an s6 gateway
# restart completes well within a minute — so a scheduler that honors it
# spaces its next attempt PAST the outage window instead of burning its whole
# retry budget inside it (OOF-266). Honored by QStash once NAS propagates it;
# harmless (ignored) until then.
_CRON_FIRE_RETRY_AFTER_SECONDS = 60
@router.get("/api/cron/jobs")
async def list_cron_jobs(profile: str = "all"):
return await _run_cron_dashboard_io(_list_cron_jobs_sync, profile)
@router.get("/api/cron/jobs/{job_id}")
async def get_cron_job(job_id: str, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_get_cron_job_sync, job_id, profile)
@router.get("/api/cron/jobs/{job_id}/runs")
async def list_cron_job_runs(job_id: str, profile: Optional[str] = None, limit: int = 20):
return await _run_cron_dashboard_io(_list_cron_job_runs_sync, job_id, profile, limit)
@router.post("/api/cron/jobs")
async def create_cron_job(body: CronJobCreate, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_create_cron_job_sync, body, profile)
@router.get("/api/cron/delivery-targets")
async def get_cron_delivery_targets():
"""Delivery targets the cron dropdown should offer.
Always includes the implicit ``local`` option. Beyond that, the list is
derived dynamically from the configured gateway platforms via
``cron.scheduler.cron_delivery_targets()`` — no hardcoded platform list. A
configured platform that hasn't set its cron home channel is still returned
with ``home_target_set: false`` so the UI can surface it as "configure a
home channel first" rather than hiding it.
"""
targets = [
{
"id": "local",
"name": "Local (save only)",
"home_target_set": True,
"home_env_var": None,
}
]
try:
from cron.scheduler import cron_delivery_targets
targets.extend(cron_delivery_targets())
except Exception:
_log.exception("GET /api/cron/delivery-targets failed")
return {"targets": targets}
@router.put("/api/cron/jobs/{job_id}")
async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_update_cron_job_sync, job_id, body, profile)
@router.post("/api/cron/jobs/{job_id}/pause")
async def pause_cron_job(job_id: str, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_pause_cron_job_sync, job_id, profile)
@router.post("/api/cron/jobs/{job_id}/resume")
async def resume_cron_job(job_id: str, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_resume_cron_job_sync, job_id, profile)
@router.post("/api/cron/jobs/{job_id}/trigger")
async def trigger_cron_job(job_id: str, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_trigger_cron_job_sync, job_id, profile)
@router.delete("/api/cron/jobs/{job_id}")
async def delete_cron_job(job_id: str, profile: Optional[str] = None):
return await _run_cron_dashboard_io(_delete_cron_job_sync, job_id, profile)
@router.post("/api/cron/fire")
async def cron_fire_webhook(request: Request):
"""Chronos managed-cron fire webhook (NAS -> agent) — gateway forwarder.
Authenticated by a short-lived NAS-minted JWT (verified by the pluggable
Chronos fire-verifier), NOT the dashboard session cookie — so this path is
in ``PUBLIC_API_PATHS`` to bypass the dashboard auth gate, and the JWT is
the real gate.
The dashboard is only the PUBLIC DOOR here (on hosted deployments the Fly
proxy exposes exactly one port, the dashboard's). Cron execution belongs
to the GATEWAY process, which owns the live platform adapters — required
for relay-fronted logical platforms (their only sender is the live relay
adapter) and E2EE rooms, neither of which the dashboard's standalone send
path can serve. So after verifying the JWT this handler FORWARDS the fire
to the gateway api_server's own ``/api/cron/fire`` on loopback and passes
the gateway's response through (the gateway re-verifies the JWT — defense
in depth, no new trust link).
Gateway unreachable (scale-to-zero wake still booting, restart window,
api_server disabled) → 503, so NAS retries per the Chronos contract
(non-2xx = retryable). The store CAS claim de-dupes the eventual double
fire. Deliberately NO local-execution fallback: delivering from the wrong
process is worse than a delayed retry.
"""
from plugins.cron_providers.chronos.verify import get_fire_verifier
auth = request.headers.get("Authorization", "")
token = auth[7:].strip() if auth.startswith("Bearer ") else ""
cfg = await asyncio.to_thread(load_config)
claims = get_fire_verifier()(
token=token,
expected_audience=cfg_get(cfg, "cron", "chronos", "expected_audience", default=""),
jwks_or_key=cfg_get(cfg, "cron", "chronos", "nas_jwks_url", default="") or None,
issuer=cfg_get(cfg, "cron", "chronos", "portal_url", default="") or None,
)
if claims is None:
return JSONResponse({"error": "invalid fire token"}, status_code=401)
try:
body = await request.json()
except Exception:
body = {}
job_id = (body or {}).get("job_id") if isinstance(body, dict) else None
if not job_id:
return JSONResponse({"error": "missing job_id"}, status_code=400)
# _find_cron_job_profile walks every profile and lists its jobs (file
# I/O per profile) — run it off the event loop like the other cron
# dashboard endpoints.
profile = await _run_cron_dashboard_io(_find_cron_job_profile, job_id)
if not profile:
# Job is gone (cancelled / completed) — nothing to fire. 200 so NAS
# does not retry a fire that is intentionally absent.
return JSONResponse({"status": "gone", "job_id": job_id}, status_code=200)
forwarded = await _forward_cron_fire_to_gateway(profile, job_id, auth)
if forwarded is None:
# Durably stamp the miss on the job record (last_fire_error) so the
# user and their agent can see "scheduled fire could not reach the
# runner" in `cronjob list` / the dashboard — without this, a dead
# 8642 hop is invisible outside gui.log (no execution row is ever
# created because the claim never happens). Best-effort: visibility
# must never break the retry contract below.
try:
await _run_cron_dashboard_io(
_call_cron_for_profile,
profile,
"note_fire_forward_failure",
job_id,
"scheduled fire could not be forwarded to the gateway "
"api_server (127.0.0.1 loopback unreachable); the gateway "
"process may be down or its api_server adapter not bound "
"(missing API_SERVER_KEY)",
)
except Exception:
_log.debug("could not stamp last_fire_error for %s", job_id, exc_info=True)
# Gateway unreachable. Split by OPERATOR INTENT (OOF-266):
#
# - Deliberately stopped gateway (durable desired_state == "stopped",
# written only by the s6 lifecycle commands): retrying can never
# succeed until a human starts the gateway again, so the retry
# budget is pure waste and the resulting 503→502 storms page the
# NAS on-call for a non-incident. Drop with 200 + a structured log
# line, mirroring NAS's own instance_stopped drop in the relay.
# The fire is NOT lost silently: the log names job and profile,
# and the gateway's Chronos provider reconciles + re-arms every
# job on its next startup (plugins/cron_providers/chronos
# start() -> reconcile()), so fires resume when the operator
# starts the gateway.
#
# - Transient window (scale-to-zero wake, restart, crash loop —
# desired_state is "running" or unknown): keep the retryable 503,
# but stamp Retry-After so a scheduler that honors it spaces its
# next attempt past the wake/restart window instead of exhausting
# the whole retry budget inside it.
if await _run_cron_dashboard_io(_gateway_intentionally_stopped, profile):
_log.info(
"cron fire dropped: gateway for profile %r is deliberately "
"stopped (desired_state=stopped); job %s will resume via "
"Chronos reconcile on next gateway start",
profile, job_id,
)
return JSONResponse(
{
"status": "gateway_stopped",
"detail": "gateway deliberately stopped; fire dropped, "
"jobs re-arm on next gateway start",
"job_id": job_id,
"profile": profile,
},
status_code=200,
)
return JSONResponse(
{
"error": "gateway unreachable; retry",
"job_id": job_id,
"profile": profile,
},
status_code=503,
headers={"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)},
)
status_code, gateway_body = forwarded
if isinstance(gateway_body, dict):
gateway_body.setdefault("job_id", job_id)
headers = (
# The gateway's own 503s (draining, admission failure) are equally
# transient — give the scheduler the same spacing hint.
{"Retry-After": str(_CRON_FIRE_RETRY_AFTER_SECONDS)}
if status_code == 503
else None
)
return JSONResponse(gateway_body, status_code=status_code, headers=headers)
@router.get("/api/cron/blueprints")
async def list_cron_blueprints():
"""Return the blueprint catalog as form schemas for the dashboard gallery.
The ``deliver`` slot's options are rewritten from the user's actually
configured gateway platforms (plus the universal origin/local/all), so the
form never offers a platform that isn't connected.
"""
try:
from cron.blueprint_catalog import CATALOG, blueprint_catalog_entry
deliver_options = None
try:
from cron.scheduler import cron_delivery_targets
platforms = [t["id"] for t in cron_delivery_targets() if t.get("id")]
deliver_options = ["origin", "local", *platforms]
except Exception:
_log.debug("cron_delivery_targets unavailable; using static deliver options", exc_info=True)
entries = []
for r in CATALOG:
entry = blueprint_catalog_entry(r)
if deliver_options:
for f in entry.get("fields", []):
if f.get("name") == "deliver":
f["options"] = deliver_options
entries.append(entry)
return {"blueprints": entries}
except Exception as e:
_log.exception("GET /api/cron/blueprints failed")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/cron/blueprints/instantiate")
async def instantiate_blueprint(body: AutomationBlueprintInstantiate, profile: str = "default"):
"""Fill a blueprint's slots and create the cron job (form-submit path)."""
try:
from cron.blueprint_catalog import fill_blueprint, get_blueprint, BlueprintFillError
blueprint = get_blueprint(body.blueprint)
if blueprint is None:
raise HTTPException(status_code=404, detail=f"Unknown blueprint: {body.blueprint}")
try:
spec = fill_blueprint(blueprint, body.values)
except BlueprintFillError as exc:
# Field-level validation error — 422 so the form can show it inline.
raise HTTPException(status_code=422, detail=str(exc)) from exc
# Blueprint-created jobs deliver to the dashboard's configured target by
# default; the form's deliver slot overrides via spec["deliver"].
spec.pop("origin", None)
# create_job does per-profile file I/O — keep it off the event loop
# like the sibling cron endpoints (partial avoids **spec keys ever
# colliding with the wrapper's own parameters).
_create = functools.partial(_call_cron_for_profile, profile, "create_job", **spec)
created = await _run_cron_dashboard_io(_create)
# Same contract as the other dashboard mutations: reconcile the
# profile-scoped provider (best-effort; fail-closed for external
# providers on a multi-profile dashboard). Off the event loop —
# a Chronos reconcile does file I/O plus NAS network calls.
await _run_cron_dashboard_io(_notify_cron_provider_for_profile, profile)
return created
except HTTPException:
raise
except Exception as e:
_raise_if_cron_registration_error(e)
_log.exception("POST /api/cron/blueprints/instantiate failed")
raise HTTPException(status_code=400, detail=str(e))
+196
View File
@@ -0,0 +1,196 @@
"""Git dashboard routes (extracted verbatim from web_server.py).
Handler bodies are byte-identical to their previous in-web_server form; the
helpers they call (``_git_op``, ``_git_path``) still live in web_server and are
reached via the late-binding seam in :mod:`hermes_cli.web_deps`, so
``monkeypatch.setattr(web_server, ...)`` keeps working.
"""
from typing import Optional
from fastapi import APIRouter
from hermes_cli import web_git as _web_git # noqa: F401 — used by handlers
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
GitPathBody,
GitFileBody,
GitCommitBody,
GitPrListBody,
GitWorktreeAddBody,
GitWorktreeRemoveBody,
GitBranchSwitchBody,
)
router = APIRouter()
# Late-bound web_server helpers (resolved at call time; cycle-safe,
# monkeypatch-transparent).
_git_op = late("_git_op")
_git_path = late("_git_path")
@router.get("/api/git/status")
async def git_status_route(path: str):
return await _git_op(_web_git.repo_status, _git_path(path))
# ─── gh CLI auth probe ───────────────────────────────────────────────────────
# Cached `gh auth status` result. Consumed by the desktop composer's GitHub
# suggestion pill: GitHub deliberately has NO MCP catalog entry (its hosted
# MCP requires a per-host OAuth app — generic DCR 404s — and the bundled
# github/* skills via gh CLI are the more capable integration), so the pill
# offers the `/github-auth` skill instead, and only to users who aren't
# already authenticated. The probe is read-only and never prompts.
_GH_AUTH_TTL_S = 300.0
_gh_auth_cache: Optional[tuple] = None # (monotonic_ts, payload)
@router.get("/api/git/gh-auth")
async def gh_auth_status_route(refresh: bool = False):
"""Report whether the `gh` CLI is present and authenticated.
Returns ``{"available": bool, "authenticated": bool}``. Cached for five
minutes (`refresh=true` bypasses — the pill uses it after a completed
login so the suggestion withdraws immediately).
"""
global _gh_auth_cache
import asyncio
import time
if not refresh and _gh_auth_cache and time.monotonic() - _gh_auth_cache[0] < _GH_AUTH_TTL_S:
return _gh_auth_cache[1]
def _probe() -> dict:
import shutil
import subprocess
gh = shutil.which("gh")
if not gh:
return {"available": False, "authenticated": False}
try:
# `gh auth status` exits 0 when at least one host is logged in.
# Never interactive; DEVNULL stdin guards against any prompt.
proc = subprocess.run(
[gh, "auth", "status"],
stdin=subprocess.DEVNULL,
capture_output=True,
timeout=10,
)
return {"available": True, "authenticated": proc.returncode == 0}
except Exception:
return {"available": True, "authenticated": False}
payload = await asyncio.to_thread(_probe)
_gh_auth_cache = (time.monotonic(), payload)
return payload
@router.get("/api/git/worktrees")
async def git_worktrees_route(path: str):
return {"worktrees": await _git_op(_web_git.worktree_list, _git_path(path))}
@router.get("/api/git/branches")
async def git_branches_route(path: str):
return {"branches": await _git_op(_web_git.branch_list, _git_path(path))}
@router.get("/api/git/base-branches")
async def git_base_branches_route(path: str):
return {"branches": await _git_op(_web_git.base_branch_list, _git_path(path))}
@router.get("/api/git/review/list")
async def git_review_list_route(path: str, scope: str = "uncommitted", base: Optional[str] = None):
return await _git_op(_web_git.review_list, _git_path(path), scope, base)
@router.get("/api/git/review/diff")
async def git_review_diff_route(
path: str, file: str, scope: str = "uncommitted", base: Optional[str] = None, staged: bool = False
):
return {"diff": await _git_op(_web_git.review_diff, _git_path(path), file, scope, base, staged)}
@router.get("/api/git/file-diff")
async def git_file_diff_route(path: str, file: str):
return {"diff": await _git_op(_web_git.file_diff_vs_head, _git_path(path), file)}
@router.get("/api/git/review/commit-context")
async def git_commit_context_route(path: str):
return await _git_op(_web_git.review_commit_context, _git_path(path))
@router.get("/api/git/review/rev-parse")
async def git_rev_parse_route(path: str, ref: Optional[str] = None):
return {"sha": await _git_op(_web_git.review_rev_parse, _git_path(path), ref)}
@router.get("/api/git/review/ship-info")
async def git_ship_info_route(path: str):
return await _git_op(_web_git.review_ship_info, _git_path(path))
@router.post("/api/git/review/pr-list")
async def git_pr_list_route(body: GitPrListBody):
return await _git_op(_web_git.review_pr_list, _git_path(body.path), body.branches, body.numbers)
@router.post("/api/git/review/stage")
async def git_stage_route(body: GitFileBody):
return await _git_op(_web_git.review_stage, _git_path(body.path), body.file)
@router.post("/api/git/review/unstage")
async def git_unstage_route(body: GitFileBody):
return await _git_op(_web_git.review_unstage, _git_path(body.path), body.file)
@router.post("/api/git/review/revert")
async def git_revert_route(body: GitFileBody):
return await _git_op(_web_git.review_revert, _git_path(body.path), body.file)
@router.post("/api/git/review/commit")
async def git_commit_route(body: GitCommitBody):
return await _git_op(_web_git.review_commit, _git_path(body.path), body.message, body.push)
@router.post("/api/git/review/push")
async def git_push_route(body: GitPathBody):
return await _git_op(_web_git.review_push, _git_path(body.path))
@router.post("/api/git/review/create-pr")
async def git_create_pr_route(body: GitPathBody):
return await _git_op(_web_git.review_create_pr, _git_path(body.path))
@router.post("/api/git/worktree/add")
async def git_worktree_add_route(body: GitWorktreeAddBody):
options = {
key: value
for key, value in {
"name": body.name,
"branch": body.branch,
"base": body.base,
"existingBranch": body.existingBranch,
}.items()
if value
}
return await _git_op(_web_git.worktree_add, _git_path(body.path), options)
@router.post("/api/git/worktree/remove")
async def git_worktree_remove_route(body: GitWorktreeRemoveBody):
return await _git_op(
_web_git.worktree_remove, _git_path(body.path), _git_path(body.worktreePath), body.force
)
@router.post("/api/git/branch/switch")
async def git_branch_switch_route(body: GitBranchSwitchBody):
return await _git_op(_web_git.branch_switch, _git_path(body.path), body.branch)
File diff suppressed because it is too large Load Diff
+577
View File
@@ -0,0 +1,577 @@
"""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")
# Config read-modify-write serialization for off-loop handlers (defined in
# web_server.py; LateState supports ``with``-blocks, so this is the live lock).
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@router.get("/api/mcp/servers")
async def list_mcp_servers(profile: Optional[str] = None):
from hermes_cli.mcp_config import _get_mcp_servers
def _read():
with _profile_scope(profile):
return _get_mcp_servers()
servers = await asyncio.to_thread(_read)
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
def _run():
with _profile_scope(body.profile or profile):
# _save_mcp_server does its own load→mutate→save of config.yaml;
# serialize the whole cycle against other off-loop config writers.
# The duplicate-name check lives under the same lock span so a
# concurrent add of the same name can't slip between check and save.
with _CONFIG_MUTATION_LOCK:
if name in _get_mcp_servers():
raise HTTPException(
status_code=409, detail=f"Server '{name}' already exists"
)
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",
)
try:
await asyncio.to_thread(_run)
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
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
return _replace_mcp_servers(body.servers)
ok, issues = await asyncio.to_thread(_run)
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
def _run():
with _profile_scope(profile):
with _CONFIG_MUTATION_LOCK:
return _remove_mcp_server(name)
removed = await asyncio.to_thread(_run)
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,
)
def _read():
with _profile_scope(profile):
return _get_mcp_servers()
servers = await asyncio.to_thread(_read)
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": [],
}
# Additive-optional per-tool schema size (chars of the converted registry
# schema) — the desktop's cost overlay estimates tokens from it. Older
# renderers ignore the extra key; failed probes simply omit it.
schema_chars = details.get("schema_chars") or {}
return {
"ok": True,
"tools": [
{
"name": t,
"description": d,
**(
{"schema_chars": schema_chars[t]}
if isinstance(schema_chars.get(t), int)
else {}
),
}
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))
def _read():
with _profile_scope(profile):
return _get_mcp_servers(), str(get_hermes_home().expanduser().resolve(strict=False))
servers, flow_home = await asyncio.to_thread(_read)
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.delete("/api/mcp/oauth/flows/{flow_id}")
async def cancel_mcp_oauth_flow(flow_id: str, request: Request):
"""Cancel an in-flight MCP OAuth flow (the desktop's inline-card/pill
cancel). mark_error unblocks both worker waits, so the worker exits and
frees the per-server "already in progress" slot — without this, a renderer
that stops polling leaves the flow squatting until its 300s callback
timeout and every retry 409s. Idempotent: an already-settled flow is left
as-is (approved stays approved)."""
_require_token(request)
flow = _mcp_oauth_flows.get(flow_id)
if flow is None:
# Expired/GC'd is the goal state of a cancel — not an error.
return {"ok": True, "status": "expired"}
flow.mark_error("Cancelled by user")
return {"ok": True, "status": flow.snapshot()["status"]}
@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.
"""
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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)}
return await asyncio.to_thread(_run)
@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:
def _read():
with _profile_scope(profile):
catalog = list(mcp_catalog.list_catalog())
state = {
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
for e in catalog
}
return catalog, state
catalog_entries, installed_state = await asyncio.to_thread(_read)
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 "",
# Composer-suggestion triggers (desktop brand pills). Present
# only for entries whose manifest declares a `suggest` block.
"suggest": {
"keywords": list(entry.suggest.keywords),
"hosts": list(entry.suggest.hosts),
} if entry.suggest else None,
"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}'")
# Catalog credentials are a closed schema: configuring one MCP must not
# become a generic write primitive for unrelated process environment.
declared_env = {spec.name for spec in (entry.auth.env or [])}
undeclared_env = sorted(set(body.env) - declared_env)
if undeclared_env:
raise HTTPException(
status_code=400,
detail=(
f"Catalog entry '{name}' does not declare environment "
f"variable(s): {', '.join(undeclared_env)}"
),
)
# Validate the complete map before the first write. This preserves the
# existing writer/install flow while ensuring a mixed valid+invalid request
# cannot partially persist credentials.
from hermes_cli.config import validate_env_var_name_for_write
try:
for key in body.env:
validate_env_var_name_for_write(key)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
# Persist any supplied, declared env vars first.
effective_profile = body.profile or profile
if body.env:
def _write_env():
with _profile_scope(effective_profile):
for k, v in body.env.items():
if v:
save_env_value(k, v)
await asyncio.to_thread(_write_env)
# 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}
File diff suppressed because it is too large Load Diff
+928
View File
@@ -0,0 +1,928 @@
"""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 json
import logging
import sqlite3
import time # noqa: F401
from typing import Any, Dict, List, Optional # noqa: F401
from fastapi import APIRouter, HTTPException, Query, Request # noqa: F401
from fastapi.encoders import jsonable_encoder
from fastapi.responses import StreamingResponse
from hermes_cli.web_deps import late
from hermes_cli.web_models import (
BulkDeleteSessions,
SessionImport,
SessionOwnerBackfill,
SessionPrune,
SessionRename,
)
from hermes_state import is_malformed_db_error, is_transient_sqlite_error
# 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")
def _resolve_session_id(db, session_id: str) -> Optional[str]:
"""Resolve *session_id*, distinguishing "absent" from "unreadable".
A corrupt ``state.db`` does not raise on every read. The exact-match
lookup goes through the ``sessions`` primary-key index, so a damaged
index simply *misses* and returns None — indistinguishable from a
session that was never there. Only the prefix fallback scans the base
b-tree and raises ``database disk image is malformed``.
Both outcomes used to end at ``404 Session not found``: one silently, one
as an unhandled 500 that the Desktop surfaced as "session unavailable".
Reporting a corrupt store as an empty one cost a day of looking at
session logic during the 2026-08-31 incident, so classify it here and say
what is actually wrong.
"""
try:
return db.resolve_session_id(session_id)
except sqlite3.DatabaseError as exc:
if not is_malformed_db_error(exc):
raise
_log.error(
"state.db is corrupt while resolving session %s: %s", session_id, exc
)
raise HTTPException(
status_code=503,
detail=(
"Session store is corrupt (database disk image is malformed). "
"Sessions cannot be read until it is repaired — run "
"`hermes doctor` for diagnosis."
),
) from exc
@list_router.get("/api/sessions")
def get_sessions(
# ``le=100`` caps the page size (idea from #39200): an unbounded limit
# lets one request drag every session row (plus correlated-subquery
# preview work) out of SQLite in a single hit.
limit: int = Query(20, ge=0, le=100),
offset: int = Query(0, ge=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:
# Auto-archive is the only configured write on this GET path. Run it
# through a dedicated maintenance connection, close that writer, then
# open the listing connection read-only.
_maybe_auto_archive_for_profile(profile)
db = _open_session_db_for_profile(profile, read_only=True)
try:
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 sqlite3.OperationalError as exc:
_log.exception("GET /api/sessions failed")
# 503, not 500: the store is busy, not gone. The desktop keeps the
# sidebar it already has instead of reading a 500 as an authoritative
# empty list. Retrying the OPEN here is deliberately not done — the
# bounded retry lives in SessionDB's read-only constructor, so every
# read-only opener gets it, not just this route.
transient = is_transient_sqlite_error(exc)
raise HTTPException(
status_code=503 if transient else 500,
detail=(
"Session store is busy (disk I/O or lock). Retry; the list was not cleared."
if transient
else "Internal server error"
),
) from exc
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, read_only=True)
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,
fields=(
"session_id",
"role",
"snippet",
"source",
"model",
"session_started",
),
)
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, read_only=False)
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, read_only=True)
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, ended, non-archived session in a single
transaction.
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
* "Empty" means the session owns no rows in ``messages`` at all — not
merely ``message_count == 0``. A rewound or in-place-compacted chat
keeps its dropped turns as soft-archived (``active = 0``) rows while
the counter reads zero, and those rows are the only recoverable copy
of the transcript (#95868).
* 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, read_only=False)
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, read_only=True)
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, read_only=True)
try:
sid = _resolve_session_id(db, 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, read_only=True)
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] = Query(None, ge=0),
offset: int = Query(0, ge=0),
order: Optional[str] = Query(None),
include_compacted: bool = Query(False),
):
if order not in (None, "oldest", "latest"):
raise HTTPException(
status_code=400,
detail="order must be one of: oldest, latest",
)
def _read():
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = _resolve_session_id(db, session_id)
if not sid:
return None
sid = db.resolve_resume_session_id(sid)
# Always page this endpoint. An omitted limit used to load an
# entire transcript, which can be hundreds of thousands of rows
# for a runaway session and exhaust the dashboard process. Keep
# explicit pagination anchored at the start, while the default
# dashboard view returns the latest page in chronological order.
default_page = limit is None
latest_page = order == "latest" or (order is None and default_page)
_limit = 500 if default_page else min(limit, 500)
return sid, _limit, db.get_messages(
sid,
limit=_limit,
offset=offset,
latest=latest_page,
include_compacted=include_compacted,
)
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
from agent.compaction_display import project_compaction_message_for_display
from agent.context_compressor import is_compaction_summary_message
projected_messages = []
for message in messages:
if not is_compaction_summary_message(message):
projected_messages.append(message)
continue
display_view = project_compaction_message_for_display(message)
projected = message.copy()
if display_view is None:
if not projected.get("display_kind"):
projected["display_kind"] = "hidden"
else:
# Keep the physical content for inspection/export compatibility;
# Desktop consumes this display-only projection. A legacy hidden
# wrapper must not hide a successfully recovered live ask.
projected["display_content"] = display_view.get("content")
projected.pop("display_kind", None)
projected_messages.append(projected)
return {
"session_id": sid,
"messages": projected_messages,
"pagination": {
"limit": _limit,
"offset": offset,
"order": order or ("latest" if limit is None else "oldest"),
"returned": len(projected_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, read_only=False)
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 = _resolve_session_id(db, 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.post("/api/sessions/owner-backfill")
async def backfill_session_owner_profiles(body: SessionOwnerBackfill):
"""Stamp legacy ``profile_name = NULL`` session rows with this store's own
serving-profile identity (#94724 legacy-session migration).
Pre-#95407 rows never recorded an owning profile. That was fine while one
backend served everything, but a Desktop with registry topology (≥2
registered connections) fails closed on unowned rows by design — leaving
every pre-campaign session unresumable with no migration path. Each
profile's ``state.db`` belongs to exactly one profile, so stamping that
store's own name is a single-match backfill, never a guess; the value
written is the SAME serving-profile identity the list endpoints already
stamp onto outgoing rows (``row_profile`` in ``get_sessions``). Idempotent
and one-shot-per-row: non-NULL owners are never overwritten and a second
call reports 0.
"""
profile_name: Optional[str] = None
if body.profile:
profile_name, _ = _cron_profile_home(body.profile)
stamp = profile_name or _cron_default_profile()
def _backfill():
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
return db.backfill_null_session_profiles(stamp)
finally:
db.close()
try:
stamped = await asyncio.to_thread(_backfill)
except HTTPException:
raise
except Exception:
_log.exception("POST /api/sessions/owner-backfill failed")
raise HTTPException(status_code=500, detail="Internal server error")
if stamped:
_log.info(
"owner-backfill: stamped %d legacy NULL-profile session row(s) with profile %r",
stamped,
stamp,
)
return {"ok": True, "stamped": stamped, "profile": stamp}
@manage_router.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename, archive, hide, pin, and/or mark read/unread.
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
restores the session; ``hidden`` controls generic list visibility;
``pinned`` sets the durable keep flag (exempts the
session from the auto-archive sweep); ``unread`` toggles the read-state
watermark (True = explicitly unread, False = read up to now — see
``SessionDB.set_session_read``). Any field may be omitted. ``profile``
targets another profile's session.
"""
db = _open_session_db_for_profile(body.profile, read_only=False)
try:
sid = _resolve_session_id(db, 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.hidden is None
and body.pinned is None
and body.unread is None
):
raise HTTPException(
status_code=400,
detail="Nothing to update; provide 'title', 'archived', 'hidden', 'pinned', and/or 'unread'.",
)
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.hidden is not None:
db.set_session_hidden(sid, body.hidden)
if body.pinned is not None:
db.set_session_pinned(sid, body.pinned)
if body.unread is not None:
db.set_session_read(sid, read=not body.unread)
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
if body.hidden is not None:
result["hidden"] = bool(body.hidden)
if body.pinned is not None:
result["pinned"] = bool(body.pinned)
if body.unread is not None:
result["unread"] = bool(body.unread)
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):
"""Stream a single session (metadata + messages) as JSON."""
def _prepare_export():
db = _open_session_db_for_profile(profile, read_only=True)
try:
sid = _resolve_session_id(db, session_id)
return (sid, db.get_session(sid)) if sid else None
finally:
db.close()
prepared = await asyncio.to_thread(_prepare_export)
if prepared is None or prepared[1] is None:
raise HTTPException(status_code=404, detail="Session not found")
sid, session = prepared
def _stream_export():
db = _open_session_db_for_profile(profile, read_only=True)
try:
metadata = json.dumps(
jsonable_encoder(session),
ensure_ascii=False,
separators=(",", ":"),
)
yield metadata[:-1] + ',"messages":['
# Keyset pagination (id > last_seen): O(n) total over the
# transcript, vs OFFSET's O(n²) on huge sessions.
last_id = None
first = True
while True:
messages = db.get_messages(
sid,
limit=500,
after_id=last_id if last_id is not None else 0,
)
for message in messages:
if not first:
yield ","
yield json.dumps(
jsonable_encoder(message),
ensure_ascii=False,
separators=(",", ":"),
)
first = False
if len(messages) < 500:
break
last_id = messages[-1].get("id")
if last_id is None:
break # defensive: cannot keyset without row ids
yield "]}"
finally:
db.close()
return StreamingResponse(
_stream_export(),
media_type="application/json",
)
@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)
+578
View File
@@ -0,0 +1,578 @@
"""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")
# Config read-modify-write serialization for off-loop handlers (see the
# definition in web_server.py). LateState supports ``with``-blocks, so this
# is the live lock object, not a frozen import-time copy.
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@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/official")
async def list_official_skills(profile: Optional[str] = None):
"""List the ENTIRE built-in optional-skills catalog shipped with the repo.
Backs the desktop Capabilities → Skills list: every official optional
skill appears alongside the installed skills with an install affordance.
Local-checkout scan only (no network), plus the per-profile installed
map so the UI can mark rows that are already installed.
"""
def _run():
from tools.skills_hub import OptionalSkillSource
installed = _installed_hub_identifiers(profile)
out = []
for m in OptionalSkillSource().list_local():
payload = _skill_meta_to_payload(m)
ident = payload.get("identifier") or ""
# identifier format: official/<category>/<skill> (category dirs
# mirror skills/): surface the category for row subtitles.
rel = ident.split("/", 1)[-1] if "/" in ident else ident
payload["category"] = rel.split("/", 1)[0] if "/" in rel else "general"
payload["installed"] = ident in installed
out.append(payload)
return {"skills": out}
try:
return await asyncio.to_thread(_run)
except HTTPException:
raise
except Exception as exc:
_log.exception("official skills catalog listing failed")
raise HTTPException(status_code=502, detail=f"Official catalog failed: {exc}")
@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
tier1 = None
try:
q_path = quarantine_bundle(bundle)
result = scan_skill(q_path, source=scan_source)
# Advisory SkillEvaluator Tier 1 second opinion (same contract
# as the CLI installer: optional binary, never blocks, errors
# degrade to no data).
try:
from tools.skillevaluator_scan import (
run_tier1_scan, tier1_advisory_enabled,
)
if tier1_advisory_enabled():
t1 = run_tier1_scan(q_path)
if t1.available:
tier1 = {
"passed": t1.passed,
"incomplete_checks": t1.incomplete_checks,
"findings": [
{
"check": f.check,
"validator": f.validator,
"severity": f.severity,
"message": f.message,
"file": f.file,
"line": f.line,
"secrets_class": f.is_secrets_class,
}
for f in t1.findings
],
}
except Exception:
_log.debug("Tier 1 advisory scan skipped", exc_info=True)
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,
# Advisory SkillEvaluator Tier 1 block, or None when the
# optional scanner isn't installed/enabled.
"tier1": tier1,
}
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,
)
def _run():
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
return await asyncio.to_thread(_run)
@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
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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}
return await asyncio.to_thread(_run)
@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
def _run():
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)}
return await asyncio.to_thread(_run)
@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
def _run():
with _profile_scope(body.profile):
return _create_skill(body.name, body.content, body.category or None)
result = await asyncio.to_thread(_run)
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
def _run():
with _profile_scope(body.profile):
return _edit_skill(body.name, body.content)
result = await asyncio.to_thread(_run)
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
+833
View File
@@ -0,0 +1,833 @@
"""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 asyncio
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")
run_in_threadpool = late("run_in_threadpool")
# 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")
# Dynamic variants: built-ins + plugin-registered backends, computed per
# request so a plugin installed after server start still shows up.
_terminal_backend_rows = late("_terminal_backend_rows")
_terminal_backend_names = late("_terminal_backend_names")
# Config read-modify-write serialization for off-loop handlers (defined in
# web_server.py; LateState supports ``with``-blocks, so this is the live lock).
_CONFIG_MUTATION_LOCK = LateState("_CONFIG_MUTATION_LOCK")
@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,
get_nous_subscription_features,
gui_toolset_label,
)
from hermes_cli.platforms import platform_label
from toolsets import resolve_toolset
def _read():
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
}
features = get_nous_subscription_features(config)
return config, toolset_rows, enabled_by_platform, features
config, toolset_rows, enabled_by_platform, features = await run_in_threadpool(_read)
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, features=features),
"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)
def _run():
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):
with _CONFIG_MUTATION_LOCK:
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
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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)
await asyncio.to_thread(_run)
# Install-on-enable: when the newly enabled toolset's provider carries a
# post_setup hook with a registered, UNSATISFIED install-state predicate
# (cua-driver binary missing, etc. — see _POST_SETUP_INSTALLED), spawn the
# same background install `hermes tools` runs interactively. Without this,
# a dashboard/desktop toggle "saves" but the tool silently never appears
# in the schema because its check_fn can't find the binary — the exact
# dead-end that forced users to discover `hermes computer-use install`
# by hand. Best-effort: a spawn failure never fails the toggle.
post_setup_started: Optional[str] = None
if body.enabled and name not in _CONFIG_ONLY_TOOLSETS:
def _pending_install_key() -> Optional[str]:
from hermes_cli.tools_config import (
TOOL_CATEGORIES,
_post_setup_already_installed,
_visible_providers,
)
cat = TOOL_CATEGORIES.get(name)
if not cat:
return None
with _profile_scope(body.profile or profile):
config = load_config()
for prov in _visible_providers(cat, config):
key = prov.get("post_setup")
if key and not _post_setup_already_installed(key):
return key
return None
try:
pending_key = await asyncio.to_thread(_pending_install_key)
if pending_key:
_spawn_hermes_action(
_profile_cli_args(body.profile or profile)
+ ["tools", "post-setup", pending_key],
"tools-post-setup",
)
post_setup_started = pending_key
except Exception:
_log.exception("install-on-enable post-setup spawn failed for %s", name)
return {
"ok": True,
"name": name,
"platform": target_platform,
"enabled": body.enabled,
"post_setup_started": post_setup_started,
}
@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}")
def _read():
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
return cat, providers, active_provider, active_search_backend, active_extract_backend
cat, providers, active_provider, active_search_backend, active_extract_backend = await asyncio.to_thread(_read)
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}
def _read():
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 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
return row, plugin, catalog, default_model, current
resolved = await asyncio.to_thread(_read)
if resolved is None:
return {
"name": name,
"has_models": False,
"models": [],
"current": None,
"default": None,
}
row, plugin, catalog, default_model, current = resolved
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")
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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 plugin
plugin = await asyncio.to_thread(_run)
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')",
)
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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. This hits
# the network (Portal), so it runs AFTER releasing the mutation lock:
# holding a process-wide config-write lock across a network fetch
# would stall every other config writer behind a slow Portal call.
# Still inside the worker thread + profile scope.
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
response = await asyncio.to_thread(_run)
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}")
def _run():
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 saved, skipped, status
saved, skipped, status = await asyncio.to_thread(_run)
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.
"""
def _read():
with _profile_scope(profile):
config = load_config()
terminal_cfg = config.get("terminal")
if not isinstance(terminal_cfg, dict):
terminal_cfg = {}
rows = _terminal_backend_rows()
active = str(terminal_cfg.get("backend") or "local").strip().lower()
if active not in {row["name"] for row in rows}:
active = "local"
backends = []
for row in rows:
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}
return await asyncio.to_thread(_read)
@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()
valid_names = _terminal_backend_names()
if backend not in valid_names:
raise HTTPException(
status_code=400,
detail=f"Unknown terminal backend: {body.backend!r}. "
f"Use one of: {', '.join(sorted(valid_names))}",
)
def _run():
with _profile_scope(body.profile or profile):
with _CONFIG_MUTATION_LOCK:
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)
await asyncio.to_thread(_run)
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
def _read():
with _profile_scope(profile):
return computer_use_status()
return await asyncio.to_thread(_read)
@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"}