""" 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 """ import logging import os import secrets import sys import time from pathlib import Path from typing import Any, Dict, List, Optional 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 ( 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, redact_key, ) from gateway.status import get_running_pid, read_runtime_status try: from fastapi import FastAPI, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, JSONResponse from fastapi.staticfiles import StaticFiles from pydantic import BaseModel except ImportError: raise SystemExit( "Web UI requires fastapi and uvicorn.\n" "Run 'hermes web' to auto-install, or: pip install hermes-agent[web]" ) WEB_DIST = Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) app = FastAPI(title="Hermes Agent", version=__version__) # --------------------------------------------------------------------------- # Session token for protecting sensitive endpoints (reveal). # Generated fresh on every server start — dies when the process exits. # Injected into the SPA HTML so only the legitimate web UI can use it. # --------------------------------------------------------------------------- _SESSION_TOKEN = secrets.token_urlsafe(32) # 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=["*"], ) # --------------------------------------------------------------------------- # 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", }, "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", "options": ["local", "openai", "mistral"], }, "display.skin": { "type": "select", "description": "CLI visual theme", "options": ["default", "ares", "mono", "slate"], }, "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": ["queue", "interrupt", "block"], }, "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", "smart_model_routing": "agent", } # 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) class ConfigUpdate(BaseModel): config: dict class EnvVarUpdate(BaseModel): key: str value: str class EnvVarDelete(BaseModel): key: str class EnvVarReveal(BaseModel): key: str @app.get("/api/status") async def get_status(): current_ver, latest_ver = check_config_version() gateway_pid = get_running_pid() gateway_running = gateway_pid is not None 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 runtime = read_runtime_status() 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 = {} 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 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_state": gateway_state, "gateway_platforms": gateway_platforms, "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, "active_sessions": active_sessions, } @app.get("/api/sessions") async def get_sessions(): try: from hermes_state import SessionDB db = SessionDB() try: sessions = db.list_sessions_rich(limit=20) 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 ) return sessions finally: db.close() except Exception as e: _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.""" 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) matches = db.search_messages(query=prefix_query, limit=limit) # Group by session_id — return unique sessions with their best snippet seen: dict = {} for m in matches: sid = m["session_id"] if sid not in seen: seen[sid] = { "session_id": sid, "snippet": m.get("snippet", ""), "role": m.get("role"), "source": m.get("source"), "model": m.get("model"), "session_started": m.get("session_started"), } return {"results": list(seen.values())} finally: db.close() except 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. """ config = dict(config) # shallow copy model_val = config.get("model") if isinstance(model_val, dict): config["model"] = model_val.get("default", model_val.get("name", "")) 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} 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. """ 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) 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 config["model"] = disk_model 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 as e: _log.exception("PUT /api/config failed") raise HTTPException(status_code=500, detail="Internal server error") @app.get("/api/auth/session-token") async def get_session_token(): """Return the ephemeral session token for this server instance. The token protects sensitive endpoints (reveal). It's served to the SPA which stores it in memory — it's never persisted and dies when the server process exits. CORS already restricts this to localhost origins. """ return {"token": _SESSION_TOKEN} @app.get("/api/env") async def get_env_vars(): env_on_disk = load_env() 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), } 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 Exception as e: _log.exception("PUT /api/env failed") raise HTTPException(status_code=500, detail="Internal server error") @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 Exception as e: _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 --- auth = request.headers.get("authorization", "") if auth != f"Bearer {_SESSION_TOKEN}": raise HTTPException(status_code=401, detail="Unauthorized") # --- 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} # --------------------------------------------------------------------------- # Session detail endpoints # --------------------------------------------------------------------------- @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}/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() # --------------------------------------------------------------------------- # 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, ): 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 = {} has_filters = bool(level or component) comp_prefixes = COMPONENT_PREFIXES.get(component, ()) if component else () result = _read_tail( log_path, min(lines, 500), has_filters=has_filters, min_level=level, component_prefixes=comp_prefixes, ) 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 @app.get("/api/cron/jobs") async def list_cron_jobs(): from cron.jobs import list_jobs return list_jobs(include_disabled=True) @app.get("/api/cron/jobs/{job_id}") async def get_cron_job(job_id: str): from cron.jobs import get_job job = 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): from cron.jobs import create_job try: job = create_job(prompt=body.prompt, schedule=body.schedule, name=body.name, deliver=body.deliver) return job 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): from cron.jobs import update_job job = update_job(job_id, body.updates) 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): from cron.jobs import pause_job job = 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): from cron.jobs import resume_job job = 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): from cron.jobs import trigger_job job = 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): from cron.jobs import remove_job if not remove_job(job_id): raise HTTPException(status_code=404, detail="Job not found") return {"ok": True} # --------------------------------------------------------------------------- # 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 # --------------------------------------------------------------------------- # 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 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 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 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 FROM sessions WHERE started_at > ? """, (cutoff,)) totals = dict(cur3.fetchone()) return {"daily": daily, "by_model": by_model, "totals": totals, "period_days": days} finally: db.close() def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing.""" if not WEB_DIST.exists(): @application.get("/{full_path:path}") async def no_frontend(full_path: str): return JSONResponse( {"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404, ) return application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") @application.get("/{full_path:path}") async def serve_spa(full_path: str): 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 FileResponse( WEB_DIST / "index.html", headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, ) mount_spa(app) def start_server(host: str = "127.0.0.1", port: int = 9119, open_browser: bool = True): """Start the web UI server.""" import uvicorn if host not in ("127.0.0.1", "localhost", "::1"): import logging logging.warning( "Binding to %s — the web UI exposes config and API keys. " "Only bind to non-localhost if you trust all users on the network.", host, ) if open_browser: import threading import webbrowser def _open(): import time as _t _t.sleep(1.0) webbrowser.open(f"http://{host}:{port}") threading.Thread(target=_open, daemon=True).start() print(f" Hermes Web UI → http://{host}:{port}") uvicorn.run(app, host=host, port=port, log_level="warning")