/ 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")