Import AITURK IDE 1.0.0-beta.1 from Hermes 63279301; preserve MIT license
This commit is contained in:
@@ -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``.
|
||||
"""
|
||||
@@ -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))
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user