985 lines
36 KiB
Python
985 lines
36 KiB
Python
"""Pairing, webhooks, gateway lifecycle, credential pool, memory provider and operations (doctor/backup/import/hooks/checkpoints) dashboard routes.
|
|
|
|
Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on
|
|
``web_server`` stay there and are imported lazily at call time (cycle-safe).
|
|
"""
|
|
|
|
import logging
|
|
import re
|
|
import tempfile
|
|
import zipfile
|
|
import asyncio
|
|
import os
|
|
import secrets
|
|
from datetime import datetime, timezone
|
|
from fastapi import APIRouter
|
|
from hermes_cli.web_routers._common import http_failure
|
|
from hermes_cli.web_deps import late
|
|
from fastapi import File, Form, HTTPException, UploadFile
|
|
from fastapi.responses import FileResponse
|
|
from hermes_cli.config import redact_key
|
|
from hermes_cli.web_models import PairingApprove, PairingRevoke, WebhookCreate, WebhookEnabledToggle, CredentialPoolAdd, MemoryProviderSelect, MemoryReset, BackupRequest, ImportRequest, HookCreate, HookDelete
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
_log = logging.getLogger("hermes_cli.web_server")
|
|
router = APIRouter()
|
|
|
|
# web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative.
|
|
_discover_memory_provider_statuses = late("_discover_memory_provider_statuses")
|
|
_gateway_subcommand = late("_gateway_subcommand")
|
|
_normalize_memory_provider_name = late("_normalize_memory_provider_name")
|
|
_path_is_under = late("_path_is_under")
|
|
_require_memory_provider_ready = late("_require_memory_provider_ready")
|
|
_resolve_profile_dir = late("_resolve_profile_dir")
|
|
_spawn_hermes_action = late("_spawn_hermes_action")
|
|
_write_platform_enabled = late("_write_platform_enabled")
|
|
get_hermes_home = late("get_hermes_home")
|
|
load_config = late("load_config")
|
|
save_config = late("save_config")
|
|
_restart_gateway_after = late("_restart_gateway_after")
|
|
|
|
|
|
def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]:
|
|
return _restart_gateway_after(profile, what="enabling webhooks", label="Webhook enable")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pairing endpoints — approve / revoke / list messaging pairing codes.
|
|
#
|
|
# These are how a remote admin onboards messaging users (Telegram, Discord, …)
|
|
# without shell access. Wraps gateway.pairing.PairingStore directly.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _pairing_store(profile: Optional[str] = None):
|
|
"""Pairing store for ``profile`` — the dashboard's own when unspecified.
|
|
|
|
Every other admin endpoint scopes by profile, and the gateway already
|
|
keeps one store per served profile (``gateway/run.py``). Without this the
|
|
dashboard and desktop always read the global store, so an operator on a
|
|
named profile approves into a whitelist their gateway never consults.
|
|
|
|
``PairingStore`` resolves the profile's home itself (``default`` maps back
|
|
to the global store), so this only needs to validate the name — no
|
|
``_profile_scope`` needed, and nothing process-global is swapped across
|
|
the ``await`` boundary.
|
|
"""
|
|
from gateway.pairing import PairingStore
|
|
|
|
requested = (profile or "").strip()
|
|
if not requested or requested.lower() == "current":
|
|
return PairingStore()
|
|
|
|
_resolve_profile_dir(requested) # 400/404 on an unknown profile
|
|
|
|
return PairingStore(profile=requested)
|
|
|
|
|
|
@router.get("/api/pairing")
|
|
async def list_pairing(profile: Optional[str] = None):
|
|
store = _pairing_store(profile)
|
|
return {
|
|
"pending": store.list_pending(),
|
|
"approved": store.list_approved(),
|
|
}
|
|
|
|
|
|
@router.post("/api/pairing/approve")
|
|
async def approve_pairing(body: PairingApprove):
|
|
store = _pairing_store(body.profile)
|
|
platform = (body.platform or "").lower().strip()
|
|
# `request_id` is what an admin surface sends after listing pending
|
|
# requests; `code` is the one-time code the user relays from their DM.
|
|
# A GUI that only knows the older field name still works — a value with
|
|
# request-id shape routes to the request path either way.
|
|
target = (body.request_id or body.code or "").strip()
|
|
if not platform or not target:
|
|
raise HTTPException(
|
|
status_code=400, detail="platform and request_id or code are required"
|
|
)
|
|
|
|
by_request_id = bool(body.request_id) or store.looks_like_request_id(target)
|
|
if by_request_id:
|
|
result = store.approve_request(platform, target)
|
|
else:
|
|
result = store.approve_code(platform, target.upper())
|
|
|
|
if result:
|
|
return {"ok": True, "user": result}
|
|
# Lockout only gates the code path, so only report it there — otherwise a
|
|
# stale request id would surface as a bogus 429 while the platform sat
|
|
# locked out for an unrelated reason.
|
|
if not by_request_id and store._is_locked_out(platform):
|
|
raise HTTPException(
|
|
status_code=429,
|
|
detail=f"Platform '{platform}' is locked out after too many failed approvals.",
|
|
)
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"Pairing request or code not found or expired for platform '{platform}'.",
|
|
)
|
|
|
|
|
|
@router.post("/api/pairing/revoke")
|
|
async def revoke_pairing(body: PairingRevoke):
|
|
store = _pairing_store(body.profile)
|
|
platform = (body.platform or "").lower().strip()
|
|
if not platform or not body.user_id:
|
|
raise HTTPException(status_code=400, detail="platform and user_id are required")
|
|
if store.revoke(platform, body.user_id):
|
|
return {"ok": True}
|
|
raise HTTPException(
|
|
status_code=404,
|
|
detail=f"User {body.user_id} not found in approved list for {platform}.",
|
|
)
|
|
|
|
|
|
@router.post("/api/pairing/clear-pending")
|
|
async def clear_pending_pairing(profile: Optional[str] = None):
|
|
store = _pairing_store(profile)
|
|
count = store.clear_pending()
|
|
return {"ok": True, "cleared": count}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Webhook subscription endpoints — list / subscribe / remove.
|
|
#
|
|
# Wraps the same JSON store the CLI uses (hermes_cli.webhook); the webhook
|
|
# adapter hot-reloads it without a gateway restart. Per-route HMAC secrets
|
|
# are redacted on read and surfaced once on create.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _webhook_route_summary(name: str, route: Dict[str, Any], base_url: str) -> Dict[str, Any]:
|
|
return {
|
|
"name": name,
|
|
"description": route.get("description", ""),
|
|
"events": list(route.get("events") or []),
|
|
"deliver": route.get("deliver", "log"),
|
|
"deliver_only": bool(route.get("deliver_only")),
|
|
"prompt": route.get("prompt", ""),
|
|
"script": route.get("script", ""),
|
|
"skills": list(route.get("skills") or []),
|
|
"created_at": route.get("created_at"),
|
|
"url": f"{base_url}/webhooks/{name}",
|
|
# Secret is masked on read; full value only returned on create.
|
|
"secret_set": bool(route.get("secret")),
|
|
# Default-enabled; only an explicit enabled:false turns a route off.
|
|
"enabled": route.get("enabled", True) is not False,
|
|
}
|
|
|
|
|
|
@router.get("/api/webhooks")
|
|
async def list_webhooks():
|
|
import hermes_cli.webhook as wh
|
|
|
|
base_url = wh._get_webhook_base_url()
|
|
subs = wh._load_subscriptions()
|
|
return {
|
|
"enabled": wh._is_webhook_enabled(),
|
|
"base_url": base_url,
|
|
"subscriptions": [
|
|
_webhook_route_summary(name, route, base_url)
|
|
for name, route in subs.items()
|
|
],
|
|
}
|
|
|
|
|
|
@router.post("/api/webhooks/enable")
|
|
async def enable_webhooks():
|
|
try:
|
|
_write_platform_enabled("webhook", True)
|
|
except Exception as exc:
|
|
_log.exception("Failed to enable webhook platform from dashboard")
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail="Failed to enable webhook platform.",
|
|
) from exc
|
|
|
|
restart_result = _restart_gateway_after_webhook_enable()
|
|
return {
|
|
"ok": True,
|
|
"platform": "webhook",
|
|
"enabled": True,
|
|
"needs_restart": not restart_result["restart_started"],
|
|
**restart_result,
|
|
}
|
|
|
|
|
|
@router.post("/api/webhooks")
|
|
async def create_webhook(body: WebhookCreate):
|
|
import re as _re
|
|
import secrets as _secrets
|
|
import time as _time
|
|
import hermes_cli.webhook as wh
|
|
|
|
if not wh._is_webhook_enabled():
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Webhook platform is not enabled. Enable it from the Webhooks page first.",
|
|
)
|
|
|
|
name = (body.name or "").strip().lower().replace(" ", "-")
|
|
if not _re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Invalid name. Use lowercase alphanumeric with hyphens/underscores.",
|
|
)
|
|
|
|
if body.deliver_only and body.deliver == "log":
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Direct delivery requires a real target (telegram, discord, …), not 'log'.",
|
|
)
|
|
|
|
secret = body.secret or _secrets.token_urlsafe(32)
|
|
route: Dict[str, Any] = {
|
|
"description": body.description or f"Dashboard-created subscription: {name}",
|
|
"events": [e.strip() for e in body.events if e.strip()],
|
|
"secret": secret,
|
|
"prompt": body.prompt or "",
|
|
"skills": [s.strip() for s in body.skills if s.strip()],
|
|
"deliver": body.deliver or "log",
|
|
"created_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
|
|
}
|
|
if body.script and body.script.strip():
|
|
route["script"] = body.script.strip()
|
|
if body.deliver_only:
|
|
route["deliver_only"] = True
|
|
if body.deliver_chat_id:
|
|
route["deliver_extra"] = {"chat_id": body.deliver_chat_id}
|
|
|
|
subs = wh._load_subscriptions()
|
|
subs[name] = route
|
|
wh._save_subscriptions(subs)
|
|
|
|
base_url = wh._get_webhook_base_url()
|
|
summary = _webhook_route_summary(name, route, base_url)
|
|
# Surface the secret exactly once, on create.
|
|
summary["secret"] = secret
|
|
return summary
|
|
|
|
|
|
@router.delete("/api/webhooks/{name}")
|
|
async def delete_webhook(name: str):
|
|
import hermes_cli.webhook as wh
|
|
|
|
key = (name or "").strip().lower()
|
|
subs = wh._load_subscriptions()
|
|
if key not in subs:
|
|
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
|
|
del subs[key]
|
|
wh._save_subscriptions(subs)
|
|
return {"ok": True}
|
|
|
|
|
|
@router.put("/api/webhooks/{name}/enabled")
|
|
async def set_webhook_enabled(name: str, body: WebhookEnabledToggle):
|
|
"""Enable or disable a webhook route.
|
|
|
|
Disabled routes stay in the subscriptions file (so they can be
|
|
re-enabled) but the gateway rejects incoming events with 403. The
|
|
gateway hot-reloads the subscriptions file, so this takes effect on the
|
|
next event without a restart.
|
|
"""
|
|
import hermes_cli.webhook as wh
|
|
|
|
key = (name or "").strip().lower()
|
|
subs = wh._load_subscriptions()
|
|
if key not in subs:
|
|
raise HTTPException(status_code=404, detail=f"No subscription named '{key}'")
|
|
subs[key]["enabled"] = bool(body.enabled)
|
|
wh._save_subscriptions(subs)
|
|
return {"ok": True, "name": key, "enabled": bool(body.enabled)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Gateway lifecycle endpoints — start / stop.
|
|
#
|
|
# restart + update already exist above; these complete the lifecycle so a
|
|
# remote admin can bring the gateway up or down without shell access. Both
|
|
# spawn the real `hermes gateway <verb>` so behaviour matches the CLI exactly.
|
|
# Status is already surfaced by /api/status (gateway_running/state/platforms).
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/api/gateway/start")
|
|
async def start_gateway(profile: Optional[str] = None):
|
|
with http_failure("Failed to spawn gateway start", 500, "Failed to start gateway"):
|
|
proc = _spawn_hermes_action(_gateway_subcommand(profile, "start"), "gateway-start")
|
|
return {"ok": True, "pid": proc.pid, "name": "gateway-start"}
|
|
|
|
|
|
@router.post("/api/gateway/stop")
|
|
async def stop_gateway(profile: Optional[str] = None):
|
|
with http_failure("Failed to spawn gateway stop", 500, "Failed to stop gateway"):
|
|
proc = _spawn_hermes_action(_gateway_subcommand(profile, "stop"), "gateway-stop")
|
|
return {"ok": True, "pid": proc.pid, "name": "gateway-stop"}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Credential pool endpoints — list / add / remove rotation keys.
|
|
#
|
|
# The credential pool (auth.json -> credential_pool.<provider>[]) holds the
|
|
# rotating API keys the agent round-robins through. Secrets are redacted on
|
|
# read; only the agent ever sees the raw values at session start.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _pool_entry_summary(entry: Any, index: int) -> Dict[str, Any]:
|
|
"""Redacted, display-safe view of one PooledCredential.
|
|
|
|
``index`` is 1-based to match CredentialPool.remove_index().
|
|
"""
|
|
token = getattr(entry, "access_token", "") or ""
|
|
return {
|
|
"index": index,
|
|
"id": getattr(entry, "id", None),
|
|
"label": getattr(entry, "label", None),
|
|
"auth_type": getattr(entry, "auth_type", None),
|
|
"source": getattr(entry, "source", None),
|
|
"priority": getattr(entry, "priority", 0),
|
|
"last_status": getattr(entry, "last_status", None),
|
|
"request_count": getattr(entry, "request_count", 0),
|
|
"token_preview": redact_key(token) if token else "",
|
|
"has_refresh": bool(getattr(entry, "refresh_token", None)),
|
|
}
|
|
|
|
|
|
@router.get("/api/credentials/pool")
|
|
async def list_credential_pool():
|
|
from agent.credential_pool import load_pool
|
|
from hermes_cli.auth import read_credential_pool
|
|
|
|
# load_pool() may hit the network synchronously (Copilot token exchange
|
|
# over raw urllib). urllib's timeout does NOT bound DNS resolution
|
|
# (getaddrinfo blocks in C), so on a networkless Windows host this froze
|
|
# the uvicorn event loop for 17 minutes (2026-08-22 00:03-00:20 stall).
|
|
# Keep every provider load off the loop - same pattern as
|
|
# get_memory_status below.
|
|
def _run():
|
|
providers = []
|
|
# read_credential_pool(None) lists every provider that has pooled entries;
|
|
# load_pool() then gives us the rich PooledCredential objects per provider.
|
|
raw_pool = read_credential_pool()
|
|
for provider_id in sorted(raw_pool.keys()):
|
|
try:
|
|
pool = load_pool(provider_id)
|
|
except Exception:
|
|
_log.exception("load_pool(%s) failed", provider_id)
|
|
continue
|
|
entries = pool.entries()
|
|
if not entries:
|
|
continue
|
|
providers.append({
|
|
"provider": provider_id,
|
|
"entries": [
|
|
_pool_entry_summary(e, i) for i, e in enumerate(entries, start=1)
|
|
],
|
|
})
|
|
return {"providers": providers}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.post("/api/credentials/pool")
|
|
async def add_credential_pool_entry(body: CredentialPoolAdd):
|
|
import uuid as _uuid
|
|
from agent.credential_pool import (
|
|
load_pool,
|
|
PooledCredential,
|
|
AUTH_TYPE_API_KEY,
|
|
CUSTOM_POOL_PREFIX,
|
|
SOURCE_MANUAL,
|
|
)
|
|
|
|
provider = (body.provider or "").strip().lower()
|
|
api_key = (body.api_key or "").strip()
|
|
if not provider or not api_key:
|
|
raise HTTPException(status_code=400, detail="provider and api_key are required")
|
|
|
|
# load_pool() may run synchronous OAuth token exchanges (network I/O);
|
|
# keep it off the event loop - see list_credential_pool (2026-08-22
|
|
# 17-minute stall fix).
|
|
def _run():
|
|
try:
|
|
pool = load_pool(provider)
|
|
label = (body.label or "").strip() or f"key #{len(pool.entries()) + 1}"
|
|
entry = PooledCredential(
|
|
provider=provider,
|
|
id=_uuid.uuid4().hex[:6],
|
|
label=label,
|
|
auth_type=AUTH_TYPE_API_KEY,
|
|
priority=0,
|
|
source=SOURCE_MANUAL,
|
|
access_token=api_key,
|
|
)
|
|
pool.add_entry(entry)
|
|
# Re-adding a credential is an explicit re-engagement signal: lift
|
|
# every suppression for this provider so a source deleted earlier
|
|
# (via DELETE below or `hermes auth add`) can seed again.
|
|
# Mirrors the `hermes auth add` behaviour in auth_commands.py.
|
|
if not provider.startswith(CUSTOM_POOL_PREFIX):
|
|
try:
|
|
from hermes_cli.auth import (
|
|
_load_auth_store,
|
|
unsuppress_credential_source,
|
|
)
|
|
suppressed = _load_auth_store().get("suppressed_sources", {})
|
|
for src in list(suppressed.get(provider, []) or []):
|
|
unsuppress_credential_source(provider, src)
|
|
except Exception:
|
|
_log.exception("unsuppress after pool add failed (non-fatal)")
|
|
return {"ok": True, "provider": provider, "count": len(pool.entries())}
|
|
except HTTPException:
|
|
raise
|
|
except Exception as exc:
|
|
_log.exception("POST /api/credentials/pool failed")
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.delete("/api/credentials/pool/{provider}/{index}")
|
|
async def remove_credential_pool_entry(provider: str, index: int):
|
|
"""Remove a pool entry. ``index`` is 1-based (matches the list response).
|
|
|
|
Removal must be sticky (#55217): ``load_pool()`` re-seeds entries from
|
|
their backing source (.env var, OAuth singleton file, custom-provider
|
|
config) on every call, so deleting only the pool row silently reverts on
|
|
the next dashboard refresh. We dispatch through the same RemovalStep
|
|
registry the CLI ``hermes auth remove`` uses: each source cleans up its
|
|
external state and suppresses ``(provider, source)`` so the seeders skip
|
|
it. Manual entries have no registered step — nothing external to clean,
|
|
no suppression needed (they aren't re-seeded).
|
|
"""
|
|
from agent.credential_pool import load_pool
|
|
from agent.credential_sources import find_removal_step
|
|
from hermes_cli.auth import suppress_credential_source
|
|
|
|
provider = (provider or "").strip().lower()
|
|
# load_pool() may run synchronous token exchanges and the removal steps do
|
|
# blocking disk writes - keep them off the event loop (see
|
|
# list_credential_pool; 2026-08-22 17-minute stall fix).
|
|
def _run():
|
|
try:
|
|
pool = load_pool(provider)
|
|
removed = pool.remove_index(index)
|
|
except Exception as exc:
|
|
_log.exception("DELETE /api/credentials/pool failed")
|
|
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
|
if removed is None:
|
|
raise HTTPException(status_code=404, detail="No pool entry at that index")
|
|
|
|
cleaned: List[str] = []
|
|
hints: List[str] = []
|
|
step = find_removal_step(provider, removed.source or "")
|
|
if step is not None:
|
|
try:
|
|
result = step.remove_fn(provider, removed)
|
|
cleaned = list(result.cleaned)
|
|
hints = list(result.hints)
|
|
if result.suppress:
|
|
suppress_credential_source(provider, removed.source)
|
|
except Exception:
|
|
# Cleanup is best-effort, but suppression is the actual bug fix -
|
|
# without it the entry resurrects on the next load_pool(). Apply
|
|
# it even when source-specific cleanup blew up.
|
|
_log.exception(
|
|
"credential source cleanup failed for %s/%s; suppressing anyway",
|
|
provider, removed.source,
|
|
)
|
|
try:
|
|
suppress_credential_source(provider, removed.source)
|
|
except Exception:
|
|
_log.exception("suppress_credential_source failed")
|
|
return {
|
|
"ok": True,
|
|
"provider": provider,
|
|
"count": len(pool.entries()),
|
|
"cleaned": cleaned,
|
|
"hints": hints,
|
|
}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Memory provider endpoints — status / list providers / select / disable / reset.
|
|
#
|
|
# Provider setup is dashboard-native when a provider exposes get_config_schema().
|
|
# The dashboard never runs interactive provider setup hooks; activation is only
|
|
# allowed once the provider is discoverable, available, and has required config.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get("/api/memory")
|
|
async def get_memory_status():
|
|
# load_config(), file stats and provider discovery are disk reads — keep
|
|
# them off the event loop.
|
|
def _run():
|
|
cfg = load_config()
|
|
active = ""
|
|
mem = cfg.get("memory")
|
|
if isinstance(mem, dict):
|
|
active = _normalize_memory_provider_name(mem.get("provider"))
|
|
|
|
# Built-in memory file sizes (so the UI can show what a reset would erase).
|
|
mem_dir = get_hermes_home() / "memories"
|
|
files = {}
|
|
for fname, key in (("MEMORY.md", "memory"), ("USER.md", "user")):
|
|
path = mem_dir / fname
|
|
files[key] = path.stat().st_size if path.exists() else 0
|
|
|
|
return {
|
|
"active": active,
|
|
"providers": _discover_memory_provider_statuses(),
|
|
"builtin_files": files,
|
|
}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.put("/api/memory/provider")
|
|
async def set_memory_provider(body: MemoryProviderSelect):
|
|
from hermes_cli.web_server import _CONFIG_MUTATION_LOCK
|
|
provider = _normalize_memory_provider_name(body.provider)
|
|
|
|
def _run():
|
|
_require_memory_provider_ready(provider)
|
|
|
|
with _CONFIG_MUTATION_LOCK:
|
|
cfg = load_config()
|
|
if not isinstance(cfg.get("memory"), dict):
|
|
cfg["memory"] = {}
|
|
cfg["memory"]["provider"] = provider
|
|
save_config(cfg)
|
|
return {"ok": True, "active": provider}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.post("/api/memory/reset")
|
|
async def reset_memory(body: MemoryReset):
|
|
target = (body.target or "all").strip().lower()
|
|
if target not in {"all", "memory", "user"}:
|
|
raise HTTPException(status_code=400, detail="target must be all, memory, or user")
|
|
|
|
mem_dir = get_hermes_home() / "memories"
|
|
deleted = []
|
|
targets = []
|
|
if target in {"all", "memory"}:
|
|
targets.append("MEMORY.md")
|
|
if target in {"all", "user"}:
|
|
targets.append("USER.md")
|
|
for fname in targets:
|
|
path = mem_dir / fname
|
|
if path.exists():
|
|
try:
|
|
path.unlink()
|
|
deleted.append(fname)
|
|
except OSError as exc:
|
|
raise HTTPException(status_code=500, detail=f"Could not delete {fname}: {exc}")
|
|
return {"ok": True, "deleted": deleted}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Operations endpoints — doctor / security audit / backup / import /
|
|
# checkpoints / hooks.
|
|
#
|
|
# Diagnostic and maintenance commands. The long-running / text-output ones
|
|
# (doctor, security audit, backup, import, skills install) are spawned as
|
|
# background actions whose logs the dashboard tails via
|
|
# /api/actions/{name}/status — same pattern as gateway restart and update.
|
|
# The cheap, structured reads (hooks list, checkpoints list) return JSON
|
|
# directly.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.post("/api/ops/doctor")
|
|
async def run_doctor():
|
|
try:
|
|
proc = _spawn_hermes_action(["doctor"], "doctor")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn doctor")
|
|
raise HTTPException(status_code=500, detail=f"Failed to run doctor: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": "doctor"}
|
|
|
|
|
|
@router.post("/api/ops/security-audit")
|
|
async def run_security_audit():
|
|
try:
|
|
proc = _spawn_hermes_action(["security", "audit"], "security-audit")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn security audit")
|
|
raise HTTPException(status_code=500, detail=f"Failed to run security audit: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": "security-audit"}
|
|
|
|
|
|
def _dashboard_backup_dir() -> Path:
|
|
return get_hermes_home() / "backups"
|
|
|
|
|
|
def _new_dashboard_backup_path() -> Path:
|
|
stamp = datetime.now().strftime("%Y-%m-%d-%H%M%S")
|
|
return _dashboard_backup_dir() / f"hermes-backup-{stamp}-{secrets.token_hex(4)}.zip"
|
|
|
|
|
|
@router.post("/api/ops/backup")
|
|
async def run_backup(body: BackupRequest):
|
|
args = ["backup"]
|
|
archive: Optional[Path] = None
|
|
output = (body.output or "").strip()
|
|
if output:
|
|
args.extend(["-o", output])
|
|
else:
|
|
archive = _new_dashboard_backup_path()
|
|
try:
|
|
archive.parent.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Could not create backup directory: {exc}",
|
|
)
|
|
args.extend(["-o", str(archive)])
|
|
try:
|
|
proc = _spawn_hermes_action(args, "backup")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn backup")
|
|
raise HTTPException(status_code=500, detail=f"Failed to run backup: {exc}")
|
|
response = {"ok": True, "pid": proc.pid, "name": "backup"}
|
|
if archive is not None:
|
|
response["archive"] = str(archive)
|
|
return response
|
|
|
|
|
|
@router.get("/api/ops/backup/download")
|
|
async def download_dashboard_backup(archive: str):
|
|
try:
|
|
backup_dir = _dashboard_backup_dir().expanduser().resolve(strict=False)
|
|
target = Path(archive).expanduser().resolve(strict=True)
|
|
except FileNotFoundError:
|
|
raise HTTPException(status_code=404, detail="Backup not found")
|
|
except (OSError, RuntimeError):
|
|
raise HTTPException(status_code=400, detail="Invalid backup path")
|
|
|
|
if not _path_is_under(backup_dir, target):
|
|
raise HTTPException(status_code=403, detail="Backup is outside the dashboard backup directory")
|
|
if not target.is_file():
|
|
raise HTTPException(status_code=404, detail="Backup not found")
|
|
|
|
return FileResponse(
|
|
path=str(target),
|
|
media_type="application/zip",
|
|
filename=target.name,
|
|
content_disposition_type="attachment",
|
|
)
|
|
|
|
|
|
@router.post("/api/ops/import")
|
|
async def run_import(body: ImportRequest):
|
|
archive = (body.archive or "").strip()
|
|
if not archive:
|
|
raise HTTPException(status_code=400, detail="archive path is required")
|
|
if not os.path.isfile(archive):
|
|
raise HTTPException(status_code=404, detail=f"Archive not found: {archive}")
|
|
args = ["import", archive]
|
|
if body.force:
|
|
args.append("--force")
|
|
try:
|
|
proc = _spawn_hermes_action(args, "import")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn import")
|
|
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": "import"}
|
|
|
|
|
|
def _safe_backup_upload_name(filename: str | None) -> str:
|
|
name = Path(filename or "backup.zip").name.strip()
|
|
name = re.sub(r"[^A-Za-z0-9._-]+", "-", name).strip(".-")
|
|
if not name:
|
|
name = "backup.zip"
|
|
if not name.lower().endswith(".zip"):
|
|
name = f"{name}.zip"
|
|
return name
|
|
|
|
|
|
@router.post("/api/ops/import-upload")
|
|
async def run_import_upload(
|
|
file: UploadFile = File(...),
|
|
force: bool = Form(False),
|
|
):
|
|
from hermes_cli.web_server import _MANAGED_FILE_MAX_BYTES, _UPLOAD_CHUNK_BYTES
|
|
staging_dir = _dashboard_backup_dir()
|
|
try:
|
|
staging_dir.mkdir(parents=True, exist_ok=True)
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Could not create import staging directory: {exc}",
|
|
)
|
|
|
|
safe_name = _safe_backup_upload_name(file.filename)
|
|
stamp = datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
|
|
target = staging_dir / f"dashboard-import-{stamp}-{secrets.token_hex(4)}-{safe_name}"
|
|
tmp_fd, tmp_name = tempfile.mkstemp(
|
|
prefix=f".{target.name}.",
|
|
suffix=".upload",
|
|
dir=str(staging_dir),
|
|
)
|
|
tmp_path = Path(tmp_name)
|
|
total = 0
|
|
renamed = False
|
|
try:
|
|
with os.fdopen(tmp_fd, "wb") as out:
|
|
while True:
|
|
chunk = await file.read(_UPLOAD_CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
total += len(chunk)
|
|
if total > _MANAGED_FILE_MAX_BYTES:
|
|
raise HTTPException(status_code=413, detail="Archive is too large")
|
|
out.write(chunk)
|
|
os.replace(tmp_path, target)
|
|
renamed = True
|
|
except HTTPException:
|
|
raise
|
|
except PermissionError:
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Import staging directory is not writable",
|
|
)
|
|
except OSError as exc:
|
|
raise HTTPException(
|
|
status_code=500,
|
|
detail=f"Could not write uploaded archive: {exc}",
|
|
)
|
|
finally:
|
|
if not renamed:
|
|
tmp_path.unlink(missing_ok=True)
|
|
await file.close()
|
|
|
|
if not zipfile.is_zipfile(target):
|
|
target.unlink(missing_ok=True)
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Uploaded archive is not a valid zip file",
|
|
)
|
|
|
|
args = ["import", str(target)]
|
|
if force:
|
|
args.append("--force")
|
|
try:
|
|
proc = _spawn_hermes_action(args, "import")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn import")
|
|
raise HTTPException(status_code=500, detail=f"Failed to run import: {exc}")
|
|
return {
|
|
"ok": True,
|
|
"pid": proc.pid,
|
|
"name": "import",
|
|
"archive": str(target),
|
|
"uploaded_bytes": total,
|
|
}
|
|
|
|
|
|
@router.get("/api/ops/hooks")
|
|
async def list_hooks():
|
|
"""List configured shell hooks from config.yaml with consent + health.
|
|
|
|
Reports each hook's allowlist (consent) status and whether the script is
|
|
currently executable, plus the set of valid hook events so the create
|
|
form can offer them.
|
|
"""
|
|
def _run():
|
|
from hermes_cli.config import load_config as _load_config
|
|
from agent import shell_hooks
|
|
|
|
try:
|
|
from hermes_cli.plugins import VALID_HOOKS
|
|
valid_events = sorted(VALID_HOOKS)
|
|
except Exception:
|
|
valid_events = []
|
|
|
|
specs = []
|
|
try:
|
|
specs = shell_hooks.iter_configured_hooks(_load_config())
|
|
except Exception:
|
|
_log.exception("iter_configured_hooks failed")
|
|
|
|
out = []
|
|
for spec in specs:
|
|
entry = None
|
|
try:
|
|
entry = shell_hooks.allowlist_entry_for(spec.event, spec.command)
|
|
except Exception:
|
|
pass
|
|
executable = False
|
|
try:
|
|
executable = shell_hooks.script_is_executable(spec.command)
|
|
except Exception:
|
|
pass
|
|
out.append({
|
|
"event": spec.event,
|
|
"matcher": spec.matcher,
|
|
"command": spec.command,
|
|
"timeout": spec.timeout,
|
|
"allowed": entry is not None,
|
|
"approved_at": (entry or {}).get("approved_at"),
|
|
"executable": executable,
|
|
})
|
|
|
|
return {"hooks": out, "valid_events": valid_events}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.post("/api/ops/hooks")
|
|
async def create_hook(body: HookCreate):
|
|
"""Add a shell hook to config.yaml (and optionally approve it).
|
|
|
|
Shell hooks run arbitrary commands, so this is a privileged action: it
|
|
writes to the ``hooks:`` config block and, when ``approve`` is set, records
|
|
consent in the allowlist so the hook actually fires. Takes effect on the
|
|
next session / gateway restart.
|
|
"""
|
|
from hermes_cli.web_server import _CONFIG_MUTATION_LOCK
|
|
from agent import shell_hooks
|
|
|
|
event = (body.event or "").strip()
|
|
command = (body.command or "").strip()
|
|
if not event or not command:
|
|
raise HTTPException(status_code=400, detail="event and command are required")
|
|
|
|
try:
|
|
from hermes_cli.plugins import VALID_HOOKS
|
|
if event not in VALID_HOOKS:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unknown event '{event}'. Valid: {', '.join(sorted(VALID_HOOKS))}",
|
|
)
|
|
except HTTPException:
|
|
raise
|
|
except Exception:
|
|
pass
|
|
|
|
def _run():
|
|
with _CONFIG_MUTATION_LOCK:
|
|
cfg = load_config()
|
|
hooks_cfg = cfg.get("hooks")
|
|
if not isinstance(hooks_cfg, dict):
|
|
hooks_cfg = {}
|
|
cfg["hooks"] = hooks_cfg
|
|
entries = hooks_cfg.get(event)
|
|
if not isinstance(entries, list):
|
|
entries = []
|
|
hooks_cfg[event] = entries
|
|
|
|
new_entry: Dict[str, Any] = {"command": command}
|
|
if body.matcher:
|
|
new_entry["matcher"] = body.matcher
|
|
if body.timeout is not None:
|
|
new_entry["timeout"] = int(body.timeout)
|
|
entries.append(new_entry)
|
|
save_config(cfg)
|
|
|
|
approved = False
|
|
if body.approve:
|
|
try:
|
|
shell_hooks._record_approval(event, command)
|
|
approved = True
|
|
except Exception:
|
|
_log.exception("hook consent record failed")
|
|
|
|
return {"ok": True, "event": event, "command": command, "approved": approved}
|
|
|
|
return await asyncio.to_thread(_run)
|
|
|
|
|
|
@router.delete("/api/ops/hooks")
|
|
async def delete_hook(body: HookDelete):
|
|
"""Remove a hook from config.yaml and revoke its consent allowlist entry."""
|
|
from hermes_cli.web_server import _CONFIG_MUTATION_LOCK
|
|
from agent import shell_hooks
|
|
|
|
event = (body.event or "").strip()
|
|
command = (body.command or "").strip()
|
|
if not event or not command:
|
|
raise HTTPException(status_code=400, detail="event and command are required")
|
|
|
|
def _run():
|
|
removed = False
|
|
with _CONFIG_MUTATION_LOCK:
|
|
cfg = load_config()
|
|
hooks_cfg = cfg.get("hooks")
|
|
if isinstance(hooks_cfg, dict) and isinstance(hooks_cfg.get(event), list):
|
|
before = len(hooks_cfg[event])
|
|
hooks_cfg[event] = [
|
|
e for e in hooks_cfg[event]
|
|
if not (isinstance(e, dict) and e.get("command") == command)
|
|
]
|
|
removed = len(hooks_cfg[event]) < before
|
|
if not hooks_cfg[event]:
|
|
del hooks_cfg[event]
|
|
if not hooks_cfg:
|
|
cfg.pop("hooks", None)
|
|
save_config(cfg)
|
|
|
|
# Revoke consent regardless so a re-add re-prompts.
|
|
try:
|
|
shell_hooks.revoke(command)
|
|
except Exception:
|
|
pass
|
|
return removed
|
|
|
|
removed = await asyncio.to_thread(_run)
|
|
|
|
if not removed:
|
|
raise HTTPException(status_code=404, detail="No matching hook found")
|
|
return {"ok": True}
|
|
|
|
|
|
@router.get("/api/ops/checkpoints")
|
|
async def list_checkpoints():
|
|
"""List the /rollback shadow store checkpoints (read-only)."""
|
|
# Checkpoints live under <hermes_home>/checkpoints/. Surface a count +
|
|
# total size so the dashboard can show what a prune would reclaim; the
|
|
# actual prune is a spawned action so confirmation/pruning logic stays
|
|
# in one place (the CLI).
|
|
cp_dir = get_hermes_home() / "checkpoints"
|
|
sessions = []
|
|
total_bytes = 0
|
|
if cp_dir.is_dir():
|
|
with os.scandir(cp_dir) as scan:
|
|
children = sorted((Path(e.path) for e in scan), key=lambda p: p.name)
|
|
for child in children:
|
|
if not child.is_dir():
|
|
continue
|
|
size = 0
|
|
count = 0
|
|
for f in child.rglob("*"):
|
|
if f.is_file():
|
|
try:
|
|
size += f.stat().st_size
|
|
count += 1
|
|
except OSError:
|
|
pass
|
|
total_bytes += size
|
|
sessions.append({
|
|
"session": child.name,
|
|
"files": count,
|
|
"bytes": size,
|
|
})
|
|
return {"sessions": sessions, "total_bytes": total_bytes}
|
|
|
|
|
|
@router.post("/api/ops/checkpoints/prune")
|
|
async def prune_checkpoints():
|
|
try:
|
|
proc = _spawn_hermes_action(["checkpoints", "prune"], "checkpoints-prune")
|
|
except Exception as exc:
|
|
_log.exception("Failed to spawn checkpoints prune")
|
|
raise HTTPException(status_code=500, detail=f"Failed to prune checkpoints: {exc}")
|
|
return {"ok": True, "pid": proc.pid, "name": "checkpoints-prune"}
|