mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
refactor(web): extract sessions/mcp/skills/tools routes to APIRouter modules (wave 2; route-table equality verified)
- hermes_cli/web_routers/sessions.py: 14 routes across 3 routers (list_router, search_router, manage_router) mounted at the three original registration points so global route order is preserved exactly. - hermes_cli/web_routers/mcp.py: 11 routes; OAuth flow registry (_mcp_oauth_flows/lock/cap) stays in web_server, reached via new web_deps.LateState live proxies so tests mutating web_server._mcp_oauth_flows keep working. - hermes_cli/web_routers/skills.py: 12 routes across hub_router + router (two original registration points straddle the profiles router include). - hermes_cli/web_routers/tools.py: 12 routes; toolset/terminal catalogs stay in web_server (some are defined after the mount point), reached via LateState. - web_deps.py: add LateState — operation-time proxy for web_server-owned module state (getattr/item/iter/len/contains/context-manager/comparisons). - Handler bodies byte-identical; legacy re-exports keep web_server.<handler> importable for tests. - Verified: ordered route table (method, path) identical to pre-refactor app (291 routes); import smoke; ruff; windows-footguns clean. - test_web_server_sessiondb_eventloop.py: structural AST scan now reads both web_server.py and web_routers/sessions.py (handlers moved; helpers stayed).
This commit is contained in:
parent
1a3a9de630
commit
011ec4513e
7 changed files with 2578 additions and 2116 deletions
|
|
@ -56,6 +56,85 @@ def late_attr(name: str) -> Any:
|
|||
return getattr(_server(), name)
|
||||
|
||||
|
||||
class LateState:
|
||||
"""Live proxy for module-level state owned by ``web_server``.
|
||||
|
||||
Extracted routers can't ``from web_server import _mcp_oauth_flows`` —
|
||||
that would freeze the object at import time (breaking tests that mutate
|
||||
or replace it on ``web_server``) and be a circular import besides. Some
|
||||
of the state is also defined *after* the router's ``include_router``
|
||||
point in web_server's body, so even a late module-import wouldn't see it
|
||||
yet. This proxy forwards every operation the extracted handlers actually
|
||||
perform — attribute access, item get/set/del, iteration, membership,
|
||||
``len``/truthiness, ``with``-blocks (locks), and rich comparisons
|
||||
(numeric limits) — to ``web_server.<name>`` resolved at operation time,
|
||||
so mutating or monkeypatching the attribute on ``web_server`` stays
|
||||
authoritative.
|
||||
"""
|
||||
|
||||
__slots__ = ("_name",)
|
||||
|
||||
def __init__(self, name: str) -> None:
|
||||
object.__setattr__(self, "_name", name)
|
||||
|
||||
def _target(self) -> Any:
|
||||
return getattr(_server(), object.__getattribute__(self, "_name"))
|
||||
|
||||
def __getattr__(self, attr: str) -> Any:
|
||||
return getattr(self._target(), attr)
|
||||
|
||||
def __getitem__(self, key: Any) -> Any:
|
||||
return self._target()[key]
|
||||
|
||||
def __setitem__(self, key: Any, value: Any) -> None:
|
||||
self._target()[key] = value
|
||||
|
||||
def __delitem__(self, key: Any) -> None:
|
||||
del self._target()[key]
|
||||
|
||||
def __contains__(self, item: Any) -> bool:
|
||||
return item in self._target()
|
||||
|
||||
def __iter__(self):
|
||||
return iter(self._target())
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._target())
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
return bool(self._target())
|
||||
|
||||
def __enter__(self):
|
||||
return self._target().__enter__()
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return self._target().__exit__(*exc)
|
||||
|
||||
def __eq__(self, other: Any) -> bool:
|
||||
return self._target() == other
|
||||
|
||||
def __ne__(self, other: Any) -> bool:
|
||||
return self._target() != other
|
||||
|
||||
def __lt__(self, other: Any) -> bool:
|
||||
return self._target() < other
|
||||
|
||||
def __le__(self, other: Any) -> bool:
|
||||
return self._target() <= other
|
||||
|
||||
def __gt__(self, other: Any) -> bool:
|
||||
return self._target() > other
|
||||
|
||||
def __ge__(self, other: Any) -> bool:
|
||||
return self._target() >= other
|
||||
|
||||
def __hash__(self) -> int:
|
||||
return hash(self._target())
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debugging aid
|
||||
return f"<LateState {object.__getattribute__(self, '_name')} -> {self._target()!r}>"
|
||||
|
||||
|
||||
# --- Named accessors for the shared server state (call-time reads) ---------
|
||||
|
||||
|
||||
|
|
|
|||
478
hermes_cli/web_routers/mcp.py
Normal file
478
hermes_cli/web_routers/mcp.py
Normal file
|
|
@ -0,0 +1,478 @@
|
|||
"""MCP dashboard routes (extracted verbatim from web_server.py).
|
||||
|
||||
Handler bodies are byte-identical. The OAuth flow registry
|
||||
(``_mcp_oauth_flows`` + lock + pending cap) and the worker/helpers stay in
|
||||
web_server - reached via the late-binding seam in :mod:`hermes_cli.web_deps`
|
||||
(``late`` for callables, ``LateState`` for the mutable registry/lock/limit) so
|
||||
tests that mutate ``web_server._mcp_oauth_flows`` or
|
||||
``monkeypatch.setattr(web_server, "_run_dashboard_mcp_oauth", ...)`` keep
|
||||
working unchanged.
|
||||
"""
|
||||
|
||||
import asyncio # noqa: F401 — used by handlers
|
||||
import logging
|
||||
import secrets # noqa: F401
|
||||
import threading # noqa: F401
|
||||
from typing import Any, Dict, Optional # noqa: F401
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request # noqa: F401
|
||||
from fastapi.responses import HTMLResponse # noqa: F401
|
||||
|
||||
from hermes_cli.web_deps import late, LateState
|
||||
from hermes_cli.web_models import (
|
||||
MCPCatalogInstall,
|
||||
MCPEnabledToggle,
|
||||
MCPServerCreate,
|
||||
MCPServersReplace,
|
||||
)
|
||||
|
||||
# Same logger the handlers used before extraction (identical logger object).
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
||||
# monkeypatch-transparent).
|
||||
_config_profile_scope = late("_config_profile_scope")
|
||||
_gc_mcp_oauth_flows = late("_gc_mcp_oauth_flows")
|
||||
_mcp_install_action_name = late("_mcp_install_action_name")
|
||||
_mcp_oauth_callback_url = late("_mcp_oauth_callback_url")
|
||||
_mcp_server_summary = late("_mcp_server_summary")
|
||||
_normalize_mcp_server_create = late("_normalize_mcp_server_create")
|
||||
_profile_cli_args = late("_profile_cli_args")
|
||||
_profile_scope = late("_profile_scope")
|
||||
_require_token = late("_require_token")
|
||||
_run_dashboard_mcp_oauth = late("_run_dashboard_mcp_oauth")
|
||||
_spawn_hermes_action = late("_spawn_hermes_action")
|
||||
load_config = late("load_config")
|
||||
save_config = late("save_config")
|
||||
save_env_value = late("save_env_value")
|
||||
|
||||
# Live proxies for web_server-owned module state (mutations/monkeypatches
|
||||
# on web_server remain authoritative; resolved at operation time).
|
||||
_mcp_oauth_flows = LateState("_mcp_oauth_flows")
|
||||
_mcp_oauth_flows_lock = LateState("_mcp_oauth_flows_lock")
|
||||
_MAX_PENDING_MCP_OAUTH_FLOWS = LateState("_MAX_PENDING_MCP_OAUTH_FLOWS")
|
||||
|
||||
|
||||
@router.get("/api/mcp/servers")
|
||||
async def list_mcp_servers(profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
return {
|
||||
"servers": [
|
||||
_mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items())
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/mcp/servers")
|
||||
async def add_mcp_server(body: MCPServerCreate, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import (
|
||||
_get_mcp_servers,
|
||||
_save_bearer_auth_token,
|
||||
_save_mcp_server,
|
||||
)
|
||||
|
||||
try:
|
||||
name, server_config, bearer_token = _normalize_mcp_server_create(body)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
existing = _get_mcp_servers()
|
||||
if name in existing:
|
||||
raise HTTPException(status_code=409, detail=f"Server '{name}' already exists")
|
||||
|
||||
try:
|
||||
with _profile_scope(body.profile or profile):
|
||||
if bearer_token is not None:
|
||||
server_config["headers"] = _save_bearer_auth_token(name, bearer_token)
|
||||
if not _save_mcp_server(name, server_config):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Server '{name}' rejected: suspicious command/args configuration",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("POST /api/mcp/servers failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
return _mcp_server_summary(name, server_config)
|
||||
|
||||
|
||||
@router.put("/api/mcp/servers")
|
||||
async def replace_mcp_servers(body: MCPServersReplace, profile: Optional[str] = None):
|
||||
"""Replace the entire ``mcp_servers`` map (the GUI mcp.json editor's save).
|
||||
|
||||
The generic ``/api/config`` endpoint deep-merges maps, so it can never
|
||||
delete a server key, drop an ``enabled: false`` flag, or remove a nested
|
||||
field — edits looked saved but the stale entry survived on disk. This
|
||||
endpoint sets the whole map so removals actually persist. Storage stays
|
||||
the config.yaml ``mcp_servers`` key the CLI/TUI already read.
|
||||
"""
|
||||
from hermes_cli.mcp_config import _replace_mcp_servers
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
ok, issues = _replace_mcp_servers(body.servers)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=400, detail="; ".join(issues))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.delete("/api/mcp/servers/{name}")
|
||||
async def remove_mcp_server(name: str, profile: Optional[str] = None):
|
||||
from hermes_cli.mcp_config import _remove_mcp_server
|
||||
|
||||
with _profile_scope(profile):
|
||||
removed = _remove_mcp_server(name)
|
||||
if not removed:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/api/mcp/servers/{name}/test")
|
||||
async def test_mcp_server(name: str, profile: Optional[str] = None):
|
||||
"""Connect to the server, list its tools, disconnect. Returns tool list."""
|
||||
from hermes_cli.mcp_config import (
|
||||
_get_mcp_servers,
|
||||
_oauth_tokens_present,
|
||||
_probe_single_server,
|
||||
)
|
||||
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
|
||||
details: Dict[str, Any] = {}
|
||||
# An `auth: oauth` server that serves tools/list anonymously would probe OK
|
||||
# with no token — a false green. Require a token on disk for it, matching the
|
||||
# /auth verification (some providers don't enforce auth on tools/list).
|
||||
needs_oauth_token = servers[name].get("auth") == "oauth"
|
||||
|
||||
def _probe_scoped():
|
||||
# Home-only scope (contextvar), NOT _profile_scope. A probe blocks for
|
||||
# as long as the server takes to spawn/connect — a stdio `npx` cold
|
||||
# start is many seconds — and _profile_scope holds a process-global
|
||||
# skills lock for its ENTIRE body. Holding that across the probe
|
||||
# serialized every other endpoint (config/skills/toolsets all take the
|
||||
# same lock), so a slow server made unrelated requests time out at 15s.
|
||||
# The probe touches no skills globals; it only needs the HERMES_HOME
|
||||
# override for .env interpolation + OAuth token resolution, which the
|
||||
# contextvar provides (copied into this to_thread worker; and
|
||||
# _run_on_mcp_loop re-wraps it onto the MCP event-loop thread).
|
||||
with _config_profile_scope(profile):
|
||||
tools = _probe_single_server(name, servers[name], details=details)
|
||||
token_present = _oauth_tokens_present(name) if needs_oauth_token else True
|
||||
return tools, token_present
|
||||
|
||||
try:
|
||||
# Probe blocks on a dedicated MCP event loop — run in a thread so the
|
||||
# FastAPI event loop is never blocked.
|
||||
tools, token_present = await asyncio.to_thread(_probe_scoped)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": str(exc),
|
||||
"tools": [],
|
||||
}
|
||||
if not token_present:
|
||||
return {
|
||||
"ok": False,
|
||||
"error": "OAuth authentication required — no token found.",
|
||||
"tools": [],
|
||||
}
|
||||
return {
|
||||
"ok": True,
|
||||
"tools": [{"name": t, "description": d} for t, d in tools],
|
||||
"prompts": details.get("prompts", 0),
|
||||
"resources": details.get("resources", 0),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/mcp/servers/{name}/auth")
|
||||
async def auth_mcp_server(name: str, request: Request, profile: Optional[str] = None):
|
||||
"""Start MCP OAuth and hand the authorization URL to the dashboard browser."""
|
||||
from hermes_cli.mcp_config import _get_mcp_servers
|
||||
from tools.mcp_dashboard_oauth import DashboardOAuthFlow
|
||||
|
||||
_require_token(request)
|
||||
_gc_mcp_oauth_flows()
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
process_home = str(get_hermes_home().expanduser().resolve(strict=False))
|
||||
with _profile_scope(profile):
|
||||
servers = _get_mcp_servers()
|
||||
flow_home = str(get_hermes_home().expanduser().resolve(strict=False))
|
||||
if name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
cfg = dict(servers[name])
|
||||
if not cfg.get("url"):
|
||||
raise HTTPException(status_code=400, detail="stdio servers authenticate via env keys, not OAuth")
|
||||
if cfg.get("headers") and cfg.get("auth") != "oauth":
|
||||
raise HTTPException(status_code=400, detail="This server uses header/API-key auth, not OAuth")
|
||||
cfg["auth"] = "oauth"
|
||||
|
||||
flow_id = secrets.token_urlsafe(24)
|
||||
flow = DashboardOAuthFlow(
|
||||
flow_id=flow_id,
|
||||
server_name=name,
|
||||
profile=profile,
|
||||
hermes_home=flow_home,
|
||||
redirect_uri=(cfg.get("oauth") or {}).get("redirect_uri")
|
||||
or _mcp_oauth_callback_url(request, name),
|
||||
reconnect_live=flow_home == process_home,
|
||||
)
|
||||
with _mcp_oauth_flows_lock:
|
||||
pending = sum(
|
||||
not flow.worker_done
|
||||
for flow in _mcp_oauth_flows.values()
|
||||
)
|
||||
if pending >= _MAX_PENDING_MCP_OAUTH_FLOWS:
|
||||
raise HTTPException(
|
||||
status_code=429,
|
||||
detail="Too many MCP OAuth flows are already in progress",
|
||||
)
|
||||
if any(
|
||||
flow.server_name == name
|
||||
and flow.hermes_home == flow_home
|
||||
and not flow.worker_done
|
||||
for flow in _mcp_oauth_flows.values()
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"MCP OAuth for '{name}' is already in progress",
|
||||
)
|
||||
_mcp_oauth_flows[flow_id] = flow
|
||||
threading.Thread(
|
||||
target=_run_dashboard_mcp_oauth,
|
||||
args=(flow, cfg),
|
||||
daemon=True,
|
||||
name=f"mcp-oauth-{name}",
|
||||
).start()
|
||||
try:
|
||||
await flow.wait_for_authorization_url(timeout=30)
|
||||
except Exception as exc:
|
||||
flow.mark_error(str(exc))
|
||||
return flow.snapshot()
|
||||
|
||||
|
||||
@router.get("/api/mcp/oauth/flows/{flow_id}")
|
||||
async def mcp_oauth_flow_status(flow_id: str, request: Request):
|
||||
_require_token(request)
|
||||
_gc_mcp_oauth_flows()
|
||||
flow = _mcp_oauth_flows.get(flow_id)
|
||||
if flow is None:
|
||||
raise HTTPException(status_code=404, detail="OAuth flow not found or expired")
|
||||
snapshot = flow.snapshot()
|
||||
snapshot["tools"] = flow.tools
|
||||
return snapshot
|
||||
|
||||
|
||||
@router.get("/api/mcp/oauth/callback/{server_name:path}")
|
||||
async def mcp_oauth_callback(
|
||||
server_name: str,
|
||||
code: Optional[str] = None,
|
||||
state: Optional[str] = None,
|
||||
error: Optional[str] = None,
|
||||
):
|
||||
_gc_mcp_oauth_flows()
|
||||
with _mcp_oauth_flows_lock:
|
||||
candidates = [
|
||||
flow
|
||||
for flow in _mcp_oauth_flows.values()
|
||||
if flow.server_name == server_name
|
||||
and flow.status == "authorization_required"
|
||||
]
|
||||
flow = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates
|
||||
if candidate.expected_state is not None
|
||||
and state is not None
|
||||
and secrets.compare_digest(candidate.expected_state, state)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if flow is None:
|
||||
return HTMLResponse("<h1>OAuth flow expired</h1><p>Return to Hermes and try again.</p>", status_code=404)
|
||||
try:
|
||||
flow.deliver_callback(code=code, state=state, error=error)
|
||||
except ValueError as exc:
|
||||
reason = str(exc)
|
||||
status_code = 409 if "already received" in reason else 400
|
||||
return HTMLResponse(
|
||||
"<h1>OAuth callback rejected</h1>"
|
||||
"<p>The callback was invalid or already used.</p>",
|
||||
status_code=status_code,
|
||||
)
|
||||
if error:
|
||||
return HTMLResponse("<h1>Authorization failed</h1><p>Return to Hermes for details.</p>", status_code=400)
|
||||
return HTMLResponse("<h1>Authorization received</h1><p>You can close this tab and return to Hermes.</p>")
|
||||
|
||||
|
||||
@router.put("/api/mcp/servers/{name}/enabled")
|
||||
async def set_mcp_server_enabled(
|
||||
name: str, body: MCPEnabledToggle, profile: Optional[str] = None
|
||||
):
|
||||
"""Enable or disable an MCP server (takes effect on next session/gateway).
|
||||
|
||||
Toggles the ``enabled`` key on the server's config.yaml entry — the same
|
||||
flag the agent reads at startup. Disabled servers stay in config so they
|
||||
can be re-enabled without re-entering their settings.
|
||||
"""
|
||||
with _profile_scope(body.profile or profile):
|
||||
cfg = load_config()
|
||||
servers = cfg.get("mcp_servers")
|
||||
if not isinstance(servers, dict) or name not in servers:
|
||||
raise HTTPException(status_code=404, detail=f"Server '{name}' not found")
|
||||
if not isinstance(servers[name], dict):
|
||||
raise HTTPException(status_code=400, detail="Malformed server config")
|
||||
servers[name]["enabled"] = bool(body.enabled)
|
||||
save_config(cfg)
|
||||
return {"ok": True, "name": name, "enabled": bool(body.enabled)}
|
||||
|
||||
|
||||
@router.get("/api/mcp/catalog")
|
||||
async def list_mcp_catalog(profile: Optional[str] = None):
|
||||
"""Browse the Nous-approved MCP catalog (the optional-mcps/ manifests).
|
||||
|
||||
Each entry reports whether it's already installed and enabled so the UI
|
||||
can show install / enabled state inline. This is the same catalog
|
||||
`hermes mcp catalog` / `hermes mcp install` read. ``profile`` scopes
|
||||
the installed/enabled annotations (the catalog itself is repo-shipped
|
||||
and identical for every profile).
|
||||
"""
|
||||
try:
|
||||
from hermes_cli import mcp_catalog
|
||||
except Exception as exc:
|
||||
_log.exception("mcp_catalog import failed")
|
||||
raise HTTPException(status_code=500, detail=f"Catalog unavailable: {exc}")
|
||||
|
||||
entries = []
|
||||
try:
|
||||
with _profile_scope(profile):
|
||||
catalog_entries = list(mcp_catalog.list_catalog())
|
||||
installed_state = {
|
||||
e.name: (mcp_catalog.is_installed(e.name), mcp_catalog.is_enabled(e.name))
|
||||
for e in catalog_entries
|
||||
}
|
||||
for entry in catalog_entries:
|
||||
auth = entry.auth
|
||||
transport = entry.transport
|
||||
install = entry.install
|
||||
entries.append({
|
||||
"name": entry.name,
|
||||
"description": entry.description,
|
||||
"source": entry.source,
|
||||
"transport": transport.type,
|
||||
"auth_type": getattr(auth, "type", "none"),
|
||||
# Env vars the user must supply (names + prompts only, never values).
|
||||
"required_env": [
|
||||
{"name": e.name, "prompt": e.prompt, "required": e.required}
|
||||
for e in getattr(auth, "env", []) or []
|
||||
],
|
||||
# Transport details so the UI can show exactly what connects/runs.
|
||||
# The trust model (docs: user-guide/features/mcp) tells users to
|
||||
# inspect command/args/url and the install bootstrap before
|
||||
# installing — surface them rather than hiding them in the repo.
|
||||
"command": transport.command,
|
||||
"args": list(transport.args or []),
|
||||
"url": transport.url,
|
||||
# Git bootstrap (present only for entries that clone + build).
|
||||
"install_url": install.url if install else None,
|
||||
"install_ref": install.ref if install else None,
|
||||
"bootstrap": list(install.bootstrap) if install else [],
|
||||
# Default tool pre-selection hint and post-install guidance.
|
||||
"default_enabled": list(entry.tools.default_enabled)
|
||||
if entry.tools.default_enabled is not None
|
||||
else None,
|
||||
"post_install": entry.post_install or "",
|
||||
"needs_install": entry.install is not None,
|
||||
"installed": installed_state.get(entry.name, (False, False))[0],
|
||||
"enabled": installed_state.get(entry.name, (False, False))[1],
|
||||
})
|
||||
except HTTPException:
|
||||
# Unknown/invalid profile → 404, not a silently-empty catalog.
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("list_mcp_catalog failed")
|
||||
|
||||
diagnostics = []
|
||||
try:
|
||||
diagnostics = [
|
||||
{"name": n, "kind": k, "message": m}
|
||||
for (n, k, m) in mcp_catalog.catalog_diagnostics()
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {"entries": entries, "diagnostics": diagnostics}
|
||||
|
||||
|
||||
@router.post("/api/mcp/catalog/install")
|
||||
async def install_mcp_catalog_entry(body: MCPCatalogInstall, profile: Optional[str] = None):
|
||||
"""Install a catalog MCP into config.yaml.
|
||||
|
||||
For HTTP/stdio entries with required env vars, those are written to .env
|
||||
via the standard env path so the agent can read them at session start.
|
||||
Entries that need a git bootstrap (``needs_install``) are installed via
|
||||
the CLI action path because the clone can take time.
|
||||
"""
|
||||
from hermes_cli import mcp_catalog
|
||||
|
||||
name = (body.name or "").strip()
|
||||
entry = mcp_catalog.get_entry(name)
|
||||
if entry is None:
|
||||
raise HTTPException(status_code=404, detail=f"No catalog entry '{name}'")
|
||||
|
||||
# Persist any supplied env vars first (catalog entries declare which names
|
||||
# they need; we only write the ones the user provided).
|
||||
effective_profile = body.profile or profile
|
||||
if body.env:
|
||||
with _profile_scope(effective_profile):
|
||||
for k, v in body.env.items():
|
||||
if v:
|
||||
save_env_value(k, v)
|
||||
|
||||
# Git-bootstrap entries can take a while to clone — run via the background
|
||||
# action path so the request returns immediately and the UI can tail logs.
|
||||
# The -p subprocess rebinds HERMES_HOME-derived paths in the child.
|
||||
if entry.install is not None:
|
||||
# Unique per-entry action name: a shared "mcp-install" would let a
|
||||
# re-click (or a second entry) overwrite the tracked process/log while
|
||||
# the first clone is still running.
|
||||
action = _mcp_install_action_name(name)
|
||||
try:
|
||||
_spawn_hermes_action(
|
||||
_profile_cli_args(effective_profile) + ["mcp", "install", name],
|
||||
action,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Install failed: {exc}")
|
||||
return {"ok": True, "name": name, "background": True, "action": action}
|
||||
|
||||
# No git step — install synchronously via the catalog API. install_entry
|
||||
# routes through load_config/save_config + save_env_value, all call-time
|
||||
# resolvers, so the context override scopes it. Wrap the to_thread body
|
||||
# in the scope INSIDE the thread (contextvars don't propagate into
|
||||
# to_thread the other way around — asyncio.to_thread copies context, so
|
||||
# setting it here works; keep it explicit for clarity).
|
||||
def _install_scoped():
|
||||
with _profile_scope(effective_profile):
|
||||
mcp_catalog.install_entry(entry, enable=body.enable)
|
||||
|
||||
try:
|
||||
await asyncio.to_thread(_install_scoped)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("install_mcp_catalog_entry failed")
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
return {"ok": True, "name": name, "background": False}
|
||||
709
hermes_cli/web_routers/sessions.py
Normal file
709
hermes_cli/web_routers/sessions.py
Normal file
|
|
@ -0,0 +1,709 @@
|
|||
"""Session dashboard routes (extracted verbatim from web_server.py).
|
||||
|
||||
Three routers because the original registration points are far apart and
|
||||
global route order matters: ``list_router`` (GET /api/sessions) was registered
|
||||
before the profiles ``sessions_router`` include, ``search_router``
|
||||
(GET /api/sessions/search) right after it, and ``manage_router`` (the
|
||||
mutation/detail endpoints) thousands of lines later - each is mounted at its
|
||||
original registration point so the app's route table is byte-identical.
|
||||
|
||||
Handler bodies are byte-identical; web_server-owned helpers are reached via
|
||||
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
|
||||
``monkeypatch.setattr(web_server, "_helper", ...)`` keep working.
|
||||
"""
|
||||
|
||||
import asyncio # noqa: F401 — used by handlers
|
||||
import logging
|
||||
import time # noqa: F401
|
||||
from typing import Any, Dict, List, Optional # noqa: F401
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request # noqa: F401
|
||||
|
||||
from hermes_cli.web_deps import late
|
||||
from hermes_cli.web_models import (
|
||||
BulkDeleteSessions,
|
||||
SessionImport,
|
||||
SessionPrune,
|
||||
SessionRename,
|
||||
)
|
||||
|
||||
# Same logger the handlers used before extraction (identical logger object).
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
|
||||
list_router = APIRouter()
|
||||
search_router = APIRouter()
|
||||
manage_router = APIRouter()
|
||||
|
||||
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
||||
# monkeypatch-transparent).
|
||||
_cron_default_profile = late("_cron_default_profile")
|
||||
_cron_profile_home = late("_cron_profile_home")
|
||||
_import_sessions_for_profile = late("_import_sessions_for_profile")
|
||||
_maybe_auto_archive_for_profile = late("_maybe_auto_archive_for_profile")
|
||||
_open_session_db_for_profile = late("_open_session_db_for_profile")
|
||||
_prune_sessions = late("_prune_sessions")
|
||||
_read_session_import_body = late("_read_session_import_body")
|
||||
_session_latest_descendant = late("_session_latest_descendant")
|
||||
_strip_session_list_rows = late("_strip_session_list_rows")
|
||||
|
||||
|
||||
@list_router.get("/api/sessions")
|
||||
def get_sessions(
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
order: str = "created",
|
||||
source: str = None,
|
||||
sources: str = None,
|
||||
exclude_sources: str = None,
|
||||
cwd_prefix: str = None,
|
||||
full: bool = False,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
``archived`` controls how soft-archived sessions are treated:
|
||||
``exclude`` (default) hides them, ``only`` returns just the archived ones
|
||||
(used by the desktop "Archived sessions" settings panel), and ``include``
|
||||
returns both.
|
||||
|
||||
``order`` controls pagination order: ``created`` (default, by original
|
||||
start time) or ``recent`` (by latest activity across the compression
|
||||
chain). ``recent`` keeps a long-running conversation on the first page
|
||||
after it auto-compresses into a fresh continuation id.
|
||||
|
||||
Rows omit ``system_prompt``/``model_config`` (the payload-dominating
|
||||
fields no list UI reads) unless ``full=1`` is passed.
|
||||
"""
|
||||
if archived not in ("exclude", "only", "include"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="archived must be one of: exclude, only, include",
|
||||
)
|
||||
if order not in ("created", "recent"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="order must be one of: created, recent",
|
||||
)
|
||||
profile_name: Optional[str] = None
|
||||
if profile:
|
||||
profile_name, _ = _cron_profile_home(profile)
|
||||
try:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
# Opportunistic, config-gated, double-throttled stale-session
|
||||
# sweep — the only auto_archive hook that fires for Desktop's
|
||||
# `hermes serve` backend. No-op when disabled or run recently.
|
||||
_maybe_auto_archive_for_profile(db, profile)
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
include_archived = archived == "include"
|
||||
# Optional source scoping: ``source`` includes a single class,
|
||||
# ``sources`` includes any of several comma-separated classes, and
|
||||
# ``exclude_sources`` (comma-separated) drops classes. The desktop
|
||||
# uses these to split recents (exclude=cron) from the cron-jobs
|
||||
# section (source=cron) into two independent lists.
|
||||
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
|
||||
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
|
||||
sessions = db.list_sessions_rich(
|
||||
source=source or None,
|
||||
sources=source_list or None,
|
||||
exclude_sources=exclude_list or None,
|
||||
cwd_prefix=(cwd_prefix or None),
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
order_by_last_active=order == "recent",
|
||||
# SQL-level projection: when the caller didn't ask for full
|
||||
# rows, skip the system_prompt blob inside SQLite too (pairs
|
||||
# with the API-level _strip_session_list_rows below).
|
||||
compact_rows=not full,
|
||||
include_pinned=True,
|
||||
)
|
||||
total = db.session_count(
|
||||
source=source or None,
|
||||
sources=source_list or None,
|
||||
cwd_prefix=(cwd_prefix or None),
|
||||
exclude_sources=exclude_list or None,
|
||||
min_message_count=min_message_count,
|
||||
include_archived=include_archived,
|
||||
archived_only=archived_only,
|
||||
exclude_children=True,
|
||||
)
|
||||
now = time.time()
|
||||
# Same ownership contract as get_session_detail: rows are stamped
|
||||
# with the serving profile even when the request wasn't explicitly
|
||||
# scoped, so default-profile rows never circulate unowned.
|
||||
row_profile = profile_name or _cron_default_profile()
|
||||
for s in sessions:
|
||||
s["is_active"] = (
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
s["profile"] = row_profile
|
||||
s["is_default_profile"] = row_profile == "default"
|
||||
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
s["pinned"] = bool(s.get("pinned"))
|
||||
if not full:
|
||||
_strip_session_list_rows(sessions)
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
|
||||
|
||||
@search_router.get("/api/sessions/search")
|
||||
async def search_sessions(
|
||||
q: str = "",
|
||||
limit: int = 20,
|
||||
profile: Optional[str] = None,
|
||||
source: str = None,
|
||||
sources: str = None,
|
||||
exclude_sources: str = None,
|
||||
):
|
||||
"""Search sessions by ID plus full-text message content using FTS5.
|
||||
|
||||
Direct session-id matches are surfaced first, then FTS message-content
|
||||
matches. Results are deduped by compression lineage, not by raw
|
||||
``session_id``. Auto-compression rotates a conversation onto a fresh
|
||||
session id (and leaves the old segment's messages in the FTS index), so one
|
||||
logical chat can own many ``sessions`` rows that all match the same query.
|
||||
Branches also use ``parent_session_id``, but they are real alternate
|
||||
conversations; don't collapse branch-specific hits back into the parent.
|
||||
"""
|
||||
if not q or not q.strip():
|
||||
return {"results": []}
|
||||
try:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
safe_limit = max(1, min(int(limit or 20), 100))
|
||||
source_filter = source or None
|
||||
source_list = [s.strip() for s in (sources or "").split(",") if s.strip()]
|
||||
include_sources = [source_filter] if source_filter else (source_list or None)
|
||||
exclude_list = [s.strip() for s in (exclude_sources or "").split(",") if s.strip()]
|
||||
now = time.time()
|
||||
|
||||
# Walk parent_session_id to the compression root, memoized so a
|
||||
# chain of compression segments only costs one walk. We deliberately
|
||||
# stop at branch/delegate edges: those sessions may diverge from the
|
||||
# parent and should remain searchable on their own.
|
||||
root_cache: dict = {}
|
||||
|
||||
def compression_root(session_id: str) -> str:
|
||||
if not session_id:
|
||||
return session_id
|
||||
if session_id in root_cache:
|
||||
return root_cache[session_id]
|
||||
chain = []
|
||||
cur = session_id
|
||||
visited = set()
|
||||
root = session_id
|
||||
while cur and cur not in visited:
|
||||
visited.add(cur)
|
||||
chain.append(cur)
|
||||
if cur in root_cache:
|
||||
root = root_cache[cur]
|
||||
break
|
||||
try:
|
||||
s = db.get_session(cur)
|
||||
except Exception:
|
||||
s = None
|
||||
if not s:
|
||||
root = cur
|
||||
break
|
||||
parent = s.get("parent_session_id") if isinstance(s, dict) else None
|
||||
if not parent:
|
||||
root = cur
|
||||
break
|
||||
try:
|
||||
parent_session = db.get_session(parent)
|
||||
except Exception:
|
||||
parent_session = None
|
||||
if not parent_session:
|
||||
root = cur
|
||||
break
|
||||
parent_ended_at = parent_session.get("ended_at")
|
||||
started_at = s.get("started_at")
|
||||
is_compression_edge = (
|
||||
parent_session.get("end_reason") == "compression"
|
||||
and parent_ended_at is not None
|
||||
and started_at is not None
|
||||
and started_at >= parent_ended_at
|
||||
)
|
||||
if not is_compression_edge:
|
||||
root = cur
|
||||
break
|
||||
cur = parent
|
||||
for node in chain:
|
||||
root_cache[node] = root
|
||||
return root
|
||||
|
||||
tip_cache: dict = {}
|
||||
|
||||
def lineage_tip(root_id: str) -> str:
|
||||
if root_id in tip_cache:
|
||||
return tip_cache[root_id]
|
||||
tip = root_id
|
||||
try:
|
||||
resolved = db.get_compression_tip(root_id)
|
||||
if resolved:
|
||||
tip = resolved
|
||||
except Exception:
|
||||
pass
|
||||
tip_cache[root_id] = tip
|
||||
return tip
|
||||
|
||||
# Both ID matches and content matches share one keyspace, keyed by
|
||||
# compression lineage root, so an id-hit and a content-hit on the
|
||||
# same logical conversation collapse to a single result. The first
|
||||
# hit for a lineage wins; ID matches run first and take priority.
|
||||
seen: dict = {}
|
||||
|
||||
def add_lineage_result(raw_sid: str, payload: dict) -> None:
|
||||
if not raw_sid:
|
||||
return
|
||||
root = compression_root(raw_sid)
|
||||
if root in seen or len(seen) >= safe_limit:
|
||||
return
|
||||
payload = dict(payload)
|
||||
sid = lineage_tip(root)
|
||||
payload["session_id"] = sid
|
||||
payload["lineage_root"] = root
|
||||
try:
|
||||
row = db.get_session_rich_row(sid)
|
||||
except Exception:
|
||||
row = None
|
||||
if row:
|
||||
payload.update(
|
||||
{
|
||||
"id": row.get("id") or sid,
|
||||
"source": row.get("source"),
|
||||
"model": row.get("model"),
|
||||
"title": row.get("title"),
|
||||
"started_at": row.get("started_at"),
|
||||
"ended_at": row.get("ended_at"),
|
||||
"last_active": row.get("last_active") or row.get("started_at"),
|
||||
"is_active": (
|
||||
row.get("ended_at") is None
|
||||
and (now - (row.get("last_active") or row.get("started_at") or 0)) < 300
|
||||
),
|
||||
"message_count": row.get("message_count") or 0,
|
||||
"tool_call_count": row.get("tool_call_count") or 0,
|
||||
"input_tokens": row.get("input_tokens") or 0,
|
||||
"output_tokens": row.get("output_tokens") or 0,
|
||||
"preview": row.get("preview"),
|
||||
"parent_session_id": row.get("parent_session_id"),
|
||||
"archived": bool(row.get("archived")),
|
||||
}
|
||||
)
|
||||
else:
|
||||
payload["id"] = sid
|
||||
seen[root] = payload
|
||||
|
||||
# Direct ID matches first: users often paste a session id from CLI,
|
||||
# logs, or another Hermes surface. FTS can't find those unless the
|
||||
# id happens to appear in message text. search_sessions_by_id is
|
||||
# SQL-bounded, so this stays cheap even with thousands of sessions.
|
||||
for row in db.search_sessions_by_id(
|
||||
q,
|
||||
limit=safe_limit,
|
||||
include_archived=True,
|
||||
source=source_filter,
|
||||
sources=source_list or None,
|
||||
exclude_sources=exclude_list or None,
|
||||
):
|
||||
sid = row.get("id")
|
||||
preview = (row.get("preview") or "").strip()
|
||||
snippet = preview or f"Session ID: {sid}"
|
||||
add_lineage_result(
|
||||
sid,
|
||||
{
|
||||
"snippet": snippet,
|
||||
"role": None,
|
||||
"source": row.get("source"),
|
||||
"model": row.get("model"),
|
||||
"session_started": row.get("started_at"),
|
||||
},
|
||||
)
|
||||
|
||||
# Auto-add prefix wildcards so partial words match
|
||||
# e.g. "nimb" → "nimb*" matches "nimby"
|
||||
# Preserve quoted phrases and existing wildcards as-is
|
||||
import re
|
||||
terms = []
|
||||
for token in re.findall(r'"[^"]*"|\S+', q.strip()):
|
||||
if token.startswith('"') or token.endswith("*"):
|
||||
terms.append(token)
|
||||
else:
|
||||
terms.append(token + "*")
|
||||
prefix_query = " ".join(terms)
|
||||
# Over-fetch so lineage dedup can still surface `limit` distinct
|
||||
# conversations even when several hits collapse onto one root.
|
||||
fetch_limit = max(safe_limit * 5, 50)
|
||||
matches = db.search_messages(
|
||||
query=prefix_query,
|
||||
source_filter=include_sources,
|
||||
exclude_sources=exclude_list or None,
|
||||
limit=fetch_limit,
|
||||
)
|
||||
|
||||
for m in matches:
|
||||
if len(seen) >= safe_limit:
|
||||
break
|
||||
add_lineage_result(
|
||||
m["session_id"],
|
||||
{
|
||||
"snippet": m.get("snippet", ""),
|
||||
"role": m.get("role"),
|
||||
"source": m.get("source"),
|
||||
"model": m.get("model"),
|
||||
"session_started": m.get("session_started"),
|
||||
},
|
||||
)
|
||||
return {"results": list(seen.values())}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions/search failed")
|
||||
raise HTTPException(status_code=500, detail="Search failed")
|
||||
|
||||
|
||||
@manage_router.post("/api/sessions/bulk-delete")
|
||||
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
"""Delete every session in ``body.ids`` in a single DB transaction.
|
||||
|
||||
Backs the dashboard's bulk-select-and-delete flow on the sessions
|
||||
page. POST (not DELETE) because most HTTP clients refuse to send a
|
||||
request body on DELETE and a body is the natural shape for a list
|
||||
of IDs — Starlette accepts both, but POSTing a list keeps proxies,
|
||||
curl, and the browser ``fetch`` API consistent.
|
||||
|
||||
Per-row contract matches :meth:`SessionDB.delete_sessions`:
|
||||
|
||||
* Unknown IDs are silently skipped (the response ``deleted`` count
|
||||
reflects what really happened, not the input length). This is
|
||||
deliberate — UI selection state can race against another tab's
|
||||
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
|
||||
batch.
|
||||
* Children of every deleted parent are orphaned, not cascade-
|
||||
deleted.
|
||||
* Active and archived sessions ARE deleted when explicitly
|
||||
selected — unlike ``DELETE /api/sessions/empty``, the user
|
||||
hand-picked the rows so we trust the selection.
|
||||
* Like the other session-delete endpoints, this does NOT pass a
|
||||
``sessions_dir`` through; on-disk transcript / request-dump
|
||||
cleanup runs at the CLI/agent layer on the next prune pass.
|
||||
|
||||
The response carries the actual deleted count, so the dashboard
|
||||
can surface it in a toast. The IDs that were removed are not
|
||||
echoed back because the client already knows what it asked to
|
||||
delete (unknown IDs are silently skipped — see contract above)
|
||||
and can prune its in-memory list directly from the request.
|
||||
"""
|
||||
# Enforce a hard cap so a runaway/typo'd selection can't lock the
|
||||
# DB writer for an extended window. The dashboard pages 20 rows
|
||||
# at a time; 500 covers a "select all on every page in a
|
||||
# reasonable scrollback" worst case without opening the door to
|
||||
# multi-thousand-row transactions.
|
||||
if len(body.ids) > 500:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ids must contain at most 500 entries",
|
||||
)
|
||||
def _delete() -> int:
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
return db.delete_sessions(body.ids)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
deleted = await asyncio.to_thread(_delete)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
|
||||
|
||||
@manage_router.post("/api/sessions/import")
|
||||
async def import_sessions_endpoint(request: Request):
|
||||
"""Import one or more sessions exported from the dashboard or CLI.
|
||||
|
||||
This is intentionally separate from ``/api/ops/import``: that endpoint
|
||||
restores a whole Hermes backup archive, while this endpoint is scoped to
|
||||
session rows/messages and is safe to use from the Sessions page.
|
||||
"""
|
||||
try:
|
||||
raw_body = await _read_session_import_body(request)
|
||||
body = SessionImport.model_validate_json(raw_body)
|
||||
except HTTPException:
|
||||
raise
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid session import payload") from exc
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_import_sessions_for_profile, body.profile, body.sessions)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
|
||||
if not result.get("ok", False):
|
||||
raise HTTPException(status_code=400, detail=result)
|
||||
return result
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
||||
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
||||
UI hides the affordance so users aren't presented with a button
|
||||
that does nothing. Cheap, single-COUNT query.
|
||||
"""
|
||||
def _count() -> int:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
return db.count_empty_sessions()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return {"count": await asyncio.to_thread(_count)}
|
||||
|
||||
|
||||
@manage_router.delete("/api/sessions/empty")
|
||||
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
"""Delete every empty (``message_count == 0``), ended,
|
||||
non-archived session in a single transaction.
|
||||
|
||||
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
|
||||
|
||||
* Active sessions are skipped (``ended_at IS NULL``) so a live
|
||||
agent isn't yanked mid-handshake.
|
||||
* Archived sessions are skipped — the user explicitly chose to
|
||||
keep those rows.
|
||||
* Children of deleted parents are orphaned, not cascade-deleted.
|
||||
|
||||
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
|
||||
below, this doesn't pass a ``sessions_dir`` through — the on-disk
|
||||
transcript / request-dump cleanup is wired at the CLI/agent layer
|
||||
but the web server historically leaves file cleanup to the next
|
||||
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
||||
the two delete endpoints' DB-vs-disk behaviour consistent.
|
||||
"""
|
||||
def _delete() -> int:
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
return db.delete_empty_sessions()
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
deleted = await asyncio.to_thread(_delete)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/stats")
|
||||
async def get_session_stats(profile: Optional[str] = None):
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
archived = db.session_count(archived_only=True)
|
||||
messages = db.message_count()
|
||||
by_source: Dict[str, int] = {}
|
||||
try:
|
||||
by_source = db.session_count_by_source(
|
||||
include_archived=True,
|
||||
exclude_children=True,
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"total": total,
|
||||
"active_store": active_store,
|
||||
"archived": archived,
|
||||
"messages": messages,
|
||||
"by_source": by_source,
|
||||
}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str, profile: Optional[str] = None):
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
session = db.get_session(sid) if sid else None
|
||||
if not session:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
# Always stamp the owning profile — the serving profile is known even
|
||||
# when the request carries no ``?profile=`` (it's this process's own
|
||||
# profile). Stamping only on explicit ``?profile=`` left rows for the
|
||||
# default/primary profile systematically unowned, so multi-profile
|
||||
# clients resolved them to whichever gateway happened to be active
|
||||
# (cross-profile open asymmetry, #67603 family).
|
||||
session["profile"] = (
|
||||
_cron_profile_home(profile)[0] if profile else _cron_default_profile()
|
||||
)
|
||||
session["is_default_profile"] = session["profile"] == "default"
|
||||
return session
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/{session_id}/latest-descendant")
|
||||
async def get_session_latest_descendant(
|
||||
session_id: str,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
def _lookup():
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
return _session_latest_descendant(session_id, db)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
latest, path = await asyncio.to_thread(_lookup)
|
||||
if not latest:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return {
|
||||
"requested_session_id": path[0] if path else session_id,
|
||||
"session_id": latest,
|
||||
"path": path,
|
||||
"changed": bool(path and latest != path[0]),
|
||||
}
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/{session_id}/messages")
|
||||
async def get_session_messages(
|
||||
session_id: str,
|
||||
profile: Optional[str] = None,
|
||||
limit: Optional[int] = None,
|
||||
offset: int = 0,
|
||||
):
|
||||
def _read():
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
return None
|
||||
sid = db.resolve_resume_session_id(sid)
|
||||
# Clamp limit to prevent abuse (max 500 per page)
|
||||
_limit = min(limit, 500) if limit is not None else None
|
||||
return sid, _limit, db.get_messages(sid, limit=_limit, offset=offset)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
result = await asyncio.to_thread(_read)
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
sid, _limit, messages = result
|
||||
return {
|
||||
"session_id": sid,
|
||||
"messages": messages,
|
||||
"pagination": {
|
||||
"limit": _limit,
|
||||
"offset": offset,
|
||||
"returned": len(messages),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@manage_router.delete("/api/sessions/{session_id}")
|
||||
async def delete_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
# ``profile`` deletes a session belonging to another (local) profile by
|
||||
# opening its state.db directly. Remote profiles never reach here — the
|
||||
# desktop routes their DELETE to the remote backend. Omit for current/default.
|
||||
def _delete():
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
# Resolve exact ids / unique prefixes like every other session endpoint
|
||||
# (detail, messages, rename, export all do). A session that no longer
|
||||
# exists is an idempotent success: DELETE's contract is "ensure it's
|
||||
# gone", and the desktop optimistically removes the row then RESTORES it
|
||||
# on any error — so a 404 on an already-absent row resurrected a ghost
|
||||
# row and surfaced "session not found". /goal + auto-compression churn
|
||||
# leaves transient empty rows (reaped by empty-session hygiene) that
|
||||
# race the sidebar snapshot, which is exactly when this fired. Mirrors
|
||||
# the bulk-delete endpoint, which already treats ghost ids as success.
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
return {"ok": True, "already_absent": True}
|
||||
db.delete_session(sid)
|
||||
return {"ok": True}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return await asyncio.to_thread(_delete)
|
||||
|
||||
|
||||
@manage_router.patch("/api/sessions/{session_id}")
|
||||
async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
"""Update a session: rename, archive, and/or pin it.
|
||||
|
||||
``title`` renames (empty/null clears the title); ``archived`` soft-hides or
|
||||
restores the session; ``pinned`` sets the durable keep flag (exempts the
|
||||
session from the auto-archive sweep). Any field may be omitted. ``profile``
|
||||
targets another profile's session.
|
||||
"""
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
if body.title is None and body.archived is None and body.pinned is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nothing to update; provide 'title', 'archived', and/or 'pinned'.",
|
||||
)
|
||||
if body.title is not None:
|
||||
try:
|
||||
db.set_session_title(sid, body.title or "")
|
||||
except ValueError as e:
|
||||
# Title too long, invalid characters, or already in use.
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
if body.archived is not None:
|
||||
db.set_session_archived(sid, body.archived)
|
||||
if body.pinned is not None:
|
||||
db.set_session_pinned(sid, body.pinned)
|
||||
result = {"ok": True, "title": db.get_session_title(sid) or ""}
|
||||
if body.archived is not None:
|
||||
result["archived"] = bool(body.archived)
|
||||
if body.pinned is not None:
|
||||
result["pinned"] = bool(body.pinned)
|
||||
return result
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@manage_router.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
def _export():
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
return db.export_session(sid) if sid else None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
data = await asyncio.to_thread(_export)
|
||||
if data is None:
|
||||
raise HTTPException(status_code=404, detail="Session not found")
|
||||
return data
|
||||
|
||||
|
||||
@manage_router.post("/api/sessions/prune")
|
||||
async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions matching filters without blocking the event loop."""
|
||||
return await asyncio.to_thread(_prune_sessions, body)
|
||||
490
hermes_cli/web_routers/skills.py
Normal file
490
hermes_cli/web_routers/skills.py
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
"""Skills dashboard routes (extracted verbatim from web_server.py).
|
||||
|
||||
Two routers because the original registration points are far apart and global
|
||||
route order matters: ``hub_router`` (the skills-hub install/search/scan
|
||||
endpoints) was registered before the profiles ``router`` include in
|
||||
web_server, the plain skills CRUD ``router`` after it - each is mounted at
|
||||
its original registration point.
|
||||
|
||||
Handler bodies are byte-identical; web_server-owned helpers are reached via
|
||||
the late-binding seam in :mod:`hermes_cli.web_deps` so tests that
|
||||
``monkeypatch.setattr(web_server, "_spawn_hermes_action", ...)`` keep
|
||||
working.
|
||||
"""
|
||||
|
||||
import asyncio # noqa: F401 — used by handlers
|
||||
import logging
|
||||
from typing import Optional # noqa: F401
|
||||
|
||||
from fastapi import APIRouter, HTTPException # noqa: F401
|
||||
|
||||
from hermes_cli.web_deps import late, LateState
|
||||
from hermes_cli.web_models import (
|
||||
SkillContentUpdate,
|
||||
SkillCreate,
|
||||
SkillInstallRequest,
|
||||
SkillToggle,
|
||||
SkillUninstallRequest,
|
||||
SkillsUpdateRequest,
|
||||
)
|
||||
|
||||
# Same logger the handlers used before extraction (identical logger object).
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
|
||||
hub_router = APIRouter()
|
||||
router = APIRouter()
|
||||
|
||||
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
||||
# monkeypatch-transparent).
|
||||
_clear_skills_prompt_cache = late("_clear_skills_prompt_cache")
|
||||
_config_profile_scope = late("_config_profile_scope")
|
||||
_hub_action_name = late("_hub_action_name")
|
||||
_installed_hub_identifiers = late("_installed_hub_identifiers")
|
||||
_profile_cli_args = late("_profile_cli_args")
|
||||
_profile_scope = late("_profile_scope")
|
||||
_skill_meta_to_payload = late("_skill_meta_to_payload")
|
||||
_spawn_hermes_action = late("_spawn_hermes_action")
|
||||
load_config = late("load_config")
|
||||
|
||||
# Live proxies for web_server-owned module state (mutations/monkeypatches
|
||||
# on web_server remain authoritative; resolved at operation time).
|
||||
_SKILL_HUB_SOURCE_LABELS = LateState("_SKILL_HUB_SOURCE_LABELS")
|
||||
|
||||
|
||||
@hub_router.post("/api/skills/hub/install")
|
||||
async def install_skill_hub(body: SkillInstallRequest, profile: Optional[str] = None):
|
||||
identifier = (body.identifier or "").strip()
|
||||
if not identifier:
|
||||
raise HTTPException(status_code=400, detail="identifier is required")
|
||||
name = _hub_action_name("install", identifier)
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(body.profile or profile)
|
||||
+ ["skills", "install", identifier, "--yes"],
|
||||
name,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills install")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to install skill: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": name}
|
||||
|
||||
|
||||
@hub_router.post("/api/skills/hub/uninstall")
|
||||
async def uninstall_skill_hub(body: SkillUninstallRequest, profile: Optional[str] = None):
|
||||
name = (body.name or "").strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=400, detail="name is required")
|
||||
action = _hub_action_name("uninstall", name)
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(body.profile or profile) + ["skills", "uninstall", name, "--yes"],
|
||||
action,
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills uninstall")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to uninstall skill: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": action}
|
||||
|
||||
|
||||
@hub_router.post("/api/skills/hub/update")
|
||||
async def update_skills_hub(
|
||||
body: Optional[SkillsUpdateRequest] = None, profile: Optional[str] = None
|
||||
):
|
||||
try:
|
||||
effective = (body.profile if body else None) or profile
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(effective) + ["skills", "update"], "skills-update"
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn skills update")
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update skills: {exc}")
|
||||
return {"ok": True, "pid": proc.pid, "name": "skills-update"}
|
||||
|
||||
|
||||
@hub_router.get("/api/skills/hub/sources")
|
||||
async def list_skills_hub_sources(profile: Optional[str] = None):
|
||||
"""List the configured skill-hub sources and installed-skill provenance.
|
||||
|
||||
Gives the dashboard something to show BEFORE a search runs — which hubs
|
||||
are wired up, their trust tier, and a set of featured skills pulled from
|
||||
the centralized index (zero extra API calls). Without this the Browse-hub
|
||||
tab is a blank page with no indication it's even connected to anything.
|
||||
``profile`` scopes the installed-skill provenance to that profile.
|
||||
"""
|
||||
|
||||
def _run():
|
||||
from tools.skills_hub import create_source_router
|
||||
|
||||
with _config_profile_scope(profile):
|
||||
sources = create_source_router()
|
||||
out = []
|
||||
index_available = False
|
||||
featured = []
|
||||
for src in sources:
|
||||
sid = src.source_id()
|
||||
entry = {
|
||||
"id": sid,
|
||||
"label": _SKILL_HUB_SOURCE_LABELS.get(sid, sid),
|
||||
}
|
||||
# GitHub exposes a rate-limit flag; the index an availability flag.
|
||||
if sid == "github":
|
||||
try:
|
||||
entry["rate_limited"] = bool(getattr(src, "is_rate_limited", False))
|
||||
except Exception:
|
||||
entry["rate_limited"] = False
|
||||
if sid == "hermes-index":
|
||||
try:
|
||||
index_available = bool(getattr(src, "is_available", False))
|
||||
except Exception:
|
||||
index_available = False
|
||||
entry["available"] = index_available
|
||||
# Empty-query search on the index returns featured/popular skills.
|
||||
if index_available:
|
||||
try:
|
||||
featured = [
|
||||
_skill_meta_to_payload(m) for m in src.search("", limit=12)
|
||||
]
|
||||
except Exception:
|
||||
featured = []
|
||||
out.append(entry)
|
||||
# Tell the UI which sources are worth searching individually (for its
|
||||
# progressive per-source fan-out). Mirror parallel_search_sources: when
|
||||
# the centralized index is available it already subsumes the external
|
||||
# API sources, so they're redundant — skipping them avoids ~70 GitHub
|
||||
# calls per keystroke. Keep this set in sync with that function's
|
||||
# ``_api_source_ids``.
|
||||
_api_source_ids = frozenset(
|
||||
{"github", "skills-sh", "clawhub", "lobehub", "well-known"}
|
||||
)
|
||||
for entry in out:
|
||||
entry["searchable"] = not (index_available and entry["id"] in _api_source_ids)
|
||||
return {
|
||||
"sources": out,
|
||||
"index_available": index_available,
|
||||
"featured": featured,
|
||||
"installed": _installed_hub_identifiers(profile),
|
||||
}
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub sources listing failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub sources failed: {exc}")
|
||||
|
||||
|
||||
@hub_router.get("/api/skills/hub/search")
|
||||
async def search_skills_hub(
|
||||
q: str = "", source: str = "all", limit: int = 20, profile: Optional[str] = None
|
||||
):
|
||||
"""Search the skill hub across all configured sources.
|
||||
|
||||
Network-bound (parallel source search); runs in a thread so the FastAPI
|
||||
loop isn't blocked. Returns structured results the UI installs by
|
||||
identifier via POST /api/skills/hub/install, previews via
|
||||
/api/skills/hub/preview, and scans via /api/skills/hub/scan.
|
||||
"""
|
||||
query = (q or "").strip()
|
||||
if not query:
|
||||
return {"results": [], "source_counts": {}, "timed_out": [], "installed": {}}
|
||||
|
||||
def _run():
|
||||
from tools.skills_hub import create_source_router, parallel_search_sources
|
||||
|
||||
with _config_profile_scope(profile):
|
||||
sources = create_source_router()
|
||||
capped = min(max(limit, 1), 50)
|
||||
all_results, source_counts, timed_out = parallel_search_sources(
|
||||
sources, query=query, source_filter=source or "all", overall_timeout=30
|
||||
)
|
||||
|
||||
# Dedupe by identifier, preferring higher trust (mirrors unified_search).
|
||||
_rank = {"builtin": 2, "trusted": 1, "community": 0}
|
||||
seen = {}
|
||||
for r in all_results:
|
||||
if r.identifier not in seen:
|
||||
seen[r.identifier] = r
|
||||
elif _rank.get(r.trust_level, 0) > _rank.get(seen[r.identifier].trust_level, 0):
|
||||
seen[r.identifier] = r
|
||||
deduped = list(seen.values())[:capped]
|
||||
|
||||
return {
|
||||
"results": [_skill_meta_to_payload(m) for m in deduped],
|
||||
"source_counts": source_counts,
|
||||
"timed_out": timed_out,
|
||||
"installed": _installed_hub_identifiers(profile),
|
||||
}
|
||||
|
||||
try:
|
||||
return await asyncio.to_thread(_run)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub search failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}")
|
||||
|
||||
|
||||
@hub_router.get("/api/skills/hub/preview")
|
||||
async def preview_skill_hub(identifier: str = "", profile: Optional[str] = None):
|
||||
"""Fetch a hub skill's SKILL.md content + metadata for in-dashboard reading.
|
||||
|
||||
Resolves the identifier across configured sources (same path the CLI
|
||||
installer uses), then returns the rendered SKILL.md text and the file
|
||||
manifest WITHOUT installing anything. This is the 'read the actual skill
|
||||
before installing' affordance the Browse-hub tab was missing.
|
||||
|
||||
Scoped to ``profile`` so a non-default profile with different hub taps
|
||||
resolves against ITS source router, not the default profile's.
|
||||
"""
|
||||
ident = (identifier or "").strip()
|
||||
if not ident:
|
||||
raise HTTPException(status_code=400, detail="identifier is required")
|
||||
|
||||
def _run():
|
||||
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
|
||||
from tools.skills_hub import create_source_router
|
||||
|
||||
with _config_profile_scope(profile):
|
||||
sources = create_source_router()
|
||||
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
|
||||
if not bundle and not meta:
|
||||
return None
|
||||
|
||||
files = {}
|
||||
skill_md = ""
|
||||
if bundle:
|
||||
for rel, content in (bundle.files or {}).items():
|
||||
if isinstance(content, bytes):
|
||||
# Some sources (e.g. official optional skills) store every
|
||||
# file as bytes. Decode text so SKILL.md / docs render;
|
||||
# only fall back to a placeholder for genuinely-binary data.
|
||||
try:
|
||||
files[rel] = content.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
files[rel] = "(binary file)"
|
||||
else:
|
||||
files[rel] = content
|
||||
skill_md = files.get("SKILL.md", "") or ""
|
||||
|
||||
m = meta or bundle
|
||||
return {
|
||||
"name": getattr(m, "name", ident),
|
||||
"description": getattr(m, "description", "") or "",
|
||||
"source": getattr(m, "source", "") or "",
|
||||
"identifier": getattr(m, "identifier", ident) or ident,
|
||||
"trust_level": getattr(m, "trust_level", "community") or "community",
|
||||
"repo": getattr(m, "repo", None),
|
||||
"tags": list(getattr(m, "tags", None) or []),
|
||||
"skill_md": skill_md,
|
||||
"files": sorted(files.keys()),
|
||||
}
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub preview failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub preview failed: {exc}")
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
|
||||
return result
|
||||
|
||||
|
||||
@hub_router.get("/api/skills/hub/scan")
|
||||
async def scan_skill_hub(identifier: str = "", profile: Optional[str] = None):
|
||||
"""Run the install-time security scan on a hub skill WITHOUT installing it.
|
||||
|
||||
Fetches the bundle, quarantines it, and runs the same `scan_skill` /
|
||||
`should_allow_install` pipeline the CLI installer uses — then cleans up the
|
||||
quarantine. Returns the verdict, per-finding detail, trust tier, and the
|
||||
install-policy decision so the dashboard can show a visual safety result
|
||||
on demand (the 'scan' button the Browse-hub tab was missing).
|
||||
|
||||
Scoped to ``profile`` so the bundle resolves against that profile's hub
|
||||
source router, matching where an install would pull it from.
|
||||
"""
|
||||
ident = (identifier or "").strip()
|
||||
if not ident:
|
||||
raise HTTPException(status_code=400, detail="identifier is required")
|
||||
|
||||
def _run():
|
||||
import shutil as _shutil
|
||||
|
||||
from hermes_cli.skills_hub import _resolve_source_meta_and_bundle
|
||||
from tools.skills_hub import create_source_router, quarantine_bundle
|
||||
from tools.skills_guard import scan_skill, should_allow_install
|
||||
|
||||
with _config_profile_scope(profile):
|
||||
sources = create_source_router()
|
||||
meta, bundle, _src = _resolve_source_meta_and_bundle(ident, sources)
|
||||
if not bundle:
|
||||
return None
|
||||
|
||||
if bundle.source == "official":
|
||||
scan_source = "official"
|
||||
else:
|
||||
scan_source = (
|
||||
getattr(bundle, "identifier", "")
|
||||
or getattr(meta, "identifier", "")
|
||||
or ident
|
||||
)
|
||||
|
||||
q_path = None
|
||||
try:
|
||||
q_path = quarantine_bundle(bundle)
|
||||
result = scan_skill(q_path, source=scan_source)
|
||||
finally:
|
||||
if q_path is not None:
|
||||
_shutil.rmtree(q_path, ignore_errors=True)
|
||||
|
||||
allowed, reason = should_allow_install(result, force=False)
|
||||
# `allowed` may be None ("ask") for agent-created/dangerous gates.
|
||||
if allowed is True:
|
||||
policy = "allow"
|
||||
elif allowed is None:
|
||||
policy = "ask"
|
||||
else:
|
||||
policy = "block"
|
||||
|
||||
findings = [
|
||||
{
|
||||
"severity": f.severity,
|
||||
"category": f.category,
|
||||
"file": f.file,
|
||||
"line": f.line,
|
||||
"description": f.description,
|
||||
}
|
||||
for f in result.findings
|
||||
]
|
||||
# Per-severity tally for an at-a-glance summary.
|
||||
counts = {"critical": 0, "high": 0, "medium": 0, "low": 0}
|
||||
for f in result.findings:
|
||||
if f.severity in counts:
|
||||
counts[f.severity] += 1
|
||||
|
||||
return {
|
||||
"name": result.skill_name,
|
||||
"identifier": ident,
|
||||
"source": result.source,
|
||||
"trust_level": result.trust_level,
|
||||
"verdict": result.verdict,
|
||||
"summary": result.summary,
|
||||
"policy": policy,
|
||||
"policy_reason": reason,
|
||||
"findings": findings,
|
||||
"severity_counts": counts,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await asyncio.to_thread(_run)
|
||||
except Exception as exc:
|
||||
_log.exception("skills hub scan failed")
|
||||
raise HTTPException(status_code=502, detail=f"Hub scan failed: {exc}")
|
||||
if result is None:
|
||||
raise HTTPException(status_code=404, detail=f"Skill not found: {ident}")
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/api/skills")
|
||||
async def get_skills(profile: Optional[str] = None):
|
||||
from tools.skills_tool import _find_all_skills
|
||||
from hermes_cli.skills_config import get_disabled_skills
|
||||
from tools.skill_usage import (
|
||||
_read_bundled_manifest_names,
|
||||
_read_hub_installed_names,
|
||||
activity_count,
|
||||
load_usage,
|
||||
)
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
skills = _find_all_skills(skip_disabled=True)
|
||||
usage = load_usage()
|
||||
# Set-based provenance (same classification as skill_usage.provenance,
|
||||
# without a per-skill manifest read): hub > bundled > agent, where
|
||||
# "agent" covers agent-authored AND local hand-made skills — the ones
|
||||
# the user may edit/delete from the UI.
|
||||
bundled_names = _read_bundled_manifest_names()
|
||||
hub_names = _read_hub_installed_names()
|
||||
for s in skills:
|
||||
s["enabled"] = s["name"] not in disabled
|
||||
s["usage"] = activity_count(usage.get(s["name"], {}))
|
||||
s["provenance"] = (
|
||||
"hub" if s["name"] in hub_names
|
||||
else "bundled" if s["name"] in bundled_names
|
||||
else "agent"
|
||||
)
|
||||
return skills
|
||||
|
||||
|
||||
@router.put("/api/skills/toggle")
|
||||
async def toggle_skill(body: SkillToggle, profile: Optional[str] = None):
|
||||
from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
disabled = get_disabled_skills(config)
|
||||
if body.enabled:
|
||||
disabled.discard(body.name)
|
||||
else:
|
||||
disabled.add(body.name)
|
||||
save_disabled_skills(config, disabled)
|
||||
return {"ok": True, "name": body.name, "enabled": body.enabled}
|
||||
|
||||
|
||||
@router.get("/api/skills/content")
|
||||
async def get_skill_content(name: str, profile: Optional[str] = None):
|
||||
"""Return the raw SKILL.md text for a skill, for the dashboard editor."""
|
||||
from tools.skill_manager_tool import _find_skill
|
||||
|
||||
with _profile_scope(profile):
|
||||
found = _find_skill(name)
|
||||
if not found:
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{name}' not found.")
|
||||
skill_md = found["path"] / "SKILL.md"
|
||||
if not skill_md.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Skill '{name}' has no SKILL.md.")
|
||||
try:
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise HTTPException(status_code=500, detail=str(exc)) from exc
|
||||
return {"name": name, "content": content, "path": str(skill_md)}
|
||||
|
||||
|
||||
@router.post("/api/skills")
|
||||
async def create_skill(body: SkillCreate):
|
||||
"""Create a new custom skill (SKILL.md) from the dashboard editor.
|
||||
|
||||
Calls the same validated write path as the agent's ``skill_manage``
|
||||
tool (frontmatter validation, name/category validation, size limit,
|
||||
optional security scan) — but bypasses the agent write-approval gate:
|
||||
a write from the authenticated dashboard IS the user acting directly.
|
||||
"""
|
||||
from tools.skill_manager_tool import _create_skill
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
result = _create_skill(body.name, body.content, body.category or None)
|
||||
if not result.get("success"):
|
||||
raise HTTPException(status_code=400, detail=result.get("error", "Failed to create skill."))
|
||||
_clear_skills_prompt_cache()
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/api/skills/content")
|
||||
async def update_skill_content(body: SkillContentUpdate):
|
||||
"""Replace the SKILL.md of an existing skill (full rewrite) from the editor."""
|
||||
from tools.skill_manager_tool import _edit_skill
|
||||
|
||||
with _profile_scope(body.profile):
|
||||
result = _edit_skill(body.name, body.content)
|
||||
if not result.get("success"):
|
||||
err = result.get("error", "Failed to update skill.")
|
||||
status = 404 if "not found" in str(err).lower() else 400
|
||||
raise HTTPException(status_code=status, detail=err)
|
||||
_clear_skills_prompt_cache()
|
||||
return result
|
||||
734
hermes_cli/web_routers/tools.py
Normal file
734
hermes_cli/web_routers/tools.py
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
"""Toolset / terminal-backend dashboard routes (extracted verbatim from
|
||||
web_server.py).
|
||||
|
||||
Handler bodies are byte-identical. The toolset/terminal catalogs
|
||||
(``_MODEL_CATALOG_TOOLSETS``, ``_TERMINAL_BACKENDS``,
|
||||
``_TERMINAL_BACKEND_NAMES`` - some defined *after* this router's mount point
|
||||
in web_server's body) and all helpers stay in web_server - reached via the
|
||||
late-binding seam in :mod:`hermes_cli.web_deps` (``late`` for callables,
|
||||
``LateState`` for the catalog constants) so monkeypatching on web_server
|
||||
stays authoritative.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys # noqa: F401 — used by handlers
|
||||
from typing import Any, Dict, List, Optional # noqa: F401
|
||||
|
||||
from fastapi import APIRouter, HTTPException # noqa: F401
|
||||
|
||||
from hermes_cli.web_deps import late, LateState
|
||||
from hermes_cli.web_models import (
|
||||
TerminalBackendSelect,
|
||||
ToolsetEnvUpdate,
|
||||
ToolsetModelSelect,
|
||||
ToolsetPostSetup,
|
||||
ToolsetProviderSelect,
|
||||
ToolsetToggle,
|
||||
)
|
||||
|
||||
# Same logger the handlers used before extraction (identical logger object).
|
||||
_log = logging.getLogger("hermes_cli.web_server")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Late-bound web_server helpers (resolved at call time; cycle-safe,
|
||||
# monkeypatch-transparent).
|
||||
_find_toolset_provider_row = late("_find_toolset_provider_row")
|
||||
_probe_terminal_backend = late("_probe_terminal_backend")
|
||||
_profile_cli_args = late("_profile_cli_args")
|
||||
_profile_scope = late("_profile_scope")
|
||||
_resolve_toolset_model_plugin = late("_resolve_toolset_model_plugin")
|
||||
_spawn_hermes_action = late("_spawn_hermes_action")
|
||||
_toolset_model_catalog = late("_toolset_model_catalog")
|
||||
load_config = late("load_config")
|
||||
save_config = late("save_config")
|
||||
|
||||
# Live proxies for web_server-owned module state (mutations/monkeypatches
|
||||
# on web_server remain authoritative; resolved at operation time).
|
||||
_MODEL_CATALOG_TOOLSETS = LateState("_MODEL_CATALOG_TOOLSETS")
|
||||
_TERMINAL_BACKENDS = LateState("_TERMINAL_BACKENDS")
|
||||
_TERMINAL_BACKEND_NAMES = LateState("_TERMINAL_BACKEND_NAMES")
|
||||
|
||||
|
||||
@router.get("/api/tools/toolsets")
|
||||
async def get_toolsets(profile: Optional[str] = None):
|
||||
from hermes_cli.tools_config import (
|
||||
_CONFIG_ONLY_TOOLSETS,
|
||||
_get_effective_configurable_toolsets,
|
||||
_get_platform_tools,
|
||||
_toolset_configuration_platform,
|
||||
_toolset_has_keys,
|
||||
gui_toolset_label,
|
||||
)
|
||||
from hermes_cli.platforms import platform_label
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
toolset_rows = _get_effective_configurable_toolsets()
|
||||
target_platforms = {
|
||||
_toolset_configuration_platform(name) for name, _, _ in toolset_rows
|
||||
}
|
||||
enabled_by_platform = {
|
||||
platform: _get_platform_tools(
|
||||
config,
|
||||
platform,
|
||||
include_default_mcp_servers=False,
|
||||
)
|
||||
for platform in target_platforms
|
||||
}
|
||||
result = []
|
||||
for name, label, desc in toolset_rows:
|
||||
try:
|
||||
tools = sorted(set(resolve_toolset(name)))
|
||||
except Exception:
|
||||
tools = []
|
||||
target_platform = _toolset_configuration_platform(name)
|
||||
if name in _CONFIG_ONLY_TOOLSETS:
|
||||
# Config-only capabilities (stt) have no per-platform toolset —
|
||||
# their switch is their own config section (e.g. stt.enabled).
|
||||
from utils import is_truthy_value
|
||||
|
||||
section = config.get(name)
|
||||
section = section if isinstance(section, dict) else {}
|
||||
is_enabled = is_truthy_value(section.get("enabled", True), default=True)
|
||||
else:
|
||||
is_enabled = name in enabled_by_platform[target_platform]
|
||||
result.append({
|
||||
"name": name,
|
||||
"label": gui_toolset_label(label),
|
||||
"description": desc,
|
||||
"platform": target_platform,
|
||||
"platform_label": gui_toolset_label(
|
||||
platform_label(target_platform, target_platform)
|
||||
),
|
||||
"enabled": is_enabled,
|
||||
"available": is_enabled,
|
||||
"configured": _toolset_has_keys(name, config),
|
||||
"tools": tools,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.put("/api/tools/toolsets/{name}")
|
||||
async def toggle_toolset(name: str, body: ToolsetToggle, profile: Optional[str] = None):
|
||||
"""Enable/disable a configurable toolset for its configuration platform.
|
||||
|
||||
Most toolsets persist to ``platform_toolsets.cli``. Platform-restricted
|
||||
toolsets instead target their supported platform (for example, Discord's
|
||||
native toolsets persist to ``platform_toolsets.discord``). The shared
|
||||
``_save_platform_tools`` helper keeps the GUI and CLI in lockstep. Scoped
|
||||
to ``body.profile`` when provided. Returns 400 for unknown toolset keys.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
_CONFIG_ONLY_TOOLSETS,
|
||||
_get_effective_configurable_toolsets,
|
||||
_get_platform_tools,
|
||||
_save_platform_tools,
|
||||
_toolset_configuration_platform,
|
||||
)
|
||||
|
||||
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
target_platform = _toolset_configuration_platform(name)
|
||||
if name in _CONFIG_ONLY_TOOLSETS:
|
||||
# Config-only capabilities (stt) toggle their own config section's
|
||||
# ``enabled`` flag — there is no platform_toolsets entry to write.
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
section = config.setdefault(name, {})
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
config[name] = section
|
||||
section["enabled"] = bool(body.enabled)
|
||||
save_config(config)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"platform": target_platform,
|
||||
"enabled": body.enabled,
|
||||
}
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
enabled = set(
|
||||
_get_platform_tools(
|
||||
config,
|
||||
target_platform,
|
||||
include_default_mcp_servers=False,
|
||||
)
|
||||
)
|
||||
if body.enabled:
|
||||
enabled.add(name)
|
||||
else:
|
||||
enabled.discard(name)
|
||||
_save_platform_tools(config, target_platform, enabled)
|
||||
return {
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"platform": target_platform,
|
||||
"enabled": body.enabled,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/api/tools/toolsets/{name}/config")
|
||||
async def get_toolset_config(name: str, profile: Optional[str] = None):
|
||||
"""Return the provider matrix + key status for a toolset's config panel.
|
||||
|
||||
Surfaces the same provider rows the CLI ``hermes tools`` picker shows
|
||||
(via ``_visible_providers``), each with its ``env_vars`` annotated with
|
||||
current ``is_set`` state so the GUI can render provider selection + key
|
||||
entry. Toolsets without a ``TOOL_CATEGORIES`` entry return an empty
|
||||
provider list and ``has_category: false``. Returns 400 for unknown keys.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_get_effective_configurable_toolsets,
|
||||
_is_provider_active,
|
||||
_visible_providers,
|
||||
provider_readiness_status,
|
||||
web_provider_capabilities,
|
||||
)
|
||||
from hermes_cli.config import get_env_value
|
||||
from hermes_cli.nous_subscription import get_nous_subscription_features
|
||||
|
||||
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
providers = []
|
||||
active_provider = None
|
||||
active_search_backend = None
|
||||
active_extract_backend = None
|
||||
if cat:
|
||||
# Fetch portal/entitlement state once for the whole matrix — the
|
||||
# per-provider readiness computation below reuses it instead of
|
||||
# re-probing per row.
|
||||
features = get_nous_subscription_features(config, force_fresh=True)
|
||||
for prov in _visible_providers(cat, config, force_fresh=True):
|
||||
env_vars = [
|
||||
{
|
||||
"key": e["key"],
|
||||
"prompt": e.get("prompt", e["key"]),
|
||||
"url": e.get("url"),
|
||||
"default": e.get("default"),
|
||||
"is_set": bool(get_env_value(e["key"])),
|
||||
}
|
||||
for e in prov.get("env_vars", [])
|
||||
]
|
||||
# Surface the same active-provider determination the CLI picker
|
||||
# uses (``_is_provider_active``) so the GUI highlights the provider
|
||||
# actually written to config (e.g. web.backend), not just the first
|
||||
# keyless one in the list.
|
||||
is_active = _is_provider_active(prov, config, force_fresh=True)
|
||||
if is_active and active_provider is None:
|
||||
active_provider = prov["name"]
|
||||
row = {
|
||||
"name": prov["name"],
|
||||
"badge": prov.get("badge", ""),
|
||||
"tag": prov.get("tag", ""),
|
||||
"env_vars": env_vars,
|
||||
"post_setup": prov.get("post_setup"),
|
||||
"requires_nous_auth": bool(prov.get("requires_nous_auth")),
|
||||
"is_active": is_active,
|
||||
# Honest server-side readiness. The GUI's old client-side
|
||||
# heuristic showed "Ready" for every zero-env-var row —
|
||||
# including logged-out Nous Subscription rows and never-run
|
||||
# post_setup installs (see provider_readiness_status).
|
||||
"status": provider_readiness_status(
|
||||
prov, config, features=features, is_active=is_active
|
||||
),
|
||||
}
|
||||
if name == "web" and prov.get("web_backend"):
|
||||
# The runtime split web into two capabilities long ago
|
||||
# (web.search_backend / web.extract_backend); surface each
|
||||
# row's backend key and which capabilities it can serve so
|
||||
# the GUI can offer per-capability selection.
|
||||
row["web_backend"] = prov["web_backend"]
|
||||
row["capabilities"] = web_provider_capabilities(prov["web_backend"])
|
||||
if name == "tts" and prov.get("tts_provider"):
|
||||
# The provider key written to tts.provider on selection.
|
||||
# Doubles as the config section holding the provider's
|
||||
# voice/model settings (tts.<key>.*) so the GUI can render
|
||||
# those fields inline in the Capabilities panel.
|
||||
row["tts_provider"] = prov["tts_provider"]
|
||||
providers.append(row)
|
||||
if name == "web":
|
||||
# Resolve the per-capability active backends exactly the way the
|
||||
# web_search / web_extract dispatchers do (per-capability key →
|
||||
# shared web.backend → credential auto-detect), so the GUI badges
|
||||
# reflect what a tool call would actually hit right now.
|
||||
try:
|
||||
from tools.web_tools import _get_extract_backend, _get_search_backend
|
||||
|
||||
active_search_backend = _get_search_backend()
|
||||
active_extract_backend = _get_extract_backend()
|
||||
except Exception:
|
||||
active_search_backend = None
|
||||
active_extract_backend = None
|
||||
payload = {
|
||||
"name": name,
|
||||
"has_category": cat is not None,
|
||||
"providers": providers,
|
||||
"active_provider": active_provider,
|
||||
}
|
||||
if name == "web":
|
||||
payload["active_search_backend"] = active_search_backend
|
||||
payload["active_extract_backend"] = active_extract_backend
|
||||
return payload
|
||||
|
||||
|
||||
@router.get("/api/tools/toolsets/{name}/models")
|
||||
async def get_toolset_models(
|
||||
name: str, provider: Optional[str] = None, profile: Optional[str] = None
|
||||
):
|
||||
"""Return the model catalog for a toolset backend (image/video gen).
|
||||
|
||||
The GUI counterpart of the model picker `hermes tools` runs after a
|
||||
backend is selected — e.g. FAL's multi-model catalog (speed / strengths /
|
||||
price per model). ``provider`` names a picker row; omitted, the currently
|
||||
active provider is used. Toolsets without model catalogs return
|
||||
``has_models: false``.
|
||||
"""
|
||||
section = _MODEL_CATALOG_TOOLSETS.get(name)
|
||||
if section is None:
|
||||
return {"name": name, "has_models": False, "models": [], "current": None, "default": None}
|
||||
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
row = _find_toolset_provider_row(name, config, provider)
|
||||
plugin = _resolve_toolset_model_plugin(name, row) if row else None
|
||||
if not plugin:
|
||||
return {
|
||||
"name": name,
|
||||
"has_models": False,
|
||||
"models": [],
|
||||
"current": None,
|
||||
"default": None,
|
||||
}
|
||||
|
||||
catalog, default_model = _toolset_model_catalog(name, plugin)
|
||||
section_cfg = config.get(section)
|
||||
current = None
|
||||
if isinstance(section_cfg, dict):
|
||||
raw = section_cfg.get("model")
|
||||
if isinstance(raw, str) and raw.strip():
|
||||
current = raw.strip()
|
||||
if current not in catalog:
|
||||
current = default_model if default_model in catalog else None
|
||||
|
||||
models = [
|
||||
{
|
||||
"id": model_id,
|
||||
"display": meta.get("display", model_id),
|
||||
"speed": meta.get("speed", ""),
|
||||
"strengths": meta.get("strengths", ""),
|
||||
"price": meta.get("price", ""),
|
||||
}
|
||||
for model_id, meta in catalog.items()
|
||||
]
|
||||
return {
|
||||
"name": name,
|
||||
"has_models": bool(models),
|
||||
"provider": row.get("name") if row else None,
|
||||
"plugin": plugin,
|
||||
"models": models,
|
||||
"current": current,
|
||||
"default": default_model,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/api/tools/toolsets/{name}/model")
|
||||
async def select_toolset_model(
|
||||
name: str, body: ToolsetModelSelect, profile: Optional[str] = None
|
||||
):
|
||||
"""Persist a backend model selection (``image_gen.model`` / ``video_gen.model``).
|
||||
|
||||
Validates the model against the resolved backend's catalog — the same
|
||||
write the CLI's post-selection model picker performs. Returns 400 for
|
||||
toolsets without model catalogs or unknown model ids.
|
||||
"""
|
||||
section = _MODEL_CATALOG_TOOLSETS.get(name)
|
||||
if section is None:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Toolset has no model catalog: {name}"
|
||||
)
|
||||
|
||||
model_id = (body.model or "").strip()
|
||||
if not model_id:
|
||||
raise HTTPException(status_code=400, detail="model is required")
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
row = _find_toolset_provider_row(name, config, body.provider)
|
||||
plugin = _resolve_toolset_model_plugin(name, row) if row else None
|
||||
if not plugin:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"No model-capable backend is active for {name}",
|
||||
)
|
||||
|
||||
catalog, _default = _toolset_model_catalog(name, plugin)
|
||||
if model_id not in catalog:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown model {model_id!r} for backend {plugin!r}",
|
||||
)
|
||||
|
||||
section_cfg = config.setdefault(section, {})
|
||||
if not isinstance(section_cfg, dict):
|
||||
section_cfg = {}
|
||||
config[section] = section_cfg
|
||||
section_cfg["model"] = model_id
|
||||
save_config(config)
|
||||
|
||||
return {"ok": True, "name": name, "model": model_id, "plugin": plugin}
|
||||
|
||||
|
||||
@router.put("/api/tools/toolsets/{name}/provider")
|
||||
async def select_toolset_provider(
|
||||
name: str, body: ToolsetProviderSelect, profile: Optional[str] = None
|
||||
):
|
||||
"""Persist a provider selection for a toolset (no key prompting).
|
||||
|
||||
Delegates to ``apply_provider_selection`` — the shared, non-interactive
|
||||
core extracted from the CLI configurator — so the GUI and ``hermes tools``
|
||||
write identical config keys (``web.backend``, ``tts.provider``, etc.).
|
||||
API keys and post-setup flows are handled by separate endpoints. Returns
|
||||
400 for unknown toolset or provider names.
|
||||
|
||||
For the ``web`` toolset only, an optional ``capability`` ('search' |
|
||||
'extract') scopes the selection to ``web.search_backend`` /
|
||||
``web.extract_backend`` — the same per-capability overrides the runtime
|
||||
dispatchers (``tools.web_tools._get_search_backend`` /
|
||||
``_get_extract_backend``) resolve first. The provider must actually
|
||||
support the requested capability (a search-only backend can't be the
|
||||
extract backend). Omitting ``capability`` keeps the legacy whole-provider
|
||||
behavior (writes ``web.backend``).
|
||||
|
||||
Managed Nous rows (``managed_nous_feature``) additionally report the
|
||||
Portal entitlement state: the CLI flow gates these selections on
|
||||
``ensure_nous_portal_access`` (inline login), but the GUI has no inline
|
||||
prompt, so selecting one while logged out / unentitled used to write the
|
||||
config keys and then never activate (``_is_provider_active`` requires
|
||||
``managed_by_nous``). The response now carries an additive
|
||||
``needs_nous_auth: true`` + ``feature`` so the client can drive the
|
||||
existing Nous Portal OAuth flow (``POST /api/providers/oauth/nous/start``)
|
||||
and refetch.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
apply_provider_selection,
|
||||
web_provider_capabilities,
|
||||
_get_effective_configurable_toolsets,
|
||||
_visible_providers,
|
||||
)
|
||||
from hermes_cli.nous_subscription import (
|
||||
MANAGED_FEATURE_COVERAGE_CATEGORY,
|
||||
get_nous_subscription_features,
|
||||
)
|
||||
|
||||
valid = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
if body.capability is not None:
|
||||
if name != "web":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="capability selection is only supported for the web toolset",
|
||||
)
|
||||
if body.capability not in ("search", "extract"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown capability: {body.capability!r} (expected 'search' or 'extract')",
|
||||
)
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
if body.capability is not None:
|
||||
# Per-capability path: resolve the picker row to its backend key
|
||||
# and write web.<capability>_backend. Does NOT touch web.backend,
|
||||
# so the other capability keeps resolving through the shared
|
||||
# fallback chain.
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
providers = _visible_providers(cat, config, force_fresh=True) if cat else []
|
||||
prov = next((p for p in providers if p.get("name") == body.provider), None)
|
||||
if prov is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown provider {body.provider!r} for toolset {name!r}",
|
||||
)
|
||||
backend = prov.get("web_backend")
|
||||
if not backend:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Provider {body.provider!r} has no web backend key",
|
||||
)
|
||||
if body.capability not in web_provider_capabilities(backend):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"{body.provider} does not support {body.capability}",
|
||||
)
|
||||
web_cfg = config.setdefault("web", {})
|
||||
if not isinstance(web_cfg, dict):
|
||||
web_cfg = {}
|
||||
config["web"] = web_cfg
|
||||
web_cfg[f"{body.capability}_backend"] = backend
|
||||
else:
|
||||
try:
|
||||
apply_provider_selection(name, body.provider, config)
|
||||
except KeyError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc).strip('"'))
|
||||
save_config(config)
|
||||
response: Dict[str, Any] = {"ok": True, "name": name, "provider": body.provider}
|
||||
if body.capability is not None:
|
||||
response["capability"] = body.capability
|
||||
|
||||
# Entitlement check for managed Nous rows — mirrors the gate the CLI
|
||||
# applies via ensure_nous_portal_access at selection time.
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
row = None
|
||||
if cat:
|
||||
row = next(
|
||||
(
|
||||
p
|
||||
for p in _visible_providers(cat, config, force_fresh=True)
|
||||
if p.get("name") == body.provider
|
||||
),
|
||||
None,
|
||||
)
|
||||
managed_feature = (row or {}).get("managed_nous_feature")
|
||||
if managed_feature:
|
||||
features = get_nous_subscription_features(config, force_fresh=True)
|
||||
acct = features.account_info
|
||||
category = MANAGED_FEATURE_COVERAGE_CATEGORY.get(managed_feature)
|
||||
entitled = bool(
|
||||
acct
|
||||
and acct.logged_in
|
||||
and (
|
||||
acct.tool_gateway_entitled_for(category)
|
||||
if category
|
||||
else acct.tool_gateway_entitled
|
||||
)
|
||||
)
|
||||
if not entitled:
|
||||
response["needs_nous_auth"] = True
|
||||
response["feature"] = managed_feature
|
||||
return response
|
||||
|
||||
|
||||
@router.put("/api/tools/toolsets/{name}/env")
|
||||
async def save_toolset_env(name: str, body: ToolsetEnvUpdate, profile: Optional[str] = None):
|
||||
"""Persist API keys for a toolset's provider env vars.
|
||||
|
||||
Writes each ``key: value`` to ``~/.hermes/.env`` via ``save_env_value`` —
|
||||
the same store ``hermes tools`` writes when it prompts for keys. Keys are
|
||||
validated against the env-var allowlist for the toolset's category (the
|
||||
union of every visible provider's ``env_vars``), so the GUI can't write an
|
||||
arbitrary env var through this endpoint. A blank value is treated as
|
||||
"leave unchanged" and skipped. Returns the saved/skipped key lists and the
|
||||
refreshed ``is_set`` status. Returns 400 for unknown toolset or env keys.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
TOOL_CATEGORIES,
|
||||
_get_effective_configurable_toolsets,
|
||||
_visible_providers,
|
||||
)
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
|
||||
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
cat = TOOL_CATEGORIES.get(name)
|
||||
allowed: set[str] = set()
|
||||
if cat:
|
||||
for prov in _visible_providers(cat, config, force_fresh=True):
|
||||
for e in prov.get("env_vars", []):
|
||||
allowed.add(e["key"])
|
||||
|
||||
unknown = [k for k in body.env if k not in allowed]
|
||||
if unknown:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown env var(s) for toolset {name}: {', '.join(sorted(unknown))}",
|
||||
)
|
||||
|
||||
saved: List[str] = []
|
||||
skipped: List[str] = []
|
||||
for key, value in body.env.items():
|
||||
if value and value.strip():
|
||||
try:
|
||||
save_env_value(key, value.strip())
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc))
|
||||
saved.append(key)
|
||||
else:
|
||||
skipped.append(key)
|
||||
|
||||
status = {k: bool(get_env_value(k)) for k in allowed}
|
||||
return {"ok": True, "name": name, "saved": saved, "skipped": skipped, "is_set": status}
|
||||
|
||||
|
||||
@router.post("/api/tools/toolsets/{name}/post-setup")
|
||||
async def run_toolset_post_setup(
|
||||
name: str, body: ToolsetPostSetup, profile: Optional[str] = None
|
||||
):
|
||||
"""Spawn a provider's post-setup install hook as a background action.
|
||||
|
||||
Post-setup hooks (npm install for browser/Camofox, pip install for
|
||||
KittenTTS/Piper/ddgs, cua-driver fetch, etc.) are long-running and
|
||||
text-output, so this follows the spawn-action pattern: it launches
|
||||
``hermes tools post-setup <key>`` and the frontend tails the log via
|
||||
``GET /api/actions/tools-post-setup/status``. The ``key`` is validated
|
||||
against the declared post-setup allowlist before spawning. Returns 400
|
||||
for unknown toolset or post-setup key.
|
||||
|
||||
``profile`` spawns the hook as ``hermes -p <profile> tools post-setup``.
|
||||
Most hooks install machine-level artifacts (repo node_modules, shared
|
||||
pip packages) where the scope is inert, but hooks that read config or
|
||||
write per-profile state must see the same HERMES_HOME the rest of the
|
||||
drawer's writes targeted — so the scope is threaded for consistency.
|
||||
"""
|
||||
from hermes_cli.tools_config import (
|
||||
_get_effective_configurable_toolsets,
|
||||
valid_post_setup_keys,
|
||||
)
|
||||
|
||||
valid_ts = {ts_key for ts_key, _, _ in _get_effective_configurable_toolsets()}
|
||||
if name not in valid_ts:
|
||||
raise HTTPException(status_code=400, detail=f"Unknown toolset: {name}")
|
||||
|
||||
if body.key not in valid_post_setup_keys():
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"Unknown post-setup key: {body.key}"
|
||||
)
|
||||
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(body.profile or profile)
|
||||
+ ["tools", "post-setup", body.key],
|
||||
"tools-post-setup",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn tools post-setup")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to run post-setup: {exc}"
|
||||
)
|
||||
return {"ok": True, "pid": proc.pid, "name": "tools-post-setup", "key": body.key}
|
||||
|
||||
|
||||
@router.get("/api/tools/terminal/backends")
|
||||
async def get_terminal_backends(profile: Optional[str] = None):
|
||||
"""Terminal execution backend rows with health probes for the picker panel.
|
||||
|
||||
Returns ``{active, backends: [{name, label, description, active, status,
|
||||
detail}]}`` where ``status`` is ``ready`` / ``needs_setup`` /
|
||||
``unavailable`` and ``detail`` carries setup guidance for non-ready rows.
|
||||
Probes are fast (<~2s each) and defensive — a probe failure surfaces as a
|
||||
status, never an error response.
|
||||
"""
|
||||
with _profile_scope(profile):
|
||||
config = load_config()
|
||||
terminal_cfg = config.get("terminal")
|
||||
if not isinstance(terminal_cfg, dict):
|
||||
terminal_cfg = {}
|
||||
active = str(terminal_cfg.get("backend") or "local").strip().lower()
|
||||
if active not in _TERMINAL_BACKEND_NAMES:
|
||||
active = "local"
|
||||
|
||||
backends = []
|
||||
for row in _TERMINAL_BACKENDS:
|
||||
status, detail = _probe_terminal_backend(row["name"], terminal_cfg)
|
||||
backends.append({
|
||||
"name": row["name"],
|
||||
"label": row["label"],
|
||||
"description": row["description"],
|
||||
"active": row["name"] == active,
|
||||
"status": status,
|
||||
"detail": detail,
|
||||
})
|
||||
return {"active": active, "backends": backends}
|
||||
|
||||
|
||||
@router.put("/api/tools/terminal/backend")
|
||||
async def select_terminal_backend(
|
||||
body: TerminalBackendSelect, profile: Optional[str] = None
|
||||
):
|
||||
"""Persist ``terminal.backend`` in config.yaml.
|
||||
|
||||
Validates against the known backend set (the same enum the raw-config
|
||||
settings row exposes). Selecting a backend that still needs setup is
|
||||
allowed — the picker shows guidance instead of blocking, matching the CLI.
|
||||
"""
|
||||
backend = (body.backend or "").strip().lower()
|
||||
if backend not in _TERMINAL_BACKEND_NAMES:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Unknown terminal backend: {body.backend!r}. "
|
||||
f"Use one of: {', '.join(sorted(_TERMINAL_BACKEND_NAMES))}",
|
||||
)
|
||||
|
||||
with _profile_scope(body.profile or profile):
|
||||
config = load_config()
|
||||
terminal_cfg = config.setdefault("terminal", {})
|
||||
if not isinstance(terminal_cfg, dict):
|
||||
terminal_cfg = {}
|
||||
config["terminal"] = terminal_cfg
|
||||
terminal_cfg["backend"] = backend
|
||||
save_config(config)
|
||||
return {"ok": True, "backend": backend}
|
||||
|
||||
|
||||
@router.get("/api/tools/computer-use/status")
|
||||
async def get_computer_use_status(profile: Optional[str] = None):
|
||||
"""Cross-platform Computer Use readiness for the desktop card.
|
||||
|
||||
See ``tools.computer_use.permissions.computer_use_status`` for the payload
|
||||
shape. Read-only and fast (shells ``cua-driver doctor`` + macOS
|
||||
``permissions status``).
|
||||
"""
|
||||
from tools.computer_use.permissions import computer_use_status
|
||||
|
||||
with _profile_scope(profile):
|
||||
return computer_use_status()
|
||||
|
||||
|
||||
@router.post("/api/tools/computer-use/permissions/grant")
|
||||
async def grant_computer_use_permissions(profile: Optional[str] = None):
|
||||
"""Spawn ``hermes computer-use permissions grant`` as a background action.
|
||||
|
||||
macOS-only: ``cua-driver permissions grant`` launches CuaDriver via
|
||||
LaunchServices so the TCC dialog is attributed to com.trycua.driver, then
|
||||
waits for approval. The frontend polls ``GET /api/actions/computer-use-
|
||||
grant/status`` and re-reads ``/status`` once it exits. Windows/Linux have
|
||||
no TCC toggles to grant, so this returns 400 there.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Computer Use permission grants are a macOS concept.",
|
||||
)
|
||||
try:
|
||||
proc = _spawn_hermes_action(
|
||||
_profile_cli_args(profile)
|
||||
+ ["computer-use", "permissions", "grant"],
|
||||
"computer-use-grant",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
_log.exception("Failed to spawn computer-use permissions grant")
|
||||
raise HTTPException(
|
||||
status_code=500, detail=f"Failed to request permissions: {exc}"
|
||||
)
|
||||
return {"ok": True, "pid": proc.pid, "name": "computer-use-grant"}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -4,6 +4,7 @@ import threading
|
|||
from pathlib import Path
|
||||
|
||||
from hermes_cli import web_server
|
||||
from hermes_cli.web_routers import sessions as web_sessions
|
||||
|
||||
|
||||
TARGET_HANDLERS = {
|
||||
|
|
@ -29,15 +30,18 @@ def _call_name(call: ast.Call) -> str | None:
|
|||
|
||||
|
||||
def test_sessiondb_handlers_open_connections_inside_executor_helpers():
|
||||
tree = ast.parse(Path(web_server.__file__).read_text(encoding="utf-8"))
|
||||
handlers = {
|
||||
node.name: node
|
||||
for node in tree.body
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS
|
||||
}
|
||||
top_level_helpers = {
|
||||
node.name: node for node in tree.body if isinstance(node, ast.FunctionDef)
|
||||
}
|
||||
# The session route handlers were extracted to web_routers/sessions.py
|
||||
# (wave 2); the analytics handlers and the executor helpers still live in
|
||||
# web_server.py — scan both modules' top-level bodies.
|
||||
handlers: dict[str, ast.AsyncFunctionDef] = {}
|
||||
top_level_helpers: dict[str, ast.FunctionDef] = {}
|
||||
for mod in (web_server, web_sessions):
|
||||
tree = ast.parse(Path(mod.__file__).read_text(encoding="utf-8"))
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.AsyncFunctionDef) and node.name in TARGET_HANDLERS:
|
||||
handlers[node.name] = node
|
||||
elif isinstance(node, ast.FunctionDef):
|
||||
top_level_helpers[node.name] = node
|
||||
assert handlers.keys() == TARGET_HANDLERS
|
||||
|
||||
for name, handler in handlers.items():
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue