/ hermes_cli / web_server.py
web_server.py
1 """ 2 Hermes Agent — Web UI server. 3 4 Provides a FastAPI backend serving the Vite/React frontend and REST API 5 endpoints for managing configuration, environment variables, and sessions. 6 7 Usage: 8 python -m hermes_cli.main web # Start on http://127.0.0.1:9119 9 python -m hermes_cli.main web --port 8080 10 """ 11 12 import asyncio 13 import hmac 14 import importlib.util 15 import json 16 import logging 17 import os 18 import secrets 19 import subprocess 20 import sys 21 import threading 22 import time 23 import urllib.parse 24 import urllib.request 25 from pathlib import Path 26 from typing import Any, Dict, List, Optional, Tuple 27 28 import yaml 29 30 PROJECT_ROOT = Path(__file__).parent.parent.resolve() 31 if str(PROJECT_ROOT) not in sys.path: 32 sys.path.insert(0, str(PROJECT_ROOT)) 33 34 from hermes_cli import __version__, __release_date__ 35 from hermes_cli.config import ( 36 cfg_get, 37 DEFAULT_CONFIG, 38 OPTIONAL_ENV_VARS, 39 get_config_path, 40 get_env_path, 41 get_hermes_home, 42 load_config, 43 load_env, 44 save_config, 45 save_env_value, 46 remove_env_value, 47 check_config_version, 48 redact_key, 49 ) 50 from gateway.status import get_running_pid, read_runtime_status 51 52 try: 53 from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect 54 from fastapi.middleware.cors import CORSMiddleware 55 from fastapi.responses import FileResponse, HTMLResponse, JSONResponse 56 from fastapi.staticfiles import StaticFiles 57 from pydantic import BaseModel 58 except ImportError: 59 raise SystemExit( 60 "Web UI requires fastapi and uvicorn.\n" 61 f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" 62 ) 63 64 WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" 65 _log = logging.getLogger(__name__) 66 67 app = FastAPI(title="Hermes Agent", version=__version__) 68 69 # --------------------------------------------------------------------------- 70 # Session token for protecting sensitive endpoints (reveal). 71 # Generated fresh on every server start — dies when the process exits. 72 # Injected into the SPA HTML so only the legitimate web UI can use it. 73 # --------------------------------------------------------------------------- 74 _SESSION_TOKEN = secrets.token_urlsafe(32) 75 _SESSION_HEADER_NAME = "X-Hermes-Session-Token" 76 77 # In-browser Chat tab (/chat, /api/pty, …). Off unless ``hermes dashboard --tui`` 78 # or HERMES_DASHBOARD_TUI=1. Set from :func:`start_server`. 79 _DASHBOARD_EMBEDDED_CHAT_ENABLED = False 80 81 # Simple rate limiter for the reveal endpoint 82 _reveal_timestamps: List[float] = [] 83 _REVEAL_MAX_PER_WINDOW = 5 84 _REVEAL_WINDOW_SECONDS = 30 85 86 # CORS: restrict to localhost origins only. The web UI is intended to run 87 # locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website 88 # read/modify config and secrets. 89 90 app.add_middleware( 91 CORSMiddleware, 92 allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", 93 allow_methods=["*"], 94 allow_headers=["*"], 95 ) 96 97 # --------------------------------------------------------------------------- 98 # Endpoints that do NOT require the session token. Everything else under 99 # /api/ is gated by the auth middleware below. Keep this list minimal — 100 # only truly non-sensitive, read-only endpoints belong here. 101 # --------------------------------------------------------------------------- 102 _PUBLIC_API_PATHS: frozenset = frozenset({ 103 "/api/status", 104 "/api/config/defaults", 105 "/api/config/schema", 106 "/api/model/info", 107 "/api/dashboard/themes", 108 "/api/dashboard/plugins", 109 "/api/dashboard/plugins/rescan", 110 }) 111 112 113 def _has_valid_session_token(request: Request) -> bool: 114 """True if the request carries a valid dashboard session token. 115 116 The dedicated session header avoids collisions with reverse proxies that 117 already use ``Authorization`` (for example Caddy ``basic_auth``). We still 118 accept the legacy Bearer path for backward compatibility with older 119 dashboard bundles. 120 """ 121 session_header = request.headers.get(_SESSION_HEADER_NAME, "") 122 if session_header and hmac.compare_digest( 123 session_header.encode(), 124 _SESSION_TOKEN.encode(), 125 ): 126 return True 127 128 auth = request.headers.get("authorization", "") 129 expected = f"Bearer {_SESSION_TOKEN}" 130 return hmac.compare_digest(auth.encode(), expected.encode()) 131 132 133 def _require_token(request: Request) -> None: 134 """Validate the ephemeral session token. Raises 401 on mismatch.""" 135 if not _has_valid_session_token(request): 136 raise HTTPException(status_code=401, detail="Unauthorized") 137 138 139 # Accepted Host header values for loopback binds. DNS rebinding attacks 140 # point a victim browser at an attacker-controlled hostname (evil.test) 141 # which resolves to 127.0.0.1 after a TTL flip — bypassing same-origin 142 # checks because the browser now considers evil.test and our dashboard 143 # "same origin". Validating the Host header at the app layer rejects any 144 # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. 145 _LOOPBACK_HOST_VALUES: frozenset = frozenset({ 146 "localhost", "127.0.0.1", "::1", 147 }) 148 149 150 def _is_accepted_host(host_header: str, bound_host: str) -> bool: 151 """True if the Host header targets the interface we bound to. 152 153 Accepts: 154 - Exact bound host (with or without port suffix) 155 - Loopback aliases when bound to loopback 156 - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, 157 no protection possible at this layer) 158 """ 159 if not host_header: 160 return False 161 # Strip port suffix. IPv6 addresses use bracket notation: 162 # [::1] — no port 163 # [::1]:9119 — with port 164 # Plain hosts/v4: 165 # localhost:9119 166 # 127.0.0.1:9119 167 h = host_header.strip() 168 if h.startswith("["): 169 # IPv6 bracketed — port (if any) follows "]:" 170 close = h.find("]") 171 if close != -1: 172 host_only = h[1:close] # strip brackets 173 else: 174 host_only = h.strip("[]") 175 else: 176 host_only = h.rsplit(":", 1)[0] if ":" in h else h 177 host_only = host_only.lower() 178 179 # 0.0.0.0 bind means operator explicitly opted into all-interfaces 180 # (requires --insecure per web_server.start_server). No Host-layer 181 # defence can protect that mode; rely on operator network controls. 182 if bound_host in ("0.0.0.0", "::"): 183 return True 184 185 # Loopback bind: accept the loopback names 186 bound_lc = bound_host.lower() 187 if bound_lc in _LOOPBACK_HOST_VALUES: 188 return host_only in _LOOPBACK_HOST_VALUES 189 190 # Explicit non-loopback bind: require exact host match 191 return host_only == bound_lc 192 193 194 @app.middleware("http") 195 async def host_header_middleware(request: Request, call_next): 196 """Reject requests whose Host header doesn't match the bound interface. 197 198 Defends against DNS rebinding: a victim browser on a localhost 199 dashboard is tricked into fetching from an attacker hostname that 200 TTL-flips to 127.0.0.1. CORS and same-origin checks don't help — 201 the browser now treats the attacker origin as same-origin with the 202 dashboard. Host-header validation at the app layer catches it. 203 204 See GHSA-ppp5-vxwm-4cf7. 205 """ 206 # Store the bound host on app.state so this middleware can read it — 207 # set by start_server() at listen time. 208 bound_host = getattr(app.state, "bound_host", None) 209 if bound_host: 210 host_header = request.headers.get("host", "") 211 if not _is_accepted_host(host_header, bound_host): 212 return JSONResponse( 213 status_code=400, 214 content={ 215 "detail": ( 216 "Invalid Host header. Dashboard requests must use " 217 "the hostname the server was bound to." 218 ), 219 }, 220 ) 221 return await call_next(request) 222 223 224 @app.middleware("http") 225 async def auth_middleware(request: Request, call_next): 226 """Require the session token on all /api/ routes except the public list.""" 227 path = request.url.path 228 if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not path.startswith("/api/plugins/"): 229 if not _has_valid_session_token(request): 230 return JSONResponse( 231 status_code=401, 232 content={"detail": "Unauthorized"}, 233 ) 234 return await call_next(request) 235 236 237 # --------------------------------------------------------------------------- 238 # Config schema — auto-generated from DEFAULT_CONFIG 239 # --------------------------------------------------------------------------- 240 241 # Manual overrides for fields that need select options or custom types 242 _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { 243 "model": { 244 "type": "string", 245 "description": "Default model (e.g. anthropic/claude-sonnet-4.6)", 246 "category": "general", 247 }, 248 "model_context_length": { 249 "type": "number", 250 "description": "Context window override (0 = auto-detect from model metadata)", 251 "category": "general", 252 }, 253 "terminal.backend": { 254 "type": "select", 255 "description": "Terminal execution backend", 256 "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"], 257 }, 258 "terminal.vercel_runtime": { 259 "type": "select", 260 "description": "Vercel Sandbox runtime", 261 "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py 262 }, 263 "terminal.modal_mode": { 264 "type": "select", 265 "description": "Modal sandbox mode", 266 "options": ["sandbox", "function"], 267 }, 268 "tts.provider": { 269 "type": "select", 270 "description": "Text-to-speech provider", 271 "options": ["edge", "elevenlabs", "openai", "neutts"], 272 }, 273 "stt.provider": { 274 "type": "select", 275 "description": "Speech-to-text provider", 276 "options": ["local", "openai", "mistral"], 277 }, 278 "display.skin": { 279 "type": "select", 280 "description": "CLI visual theme", 281 "options": ["default", "ares", "mono", "slate"], 282 }, 283 "dashboard.theme": { 284 "type": "select", 285 "description": "Web dashboard visual theme", 286 "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], 287 }, 288 "display.resume_display": { 289 "type": "select", 290 "description": "How resumed sessions display history", 291 "options": ["minimal", "full", "off"], 292 }, 293 "display.busy_input_mode": { 294 "type": "select", 295 "description": "Input behavior while agent is running", 296 "options": ["interrupt", "queue", "steer"], 297 }, 298 "memory.provider": { 299 "type": "select", 300 "description": "Memory provider plugin", 301 "options": ["builtin", "honcho"], 302 }, 303 "approvals.mode": { 304 "type": "select", 305 "description": "Dangerous command approval mode", 306 "options": ["ask", "yolo", "deny"], 307 }, 308 "context.engine": { 309 "type": "select", 310 "description": "Context management engine", 311 "options": ["default", "custom"], 312 }, 313 "human_delay.mode": { 314 "type": "select", 315 "description": "Simulated typing delay mode", 316 "options": ["off", "typing", "fixed"], 317 }, 318 "logging.level": { 319 "type": "select", 320 "description": "Log level for agent.log", 321 "options": ["DEBUG", "INFO", "WARNING", "ERROR"], 322 }, 323 "agent.service_tier": { 324 "type": "select", 325 "description": "API service tier (OpenAI/Anthropic)", 326 "options": ["", "auto", "default", "flex"], 327 }, 328 "delegation.reasoning_effort": { 329 "type": "select", 330 "description": "Reasoning effort for delegated subagents", 331 "options": ["", "low", "medium", "high"], 332 }, 333 } 334 335 # Categories with fewer fields get merged into "general" to avoid tab sprawl. 336 _CATEGORY_MERGE: Dict[str, str] = { 337 "privacy": "security", 338 "context": "agent", 339 "skills": "agent", 340 "cron": "agent", 341 "network": "agent", 342 "checkpoints": "agent", 343 "approvals": "security", 344 "human_delay": "display", 345 "dashboard": "display", 346 "code_execution": "agent", 347 "prompt_caching": "agent", 348 "goals": "agent", 349 # Only `telegram.reactions` currently lives under telegram — fold it in 350 # with the other messaging-platform config (discord) so it isn't an 351 # orphan tab of one field. 352 "telegram": "discord", 353 } 354 355 # Display order for tabs — unlisted categories sort alphabetically after these. 356 _CATEGORY_ORDER = [ 357 "general", "agent", "terminal", "display", "delegation", 358 "memory", "compression", "security", "browser", "voice", 359 "tts", "stt", "logging", "discord", "auxiliary", 360 ] 361 362 363 def _infer_type(value: Any) -> str: 364 """Infer a UI field type from a Python value.""" 365 if isinstance(value, bool): 366 return "boolean" 367 if isinstance(value, int): 368 return "number" 369 if isinstance(value, float): 370 return "number" 371 if isinstance(value, list): 372 return "list" 373 if isinstance(value, dict): 374 return "object" 375 return "string" 376 377 378 def _build_schema_from_config( 379 config: Dict[str, Any], 380 prefix: str = "", 381 ) -> Dict[str, Dict[str, Any]]: 382 """Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict.""" 383 schema: Dict[str, Dict[str, Any]] = {} 384 for key, value in config.items(): 385 full_key = f"{prefix}.{key}" if prefix else key 386 387 # Skip internal / version keys 388 if full_key in ("_config_version",): 389 continue 390 391 # Category is the first path component for nested keys, or "general" 392 # for top-level scalar fields (model, toolsets, timezone, etc.). 393 if prefix: 394 category = prefix.split(".")[0] 395 elif isinstance(value, dict): 396 category = key 397 else: 398 category = "general" 399 400 if isinstance(value, dict): 401 # Recurse into nested dicts 402 schema.update(_build_schema_from_config(value, full_key)) 403 else: 404 entry: Dict[str, Any] = { 405 "type": _infer_type(value), 406 "description": full_key.replace(".", " → ").replace("_", " ").title(), 407 "category": category, 408 } 409 # Apply manual overrides 410 if full_key in _SCHEMA_OVERRIDES: 411 entry.update(_SCHEMA_OVERRIDES[full_key]) 412 # Merge small categories 413 entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"]) 414 schema[full_key] = entry 415 return schema 416 417 418 CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG) 419 420 # Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced 421 # by the normalize/denormalize cycle. Insert model_context_length right after 422 # the "model" key so it renders adjacent in the frontend. 423 _mcl_entry = _SCHEMA_OVERRIDES["model_context_length"] 424 _ordered_schema: Dict[str, Dict[str, Any]] = {} 425 for _k, _v in CONFIG_SCHEMA.items(): 426 _ordered_schema[_k] = _v 427 if _k == "model": 428 _ordered_schema["model_context_length"] = _mcl_entry 429 CONFIG_SCHEMA = _ordered_schema 430 431 432 class ConfigUpdate(BaseModel): 433 config: dict 434 435 436 class EnvVarUpdate(BaseModel): 437 key: str 438 value: str 439 440 441 class EnvVarDelete(BaseModel): 442 key: str 443 444 445 class EnvVarReveal(BaseModel): 446 key: str 447 448 449 class ModelAssignment(BaseModel): 450 """Payload for POST /api/model/set — assign a provider/model to a slot. 451 452 scope="main" → writes model.provider + model.default 453 scope="auxiliary" → writes auxiliary.<task>.provider + auxiliary.<task>.model 454 scope="auxiliary" with task="" → applied to every auxiliary.* slot 455 scope="auxiliary" with task="__reset__" → resets every slot to provider="auto" 456 """ 457 scope: str 458 provider: str 459 model: str 460 task: str = "" 461 462 463 _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL") 464 try: 465 _GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "3")) 466 except (ValueError, TypeError): 467 _log.warning( 468 "Invalid GATEWAY_HEALTH_TIMEOUT value %r — using default 3.0s", 469 os.getenv("GATEWAY_HEALTH_TIMEOUT"), 470 ) 471 _GATEWAY_HEALTH_TIMEOUT = 3.0 472 473 # DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT. 474 # Cross-container / cross-host gateway liveness detection will be folded into a 475 # first-class dashboard config key so it's no longer Docker-adjacent lore buried 476 # in env vars. The env vars still work for now so existing Compose deployments 477 # don't break. Do not add new callers — wire new uses through the planned 478 # config surface. 479 480 481 def _probe_gateway_health() -> tuple[bool, dict | None]: 482 """Probe the gateway via its HTTP health endpoint (cross-container). 483 484 .. deprecated:: 485 Driven by the deprecated ``GATEWAY_HEALTH_URL`` / 486 ``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside 487 a move to a first-class dashboard config key. See 488 :data:`_GATEWAY_HEALTH_URL` for context. 489 490 Uses ``/health/detailed`` first (returns full state), falling back to 491 the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``. 492 493 Accepts any of these as ``GATEWAY_HEALTH_URL``: 494 - ``http://gateway:8642`` (base URL — recommended) 495 - ``http://gateway:8642/health`` (explicit health path) 496 - ``http://gateway:8642/health/detailed`` (explicit detailed path) 497 498 This is a **blocking** call — run via ``run_in_executor`` from async code. 499 """ 500 if not _GATEWAY_HEALTH_URL: 501 return False, None 502 503 # Normalise to base URL so we always probe the right paths regardless of 504 # whether the user included /health or /health/detailed in the env var. 505 base = _GATEWAY_HEALTH_URL.rstrip("/") 506 if base.endswith("/health/detailed"): 507 base = base[: -len("/health/detailed")] 508 elif base.endswith("/health"): 509 base = base[: -len("/health")] 510 511 for path in (f"{base}/health/detailed", f"{base}/health"): 512 try: 513 req = urllib.request.Request(path, method="GET") 514 with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp: 515 if resp.status == 200: 516 body = json.loads(resp.read()) 517 return True, body 518 except Exception: 519 continue 520 return False, None 521 522 523 @app.get("/api/status") 524 async def get_status(): 525 current_ver, latest_ver = check_config_version() 526 527 # --- Gateway liveness detection --- 528 # Try local PID check first (same-host). If that fails and a remote 529 # GATEWAY_HEALTH_URL is configured, probe the gateway over HTTP so the 530 # dashboard works when the gateway runs in a separate container. 531 gateway_pid = get_running_pid() 532 gateway_running = gateway_pid is not None 533 remote_health_body: dict | None = None 534 535 if not gateway_running and _GATEWAY_HEALTH_URL: 536 loop = asyncio.get_event_loop() 537 alive, remote_health_body = await loop.run_in_executor( 538 None, _probe_gateway_health 539 ) 540 if alive: 541 gateway_running = True 542 # PID from the remote container (display only — not locally valid) 543 if remote_health_body: 544 gateway_pid = remote_health_body.get("pid") 545 546 gateway_state = None 547 gateway_platforms: dict = {} 548 gateway_exit_reason = None 549 gateway_updated_at = None 550 configured_gateway_platforms: set[str] | None = None 551 try: 552 from gateway.config import load_gateway_config 553 554 gateway_config = load_gateway_config() 555 configured_gateway_platforms = { 556 platform.value for platform in gateway_config.get_connected_platforms() 557 } 558 except Exception: 559 configured_gateway_platforms = None 560 561 # Prefer the detailed health endpoint response (has full state) when the 562 # local runtime status file is absent or stale (cross-container). 563 runtime = read_runtime_status() 564 if runtime is None and remote_health_body and remote_health_body.get("gateway_state"): 565 runtime = remote_health_body 566 567 if runtime: 568 gateway_state = runtime.get("gateway_state") 569 gateway_platforms = runtime.get("platforms") or {} 570 if configured_gateway_platforms is not None: 571 gateway_platforms = { 572 key: value 573 for key, value in gateway_platforms.items() 574 if key in configured_gateway_platforms 575 } 576 gateway_exit_reason = runtime.get("exit_reason") 577 gateway_updated_at = runtime.get("updated_at") 578 if not gateway_running: 579 gateway_state = gateway_state if gateway_state in ("stopped", "startup_failed") else "stopped" 580 gateway_platforms = {} 581 elif gateway_running and remote_health_body is not None: 582 # The health probe confirmed the gateway is alive, but the local 583 # runtime status file may be stale (cross-container). Override 584 # stopped/None state so the dashboard shows the correct badge. 585 if gateway_state in (None, "stopped"): 586 gateway_state = "running" 587 588 # If there was no runtime info at all but the health probe confirmed alive, 589 # ensure we still report the gateway as running (no shared volume scenario). 590 if gateway_running and gateway_state is None and remote_health_body is not None: 591 gateway_state = "running" 592 593 active_sessions = 0 594 try: 595 from hermes_state import SessionDB 596 db = SessionDB() 597 try: 598 sessions = db.list_sessions_rich(limit=50) 599 now = time.time() 600 active_sessions = sum( 601 1 for s in sessions 602 if s.get("ended_at") is None 603 and (now - s.get("last_active", s.get("started_at", 0))) < 300 604 ) 605 finally: 606 db.close() 607 except Exception: 608 pass 609 610 return { 611 "version": __version__, 612 "release_date": __release_date__, 613 "hermes_home": str(get_hermes_home()), 614 "config_path": str(get_config_path()), 615 "env_path": str(get_env_path()), 616 "config_version": current_ver, 617 "latest_config_version": latest_ver, 618 "gateway_running": gateway_running, 619 "gateway_pid": gateway_pid, 620 "gateway_health_url": _GATEWAY_HEALTH_URL, 621 "gateway_state": gateway_state, 622 "gateway_platforms": gateway_platforms, 623 "gateway_exit_reason": gateway_exit_reason, 624 "gateway_updated_at": gateway_updated_at, 625 "active_sessions": active_sessions, 626 } 627 628 629 # --------------------------------------------------------------------------- 630 # Gateway + update actions (invoked from the Status page). 631 # 632 # Both commands are spawned as detached subprocesses so the HTTP request 633 # returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()`` 634 # calls fail fast with EOF rather than hanging forever. stdout/stderr are 635 # streamed to a per-action log file under ``~/.hermes/logs/<action>.log`` so 636 # the dashboard can tail them back to the user. 637 # --------------------------------------------------------------------------- 638 639 _ACTION_LOG_DIR: Path = get_hermes_home() / "logs" 640 641 # Short ``name`` (from the URL) → absolute log file path. 642 _ACTION_LOG_FILES: Dict[str, str] = { 643 "gateway-restart": "gateway-restart.log", 644 "hermes-update": "hermes-update.log", 645 } 646 647 # ``name`` → most recently spawned Popen handle. Used so ``status`` can 648 # report liveness and exit code without shelling out to ``ps``. 649 _ACTION_PROCS: Dict[str, subprocess.Popen] = {} 650 651 652 def _spawn_hermes_action(subcommand: List[str], name: str) -> subprocess.Popen: 653 """Spawn ``hermes <subcommand>`` detached and record the Popen handle. 654 655 Uses the running interpreter's ``hermes_cli.main`` module so the action 656 inherits the same venv/PYTHONPATH the web server is using. 657 """ 658 log_file_name = _ACTION_LOG_FILES[name] 659 _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) 660 log_path = _ACTION_LOG_DIR / log_file_name 661 log_file = open(log_path, "ab", buffering=0) 662 log_file.write( 663 f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() 664 ) 665 666 cmd = [sys.executable, "-m", "hermes_cli.main", *subcommand] 667 668 popen_kwargs: Dict[str, Any] = { 669 "cwd": str(PROJECT_ROOT), 670 "stdin": subprocess.DEVNULL, 671 "stdout": log_file, 672 "stderr": subprocess.STDOUT, 673 "env": {**os.environ, "HERMES_NONINTERACTIVE": "1"}, 674 } 675 if sys.platform == "win32": 676 popen_kwargs["creationflags"] = ( 677 subprocess.CREATE_NEW_PROCESS_GROUP # type: ignore[attr-defined] 678 | getattr(subprocess, "DETACHED_PROCESS", 0) 679 ) 680 else: 681 popen_kwargs["start_new_session"] = True 682 683 proc = subprocess.Popen(cmd, **popen_kwargs) 684 _ACTION_PROCS[name] = proc 685 return proc 686 687 688 def _tail_lines(path: Path, n: int) -> List[str]: 689 """Return the last ``n`` lines of ``path``. Reads the whole file — fine 690 for our small per-action logs. Binary-decoded with ``errors='replace'`` 691 so log corruption doesn't 500 the endpoint.""" 692 if not path.exists(): 693 return [] 694 try: 695 text = path.read_text(errors="replace") 696 except OSError: 697 return [] 698 lines = text.splitlines() 699 return lines[-n:] if n > 0 else lines 700 701 702 @app.post("/api/gateway/restart") 703 async def restart_gateway(): 704 """Kick off a ``hermes gateway restart`` in the background.""" 705 try: 706 proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart") 707 except Exception as exc: 708 _log.exception("Failed to spawn gateway restart") 709 raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}") 710 return { 711 "ok": True, 712 "pid": proc.pid, 713 "name": "gateway-restart", 714 } 715 716 717 @app.post("/api/hermes/update") 718 async def update_hermes(): 719 """Kick off ``hermes update`` in the background.""" 720 try: 721 proc = _spawn_hermes_action(["update"], "hermes-update") 722 except Exception as exc: 723 _log.exception("Failed to spawn hermes update") 724 raise HTTPException(status_code=500, detail=f"Failed to start update: {exc}") 725 return { 726 "ok": True, 727 "pid": proc.pid, 728 "name": "hermes-update", 729 } 730 731 732 @app.get("/api/actions/{name}/status") 733 async def get_action_status(name: str, lines: int = 200): 734 """Tail an action log and report whether the process is still running.""" 735 log_file_name = _ACTION_LOG_FILES.get(name) 736 if log_file_name is None: 737 raise HTTPException(status_code=404, detail=f"Unknown action: {name}") 738 739 log_path = _ACTION_LOG_DIR / log_file_name 740 tail = _tail_lines(log_path, min(max(lines, 1), 2000)) 741 742 proc = _ACTION_PROCS.get(name) 743 if proc is None: 744 running = False 745 exit_code: Optional[int] = None 746 pid: Optional[int] = None 747 else: 748 exit_code = proc.poll() 749 running = exit_code is None 750 pid = proc.pid 751 752 return { 753 "name": name, 754 "running": running, 755 "exit_code": exit_code, 756 "pid": pid, 757 "lines": tail, 758 } 759 760 761 @app.get("/api/sessions") 762 async def get_sessions(limit: int = 20, offset: int = 0): 763 try: 764 from hermes_state import SessionDB 765 db = SessionDB() 766 try: 767 sessions = db.list_sessions_rich(limit=limit, offset=offset) 768 total = db.session_count() 769 now = time.time() 770 for s in sessions: 771 s["is_active"] = ( 772 s.get("ended_at") is None 773 and (now - s.get("last_active", s.get("started_at", 0))) < 300 774 ) 775 return {"sessions": sessions, "total": total, "limit": limit, "offset": offset} 776 finally: 777 db.close() 778 except Exception: 779 _log.exception("GET /api/sessions failed") 780 raise HTTPException(status_code=500, detail="Internal server error") 781 782 783 @app.get("/api/sessions/search") 784 async def search_sessions(q: str = "", limit: int = 20): 785 """Full-text search across session message content using FTS5.""" 786 if not q or not q.strip(): 787 return {"results": []} 788 try: 789 from hermes_state import SessionDB 790 db = SessionDB() 791 try: 792 # Auto-add prefix wildcards so partial words match 793 # e.g. "nimb" → "nimb*" matches "nimby" 794 # Preserve quoted phrases and existing wildcards as-is 795 import re 796 terms = [] 797 for token in re.findall(r'"[^"]*"|\S+', q.strip()): 798 if token.startswith('"') or token.endswith("*"): 799 terms.append(token) 800 else: 801 terms.append(token + "*") 802 prefix_query = " ".join(terms) 803 matches = db.search_messages(query=prefix_query, limit=limit) 804 # Group by session_id — return unique sessions with their best snippet 805 seen: dict = {} 806 for m in matches: 807 sid = m["session_id"] 808 if sid not in seen: 809 seen[sid] = { 810 "session_id": sid, 811 "snippet": m.get("snippet", ""), 812 "role": m.get("role"), 813 "source": m.get("source"), 814 "model": m.get("model"), 815 "session_started": m.get("session_started"), 816 } 817 return {"results": list(seen.values())} 818 finally: 819 db.close() 820 except Exception: 821 _log.exception("GET /api/sessions/search failed") 822 raise HTTPException(status_code=500, detail="Search failed") 823 824 825 def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: 826 """Normalize config for the web UI. 827 828 Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``) 829 or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built 830 from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the 831 dict form. Normalize to the string form so the frontend schema matches. 832 833 Also surfaces ``model_context_length`` as a top-level field so the web UI can 834 display and edit it. A value of 0 means "auto-detect". 835 """ 836 config = dict(config) # shallow copy 837 model_val = config.get("model") 838 if isinstance(model_val, dict): 839 # Extract context_length before flattening the dict 840 ctx_len = model_val.get("context_length", 0) 841 config["model"] = model_val.get("default", model_val.get("name", "")) 842 config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0 843 else: 844 config["model_context_length"] = 0 845 return config 846 847 848 @app.get("/api/config") 849 async def get_config(): 850 config = _normalize_config_for_web(load_config()) 851 # Strip internal keys that the frontend shouldn't see or send back 852 return {k: v for k, v in config.items() if not k.startswith("_")} 853 854 855 @app.get("/api/config/defaults") 856 async def get_defaults(): 857 return DEFAULT_CONFIG 858 859 860 @app.get("/api/config/schema") 861 async def get_schema(): 862 return {"fields": CONFIG_SCHEMA, "category_order": _CATEGORY_ORDER} 863 864 865 _EMPTY_MODEL_INFO: dict = { 866 "model": "", 867 "provider": "", 868 "auto_context_length": 0, 869 "config_context_length": 0, 870 "effective_context_length": 0, 871 "capabilities": {}, 872 } 873 874 875 @app.get("/api/model/info") 876 def get_model_info(): 877 """Return resolved model metadata for the currently configured model. 878 879 Calls the same context-length resolution chain the agent uses, so the 880 frontend can display "Auto-detected: 200K" alongside the override field. 881 Also returns model capabilities (vision, reasoning, tools) when available. 882 """ 883 try: 884 cfg = load_config() 885 model_cfg = cfg.get("model", "") 886 887 # Extract model name and provider from the config 888 if isinstance(model_cfg, dict): 889 model_name = model_cfg.get("default", model_cfg.get("name", "")) 890 provider = model_cfg.get("provider", "") 891 base_url = model_cfg.get("base_url", "") 892 config_ctx = model_cfg.get("context_length") 893 else: 894 model_name = str(model_cfg) if model_cfg else "" 895 provider = "" 896 base_url = "" 897 config_ctx = None 898 899 if not model_name: 900 return dict(_EMPTY_MODEL_INFO, provider=provider) 901 902 # Resolve auto-detected context length (pass config_ctx=None to get 903 # purely auto-detected value, then separately report the override) 904 try: 905 from agent.model_metadata import get_model_context_length 906 auto_ctx = get_model_context_length( 907 model=model_name, 908 base_url=base_url, 909 provider=provider, 910 config_context_length=None, # ignore override — we want auto value 911 ) 912 except Exception: 913 auto_ctx = 0 914 915 config_ctx_int = 0 916 if isinstance(config_ctx, int) and config_ctx > 0: 917 config_ctx_int = config_ctx 918 919 # Effective is what the agent actually uses 920 effective_ctx = config_ctx_int if config_ctx_int > 0 else auto_ctx 921 922 # Try to get model capabilities from models.dev 923 caps = {} 924 try: 925 from agent.models_dev import get_model_capabilities 926 mc = get_model_capabilities(provider=provider, model=model_name) 927 if mc is not None: 928 caps = { 929 "supports_tools": mc.supports_tools, 930 "supports_vision": mc.supports_vision, 931 "supports_reasoning": mc.supports_reasoning, 932 "context_window": mc.context_window, 933 "max_output_tokens": mc.max_output_tokens, 934 "model_family": mc.model_family, 935 } 936 except Exception: 937 pass 938 939 return { 940 "model": model_name, 941 "provider": provider, 942 "auto_context_length": auto_ctx, 943 "config_context_length": config_ctx_int, 944 "effective_context_length": effective_ctx, 945 "capabilities": caps, 946 } 947 except Exception: 948 _log.exception("GET /api/model/info failed") 949 return dict(_EMPTY_MODEL_INFO) 950 951 952 # --------------------------------------------------------------------------- 953 # Model assignment — pick provider+model for main slot or auxiliary slots. 954 # Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the 955 # Models page (which has no chat PTY open) can drive it. 956 # --------------------------------------------------------------------------- 957 958 # Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"] 959 # in hermes_cli/config.py — listed here for deterministic ordering in the UI. 960 _AUX_TASK_SLOTS: Tuple[str, ...] = ( 961 "vision", 962 "web_extract", 963 "compression", 964 "session_search", 965 "skills_hub", 966 "approval", 967 "mcp", 968 "title_generation", 969 "curator", 970 ) 971 972 973 @app.get("/api/model/options") 974 def get_model_options(): 975 """Return authenticated providers + their curated model lists. 976 977 REST equivalent of the ``model.options`` JSON-RPC on tui_gateway, so the 978 dashboard Models page can render the picker without a live chat session. 979 The response shape matches ``model.options`` 1:1 so ``ModelPickerDialog`` 980 can share the same types. 981 """ 982 try: 983 from hermes_cli.model_switch import list_authenticated_providers 984 985 cfg = load_config() 986 model_cfg = cfg.get("model", {}) 987 if isinstance(model_cfg, dict): 988 current_model = model_cfg.get("default", model_cfg.get("name", "")) or "" 989 current_provider = model_cfg.get("provider", "") or "" 990 current_base_url = model_cfg.get("base_url", "") or "" 991 else: 992 current_model = str(model_cfg) if model_cfg else "" 993 current_provider = "" 994 current_base_url = "" 995 996 user_providers = cfg.get("providers") if isinstance(cfg.get("providers"), dict) else {} 997 custom_providers = ( 998 cfg.get("custom_providers") 999 if isinstance(cfg.get("custom_providers"), list) 1000 else [] 1001 ) 1002 1003 providers = list_authenticated_providers( 1004 current_provider=current_provider, 1005 current_base_url=current_base_url, 1006 current_model=current_model, 1007 user_providers=user_providers, 1008 custom_providers=custom_providers, 1009 max_models=50, 1010 ) 1011 return { 1012 "providers": providers, 1013 "model": current_model, 1014 "provider": current_provider, 1015 } 1016 except Exception: 1017 _log.exception("GET /api/model/options failed") 1018 raise HTTPException(status_code=500, detail="Failed to list model options") 1019 1020 1021 @app.get("/api/model/auxiliary") 1022 def get_auxiliary_models(): 1023 """Return current auxiliary task assignments. 1024 1025 Shape: 1026 { 1027 "tasks": [ 1028 {"task": "vision", "provider": "auto", "model": "", "base_url": ""}, 1029 ... 1030 ], 1031 "main": {"provider": "openrouter", "model": "anthropic/claude-opus-4.7"}, 1032 } 1033 """ 1034 try: 1035 cfg = load_config() 1036 aux_cfg = cfg.get("auxiliary", {}) 1037 if not isinstance(aux_cfg, dict): 1038 aux_cfg = {} 1039 1040 tasks = [] 1041 for slot in _AUX_TASK_SLOTS: 1042 slot_cfg = aux_cfg.get(slot, {}) if isinstance(aux_cfg.get(slot), dict) else {} 1043 tasks.append({ 1044 "task": slot, 1045 "provider": str(slot_cfg.get("provider", "auto") or "auto"), 1046 "model": str(slot_cfg.get("model", "") or ""), 1047 "base_url": str(slot_cfg.get("base_url", "") or ""), 1048 }) 1049 1050 model_cfg = cfg.get("model", {}) 1051 if isinstance(model_cfg, dict): 1052 main = { 1053 "provider": str(model_cfg.get("provider", "") or ""), 1054 "model": str(model_cfg.get("default", model_cfg.get("name", "")) or ""), 1055 } 1056 else: 1057 main = {"provider": "", "model": str(model_cfg) if model_cfg else ""} 1058 1059 return {"tasks": tasks, "main": main} 1060 except Exception: 1061 _log.exception("GET /api/model/auxiliary failed") 1062 raise HTTPException(status_code=500, detail="Failed to read auxiliary config") 1063 1064 1065 @app.post("/api/model/set") 1066 async def set_model_assignment(body: ModelAssignment): 1067 """Assign a model to the main slot or an auxiliary task slot. 1068 1069 Writes to ``~/.hermes/config.yaml`` — applies to **new** sessions only. 1070 The currently running chat PTY (if any) is not affected; use the 1071 ``/model`` slash command inside a chat to hot-swap that specific session. 1072 """ 1073 scope = (body.scope or "").strip().lower() 1074 provider = (body.provider or "").strip() 1075 model = (body.model or "").strip() 1076 task = (body.task or "").strip().lower() 1077 1078 if scope not in ("main", "auxiliary"): 1079 raise HTTPException(status_code=400, detail="scope must be 'main' or 'auxiliary'") 1080 1081 try: 1082 cfg = load_config() 1083 1084 if scope == "main": 1085 if not provider or not model: 1086 raise HTTPException(status_code=400, detail="provider and model required for main") 1087 model_cfg = cfg.get("model", {}) 1088 if not isinstance(model_cfg, dict): 1089 model_cfg = {} 1090 model_cfg["provider"] = provider 1091 model_cfg["default"] = model 1092 # Clear stale base_url so the resolver picks the provider's own default. 1093 if "base_url" in model_cfg and model_cfg.get("base_url"): 1094 model_cfg["base_url"] = "" 1095 # Also clear hardcoded context_length override — new model may have 1096 # a different context window. 1097 if "context_length" in model_cfg: 1098 model_cfg.pop("context_length", None) 1099 cfg["model"] = model_cfg 1100 save_config(cfg) 1101 return {"ok": True, "scope": "main", "provider": provider, "model": model} 1102 1103 # scope == "auxiliary" 1104 aux = cfg.get("auxiliary") 1105 if not isinstance(aux, dict): 1106 aux = {} 1107 1108 if task == "__reset__": 1109 # Reset every slot to provider="auto", model="" — keeps other fields intact. 1110 for slot in _AUX_TASK_SLOTS: 1111 slot_cfg = aux.get(slot) 1112 if not isinstance(slot_cfg, dict): 1113 slot_cfg = {} 1114 slot_cfg["provider"] = "auto" 1115 slot_cfg["model"] = "" 1116 aux[slot] = slot_cfg 1117 cfg["auxiliary"] = aux 1118 save_config(cfg) 1119 return {"ok": True, "scope": "auxiliary", "reset": True} 1120 1121 if not provider: 1122 raise HTTPException(status_code=400, detail="provider required for auxiliary") 1123 1124 targets = [task] if task else list(_AUX_TASK_SLOTS) 1125 for slot in targets: 1126 if slot not in _AUX_TASK_SLOTS: 1127 raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") 1128 slot_cfg = aux.get(slot) 1129 if not isinstance(slot_cfg, dict): 1130 slot_cfg = {} 1131 slot_cfg["provider"] = provider 1132 slot_cfg["model"] = model 1133 aux[slot] = slot_cfg 1134 1135 cfg["auxiliary"] = aux 1136 save_config(cfg) 1137 return { 1138 "ok": True, 1139 "scope": "auxiliary", 1140 "tasks": targets, 1141 "provider": provider, 1142 "model": model, 1143 } 1144 except HTTPException: 1145 raise 1146 except Exception: 1147 _log.exception("POST /api/model/set failed") 1148 raise HTTPException(status_code=500, detail="Failed to save model assignment") 1149 1150 1151 1152 1153 def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: 1154 """Reverse _normalize_config_for_web before saving. 1155 1156 Reconstructs ``model`` as a dict by reading the current on-disk config 1157 to recover model subkeys (provider, base_url, api_mode, etc.) that were 1158 stripped from the GET response. The frontend only sees model as a flat 1159 string; the rest is preserved transparently. 1160 1161 Also handles ``model_context_length`` — writes it back into the model dict 1162 as ``context_length``. A value of 0 or absent means "auto-detect" (omitted 1163 from the dict so get_model_context_length() uses its normal resolution). 1164 """ 1165 config = dict(config) 1166 # Remove any _model_meta that might have leaked in (shouldn't happen 1167 # with the stripped GET response, but be defensive) 1168 config.pop("_model_meta", None) 1169 1170 # Extract and remove model_context_length before processing model 1171 ctx_override = config.pop("model_context_length", 0) 1172 if not isinstance(ctx_override, int): 1173 try: 1174 ctx_override = int(ctx_override) 1175 except (TypeError, ValueError): 1176 ctx_override = 0 1177 1178 model_val = config.get("model") 1179 if isinstance(model_val, str) and model_val: 1180 # Read the current disk config to recover model subkeys 1181 try: 1182 disk_config = load_config() 1183 disk_model = disk_config.get("model") 1184 if isinstance(disk_model, dict): 1185 # Preserve all subkeys, update default with the new value 1186 disk_model["default"] = model_val 1187 # Write context_length into the model dict (0 = remove/auto) 1188 if ctx_override > 0: 1189 disk_model["context_length"] = ctx_override 1190 else: 1191 disk_model.pop("context_length", None) 1192 config["model"] = disk_model 1193 else: 1194 # Model was previously a bare string — upgrade to dict if 1195 # user is setting a context_length override 1196 if ctx_override > 0: 1197 config["model"] = { 1198 "default": model_val, 1199 "context_length": ctx_override, 1200 } 1201 except Exception: 1202 pass # can't read disk config — just use the string form 1203 return config 1204 1205 1206 @app.put("/api/config") 1207 async def update_config(body: ConfigUpdate): 1208 try: 1209 save_config(_denormalize_config_from_web(body.config)) 1210 return {"ok": True} 1211 except Exception: 1212 _log.exception("PUT /api/config failed") 1213 raise HTTPException(status_code=500, detail="Internal server error") 1214 1215 1216 @app.get("/api/env") 1217 async def get_env_vars(): 1218 env_on_disk = load_env() 1219 result = {} 1220 for var_name, info in OPTIONAL_ENV_VARS.items(): 1221 value = env_on_disk.get(var_name) 1222 result[var_name] = { 1223 "is_set": bool(value), 1224 "redacted_value": redact_key(value) if value else None, 1225 "description": info.get("description", ""), 1226 "url": info.get("url"), 1227 "category": info.get("category", ""), 1228 "is_password": info.get("password", False), 1229 "tools": info.get("tools", []), 1230 "advanced": info.get("advanced", False), 1231 } 1232 return result 1233 1234 1235 @app.put("/api/env") 1236 async def set_env_var(body: EnvVarUpdate): 1237 try: 1238 save_env_value(body.key, body.value) 1239 return {"ok": True, "key": body.key} 1240 except Exception: 1241 _log.exception("PUT /api/env failed") 1242 raise HTTPException(status_code=500, detail="Internal server error") 1243 1244 1245 @app.delete("/api/env") 1246 async def remove_env_var(body: EnvVarDelete): 1247 try: 1248 removed = remove_env_value(body.key) 1249 if not removed: 1250 raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") 1251 return {"ok": True, "key": body.key} 1252 except HTTPException: 1253 raise 1254 except Exception: 1255 _log.exception("DELETE /api/env failed") 1256 raise HTTPException(status_code=500, detail="Internal server error") 1257 1258 1259 @app.post("/api/env/reveal") 1260 async def reveal_env_var(body: EnvVarReveal, request: Request): 1261 """Return the real (unredacted) value of a single env var. 1262 1263 Protected by: 1264 - Ephemeral session token (generated per server start, injected into SPA) 1265 - Rate limiting (max 5 reveals per 30s window) 1266 - Audit logging 1267 """ 1268 # --- Token check --- 1269 _require_token(request) 1270 1271 # --- Rate limit --- 1272 now = time.time() 1273 cutoff = now - _REVEAL_WINDOW_SECONDS 1274 _reveal_timestamps[:] = [t for t in _reveal_timestamps if t > cutoff] 1275 if len(_reveal_timestamps) >= _REVEAL_MAX_PER_WINDOW: 1276 raise HTTPException(status_code=429, detail="Too many reveal requests. Try again shortly.") 1277 _reveal_timestamps.append(now) 1278 1279 # --- Reveal --- 1280 env_on_disk = load_env() 1281 value = env_on_disk.get(body.key) 1282 if value is None: 1283 raise HTTPException(status_code=404, detail=f"{body.key} not found in .env") 1284 1285 _log.info("env/reveal: %s", body.key) 1286 return {"key": body.key, "value": value} 1287 1288 1289 # --------------------------------------------------------------------------- 1290 # OAuth provider endpoints — status + disconnect (Phase 1) 1291 # --------------------------------------------------------------------------- 1292 # 1293 # Phase 1 surfaces *which OAuth providers exist* and whether each is 1294 # connected, plus a disconnect button. The actual login flow (PKCE for 1295 # Anthropic, device-code for Nous/Codex) still runs in the CLI for now; 1296 # Phase 2 will add in-browser flows. For unconnected providers we return 1297 # the canonical ``hermes auth add <provider>`` command so the dashboard 1298 # can surface a one-click copy. 1299 1300 1301 def _truncate_token(value: Optional[str], visible: int = 6) -> str: 1302 """Return ``...XXXXXX`` (last N chars) for safe display in the UI. 1303 1304 We never expose more than the trailing ``visible`` characters of an 1305 OAuth access token. JWT prefixes (the part before the first dot) are 1306 stripped first when present so the visible suffix is always part of 1307 the signing region rather than a meaningless header chunk. 1308 """ 1309 if not value: 1310 return "" 1311 s = str(value) 1312 if "." in s and s.count(".") >= 2: 1313 # Looks like a JWT — show the trailing piece of the signature only. 1314 s = s.rsplit(".", 1)[-1] 1315 if len(s) <= visible: 1316 return s 1317 return f"…{s[-visible:]}" 1318 1319 1320 def _anthropic_oauth_status() -> Dict[str, Any]: 1321 """Combined status across the three Anthropic credential sources we read. 1322 1323 Hermes resolves Anthropic creds in this order at runtime: 1324 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed PKCE flow 1325 2. ``~/.claude/.credentials.json`` — Claude Code CLI credentials (auto) 1326 3. ``ANTHROPIC_TOKEN`` / ``ANTHROPIC_API_KEY`` env vars 1327 The dashboard reports the highest-priority source that's actually present. 1328 """ 1329 try: 1330 from agent.anthropic_adapter import ( 1331 read_hermes_oauth_credentials, 1332 read_claude_code_credentials, 1333 _HERMES_OAUTH_FILE, 1334 ) 1335 except ImportError: 1336 read_claude_code_credentials = None # type: ignore 1337 read_hermes_oauth_credentials = None # type: ignore 1338 _HERMES_OAUTH_FILE = None # type: ignore 1339 1340 hermes_creds = None 1341 if read_hermes_oauth_credentials: 1342 try: 1343 hermes_creds = read_hermes_oauth_credentials() 1344 except Exception: 1345 hermes_creds = None 1346 if hermes_creds and hermes_creds.get("accessToken"): 1347 return { 1348 "logged_in": True, 1349 "source": "hermes_pkce", 1350 "source_label": f"Hermes PKCE ({_HERMES_OAUTH_FILE})", 1351 "token_preview": _truncate_token(hermes_creds.get("accessToken")), 1352 "expires_at": hermes_creds.get("expiresAt"), 1353 "has_refresh_token": bool(hermes_creds.get("refreshToken")), 1354 } 1355 1356 cc_creds = None 1357 if read_claude_code_credentials: 1358 try: 1359 cc_creds = read_claude_code_credentials() 1360 except Exception: 1361 cc_creds = None 1362 if cc_creds and cc_creds.get("accessToken"): 1363 return { 1364 "logged_in": True, 1365 "source": "claude_code", 1366 "source_label": "Claude Code (~/.claude/.credentials.json)", 1367 "token_preview": _truncate_token(cc_creds.get("accessToken")), 1368 "expires_at": cc_creds.get("expiresAt"), 1369 "has_refresh_token": bool(cc_creds.get("refreshToken")), 1370 } 1371 1372 env_token = os.getenv("ANTHROPIC_TOKEN") or os.getenv("CLAUDE_CODE_OAUTH_TOKEN") 1373 if env_token: 1374 return { 1375 "logged_in": True, 1376 "source": "env_var", 1377 "source_label": "ANTHROPIC_TOKEN environment variable", 1378 "token_preview": _truncate_token(env_token), 1379 "expires_at": None, 1380 "has_refresh_token": False, 1381 } 1382 return {"logged_in": False, "source": None} 1383 1384 1385 def _claude_code_only_status() -> Dict[str, Any]: 1386 """Surface Claude Code CLI credentials as their own provider entry. 1387 1388 Independent of the Anthropic entry above so users can see whether their 1389 Claude Code subscription tokens are actively flowing into Hermes even 1390 when they also have a separate Hermes-managed PKCE login. 1391 """ 1392 try: 1393 from agent.anthropic_adapter import read_claude_code_credentials 1394 creds = read_claude_code_credentials() 1395 except Exception: 1396 creds = None 1397 if creds and creds.get("accessToken"): 1398 return { 1399 "logged_in": True, 1400 "source": "claude_code_cli", 1401 "source_label": "~/.claude/.credentials.json", 1402 "token_preview": _truncate_token(creds.get("accessToken")), 1403 "expires_at": creds.get("expiresAt"), 1404 "has_refresh_token": bool(creds.get("refreshToken")), 1405 } 1406 return {"logged_in": False, "source": None} 1407 1408 1409 # Provider catalog. The order matters — it's how we render the UI list. 1410 # ``cli_command`` is what the dashboard surfaces as the copy-to-clipboard 1411 # fallback while Phase 2 (in-browser flows) isn't built yet. 1412 # ``flow`` describes the OAuth shape so the future modal can pick the 1413 # right UI: ``pkce`` = open URL + paste callback code, ``device_code`` = 1414 # show code + verification URL + poll, ``external`` = read-only (delegated 1415 # to a third-party CLI like Claude Code or Qwen). 1416 _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( 1417 { 1418 "id": "anthropic", 1419 "name": "Anthropic (Claude API)", 1420 "flow": "pkce", 1421 "cli_command": "hermes auth add anthropic", 1422 "docs_url": "https://docs.claude.com/en/api/getting-started", 1423 "status_fn": _anthropic_oauth_status, 1424 }, 1425 { 1426 "id": "claude-code", 1427 "name": "Claude Code (subscription)", 1428 "flow": "external", 1429 "cli_command": "claude setup-token", 1430 "docs_url": "https://docs.claude.com/en/docs/claude-code", 1431 "status_fn": _claude_code_only_status, 1432 }, 1433 { 1434 "id": "nous", 1435 "name": "Nous Portal", 1436 "flow": "device_code", 1437 "cli_command": "hermes auth add nous", 1438 "docs_url": "https://portal.nousresearch.com", 1439 "status_fn": None, # dispatched via auth.get_nous_auth_status 1440 }, 1441 { 1442 "id": "openai-codex", 1443 "name": "OpenAI Codex (ChatGPT)", 1444 "flow": "device_code", 1445 "cli_command": "hermes auth add openai-codex", 1446 "docs_url": "https://platform.openai.com/docs", 1447 "status_fn": None, # dispatched via auth.get_codex_auth_status 1448 }, 1449 { 1450 "id": "qwen-oauth", 1451 "name": "Qwen (via Qwen CLI)", 1452 "flow": "external", 1453 "cli_command": "hermes auth add qwen-oauth", 1454 "docs_url": "https://github.com/QwenLM/qwen-code", 1455 "status_fn": None, # dispatched via auth.get_qwen_auth_status 1456 }, 1457 { 1458 "id": "minimax-oauth", 1459 "name": "MiniMax (OAuth)", 1460 "flow": "pkce", 1461 "cli_command": "hermes auth add minimax-oauth", 1462 "docs_url": "https://www.minimax.io", 1463 "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status 1464 }, 1465 ) 1466 1467 1468 def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: 1469 """Dispatch to the right status helper for an OAuth provider entry.""" 1470 if status_fn is not None: 1471 try: 1472 return status_fn() 1473 except Exception as e: 1474 return {"logged_in": False, "error": str(e)} 1475 try: 1476 from hermes_cli import auth as hauth 1477 if provider_id == "nous": 1478 raw = hauth.get_nous_auth_status() 1479 return { 1480 "logged_in": bool(raw.get("logged_in")), 1481 "source": "nous_portal", 1482 "source_label": raw.get("portal_base_url") or "Nous Portal", 1483 "token_preview": _truncate_token(raw.get("access_token")), 1484 "expires_at": raw.get("access_expires_at"), 1485 "has_refresh_token": bool(raw.get("has_refresh_token")), 1486 } 1487 if provider_id == "openai-codex": 1488 raw = hauth.get_codex_auth_status() 1489 return { 1490 "logged_in": bool(raw.get("logged_in")), 1491 "source": raw.get("source") or "openai_codex", 1492 "source_label": raw.get("auth_mode") or "OpenAI Codex", 1493 "token_preview": _truncate_token(raw.get("api_key")), 1494 "expires_at": None, 1495 "has_refresh_token": False, 1496 "last_refresh": raw.get("last_refresh"), 1497 } 1498 if provider_id == "qwen-oauth": 1499 raw = hauth.get_qwen_auth_status() 1500 return { 1501 "logged_in": bool(raw.get("logged_in")), 1502 "source": "qwen_cli", 1503 "source_label": raw.get("auth_store_path") or "Qwen CLI", 1504 "token_preview": _truncate_token(raw.get("access_token")), 1505 "expires_at": raw.get("expires_at"), 1506 "has_refresh_token": bool(raw.get("has_refresh_token")), 1507 } 1508 if provider_id == "minimax-oauth": 1509 raw = hauth.get_minimax_oauth_auth_status() 1510 return { 1511 "logged_in": bool(raw.get("logged_in")), 1512 "source": "minimax_oauth", 1513 "source_label": f"MiniMax ({raw.get('region', 'global')})", 1514 "token_preview": None, 1515 "expires_at": raw.get("expires_at"), 1516 "has_refresh_token": True, 1517 } 1518 except Exception as e: 1519 return {"logged_in": False, "error": str(e)} 1520 return {"logged_in": False} 1521 1522 1523 @app.get("/api/providers/oauth") 1524 async def list_oauth_providers(): 1525 """Enumerate every OAuth-capable LLM provider with current status. 1526 1527 Response shape (per provider): 1528 id stable identifier (used in DELETE path) 1529 name human label 1530 flow "pkce" | "device_code" | "external" 1531 cli_command fallback CLI command for users to run manually 1532 docs_url external docs/portal link for the "Learn more" link 1533 status: 1534 logged_in bool — currently has usable creds 1535 source short slug ("hermes_pkce", "claude_code", ...) 1536 source_label human-readable origin (file path, env var name) 1537 token_preview last N chars of the token, never the full token 1538 expires_at ISO timestamp string or null 1539 has_refresh_token bool 1540 """ 1541 providers = [] 1542 for p in _OAUTH_PROVIDER_CATALOG: 1543 status = _resolve_provider_status(p["id"], p.get("status_fn")) 1544 providers.append({ 1545 "id": p["id"], 1546 "name": p["name"], 1547 "flow": p["flow"], 1548 "cli_command": p["cli_command"], 1549 "docs_url": p["docs_url"], 1550 "status": status, 1551 }) 1552 return {"providers": providers} 1553 1554 1555 @app.delete("/api/providers/oauth/{provider_id}") 1556 async def disconnect_oauth_provider(provider_id: str, request: Request): 1557 """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" 1558 _require_token(request) 1559 1560 valid_ids = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} 1561 if provider_id not in valid_ids: 1562 raise HTTPException( 1563 status_code=400, 1564 detail=f"Unknown provider: {provider_id}. " 1565 f"Available: {', '.join(sorted(valid_ids))}", 1566 ) 1567 1568 # Anthropic and claude-code clear the same Hermes-managed PKCE file 1569 # AND forget the Claude Code import. We don't touch ~/.claude/* directly 1570 # — that's owned by the Claude Code CLI; users can re-auth there if they 1571 # want to undo a disconnect. 1572 if provider_id in ("anthropic", "claude-code"): 1573 try: 1574 from agent.anthropic_adapter import _HERMES_OAUTH_FILE 1575 if _HERMES_OAUTH_FILE.exists(): 1576 _HERMES_OAUTH_FILE.unlink() 1577 except Exception: 1578 pass 1579 # Also clear the credential pool entry if present. 1580 try: 1581 from hermes_cli.auth import clear_provider_auth 1582 clear_provider_auth("anthropic") 1583 except Exception: 1584 pass 1585 _log.info("oauth/disconnect: %s", provider_id) 1586 return {"ok": True, "provider": provider_id} 1587 1588 try: 1589 from hermes_cli.auth import clear_provider_auth 1590 cleared = clear_provider_auth(provider_id) 1591 _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) 1592 return {"ok": bool(cleared), "provider": provider_id} 1593 except Exception as e: 1594 _log.exception("disconnect %s failed", provider_id) 1595 raise HTTPException(status_code=500, detail=str(e)) 1596 1597 1598 # --------------------------------------------------------------------------- 1599 # OAuth Phase 2 — in-browser PKCE & device-code flows 1600 # --------------------------------------------------------------------------- 1601 # 1602 # Two flow shapes are supported: 1603 # 1604 # PKCE (Anthropic): 1605 # 1. POST /api/providers/oauth/anthropic/start 1606 # → server generates code_verifier + challenge, builds claude.ai 1607 # authorize URL, stashes verifier in _oauth_sessions[session_id] 1608 # → returns { session_id, flow: "pkce", auth_url } 1609 # 2. UI opens auth_url in a new tab. User authorizes, copies code. 1610 # 3. POST /api/providers/oauth/anthropic/submit { session_id, code } 1611 # → server exchanges (code + verifier) → tokens at console.anthropic.com 1612 # → persists to ~/.hermes/.anthropic_oauth.json AND credential pool 1613 # → returns { ok: true, status: "approved" } 1614 # 1615 # Device code (Nous, OpenAI Codex): 1616 # 1. POST /api/providers/oauth/{nous|openai-codex}/start 1617 # → server hits provider's device-auth endpoint 1618 # → gets { user_code, verification_url, device_code, interval, expires_in } 1619 # → spawns background poller thread that polls the token endpoint 1620 # every `interval` seconds until approved/expired 1621 # → stores poll status in _oauth_sessions[session_id] 1622 # → returns { session_id, flow: "device_code", user_code, 1623 # verification_url, expires_in, poll_interval } 1624 # 2. UI opens verification_url in a new tab and shows user_code. 1625 # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id} 1626 # every 2s until status != "pending". 1627 # 4. On "approved" the background thread has already saved creds; UI 1628 # refreshes the providers list. 1629 # 1630 # Sessions are kept in-memory only (single-process FastAPI) and time out 1631 # after 15 minutes. A periodic cleanup runs on each /start call to GC 1632 # expired sessions so the dict doesn't grow without bound. 1633 1634 _OAUTH_SESSION_TTL_SECONDS = 15 * 60 1635 _oauth_sessions: Dict[str, Dict[str, Any]] = {} 1636 _oauth_sessions_lock = threading.Lock() 1637 1638 # Import OAuth constants from canonical source instead of duplicating. 1639 # Guarded so hermes web still starts if anthropic_adapter is unavailable; 1640 # Phase 2 endpoints will return 501 in that case. 1641 try: 1642 from agent.anthropic_adapter import ( 1643 _OAUTH_CLIENT_ID as _ANTHROPIC_OAUTH_CLIENT_ID, 1644 _OAUTH_TOKEN_URL as _ANTHROPIC_OAUTH_TOKEN_URL, 1645 _OAUTH_REDIRECT_URI as _ANTHROPIC_OAUTH_REDIRECT_URI, 1646 _OAUTH_SCOPES as _ANTHROPIC_OAUTH_SCOPES, 1647 _generate_pkce as _generate_pkce_pair, 1648 ) 1649 _ANTHROPIC_OAUTH_AVAILABLE = True 1650 except ImportError: 1651 _ANTHROPIC_OAUTH_AVAILABLE = False 1652 _ANTHROPIC_OAUTH_AUTHORIZE_URL = "https://claude.ai/oauth/authorize" 1653 1654 1655 def _gc_oauth_sessions() -> None: 1656 """Drop expired sessions. Called opportunistically on /start.""" 1657 cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS 1658 with _oauth_sessions_lock: 1659 stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff] 1660 for sid in stale: 1661 _oauth_sessions.pop(sid, None) 1662 1663 1664 def _new_oauth_session(provider_id: str, flow: str) -> tuple[str, Dict[str, Any]]: 1665 """Create + register a new OAuth session, return (session_id, session_dict).""" 1666 sid = secrets.token_urlsafe(16) 1667 sess = { 1668 "session_id": sid, 1669 "provider": provider_id, 1670 "flow": flow, 1671 "created_at": time.time(), 1672 "status": "pending", # pending | approved | denied | expired | error 1673 "error_message": None, 1674 } 1675 with _oauth_sessions_lock: 1676 _oauth_sessions[sid] = sess 1677 return sid, sess 1678 1679 1680 def _save_anthropic_oauth_creds(access_token: str, refresh_token: str, expires_at_ms: int) -> None: 1681 """Persist Anthropic PKCE creds to both Hermes file AND credential pool. 1682 1683 Mirrors what auth_commands.add_command does so the dashboard flow leaves 1684 the system in the same state as ``hermes auth add anthropic``. 1685 """ 1686 from agent.anthropic_adapter import _HERMES_OAUTH_FILE 1687 payload = { 1688 "accessToken": access_token, 1689 "refreshToken": refresh_token, 1690 "expiresAt": expires_at_ms, 1691 } 1692 _HERMES_OAUTH_FILE.parent.mkdir(parents=True, exist_ok=True) 1693 _HERMES_OAUTH_FILE.write_text(json.dumps(payload, indent=2), encoding="utf-8") 1694 # Best-effort credential-pool insert. Failure here doesn't invalidate 1695 # the file write — pool registration only matters for the rotation 1696 # strategy, not for runtime credential resolution. 1697 try: 1698 from agent.credential_pool import ( 1699 PooledCredential, 1700 load_pool, 1701 AUTH_TYPE_OAUTH, 1702 SOURCE_MANUAL, 1703 ) 1704 import uuid 1705 pool = load_pool("anthropic") 1706 # Avoid duplicate entries: delete any prior dashboard-issued OAuth entry 1707 existing = [e for e in pool.entries() if getattr(e, "source", "").startswith(f"{SOURCE_MANUAL}:dashboard_pkce")] 1708 for e in existing: 1709 try: 1710 pool.remove_entry(getattr(e, "id", "")) 1711 except Exception: 1712 pass 1713 entry = PooledCredential( 1714 provider="anthropic", 1715 id=uuid.uuid4().hex[:6], 1716 label="dashboard PKCE", 1717 auth_type=AUTH_TYPE_OAUTH, 1718 priority=0, 1719 source=f"{SOURCE_MANUAL}:dashboard_pkce", 1720 access_token=access_token, 1721 refresh_token=refresh_token, 1722 expires_at_ms=expires_at_ms, 1723 ) 1724 pool.add_entry(entry) 1725 except Exception as e: 1726 _log.warning("anthropic pool add (dashboard) failed: %s", e) 1727 1728 1729 def _start_anthropic_pkce() -> Dict[str, Any]: 1730 """Begin PKCE flow. Returns the auth URL the UI should open.""" 1731 if not _ANTHROPIC_OAUTH_AVAILABLE: 1732 raise HTTPException(status_code=501, detail="Anthropic OAuth not available (missing adapter)") 1733 verifier, challenge = _generate_pkce_pair() 1734 sid, sess = _new_oauth_session("anthropic", "pkce") 1735 sess["verifier"] = verifier 1736 sess["state"] = verifier # Anthropic round-trips verifier as state 1737 params = { 1738 "code": "true", 1739 "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, 1740 "response_type": "code", 1741 "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, 1742 "scope": _ANTHROPIC_OAUTH_SCOPES, 1743 "code_challenge": challenge, 1744 "code_challenge_method": "S256", 1745 "state": verifier, 1746 } 1747 auth_url = f"{_ANTHROPIC_OAUTH_AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" 1748 return { 1749 "session_id": sid, 1750 "flow": "pkce", 1751 "auth_url": auth_url, 1752 "expires_in": _OAUTH_SESSION_TTL_SECONDS, 1753 } 1754 1755 1756 def _submit_anthropic_pkce(session_id: str, code_input: str) -> Dict[str, Any]: 1757 """Exchange authorization code for tokens. Persists on success.""" 1758 with _oauth_sessions_lock: 1759 sess = _oauth_sessions.get(session_id) 1760 if not sess or sess["provider"] != "anthropic" or sess["flow"] != "pkce": 1761 raise HTTPException(status_code=404, detail="Unknown or expired session") 1762 if sess["status"] != "pending": 1763 return {"ok": False, "status": sess["status"], "message": sess.get("error_message")} 1764 1765 # Anthropic's redirect callback page formats the code as `<code>#<state>`. 1766 # Strip the state suffix if present (we already have the verifier server-side). 1767 parts = code_input.strip().split("#", 1) 1768 code = parts[0].strip() 1769 if not code: 1770 return {"ok": False, "status": "error", "message": "No code provided"} 1771 state_from_callback = parts[1] if len(parts) > 1 else "" 1772 1773 exchange_data = json.dumps({ 1774 "grant_type": "authorization_code", 1775 "client_id": _ANTHROPIC_OAUTH_CLIENT_ID, 1776 "code": code, 1777 "state": state_from_callback or sess["state"], 1778 "redirect_uri": _ANTHROPIC_OAUTH_REDIRECT_URI, 1779 "code_verifier": sess["verifier"], 1780 }).encode() 1781 req = urllib.request.Request( 1782 _ANTHROPIC_OAUTH_TOKEN_URL, 1783 data=exchange_data, 1784 headers={ 1785 "Content-Type": "application/json", 1786 "User-Agent": "hermes-dashboard/1.0", 1787 }, 1788 method="POST", 1789 ) 1790 try: 1791 with urllib.request.urlopen(req, timeout=20) as resp: 1792 result = json.loads(resp.read().decode()) 1793 except Exception as e: 1794 with _oauth_sessions_lock: 1795 sess["status"] = "error" 1796 sess["error_message"] = f"Token exchange failed: {e}" 1797 return {"ok": False, "status": "error", "message": sess["error_message"]} 1798 1799 access_token = result.get("access_token", "") 1800 refresh_token = result.get("refresh_token", "") 1801 expires_in = int(result.get("expires_in") or 3600) 1802 if not access_token: 1803 with _oauth_sessions_lock: 1804 sess["status"] = "error" 1805 sess["error_message"] = "No access token returned" 1806 return {"ok": False, "status": "error", "message": sess["error_message"]} 1807 1808 expires_at_ms = int(time.time() * 1000) + (expires_in * 1000) 1809 try: 1810 _save_anthropic_oauth_creds(access_token, refresh_token, expires_at_ms) 1811 except Exception as e: 1812 with _oauth_sessions_lock: 1813 sess["status"] = "error" 1814 sess["error_message"] = f"Save failed: {e}" 1815 return {"ok": False, "status": "error", "message": sess["error_message"]} 1816 with _oauth_sessions_lock: 1817 sess["status"] = "approved" 1818 _log.info("oauth/pkce: anthropic login completed (session=%s)", session_id) 1819 return {"ok": True, "status": "approved"} 1820 1821 1822 async def _start_device_code_flow(provider_id: str) -> Dict[str, Any]: 1823 """Initiate a device-code flow (Nous or OpenAI Codex). 1824 1825 Calls the provider's device-auth endpoint via the existing CLI helpers, 1826 then spawns a background poller. Returns the user-facing display fields 1827 so the UI can render the verification page link + user code. 1828 """ 1829 if provider_id == "nous": 1830 from hermes_cli.auth import _request_device_code, PROVIDER_REGISTRY 1831 import httpx 1832 pconfig = PROVIDER_REGISTRY["nous"] 1833 portal_base_url = ( 1834 os.getenv("HERMES_PORTAL_BASE_URL") 1835 or os.getenv("NOUS_PORTAL_BASE_URL") 1836 or pconfig.portal_base_url 1837 ).rstrip("/") 1838 client_id = pconfig.client_id 1839 scope = pconfig.scope 1840 def _do_nous_device_request(): 1841 with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: 1842 return _request_device_code( 1843 client=client, 1844 portal_base_url=portal_base_url, 1845 client_id=client_id, 1846 scope=scope, 1847 ) 1848 device_data = await asyncio.get_event_loop().run_in_executor(None, _do_nous_device_request) 1849 sid, sess = _new_oauth_session("nous", "device_code") 1850 sess["device_code"] = str(device_data["device_code"]) 1851 sess["interval"] = int(device_data["interval"]) 1852 sess["expires_at"] = time.time() + int(device_data["expires_in"]) 1853 sess["portal_base_url"] = portal_base_url 1854 sess["client_id"] = client_id 1855 threading.Thread( 1856 target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}" 1857 ).start() 1858 return { 1859 "session_id": sid, 1860 "flow": "device_code", 1861 "user_code": str(device_data["user_code"]), 1862 "verification_url": str(device_data["verification_uri_complete"]), 1863 "expires_in": int(device_data["expires_in"]), 1864 "poll_interval": int(device_data["interval"]), 1865 } 1866 1867 if provider_id == "openai-codex": 1868 # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. 1869 sid, _ = _new_oauth_session("openai-codex", "device_code") 1870 # Use the helper but in a thread because it polls inline. 1871 # We can't extract just the start step without refactoring auth.py, 1872 # so we run the full helper in a worker and proxy the user_code + 1873 # verification_url back via the session dict. The helper prints 1874 # to stdout — we capture nothing here, just status. 1875 threading.Thread( 1876 target=_codex_full_login_worker, args=(sid,), daemon=True, 1877 name=f"oauth-codex-{sid[:6]}", 1878 ).start() 1879 # Block briefly until the worker has populated the user_code, OR error. 1880 deadline = time.time() + 10 1881 while time.time() < deadline: 1882 with _oauth_sessions_lock: 1883 s = _oauth_sessions.get(sid) 1884 if s and (s.get("user_code") or s["status"] != "pending"): 1885 break 1886 await asyncio.sleep(0.1) 1887 with _oauth_sessions_lock: 1888 s = _oauth_sessions.get(sid, {}) 1889 if s.get("status") == "error": 1890 raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed") 1891 if not s.get("user_code"): 1892 raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code") 1893 return { 1894 "session_id": sid, 1895 "flow": "device_code", 1896 "user_code": s["user_code"], 1897 "verification_url": s["verification_url"], 1898 "expires_in": int(s.get("expires_in") or 900), 1899 "poll_interval": int(s.get("interval") or 5), 1900 } 1901 1902 raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") 1903 1904 1905 def _nous_poller(session_id: str) -> None: 1906 """Background poller that drives a Nous device-code flow to completion.""" 1907 from hermes_cli.auth import _poll_for_token, refresh_nous_oauth_from_state 1908 from datetime import datetime, timezone 1909 import httpx 1910 with _oauth_sessions_lock: 1911 sess = _oauth_sessions.get(session_id) 1912 if not sess: 1913 return 1914 portal_base_url = sess["portal_base_url"] 1915 client_id = sess["client_id"] 1916 device_code = sess["device_code"] 1917 interval = sess["interval"] 1918 expires_in = max(60, int(sess["expires_at"] - time.time())) 1919 try: 1920 with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: 1921 token_data = _poll_for_token( 1922 client=client, 1923 portal_base_url=portal_base_url, 1924 client_id=client_id, 1925 device_code=device_code, 1926 expires_in=expires_in, 1927 poll_interval=interval, 1928 ) 1929 # Same post-processing as _nous_device_code_login (mint agent key) 1930 now = datetime.now(timezone.utc) 1931 token_ttl = int(token_data.get("expires_in") or 0) 1932 auth_state = { 1933 "portal_base_url": portal_base_url, 1934 "inference_base_url": token_data.get("inference_base_url"), 1935 "client_id": client_id, 1936 "scope": token_data.get("scope"), 1937 "token_type": token_data.get("token_type", "Bearer"), 1938 "access_token": token_data["access_token"], 1939 "refresh_token": token_data.get("refresh_token"), 1940 "obtained_at": now.isoformat(), 1941 "expires_at": ( 1942 datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat() 1943 if token_ttl else None 1944 ), 1945 "expires_in": token_ttl, 1946 } 1947 full_state = refresh_nous_oauth_from_state( 1948 auth_state, min_key_ttl_seconds=300, timeout_seconds=15.0, 1949 force_refresh=False, force_mint=True, 1950 ) 1951 from hermes_cli.auth import persist_nous_credentials 1952 persist_nous_credentials(full_state) 1953 with _oauth_sessions_lock: 1954 sess["status"] = "approved" 1955 _log.info("oauth/device: nous login completed (session=%s)", session_id) 1956 except Exception as e: 1957 _log.warning("nous device-code poll failed (session=%s): %s", session_id, e) 1958 with _oauth_sessions_lock: 1959 sess["status"] = "error" 1960 sess["error_message"] = str(e) 1961 1962 1963 def _codex_full_login_worker(session_id: str) -> None: 1964 """Run the complete OpenAI Codex device-code flow. 1965 1966 Codex doesn't use the standard OAuth device-code endpoints; it has its 1967 own ``/api/accounts/deviceauth/usercode`` (JSON body, returns 1968 ``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body 1969 polled until 200). On success the response carries an 1970 ``authorization_code`` + ``code_verifier`` that get exchanged at 1971 CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code. 1972 1973 The flow is replicated inline (rather than calling 1974 _codex_device_code_login) because that helper prints/blocks/polls in a 1975 single function — we need to surface the user_code to the dashboard the 1976 moment we receive it, well before polling completes. 1977 """ 1978 try: 1979 import httpx 1980 from hermes_cli.auth import ( 1981 CODEX_OAUTH_CLIENT_ID, 1982 CODEX_OAUTH_TOKEN_URL, 1983 DEFAULT_CODEX_BASE_URL, 1984 ) 1985 issuer = "https://auth.openai.com" 1986 1987 # Step 1: request device code 1988 with httpx.Client(timeout=httpx.Timeout(15.0)) as client: 1989 resp = client.post( 1990 f"{issuer}/api/accounts/deviceauth/usercode", 1991 json={"client_id": CODEX_OAUTH_CLIENT_ID}, 1992 headers={"Content-Type": "application/json"}, 1993 ) 1994 if resp.status_code != 200: 1995 raise RuntimeError(f"deviceauth/usercode returned {resp.status_code}") 1996 device_data = resp.json() 1997 user_code = device_data.get("user_code", "") 1998 device_auth_id = device_data.get("device_auth_id", "") 1999 poll_interval = max(3, int(device_data.get("interval", "5"))) 2000 if not user_code or not device_auth_id: 2001 raise RuntimeError("device-code response missing user_code or device_auth_id") 2002 verification_url = f"{issuer}/codex/device" 2003 with _oauth_sessions_lock: 2004 sess = _oauth_sessions.get(session_id) 2005 if not sess: 2006 return 2007 sess["user_code"] = user_code 2008 sess["verification_url"] = verification_url 2009 sess["device_auth_id"] = device_auth_id 2010 sess["interval"] = poll_interval 2011 sess["expires_in"] = 15 * 60 # OpenAI's effective limit 2012 sess["expires_at"] = time.time() + sess["expires_in"] 2013 2014 # Step 2: poll until authorized 2015 deadline = time.time() + sess["expires_in"] 2016 code_resp = None 2017 with httpx.Client(timeout=httpx.Timeout(15.0)) as client: 2018 while time.time() < deadline: 2019 time.sleep(poll_interval) 2020 poll = client.post( 2021 f"{issuer}/api/accounts/deviceauth/token", 2022 json={"device_auth_id": device_auth_id, "user_code": user_code}, 2023 headers={"Content-Type": "application/json"}, 2024 ) 2025 if poll.status_code == 200: 2026 code_resp = poll.json() 2027 break 2028 if poll.status_code in (403, 404): 2029 continue # user hasn't authorized yet 2030 raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") 2031 2032 if code_resp is None: 2033 with _oauth_sessions_lock: 2034 sess["status"] = "expired" 2035 sess["error_message"] = "Device code expired before approval" 2036 return 2037 2038 # Step 3: exchange authorization_code for tokens 2039 authorization_code = code_resp.get("authorization_code", "") 2040 code_verifier = code_resp.get("code_verifier", "") 2041 if not authorization_code or not code_verifier: 2042 raise RuntimeError("device-auth response missing authorization_code/code_verifier") 2043 with httpx.Client(timeout=httpx.Timeout(15.0)) as client: 2044 token_resp = client.post( 2045 CODEX_OAUTH_TOKEN_URL, 2046 data={ 2047 "grant_type": "authorization_code", 2048 "code": authorization_code, 2049 "redirect_uri": f"{issuer}/deviceauth/callback", 2050 "client_id": CODEX_OAUTH_CLIENT_ID, 2051 "code_verifier": code_verifier, 2052 }, 2053 headers={"Content-Type": "application/x-www-form-urlencoded"}, 2054 ) 2055 if token_resp.status_code != 200: 2056 raise RuntimeError(f"token exchange returned {token_resp.status_code}") 2057 tokens = token_resp.json() 2058 access_token = tokens.get("access_token", "") 2059 refresh_token = tokens.get("refresh_token", "") 2060 if not access_token: 2061 raise RuntimeError("token exchange did not return access_token") 2062 2063 # Persist via credential pool — same shape as auth_commands.add_command 2064 from agent.credential_pool import ( 2065 PooledCredential, 2066 load_pool, 2067 AUTH_TYPE_OAUTH, 2068 SOURCE_MANUAL, 2069 ) 2070 import uuid as _uuid 2071 pool = load_pool("openai-codex") 2072 base_url = ( 2073 os.getenv("HERMES_CODEX_BASE_URL", "").strip().rstrip("/") 2074 or DEFAULT_CODEX_BASE_URL 2075 ) 2076 entry = PooledCredential( 2077 provider="openai-codex", 2078 id=_uuid.uuid4().hex[:6], 2079 label="dashboard device_code", 2080 auth_type=AUTH_TYPE_OAUTH, 2081 priority=0, 2082 source=f"{SOURCE_MANUAL}:dashboard_device_code", 2083 access_token=access_token, 2084 refresh_token=refresh_token, 2085 base_url=base_url, 2086 ) 2087 pool.add_entry(entry) 2088 with _oauth_sessions_lock: 2089 sess["status"] = "approved" 2090 _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) 2091 except Exception as e: 2092 _log.warning("codex device-code worker failed (session=%s): %s", session_id, e) 2093 with _oauth_sessions_lock: 2094 s = _oauth_sessions.get(session_id) 2095 if s: 2096 s["status"] = "error" 2097 s["error_message"] = str(e) 2098 2099 2100 @app.post("/api/providers/oauth/{provider_id}/start") 2101 async def start_oauth_login(provider_id: str, request: Request): 2102 """Initiate an OAuth login flow. Token-protected.""" 2103 _require_token(request) 2104 _gc_oauth_sessions() 2105 valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} 2106 if provider_id not in valid: 2107 raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") 2108 catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id) 2109 if catalog_entry["flow"] == "external": 2110 raise HTTPException( 2111 status_code=400, 2112 detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually", 2113 ) 2114 try: 2115 if catalog_entry["flow"] == "pkce": 2116 return _start_anthropic_pkce() 2117 if catalog_entry["flow"] == "device_code": 2118 return await _start_device_code_flow(provider_id) 2119 except HTTPException: 2120 raise 2121 except Exception as e: 2122 _log.exception("oauth/start %s failed", provider_id) 2123 raise HTTPException(status_code=500, detail=str(e)) 2124 raise HTTPException(status_code=400, detail="Unsupported flow") 2125 2126 2127 class OAuthSubmitBody(BaseModel): 2128 session_id: str 2129 code: str 2130 2131 2132 @app.post("/api/providers/oauth/{provider_id}/submit") 2133 async def submit_oauth_code(provider_id: str, body: OAuthSubmitBody, request: Request): 2134 """Submit the auth code for PKCE flows. Token-protected.""" 2135 _require_token(request) 2136 if provider_id == "anthropic": 2137 return await asyncio.get_event_loop().run_in_executor( 2138 None, _submit_anthropic_pkce, body.session_id, body.code, 2139 ) 2140 raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") 2141 2142 2143 @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") 2144 async def poll_oauth_session(provider_id: str, session_id: str): 2145 """Poll a device-code session's status (no auth — read-only state).""" 2146 with _oauth_sessions_lock: 2147 sess = _oauth_sessions.get(session_id) 2148 if not sess: 2149 raise HTTPException(status_code=404, detail="Session not found or expired") 2150 if sess["provider"] != provider_id: 2151 raise HTTPException(status_code=400, detail="Provider mismatch for session") 2152 return { 2153 "session_id": session_id, 2154 "status": sess["status"], 2155 "error_message": sess.get("error_message"), 2156 "expires_at": sess.get("expires_at"), 2157 } 2158 2159 2160 @app.delete("/api/providers/oauth/sessions/{session_id}") 2161 async def cancel_oauth_session(session_id: str, request: Request): 2162 """Cancel a pending OAuth session. Token-protected.""" 2163 _require_token(request) 2164 with _oauth_sessions_lock: 2165 sess = _oauth_sessions.pop(session_id, None) 2166 if sess is None: 2167 return {"ok": False, "message": "session not found"} 2168 return {"ok": True, "session_id": session_id} 2169 2170 2171 # --------------------------------------------------------------------------- 2172 # Session detail endpoints 2173 # --------------------------------------------------------------------------- 2174 2175 2176 @app.get("/api/sessions/{session_id}") 2177 async def get_session_detail(session_id: str): 2178 from hermes_state import SessionDB 2179 db = SessionDB() 2180 try: 2181 sid = db.resolve_session_id(session_id) 2182 session = db.get_session(sid) if sid else None 2183 if not session: 2184 raise HTTPException(status_code=404, detail="Session not found") 2185 return session 2186 finally: 2187 db.close() 2188 2189 2190 @app.get("/api/sessions/{session_id}/messages") 2191 async def get_session_messages(session_id: str): 2192 from hermes_state import SessionDB 2193 db = SessionDB() 2194 try: 2195 sid = db.resolve_session_id(session_id) 2196 if not sid: 2197 raise HTTPException(status_code=404, detail="Session not found") 2198 messages = db.get_messages(sid) 2199 return {"session_id": sid, "messages": messages} 2200 finally: 2201 db.close() 2202 2203 2204 @app.delete("/api/sessions/{session_id}") 2205 async def delete_session_endpoint(session_id: str): 2206 from hermes_state import SessionDB 2207 db = SessionDB() 2208 try: 2209 if not db.delete_session(session_id): 2210 raise HTTPException(status_code=404, detail="Session not found") 2211 return {"ok": True} 2212 finally: 2213 db.close() 2214 2215 2216 # --------------------------------------------------------------------------- 2217 # Log viewer endpoint 2218 # --------------------------------------------------------------------------- 2219 2220 2221 @app.get("/api/logs") 2222 async def get_logs( 2223 file: str = "agent", 2224 lines: int = 100, 2225 level: Optional[str] = None, 2226 component: Optional[str] = None, 2227 search: Optional[str] = None, 2228 ): 2229 from hermes_cli.logs import _read_tail, LOG_FILES 2230 2231 log_name = LOG_FILES.get(file) 2232 if not log_name: 2233 raise HTTPException(status_code=400, detail=f"Unknown log file: {file}") 2234 log_path = get_hermes_home() / "logs" / log_name 2235 if not log_path.exists(): 2236 return {"file": file, "lines": []} 2237 2238 try: 2239 from hermes_logging import COMPONENT_PREFIXES 2240 except ImportError: 2241 COMPONENT_PREFIXES = {} 2242 2243 # Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an 2244 # empty tuple as "must match a prefix" (startswith(()) is always False), 2245 # so passing () instead of None silently drops every line. 2246 min_level = level if level and level.upper() != "ALL" else None 2247 if component and component.lower() != "all": 2248 comp_prefixes = COMPONENT_PREFIXES.get(component) 2249 if comp_prefixes is None: 2250 raise HTTPException( 2251 status_code=400, 2252 detail=f"Unknown component: {component}. " 2253 f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", 2254 ) 2255 else: 2256 comp_prefixes = None 2257 2258 has_filters = bool(min_level or comp_prefixes or search) 2259 result = _read_tail( 2260 log_path, min(lines, 500) if not search else 2000, 2261 has_filters=has_filters, 2262 min_level=min_level, 2263 component_prefixes=comp_prefixes, 2264 ) 2265 # Post-filter by search term (case-insensitive substring match). 2266 # _read_tail doesn't support free-text search, so we filter here and 2267 # trim to the requested line count afterward. 2268 if search: 2269 needle = search.lower() 2270 result = [l for l in result if needle in l.lower()][-min(lines, 500):] 2271 return {"file": file, "lines": result} 2272 2273 2274 # --------------------------------------------------------------------------- 2275 # Cron job management endpoints 2276 # --------------------------------------------------------------------------- 2277 2278 2279 class CronJobCreate(BaseModel): 2280 prompt: str 2281 schedule: str 2282 name: str = "" 2283 deliver: str = "local" 2284 2285 2286 class CronJobUpdate(BaseModel): 2287 updates: dict 2288 2289 2290 @app.get("/api/cron/jobs") 2291 async def list_cron_jobs(): 2292 from cron.jobs import list_jobs 2293 return list_jobs(include_disabled=True) 2294 2295 2296 @app.get("/api/cron/jobs/{job_id}") 2297 async def get_cron_job(job_id: str): 2298 from cron.jobs import get_job 2299 job = get_job(job_id) 2300 if not job: 2301 raise HTTPException(status_code=404, detail="Job not found") 2302 return job 2303 2304 2305 @app.post("/api/cron/jobs") 2306 async def create_cron_job(body: CronJobCreate): 2307 from cron.jobs import create_job 2308 try: 2309 job = create_job(prompt=body.prompt, schedule=body.schedule, 2310 name=body.name, deliver=body.deliver) 2311 return job 2312 except Exception as e: 2313 _log.exception("POST /api/cron/jobs failed") 2314 raise HTTPException(status_code=400, detail=str(e)) 2315 2316 2317 @app.put("/api/cron/jobs/{job_id}") 2318 async def update_cron_job(job_id: str, body: CronJobUpdate): 2319 from cron.jobs import update_job 2320 job = update_job(job_id, body.updates) 2321 if not job: 2322 raise HTTPException(status_code=404, detail="Job not found") 2323 return job 2324 2325 2326 @app.post("/api/cron/jobs/{job_id}/pause") 2327 async def pause_cron_job(job_id: str): 2328 from cron.jobs import pause_job 2329 job = pause_job(job_id) 2330 if not job: 2331 raise HTTPException(status_code=404, detail="Job not found") 2332 return job 2333 2334 2335 @app.post("/api/cron/jobs/{job_id}/resume") 2336 async def resume_cron_job(job_id: str): 2337 from cron.jobs import resume_job 2338 job = resume_job(job_id) 2339 if not job: 2340 raise HTTPException(status_code=404, detail="Job not found") 2341 return job 2342 2343 2344 @app.post("/api/cron/jobs/{job_id}/trigger") 2345 async def trigger_cron_job(job_id: str): 2346 from cron.jobs import trigger_job 2347 job = trigger_job(job_id) 2348 if not job: 2349 raise HTTPException(status_code=404, detail="Job not found") 2350 return job 2351 2352 2353 @app.delete("/api/cron/jobs/{job_id}") 2354 async def delete_cron_job(job_id: str): 2355 from cron.jobs import remove_job 2356 if not remove_job(job_id): 2357 raise HTTPException(status_code=404, detail="Job not found") 2358 return {"ok": True} 2359 2360 2361 # --------------------------------------------------------------------------- 2362 # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) 2363 # --------------------------------------------------------------------------- 2364 2365 2366 class ProfileCreate(BaseModel): 2367 name: str 2368 clone_from_default: bool = False 2369 2370 2371 class ProfileRename(BaseModel): 2372 new_name: str 2373 2374 2375 class ProfileSoulUpdate(BaseModel): 2376 content: str 2377 2378 2379 def _profile_attr(info, name: str, default: Any = None) -> Any: 2380 try: 2381 return getattr(info, name) 2382 except Exception: 2383 return default 2384 2385 2386 def _profile_to_dict(info) -> Dict[str, Any]: 2387 return { 2388 "name": _profile_attr(info, "name", ""), 2389 "path": str(_profile_attr(info, "path", "")), 2390 "is_default": bool(_profile_attr(info, "is_default", False)), 2391 "model": _profile_attr(info, "model"), 2392 "provider": _profile_attr(info, "provider"), 2393 "has_env": bool(_profile_attr(info, "has_env", False)), 2394 "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), 2395 } 2396 2397 2398 def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: 2399 def _safe(callable_, default): 2400 try: 2401 return callable_() 2402 except Exception: 2403 return default 2404 2405 profiles: List[Dict[str, Any]] = [] 2406 default_home = profiles_mod._get_default_hermes_home() 2407 if default_home.is_dir(): 2408 model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) 2409 profiles.append({ 2410 "name": "default", 2411 "path": str(default_home), 2412 "is_default": True, 2413 "model": model, 2414 "provider": provider, 2415 "has_env": (default_home / ".env").exists(), 2416 "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), 2417 }) 2418 2419 profiles_root = profiles_mod._get_profiles_root() 2420 if profiles_root.is_dir(): 2421 for entry in sorted(profiles_root.iterdir()): 2422 if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): 2423 continue 2424 model, provider = _safe(lambda entry=entry: profiles_mod._read_config_model(entry), (None, None)) 2425 profiles.append({ 2426 "name": entry.name, 2427 "path": str(entry), 2428 "is_default": False, 2429 "model": model, 2430 "provider": provider, 2431 "has_env": (entry / ".env").exists(), 2432 "skill_count": _safe(lambda entry=entry: profiles_mod._count_skills(entry), 0), 2433 }) 2434 2435 return profiles 2436 2437 2438 def _resolve_profile_dir(name: str) -> Path: 2439 """Validate ``name`` and resolve to its directory or raise an HTTPException.""" 2440 from hermes_cli import profiles as profiles_mod 2441 try: 2442 profiles_mod.validate_profile_name(name) 2443 except ValueError as e: 2444 raise HTTPException(status_code=400, detail=str(e)) 2445 if not profiles_mod.profile_exists(name): 2446 raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.") 2447 return profiles_mod.get_profile_dir(name) 2448 2449 2450 def _profile_setup_command(name: str) -> str: 2451 """Return the shell command used to configure a profile in the CLI.""" 2452 _resolve_profile_dir(name) 2453 return "hermes setup" if name == "default" else f"{name} setup" 2454 2455 2456 @app.get("/api/profiles") 2457 async def list_profiles_endpoint(): 2458 from hermes_cli import profiles as profiles_mod 2459 try: 2460 return {"profiles": [_profile_to_dict(p) for p in profiles_mod.list_profiles()]} 2461 except Exception: 2462 _log.exception("GET /api/profiles failed; falling back to profile directory scan") 2463 return {"profiles": _fallback_profile_dicts(profiles_mod)} 2464 2465 2466 @app.post("/api/profiles") 2467 async def create_profile_endpoint(body: ProfileCreate): 2468 from hermes_cli import profiles as profiles_mod 2469 try: 2470 path = profiles_mod.create_profile( 2471 name=body.name, 2472 clone_from="default" if body.clone_from_default else None, 2473 clone_config=body.clone_from_default, 2474 ) 2475 # Match the CLI's profile-create flow: fresh named profiles get the 2476 # bundled skills installed. When cloning from default, create_profile() 2477 # has already copied the source profile's skills, including any 2478 # user-installed skills. 2479 if not body.clone_from_default: 2480 profiles_mod.seed_profile_skills(path, quiet=True) 2481 2482 # Match the CLI's profile-create flow: named profiles should get a 2483 # wrapper in ~/.local/bin when the alias is safe to create. 2484 collision = profiles_mod.check_alias_collision(body.name) 2485 if not collision: 2486 profiles_mod.create_wrapper_script(body.name) 2487 except (ValueError, FileExistsError, FileNotFoundError) as e: 2488 raise HTTPException(status_code=400, detail=str(e)) 2489 except Exception as e: 2490 _log.exception("POST /api/profiles failed") 2491 raise HTTPException(status_code=500, detail=str(e)) 2492 return {"ok": True, "name": body.name, "path": str(path)} 2493 2494 2495 @app.get("/api/profiles/{name}/setup-command") 2496 async def get_profile_setup_command(name: str): 2497 return {"command": _profile_setup_command(name)} 2498 2499 2500 @app.post("/api/profiles/{name}/open-terminal") 2501 async def open_profile_terminal_endpoint(name: str): 2502 try: 2503 command = _profile_setup_command(name) 2504 2505 if sys.platform.startswith("win"): 2506 subprocess.Popen(["cmd.exe", "/c", "start", "", command]) 2507 elif sys.platform == "darwin": 2508 escaped = command.replace("\\", "\\\\").replace('"', '\\"') 2509 applescript = ( 2510 'tell application "Terminal"\n' 2511 "activate\n" 2512 f'do script "{escaped}"\n' 2513 "end tell" 2514 ) 2515 subprocess.Popen(["osascript", "-e", applescript]) 2516 else: 2517 terminal_commands = [ 2518 ("x-terminal-emulator", ["x-terminal-emulator", "-e", "sh", "-lc", command]), 2519 ("gnome-terminal", ["gnome-terminal", "--", "sh", "-lc", command]), 2520 ("konsole", ["konsole", "-e", "sh", "-lc", command]), 2521 ("xfce4-terminal", ["xfce4-terminal", "-e", f"sh -lc '{command}'"]), 2522 ("mate-terminal", ["mate-terminal", "-e", f"sh -lc '{command}'"]), 2523 ("lxterminal", ["lxterminal", "-e", f"sh -lc '{command}'"]), 2524 ("tilix", ["tilix", "-e", "sh", "-lc", command]), 2525 ("alacritty", ["alacritty", "-e", "sh", "-lc", command]), 2526 ("kitty", ["kitty", "sh", "-lc", command]), 2527 ("xterm", ["xterm", "-e", "sh", "-lc", command]), 2528 ] 2529 for executable, popen_args in terminal_commands: 2530 if subprocess.call( 2531 ["which", executable], 2532 stdout=subprocess.DEVNULL, 2533 stderr=subprocess.DEVNULL, 2534 ) == 0: 2535 subprocess.Popen(popen_args) 2536 break 2537 else: 2538 raise HTTPException( 2539 status_code=400, 2540 detail="No supported terminal emulator found", 2541 ) 2542 except FileNotFoundError as e: 2543 raise HTTPException(status_code=404, detail=str(e)) 2544 except ValueError as e: 2545 raise HTTPException(status_code=400, detail=str(e)) 2546 except HTTPException: 2547 raise 2548 except Exception as e: 2549 _log.exception("POST /api/profiles/%s/open-terminal failed", name) 2550 raise HTTPException(status_code=500, detail=str(e)) 2551 return {"ok": True, "command": command} 2552 2553 2554 @app.patch("/api/profiles/{name}") 2555 async def rename_profile_endpoint(name: str, body: ProfileRename): 2556 from hermes_cli import profiles as profiles_mod 2557 try: 2558 path = profiles_mod.rename_profile(name, body.new_name) 2559 except FileNotFoundError as e: 2560 raise HTTPException(status_code=404, detail=str(e)) 2561 except (ValueError, FileExistsError) as e: 2562 raise HTTPException(status_code=400, detail=str(e)) 2563 except Exception as e: 2564 _log.exception("PATCH /api/profiles/%s failed", name) 2565 raise HTTPException(status_code=500, detail=str(e)) 2566 return {"ok": True, "name": body.new_name, "path": str(path)} 2567 2568 2569 @app.delete("/api/profiles/{name}") 2570 async def delete_profile_endpoint(name: str): 2571 """Delete a profile. The dashboard collects the user's confirmation in 2572 its own dialog before this request, so we always pass ``yes=True`` to 2573 skip the CLI's interactive prompt.""" 2574 from hermes_cli import profiles as profiles_mod 2575 try: 2576 path = profiles_mod.delete_profile(name, yes=True) 2577 except FileNotFoundError as e: 2578 raise HTTPException(status_code=404, detail=str(e)) 2579 except ValueError as e: 2580 raise HTTPException(status_code=400, detail=str(e)) 2581 except Exception as e: 2582 _log.exception("DELETE /api/profiles/%s failed", name) 2583 raise HTTPException(status_code=500, detail=str(e)) 2584 return {"ok": True, "path": str(path)} 2585 2586 2587 @app.get("/api/profiles/{name}/soul") 2588 async def get_profile_soul(name: str): 2589 soul_path = _resolve_profile_dir(name) / "SOUL.md" 2590 if soul_path.exists(): 2591 try: 2592 return {"content": soul_path.read_text(encoding="utf-8"), "exists": True} 2593 except OSError as e: 2594 raise HTTPException(status_code=500, detail=f"Could not read SOUL.md: {e}") 2595 return {"content": "", "exists": False} 2596 2597 2598 @app.put("/api/profiles/{name}/soul") 2599 async def update_profile_soul(name: str, body: ProfileSoulUpdate): 2600 soul_path = _resolve_profile_dir(name) / "SOUL.md" 2601 try: 2602 soul_path.write_text(body.content, encoding="utf-8") 2603 except OSError as e: 2604 _log.exception("PUT /api/profiles/%s/soul failed", name) 2605 raise HTTPException(status_code=500, detail=f"Could not write SOUL.md: {e}") 2606 return {"ok": True} 2607 2608 2609 # --------------------------------------------------------------------------- 2610 # Skills & Tools endpoints 2611 # --------------------------------------------------------------------------- 2612 2613 2614 class SkillToggle(BaseModel): 2615 name: str 2616 enabled: bool 2617 2618 2619 @app.get("/api/skills") 2620 async def get_skills(): 2621 from tools.skills_tool import _find_all_skills 2622 from hermes_cli.skills_config import get_disabled_skills 2623 config = load_config() 2624 disabled = get_disabled_skills(config) 2625 skills = _find_all_skills(skip_disabled=True) 2626 for s in skills: 2627 s["enabled"] = s["name"] not in disabled 2628 return skills 2629 2630 2631 @app.put("/api/skills/toggle") 2632 async def toggle_skill(body: SkillToggle): 2633 from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills 2634 config = load_config() 2635 disabled = get_disabled_skills(config) 2636 if body.enabled: 2637 disabled.discard(body.name) 2638 else: 2639 disabled.add(body.name) 2640 save_disabled_skills(config, disabled) 2641 return {"ok": True, "name": body.name, "enabled": body.enabled} 2642 2643 2644 @app.get("/api/tools/toolsets") 2645 async def get_toolsets(): 2646 from hermes_cli.tools_config import ( 2647 _get_effective_configurable_toolsets, 2648 _get_platform_tools, 2649 _toolset_has_keys, 2650 ) 2651 from toolsets import resolve_toolset 2652 2653 config = load_config() 2654 enabled_toolsets = _get_platform_tools( 2655 config, 2656 "cli", 2657 include_default_mcp_servers=False, 2658 ) 2659 result = [] 2660 for name, label, desc in _get_effective_configurable_toolsets(): 2661 try: 2662 tools = sorted(set(resolve_toolset(name))) 2663 except Exception: 2664 tools = [] 2665 is_enabled = name in enabled_toolsets 2666 result.append({ 2667 "name": name, "label": label, "description": desc, 2668 "enabled": is_enabled, 2669 "available": is_enabled, 2670 "configured": _toolset_has_keys(name, config), 2671 "tools": tools, 2672 }) 2673 return result 2674 2675 2676 # --------------------------------------------------------------------------- 2677 # Raw YAML config endpoint 2678 # --------------------------------------------------------------------------- 2679 2680 2681 class RawConfigUpdate(BaseModel): 2682 yaml_text: str 2683 2684 2685 @app.get("/api/config/raw") 2686 async def get_config_raw(): 2687 path = get_config_path() 2688 if not path.exists(): 2689 return {"yaml": ""} 2690 return {"yaml": path.read_text(encoding="utf-8")} 2691 2692 2693 @app.put("/api/config/raw") 2694 async def update_config_raw(body: RawConfigUpdate): 2695 try: 2696 parsed = yaml.safe_load(body.yaml_text) 2697 if not isinstance(parsed, dict): 2698 raise HTTPException(status_code=400, detail="YAML must be a mapping") 2699 save_config(parsed) 2700 return {"ok": True} 2701 except yaml.YAMLError as e: 2702 raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") 2703 2704 2705 # --------------------------------------------------------------------------- 2706 # Token / cost analytics endpoint 2707 # --------------------------------------------------------------------------- 2708 2709 2710 @app.get("/api/analytics/usage") 2711 async def get_usage_analytics(days: int = 30): 2712 from hermes_state import SessionDB 2713 from agent.insights import InsightsEngine 2714 2715 db = SessionDB() 2716 try: 2717 cutoff = time.time() - (days * 86400) 2718 cur = db._conn.execute(""" 2719 SELECT date(started_at, 'unixepoch') as day, 2720 SUM(input_tokens) as input_tokens, 2721 SUM(output_tokens) as output_tokens, 2722 SUM(cache_read_tokens) as cache_read_tokens, 2723 SUM(reasoning_tokens) as reasoning_tokens, 2724 COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, 2725 COALESCE(SUM(actual_cost_usd), 0) as actual_cost, 2726 COUNT(*) as sessions, 2727 SUM(COALESCE(api_call_count, 0)) as api_calls 2728 FROM sessions WHERE started_at > ? 2729 GROUP BY day ORDER BY day 2730 """, (cutoff,)) 2731 daily = [dict(r) for r in cur.fetchall()] 2732 2733 cur2 = db._conn.execute(""" 2734 SELECT model, 2735 SUM(input_tokens) as input_tokens, 2736 SUM(output_tokens) as output_tokens, 2737 COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, 2738 COUNT(*) as sessions, 2739 SUM(COALESCE(api_call_count, 0)) as api_calls 2740 FROM sessions WHERE started_at > ? AND model IS NOT NULL 2741 GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC 2742 """, (cutoff,)) 2743 by_model = [dict(r) for r in cur2.fetchall()] 2744 2745 cur3 = db._conn.execute(""" 2746 SELECT SUM(input_tokens) as total_input, 2747 SUM(output_tokens) as total_output, 2748 SUM(cache_read_tokens) as total_cache_read, 2749 SUM(reasoning_tokens) as total_reasoning, 2750 COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, 2751 COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, 2752 COUNT(*) as total_sessions, 2753 SUM(COALESCE(api_call_count, 0)) as total_api_calls 2754 FROM sessions WHERE started_at > ? 2755 """, (cutoff,)) 2756 totals = dict(cur3.fetchone()) 2757 insights_report = InsightsEngine(db).generate(days=days) 2758 skills = insights_report.get("skills", { 2759 "summary": { 2760 "total_skill_loads": 0, 2761 "total_skill_edits": 0, 2762 "total_skill_actions": 0, 2763 "distinct_skills_used": 0, 2764 }, 2765 "top_skills": [], 2766 }) 2767 2768 return { 2769 "daily": daily, 2770 "by_model": by_model, 2771 "totals": totals, 2772 "period_days": days, 2773 "skills": skills, 2774 } 2775 finally: 2776 db.close() 2777 2778 2779 @app.get("/api/analytics/models") 2780 async def get_models_analytics(days: int = 30): 2781 """Rich per-model analytics for the Models dashboard page. 2782 2783 Returns token/cost/session breakdown per model plus capability metadata 2784 from models.dev (context window, vision, tools, reasoning, etc.). 2785 """ 2786 from hermes_state import SessionDB 2787 2788 db = SessionDB() 2789 try: 2790 cutoff = time.time() - (days * 86400) 2791 2792 cur = db._conn.execute(""" 2793 SELECT model, 2794 billing_provider, 2795 SUM(input_tokens) as input_tokens, 2796 SUM(output_tokens) as output_tokens, 2797 SUM(cache_read_tokens) as cache_read_tokens, 2798 SUM(reasoning_tokens) as reasoning_tokens, 2799 COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, 2800 COALESCE(SUM(actual_cost_usd), 0) as actual_cost, 2801 COUNT(*) as sessions, 2802 SUM(COALESCE(api_call_count, 0)) as api_calls, 2803 SUM(tool_call_count) as tool_calls, 2804 MAX(started_at) as last_used_at, 2805 AVG(input_tokens + output_tokens) as avg_tokens_per_session 2806 FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' 2807 GROUP BY model, billing_provider 2808 ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC 2809 """, (cutoff,)) 2810 rows = [dict(r) for r in cur.fetchall()] 2811 2812 models = [] 2813 for row in rows: 2814 provider = row.get("billing_provider") or "" 2815 model_name = row["model"] 2816 caps = {} 2817 try: 2818 from agent.models_dev import get_model_capabilities 2819 mc = get_model_capabilities(provider=provider, model=model_name) 2820 if mc is not None: 2821 caps = { 2822 "supports_tools": mc.supports_tools, 2823 "supports_vision": mc.supports_vision, 2824 "supports_reasoning": mc.supports_reasoning, 2825 "context_window": mc.context_window, 2826 "max_output_tokens": mc.max_output_tokens, 2827 "model_family": mc.model_family, 2828 } 2829 except Exception: 2830 pass 2831 2832 models.append({ 2833 "model": model_name, 2834 "provider": provider, 2835 "input_tokens": row["input_tokens"], 2836 "output_tokens": row["output_tokens"], 2837 "cache_read_tokens": row["cache_read_tokens"], 2838 "reasoning_tokens": row["reasoning_tokens"], 2839 "estimated_cost": row["estimated_cost"], 2840 "actual_cost": row["actual_cost"], 2841 "sessions": row["sessions"], 2842 "api_calls": row["api_calls"], 2843 "tool_calls": row["tool_calls"], 2844 "last_used_at": row["last_used_at"], 2845 "avg_tokens_per_session": row["avg_tokens_per_session"], 2846 "capabilities": caps, 2847 }) 2848 2849 totals_cur = db._conn.execute(""" 2850 SELECT COUNT(DISTINCT model) as distinct_models, 2851 SUM(input_tokens) as total_input, 2852 SUM(output_tokens) as total_output, 2853 SUM(cache_read_tokens) as total_cache_read, 2854 SUM(reasoning_tokens) as total_reasoning, 2855 COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, 2856 COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, 2857 COUNT(*) as total_sessions, 2858 SUM(COALESCE(api_call_count, 0)) as total_api_calls 2859 FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' 2860 """, (cutoff,)) 2861 totals = dict(totals_cur.fetchone()) 2862 2863 return { 2864 "models": models, 2865 "totals": totals, 2866 "period_days": days, 2867 } 2868 finally: 2869 db.close() 2870 2871 2872 # --------------------------------------------------------------------------- 2873 # /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab. 2874 # 2875 # The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind 2876 # a POSIX pseudo-terminal, and forwards bytes + resize escapes across a 2877 # WebSocket. The browser renders the ANSI through xterm.js (see 2878 # web/src/pages/ChatPage.tsx). 2879 # 2880 # Auth: ``?token=<session_token>`` query param (browsers can't set 2881 # Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as 2882 # REST. Localhost-only — we defensively reject non-loopback clients even 2883 # though uvicorn binds to 127.0.0.1. 2884 # --------------------------------------------------------------------------- 2885 2886 import re 2887 import asyncio 2888 2889 from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError 2890 2891 _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") 2892 _PTY_READ_CHUNK_TIMEOUT = 0.2 2893 _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") 2894 # Starlette's TestClient reports the peer as "testclient"; treat it as 2895 # loopback so tests don't need to rewrite request scope. 2896 _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) 2897 2898 2899 def _is_public_bind() -> bool: 2900 """True when bound to all-interfaces (operator used --insecure).""" 2901 return getattr(app.state, "bound_host", "") in ("0.0.0.0", "::") 2902 2903 2904 def _ws_client_is_allowed(ws: "WebSocket") -> bool: 2905 """Check if the WebSocket client IP is acceptable. 2906 2907 Allows loopback always; allows any IP when bound to all-interfaces 2908 (--insecure mode, guarded by session token auth). 2909 """ 2910 if _is_public_bind(): 2911 return True 2912 client_host = ws.client.host if ws.client else "" 2913 if not client_host: 2914 return True 2915 return client_host in _LOOPBACK_HOSTS 2916 2917 # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) 2918 # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id 2919 # the chat tab generates on mount; entries auto-evict when the last subscriber 2920 # drops AND the publisher has disconnected. 2921 _event_channels: dict[str, set] = {} 2922 _event_lock = asyncio.Lock() 2923 2924 2925 def _resolve_chat_argv( 2926 resume: Optional[str] = None, 2927 sidecar_url: Optional[str] = None, 2928 ) -> tuple[list[str], Optional[str], Optional[dict]]: 2929 """Resolve the argv + cwd + env for the chat PTY. 2930 2931 Default: whatever ``hermes --tui`` would run. Tests monkeypatch this 2932 function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``) 2933 so nothing has to build Node or the TUI bundle. 2934 2935 Session resume is propagated via the ``HERMES_TUI_RESUME`` env var — 2936 matching what ``hermes_cli.main._launch_tui`` does for the CLI path. 2937 Appending ``--resume <id>`` to argv doesn't work because ``ui-tui`` does 2938 not parse its argv. 2939 2940 `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so 2941 the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the 2942 dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). 2943 """ 2944 from hermes_cli.main import PROJECT_ROOT, _make_tui_argv 2945 2946 argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) 2947 env = os.environ.copy() 2948 env.setdefault("NODE_ENV", "production") 2949 2950 if resume: 2951 env["HERMES_TUI_RESUME"] = resume 2952 2953 if sidecar_url: 2954 env["HERMES_TUI_SIDECAR_URL"] = sidecar_url 2955 2956 return list(argv), str(cwd) if cwd else None, env 2957 2958 2959 def _build_sidecar_url(channel: str) -> Optional[str]: 2960 """ws:// URL the PTY child should publish events to, or None when unbound.""" 2961 host = getattr(app.state, "bound_host", None) 2962 port = getattr(app.state, "bound_port", None) 2963 2964 if not host or not port: 2965 return None 2966 2967 netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" 2968 qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) 2969 2970 return f"ws://{netloc}/api/pub?{qs}" 2971 2972 2973 async def _broadcast_event(channel: str, payload: str) -> None: 2974 """Fan out one publisher frame to every subscriber on `channel`.""" 2975 async with _event_lock: 2976 subs = list(_event_channels.get(channel, ())) 2977 2978 for sub in subs: 2979 try: 2980 await sub.send_text(payload) 2981 except Exception: 2982 # Subscriber went away mid-send; the /api/events finally clause 2983 # will remove it from the registry on its next iteration. 2984 pass 2985 2986 2987 def _channel_or_close_code(ws: WebSocket) -> Optional[str]: 2988 """Return the channel id from the query string or None if invalid.""" 2989 channel = ws.query_params.get("channel", "") 2990 2991 return channel if _VALID_CHANNEL_RE.match(channel) else None 2992 2993 2994 @app.websocket("/api/pty") 2995 async def pty_ws(ws: WebSocket) -> None: 2996 if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: 2997 await ws.close(code=4403) 2998 return 2999 3000 # --- auth + loopback check (before accept so we can close cleanly) --- 3001 token = ws.query_params.get("token", "") 3002 expected = _SESSION_TOKEN 3003 if not hmac.compare_digest(token.encode(), expected.encode()): 3004 await ws.close(code=4401) 3005 return 3006 3007 if not _ws_client_is_allowed(ws): 3008 await ws.close(code=4403) 3009 return 3010 3011 await ws.accept() 3012 3013 # --- spawn PTY ------------------------------------------------------ 3014 resume = ws.query_params.get("resume") or None 3015 channel = _channel_or_close_code(ws) 3016 sidecar_url = _build_sidecar_url(channel) if channel else None 3017 3018 try: 3019 argv, cwd, env = _resolve_chat_argv(resume=resume, sidecar_url=sidecar_url) 3020 except SystemExit as exc: 3021 # _make_tui_argv calls sys.exit(1) when node/npm is missing. 3022 await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") 3023 await ws.close(code=1011) 3024 return 3025 3026 3027 try: 3028 bridge = PtyBridge.spawn(argv, cwd=cwd, env=env) 3029 except PtyUnavailableError as exc: 3030 await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") 3031 await ws.close(code=1011) 3032 return 3033 except (FileNotFoundError, OSError) as exc: 3034 await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") 3035 await ws.close(code=1011) 3036 return 3037 3038 loop = asyncio.get_running_loop() 3039 3040 # --- reader task: PTY master → WebSocket ---------------------------- 3041 async def pump_pty_to_ws() -> None: 3042 while True: 3043 chunk = await loop.run_in_executor( 3044 None, bridge.read, _PTY_READ_CHUNK_TIMEOUT 3045 ) 3046 if chunk is None: # EOF 3047 return 3048 if not chunk: # no data this tick; yield control and retry 3049 await asyncio.sleep(0) 3050 continue 3051 try: 3052 await ws.send_bytes(chunk) 3053 except Exception: 3054 return 3055 3056 reader_task = asyncio.create_task(pump_pty_to_ws()) 3057 3058 # --- writer loop: WebSocket → PTY master ---------------------------- 3059 try: 3060 while True: 3061 msg = await ws.receive() 3062 msg_type = msg.get("type") 3063 if msg_type == "websocket.disconnect": 3064 break 3065 raw = msg.get("bytes") 3066 if raw is None: 3067 text = msg.get("text") 3068 raw = text.encode("utf-8") if isinstance(text, str) else b"" 3069 if not raw: 3070 continue 3071 3072 # Resize escape is consumed locally, never written to the PTY. 3073 match = _RESIZE_RE.match(raw) 3074 if match and match.end() == len(raw): 3075 cols = int(match.group(1)) 3076 rows = int(match.group(2)) 3077 bridge.resize(cols=cols, rows=rows) 3078 continue 3079 3080 bridge.write(raw) 3081 except WebSocketDisconnect: 3082 pass 3083 finally: 3084 reader_task.cancel() 3085 try: 3086 await reader_task 3087 except (asyncio.CancelledError, Exception): 3088 pass 3089 bridge.close() 3090 3091 3092 # --------------------------------------------------------------------------- 3093 # /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab. 3094 # 3095 # Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the 3096 # dashboard can render structured metadata (model badge, tool-call sidebar, 3097 # slash launcher, session info) alongside the xterm.js terminal that PTY 3098 # already paints. Both transports bind to the same session id when one is 3099 # active, so a tool.start emitted by the agent fans out to both sinks. 3100 # --------------------------------------------------------------------------- 3101 3102 3103 @app.websocket("/api/ws") 3104 async def gateway_ws(ws: WebSocket) -> None: 3105 if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: 3106 await ws.close(code=4403) 3107 return 3108 3109 token = ws.query_params.get("token", "") 3110 if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): 3111 await ws.close(code=4401) 3112 return 3113 3114 if not _ws_client_is_allowed(ws): 3115 await ws.close(code=4403) 3116 return 3117 3118 from tui_gateway.ws import handle_ws 3119 3120 await handle_ws(ws) 3121 3122 3123 # --------------------------------------------------------------------------- 3124 # /api/pub + /api/events — chat-tab event broadcast. 3125 # 3126 # The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by 3127 # HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every 3128 # dispatcher emit through it. The dashboard fans those frames out to any 3129 # subscriber that opened /api/events on the same channel id. This is what 3130 # gives the React sidebar its tool-call feed without breaking the PTY 3131 # child's stdio handshake with Ink. 3132 # --------------------------------------------------------------------------- 3133 3134 3135 @app.websocket("/api/pub") 3136 async def pub_ws(ws: WebSocket) -> None: 3137 if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: 3138 await ws.close(code=4403) 3139 return 3140 3141 token = ws.query_params.get("token", "") 3142 if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): 3143 await ws.close(code=4401) 3144 return 3145 3146 if not _ws_client_is_allowed(ws): 3147 await ws.close(code=4403) 3148 return 3149 3150 channel = _channel_or_close_code(ws) 3151 if not channel: 3152 await ws.close(code=4400) 3153 return 3154 3155 await ws.accept() 3156 3157 try: 3158 while True: 3159 await _broadcast_event(channel, await ws.receive_text()) 3160 except WebSocketDisconnect: 3161 pass 3162 3163 3164 @app.websocket("/api/events") 3165 async def events_ws(ws: WebSocket) -> None: 3166 if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: 3167 await ws.close(code=4403) 3168 return 3169 3170 token = ws.query_params.get("token", "") 3171 if not hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): 3172 await ws.close(code=4401) 3173 return 3174 3175 if not _ws_client_is_allowed(ws): 3176 await ws.close(code=4403) 3177 return 3178 3179 channel = _channel_or_close_code(ws) 3180 if not channel: 3181 await ws.close(code=4400) 3182 return 3183 3184 await ws.accept() 3185 3186 async with _event_lock: 3187 _event_channels.setdefault(channel, set()).add(ws) 3188 3189 try: 3190 while True: 3191 # Subscribers don't speak — the receive() just blocks until 3192 # disconnect so the connection stays open as long as the 3193 # browser holds it. 3194 await ws.receive_text() 3195 except WebSocketDisconnect: 3196 pass 3197 finally: 3198 async with _event_lock: 3199 subs = _event_channels.get(channel) 3200 3201 if subs is not None: 3202 subs.discard(ws) 3203 3204 if not subs: 3205 _event_channels.pop(channel, None) 3206 3207 3208 def mount_spa(application: FastAPI): 3209 """Mount the built SPA. Falls back to index.html for client-side routing. 3210 3211 The session token is injected into index.html via a ``<script>`` tag so 3212 the SPA can authenticate against protected API endpoints without a 3213 separate (unauthenticated) token-dispensing endpoint. 3214 """ 3215 if not WEB_DIST.exists(): 3216 @application.get("/{full_path:path}") 3217 async def no_frontend(full_path: str): 3218 return JSONResponse( 3219 {"error": "Frontend not built. Run: cd web && npm run build"}, 3220 status_code=404, 3221 ) 3222 return 3223 3224 _index_path = WEB_DIST / "index.html" 3225 3226 def _serve_index(): 3227 """Return index.html with the session token injected.""" 3228 html = _index_path.read_text() 3229 chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" 3230 token_script = ( 3231 f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";' 3232 f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};</script>" 3233 ) 3234 html = html.replace("</head>", f"{token_script}</head>", 1) 3235 return HTMLResponse( 3236 html, 3237 headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, 3238 ) 3239 3240 application.mount("/assets", StaticFiles(directory=WEB_DIST / "assets"), name="assets") 3241 3242 @application.get("/{full_path:path}") 3243 async def serve_spa(full_path: str): 3244 file_path = WEB_DIST / full_path 3245 # Prevent path traversal via url-encoded sequences (%2e%2e/) 3246 if ( 3247 full_path 3248 and file_path.resolve().is_relative_to(WEB_DIST.resolve()) 3249 and file_path.exists() 3250 and file_path.is_file() 3251 ): 3252 return FileResponse(file_path) 3253 return _serve_index() 3254 3255 3256 # --------------------------------------------------------------------------- 3257 # Dashboard theme endpoints 3258 # --------------------------------------------------------------------------- 3259 3260 # Built-in dashboard themes — label + description only. The actual color 3261 # definitions live in the frontend (web/src/themes/presets.ts). 3262 _BUILTIN_DASHBOARD_THEMES = [ 3263 {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, 3264 {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, 3265 {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, 3266 {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, 3267 {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, 3268 {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, 3269 ] 3270 3271 3272 def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: 3273 """Normalise a theme layer spec from YAML into `{hex, alpha}` form. 3274 3275 Accepts shorthand (a bare hex string) or full dict form. Returns 3276 ``None`` on garbage input so the caller can fall back to a built-in 3277 default rather than blowing up. 3278 """ 3279 if value is None: 3280 return {"hex": default_hex, "alpha": default_alpha} 3281 if isinstance(value, str): 3282 return {"hex": value, "alpha": default_alpha} 3283 if isinstance(value, dict): 3284 hex_val = value.get("hex", default_hex) 3285 alpha_val = value.get("alpha", default_alpha) 3286 if not isinstance(hex_val, str): 3287 return None 3288 try: 3289 alpha_f = float(alpha_val) 3290 except (TypeError, ValueError): 3291 alpha_f = default_alpha 3292 return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} 3293 return None 3294 3295 3296 _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { 3297 "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', 3298 "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', 3299 "baseSize": "15px", 3300 "lineHeight": "1.55", 3301 "letterSpacing": "0", 3302 } 3303 3304 _THEME_DEFAULT_LAYOUT: Dict[str, str] = { 3305 "radius": "0.5rem", 3306 "density": "comfortable", 3307 } 3308 3309 _THEME_OVERRIDE_KEYS = { 3310 "card", "cardForeground", "popover", "popoverForeground", 3311 "primary", "primaryForeground", "secondary", "secondaryForeground", 3312 "muted", "mutedForeground", "accent", "accentForeground", 3313 "destructive", "destructiveForeground", "success", "warning", 3314 "border", "input", "ring", 3315 } 3316 3317 # Well-known named asset slots themes can populate. Any other keys under 3318 # ``assets.custom`` are exposed as ``--theme-asset-custom-<key>`` CSS vars 3319 # for plugin/shell use. 3320 _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} 3321 3322 # Component-style buckets themes can override. The value under each bucket 3323 # is a mapping from camelCase property name to CSS string; each pair emits 3324 # ``--component-<bucket>-<kebab-property>`` on :root. The frontend's shell 3325 # components (Card, App header, Backdrop, etc.) consume these vars so themes 3326 # can restyle chrome (clip-path, border-image, segmented progress, etc.) 3327 # without shipping their own CSS. 3328 _THEME_COMPONENT_BUCKETS = { 3329 "card", "header", "footer", "sidebar", "tab", 3330 "progress", "badge", "backdrop", "page", 3331 } 3332 3333 _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} 3334 3335 # Cap on customCSS length so a malformed/oversized theme YAML can't blow up 3336 # the response payload or the <style> tag. 32 KiB is plenty for every 3337 # practical reskin (the Strike Freedom demo is ~2 KiB). 3338 _THEME_CUSTOM_CSS_MAX = 32 * 1024 3339 3340 3341 def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]: 3342 """Normalise a user theme YAML into the wire format `ThemeProvider` 3343 expects. Returns ``None`` if the theme is unusable. 3344 3345 Accepts both the full schema (palette/typography/layout) and a loose 3346 form with bare hex strings, so hand-written YAMLs stay friendly. 3347 """ 3348 if not isinstance(data, dict): 3349 return None 3350 name = data.get("name") 3351 if not isinstance(name, str) or not name.strip(): 3352 return None 3353 3354 # Palette 3355 palette_src = data.get("palette", {}) if isinstance(data.get("palette"), dict) else {} 3356 # Allow top-level `colors.background` as a shorthand too. 3357 colors_src = data.get("colors", {}) if isinstance(data.get("colors"), dict) else {} 3358 3359 def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]: 3360 spec = palette_src.get(key, colors_src.get(key)) 3361 parsed = _parse_theme_layer(spec, default_hex, default_alpha) 3362 return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha} 3363 3364 palette = { 3365 "background": _layer("background", "#041c1c", 1.0), 3366 "midground": _layer("midground", "#ffe6cb", 1.0), 3367 "foreground": _layer("foreground", "#ffffff", 0.0), 3368 "warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)", 3369 "noiseOpacity": 1.0, 3370 } 3371 raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity")) 3372 try: 3373 palette["noiseOpacity"] = float(raw_noise) if raw_noise is not None else 1.0 3374 except (TypeError, ValueError): 3375 palette["noiseOpacity"] = 1.0 3376 3377 # Typography 3378 typo_src = data.get("typography", {}) if isinstance(data.get("typography"), dict) else {} 3379 typography = dict(_THEME_DEFAULT_TYPOGRAPHY) 3380 for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"): 3381 val = typo_src.get(key) 3382 if isinstance(val, str) and val.strip(): 3383 typography[key] = val 3384 3385 # Layout 3386 layout_src = data.get("layout", {}) if isinstance(data.get("layout"), dict) else {} 3387 layout = dict(_THEME_DEFAULT_LAYOUT) 3388 radius = layout_src.get("radius") 3389 if isinstance(radius, str) and radius.strip(): 3390 layout["radius"] = radius 3391 density = layout_src.get("density") 3392 if isinstance(density, str) and density in ("compact", "comfortable", "spacious"): 3393 layout["density"] = density 3394 3395 # Color overrides — keep only valid keys with string values. 3396 overrides_src = data.get("colorOverrides", {}) 3397 color_overrides: Dict[str, str] = {} 3398 if isinstance(overrides_src, dict): 3399 for key, val in overrides_src.items(): 3400 if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip(): 3401 color_overrides[key] = val 3402 3403 # Assets — named slots + arbitrary user-defined keys. Values must be 3404 # strings (URLs or CSS ``url(...)``/``linear-gradient(...)`` expressions). 3405 # We don't fetch remote assets here; the frontend just injects them as 3406 # CSS vars. Empty values are dropped so a theme can explicitly clear a 3407 # slot by setting ``hero: ""``. 3408 assets_out: Dict[str, Any] = {} 3409 assets_src = data.get("assets", {}) if isinstance(data.get("assets"), dict) else {} 3410 for key in _THEME_NAMED_ASSET_KEYS: 3411 val = assets_src.get(key) 3412 if isinstance(val, str) and val.strip(): 3413 assets_out[key] = val 3414 custom_assets_src = assets_src.get("custom") 3415 if isinstance(custom_assets_src, dict): 3416 custom_assets: Dict[str, str] = {} 3417 for key, val in custom_assets_src.items(): 3418 if ( 3419 isinstance(key, str) 3420 and key.replace("-", "").replace("_", "").isalnum() 3421 and isinstance(val, str) 3422 and val.strip() 3423 ): 3424 custom_assets[key] = val 3425 if custom_assets: 3426 assets_out["custom"] = custom_assets 3427 3428 # Custom CSS — raw CSS text the frontend injects as a scoped <style> 3429 # tag on theme apply. Clipped to _THEME_CUSTOM_CSS_MAX to keep the 3430 # payload bounded. We intentionally do NOT parse/sanitise the CSS 3431 # here — the dashboard is localhost-only and themes are user-authored 3432 # YAML in ~/.hermes/, same trust level as the config file itself. 3433 custom_css_val = data.get("customCSS") 3434 custom_css: Optional[str] = None 3435 if isinstance(custom_css_val, str) and custom_css_val.strip(): 3436 custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX] 3437 3438 # Component style overrides — per-bucket dicts of camelCase CSS 3439 # property -> CSS string. The frontend converts these into CSS vars 3440 # that shell components (Card, App header, Backdrop) consume. 3441 component_styles_src = data.get("componentStyles", {}) 3442 component_styles: Dict[str, Dict[str, str]] = {} 3443 if isinstance(component_styles_src, dict): 3444 for bucket, props in component_styles_src.items(): 3445 if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict): 3446 continue 3447 clean: Dict[str, str] = {} 3448 for prop, value in props.items(): 3449 if ( 3450 isinstance(prop, str) 3451 and prop.replace("-", "").replace("_", "").isalnum() 3452 and isinstance(value, (str, int, float)) 3453 and str(value).strip() 3454 ): 3455 clean[prop] = str(value) 3456 if clean: 3457 component_styles[bucket] = clean 3458 3459 layout_variant_src = data.get("layoutVariant") 3460 layout_variant = ( 3461 layout_variant_src 3462 if isinstance(layout_variant_src, str) and layout_variant_src in _THEME_LAYOUT_VARIANTS 3463 else "standard" 3464 ) 3465 3466 result: Dict[str, Any] = { 3467 "name": name, 3468 "label": data.get("label") or name, 3469 "description": data.get("description", ""), 3470 "palette": palette, 3471 "typography": typography, 3472 "layout": layout, 3473 "layoutVariant": layout_variant, 3474 } 3475 if color_overrides: 3476 result["colorOverrides"] = color_overrides 3477 if assets_out: 3478 result["assets"] = assets_out 3479 if custom_css is not None: 3480 result["customCSS"] = custom_css 3481 if component_styles: 3482 result["componentStyles"] = component_styles 3483 return result 3484 3485 3486 def _discover_user_themes() -> list: 3487 """Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes. 3488 3489 Returns a list of fully-normalised theme definitions ready to ship 3490 to the frontend, so the client can apply them without a secondary 3491 round-trip or a built-in stub. 3492 """ 3493 themes_dir = get_hermes_home() / "dashboard-themes" 3494 if not themes_dir.is_dir(): 3495 return [] 3496 result = [] 3497 for f in sorted(themes_dir.glob("*.yaml")): 3498 try: 3499 data = yaml.safe_load(f.read_text(encoding="utf-8")) 3500 except Exception: 3501 continue 3502 normalised = _normalise_theme_definition(data) 3503 if normalised is not None: 3504 result.append(normalised) 3505 return result 3506 3507 3508 @app.get("/api/dashboard/themes") 3509 async def get_dashboard_themes(): 3510 """Return available themes and the currently active one. 3511 3512 Built-in entries ship name/label/description only (the frontend owns 3513 their full definitions in `web/src/themes/presets.ts`). User themes 3514 from `~/.hermes/dashboard-themes/*.yaml` ship with their full 3515 normalised definition under `definition`, so the client can apply 3516 them without a stub. 3517 """ 3518 config = load_config() 3519 active = cfg_get(config, "dashboard", "theme", default="default") 3520 user_themes = _discover_user_themes() 3521 seen = set() 3522 themes = [] 3523 for t in _BUILTIN_DASHBOARD_THEMES: 3524 seen.add(t["name"]) 3525 themes.append(t) 3526 for t in user_themes: 3527 if t["name"] in seen: 3528 continue 3529 themes.append({ 3530 "name": t["name"], 3531 "label": t["label"], 3532 "description": t["description"], 3533 "definition": t, 3534 }) 3535 seen.add(t["name"]) 3536 return {"themes": themes, "active": active} 3537 3538 3539 class ThemeSetBody(BaseModel): 3540 name: str 3541 3542 3543 @app.put("/api/dashboard/theme") 3544 async def set_dashboard_theme(body: ThemeSetBody): 3545 """Set the active dashboard theme (persists to config.yaml).""" 3546 config = load_config() 3547 if "dashboard" not in config: 3548 config["dashboard"] = {} 3549 config["dashboard"]["theme"] = body.name 3550 save_config(config) 3551 return {"ok": True, "theme": body.name} 3552 3553 3554 # --------------------------------------------------------------------------- 3555 # Dashboard plugin system 3556 # --------------------------------------------------------------------------- 3557 3558 def _discover_dashboard_plugins() -> list: 3559 """Scan plugins/*/dashboard/manifest.json for dashboard extensions. 3560 3561 Checks three plugin sources (same as hermes_cli.plugins): 3562 1. User plugins: ~/.hermes/plugins/<name>/dashboard/manifest.json 3563 2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json (memory/, etc.) 3564 3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS) 3565 """ 3566 plugins = [] 3567 seen_names: set = set() 3568 3569 from hermes_cli.plugins import get_bundled_plugins_dir 3570 bundled_root = get_bundled_plugins_dir() 3571 search_dirs = [ 3572 (get_hermes_home() / "plugins", "user"), 3573 (bundled_root / "memory", "bundled"), 3574 (bundled_root, "bundled"), 3575 ] 3576 if os.environ.get("HERMES_ENABLE_PROJECT_PLUGINS"): 3577 search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project")) 3578 3579 for plugins_root, source in search_dirs: 3580 if not plugins_root.is_dir(): 3581 continue 3582 for child in sorted(plugins_root.iterdir()): 3583 if not child.is_dir(): 3584 continue 3585 manifest_file = child / "dashboard" / "manifest.json" 3586 if not manifest_file.exists(): 3587 continue 3588 try: 3589 data = json.loads(manifest_file.read_text(encoding="utf-8")) 3590 name = data.get("name", child.name) 3591 if name in seen_names: 3592 continue 3593 seen_names.add(name) 3594 # Tab options: ``path`` + ``position`` for a new tab, optional 3595 # ``override`` to replace a built-in route, and ``hidden`` to 3596 # register the plugin component/slots without adding a tab 3597 # (useful for slot-only plugins like a header-crest injector). 3598 raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {} 3599 tab_info = { 3600 "path": raw_tab.get("path", f"/{name}"), 3601 "position": raw_tab.get("position", "end"), 3602 } 3603 override_path = raw_tab.get("override") 3604 if isinstance(override_path, str) and override_path.startswith("/"): 3605 tab_info["override"] = override_path 3606 if bool(raw_tab.get("hidden")): 3607 tab_info["hidden"] = True 3608 # Slots: list of named slot locations this plugin populates. 3609 # The frontend exposes ``registerSlot(pluginName, slotName, Component)`` 3610 # on window; plugins with non-empty slots call it from their JS bundle. 3611 slots_src = data.get("slots") 3612 slots: List[str] = [] 3613 if isinstance(slots_src, list): 3614 slots = [s for s in slots_src if isinstance(s, str) and s] 3615 plugins.append({ 3616 "name": name, 3617 "label": data.get("label", name), 3618 "description": data.get("description", ""), 3619 "icon": data.get("icon", "Puzzle"), 3620 "version": data.get("version", "0.0.0"), 3621 "tab": tab_info, 3622 "slots": slots, 3623 "entry": data.get("entry", "dist/index.js"), 3624 "css": data.get("css"), 3625 "has_api": bool(data.get("api")), 3626 "source": source, 3627 "_dir": str(child / "dashboard"), 3628 "_api_file": data.get("api"), 3629 }) 3630 except Exception as exc: 3631 _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) 3632 continue 3633 return plugins 3634 3635 3636 # Cache discovered plugins per-process (refresh on explicit re-scan). 3637 _dashboard_plugins_cache: Optional[list] = None 3638 3639 3640 def _get_dashboard_plugins(force_rescan: bool = False) -> list: 3641 global _dashboard_plugins_cache 3642 if _dashboard_plugins_cache is None or force_rescan: 3643 _dashboard_plugins_cache = _discover_dashboard_plugins() 3644 return _dashboard_plugins_cache 3645 3646 3647 @app.get("/api/dashboard/plugins") 3648 async def get_dashboard_plugins(): 3649 """Return discovered dashboard plugins (excludes user-hidden ones).""" 3650 plugins = _get_dashboard_plugins() 3651 # Read user's hidden plugins list from config. 3652 config = load_config() 3653 hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] 3654 # Strip internal fields before sending to frontend and filter out hidden. 3655 return [ 3656 {k: v for k, v in p.items() if not k.startswith("_")} 3657 for p in plugins 3658 if p["name"] not in hidden 3659 ] 3660 3661 3662 @app.get("/api/dashboard/plugins/rescan") 3663 async def rescan_dashboard_plugins(): 3664 """Force re-scan of dashboard plugins.""" 3665 plugins = _get_dashboard_plugins(force_rescan=True) 3666 return {"ok": True, "count": len(plugins)} 3667 3668 3669 class _AgentPluginInstallBody(BaseModel): 3670 identifier: str 3671 force: bool = False 3672 enable: bool = True 3673 3674 3675 def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]: 3676 return {k: v for k, v in p.items() if not k.startswith("_")} 3677 3678 3679 def _merged_plugins_hub() -> Dict[str, Any]: 3680 """Agent discovery + dashboard manifests + optional provider picker metadata.""" 3681 from hermes_cli.plugins_cmd import ( 3682 _discover_all_plugins, 3683 _get_current_context_engine, 3684 _get_current_memory_provider, 3685 _discover_context_engines, 3686 _discover_memory_providers, 3687 _get_disabled_set, 3688 _get_enabled_set, 3689 _read_manifest as _read_plugin_manifest_at, 3690 ) 3691 3692 dashboard_list = _get_dashboard_plugins() 3693 dash_by_name = {str(p["name"]): p for p in dashboard_list} 3694 3695 disabled_set = _get_disabled_set() 3696 enabled_set = _get_enabled_set() 3697 3698 # Read user-hidden plugins from config for the user_hidden field. 3699 config = load_config() 3700 hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] 3701 3702 plugins_root_resolved = (get_hermes_home() / "plugins").resolve() 3703 rows: List[Dict[str, Any]] = [] 3704 3705 for name, version, description, source, dir_str in _discover_all_plugins(): 3706 if name in disabled_set: 3707 runtime_status = "disabled" 3708 elif name in enabled_set: 3709 runtime_status = "enabled" 3710 else: 3711 runtime_status = "inactive" 3712 3713 dir_path = Path(dir_str) 3714 dm = dash_by_name.get(name) 3715 has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists() 3716 3717 under_user_tree = False 3718 try: 3719 dir_path.resolve().relative_to(plugins_root_resolved) 3720 under_user_tree = True 3721 except ValueError: 3722 pass 3723 3724 can_remove_update = ( 3725 source in ("user", "git") and under_user_tree and Path(dir_str).is_dir() 3726 ) 3727 3728 # Check if this plugin provides tools that require auth 3729 auth_required = False 3730 auth_command = "" 3731 manifest_data = _read_plugin_manifest_at(dir_path) 3732 provides_tools = manifest_data.get("provides_tools") or [] 3733 if provides_tools: 3734 try: 3735 from tools.registry import registry 3736 for tname in provides_tools: 3737 entry = registry.get_entry(tname) 3738 if entry and entry.check_fn and not entry.check_fn(): 3739 auth_required = True 3740 auth_command = f"hermes auth {name}" 3741 break 3742 except Exception: 3743 pass 3744 3745 rows.append({ 3746 "name": name, 3747 "version": version or "", 3748 "description": description or "", 3749 "source": source, 3750 "runtime_status": runtime_status, 3751 "has_dashboard_manifest": has_dash_manifest, 3752 "dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None, 3753 "path": dir_str, 3754 "can_remove": can_remove_update, 3755 "can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(), 3756 "auth_required": auth_required, 3757 "auth_command": auth_command, 3758 "user_hidden": name in hidden_plugins, 3759 }) 3760 3761 agent_names = {r["name"] for r in rows} 3762 orphan_dashboard = [ 3763 _strip_dashboard_manifest(p) 3764 for p in dashboard_list 3765 if str(p["name"]) not in agent_names 3766 ] 3767 3768 memory_providers: List[Dict[str, str]] = [] 3769 try: 3770 for n, desc in _discover_memory_providers(): 3771 memory_providers.append({"name": n, "description": desc}) 3772 except Exception: 3773 memory_providers = [] 3774 3775 context_engines: List[Dict[str, str]] = [] 3776 try: 3777 for n, desc in _discover_context_engines(): 3778 context_engines.append({"name": n, "description": desc}) 3779 except Exception: 3780 context_engines = [] 3781 3782 return { 3783 "plugins": rows, 3784 "orphan_dashboard_plugins": orphan_dashboard, 3785 "providers": { 3786 "memory_provider": _get_current_memory_provider() or "", 3787 "memory_options": memory_providers, 3788 "context_engine": _get_current_context_engine(), 3789 "context_options": context_engines, 3790 }, 3791 } 3792 3793 3794 @app.get("/api/dashboard/plugins/hub") 3795 async def get_plugins_hub(request: Request): 3796 """Unified agent plugins + dashboard extension metadata (session protected).""" 3797 _require_token(request) 3798 try: 3799 return _merged_plugins_hub() 3800 except Exception as exc: 3801 _log.warning("plugins/hub failed: %s", exc) 3802 raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc 3803 3804 3805 @app.post("/api/dashboard/agent-plugins/install") 3806 async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody): 3807 _require_token(request) 3808 from hermes_cli.plugins_cmd import dashboard_install_plugin 3809 3810 result = dashboard_install_plugin( 3811 body.identifier.strip(), 3812 force=body.force, 3813 enable=body.enable, 3814 ) 3815 if not result.get("ok"): 3816 raise HTTPException( 3817 status_code=400, 3818 detail=result.get("error") or "Install failed.", 3819 ) 3820 _get_dashboard_plugins(force_rescan=True) 3821 # Strip internal paths from the response 3822 result.pop("after_install_path", None) 3823 return result 3824 3825 3826 def _validate_plugin_name(name: str) -> str: 3827 """Reject path-traversal attempts in plugin name URL parameters.""" 3828 if not name or "/" in name or "\\" in name or ".." in name: 3829 raise HTTPException(status_code=400, detail="Invalid plugin name.") 3830 return name 3831 3832 3833 @app.post("/api/dashboard/agent-plugins/{name}/enable") 3834 async def post_agent_plugin_enable(request: Request, name: str): 3835 _require_token(request) 3836 name = _validate_plugin_name(name) 3837 from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled 3838 3839 result = dashboard_set_agent_plugin_enabled(name, enabled=True) 3840 if not result.get("ok"): 3841 raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.") 3842 return result 3843 3844 3845 @app.post("/api/dashboard/agent-plugins/{name}/disable") 3846 async def post_agent_plugin_disable(request: Request, name: str): 3847 _require_token(request) 3848 name = _validate_plugin_name(name) 3849 from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled 3850 3851 result = dashboard_set_agent_plugin_enabled(name, enabled=False) 3852 if not result.get("ok"): 3853 raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.") 3854 return result 3855 3856 3857 @app.post("/api/dashboard/agent-plugins/{name}/update") 3858 async def post_agent_plugin_update(request: Request, name: str): 3859 _require_token(request) 3860 name = _validate_plugin_name(name) 3861 from hermes_cli.plugins_cmd import dashboard_update_user_plugin 3862 3863 result = dashboard_update_user_plugin(name) 3864 if not result.get("ok"): 3865 raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.") 3866 _get_dashboard_plugins(force_rescan=True) 3867 return result 3868 3869 3870 @app.delete("/api/dashboard/agent-plugins/{name}") 3871 async def delete_agent_plugin(request: Request, name: str): 3872 _require_token(request) 3873 name = _validate_plugin_name(name) 3874 from hermes_cli.plugins_cmd import dashboard_remove_user_plugin 3875 3876 result = dashboard_remove_user_plugin(name) 3877 if not result.get("ok"): 3878 raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.") 3879 _get_dashboard_plugins(force_rescan=True) 3880 return result 3881 3882 3883 class _PluginProvidersPutBody(BaseModel): 3884 memory_provider: Optional[str] = None 3885 context_engine: Optional[str] = None 3886 3887 3888 @app.put("/api/dashboard/plugin-providers") 3889 async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): 3890 """Persist memory provider / context engine selection (writes config.yaml).""" 3891 _require_token(request) 3892 from hermes_cli.plugins_cmd import ( 3893 _save_context_engine, 3894 _save_memory_provider, 3895 ) 3896 3897 if body.memory_provider is not None: 3898 _save_memory_provider(body.memory_provider) 3899 if body.context_engine is not None: 3900 _save_context_engine(body.context_engine) 3901 return {"ok": True} 3902 3903 3904 class _PluginVisibilityBody(BaseModel): 3905 hidden: bool 3906 3907 3908 @app.post("/api/dashboard/plugins/{name}/visibility") 3909 async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): 3910 """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" 3911 _require_token(request) 3912 name = _validate_plugin_name(name) 3913 3914 config = load_config() 3915 if "dashboard" not in config or not isinstance(config.get("dashboard"), dict): 3916 config["dashboard"] = {} 3917 hidden_list: list = config["dashboard"].get("hidden_plugins") or [] 3918 if not isinstance(hidden_list, list): 3919 hidden_list = [] 3920 3921 if body.hidden and name not in hidden_list: 3922 hidden_list.append(name) 3923 elif not body.hidden and name in hidden_list: 3924 hidden_list.remove(name) 3925 3926 config["dashboard"]["hidden_plugins"] = hidden_list 3927 save_config(config) 3928 return {"ok": True, "name": name, "hidden": body.hidden} 3929 3930 3931 @app.get("/dashboard-plugins/{plugin_name}/{file_path:path}") 3932 async def serve_plugin_asset(plugin_name: str, file_path: str): 3933 """Serve static assets from a dashboard plugin directory. 3934 3935 Only serves files from the plugin's ``dashboard/`` subdirectory. 3936 Path traversal is blocked by checking ``resolve().is_relative_to()``. 3937 """ 3938 plugins = _get_dashboard_plugins() 3939 plugin = next((p for p in plugins if p["name"] == plugin_name), None) 3940 if not plugin: 3941 raise HTTPException(status_code=404, detail="Plugin not found") 3942 3943 base = Path(plugin["_dir"]) 3944 target = (base / file_path).resolve() 3945 3946 if not target.is_relative_to(base.resolve()): 3947 raise HTTPException(status_code=403, detail="Path traversal blocked") 3948 if not target.exists() or not target.is_file(): 3949 raise HTTPException(status_code=404, detail="File not found") 3950 3951 # Guess content type 3952 suffix = target.suffix.lower() 3953 content_types = { 3954 ".js": "application/javascript", 3955 ".mjs": "application/javascript", 3956 ".css": "text/css", 3957 ".json": "application/json", 3958 ".html": "text/html", 3959 ".svg": "image/svg+xml", 3960 ".png": "image/png", 3961 ".jpg": "image/jpeg", 3962 ".woff2": "font/woff2", 3963 ".woff": "font/woff", 3964 } 3965 media_type = content_types.get(suffix, "application/octet-stream") 3966 return FileResponse(target, media_type=media_type) 3967 3968 3969 def _mount_plugin_api_routes(): 3970 """Import and mount backend API routes from plugins that declare them. 3971 3972 Each plugin's ``api`` field points to a Python file that must expose 3973 a ``router`` (FastAPI APIRouter). Routes are mounted under 3974 ``/api/plugins/<name>/``. 3975 """ 3976 for plugin in _get_dashboard_plugins(): 3977 api_file_name = plugin.get("_api_file") 3978 if not api_file_name: 3979 continue 3980 api_path = Path(plugin["_dir"]) / api_file_name 3981 if not api_path.exists(): 3982 _log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name) 3983 continue 3984 try: 3985 module_name = f"hermes_dashboard_plugin_{plugin['name']}" 3986 spec = importlib.util.spec_from_file_location(module_name, api_path) 3987 if spec is None or spec.loader is None: 3988 continue 3989 mod = importlib.util.module_from_spec(spec) 3990 # Register in sys.modules BEFORE exec_module so pydantic/FastAPI 3991 # can resolve forward references (e.g. models defined in a file 3992 # that uses `from __future__ import annotations`). Without this, 3993 # TypeAdapter lazy-build fails at first request with 3994 # "is not fully defined" because the module namespace isn't 3995 # reachable by name for string-annotation resolution. 3996 sys.modules[module_name] = mod 3997 try: 3998 spec.loader.exec_module(mod) 3999 except Exception: 4000 sys.modules.pop(module_name, None) 4001 raise 4002 router = getattr(mod, "router", None) 4003 if router is None: 4004 _log.warning("Plugin %s api file has no 'router' attribute", plugin["name"]) 4005 continue 4006 app.include_router(router, prefix=f"/api/plugins/{plugin['name']}") 4007 _log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"]) 4008 except Exception as exc: 4009 _log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc) 4010 4011 4012 # Mount plugin API routes before the SPA catch-all. 4013 _mount_plugin_api_routes() 4014 4015 mount_spa(app) 4016 4017 4018 def start_server( 4019 host: str = "127.0.0.1", 4020 port: int = 9119, 4021 open_browser: bool = True, 4022 allow_public: bool = False, 4023 *, 4024 embedded_chat: bool = False, 4025 ): 4026 """Start the web UI server.""" 4027 import uvicorn 4028 4029 global _DASHBOARD_EMBEDDED_CHAT_ENABLED 4030 _DASHBOARD_EMBEDDED_CHAT_ENABLED = embedded_chat 4031 4032 _LOCALHOST = ("127.0.0.1", "localhost", "::1") 4033 if host not in _LOCALHOST and not allow_public: 4034 raise SystemExit( 4035 f"Refusing to bind to {host} — the dashboard exposes API keys " 4036 f"and config without robust authentication.\n" 4037 f"Use --insecure to override (NOT recommended on untrusted networks)." 4038 ) 4039 if host not in _LOCALHOST: 4040 _log.warning( 4041 "Binding to %s with --insecure — the dashboard has no robust " 4042 "authentication. Only use on trusted networks.", host, 4043 ) 4044 4045 # Record the bound host so host_header_middleware can validate incoming 4046 # Host headers against it. Defends against DNS rebinding (GHSA-ppp5-vxwm-4cf7). 4047 # bound_port is also stashed so /api/pty can build the back-WS URL the 4048 # PTY child uses to publish events to the dashboard sidebar. 4049 app.state.bound_host = host 4050 app.state.bound_port = port 4051 4052 if open_browser: 4053 import webbrowser 4054 4055 def _open(): 4056 time.sleep(1.0) 4057 webbrowser.open(f"http://{host}:{port}") 4058 4059 threading.Thread(target=_open, daemon=True).start() 4060 4061 print(f" Hermes Web UI → http://{host}:{port}") 4062 uvicorn.run(app, host=host, port=port, log_level="warning")