""" Hermes Agent — Web UI server. Provides a FastAPI backend serving the Vite/React frontend and REST API endpoints for managing configuration, environment variables, and sessions. Usage: python -m hermes_cli.main web # Start on http://127.0.0.1:9119 python -m hermes_cli.main web --port 8080 """ from contextlib import asynccontextmanager import asyncio import base64 import binascii import hmac import importlib.util import json import logging import os import secrets import stat import subprocess import sys import tempfile import threading import time import urllib.parse import urllib.request from pathlib import Path from typing import Any, Dict, List, Optional, Tuple import yaml PROJECT_ROOT = Path(__file__).parent.parent.resolve() if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from hermes_cli import __version__, __release_date__ from hermes_cli.config import ( cfg_get, DEFAULT_CONFIG, OPTIONAL_ENV_VARS, get_config_path, get_env_path, get_hermes_home, load_config, load_env, save_config, save_env_value, remove_env_value, check_config_version, detect_install_method, format_docker_update_message, recommended_update_command_for_method, redact_key, ) from gateway.status import get_running_pid, read_runtime_status from utils import env_var_enabled try: from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: # First try lazy-installing the dashboard extras. Only the user actually # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps # them out of every other install path. After install, re-import. try: from tools.lazy_deps import ensure as _lazy_ensure _lazy_ensure("tool.dashboard", prompt=False) from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except Exception: raise SystemExit( "Web UI requires fastapi and uvicorn.\n" f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. # # State lives on app.state (not module-level globals) so that asyncio.Lock is # created on the running event loop during lifespan startup. A module-level # asyncio.Lock() binds to whatever loop was active at import time, which breaks # when the same module is used across TestClient instances or uvicorn reloads. # --------------------------------------------------------------------------- @asynccontextmanager async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() yield def _get_event_state(app: "FastAPI"): """Return (event_channels, event_lock) from app.state. Lazily initialises the state if the lifespan hasn't run (e.g. when TestClient is constructed without a ``with`` block). The lifespan path is preferred because it guarantees the Lock is created on the correct event loop, but the lazy path lets existing non-``with`` TestClient usages keep working. """ try: return app.state.event_channels, app.state.event_lock except AttributeError: app.state.event_channels = {} app.state.event_lock = asyncio.Lock() return app.state.event_channels, app.state.event_lock app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) # --------------------------------------------------------------------------- # Session token for protecting sensitive endpoints (reveal). # The desktop shell mints the token and injects it via # HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the # /api calls it makes on the user's behalf; otherwise we generate one fresh # on every server start. Either way it dies when the process exits and is # injected into the SPA HTML so only the legitimate web UI can use it. # --------------------------------------------------------------------------- _SESSION_TOKEN = os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32) _SESSION_HEADER_NAME = "X-Hermes-Session-Token" # In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui`` # or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`. _DASHBOARD_EMBEDDED_CHAT_ENABLED = False # Simple rate limiter for the reveal endpoint _reveal_timestamps: List[float] = [] _REVEAL_MAX_PER_WINDOW = 5 _REVEAL_WINDOW_SECONDS = 30 # CORS: restrict to localhost origins only. The web UI is intended to run # locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website # read/modify config and secrets. app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # Endpoints that do NOT require the session token. Everything else under # /api/ is gated by the auth middleware below. # # This list is defined in ``hermes_cli.dashboard_auth.public_paths`` so the # OAuth gate middleware can honour the same allowlist — keeping the two # gates in lockstep avoids drift like the wildcard-subdomain regression # where ``/api/status`` was public under the legacy gate but 401'd under # the OAuth gate (breaking the portal's liveness probe). # # Keep the upstream list minimal — only truly non-sensitive, read-only # endpoints belong there. # --------------------------------------------------------------------------- from hermes_cli.dashboard_auth.public_paths import ( PUBLIC_API_PATHS as _PUBLIC_API_PATHS, ) def _has_valid_session_token(request: Request) -> bool: """True if the request carries a valid dashboard session token. The dedicated session header avoids collisions with reverse proxies that already use ``Authorization`` (for example Caddy ``basic_auth``). We still accept the legacy Bearer path for backward compatibility with older dashboard bundles. """ session_header = request.headers.get(_SESSION_HEADER_NAME, "") if session_header and hmac.compare_digest( session_header.encode(), _SESSION_TOKEN.encode(), ): return True auth = request.headers.get("authorization", "") expected = f"Bearer {_SESSION_TOKEN}" return hmac.compare_digest(auth.encode(), expected.encode()) def _require_token(request: Request) -> None: """Validate the ephemeral session token. Raises 401 on mismatch.""" if not _has_valid_session_token(request): raise HTTPException(status_code=401, detail="Unauthorized") # Accepted Host header values for loopback binds. DNS rebinding attacks # point a victim browser at an attacker-controlled hostname (evil.test) # which resolves to 127.0.0.1 after a TTL flip — bypassing same-origin # checks because the browser now considers evil.test and our dashboard # "same origin". Validating the Host header at the app layer rejects any # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. _LOOPBACK_HOST_VALUES: frozenset = frozenset({ "localhost", "127.0.0.1", "::1", }) def should_require_auth(host: str, allow_public: bool) -> bool: """Return True iff the dashboard OAuth auth gate must be active. Truth table: host == loopback → False (no auth) host != loopback AND allow_public (--insecure)→ False (legacy escape hatch) host != loopback AND NOT allow_public → True (gate engages) "Loopback" matches the same set used by ``--insecure`` enforcement in ``start_server``: 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local are deliberately treated as PUBLIC — a hostile device on the same LAN is exactly the threat model the gate is designed for. """ return (host not in _LOOPBACK_HOST_VALUES) and (not allow_public) def _is_accepted_host(host_header: str, bound_host: str) -> bool: """True if the Host header targets the interface we bound to. Accepts: - Exact bound host (with or without port suffix) - Loopback aliases when bound to loopback - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, no protection possible at this layer) """ if not host_header: return False # Strip port suffix. IPv6 addresses use bracket notation: # [::1] — no port # [::1]:9119 — with port # Plain hosts/v4: # localhost:9119 # 127.0.0.1:9119 h = host_header.strip() if h.startswith("["): # IPv6 bracketed — port (if any) follows "]:" close = h.find("]") if close != -1: host_only = h[1:close] # strip brackets else: host_only = h.strip("[]") else: host_only = h.rsplit(":", 1)[0] if ":" in h else h host_only = host_only.lower() # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer # defence can protect that mode; rely on operator network controls. if bound_host in {"0.0.0.0", "::"}: return True # Loopback bind: accept the loopback names bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: return host_only in _LOOPBACK_HOST_VALUES # Explicit non-loopback bind: require exact host match return host_only == bound_lc @app.middleware("http") async def host_header_middleware(request: Request, call_next): """Reject requests whose Host header doesn't match the bound interface. Defends against DNS rebinding: a victim browser on a localhost dashboard is tricked into fetching from an attacker hostname that TTL-flips to 127.0.0.1. CORS and same-origin checks don't help — the browser now treats the attacker origin as same-origin with the dashboard. Host-header validation at the app layer catches it. See GHSA-ppp5-vxwm-4cf7. """ # Store the bound host on app.state so this middleware can read it — # set by start_server() at listen time. bound_host = getattr(app.state, "bound_host", None) if bound_host: host_header = request.headers.get("host", "") if not _is_accepted_host(host_header, bound_host): return JSONResponse( status_code=400, content={ "detail": ( "Invalid Host header. Dashboard requests must use " "the hostname the server was bound to." ), }, ) return await call_next(request) # --------------------------------------------------------------------------- # Dashboard OAuth auth gate — engaged only when start_server flags the # bind as non-loopback-without-insecure. No-op pass-through in loopback # mode so the legacy auth_middleware (below) handles those binds via # the injected ``_SESSION_TOKEN``. Registered between host_header and # auth_middleware so the order is: host check → cookie auth → token auth. # --------------------------------------------------------------------------- @app.middleware("http") async def _dashboard_auth_gate(request: Request, call_next): from hermes_cli.dashboard_auth.middleware import gated_auth_middleware return await gated_auth_middleware(request, call_next) @app.middleware("http") async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" # When the OAuth gate is active, cookie-based auth (gated_auth_middleware # above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only # and is skipped here so the gate's session attachment isn't overridden. if getattr(request.app.state, "auth_required", False): return await call_next(request) path = request.url.path if path.startswith("/api/") and path not in _PUBLIC_API_PATHS: if not _has_valid_session_token(request): return JSONResponse( status_code=401, content={"detail": "Unauthorized"}, ) return await call_next(request) # --------------------------------------------------------------------------- # Config schema — auto-generated from DEFAULT_CONFIG # --------------------------------------------------------------------------- # Manual overrides for fields that need select options or custom types _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "model": { "type": "string", "description": "Default model (e.g. anthropic/claude-sonnet-4.6)", "category": "general", }, "model_context_length": { "type": "number", "description": "Context window override (0 = auto-detect from model metadata)", "category": "general", }, "terminal.backend": { "type": "select", "description": "Terminal execution backend", "options": ["local", "docker", "ssh", "modal", "daytona", "singularity"], }, "terminal.modal_mode": { "type": "select", "description": "Modal sandbox mode", "options": ["sandbox", "function"], }, "tts.provider": { "type": "select", "description": "Text-to-speech provider", "options": ["edge", "elevenlabs", "openai", "neutts"], }, "stt.provider": { "type": "select", "description": "Speech-to-text provider", # "mistral" temporarily removed — mistralai PyPI package quarantined # (malicious 2.4.6 release on 2026-05-12). Restore once available. "options": ["local", "groq", "openai", "xai", "elevenlabs"], }, "stt.elevenlabs.model_id": { "type": "select", "description": "ElevenLabs Scribe model", "options": ["scribe_v2", "scribe_v1"], }, "display.skin": { "type": "select", "description": "CLI visual theme", "options": ["default", "ares", "mono", "slate"], }, "dashboard.theme": { "type": "select", "description": "Web dashboard visual theme", "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], }, "display.resume_display": { "type": "select", "description": "How resumed sessions display history", "options": ["minimal", "full", "off"], }, "display.busy_input_mode": { "type": "select", "description": "Input behavior while agent is running", "options": ["interrupt", "queue", "steer"], }, "memory.provider": { "type": "select", "description": "Memory provider plugin", "options": ["builtin", "honcho"], }, "approvals.mode": { "type": "select", "description": "Dangerous command approval mode", "options": ["ask", "yolo", "deny"], }, "context.engine": { "type": "select", "description": "Context management engine", "options": ["default", "custom"], }, "human_delay.mode": { "type": "select", "description": "Simulated typing delay mode", "options": ["off", "typing", "fixed"], }, "logging.level": { "type": "select", "description": "Log level for agent.log", "options": ["DEBUG", "INFO", "WARNING", "ERROR"], }, "agent.service_tier": { "type": "select", "description": "API service tier (OpenAI/Anthropic)", "options": ["", "auto", "default", "flex"], }, "delegation.reasoning_effort": { "type": "select", "description": "Reasoning effort for delegated subagents", "options": ["", "low", "medium", "high"], }, } # Categories with fewer fields get merged into "general" to avoid tab sprawl. _CATEGORY_MERGE: Dict[str, str] = { "privacy": "security", "context": "agent", "skills": "agent", "cron": "agent", "network": "agent", "checkpoints": "agent", "approvals": "security", "human_delay": "display", "dashboard": "display", "code_execution": "agent", "prompt_caching": "agent", "goals": "agent", # Only `telegram.reactions` currently lives under telegram — fold it in # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. "telegram": "discord", } # Display order for tabs — unlisted categories sort alphabetically after these. _CATEGORY_ORDER = [ "general", "agent", "terminal", "display", "delegation", "memory", "compression", "security", "browser", "voice", "tts", "stt", "logging", "discord", "auxiliary", ] def _infer_type(value: Any) -> str: """Infer a UI field type from a Python value.""" if isinstance(value, bool): return "boolean" if isinstance(value, int): return "number" if isinstance(value, float): return "number" if isinstance(value, list): return "list" if isinstance(value, dict): return "object" return "string" def _build_schema_from_config( config: Dict[str, Any], prefix: str = "", ) -> Dict[str, Dict[str, Any]]: """Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict.""" schema: Dict[str, Dict[str, Any]] = {} for key, value in config.items(): full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys if full_key in {"_config_version",}: continue # Category is the first path component for nested keys, or "general" # for top-level scalar fields (model, toolsets, timezone, etc.). if prefix: category = prefix.split(".")[0] elif isinstance(value, dict): category = key else: category = "general" if isinstance(value, dict): # Recurse into nested dicts schema.update(_build_schema_from_config(value, full_key)) else: entry: Dict[str, Any] = { "type": _infer_type(value), "description": full_key.replace(".", " → ").replace("_", " ").title(), "category": category, } # Apply manual overrides if full_key in _SCHEMA_OVERRIDES: entry.update(_SCHEMA_OVERRIDES[full_key]) # Merge small categories entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"]) schema[full_key] = entry return schema CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG) # Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced # by the normalize/denormalize cycle. Insert model_context_length right after # the "model" key so it renders adjacent in the frontend. _mcl_entry = _SCHEMA_OVERRIDES["model_context_length"] _ordered_schema: Dict[str, Dict[str, Any]] = {} for _k, _v in CONFIG_SCHEMA.items(): _ordered_schema[_k] = _v if _k == "model": _ordered_schema["model_context_length"] = _mcl_entry CONFIG_SCHEMA = _ordered_schema class ConfigUpdate(BaseModel): config: dict class EnvVarUpdate(BaseModel): key: str value: str class EnvVarDelete(BaseModel): key: str class EnvVarReveal(BaseModel): key: str class MessagingPlatformUpdate(BaseModel): enabled: Optional[bool] = None env: Dict[str, str] = {} clear_env: List[str] = [] class AudioTranscriptionRequest(BaseModel): data_url: str mime_type: Optional[str] = None class ModelAssignment(BaseModel): """Payload for POST /api/model/set — assign a provider/model to a slot. scope="main" → writes model.provider + model.default scope="auxiliary" → writes auxiliary..provider + auxiliary..model scope="auxiliary" with task="" → applied to every auxiliary.* slot scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" """ scope: str provider: str model: str task: str = "" _AUDIO_MIME_EXTENSIONS: Dict[str, str] = { "audio/aac": ".aac", "audio/flac": ".flac", "audio/m4a": ".m4a", "audio/mp3": ".mp3", "audio/mp4": ".mp4", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/wave": ".wav", "audio/webm": ".webm", "audio/x-m4a": ".m4a", "audio/x-wav": ".wav", "video/webm": ".webm", } _MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 def _audio_extension_for_mime(mime_type: str) -> str: normalized = (mime_type or "").split(";", 1)[0].strip().lower() return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm") class ModelAssignment(BaseModel): """Payload for POST /api/model/set — assign a provider/model to a slot. scope="main" → writes model.provider + model.default scope="auxiliary" → writes auxiliary..provider + auxiliary..model scope="auxiliary" with task="" → applied to every auxiliary.* slot scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" """ scope: str provider: str model: str task: str = "" _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL") try: _GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3")) except (ValueError, TypeError): _log.warning( "Invalid GATEWAY_HEALTH_TIMEOUT value %r — using default 3.0s", os.getenv("GATEWAY_HEALTH_TIMEOUT"), ) _GATEWAY_HEALTH_TIMEOUT = 3.0 # DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT. # Cross-container / cross-host gateway liveness detection will be folded into a # first-class dashboard config key so it's no longer Docker-adjacent lore buried # in env vars. The env vars still work for now so existing Compose deployments # don't break. Do not add new callers — wire new uses through the planned # config surface. def _probe_gateway_health() -> tuple[bool, dict | None]: """Probe the gateway via its HTTP health endpoint (cross-container). .. deprecated:: Driven by the deprecated ``GATEWAY_HEALTH_URL`` / ``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside a move to a first-class dashboard config key. See :data:`_GATEWAY_HEALTH_URL` for context. Uses ``/health/detailed`` first (returns full state), falling back to the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``. Accepts any of these as ``GATEWAY_HEALTH_URL``: - ``http://gateway:8642`` (base URL — recommended) - ``http://gateway:8642/health`` (explicit health path) - ``http://gateway:8642/health/detailed`` (explicit detailed path) This is a **blocking** call — run via ``run_in_executor`` from async code. """ if not _GATEWAY_HEALTH_URL: return False, None # Normalise to base URL so we always probe the right paths regardless of # whether the user included /health or /health/detailed in the env var. base = _GATEWAY_HEALTH_URL.rstrip("/") if base.endswith("/health/detailed"): base = base[: -len("/health/detailed")] elif base.endswith("/health"): base = base[: -len("/health")] for path in (f"{base}/health/detailed", f"{base}/health"): try: req = urllib.request.Request(path, method="GET") with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp: if resp.status == 200: body = json.loads(resp.read()) return True, body except Exception: continue return False, None @app.get("/api/status") async def get_status(): current_ver, latest_ver = check_config_version() # --- Gateway liveness detection --- # Try local PID check first (same-host). If that fails and a remote # GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the # dashboard works when the gateway runs in a separate container. gateway_pid = get_running_pid() gateway_running = gateway_pid is not None remote_health_body: dict | None = None if not gateway_running and _GATEWAY_HEALTH_URL: loop = asyncio.get_running_loop() alive, remote_health_body = await loop.run_in_executor( None, _probe_gateway_health ) if alive: gateway_running = True # PID from the remote container (display only — not locally valid) if remote_health_body: gateway_pid = remote_health_body.get("pid") gateway_state = None gateway_platforms: dict = {} gateway_exit_reason = None gateway_updated_at = None configured_gateway_platforms: set[str] | None = None try: from gateway.config import load_gateway_config gateway_config = load_gateway_config() configured_gateway_platforms = { platform.value for platform in gateway_config.get_connected_platforms() } except Exception: configured_gateway_platforms = None # Prefer the detailed health endpoint response (has full state) when the # local runtime status file is absent or stale (cross-container). runtime = read_runtime_status() if runtime is None and remote_health_body and remote_health_body.get("gateway_state"): runtime = remote_health_body if runtime: gateway_state = runtime.get("gateway_state") gateway_platforms = runtime.get("platforms") or {} if configured_gateway_platforms is not None: gateway_platforms = { key: value for key, value in gateway_platforms.items() if key in configured_gateway_platforms } gateway_exit_reason = runtime.get("exit_reason") gateway_updated_at = runtime.get("updated_at") if not gateway_running: gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped" gateway_platforms = {} elif gateway_running and remote_health_body is not None: # The health probe confirmed the gateway is alive, but the local # runtime status file may be stale (cross-container). Override # stopped/None state so the dashboard shows the correct badge. if gateway_state in {None, "stopped"}: gateway_state = "running" # If there was no runtime info at all but the health probe confirmed alive, # ensure we still report the gateway as running (no shared volume scenario). if gateway_running and gateway_state is None and remote_health_body is not None: gateway_state = "running" active_sessions = 0 try: from hermes_state import SessionDB db = SessionDB() try: sessions = db.list_sessions_rich(limit=50) now = time.time() active_sessions = sum( 1 for s in sessions if s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) finally: db.close() except Exception: pass # Dashboard auth gate (Phase 7): surface whether the gate is engaged # and which providers are registered so ``hermes status`` and the # SPA's StatusPage can show "OAuth gate ON via Nous Research" or # "loopback only — no auth gate" with no extra round trips. auth_required = bool(getattr(app.state, "auth_required", False)) auth_providers: list[str] = [] try: from hermes_cli.dashboard_auth import list_providers as _list_providers auth_providers = [p.name for p in _list_providers()] except Exception: # Module not importable yet (early startup) — leave as []. pass return { "version": __version__, "release_date": __release_date__, "hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()), "env_path": str(get_env_path()), "config_version": current_ver, "latest_config_version": latest_ver, "gateway_running": gateway_running, "gateway_pid": gateway_pid, "gateway_health_url": _GATEWAY_HEALTH_URL, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, "active_sessions": active_sessions, "auth_required": auth_required, "auth_providers": auth_providers, } @app.get("/api/system/stats") async def get_system_stats(): """Host + process system stats for the System page. OS / Python / host identity from stdlib; CPU / memory / disk / uptime from psutil when available, with graceful degradation when it isn't. Read-only and non-sensitive (no env values, no paths beyond the hermes home root). """ import platform as _platform info: Dict[str, Any] = { "os": _platform.system(), "os_release": _platform.release(), "os_version": _platform.version(), "platform": _platform.platform(), "arch": _platform.machine(), "hostname": _platform.node(), "python_version": _platform.python_version(), "python_impl": _platform.python_implementation(), "hermes_version": __version__, "cpu_count": os.cpu_count(), } # psutil enriches the picture when present; everything below is optional. try: import psutil # type: ignore vm = psutil.virtual_memory() info["memory"] = { "total": vm.total, "available": vm.available, "used": vm.used, "percent": vm.percent, } try: du = psutil.disk_usage(str(get_hermes_home())) info["disk"] = { "total": du.total, "used": du.used, "free": du.free, "percent": du.percent, } except Exception: pass try: info["cpu_percent"] = psutil.cpu_percent(interval=0.1) la = getattr(psutil, "getloadavg", None) if la: info["load_avg"] = list(la()) except Exception: pass try: boot = psutil.boot_time() info["uptime_seconds"] = int(time.time() - boot) except Exception: pass try: proc = psutil.Process() info["process"] = { "pid": proc.pid, "rss": proc.memory_info().rss, "create_time": int(proc.create_time()), "num_threads": proc.num_threads(), } except Exception: pass info["psutil"] = True except Exception: info["psutil"] = False # stdlib-only fallbacks for load average + uptime where the kernel # exposes them. try: info["load_avg"] = list(os.getloadavg()) except (OSError, AttributeError): pass return info # --------------------------------------------------------------------------- # Curator endpoints — background skill-maintenance status + controls. # # The curator periodically reviews skills (archive stale, prune, pin). The # dashboard surfaces its state and the pause/resume/run-now controls that # `hermes curator` exposes. # --------------------------------------------------------------------------- @app.get("/api/curator") async def get_curator_status(): try: from agent import curator except Exception as exc: raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}") try: state = curator.load_state() except Exception: state = {} return { "enabled": _safe_call(curator, "is_enabled", True), "paused": _safe_call(curator, "is_paused", False), "interval_hours": _safe_call(curator, "get_interval_hours", None), "last_run_at": state.get("last_run_at"), "min_idle_hours": _safe_call(curator, "get_min_idle_hours", None), "stale_after_days": _safe_call(curator, "get_stale_after_days", None), "archive_after_days": _safe_call(curator, "get_archive_after_days", None), } class CuratorPause(BaseModel): paused: bool @app.put("/api/curator/paused") async def set_curator_paused(body: CuratorPause): from agent import curator curator.set_paused(bool(body.paused)) return {"ok": True, "paused": bool(body.paused)} @app.post("/api/curator/run") async def run_curator(): """Trigger a curator review now (backgrounded; tail via action status).""" try: proc = _spawn_hermes_action(["curator", "run"], "curator-run") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}") return {"ok": True, "pid": proc.pid, "name": "curator-run"} def _safe_call(mod, fn_name: str, default): try: fn = getattr(mod, fn_name, None) return fn() if callable(fn) else default except Exception: return default # --------------------------------------------------------------------------- # Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only). # --------------------------------------------------------------------------- @app.get("/api/portal") async def get_portal_status(): cfg = load_config() or {} auth: Dict[str, Any] = {} try: from hermes_cli.auth import get_nous_auth_status auth = get_nous_auth_status() or {} except Exception: auth = {} features = [] try: from hermes_cli.nous_subscription import get_nous_subscription_features feats = get_nous_subscription_features(cfg) if feats is not None: for feat in feats.items(): if getattr(feat, "managed_by_nous", False): state = "via Nous Portal" elif getattr(feat, "active", False) and getattr(feat, "current_provider", None): state = feat.current_provider elif getattr(feat, "active", False): state = "active" else: state = "not configured" features.append({"label": getattr(feat, "label", ""), "state": state}) except Exception: _log.exception("portal features failed") model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} return { "logged_in": bool(auth.get("logged_in")), "portal_url": auth.get("portal_base_url"), "inference_url": auth.get("inference_base_url"), "provider": str((model_cfg or {}).get("provider") or ""), "subscription_url": "https://portal.nousresearch.com/manage-subscription", "features": features, } # --------------------------------------------------------------------------- # Diagnostics: prompt-size, support dump, debug upload, config migrate. # All produce text output, so they spawn background actions tailed via # /api/actions//status. # --------------------------------------------------------------------------- @app.post("/api/ops/prompt-size") async def run_prompt_size(): try: proc = _spawn_hermes_action(["prompt-size"], "prompt-size") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "prompt-size"} @app.post("/api/ops/dump") async def run_dump(): try: proc = _spawn_hermes_action(["dump"], "dump") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "dump"} @app.post("/api/ops/config-migrate") async def run_config_migrate(): try: proc = _spawn_hermes_action(["config", "migrate"], "config-migrate") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "config-migrate"} # --------------------------------------------------------------------------- # Gateway + update actions (invoked from the Status page). # # Both commands are spawned as detached subprocesses so the HTTP request # returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()`` # calls fail fast with EOF rather than hanging forever. stdout/stderr are # streamed to a per-action log file under ``~/.hermes/logs/.log`` so # the dashboard can tail them back to the user. # --------------------------------------------------------------------------- _ACTION_LOG_DIR: Path = get_hermes_home() / "logs" # Short ``name`` (from the URL) → absolute log file path. _ACTION_LOG_FILES: Dict[str, str] = { "gateway-restart": "gateway-restart.log", "gateway-start": "gateway-start.log", "gateway-stop": "gateway-stop.log", "hermes-update": "hermes-update.log", "doctor": "action-doctor.log", "security-audit": "action-security-audit.log", "backup": "action-backup.log", "import": "action-import.log", "checkpoints-prune": "action-checkpoints-prune.log", "skills-install": "action-skills-install.log", "skills-uninstall": "action-skills-uninstall.log", "skills-update": "action-skills-update.log", "curator-run": "action-curator-run.log", "prompt-size": "action-prompt-size.log", "dump": "action-dump.log", "config-migrate": "action-config-migrate.log", } # ``name`` → most recently spawned Popen handle. Used so ``status`` can # report liveness and exit code without shelling out to ``ps``. _ACTION_PROCS: Dict[str, subprocess.Popen] = {} # ``name`` → completed synthetic action result for actions the server handled # without spawning a subprocess (for example, unsupported Docker updates). _ACTION_RESULTS: Dict[str, Dict[str, Any]] = {} def _record_completed_action(name: str, message: str, exit_code: int = 1) -> None: """Record a non-spawned action result and write it to the action log.""" log_file_name = _ACTION_LOG_FILES[name] _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = _ACTION_LOG_DIR / log_file_name with open(log_path, "ab", buffering=0) as log_file: log_file.write( f"\n=== {name} completed {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) log_file.write(message.encode("utf-8", errors="replace")) if not message.endswith("\n"): log_file.write(b"\n") _ACTION_PROCS.pop(name, None) _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None} def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: """Spawn ``hermes `` detached and record the Popen handle. Uses the running interpreter's ``hermes_cli.main`` module so the action inherits the same venv/PYTHONPATH the web server is using. """ log_file_name = _ACTION_LOG_FILES[name] _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = _ACTION_LOG_DIR / log_file_name log_file = open(log_path, "ab", buffering=0) log_file.write( f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] popen_kwargs: Dict[str, Any] = { "cwd": str(PROJECT_ROOT), "stdin": subprocess.DEVNULL, "stdout": log_file, "stderr": subprocess.STDOUT, "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, } if sys.platform == "win32": popen_kwargs["creationflags"] = ( subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] | getattr(subprocess, "DETACHED_PROCESS", 0) ) else: popen_kwargs["start_new_session"] = True proc = subprocess.Popen(cmd, **popen_kwargs) # The child inherits its own duplicated fd for stdout/stderr, so the # parent's handle can be released immediately — otherwise we leak one # fd per spawned action. log_file.close() _ACTION_RESULTS.pop(name, None) _ACTION_PROCS[name] = proc return proc def _tail_lines(path: Path, n: int) -> List[str]: """Return the last ``n`` lines of ``path``. Reads the whole file — fine for our small per-action logs. Binary-decoded with ``errors='replace'`` so log corruption doesn't 500 the endpoint.""" if not path.exists(): return [] try: text = path.read_text(encoding="utf-8", errors="replace") except OSError: return [] lines = text.splitlines() return lines[-n:] if n > 0 else lines @app.post("/api/gateway/restart") async def restart_gateway(): """Kick off a ``hermes gateway restart`` in the background.""" try: proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart") except Exception as exc: _log.exception("Failed to spawn gateway restart") raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}") return { "ok": True, "pid": proc.pid, "name": "gateway-restart", } @app.post("/api/hermes/update") async def update_hermes(): """Kick off ``hermes update`` in the background.""" install_method = detect_install_method(PROJECT_ROOT) if install_method == "docker": message = format_docker_update_message() _record_completed_action("hermes-update", message, exit_code=1) return { "ok": False, "pid": None, "name": "hermes-update", "error": "docker_update_unsupported", "message": message, "update_command": recommended_update_command_for_method(install_method), } try: proc = _spawn_hermes_action(["update"], "hermes-update") except Exception as exc: _log.exception("Failed to spawn hermes update") raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}") return { "ok": True, "pid": proc.pid, "name": "hermes-update", } @app.get("/api/hermes/update/check") async def check_hermes_update(force: bool = False): """Report whether a Hermes update is available, without applying it. Powers the dashboard's "check before you update" flow: the System page shows the commit-behind count and asks the user to confirm before ``POST /api/hermes/update`` actually runs ``hermes update``. Returns: install_method: 'git' | 'pip' | 'docker' | 'nixos' | 'homebrew' | ... current_version: installed Hermes version string behind: commits behind upstream (>=1), 0 if up to date, -1 if behind by an unknown count (nix/pypi), or null if the check could not run (offline, no remote, etc.) update_available: convenience bool (behind is non-zero and not null) can_apply: True when the dashboard's update button can apply it in place (git/pip); False for docker/nix/homebrew where the user must update out-of-band update_command: the recommended command for this install method message: human-readable guidance for non-applyable methods """ install_method = detect_install_method(PROJECT_ROOT) update_command = recommended_update_command_for_method(install_method) payload: Dict[str, Any] = { "install_method": install_method, "current_version": __version__, "behind": None, "update_available": False, "can_apply": install_method in ("git", "pip"), "update_command": update_command, "message": None, } if install_method == "docker": payload["message"] = format_docker_update_message() return payload # banner.check_for_updates() handles git / pypi / nix-revision paths and # caches the result for 6h. ``force`` busts the cache so the "Check now" # button reflects reality immediately. try: from hermes_cli.banner import check_for_updates if force: try: (get_hermes_home() / ".update_check").unlink() except OSError: pass behind = await asyncio.to_thread(check_for_updates) except Exception: _log.exception("Update check failed") behind = None payload["behind"] = behind if behind is None: payload["message"] = "Couldn't reach the update source — try again later." elif behind == 0: payload["message"] = "You're on the latest version." else: payload["update_available"] = True return payload @app.post("/api/audio/transcribe") async def transcribe_audio_upload(payload: AudioTranscriptionRequest): data_url = (payload.data_url or "").strip() if not data_url.startswith("data:") or "," not in data_url: raise HTTPException(status_code=400, detail="Invalid audio payload") header, encoded = data_url.split(",", 1) if ";base64" not in header: raise HTTPException( status_code=400, detail="Audio payload must be base64 encoded" ) mime_type = ( payload.mime_type or header[5:].split(";", 1)[0] or "audio/webm" ).strip() normalized_mime_type = mime_type.split(";", 1)[0].lower() if not ( normalized_mime_type.startswith("audio/") or normalized_mime_type == "video/webm" ): raise HTTPException( status_code=400, detail="Payload must be an audio recording" ) try: audio_bytes = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError): raise HTTPException(status_code=400, detail="Audio payload is not valid base64") if not audio_bytes: raise HTTPException(status_code=400, detail="Audio recording is empty") if len(audio_bytes) > _MAX_TRANSCRIPTION_UPLOAD_BYTES: raise HTTPException(status_code=413, detail="Audio recording is too large") temp_path = "" try: suffix = _audio_extension_for_mime(mime_type) with tempfile.NamedTemporaryFile( prefix="hermes-desktop-voice-", suffix=suffix, delete=False, ) as tmp: tmp.write(audio_bytes) temp_path = tmp.name from tools.transcription_tools import transcribe_audio loop = asyncio.get_running_loop() result = await loop.run_in_executor(None, transcribe_audio, temp_path) except HTTPException: raise except Exception as exc: _log.exception("Desktop voice transcription failed") raise HTTPException(status_code=500, detail=f"Transcription failed: {exc}") finally: if temp_path: try: os.unlink(temp_path) except OSError: pass if not result.get("success"): raise HTTPException( status_code=400, detail=result.get("error") or "Transcription failed", ) return { "ok": True, "transcript": str(result.get("transcript") or "").strip(), "provider": result.get("provider"), } class TTSSpeakRequest(BaseModel): text: str def _elevenlabs_voice_label(voice: Dict[str, Any]) -> str: name = str(voice.get("name") or voice.get("voice_id") or "Voice").strip() category = str(voice.get("category") or "").strip() return f"{name} ({category})" if category else name @app.get("/api/audio/elevenlabs/voices") async def get_elevenlabs_voices(): """Return ElevenLabs voices when an API key is configured. The desktop UI uses this for the ``tts.elevenlabs.voice_id`` dropdown. Only non-secret voice metadata is returned; the API key stays server-side. """ api_key = (load_env().get("ELEVENLABS_API_KEY") or os.environ.get("ELEVENLABS_API_KEY") or "").strip() if not api_key: return {"available": False, "voices": []} request = urllib.request.Request( "https://api.elevenlabs.io/v1/voices", headers={ "Accept": "application/json", "xi-api-key": api_key, }, ) try: loop = asyncio.get_running_loop() def _fetch() -> Dict[str, Any]: with urllib.request.urlopen(request, timeout=10) as response: return json.loads(response.read().decode("utf-8")) payload = await loop.run_in_executor(None, _fetch) except Exception as exc: _log.warning("ElevenLabs voice list failed: %s", exc) raise HTTPException(status_code=502, detail="Could not load ElevenLabs voices") voices = [] for voice in payload.get("voices") or []: if not isinstance(voice, dict): continue voice_id = str(voice.get("voice_id") or "").strip() if not voice_id: continue voices.append({ "voice_id": voice_id, "name": str(voice.get("name") or voice_id), "label": _elevenlabs_voice_label(voice), }) voices.sort(key=lambda item: str(item.get("label") or "").lower()) return {"available": True, "voices": voices} @app.post("/api/audio/speak") async def speak_text(payload: TTSSpeakRequest): """Synthesize speech and return audio as base64 data URL. Used by the desktop voice-conversation mode to play back assistant responses without exposing the on-disk file path. Reuses the existing TTS provider chain (Edge / OpenAI / ElevenLabs / etc.) configured in ``~/.hermes/config.yaml`` under ``tts.``. """ text = (payload.text or "").strip() if not text: raise HTTPException(status_code=400, detail="Text is required") try: from tools.tts_tool import text_to_speech_tool loop = asyncio.get_running_loop() result_json = await loop.run_in_executor(None, text_to_speech_tool, text) except Exception as exc: _log.exception("Desktop voice TTS failed") raise HTTPException(status_code=500, detail=f"Speech synthesis failed: {exc}") try: result = json.loads(result_json) if isinstance(result_json, str) else result_json except Exception: raise HTTPException(status_code=500, detail="Invalid TTS response") if not result.get("success"): raise HTTPException( status_code=400, detail=result.get("error") or "Speech synthesis failed", ) file_path = result.get("file_path") if not file_path or not os.path.isfile(file_path): raise HTTPException(status_code=500, detail="Audio file missing") ext = os.path.splitext(file_path)[1].lower() mime_type = { ".mp3": "audio/mpeg", ".ogg": "audio/ogg", ".opus": "audio/ogg", ".wav": "audio/wav", ".flac": "audio/flac", }.get(ext, "audio/mpeg") try: with open(file_path, "rb") as fh: audio_bytes = fh.read() except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not read audio: {exc}") finally: try: os.unlink(file_path) except OSError: pass encoded = base64.b64encode(audio_bytes).decode("ascii") return { "ok": True, "data_url": f"data:{mime_type};base64,{encoded}", "mime_type": mime_type, "provider": result.get("provider"), } @app.get("/api/actions/{name}/status") async def get_action_status(name: str, lines: int = 200): """Tail an action log and report whether the process is still running.""" log_file_name = _ACTION_LOG_FILES.get(name) if log_file_name is None: raise HTTPException(status_code=404, detail=f"Unknown action: {name}") log_path = _ACTION_LOG_DIR / log_file_name tail = _tail_lines(log_path, min(max(lines, 1), 2000)) proc = _ACTION_PROCS.get(name) if proc is None: result = _ACTION_RESULTS.get(name) running = False exit_code = result.get("exit_code") if result else None pid = result.get("pid") if result else None else: exit_code = proc.poll() running = exit_code is None pid = proc.pid return { "name": name, "running": running, "exit_code": exit_code, "pid": pid, "lines": tail, } @app.get("/api/sessions") async def get_sessions( limit: int = 20, offset: int = 0, min_messages: int = 0, archived: str = "exclude", order: str = "created", ): """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. """ 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", ) try: from hermes_state import SessionDB db = SessionDB() try: min_message_count = max(0, min_messages) archived_only = archived == "only" include_archived = archived == "include" sessions = db.list_sessions_rich( limit=limit, offset=offset, min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only, order_by_last_active=order == "recent", ) total = db.session_count( min_message_count=min_message_count, include_archived=include_archived, archived_only=archived_only, ) now = time.time() 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 ) # SQLite stores the flag as 0/1; expose a real JSON boolean. s["archived"] = bool(s.get("archived")) return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} finally: db.close() except Exception: _log.exception("GET /api/sessions failed") raise HTTPException(status_code=500, detail="Internal server error") @app.get("/api/sessions/search") async def search_sessions(q: str = "", limit: int = 20): """Full-text search across session message content using FTS5. 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: from hermes_state import SessionDB db = SessionDB() try: # 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(limit * 5, 50) matches = db.search_messages(query=prefix_query, limit=fetch_limit) # 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 # Keep the best (first / most relevant) hit per compression root. seen: dict = {} for m in matches: raw_sid = m["session_id"] root = compression_root(raw_sid) if root in seen: continue seen[root] = { "session_id": lineage_tip(root), "lineage_root": root, "snippet": m.get("snippet", ""), "role": m.get("role"), "source": m.get("source"), "model": m.get("model"), "session_started": m.get("session_started"), } if len(seen) >= limit: break return {"results": list(seen.values())} finally: db.close() except Exception: _log.exception("GET /api/sessions/search failed") raise HTTPException(status_code=500, detail="Search failed") def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: """Normalize config for the web UI. Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``) or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the dict form. Normalize to the string form so the frontend schema matches. Also surfaces ``model_context_length`` as a top-level field so the web UI can display and edit it. A value of 0 means "auto-detect". """ config = dict(config) # shallow copy model_val = config.get("model") if isinstance(model_val, dict): # Extract context_length before flattening the dict ctx_len = model_val.get("context_length", 0) config["model"] = model_val.get("default", model_val.get("name", "")) config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0 else: config["model_context_length"] = 0 return config @app.get("/api/config") async def get_config(): config = _normalize_config_for_web(load_config()) # Strip internal keys that the frontend shouldn't see or send back return {k: v for k, v in config.items() if not k.startswith("_")} @app.get("/api/config/defaults") async def get_defaults(): return DEFAULT_CONFIG @app.get("/api/config/schema") async def get_schema(): return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} _EMPTY_MODEL_INFO: dict = { "model": "", "provider": "", "auto_context_length": 0, "config_context_length": 0, "effective_context_length": 0, "capabilities": {}, } @app.get("/api/model/info") def get_model_info(): """Return resolved model metadata for the currently configured model. Calls the same context-length resolution chain the agent uses, so the frontend can display "Auto-detected: 200K" alongside the override field. Also returns model capabilities (vision, reasoning, tools) when available. """ try: cfg = load_config() model_cfg = cfg.get("model", "") # Extract model name and provider from the config if isinstance(model_cfg, dict): model_name = model_cfg.get("default", model_cfg.get("name", "")) provider = model_cfg.get("provider", "") base_url = model_cfg.get("base_url", "") config_ctx = model_cfg.get("context_length") else: model_name = str(model_cfg) if model_cfg else "" provider = "" base_url = "" config_ctx = None if not model_name: return dict(_EMPTY_MODEL_INFO, provider=provider) # Resolve auto-detected context length (pass config_ctx=None to get # purely auto-detected value, then separately report the override) try: from agent.model_metadata import get_model_context_length auto_ctx = get_model_context_length( model=model_name, base_url=base_url, provider=provider, config_context_length=None, # ignore override — we want auto value ) except Exception: auto_ctx = 0 config_ctx_int = 0 if isinstance(config_ctx, int) and config_ctx > 0: config_ctx_int = config_ctx # Effective is what the agent actually uses effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx # Try to get model capabilities from models.dev caps = {} try: from agent.models_dev import get_model_capabilities mc = get_model_capabilities(provider=provider, model=model_name) if mc is not None: caps = { "supports_tools": mc.supports_tools, "supports_vision": mc.supports_vision, "supports_reasoning": mc.supports_reasoning, "context_window": mc.context_window, "max_output_tokens": mc.max_output_tokens, "model_family": mc.model_family, } except Exception: pass return { "model": model_name, "provider": provider, "auto_context_length": auto_ctx, "config_context_length": config_ctx_int, "effective_context_length": effective_ctx, "capabilities": caps, } except Exception: _log.exception("GET /api/model/info failed") return dict(_EMPTY_MODEL_INFO) # --------------------------------------------------------------------------- # Model assignment — pick provider+model for main slot or auxiliary slots. # Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the # Models page (which has no chat PTY open) can drive it. # --------------------------------------------------------------------------- # Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"] # in hermes_cli/config.py — listed here for deterministic ordering in the UI. _AUX_TASK_SLOTS: Tuple[str, ...] = ( "vision", "web_extract", "compression", "skills_hub", "approval", "mcp", "title_generation", "triage_specifier", "kanban_decomposer", "profile_describer", "curator", ) @app.get("/api/model/options") def get_model_options(): """Return authenticated providers + their curated model lists. REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the dashboard Models page can render the picker without a live chat session. The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog`` can share the same types. """ try: from hermes_cli.inventory import build_models_payload, load_picker_context return build_models_payload( load_picker_context(), max_models=50, pricing=True, capabilities=True ) except Exception: _log.exception("GET /api/model/options failed") raise HTTPException(status_code=500, detail="Failed to list model options") @app.get("/api/model/recommended-default") def get_recommended_default_model(provider: str = ""): """Return the recommended default model for a freshly-authenticated provider. Mirrors the model-curation `hermes model` does so GUI onboarding lands on a sensible default instead of blindly taking the first curated entry. For Nous this honors the user's free/paid tier: free users get a free model, paid users get the full curated default. For any other provider it falls back to the first curated model (same as before). Response: {"provider": str, "model": str, "free_tier": bool | None} where free_tier is True/False for Nous and None otherwise. `model` may be empty if nothing could be resolved (caller degrades gracefully). """ slug = (provider or "").strip().lower() if slug == "nous": try: from hermes_cli.models import ( get_curated_nous_model_ids, get_pricing_for_provider, check_nous_free_tier, partition_nous_models_by_tier, union_with_portal_free_recommendations, union_with_portal_paid_recommendations, ) from hermes_cli.auth import get_provider_auth_state model_ids = get_curated_nous_model_ids() pricing = get_pricing_for_provider("nous") or {} free_tier = check_nous_free_tier(force_fresh=True) portal_url = "" try: state = get_provider_auth_state("nous") or {} portal_url = state.get("portal_base_url", "") or "" except Exception: portal_url = "" if free_tier: model_ids, pricing = union_with_portal_free_recommendations( model_ids, pricing, portal_url ) model_ids, _unavailable = partition_nous_models_by_tier( model_ids, pricing, free_tier=True ) else: model_ids, pricing = union_with_portal_paid_recommendations( model_ids, pricing, portal_url ) model = model_ids[0] if model_ids else "" return {"provider": "nous", "model": model, "free_tier": bool(free_tier)} except Exception: _log.exception("GET /api/model/recommended-default (nous) failed") return {"provider": "nous", "model": "", "free_tier": None} # Non-Nous: first curated model for the provider, matching prior behaviour. try: from hermes_cli.inventory import build_models_payload, load_picker_context payload = build_models_payload(load_picker_context(), max_models=50) for row in payload.get("providers", []): if str(row.get("slug", "")).lower() == slug: models = row.get("models") or [] return {"provider": slug, "model": models[0] if models else "", "free_tier": None} return {"provider": slug, "model": "", "free_tier": None} except Exception: _log.exception("GET /api/model/recommended-default failed") return {"provider": slug, "model": "", "free_tier": None} @app.get("/api/model/auxiliary") def get_auxiliary_models(): """Return current auxiliary task assignments. Shape: { "tasks": [ {"task": "vision", "provider": "auto", "model": "", "base_url": ""}, ... ], "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, } """ try: cfg = load_config() aux_cfg = cfg.get("auxiliary", {}) if not isinstance(aux_cfg, dict): aux_cfg = {} tasks = [] for slot in _AUX_TASK_SLOTS: slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {} tasks.append({ "task": slot, "provider": str(slot_cfg.get("provider", "auto") or "auto"), "model": str(slot_cfg.get("model", "") or ""), "base_url": str(slot_cfg.get("base_url", "") or ""), }) model_cfg = cfg.get("model", {}) if isinstance(model_cfg, dict): main = { "provider": str(model_cfg.get("provider", "") or ""), "model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""), } else: main = {"provider": "", "model": str(model_cfg) if model_cfg else ""} return {"tasks": tasks, "main": main} except Exception: _log.exception("GET /api/model/auxiliary failed") raise HTTPException(status_code=500, detail="Failed to read auxiliary config") @app.post("/api/model/set") async def set_model_assignment(body: ModelAssignment): """Assign a model to the main slot or an auxiliary task slot. Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only. The currently running chat PTY (if any) is not affected; use the ``/model`` slash command inside a chat to hot-swap that specific session. """ scope = (body.scope or "").strip().lower() provider = (body.provider or "").strip() model = (body.model or "").strip() task = (body.task or "").strip().lower() if scope not in {"main", "auxiliary"}: raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") try: cfg = load_config() if scope == "main": if not provider or not model: raise HTTPException(status_code=400, detail="provider and model required for main") model_cfg = cfg.get("model", {}) if not isinstance(model_cfg, dict): model_cfg = {} model_cfg["provider"] = provider model_cfg["default"] = model # Clear stale base_url so the resolver picks the provider's own default. if "base_url" in model_cfg and model_cfg.get("base_url"): model_cfg["base_url"] = "" # Also clear hardcoded context_length override — new model may have # a different context window. if "context_length" in model_cfg: model_cfg.pop("context_length", None) cfg["model"] = model_cfg # When switching the main provider to Nous, mirror the CLI's # post-model-selection behaviour (hermes_cli/main.py # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): # auto-route any *unconfigured* tools through the Nous Tool Gateway. # This is purely additive — apply_nous_managed_defaults skips every # tool where the user already has a direct key (FIRECRAWL_API_KEY, # FAL_KEY, etc.) or an explicit backend/provider in config, so it # never overwrites a user's own setup. GUI users thus land on the # gateway the same way CLI users do, without a separate prompt. gateway_tools: list[str] = [] if provider.strip().lower() == "nous": try: from hermes_cli.nous_subscription import apply_nous_managed_defaults from hermes_cli.tools_config import _get_platform_tools enabled = _get_platform_tools( cfg, "cli", include_default_mcp_servers=False ) changed = apply_nous_managed_defaults( cfg, enabled_toolsets=enabled, force_fresh=True, ) gateway_tools = sorted(changed) except Exception: # Portal lookup hiccups / non-subscriber / non-nous gating # must never block saving the model assignment. _log.debug("apply_nous_managed_defaults skipped", exc_info=True) save_config(cfg) return { "ok": True, "scope": "main", "provider": provider, "model": model, "gateway_tools": gateway_tools, } # scope == "auxiliary" aux = cfg.get("auxiliary") if not isinstance(aux, dict): aux = {} if task == "__reset__": # Reset every slot to provider="auto", model="" — keeps other fields intact. for slot in _AUX_TASK_SLOTS: slot_cfg = aux.get(slot) if not isinstance(slot_cfg, dict): slot_cfg = {} slot_cfg["provider"] = "auto" slot_cfg["model"] = "" aux[slot] = slot_cfg cfg["auxiliary"] = aux save_config(cfg) return {"ok": True, "scope": "auxiliary", "reset": True} if not provider: raise HTTPException(status_code=400, detail="provider required for auxiliary") targets = [task] if task else list(_AUX_TASK_SLOTS) for slot in targets: if slot not in _AUX_TASK_SLOTS: raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") slot_cfg = aux.get(slot) if not isinstance(slot_cfg, dict): slot_cfg = {} slot_cfg["provider"] = provider slot_cfg["model"] = model aux[slot] = slot_cfg cfg["auxiliary"] = aux save_config(cfg) return { "ok": True, "scope": "auxiliary", "tasks": targets, "provider": provider, "model": model, } except HTTPException: raise except Exception: _log.exception("POST /api/model/set failed") raise HTTPException(status_code=500, detail="Failed to save model assignment") def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: """Reverse _normalize_config_for_web before saving. Reconstructs ``model`` as a dict by reading the current on-disk config to recover model subkeys (provider, base_url, api_mode, etc.) that were stripped from the GET response. The frontend only sees model as a flat string; the rest is preserved transparently. Also handles ``model_context_length`` — writes it back into the model dict as ``context_length``. A value of 0 or absent means "auto-detect" (omitted from the dict so get_model_context_length() uses its normal resolution). """ config = dict(config) # Remove any _model_meta that might have leaked in (shouldn't happen # with the stripped GET response, but be defensive) config.pop("_model_meta", None) # Extract and remove model_context_length before processing model ctx_override = config.pop("model_context_length", 0) if not isinstance(ctx_override, int): try: ctx_override = int(ctx_override) except (TypeError, ValueError): ctx_override = 0 model_val = config.get("model") if isinstance(model_val, str) and model_val: # Read the current disk config to recover model subkeys try: disk_config = load_config() disk_model = disk_config.get("model") if isinstance(disk_model, dict): # Preserve all subkeys, update default with the new value disk_model["default"] = model_val # Write context_length into the model dict (0 = remove/auto) if ctx_override > 0: disk_model["context_length"] = ctx_override else: disk_model.pop("context_length", None) config["model"] = disk_model # Model was previously a bare string — upgrade to dict if # user is setting a context_length override elif ctx_override > 0: config["model"] = { "default": model_val, "context_length": ctx_override, } except Exception: pass # can't read disk config — just use the string form return config @app.put("/api/config") async def update_config(body: ConfigUpdate): try: save_config(_denormalize_config_from_web(body.config)) return {"ok": True} except Exception: _log.exception("PUT /api/config failed") raise HTTPException(status_code=500, detail="Internal server error") @app.get("/api/env") async def get_env_vars(): env_on_disk = load_env() channel_keys = _channel_managed_env_keys() result = {} for var_name, info in OPTIONAL_ENV_VARS.items(): value = env_on_disk.get(var_name) result[var_name] = { "is_set": bool(value), "redacted_value": redact_key(value) if value else None, "description": info.get("description", ""), "url": info.get("url"), "category": info.get("category", ""), "is_password": info.get("password", False), "tools": info.get("tools", []), "advanced": info.get("advanced", False), # True when this var is a messaging-platform credential owned by a # Channels page card. The Keys/Env page uses this to hide it and # avoid duplicating the (richer) Channels configuration UI. "channel_managed": var_name in channel_keys, } return result @app.put("/api/env") async def set_env_var(body: EnvVarUpdate): try: save_env_value(body.key, body.value) return {"ok": True, "key": body.key} except ValueError as exc: # save_env_value raises ValueError for invalid names and for keys # on the denylist (LD_PRELOAD, PATH, PYTHONPATH, …). Surface the # message to the SPA so the user understands why the write was # refused instead of seeing an opaque 500. raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception: _log.exception("PUT /api/env failed") raise HTTPException(status_code=500, detail="Internal server error") # Live credential probes keyed by env var. Each entry is (method, url, auth) # where auth is "bearer" (Authorization header) or "query" (?key=). A cheap # read-only models/key call that 401s on a bad token — enough to catch a # mistyped key before it's persisted. Providers absent from this map (or local # endpoints) are not network-validated; the client treats those as "unknown". _CREDENTIAL_PROBES: dict[str, tuple[str, str]] = { "OPENROUTER_API_KEY": ("https://openrouter.ai/api/v1/key", "bearer"), "OPENAI_API_KEY": ("https://api.openai.com/v1/models", "bearer"), "XAI_API_KEY": ("https://api.x.ai/v1/models", "bearer"), "GEMINI_API_KEY": ("https://generativelanguage.googleapis.com/v1beta/models", "query"), } @app.post("/api/providers/validate") async def validate_provider_credential(body: EnvVarUpdate, request: Request): """Live-probe a provider credential before it's saved. Returns {ok, reachable, message}. ok=True means the provider accepted the key; ok=False + reachable=True means the key is bad (caller should block); reachable=False means the network probe couldn't run (caller may save with a warning rather than hard-blocking offline users). """ _require_token(request) import httpx key = (body.key or "").strip() value = (body.value or "").strip() if not value: return {"ok": False, "reachable": True, "message": "Enter a value first."} # Local / custom endpoint: validate connectivity, not auth — any HTTP # response (even 401) proves the endpoint is up. if key == "OPENAI_BASE_URL": url = value.rstrip("/") + "/models" try: with httpx.Client(timeout=httpx.Timeout(8.0)) as client: client.get(url) return {"ok": True, "reachable": True, "message": ""} except Exception: return {"ok": False, "reachable": False, "message": f"Could not reach {url}."} probe = _CREDENTIAL_PROBES.get(key) if not probe: # No probe for this provider — can't validate, don't block. return {"ok": True, "reachable": False, "message": ""} url, auth = probe headers = {"Accept": "application/json"} params = {} if auth == "bearer": headers["Authorization"] = f"Bearer {value}" else: params["key"] = value try: with httpx.Client(timeout=httpx.Timeout(10.0)) as client: resp = client.get(url, headers=headers, params=params) except Exception: return {"ok": False, "reachable": False, "message": "Could not reach the provider to verify the key."} if resp.status_code in (401, 403): return {"ok": False, "reachable": True, "message": "That API key was rejected. Double-check it and try again."} if resp.status_code == 429 or resp.is_success: # 429 = key is valid but rate-limited; success = valid. return {"ok": True, "reachable": True, "message": ""} return {"ok": False, "reachable": True, "message": f"Provider returned HTTP {resp.status_code} for this key."} @app.delete("/api/env") async def remove_env_var(body: EnvVarDelete): try: removed = remove_env_value(body.key) if not removed: raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") return {"ok": True, "key": body.key} except HTTPException: raise except ValueError as exc: # remove_env_value raises ValueError for invalid key names. Surface # the message to the SPA so the user understands why the delete was # refused instead of seeing an opaque 500. Mirrors PUT /api/env. raise HTTPException(status_code=400, detail=str(exc)) from exc except Exception: _log.exception("DELETE /api/env failed") raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/env/reveal") async def reveal_env_var(body: EnvVarReveal, request: Request): """Return the real (unredacted) value of a single env var. Protected by: - Ephemeral session token (generated per server start, injected into SPA) - Rate limiting (max 5 reveals per 30s window) - Audit logging """ # --- Token check --- _require_token(request) # --- Rate limit --- now = time.time() cutoff = now - _REVEAL_WINDOW_SECONDS _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff] if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW: raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.") _reveal_timestamps.append(now) # --- Reveal --- env_on_disk = load_env() value = env_on_disk.get(body.key) if value is None: raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") _log.info("env/reveal: %s", body.key) return {"key": body.key, "value": value} # Entries omit fields they don't need to override; the catalog builder fills # in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified, # and pulls required_env from a plugin's PlatformEntry when available. _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "telegram": { "name": "Telegram", "description": "Run Hermes from Telegram DMs, groups, and topics.", "docs_url": "https://core.telegram.org/bots/features#botfather", "env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"), "required_env": ("TELEGRAM_BOT_TOKEN",), }, "discord": { "name": "Discord", "description": "Connect Hermes to Discord DMs, channels, and threads.", "docs_url": "https://discord.com/developers/applications", "env_vars": ( "DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", "DISCORD_REPLY_TO_MODE", ), "required_env": ("DISCORD_BOT_TOKEN",), }, "slack": { "name": "Slack", "description": "Use Hermes from Slack via Socket Mode.", "docs_url": "https://api.slack.com/apps", "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), }, "mattermost": { "name": "Mattermost", "description": "Connect Hermes to Mattermost channels and direct messages.", "docs_url": "https://mattermost.com/deploy/", "env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"), "required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"), }, "matrix": { "name": "Matrix", "description": "Use Hermes in Matrix rooms and direct messages.", "docs_url": "https://matrix.org/ecosystem/servers/", "env_vars": ( "MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_ALLOWED_USERS", ), "required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"), }, "signal": { "name": "Signal", "description": "Connect through a signal-cli REST bridge.", "docs_url": "https://github.com/bbernhard/signal-cli-rest-api", "env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"), "required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"), }, "whatsapp": { "name": "WhatsApp", "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", "docs_url": "https://github.com/tulir/whatsmeow", "env_vars": ("WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_ALLOWED_USERS"), "required_env": (), }, "homeassistant": { "name": "Home Assistant", "description": "Control your smart home from Hermes via Home Assistant.", "docs_url": "https://www.home-assistant.io/docs/authentication/", "env_vars": ("HASS_URL", "HASS_TOKEN"), "required_env": ("HASS_URL", "HASS_TOKEN"), }, "email": { "name": "Email", "description": "Talk to Hermes through an IMAP/SMTP mailbox.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", "env_vars": ( "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", ), "required_env": ( "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", ), }, "sms": { "name": "SMS (Twilio)", "description": "Send and receive text messages via Twilio.", "docs_url": "https://www.twilio.com/console", "env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), "required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), }, "dingtalk": { "name": "DingTalk", "description": "Connect Hermes to DingTalk groups (钉钉).", "docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process", "env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), "required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), }, "feishu": { "name": "Feishu / Lark", "description": "Use Hermes inside Feishu / Lark.", "docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro", "env_vars": ( "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", ), "required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), }, "wecom": { "name": "WeCom (group bot)", "description": "Send-only WeCom group bot via webhook.", "docs_url": "https://developer.work.weixin.qq.com/document/path/91770", "env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"), "required_env": ("WECOM_BOT_ID",), }, "wecom_callback": { "name": "WeCom (app)", "description": "Two-way WeCom integration via callback app.", "docs_url": "https://developer.work.weixin.qq.com/document/path/90930", "env_vars": ( "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", ), "required_env": ( "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", ), }, "weixin": { "name": "WeChat (Official Account)", "description": "Connect a WeChat Official Account.", "docs_url": "https://developers.weixin.qq.com/doc/offiaccount/Getting_Started/Overview.html", "env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"), "required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"), }, "bluebubbles": { "name": "BlueBubbles (iMessage)", "description": "Use Hermes through iMessage via a BlueBubbles server.", "docs_url": "https://bluebubbles.app/", "env_vars": ( "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_ALLOWED_USERS", ), "required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), }, "qqbot": { "name": "QQ Bot", "description": "Connect Hermes to a QQ Bot from the QQ Open Platform.", "docs_url": "https://q.qq.com", "env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"), "required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"), }, "yuanbao": { "name": "Yuanbao (元宝)", "description": "Connect Hermes to Tencent Yuanbao.", "docs_url": "", "required_env": (), }, "api_server": { "name": "API server", "description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", "env_vars": ( "API_SERVER_ENABLED", "API_SERVER_KEY", "API_SERVER_PORT", "API_SERVER_HOST", "API_SERVER_MODEL_NAME", ), "required_env": (), }, "webhook": { "name": "Webhooks", "description": "Receive events from GitHub, GitLab, and other webhook sources.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/", "env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), "required_env": (), }, } # Display order: well-known platforms surface first; unknown plugins fall to # the end alphabetically. _PLATFORM_ORDER: tuple[str, ...] = ( "telegram", "discord", "slack", "mattermost", "matrix", "whatsapp", "signal", "bluebubbles", "homeassistant", "email", "sms", "dingtalk", "feishu", "wecom", "wecom_callback", "weixin", "qqbot", "yuanbao", "api_server", "webhook", ) # Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge # toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS # falls back here so the UI can still render a friendly label. _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "SIGNAL_HTTP_URL": { "description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080", "prompt": "Signal bridge URL", "url": "https://github.com/bbernhard/signal-cli-rest-api", }, "SIGNAL_ACCOUNT": { "description": "Signal account phone number registered with the bridge", "prompt": "Signal account", }, "SIGNAL_ALLOWED_USERS": { "description": "Comma-separated Signal users allowed to use the bot", "prompt": "Allowed Signal users", }, "WHATSAPP_ENABLED": { "description": "Enable the WhatsApp gateway adapter", "prompt": "Enable WhatsApp", "advanced": True, }, "WHATSAPP_MODE": { "description": "WhatsApp bridge mode", "prompt": "WhatsApp mode", "advanced": True, }, "WHATSAPP_ALLOWED_USERS": { "description": "Comma-separated WhatsApp users allowed to use the bot", "prompt": "Allowed WhatsApp users", }, "HASS_URL": { "description": "Home Assistant base URL, e.g. https://homeassistant.local:8123", "prompt": "Home Assistant URL", }, "HASS_TOKEN": { "description": "Long-lived access token from Home Assistant (Profile → Security)", "prompt": "Home Assistant access token", "password": True, }, "EMAIL_ADDRESS": { "description": "Email address to send and receive from", "prompt": "Email address", }, "EMAIL_PASSWORD": { "description": "Email account password or app password", "prompt": "Email password", "password": True, }, "EMAIL_IMAP_HOST": { "description": "IMAP server host (e.g. imap.gmail.com)", "prompt": "IMAP host", }, "EMAIL_SMTP_HOST": { "description": "SMTP server host (e.g. smtp.gmail.com)", "prompt": "SMTP host", }, "TWILIO_ACCOUNT_SID": { "description": "Twilio Account SID", "prompt": "Twilio Account SID", "url": "https://www.twilio.com/console", }, "TWILIO_AUTH_TOKEN": { "description": "Twilio Auth Token", "prompt": "Twilio Auth Token", "password": True, }, "WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"}, "WECOM_SECRET": { "description": "WeCom group bot secret", "prompt": "WeCom Secret", "password": True, }, "WECOM_CALLBACK_CORP_ID": { "description": "WeCom corp ID", "prompt": "WeCom Corp ID", }, "WECOM_CALLBACK_CORP_SECRET": { "description": "WeCom app corp secret", "prompt": "WeCom Corp Secret", "password": True, }, "WECOM_CALLBACK_AGENT_ID": { "description": "WeCom app agent ID", "prompt": "WeCom Agent ID", }, "WECOM_CALLBACK_TOKEN": { "description": "WeCom callback verification token", "prompt": "WeCom Token", }, "WECOM_CALLBACK_ENCODING_AES_KEY": { "description": "WeCom callback AES encoding key", "prompt": "WeCom AES Key", "password": True, }, "WEIXIN_ACCOUNT_ID": { "description": "WeChat Official Account ID", "prompt": "Account ID", }, "WEIXIN_TOKEN": { "description": "WeChat callback token", "prompt": "Token", "password": True, }, "WEIXIN_BASE_URL": { "description": "WeChat platform base URL", "prompt": "Base URL", }, "FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"}, "FEISHU_APP_SECRET": { "description": "Feishu / Lark app secret", "prompt": "App secret", "password": True, }, "FEISHU_ENCRYPT_KEY": { "description": "Feishu / Lark encrypt key", "prompt": "Encrypt key", "password": True, }, "FEISHU_VERIFICATION_TOKEN": { "description": "Feishu / Lark verification token", "prompt": "Verification token", "password": True, }, "DINGTALK_CLIENT_ID": { "description": "DingTalk client ID (App key)", "prompt": "Client ID", }, "DINGTALK_CLIENT_SECRET": { "description": "DingTalk client secret (App secret)", "prompt": "Client secret", "password": True, }, } def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: """Build the messaging catalog from the gateway's Platform enum + plugin registry. Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded). Plugin platforms come from ``gateway.platform_registry.plugin_entries()``, which lets newly installed adapters (e.g. IRC) appear without a code change here. Per-platform UI metadata (description, docs URL, env-var picks) lives in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable defaults derived from the platform id and required_env. """ from gateway.config import Platform seen: set[str] = set() entries: list[dict[str, Any]] = [] for member in Platform.__members__.values(): if member.value == "local": continue if member.value in seen: continue seen.add(member.value) entries.append(_build_catalog_entry(member.value)) try: from gateway.platform_registry import platform_registry for plugin_entry in platform_registry.plugin_entries(): if plugin_entry.name in seen: continue seen.add(plugin_entry.name) entries.append(_build_catalog_entry(plugin_entry.name, plugin_entry)) except Exception: _log.debug("plugin platform registry unavailable", exc_info=True) order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} entries.sort( key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()) ) return tuple(entries) def _channel_managed_env_keys() -> frozenset[str]: """Env-var keys owned by a Channels page platform card. The Channels page is the canonical surface for configuring messaging platform credentials (with connection status, test, enable toggle and gateway restart). The Keys/Env page consults this set to hide those vars so the same fields aren't duplicated in a plainer UI. Best-effort: if the gateway catalog can't be built, nothing is flagged and Keys shows it all. """ try: keys: set[str] = set() for entry in _messaging_platform_catalog(): keys.update(entry.get("env_vars", ())) return frozenset(keys) except Exception: _log.debug("could not build channel-managed env key set", exc_info=True) return frozenset() def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) if "env_vars" in override: env_vars: tuple[str, ...] = tuple(override["env_vars"]) elif plugin_entry is not None and plugin_entry.required_env: env_vars = tuple(plugin_entry.required_env) else: prefix = platform_id.upper() + "_" env_vars = tuple(k for k in OPTIONAL_ENV_VARS if k.startswith(prefix)) if "required_env" in override: required_env = tuple(override["required_env"]) elif plugin_entry is not None: required_env = tuple(plugin_entry.required_env or ()) else: required_env = () if override.get("name"): name = override["name"] elif plugin_entry is not None and plugin_entry.label: name = plugin_entry.label else: name = platform_id.replace("_", " ").title() description = override.get("description") if not description and plugin_entry is not None: description = plugin_entry.install_hint or "" return { "id": platform_id, "name": name, "description": description or "", "docs_url": override.get("docs_url", ""), "env_vars": env_vars, "required_env": required_env, } def _catalog_lookup(platform_id: str) -> dict[str, Any] | None: for entry in _messaging_platform_catalog(): if entry["id"] == platform_id: return entry return None def _messaging_env_info(key: str) -> dict[str, Any]: info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {} return { "description": info.get("description", ""), "prompt": info.get("prompt", key), "url": info.get("url"), "is_password": info.get("password", False), "advanced": info.get("advanced", False), } def _gateway_platform_config(platform_id: str): from gateway.config import Platform, load_gateway_config config = load_gateway_config() platform = Platform(platform_id) platform_config = config.platforms.get(platform) return config, platform, platform_config def _messaging_platform_payload( entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None ) -> dict[str, Any]: platform_id = entry["id"] gateway_running = get_running_pid() is not None runtime_platforms = runtime.get("platforms") if runtime else {} runtime_platform = ( runtime_platforms.get(platform_id, {}) if isinstance(runtime_platforms, dict) else {} ) env_vars = [] for key in entry["env_vars"]: value = env_on_disk.get(key) or os.getenv(key, "") env_vars.append( { "key": key, "required": key in entry["required_env"], "is_set": bool(value), "redacted_value": redact_key(value) if value else None, **_messaging_env_info(key), } ) try: gateway_config, platform, platform_config = _gateway_platform_config( platform_id ) enabled = bool(platform_config and platform_config.enabled) configured = bool( platform_config and gateway_config._is_platform_connected(platform, platform_config) ) home_channel = ( platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None ) except Exception: enabled = False configured = all( env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"] ) home_channel = None state = ( runtime_platform.get("state") if isinstance(runtime_platform, dict) else None ) if not enabled: state = "disabled" elif not configured: state = "not_configured" elif gateway_running and not state: state = "pending_restart" elif not gateway_running and not state: state = "gateway_stopped" return { "id": platform_id, "name": entry["name"], "description": entry["description"], "docs_url": entry["docs_url"], "enabled": enabled, "configured": configured, "gateway_running": gateway_running, "state": state, "error_code": ( runtime_platform.get("error_code") if isinstance(runtime_platform, dict) else None ), "error_message": ( runtime_platform.get("error_message") if isinstance(runtime_platform, dict) else None ), "updated_at": ( runtime_platform.get("updated_at") if isinstance(runtime_platform, dict) else None ), "home_channel": home_channel, "env_vars": env_vars, } def _write_platform_enabled(platform_id: str, enabled: bool) -> None: config = load_config() platforms = config.setdefault("platforms", {}) if not isinstance(platforms, dict): platforms = {} config["platforms"] = platforms platform_config = platforms.setdefault(platform_id, {}) if not isinstance(platform_config, dict): platform_config = {} platforms[platform_id] = platform_config platform_config["enabled"] = enabled save_config(config) @app.get("/api/messaging/platforms") async def get_messaging_platforms(): env_on_disk = load_env() runtime = read_runtime_status() return { "platforms": [ _messaging_platform_payload(entry, env_on_disk, runtime) for entry in _messaging_platform_catalog() ] } @app.put("/api/messaging/platforms/{platform_id}") async def update_messaging_platform(platform_id: str, body: MessagingPlatformUpdate): entry = _catalog_lookup(platform_id) if not entry: raise HTTPException( status_code=404, detail=f"Unknown messaging platform: {platform_id}" ) allowed_env = set(entry["env_vars"]) try: for key in body.clear_env: if key not in allowed_env: raise HTTPException( status_code=400, detail=f"{key} is not configurable for {entry['name']}", ) remove_env_value(key) for key, value in body.env.items(): if key not in allowed_env: raise HTTPException( status_code=400, detail=f"{key} is not configurable for {entry['name']}", ) trimmed = value.strip() if trimmed: save_env_value(key, trimmed) if body.enabled is not None: _write_platform_enabled(platform_id, body.enabled) return {"ok": True, "platform": platform_id} except HTTPException: raise except Exception: _log.exception("PUT /api/messaging/platforms/%s failed", platform_id) raise HTTPException(status_code=500, detail="Internal server error") @app.post("/api/messaging/platforms/{platform_id}/test") async def test_messaging_platform(platform_id: str): entry = _catalog_lookup(platform_id) if not entry: raise HTTPException( status_code=404, detail=f"Unknown messaging platform: {platform_id}" ) env_on_disk = load_env() payload = _messaging_platform_payload(entry, env_on_disk, read_runtime_status()) if not payload["enabled"]: message = f"{entry['name']} is disabled. Enable it, then restart the gateway." return {"ok": False, "state": payload["state"], "message": message} if not payload["configured"]: missing = [ field["key"] for field in payload["env_vars"] if field["required"] and not field["is_set"] ] message = ( f"Missing required setup: {', '.join(missing)}" if missing else "Platform setup is incomplete." ) return {"ok": False, "state": payload["state"], "message": message} if not payload["gateway_running"]: return { "ok": False, "state": payload["state"], "message": "Gateway is not running. Restart the gateway to connect this platform.", } if payload["state"] == "connected": return { "ok": True, "state": payload["state"], "message": f"{entry['name']} is connected.", } if payload.get("error_message"): return { "ok": False, "state": payload["state"], "message": payload["error_message"], } return { "ok": False, "state": payload["state"], "message": "Setup looks complete, but the gateway has not reported a connection yet. Restart the gateway.", } # --------------------------------------------------------------------------- # OAuth provider endpoints — status + disconnect (Phase 1) # --------------------------------------------------------------------------- # # Phase 1 surfaces *which OAuth providers exist* and whether each is # connected, plus a disconnect button. The actual login flow (PKCE for # Anthropic, device-code for Nous/Codex) still runs in the CLI for now; # Phase 2 will add in-browser flows. For unconnected providers we return # the canonical ``hermes auth add `` command so the dashboard # can surface a one-click copy. def _truncate_token(value: Optional[str], visible: int = 6) -> str: """Return ``...XXXXXX`` (last N chars) for safe display in the UI. We never expose more than the trailing ``visible`` characters of an OAuth access token. JWT prefixes (the part before the first dot) are stripped first when present so the visible suffix is always part of the signing region rather than a meaningless header chunk. Returns the Entra-ID placeholder when handed a callable (Azure Foundry bearer provider) — the callable is NEVER invoked here. """ if not value: return "" if callable(value) and not isinstance(value, str): # Entra ID bearer provider — never reveal a minted token in the UI. return "" s = str(value) if "." in s and s.count(".") >= 2: # Looks like a JWT — show the trailing piece of the signature only. s = s.rsplit(".", 1)[-1] if len(s) <= visible: return s return f"…{s[-visible:]}" def _anthropic_oauth_status() -> Dict[str, Any]: """Combined status across the three Anthropic credential sources we read. Hermes resolves Anthropic creds in this order at runtime: 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow 2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto) 3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars The dashboard reports the highest-priority source that's actually present. """ try: from agent.anthropic_adapter import ( read_hermes_oauth_credentials, read_claude_code_credentials, _HERMES_OAUTH_FILE, ) except ImportError: read_claude_code_credentials = None # type: ignore read_hermes_oauth_credentials = None # type: ignore _HERMES_OAUTH_FILE = None # type: ignore hermes_creds = None if read_hermes_oauth_credentials: try: hermes_creds = read_hermes_oauth_credentials() except Exception: hermes_creds = None if hermes_creds and hermes_creds.get("accessToken"): return { "logged_in": True, "source": "hermes_pkce", "source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})", "token_preview": _truncate_token(hermes_creds.get("accessToken")), "expires_at": hermes_creds.get("expiresAt"), "has_refresh_token": bool(hermes_creds.get("refreshToken")), } cc_creds = None if read_claude_code_credentials: try: cc_creds = read_claude_code_credentials() except Exception: cc_creds = None if cc_creds and cc_creds.get("accessToken"): return { "logged_in": True, "source": "claude_code", "source_label": "Claude Code (~/.claude/.credentials.json)", "token_preview": _truncate_token(cc_creds.get("accessToken")), "expires_at": cc_creds.get("expiresAt"), "has_refresh_token": bool(cc_creds.get("refreshToken")), } env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN") if env_token: return { "logged_in": True, "source": "env_var", "source_label": "ANTHROPIC_TOKEN environment variable", "token_preview": _truncate_token(env_token), "expires_at": None, "has_refresh_token": False, } return {"logged_in": False, "source": None} def _claude_code_only_status() -> Dict[str, Any]: """Surface Claude Code CLI credentials as their own provider entry. Independent of the Anthropic entry above so users can see whether their Claude Code subscription tokens are actively flowing into Hermes even when they also have a separate Hermes-managed PKCE login. """ try: from agent.anthropic_adapter import read_claude_code_credentials creds = read_claude_code_credentials() except Exception: creds = None if creds and creds.get("accessToken"): return { "logged_in": True, "source": "claude_code_cli", "source_label": "~/.claude/.credentials.json", "token_preview": _truncate_token(creds.get("accessToken")), "expires_at": creds.get("expiresAt"), "has_refresh_token": bool(creds.get("refreshToken")), } return {"logged_in": False, "source": None} # Provider catalog. The order matters — it's how we render the UI list. # ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard # fallback while Phase 2 (in-browser flows) isn't built yet. # ``flow`` describes the OAuth shape so the future modal can pick the # right UI: ``pkce`` = open URL + paste callback code, ``device_code`` = # show code + verification URL + poll, ``external`` = read-only (delegated # to a third-party CLI like Claude Code or Qwen). _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( { "id": "anthropic", "name": "Anthropic (Claude API)", "flow": "pkce", "cli_command": "hermes auth add anthropic", "docs_url": "https://docs.claude.com/en/api/getting-started", "status_fn": _anthropic_oauth_status, }, { "id": "claude-code", "name": "Claude Code (subscription)", "flow": "external", "cli_command": "claude setup-token", "docs_url": "https://docs.claude.com/en/docs/claude-code", "status_fn": _claude_code_only_status, }, { "id": "nous", "name": "Nous Portal", "flow": "device_code", "cli_command": "hermes auth add nous", "docs_url": "https://portal.nousresearch.com", "status_fn": None, # dispatched via auth.get_nous_auth_status }, { "id": "openai-codex", "name": "OpenAI Codex (ChatGPT)", "flow": "device_code", "cli_command": "hermes auth add openai-codex", "docs_url": "https://platform.openai.com/docs", "status_fn": None, # dispatched via auth.get_codex_auth_status }, { "id": "qwen-oauth", "name": "Qwen (via Qwen CLI)", "flow": "external", "cli_command": "hermes auth add qwen-oauth", "docs_url": "https://github.com/QwenLM/qwen-code", "status_fn": None, # dispatched via auth.get_qwen_auth_status }, { "id": "minimax-oauth", "name": "MiniMax (OAuth)", # MiniMax's flow is structurally device-code (verification URI + # user code, backend polls the token endpoint) with a PKCE # extension for code-binding. The dashboard renders the same UX # as Nous's device-code flow; the PKCE bit is a security # extension that doesn't change the operator experience. "flow": "device_code", "cli_command": "hermes auth add minimax-oauth", "docs_url": "https://www.minimax.io", "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status }, { "id": "xai-oauth", "name": "xAI Grok OAuth (SuperGrok / Premium+)", # Loopback PKCE: the desktop's local backend binds a 127.0.0.1 # callback server, the client opens the browser, and the redirect # lands back on the loopback listener — no code to copy/paste. "flow": "loopback", "cli_command": "hermes auth add xai-oauth", "docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth", "status_fn": None, # dispatched via auth.get_xai_oauth_auth_status }, ) def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: """Dispatch to the right status helper for an OAuth provider entry.""" if status_fn is not None: try: return status_fn() except Exception as e: return {"logged_in": False, "error": str(e)} try: from hermes_cli import auth as hauth if provider_id == "nous": raw = hauth.get_nous_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "nous_portal", "source_label": raw.get("portal_base_url") or "Nous Portal", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("access_expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "openai-codex": raw = hauth.get_codex_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "openai_codex", "source_label": raw.get("auth_mode") or "OpenAI Codex", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": False, "last_refresh": raw.get("last_refresh"), } if provider_id == "qwen-oauth": raw = hauth.get_qwen_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "qwen_cli", "source_label": raw.get("auth_store_path") or "Qwen CLI", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "minimax-oauth": raw = hauth.get_minimax_oauth_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "minimax_oauth", "source_label": f"MiniMax ({raw.get('region', 'global')})", "token_preview": None, "expires_at": raw.get("expires_at"), "has_refresh_token": True, } if provider_id == "xai-oauth": raw = hauth.get_xai_oauth_auth_status() # source_label is meant to be a human-readable origin (auth-store # path / credential source), not the internal auth_mode string # ("oauth_pkce"). Prefer the store path, then the source slug. return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "xai_oauth", "source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": True, "last_refresh": raw.get("last_refresh"), } except Exception as e: return {"logged_in": False, "error": str(e)} return {"logged_in": False} @app.get("/api/providers/oauth") async def list_oauth_providers(): """Enumerate every OAuth-capable LLM provider with current status. Response shape (per provider): id stable identifier (used in DELETE path) name human label flow "pkce" | "device_code" | "external" | "loopback" cli_command fallback CLI command for users to run manually docs_url external docs/portal link for the "Learn more" link status: logged_in bool — currently has usable creds source short slug ("hermes_pkce", "claude_code", ...) source_label human-readable origin (file path, env var name) token_preview last N chars of the token, never the full token expires_at ISO timestamp string or null has_refresh_token bool """ providers = [] for p in _OAUTH_PROVIDER_CATALOG: status = _resolve_provider_status(p["id"], p.get("status_fn")) providers.append({ "id": p["id"], "name": p["name"], "flow": p["flow"], "cli_command": p["cli_command"], "docs_url": p["docs_url"], "status": status, }) return {"providers": providers} @app.delete("/api/providers/oauth/{provider_id}") async def disconnect_oauth_provider(provider_id: str, request: Request): """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" _require_token(request) valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid_ids: raise HTTPException( status_code=400, detail=f"Unknown provider: {provider_id}. " f"Available: {', '.join(sorted(valid_ids))}", ) # Anthropic and claude-code clear the same Hermes-managed PKCE file # AND forget the Claude Code import. We don't touch ~/.claude/* directly # — that's owned by the Claude Code CLI; users can re-auth there if they # want to undo a disconnect. if provider_id in {"anthropic", "claude-code"}: try: from agent.anthropic_adapter import _HERMES_OAUTH_FILE if _HERMES_OAUTH_FILE.exists(): _HERMES_OAUTH_FILE.unlink() except Exception: pass # Also clear the credential pool entry if present. try: from hermes_cli.auth import clear_provider_auth clear_provider_auth("anthropic") except Exception: pass _log.info("oauth/disconnect: %s", provider_id) return {"ok": True, "provider": provider_id} try: from hermes_cli.auth import clear_provider_auth cleared = clear_provider_auth(provider_id) _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) return {"ok": bool(cleared), "provider": provider_id} except Exception as e: _log.exception("disconnect %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) # --------------------------------------------------------------------------- # OAuth Phase 2 — in-browser PKCE & device-code flows # --------------------------------------------------------------------------- # # Two flow shapes are supported: # # PKCE (Anthropic): # 1. POST /api/providers/oauth/anthropic/start # → server generates code_verifier + challenge, builds claude.ai # authorize URL, stashes verifier in _oauth_sessions[session_id] # → returns { session_id, flow: "pkce", auth_url } # 2. UI opens auth_url in a new tab. User authorizes, copies code. # 3. POST /api/providers/oauth/anthropic/submit { session_id, code } # → server exchanges (code + verifier) → tokens at console.anthropic.com # → persists to ~/.hermes/.anthropic_oauth.json AND credential pool # → returns { ok: true, status: "approved" } # # Device code (Nous, OpenAI Codex): # 1. POST /api/providers/oauth/{nous|openai-codex}/start # → server hits provider's device-auth endpoint # → gets { user_code, verification_url, device_code, interval, expires_in } # → spawns background poller thread that polls the token endpoint # every `interval` seconds until approved/expired # → stores poll status in _oauth_sessions[session_id] # → returns { session_id, flow: "device_code", user_code, # verification_url, expires_in, poll_interval } # 2. UI opens verification_url in a new tab and shows user_code. # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id} # every 2s until status != "pending". # 4. On "approved" the background thread has already saved creds; UI # refreshes the providers list. # # Loopback PKCE (xAI Grok): # 1. POST /api/providers/oauth/xai-oauth/start # → server binds a 127.0.0.1 callback listener, builds the xAI # authorize URL, spawns a background worker waiting on the redirect # → returns { session_id, flow: "loopback", auth_url, expires_in } # 2. UI opens auth_url in the browser. There is NO user_code/code to # paste — the redirect lands back on the loopback listener. # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id} # (same endpoint as device_code) until status != "pending". # 4. The worker exchanges the code, persists creds, sets "approved". # DELETE /sessions/{id} cancels: the worker bails before persisting # and the callback server is shut down to free the port immediately. # # Sessions are kept in-memory only (single-process FastAPI) and time out # after 15 minutes. A periodic cleanup runs on each /start call to GC # expired sessions so the dict doesn't grow without bound. _OAUTH_SESSION_TTL_SECONDS = 15 * 60 _oauth_sessions: Dict[str, Dict[str, Any]] = {} _oauth_sessions_lock = threading.Lock() # Import OAuth constants from canonical source instead of duplicating. # Guarded so hermes web still starts if anthropic_adapter is unavailable; # Phase 2 endpoints will return 501 in that case. try: from agent.anthropic_adapter import ( _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID, _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL, _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI, _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES, _generate_pkce as _generate_pkce_pair, ) _ANTHROPIC_OAUTH_AVAILABLE = True except ImportError: _ANTHROPIC_OAUTH_AVAILABLE = False _ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize" def _gc_oauth_sessions() -> None: """Drop expired sessions. Called opportunistically on /start.""" cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS with _oauth_sessions_lock: stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff] for sid in stale: _oauth_sessions.pop(sid, None) def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]: """Create + register a new OAuth session, return (session_id, session_dict).""" sid = secrets.token_urlsafe(16) sess = { "session_id": sid, "provider": provider_id, "flow": flow, "created_at": time.time(), "status": "pending", # pending | approved | denied | expired | error "error_message": None, } with _oauth_sessions_lock: _oauth_sessions[sid] = sess return sid, sess def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: """Persist Anthropic PKCE creds to both Hermes file AND credential pool. Mirrors what auth_commands.add_command does so the dashboard flow leaves the system in the same state as ``hermes auth add anthropic``. """ from agent.anthropic_adapter import _HERMES_OAUTH_FILE payload = { "accessToken": access_token, "refreshToken": refresh_token, "expiresAt": expires_at_ms, } _HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True) tmp_path = _HERMES_OAUTH_FILE.with_name( f"{_HERMES_OAUTH_FILE.name}.tmp.{os.getpid()}.{secrets.token_hex(8)}" ) try: with tmp_path.open("w", encoding="utf-8") as handle: handle.write(json.dumps(payload, indent=2)) handle.flush() os.fsync(handle.fileno()) os.replace(tmp_path, _HERMES_OAUTH_FILE) try: _HERMES_OAUTH_FILE.chmod(stat.S_IRUSR | stat.S_IWUSR) except OSError: pass finally: try: if tmp_path.exists(): tmp_path.unlink() except OSError: pass # Best-effort credential-pool insert. Failure here doesn't invalidate # the file write — pool registration only matters for the rotation # strategy, not for runtime credential resolution. try: from agent.credential_pool import ( PooledCredential, load_pool, AUTH_TYPE_OAUTH, SOURCE_MANUAL, ) import uuid pool = load_pool("anthropic") # Avoid duplicate entries: delete any prior dashboard-issued OAuth entry existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")] for e in existing: try: pool.remove_entry(getattr(e, "id", "")) except Exception: pass entry = PooledCredential( provider="anthropic", id=uuid.uuid4().hex[:6], label="dashboard PKCE", auth_type=AUTH_TYPE_OAUTH, priority=0, source=f"{SOURCE_MANUAL}:dashboard_pkce", access_token=access_token, refresh_token=refresh_token, expires_at_ms=expires_at_ms, ) pool.add_entry(entry) except Exception as e: _log.warning("anthropic pool add (dashboard) failed: %s", e) def _start_anthropic_pkce() -> Dict[str, Any]: """Begin PKCE flow. Returns the auth URL the UI should open.""" if not _ANTHROPIC_OAUTH_AVAILABLE: raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)") verifier, challenge = _generate_pkce_pair() sid, sess = _new_oauth_session("anthropic", "pkce") sess["verifier"] = verifier sess["state"] = verifier # Anthropic round-trips verifier as state params = { "code": "true", "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, "response_type": "code", "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, "scope": _ANTHROPIC_OAUTH_SCOPES, "code_challenge": challenge, "code_challenge_method": "S256", "state": verifier, } auth_url = f"{_ANTHROPIC_OAUTH_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" return { "session_id": sid, "flow": "pkce", "auth_url": auth_url, "expires_in": _OAUTH_SESSION_TTL_SECONDS, } def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: """Exchange authorization code for tokens. Persists on success.""" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce": raise HTTPException(status_code=404, detail="Unknown or expired session") if sess["status"] != "pending": return {"ok": False, "status": sess["status"], "message": sess.get("error_message")} # Anthropic's redirect callback page formats the code as `#`. # Strip the state suffix if present (we already have the verifier server-side). parts = code_input.strip().split("#", 1) code = parts[0].strip() if not code: return {"ok": False, "status": "error", "message": "No code provided"} state_from_callback = parts[1] if len(parts) > 1 else "" exchange_data = json.dumps({ "grant_type": "authorization_code", "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, "code": code, "state": state_from_callback or sess["state"], "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, "code_verifier": sess["verifier"], }).encode() req = urllib.request.Request( _ANTHROPIC_OAUTH_TOKEN_URL, data=exchange_data, headers={ "Content-Type": "application/json", "User-Agent": "hermes-dashboard/1.0", }, method="POST", ) try: with urllib.request.urlopen(req, timeout=20) as resp: result = json.loads(resp.read().decode()) except Exception as e: with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = f"Token exchange failed: {e}" return {"ok": False, "status": "error", "message": sess["error_message"]} access_token = result.get("access_token", "") refresh_token = result.get("refresh_token", "") expires_in = int(result.get("expires_in") or 3600) if not access_token: with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = "No access token returned" return {"ok": False, "status": "error", "message": sess["error_message"]} expires_at_ms = int(time.time() * 1000) + (expires_in * 1000) try: _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) except Exception as e: with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = f"Save failed: {e}" return {"ok": False, "status": "error", "message": sess["error_message"]} with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/pkce: anthropic login completed (session=%s)", session_id) return {"ok": True, "status": "approved"} async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: """Initiate a device-code flow (Nous, OpenAI Codex, or MiniMax). Calls the provider's device-auth endpoint via the existing CLI helpers, then spawns a background poller. Returns the user-facing display fields so the UI can render the verification page link + user code. """ if provider_id == "nous": from hermes_cli.auth import ( _request_device_code, PROVIDER_REGISTRY, ) import httpx pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id scope = pconfig.scope def _do_nous_device_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, ) as client: return ( _request_device_code( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope, ), scope, ) device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( None, _do_nous_device_request ) sid, sess = _new_oauth_session("nous", "device_code") sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) sess["portal_base_url"] = portal_base_url sess["client_id"] = client_id sess["scope"] = effective_scope threading.Thread( target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}" ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri_complete"]), "expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]), } if provider_id == "openai-codex": # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. sid, _ = _new_oauth_session("openai-codex", "device_code") # Use the helper but in a thread because it polls inline. # We can't extract just the start step without refactoring auth.py, # so we run the full helper in a worker and proxy the user_code + # verification_url back via the session dict. The helper prints # to stdout — we capture nothing here, just status. threading.Thread( target=_codex_full_login_worker, args=(sid,), daemon=True, name=f"oauth-codex-{sid[:6]}", ).start() # Block briefly until the worker has populated the user_code, OR error. deadline = time.monotonic() + 10 while time.monotonic() < deadline: with _oauth_sessions_lock: s = _oauth_sessions.get(sid) if s and (s.get("user_code") or s["status"] != "pending"): break await asyncio.sleep(0.1) with _oauth_sessions_lock: s = _oauth_sessions.get(sid, {}) if s.get("status") == "error": raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed") if not s.get("user_code"): raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code") return { "session_id": sid, "flow": "device_code", "user_code": s["user_code"], "verification_url": s["verification_url"], "expires_in": int(s.get("expires_in") or 900), "poll_interval": int(s.get("interval") or 5), } if provider_id == "minimax-oauth": # MiniMax uses a device-code-style flow (verification URI + user # code + background poll) with a PKCE extension on top. From the # operator's perspective it's identical to Nous's device-code # flow; the PKCE bit (verifier + challenge from # _minimax_pkce_pair) is a security extension that binds the # token exchange to the original session. from hermes_cli.auth import ( _minimax_pkce_pair, _minimax_request_user_code, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_GLOBAL_BASE, ) import httpx verifier, challenge, state = _minimax_pkce_pair() portal_base_url = ( os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE ).rstrip("/") def _do_minimax_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True, ) as client: return _minimax_request_user_code( client=client, portal_base_url=portal_base_url, client_id=MINIMAX_OAUTH_CLIENT_ID, code_challenge=challenge, state=state, ) device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) sid, sess = _new_oauth_session("minimax-oauth", "device_code") # The CLI flow names this `interval_ms` because MiniMax's # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). interval_raw = device_data.get("interval") sess["interval_ms"] = ( int(interval_raw) if interval_raw is not None else None ) sess["user_code"] = str(device_data["user_code"]) sess["code_verifier"] = verifier sess["state"] = state sess["portal_base_url"] = portal_base_url sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID sess["region"] = "global" # `expired_in` from MiniMax is overloaded — could be a unix-ms # timestamp OR a seconds-from-now duration. Mirror the heuristic # in _minimax_poll_token. Stash the raw value for the poller; # compute a derived expires_at + UI-friendly expires_in seconds. expired_in_raw = int(device_data["expired_in"]) sess["expired_in_raw"] = expired_in_raw if expired_in_raw > 1_000_000_000_000: # likely unix-ms expires_at_ts = expired_in_raw / 1000.0 expires_in_seconds = max(0, int(expires_at_ts - time.time())) else: expires_at_ts = time.time() + expired_in_raw expires_in_seconds = expired_in_raw sess["expires_at"] = expires_at_ts threading.Thread( target=_minimax_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}", ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri"]), "expires_in": expires_in_seconds, "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000), } raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") # xAI Grok OAuth uses a loopback-redirect PKCE flow (RFC 8252). Unlike the # device-code providers there is no user_code to display: the local backend # binds a 127.0.0.1 callback server, the client opens the authorize URL in # the browser, and the redirect lands back on the loopback listener. The # background worker waits for that callback, exchanges the code, and persists # the tokens exactly like `hermes auth add xai-oauth`. _XAI_LOOPBACK_TIMEOUT_SECONDS = 300.0 def _start_xai_loopback_flow() -> Dict[str, Any]: """Begin the xAI loopback PKCE flow. Binds the local callback server, builds the authorize URL, and spawns a background worker that waits for the redirect and finishes the exchange. Returns the authorize URL for the client to open in the browser. """ from hermes_cli import auth as hauth discovery = hauth._xai_oauth_discovery() server, thread, callback_result, redirect_uri = hauth._xai_start_callback_server() try: hauth._xai_validate_loopback_redirect_uri(redirect_uri) verifier = hauth._oauth_pkce_code_verifier() challenge = hauth._oauth_pkce_code_challenge(verifier) state = secrets.token_hex(16) nonce = secrets.token_hex(16) authorize_url = hauth._xai_oauth_build_authorize_url( authorization_endpoint=discovery["authorization_endpoint"], redirect_uri=redirect_uri, code_challenge=challenge, state=state, nonce=nonce, ) except Exception: # Binding succeeded but URL construction failed — release the socket # and join the serving thread so we don't leak a listener (or a # lingering daemon thread) on the loopback port. try: server.shutdown() server.server_close() except Exception: pass try: thread.join(timeout=1.0) except Exception: pass raise sid, sess = _new_oauth_session("xai-oauth", "loopback") sess["server"] = server sess["thread"] = thread sess["callback_result"] = callback_result sess["redirect_uri"] = redirect_uri sess["verifier"] = verifier sess["challenge"] = challenge sess["state"] = state sess["token_endpoint"] = discovery["token_endpoint"] sess["discovery"] = discovery sess["expires_at"] = time.time() + _XAI_LOOPBACK_TIMEOUT_SECONDS threading.Thread( target=_xai_loopback_worker, args=(sid,), daemon=True, name=f"oauth-xai-{sid[:6]}", ).start() return { "session_id": sid, "flow": "loopback", "auth_url": authorize_url, "expires_in": int(_XAI_LOOPBACK_TIMEOUT_SECONDS), } def _xai_loopback_worker(session_id: str) -> None: """Wait for the xAI loopback callback, exchange the code, persist tokens.""" from datetime import datetime, timezone from hermes_cli import auth as hauth with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return def _fail(message: str) -> None: with _oauth_sessions_lock: s = _oauth_sessions.get(session_id) if s is not None: s["status"] = "error" s["error_message"] = message def _cancelled() -> bool: # The session is removed from the registry when the user cancels # (DELETE /sessions/{id}). If that happened while we were blocked on # the callback or token exchange, abort instead of persisting tokens # the user no longer wants. with _oauth_sessions_lock: return session_id not in _oauth_sessions try: callback = hauth._xai_wait_for_callback( sess["server"], sess["thread"], sess["callback_result"], timeout_seconds=_XAI_LOOPBACK_TIMEOUT_SECONDS, ) except Exception as exc: _fail(f"xAI authorization timed out: {exc}") return if _cancelled(): return if callback.get("error"): detail = callback.get("error_description") or callback["error"] _fail(f"xAI authorization failed: {detail}") return if callback.get("state") != sess["state"]: _fail("xAI authorization failed: state mismatch.") return code = str(callback.get("code") or "").strip() if not code: _fail("xAI authorization failed: missing authorization code.") return try: payload = hauth._xai_oauth_exchange_code_for_tokens( token_endpoint=sess["token_endpoint"], code=code, redirect_uri=sess["redirect_uri"], code_verifier=sess["verifier"], code_challenge=sess["challenge"], ) access_token = str(payload.get("access_token", "") or "").strip() refresh_token = str(payload.get("refresh_token", "") or "").strip() if not access_token or not refresh_token: _fail("xAI token exchange did not return the expected tokens.") return base_url = hauth._xai_validate_inference_base_url( os.getenv("HERMES_XAI_BASE_URL", "").strip().rstrip("/") or os.getenv("XAI_BASE_URL", "").strip().rstrip("/"), fallback=hauth.DEFAULT_XAI_OAUTH_BASE_URL, ) last_refresh = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") tokens = { "access_token": access_token, "refresh_token": refresh_token, "id_token": str(payload.get("id_token", "") or "").strip(), "expires_in": payload.get("expires_in"), "token_type": str(payload.get("token_type") or "Bearer").strip() or "Bearer", } if _cancelled(): return hauth._save_xai_oauth_tokens( tokens, discovery=sess.get("discovery"), redirect_uri=sess["redirect_uri"], last_refresh=last_refresh, ) _add_xai_oauth_pool_entry(access_token, refresh_token, base_url, last_refresh) except Exception as exc: _fail(f"xAI token exchange failed: {exc}") return with _oauth_sessions_lock: s = _oauth_sessions.get(session_id) if s is not None: s["status"] = "approved" _log.info("oauth/loopback: xai-oauth login completed (session=%s)", session_id) def _add_xai_oauth_pool_entry( access_token: str, refresh_token: str, base_url: str, last_refresh: str ) -> None: """Mirror `hermes auth add xai-oauth`'s credential-pool insert. Best-effort: the auth-store write in _save_xai_oauth_tokens is the source of truth for runtime resolution; the pool entry only matters for the rotation strategy. """ try: import uuid from agent.credential_pool import ( PooledCredential, load_pool, AUTH_TYPE_OAUTH, SOURCE_MANUAL, ) pool = load_pool("xai-oauth") existing = [ e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_xai_pkce") ] for e in existing: try: pool.remove_entry(getattr(e, "id", "")) except Exception: pass entry = PooledCredential( provider="xai-oauth", id=uuid.uuid4().hex[:6], label="dashboard PKCE", auth_type=AUTH_TYPE_OAUTH, priority=0, source=f"{SOURCE_MANUAL}:dashboard_xai_pkce", access_token=access_token, refresh_token=refresh_token, base_url=base_url, last_refresh=last_refresh, ) pool.add_entry(entry) except Exception as e: _log.warning("xai-oauth pool add (dashboard) failed: %s", e) def _nous_poller(session_id: str) -> None: """Background poller that drives a Nous device-code flow to completion.""" from hermes_cli.auth import ( _poll_for_token, refresh_nous_oauth_from_state, ) from datetime import datetime, timezone import httpx with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return portal_base_url = sess["portal_base_url"] client_id = sess["client_id"] device_code = sess["device_code"] interval = sess["interval"] scope = sess.get("scope") expires_in = max(60, int(sess["expires_at"] - time.time())) try: with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: token_data = _poll_for_token( client=client, portal_base_url=portal_base_url, client_id=client_id, device_code=device_code, expires_in=expires_in, poll_interval=interval, ) # Same post-processing as _nous_device_code_login (validate/refresh JWT) now = datetime.now(timezone.utc) token_ttl = int(token_data.get("expires_in") or 0) auth_state = { "portal_base_url": portal_base_url, "inference_base_url": token_data.get("inference_base_url"), "client_id": client_id, "scope": token_data.get("scope") or scope, "token_type": token_data.get("token_type", "Bearer"), "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), "obtained_at": now.isoformat(), "expires_at": ( datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat() if token_ttl else None ), "expires_in": token_ttl, } full_state = refresh_nous_oauth_from_state( auth_state, timeout_seconds=15.0, force_refresh=False, ) from hermes_cli.auth import persist_nous_credentials persist_nous_credentials(full_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: nous login completed (session=%s)", session_id) except Exception as e: _log.warning("nous device-code poll failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = str(e) def _minimax_poller(session_id: str) -> None: """Background poller that drives a MiniMax OAuth flow to completion. Mirrors `_nous_poller` but calls the MiniMax-specific token endpoint, which uses a PKCE-style ``code_verifier`` + ``user_code`` rather than the ``device_code`` field used by Nous. On success, builds the same auth_state dict that ``_minimax_oauth_login`` (the CLI flow) builds and persists via ``_minimax_save_auth_state`` — so the dashboard path leaves the system in the same state as ``hermes auth add minimax-oauth``. """ from hermes_cli.auth import ( _minimax_poll_token, _minimax_resolve_token_expiry_unix, _minimax_save_auth_state, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE, ) from datetime import datetime, timezone import httpx with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return portal_base_url = sess["portal_base_url"] client_id = sess["client_id"] user_code = sess["user_code"] code_verifier = sess["code_verifier"] interval_ms = sess.get("interval_ms") expired_in_raw = sess["expired_in_raw"] try: with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True, ) as client: token_data = _minimax_poll_token( client=client, portal_base_url=portal_base_url, client_id=client_id, user_code=user_code, code_verifier=code_verifier, expired_in=expired_in_raw, interval_ms=interval_ms, ) # Build the auth_state dict in the same shape as the CLI flow's # `_minimax_oauth_login` so `_minimax_save_auth_state` writes # the canonical record. Region is fixed to "global" for the # dashboard path; cn-region operators can still use the CLI # flow which supports `--region cn`. now = datetime.now(timezone.utc) expires_at_ts = _minimax_resolve_token_expiry_unix( int(token_data["expired_in"]), now=now, ) expires_in_s = max(0, int(expires_at_ts - now.timestamp())) auth_state = { "provider": "minimax-oauth", "region": sess.get("region", "global"), "portal_base_url": portal_base_url, "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, "client_id": client_id, "scope": MINIMAX_OAUTH_SCOPE, "token_type": token_data.get("token_type", "Bearer"), "access_token": token_data["access_token"], "refresh_token": token_data["refresh_token"], "resource_url": token_data.get("resource_url"), "obtained_at": now.isoformat(), "expires_at": datetime.fromtimestamp( expires_at_ts, tz=timezone.utc ).isoformat(), "expires_in": expires_in_s, } _minimax_save_auth_state(auth_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: minimax login completed (session=%s)", session_id) except Exception as e: _log.warning("minimax device-code poll failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = str(e) def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. Codex doesn't use the standard OAuth device-code endpoints; it has its own ``/api/accounts/deviceauth/usercode`` (JSON body, returns ``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body polled until 200). On success the response carries an ``authorization_code`` + ``code_verifier`` that get exchanged at CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code. The flow is replicated inline (rather than calling _codex_device_code_login) because that helper prints/blocks/polls in a single function — we need to surface the user_code to the dashboard the moment we receive it, well before polling completes. """ try: import httpx from hermes_cli.auth import ( CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, DEFAULT_CODEX_BASE_URL, ) issuer = "https://auth.openai.com" # Step 1: request device code with httpx.Client(timeout=httpx.Timeout(15.0)) as client: resp = client.post( f"{issuer}/api/accounts/deviceauth/usercode", json={"client_id": CODEX_OAUTH_CLIENT_ID}, headers={"Content-Type": "application/json"}, ) if resp.status_code != 200: raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}") device_data = resp.json() user_code = device_data.get("user_code", "") device_auth_id = device_data.get("device_auth_id", "") poll_interval = max(3, int(device_data.get("interval", "5"))) if not user_code or not device_auth_id: raise RuntimeError("device-code response missing user_code or device_auth_id") verification_url = f"{issuer}/codex/device" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return sess["user_code"] = user_code sess["verification_url"] = verification_url sess["device_auth_id"] = device_auth_id sess["interval"] = poll_interval sess["expires_in"] = 15 * 60 # OpenAI's effective limit sess["expires_at"] = time.time() + sess["expires_in"] # Step 2: poll until authorized deadline = time.monotonic() + sess["expires_in"] code_resp = None with httpx.Client(timeout=httpx.Timeout(15.0)) as client: while time.monotonic() < deadline: time.sleep(poll_interval) poll = client.post( f"{issuer}/api/accounts/deviceauth/token", json={"device_auth_id": device_auth_id, "user_code": user_code}, headers={"Content-Type": "application/json"}, ) if poll.status_code == 200: code_resp = poll.json() break if poll.status_code in {403, 404}: continue # user hasn't authorized yet raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") if code_resp is None: with _oauth_sessions_lock: sess["status"] = "expired" sess["error_message"] = "Device code expired before approval" return # Step 3: exchange authorization_code for tokens authorization_code = code_resp.get("authorization_code", "") code_verifier = code_resp.get("code_verifier", "") if not authorization_code or not code_verifier: raise RuntimeError("device-auth response missing authorization_code/code_verifier") with httpx.Client(timeout=httpx.Timeout(15.0)) as client: token_resp = client.post( CODEX_OAUTH_TOKEN_URL, data={ "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": f"{issuer}/deviceauth/callback", "client_id": CODEX_OAUTH_CLIENT_ID, "code_verifier": code_verifier, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if token_resp.status_code != 200: raise RuntimeError(f"token exchange returned {token_resp.status_code}") tokens = token_resp.json() access_token = tokens.get("access_token", "") refresh_token = tokens.get("refresh_token", "") if not access_token: raise RuntimeError("token exchange did not return access_token") from hermes_cli.auth import _save_codex_tokens _save_codex_tokens({ "access_token": access_token, "refresh_token": refresh_token, }) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) except Exception as e: _log.warning("codex device-code worker failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: s = _oauth_sessions.get(session_id) if s: s["status"] = "error" s["error_message"] = str(e) @app.post("/api/providers/oauth/{provider_id}/start") async def start_oauth_login(provider_id: str, request: Request): """Initiate an OAuth login flow. Token-protected.""" _require_token(request) _gc_oauth_sessions() valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid: raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id) if catalog_entry["flow"] == "external": raise HTTPException( status_code=400, detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually", ) try: # The pkce branch is gated on provider_id == "anthropic" because # `_start_anthropic_pkce()` is hardcoded to the Anthropic flow. # Routing any other future pkce-flagged provider through it would # silently launch the Anthropic OAuth flow (the bug fixed in this # change for MiniMax). New PKCE providers must add their own # start function and an explicit branch here. if catalog_entry["flow"] == "pkce" and provider_id == "anthropic": return _start_anthropic_pkce() if catalog_entry["flow"] == "device_code": return await _start_device_code_flow(provider_id) if catalog_entry["flow"] == "loopback" and provider_id == "xai-oauth": return await asyncio.get_running_loop().run_in_executor( None, _start_xai_loopback_flow ) except HTTPException: raise except Exception as e: _log.exception("oauth/start %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=400, detail="Unsupported flow") class OAuthSubmitBody(BaseModel): session_id: str code: str @app.post("/api/providers/oauth/{provider_id}/submit") async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): """Submit the auth code for PKCE flows. Token-protected.""" _require_token(request) if provider_id == "anthropic": return await asyncio.get_running_loop().run_in_executor( None, _submit_anthropic_pkce, body.session_id, body.code, ) raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") async def poll_oauth_session(provider_id: str, session_id: str): """Poll a session's status (no auth — read-only state). Shared by the device-code flows (Nous, OpenAI Codex, MiniMax) and the loopback flow (xAI Grok). Both surface progress through the same background-worker-updated ``status`` field, so a single poll endpoint serves them all. """ with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: raise HTTPException(status_code=404, detail="Session not found or expired") if sess["provider"] != provider_id: raise HTTPException(status_code=400, detail="Provider mismatch for session") return { "session_id": session_id, "status": sess["status"], "error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"), } @app.delete("/api/providers/oauth/sessions/{session_id}") async def cancel_oauth_session(session_id: str, request: Request): """Cancel a pending OAuth session. Token-protected.""" _require_token(request) with _oauth_sessions_lock: sess = _oauth_sessions.pop(session_id, None) if sess is None: return {"ok": False, "message": "session not found"} # Loopback sessions own a bound 127.0.0.1 callback server. Without an # explicit shutdown the worker would keep that port held until # _xai_wait_for_callback times out (up to 5 min). Free it immediately so # an orphaned listener can't block a subsequent sign-in attempt. if sess.get("flow") == "loopback": # The worker is blocked in _xai_wait_for_callback, which polls # callback_result rather than the server state. Flag the result as # cancelled so that loop returns on its next tick instead of spinning # until the timeout — otherwise repeated cancel/retry piles up daemon # threads. (_cancelled() in the worker then short-circuits before any # persist.) result = sess.get("callback_result") if isinstance(result, dict): result["error"] = result.get("error") or "cancelled" server = sess.get("server") thread = sess.get("thread") try: if server is not None: server.shutdown() server.server_close() except Exception: pass try: if thread is not None: thread.join(timeout=1.0) except Exception: pass return {"ok": True, "session_id": session_id} # --------------------------------------------------------------------------- # Session detail endpoints # --------------------------------------------------------------------------- def _session_latest_descendant(session_id: str): """Resolve a session id to the newest child leaf session. /model may create child sessions. Dashboard refresh should continue the newest child instead of reopening the old parent. """ from hermes_state import SessionDB def row_get(row, key, index): if isinstance(row, dict): return row.get(key) try: return row[key] except Exception: try: return row[index] except Exception: return None db = SessionDB() try: sid = db.resolve_session_id(session_id) if not sid or not db.get_session(sid): return None, [] conn = ( getattr(db, "conn", None) or getattr(db, "_conn", None) or getattr(db, "connection", None) or getattr(db, "_connection", None) ) rows = [] if conn is not None: raw_rows = conn.execute( "SELECT id, parent_session_id, started_at FROM sessions" ).fetchall() for row in raw_rows: rows.append({ "id": row_get(row, "id", 0), "parent_session_id": row_get(row, "parent_session_id", 1), "started_at": row_get(row, "started_at", 2), }) else: rows = db.list_sessions_rich(limit=10000, offset=0) children = {} for row in rows: rid = row.get("id") parent = row.get("parent_session_id") if rid and parent: children.setdefault(parent, []).append(row) def started(row): try: return float(row.get("started_at") or 0) except Exception: return 0.0 current = sid path = [sid] seen = {sid} while children.get(current): candidates = [r for r in children[current] if r.get("id") not in seen] if not candidates: break candidates.sort(key=started, reverse=True) current = candidates[0]["id"] path.append(current) seen.add(current) return current, path finally: db.close() # CRITICAL — every literal-path route below MUST be declared BEFORE the # templated ``/api/sessions/{session_id}`` family that follows. FastAPI/ # Starlette match routes in registration order, and the ``{session_id}`` # pattern is unconstrained — it would otherwise swallow e.g. # ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or # ``GET /api/sessions/stats`` as "operate on the session with id # 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse, # succeed and delete the wrong row). Same story as the older # ``/api/sessions/search`` endpoint up at line ~1191. If you split or # reorder this block, move every route in it together. class BulkDeleteSessions(BaseModel): ids: List[str] @app.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", ) from hermes_state import SessionDB db = SessionDB() try: deleted = db.delete_sessions(body.ids) return {"ok": True, "deleted": deleted} finally: db.close() @app.get("/api/sessions/empty/count") async def count_empty_sessions_endpoint(): """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. """ from hermes_state import SessionDB db = SessionDB() try: return {"count": db.count_empty_sessions()} finally: db.close() @app.delete("/api/sessions/empty") async def delete_empty_sessions_endpoint(): """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. """ from hermes_state import SessionDB db = SessionDB() try: deleted = db.delete_empty_sessions() return {"ok": True, "deleted": deleted} finally: db.close() @app.get("/api/sessions/stats") async def get_session_stats(): """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. """ from hermes_state import SessionDB db = SessionDB() 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: for s in db.list_sessions_rich(limit=10000, include_archived=True): src = str(s.get("source") or "cli") by_source[src] = by_source.get(src, 0) + 1 except Exception: pass return { "total": total, "active_store": active_store, "archived": archived, "messages": messages, "by_source": by_source, } finally: db.close() @app.get("/api/sessions/{session_id}") async def get_session_detail(session_id: str): from hermes_state import SessionDB db = SessionDB() 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") return session finally: db.close() @app.get("/api/sessions/{session_id}/latest-descendant") async def get_session_latest_descendant(session_id: str): latest, path = _session_latest_descendant(session_id) 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]), } @app.get("/api/sessions/{session_id}/messages") async def get_session_messages(session_id: str): from hermes_state import SessionDB db = SessionDB() try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") messages = db.get_messages(sid) return {"session_id": sid, "messages": messages} finally: db.close() @app.delete("/api/sessions/{session_id}") async def delete_session_endpoint(session_id: str): from hermes_state import SessionDB db = SessionDB() try: if not db.delete_session(session_id): raise HTTPException(status_code=404, detail="Session not found") return {"ok": True} finally: db.close() class SessionRename(BaseModel): title: Optional[str] = None archived: Optional[bool] = None @app.patch("/api/sessions/{session_id}") async def rename_session_endpoint(session_id: str, body: SessionRename): """Update a session: rename (or clear its title) and/or archive it. ``title`` renames (empty/null clears the title); ``archived`` soft-hides or restores the session. Either field may be omitted. """ from hermes_state import SessionDB db = SessionDB() 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: raise HTTPException( status_code=400, detail="Nothing to update; provide 'title' and/or 'archived'.", ) 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) result = {"ok": True, "title": db.get_session_title(sid) or ""} if body.archived is not None: result["archived"] = bool(body.archived) return result finally: db.close() @app.get("/api/sessions/{session_id}/export") async def export_session_endpoint(session_id: str): """Export a single session (metadata + messages) as JSON.""" from hermes_state import SessionDB db = SessionDB() try: sid = db.resolve_session_id(session_id) if not sid: raise HTTPException(status_code=404, detail="Session not found") data = db.export_session(sid) if data is None: raise HTTPException(status_code=404, detail="Session not found") return data finally: db.close() class SessionPrune(BaseModel): older_than_days: int = 90 source: Optional[str] = None @app.post("/api/sessions/prune") async def prune_sessions_endpoint(body: SessionPrune): """Delete ended sessions older than N days (mirrors `hermes sessions prune`).""" if body.older_than_days < 1: raise HTTPException(status_code=400, detail="older_than_days must be >= 1") from hermes_state import SessionDB db = SessionDB() try: sessions_dir = get_hermes_home() / "sessions" removed = db.prune_sessions( older_than_days=body.older_than_days, source=(body.source or None), sessions_dir=sessions_dir if sessions_dir.exists() else None, ) return {"ok": True, "removed": removed} finally: db.close() # --------------------------------------------------------------------------- # Log viewer endpoint # --------------------------------------------------------------------------- @app.get("/api/logs") async def get_logs( file: str = "agent", lines: int = 100, level: Optional[str] = None, component: Optional[str] = None, search: Optional[str] = None, ): from hermes_cli.logs import _read_tail, LOG_FILES log_name = LOG_FILES.get(file) if not log_name: raise HTTPException(status_code=400, detail=f"Unknown log file: {file}") log_path = get_hermes_home() / "logs" / log_name if not log_path.exists(): return {"file": file, "lines": []} try: from hermes_logging import COMPONENT_PREFIXES except ImportError: COMPONENT_PREFIXES = {} # Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an # empty tuple as "must match a prefix" (startswith(()) is always False), # so passing () instead of None silently drops every line. min_level = level if level and level.upper() != "ALL" else None if component and component.lower() != "all": comp_prefixes = COMPONENT_PREFIXES.get(component) if comp_prefixes is None: raise HTTPException( status_code=400, detail=f"Unknown component: {component}. " f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", ) else: comp_prefixes = None has_filters = bool(min_level or comp_prefixes or search) result = _read_tail( log_path, min(lines, 500) if not search else 2000, has_filters=has_filters, min_level=min_level, component_prefixes=comp_prefixes, ) # Post-filter by search term (case-insensitive substring match). # _read_tail doesn't support free-text search, so we filter here and # trim to the requested line count afterward. if search: needle = search.lower() result = [l for l in result if needle in l.lower()][-min(lines, 500):] return {"file": file, "lines": result} # --------------------------------------------------------------------------- # Cron job management endpoints # --------------------------------------------------------------------------- class CronJobCreate(BaseModel): prompt: str schedule: str name: str = "" deliver: str = "local" class CronJobUpdate(BaseModel): updates: dict _CRON_PROFILE_LOCK = threading.RLock() def _cron_profile_dicts() -> List[Dict[str, Any]]: """Return dashboard profile records, falling back to a directory scan.""" from hermes_cli import profiles as profiles_mod try: return [_profile_to_dict(p) for p in profiles_mod.list_profiles()] except Exception: _log.exception("Failed to list profiles for cron dashboard; falling back to directory scan") return _fallback_profile_dicts(profiles_mod) def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]: """Resolve a profile query value to (profile_name, HERMES_HOME).""" from hermes_cli import profiles as profiles_mod raw = (profile or "default").strip() or "default" try: canon = profiles_mod.normalize_profile_name(raw) profiles_mod.validate_profile_name(canon) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not profiles_mod.profile_exists(canon): raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.") return canon, profiles_mod.get_profile_dir(canon) def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]: annotated = dict(job) annotated["profile"] = profile annotated["profile_name"] = profile annotated["hermes_home"] = str(home) annotated["is_default_profile"] = profile == "default" return annotated def _call_cron_for_profile(profile: Optional[str], func_name: str, *args, **kwargs): """Run cron.jobs helpers against the selected profile's cron directory. cron.jobs keeps CRON_DIR/JOBS_FILE/OUTPUT_DIR as module globals resolved from the process HERMES_HOME at import time. The dashboard is a single process that can inspect many profiles, so temporarily retarget those globals while holding a lock and restore them immediately after the call. """ profile_name, home = _cron_profile_home(profile) with _CRON_PROFILE_LOCK: from cron import jobs as cron_jobs old_cron_dir = cron_jobs.CRON_DIR old_jobs_file = cron_jobs.JOBS_FILE old_output_dir = cron_jobs.OUTPUT_DIR cron_jobs.CRON_DIR = home / "cron" cron_jobs.JOBS_FILE = cron_jobs.CRON_DIR / "jobs.json" cron_jobs.OUTPUT_DIR = cron_jobs.CRON_DIR / "output" try: result = getattr(cron_jobs, func_name)(*args, **kwargs) finally: cron_jobs.CRON_DIR = old_cron_dir cron_jobs.JOBS_FILE = old_jobs_file cron_jobs.OUTPUT_DIR = old_output_dir if isinstance(result, list): return [_annotate_cron_job(j, profile_name, home) for j in result] if isinstance(result, dict): return _annotate_cron_job(result, profile_name, home) return result def _find_cron_job_profile(job_id: str) -> Optional[str]: for profile in _cron_profile_dicts(): name = str(profile.get("name") or "") if not name: continue jobs = _call_cron_for_profile(name, "list_jobs", True) if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs): return name return None @app.get("/api/cron/jobs") async def list_cron_jobs(profile: str = "all"): requested = (profile or "all").strip() if requested.lower() != "all": return _call_cron_for_profile(requested, "list_jobs", True) jobs: List[Dict[str, Any]] = [] for item in _cron_profile_dicts(): name = str(item.get("name") or "") if not name: continue try: jobs.extend(_call_cron_for_profile(name, "list_jobs", True)) except Exception: _log.exception("Failed to list cron jobs for profile %s", name) return jobs @app.get("/api/cron/jobs/{job_id}") async def get_cron_job(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "get_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job @app.post("/api/cron/jobs") async def create_cron_job(body: CronJobCreate, profile: str = "default"): try: return _call_cron_for_profile( profile, "create_job", prompt=body.prompt, schedule=body.schedule, name=body.name, deliver=body.deliver, ) except Exception as e: _log.exception("POST /api/cron/jobs failed") raise HTTPException(status_code=400, detail=str(e)) @app.put("/api/cron/jobs/{job_id}") async def update_cron_job(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") try: job = _call_cron_for_profile(selected, "update_job", job_id, body.updates) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not job: raise HTTPException(status_code=404, detail="Job not found") return job @app.post("/api/cron/jobs/{job_id}/pause") async def pause_cron_job(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "pause_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job @app.post("/api/cron/jobs/{job_id}/resume") async def resume_cron_job(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "resume_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job @app.post("/api/cron/jobs/{job_id}/trigger") async def trigger_cron_job(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "trigger_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job @app.delete("/api/cron/jobs/{job_id}") async def delete_cron_job(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") try: removed = _call_cron_for_profile(selected, "remove_job", job_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not removed: raise HTTPException(status_code=404, detail="Job not found") return {"ok": True} # --------------------------------------------------------------------------- # MCP server endpoints — list / add / remove / test. # # Wraps the same config data layer the CLI uses (hermes_cli.mcp_config), so # servers managed here show up under `hermes mcp list` and vice versa. Secrets # in stdio `env` blocks are redacted on read; the agent picks them up from # config.yaml at session start exactly as with CLI-added servers. # --------------------------------------------------------------------------- class MCPServerCreate(BaseModel): name: str url: Optional[str] = None command: Optional[str] = None args: List[str] = [] # env: KEY=VALUE map for stdio servers (API keys, etc.) env: Dict[str, str] = {} # auth: "oauth" | "header" | None auth: Optional[str] = None def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]: """Mask secret-shaped MCP env values for read responses.""" out: Dict[str, str] = {} for k, v in (env or {}).items(): try: out[str(k)] = redact_key(str(v)) if v else "" except Exception: out[str(k)] = "***" return out def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown") return { "name": name, "transport": transport, "url": cfg.get("url"), "command": cfg.get("command"), "args": list(cfg.get("args") or []), "env": _redact_mcp_env(cfg.get("env") or {}), "auth": cfg.get("auth"), "enabled": cfg.get("enabled", True) is not False, # Tool selection: list of enabled tool names, or None = all. "tools": cfg.get("tools"), } @app.get("/api/mcp/servers") async def list_mcp_servers(): from hermes_cli.mcp_config import _get_mcp_servers servers = _get_mcp_servers() return { "servers": [ _mcp_server_summary(name, cfg) for name, cfg in sorted(servers.items()) ] } @app.post("/api/mcp/servers") async def add_mcp_server(body: MCPServerCreate): from hermes_cli.mcp_config import _get_mcp_servers, _save_mcp_server name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="Server name is required") if name in _get_mcp_servers(): raise HTTPException(status_code=409, detail=f"Server '{name}' already exists") if not body.url and not body.command: raise HTTPException( status_code=400, detail="Provide either a URL (HTTP/SSE server) or a command (stdio server)", ) server_config: Dict[str, Any] = {} if body.url: server_config["url"] = body.url.strip() if body.command: server_config["command"] = body.command.strip() if body.args: server_config["args"] = list(body.args) if body.env: server_config["env"] = dict(body.env) if body.auth: server_config["auth"] = body.auth try: _save_mcp_server(name, server_config) 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) @app.delete("/api/mcp/servers/{name}") async def remove_mcp_server(name: str): from hermes_cli.mcp_config import _remove_mcp_server if not _remove_mcp_server(name): raise HTTPException(status_code=404, detail=f"Server '{name}' not found") return {"ok": True} @app.post("/api/mcp/servers/{name}/test") async def test_mcp_server(name: str): """Connect to the server, list its tools, disconnect. Returns tool list.""" from hermes_cli.mcp_config import _get_mcp_servers, _probe_single_server servers = _get_mcp_servers() if name not in servers: raise HTTPException(status_code=404, detail=f"Server '{name}' not found") try: # Probe blocks on a dedicated MCP event loop — run in a thread so the # FastAPI event loop is never blocked. tools = await asyncio.to_thread(_probe_single_server, name, servers[name]) except Exception as exc: return { "ok": False, "error": str(exc), "tools": [], } return { "ok": True, "tools": [{"name": t, "description": d} for t, d in tools], } class MCPEnabledToggle(BaseModel): enabled: bool @app.put("/api/mcp/servers/{name}/enabled") async def set_mcp_server_enabled(name: str, body: MCPEnabledToggle): """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. """ 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)} @app.get("/api/mcp/catalog") async def list_mcp_catalog(): """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. """ 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: for entry in mcp_catalog.list_catalog(): auth = entry.auth entries.append({ "name": entry.name, "description": entry.description, "source": entry.source, "transport": entry.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 [] ], "needs_install": entry.install is not None, "installed": mcp_catalog.is_installed(entry.name), "enabled": mcp_catalog.is_enabled(entry.name), }) 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} class MCPCatalogInstall(BaseModel): name: str # env: KEY=VALUE map for catalog entries that declare required env vars. env: Dict[str, str] = {} enable: bool = True @app.post("/api/mcp/catalog/install") async def install_mcp_catalog_entry(body: MCPCatalogInstall): """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). if body.env: 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. if entry.install is not None: try: proc = _spawn_hermes_action(["mcp", "install", name], "mcp-install") except Exception as exc: raise HTTPException(status_code=500, detail=f"Install failed: {exc}") return {"ok": True, "name": name, "background": True, "action": "mcp-install"} # No git step — install synchronously via the catalog API. try: await asyncio.to_thread(mcp_catalog.install_entry, entry, enable=body.enable) 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} # Register the mcp-install action log so /api/actions/mcp-install/status works. _ACTION_LOG_FILES.setdefault("mcp-install", "action-mcp-install.log") # --------------------------------------------------------------------------- # 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. # --------------------------------------------------------------------------- class PairingApprove(BaseModel): platform: str code: str class PairingRevoke(BaseModel): platform: str user_id: str def _pairing_store(): from gateway.pairing import PairingStore return PairingStore() @app.get("/api/pairing") async def list_pairing(): store = _pairing_store() return { "pending": store.list_pending(), "approved": store.list_approved(), } @app.post("/api/pairing/approve") async def approve_pairing(body: PairingApprove): store = _pairing_store() platform = (body.platform or "").lower().strip() code = (body.code or "").upper().strip() if not platform or not code: raise HTTPException(status_code=400, detail="platform and code are required") result = store.approve_code(platform, code) if result: return {"ok": True, "user": result} if 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"Code '{code}' not found or expired for platform '{platform}'.", ) @app.post("/api/pairing/revoke") async def revoke_pairing(body: PairingRevoke): store = _pairing_store() 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}.", ) @app.post("/api/pairing/clear-pending") async def clear_pending_pairing(): store = _pairing_store() 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. # --------------------------------------------------------------------------- class WebhookCreate(BaseModel): name: str description: Optional[str] = None events: List[str] = [] prompt: Optional[str] = None skills: List[str] = [] deliver: str = "log" deliver_only: bool = False deliver_chat_id: Optional[str] = None # secret: omit to auto-generate secret: Optional[str] = None 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", ""), "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, } @app.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() ], } @app.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 in messaging settings 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.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 @app.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} class WebhookEnabledToggle(BaseModel): enabled: bool @app.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 ` so behaviour matches the CLI exactly. # Status is already surfaced by /api/status (gateway_running/state/platforms). # --------------------------------------------------------------------------- @app.post("/api/gateway/start") async def start_gateway(): try: proc = _spawn_hermes_action(["gateway", "start"], "gateway-start") except Exception as exc: _log.exception("Failed to spawn gateway start") raise HTTPException(status_code=500, detail=f"Failed to start gateway: {exc}") return {"ok": True, "pid": proc.pid, "name": "gateway-start"} @app.post("/api/gateway/stop") async def stop_gateway(): try: proc = _spawn_hermes_action(["gateway", "stop"], "gateway-stop") except Exception as exc: _log.exception("Failed to spawn gateway stop") raise HTTPException(status_code=500, detail=f"Failed to stop gateway: {exc}") return {"ok": True, "pid": proc.pid, "name": "gateway-stop"} # --------------------------------------------------------------------------- # Credential pool endpoints — list / add / remove rotation keys. # # The credential pool (auth.json -> credential_pool.[]) 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. # --------------------------------------------------------------------------- class CredentialPoolAdd(BaseModel): provider: str # api_key for API-key providers; OAuth pooling stays CLI-only (it needs # an interactive browser flow that doesn't belong in a single POST). api_key: str label: Optional[str] = None 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)), } @app.get("/api/credentials/pool") async def list_credential_pool(): from agent.credential_pool import load_pool from hermes_cli.auth import read_credential_pool 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} @app.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, 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") 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) except Exception as exc: _log.exception("POST /api/credentials/pool failed") raise HTTPException(status_code=400, detail=str(exc)) from exc return {"ok": True, "provider": provider, "count": len(pool.entries())} @app.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).""" from agent.credential_pool import load_pool provider = (provider or "").strip().lower() 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") return {"ok": True, "provider": provider, "count": len(pool.entries())} # --------------------------------------------------------------------------- # Memory provider endpoints — status / list providers / select / disable / reset. # # Selecting a provider only writes config.memory.provider (full interactive # provider setup, with its API-key prompts, stays on the CLI via # `hermes memory setup`). The dashboard covers the common admin actions: # see which provider is active, switch the built-in store on/off, and wipe # built-in memory files. # --------------------------------------------------------------------------- class MemoryProviderSelect(BaseModel): # "" or "built-in" disables the external provider (built-in only). provider: str class MemoryReset(BaseModel): # "all" | "memory" | "user" target: str = "all" @app.get("/api/memory") async def get_memory_status(): from plugins.memory import discover_memory_providers cfg = load_config() active = "" mem = cfg.get("memory") if isinstance(mem, dict): active = str(mem.get("provider") or "") providers = [] try: for name, description, configured in discover_memory_providers(): providers.append({ "name": name, "description": description, "configured": bool(configured), }) except Exception: _log.exception("discover_memory_providers failed") # 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": providers, "builtin_files": files, } @app.put("/api/memory/provider") async def set_memory_provider(body: MemoryProviderSelect): provider = (body.provider or "").strip() if provider.lower() in {"built-in", "builtin", "none"}: provider = "" if provider: from plugins.memory import discover_memory_providers valid = {name for name, _d, _c in discover_memory_providers()} if provider not in valid: raise HTTPException( status_code=400, detail=f"Unknown memory provider '{provider}'. Run `hermes memory setup` to configure a new one.", ) cfg = load_config() if not isinstance(cfg.get("memory"), dict): cfg["memory"] = {} cfg["memory"]["provider"] = provider save_config(cfg) return {"ok": True, "active": provider} @app.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. # --------------------------------------------------------------------------- @app.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"} @app.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"} class BackupRequest(BaseModel): # Optional output path; defaults to a timestamped zip in the home dir. output: Optional[str] = None @app.post("/api/ops/backup") async def run_backup(body: BackupRequest): args = ["backup"] if body.output: args.append(body.output.strip()) 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}") return {"ok": True, "pid": proc.pid, "name": "backup"} class ImportRequest(BaseModel): archive: str @app.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}") try: proc = _spawn_hermes_action(["import", archive], "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"} @app.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. """ 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} class HookCreate(BaseModel): event: str command: str matcher: Optional[str] = None timeout: Optional[int] = None # approve: write the consent allowlist entry too (the operator using the # authenticated dashboard is giving consent). Without it the hook is # configured but won't fire until approved. approve: bool = True @app.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 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 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} class HookDelete(BaseModel): event: str command: str @app.delete("/api/ops/hooks") async def delete_hook(body: HookDelete): """Remove a hook from config.yaml and revoke its consent allowlist entry.""" 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") cfg = load_config() hooks_cfg = cfg.get("hooks") removed = False 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 if not removed: raise HTTPException(status_code=404, detail="No matching hook found") return {"ok": True} @app.get("/api/ops/checkpoints") async def list_checkpoints(): """List the /rollback shadow store checkpoints (read-only).""" # Checkpoints live under /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(): for child in sorted(cp_dir.iterdir()): 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} @app.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"} # --------------------------------------------------------------------------- # Skills hub endpoints — search / install / uninstall / update. # # Search and install touch the network (GitHub, hub sources) and run the same # complex source-router pipeline the CLI uses, so they're spawned as background # actions whose logs the dashboard tails. The already-installed skill list + # enable/disable toggle live in the existing /api/skills endpoints. # --------------------------------------------------------------------------- class SkillInstallRequest(BaseModel): identifier: str @app.post("/api/skills/hub/install") async def install_skill_hub(body: SkillInstallRequest): identifier = (body.identifier or "").strip() if not identifier: raise HTTPException(status_code=400, detail="identifier is required") try: proc = _spawn_hermes_action(["skills", "install", identifier], "skills-install") 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": "skills-install"} class SkillUninstallRequest(BaseModel): name: str @app.post("/api/skills/hub/uninstall") async def uninstall_skill_hub(body: SkillUninstallRequest): name = (body.name or "").strip() if not name: raise HTTPException(status_code=400, detail="name is required") try: proc = _spawn_hermes_action(["skills", "uninstall", name, "--yes"], "skills-uninstall") 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": "skills-uninstall"} @app.post("/api/skills/hub/update") async def update_skills_hub(): try: proc = _spawn_hermes_action(["skills", "update"], "skills-update") 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"} @app.get("/api/skills/hub/search") async def search_skills_hub(q: str = "", source: str = "all", limit: int = 20): """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. """ query = (q or "").strip() if not query: return {"results": []} def _run(): from tools.skills_hub import create_source_router, unified_search sources = create_source_router() metas = unified_search( query, sources, source_filter=source or "all", limit=min(max(limit, 1), 50) ) return [ { "name": m.name, "description": m.description, "source": m.source, "identifier": m.identifier, "trust_level": m.trust_level, "repo": m.repo, "tags": list(m.tags or []), } for m in metas ] try: results = await asyncio.to_thread(_run) except Exception as exc: _log.exception("skills hub search failed") raise HTTPException(status_code=502, detail=f"Hub search failed: {exc}") return {"results": results} # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- class ProfileCreate(BaseModel): name: str clone_from_default: bool = False clone_all: bool = False no_skills: bool = False description: Optional[str] = None provider: Optional[str] = None model: Optional[str] = None class ProfileRename(BaseModel): new_name: str class ProfileSoulUpdate(BaseModel): content: str class ProfileActiveUpdate(BaseModel): name: str class ProfileDescriptionUpdate(BaseModel): description: str = "" class ProfileModelUpdate(BaseModel): provider: str model: str class ProfileDescribeAuto(BaseModel): overwrite: bool = False def _profile_attr(info, name: str, default: Any = None) -> Any: try: return getattr(info, name) except Exception: return default def _profile_to_dict(info) -> Dict[str, Any]: return { "name": _profile_attr(info, "name", ""), "path": str(_profile_attr(info, "path", "")), "is_default": bool(_profile_attr(info, "is_default", False)), "model": _profile_attr(info, "model"), "provider": _profile_attr(info, "provider"), "has_env": bool(_profile_attr(info, "has_env", False)), "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), "gateway_running": bool(_profile_attr(info, "gateway_running", False)), "description": _profile_attr(info, "description", "") or "", "description_auto": bool(_profile_attr(info, "description_auto", False)), "distribution_name": _profile_attr(info, "distribution_name"), "distribution_version": _profile_attr(info, "distribution_version"), "distribution_source": _profile_attr(info, "distribution_source"), "has_alias": _profile_attr(info, "alias_path") is not None, } def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: def _safe(callable_, default): try: return callable_() except Exception: return default profiles: List[Dict[str, Any]] = [] default_home = profiles_mod._get_default_hermes_home() if default_home.is_dir(): model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) profiles.append({ "name": "default", "path": str(default_home), "is_default": True, "model": model, "provider": provider, "has_env": (default_home / ".env").exists(), "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), "gateway_running": _safe(lambda: profiles_mod._check_gateway_running(default_home), False), "description": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description", ""), ""), "description_auto": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description_auto", False), False), "distribution_name": None, "distribution_version": None, "distribution_source": None, "has_alias": False, }) profiles_root = profiles_mod._get_profiles_root() if profiles_root.is_dir(): for entry in sorted(profiles_root.iterdir()): if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): continue model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None)) profiles.append({ "name": entry.name, "path": str(entry), "is_default": False, "model": model, "provider": provider, "has_env": (entry / ".env").exists(), "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), "gateway_running": _safe(lambda entry=entry: profiles_mod._check_gateway_running(entry), False), "description": _safe(lambda entry=entry: profiles_mod.read_profile_meta(entry).get("description", ""), ""), "description_auto": _safe(lambda entry=entry: profiles_mod.read_profile_meta(entry).get("description_auto", False), False), "distribution_name": None, "distribution_version": None, "distribution_source": None, "has_alias": False, }) return profiles def _resolve_profile_dir(name: str) -> Path: """Validate ``name`` and resolve to its directory or raise an HTTPException.""" from hermes_cli import profiles as profiles_mod try: profiles_mod.validate_profile_name(name) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not profiles_mod.profile_exists(name): raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.") return profiles_mod.get_profile_dir(name) def _profile_setup_command(name: str) -> str: """Return the shell command used to configure a profile in the CLI.""" _resolve_profile_dir(name) return "hermes setup" if name == "default" else f"{name} setup" def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None: """Write the main model assignment into a specific profile's config.yaml. Scopes ``load_config``/``save_config`` to ``profile_dir`` via the context-local HERMES_HOME override so the write lands in the target profile's config rather than the dashboard process's active profile. Clears any stale ``base_url`` / ``context_length`` the same way ``POST /api/model/set`` does, since the new model may differ. """ from hermes_constants import set_hermes_home_override, reset_hermes_home_override token = set_hermes_home_override(str(profile_dir)) try: cfg = load_config() model_cfg = cfg.get("model", {}) if not isinstance(model_cfg, dict): model_cfg = {} model_cfg["provider"] = provider model_cfg["default"] = model if model_cfg.get("base_url"): model_cfg["base_url"] = "" model_cfg.pop("context_length", None) cfg["model"] = model_cfg save_config(cfg) finally: reset_hermes_home_override(token) @app.get("/api/profiles") async def list_profiles_endpoint(): from hermes_cli import profiles as profiles_mod try: return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} except Exception: _log.exception("GET /api/profiles failed; falling back to profile directory scan") return {"profiles": _fallback_profile_dicts(profiles_mod)} @app.post("/api/profiles") async def create_profile_endpoint(body: ProfileCreate): from hermes_cli import profiles as profiles_mod clone = body.clone_from_default or body.clone_all try: path = profiles_mod.create_profile( name=body.name, clone_from="default" if clone else None, clone_all=body.clone_all, clone_config=body.clone_from_default and not body.clone_all, no_skills=body.no_skills, description=body.description, ) # Match the CLI's profile-create flow: fresh named profiles get the # bundled skills installed. When cloning from default, create_profile() # has already copied the source profile's skills, including any # user-installed skills. When no_skills=True, create_profile() wrote # the opt-out marker and seed_profile_skills() will no-op. if not clone: profiles_mod.seed_profile_skills(path, quiet=True) # Match the CLI's profile-create flow: named profiles should get a # wrapper in ~/.local/bin when the alias is safe to create. collision = profiles_mod.check_alias_collision(body.name) if not collision: profiles_mod.create_wrapper_script(body.name) except (ValueError, FileExistsError, FileNotFoundError) as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: _log.exception("POST /api/profiles failed") raise HTTPException(status_code=500, detail=str(e)) # Optional explicit model assignment for the new profile. Best-effort: # the profile already exists, so a model-write hiccup must not 500 the # whole create — the user can set the model later from the Models page # or ` setup`. provider = (body.provider or "").strip() model = (body.model or "").strip() model_set = False if provider and model: try: _write_profile_model(path, provider, model) model_set = True except Exception: _log.exception("Setting model for new profile %s failed", body.name) return {"ok": True, "name": body.name, "path": str(path), "model_set": model_set} @app.get("/api/profiles/active") async def get_active_profile_endpoint(): """Return the sticky active profile and the profile this dashboard process is currently running as. ``active`` is the sticky default written by ``hermes profile use`` — the profile new CLI invocations pick up. ``current`` is the profile the running dashboard/gateway is scoped to (derived from HERMES_HOME). """ from hermes_cli import profiles as profiles_mod try: active = profiles_mod.get_active_profile() or "default" except Exception: active = "default" try: current = profiles_mod.get_active_profile_name() or "default" except Exception: current = "default" return {"active": active, "current": current} @app.post("/api/profiles/active") async def set_active_profile_endpoint(body: ProfileActiveUpdate): """Set the sticky active profile (mirrors ``hermes profile use``). Note: this does not retarget the already-running dashboard process — it changes which profile subsequent CLI commands and gateways use. """ from hermes_cli import profiles as profiles_mod try: profiles_mod.set_active_profile(body.name) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: _log.exception("POST /api/profiles/active failed") raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "active": profiles_mod.normalize_profile_name(body.name)} @app.get("/api/profiles/{name}/setup-command") async def get_profile_setup_command(name: str): return {"command": _profile_setup_command(name)} @app.post("/api/profiles/{name}/open-terminal") async def open_profile_terminal_endpoint(name: str): try: command = _profile_setup_command(name) if sys.platform.startswith("win"): subprocess.Popen(["cmd.exe", "/c", "start", "", command]) elif sys.platform == "darwin": escaped = command.replace("\\", "\\\\").replace('"', '\\"') applescript = ( 'tell application "Terminal"\n' "activate\n" f'do script "{escaped}"\n' "end tell" ) subprocess.Popen(["osascript", "-e", applescript]) else: terminal_commands = [ ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), ("konsole", ["konsole", "-e", "sh", "-lc", command]), ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]), ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]), ("tilix", ["tilix", "-e", "sh", "-lc", command]), ("alacritty", ["alacritty", "-e", "sh", "-lc", command]), ("kitty", ["kitty", "sh", "-lc", command]), ("xterm", ["xterm", "-e", "sh", "-lc", command]), ] for executable, popen_args in terminal_commands: if subprocess.call( ["which", executable], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) == 0: subprocess.Popen(popen_args) break else: raise HTTPException( status_code=400, detail="No supported terminal emulator found", ) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except HTTPException: raise except Exception as e: _log.exception("POST /api/profiles/%s/open-terminal failed", name) raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "command": command} @app.patch("/api/profiles/{name}") async def rename_profile_endpoint(name: str, body: ProfileRename): from hermes_cli import profiles as profiles_mod try: path = profiles_mod.rename_profile(name, body.new_name) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except (ValueError, FileExistsError) as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: _log.exception("PATCH /api/profiles/%s failed", name) raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "name": body.new_name, "path": str(path)} @app.delete("/api/profiles/{name}") async def delete_profile_endpoint(name: str): """Delete a profile. The dashboard collects the user's confirmation in its own dialog before this request, so we always pass ``yes=True`` to skip the CLI's interactive prompt.""" from hermes_cli import profiles as profiles_mod try: path = profiles_mod.delete_profile(name, yes=True) except FileNotFoundError as e: raise HTTPException(status_code=404, detail=str(e)) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: _log.exception("DELETE /api/profiles/%s failed", name) raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "path": str(path)} @app.get("/api/profiles/{name}/soul") async def get_profile_soul(name: str): soul_path = _resolve_profile_dir(name) / "SOUL.md" if soul_path.exists(): try: return {"content": soul_path.read_text(encoding="utf-8"), "exists": True} except OSError as e: raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") return {"content": "", "exists": False} @app.put("/api/profiles/{name}/soul") async def update_profile_soul(name: str, body: ProfileSoulUpdate): soul_path = _resolve_profile_dir(name) / "SOUL.md" try: soul_path.write_text(body.content, encoding="utf-8") except OSError as e: _log.exception("PUT /api/profiles/%s/soul failed", name) raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") return {"ok": True} @app.put("/api/profiles/{name}/description") async def update_profile_description_endpoint(name: str, body: ProfileDescriptionUpdate): """Set or clear a profile's role description (kanban routing signal). Empty string clears the description. Non-empty stores it as a user-authored description (``description_auto: false``) so the auto-describer won't overwrite it on a sweep. """ from hermes_cli import profiles as profiles_mod profile_dir = _resolve_profile_dir(name) text = (body.description or "").strip() try: profiles_mod.write_profile_meta( profile_dir, description=text, description_auto=False, ) except Exception as e: _log.exception("PUT /api/profiles/%s/description failed", name) raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "description": text, "description_auto": False} @app.put("/api/profiles/{name}/model") async def update_profile_model_endpoint(name: str, body: ProfileModelUpdate): """Set the main model (``model.default`` + ``model.provider``) for a specific profile's config.yaml, without touching the dashboard's own active profile. Mirrors ``POST /api/model/set`` (main scope) but scoped to the named profile via the HERMES_HOME override. """ profile_dir = _resolve_profile_dir(name) provider = (body.provider or "").strip() model = (body.model or "").strip() if not provider or not model: raise HTTPException(status_code=400, detail="provider and model are required") try: _write_profile_model(profile_dir, provider, model) except Exception as e: _log.exception("PUT /api/profiles/%s/model failed", name) raise HTTPException(status_code=500, detail=str(e)) return {"ok": True, "provider": provider, "model": model} @app.post("/api/profiles/{name}/describe-auto") async def describe_profile_auto_endpoint(name: str, body: ProfileDescribeAuto): """Auto-generate a profile's description via the auxiliary LLM (``auxiliary.profile_describer``). Mirrors ``hermes profile describe --auto``. A failed generation (no aux client, LLM error, …) is returned as ``ok: false`` with a reason rather than an HTTP error so the UI can surface it inline and let the operator fix config and retry. """ _resolve_profile_dir(name) try: from hermes_cli import profile_describer outcome = profile_describer.describe_profile(name, overwrite=bool(body.overwrite)) except Exception as e: _log.exception("POST /api/profiles/%s/describe-auto failed", name) raise HTTPException(status_code=500, detail=str(e)) return { "ok": bool(outcome.ok), "reason": outcome.reason, "description": outcome.description, # Only a successful generation is an auto-authored description. A failed # sweep leaves any existing description untouched, so don't claim it's # auto-generated. "description_auto": bool(outcome.ok), } # --------------------------------------------------------------------------- # Skills & Tools endpoints # --------------------------------------------------------------------------- class SkillToggle(BaseModel): name: str enabled: bool @app.get("/api/skills") async def get_skills(): from tools.skills_tool import _find_all_skills from hermes_cli.skills_config import get_disabled_skills config = load_config() disabled = get_disabled_skills(config) skills = _find_all_skills(skip_disabled=True) for s in skills: s["enabled"] = s["name"] not in disabled return skills @app.put("/api/skills/toggle") async def toggle_skill(body: SkillToggle): from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills 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} @app.get("/api/tools/toolsets") async def get_toolsets(): from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, _toolset_has_keys, ) from toolsets import resolve_toolset config = load_config() enabled_toolsets = _get_platform_tools( config, "cli", include_default_mcp_servers=False, ) result = [] for name, label, desc in _get_effective_configurable_toolsets(): try: tools = sorted(set(resolve_toolset(name))) except Exception: tools = [] is_enabled = name in enabled_toolsets result.append({ "name": name, "label": label, "description": desc, "enabled": is_enabled, "available": is_enabled, "configured": _toolset_has_keys(name, config), "tools": tools, }) return result class ToolsetToggle(BaseModel): enabled: bool @app.put("/api/tools/toolsets/{name}") async def toggle_toolset(name: str, body: ToolsetToggle): """Enable/disable a configurable toolset for the desktop (cli) platform. Persists to ``platform_toolsets.cli`` via the same ``_save_platform_tools`` helper the CLI ``hermes tools`` picker uses, so the GUI and CLI stay in lockstep. Returns 400 for unknown toolset keys. """ from hermes_cli.tools_config import ( _get_effective_configurable_toolsets, _get_platform_tools, _save_platform_tools, ) 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}") config = load_config() enabled = set( _get_platform_tools(config, "cli", include_default_mcp_servers=False) ) if body.enabled: enabled.add(name) else: enabled.discard(name) _save_platform_tools(config, "cli", enabled) return {"ok": True, "name": name, "enabled": body.enabled} @app.get("/api/tools/toolsets/{name}/config") async def get_toolset_config(name: str): """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, ) from hermes_cli.config import get_env_value 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}") config = load_config() cat = TOOL_CATEGORIES.get(name) providers = [] active_provider = None if cat: 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"] providers.append({ "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, }) return { "name": name, "has_category": cat is not None, "providers": providers, "active_provider": active_provider, } class ToolsetProviderSelect(BaseModel): provider: str @app.put("/api/tools/toolsets/{name}/provider") async def select_toolset_provider(name: str, body: ToolsetProviderSelect): """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. """ from hermes_cli.tools_config import ( apply_provider_selection, _get_effective_configurable_toolsets, ) 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}") config = load_config() try: apply_provider_selection(name, body.provider, config) except KeyError as exc: raise HTTPException(status_code=400, detail=str(exc).strip('"')) save_config(config) return {"ok": True, "name": name, "provider": body.provider} # --------------------------------------------------------------------------- # Raw YAML config endpoint # --------------------------------------------------------------------------- class RawConfigUpdate(BaseModel): yaml_text: str @app.get("/api/config/raw") async def get_config_raw(): path = get_config_path() if not path.exists(): return {"yaml": ""} return {"yaml": path.read_text(encoding="utf-8")} @app.put("/api/config/raw") async def update_config_raw(body: RawConfigUpdate): try: parsed = yaml.safe_load(body.yaml_text) if not isinstance(parsed, dict): raise HTTPException(status_code=400, detail="YAML must be a mapping") save_config(parsed) return {"ok": True} except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") # --------------------------------------------------------------------------- # Token / cost analytics endpoint # --------------------------------------------------------------------------- @app.get("/api/analytics/usage") async def get_usage_analytics(days: int = 30): from hermes_state import SessionDB from agent.insights import InsightsEngine db = SessionDB() try: cutoff = time.time() - (days * 86400) cur = db._conn.execute(""" SELECT date(started_at, 'unixepoch') as day, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cache_read_tokens) as cache_read_tokens, SUM(reasoning_tokens) as reasoning_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as actual_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? GROUP BY day ORDER BY day """, (cutoff,)) daily = [dict(r) for r in cur.fetchall()] cur2 = db._conn.execute(""" SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC """, (cutoff,)) by_model = [dict(r) for r in cur2.fetchall()] cur3 = db._conn.execute(""" SELECT SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(cache_read_tokens) as total_cache_read, SUM(reasoning_tokens) as total_reasoning, COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? """, (cutoff,)) totals = dict(cur3.fetchone()) insights_report = InsightsEngine(db).generate(days=days) skills = insights_report.get("skills", { "summary": { "total_skill_loads": 0, "total_skill_edits": 0, "total_skill_actions": 0, "distinct_skills_used": 0, }, "top_skills": [], }) return { "daily": daily, "by_model": by_model, "totals": totals, "period_days": days, "skills": skills, } finally: db.close() @app.get("/api/analytics/models") async def get_models_analytics(days: int = 30): """Rich per-model analytics for the Models dashboard page. Returns token/cost/session breakdown per model plus capability metadata from models.dev (context window, vision, tools, reasoning, etc.). """ from hermes_state import SessionDB db = SessionDB() try: cutoff = time.time() - (days * 86400) cur = db._conn.execute(""" SELECT model, billing_provider, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cache_read_tokens) as cache_read_tokens, SUM(reasoning_tokens) as reasoning_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as actual_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls, SUM(tool_call_count) as tool_calls, MAX(started_at) as last_used_at, AVG(input_tokens + output_tokens) as avg_tokens_per_session FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' GROUP BY model, billing_provider ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC """, (cutoff,)) rows = [dict(r) for r in cur.fetchall()] models = [] for row in rows: provider = row.get("billing_provider") or "" model_name = row["model"] caps = {} try: from agent.models_dev import get_model_capabilities mc = get_model_capabilities(provider=provider, model=model_name) if mc is not None: caps = { "supports_tools": mc.supports_tools, "supports_vision": mc.supports_vision, "supports_reasoning": mc.supports_reasoning, "context_window": mc.context_window, "max_output_tokens": mc.max_output_tokens, "model_family": mc.model_family, } except Exception: pass models.append({ "model": model_name, "provider": provider, "input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"], "cache_read_tokens": row["cache_read_tokens"], "reasoning_tokens": row["reasoning_tokens"], "estimated_cost": row["estimated_cost"], "actual_cost": row["actual_cost"], "sessions": row["sessions"], "api_calls": row["api_calls"], "tool_calls": row["tool_calls"], "last_used_at": row["last_used_at"], "avg_tokens_per_session": row["avg_tokens_per_session"], "capabilities": caps, }) totals_cur = db._conn.execute(""" SELECT COUNT(DISTINCT model) as distinct_models, SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(cache_read_tokens) as total_cache_read, SUM(reasoning_tokens) as total_reasoning, COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' """, (cutoff,)) totals = dict(totals_cur.fetchone()) return { "models": models, "totals": totals, "period_days": days, } finally: db.close() # --------------------------------------------------------------------------- # /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab. # # The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind # a POSIX pseudo-terminal, and forwards bytes + resize escapes across a # WebSocket. The browser renders the ANSI through xterm.js (see # web/src/pages/ChatPage.tsx). # # Auth: ``?token=`` query param (browsers can't set # Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as # REST. Localhost-only — we defensively reject non-loopback clients even # though uvicorn binds to 127.0.0.1. # --------------------------------------------------------------------------- import re # PTY bridge is POSIX-only (depends on fcntl/termios/ptyprocess). On native # Windows the import raises; catch and leave PtyBridge=None so the rest of # the dashboard (sessions, jobs, metrics, config editor) still loads and the # /api/pty endpoint cleanly refuses with a WSL-suggested message. try: from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError _PTY_BRIDGE_AVAILABLE = True except ImportError as _pty_import_err: # pragma: no cover - Windows-only path PtyBridge = None # type: ignore[assignment] _PTY_BRIDGE_AVAILABLE = False class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] """Stub on platforms where pty_bridge can't be imported.""" pass _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") # Starlette's TestClient reports the peer as "testclient"; treat it as # loopback so tests don't need to rewrite request scope. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. Loopback bind: only loopback clients allowed — the legacy ``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we don't want LAN hosts guessing tokens. Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a specific address such as a Tailscale/LAN IP, always with ``--insecure``): allow any peer. The operator explicitly opted into non-loopback exposure, so the loopback-only peer restriction does not apply. DNS-rebinding is still blocked by the Host/Origin guard in :func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and requires the Host header to match the bound interface — the same defence ``_is_accepted_host`` applies to non-loopback HTTP requests. Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True`` (enabled when the OAuth gate is active so cookies can pick up ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the X-Forwarded-For value, which is the real internet client IP. The OAuth gate + single-use ``?ticket=`` is the auth at that point; the Host/Origin guard in :func:`_ws_host_origin_is_allowed` is what blocks DNS-rebinding here, not the peer IP. """ if getattr(app.state, "auth_required", False): return True # Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN / # Tailscale address) means the operator opted into non-loopback # access via --insecure. The loopback-only peer gate only applies to # an actual loopback bind; otherwise the WS handshake is rejected even # though same-bind HTTP requests pass _is_accepted_host. bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() if bound_host and bound_host not in _LOOPBACK_HOSTS: return True client_host = ws.client.host if ws.client else "" if not client_host: return True return client_host in _LOOPBACK_HOSTS def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: """Apply the dashboard Host/Origin guard to WebSocket upgrades. FastAPI HTTP middleware does not run for WebSocket routes, so the DNS-rebinding Host check used for normal dashboard HTTP requests must be repeated here before accepting the upgrade. Browsers also send an Origin header on WebSocket handshakes; when present, require it to target the same bound dashboard host. """ bound_host = getattr(app.state, "bound_host", None) if not bound_host: return True host_header = ws.headers.get("host", "") if not _is_accepted_host(host_header, bound_host): return False origin = ws.headers.get("origin", "") if not origin: return True parsed = urllib.parse.urlparse(origin) if parsed.scheme not in {"http", "https"}: # Packaged Electron loads the desktop renderer over a non-web origin # such as file://, null, or a custom app:// scheme. This helper is # called only AFTER _ws_auth_ok has already accepted the WS credential, # which is the real auth boundary in every mode: # * loopback bind → legacy dashboard session token # * non-loopback --insecure → legacy session token (Tailscale / LAN) # * OAuth-gated public bind → single-use, 30s-TTL, identity-bound # ?ticket= minted at the cookie-authed POST /api/auth/ws-ticket # A non-web origin can only be produced by a native client (the desktop # shell); a DNS-rebinding attack always arrives from an http(s) origin # and is still match-checked against the bound host below. So once the # credential check upstream has passed, the Origin guard adds nothing # for a non-web origin — trust it in every mode. # # (Earlier revisions restricted this to loopback, then to non-gated # binds; both excluded the packaged desktop talking to a remote # OAuth-gated gateway, whose file:// renderer origin then got rejected # at the WS upgrade even with a valid ticket. The ticket is the gate, # not the origin.) return True if not parsed.netloc: return False return _is_accepted_host(parsed.netloc, bound_host) def _ws_request_is_allowed(ws: "WebSocket") -> bool: """Return True when the WebSocket upgrade matches dashboard boundaries.""" return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws) def _ws_auth_ok(ws: "WebSocket") -> bool: """Validate WS-upgrade auth in either loopback or gated mode. Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query parameter, constant-time compared. Gated (public bind, no ``--insecure``): one of two credentials — * ``?ticket=`` — a browser-minted, single-use, 30s-TTL ticket consumed against the dashboard-auth ticket store. This is what the SPA (and native clients) use. * ``?internal=`` — the process-lifetime internal credential, used only by WS clients the server spawns itself (the embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It is multi-use and never expires so the child can reconnect, and is never injected into the SPA — see ``dashboard_auth.ws_tickets`` for the threat model. The legacy ``?token=`` path is unconditionally rejected in gated mode (the SPA bundle isn't carrying the token any longer, and a leaked ``_SESSION_TOKEN`` must not grant WS access once the gate is engaged). Returns True if the WS should be accepted; callers close with the appropriate WS code (4401) on False. Audit-logs the rejection so operators can debug "WS keeps closing" issues from the log. """ auth_required = bool(getattr(app.state, "auth_required", False)) if auth_required: # Lazy import — keeps this function importable in test harnesses # that don't bring in the dashboard_auth layer. from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log from hermes_cli.dashboard_auth.ws_tickets import ( TicketInvalid, consume_internal_credential, consume_ticket, ) # Server-spawned children (PTY child → /api/ws, /api/pub) present the # multi-use internal credential rather than a single-use ticket, so # they survive reconnects and slow cold boots. internal = ws.query_params.get("internal", "") if internal: try: consume_internal_credential(internal) return True except TicketInvalid as exc: audit_log( AuditEvent.WS_TICKET_REJECTED, reason=f"internal: {exc}", ip=(ws.client.host if ws.client else ""), path=ws.url.path, ) return False ticket = ws.query_params.get("ticket", "") if not ticket: return False try: consume_ticket(ticket) return True except TicketInvalid as exc: audit_log( AuditEvent.WS_TICKET_REJECTED, reason=str(exc), ip=(ws.client.host if ws.client else ""), path=ws.url.path, ) return False token = ws.query_params.get("token", "") return hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()) # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. # (State is initialised in _lifespan on app startup — see above.) def _resolve_chat_argv( resume: Optional[str] = None, sidecar_url: Optional[str] = None, ) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve the argv + cwd + env for the chat PTY. Default: whatever ``hermes --tui`` would run. Tests monkeypatch this function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``) so nothing has to build Node or the TUI bundle. Session resume is propagated via the ``HERMES_TUI_RESUME`` env var — matching what ``hermes_cli.main._launch_tui`` does for the CLI path. Appending ``--resume `` to argv doesn't work because ``ui-tui`` does not parse its argv. ``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to this process's in-memory ``tui_gateway`` instance instead of spawning its own Python gateway subprocess. `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). """ from hermes_cli.main import PROJECT_ROOT, _make_tui_argv argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) env = os.environ.copy() env.setdefault("NODE_ENV", "production") # Browser-embedded chat should prefer stable wheel-based scrollback over # native terminal mouse tracking. When mouse tracking is enabled, wheel # events are consumed by the TUI and forwarded as terminal input, which # makes browser-side transcript scrolling feel broken. Keep the terminal # build unchanged for native CLI usage; only disable mouse tracking for # the dashboard PTY path. env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") if resume: latest_resume, _latest_path = _session_latest_descendant(resume) if latest_resume: resume = latest_resume env["HERMES_TUI_RESUME"] = resume if sidecar_url: env["HERMES_TUI_SIDECAR_URL"] = sidecar_url if gateway_ws_url := _build_gateway_ws_url(): env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url return list(argv), str(cwd) if cwd else None, env def _build_gateway_ws_url() -> Optional[str]: """ws:// URL the PTY child should attach to for JSON-RPC gateway traffic. Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``. Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the server-spawned PTY child authenticates with the process-lifetime internal credential (``?internal=``). It must NOT use a single-use browser ticket: the child reads this URL once at startup and reuses it on every reconnect, and a 30s-TTL ticket can expire before a slow cold boot even dials. """ host = getattr(app.state, "bound_host", None) port = getattr(app.state, "bound_port", None) if not host or not port: return None netloc = ( f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" ) if getattr(app.state, "auth_required", False): from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential qs = urllib.parse.urlencode({"internal": internal_ws_credential()}) else: qs = urllib.parse.urlencode({"token": _SESSION_TOKEN}) return f"ws://{netloc}/api/ws?{qs}" def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``. Gated mode: authenticates with the process-lifetime internal credential (``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY child is a server-spawned process we trust; the credential is multi-use and never expires, so the child can reconnect ``/api/pub`` without a new URL. (This previously minted a single-use 30s ticket, which meant the child could not reconnect and could miss the window on a slow cold boot.) Connections authenticated this way are recorded under the ``server-internal`` identity in the audit log. """ host = getattr(app.state, "bound_host", None) port = getattr(app.state, "bound_port", None) if not host or not port: return None netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" if getattr(app.state, "auth_required", False): # Gated mode — use the internal credential so the WS upgrade survives # _ws_auth_ok and the child can reconnect. from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential qs = urllib.parse.urlencode( {"internal": internal_ws_credential(), "channel": channel} ) else: qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) return f"ws://{netloc}/api/pub?{qs}" async def _broadcast_event(app: Any, channel: str, payload: str) -> None: """Fan out one publisher frame to every subscriber on `channel`.""" event_channels, event_lock = _get_event_state(app) async with event_lock: subs = list(event_channels.get(channel, ())) for sub in subs: try: await sub.send_text(payload) except Exception: # Subscriber went away mid-send; the /api/events finally clause # will remove it from the registry on its next iteration. _log.warning("broadcast send failed for subscriber on %s", channel, exc_info=True) def _channel_or_close_code(ws: WebSocket) -> Optional[str]: """Return the channel id from the query string or None if invalid.""" channel = ws.query_params.get("channel", "") return channel if _VALID_CHANNEL_RE.match(channel) else None @app.websocket("/api/pty") async def pty_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return # --- auth + loopback check (before accept so we can close cleanly) --- if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return await ws.accept() # On native Windows, the POSIX PTY bridge can't be imported. Tell the # client and close cleanly rather than pretending the feature works. if not _PTY_BRIDGE_AVAILABLE: await ws.send_text( "\r\n\x1b[31mChat unavailable: the embedded terminal requires a " "POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n" "\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat " "tab — the rest of the dashboard works here.\x1b[0m\r\n" ) await ws.close(code=1011) return # --- spawn PTY ------------------------------------------------------ resume = ws.query_params.get("resume") or None channel = _channel_or_close_code(ws) sidecar_url = _build_sidecar_url(channel) if channel else None try: argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url) except SystemExit as exc: # _make_tui_argv calls sys.exit(1) when node/npm is missing. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return try: bridge = PtyBridge.spawn(argv, cwd=cwd, env=env) except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return except (FileNotFoundError, OSError) as exc: await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") await ws.close(code=1011) return loop = asyncio.get_running_loop() # --- reader task: PTY master → WebSocket ---------------------------- async def pump_pty_to_ws() -> None: while True: chunk = await loop.run_in_executor( None, bridge.read, _PTY_READ_CHUNK_TIMEOUT ) if chunk is None: # EOF return if not chunk: # no data this tick; yield control and retry await asyncio.sleep(0) continue try: await ws.send_bytes(chunk) except Exception: return reader_task = asyncio.create_task(pump_pty_to_ws()) # --- writer loop: WebSocket → PTY master ---------------------------- try: while True: msg = await ws.receive() msg_type = msg.get("type") if msg_type == "websocket.disconnect": break raw = msg.get("bytes") if raw is None: text = msg.get("text") raw = text.encode("utf-8") if isinstance(text, str) else b"" if not raw: continue # Resize escape is consumed locally, never written to the PTY. match = _RESIZE_RE.match(raw) if match and match.end() == len(raw): cols = int(match.group(1)) rows = int(match.group(2)) bridge.resize(cols=cols, rows=rows) continue bridge.write(raw) except WebSocketDisconnect: pass finally: reader_task.cancel() try: await reader_task except (asyncio.CancelledError, Exception): pass bridge.close() # --------------------------------------------------------------------------- # /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab. # # Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the # dashboard can render structured metadata (model badge, tool-call sidebar, # slash launcher, session info) alongside the xterm.js terminal that PTY # already paints. Both transports bind to the same session id when one is # active, so a tool.start emitted by the agent fans out to both sinks. # --------------------------------------------------------------------------- @app.websocket("/api/ws") async def gateway_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return from tui_gateway.ws import handle_ws await handle_ws(ws) # --------------------------------------------------------------------------- # /api/pub + /api/events — chat-tab event broadcast. # # The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by # HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every # dispatcher emit through it. The dashboard fans those frames out to any # subscriber that opened /api/events on the same channel id. This is what # gives the React sidebar its tool-call feed without breaking the PTY # child's stdio handshake with Ink. # --------------------------------------------------------------------------- @app.websocket("/api/pub") async def pub_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() try: while True: await _broadcast_event(ws.app, channel, await ws.receive_text()) except WebSocketDisconnect: pass @app.websocket("/api/events") async def events_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() event_channels, event_lock = _get_event_state(ws.app) async with event_lock: event_channels.setdefault(channel, set()).add(ws) try: while True: # Subscribers don't speak — the receive() just blocks until # disconnect so the connection stays open as long as the # browser holds it. await ws.receive_text() except WebSocketDisconnect: pass finally: async with event_lock: subs = event_channels.get(channel) if subs is not None: subs.discard(ws) if not subs: event_channels.pop(channel, None) def _normalise_prefix(raw: Optional[str]) -> str: """Normalise an X-Forwarded-Prefix header value. Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix` — the single source of truth lives in the dashboard_auth package so the gate middleware, the OAuth routes, the cookie helpers, and the SPA mount all agree on validation rules. """ from hermes_cli.dashboard_auth.prefix import normalise_prefix return normalise_prefix(raw) def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing. The session token is injected into index.html via a ``" ) else: bootstrap_script = ( f'" ) if prefix: # Rewrite absolute asset URLs baked into the Vite build so the # browser fetches them through the same proxy prefix. html = html.replace('href="/assets/', f'href="{prefix}/assets/') html = html.replace('src="/assets/', f'src="{prefix}/assets/') html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') html = html.replace('href="/fonts/', f'href="{prefix}/fonts/') html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/') html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/') html = html.replace("", f"{bootstrap_script}", 1) return HTMLResponse( html, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, ) # When served behind a path-prefix proxy, the built CSS contains # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. # Browsers resolve those against the document origin, which means # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` # (the MC Pages app), not the Hermes backend. Intercept CSS asset # requests BEFORE the StaticFiles mount and rewrite the absolute paths # when a prefix is in play. @application.get("/assets/{filename}.css") async def serve_css(filename: str, request: Request): css_path = WEB_DIST / "assets" / f"{filename}.css" if not css_path.is_file() or not css_path.resolve().is_relative_to( WEB_DIST.resolve() ): return JSONResponse({"error": "not found"}, status_code=404) prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) css = css_path.read_text() if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") return Response(content=css, media_type="text/css") application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) # An unmatched /api/* path is a missing/renamed endpoint, NOT a # client-side route. Falling through to index.html here returns # `` with status 200, which makes JSON clients (the # desktop app's fetchJson, dashboard fetch wrappers) blow up with an # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON # so the caller sees a clear "no such endpoint" instead. if full_path == "api" or full_path.startswith("api/"): return JSONResponse( {"detail": f"No such API endpoint: /{full_path}"}, status_code=404, ) file_path = WEB_DIST / full_path # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( full_path and file_path.resolve().is_relative_to(WEB_DIST.resolve()) and file_path.exists() and file_path.is_file() ): return FileResponse(file_path) return _serve_index(prefix) # --------------------------------------------------------------------------- # Dashboard theme endpoints # --------------------------------------------------------------------------- # Built-in dashboard themes — label + description only. The actual color # definitions live in the frontend (web/src/themes/presets.ts). _BUILTIN_DASHBOARD_THEMES = [ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, ] def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: """Normalise a theme layer spec from YAML into `{hex, alpha}` form. Accepts shorthand (a bare hex string) or full dict form. Returns ``None`` on garbage input so the caller can fall back to a built-in default rather than blowing up. """ if value is None: return {"hex": default_hex, "alpha": default_alpha} if isinstance(value, str): return {"hex": value, "alpha": default_alpha} if isinstance(value, dict): hex_val = value.get("hex", default_hex) alpha_val = value.get("alpha", default_alpha) if not isinstance(hex_val, str): return None try: alpha_f = float(alpha_val) except (TypeError, ValueError): alpha_f = default_alpha return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} return None _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', "baseSize": "15px", "lineHeight": "1.55", "letterSpacing": "0", } _THEME_DEFAULT_LAYOUT: Dict[str, str] = { "radius": "0.5rem", "density": "comfortable", } _THEME_OVERRIDE_KEYS = { "card", "cardForeground", "popover", "popoverForeground", "primary", "primaryForeground", "secondary", "secondaryForeground", "muted", "mutedForeground", "accent", "accentForeground", "destructive", "destructiveForeground", "success", "warning", "border", "input", "ring", } # Well-known named asset slots themes can populate. Any other keys under # ``assets.custom`` are exposed as ``--theme-asset-custom-`` CSS vars # for plugin/shell use. _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} # Component-style buckets themes can override. The value under each bucket # is a mapping from camelCase property name to CSS string; each pair emits # ``--component--`` on :root. The frontend's shell # components (Card, App header, Backdrop, etc.) consume these vars so themes # can restyle chrome (clip-path, border-image, segmented progress, etc.) # without shipping their own CSS. _THEME_COMPONENT_BUCKETS = { "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page", } _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} # Cap on customCSS length so a malformed/oversized theme YAML can't blow up # the response payload or the