""" Hermes Agent — Web UI server. Provides a FastAPI backend serving the Vite/React frontend and REST API endpoints for managing configuration, environment variables, and sessions. Usage: python -m hermes_cli.main web # Start on http://127.0.0.1:9119 python -m hermes_cli.main web --port 8080 """ import contextlib from contextlib import asynccontextmanager, contextmanager import asyncio import atexit import base64 import binascii import concurrent.futures import functools from collections import deque from dataclasses import dataclass from datetime import datetime, timezone import hashlib import hmac import inspect import importlib.util import ipaddress import json import logging import math import mimetypes import os import queue import re import secrets import shlex import shutil import stat import subprocess import sys import sysconfig import tempfile import threading import time import urllib.error import urllib.parse import zipfile from hermes_cli._subprocess_compat import windows_detach_flags, windows_hide_flags from hermes_cli.install_identity import get_install_id as _shared_get_install_id import urllib.request from pathlib import Path from typing import Any, Dict, List, Literal, Optional, Tuple import yaml PROJECT_ROOT = Path(__file__).parent.parent.resolve() if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from hermes_cli import __version__, __release_date__ from hermes_cli.config import ( build_cron_model_impact, cfg_get, DEFAULT_CONFIG, OPTIONAL_ENV_VARS, clear_model_endpoint_credentials, get_config_path, get_env_path, get_hermes_home, get_process_hermes_home, load_config, load_env, read_raw_config, resolve_cron_model_drift_defaults, save_config, save_env_value, remove_env_value, custom_endpoint_key_env, coerce_provider_id, find_provider_entry, check_config_version, detect_install_method, format_docker_update_message, is_nix_install_method, recommended_update_command_for_method, redact_key, write_platform_config_field, _deep_merge, ) from plugins.memory.config_schema import ( ProviderConfigSchema, ProviderField, STORAGE_HONCHO_HOST_BLOCK, get_provider_config_schema, ) from gateway.status import ( derive_gateway_busy, derive_gateway_drainable, get_running_pid_cached, get_running_pid, get_runtime_status_running_pid, normalize_updated_at, parse_active_agents, read_runtime_status, resolve_gateway_liveness, ) from utils import env_var_enabled try: from fastapi import ( FastAPI, File, Form, HTTPException, Query, Request, UploadFile, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, SecretStr, field_validator from starlette.concurrency import run_in_threadpool except ImportError: # First try lazy-installing the dashboard extras. Only the user actually # running `hermes dashboard` needs fastapi+uvicorn; lazy install keeps # them out of every other install path. After install, re-import. try: from tools.lazy_deps import ensure as _lazy_ensure _lazy_ensure("tool.dashboard", prompt=False) from fastapi import ( FastAPI, File, Form, HTTPException, Query, Request, UploadFile, WebSocket, WebSocketDisconnect, ) from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from pydantic import BaseModel, SecretStr, field_validator from starlette.concurrency import run_in_threadpool except Exception: raise SystemExit( "Web UI requires fastapi and uvicorn.\n" f"Install with: {sys.executable} -m pip install 'fastapi' 'uvicorn[standard]'" ) WEB_DIST = Path(os.environ["HERMES_WEB_DIST"]) if "HERMES_WEB_DIST" in os.environ else Path(__file__).parent / "web_dist" _log = logging.getLogger(__name__) def _process_start_marker(pid: int) -> str: """Return a cross-runtime marker for the current incarnation of ``pid``. ``ProcessLookupError`` means the process is absent. Other failures are left distinct so callers can fail safe rather than killing a healthy backend. """ if sys.platform == "linux": try: stat_line = Path(f"/proc/{pid}/stat").read_text(encoding="utf-8") except FileNotFoundError as exc: raise ProcessLookupError(pid) from exc # The command in field 2 may contain spaces or parentheses. Splitting # after its final ')' leaves field 3 at index zero and field 22 at 19. fields = stat_line.rsplit(")", 1)[1].strip().split() if len(fields) < 20 or not fields[19].isdigit(): raise OSError(f"invalid /proc stat data for PID {pid}") return f"linux:{fields[19]}" if os.name == "nt": import ctypes from ctypes import wintypes process_query_limited_information = 0x1000 kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) kernel32.OpenProcess.argtypes = [wintypes.DWORD, wintypes.BOOL, wintypes.DWORD] kernel32.OpenProcess.restype = wintypes.HANDLE kernel32.GetProcessTimes.argtypes = [ wintypes.HANDLE, ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ctypes.POINTER(wintypes.FILETIME), ] kernel32.GetProcessTimes.restype = wintypes.BOOL kernel32.CloseHandle.argtypes = [wintypes.HANDLE] kernel32.CloseHandle.restype = wintypes.BOOL handle = kernel32.OpenProcess(process_query_limited_information, False, pid) if not handle: error = ctypes.get_last_error() if error in (87, 1168): # invalid parameter / not found raise ProcessLookupError(pid) raise OSError(error, f"OpenProcess failed for PID {pid}") creation = wintypes.FILETIME() exit_time = wintypes.FILETIME() kernel = wintypes.FILETIME() user = wintypes.FILETIME() try: if not kernel32.GetProcessTimes( handle, ctypes.byref(creation), ctypes.byref(exit_time), ctypes.byref(kernel), ctypes.byref(user), ): error = ctypes.get_last_error() raise OSError(error, f"GetProcessTimes failed for PID {pid}") finally: kernel32.CloseHandle(handle) filetime = (creation.dwHighDateTime << 32) | creation.dwLowDateTime return f"win:{filetime + 504911232000000000}" result = subprocess.run( ["ps", "-p", str(pid), "-o", "lstart="], capture_output=True, text=True, check=False, ) marker = result.stdout.strip() if result.returncode == 0 and marker: return f"ps:{marker}" if result.returncode == 1 and not marker: raise ProcessLookupError(pid) raise OSError(f"ps could not inspect PID {pid}: {result.stderr.strip()}") def _valid_parent_start_marker(marker: str) -> bool: prefix, separator, value = marker.partition(":") if not separator or not value or value != value.strip(): return False if prefix in ("linux", "win", "winms"): return value.isdigit() return prefix == "ps" def _parent_start_markers_match(actual: str, expected: str) -> bool: """Compare parent markers across Desktop protocol generations. Older Windows Desktop builds send .NET ticks (``win:``). New builds use Electron's native process creation time in Unix milliseconds (``winms:``) so startup does not need to launch PowerShell. The backend still reads the exact FILETIME and normalizes it only when the expected marker is ``winms``. """ if actual == expected: return True if not actual.startswith("win:") or not expected.startswith("winms:"): return False try: dotnet_ticks = int(actual.removeprefix("win:")) expected_unix_ms = int(expected.removeprefix("winms:")) except ValueError: return False dotnet_ticks_at_unix_epoch = 621_355_968_000_000_000 actual_unix_ms = (dotnet_ticks - dotnet_ticks_at_unix_epoch) // 10_000 return actual_unix_ms == expected_unix_ms # --------------------------------------------------------------------------- # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. # # State lives on app.state (not module-level globals) so that asyncio.Lock is # created on the running event loop during lifespan startup. A module-level # asyncio.Lock() binds to whatever loop was active at import time, which breaks # when the same module is used across TestClient instances or uvicorn reloads. # --------------------------------------------------------------------------- def _start_desktop_cron_ticker(stop_event: "threading.Event", interval: int = 60) -> None: """Tick the cron scheduler from inside the desktop dashboard backend. The scheduler tick loop normally lives in ``hermes gateway run`` — but the desktop app spawns a ``hermes dashboard`` backend, not a gateway, so a cron a user creates in the app would never fire. We run the resolved cron scheduler provider here (no live adapters; delivery falls back to the per-platform send path). Every local profile's store is ticked, not just this backend's own (#69377's desktop sibling): the desktop pools per-profile backends and reaps them after ~10 idle minutes, so a secondary profile's ticker dies with its backend and that profile's jobs silently stop firing until the user next opens it ("tasks on the sleeping profile could be idle" — community report, Aug 2026). The primary backend outlives the pool, so it owns every profile's tick, exactly like a multiplex gateway. External providers keep the single-store behavior — their registries are not profile-scoped (see _notify_cron_provider_for_profile). Cross-process safe: the built-in provider's ``cron.scheduler.tick`` takes the per-store ``cron/.tick.lock`` file lock, so this never double-fires alongside a real gateway or a live pool backend on the same profile home — whichever process grabs the lock first wins the tick. """ from cron.scheduler_provider import InProcessCronScheduler, resolve_cron_scheduler provider = resolve_cron_scheduler() start_kwargs: dict = {"interval": interval} if isinstance(provider, InProcessCronScheduler): try: from hermes_cli.profiles import profiles_to_serve profile_homes = list(profiles_to_serve(multiplex=True)) if len(profile_homes) > 1: start_kwargs["profile_homes"] = profile_homes # Stand down, per tick, for any profile whose OWN gateway is # running: that gateway ticks it with live adapters, and the # tick-lock race otherwise lets this adapter-less ticker win # and deliver the job through the standalone path (#100489). # Evaluated every cycle so a gateway starting/stopping later # is picked up without a dashboard restart. from hermes_cli.profiles import _check_gateway_running start_kwargs["profile_gate"] = ( lambda _name, home: not _check_gateway_running(Path(home)) ) from hermes_logging import enable_profile_log_routing enable_profile_log_routing(profile_homes) _log.info( "Desktop cron scheduler will tick %d profile(s): %s", len(profile_homes), [name for name, _home in profile_homes], ) except Exception: # Fail open to the single-store ticker — the active profile's # jobs must keep firing even if profile enumeration breaks. _log.exception("Desktop cron: profile enumeration failed; ticking active profile only") _log.info("Desktop cron scheduler started (provider=%s, interval=%ds)", provider.name, interval) provider.start(stop_event, **start_kwargs) # Desktop `serve` only (start_server(start_mcp_discovery_after_bind=True)): # seconds after the READY sentinel before the MCP discovery thread starts. _DESKTOP_MCP_DISCOVERY_DELAY_S = 1.0 def _warm_gateway_module() -> None: """Pre-import heavy modules so the event loop is not stalled on first use. On a cold Windows install, importing these module chains triggers .pyc compilation and Defender real-time scans that can stall the event loop for 15-30s. The original fix (pre-#60800) only warmed ``hermes_cli.gateway``. But the first WS connection and its initial RPC burst (``setup.status``, ``setup.runtime_check``, ``gateway.ready``→``resolve_skin``) pull in several *other* heavy chains that were still imported on the loop thread, contributing to the ~14s cold-start stall (#60800). Warm them all here so the cost is paid in a worker thread while the server socket is already open. """ for mod in ( "hermes_cli.gateway", # setup.status / setup.runtime_check resolve provider auth state, # which imports copilot_auth (→ subprocess module) and scans # credential files. First import is noticeably slow on Windows. "hermes_cli.auth", "hermes_cli.copilot_auth", "hermes_cli.runtime_provider", # resolve_skin() reads config + initialises the skin engine. # Even though handle_ws now calls it via asyncio.to_thread # (see tui_gateway/ws.py), warming it here avoids the first-call # import cost inside that thread. "hermes_cli.skin_engine", # model.options / picker context — parses provider catalogs and # the models.dev cache on first use. "hermes_cli.inventory", "hermes_cli.model_switch", ): try: __import__(mod) except Exception: pass def _resolve_restart_drain_timeout() -> float: try: from hermes_cli.gateway import _get_restart_drain_timeout return _get_restart_drain_timeout() except ImportError: from gateway.restart import DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT return DEFAULT_GATEWAY_RESTART_DRAIN_TIMEOUT def _eager_reconcile_own_session_db() -> None: """One writable open of this process's own state.db at startup. ``SessionDB.__init__`` runs ``_init_schema`` → ``_reconcile_columns``, bringing a store left behind by `hermes update` current before the dashboard's first session-list poll, with the open-time lock patience (jittered retries) absorbing transient contention. Never raises: a store this cannot fix is still served through the read-probe heal in :func:`_open_session_db_at_path`, which retries on every poll. """ try: from hermes_state import SessionDB, _default_db_path SessionDB(db_path=Path(_default_db_path()), read_only=False).close() except Exception as exc: _log.warning( "startup schema reconcile of state.db failed (%s); session " "reads will retry the heal per poll", exc, ) @asynccontextmanager async def _lifespan(app: "FastAPI"): app.state.event_channels = {} # dict[str, set] app.state.event_lock = asyncio.Lock() app.state.pty_active_session_files = {} # dict[str, Path] # Serializes chat-argv resolution so concurrent /api/pty connections # don't trigger overlapping ``npm install`` / ``npm run build`` work. # On app.state (not a module global) so the Lock binds to the running # event loop during lifespan startup — see _get_event_state's docstring. app.state.chat_argv_lock = asyncio.Lock() # Bring this profile's state.db schema current BEFORE the first # session-list poll (#79531/#80037). Migrations used to run lazily on # the first writable open — typically the user's first new session — # so a store left behind by `hermes update` kept 500ing every # /api/sessions poll (and the read-probe heal, while it retries per # poll, can lose repeatedly to lock contention from orphaned sibling # backends). One writable open here runs _init_schema → # _reconcile_columns with the full open-time lock patience. Runs in a # daemon thread so a locked store never delays the server socket (the # Desktop ready-probe times out at 10s, GH-73083); reads that land # before it finishes are still covered by the read-probe heal. threading.Thread( target=_eager_reconcile_own_session_db, daemon=True, name="statedb-eager-reconcile", ).start() # Import hermes_cli.gateway eagerly *before* the lifespan yield so the # GIL-heavy .pyc compilation and Defender scan cost is absorbed during # backend initialisation — before the server socket accepts probes. # On Windows + Python 3.11 the import does not release the GIL, so # run_in_executor still froze the event loop for 15-22 s, causing the # Desktop's 10-second WebSocket ready-probe to time out (GH-73083). _warm_gateway_module() # Snapshot the checkout revision at boot so risky lazy-import paths (the # model picker) can detect when `hermes update` replaced the code # underneath this long-lived process and refuse with a clear "restart # required" message instead of a stale-module ImportError (#86207). This # mirrors the gateway's record_boot_fingerprint in gateway/run.py; the # dashboard is a separate process/unit that the update flow does not # reliably restart, so it must detect the drift itself. from gateway.code_skew import record_boot_fingerprint record_boot_fingerprint() # Hosted Bot rooms belong to the backend process, not to any connected # Desktop socket. Recovery may need a contended state.db migration, so keep # it off the lifespan's pre-yield path: Group Chat startup must degrade on # its own instead of preventing every dashboard/Desktop feature from booting. from tui_gateway import methods_groups as _hosted_groups import tui_gateway.server # noqa: F401 hosted_room_start_cancel = threading.Event() def _start_hosted_rooms() -> None: try: _hosted_groups.start_hosted_room_service() except Exception: _log.exception("Hosted Group Chat recovery failed during backend startup") finally: if hosted_room_start_cancel.is_set(): _hosted_groups.stop_hosted_room_service(timeout=1.0) hosted_room_start_thread = threading.Thread( target=_start_hosted_rooms, daemon=True, name="hosted-room-startup", ) hosted_room_start_thread.start() # Desktop-spawned backends (HERMES_DESKTOP=1) fire cron jobs themselves, # since the app has no gateway running the scheduler. Server `hermes # dashboard` is unaffected — it relies on its own gateway. cron_stop: "threading.Event | None" = None cron_thread: "threading.Thread | None" = None if os.getenv("HERMES_DESKTOP") == "1": # Before forking a fresh gateway, reap any orphan left by a previous # serve session. Graceful shutdown reaps the managed child, but an # abnormal exit (crash, SIGKILL, power loss, forced update) reparents # the old gateway to launchd (PPID=1). It keeps holding the QQ # WebSocket, and a newly forked gateway then races the same credential, # splitting messages across parallel session trees (#77276). # # The sweep itself still runs unconditionally — a stale-but-present # registration must not veto the #77276 orphan reap. Protection for # a healthy standalone gateway (launched via `hermes gateway run`, # no service supervisor) lives INSIDE the reaper: it probes the # registration with cleanup_stale=False so the recorded PID always # joins the exclusion set, even when liveness validation would have # unlinked the record mid-sweep. That matters most on Windows, where # every layer of the launcher chain (stub -> venv python -> runtime # python) carries "gateway run" in its command line, so # find_gateway_pids() matches processes the pidfile exclusion cannot # see, and os.kill(SIGTERM) is a hard TerminateProcess — the # gateway's planned-stop watcher (0.5s poll) has no time to drain. try: from hermes_cli.gateway import _reap_unsupervised_gateway_orphans _reap_unsupervised_gateway_orphans() except Exception: _log.exception("Desktop startup: orphan gateway reap failed") cron_stop = threading.Event() cron_thread = threading.Thread( target=_start_desktop_cron_ticker, args=(cron_stop,), daemon=True, name="desktop-cron-ticker", ) cron_thread.start() # Reap idle/dead keep-alive PTY sessions in the background (30-min TTL). pty_reaper_task = asyncio.create_task(run_reaper(PTY_REGISTRY)) # Periodic authenticated self-test (feeds the ``dashboard`` component on # /api/status). The loop exits immediately when httpx is unavailable. selftest_task = asyncio.create_task(_dashboard_selftest_loop()) # Live auto-archive timer — keeps a backend that stays up for days # sweeping stale sessions on schedule, independent of list requests. auto_archive_task = asyncio.create_task(_auto_archive_ticker_loop()) # Managed local runtime: when the user opted in (local_runtime.enabled, # set by the Local Models 'Use' action), bring the llama-server back up # so a restart doesn't strand a llamacpp main model without a backend. # Off-thread and best-effort: binary check + spawn + health poll must # not delay the server socket, and failure falls back to configured # cloud providers exactly like a cold start. def _boot_local_runtime(): try: from hermes_cli.config import load_config from hermes_cli.local_runtime.bootstrap import ensure_local_runtime # Server only — models load on first inference, always (residency # design: downloaded = available; demand loads; idleness # evicts). An empty router holds no VRAM; warming a model at # boot would reload gigabytes nobody asked for yet. ensure_local_runtime(load_config()) except Exception as exc: # noqa: BLE001 logging.getLogger(__name__).warning("local runtime boot failed: %s", exc) threading.Thread(target=_boot_local_runtime, daemon=True, name="local-runtime-boot").start() try: yield finally: hosted_room_start_cancel.set() _hosted_groups.stop_hosted_room_service(timeout=5.0) hosted_room_start_thread.join(timeout=1.0) if cron_stop is not None: cron_stop.set() pty_reaper_task.cancel() selftest_task.cancel() auto_archive_task.cancel() await PTY_REGISTRY.close_all() # Stop the managed llama-server with its parent — a supervisor-less # orphan would keep VRAM pinned after the app closes. try: from hermes_cli.local_runtime.bootstrap import shutdown_local_runtime shutdown_local_runtime() except Exception: # noqa: BLE001 pass if os.getenv("HERMES_DESKTOP") == "1": _terminate_desktop_managed_gateway() def _get_event_state(app: "FastAPI"): """Return (event_channels, event_lock) from app.state. Lazily initialises the state if the lifespan hasn't run (e.g. when TestClient is constructed without a ``with`` block). The lifespan path is preferred because it guarantees the Lock is created on the correct event loop, but the lazy path lets existing non-``with`` TestClient usages keep working. """ try: return app.state.event_channels, app.state.event_lock except AttributeError: app.state.event_channels = {} app.state.event_lock = asyncio.Lock() return app.state.event_channels, app.state.event_lock def _get_chat_argv_lock(app: "FastAPI") -> asyncio.Lock: """Return the chat-argv resolution lock from app.state. Mirrors :func:`_get_event_state`: prefers the lifespan-initialised Lock (created on the correct event loop) but lazily initialises it for non-``with`` TestClient usages. """ try: return app.state.chat_argv_lock except AttributeError: app.state.chat_argv_lock = asyncio.Lock() return app.state.chat_argv_lock def _get_pty_active_session_files(app: "FastAPI") -> dict[str, Path]: """Return channel -> active-session-file state for dashboard PTYs.""" try: return app.state.pty_active_session_files except AttributeError: app.state.pty_active_session_files = {} return app.state.pty_active_session_files app = FastAPI(title="Hermes Agent", version=__version__, lifespan=_lifespan) # Memory-provider OAuth connect routes live in the memory layer, not here. from hermes_cli.memory_oauth import router as _memory_oauth_router # noqa: E402 app.include_router(_memory_oauth_router) # --------------------------------------------------------------------------- # Session token for protecting sensitive endpoints (reveal). # The desktop shell mints the token and injects it via # HERMES_DASHBOARD_SESSION_TOKEN so its main process can authenticate the # /api calls it makes on the user's behalf; otherwise we generate one fresh # on every server start. Either way it dies when the process exits and is # injected into the SPA HTML so only the legitimate web UI can use it. # --------------------------------------------------------------------------- def _resolve_session_token() -> str: return os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or secrets.token_urlsafe(32) _SESSION_TOKEN = _resolve_session_token() _SESSION_HEADER_NAME = "X-Hermes-Session-Token" _SSH_OWNER_NONCE: Optional[str] = None _SSH_RUNTIME_PURELIB: Optional[Tuple[str, int, int]] = None _SSH_RUNTIME_MARKER: Optional[str] = None def _apply_ssh_session_token(token: str) -> None: global _SESSION_TOKEN if token: _SESSION_TOKEN = token def _apply_ssh_owner_nonce(nonce: Optional[str]) -> None: global _SSH_OWNER_NONCE, _SSH_RUNTIME_PURELIB, _SSH_RUNTIME_MARKER _SSH_OWNER_NONCE = nonce _SSH_RUNTIME_PURELIB = None _SSH_RUNTIME_MARKER = None if nonce: try: purelib = sysconfig.get_paths()["purelib"] except (KeyError, OSError): return # Primary identity: a marker FILE written into site-packages now. # A replaced venv (rm -rf && recreate — same OR different Python # version) loses the marker deterministically, while pip installs # into the live venv leave it untouched (no false stales). A bare # (dev, ino) snapshot of the directory is NOT sufficient on its # own: ext4 reuses directory inodes immediately, so the exact # reported repro (`rm -rf venv && uv venv`) can land on the same # inode and pass undetected (proven live during salvage). try: marker = os.path.join(purelib, f".hermes-ssh-runtime-{nonce}") with open(marker, "w", encoding="utf-8") as fh: fh.write(f"pid={os.getpid()}\n") _SSH_RUNTIME_MARKER = marker except OSError: pass # read-only site-packages — fall back to the stat snapshot try: st = os.stat(purelib) _SSH_RUNTIME_PURELIB = (purelib, st.st_dev, st.st_ino) except OSError: pass def _ssh_runtime_intact() -> bool: # Marker file is the deterministic signal when we managed to write one. if _SSH_RUNTIME_MARKER is not None: return os.path.isfile(_SSH_RUNTIME_MARKER) # Fallback (read-only site-packages): directory identity snapshot. # Weaker — inode reuse can mask a same-filesystem recreate — but still # catches cross-device moves and version-bump path changes. if _SSH_RUNTIME_PURELIB is None: return True purelib, device, inode = _SSH_RUNTIME_PURELIB try: st = os.stat(purelib) except OSError: return False return (st.st_dev, st.st_ino) == (device, inode) # In-browser Chat tab (/chat, /api/pty, /api/ws, …). Always enabled: the # desktop app and the dashboard's own Chat tab both drive the agent over the # `/api/ws` + `/api/pty` WebSockets, so the embedded-chat surface is an # unconditional part of the dashboard. Kept as a module-level constant (rather # than inlining ``True`` at every gate) so the WS endpoints and the SPA token # injection share a single, testable seam. _DASHBOARD_EMBEDDED_CHAT_ENABLED = True # Desktop's file.attach compatibility transport sends a complete base64 data # URL in one JSON-RPC frame. Uvicorn defaults to 16 MiB, which rejects files at # the preview ceiling before the dispatcher sees them. Keep the gateway # finite while allowing the 256 MiB raw Desktop attach cap plus base64/JSON # overhead. _DESKTOP_ATTACHMENT_WS_MAX_BYTES = 384 * 1024 * 1024 # Simple rate limiter for the reveal endpoint _reveal_timestamps: List[float] = [] _REVEAL_MAX_PER_WINDOW = 5 _REVEAL_WINDOW_SECONDS = 30 # CORS: restrict to localhost origins only. The web UI is intended to run # locally; binding to 0.0.0.0 with allow_origins=["*"] would let any website # read/modify config and secrets. app.add_middleware( CORSMiddleware, allow_origin_regex=r"^https?://(localhost|127\.0\.0\.1)(:\d+)?$", allow_methods=["*"], allow_headers=["*"], ) # --------------------------------------------------------------------------- # Endpoints that do NOT require the session token. Everything else under # /api/ is gated by the auth middleware below. # # This list is defined in ``hermes_cli.dashboard_auth.public_paths`` so the # OAuth gate middleware can honour the same allowlist — keeping the two # gates in lockstep avoids drift like the wildcard-subdomain regression # where ``/api/status`` was public under the legacy gate but 401'd under # the OAuth gate (breaking the portal's liveness probe). # # Keep the upstream list minimal — only truly non-sensitive, read-only # endpoints belong there. # --------------------------------------------------------------------------- from hermes_cli.dashboard_auth.public_paths import ( PUBLIC_API_PATHS as _PUBLIC_API_PATHS, ) def _has_valid_session_token(request: Request) -> bool: """True if the request carries a valid dashboard session token. The dedicated session header avoids collisions with reverse proxies that already use ``Authorization`` (for example Caddy ``basic_auth``). We still accept the legacy Bearer path for backward compatibility with older dashboard bundles. """ session_header = request.headers.get(_SESSION_HEADER_NAME, "") if session_header and hmac.compare_digest( session_header.encode(), _SESSION_TOKEN.encode(), ): return True auth = request.headers.get("authorization", "") expected = f"Bearer {_SESSION_TOKEN}" return hmac.compare_digest(auth.encode(), expected.encode()) # Routes that may also authenticate via a ``?token=`` query param, for download # links opened by the OS shell or a new browser tab where the session header # can't be set. Kept narrow — same query-token tradeoff as the /api/pty WS. _QUERY_TOKEN_API_PATHS: frozenset[str] = frozenset({"/api/files/download"}) def _has_valid_query_token(request: Request, path: str) -> bool: if path not in _QUERY_TOKEN_API_PATHS: return False token = request.query_params.get("token", "") return bool(token) and hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()) def _require_token(request: Request) -> None: """Authorize a sensitive endpoint, raising 401 if the caller isn't allowed. Two auth schemes protect the dashboard, exactly one active per bind: * **Loopback / ``--insecure`` mode** (``auth_required`` False): the ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header). Validate it here. * **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is NOT injected (the SPA authenticates with a session cookie), so there is no token to check. The ``gated_auth_middleware`` has already verified the cookie before the request reached this handler — any non-public ``/api/`` route it lets through carries a verified ``request.state.session``. The legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring the (absent) token here would 401 every cookie-authenticated request, making plugin install/enable/disable and the other ``_require_token`` endpoints permanently unreachable behind the gate. Defer to the gate. """ if getattr(request.app.state, "auth_required", False): # Gate is authoritative. It attaches ``request.state.session`` on # success and 401s otherwise, so a request that reached us is already # authenticated. Belt-and-braces: confirm the session is present. if getattr(request.state, "session", None) is not None: return raise HTTPException(status_code=401, detail="Unauthorized") if not _has_valid_session_token(request): raise HTTPException(status_code=401, detail="Unauthorized") # Accepted Host header values for loopback binds. DNS rebinding attacks # point a victim browser at an attacker-controlled hostname (evil.test) # which resolves to 127.0.0.1 after a TTL flip — bypassing same-origin # checks because the browser now considers evil.test and our dashboard # "same origin". Validating the Host header at the app layer rejects any # request whose Host isn't one we bound for. See GHSA-ppp5-vxwm-4cf7. _LOOPBACK_HOST_VALUES: frozenset = frozenset({ "localhost", "127.0.0.1", "::1", }) def _dashboard_public_hosts() -> frozenset[str]: """Return the exact hostname declared by ``dashboard.public_url``. ``public_url`` is already Hermes' canonical browser-facing URL behind a reverse proxy. Reusing its validated hostname here keeps OAuth redirects, HTTP Host validation, and WebSocket Origin validation on one source of truth. Malformed or unset values fail closed as an empty set. """ from hermes_cli.dashboard_auth.prefix import resolve_public_url public_url = resolve_public_url() if not public_url: return frozenset() try: hostname = urllib.parse.urlparse(public_url).hostname except ValueError: return frozenset() if not hostname: return frozenset() return frozenset({hostname.lower()}) def should_require_auth(host: str, allow_public: bool = False) -> bool: """Return True iff the dashboard auth gate must be active. Truth table: host == loopback → False (no auth — local-only, trusted operator) host != loopback → True (gate engages — OAuth or password required) "Loopback" is 127.0.0.1, localhost, ::1. RFC1918 / CGNAT / link-local are deliberately treated as PUBLIC — a hostile device on the same LAN is exactly the threat model the gate is designed for. ``allow_public`` (the legacy ``--insecure`` escape hatch) NO LONGER disables the gate. It is accepted for backward-compat with old launch scripts and desktop shells but is ignored: a non-loopback bind ALWAYS requires an auth provider (OAuth or the bundled password provider). This closes the unauthenticated-public-dashboard hole behind the June 2026 ``hermes-0day`` MCP-persistence campaign, where ``--insecure --host 0.0.0.0`` left the config/MCP/agent surface open to internet scanners. """ return host not in _LOOPBACK_HOST_VALUES def should_require_dashboard_auth( host: str, trusted_public_hosts: Optional[frozenset[str]] = None, ) -> bool: """Return whether the dashboard auth gate must be active. The browser-facing URL is part of the exposure boundary: a non-loopback ``dashboard.public_url`` requires authentication even when a reverse proxy reaches a backend bound to loopback. Callers may pass the already-resolved host set so startup and request validation use the same snapshot. """ if trusted_public_hosts is None: trusted_public_hosts = _dashboard_public_hosts() return should_require_auth(host) or any( candidate not in _LOOPBACK_HOST_VALUES for candidate in trusted_public_hosts ) def _desktop_loopback_auth_exempt( host: str, ssh_session_token: Optional[str] = None, ssh_owner_nonce: Optional[str] = None, ) -> bool: """True for a Desktop-owned loopback backend (#96490). A non-loopback ``dashboard.public_url`` engages the ticket-only auth gate for EVERY ``hermes serve`` on the machine — including the private loopback backends the Desktop app spawns for itself. Those backends authenticate with the per-spawn session token (injected via ``HERMES_DASHBOARD_SESSION_TOKEN`` for local spawns, ``--ssh-session-token -file``/``--ssh-owner-nonce`` for Desktop SSH), which the gate's WS path refuses outright — Desktop could not boot with a ``public_url`` configured. The public_url describes a DIFFERENT deployment: the actual public dashboard is a separate process on a non-loopback bind, whose own startup computes ``should_require_dashboard_auth`` from its host and stays gated. Exempting this process therefore never opens the public surface. Exemption requires ALL of: loopback bind, ``HERMES_DESKTOP=1`` (set by every Desktop spawn path — local and SSH), and an operator-minted credential (env token, SSH session token, or owner nonce). A plain ``hermes serve`` with ``HERMES_DESKTOP=1`` exported but no credential is NOT exempt. """ if host not in _LOOPBACK_HOST_VALUES: return False if os.environ.get("HERMES_DESKTOP") != "1": return False return bool( os.environ.get("HERMES_DASHBOARD_SESSION_TOKEN") or ssh_session_token or ssh_owner_nonce ) def _host_header_hostname(host_header: str) -> str: """Return a normalized hostname from a valid HTTP Host authority. Host headers are authorities, not full URLs. Reject ambiguous ports, malformed IPv6 brackets, and URL syntax so validation always fails closed. """ value = (host_header or "").strip() if not value: return "" if any(char in value for char in ('"', "'", "<", ">", " ", "\n", "\r", "\t")): return "" if "://" in value or any(char in value for char in ("/", "?", "#", "@")): return "" if value.startswith("["): close = value.find("]") if close == -1: return "" hostname = value[1:close] # Bracket notation is reserved for IPv6 literals. if ":" not in hostname: return "" suffix = value[close + 1:] if suffix and not re.fullmatch(r":\d+", suffix): return "" return hostname.lower() # Unbracketed IPv6 authorities are ambiguous with a port separator. if value.count(":") > 1: return "" if ":" in value: hostname, port = value.rsplit(":", 1) if not hostname or not port.isdigit(): return "" return hostname.lower() return value.lower() def _is_accepted_host( host_header: str, bound_host: str, trusted_public_hosts: frozenset[str] = frozenset(), ) -> bool: """True if the Host header targets the interface we bound to. Accepts: - Exact bound host (with or without port suffix) - Loopback aliases when bound to loopback - Exact operator-declared public hosts (with or without port suffix) - Any host when bound to 0.0.0.0 (explicit opt-in to non-loopback, no protection possible at this layer) """ host_only = _host_header_hostname(host_header) if not host_only: return False if host_only in trusted_public_hosts: return True # 0.0.0.0 bind means operator explicitly opted into all-interfaces # (requires --insecure per web_server.start_server). No Host-layer # defence can protect that mode; rely on operator network controls. if bound_host in {"0.0.0.0", "::"}: return True # Loopback bind: accept the loopback names bound_lc = bound_host.lower() if bound_lc in _LOOPBACK_HOST_VALUES: return host_only in _LOOPBACK_HOST_VALUES # Explicit non-loopback bind: require exact host match return host_only == bound_lc @app.middleware("http") async def host_header_middleware(request: Request, call_next): """Reject requests whose Host header doesn't match the bound interface. Defends against DNS rebinding: a victim browser on a localhost dashboard is tricked into fetching from an attacker hostname that TTL-flips to 127.0.0.1. CORS and same-origin checks don't help — the browser now treats the attacker origin as same-origin with the dashboard. Host-header validation at the app layer catches it. See GHSA-ppp5-vxwm-4cf7. """ # Store the bound host on app.state so this middleware can read it — # set by start_server() at listen time. bound_host = getattr(app.state, "bound_host", None) if bound_host: host_header = request.headers.get("host", "") trusted_public_hosts = getattr( app.state, "trusted_public_hosts", frozenset() ) if not _is_accepted_host( host_header, bound_host, trusted_public_hosts ): return JSONResponse( status_code=400, content={ "detail": ( "Invalid Host header. Dashboard requests must use the " "bound hostname or the configured public hostname." ), }, ) return await call_next(request) @app.middleware("http") async def _plugin_api_runtime_gate(request: Request, call_next): """Block requests to disabled plugin API routes at request time. :func:`_mount_plugin_api_routes` gates at import time, but if a plugin is disabled *after* the dashboard is already running, its FastAPI router remains mounted until restart. This middleware enforces the enabled/ disabled policy on every request to ``/api/plugins/{name}/...`` so that runtime config changes take effect immediately. Registered BEFORE the auth middlewares (so it executes AFTER them): a request that hasn't cleared auth must get auth's 401 first, never this gate's 404 — otherwise an unauthenticated caller could fingerprint which plugins are installed/enabled by reading the status code. We only reach the enabled/disabled check for a request that auth already let through. """ path = request.url.path if path.startswith("/api/plugins/"): # Only gate authenticated requests. Unauthenticated ones fall # through so auth_middleware / the OAuth gate return 401 first and # this route can't be used as a plugin-name oracle. _authed = ( getattr(request.state, "token_authenticated", False) or getattr(request.app.state, "auth_required", False) or _has_valid_session_token(request) or _has_valid_query_token(request, path) ) if _authed: # Extract plugin name from /api/plugins//... parts = path.split("/") # parts: ['', 'api', 'plugins', '', ...] if len(parts) >= 4: plugin_name = parts[3] if plugin_name: try: from hermes_cli.plugins_cmd import ( _get_enabled_set, _get_disabled_set, ) enabled_set = _get_enabled_set() disabled_set = _get_disabled_set() except Exception: enabled_set = set() disabled_set = set() # Determine plugin source. Check the cached plugin list; # if not found, assume user plugin (safe default — blocks). plugins = _get_dashboard_plugins() plugin = next( (p for p in plugins if p.get("name") == plugin_name), None, ) source = plugin.get("source") if plugin else "user" if source == "user": if plugin_name in disabled_set or plugin_name not in enabled_set: return JSONResponse( status_code=404, content={"detail": "Plugin not found"}, ) elif source == "bundled": if plugin_name in disabled_set: return JSONResponse( status_code=404, content={"detail": "Plugin not found"}, ) return await call_next(request) # --------------------------------------------------------------------------- # Dashboard OAuth auth gate — engaged only when start_server flags the # bind as non-loopback-without-insecure. No-op pass-through in loopback # mode so the legacy auth_middleware (below) handles those binds via # the injected ``_SESSION_TOKEN``. Registered between host_header and # auth_middleware so the order is: host check → cookie auth → token auth. # --------------------------------------------------------------------------- @app.middleware("http") async def _dashboard_auth_gate(request: Request, call_next): from hermes_cli.dashboard_auth.middleware import gated_auth_middleware return await gated_auth_middleware(request, call_next) @app.middleware("http") async def auth_middleware(request: Request, call_next): """Require the session token on all /api/ routes except the public list.""" # A request already authenticated by the token-auth seam (a service caller # presenting a bearer token on a registered token route) carries # ``token_authenticated`` — never bounce it through the cookie/session gate. if getattr(request.state, "token_authenticated", False): return await call_next(request) # When the OAuth gate is active, cookie-based auth (gated_auth_middleware # above) is authoritative. The legacy _SESSION_TOKEN path is loopback-only # and is skipped here so the gate's session attachment isn't overridden. if getattr(request.app.state, "auth_required", False): return await call_next(request) path = request.url.path is_mcp_oauth_callback = path.startswith("/api/mcp/oauth/callback/") if path.startswith("/api/") and path not in _PUBLIC_API_PATHS and not is_mcp_oauth_callback: if not _has_valid_session_token(request) and not _has_valid_query_token(request, path): return JSONResponse( status_code=401, content={"detail": "Unauthorized"}, ) return await call_next(request) @app.middleware("http") async def _token_auth_seam(request: Request, call_next): """Outermost auth seam: non-interactive bearer-token auth for opted-in routes. Registered LAST so it runs FIRST (Starlette middleware is outermost-last). A registered token route is fully owned here — authenticate by token, attach the principal + ``token_authenticated`` flag, and let the downstream cookie/session gates skip enforcement. Non-token routes pass straight through untouched. """ from hermes_cli.dashboard_auth.token_auth import token_auth_middleware return await token_auth_middleware(request, call_next) # --------------------------------------------------------------------------- # Dashboard component health — in-process error/self-test counters that feed # the ``components`` dict on ``/api/status``. That endpoint is in # ``PUBLIC_API_PATHS``, so everything exported from here must be counts and # enums only: no exception messages, no request paths, no tokens. # --------------------------------------------------------------------------- _DASHBOARD_HEALTH_WINDOW_SECONDS = 300.0 class DashboardHealth: """Module-level holder for dashboard-process health signals. Tracks unhandled exceptions / 5xx responses seen by the outermost HTTP middleware (rolling window) and the result of the periodic authenticated self-test. ``last_error_path`` and ``last_error_type`` are internal diagnostics for logs/debuggers — :meth:`snapshot` deliberately exports neither (public-payload no-secrets contract). """ def __init__(self, window_seconds: float = _DASHBOARD_HEALTH_WINDOW_SECONDS) -> None: self.window_seconds = window_seconds self._error_times: "deque[float]" = deque(maxlen=256) self.last_error_type: Optional[str] = None self.last_error_path: Optional[str] = None # internal-only, never serialized self.last_error_at: Optional[float] = None self.selftest_status: str = "unknown" # unknown | ok | failing self.selftest_http_status: Optional[int] = None self.selftest_at: Optional[float] = None def record_error(self, exc_type: str, path: str) -> None: now = time.time() self._error_times.append(now) self.last_error_type = exc_type self.last_error_path = path self.last_error_at = now def record_selftest(self, passed: bool, http_status: Optional[int]) -> None: self.selftest_status = "ok" if passed else "failing" self.selftest_http_status = http_status self.selftest_at = time.time() def recent_error_count(self) -> int: cutoff = time.time() - self.window_seconds while self._error_times and self._error_times[0] < cutoff: self._error_times.popleft() return len(self._error_times) def snapshot(self) -> Dict[str, Any]: """Public component payload: status enum + counts + timestamps only.""" errors = self.recent_error_count() status = "degraded" if (errors or self.selftest_status == "failing") else "ok" return { "status": status, "recent_unhandled_errors": errors, "last_error_at": self.last_error_at, "selftest": self.selftest_status, } DASHBOARD_HEALTH = DashboardHealth() @app.middleware("http") async def _dashboard_health_middleware(request: Request, call_next): """Outermost middleware: count unhandled exceptions and 5xx responses. Registered after ``_token_auth_seam`` so it is the outermost layer (Starlette middleware is outermost-last) — nothing below can raise past it unseen. Records into :data:`DASHBOARD_HEALTH` and re-raises; never swallows or alters the response. """ try: response = await call_next(request) except Exception as exc: DASHBOARD_HEALTH.record_error(type(exc).__name__, request.url.path) raise if response.status_code >= 500: DASHBOARD_HEALTH.record_error(f"http_{response.status_code}", request.url.path) return response # --------------------------------------------------------------------------- # Authenticated-route self-test: every minute, make one in-process request # against a cheap DB-touching authenticated route with the real session # token. Catches the class of failure where liveness looks fine but every # authenticated request 500s (e.g. wedged state DB). # --------------------------------------------------------------------------- _DASHBOARD_SELFTEST_INTERVAL_SECONDS = 60.0 _DASHBOARD_SELFTEST_ROUTE = "/api/sessions?limit=1" async def _dashboard_selftest_once() -> None: """Run one authenticated in-process self-test request and record it.""" try: import httpx except ImportError: return # optional dependency — skip cleanly, leave status "unknown" try: transport = httpx.ASGITransport(app=app) # base_url uses a loopback name so the Host-header middleware accepts # the request on loopback binds. async with httpx.AsyncClient( transport=transport, base_url="http://127.0.0.1" ) as client: resp = await client.get( _DASHBOARD_SELFTEST_ROUTE, headers={_SESSION_HEADER_NAME: _SESSION_TOKEN}, ) DASHBOARD_HEALTH.record_selftest(resp.status_code == 200, resp.status_code) except Exception: DASHBOARD_HEALTH.record_selftest(False, None) async def _dashboard_selftest_loop() -> None: """Periodic self-test driver started from the lifespan.""" try: import httpx # noqa: F401 except ImportError: _log.debug("httpx unavailable — dashboard self-test disabled") return while True: await asyncio.sleep(_DASHBOARD_SELFTEST_INTERVAL_SECONDS) # On OAuth-gated binds the legacy session token is not honoured, so # the probe would false-alarm 401 — skip until the gate is off. if getattr(app.state, "auth_required", False): continue await _dashboard_selftest_once() # --------------------------------------------------------------------------- # Config schema — auto-generated from DEFAULT_CONFIG # --------------------------------------------------------------------------- # Manual overrides for fields that need select options or custom types def _memory_provider_options() -> List[str]: """Discovered memory providers for the ``memory.provider`` select. Directory-scan only (no provider imports), so it's safe at module import time. ``""`` (built-in only) is always first; discovery failures degrade to the bundled defaults rather than dropping the field. The literal ``builtin`` alias is deliberately NOT offered — built-in memory is not a provider plugin, and ``_normalize_memory_provider_name`` already maps any legacy ``builtin``/``built-in``/``none`` value back to ``""`` (#49513). """ options = [""] try: from plugins.memory import list_memory_provider_names options.extend(list_memory_provider_names()) except Exception: options.extend(["honcho"]) # Dedupe, preserve order return list(dict.fromkeys(options)) def _timezone_options() -> List[str]: """Return sorted IANA timezone identifiers, cached at import time.""" try: import zoneinfo return sorted(zoneinfo.available_timezones()) or ["UTC"] except Exception: # pragma: no cover return ["UTC"] _SCHEMA_OVERRIDES: Dict[str, Dict[str, Any]] = { "timezone": { "type": "select", "description": "IANA timezone (e.g. America/New_York). Blank uses the system timezone.", "options": _timezone_options(), "searchable": True, "clearable": True, }, "memory.provider": { "type": "select", "description": "Memory provider plugin", "options": _memory_provider_options(), }, "model": { "type": "string", "description": "Default model (e.g. anthropic/claude-sonnet-4.6)", "category": "general", }, "model_context_length": { "type": "number", "description": "Context window override (0 = auto-detect from model metadata)", "category": "general", }, "terminal.backend": { "type": "select", "description": "Terminal execution backend", "options": ["local", "docker", "ssh", "modal", "daytona", "vercel_sandbox", "singularity"], }, "terminal.vercel_runtime": { "type": "select", "description": "Vercel Sandbox runtime", "options": ["node24", "node22", "python3.13"], # sync with _SUPPORTED_VERCEL_RUNTIMES in terminal_tool.py }, "terminal.modal_mode": { "type": "select", "description": "Modal sandbox mode", "options": ["sandbox", "function"], }, "proxy.enabled": { "type": "boolean", "description": ( "Docker-only egress credential firewall. Requires `hermes egress setup` " "and `hermes egress start`; Modal/SSH/Daytona are not wired yet." ), "category": "security", }, "proxy.credential_source": { "type": "select", "description": "Where iron-proxy loads real upstream secrets at start time", "options": ["env", "bitwarden"], "category": "security", }, "proxy.enforce_on_docker": { "type": "boolean", "description": "Refuse Docker sandboxes when egress is enabled but not configured/running", "category": "security", }, "tts.provider": { "type": "select", "description": "Text-to-speech provider", "options": ["edge", "elevenlabs", "openai", "xai", "minimax", "mistral", "gemini", "neutts", "kittentts", "piper"], }, "stt.provider": { "type": "select", "description": "Speech-to-text provider", # "mistral" temporarily removed — mistralai PyPI package quarantined # (malicious 2.4.6 release on 2026-05-12). Restore once available. "options": ["local", "groq", "openai", "xai", "elevenlabs"], }, "stt.local.model": { "type": "select", "description": "Local faster-whisper model size", "options": ["tiny", "base", "small", "medium", "large-v3"], }, "stt.groq.model": { "type": "select", "description": "Groq Whisper model", "options": ["whisper-large-v3-turbo", "whisper-large-v3", "distil-whisper-large-v3-en"], }, "stt.openai.model": { "type": "select", "description": "OpenAI transcription model", "options": ["whisper-1", "gpt-4o-mini-transcribe", "gpt-4o-transcribe", "gpt-transcribe"], }, "stt.elevenlabs.model_id": { "type": "select", "description": "ElevenLabs Scribe model", "options": ["scribe_v2", "scribe_v1"], }, "display.skin": { "type": "select", "description": "CLI visual theme", "options": ["default", "ares", "mono", "slate"], }, "dashboard.theme": { "type": "select", "description": "Web dashboard visual theme", "options": ["default", "midnight", "ember", "mono", "cyberpunk", "rose"], }, "display.resume_display": { "type": "select", "description": "How resumed sessions display history", "options": ["minimal", "full", "off"], }, "display.busy_input_mode": { "type": "select", "description": "Input behavior while agent is running", "options": ["interrupt", "queue", "steer"], }, "approvals.mode": { "type": "select", "description": "Dangerous command approval mode", "options": ["manual", "smart", "off"], }, "context.engine": { "type": "select", "description": "Context management engine", "options": ["default", "custom"], }, "human_delay.mode": { "type": "select", "description": "Simulated typing delay mode", "options": ["off", "typing", "fixed"], }, "logging.level": { "type": "select", "description": "Log level for agent.log", "options": ["DEBUG", "INFO", "WARNING", "ERROR"], }, "agent.service_tier": { "type": "select", "description": "Fast mode: fast = always, auto = first N seconds of each turn, cold = first turn only", "options": ["", "normal", "fast", "auto", "cold"], }, "delegation.reasoning_effort": { "type": "select", "description": "Reasoning effort for delegated subagents", "options": ["", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"], }, "updates.non_interactive_local_changes": { "type": "select", "description": ( "When the chat app / gateway updates Hermes (no terminal prompt), " "what to do with uncommitted local source edits. 'stash' keeps them " "and re-applies them after the update; 'discard' throws them away. " "Terminal updates always ask, regardless of this setting." ), "options": ["stash", "discard"], }, "updates.refresh_cua_driver": { "type": "boolean", "description": ( "Refresh an already-installed cua-driver during hermes update. " "Disable this on non-admin macOS accounts where /Applications is " "not writable." ), }, "browser.headed": { "type": "boolean", "description": "Run the local browser in headed mode (visible window). Also keeps the window open between turns; idle sessions are still reaped after browser.inactivity_timeout.", }, "plugins.hook_callback_timeout": { "type": "number", "description": ( "Wall-clock cap (seconds) for timeout-bounded in-process Python " "plugin hook callbacks (hot-path observers + pre_tool_call). " "Timed-out pre_tool_call fails closed. 0 disables the cap; " "values above 600 are clamped. Caller-thread hooks such as " "subagent_stop are never moved onto a timeout worker." ), }, } # Categories with fewer fields get merged into "general" to avoid tab sprawl. _CATEGORY_MERGE: Dict[str, str] = { "privacy": "security", "context": "agent", "skills": "agent", "cron": "agent", "network": "agent", # `models_dev.url` (mirror override) is the only schema-surfaced # models_dev field — fold it in with the other network/agent plumbing # rather than spawning a one-field orphan tab. "models_dev": "agent", "checkpoints": "agent", "approvals": "security", "human_delay": "display", "dashboard": "display", "code_execution": "agent", "prompt_caching": "agent", # bot_mode holds a couple of relay tuning knobs — keep it folded into the # agent tab rather than spawning a tiny standalone category. "bot_mode": "agent", "goals": "agent", "updates": "general", # `onboarding.profile_build` is the only schema-surfaced onboarding field # (`onboarding.seen` is an internal latch dict, not a user setting), so fold # it into the agent tab rather than spawning a one-field orphan category. "onboarding": "agent", # Only `telegram.reactions` currently lives under telegram — fold it in # with the other messaging-platform config (discord) so it isn't an # orphan tab of one field. "telegram": "discord", # `mcp.auto_reload_on_config_change` is the only schema-surfaced mcp # runtime field (server definitions live under mcp_servers, edited via # the MCP tab) — fold it into the agent tab rather than spawning a # one-field orphan category. "mcp": "agent", # `computer_use.cua_telemetry` is the only schema-surfaced computer_use # field — fold it into the agent tab rather than spawning a one-field # orphan category. "computer_use": "agent", # `telemetry.shared_metrics.enabled` is the only schema-surfaced telemetry # field — fold it into security alongside the other privacy-posture toggles. "telemetry": "security", # `plugins.hook_callback_timeout` is the only schema-surfaced plugins field # (`enabled`/`disabled` are list allow-lists omitted from DEFAULT_CONFIG) — # fold it into the agent tab rather than spawning a one-field orphan category. "plugins": "agent", # `doctor.live_probe_timeout` is the only schema-surfaced doctor field — # fold it into general rather than spawning a one-field orphan category. "doctor": "general", # `runtime.nofile_soft_limit` (#78873) is the only schema-surfaced runtime # field — fold it into the agent tab rather than spawning a one-field # orphan category. "runtime": "agent", # `session.terminal_continue` is the only schema-surfaced session field — # fold it into general rather than spawning a one-field orphan category. "session": "general", # `nous.keepalive_interval_seconds` is the only schema-surfaced nous field # (Portal tokens live in auth.json) — fold it into the agent tab. "nous": "agent", } # Display order for tabs — unlisted categories sort alphabetically after these. _CATEGORY_ORDER = [ "general", "agent", "terminal", "display", "delegation", "memory", "compression", "security", "browser", "voice", "tts", "stt", "logging", "discord", "auxiliary", ] def _infer_type(value: Any) -> str: """Infer a UI field type from a Python value.""" if isinstance(value, bool): return "boolean" if isinstance(value, int): return "number" if isinstance(value, float): return "number" if isinstance(value, list): return "list" if isinstance(value, dict): return "object" return "string" def _build_schema_from_config( config: Dict[str, Any], prefix: str = "", ) -> Dict[str, Dict[str, Any]]: """Walk DEFAULT_CONFIG and produce a flat dot-path → field schema dict.""" schema: Dict[str, Dict[str, Any]] = {} for key, value in config.items(): full_key = f"{prefix}.{key}" if prefix else key # Skip internal / version keys if full_key in {"_config_version"}: continue # Category is the first path component for nested keys, or "general" # for top-level scalar fields (model, toolsets, timezone, etc.). if prefix: category = prefix.split(".")[0] elif isinstance(value, dict): category = key else: category = "general" if isinstance(value, dict): # Recurse into nested dicts schema.update(_build_schema_from_config(value, full_key)) else: entry: Dict[str, Any] = { "type": _infer_type(value), "description": full_key.replace(".", " → ").replace("_", " ").title(), "category": category, } # Apply manual overrides if full_key in _SCHEMA_OVERRIDES: entry.update(_SCHEMA_OVERRIDES[full_key]) # Merge small categories entry["category"] = _CATEGORY_MERGE.get(entry["category"], entry["category"]) schema[full_key] = entry return schema CONFIG_SCHEMA = _build_schema_from_config(DEFAULT_CONFIG) # Inject virtual fields that don't live in DEFAULT_CONFIG but are surfaced # by the normalize/denormalize cycle. Insert model_context_length right after # the "model" key so it renders adjacent in the frontend. _mcl_entry = _SCHEMA_OVERRIDES["model_context_length"] _ordered_schema: Dict[str, Dict[str, Any]] = {} for _k, _v in CONFIG_SCHEMA.items(): _ordered_schema[_k] = _v if _k == "model": _ordered_schema["model_context_length"] = _mcl_entry CONFIG_SCHEMA = _ordered_schema def _is_command_provider_block(value: Any) -> bool: """Return True when *value* declares a command-type voice provider. Mirrors the runtime discriminators (``tools.tts_tool._is_command_provider_config`` / ``tools.transcription_tools._is_command_stt_provider_config``) and the desktop's ``isCommandProvider`` in ``apps/desktop/src/app/settings/helpers.ts``: ``type`` is OPTIONAL and case/space-insensitive (absent or normalizing to ``"command"``), and ``command`` MUST be a non-empty string. Built-in blocks (which carry ``voice``/``model`` and no ``command``) and the ``providers`` container itself are rejected. """ if not isinstance(value, dict): return False ptype = str(value.get("type") or "").strip().lower() if ptype and ptype != "command": return False command = value.get("command") return isinstance(command, str) and bool(command.strip()) def _custom_provider_options( kind: str, builtin_names: List[str], cfg: Dict[str, Any], ) -> List[str]: """Return a merged provider option list without hard-coding vendor names. *kind* is ``"tts"`` or ``"stt"``. The result keeps the built-in display names first (original order — NOT re-sorted), then appends: 1. Command-type providers declared under the canonical ``.providers.`` location, plus the legacy top-level ``.`` fallback — exactly the dual resolution the runtime performs in ``_get_named_provider_config`` / ``_get_named_stt_provider_config``. Names colliding with a RUNTIME built-in are excluded case-insensitively (the runtime rejects a built-in name as a command provider before any config lookup), so a ``providers.EDGE`` command block is not offered. 2. Plugin-registered provider names from ``agent.tts_registry`` / ``agent.transcription_registry`` — opportunistic only: plugins register at runtime via ``ctx.register_tts_provider()``, and this process does not necessarily call ``discover_plugins()``, so the registry may legitimately be empty here. (There is no static ``provides: [tts]`` manifest convention to scan — real manifests only carry ``provides_tools``/``provides_hooks``.) 3. The current ``.provider`` value when not already present — a custom name that only appears as the active provider stays selectable (matches desktop ``enumOptionsFor``'s current-value preservation). Guard semantics deliberately mirror ``apps/desktop/src/app/settings/helpers.ts:commandProviderNames`` so the backend schema (web dashboard) and the desktop client agree on which names are offered. """ names = [str(n) for n in builtin_names] seen = {n.strip().lower() for n in names} # Guard against the RUNTIME built-in sets, not the display shortlist # above: the display list drifts from the runtime sets (e.g. omits # ``deepinfra``), and filtering on it would offer names the runtime # would never honour as command providers. if kind == "tts": from tools.tts_tool import BUILTIN_TTS_PROVIDERS as _runtime_builtins else: from tools.transcription_tools import BUILTIN_STT_PROVIDERS as _runtime_builtins def _add(name: Any) -> None: if not isinstance(name, str): return stripped = name.strip() key = stripped.lower() if stripped and key not in seen: names.append(stripped) seen.add(key) section = cfg.get(kind) if not isinstance(section, dict): section = {} # Canonical nested location first, then the legacy top-level fallback — # the same order the runtime resolves them in. candidate_blocks: List[Any] = [] providers_map = section.get("providers") if isinstance(providers_map, dict): candidate_blocks.append(providers_map) candidate_blocks.append( {k: v for k, v in section.items() if k != "providers"} ) for block in candidate_blocks: for name, value in block.items(): if ( isinstance(name, str) and name.strip().lower() not in _runtime_builtins and _is_command_provider_block(value) ): _add(name) # Plugin-registered providers (only populated when plugins are loaded in # this process). Registry names can never collide with built-ins — the # registries reject such registrations. try: if kind == "tts": from agent.tts_registry import list_providers as _list_voice_providers else: from agent.transcription_registry import list_providers as _list_voice_providers for _p in _list_voice_providers(): _add(getattr(_p, "name", None)) except Exception: # pragma: no cover - registry import should not break schema pass # Current-value preservation (``cfg_get`` takes *keys*, not dotted paths). _add(cfg_get(cfg, kind, "provider")) return names def _memory_provider_schema_options(cfg: Dict[str, Any]) -> List[str]: """Discovered memory providers for a per-request schema merge. Reuses the cheap directory scan of :func:`_memory_provider_options` and additionally preserves the currently-configured provider, so a value selected in config but not (yet) discoverable — e.g. a plugin removed from disk — never silently vanishes from the dropdown. """ options = _memory_provider_options() memory = cfg.get("memory") configured = memory.get("provider") if isinstance(memory, dict) else None current = _normalize_memory_provider_name(configured) if current and current not in options: options = [*options, current] return options def _schema_with_dynamic_provider_options() -> Dict[str, Dict[str, Any]]: """Return CONFIG_SCHEMA with per-request discovery-driven options merged. Some ``*.provider`` selects have options that are discovered at runtime (voice backends via the tts/stt registries + config.yaml command providers; memory providers via a plugin-dir scan). The module-level ``_SCHEMA_OVERRIDES`` freezes those lists at import time, so a provider installed after the server started never appears. This recomputes them at request time — reflecting the CURRENT config.yaml, the profile-scoped config when the request carries a ``profile`` param, and mid-session plugin installs — for every surface that reads the schema (desktop, CLI, dashboard), with no extra frontend round-trips. The module-level ``CONFIG_SCHEMA`` is never mutated; entries that change are shallow-copied onto a copied mapping. """ try: cfg = load_config() except Exception: # pragma: no cover - schema must survive config errors return CONFIG_SCHEMA overlay: Dict[str, Dict[str, Any]] = {} def merge(key: str, options: List[str]) -> None: entry = CONFIG_SCHEMA.get(key) if isinstance(entry, dict) and isinstance(entry.get("options"), list) and options != entry["options"]: overlay[key] = {**entry, "options": options} for kind in ("tts", "stt"): entry = CONFIG_SCHEMA.get(f"{kind}.provider") existing = entry.get("options") if isinstance(entry, dict) else None if isinstance(existing, list): merge(f"{kind}.provider", _custom_provider_options(kind, list(existing), cfg)) merge("memory.provider", _memory_provider_schema_options(cfg)) tb_entry = CONFIG_SCHEMA.get("terminal.backend") if isinstance(tb_entry, dict) and isinstance(tb_entry.get("options"), list): try: plugin_names = sorted( {row["name"] for row in _plugin_terminal_backend_rows()} - set(tb_entry["options"]) ) except Exception: plugin_names = [] if plugin_names: merge("terminal.backend", [*tb_entry["options"], *plugin_names]) if not overlay: return CONFIG_SCHEMA return {**CONFIG_SCHEMA, **overlay} from hermes_cli.web_models import ( # noqa: F401 ConfigUpdate, EnvVarUpdate, EnvVarDelete, EnvVarReveal, MemoryProviderConfigUpdate, MemoryProviderSetupRequest, CustomEndpointUpdate, MessagingPlatformUpdate, TelegramOnboardingStart, TelegramOnboardingApply, WhatsAppOnboardingStart, WhatsAppOnboardingApply, AudioTranscriptionRequest, ManagedFileUpload, ChatImageUpload, ManagedDirectoryCreate, ManagedFileDelete, ModelAssignment, MoaModelSlot, _MoaReferenceControls, MoaPresetPayload, MoaConfigPayload, FsWriteText, GitPathBody, GitFileBody, GitCommitBody, GitWorktreeAddBody, GitWorktreeRemoveBody, GitBranchSwitchBody, CuratorPause, LearningNodeRef, LearningNodeEdit, DebugShareRequest, TTSSpeakRequest, TTSLeaseRequest, OAuthSubmitBody, BulkDeleteSessions, SessionImport, SessionRename, SessionPrune, CronJobCreate, CronJobUpdate, AutomationBlueprintInstantiate, MCPServerCreate, MCPServersReplace, MCPEnabledToggle, MCPCatalogInstall, PairingApprove, PairingRevoke, WebhookCreate, WebhookEnabledToggle, CredentialPoolAdd, MemoryProviderSelect, MemoryReset, BackupRequest, ImportRequest, HookCreate, HookDelete, SkillInstallRequest, SkillUninstallRequest, SkillsUpdateRequest, ProfileCreate, ProfileRename, ProfileSoulUpdate, ProfileActiveUpdate, ProfileDescriptionUpdate, ProfileModelUpdate, ProfileDescribeAuto, SkillToggle, SkillCreate, SkillContentUpdate, ToolsetToggle, ToolsetProviderSelect, ToolsetModelSelect, ToolsetEnvUpdate, ToolsetPostSetup, TerminalBackendSelect, RawConfigUpdate, ThemeSetBody, FontSetBody, _AgentPluginInstallBody, _PluginProvidersPutBody, _PluginVisibilityBody, ) _AUDIO_MIME_EXTENSIONS: Dict[str, str] = { "audio/aac": ".aac", "audio/flac": ".flac", "audio/m4a": ".m4a", "audio/mp3": ".mp3", "audio/mp4": ".mp4", "audio/mpeg": ".mp3", "audio/ogg": ".ogg", "audio/wav": ".wav", "audio/wave": ".wav", "audio/webm": ".webm", "audio/x-m4a": ".m4a", "audio/x-wav": ".wav", "video/webm": ".webm", } _MAX_TRANSCRIPTION_UPLOAD_BYTES = 25 * 1024 * 1024 def _audio_extension_for_mime(mime_type: str) -> str: normalized = (mime_type or "").split(";", 1)[0].strip().lower() return _AUDIO_MIME_EXTENSIONS.get(normalized, ".webm") def _normalize_main_model_assignment(provider: str, model: str) -> tuple[str, str]: """Normalize a main-slot (provider, model) pair before persisting. The Models page has two assignment paths and only one of them was safe: - The "Change" picker sends a real Hermes provider slug — fine. - The per-card "Use as → Main model" menu sends ``entry.provider`` from the analytics rows, falling back to the model's VENDOR prefix (``modelVendor("anthropic/claude-opus-4.6") == "anthropic"``) when the session row has no ``billing_provider`` (older sessions, NULL rows). That wrote ``provider: anthropic`` + ``default: anthropic/claude-opus-4.6`` to config — a vendor-prefixed OpenRouter slug on the NATIVE Anthropic provider. New sessions then 400 against api.anthropic.com ("model: anthropic/claude-opus-4.6 not found") and the user reads it as "changing models does nothing". Two repairs, both at this single chokepoint so every caller inherits: 1. Vendor-name → Hermes-provider mapping: when the provider string is not a known Hermes provider/alias (e.g. ``moonshotai``, ``x-ai`` is known but ``poolside`` isn't) but the model is a vendor-prefixed aggregator slug, keep the user's CURRENT aggregator if they're on one, else fall back to openrouter. Named custom providers (``custom:litellm``, etc.) are excluded from this fallback: ``_KNOWN_PROVIDER_NAMES`` only lists the bare ``"custom"`` bucket, never a specific ``custom:`` slug, so without this exclusion every named custom provider paired with a slash-bearing model (e.g. ``ollama/glm-5.2`` behind a LiteLLM proxy) looked exactly like the stray-vendor-prefix case above and got silently reassigned to ``openrouter``. 2. Model-format normalization for the resolved provider via ``normalize_model_for_provider`` (e.g. ``anthropic/claude-opus-4.6`` on native anthropic → ``claude-opus-4-6``). """ from hermes_cli.config import get_compatible_custom_providers from hermes_cli.models import _KNOWN_PROVIDER_NAMES, normalize_provider from hermes_cli.model_normalize import normalize_model_for_provider from hermes_cli.providers import resolve_custom_provider, resolve_user_provider prov_in = (provider or "").strip() model_in = (model or "").strip() canonical = normalize_provider(prov_in) # User-declared providers are real routing targets, not analytics vendor # labels. Resolve them before the unknown-vendor fallback. ``providers:`` # keeps its declared bare slug; ``custom_providers:`` canonicalizes both a # bare display name and ``custom:`` to the durable custom slug. try: cfg = load_config() except Exception: cfg = {} user_providers = cfg.get("providers") if isinstance(cfg, dict) else None user_provider = resolve_user_provider( prov_in, user_providers if isinstance(user_providers, dict) else {} ) custom_provider = resolve_custom_provider( prov_in, get_compatible_custom_providers(cfg) if isinstance(cfg, dict) else [], ) if user_provider is not None: return user_provider.id, model_in if custom_provider is not None: return custom_provider.id, model_in # A named custom provider that didn't resolve above (typo, config # mismatch, entry missing from custom_providers/providers) must still # not be treated as a stray vendor prefix -- it isn't a known Hermes # provider/alias, but it also isn't the analytics-vendor case this # fallback exists for. Match only the durable named-custom syntax # (bare "custom" bucket, or "custom:" per # ``providers.custom_provider_slug``) -- a bare ``startswith("custom")`` # would also swallow unrelated unconfigured vendor names that merely # happen to start with "custom" (e.g. "customproxy"). is_custom_provider_slug = canonical == "custom" or canonical.startswith("custom:") if ( canonical not in _KNOWN_PROVIDER_NAMES and not is_custom_provider_slug and "/" in model_in ): # Vendor prefix posing as a provider (analytics fallback). Resolve # against the user's current provider when it's an aggregator that # serves vendor-prefixed slugs; otherwise default to openrouter. try: cur_cfg = cfg.get("model", {}) cur_provider = ( str(cur_cfg.get("provider", "") or "").strip().lower() if isinstance(cur_cfg, dict) else "" ) except Exception: cur_provider = "" from hermes_cli.models import _AGGREGATOR_PROVIDERS if cur_provider and normalize_provider(cur_provider) in _AGGREGATOR_PROVIDERS: canonical = normalize_provider(cur_provider) prov_in = cur_provider else: canonical = "openrouter" prov_in = "openrouter" # Custom/user-config providers keep the model verbatim — the registry # normalizer doesn't know their namespaces. if canonical in _KNOWN_PROVIDER_NAMES and not canonical.startswith("custom"): try: normalized_model = normalize_model_for_provider(model_in, canonical) if normalized_model: model_in = normalized_model except Exception: _log.debug("model normalization failed for %s/%s", prov_in, model_in, exc_info=True) return prov_in, model_in def _apply_main_model_assignment( model_cfg: "Any", provider: str, model: str, base_url: str = "", api_key: str = "" ) -> dict: """Apply a main-slot model assignment to a ``model`` config dict in place. Sets ``provider``/``default``, then reconciles ``base_url``: - An explicitly supplied ``base_url`` is always persisted (covers ``custom``/local endpoints and any provider whose key is bound to a non-default host). - Otherwise, a stale ``base_url`` is cleared ONLY when switching to a *different* provider — that URL belonged to the old provider. When the provider is unchanged and no new URL is supplied, the existing ``base_url`` is preserved. This keeps a user's custom endpoint (e.g. a Xiaomi MiMo Token Plan host, ``https://token-plan-*.xiaomimimo.com/v1``) alive when they merely re-pick a model under the same provider — picking a model previously wiped it, forcing the registry default and breaking Token Plan keys. The runtime resolver reads ``model.base_url`` from config (it ignores ``OPENAI_BASE_URL``) and only honors it when the configured provider matches and the pool entry is on the registry default, so preserving it here is what lets the override actually route. The hardcoded ``context_length`` override is always dropped since the new model may have a different context window. Returns the same dict (coerced to a fresh dict if the input wasn't one) so callers can assign it straight back onto the model config. """ if not isinstance(model_cfg, dict): model_cfg = {} prev_provider = str(model_cfg.get("provider") or "").strip().lower() new_provider = provider.strip().lower() model_cfg["provider"] = provider model_cfg["default"] = model if base_url.strip(): model_cfg["base_url"] = base_url.strip() elif model_cfg.get("base_url") and new_provider != prev_provider: # Switching providers: the old URL belonged to the old provider, drop # it so the new provider's default endpoint is used. Same-provider # re-assignment keeps the user's configured base_url intact. model_cfg["base_url"] = "" # The endpoint key follows the same lifecycle as base_url: an explicit key # is always persisted; an existing key is dropped only when switching to a # different provider (it belonged to the old endpoint), and preserved on a # same-provider re-pick so re-selecting a model doesn't wipe the key. if api_key.strip(): model_cfg["api_key"] = api_key.strip() model_cfg.pop("api", None) elif (model_cfg.get("api_key") or model_cfg.get("api")) and new_provider != prev_provider: # A stale endpoint secret can live under the legacy ``api`` alias with # no ``api_key`` (the resolver still reads ``model.api`` as a key), so # the switch-clears-the-key path must trigger on either field — else the # old endpoint's secret survives in config.yaml and contaminates a later # custom resolution. clear_model_endpoint_credentials scrubs both. clear_model_endpoint_credentials(model_cfg, clear_api_mode=False) if new_provider != prev_provider: clear_model_endpoint_credentials(model_cfg, clear_api_key=False) model_cfg.pop("context_length", None) return model_cfg _GATEWAY_HEALTH_URL = os.getenv("GATEWAY_HEALTH_URL") _GATEWAY_HEALTH_TIMEOUT_MAX = 1.0 _GATEWAY_HEALTH_ROUTE_TIMEOUT = 1.0 try: _GATEWAY_HEALTH_TIMEOUT = float(os.getenv("GATEWAY_HEALTH_TIMEOUT", "1")) except (ValueError, TypeError): _log.warning( "Invalid GATEWAY_HEALTH_TIMEOUT value %r — using default 1.0s", os.getenv("GATEWAY_HEALTH_TIMEOUT"), ) _GATEWAY_HEALTH_TIMEOUT = 1.0 if _GATEWAY_HEALTH_TIMEOUT <= 0: _log.warning( "Invalid non-positive GATEWAY_HEALTH_TIMEOUT value %.3fs — using default 1.0s", _GATEWAY_HEALTH_TIMEOUT, ) _GATEWAY_HEALTH_TIMEOUT = 1.0 elif _GATEWAY_HEALTH_TIMEOUT > _GATEWAY_HEALTH_TIMEOUT_MAX: _log.warning( "Capping GATEWAY_HEALTH_TIMEOUT %.3fs to %.3fs for dashboard liveness probes", _GATEWAY_HEALTH_TIMEOUT, _GATEWAY_HEALTH_TIMEOUT_MAX, ) _GATEWAY_HEALTH_TIMEOUT = _GATEWAY_HEALTH_TIMEOUT_MAX _STATUS_ACTIVE_SESSIONS_TIMEOUT = 0.75 # DEPRECATED (scheduled for removal): GATEWAY_HEALTH_URL / GATEWAY_HEALTH_TIMEOUT. # Cross-container / cross-host gateway liveness detection will be folded into a # first-class dashboard config key so it's no longer Docker-adjacent lore buried # in env vars. The env vars still work for now so existing Compose deployments # don't break. Do not add new callers — wire new uses through the planned # config surface. def _probe_gateway_health() -> tuple[bool, dict | None]: """Probe the gateway via its HTTP health endpoint (cross-container). .. deprecated:: Driven by the deprecated ``GATEWAY_HEALTH_URL`` / ``GATEWAY_HEALTH_TIMEOUT`` env vars. Scheduled for removal alongside a move to a first-class dashboard config key. See :data:`_GATEWAY_HEALTH_URL` for context. Uses ``/health/detailed`` first (returns full state), falling back to the simpler ``/health`` endpoint. Returns ``(is_alive, body_dict)``. Accepts any of these as ``GATEWAY_HEALTH_URL``: - ``http://gateway:8642`` (base URL — recommended) - ``http://gateway:8642/health`` (explicit health path) - ``http://gateway:8642/health/detailed`` (explicit detailed path) This is a **blocking** call — run via ``run_in_executor`` from async code. """ if not _GATEWAY_HEALTH_URL: return False, None # Normalise to base URL so we always probe the right paths regardless of # whether the user included /health or /health/detailed in the env var. base = _GATEWAY_HEALTH_URL.rstrip("/") if base.endswith("/health/detailed"): base = base[: -len("/health/detailed")] elif base.endswith("/health"): base = base[: -len("/health")] for path in (f"{base}/health/detailed", f"{base}/health"): try: req = urllib.request.Request(path, method="GET") with urllib.request.urlopen(req, timeout=_GATEWAY_HEALTH_TIMEOUT) as resp: if resp.status == 200: body = json.loads(resp.read()) return True, body except Exception: continue return False, None def _count_status_active_sessions() -> int: """Return the dashboard status active-session count. This is best-effort status garnish, not a critical path. Opens read-only (via the shared stale-schema heal, same as every other dashboard read path) so /api/status never routinely writes to state.db while another Hermes process is using it. """ from hermes_state import _default_db_path # The heal helper bootstraps a missing store; this garnish must not — on # a fresh install /api/status polls would otherwise create state.db # before the user's first session. if not Path(_default_db_path()).exists(): return 0 db = _open_session_db_for_profile(None, read_only=True) try: sessions = db.list_sessions_rich(limit=50, compact_rows=True) now = time.time() return sum( 1 for s in sessions if s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) finally: db.close() async def _status_active_sessions() -> int: try: return await asyncio.wait_for( run_in_threadpool(_count_status_active_sessions), timeout=_STATUS_ACTIVE_SESSIONS_TIMEOUT, ) except asyncio.TimeoutError: _log.debug( "/api/status active session count exceeded %.2fs; returning 0", _STATUS_ACTIVE_SESSIONS_TIMEOUT, ) except Exception as exc: _log.debug("/api/status active session count unavailable: %s", exc) return 0 # Image MIME types this endpoint will serve. Extension-allowlisted so an # authenticated caller can't pull non-image files through it. _MEDIA_CONTENT_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon", } _MEDIA_MAX_BYTES = 25 * 1024 * 1024 _MANAGED_FILES_ROOT_ENV = "HERMES_DASHBOARD_FILES_ROOT" _MANAGED_FILE_MAX_BYTES = 100 * 1024 * 1024 _STREAMABLE_MEDIA_EXTENSIONS = frozenset( { ".avi", ".flac", ".m4a", ".mkv", ".mov", ".mp3", ".mp4", ".ogg", ".opus", ".wav", ".webm", } ) _HOSTED_MANAGED_FILES_ROOT = Path("/opt/data") @dataclass(frozen=True) class ManagedFilesPolicy: default_path: Path locked_root: Path | None can_change_path: bool _FS_READDIR_HIDDEN = { ".git", ".hg", ".svn", ".cache", ".next", ".turbo", ".venv", "__pycache__", "build", "dist", "node_modules", "target", "venv", } # Filenames that must never be listed, read, or downloaded through the # managed-files API. These typically contain credentials (API keys, tokens) # and exposing them through the dashboard file browser is a security leak — # see issue #57505. The set mirrors the credential-file basenames of the two # canonical credential guards elsewhere in the codebase # (agent.file_safety.get_read_block_error and # gateway.platforms.base._ROOT_CREDENTIAL_FILES) so the dashboard Files tab # doesn't lag behind them — an operator can point the managed root at # HERMES_HOME itself, at which point every one of these basenames is a live # secret store sitting in the browsable tree. _SENSITIVE_MANAGED_FILE_BASENAMES = frozenset({ "auth.json", "auth.lock", "credentials", "config.yaml", ".anthropic_oauth.json", "google_token.json", "google_oauth_pending.json", "google_oauth.json", "webhook_subscriptions.json", "bws_cache.json", "bws_cache.enc.json", # git's credential-store helper cache (agent.file_safety blocks this too). ".git-credentials", }) from hermes_cli.web_routers import files as _files_routes # noqa: E402 app.include_router(_files_routes.router) from hermes_cli.web_routers.files import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_media, upload_managed_file_stream, ) _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 def _fs_path(raw_path: str) -> Path: raw = str(raw_path or "").strip() if not raw: raise HTTPException(status_code=400, detail="Path is required") if "\0" in raw: raise HTTPException(status_code=400, detail="Invalid path") try: if raw.lower().startswith("file:"): parsed = urllib.parse.urlparse(raw) if parsed.netloc and parsed.netloc not in {"", "localhost"}: raise ValueError raw = urllib.request.url2pathname(parsed.path) candidate = Path(raw).expanduser() if not candidate.is_absolute(): candidate = Path.cwd() / candidate return candidate.resolve(strict=False) except (OSError, RuntimeError, ValueError): raise HTTPException(status_code=400, detail="Invalid path") def _canonical_path(path: Path, *, require_exists: bool = False) -> Path: try: return path.expanduser().resolve(strict=require_exists) except FileNotFoundError: if require_exists: raise HTTPException(status_code=404, detail="Path not found") raise except (OSError, RuntimeError): raise HTTPException(status_code=400, detail="Invalid path") def _ensure_managed_root(raw_path: str | Path) -> Path: root = Path(raw_path).expanduser() try: root.mkdir(parents=True, exist_ok=True) resolved = root.resolve() except (OSError, RuntimeError) as exc: raise HTTPException(status_code=500, detail=f"Managed files root is unavailable: {exc}") if not resolved.is_dir(): raise HTTPException(status_code=500, detail="Managed files root is not a directory") return resolved def _path_is_under(root: Path, target: Path) -> bool: return target == root or root in target.parents def _path_text(raw_path: str | None) -> str: text = str(raw_path or "").strip() if "\x00" in text: raise HTTPException(status_code=400, detail="Invalid path") return text def _default_hermes_root_is_opt_data() -> bool: raw = os.environ.get("HERMES_HOME", "").strip() if not raw: return False try: from hermes_constants import get_default_hermes_root root = get_default_hermes_root().expanduser().resolve(strict=False) except (OSError, RuntimeError): root = Path(raw).expanduser().resolve(strict=False) return root == _HOSTED_MANAGED_FILES_ROOT def _dashboard_local_update_managed_externally() -> bool: """Return true when the dashboard should not offer ``hermes update``. Containerized dashboards are updated by the outer launcher/image, not by an in-browser local update action. Keep this dashboard capability separate from install-method detection: manual git/pip installs inside containers can still behave like their actual install method in the CLI. However, when the install method is ``git`` (a bind-mounted checkout inside a container — e.g. the hermes-webui image sharing the Hermes source tree), the dashboard's ``hermes update`` button is the correct update path and should not be suppressed. Other containerized install methods remain externally managed unless their apply path is proven safe inside the running container filesystem. """ if _default_hermes_root_is_opt_data(): return True try: from hermes_constants import is_container if not is_container(): return False except Exception: return False # We are inside a container, but the install may still be self-managed. # If the install method is git, the dashboard update button works against # the mounted checkout and should be offered. Keep pip blocked inside # containers: its apply path mutates the running container filesystem and # is not the bind-mounted checkout case this gate is meant to recover. try: method = detect_install_method(PROJECT_ROOT) if method == "git": return False except Exception: pass return True def _managed_files_policy(request: Request, *, create_root: bool = True) -> ManagedFilesPolicy: raw_forced_root = os.environ.get(_MANAGED_FILES_ROOT_ENV, "").strip() if raw_forced_root: root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root)) return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) # Remote/OAuth access does not imply a hosted container. Users can expose a # local dashboard through the auth gate (for example a macOS launchd install) # and still expect the Files page to browse their local home directory. Lock # to /opt/data only when the installation's Hermes root is actually /opt/data # (the container/hosted layout) or when HERMES_DASHBOARD_FILES_ROOT is set. if _default_hermes_root_is_opt_data(): root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False) home = _canonical_path(Path.home()) return ManagedFilesPolicy(default_path=home, locked_root=None, can_change_path=True) def _resolve_managed_path( raw_path: str | None, request: Request, *, for_write: bool = False, ) -> tuple[ManagedFilesPolicy, Path, str]: policy = _managed_files_policy(request) text = _path_text(raw_path) root = policy.locked_root if root is not None and (not text or text in {".", "/"}): candidate = root elif not text: candidate = policy.default_path else: candidate = Path(text).expanduser() if root is not None and not candidate.is_absolute(): if any(part == ".." for part in candidate.parts): raise HTTPException(status_code=400, detail="Path cannot contain '..'") candidate = root / candidate elif not candidate.is_absolute(): raise HTTPException(status_code=400, detail="Path must be absolute") if ".." in candidate.parts: raise HTTPException(status_code=400, detail="Path cannot contain '..'") if for_write and not candidate.exists(): parent = _canonical_path(candidate.parent) resolved = parent / candidate.name else: resolved = _canonical_path(candidate, require_exists=not for_write) if root is not None and not _path_is_under(root, resolved): raise HTTPException(status_code=403, detail="Path outside managed files root") return policy, resolved, str(resolved) def _managed_response_meta(policy: ManagedFilesPolicy) -> Dict[str, Any]: locked_root = str(policy.locked_root) if policy.locked_root is not None else None return { "root": locked_root, "locked_root": locked_root, "can_change_path": policy.can_change_path, } def _managed_file_entry(policy: ManagedFilesPolicy, target: Path) -> Dict[str, Any]: try: resolved = target.resolve() except (OSError, RuntimeError): raise HTTPException(status_code=400, detail="Invalid path") if policy.locked_root is not None and not _path_is_under(policy.locked_root, resolved): raise HTTPException(status_code=403, detail="Path outside managed files root") try: st = resolved.stat() except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not stat path: {exc}") is_dir = resolved.is_dir() mime_type = None if is_dir else (mimetypes.guess_type(resolved.name)[0] or "application/octet-stream") return { "name": target.name or resolved.name or str(resolved), "path": str(resolved), "is_directory": is_dir, "size": None if is_dir else st.st_size, "mtime": st.st_mtime, "mime_type": mime_type, } # Stream uploads to disk in fixed-size chunks. The legacy JSON endpoint above # buffers the whole file as a base64 data URL in a JSON body, which (a) inflates # the payload ~33%, (b) holds the entire file (plus its decoded copy) in memory, # and (c) reliably trips upstream proxy body-size/timeout limits with a 502 on # large backup archives (NS-501). This multipart endpoint reads the request body # in 1 MiB chunks straight to a temp file, enforces the size cap as it goes, and # atomically renames into place — constant memory, no base64 inflation. _UPLOAD_CHUNK_BYTES = 1024 * 1024 # --------------------------------------------------------------------------- # Git ops — the remote half of the desktop coding rail + review pane. # # The desktop runs these as Electron-local git on the user's machine; over a # remote gateway that's the wrong filesystem, so we mirror them here (same auth # gate + path hardening as /api/fs). Logic lives in ``hermes_cli.web_git``; # these are thin, executor-offloaded wrappers (git/gh can block). # --------------------------------------------------------------------------- from hermes_cli import web_git as _web_git # noqa: E402 async def _git_op(fn, *args): """Run a (blocking) git op off the event loop; map a failed mutation to 400.""" loop = asyncio.get_running_loop() try: return await loop.run_in_executor(None, fn, *args) except RuntimeError as exc: raise HTTPException(status_code=400, detail=str(exc) or "git operation failed") def _git_path(path: str) -> str: return str(_fs_path(path)) from hermes_cli.web_routers import git as _git_routes # noqa: E402 app.include_router(_git_routes.router) from hermes_cli.web_routers import local_models as _local_models_routes # noqa: E402 app.include_router(_local_models_routes.router) from hermes_cli.web_routers.git import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. git_status_route, git_worktrees_route, git_branches_route, git_base_branches_route, git_review_list_route, git_review_diff_route, git_file_diff_route, git_commit_context_route, git_rev_parse_route, git_ship_info_route, git_stage_route, git_unstage_route, git_revert_route, git_commit_route, git_push_route, git_create_pr_route, git_worktree_add_route, git_worktree_remove_route, git_branch_switch_route, ) # Host TCP ports each port-binding gateway platform listens on, as # ``platform-name -> (config port key, adapter default)``. Mirrors # ``PORT_BINDING_PLATFORM_VALUES`` in gateway/config.py and each adapter's # DEFAULT_PORT / DEFAULT_WEBHOOK_PORT constant. Used only for the dashboard's # gateway-topology readout — best-effort display data, not a bind source. _PORT_BINDING_PLATFORM_PORTS: Dict[str, Tuple[str, int]] = { "webhook": ("port", 8644), "api_server": ("port", 8642), "msgraph_webhook": ("port", 8646), "feishu": ("webhook_port", 8765), "wecom_callback": ("port", 8645), "bluebubbles": ("webhook_port", 8645), "sms": ("webhook_port", 8080), "whatsapp_cloud": ("webhook_port", 8090), "line": ("port", 8646), "teams": ("port", 3978), } # Platform states that mean the adapter is NOT serving its port right now. _PLATFORM_DEAD_STATES = frozenset({"fatal", "disconnected", "stopped"}) def _profile_platform_ports(profile_home: Path, runtime: Optional[dict]) -> Dict[str, int]: """Best-effort map of ``platform -> host TCP port`` for one profile's gateway. Reads the platforms the running gateway reported in its ``gateway_state.json`` and resolves each port-binding platform's port from the profile's ``config.yaml`` (top-level ``platforms:`` wins over ``gateway.platforms:``, matching ``load_gateway_config`` precedence), falling back to the adapter default. Display-only: env-var port overrides (e.g. ``WEBHOOK_PORT`` in that profile's .env) are not resolved here. """ platforms = (runtime or {}).get("platforms") or {} active = [ name for name, state in platforms.items() if name in _PORT_BINDING_PLATFORM_PORTS and isinstance(state, dict) and state.get("state") not in _PLATFORM_DEAD_STATES ] if not active: return {} blocks: Dict[str, dict] = {} try: # Multi-profile probe: load_config() targets the ACTIVE profile's # home, so read the probed profile's file via the raw primitive. from hermes_cli.config import read_user_config_raw cfg = read_user_config_raw(profile_home / "config.yaml") gateway_cfg = cfg.get("gateway") if isinstance(cfg.get("gateway"), dict) else {} # gateway.platforms first, top-level platforms second — later wins, # matching the precedence in gateway.config.load_gateway_config(). for src in ((gateway_cfg or {}).get("platforms"), cfg.get("platforms")): if not isinstance(src, dict): continue for plat_name, plat_block in src.items(): if isinstance(plat_block, dict): blocks.setdefault(plat_name, {}).update(plat_block) except Exception: blocks = {} ports: Dict[str, int] = {} for name in active: port_key, default_port = _PORT_BINDING_PLATFORM_PORTS[name] block = blocks.get(name) or {} extra = block.get("extra") if isinstance(block.get("extra"), dict) else {} raw = block.get(port_key, (extra or {}).get(port_key, default_port)) try: ports[name] = int(raw) except (TypeError, ValueError): ports[name] = default_port return ports def _profile_gateway_writer_identity( profile_home: Path, runtime: Optional[dict] ) -> Optional[tuple]: """``(pid, start_time)`` identity of the profile's LIVE gateway, or None. Reuses the validated-liveness helper — recorded PID checked against the live process table, the start-time PID-reuse fingerprint, and the profile's home — then reads the live process's fingerprint via the same ``_get_process_start_time`` that stamped it, so equality is exact (no unit or clock-source mismatch). None when the record doesn't belong to a live gateway; nothing in it is current by definition then. """ try: from gateway.status import ( _get_process_start_time, get_runtime_status_running_pid, ) pid = get_runtime_status_running_pid(runtime, expected_home=profile_home) if pid is None: return None start_time = _get_process_start_time(pid) if start_time is None: return None return (pid, start_time) except Exception: return None def _owned_profile_platforms( writer_identity: Optional[tuple], platforms: dict ) -> dict: """Keep only platform entries the profile's CURRENT process wrote. Gateway startup deliberately preserves plain platform entries in ``gateway_state.json`` across restarts (the dashboard keeps showing last-known state while adapters reconnect), and the active-profile endpoint compensates by filtering them against the current configuration. The cross-profile aggregation has no equivalent config context (a profile's platform set depends on tokens in that profile's ``.env`` behind its secret scope), so it demands strict process ownership instead: ``write_runtime_status`` stamps every platform write with the writer's ``(pid, start_time)`` identity, and an entry is aggregatable only when that identity equals the profile's live gateway process — exact match, no clock heuristics, so an entry written moments before a fast restart can never masquerade as current. A fatal entry left behind by a platform the operator has since disabled/removed thus stops degrading fleet health as soon as that profile's gateway restarts (a config change requires that restart to take effect anyway). Fail closed: entries without a writer identity (legacy records) or records with no live process are excluded — aggregation is a supplement, and a false "degraded forever" is the worse failure mode. """ if writer_identity is None: return {} live_pid, live_start = writer_identity owned: Dict[str, dict] = {} for key, value in platforms.items(): if not isinstance(value, dict): continue if ( value.get("writer_pid") == live_pid and value.get("writer_start_time") == live_start ): owned[key] = value return owned def _collect_profile_gateway_topology() -> Dict[str, Any]: """Enumerate profiles and the gateways serving them for ``/api/status``. Returns ``{"profiles": [...], "gateway_mode": ..., "gateways": [...]}``: * ``profiles`` — every profile on the host (default + named), from ``profiles_to_serve(True)`` (the cheap enumeration chokepoint — no per-profile config reads or skill counts). * ``gateways`` — one entry per profile with a LIVE gateway process: ``{"profile", "ports", "served_profiles"?}``. Liveness reuses ``_check_gateway_running`` so this agrees with the profiles sidebar. * ``gateway_mode`` — ``"multiplex"`` when the default gateway serves multiple profiles (gateway.multiplex_profiles), ``"single"`` for one live gateway, ``"multiple"`` for independent per-profile gateways, ``"none"`` when nothing is running. * ``profile_platforms`` — ``{profile: platforms}`` runtime platform maps for each LIVE gateway, ownership-filtered to entries stamped by that profile's current process (stale preserved entries for since-removed platforms are excluded — see ``_owned_profile_platforms``). Internal aggregation input for ``/api/status`` (independent per-profile gateways write failures to their own ``gateway_state.json``, which the unparameterized endpoint would otherwise never see). Never exposed directly. """ try: from hermes_cli.profiles import _check_gateway_running, profiles_to_serve from gateway.status import read_runtime_status homes = profiles_to_serve(True) except Exception: _log.debug("profile/gateway topology enumeration failed", exc_info=True) return { "profiles": [], "gateway_mode": "unknown", "gateways": [], "profile_platforms": {}, } profile_names = [name for name, _home in homes] gateways: List[Dict[str, Any]] = [] profile_platforms: Dict[str, dict] = {} multiplex = False for name, home in homes: try: if not _check_gateway_running(home): continue except Exception: continue try: runtime = read_runtime_status(home / "gateway_state.json") except Exception: runtime = None served = [str(p) for p in ((runtime or {}).get("served_profiles") or [])] if name == "default" and len(served) > 1: multiplex = True plats = (runtime or {}).get("platforms") if isinstance(plats, dict) and plats: # Ownership filter: gateway startup preserves plain platform # entries across restarts, so the raw map can carry fatal state # for platforms the operator has since disabled/removed. Only # entries stamped with the profile's current live process's # writer identity are aggregation candidates (see # _owned_profile_platforms). owned = _owned_profile_platforms( _profile_gateway_writer_identity(home, runtime), plats ) if owned: profile_platforms[name] = owned entry: Dict[str, Any] = { "profile": name, "ports": _profile_platform_ports(home, runtime), } if served: entry["served_profiles"] = served gateways.append(entry) if multiplex: mode = "multiplex" elif len(gateways) > 1: mode = "multiple" elif len(gateways) == 1: mode = "single" else: mode = "none" return { "profiles": profile_names, "gateway_mode": mode, "gateways": gateways, "profile_platforms": profile_platforms, } # /api/status is polled ~1/s by the desktop app while it waits for the backend # (and again by the dashboard badge). Each uncached call above walks 7+ profile # homes (yaml.safe_load with the pure-Python loader + psutil process-table # probes + realpath walks) inside the default executor; concurrent polls pile # up and hold the GIL for 14-16s, starving the event loop — the desktop WS # never receives gateway.ready and boot fails ("event loop stalled ... GIL # pressure suspected"). Topology changes on gateway start/stop, so a short TTL # cache with a collapse lock keeps the scan to one per window. The cache also # remembers which collector produced the entry: tests monkeypatch # _collect_profile_gateway_topology per case, and the identity check keeps # them hermetic without needing a reset hook (a swapped collector is a miss). _TOPOLOGY_CACHE: Dict[str, Any] = {"ts": 0.0, "data": None, "fn": None} _TOPOLOGY_CACHE_LOCK = threading.Lock() _TOPOLOGY_CACHE_TTL = 10.0 # Stable install identity for /api/status. One random opaque id per physical # install, minted on first read and persisted under the ROOT Hermes home # (get_default_hermes_root()) — NOT the profile-scoped HERMES_HOME — so every # profile served by the same install reports the same id. Clients (the desktop # connection registry) use it to recognize that two registered addresses # (hostname + Tailscale IP, LAN + WAN) are one backend and collapse duplicate # roster rows. Privacy: uuid4 hex, no hardware/user-derived material; the only # fact it reveals is "these addresses are the same box", which is the feature. # It must never change across restarts/updates, so reads are cached for the # process lifetime and the file is written once, atomically. _INSTALL_ID_CACHE: Dict[str, Optional[str]] = {"root": None, "value": None} def get_install_id() -> Optional[str]: """Process-lifetime-cached stable install id.""" return _shared_get_install_id(cache=_INSTALL_ID_CACHE) # Serializes read-modify-write cycles over config.yaml for handlers that run # in worker threads (asyncio.to_thread). config.py's _CONFIG_LOCK covers each # load_config()/save_config() call individually, not the span between them — # when these handlers ran on the event loop the loop itself serialized the # whole cycle, but off-loop two concurrent updates could interleave # load→mutate→save and silently drop one another's writes. Held only in # worker threads, so it can never block the event loop. RLock so a locked # section that calls helpers which also take it can't self-deadlock. _CONFIG_MUTATION_LOCK = threading.RLock() def _topology_cache_get(fn: Any) -> Optional[Dict[str, Any]]: if ( _TOPOLOGY_CACHE["data"] is not None and _TOPOLOGY_CACHE["fn"] is fn and time.monotonic() - _TOPOLOGY_CACHE["ts"] < _TOPOLOGY_CACHE_TTL ): return _TOPOLOGY_CACHE["data"] return None def _collect_profile_gateway_topology_cached() -> Dict[str, Any]: fn = _collect_profile_gateway_topology cached = _topology_cache_get(fn) if cached is not None: return cached with _TOPOLOGY_CACHE_LOCK: cached = _topology_cache_get(fn) if cached is not None: return cached data = fn() _TOPOLOGY_CACHE["data"] = data _TOPOLOGY_CACHE["fn"] = fn _TOPOLOGY_CACHE["ts"] = time.monotonic() return data def _load_configured_gateway_platforms() -> set[str]: """Load connected platform names away from the asyncio event loop. The first ``load_gateway_config()`` call performs platform discovery and can take longer than Desktop's WebSocket connect timeout on Windows. This helper is synchronous by design; ``get_status`` runs it in Starlette's worker pool so a concurrent ``/api/ws`` handshake can still complete. """ from gateway.config import load_gateway_config gateway_config = load_gateway_config() return {platform.value for platform in gateway_config.get_connected_platforms()} @app.get("/api/ssh/ownership") async def get_ssh_ownership(request: Request): _require_token(request) if not _SSH_OWNER_NONCE: raise HTTPException(status_code=404, detail="SSH ownership is not active") return { "ok": True, "sshOwnerNonce": _SSH_OWNER_NONCE, "protocolVersion": 1, "runtimeIntact": _ssh_runtime_intact(), } @app.get("/api/health") async def get_health(): """Lightweight process liveness for desktop/backend readiness probes.""" return { "ok": True, "version": __version__, "auth_required": bool(getattr(app.state, "auth_required", False)), } _PROFILE_PLATFORM_STATUS_KEY_RE = re.compile( # Profile segment mirrors hermes_cli.profiles._PROFILE_ID_RE. Platform # segment mirrors the Platform enum's normalized values: built-in members # plus plugin directory names (lowercased), which allow hyphens as well # as underscores (e.g. ``reviewer:foo-bar``). r"^[a-z0-9][a-z0-9_-]{0,63}:[a-z0-9][a-z0-9_-]{0,63}$" ) def _is_profile_platform_status_key(key: object) -> bool: """Accept only the runner's public ``:`` key grammar.""" return isinstance(key, str) and bool(_PROFILE_PLATFORM_STATUS_KEY_RE.fullmatch(key)) def _status_platform_key_allowed( key: object, configured: "set[str] | None" ) -> bool: """Decide whether a runtime-status platform key may appear publicly. Namespaced ``:`` keys are validated against the key grammar *unconditionally* — the config-set load failing must not fail open into projecting arbitrary colon-containing keys from a process-local JSON file onto the public endpoint. Plain platform keys keep the long-standing behavior: checked against the configured set when it loaded, passed through when it did not. """ if not isinstance(key, str): return False if ":" in key: return _is_profile_platform_status_key(key) return configured is None or key in configured # Per-entry writer-identity stamps (added by gateway.status.write_runtime_status # for the aggregation ownership check) are process recon — the same class of # detail as the auth-gated top-level ``gateway_pid`` — and must not project # onto the public endpoint. _PRIVATE_PLATFORM_ENTRY_KEYS = frozenset({"writer_pid", "writer_start_time"}) def _public_platform_entry(value: Any) -> Any: """Strip writer-identity stamps from a platform entry before projection.""" if not isinstance(value, dict): return value return {k: v for k, v in value.items() if k not in _PRIVATE_PLATFORM_ENTRY_KEYS} def _merge_profile_gateway_platforms( gateway_platforms: dict, profile_platforms: dict ) -> dict: """Merge independent per-profile gateway platform states (OOF-3). Hosts that run separate gateway services per profile (``gateway_mode == "multiple"``) persist each profile's platform failures in that profile's own ``gateway_state.json``. The unparameterized ``/api/status`` — the machine-level probe NAS health monitoring reads — only read the active profile's file, so those failures were invisible to fleet health. Fold them in under the same validated ``:`` grammar the multiplex path uses. The active profile's own map is skipped (its entries are already present, including any multiplex-namespaced ones), and existing keys are never overwritten. """ try: from hermes_cli.profiles import get_active_profile_name active = get_active_profile_name() except Exception: active = "default" merged = dict(gateway_platforms) for prof, plats in (profile_platforms or {}).items(): if prof == active or not isinstance(plats, dict): continue for key, value in plats.items(): if not isinstance(key, str) or ":" in key or not isinstance(value, dict): continue namespaced = f"{prof}:{key}" if not _is_profile_platform_status_key(namespaced): continue merged.setdefault(namespaced, _public_platform_entry(value)) return merged @app.get("/api/status") async def get_status(profile: Optional[str] = None): status_scope = None requested_profile = (profile or "").strip() # Plain /api/status stays the machine-level public liveness probe. The # dashboard adds ?profile= when its management switcher targets another # profile, so its gateway badge reflects the selected profile. # # Use the config-only (contextvar) scope, NOT _profile_scope: this handler # awaits the remote-health probe, and _profile_scope swaps process-global # skills-module attributes that a concurrent request would cross-restore # across that await. Status only resolves get_hermes_home() at call time # (config/env/gateway state), which the task-local contextvar covers. profile_dir: Optional[Path] = None if requested_profile and requested_profile.lower() != "current": profile_dir = _resolve_profile_dir(requested_profile) status_scope = _config_profile_scope(requested_profile) status_scope.__enter__() try: current_ver, latest_ver = check_config_version() # --- Gateway liveness detection --- # Delegated to the single shared ladder in gateway.status so this # endpoint and /api/messaging/platforms can never disagree about # whether the gateway is up (they used to: sidebar "running" while # the Channels page rendered "The gateway is not running"). # # When ?profile= was given, scope PID and state reads to that # profile's directory — gateway identity files (PID, lock, runtime # status) are written to the per-profile home, not the process-level # HERMES_HOME (see issue #69143). Plain /api/status keeps the exact # zero-arg call so its behavior (and cache signature) is unchanged. # # The module-level probe references are handed to the resolver so the # long-standing `monkeypatch.setattr(web_server, "get_running_pid_cached", ...)` # seam used across the test-suite still intercepts them. def _bounded_health_probe(): """Health probe with the route's blocking-call budget preserved. The resolver only reaches this rung when the local PID probe came up empty, so the timeout is paid at most once per request and only in the cross-container case that needs it. """ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: future = pool.submit(_probe_gateway_health) try: return future.result(timeout=_GATEWAY_HEALTH_ROUTE_TIMEOUT) except concurrent.futures.TimeoutError: _log.warning( "/api/status gateway health probe exceeded %.2fs; " "using local status", _GATEWAY_HEALTH_ROUTE_TIMEOUT, ) return False, None except Exception: return False, None local_runtime = ( read_runtime_status(path=profile_dir / "gateway_state.json") if profile_dir else read_runtime_status() ) liveness = await run_in_threadpool( lambda: resolve_gateway_liveness( profile_dir=profile_dir, runtime=local_runtime, health_probe=_bounded_health_probe if _GATEWAY_HEALTH_URL else None, pid_probe=get_running_pid_cached, runtime_reader=read_runtime_status, runtime_pid_probe=get_runtime_status_running_pid, ) ) gateway_running = liveness.running gateway_pid = liveness.pid remote_health_body: dict | None = liveness.health_body gateway_state = None gateway_platforms: dict = {} gateway_exit_reason = None gateway_updated_at = None configured_gateway_platforms: set[str] | None = None try: configured_gateway_platforms = await run_in_threadpool( _load_configured_gateway_platforms ) except Exception: configured_gateway_platforms = None # Prefer the detailed health endpoint response (has full state) when the # local runtime status file is absent or stale (cross-container). runtime = local_runtime if runtime is None and remote_health_body and remote_health_body.get("gateway_state"): runtime = remote_health_body if runtime: gateway_state = runtime.get("gateway_state") gateway_platforms = runtime.get("platforms") or {} # Namespaced entries are emitted by configured secondary-profile # adapters. The config set here belongs to the active/default # profile, so suffix-checking against it would incorrectly hide # secondary-only platforms. Colon-containing keys are validated # against the narrow key grammar UNCONDITIONALLY — a failed config # load must not fail open into projecting arbitrary keys from a # process-local JSON file onto this public endpoint. gateway_platforms = { key: _public_platform_entry(value) for key, value in gateway_platforms.items() if _status_platform_key_allowed(key, configured_gateway_platforms) } gateway_exit_reason = runtime.get("exit_reason") # Contract: gateway_updated_at is RFC3339 string | null, never a # number. ``runtime`` here may be the local gateway_state.json # (legacy gateways wrote epoch floats; hand edits can inject # anything) or a remote /health/detailed body — normalize both. gateway_updated_at = normalize_updated_at(runtime.get("updated_at")) if not gateway_running: gateway_state = gateway_state if gateway_state in {"stopped", "startup_failed"} else "stopped" # A cleanly stopped gateway's platform states are stale noise — # clear them so a dead process can't report "connected". But a # startup_failed gateway's FATAL entries are the diagnosis: # they carry per-profile credential collisions and auth # failures (multiplex entries under ``:``) # that the single exit_reason string can't express. Writer # -identity and freshness filtering upstream already dropped # entries from other/older processes, so keeping fatals here # cannot leak another gateway's live state (#80451 follow-up). if gateway_state == "startup_failed": gateway_platforms = { key: value for key, value in gateway_platforms.items() if isinstance(value, dict) and value.get("state") == "fatal" } else: gateway_platforms = {} elif gateway_running and remote_health_body is not None: # The health probe confirmed the gateway is alive, but the local # runtime status file may be stale (cross-container). Override # stopped/None state so the dashboard shows the correct badge. if gateway_state in {None, "stopped"}: gateway_state = "running" # If there was no runtime info at all but the health probe confirmed alive, # ensure we still report the gateway as running (no shared volume scenario). if gateway_running and gateway_state is None and remote_health_body is not None: gateway_state = "running" # Profile + gateway topology (cached, TTL 10s): fetched here — before # the platform rollup — because plain ``/api/status`` is the # machine-level probe NAS reads, and hosts running independent # per-profile gateway services (gateway_mode == "multiple") persist # each profile's platform failures in that profile's own # gateway_state.json. Fold those in under the validated # ``:`` grammar so fleet health sees them (OOF-3). # A ``?profile=`` request targets one profile's view and is left # unmerged. topology = await run_in_threadpool(_collect_profile_gateway_topology_cached) if not requested_profile: gateway_platforms = _merge_profile_gateway_platforms( gateway_platforms, topology.get("profile_platforms") or {} ) active_sessions = await _status_active_sessions() # Busy/drainable readout (NAS lifecycle-safety gate). active_agents is # the in-flight gateway-turn count the gateway now persists at every # turn boundary; gateway_busy/gateway_drainable are derived from it + # liveness via the single shared contract in gateway.status. Liveness # keys off gateway_running (a live PID/health probe), NEVER # gateway_updated_at — a healthy idle gateway never advances that. active_agents = parse_active_agents((runtime or {}).get("active_agents", 0)) gateway_busy = derive_gateway_busy( gateway_running=gateway_running, gateway_state=gateway_state, active_agents=active_agents, ) gateway_drainable = derive_gateway_drainable( gateway_running=gateway_running, gateway_state=gateway_state, ) # Resolved drain timeout (seconds) so NAS can size its poll deadline # without out-of-band knowledge. Offload to a thread: on a cold # Windows install the first import of hermes_cli.gateway blocks the # asyncio event loop for 15-30s (.pyc compilation + Defender scans), # exceeding the desktop handshake's 15s socket timeout. After the # first call the module is in sys.modules and the worker call returns # in microseconds. restart_drain_timeout = await run_in_threadpool(_resolve_restart_drain_timeout) # Dashboard auth gate (Phase 7): surface whether the gate is engaged # and which providers are registered so ``hermes status`` and the # SPA's StatusPage can show "OAuth gate ON via Nous Research" or # "loopback only — no auth gate" with no extra round trips. auth_required = bool(getattr(app.state, "auth_required", False)) auth_providers: list[str] = [] # RFC 8252 native-app capability advertisement. The desktop reads this # to decide whether it can use the system-browser + loopback + PKCE # flow (no embedded webview, no session cookies) or must fall back to # the legacy embedded-webview cookie flow. "cookie" is always available # in gated mode; "native_pkce" is present when at least one interactive # session provider is registered — OAuth providers broker the upstream # IDP round trip, password providers complete interactively at /login # in the system browser (where OS password managers can autofill; an # embedded webview cannot reach them). Token-only credentials (e.g. # drain) don't count. Absent field / missing "native_pkce" ⇒ older # gateway ⇒ desktop falls back automatically. auth_flows: list[str] = [] try: from hermes_cli.dashboard_auth import ( list_providers as _list_providers, list_session_providers as _list_session_providers, ) auth_providers = [p.name for p in _list_providers()] if auth_required: auth_flows.append("cookie") if _list_session_providers(): auth_flows.append("native_pkce") except Exception: # Module not importable yet (early startup) — leave as []. pass # Nous bootstrap-session validity for the NAS health sweep. A hosted # agent whose Nous auth dies terminally (invalid_grant / quarantine) # looks HEALTHY to every liveness/connectivity probe — the machine, # relay, and this dashboard all stay up — yet every inference turn # fails. This is the ONLY signal that surfaces that condition, and it # is determinable with no working token (local auth-store state). NAS # re-mints the bootstrap session when it reads "terminal". Best-effort: # never let auth classification break the public liveness probe. nous_session_valid = "unknown" try: from hermes_cli.auth import get_nous_session_validity nous_session_valid = get_nous_session_validity() except Exception: nous_session_valid = "unknown" # Always-public liveness + auth-gate shape. Safe for external uptime # probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login # bootstrap, and anyone who can curl the host — i.e. exactly the audience # ``PUBLIC_API_PATHS`` documents this endpoint as serving. status = { "version": __version__, "release_date": __release_date__, "config_version": current_ver, "latest_config_version": latest_ver, "can_update_hermes": not _dashboard_local_update_managed_externally(), "gateway_running": gateway_running, "gateway_state": gateway_state, "gateway_platforms": gateway_platforms, "gateway_exit_reason": gateway_exit_reason, "gateway_updated_at": gateway_updated_at, "active_agents": active_agents, "gateway_busy": gateway_busy, "gateway_drainable": gateway_drainable, "restart_drain_timeout": restart_drain_timeout, "active_sessions": active_sessions, "auth_required": auth_required, "auth_providers": auth_providers, "auth_flows": auth_flows, "nous_session_valid": nous_session_valid, } # Stable per-install identity (see get_install_id above). First call # may touch disk, so keep it off the event loop; afterwards it is a # process-global cache hit. Omitted (not null) when unpersistable so # older-client behavior and the no-identity fallback stay identical. install_id = await run_in_threadpool(get_install_id) if install_id: status["install_id"] = install_id # Component-level health rollup. Counts and status enums only — this # payload is public (PUBLIC_API_PATHS), so no messages, paths, or # other detail that could carry secrets. The storage probe reuses the # gateway readiness state_db check (read-only, 1s-bounded) in an # executor so a wedged DB can't stall the event loop. components: Dict[str, Any] = { "gateway": { "status": "ok" if gateway_running and gateway_state in {"running", "draining"} else "degraded", "state": gateway_state or ("running" if gateway_running else "stopped"), }, "dashboard": DASHBOARD_HEALTH.snapshot(), } try: from gateway.readiness import _probe_state_db storage_check = await run_in_threadpool(_probe_state_db, get_hermes_home()) components["storage"] = {"status": storage_check.get("status", "degraded")} except Exception: components["storage"] = {"status": "degraded"} platform_states = [ str(value.get("state") or value.get("status") or "").lower() for value in gateway_platforms.values() if isinstance(value, dict) ] platforms_ok = all( state in {"connected", "running", "ok"} for state in platform_states ) components["platforms"] = { "status": "ok" if platforms_ok else "degraded", "configured": len(gateway_platforms), "connected": sum( 1 for state in platform_states if state in {"connected", "running", "ok"} ), } status["components"] = components status["overall"] = ( "ok" if all(item.get("status") == "ok" for item in components.values()) else "degraded" ) # Memory-pressure rollup (NS-656). Distilled from the gateway's # 30s loop heartbeat + lifecycle sentinel — two small file reads, # no gateway IPC. Coarse MB numbers/enums/booleans only: this # endpoint is public (PUBLIC_API_PATHS), same disclosure class as # nous_session_valid above. Deliberately NOT folded into # components/overall — memory pressure is advisory (toast/notice # material), not a liveness verdict, and flipping `overall` to # "degraded" on it would page NAS's availability sweep for a # condition the valve is already handling. try: from gateway.memory_status import collect_memory_status status["memory"] = await run_in_threadpool( collect_memory_status, profile_dir if profile_dir else get_hermes_home(), ) except Exception: status["memory"] = {"pressure": "unknown"} # Disk-usage rollup (NS-656, same lineage as OOF-2/OOF-107 fleet # disk-exhaustion incidents). One statvfs call on HERMES_HOME's # filesystem — coarse MB numbers + enum, same public disclosure # class as the memory block, and equally advisory: not folded # into components/overall. try: from gateway.disk_status import collect_disk_status status["disk"] = await run_in_threadpool( collect_disk_status, profile_dir if profile_dir else get_hermes_home(), ) except Exception: status["disk"] = {"pressure": "unknown"} # Deferred FTS rebuild progress (schema v23): lets the desktop / # dashboard render a "search index rebuilding: N%" indicator instead # of users wondering why old-message search is slower after an # update. None/absent when no rebuild is pending (the common case). # Read-only probe, never blocks startup, never raises. try: from hermes_state import SessionDB as _SDB from hermes_constants import get_hermes_home as _ghh _db_path = _ghh() / "state.db" if _db_path.exists(): _sdb = _SDB(db_path=_db_path, read_only=True) try: _rebuild = _sdb.fts_rebuild_status() finally: _sdb.close() if _rebuild is not None: status["fts_rebuild"] = _rebuild except Exception: pass # Profile + gateway topology: which profiles exist, whether one # multiplexed gateway or several per-profile gateways serve them, and # (gated) which host ports the live gateways' port-binding platforms # listen on. Enumerating profiles walks the filesystem and probes the # process table, so keep it off the event loop. # # Split by sensitivity: profile NAMES (``profiles``) and the gateway # ``gateway_mode`` are low-sensitivity PRODUCT surface — Hermes Cloud # renders the profile list in the Portal, which reads this endpoint over # the network (a gated bind), so they must survive the auth gate. The # per-gateway ``gateways[]`` detail carries host ports (deployment # recon), so it stays gated with the host paths / PID below. # (``topology`` was already fetched above, before the platform rollup, # so the per-profile platform merge could use it — the TTL cache makes # the earlier fetch the only real scan either way.) status["profiles"] = topology["profiles"] status["gateway_mode"] = topology["gateway_mode"] # Absolute host paths, the gateway PID, the internal gateway health # URL, and per-gateway ports are deployment recon a liveness probe never # needs. ``/api/status`` is in ``PUBLIC_API_PATHS`` so it bypasses # dashboard auth; on a network-exposed (gated) bind that means *any* # unauthenticated caller reaches it, and leaking host metadata there # contradicts the allowlist's own contract ("version, gateway state, # active session count, and the dashboard auth-gate shape. No bodies, no # session content, no secrets"). Surface this detail only on a loopback # / ``--insecure`` bind, where the dashboard is local-only and the # caller is already inside the trust envelope — the same loopback/gated # split ``should_require_auth`` draws. if not auth_required: status.update({ "hermes_home": str(get_hermes_home()), "config_path": str(get_config_path()), "env_path": str(get_env_path()), "gateway_pid": gateway_pid, "gateway_health_url": _GATEWAY_HEALTH_URL, "gateways": topology["gateways"], }) return status finally: if status_scope is not None: status_scope.__exit__(*sys.exc_info()) _WINDOWS_11_MIN_BUILD = 22000 def _windows_build_number(version: str, platform_label: str) -> Optional[int]: """Extract the Windows NT build number from stdlib platform strings.""" for value in (version or "", platform_label or ""): match = re.search(r"(?:^|[^\d])10\.0\.(\d{5,})(?:[^\d]|$)", value) if not match: continue try: return int(match.group(1)) except ValueError: continue return None def _display_system_platform( *, system: str, release: str, version: str, platform_label: str, ) -> Dict[str, str]: """Return host OS fields for display while preserving stdlib detail.""" if system == "Windows" and release == "10": build = _windows_build_number(version, platform_label) if build is not None and build >= _WINDOWS_11_MIN_BUILD: platform_label = re.sub( r"^Windows-10(?=-)", "Windows-11", platform_label, count=1, ) release = "11" return { "os": system, "os_release": release, "os_version": version, "platform": platform_label, } @app.get("/api/system/stats") async def get_system_stats(): """Host + process system stats for the System page. OS / Python / host identity from stdlib; CPU / memory / disk / uptime from psutil when available, with graceful degradation when it isn't. Read-only and non-sensitive (no env values, no paths beyond the hermes home root). """ import platform as _platform info: Dict[str, Any] = { **_display_system_platform( system=_platform.system(), release=_platform.release(), version=_platform.version(), platform_label=_platform.platform(), ), "arch": _platform.machine(), "hostname": _platform.node(), "python_version": _platform.python_version(), "python_impl": _platform.python_implementation(), "hermes_version": __version__, "cpu_count": os.cpu_count(), } # psutil enriches the picture when present; everything below is optional. try: import psutil # type: ignore vm = psutil.virtual_memory() info["memory"] = { "total": vm.total, "available": vm.available, "used": vm.used, "percent": vm.percent, } try: du = psutil.disk_usage(str(get_hermes_home())) info["disk"] = { "total": du.total, "used": du.used, "free": du.free, "percent": du.percent, } except Exception: pass try: info["cpu_percent"] = psutil.cpu_percent(interval=0.1) la = getattr(psutil, "getloadavg", None) if la: info["load_avg"] = list(la()) except Exception: pass try: boot = psutil.boot_time() info["uptime_seconds"] = int(time.time() - boot) except Exception: pass try: proc = psutil.Process() info["process"] = { "pid": proc.pid, "rss": proc.memory_info().rss, "create_time": int(proc.create_time()), "num_threads": proc.num_threads(), } except Exception: pass info["psutil"] = True except Exception: info["psutil"] = False # stdlib-only fallbacks for load average + uptime where the kernel # exposes them. try: info["load_avg"] = list(os.getloadavg()) except (OSError, AttributeError): pass return info # --------------------------------------------------------------------------- # Curator endpoints — background skill-maintenance status + controls. # # The curator periodically reviews skills (archive stale, prune, pin). The # dashboard surfaces its state and the pause/resume/run-now controls that # `hermes curator` exposes. # --------------------------------------------------------------------------- @app.get("/api/curator") async def get_curator_status(): try: from agent import curator except Exception as exc: raise HTTPException(status_code=500, detail=f"Curator unavailable: {exc}") try: state = curator.load_state() except Exception: state = {} return { "enabled": _safe_call(curator, "is_enabled", True), "paused": _safe_call(curator, "is_paused", False), "interval_hours": _safe_call(curator, "get_interval_hours", None), "last_run_at": state.get("last_run_at"), "min_idle_hours": _safe_call(curator, "get_min_idle_hours", None), "stale_after_days": _safe_call(curator, "get_stale_after_days", None), "archive_after_days": _safe_call(curator, "get_archive_after_days", None), } @app.put("/api/curator/paused") async def set_curator_paused(body: CuratorPause): from agent import curator curator.set_paused(bool(body.paused)) return {"ok": True, "paused": bool(body.paused)} @app.post("/api/curator/run") async def run_curator(): """Trigger a curator review now (backgrounded; tail via action status).""" try: proc = _spawn_hermes_action(["curator", "run"], "curator-run") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed to run curator: {exc}") return {"ok": True, "pid": proc.pid, "name": "curator-run"} @app.get("/api/learning/graph") async def get_learning_graph(profile: Optional[str] = None): """Learning graph payload for the desktop panel. Profile-scoped view of learned, non-base skills plus memory chunks, with graph links derived from skill relations and memory-skill overlap. """ def _run(): from agent.learning_graph import build_learning_graph with _profile_scope(profile): return build_learning_graph() try: # _profile_scope takes _SKILLS_PROFILE_LOCK and the graph build reads # skills/memories from disk — keep it off the event loop. return await asyncio.to_thread(_run) except Exception: _log.exception("GET /api/learning/graph failed") raise HTTPException(status_code=500, detail="Failed to build learning graph") @app.get("/api/learning/node") async def get_learning_node(id: str, profile: Optional[str] = None): """Current content of a journey node (skill SKILL.md or memory chunk), for an edit prefill.""" from agent.learning_mutations import node_detail def _run(): with _profile_scope(profile): return node_detail(id) res = await asyncio.to_thread(_run) if not res.get("ok"): raise HTTPException(status_code=404, detail=res.get("message", "not found")) return res @app.delete("/api/learning/node") async def delete_learning_node(body: LearningNodeRef): """Delete a journey node — skills are archived (restorable), memories removed.""" from agent.learning_mutations import delete_node def _run(): with _profile_scope(body.profile): return delete_node(body.id) res = await asyncio.to_thread(_run) if not res.get("ok"): raise HTTPException(status_code=400, detail=res.get("message", "delete failed")) return res @app.put("/api/learning/node") async def update_learning_node(body: LearningNodeEdit): """Rewrite a journey node's content (SKILL.md or memory chunk).""" from agent.learning_mutations import edit_node def _run(): with _profile_scope(body.profile): return edit_node(body.id, body.content) res = await asyncio.to_thread(_run) if not res.get("ok"): raise HTTPException(status_code=400, detail=res.get("message", "edit failed")) return res def _safe_call(mod, fn_name: str, default): try: fn = getattr(mod, fn_name, None) return fn() if callable(fn) else default except Exception: return default # --------------------------------------------------------------------------- # Portal endpoint — Nous Portal auth + Tool Gateway routing status (read-only). # --------------------------------------------------------------------------- @app.get("/api/portal") async def get_portal_status(): # load_config() + auth/subscription snapshots are disk reads — this is a # polled endpoint, so keep them off the event loop. def _run(): return _get_portal_status_sync() return await asyncio.to_thread(_run) def _get_portal_status_sync(): cfg = load_config() or {} auth: Dict[str, Any] = {} try: from hermes_cli.auth import get_nous_auth_status_local # Read-only dashboard endpoint: refresh-free snapshot so polling # never performs an OAuth refresh or burns a refresh token. auth = get_nous_auth_status_local() or {} except Exception: auth = {} features = [] try: from hermes_cli.nous_subscription import get_nous_subscription_features feats = get_nous_subscription_features(cfg) if feats is not None: for feat in feats.items(): if getattr(feat, "managed_by_nous", False): state = "via Nous Portal" elif getattr(feat, "active", False) and getattr(feat, "current_provider", None): state = feat.current_provider elif getattr(feat, "active", False): state = "active" else: state = "not configured" features.append({"label": getattr(feat, "label", ""), "state": state}) except Exception: _log.exception("portal features failed") model_cfg = cfg.get("model") if isinstance(cfg.get("model"), dict) else {} return { "logged_in": bool(auth.get("logged_in")), "portal_url": auth.get("portal_base_url"), "inference_url": auth.get("inference_base_url"), "provider": str((model_cfg or {}).get("provider") or ""), "subscription_url": "https://portal.nousresearch.com/manage-subscription", "features": features, } # --------------------------------------------------------------------------- # Diagnostics: prompt-size, support dump, debug upload, config migrate. # All produce text output, so they spawn background actions tailed via # /api/actions//status. # --------------------------------------------------------------------------- @app.post("/api/ops/prompt-size") async def run_prompt_size(): try: proc = _spawn_hermes_action(["prompt-size"], "prompt-size") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "prompt-size"} @app.post("/api/ops/dump") async def run_dump(): try: proc = _spawn_hermes_action(["dump"], "dump") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "dump"} @app.post("/api/ops/config-migrate") async def run_config_migrate(): try: proc = _spawn_hermes_action(["config", "migrate"], "config-migrate") except Exception as exc: raise HTTPException(status_code=500, detail=f"Failed: {exc}") return {"ok": True, "pid": proc.pid, "name": "config-migrate"} @app.post("/api/ops/debug-share") async def run_debug_share_endpoint(body: DebugShareRequest | None = None): """Upload a redacted debug report + full logs and return the paste URLs. Unlike the other diagnostics actions (doctor, dump, prompt-size) this is *synchronous*: the whole point of ``debug share`` is the set of shareable URLs it produces, so we run the upload in a worker thread and return the structured ``{urls, failures, redacted, ...}`` payload directly. The dashboard renders those as real, copyable links instead of scraping a log tail. Pastes auto-delete after 6 hours (handled inside the share core). """ from hermes_cli.debug import build_debug_share req = body or DebugShareRequest() try: result = await asyncio.to_thread( build_debug_share, log_lines=max(1, min(int(req.lines), 5000)), redact=bool(req.redact), ) except RuntimeError as exc: # Required summary-report upload failed (offline / paste service down). raise HTTPException(status_code=502, detail=f"Upload failed: {exc}") except Exception as exc: _log.exception("debug share failed") raise HTTPException(status_code=500, detail=f"Failed: {exc}") return { "ok": True, "urls": result.urls, "failures": result.failures, "redacted": result.redacted, "auto_delete_seconds": result.auto_delete_seconds, } # --------------------------------------------------------------------------- # Gateway + update actions (invoked from the Status page). # # Both commands are spawned as detached subprocesses so the HTTP request # returns immediately. stdin is closed (``DEVNULL``) so any stray ``input()`` # calls fail fast with EOF rather than hanging forever. stdout/stderr are # streamed to a per-action log file under ``~/.hermes/logs/.log`` so # the dashboard can tail them back to the user. # --------------------------------------------------------------------------- _ACTION_LOG_DIR: Path = get_hermes_home() / "logs" _ACTION_LOG_TAIL_MAX_BYTES = 256 * 1024 _ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES = 8 * 1024 _ACTION_LOG_TAIL_MAX_CHUNK_BYTES = 64 * 1024 # Short ``name`` (from the URL) → absolute log file path. _ACTION_LOG_FILES: Dict[str, str] = { "gateway-restart": "gateway-restart.log", "gateway-start": "gateway-start.log", "gateway-stop": "gateway-stop.log", "hermes-update": "hermes-update.log", "doctor": "action-doctor.log", "security-audit": "action-security-audit.log", "backup": "action-backup.log", "import": "action-import.log", "checkpoints-prune": "action-checkpoints-prune.log", "skills-install": "action-skills-install.log", "skills-uninstall": "action-skills-uninstall.log", "skills-update": "action-skills-update.log", "curator-run": "action-curator-run.log", "prompt-size": "action-prompt-size.log", "dump": "action-dump.log", "config-migrate": "action-config-migrate.log", "tools-post-setup": "action-tools-post-setup.log", } # ``name`` → most recently spawned Popen handle. Used so ``status`` can # report liveness and exit code without shelling out to ``ps``. _ACTION_PROCS: Dict[str, subprocess.Popen] = {} _ACTION_COMMANDS: Dict[str, Tuple[str, ...]] = {} _ACTION_IDS: Dict[str, str] = {} # A finished ``gateway-restart`` child does not mean the gateway is back: the # child exits as soon as it has handed the restart to the supervisor (or to the # running gateway), while the gateway itself is still stopping and coming up. # The in-flight reuse in :func:`_spawn_gateway_restart` therefore stops # coalescing exactly when repeat requests do the most damage, so a stale cached # frontend that re-fires its restart every few seconds gets a brand new restart # every time (#89034: 77 restarts, 17 of them inside one minute, killing the # gateway often enough mid-FTS5-write to corrupt state.db). Suppress repeats # for a short window after the last spawn as well. # # MAINTAINER DECISION: a fixed window, not "until the gateway reports healthy". # Health-gating is what #89034 asks for, but it cannot be made to fail safe # here — a gateway that never comes back would leave the restart action # permanently inert, which is a worse failure than the flood it prevents. A # fixed window always releases. 10s is above the ~3.5s spacing of the reported # storm and below the time an operator waits before deliberately retrying. GATEWAY_RESTART_COOLDOWN_SECONDS = 10.0 # ``(monotonic spawn time, Popen, command)`` for the last gateway restart this # process started. Deliberately NOT read out of ``_ACTION_PROCS``: entries # there are reaped once the child exits, and a guard that disappears when the # child exits is the bug this exists to fix. _LAST_GATEWAY_RESTART: Optional[Tuple[float, subprocess.Popen, Tuple[str, ...]]] = None _UPDATE_ACTION_COMPLETED_RE = re.compile( r"^=== hermes-update completed ([0-9a-f]{32}) ===$" ) # ``name`` → completed synthetic action result for actions the server handled # without spawning a subprocess (for example, unsupported Docker updates). _ACTION_RESULTS: Dict[str, Dict[str, Any]] = {} def _terminate_desktop_managed_gateway() -> None: """Stop a live gateway restart child when its Desktop backend shuts down.""" proc = _ACTION_PROCS.get("gateway-restart") if proc is None: return try: if proc.poll() is None: proc.terminate() except OSError: # The child may have exited between poll() and terminate(). pass def _record_completed_action(name: str, message: str, exit_code: int = 1) -> None: """Record a non-spawned action result and write it to the action log.""" log_file_name = _ACTION_LOG_FILES[name] _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = _ACTION_LOG_DIR / log_file_name with open(log_path, "ab", buffering=0) as log_file: log_file.write( f"\n=== {name} completed {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) log_file.write(message.encode("utf-8", errors="replace")) if not message.endswith("\n"): log_file.write(b"\n") _ACTION_PROCS.pop(name, None) _ACTION_COMMANDS.pop(name, None) _ACTION_IDS.pop(name, None) _ACTION_RESULTS[name] = {"exit_code": exit_code, "pid": None} def _dashboard_spawn_executable() -> str: """Interpreter for detached dashboard actions. Prefers the install's own venv interpreter over ``sys.executable`` when they differ. Under an SSH remote backend the web server is launched by running the **uv base interpreter** with the venv's site-packages injected into ``sys.path`` at startup (``-c "sys.path[:0]=[...]; runpy.run_module('hermes_cli.main', ...)"``) — so ``sys.executable`` is the dependency-less base python and a detached action spawned from it dies on the first third-party import (``ModuleNotFoundError: yaml``), because the injected path is a startup artifact of the parent and is not inherited (#90026). The venv launcher resolves the same dependency set on its own. Falls back to ``sys.executable`` when no venv interpreter exists next to the install (in-process dev runs, exotic layouts). On Windows the spawn below carries ``windows_detach_flags()`` (CREATE_NO_WINDOW), so the console python owns a single hidden console that its own subprocess spawns inherit — the action stays invisible without resorting to console-less pythonw.exe, which would make every console-subsystem descendant flash its own conhost (#54220/#56747). """ exe = Path(sys.executable) try: for rel in ("venv/bin/python", "venv/Scripts/python.exe"): candidate = PROJECT_ROOT / rel if candidate.is_file(): # Same interpreter → keep sys.executable (preserves the # docstring's console-ownership behavior verbatim). Compare # UNRESOLVED normalized paths: a venv's bin/python is # typically a SYMLINK to the base interpreter, so resolving # both sides makes the venv python and the dependency-less # base compare equal — exactly the SSH-runtime case this # function exists to fix. The unresolved path IS the venv's # identity (pyvenv.cfg discovery keys off argv0's location). if os.path.normcase(os.path.normpath(str(candidate))) == ( os.path.normcase(os.path.normpath(str(exe))) ): return sys.executable # Return the candidate UNRESOLVED for the same reason: # invoking the resolved target would bypass pyvenv.cfg and # run the bare base interpreter again. return str(candidate) except OSError: pass return sys.executable def _spawn_hermes_action( subcommand: List[str], name: str, *, env_overrides: Optional[Dict[str, str]] = None, ) -> subprocess.Popen: """Spawn ``hermes `` detached and record the Popen handle. Uses the running interpreter's ``hermes_cli.main`` module so the action inherits the same venv/PYTHONPATH the web server is using. """ log_file_name = _ACTION_LOG_FILES[name] _ACTION_LOG_DIR.mkdir(parents=True, exist_ok=True) log_path = _ACTION_LOG_DIR / log_file_name log_file = open(log_path, "ab", buffering=0) log_file.write( f"\n=== {name} started {time.strftime('%Y-%m-%d %H:%M:%S')} ===\n".encode() ) cmd = [_dashboard_spawn_executable(), "-m", "hermes_cli.main", *subcommand] # The dashboard runs *inside* the gateway process, so os.environ carries # _HERMES_GATEWAY=1. Inheriting it makes a spawned `hermes gateway restart` # trip the in-process restart-loop guard and exit 1 — silently failing the # dashboard's auto-restart paths. The gateway's own restart watcher already # drops it (gateway/run.py); mirror that here (#52470). action_env = {**os.environ, "HERMES_NONINTERACTIVE": "1"} action_env.pop("_HERMES_GATEWAY", None) popen_kwargs: Dict[str, Any] = { "cwd": str(PROJECT_ROOT), "stdin": subprocess.DEVNULL, "stdout": log_file, "stderr": subprocess.STDOUT, "env": {**action_env, **(env_overrides or {})}, } if sys.platform == "win32": popen_kwargs["creationflags"] = windows_detach_flags() else: popen_kwargs["start_new_session"] = True proc = subprocess.Popen(cmd, **popen_kwargs) # The child inherits its own duplicated fd for stdout/stderr, so the # parent's handle can be released immediately — otherwise we leak one # fd per spawned action. log_file.close() _ACTION_RESULTS.pop(name, None) _ACTION_COMMANDS[name] = tuple(subcommand) _ACTION_PROCS[name] = proc action_id = (env_overrides or {}).get("HERMES_ACTION_ID") if action_id: _ACTION_IDS[name] = action_id else: _ACTION_IDS.pop(name, None) return proc def _tail_lines(path: Path, n: int) -> List[str]: """Return the last ``n`` lines of ``path`` without loading huge logs.""" if n <= 0 or not path.exists(): return [] try: size = path.stat().st_size except OSError: return [] if size <= 0: return [] min_offset = max(0, size - _ACTION_LOG_TAIL_MAX_BYTES) offset = size chunk_size = _ACTION_LOG_TAIL_INITIAL_CHUNK_BYTES newline_count = 0 chunks: List[bytes] = [] drop_partial_first_line = False try: with path.open("rb") as handle: while offset > min_offset and newline_count <= n: read_size = min(chunk_size, offset - min_offset) offset -= read_size handle.seek(offset) chunk = handle.read(read_size) chunks.append(chunk) newline_count += chunk.count(b"\n") chunk_size = min( chunk_size * 2, _ACTION_LOG_TAIL_MAX_CHUNK_BYTES, ) if offset > 0: handle.seek(offset - 1) drop_partial_first_line = handle.read(1) != b"\n" except OSError: return [] lines = ( b"".join(reversed(chunks)) .decode("utf-8", errors="replace") .splitlines() ) if drop_partial_first_line and lines: lines = lines[1:] return lines[-n:] def _durable_completed_update_action_id(lines: List[str]) -> Optional[str]: """Recover the latest successful update identity from ``update.log``. The dashboard action process can restart the dashboard that spawned it. That loses the in-memory ``Popen``/result registries while the durable update log survives. Only accept a completion marker that occurs after the latest update-start marker, so a stale success cannot mask a newer failed attempt. """ last_start = -1 last_completed = -1 completed_action_id: Optional[str] = None for index, line in enumerate(lines): if line.startswith("=== hermes update started "): last_start = index match = _UPDATE_ACTION_COMPLETED_RE.fullmatch(line.strip()) if match: last_completed = index completed_action_id = match.group(1) if completed_action_id and last_completed > last_start: return completed_action_id return None def _gateway_subcommand(profile: Optional[str], verb: str) -> List[str]: return _profile_cli_args(profile) + ["gateway", verb] def _gateway_display_command(profile: Optional[str], verb: str) -> str: return " ".join(["hermes", *_gateway_subcommand(profile, verb)]) # Kept in sync with the corresponding frontend validation in ChannelsPage.tsx. _TELEGRAM_BOT_TOKEN_RE = re.compile(r"\d+:[A-Za-z0-9_-]{30,}") _TELEGRAM_USER_ID_RE = re.compile(r"\d+") _SLACK_MEMBER_ID_RE = re.compile(r"[UW][A-Z0-9]{2,}") def _validate_messaging_env_value(platform_id: str, key: str, value: str) -> None: """Reject platform credentials that are clearly in the wrong field.""" if not value: return if platform_id == "telegram": if key == "TELEGRAM_BOT_TOKEN" and not _TELEGRAM_BOT_TOKEN_RE.fullmatch(value): raise HTTPException( status_code=400, detail="Telegram bot token must be the complete token from @BotFather, such as 123456789:ABC…", ) if key == "TELEGRAM_ALLOWED_USERS": user_ids = [part.strip() for part in value.split(",") if part.strip()] if any(not _TELEGRAM_USER_ID_RE.fullmatch(user_id) for user_id in user_ids): raise HTTPException( status_code=400, detail="Telegram allowed users must be comma-separated numeric user IDs.", ) return if platform_id != "slack": return if key == "SLACK_BOT_TOKEN" and not value.startswith("xoxb-"): raise HTTPException( status_code=400, detail="Slack Bot Token must start with xoxb-. Paste the bot token from OAuth & Permissions.", ) if key == "SLACK_APP_TOKEN" and not value.startswith("xapp-"): raise HTTPException( status_code=400, detail="Slack App Token must start with xapp-. Paste the app-level token from Basic Information > App-Level Tokens.", ) if key == "SLACK_ALLOWED_USERS": # Mirror the gateway's parse (gateway/platforms/slack.py): split on comma, # strip, and drop empty entries so a trailing/interior comma isn't rejected # here when the runtime would accept it. "*" is the allow-all wildcard. user_ids = [part.strip() for part in value.split(",") if part.strip()] invalid = [ user_id for user_id in user_ids if user_id != "*" and not _SLACK_MEMBER_ID_RE.fullmatch(user_id) ] if invalid: raise HTTPException( status_code=400, detail="Slack allowed user IDs must be comma-separated member IDs like U01ABC2DEF3.", ) def _spawn_gateway_restart(profile: Optional[str] = None) -> Tuple[subprocess.Popen, bool]: """Spawn ``hermes gateway restart``, reusing an in-flight restart. Multiple dashboard paths can request a restart in quick succession (restart button double-click, or a stale cached frontend firing its own restart after the server already auto-restarted post-onboarding). Two concurrent ``hermes gateway restart`` children race each other on the manual kill-and-start path, so reuse the live one instead. Reusing only the *live* child is not enough. The child exits as soon as the restart has been handed off, long before the gateway is back, so a frontend re-firing every few seconds cleared that guard every time and kept restarting a gateway that was still coming up (#89034). Requests within ``GATEWAY_RESTART_COOLDOWN_SECONDS`` of the last spawn for the same profile are coalesced onto that spawn as well. Before spawning, sweep for orphaned gateway processes whose parent has exited (e.g. desktop-app restarts leaving a reparented gateway child under launchd/PPID=1). Without this the orphan keeps its platform connection alive and the fresh gateway stacks a duplicate (#77276). Returns ``(proc, reused)``. """ # Reap orphaned gateways before spawning a new one (#77276). try: from hermes_cli.gateway import _reap_unsupervised_gateway_orphans _reap_unsupervised_gateway_orphans() except Exception: pass # best-effort — don't block the restart on a reap failure global _LAST_GATEWAY_RESTART subcommand = _gateway_subcommand(profile, "restart") existing = _ACTION_PROCS.get("gateway-restart") if existing is not None and existing.poll() is None: existing_command = _ACTION_COMMANDS.get("gateway-restart") if existing_command is None or existing_command == tuple(subcommand): return existing, True raise RuntimeError("gateway restart already in progress for another profile") recent = _LAST_GATEWAY_RESTART if recent is not None: spawned_at, recent_proc, recent_command = recent age = time.monotonic() - spawned_at if recent_command == tuple(subcommand) else None if age is not None and age < GATEWAY_RESTART_COOLDOWN_SECONDS: _log.info( "Coalescing gateway restart: one was started %.1fs ago " "(pid %s) and the gateway may still be coming back; not " "spawning another (#89034).", age, getattr(recent_proc, "pid", "?"), ) return recent_proc, True proc = _spawn_hermes_action(subcommand, "gateway-restart") _LAST_GATEWAY_RESTART = (time.monotonic(), proc, tuple(subcommand)) return proc, False def _restart_gateway_after_webhook_enable(profile: Optional[str] = None) -> dict[str, Any]: """Best-effort gateway restart after enabling the webhook platform.""" try: proc, reused = _spawn_gateway_restart(profile) except Exception as exc: _log.exception("Failed to auto-restart gateway after enabling webhooks") return { "restart_started": False, "restart_error": str(exc), } if reused: _log.info( "Webhook enable: reusing in-flight gateway restart (pid %s)", proc.pid, ) return { "restart_started": True, "restart_action": "gateway-restart", "restart_pid": proc.pid, } from hermes_cli.web_routers import actions as _actions_routes # noqa: E402 app.include_router(_actions_routes.router) from hermes_cli.web_routers import audio as _audio_routes # noqa: E402 app.include_router(_audio_routes.router) from hermes_cli.web_routers.audio import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. speak_text, ) # Collapses repeated identical ElevenLabs voice-list failures (the desktop # re-polls on every settings open/focus) to a single log line. Re-arms on # success or when the error signature changes, so a real new failure is seen. _voice_list_last_error: Optional[str] = None def _voice_list_error_logged_once(signature: Optional[str]) -> bool: """Return True if ``signature`` is new and should be logged now. Passing ``None`` clears the latch (call on success). Idempotent per signature: the same error logs once until it changes. """ global _voice_list_last_error if signature is None: _voice_list_last_error = None return False if signature == _voice_list_last_error: return False _voice_list_last_error = signature return True def _split_text_for_speak_stream(text: str, cap: int) -> list: """Split *text* into provider-cap-sized pieces on sentence boundaries. Deliberately NOT unified with gateway.platforms.helpers' split_text_fence_aware: this splitter reflows whitespace (sentences are re-joined with single spaces) and has no fence/markdown semantics, so expressing it as knobs on the fence-aware core would change behavior. """ from tools.tts_streaming import SENTENCE_BOUNDARY_RE as _SENTENCE_BOUNDARY_RE cap = cap if cap and cap > 0 else 4000 pieces, buf = [], "" for sentence in filter(str.strip, _SENTENCE_BOUNDARY_RE.split(text)): while len(sentence) > cap: pieces.append(sentence[:cap]) sentence = sentence[cap:] if buf and len(buf) + len(sentence) + 1 > cap: pieces.append(buf) buf = sentence else: buf = f"{buf} {sentence}" if buf else sentence if buf: pieces.append(buf) return pieces app.include_router(_actions_routes.status_router) # Per-row fields that no session LIST consumer reads but that dominate the # payload. ``system_prompt`` is the fully rendered prompt — tens of KB per # row — and made a 21-row /api/sessions response 528KB (96% dead weight), # re-fetched by the desktop sidebar on every refresh. The desktop's # SessionInfo type doesn't declare either field and the web UI never touches # them; ``GET /api/sessions/{id}`` detail reads stay complete. List callers # that genuinely need the full rows can pass ``?full=1``. _SESSION_LIST_HEAVY_FIELDS = ("system_prompt", "model_config") def _strip_session_list_rows(sessions: List[Dict[str, Any]]) -> List[Dict[str, Any]]: for s in sessions: for key in _SESSION_LIST_HEAVY_FIELDS: s.pop(key, None) return sessions from hermes_cli.web_routers import sessions as _sessions_routes # noqa: E402 app.include_router(_sessions_routes.list_router) from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_sessions, ) from hermes_cli.web_routers import profiles as _profiles_routes # noqa: E402 app.include_router(_profiles_routes.sessions_router) from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_profiles_sessions, get_profiles_sessions_sidebar, ) app.include_router(_sessions_routes.search_router) from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. search_sessions, ) def _normalize_config_for_web(config: Dict[str, Any]) -> Dict[str, Any]: """Normalize config for the web UI. Hermes supports ``model`` as either a bare string (``"anthropic/claude-sonnet-4"``) or a dict (``{default: ..., provider: ..., base_url: ...}``). The schema is built from DEFAULT_CONFIG where ``model`` is a string, but user configs often have the dict form. Normalize to the string form so the frontend schema matches. Also surfaces ``model_context_length`` as a top-level field so the web UI can display and edit it. A value of 0 means "auto-detect". """ config = dict(config) # shallow copy model_val = config.get("model") if isinstance(model_val, dict): # Extract context_length before flattening the dict ctx_len = model_val.get("context_length", 0) config["model"] = model_val.get("default", model_val.get("name", "")) config["model_context_length"] = ctx_len if isinstance(ctx_len, int) else 0 else: config["model_context_length"] = 0 return config # ── Memory provider config: one generic GET/PUT pair, dispatching on storage ── def _provider_field_entry(field: ProviderField) -> Dict[str, Any]: """Static, storage-independent shape of one field for the UI payload.""" return { "key": field.key, "label": field.label, "kind": field.kind, "description": field.description, "info": field.info, "placeholder": field.placeholder, "inline": field.inline, "group": field.group, "options": [ {"value": opt.value, "label": opt.label, "description": opt.description} for opt in field.options ], } # Sentinel: remove this key so it falls back to the host or built-in default. _UNSET: Any = object() def _coerce_field_value(field: ProviderField, raw: str) -> Any: """Coerce a submitted non-secret value to its native JSON type. Values arrive as strings over the API; this converts them to the type the Honcho resolver expects (bool/number/list/dict), so e.g. a boolean is stored as a JSON ``false`` rather than the string ``"false"`` (which would read as truthy). Returns ``_UNSET`` when the field should be removed. Raises ``ValueError`` on malformed input. """ value = (raw or "").strip() kind = field.kind if kind == "select": if not value: value = field.default if value not in field.allowed_values(): raise ValueError(f"Invalid value for '{field.key}'") return value if kind == "bool": from utils import is_truthy_value return is_truthy_value(value) if kind == "number": if not value: return _UNSET try: number = float(value) except ValueError as exc: raise ValueError(f"Invalid number for '{field.key}'") from exc return int(number) if number.is_integer() else number if kind == "json": if not value: return _UNSET try: parsed = json.loads(value) except (ValueError, TypeError) as exc: raise ValueError(f"Invalid JSON for '{field.key}'") from exc if not isinstance(parsed, (dict, list)): raise ValueError(f"'{field.key}' must be a JSON object or array") return parsed # text / secret — blank clears the key so it falls back to host/default. return value if value else _UNSET def _serialize_field_value(field: ProviderField, value: Any) -> str: """Render a stored native value as the string the generic UI edits. ``None`` (key absent) yields the field's declared default. Bools become ``"true"``/``"false"``, JSON objects/arrays are re-encoded, numbers are stringified — so the renderer's per-kind controls always get the shape they expect regardless of how the value sits on disk. """ if value is None: return field.default if field.kind == "bool": from utils import is_truthy_value return "true" if is_truthy_value(value) else "false" if field.kind == "json": if isinstance(value, (dict, list)): return json.dumps(value) return str(value) return str(value) # — flat-json backend (default; reusable for simple providers) — def _flat_json_path(provider: ProviderConfigSchema) -> Path: return get_hermes_home() / provider.name / "config.json" def _read_flat_json(provider: ProviderConfigSchema) -> Dict[str, Any]: path = _flat_json_path(provider) if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: _log.warning("Failed to read memory provider config from %s", path, exc_info=True) return {} return data if isinstance(data, dict) else {} def _read_field(field: ProviderField, sources: tuple, env: Dict[str, str]) -> Any: """Return the stored native value from the first source holding it, or ``None``. Presence (``key in source``) decides, not truthiness, so a stored ``False`` or ``0`` survives instead of being mistaken for "unset". """ for source in sources: for source_key in (field.key, *field.aliases): if source_key in source and source[source_key] is not None: return source[source_key] for env_key in field.env_fallbacks: value = env.get(env_key) if value: return value return None def _declared_field_is_set(field: ProviderField, sources: tuple, env: Dict[str, str]) -> bool: for env_key in (field.env_key, *field.env_fallbacks): if env_key and env.get(env_key): return True return any(source.get(k) for source in sources for k in (field.key, *field.aliases)) # — honcho host-block backend — def _honcho_resolvers(): """Lazily import the Honcho plugin's resolvers (optional plugin).""" from plugins.memory.honcho.client import _host_block, resolve_active_host, resolve_config_path return resolve_active_host, resolve_config_path, _host_block def _honcho_read_sources() -> tuple[Dict[str, Any], str, Dict[str, Any]]: """Return (root config, active host key, host block) for the current profile.""" resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers() host = resolve_active_host() path = resolve_config_path() raw: Dict[str, Any] = {} if path.exists(): try: loaded = json.loads(path.read_text(encoding="utf-8")) raw = loaded if isinstance(loaded, dict) else {} except Exception: _log.warning("Failed to read Honcho config from %s", path, exc_info=True) return raw, host, host_block_of(raw, host) def _declared_provider_payload(provider: ProviderConfigSchema) -> Dict[str, Any]: fields: List[Dict[str, Any]] = [] env = load_env() is_honcho = provider.storage == STORAGE_HONCHO_HOST_BLOCK if is_honcho: raw, host, host_block = _honcho_read_sources() def sources_for(field: ProviderField) -> tuple: return (host_block, raw) if field.scope == "host" else (raw,) else: host = "" data = _read_flat_json(provider) def sources_for(field: ProviderField) -> tuple: return (data,) for field in provider.fields: entry = _provider_field_entry(field) sources = sources_for(field) if field.is_secret: entry["value"] = "" # secrets are write-only over the API entry["is_set"] = _declared_field_is_set(field, sources, env) fields.append(entry) continue native = _read_field(field, sources, env) if is_honcho and not field.placeholder and field.key in {"workspace", "aiPeer"}: # Blank fields surface the resolved host Honcho will actually use. entry["placeholder"] = host value = _serialize_field_value(field, native) if field.kind == "select" and value not in field.allowed_values(): value = field.default entry["value"] = value # Presence, not truthiness — a stored False/0 is still "set". entry["is_set"] = native is not None if is_honcho else bool(value) fields.append(entry) return {"name": provider.name, "label": provider.label, "docs_url": provider.docs_url, "fields": fields} def _apply_field_values(provider: ProviderConfigSchema, values: Dict[str, str], target_for) -> None: """Apply submitted non-secret fields to their backend dict, in place. Only keys present in ``values`` are touched, so a partial save never clobbers fields owned by another surface. ``_UNSET`` clears the key (and its aliases) so it falls back to the host/default mapping. """ for field in provider.fields: if field.is_secret or field.key not in values: continue target = target_for(field) coerced = _coerce_field_value(field, values[field.key]) if coerced is _UNSET: target.pop(field.key, None) for alias in field.aliases: target.pop(alias, None) else: target[field.key] = coerced def _write_provider_flat(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: from utils import atomic_json_write existing = _read_flat_json(provider) for field in provider.fields: if field.is_secret: submitted = (values.get(field.key) or "").strip() if submitted and field.env_key: save_env_value(field.env_key, submitted) _apply_field_values(provider, values, lambda field: existing) path = _flat_json_path(provider) path.parent.mkdir(parents=True, exist_ok=True) atomic_json_write(path, existing, mode=0o600) def _write_provider_honcho(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: """Persist submitted fields to Honcho's real config for the active host. Only keys present in ``values`` are touched, so a partial save (e.g. the inline panel) never clobbers fields owned by the full-config editor. Blank text clears a key so it falls back to the host/default mapping. """ from plugins.memory.honcho.oauth import ACCESS_TOKEN_PREFIX, _config_refresh_lock from utils import atomic_json_write resolve_active_host, resolve_config_path, host_block_of = _honcho_resolvers() host = resolve_active_host() # Write the file reads resolve, or a save shadows it with a sparse copy. path = resolve_config_path() # OAuth rotation is single-use; an unlocked RMW here can revoke the grant. with _config_refresh_lock(path): cfg: Dict[str, Any] = {} if path.exists(): try: loaded = json.loads(path.read_text(encoding="utf-8")) cfg = loaded if isinstance(loaded, dict) else {} except Exception: _log.warning("Failed to read Honcho config from %s", path, exc_info=True) hosts = cfg.get("hosts") cfg["hosts"] = hosts = hosts if isinstance(hosts, dict) else {} # Update the block reads resolve (legacy dot-form included), never shadow it. existing = host_block_of(cfg, host) host_key = next((k for k, v in hosts.items() if v is existing), host) if existing else host host_block = hosts.setdefault(host_key, existing) for field in provider.fields: if not field.is_secret: continue submitted = (values.get(field.key) or "").strip() if not submitted: continue if field.env_key: save_env_value(field.env_key, submitted) # Persist where the client reads first; an OAuth token owns that slot. stored = host_block.get(field.key) if not (isinstance(stored, str) and stored.startswith(ACCESS_TOKEN_PREFIX)): host_block[field.key] = submitted _apply_field_values(provider, values, lambda field: host_block if field.scope == "host" else cfg) path.parent.mkdir(parents=True, exist_ok=True) atomic_json_write(path, cfg, mode=0o600) def _stringify_submitted_values(values: Dict[str, Any]) -> Dict[str, str]: """The declared-schema path edits strings; the dashboard may send natives.""" out: Dict[str, str] = {} for key, value in values.items(): if value is None: out[key] = "" elif isinstance(value, str): out[key] = value elif isinstance(value, bool): out[key] = "true" if value else "false" elif isinstance(value, (dict, list)): out[key] = json.dumps(value) else: out[key] = str(value) return out def _update_memory_provider_config(provider: ProviderConfigSchema, values: Dict[str, str]) -> None: if provider.storage == STORAGE_HONCHO_HOST_BLOCK: _write_provider_honcho(provider, values) else: _write_provider_flat(provider, values) config = load_config() memory_config = config.get("memory") if not isinstance(memory_config, dict): memory_config = {} config["memory"] = memory_config if memory_config.get("provider") != provider.name: memory_config["provider"] = provider.name save_config(config) def _memory_provider_label(name: str) -> str: return name.replace("_", " ").replace("-", " ").title() def _normalize_memory_provider_name(name: Any) -> str: provider = str(name or "").strip() if provider.lower() in {"built-in", "builtin", "none"}: return "" return provider def _load_memory_provider(name: str): try: from plugins.memory import load_memory_provider return load_memory_provider(name) except Exception: _log.debug("Failed to load memory provider %s", name, exc_info=True) return None def _memory_provider_manifest(name: str) -> Dict[str, Any]: try: from plugins.memory import find_provider_dir provider_dir = find_provider_dir(name) if provider_dir is None: return {} manifest_path = provider_dir / "plugin.yaml" if not manifest_path.exists(): return {} with manifest_path.open(encoding="utf-8-sig") as handle: manifest = yaml.safe_load(handle) or {} return manifest if isinstance(manifest, dict) else {} except Exception: _log.debug("Failed to read memory provider manifest for %s", name, exc_info=True) return {} def _string_list(value: Any) -> List[str]: if not isinstance(value, list): return [] return [str(item).strip() for item in value if str(item).strip()] def _memory_provider_setup_manifest(name: str) -> Dict[str, Any]: manifest = _memory_provider_manifest(name) external_dependencies: List[Dict[str, str]] = [] for raw in manifest.get("external_dependencies") or []: if not isinstance(raw, dict): continue dep = { "name": str(raw.get("name") or "").strip(), "install": str(raw.get("install") or "").strip(), "check": str(raw.get("check") or "").strip(), } if dep["name"] or dep["install"] or dep["check"]: external_dependencies.append(dep) return { "pip_dependencies": _string_list(manifest.get("pip_dependencies")), "external_dependencies": external_dependencies, "required_env": _string_list(manifest.get("requires_env")), } def _memory_provider_setup_info(name: str) -> Dict[str, Any]: setup = _memory_provider_setup_manifest(name) setup["dependencies_installed"] = _memory_provider_dependencies_installed(setup) return setup _MEMORY_PROVIDER_IMPORT_NAMES = { "honcho-ai": "honcho", "mem0ai": "mem0", "hindsight-client": "hindsight_client", "hindsight-all": "hindsight", } def _memory_provider_dependency_package(dep: str) -> str: return re.split(r"[\[<>=!~;]", dep, maxsplit=1)[0].strip() def _memory_provider_import_name(dep: str) -> str: package = _memory_provider_dependency_package(dep) return _MEMORY_PROVIDER_IMPORT_NAMES.get(package, package.replace("-", "_")) def _dependency_importable(dep: str) -> bool: import_name = _memory_provider_import_name(dep) if not import_name: return False try: __import__(import_name) return True except ImportError: return False def _trim_setup_output(value: Optional[str], limit: int = 4000) -> str: text = str(value or "") if len(text) <= limit: return text return f"{text[:limit]}\n... truncated ..." def _memory_provider_setup_env() -> Dict[str, str]: # External package-manager child (npm/uv/pip): exact env preservation — # scrubbing or HOME rewriting could break user tool auth/config. from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=False) home = Path.home() extra_bins = [ home / ".brv-cli" / "bin", home / ".local" / "bin", home / ".npm-global" / "bin", Path("/usr/local/bin"), ] existing_path = env.get("PATH", "") prefix = os.pathsep.join(str(path) for path in extra_bins if path.exists()) if prefix: env["PATH"] = prefix + os.pathsep + existing_path return env def _command_result( *, kind: str, name: str, status: str, command: str = "", completed: Optional[subprocess.CompletedProcess] = None, error: Optional[str] = None, ) -> Dict[str, Any]: return { "kind": kind, "name": name, "status": status, "command": command, "returncode": None if completed is None else completed.returncode, "stdout": "" if completed is None else _trim_setup_output(completed.stdout), "stderr": _trim_setup_output(error or ("" if completed is None else completed.stderr)), } def _run_setup_command( command: Any, *, display: str, shell: bool = False, timeout: int = 180, ) -> subprocess.CompletedProcess: return subprocess.run( command, shell=shell, executable="/bin/bash" if shell else None, env=_memory_provider_setup_env(), capture_output=True, text=True, # Lossy UTF-8 decode — setup tools emit UTF-8; never let a # locale-mismatched byte raise in the reader thread (#52649). encoding="utf-8", errors="replace", timeout=timeout, check=False, ) def _memory_provider_dependencies_installed(setup: Dict[str, Any]) -> bool: pip_dependencies = _string_list(setup.get("pip_dependencies")) external_dependencies = setup.get("external_dependencies") or [] pip_ok = all(_dependency_importable(dep) for dep in pip_dependencies) external_ok = True for dep in external_dependencies: if not isinstance(dep, dict): continue check_cmd = str(dep.get("check") or "").strip() install_cmd = str(dep.get("install") or "").strip() if not check_cmd: if install_cmd: external_ok = False continue try: completed = _run_setup_command( shlex.split(check_cmd), display=check_cmd, timeout=20, ) except Exception: external_ok = False continue if completed.returncode != 0: external_ok = False return pip_ok and external_ok def _install_memory_provider_pip_dependencies(dependencies: List[str]) -> List[Dict[str, Any]]: missing = [dep for dep in dependencies if not _dependency_importable(dep)] if not dependencies: return [] if not missing: return [ _command_result(kind="pip", name=", ".join(dependencies), status="already_installed") ] # Route through the lazy-install pipeline (tools.lazy_deps.install_specs) # instead of shelling out to pip against sys.executable directly. That # pipeline is environment-aware: on hosted/immutable images the agent venv # under /opt/hermes is sealed read-only, and installs must be redirected # to the writable durable target on the data volume # (HERMES_LAZY_INSTALL_TARGET, e.g. /opt/data/lazy-packages) — the same # path every lazy backend already uses. A direct `pip install --python # sys.executable` on those images fails with a permission error (NS-605). # install_specs also activates the target on sys.path post-install so the # availability recheck below sees the new packages without a restart. try: from tools.lazy_deps import install_specs outcome = install_specs(missing, timeout=240) except Exception as exc: return [ _command_result( kind="pip", name=", ".join(missing), status="failed", error=str(exc), ) ] if outcome.blocked: return [ _command_result( kind="pip", name=", ".join(missing), status="failed", command=outcome.command, error=outcome.reason, ) ] return [ _command_result( kind="pip", name=", ".join(missing), status="installed" if outcome.ok else "failed", command=outcome.command, completed=subprocess.CompletedProcess( args=outcome.command, returncode=0 if outcome.ok else 1, stdout=outcome.stdout, stderr=outcome.stderr, ), ) ] from hermes_cli.web_routers import memory_providers as _memory_providers_routes # noqa: E402 app.include_router(_memory_providers_routes.router) def _normalize_memory_provider_schema(name: str, provider: Any) -> List[Dict[str, Any]]: raw_schema: List[Dict[str, Any]] = [] if provider is not None and hasattr(provider, "get_config_schema"): try: raw = provider.get_config_schema() if isinstance(raw, list): raw_schema = [field for field in raw if isinstance(field, dict)] except Exception: _log.warning("Failed to read memory provider schema for %s", name, exc_info=True) fields: List[Dict[str, Any]] = [] for raw in raw_schema: key = str(raw.get("key") or "").strip() if not key: continue choices = raw.get("choices") or raw.get("options") or [] if not isinstance(choices, list): choices = [] explicit_kind = str(raw.get("kind") or raw.get("type") or "").strip().lower() if raw.get("secret"): kind = "secret" elif choices: kind = "select" elif explicit_kind in {"bool", "boolean"} or isinstance(raw.get("default"), bool): kind = "boolean" elif explicit_kind in {"int", "integer"} or ( isinstance(raw.get("default"), int) and not isinstance(raw.get("default"), bool) ): kind = "integer" elif explicit_kind in {"float", "number"} or isinstance(raw.get("default"), float): kind = "number" else: kind = "text" options = [] for choice in choices: value = str(choice) options.append({"value": value, "label": value, "description": ""}) description = str(raw.get("description") or "") fields.append({ "key": key, "label": str(raw.get("label") or key.replace("_", " ").title()), "kind": kind, "description": description, "placeholder": str(raw.get("placeholder") or ""), "required": bool(raw.get("required", False)), "default": raw.get("default", ""), "options": options, "url": str(raw.get("url") or ""), "when": raw.get("when") if isinstance(raw.get("when"), dict) else None, "minimum": raw.get("minimum"), "maximum": raw.get("maximum"), "step": raw.get("step"), "_env_key": str(raw.get("env_var") or "") or None, }) return fields def _read_json_file(path: Path) -> Dict[str, Any]: if not path.exists(): return {} try: data = json.loads(path.read_text(encoding="utf-8")) except Exception: _log.debug("Failed to read JSON config from %s", path, exc_info=True) return {} return data if isinstance(data, dict) else {} def _read_memory_provider_existing_values(name: str) -> Dict[str, Any]: """Best-effort read of existing provider config across legacy/native stores.""" hermes_home = get_hermes_home() values: Dict[str, Any] = {} # Common native provider stores. for path in ( hermes_home / f"{name}.json", hermes_home / name / "config.json", ): values.update(_read_json_file(path)) try: cfg = load_config() except Exception: cfg = {} memory_cfg = cfg.get("memory") if isinstance(cfg, dict) else {} if isinstance(memory_cfg, dict): provider_cfg = memory_cfg.get(name) if isinstance(provider_cfg, dict): values.update(provider_cfg) legacy_cfg = memory_cfg.get("provider_config") if isinstance(legacy_cfg, dict): values = {**legacy_cfg, **values} # Holographic stores under plugins.hermes-memory-store. plugins_cfg = cfg.get("plugins") if isinstance(cfg, dict) else {} if name == "holographic" and isinstance(plugins_cfg, dict): holographic_cfg = plugins_cfg.get("hermes-memory-store") if isinstance(holographic_cfg, dict): values.update(holographic_cfg) return values def _env_lookup(env_key: Optional[str]) -> str: if not env_key: return "" env_on_disk = load_env() return str(env_on_disk.get(env_key) or os.environ.get(env_key) or "") def _coerce_bool(value: Any, *, default: bool = False) -> bool: if isinstance(value, bool): return value if value is None or value == "": return default if isinstance(value, (int, float)): return bool(value) text = str(value).strip().lower() if text in {"1", "true", "yes", "on"}: return True if text in {"0", "false", "no", "off"}: return False raise ValueError(f"Invalid boolean value: {value}") def _field_default(field: Dict[str, Any]) -> Any: default = field.get("default", "") if field["kind"] == "boolean": return _coerce_bool(default, default=False) return default def _field_value(field: Dict[str, Any], data: Dict[str, Any]) -> Any: if field["kind"] == "secret": return "" value = data.get(field["key"]) if value in (None, ""): value = _env_lookup(field.get("_env_key")) if value in (None, ""): value = _field_default(field) if field["kind"] == "select": allowed = {opt["value"] for opt in field.get("options", [])} value = str(value) return value if value in allowed else str(_field_default(field)) if field["kind"] == "boolean": return _coerce_bool(value, default=_coerce_bool(_field_default(field), default=False)) return str(value) def _field_is_set(field: Dict[str, Any], data: Dict[str, Any]) -> bool: if field["kind"] == "secret": return bool(_env_lookup(field.get("_env_key")) or data.get(field["key"])) value = _field_value(field, data) return value not in (None, "") def _field_visible( field: Dict[str, Any], data: Dict[str, Any], fields_by_key: Optional[Dict[str, Dict[str, Any]]] = None, ) -> bool: when = field.get("when") if not isinstance(when, dict) or not when: return True for dep_key, expected in when.items(): dep_field = (fields_by_key or {}).get(str(dep_key)) or { "key": str(dep_key), "kind": "text", "default": "", "_env_key": None, } actual = _field_value(dep_field, data) if str(actual) != str(expected): return False return True def _memory_provider_is_configured(name: str, provider: Any) -> bool: data = _read_memory_provider_existing_values(name) fields = _normalize_memory_provider_schema(name, provider) fields_by_key = {field["key"]: field for field in fields} visible_fields = [ field for field in fields if _field_visible(field, data, fields_by_key) ] required_fields = [field for field in visible_fields if field.get("required")] if not required_fields: return True return all(_field_is_set(field, data) for field in required_fields) def _discover_memory_provider_statuses() -> List[Dict[str, Any]]: discovered: Dict[str, Dict[str, Any]] = {} try: from plugins.memory import discover_memory_providers for name, description, available in discover_memory_providers(): discovered[str(name)] = { "name": str(name), "description": str(description or ""), "available": bool(available), "missing": False, } except Exception: _log.exception("discover_memory_providers failed") cfg = load_config() active = "" mem = cfg.get("memory") if isinstance(mem, dict): active = _normalize_memory_provider_name(mem.get("provider")) if active and active not in discovered: discovered[active] = { "name": active, "description": "Configured provider was not found.", "available": False, "missing": True, } providers: List[Dict[str, Any]] = [] for name in sorted(discovered): row = discovered[name] provider = None if row["missing"] else _load_memory_provider(name) setup = _memory_provider_setup_info(name) configured = False if row["missing"] else _memory_provider_is_configured(name, provider) schema_fields = [] if row["missing"] else _normalize_memory_provider_schema(name, provider) if row["missing"]: status = "missing" elif not row["available"] and not setup.get("dependencies_installed", True): status = "unavailable" elif not configured: status = "needs_config" elif not row["available"] and schema_fields: status = "needs_config" elif not row["available"]: status = "unavailable" else: status = "ready" providers.append({ "name": name, "description": row["description"], "available": row["available"], "configured": configured, "status": status, "setup": setup, }) return providers def _require_memory_provider_ready(name: str) -> None: if not name: return statuses = {row["name"]: row for row in _discover_memory_provider_statuses()} row = statuses.get(name) if row is None: raise HTTPException( status_code=400, detail=f"Unknown memory provider '{name}'.", ) if row["status"] != "ready": raise HTTPException( status_code=400, detail=( f"Memory provider '{name}' is not ready " f"({row['status'].replace('_', ' ')}). Configure it in the dashboard first." ), ) from hermes_cli.web_routers import config_env as _config_env_routes # noqa: E402 app.include_router(_config_env_routes.config_router) from hermes_cli.web_routers.config_env import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_config, get_schema, ) _EMPTY_MODEL_INFO: dict = { "model": "", "provider": "", "auto_context_length": 0, "config_context_length": 0, "effective_context_length": 0, "capabilities": {}, } from hermes_cli.web_routers import models as _models_routes # noqa: E402 app.include_router(_models_routes.router) from hermes_cli.web_routers.models import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_model_info, get_model_options, get_recommended_default_model, set_moa_models, ) # --------------------------------------------------------------------------- # Model assignment — pick provider+model for main slot or auxiliary slots. # Mirrors the model.options JSON-RPC from tui_gateway but uses REST so the # Models page (which has no chat PTY open) can drive it. # --------------------------------------------------------------------------- # Canonical auxiliary task slots. Keep in sync with DEFAULT_CONFIG["auxiliary"] # in hermes_cli/config.py — listed here for deterministic ordering in the UI. _AUX_TASK_SLOTS: Tuple[str, ...] = ( "vision", "compression", "skills_hub", "approval", "mcp", "title_generation", "review", "triage_specifier", "kanban_decomposer", "profile_describer", "curator", ) def _dashboard_code_skew_guard() -> Optional[str]: """Return a clear \"restart required\" message when this process runs stale code. The dashboard and Desktop-owned ``hermes serve`` are long-lived; their ``sys.modules`` is frozen at boot. When ``hermes update`` (or a manual ``git pull``) replaces the checkout underneath them, a first-time lazy import on a new code path can resolve a freshly-pulled consumer module against a stale cached dependency -> ImportError — e.g. ``/api/model/options`` 500 after the update added ``agent.model_metadata.is_grok_46_family`` while the running process kept serving the pre-update module (#86207). Mirror the gateway's ``_model_switch_skew_guard``: refuse the risky call with an actionable, deployment-aware message instead of crashing with a cryptic import error (#97046). Returns None when no drift is detectable (fresh process, or a non-git install where the boot fingerprint could not be read — never a false positive). """ from gateway.code_skew import detect_code_skew skew = detect_code_skew() if not skew: return None boot_rev, disk_rev = skew return ( f"This process is running code from {boot_rev} but the checkout on " f"disk is now {disk_rev}. The model picker would risk a stale-module " f"crash — {_dashboard_skew_restart_hint()}" ) def _dashboard_skew_restart_hint() -> str: """Restart advice that matches how this process is actually owned. The same FastAPI app backs the browser dashboard *and* Desktop-owned ``hermes serve --isolated`` (local or SSH). Hardcoding a systemd unit misleads macOS/launchd hosts and Desktop SSH backends, which have no ``hermes-dashboard`` unit (#97046). """ if os.environ.get("HERMES_SERVE_HEADLESS") == "1": return ( "restart the Desktop-owned backend to load the new code " "(use Restart backend in Hermes Desktop, or quit and reopen the app)" ) return ( "restart this Hermes process to load the new code " "(hermes dashboard --port , or the equivalent service restart for this install)" ) def _apply_model_assignment_sync( scope: str, provider: str, model: str, task: str, base_url: str, api_key: str = "" ): """Synchronous body of POST /api/model/set. Runs inside ``_profile_scope`` (in a worker thread) so every load_config/save_config lands in the requested profile. Raises HTTPException for validation errors — the async wrapper re-raises them. """ cfg = load_config() if scope == "main": if not provider or not model: raise HTTPException(status_code=400, detail="provider and model required for main") provider, model = _normalize_main_model_assignment(provider, model) providers_cfg = cfg.get("providers") provider_entry = providers_cfg.get(provider) if isinstance(providers_cfg, dict) else None if not base_url and isinstance(provider_entry, dict) and provider_entry.get("base_url"): base_url = str(provider_entry.get("base_url") or "").strip() model_cfg = _apply_main_model_assignment( cfg.get("model", {}), provider, model, base_url, api_key ) _raw_assign_entry = None try: _stored, _raw_assign_entry = find_provider_entry( read_raw_config().get("providers"), provider ) except Exception: _raw_assign_entry = None _assign_key_env = ( str(_raw_assign_entry.get("key_env") or "").strip() if isinstance(_raw_assign_entry, dict) else "" ) if _assign_key_env: # #88990: carry the credential POINTER, never a resolved secret. model_cfg["key_env"] = _assign_key_env model_cfg.pop("api_key", None) elif isinstance(provider_entry, dict) and provider_entry.get("api_key"): # #88990: provider_entry comes from load_config(), which expands # ${VAR} env refs to plaintext. Copying that resolved value into # model.api_key writes the SECRET into config.yaml (and recreates # it on every re-apply, even after the user deletes it by hand). # Prefer the raw ${VAR} template; only fall back to the expanded # value when the raw yaml itself stores the key as a literal (no # new exposure in that case). _raw_key = ( str(_raw_assign_entry.get("api_key") or "").strip() if isinstance(_raw_assign_entry, dict) else "" ) if _raw_key.startswith("${") and _raw_key.endswith("}"): model_cfg["api_key"] = _raw_key else: model_cfg["api_key"] = provider_entry["api_key"] cfg["model"] = model_cfg # When switching the main provider to Nous, mirror the CLI's # post-model-selection behaviour (hermes_cli/main.py # prompt_enable_tool_gateway / tools_config apply_nous_managed_defaults): # auto-route any *unconfigured* tools through the Nous Tool Gateway. # This is purely additive — apply_nous_managed_defaults skips every # tool where the user already has a direct key (FIRECRAWL_API_KEY, # FAL_KEY, etc.) or an explicit backend/provider in config, so it # never overwrites a user's own setup. GUI users thus land on the # gateway the same way CLI users do, without a separate prompt. gateway_tools: list[str] = [] if provider.strip().lower() == "nous": try: from hermes_cli.nous_subscription import apply_nous_managed_defaults from hermes_cli.tools_config import _get_platform_tools enabled = _get_platform_tools( cfg, "cli", include_default_mcp_servers=False ) changed = apply_nous_managed_defaults( cfg, enabled_toolsets=enabled, force_fresh=True, ) gateway_tools = sorted(changed) except Exception: # Portal lookup hiccups / non-subscriber / non-nous gating # must never block saving the model assignment. _log.debug("apply_nous_managed_defaults skipped", exc_info=True) save_config(cfg) # Register a named ``custom_providers`` entry for a custom/local # endpoint, mirroring the ``hermes model`` custom flow # (_save_custom_provider). Without this the endpoint only lives in # ``model.*`` and the picker has no proper ready row for it — the # GUI then surfaces a "needs setup" dead-end on the bare ``custom`` # provider. Dedups by base_url, so re-saving is idempotent. if provider.strip().lower() in {"custom", "local"} and base_url: try: from hermes_cli.main import _auto_provider_name, _save_custom_provider _save_custom_provider( base_url, api_key, model, name=_auto_provider_name(base_url), ) except Exception: # Never block the assignment on the bookkeeping write — # model.* is already persisted and routable. _log.debug("custom_providers registration skipped", exc_info=True) # Surface auxiliary slots still pinned to a *different* provider than # the new main one. Switching the main model does NOT touch aux pins # (they're independent, sticky per-task overrides — see # auxiliary_client._resolve_auto). A user who switches main away from # a now-unpaid provider (e.g. nous with $0 balance) keeps paying 402s # on every background aux call until they reset those pins. We never # auto-clear them — pinning aux to a cheaper/different model is a # legitimate config — but we tell the caller so the UI can offer a # "reset to main" nudge instead of silently burning credits. new_provider = provider.strip().lower() stale_aux: list[dict] = [] aux_cfg = cfg.get("auxiliary", {}) if isinstance(aux_cfg, dict): for slot in _AUX_TASK_SLOTS: slot_cfg = aux_cfg.get(slot) if not isinstance(slot_cfg, dict): continue slot_provider = str(slot_cfg.get("provider", "") or "").strip() if ( slot_provider and slot_provider.lower() not in {"auto", ""} and slot_provider.lower() != new_provider ): stale_aux.append({ "task": slot, "provider": slot_provider, "model": str(slot_cfg.get("model", "") or ""), }) try: effective_config = load_config() effective_provider, effective_model = resolve_cron_model_drift_defaults( effective_config ) cron_model_impact = build_cron_model_impact( current_provider=effective_provider or provider, current_model=effective_model or model, config=effective_config, ) except Exception: _log.debug("cron model impact inspection failed", exc_info=True) cron_model_impact = build_cron_model_impact(config=cfg, jobs={}) return { "ok": True, "scope": "main", "provider": provider, "model": model, "base_url": model_cfg.get("base_url", ""), "gateway_tools": gateway_tools, "stale_aux": stale_aux, "cron_model_impact": cron_model_impact, } # scope == "auxiliary" aux = cfg.get("auxiliary") if not isinstance(aux, dict): aux = {} if task == "__reset__": # Reset every slot to provider="auto", model="" — keeps other fields intact. for slot in _AUX_TASK_SLOTS: slot_cfg = aux.get(slot) if not isinstance(slot_cfg, dict): slot_cfg = {} slot_cfg["provider"] = "auto" slot_cfg["model"] = "" slot_cfg.pop("base_url", None) clear_model_endpoint_credentials(slot_cfg) aux[slot] = slot_cfg cfg["auxiliary"] = aux save_config(cfg) return {"ok": True, "scope": "auxiliary", "reset": True} if not provider: raise HTTPException(status_code=400, detail="provider required for auxiliary") targets = [task] if task else list(_AUX_TASK_SLOTS) for slot in targets: if slot not in _AUX_TASK_SLOTS: raise HTTPException(status_code=400, detail=f"unknown auxiliary task: {slot}") slot_cfg = aux.get(slot) if not isinstance(slot_cfg, dict): slot_cfg = {} prev_provider = str(slot_cfg.get("provider") or "").strip().lower() new_provider = provider.strip().lower() slot_cfg["provider"] = provider slot_cfg["model"] = model if base_url: # Sibling of the main-slot endpoint handling (#65254): an aux # assignment for a custom/local endpoint must carry its own # base_url, or the slot silently rebinds to whatever # model.base_url happens to hold — and breaks entirely once the # main slot switches away and clears it. The auxiliary resolver # already reads auxiliary..base_url/api_key # (_resolve_task_provider_model), so persisting them here is # what actually wires the endpoint in. slot_cfg["base_url"] = base_url if api_key: slot_cfg["api_key"] = api_key elif new_provider != prev_provider and new_provider != "custom": slot_cfg.pop("base_url", None) clear_model_endpoint_credentials(slot_cfg) aux[slot] = slot_cfg cfg["auxiliary"] = aux save_config(cfg) return { "ok": True, "scope": "auxiliary", "tasks": targets, "provider": provider, "model": model, } def _infer_provider_on_model_change(model_val: str, prev_provider: str) -> tuple[str, str]: """Infer which provider serves ``model_val`` when the flat Config-page Model field changes, given the previously-saved ``prev_provider``. Returns ``(provider, model)``; ``provider`` is empty when no switch is warranted (leave the existing provider untouched). Two signals, in order: 1. Curated-catalog detection (``detect_provider_for_model``) — handles the ~28 OpenRouter-curated models and direct provider-static catalogs. 2. Vendor-slug heuristic — a ``vendor/model`` slug cannot belong to a single-model / non-aggregator provider (e.g. ``ollama-local``). When the current provider is not an aggregator that serves vendor-prefixed slugs, route to an aggregator. ``_normalize_main_model_assignment`` (called by the caller) keeps the user's current aggregator when they're already on one, else falls back to openrouter — the same chokepoint logic as ``POST /api/model/set``. """ name = (model_val or "").strip() if not name: return "", name try: from hermes_cli.models import ( _AGGREGATOR_PROVIDERS, detect_provider_for_model, normalize_provider, ) except Exception: return "", name try: detected = detect_provider_for_model(name, prev_provider) except Exception: detected = None if detected: return detected[0], detected[1] # Vendor-prefixed slug under a non-aggregator provider → reassign. Use a # sentinel "openrouter" here; _normalize_main_model_assignment resolves the # real aggregator (keeps a current aggregator, else openrouter). if "/" in name: try: cur_is_aggregator = normalize_provider(prev_provider) in _AGGREGATOR_PROVIDERS except Exception: cur_is_aggregator = False if not cur_is_aggregator: return "openrouter", name return "", name def _denormalize_config_from_web(config: Dict[str, Any]) -> Dict[str, Any]: """Reverse _normalize_config_for_web before saving. Reconstructs ``model`` as a dict by reading the current on-disk config to recover model subkeys (provider, base_url, api_mode, etc.) that were stripped from the GET response. The frontend only sees model as a flat string; the rest is preserved transparently. Also handles ``model_context_length`` — writes it back into the model dict as ``context_length``. A value of 0 means "auto-detect" (omitted from the dict so get_model_context_length() uses its normal resolution). ``config`` may be a partial update (e.g. the Settings autosave diff) that omits ``model_context_length`` entirely when the user didn't touch it — that must leave the on-disk override untouched, not get treated the same as an explicit 0 and cleared. """ config = dict(config) # Remove any _model_meta that might have leaked in (shouldn't happen # with the stripped GET response, but be defensive) config.pop("_model_meta", None) # Extract and remove model_context_length before processing model, but # remember whether it was actually present: a partial update omitting the # key means "unchanged", which is different from an explicit 0. ctx_sent = "model_context_length" in config ctx_override = config.pop("model_context_length", 0) if not isinstance(ctx_override, int): try: ctx_override = int(ctx_override) except (TypeError, ValueError): ctx_override = 0 model_val = config.get("model") if (isinstance(model_val, str) and model_val) or ctx_sent: # Read the current disk config to recover model subkeys try: disk_config = load_config() disk_model = disk_config.get("model") if isinstance(disk_model, dict): if isinstance(model_val, str) and model_val: prev_default = str(disk_model.get("default") or "").strip() prev_provider = str(disk_model.get("provider") or "").strip() # When the model name actually changed, re-detect which # provider serves it. The Config-page Model field is a flat # string with no provider info, so without this a user who # picks an OpenRouter model while their default provider is # ollama-local keeps the stale provider and 404s. Only fires # on a real model change so saving unrelated config fields # never overwrites an explicit provider. if model_val != prev_default and prev_provider: new_provider, resolved_model = _infer_provider_on_model_change( model_val, prev_provider ) if new_provider and new_provider.strip().lower() != prev_provider.lower(): # Route through the canonical assignment chokepoints so # the model is normalized for the new provider and stale # base_url/api_mode/api_key are cleared on the switch # (and preserved on a same-provider re-pick). norm_provider, norm_model = _normalize_main_model_assignment( new_provider, resolved_model ) disk_model = _apply_main_model_assignment( disk_model, norm_provider, norm_model ) model_val = norm_model # Preserve all subkeys, update default with the new value disk_model["default"] = model_val # Write context_length into the model dict (0 = remove/auto), # but only when the payload actually carried the key. if ctx_sent: if ctx_override > 0: disk_model["context_length"] = ctx_override else: disk_model.pop("context_length", None) config["model"] = disk_model # Model was previously a bare string (or absent) — upgrade to a # dict if the user is setting a context_length override. elif ctx_sent and ctx_override > 0: if isinstance(model_val, str) and model_val: default = model_val elif isinstance(disk_model, str) and disk_model: default = disk_model else: default = "" config["model"] = { "default": default, "context_length": ctx_override, } except Exception: pass # can't read disk config — just use the string form return config app.include_router(_config_env_routes.router) def _is_other_profile(profile: Optional[str]) -> bool: """True when ``profile`` names a profile other than this process's own.""" requested = (profile or "").strip() if not requested or requested.lower() == "current": return False try: target = _resolve_profile_dir(requested) except HTTPException: return True return target.resolve() != get_process_hermes_home().resolve() def _approval_mode_of(config: Dict[str, Any]) -> str: """Normalize approvals.mode from an in-memory config document. Both sides of the broadcast comparison use in-memory documents (the raw on-disk dict and the about-to-be-saved dict): re-reading through the config cache after a save can serve the pre-save document when the replacement file collides on the (mtime_ns, size) cache key, which would suppress the broadcast exactly when the mode changed. Absent block or key normalizes to the same default the approval gate uses. """ from tools.approval import _normalize_approval_mode approvals = config.get("approvals") default_mode = (DEFAULT_CONFIG.get("approvals") or {}).get("mode", "manual") mode = approvals.get("mode", default_mode) if isinstance(approvals, dict) else default_mode return _normalize_approval_mode(mode) def _broadcast_gateway_session_info() -> None: """Broadcast session.info on the in-process gateway when it's loaded. ``sys.modules`` guard, not an import: gateway never imported means no live sessions in this process to notify. """ server = sys.modules.get("tui_gateway.server") if server is None: return try: server.broadcast_session_info() except Exception: _log.exception("session.info broadcast after config save failed") def _parse_model_ids(resp: "Any") -> List[str]: """Extract model ids from an OpenAI-compatible ``/v1/models`` response. Tolerant of the common shapes: ``{"data": [{"id": ...}]}`` (OpenAI / vLLM / llama.cpp) and a bare ``{"data": ["id", ...]}``. Returns ``[]`` on any parse/HTTP error so a slightly non-standard endpoint never hard-blocks. """ try: if not resp.is_success: return [] payload = resp.json() except Exception: return [] data = payload.get("data") if isinstance(payload, dict) else payload if not isinstance(data, list): return [] ids: List[str] = [] for item in data: if isinstance(item, dict): mid = str(item.get("id") or "").strip() else: mid = str(item or "").strip() if mid: ids.append(mid) return ids # Entries omit fields they don't need to override; the catalog builder fills # in env_vars from OPTIONAL_ENV_VARS via prefix matching when not specified, # and pulls required_env from a plugin's PlatformEntry when available. _PLATFORM_OVERRIDES: dict[str, dict[str, Any]] = { "telegram": { "name": "Telegram", "description": "Run Hermes from Telegram DMs, groups, and topics.", "docs_url": "https://core.telegram.org/bots/features#botfather", "env_vars": ("TELEGRAM_BOT_TOKEN", "TELEGRAM_ALLOWED_USERS", "TELEGRAM_PROXY"), "required_env": ("TELEGRAM_BOT_TOKEN",), }, "discord": { "name": "Discord", "description": "Connect Hermes to Discord DMs, channels, and threads.", "docs_url": "https://discord.com/developers/applications", "env_vars": ( "DISCORD_BOT_TOKEN", "DISCORD_ALLOWED_USERS", ), "required_env": ("DISCORD_BOT_TOKEN",), }, "slack": { "name": "Slack", "description": "Use Hermes from Slack via Socket Mode. Add allowed Slack member IDs so connected bots can respond.", "docs_url": "https://api.slack.com/apps", "env_vars": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_ALLOWED_USERS"), "required_env": ("SLACK_BOT_TOKEN", "SLACK_APP_TOKEN"), }, "mattermost": { "name": "Mattermost", "description": "Connect Hermes to Mattermost channels and direct messages.", "docs_url": "https://mattermost.com/deploy/", "env_vars": ("MATTERMOST_URL", "MATTERMOST_TOKEN", "MATTERMOST_ALLOWED_USERS"), "required_env": ("MATTERMOST_URL", "MATTERMOST_TOKEN"), }, "matrix": { "name": "Matrix", "description": "Use Hermes in Matrix rooms and direct messages.", "docs_url": "https://matrix.org/ecosystem/servers/", "env_vars": ( "MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID", "MATRIX_ALLOWED_USERS", ), "required_env": ("MATRIX_HOMESERVER", "MATRIX_ACCESS_TOKEN", "MATRIX_USER_ID"), }, "signal": { "name": "Signal", "description": "Connect through a signal-cli REST bridge.", "docs_url": "https://github.com/bbernhard/signal-cli-rest-api", "env_vars": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT", "SIGNAL_ALLOWED_USERS"), "required_env": ("SIGNAL_HTTP_URL", "SIGNAL_ACCOUNT"), }, "whatsapp": { "name": "WhatsApp", "description": "Use Hermes through the bundled WhatsApp bridge with QR-based auth.", "docs_url": "https://github.com/tulir/whatsmeow", "env_vars": ( "WHATSAPP_ENABLED", "WHATSAPP_MODE", "WHATSAPP_DM_POLICY", "WHATSAPP_ALLOWED_USERS", ), "required_env": (), }, "homeassistant": { "name": "Home Assistant", "description": "Control your smart home from Hermes via Home Assistant.", "docs_url": "https://www.home-assistant.io/docs/authentication/", "env_vars": ("HASS_URL", "HASS_TOKEN"), "required_env": ("HASS_URL", "HASS_TOKEN"), }, "email": { "name": "Email", "description": "Talk to Hermes through an IMAP/SMTP mailbox.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", "env_vars": ( "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", ), "required_env": ( "EMAIL_ADDRESS", "EMAIL_PASSWORD", "EMAIL_IMAP_HOST", "EMAIL_SMTP_HOST", ), }, "sms": { "name": "SMS (Twilio)", "description": "Send and receive text messages via Twilio.", "docs_url": "https://www.twilio.com/console", "env_vars": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), "required_env": ("TWILIO_ACCOUNT_SID", "TWILIO_AUTH_TOKEN"), }, "dingtalk": { "name": "DingTalk", "description": "Connect Hermes to DingTalk groups (钉钉).", "docs_url": "https://open.dingtalk.com/document/orgapp/the-robot-development-process", "env_vars": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), "required_env": ("DINGTALK_CLIENT_ID", "DINGTALK_CLIENT_SECRET"), }, "feishu": { "name": "Feishu / Lark", "description": "Use Hermes inside Feishu / Lark.", "docs_url": "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/reference/im-v1/intro", "env_vars": ( "FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_ENCRYPT_KEY", "FEISHU_VERIFICATION_TOKEN", ), "required_env": ("FEISHU_APP_ID", "FEISHU_APP_SECRET"), }, "google_chat": { "name": "Google Chat", "description": "Connect Hermes to Google Chat via Cloud Pub/Sub.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/google_chat", }, "wecom": { "name": "WeCom (group bot)", "description": "Send-only WeCom group bot via webhook.", "docs_url": "https://developer.work.weixin.qq.com/document/path/91770", "env_vars": ("WECOM_BOT_ID", "WECOM_SECRET"), "required_env": ("WECOM_BOT_ID",), }, "wecom_callback": { "name": "WeCom (app)", "description": "Two-way WeCom integration via callback app.", "docs_url": "https://developer.work.weixin.qq.com/document/path/90930", "env_vars": ( "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", "WECOM_CALLBACK_TOKEN", "WECOM_CALLBACK_ENCODING_AES_KEY", ), "required_env": ( "WECOM_CALLBACK_CORP_ID", "WECOM_CALLBACK_CORP_SECRET", "WECOM_CALLBACK_AGENT_ID", ), }, "weixin": { "name": "Weixin / WeChat (Personal)", "description": "Connect a personal WeChat account through Tencent's iLink Bot API.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/weixin/", "env_vars": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN", "WEIXIN_BASE_URL"), "required_env": ("WEIXIN_ACCOUNT_ID", "WEIXIN_TOKEN"), }, "bluebubbles": { "name": "BlueBubbles (iMessage)", "description": "Use Hermes through iMessage via a BlueBubbles server.", "docs_url": "https://bluebubbles.app/", "env_vars": ( "BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD", "BLUEBUBBLES_ALLOWED_USERS", ), "required_env": ("BLUEBUBBLES_SERVER_URL", "BLUEBUBBLES_PASSWORD"), }, "qqbot": { "name": "QQ Bot", "description": "Connect Hermes to a QQ Bot from the QQ Open Platform.", "docs_url": "https://q.qq.com", "env_vars": ("QQ_APP_ID", "QQ_CLIENT_SECRET", "QQ_ALLOWED_USERS"), "required_env": ("QQ_APP_ID", "QQ_CLIENT_SECRET"), }, # Teams ships as a platform plugin, so its name/env vars come from the # plugin registry. Only the docs link needs an override here so the # Channels page can point at the Microsoft Teams setup guide. "teams": { "description": "Connect Hermes to Microsoft Teams chats via the Bot Framework.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/teams", }, # Bundled platform plugins: name comes from the plugin registry label; # give each a human description (the registry's install_hint is a # dependency note, not a description) and a docs link. "irc": { "description": "Relay messages between an IRC channel (or DMs) and Hermes.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/irc", }, "line": { "description": "Use Hermes from LINE via the LINE Messaging API webhook.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/line", }, "ntfy": { "description": "Chat with Hermes over ntfy push topics (ntfy.sh or self-hosted).", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/ntfy", }, "photon": { "description": "Use Hermes through iMessage via Photon's managed Spectrum platform.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/photon", }, "raft": { "description": "Join a Raft workspace as an external agent.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/raft", }, "simplex": { "description": "Talk to Hermes over SimpleX Chat via a local simplex-chat daemon.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/simplex", }, "yuanbao": { "name": "Yuanbao (元宝)", "description": "Connect Hermes to Tencent Yuanbao.", "docs_url": "", "required_env": (), }, "api_server": { "name": "API server", "description": "Expose Hermes as an OpenAI-compatible HTTP API for tools like Open WebUI.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/", "env_vars": ( "API_SERVER_ENABLED", "API_SERVER_KEY", "API_SERVER_PORT", "API_SERVER_HOST", "API_SERVER_MODEL_NAME", ), "required_env": (), }, "webhook": { "name": "Webhooks", "description": "Receive events from GitHub, GitLab, and other webhook sources.", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/webhooks/", "env_vars": ("WEBHOOK_ENABLED", "WEBHOOK_PORT", "WEBHOOK_SECRET"), "required_env": (), }, "msgraph_webhook": { "name": "Microsoft Graph Webhook", "description": "Receive Microsoft Graph change notifications (Teams meetings, Outlook, …).", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/msgraph-webhook", "required_env": (), }, "whatsapp_cloud": { "name": "WhatsApp Cloud API", "description": "Use Hermes via Meta's hosted WhatsApp Cloud API (no local bridge).", "docs_url": "https://hermes-agent.nousresearch.com/docs/user-guide/messaging/whatsapp-cloud", }, "relay": { "name": "Relay (experimental)", "description": "Generic relay adapter fronted by the Hermes Relay connector.", "docs_url": "", "required_env": (), }, } # Display order: well-known platforms surface first; unknown plugins fall to # the end alphabetically. _PLATFORM_ORDER: tuple[str, ...] = ( "telegram", "discord", "slack", "mattermost", "matrix", "whatsapp", "signal", "bluebubbles", "homeassistant", "email", "sms", "dingtalk", "feishu", "google_chat", "wecom", "wecom_callback", "weixin", "qqbot", "yuanbao", "api_server", "webhook", ) # Display labels for env vars not in OPTIONAL_ENV_VARS (HOME_CHANNEL_*, bridge # toggles, Twilio, HASS, Email, etc.). Anything missing from OPTIONAL_ENV_VARS # falls back here so the UI can still render a friendly label. _MESSAGING_ENV_FALLBACKS: dict[str, dict[str, Any]] = { "SIGNAL_HTTP_URL": { "description": "signal-cli REST API base URL, e.g. http://127.0.0.1:8080", "prompt": "Signal bridge URL", "url": "https://github.com/bbernhard/signal-cli-rest-api", }, "SIGNAL_ACCOUNT": { "description": "Signal account phone number registered with the bridge", "prompt": "Signal account", }, "SIGNAL_ALLOWED_USERS": { "description": "Comma-separated Signal users allowed to use the bot", "prompt": "Allowed Signal users", }, "WHATSAPP_ENABLED": { "description": "Enable the WhatsApp gateway adapter", "prompt": "Enable WhatsApp", "advanced": True, }, "WHATSAPP_MODE": { "description": "WhatsApp bridge mode", "prompt": "WhatsApp mode", "advanced": True, }, "WHATSAPP_DM_POLICY": { "description": "How WhatsApp direct messages are authorized", "prompt": "WhatsApp DM policy", "advanced": True, }, "WHATSAPP_ALLOWED_USERS": { "description": "Comma-separated WhatsApp users allowed to use the bot", "prompt": "Allowed WhatsApp users", }, "HASS_URL": { "description": "Home Assistant base URL, e.g. https://homeassistant.local:8123", "prompt": "Home Assistant URL", }, "HASS_TOKEN": { "description": "Long-lived access token from Home Assistant (Profile → Security)", "prompt": "Home Assistant access token", "password": True, }, "EMAIL_ADDRESS": { "description": "Email address to send and receive from", "prompt": "Email address", }, "EMAIL_PASSWORD": { "description": "Email account password or app password", "prompt": "Email password", "password": True, }, "EMAIL_IMAP_HOST": { "description": "IMAP server host (e.g. imap.gmail.com)", "prompt": "IMAP host", }, "EMAIL_SMTP_HOST": { "description": "SMTP server host (e.g. smtp.gmail.com)", "prompt": "SMTP host", }, "TWILIO_ACCOUNT_SID": { "description": "Twilio Account SID", "prompt": "Twilio Account SID", "url": "https://www.twilio.com/console", }, "TWILIO_AUTH_TOKEN": { "description": "Twilio Auth Token", "prompt": "Twilio Auth Token", "password": True, }, "WECOM_BOT_ID": {"description": "WeCom group bot ID", "prompt": "WeCom Bot ID"}, "WECOM_SECRET": { "description": "WeCom group bot secret", "prompt": "WeCom Secret", "password": True, }, "WECOM_CALLBACK_CORP_ID": { "description": "WeCom corp ID", "prompt": "WeCom Corp ID", }, "WECOM_CALLBACK_CORP_SECRET": { "description": "WeCom app corp secret", "prompt": "WeCom Corp Secret", "password": True, }, "WECOM_CALLBACK_AGENT_ID": { "description": "WeCom app agent ID", "prompt": "WeCom Agent ID", }, "WECOM_CALLBACK_TOKEN": { "description": "WeCom callback verification token", "prompt": "WeCom Token", }, "WECOM_CALLBACK_ENCODING_AES_KEY": { "description": "WeCom callback AES encoding key", "prompt": "WeCom AES Key", "password": True, }, "WEIXIN_ACCOUNT_ID": { "description": "iLink Bot account ID obtained through QR login in hermes gateway setup", "prompt": "iLink Bot account ID", }, "WEIXIN_TOKEN": { "description": "iLink Bot token obtained through QR login in hermes gateway setup", "prompt": "iLink Bot token", "password": True, }, "WEIXIN_BASE_URL": { "description": "iLink API base URL saved by QR login (default: https://ilinkai.weixin.qq.com)", "prompt": "iLink API base URL", }, "FEISHU_APP_ID": {"description": "Feishu / Lark app ID", "prompt": "App ID"}, "FEISHU_APP_SECRET": { "description": "Feishu / Lark app secret", "prompt": "App secret", "password": True, }, "FEISHU_ENCRYPT_KEY": { "description": "Feishu / Lark encrypt key", "prompt": "Encrypt key", "password": True, }, "FEISHU_VERIFICATION_TOKEN": { "description": "Feishu / Lark verification token", "prompt": "Verification token", "password": True, }, "DINGTALK_CLIENT_ID": { "description": "DingTalk client ID (App key)", "prompt": "Client ID", }, "DINGTALK_CLIENT_SECRET": { "description": "DingTalk client secret (App secret)", "prompt": "Client secret", "password": True, }, } def _messaging_platform_catalog() -> tuple[dict[str, Any], ...]: """Build the messaging catalog from the gateway's Platform enum + plugin registry. Built-in platforms come from ``gateway.config.Platform`` (LOCAL is excluded). Plugin platforms come from ``gateway.platform_registry.plugin_entries()``, which lets newly installed adapters (e.g. IRC) appear without a code change here. Per-platform UI metadata (description, docs URL, env-var picks) lives in :data:`_PLATFORM_OVERRIDES`; anything not overridden gets reasonable defaults derived from the platform id and required_env. """ from gateway.config import Platform # Resolve plugin entries FIRST. Plugin platforms (irc, ntfy, photon, …) # leak into ``Platform.__members__`` as pseudo-members the moment any # earlier code path calls ``Platform("")`` — and iterating the # enum first would then claim them with no plugin metadata, rendering # nameless "Irc"/"Ntfy" cards with empty descriptions on the Channels # page while the real label/install-hint sat unused in the registry. plugin_map: dict[str, Any] = {} try: # Plugin discovery only runs as a side effect of importing # model_tools; this server process doesn't do that, so trigger it # explicitly (idempotent) or plugin_entries() is empty here and # every plugin platform renders nameless. from hermes_cli.plugins import discover_plugins discover_plugins() from gateway.platform_registry import platform_registry for plugin_entry in platform_registry.plugin_entries(): plugin_map[plugin_entry.name] = plugin_entry except Exception: _log.debug("plugin platform registry unavailable", exc_info=True) seen: set[str] = set() entries: list[dict[str, Any]] = [] for member in Platform.__members__.values(): if member.value == "local": continue if member.value in seen: continue seen.add(member.value) entries.append( _build_catalog_entry(member.value, plugin_map.get(member.value)) ) for name, plugin_entry in plugin_map.items(): if name in seen: continue seen.add(name) entries.append(_build_catalog_entry(name, plugin_entry)) order = {pid: idx for idx, pid in enumerate(_PLATFORM_ORDER)} entries.sort( key=lambda e: (order.get(e["id"], len(_PLATFORM_ORDER)), e["name"].lower()) ) return tuple(entries) def _channel_managed_env_keys() -> frozenset[str]: """Env-var keys owned by a Channels page platform card. The Channels page is the canonical surface for configuring messaging platform credentials (with connection status, test, enable toggle and gateway restart). The Keys/Env page consults this set to hide those vars so the same fields aren't duplicated in a plainer UI. Best-effort: if the gateway catalog can't be built, nothing is flagged and Keys shows it all. """ try: keys: set[str] = set() for entry in _messaging_platform_catalog(): keys.update(entry.get("env_vars", ())) return frozenset(keys) except Exception: _log.debug("could not build channel-managed env key set", exc_info=True) return frozenset() # Cross-cutting gateway / relay knobs stay on the Keys → Settings tab even though # they use the ``messaging`` category in OPTIONAL_ENV_VARS. Platform-scoped vars # (``DISCORD_*``, ``MATRIX_*``, …) are owned by the Messaging UI instead. _MESSAGING_KEYS_PAGE_KEYS = frozenset({ "GATEWAY_ALLOW_ALL_USERS", "GATEWAY_PROXY_KEY", "GATEWAY_PROXY_URL", }) def _platform_env_prefixes(platform_id: str) -> tuple[str, ...]: """Env-var prefixes owned by a messaging platform card.""" aliases: dict[str, tuple[str, ...]] = { "email": ("EMAIL_",), "homeassistant": ("HASS_",), "qqbot": ("QQ_", "QQBOT_"), "sms": ("TWILIO_",), "wecom": ("WECOM_BOT_", "WECOM_SECRET"), "wecom_callback": ("WECOM_CALLBACK_",), } if platform_id in aliases: return aliases[platform_id] return (platform_id.upper().replace("-", "_") + "_",) # Which per-platform knobs the setup UI hides, and why: see # hermes_cli/setup_hidden_env.py. Shared with the `hermes setup gateway` # wizard so the surfaces ask for the same things. from hermes_cli.setup_hidden_env import ( # noqa: E402 is_setup_hidden_env as _is_setup_hidden_env, ) def _discover_platform_env_vars(platform_id: str) -> tuple[str, ...]: """All messaging-category env vars for a platform (override + plugin + prefix).""" prefixes = _platform_env_prefixes(platform_id) keys: list[str] = [] for name, info in OPTIONAL_ENV_VARS.items(): if info.get("category") != "messaging": continue if name in _MESSAGING_KEYS_PAGE_KEYS: continue if _is_setup_hidden_env(name): continue if not any(name.startswith(prefix) for prefix in prefixes): continue keys.append(name) return tuple(sorted(set(keys))) def _merge_platform_env_vars( platform_id: str, override: dict[str, Any], plugin_entry: Any | None, ) -> tuple[str, ...]: """Canonical env-var list for a messaging platform card. Required credentials always survive: a platform that genuinely needs one of the hidden-suffix vars to connect keeps it, since hiding a required field would make the platform unconfigurable. """ discovered = _discover_platform_env_vars(platform_id) if "env_vars" in override: explicit = tuple( key for key in override["env_vars"] if not _is_setup_hidden_env(key) ) return tuple(dict.fromkeys((*explicit, *discovered))) if plugin_entry is not None and plugin_entry.required_env: return tuple(dict.fromkeys((*tuple(plugin_entry.required_env), *discovered))) return discovered def _build_catalog_entry( platform_id: str, plugin_entry: Any | None = None ) -> dict[str, Any]: override = _PLATFORM_OVERRIDES.get(platform_id, {}) env_vars = _merge_platform_env_vars(platform_id, override, plugin_entry) if "required_env" in override: required_env = tuple(override["required_env"]) elif plugin_entry is not None: required_env = tuple(plugin_entry.required_env or ()) else: required_env = () if override.get("name"): name = override["name"] elif plugin_entry is not None and plugin_entry.label: name = plugin_entry.label else: name = platform_id.replace("_", " ").title() description = override.get("description") if not description and plugin_entry is not None: description = plugin_entry.install_hint or "" return { "id": platform_id, "name": name, "description": description or "", "docs_url": override.get("docs_url", ""), "env_vars": env_vars, "required_env": required_env, } def _catalog_lookup(platform_id: str) -> dict[str, Any] | None: for entry in _messaging_platform_catalog(): if entry["id"] == platform_id: return entry return None def _messaging_env_info(key: str) -> dict[str, Any]: info = OPTIONAL_ENV_VARS.get(key) or _MESSAGING_ENV_FALLBACKS.get(key) or {} return { "description": info.get("description", ""), "prompt": info.get("prompt", key), "help": info.get("help", ""), "url": info.get("url"), "is_password": info.get("password", False), "advanced": info.get("advanced", False), } def _gateway_platform_config(platform_id: str): from gateway.config import Platform, load_gateway_config config = load_gateway_config() platform = Platform(platform_id) platform_config = config.platforms.get(platform) return config, platform, platform_config def _messaging_platform_payload( entry: dict[str, Any], env_on_disk: dict[str, str], runtime: dict | None, scoped: bool = False, profile_home: Optional[Path] = None, ) -> dict[str, Any]: platform_id = entry["id"] runtime_platforms = runtime.get("platforms") if runtime else {} runtime_platform = ( runtime_platforms.get(platform_id, {}) if isinstance(runtime_platforms, dict) else {} ) # Same shared ladder /api/status uses. Before this was unified, the two # endpoints disagreed on the same page load — the sidebar strip read # "running" (it probed GATEWAY_HEALTH_URL and scoped to the requested # profile) while the Channels page rendered "The gateway is not running" # (it did neither). Cross-container, profile-scoped, and # launch-service-managed deployments each hit that split. # # profile_home is passed when the request was scoped to a named profile: # gateway/status readers resolve process-level paths and do NOT follow the # HERMES_HOME contextvar override (#56986 / #69143), so the profile's # directory has to be handed over explicitly or messaging silently reports # another profile's gateway (#71211). liveness = resolve_gateway_liveness( profile_dir=profile_home, runtime=runtime, health_probe=( _probe_gateway_health if _GATEWAY_HEALTH_URL else None ), pid_probe=get_running_pid_cached, runtime_reader=read_runtime_status, runtime_pid_probe=get_runtime_status_running_pid, ) gateway_running = liveness.running env_vars = [] for key in entry["env_vars"]: # When profile-scoped, judge only the profile's own .env — the # dashboard process's os.environ carries the ROOT install's .env # (loaded at startup) and would falsely report the root credentials # as the profile's. value = env_on_disk.get(key) or ("" if scoped else os.getenv(key, "")) env_vars.append( { "key": key, "required": key in entry["required_env"], "is_set": bool(value), "redacted_value": redact_key(value) if value else None, **_messaging_env_info(key), } ) if scoped: # Profile-scoped view: derive enablement/configuration from the # profile's config.yaml + .env only. load_gateway_config()'s # env-override layer reads os.environ and would leak the root # install's tokens into the profile's reported state. try: cfg = load_config() platforms_cfg = cfg.get("platforms") or {} plat_cfg = platforms_cfg.get(platform_id) if not isinstance(plat_cfg, dict): plat_cfg = {} enabled = bool(plat_cfg.get("enabled")) hc = plat_cfg.get("home_channel") home_channel = hc if isinstance(hc, dict) else None except Exception: enabled = False home_channel = None configured = all(env_on_disk.get(key) for key in entry["required_env"]) else: try: gateway_config, platform, platform_config = _gateway_platform_config( platform_id ) enabled = bool(platform_config and platform_config.enabled) configured = bool( platform_config and gateway_config._is_platform_connected(platform, platform_config) ) home_channel = ( platform_config.home_channel.to_dict() if platform_config and platform_config.home_channel else None ) except Exception: enabled = False configured = all( env_on_disk.get(key) or os.getenv(key, "") for key in entry["required_env"] ) home_channel = None state = ( runtime_platform.get("state") if isinstance(runtime_platform, dict) else None ) runtime_gateway_state = runtime.get("gateway_state") if isinstance(runtime, dict) else None runtime_gateway_error = runtime.get("exit_reason") if isinstance(runtime, dict) else None if not enabled: state = "disabled" elif not configured: state = "not_configured" elif gateway_running and not state: state = "pending_restart" elif ( not gateway_running and not state and runtime_gateway_state == "startup_failed" ): state = "startup_failed" elif not gateway_running and not state: state = "gateway_stopped" error_code = ( runtime_platform.get("error_code") if isinstance(runtime_platform, dict) else None ) error_message = ( runtime_platform.get("error_message") if isinstance(runtime_platform, dict) else None ) if state == "startup_failed": error_code = error_code or "startup_failed" error_message = error_message or runtime_gateway_error whatsapp_setup = None if platform_id == "whatsapp": whatsapp_mode = ( env_on_disk.get("WHATSAPP_MODE") or ("" if scoped else os.getenv("WHATSAPP_MODE", "")) ).strip() allowed_users_value = ( env_on_disk.get("WHATSAPP_ALLOWED_USERS") or ("" if scoped else os.getenv("WHATSAPP_ALLOWED_USERS", "")) ).strip() whatsapp_setup = { "mode": whatsapp_mode if whatsapp_mode in {"bot", "self-chat"} else "", "allowed_users_set": bool(allowed_users_value), "home_channel_set": bool(home_channel), } payload = { "id": platform_id, "name": entry["name"], "description": entry["description"], "docs_url": entry["docs_url"], "enabled": enabled, "configured": configured, "gateway_running": gateway_running, "state": state, "error_code": error_code, "error_message": error_message, "updated_at": ( runtime_platform.get("updated_at") if isinstance(runtime_platform, dict) else None ), "home_channel": home_channel, "env_vars": env_vars, } if whatsapp_setup is not None: payload["whatsapp_setup"] = whatsapp_setup return payload def _write_platform_enabled(platform_id: str, enabled: bool) -> None: write_platform_config_field(platform_id, "enabled", enabled) from hermes_cli.web_routers import messaging as _messaging_routes # noqa: E402 app.include_router(_messaging_routes.router) from hermes_cli.web_routers.messaging import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. apply_whatsapp_onboarding, start_whatsapp_onboarding, ) @dataclass class _WhatsAppOnboardingSession: proc: subprocess.Popen | None mode: str allowed_users: str session_path: str expires_at: str expires_at_ts: float profile: str | None = None status: str = "starting" qr_payload: str | None = None account_id: str | None = None account_name: str | None = None account_phone: str | None = None error: str | None = None _whatsapp_onboarding_sessions: dict[str, _WhatsAppOnboardingSession] = {} def _whatsapp_session_path() -> Path: from hermes_constants import get_hermes_dir return get_hermes_dir("platforms/whatsapp/session", "whatsapp/session") def _whatsapp_onboarding_payload(pairing_id: str, record: _WhatsAppOnboardingSession) -> dict[str, Any]: return { "pairing_id": pairing_id, "status": record.status, "qr_payload": record.qr_payload, "expires_at": record.expires_at, "mode": record.mode, "allowed_users": record.allowed_users, "account_id": record.account_id, "account_name": record.account_name, "account_phone": record.account_phone, "error": record.error, } def _restart_gateway_after_whatsapp_onboarding(profile: Optional[str] = None) -> dict[str, Any]: try: proc, reused = _spawn_gateway_restart(profile) except Exception as exc: _log.exception("Failed to auto-restart gateway after WhatsApp onboarding") return { "restart_started": False, "restart_error": str(exc), } if reused: _log.info( "WhatsApp onboarding: reusing in-flight gateway restart (pid %s)", proc.pid, ) return { "restart_started": True, "restart_action": "gateway-restart", "restart_pid": proc.pid, } _TELEGRAM_ONBOARDING_DEFAULT_URL = "https://setup.hermes-agent.nousresearch.com" _TELEGRAM_ONBOARDING_USER_AGENT = f"HermesDashboard/{__version__}" @dataclass class _TelegramOnboardingPairing: poll_token: str expires_at: str expires_at_ts: float bot_token: str | None = None bot_username: str | None = None owner_user_id: str | None = None _telegram_onboarding_pairings: dict[str, _TelegramOnboardingPairing] = {} _telegram_onboarding_lock = threading.RLock() def _telegram_onboarding_base_url() -> str: return ( os.getenv("TELEGRAM_ONBOARDING_URL", _TELEGRAM_ONBOARDING_DEFAULT_URL) .strip() .rstrip("/") ) def _telegram_onboarding_error_message(error: str, fallback: str) -> str: return { "not_found": "Telegram pairing was not found. Start a new setup.", "expired": "Telegram setup expired. Start a new setup.", "claimed": "Telegram setup was already claimed. Start a new setup.", "unauthorized": "Telegram setup service rejected this request.", "telegram_manager_bot_token_not_configured": "Telegram setup service is not configured.", "telegram_token_fetch_failed": "Telegram could not finish bot setup. Try again.", }.get(error, fallback) def _telegram_onboarding_request_sync( method: str, path: str, *, body: dict[str, Any] | None = None, bearer_token: str | None = None, ) -> dict[str, Any]: import httpx headers = { "Accept": "application/json", "User-Agent": _TELEGRAM_ONBOARDING_USER_AGENT, } request_kwargs: dict[str, Any] = {} if body is not None: headers["Content-Type"] = "application/json" request_kwargs["json"] = body if bearer_token: headers["Authorization"] = f"Bearer {bearer_token}" url = f"{_telegram_onboarding_base_url()}{path}" try: with httpx.Client(timeout=httpx.Timeout(10.0)) as client: response = client.request( method, url, headers=headers, **request_kwargs, ) response.raise_for_status() except httpx.HTTPStatusError as exc: try: parsed = exc.response.json() except Exception: parsed = {} error = str(parsed.get("error") or parsed.get("status") or "") detail = _telegram_onboarding_error_message( error, "Telegram setup service returned an error.", ) status_code = 404 if exc.response.status_code == 404 else 502 if error in {"expired", "claimed"}: status_code = 410 raise HTTPException(status_code=status_code, detail=detail) from exc except httpx.RequestError as exc: raise HTTPException( status_code=502, detail="Telegram setup service is unavailable. Try again shortly.", ) from exc except Exception as exc: raise HTTPException( status_code=502, detail="Telegram setup service is unavailable. Try again shortly.", ) from exc try: parsed = response.json() except Exception as exc: raise HTTPException( status_code=502, detail="Telegram setup service returned an invalid response.", ) from exc if not isinstance(parsed, dict): raise HTTPException( status_code=502, detail="Telegram setup service returned an invalid response.", ) return parsed # --------------------------------------------------------------------------- # OAuth provider endpoints — status + disconnect (Phase 1) # --------------------------------------------------------------------------- # # Phase 1 surfaces *which OAuth providers exist* and whether each is # connected, plus a disconnect button. Anthropic subscription OAuth is # deliberately delegated away from the dashboard: its card is external and # points to the supported terminal path. Phase 2 adds in-browser device-code # flows for providers that support them. For unconnected providers we return # the canonical ``hermes auth add `` command so the dashboard can # surface a one-click copy. def _truncate_token(value: Optional[str], visible: int = 6) -> str: """Return ``...XXXXXX`` (last N chars) for safe display in the UI. We never expose more than the trailing ``visible`` characters of an OAuth access token. JWT prefixes (the part before the first dot) are stripped first when present so the visible suffix is always part of the signing region rather than a meaningless header chunk. Returns the Entra-ID placeholder when handed a callable (Azure Foundry bearer provider) — the callable is NEVER invoked here. """ if not value: return "" if callable(value) and not isinstance(value, str): # Entra ID bearer provider — never reveal a minted token in the UI. return "" s = str(value) if "." in s and s.count(".") >= 2: # Looks like a JWT — show the trailing piece of the signature only. s = s.rsplit(".", 1)[-1] if len(s) <= visible: return s return f"…{s[-visible:]}" def _anthropic_oauth_status() -> Dict[str, Any]: """Status for the "Anthropic API Key" catalog entry. Two sources, in priority order: 1. ``~/.hermes/.anthropic_oauth.json`` — Hermes-managed terminal PKCE credentials (the dashboard no longer has a Connect button for this) 2. ``ANTHROPIC_API_KEY`` → ``ANTHROPIC_TOKEN`` → ``CLAUDE_CODE_OAUTH_TOKEN`` env vars (registry order) — from ``.env``, the shell, or an external secret source like Bitwarden (whose keys are injected into the process env during ``load_hermes_dotenv()``, so the same check covers them) Claude Code's ``~/.claude/.credentials.json`` is deliberately NOT read here — it has its own dedicated catalog entry (``claude-code`` → ``_claude_code_only_status``). Reporting it under the API-key entry double-counts the token and shadows a real ANTHROPIC_API_KEY. """ try: from agent.anthropic_adapter import ( read_hermes_oauth_credentials, _get_hermes_oauth_file, ) except ImportError: read_hermes_oauth_credentials = None # type: ignore _get_hermes_oauth_file = None # type: ignore hermes_creds = None if read_hermes_oauth_credentials: try: hermes_creds = read_hermes_oauth_credentials() except Exception: hermes_creds = None if hermes_creds and hermes_creds.get("accessToken"): return { "logged_in": True, "source": "hermes_pkce", "source_label": f"Hermes PKCE ({_get_hermes_oauth_file() if _get_hermes_oauth_file else None})", "token_preview": _truncate_token(hermes_creds.get("accessToken")), "expires_at": hermes_creds.get("expiresAt"), "has_refresh_token": bool(hermes_creds.get("refreshToken")), } # Env-var / secret-source path. ``get_env_value`` checks the process # environment first (where Bitwarden-sourced secrets land) then .env. env_var_order: tuple = ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN") try: from hermes_cli.auth import PROVIDER_REGISTRY env_var_order = PROVIDER_REGISTRY["anthropic"].api_key_env_vars except (ImportError, KeyError): pass try: from hermes_cli.config import get_env_value except ImportError: get_env_value = None # type: ignore try: from hermes_cli.env_loader import format_secret_source_suffix except ImportError: format_secret_source_suffix = None # type: ignore for var in env_var_order: value = (get_env_value(var) if get_env_value else None) or os.getenv(var) if not value: continue suffix = format_secret_source_suffix(var) if format_secret_source_suffix else "" return { "logged_in": True, "source": "env_var", "source_label": f"{var}{suffix}", "token_preview": _truncate_token(value), "expires_at": None, "has_refresh_token": False, } return {"logged_in": False, "source": None} def _claude_code_only_status() -> Dict[str, Any]: """Surface Claude Code CLI credentials as their own provider entry. Independent of the Anthropic entry above so users can see whether their Claude Code subscription tokens are actively flowing into Hermes even when they also have a separate Hermes-managed PKCE login. """ try: from agent.anthropic_adapter import read_claude_code_credentials creds = read_claude_code_credentials() except Exception: creds = None if creds and creds.get("accessToken"): return { "logged_in": True, "source": "claude_code_cli", "source_label": "~/.claude/.credentials.json", "token_preview": _truncate_token(creds.get("accessToken")), "expires_at": creds.get("expiresAt"), "has_refresh_token": bool(creds.get("refreshToken")), } return {"logged_in": False, "source": None} def _copilot_acp_status() -> Dict[str, Any]: """Status for copilot-acp — credentials are owned by the Copilot CLI. ``logged_in`` is claimed only on positive evidence (a supported env token or a known on-disk GitHub Copilot credential store, via ``auth.get_external_process_provider_status``). The Copilot CLI may also hold its session in an OS keychain Hermes can't read, so the unverified state is presented as "managed by the Copilot CLI" — never as signed out. """ try: from hermes_cli.auth import get_external_process_provider_status status = get_external_process_provider_status("copilot-acp") or {} except Exception: status = {} verified = bool(status.get("auth_verified")) configured = bool(status.get("configured")) if verified: source_label = status.get("auth_source") or "Copilot credentials detected" elif configured: found = status.get("resolved_command") or status.get("command") or "copilot" source_label = f"Managed by the GitHub Copilot CLI ({found})" else: source_label = "GitHub Copilot CLI not found on PATH" return { "logged_in": verified, "source": "copilot_cli", "source_label": source_label, "token_preview": None, "expires_at": None, "has_refresh_token": False, "configured": configured, } def _external_process_cli_command(provider_id: str, default: str) -> str: """Render an external-process provider's sign-in command with the CLI the user actually has configured. The static catalog assumes the default executable name; users who point Hermes at a custom binary (``HERMES_COPILOT_ACP_COMMAND`` / ``COPILOT_CLI_PATH``) would otherwise be told to run a command that isn't the one Hermes spawns. Non-external-process providers get ``default`` back untouched. """ try: from hermes_cli.auth import PROVIDER_REGISTRY, get_external_process_provider_status pconfig = PROVIDER_REGISTRY.get(provider_id) if not pconfig or pconfig.auth_type != "external_process": return default status = get_external_process_provider_status(provider_id) or {} command = str(status.get("command") or "").strip() if command: parts = default.split(" ", 1) tail = f" {parts[1]}" if len(parts) > 1 else "" return f"{command}{tail}" except Exception: pass return default # Explicit, hand-tuned OAuth/account provider cards. These carry the bits that # can't be derived from the unified provider catalog: the OAuth ``flow`` shape, # the per-provider ``status_fn``, the ``cli_command`` fallback, and curated # display order. They are the OVERRIDE BASE for ``_build_oauth_catalog()``, # which unions them with every accounts-tab provider in ``provider_catalog()`` # so newly-added OAuth/external providers appear automatically (no hand edit). # This tuple also still includes two entries that are NOT catalog providers but # must show on the Accounts tab: the Anthropic credential-status card and the # synthetic ``claude-code`` subscription row. # ``flow`` describes the account-management shape so the UI can pick the right # behavior: ``device_code`` = show code + verification URL + poll, and # ``external`` = read-only/delegated to a terminal or third-party CLI. _OAUTH_PROVIDER_CATALOG: tuple[Dict[str, Any], ...] = ( { "id": "nous", "name": "Nous Portal", "flow": "device_code", "cli_command": "hermes auth add nous", "docs_url": "https://portal.nousresearch.com", "status_fn": None, # dispatched via auth.get_nous_auth_status }, { "id": "openai-codex", "name": "ChatGPT or Codex Subscription", "flow": "device_code", "cli_command": "hermes auth add openai-codex", "docs_url": "https://platform.openai.com/docs", "status_fn": None, # dispatched via auth.get_codex_auth_status }, { "id": "qwen-oauth", "name": "Qwen (via Qwen CLI)", "flow": "external", "cli_command": "hermes auth add qwen-oauth", "docs_url": "https://github.com/QwenLM/qwen-code", "status_fn": None, # dispatched via auth.get_qwen_auth_status }, { "id": "minimax-oauth", "name": "MiniMax (OAuth)", # MiniMax's flow is structurally device-code (verification URI + # user code, backend polls the token endpoint) with a PKCE # extension for code-binding. The dashboard renders the same UX # as Nous's device-code flow; the PKCE bit is a security # extension that doesn't change the operator experience. "flow": "device_code", "cli_command": "hermes auth add minimax-oauth", "docs_url": "https://www.minimax.io", "status_fn": None, # dispatched via auth.get_minimax_oauth_auth_status }, { "id": "xai-oauth", "name": "xAI Grok OAuth (SuperGrok / Premium+)", # Device code is the default because it works in remote shells, # containers, and desktop installs without requiring a reachable # 127.0.0.1 callback. "flow": "device_code", "cli_command": "hermes auth add xai-oauth", "docs_url": "https://hermes-agent.nousresearch.com/docs/guides/xai-grok-oauth", "status_fn": None, # dispatched via auth.get_xai_oauth_auth_status }, { "id": "copilot-acp", "name": "GitHub Copilot (ACP)", "flow": "external", # `copilot login` is the CLI's non-interactive device-code login # subcommand; the previous `copilot /login` form is not a valid # invocation (slash-commands only exist inside an interactive # session, reachable as `copilot -i /login`). "cli_command": "copilot login", "docs_url": "https://docs.github.com/en/copilot", "status_fn": _copilot_acp_status, }, # ── Anthropic / Claude entries sit at the bottom. # # This card is deliberately flow == "external" (no in-dashboard "Connect" # button walking the user through claude.ai/oauth/authorize from the web # server). Hermes previously reimplemented that subscription-OAuth PKCE # dance itself for the dashboard (issues #87887/#87888); that surface was # removed because it lets an unattended, scriptable HTTP endpoint mint # Claude Pro/Max subscription tokens outside Anthropic's own client, # which sits on the wrong side of Anthropic's usage policies for OAuth # credentials. Login still works via the terminal (`hermes auth add # anthropic`, unaffected by this change) or a plain API key below. { "id": "anthropic", "name": "Anthropic API Key", "flow": "external", "cli_command": "hermes auth add anthropic", "docs_url": "https://docs.claude.com/en/api/getting-started", "status_fn": _anthropic_oauth_status, }, { "id": "claude-code", "name": "Anthropic OAuth: Required Extra Usage Credits to Use Subscription", "flow": "external", "cli_command": "claude setup-token", "docs_url": "https://docs.claude.com/en/docs/claude-code", "status_fn": _claude_code_only_status, }, ) def _resolve_provider_status(provider_id: str, status_fn) -> Dict[str, Any]: """Dispatch to the right status helper for an OAuth provider entry.""" if status_fn is not None: try: return status_fn() except Exception as e: return {"logged_in": False, "error": str(e)} try: from hermes_cli import auth as hauth if provider_id == "nous": # Read-only accounts-tab card: refresh-free snapshot so listing # providers never performs an OAuth refresh. raw = hauth.get_nous_auth_status_local() return { "logged_in": bool(raw.get("logged_in")), "source": "nous_portal", "source_label": raw.get("portal_base_url") or "Nous Portal", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("access_expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "openai-codex": raw = hauth.get_codex_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "openai_codex", "source_label": raw.get("auth_mode") or "OpenAI Codex", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": False, "last_refresh": raw.get("last_refresh"), } if provider_id == "qwen-oauth": raw = hauth.get_qwen_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "qwen_cli", "source_label": raw.get("auth_store_path") or "Qwen CLI", "token_preview": _truncate_token(raw.get("access_token")), "expires_at": raw.get("expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } if provider_id == "minimax-oauth": raw = hauth.get_minimax_oauth_auth_status() return { "logged_in": bool(raw.get("logged_in")), "source": "minimax_oauth", "source_label": f"MiniMax ({raw.get('region', 'global')})", "token_preview": None, "expires_at": raw.get("expires_at"), "has_refresh_token": True, } if provider_id == "xai-oauth": raw = hauth.get_xai_oauth_auth_status() # source_label is meant to be a human-readable origin (auth-store # path / credential source), not the internal auth_mode string # ("oauth_pkce"). Prefer the store path, then the source slug. return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or "xai_oauth", "source_label": raw.get("auth_store") or raw.get("source") or "xAI Grok OAuth", "token_preview": _truncate_token(raw.get("api_key")), "expires_at": None, "has_refresh_token": True, "last_refresh": raw.get("last_refresh"), } # No hand-written branch for this provider id: fall through to the # canonical slug-driven dispatcher so accounts-tab providers derived # from the unified catalog (which carry status_fn=None) still reflect # real login state instead of rendering permanently logged-out. This # closes the membership-auto-extends-but-status-doesn't gap: add an # OAuth/account provider plugin and its card shows the right state. raw = hauth.get_auth_status(provider_id) if isinstance(raw, dict) and "logged_in" in raw: return { "logged_in": bool(raw.get("logged_in")), "source": raw.get("source") or raw.get("provider") or provider_id, "source_label": ( raw.get("source_label") or raw.get("auth_store") or raw.get("auth_store_path") or raw.get("base_url") or raw.get("name") or "" ), "token_preview": _truncate_token( raw.get("access_token") or raw.get("api_key") ), "expires_at": raw.get("expires_at") or raw.get("access_expires_at"), "has_refresh_token": bool(raw.get("has_refresh_token")), } except Exception as e: return {"logged_in": False, "error": str(e)} return {"logged_in": False} def _oauth_provider_disconnect_command(provider: Dict[str, Any]) -> Optional[str]: """Shell command that clears an external provider's credentials. External providers store their credentials outside Hermes, so the disconnect API deliberately refuses them (we never delete files another CLI owns on the user's behalf via a silent API call). For the ones we know how to clear we instead hand the GUI a command it can *run in the embedded terminal* — the user sees exactly what executes, and Hermes then stops resolving the token. Claude Code has no scriptable logout (only the interactive ``/logout``), so we remove the credential the same way logout does: the macOS Keychain entry (``Claude Code-credentials``) and/or the ``~/.claude/.credentials.json`` file — the two sources ``read_claude_code_credentials()`` consults. Returns None for providers we can't safely clear (the GUI shows a manual hint). """ if provider.get("flow") != "external": return None if provider.get("id") == "claude-code": rm_file = "rm -f ~/.claude/.credentials.json" if sys.platform == "darwin": return f'security delete-generic-password -s "Claude Code-credentials" 2>/dev/null; {rm_file}' return rm_file return None def _oauth_provider_disconnect_hint(provider: Dict[str, Any], status: Dict[str, Any]) -> Optional[str]: """Return the manual disconnect path when the API cannot clear this provider.""" # "anthropic" is flow == "external" (no in-dashboard OAuth login, see the # catalog entry) but, unlike other external providers, Hermes still OWNS # the credential it can show here: the Hermes-managed PKCE file # (~/.hermes/.anthropic_oauth.json) and its credential-pool entry, both # written by `hermes auth add anthropic` in the terminal. Those are ours # to clear via the API, so this provider is excluded from the generic # "external providers can't be auto-disconnected" rule below. if provider.get("flow") == "external" and provider.get("id") != "anthropic": if _oauth_provider_disconnect_command(provider): # The GUI offers a one-click "run in terminal" path; this hint is the # fallback wording for surfaces that only show text. return "Managed outside Hermes — run the disconnect command to remove it." return "Managed by that provider's CLI; remove it there." if status.get("source") == "env_var": return "Remove the API key from Settings → Keys instead." return None def _build_oauth_catalog() -> list[Dict[str, Any]]: """Build the Accounts-tab provider list. MEMBERSHIP is the union of: 1. ``_OAUTH_PROVIDER_CATALOG`` — the explicit, hand-tuned cards that carry bespoke flow / status_fn / cli_command (including the api-key Anthropic PKCE card and the synthetic claude-code subscription row, which are not catalog providers), and 2. every accounts-tab provider in the unified ``provider_catalog()`` (the ``hermes model`` universe) — so any OAuth/external provider added as a plugin appears automatically, with sensible defaults, even if no explicit card was written for it. The explicit catalog wins on metadata; the unified catalog guarantees we never silently drop a provider the CLI picker offers. Order: explicit cards first (their curated order), then any catalog-only providers appended in ``hermes model`` order. """ rows: list[Dict[str, Any]] = [] seen: set[str] = set() # 1. Explicit hand-tuned cards (authoritative metadata + curated order). for entry in _OAUTH_PROVIDER_CATALOG: if entry["id"] in seen: continue seen.add(entry["id"]) rows.append(dict(entry)) # 2. Catalog accounts-providers not already covered — keeps the Accounts tab # in lockstep with the `hermes model` universe (zero-edit for new plugins). try: from hermes_cli.provider_catalog import provider_catalog for d in provider_catalog(): if d.tab != "accounts" or d.slug in seen: continue seen.add(d.slug) rows.append({ "id": d.slug, "name": d.label, "flow": "external", "cli_command": f"hermes auth add {d.slug}", "docs_url": d.signup_url or "", "status_fn": None, }) except Exception: pass return rows @app.get("/api/providers/oauth") async def list_oauth_providers(profile: Optional[str] = None): """Enumerate every OAuth-capable LLM provider with current status. Response shape (per provider): id stable identifier (used in DELETE path) name human label flow "device_code" | "external" cli_command fallback CLI command for users to run manually disconnect_command shell command that clears an external provider's creds (run in the embedded terminal), else null docs_url external docs/portal link for the "Learn more" link status: logged_in bool — currently has usable creds source short slug ("hermes_pkce", "claude_code", ...) source_label human-readable origin (file path, env var name) token_preview last N chars of the token, never the full token expires_at ISO timestamp string or null has_refresh_token bool Membership is derived from the unified provider_catalog() so this stays in sync with the `hermes model` picker; _OAUTH_OVERRIDES supplies per-provider flow/status/cli metadata. """ def _run(): with _profile_scope(profile): providers = [] for p in _build_oauth_catalog(): status = _resolve_provider_status(p["id"], p.get("status_fn")) disconnect_hint = _oauth_provider_disconnect_hint(p, status) providers.append({ "id": p["id"], "name": p["name"], "flow": p["flow"], "cli_command": _external_process_cli_command(p["id"], p["cli_command"]), "docs_url": p["docs_url"], "disconnect_hint": disconnect_hint, "disconnect_command": _oauth_provider_disconnect_command(p), "disconnectable": disconnect_hint is None, "status": status, }) return {"providers": providers} return await asyncio.to_thread(_run) @app.delete("/api/providers/oauth/{provider_id}") async def disconnect_oauth_provider( provider_id: str, request: Request, profile: Optional[str] = None, ): """Disconnect an OAuth provider. Token-protected (matches /env/reveal).""" _require_token(request) def _run(): with _profile_scope(profile): catalog_by_id = {p["id"]: p for p in _build_oauth_catalog()} provider = catalog_by_id.get(provider_id) if provider is None: raise HTTPException( status_code=400, detail=f"Unknown provider: {provider_id}. " f"Available: {', '.join(sorted(catalog_by_id))}", ) disconnect_hint = _oauth_provider_disconnect_hint(provider, {}) if disconnect_hint: raise HTTPException( status_code=400, detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", ) status = _resolve_provider_status(provider_id, provider.get("status_fn")) disconnect_hint = _oauth_provider_disconnect_hint(provider, status) if disconnect_hint: raise HTTPException( status_code=400, detail=f"{provider['name']} cannot be disconnected automatically. {disconnect_hint}", ) # Anthropic clears only the Hermes-managed PKCE file and auth-store entry. # The separate claude-code catalog row is external/read-only and rejected # above so we never pretend to remove ~/.claude/* credentials owned by the CLI. if provider_id == "anthropic": cleared = False try: from agent.anthropic_adapter import _get_hermes_oauth_file oauth_file = _get_hermes_oauth_file() if oauth_file.exists(): oauth_file.unlink() cleared = True except Exception: pass # Also clear the credential pool entry if present. try: from hermes_cli.auth import clear_provider_auth cleared = clear_provider_auth("anthropic") or cleared except Exception: pass _log.info("oauth/disconnect: %s", provider_id) return {"ok": bool(cleared), "provider": provider_id} try: from hermes_cli.auth import clear_provider_auth, invalidate_nous_auth_status_cache cleared = clear_provider_auth(provider_id) if provider_id == "nous": invalidate_nous_auth_status_cache() _log.info("oauth/disconnect: %s (cleared=%s)", provider_id, cleared) return {"ok": bool(cleared), "provider": provider_id} except Exception as e: _log.exception("disconnect %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) return await asyncio.to_thread(_run) # --------------------------------------------------------------------------- # OAuth Phase 2 — in-browser device-code flows # --------------------------------------------------------------------------- # # Anthropic previously had a dashboard-triggered PKCE flow here too (server # generates a claude.ai/oauth/authorize URL, exchanges the code for tokens at # the Anthropic token endpoint, persists them). It was removed: an unattended # HTTP endpoint minting Claude Pro/Max subscription tokens outside Anthropic's # own client sits on the wrong side of Anthropic's usage policies for OAuth # credentials. The "anthropic" catalog entry is now flow == "external" and # points at `hermes auth add anthropic` (terminal PKCE, unaffected) instead. # # Device code (Nous, OpenAI Codex): # 1. POST /api/providers/oauth/{nous|openai-codex}/start # → server hits provider's device-auth endpoint # → gets { user_code, verification_url, device_code, interval, expires_in } # → spawns background poller thread that polls the token endpoint # every `interval` seconds until approved/expired # → stores poll status in _oauth_sessions[session_id] # → returns { session_id, flow: "device_code", user_code, # verification_url, expires_in, poll_interval } # 2. UI opens verification_url in a new tab and shows user_code. # 3. UI polls GET /api/providers/oauth/{provider}/poll/{session_id} # every 2s until status != "pending". # 4. On "approved" the background thread has already saved creds; UI # refreshes the providers list. # # Sessions are kept in-memory only (single-process FastAPI) and time out # after 15 minutes. A periodic cleanup runs on each /start call to GC # expired sessions so the dict doesn't grow without bound. _OAUTH_SESSION_TTL_SECONDS = 15 * 60 _oauth_sessions: Dict[str, Dict[str, Any]] = {} _oauth_sessions_lock = threading.Lock() def _gc_oauth_sessions() -> None: """Drop expired sessions. Called opportunistically on /start.""" cutoff = time.time() - _OAUTH_SESSION_TTL_SECONDS with _oauth_sessions_lock: stale = [sid for sid, sess in _oauth_sessions.items() if sess["created_at"] < cutoff] for sid in stale: _oauth_sessions.pop(sid, None) def _oauth_profile_name(profile: Optional[str]) -> Optional[str]: requested = (profile or "").strip() if not requested or requested.lower() == "current": return None return requested def _validate_oauth_profile(profile: Optional[str]) -> None: profile_name = _oauth_profile_name(profile) if profile_name: _resolve_profile_dir(profile_name) def _new_oauth_session( provider_id: str, flow: str, profile: Optional[str] = None, ) -> tuple[str, Dict[str, Any]]: """Create + register a new OAuth session, return (session_id, session_dict).""" sid = secrets.token_urlsafe(16) profile_name = _oauth_profile_name(profile) sess = { "session_id": sid, "provider": provider_id, "flow": flow, "profile": profile_name, "created_at": time.time(), "status": "pending", # pending | approved | denied | expired | error "error_message": None, } with _oauth_sessions_lock: _oauth_sessions[sid] = sess return sid, sess def _oauth_session_profile( session_id: str, fallback: Optional[str] = None, ) -> Optional[str]: """Return the profile that owns an OAuth session, if one was provided.""" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) profile = sess.get("profile") if sess else None return profile or _oauth_profile_name(fallback) async def _start_device_code_flow( provider_id: str, profile: Optional[str] = None, ) -> Dict[str, Any]: """Initiate a device-code flow (Nous, OpenAI Codex, MiniMax, or xAI). Calls the provider's device-auth endpoint via the existing CLI helpers, then spawns a background poller. Returns the user-facing display fields so the UI can render the verification page link + user code. """ if provider_id == "nous": from hermes_cli.auth import ( _request_device_code, PROVIDER_REGISTRY, ) import httpx pconfig = PROVIDER_REGISTRY["nous"] portal_base_url = ( os.getenv("HERMES_PORTAL_BASE_URL") or os.getenv("NOUS_PORTAL_BASE_URL") or pconfig.portal_base_url ).rstrip("/") client_id = pconfig.client_id scope = pconfig.scope def _do_nous_device_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, ) as client: return ( _request_device_code( client=client, portal_base_url=portal_base_url, client_id=client_id, scope=scope, ), scope, ) device_data, effective_scope = await asyncio.get_running_loop().run_in_executor( None, _do_nous_device_request ) sid, sess = _new_oauth_session("nous", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) sess["portal_base_url"] = portal_base_url sess["client_id"] = client_id sess["scope"] = effective_scope threading.Thread( target=_nous_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}" ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri_complete"]), "expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]), } if provider_id == "openai-codex": # Codex uses fixed OpenAI device-auth endpoints; reuse the helper. sid, _ = _new_oauth_session("openai-codex", "device_code", profile=profile) # Use the helper but in a thread because it polls inline. # We can't extract just the start step without refactoring auth.py, # so we run the full helper in a worker and proxy the user_code + # verification_url back via the session dict. The helper prints # to stdout — we capture nothing here, just status. threading.Thread( target=_codex_full_login_worker, args=(sid,), daemon=True, name=f"oauth-codex-{sid[:6]}", ).start() # Block briefly until the worker has populated the user_code, OR error. deadline = time.monotonic() + 10 while time.monotonic() < deadline: with _oauth_sessions_lock: s = _oauth_sessions.get(sid) if s and (s.get("user_code") or s["status"] != "pending"): break await asyncio.sleep(0.1) with _oauth_sessions_lock: s = _oauth_sessions.get(sid, {}) if s.get("status") == "error": raise HTTPException(status_code=500, detail=s.get("error_message") or "device-auth failed") if not s.get("user_code"): raise HTTPException(status_code=504, detail="device-auth timed out before returning a user code") return { "session_id": sid, "flow": "device_code", "user_code": s["user_code"], "verification_url": s["verification_url"], "expires_in": int(s.get("expires_in") or 900), "poll_interval": int(s.get("interval") or 5), } if provider_id == "minimax-oauth": # MiniMax uses a device-code-style flow (verification URI + user # code + background poll) with a PKCE extension on top. From the # operator's perspective it's identical to Nous's device-code # flow; the PKCE bit (verifier + challenge from # _minimax_pkce_pair) is a security extension that binds the # token exchange to the original session. from hermes_cli.auth import ( _minimax_pkce_pair, _minimax_request_user_code, MINIMAX_OAUTH_CLIENT_ID, MINIMAX_OAUTH_GLOBAL_BASE, ) import httpx verifier, challenge, state = _minimax_pkce_pair() portal_base_url = ( os.getenv("MINIMAX_PORTAL_BASE_URL") or MINIMAX_OAUTH_GLOBAL_BASE ).rstrip("/") def _do_minimax_request(): with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True, ) as client: return _minimax_request_user_code( client=client, portal_base_url=portal_base_url, client_id=MINIMAX_OAUTH_CLIENT_ID, code_challenge=challenge, state=state, ) device_data = await asyncio.get_event_loop().run_in_executor( None, _do_minimax_request ) sid, sess = _new_oauth_session("minimax-oauth", "device_code", profile=profile) # The CLI flow names this `interval_ms` because MiniMax's # `interval` field is in milliseconds (defensive default 2000ms # in _minimax_poll_token). interval_raw = device_data.get("interval") sess["interval_ms"] = ( int(interval_raw) if interval_raw is not None else None ) sess["user_code"] = str(device_data["user_code"]) sess["code_verifier"] = verifier sess["state"] = state sess["portal_base_url"] = portal_base_url sess["client_id"] = MINIMAX_OAUTH_CLIENT_ID sess["region"] = "global" # `expired_in` from MiniMax is overloaded — could be a unix-ms # timestamp OR a seconds-from-now duration. Mirror the heuristic # in _minimax_poll_token. Stash the raw value for the poller; # compute a derived expires_at + UI-friendly expires_in seconds. expired_in_raw = int(device_data["expired_in"]) sess["expired_in_raw"] = expired_in_raw if expired_in_raw > 1_000_000_000_000: # likely unix-ms expires_at_ts = expired_in_raw / 1000.0 expires_in_seconds = max(0, int(expires_at_ts - time.time())) else: expires_at_ts = time.time() + expired_in_raw expires_in_seconds = expired_in_raw sess["expires_at"] = expires_at_ts threading.Thread( target=_minimax_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}", ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str(device_data["verification_uri"]), "expires_in": expires_in_seconds, "poll_interval": max(2, (sess["interval_ms"] or 2000) // 1000), } if provider_id == "xai-oauth": from hermes_cli.auth import _xai_oauth_request_device_code import httpx def _do_xai_device_request(): with httpx.Client( timeout=httpx.Timeout(20.0), headers={"Accept": "application/json"}, ) as client: return _xai_oauth_request_device_code(client) device_data = await asyncio.get_running_loop().run_in_executor( None, _do_xai_device_request ) sid, sess = _new_oauth_session("xai-oauth", "device_code", profile=profile) sess["device_code"] = str(device_data["device_code"]) sess["interval"] = int(device_data["interval"]) sess["expires_at"] = time.time() + int(device_data["expires_in"]) threading.Thread( target=_xai_device_poller, args=(sid,), daemon=True, name=f"oauth-poll-{sid[:6]}", ).start() return { "session_id": sid, "flow": "device_code", "user_code": str(device_data["user_code"]), "verification_url": str( device_data.get("verification_uri_complete") or device_data["verification_uri"] ), "expires_in": int(device_data["expires_in"]), "poll_interval": int(device_data["interval"]), } raise HTTPException(status_code=400, detail=f"Provider {provider_id} does not support device-code flow") def _nous_poller(session_id: str) -> None: """Background poller that drives a Nous device-code flow to completion.""" from hermes_cli.auth import ( _poll_for_token, refresh_nous_oauth_from_state, ) from datetime import datetime, timezone import httpx with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return portal_base_url = sess["portal_base_url"] client_id = sess["client_id"] device_code = sess["device_code"] interval = sess["interval"] scope = sess.get("scope") expires_in = max(60, int(sess["expires_at"] - time.time())) try: with httpx.Client(timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}) as client: token_data = _poll_for_token( client=client, portal_base_url=portal_base_url, client_id=client_id, device_code=device_code, expires_in=expires_in, poll_interval=interval, ) # Same post-processing as _nous_device_code_login (validate/refresh JWT) now = datetime.now(timezone.utc) token_ttl = int(token_data.get("expires_in") or 0) auth_state = { "portal_base_url": portal_base_url, "inference_base_url": token_data.get("inference_base_url"), "client_id": client_id, "scope": token_data.get("scope") or scope, "token_type": token_data.get("token_type", "Bearer"), "access_token": token_data["access_token"], "refresh_token": token_data.get("refresh_token"), "obtained_at": now.isoformat(), "expires_at": ( datetime.fromtimestamp(now.timestamp() + token_ttl, tz=timezone.utc).isoformat() if token_ttl else None ), "expires_in": token_ttl, } with _profile_scope(_oauth_session_profile(session_id)): full_state = refresh_nous_oauth_from_state( auth_state, timeout_seconds=15.0, force_refresh=False, ) from hermes_cli.auth import persist_nous_credentials persist_nous_credentials(full_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: nous login completed (session=%s)", session_id) except Exception as e: _log.warning("nous device-code poll failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = str(e) def _minimax_poller(session_id: str) -> None: """Background poller that drives a MiniMax OAuth flow to completion. Mirrors `_nous_poller` but calls the MiniMax-specific token endpoint, which uses a PKCE-style ``code_verifier`` + ``user_code`` rather than the ``device_code`` field used by Nous. On success, builds the same auth_state dict that ``_minimax_oauth_login`` (the CLI flow) builds and persists via ``_minimax_save_auth_state`` — so the dashboard path leaves the system in the same state as ``hermes auth add minimax-oauth``. """ from hermes_cli.auth import ( _minimax_poll_token, _minimax_resolve_token_expiry_unix, _minimax_save_auth_state, MINIMAX_OAUTH_GLOBAL_INFERENCE, MINIMAX_OAUTH_SCOPE, ) from datetime import datetime, timezone import httpx with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return portal_base_url = sess["portal_base_url"] client_id = sess["client_id"] user_code = sess["user_code"] code_verifier = sess["code_verifier"] interval_ms = sess.get("interval_ms") expired_in_raw = sess["expired_in_raw"] try: with httpx.Client( timeout=httpx.Timeout(15.0), headers={"Accept": "application/json"}, follow_redirects=True, ) as client: token_data = _minimax_poll_token( client=client, portal_base_url=portal_base_url, client_id=client_id, user_code=user_code, code_verifier=code_verifier, expired_in=expired_in_raw, interval_ms=interval_ms, ) # Build the auth_state dict in the same shape as the CLI flow's # `_minimax_oauth_login` so `_minimax_save_auth_state` writes # the canonical record. Region is fixed to "global" for the # dashboard path; cn-region operators can still use the CLI # flow which supports `--region cn`. now = datetime.now(timezone.utc) expires_at_ts = _minimax_resolve_token_expiry_unix( int(token_data["expired_in"]), now=now, ) expires_in_s = max(0, int(expires_at_ts - now.timestamp())) auth_state = { "provider": "minimax-oauth", "region": sess.get("region", "global"), "portal_base_url": portal_base_url, "inference_base_url": MINIMAX_OAUTH_GLOBAL_INFERENCE, "client_id": client_id, "scope": MINIMAX_OAUTH_SCOPE, "token_type": token_data.get("token_type", "Bearer"), "access_token": token_data["access_token"], "refresh_token": token_data["refresh_token"], "resource_url": token_data.get("resource_url"), "obtained_at": now.isoformat(), "expires_at": datetime.fromtimestamp( expires_at_ts, tz=timezone.utc ).isoformat(), "expires_in": expires_in_s, } with _profile_scope(_oauth_session_profile(session_id)): _minimax_save_auth_state(auth_state) with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: minimax login completed (session=%s)", session_id) except Exception as e: _log.warning("minimax device-code poll failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = str(e) def _xai_device_poller(session_id: str) -> None: """Background poller for xAI's OAuth device-code flow.""" import httpx from hermes_cli.auth import ( _save_xai_oauth_tokens, _xai_oauth_discovery, _xai_oauth_poll_device_token, mark_provider_active_if_unset, unsuppress_credential_source, ) with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return device_code = sess["device_code"] interval = int(sess["interval"]) expires_in = max(60, int(sess["expires_at"] - time.time())) try: discovery = _xai_oauth_discovery(20.0) with httpx.Client( timeout=httpx.Timeout(20.0), headers={"Accept": "application/json"}, ) as client: token_data = _xai_oauth_poll_device_token( client, token_endpoint=discovery["token_endpoint"], device_code=device_code, expires_in=expires_in, poll_interval=interval, ) tokens = { "access_token": str(token_data.get("access_token", "") or "").strip(), "refresh_token": str(token_data.get("refresh_token", "") or "").strip(), "id_token": str(token_data.get("id_token", "") or "").strip(), "expires_in": token_data.get("expires_in"), "token_type": str(token_data.get("token_type") or "Bearer").strip() or "Bearer", } with _profile_scope(_oauth_session_profile(session_id)): _save_xai_oauth_tokens( tokens, discovery=discovery, last_refresh=datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"), auth_mode="oauth_device_code", # Persist credentials without hijacking an existing active # chat provider. set_active=False, ) # Mirror `hermes auth add xai-oauth`: first credential may become # active when none is set yet; never overwrite an existing choice. mark_provider_active_if_unset("xai-oauth") # The singleton write above is the single source of truth: the # credential-pool load seeds it as the canonical ``device_code`` # entry. Do NOT also insert a parallel ``manual:dashboard_*`` pool # entry — that duplicates the single-use refresh token across two # entries and triggers rotation churn / ``refresh_token_reused``. # An interactive dashboard login is also an explicit re-enable # signal, so clear any ``device_code`` suppression left by a # prior ``hermes auth remove xai-oauth`` (mirrors auth_add_command # and the ``hermes model`` re-login path in _login_xai_oauth). unsuppress_credential_source("xai-oauth", "device_code") with _oauth_sessions_lock: sess["status"] = "approved" _log.info("oauth/device: xai login completed (session=%s)", session_id) except Exception as e: _log.warning("xai device-code poll failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: sess["status"] = "error" sess["error_message"] = str(e) def _http_response_error_detail(resp: Any) -> str: """Best-effort extraction of a short provider error detail.""" try: payload = resp.json() except Exception: payload = None if isinstance(payload, dict): error = payload.get("error") if isinstance(error, dict): parts = [ str(error.get(key, "")).strip() for key in ("message", "error_description", "code", "type") if str(error.get(key, "")).strip() ] if parts: return ": ".join(parts) if isinstance(error, str) and error.strip(): return error.strip() for key in ("detail", "message", "error_description"): value = payload.get(key) if isinstance(value, str) and value.strip(): return value.strip() text = str(getattr(resp, "text", "") or "").strip() return text[:500] def _codex_device_code_start_error(resp: Any) -> str: """Dashboard-facing OpenAI Codex device-code start failure.""" status = getattr(resp, "status_code", "unknown") detail = _http_response_error_detail(resp) lower = detail.lower() if "device" in lower and ("authori" in lower or "enable" in lower): message = ( "OpenAI rejected the device-code login request. Your OpenAI " "account may need device-code authorization enabled before Hermes " "can start this dashboard login. Enable device-code authorization " "in OpenAI, then return here and click Login again." ) else: message = ( "OpenAI rejected the device-code login request. Please try Login " "again from the dashboard after checking your OpenAI account settings." ) if detail: return f"{message} (HTTP {status}: {detail})" return f"{message} (HTTP {status})" def _codex_full_login_worker(session_id: str) -> None: """Run the complete OpenAI Codex device-code flow. Codex doesn't use the standard OAuth device-code endpoints; it has its own ``/api/accounts/deviceauth/usercode`` (JSON body, returns ``device_auth_id``) and ``/api/accounts/deviceauth/token`` (JSON body polled until 200). On success the response carries an ``authorization_code`` + ``code_verifier`` that get exchanged at CODEX_OAUTH_TOKEN_URL with grant_type=authorization_code. The flow is replicated inline (rather than calling _codex_device_code_login) because that helper prints/blocks/polls in a single function — we need to surface the user_code to the dashboard the moment we receive it, well before polling completes. """ try: import httpx from hermes_cli.auth import ( CODEX_OAUTH_CLIENT_ID, CODEX_OAUTH_TOKEN_URL, ) issuer = "https://auth.openai.com" # Step 1: request device code with httpx.Client(timeout=httpx.Timeout(15.0)) as client: resp = client.post( f"{issuer}/api/accounts/deviceauth/usercode", json={"client_id": CODEX_OAUTH_CLIENT_ID}, headers={"Content-Type": "application/json"}, ) if resp.status_code != 200: raise RuntimeError(_codex_device_code_start_error(resp)) device_data = resp.json() user_code = device_data.get("user_code", "") device_auth_id = device_data.get("device_auth_id", "") poll_interval = max(3, int(device_data.get("interval", "5"))) if not user_code or not device_auth_id: raise RuntimeError("device-code response missing user_code or device_auth_id") verification_url = f"{issuer}/codex/device" with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: return sess["user_code"] = user_code sess["verification_url"] = verification_url sess["device_auth_id"] = device_auth_id sess["interval"] = poll_interval sess["expires_in"] = 15 * 60 # OpenAI's effective limit sess["expires_at"] = time.time() + sess["expires_in"] # Captured now (not re-derived after cancel pops the session) so a # cancelled session can never fall back to the caller's current # profile scope at save time. session_profile = sess.get("profile") # Step 2: poll until authorized deadline = time.monotonic() + sess["expires_in"] code_resp = None with httpx.Client(timeout=httpx.Timeout(15.0)) as client: while time.monotonic() < deadline: if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id) return time.sleep(poll_interval) if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled (session=%s)", session_id) return poll = client.post( f"{issuer}/api/accounts/deviceauth/token", json={"device_auth_id": device_auth_id, "user_code": user_code}, headers={"Content-Type": "application/json"}, ) if poll.status_code == 200: code_resp = poll.json() break if poll.status_code in {403, 404}: continue # user hasn't authorized yet raise RuntimeError(f"deviceauth/token poll returned {poll.status_code}") if code_resp is None: with _oauth_sessions_lock: sess["status"] = "expired" sess["error_message"] = "Device code expired before approval" return if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled before token exchange (session=%s)", session_id) return # Step 3: exchange authorization_code for tokens authorization_code = code_resp.get("authorization_code", "") code_verifier = code_resp.get("code_verifier", "") if not authorization_code or not code_verifier: raise RuntimeError("device-auth response missing authorization_code/code_verifier") with httpx.Client(timeout=httpx.Timeout(15.0)) as client: token_resp = client.post( CODEX_OAUTH_TOKEN_URL, data={ "grant_type": "authorization_code", "code": authorization_code, "redirect_uri": f"{issuer}/deviceauth/callback", "client_id": CODEX_OAUTH_CLIENT_ID, "code_verifier": code_verifier, }, headers={"Content-Type": "application/x-www-form-urlencoded"}, ) if token_resp.status_code != 200: raise RuntimeError(f"token exchange returned {token_resp.status_code}") tokens = token_resp.json() access_token = tokens.get("access_token", "") refresh_token = tokens.get("refresh_token", "") if not access_token: raise RuntimeError("token exchange did not return access_token") from hermes_cli.auth import _save_codex_tokens # The cancellation check and the save must be one atomic critical # section under the same lock cancel_oauth_session() uses. Checking # "cancelled" and then saving as two separate steps left a window # where DELETE could flip the flag between them and the worker would # still persist tokens after the user believed the login was # aborted. Holding the lock across both closes that window: DELETE # either lands before this section (worker observes cancelled and # returns) or blocks until this section (and the save) is done. with _oauth_sessions_lock: if sess.get("cancelled"): _log.info("oauth/device: openai-codex login cancelled before token save (session=%s)", session_id) return with _profile_scope(session_profile): _save_codex_tokens({ "access_token": access_token, "refresh_token": refresh_token, }) sess["status"] = "approved" _log.info("oauth/device: openai-codex login completed (session=%s)", session_id) except Exception as e: _log.warning("codex device-code worker failed (session=%s): %s", session_id, e) with _oauth_sessions_lock: s = _oauth_sessions.get(session_id) if s: s["status"] = "error" s["error_message"] = str(e) @app.post("/api/providers/oauth/{provider_id}/start") async def start_oauth_login( provider_id: str, request: Request, profile: Optional[str] = None, ): """Initiate an OAuth login flow. Token-protected.""" _require_token(request) _gc_oauth_sessions() _validate_oauth_profile(profile) valid = {p["id"] for p in _OAUTH_PROVIDER_CATALOG} if provider_id not in valid: raise HTTPException(status_code=400, detail=f"Unknown provider {provider_id}") catalog_entry = next(p for p in _OAUTH_PROVIDER_CATALOG if p["id"] == provider_id) if catalog_entry["flow"] == "external": raise HTTPException( status_code=400, detail=f"{provider_id} uses an external CLI; run `{catalog_entry['cli_command']}` manually", ) try: if catalog_entry["flow"] == "device_code": return await _start_device_code_flow(provider_id, profile=profile) except HTTPException: raise except Exception as e: _log.exception("oauth/start %s failed", provider_id) raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=400, detail="Unsupported flow") @app.post("/api/providers/oauth/{provider_id}/submit") async def submit_oauth_code( provider_id: str, body: OAuthSubmitBody, request: Request, profile: Optional[str] = None, ): """Submit the auth code for PKCE flows. Token-protected.""" _require_token(request) raise HTTPException(status_code=400, detail=f"submit not supported for {provider_id}") @app.get("/api/providers/oauth/{provider_id}/poll/{session_id}") async def poll_oauth_session( provider_id: str, session_id: str, profile: Optional[str] = None, ): """Poll a session's status (no auth — read-only state). Shared by the device-code flows (Nous, OpenAI Codex, MiniMax, xAI). Each surfaces progress through the same background-worker-updated ``status`` field, so a single poll endpoint serves them all. """ _validate_oauth_profile(profile) requested_profile = _oauth_profile_name(profile) with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if not sess: raise HTTPException(status_code=404, detail="Session not found or expired") if sess["provider"] != provider_id: raise HTTPException(status_code=400, detail="Provider mismatch for session") if sess.get("profile") != requested_profile: raise HTTPException(status_code=400, detail="OAuth session profile mismatch") return { "session_id": session_id, "status": sess["status"], "error_message": sess.get("error_message"), "expires_at": sess.get("expires_at"), } @app.delete("/api/providers/oauth/sessions/{session_id}") async def cancel_oauth_session( session_id: str, request: Request, profile: Optional[str] = None, ): """Cancel a pending OAuth session. Token-protected. Marks the session dict ``cancelled`` before popping it so any background worker still holding a reference to that same dict (e.g. the Codex device-code poller) observes the cancellation and stops polling/exchanging/saving instead of completing the login after the user believed it was aborted. """ _require_token(request) _validate_oauth_profile(profile) requested_profile = _oauth_profile_name(profile) with _oauth_sessions_lock: sess = _oauth_sessions.get(session_id) if sess is not None: if sess.get("profile") != requested_profile: raise HTTPException(status_code=400, detail="OAuth session profile mismatch") sess["cancelled"] = True _oauth_sessions.pop(session_id, None) if sess is None: return {"ok": False, "message": "session not found"} return {"ok": True, "session_id": session_id} # --------------------------------------------------------------------------- # Session detail endpoints # --------------------------------------------------------------------------- def _session_latest_descendant(session_id: str, db): """Resolve a session id to the newest child leaf session. /model may create child sessions. Dashboard refresh should continue the newest child instead of reopening the old parent. """ def row_get(row, key, index): if isinstance(row, dict): return row.get(key) try: return row[key] except Exception: try: return row[index] except Exception: return None sid = db.resolve_session_id(session_id) if not sid or not db.get_session(sid): return None, [] conn = ( getattr(db, "conn", None) or getattr(db, "_conn", None) or getattr(db, "connection", None) or getattr(db, "_connection", None) ) rows = [] if conn is not None: raw_rows = conn.execute( """ WITH RECURSIVE descendants(id, parent_session_id, started_at) AS ( SELECT id, parent_session_id, started_at FROM sessions WHERE id = ? UNION SELECT s.id, s.parent_session_id, s.started_at FROM sessions s JOIN descendants d ON s.parent_session_id = d.id ) SELECT id, parent_session_id, started_at FROM descendants """, (sid,), ).fetchall() for row in raw_rows: rows.append({ "id": row_get(row, "id", 0), "parent_session_id": row_get(row, "parent_session_id", 1), "started_at": row_get(row, "started_at", 2), }) else: rows = db.list_sessions_rich(limit=10000, offset=0, compact_rows=True) children = {} for row in rows: rid = row.get("id") parent = row.get("parent_session_id") if rid and parent: children.setdefault(parent, []).append(row) def started(row): try: return float(row.get("started_at") or 0) except Exception: return 0.0 current = sid path = [sid] seen = {sid} while children.get(current): candidates = [r for r in children[current] if r.get("id") not in seen] if not candidates: break candidates.sort(key=started, reverse=True) current = candidates[0]["id"] path.append(current) seen.add(current) return current, path # CRITICAL — every literal-path route below MUST be declared BEFORE the # templated ``/api/sessions/{session_id}`` family that follows. FastAPI/ # Starlette match routes in registration order, and the ``{session_id}`` # pattern is unconstrained — it would otherwise swallow e.g. # ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or # ``GET /api/sessions/stats`` as "operate on the session with id # 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse, # succeed and delete the wrong row). Same story as the older # ``/api/sessions/search`` endpoint up at line ~1191. If you split or # reorder this block, move every route in it together. # Keep the dashboard import endpoint stream-safe: FastAPI otherwise parses and # buffers an arbitrarily large JSON body before SessionDB can enforce its own # per-session and transaction-work limits. _SESSION_IMPORT_MAX_BYTES = 25 * 1024 * 1024 async def _read_session_import_body(request: Request) -> bytes: body = bytearray() async for chunk in request.stream(): if len(body) + len(chunk) > _SESSION_IMPORT_MAX_BYTES: raise HTTPException(status_code=413, detail="Session import payload is too large") body.extend(chunk) return bytes(body) def _import_sessions_for_profile(profile: Optional[str], sessions: List[Dict[str, Any]]) -> Dict[str, Any]: db = _open_session_db_for_profile(profile, read_only=False) try: return db.import_sessions(sessions) finally: db.close() app.include_router(_sessions_routes.manage_router) from hermes_cli.web_routers.sessions import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. bulk_delete_sessions_endpoint, import_sessions_endpoint, count_empty_sessions_endpoint, delete_empty_sessions_endpoint, get_session_stats, get_session_detail, get_session_latest_descendant, get_session_messages, delete_session_endpoint, rename_session_endpoint, export_session_endpoint, prune_sessions_endpoint, ) # Serialises the one-time writable schema bootstrap for read-only opens. # Concurrent first-load polls otherwise race sqlite file creation: the losers # open mode=ro against a store whose schema is still being written and every # query raises "no such table: sessions". _session_db_bootstrap_lock = threading.Lock() def _session_db_read_probe_statements() -> tuple: """Stale-schema probes for read-only opens, derived from SCHEMA_SQL. Read-only opens skip _reconcile_columns(), so an older store would otherwise 500 on every poll until something opened it writable. Derived from the same schema the writable reconciler applies, so any column added there is probed here automatically — the previous hand-written probe listed four columns and went stale the first time a new column (sessions.last_activity_at) shipped, leaving the desktop sidebar empty after `hermes update` until the first message forced a writable open. """ from hermes_state_schema import schema_read_probe_statements return schema_read_probe_statements() # Stores where a heal WRITABLE OPEN SUCCEEDED and the read probe still # failed afterwards: the schema problem is one reconciliation cannot fix # (e.g. a NOT-NULL-without-default column SQLite refuses to ADD). Retrying # the full writable init on every poll would hammer a live DB for nothing, # so such stores fall back to the raw read-only open until restart. A # FAILED writable open (transient lock) is deliberately NOT recorded — # the next poll retries the heal. _session_db_heal_exhausted: set = set() # Deduplicates the heal-failure warning per store per process, so a # persistent problem is loud once instead of once per sidebar poll. _session_db_heal_warned: set = set() def _open_session_db_at_path(db_path: Path, *, read_only: bool): """Open a SessionDB at an explicit path with an explicit access mode. Writable opens keep the full init and repair path. Read-only opens bootstrap a missing or zero-byte store once, and heal an older or malformed schema through one writable open before reopening read-only. The healthy read path never takes a write lock or requests a checkpoint. Scope of the heal: the probe checks every table/column declared in SCHEMA_SQL (see ``schema_read_probe_statements``), so ANY schema addition escalates a stale store to a one-time writable open — the same reconcile the store's own backend runs at startup. Tables created outside SCHEMA_SQL (telemetry ``tel_*``, FTS shadow tables) are deliberately outside both the probe and the heal. """ import sqlite3 from hermes_state import SessionDB, is_malformed_schema_error if not read_only: return SessionDB(db_path=db_path, read_only=False) def _needs_bootstrap() -> bool: try: return db_path.stat().st_size == 0 except FileNotFoundError: return True except OSError: return False if _needs_bootstrap(): with _session_db_bootstrap_lock: if _needs_bootstrap(): SessionDB(db_path=db_path, read_only=False).close() def _open_probed(): db = SessionDB(db_path=db_path, read_only=True) # Unit-test fakes may replace SessionDB without exposing a raw # connection. Probe only real connections. conn = getattr(db, "_conn", None) if conn is not None and str(db_path) not in _session_db_heal_exhausted: try: for statement in _session_db_read_probe_statements(): conn.execute(statement).fetchone() except BaseException: db.close() raise return db try: return _open_probed() except (sqlite3.DatabaseError, UnicodeDecodeError) as exc: message = str(exc).lower() stale_schema = "no such table" in message or "no such column" in message if not stale_schema and not ( # UnicodeDecodeError = pysqlite could not decode SQLite's own # error message because corrupt file bytes were embedded in it # (#98924). The one-writable-open heal is the only repair path, # so route it through the same dispatch as malformed schema. is_malformed_schema_error(exc) or isinstance(exc, UnicodeDecodeError) ): raise SessionDB(db_path=db_path, read_only=False).close() try: return _open_probed() except (sqlite3.DatabaseError, UnicodeDecodeError) as still_stale: message = str(still_stale).lower() if "no such table" not in message and "no such column" not in message: raise # The writable open succeeded but the store is STILL behind the # probe: reconciliation cannot fix this one. Serve reads without # the probe (queries touching the broken part will still fail, # everything else works) and stop paying the writable init per # poll. _session_db_heal_exhausted.add(str(db_path)) if str(db_path) not in _session_db_heal_warned: _session_db_heal_warned.add(str(db_path)) _log.warning( "state.db at %s is missing schema that a writable " "reconcile could not add (%s); read paths may partially " "fail until the store is repaired", db_path, still_stale, ) return _open_probed() def _open_session_db_for_profile(profile: Optional[str], *, read_only: bool): """Open a SessionDB with an explicit access mode for a profile. ``profile`` None/empty selects this process's own ``state.db``. A named profile opens that profile's on-disk store directly. Access-mode semantics are documented on :func:`_open_session_db_at_path`. """ from hermes_state import _default_db_path if profile: _name, home = _cron_profile_home(profile) db_path = Path(home) / "state.db" else: db_path = Path(_default_db_path()) return _open_session_db_at_path(db_path, read_only=read_only) # In-process throttle for the opportunistic auto-archive trigger, keyed by # profile. Bounds the config.yaml read to at most once per this window per # profile; the actual sweep is throttled far more coarsely by state_meta # (sessions.min_interval_hours) inside maybe_auto_archive. _AUTO_ARCHIVE_CHECK_INTERVAL_S = 300.0 _last_auto_archive_check: Dict[str, float] = {} def _maybe_auto_archive_for_profile(profile: Optional[str]) -> None: """Run the config-gated stale-session auto-archive for ``profile``. The Desktop backend is spawned as ``hermes serve`` — it runs neither the interactive CLI nor the messaging gateway, so neither of those startup hooks fire for Desktop users. Triggering the (double-throttled, config-off by default) sweep from the session-list path is what makes ``sessions.auto_archive`` take effect there. Never raises. """ try: key = profile or "" now = time.monotonic() last = _last_auto_archive_check.get(key) if last is not None and now - last < _AUTO_ARCHIVE_CHECK_INTERVAL_S: return _last_auto_archive_check[key] = now from hermes_cli.config import load_config as _load_full_config cfg = (_load_full_config().get("sessions") or {}) if not cfg.get("auto_archive", False): return db = _open_session_db_for_profile(profile, read_only=False) try: db.maybe_auto_archive( idle_days=float(cfg.get("auto_archive_days", 3)), min_interval_hours=int(cfg.get("min_interval_hours", 24)), ) finally: db.close() except Exception as exc: _log.debug("opportunistic auto-archive skipped: %s", exc) async def _auto_archive_ticker_loop( interval_s: float = 3600.0, initial_delay_s: float = 90.0 ) -> None: """Live timer for the stale-session auto-archive (primary profile). A long-running Desktop/serve backend must keep sweeping on schedule even when no ``/api/sessions`` request arrives to fire the opportunistic trigger — e.g. the app sits open for days on an idle chat. The real cadence is still owned by state_meta (``sessions.min_interval_hours``) inside ``maybe_auto_archive``; this loop is only the poll rate. """ def _sweep() -> None: _maybe_auto_archive_for_profile(None) await asyncio.sleep(initial_delay_s) while True: try: await asyncio.to_thread(_sweep) except Exception as exc: _log.debug("auto-archive tick skipped: %s", exc) await asyncio.sleep(interval_s) def _prune_sessions(body: SessionPrune): """Delete ended sessions matching filters (mirrors `hermes sessions prune`).""" has_window = ( body.started_before is not None or body.started_after is not None ) if body.older_than_days is not None and body.older_than_days < 1 and not has_window: raise HTTPException(status_code=400, detail="older_than_days must be >= 1") # Mirror the CLI: the implicit 90-day cutoff only applies to a truly bare # prune. Any attribute filter (source, title, model, ...) suppresses it # unless the caller explicitly sent older_than_days. _attr_filters_set = any( getattr(body, f) is not None for f in ( "source", "title_like", "end_reason", "cwd_prefix", "min_messages", "max_messages", "model_like", "provider", "user_id", "chat_id", "chat_type", "branch_like", "min_tokens", "max_tokens", "min_cost", "max_cost", "min_tool_calls", "max_tool_calls", ) ) _older_than_explicit = "older_than_days" in body.model_fields_set _effective_older_than = body.older_than_days if has_window or (_attr_filters_set and not _older_than_explicit): _effective_older_than = None profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home() db = _open_session_db_for_profile(body.profile, read_only=False) try: filters = dict( older_than_days=_effective_older_than, source=(body.source or None), started_before=body.started_before, started_after=body.started_after, title_like=(body.title_like or None), end_reason=(body.end_reason or None), cwd_prefix=(body.cwd_prefix or None), min_messages=body.min_messages, max_messages=body.max_messages, model_like=(body.model_like or None), provider=(body.provider or None), user_id=(body.user_id or None), chat_id=(body.chat_id or None), chat_type=(body.chat_type or None), branch_like=(body.branch_like or None), min_tokens=body.min_tokens, max_tokens=body.max_tokens, min_cost=body.min_cost, max_cost=body.max_cost, min_tool_calls=body.min_tool_calls, max_tool_calls=body.max_tool_calls, archived=None if body.include_archived else False, ) skipped_open = db.count_open_prune_matches(**filters) if body.dry_run: rows = db.list_prune_candidates(**filters) return { "ok": True, "removed": 0, "matched": len(rows), "skipped_open": skipped_open, # Rows are ordered by last activity, not creation time. "oldest_last_active": rows[0]["last_active"] if rows else None, "newest_last_active": rows[-1]["last_active"] if rows else None, "oldest_started_at": ( min(r["started_at"] for r in rows) if rows else None ), "newest_started_at": ( max(r["started_at"] for r in rows) if rows else None ), "sessions": [ { "id": r["id"], "source": r["source"], "title": r.get("title"), "model": r.get("model"), "started_at": r["started_at"], "last_active": r["last_active"], "message_count": r["message_count"], } for r in rows ], } sessions_dir = profile_home / "sessions" removed = db.prune_sessions( sessions_dir=sessions_dir if sessions_dir.exists() else None, **filters, ) return {"ok": True, "removed": removed, "skipped_open": skipped_open} finally: db.close() # --------------------------------------------------------------------------- # Log viewer endpoint # --------------------------------------------------------------------------- @app.get("/api/logs") async def get_logs( file: str = "agent", lines: int = 100, level: Optional[str] = None, component: Optional[str] = None, search: Optional[str] = None, ): from hermes_cli.logs import _read_tail, LOG_FILES log_name = LOG_FILES.get(file) if not log_name: raise HTTPException(status_code=400, detail=f"Unknown log file: {file}") log_path = get_hermes_home() / "logs" / log_name if not log_path.exists(): return {"file": file, "lines": []} try: from hermes_logging import COMPONENT_PREFIXES except ImportError: COMPONENT_PREFIXES = {} # Normalize "ALL" / "all" / empty → no filter. _matches_filters treats an # empty tuple as "must match a prefix" (startswith(()) is always False), # so passing () instead of None silently drops every line. min_level = level if level and level.upper() != "ALL" else None if component and component.lower() != "all": comp_prefixes = COMPONENT_PREFIXES.get(component) if comp_prefixes is None: raise HTTPException( status_code=400, detail=f"Unknown component: {component}. " f"Available: {', '.join(sorted(COMPONENT_PREFIXES))}", ) else: comp_prefixes = None has_filters = bool(min_level or comp_prefixes or search) result = _read_tail( log_path, min(lines, 500) if not search else 2000, has_filters=has_filters, min_level=min_level, component_prefixes=comp_prefixes, ) # Post-filter by search term (case-insensitive substring match). # _read_tail doesn't support free-text search, so we filter here and # trim to the requested line count afterward. if search: needle = search.lower() result = [l for l in result if needle in l.lower()][-min(lines, 500):] return {"file": file, "lines": result} # --------------------------------------------------------------------------- # Cron job management endpoints # --------------------------------------------------------------------------- def _cron_optional_text(value: Any, *, strip_trailing_slash: bool = False) -> Optional[str]: if value is None: return None text = str(value).strip() if strip_trailing_slash: text = text.rstrip("/") return text or None def _cron_string_list(value: Any) -> Optional[List[str]]: if value is None: return None if isinstance(value, str): raw_items = re.split(r"[\n,]", value) elif isinstance(value, (list, tuple)): raw_items = value else: return None items = [str(item).strip() for item in raw_items if str(item).strip()] return items or None def _normalize_dashboard_cron_script(value: Any, profile_home: Path) -> Optional[str]: """Validate a dashboard-selected cron script against the profile sandbox.""" text = _cron_optional_text(value) if not text: return None scripts_root = (profile_home / "scripts").resolve() raw_path = Path(text).expanduser() candidate = raw_path.resolve() if raw_path.is_absolute() else (scripts_root / raw_path).resolve() try: relative = candidate.relative_to(scripts_root) except ValueError as exc: raise HTTPException( status_code=400, detail=f"script must be inside {scripts_root}", ) from exc if not candidate.exists(): raise HTTPException(status_code=400, detail=f"script does not exist: {candidate}") if not candidate.is_file(): raise HTTPException(status_code=400, detail=f"script is not a file: {candidate}") return str(relative) def _validate_dashboard_cron_effective_job(job: Dict[str, Any]) -> None: prompt = _cron_optional_text(job.get("prompt")) script = _cron_optional_text(job.get("script")) skills = _cron_string_list(job.get("skills")) or _cron_string_list(job.get("skill")) no_agent = bool(job.get("no_agent")) if no_agent: if not script: raise HTTPException( status_code=400, detail="no_agent=True requires a script", ) return if not (prompt or skills or script): raise HTTPException( status_code=400, detail="agent cron jobs require a prompt, skill, or script", ) def _normalize_dashboard_cron_updates( updates: Dict[str, Any], profile_home: Path, ) -> Dict[str, Any]: """Normalize dashboard JSON into cron.jobs.update_job's storage shape. This intentionally stays in the dashboard adapter layer: cron/jobs.py is the source of truth for scheduling behaviour; the dashboard only translates form payloads into the shapes that existing core functions already accept. """ normalized = dict(updates or {}) for key in ("model", "provider", "workdir"): if key in normalized: normalized[key] = _cron_optional_text(normalized[key]) if "script" in normalized: normalized["script"] = _normalize_dashboard_cron_script( normalized["script"], profile_home, ) if "base_url" in normalized: normalized["base_url"] = _cron_optional_text( normalized["base_url"], strip_trailing_slash=True ) if "deliver" in normalized: normalized["deliver"] = _cron_optional_text(normalized["deliver"]) or "local" if "failure_deliver" in normalized: # Same text normalization as deliver, but empty CLEARS the override # (failures fall back to deliver) rather than coalescing to a target # — the field is optional by design (NS-788). normalized["failure_deliver"] = _cron_optional_text( normalized["failure_deliver"] ) if "context_from" in normalized: normalized["context_from"] = _cron_string_list(normalized["context_from"]) if "enabled_toolsets" in normalized: normalized["enabled_toolsets"] = _cron_string_list(normalized["enabled_toolsets"]) return normalized def _validate_dashboard_cron_context_from( refs: Optional[List[str]], profile_name: str, ) -> None: if not refs: return for ref in refs: # "self" (the continuity toggle) resolves to the job's own id at run # time — it can't be validated against the store (create precedes the # job's existence). if isinstance(ref, str) and ref.strip().lower() == "self": continue if not _call_cron_for_profile(profile_name, "get_job", ref): raise HTTPException( status_code=400, detail=( f"context_from job '{ref}' not found in profile " f"'{profile_name}'" ), ) def _cron_profile_dicts() -> List[Dict[str, Any]]: """Return the minimal profile records needed by cron aggregation. The two callers only consume ``name``. ``list_profiles()`` also parses config/distribution metadata, probes gateway processes, and counts skills for every profile; polling cron jobs through that path creates avoidable GIL pressure on large profile pools. """ from hermes_cli import profiles as profiles_mod try: return [ { "name": name, "path": str(home), "is_default": name == "default", } for name, home in profiles_mod.profiles_to_serve(multiplex=True) ] except Exception: _log.exception("Failed to list profiles for cron dashboard; falling back to directory scan") return _fallback_profile_dicts(profiles_mod) def _cron_default_profile() -> str: """Profile to target when a cron request carries no explicit ``profile``. A desktop pool backend runs one process per profile (HERMES_HOME already scoped), but these cron endpoints deliberately route storage through the profiles tree via ``_cron_profile_home`` — so a hardcoded ``"default"`` fallback would write a non-default profile's job into ``~/.hermes``. Resolve the process's own profile instead. ``custom`` (an unrecognized HERMES_HOME outside the profiles tree) has no profile-dir equivalent, so it keeps the legacy ``default`` fallback. """ try: from hermes_cli.profiles import get_active_profile_name name = get_active_profile_name() except Exception: return "default" return "default" if name in ("default", "custom") else name def _cron_profile_home(profile: Optional[str]) -> Tuple[str, Path]: """Resolve a profile query value to (profile_name, HERMES_HOME).""" from hermes_cli import profiles as profiles_mod raw = (profile or _cron_default_profile()).strip() or "default" try: canon = profiles_mod.normalize_profile_name(raw) profiles_mod.validate_profile_name(canon) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not profiles_mod.profile_exists(canon): raise HTTPException(status_code=404, detail=f"Profile '{canon}' does not exist.") return canon, profiles_mod.get_profile_dir(canon) def _annotate_cron_job(job: Dict[str, Any], profile: str, home: Path) -> Dict[str, Any]: annotated = dict(job) annotated["profile"] = profile annotated["profile_name"] = profile annotated["hermes_home"] = str(home) annotated["is_default_profile"] = profile == "default" return annotated def _call_cron_for_profile(target_profile: Optional[str], func_name: str, *args, **kwargs): """Run cron.jobs helpers against the selected profile's cron directory. The dashboard is a single process that can inspect many profiles. Route storage through cron.jobs' execution-context override so dashboard calls cannot retarget a concurrent desktop ticker's load/save transaction. """ profile_name, home = _cron_profile_home(target_profile) from cron import jobs as cron_jobs from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) token = set_hermes_home_override(str(home)) try: with cron_jobs.use_cron_store(home): if func_name == "create_job": from cron.scheduler import create_job_with_scheduler_registration result = create_job_with_scheduler_registration(*args, **kwargs) else: result = getattr(cron_jobs, func_name)(*args, **kwargs) finally: reset_hermes_home_override(token) if isinstance(result, list): return [_annotate_cron_job(j, profile_name, home) for j in result] if isinstance(result, dict): return _annotate_cron_job(result, profile_name, home) return result def _notify_cron_provider_for_profile(target_profile: Optional[str]) -> None: """Best-effort provider reconcile against one profile's job store. Fail-closed for external providers on a multi-profile dashboard: an external provider's ``reconcile`` converges its REMOTE registry toward one profile's jobs.json, and its orphan cleanup cancels every remote entry absent from that store. The NAS registry is not profile-scoped, so reconciling profile B would silently disarm profile A's one-shots. Until the provider contract carries a profile identity through arm/cancel/list, a multi-profile dashboard must not drive unscoped external reconciles at all — the affected profile simply re-arms on its next fire/start (idempotent via dedup_key). The built-in provider re-reads jobs.json each tick and stays a no-op here. """ try: _profile_name, home = _cron_profile_home(target_profile) from cron import jobs as cron_jobs from cron.scheduler_provider import ( InProcessCronScheduler, resolve_cron_scheduler, ) from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) token = set_hermes_home_override(str(home)) try: with cron_jobs.use_cron_store(home): provider = resolve_cron_scheduler() if not isinstance(provider, InProcessCronScheduler): profile_names = [ str(p.get("name") or "") for p in _cron_profile_dicts() ] if len([n for n in profile_names if n]) > 1: _log.warning( "Skipping cron provider reconcile for profile %s: " "external provider '%s' reconcile is not " "profile-scoped and would disarm other profiles' " "armed one-shots. The mutated profile re-arms " "idempotently on its next fire/start.", target_profile, provider.name, ) return provider.on_jobs_changed() finally: reset_hermes_home_override(token) except Exception: _log.debug( "Cron provider reconciliation failed for profile %s", target_profile, exc_info=True, ) def _mutate_cron_for_profile( target_profile: Optional[str], func_name: str, *args, **kwargs ): """Apply a cron store mutation and reconcile its scheduler provider.""" result = _call_cron_for_profile(target_profile, func_name, *args, **kwargs) if result: _notify_cron_provider_for_profile(target_profile) return result def _find_cron_job_profile(job_id: str) -> Optional[str]: for profile in _cron_profile_dicts(): name = str(profile.get("name") or "") if not name: continue jobs = _call_cron_for_profile(name, "list_jobs", True) if any(j.get("id") == job_id or j.get("name") == job_id for j in jobs): return name return None def _list_cron_jobs_sync(profile: str = "all"): requested = (profile or "all").strip() if requested.lower() != "all": return _call_cron_for_profile(requested, "list_jobs", True) jobs: List[Dict[str, Any]] = [] for item in _cron_profile_dicts(): name = str(item.get("name") or "") if not name: continue try: jobs.extend(_call_cron_for_profile(name, "list_jobs", True)) except Exception: _log.exception("Failed to list cron jobs for profile %s", name) return jobs async def _run_cron_dashboard_io(func, *args, **kwargs): """Run cron dashboard profile/job I/O outside the FastAPI event loop.""" if inspect.iscoroutinefunction(func): raise TypeError("_run_cron_dashboard_io only accepts sync callables") result = await run_in_threadpool(func, *args, **kwargs) if inspect.isawaitable(result): raise TypeError("_run_cron_dashboard_io sync callable returned an awaitable") return result def _raise_if_cron_registration_error(e: Exception) -> None: """Re-raise a cron partial-failure (job saved, external scheduler registration failed) as HTTP 424 with the structured envelope. Shared by every dashboard cron-create surface so the contract can't drift between copies. The lazy import keeps cron out of module import. """ from cron.scheduler import CronSchedulerRegistrationError if isinstance(e, CronSchedulerRegistrationError): raise HTTPException(status_code=424, detail=e.to_dict()) from e from hermes_cli.web_routers import cron as _cron_routes # noqa: E402 app.include_router(_cron_routes.router) from hermes_cli.web_routers.cron import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. list_cron_jobs, get_cron_job, list_cron_job_runs, create_cron_job, get_cron_delivery_targets, update_cron_job, pause_cron_job, resume_cron_job, trigger_cron_job, delete_cron_job, cron_fire_webhook, list_cron_blueprints, instantiate_blueprint, ) def _get_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "get_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job def _list_cron_job_runs_sync(job_id: str, profile: Optional[str] = None, limit: int = 20): """Run sessions produced by a cron job, newest first. Cron runs are stored as ordinary sessions whose id is ``cron_{job_id}_{timestamp}`` (see cron/scheduler.run_job). A job's history is therefore every session whose id carries that prefix; ``source='cron'`` narrows it and the id prefix binds it to this job. Powers the run-history list under each job in the desktop cron detail. Same row shape as ``/api/sessions`` so the frontend can reuse SessionInfo. Backed by ``SessionDB.list_cron_job_runs`` — a bounded ``[prefix, hi)`` id-range scan, not the compression-chain CTE used for the recents list, so the cost scales with the requested window and not the (unbounded) total cron history. """ selected = profile or _find_cron_job_profile(job_id) # job_id may be a human name; resolve to the canonical id used in run-session ids. canonical = job_id if selected: job = _call_cron_for_profile(selected, "get_job", job_id) if job and job.get("id"): canonical = str(job["id"]) try: limit_n = max(1, min(int(limit), 100)) except (TypeError, ValueError): limit_n = 20 db = _open_session_db_for_profile(selected, read_only=True) try: runs = db.list_cron_job_runs(canonical, limit=limit_n, offset=0) now = time.time() for s in runs: s["is_active"] = ( s.get("ended_at") is None and (now - s.get("last_active", s.get("started_at", 0))) < 300 ) s["archived"] = bool(s.get("archived")) if selected: s["profile"] = selected return {"runs": runs, "limit": limit_n} finally: db.close() def _create_cron_job_sync(body: CronJobCreate, profile: Optional[str] = None): try: profile_name, profile_home = _cron_profile_home(profile) script = _normalize_dashboard_cron_script(body.script, profile_home) skills = _cron_string_list(body.skills) context_from = _cron_string_list(body.context_from) _validate_dashboard_cron_context_from(context_from, profile_name) no_agent = bool(body.no_agent) _validate_dashboard_cron_effective_job({ "prompt": body.prompt, "skills": skills, "script": script, "no_agent": no_agent, }) return _mutate_cron_for_profile( profile_name, "create_job", prompt=body.prompt or "", schedule=body.schedule, name=body.name, deliver=_cron_optional_text(body.deliver) or "local", skills=skills, model=_cron_optional_text(body.model), provider=_cron_optional_text(body.provider), base_url=_cron_optional_text(body.base_url, strip_trailing_slash=True), script=script, context_from=context_from, enabled_toolsets=_cron_string_list(body.enabled_toolsets), workdir=_cron_optional_text(body.workdir), no_agent=no_agent, ) except HTTPException: raise except Exception as e: _raise_if_cron_registration_error(e) _log.exception("POST /api/cron/jobs failed") raise HTTPException(status_code=400, detail=str(e)) def _update_cron_job_sync(job_id: str, body: CronJobUpdate, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") try: profile_name, profile_home = _cron_profile_home(selected) existing = _call_cron_for_profile(profile_name, "get_job", job_id) if not existing: raise HTTPException(status_code=404, detail="Job not found") updates = _normalize_dashboard_cron_updates( body.updates, profile_home, ) if "context_from" in updates: _validate_dashboard_cron_context_from( updates.get("context_from"), profile_name, ) execution_fields = {"prompt", "skill", "skills", "script", "no_agent"} if execution_fields.intersection(updates): effective = {**existing, **updates} if "skills" in updates and "skill" not in updates: effective["skill"] = None _validate_dashboard_cron_effective_job(effective) job = _mutate_cron_for_profile(profile_name, "update_job", job_id, updates) except HTTPException: raise except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not job: raise HTTPException(status_code=404, detail="Job not found") return job def _pause_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _mutate_cron_for_profile(selected, "pause_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job def _resume_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _mutate_cron_for_profile(selected, "resume_job", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") return job def _trigger_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") job = _call_cron_for_profile(selected, "resolve_job_ref", job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") # Do not expose the job as due before claiming it: the built-in ticker and # external/manual fire paths share the same durable claim, so only one can # execute this selected run even if they race across processes. Active jobs # keep the legacy provider call shape; paused jobs need the explicit force # flag to resume and claim atomically. force = not job.get("enabled", True) or job.get("state") == "paused" ran = _fire_cron_job_for_profile(selected, job["id"], force=force) refreshed = _call_cron_for_profile(selected, "get_job", job["id"]) if refreshed and refreshed.get("last_run_at") != job.get("last_run_at"): return refreshed if not ran: raise HTTPException( status_code=409, detail="Job is already running or was claimed by another scheduler", ) if refreshed: return refreshed # A one-shot may remove itself after exhausting repeat=1. Keep the response # shape compatible without inventing an outcome that is no longer present # in the job store; authoritative list refresh removes the completed row. return { **job, "enabled": False, "state": "completed", } def _delete_cron_job_sync(job_id: str, profile: Optional[str] = None): selected = profile or _find_cron_job_profile(job_id) if not selected: raise HTTPException(status_code=404, detail="Job not found") try: removed = _mutate_cron_for_profile(selected, "remove_job", job_id) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc if not removed: raise HTTPException(status_code=404, detail="Job not found") return {"ok": True} def _fire_cron_job_for_profile( profile: str, job_id: str, *, force: bool = False, ) -> bool: """DEPRECATED for NAS webhook fires (superseded by gateway forwarding); retained for the dashboard trigger path — do not add new uses. Run ONE due cron job end-to-end for ``profile`` via the resolved scheduler provider's ``fire_due`` (store CAS claim + ``run_one_job``). Superseded by :func:`_forward_cron_fire_to_gateway`: cron fires must execute in the GATEWAY process (which owns the live platform adapters), not the dashboard. Executing here delivered through the standalone path only, which cannot serve relay-fronted logical platforms (their only sender is the live relay adapter — no native credential exists on the box) or E2EE rooms. Kept temporarily because external callers may still resolve it via the web_deps late-binding seam. """ _profile_name, home = _cron_profile_home(profile) from cron import jobs as cron_jobs from cron.scheduler_provider import ( provider_supports_force_fire, resolve_cron_scheduler, ) from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) token = set_hermes_home_override(str(home)) try: with cron_jobs.use_cron_store(home): provider = resolve_cron_scheduler() if force: if not provider_supports_force_fire(provider): raise HTTPException( status_code=409, detail=( f"Cron provider '{getattr(provider, 'name', 'custom')}' " "does not support atomic forced firing of paused jobs" ), ) return bool( provider.fire_due(job_id, adapters=None, loop=None, force=True) ) return bool(provider.fire_due(job_id, adapters=None, loop=None)) finally: reset_hermes_home_override(token) def _profile_env_value(home: Path, key: str) -> str: """Best-effort read of one KEY=VALUE line from a profile's .env file.""" try: env_path = home / ".env" if not env_path.is_file(): return "" for line in env_path.read_text(encoding="utf-8").splitlines(): line = line.strip() if not line or line.startswith("#") or "=" not in line: continue k, v = line.split("=", 1) if k.strip() == key: return v.strip().strip('"').strip("'") except Exception: pass return "" def _gateway_fire_endpoint(profile: str, home: Path) -> str: """Resolve the loopback URL of the gateway api_server's cron-fire route. Port resolution mirrors gateway/config.py's api_server load order for the LISTENER-OWNER profile: ``platforms.api_server.extra.port`` in that profile's config.yaml, then ``API_SERVER_PORT`` (process env for the active profile, the profile's own .env otherwise), then the adapter default 8642. The bind host is the adapter's loopback default — the dashboard and gateway share a network namespace in every supported deployment (same host process tree, or the same container under s6). Multiplex mode (one gateway serving several profiles) exposes per-profile mirrors under ``/p//…``, so a non-default profile routes through the default gateway's port with that prefix — only the DEFAULT profile's api_server is bound in that mode, so the port must be read from the default home, never the target profile's (a secondary's own ``API_SERVER_PORT`` is a port nothing listens on). Per-profile-gateway mode (each profile its own process/port) uses the bare path on the profile's own port. """ import os as _os multiplex = False try: from gateway.config import _env_multiplex_profiles_override cfg = load_config() multiplex = bool(cfg_get(cfg, "gateway", "multiplex_profiles", default=False)) env_flag = _env_multiplex_profiles_override() if env_flag is not None: multiplex = env_flag except Exception: _log.debug("cron fire: multiplex detection failed; assuming single-profile", exc_info=True) listener_profile, listener_home = profile, home if multiplex and profile != "default": from hermes_constants import get_default_hermes_root listener_profile, listener_home = "default", get_default_hermes_root() _log.info( "cron fire: multiplex gateway — resolving api_server port for %s " "from the default profile's listener (%s)", profile, listener_home, ) port = 0 try: # Profile-scoped read through the CANONICAL loader (managed-scope # overlay, ${ENV_VAR} expansion, profile pathing) — never a raw # yaml.safe_load of config.yaml (tests/hermes_cli/ # test_config_read_guard.py). The HERMES_HOME override scopes # get_config_path() to the LISTENER-OWNER profile, same pattern the # deprecated _fire_cron_job_for_profile used for its store scope. from hermes_constants import ( reset_hermes_home_override, set_hermes_home_override, ) token = set_hermes_home_override(str(listener_home)) try: profile_cfg = load_config() finally: reset_hermes_home_override(token) raw = cfg_get( profile_cfg, "platforms", "api_server", "extra", "port", default=None ) if raw: port = int(raw) except Exception: port = 0 if not port: raw = ( _os.getenv("API_SERVER_PORT", "") if listener_profile == _cron_default_profile() else _profile_env_value(listener_home, "API_SERVER_PORT") ) try: port = int(raw) if raw else 0 except ValueError: port = 0 if not port: port = 8642 if multiplex and profile != "default": return f"http://127.0.0.1:{port}/p/{profile}/api/cron/fire" return f"http://127.0.0.1:{port}/api/cron/fire" async def _forward_cron_fire_to_gateway( profile: str, job_id: str, authorization: str ) -> Optional[Tuple[int, Dict[str, Any]]]: """Forward a Chronos fire callback to the gateway api_server on loopback. The dashboard is the hosted deployment's only public HTTP door (Fly proxy → internal_port 9119), but cron execution belongs to the GATEWAY process: it owns the live platform adapters, so delivery works for relay-fronted logical platforms and E2EE rooms — the standalone path the dashboard used to run cannot serve either. This forwards the fire byte-preserved (same job_id, same NAS bearer — the gateway re-verifies the JWT itself) and passes the gateway's response through. Returns ``(status_code, body)`` from the gateway, or ``None`` when the gateway is unreachable (not started yet after a scale-to-zero wake, restarting, or api_server disabled) — the caller maps that to 503 so NAS retries per the Chronos contract (non-2xx = retryable; the store CAS de-dupes the eventual double fire), UNLESS the profile's gateway was deliberately stopped (see :func:`_gateway_intentionally_stopped`), in which case the caller drops the fire with 200 — retrying into an operator-stopped gateway can never succeed and only burns scheduler retries (OOF-266). """ _profile_name, home = _cron_profile_home(profile) url = _gateway_fire_endpoint(_profile_name, home) import httpx try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( url, json={"job_id": job_id}, headers={"Authorization": authorization}, ) except Exception as exc: _log.warning( "cron fire forward to %s failed (%s: %s); returning 503 for NAS retry", url, type(exc).__name__, exc, ) return None try: body = resp.json() except Exception: body = {"raw": (resp.text or "")[:500]} if not isinstance(body, dict): body = {"raw": body} return resp.status_code, body def _gateway_intentionally_stopped(profile: Optional[str]) -> bool: """True when the profile's gateway is stopped BY OPERATOR INTENT. Reads the durable ``desired_state`` field of the profile's ``gateway_state.json`` — written exclusively by the s6 lifecycle commands (``hermes gateway stop`` persists ``"stopped"``; start and restart persist ``"running"``, see service_manager's ``_write_gateway_desired_state``). This is the same operator-intent signal container-boot reconciliation trusts, and it is precisely NOT set to "stopped" during transient windows (crash loops, drains, scale-to-zero wakes, restarts) — so it cleanly splits "retry will eventually succeed" from "retry can never succeed". Deliberately does NOT fall back to the volatile ``gateway_state`` runtime field: a legacy file without ``desired_state`` (or a gateway that crashed before persisting) must stay on the retryable-503 path. Failing open to "not intentionally stopped" is the safe direction — the worst case is retries against a dead gateway, which is exactly today's behavior. Exception-safe: any resolution or parse failure returns False. """ import json as _json try: _name, home = _cron_profile_home(profile) state_file = home / "gateway_state.json" if not state_file.exists(): return False data = _json.loads(state_file.read_text(encoding="utf-8")) if not isinstance(data, dict): return False return data.get("desired_state") == "stopped" except Exception: return False # --------------------------------------------------------------------------- # Automation Blueprints — parameterized automation blueprints. The dashboard renders the # slot schema as a form; submitting instantiates a real cron job via the same # create_job path. See cron/blueprint_catalog.py for the single source of truth. # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # MCP server endpoints — list / add / remove / test. # # Wraps the same config data layer the CLI uses (hermes_cli.mcp_config), so # servers managed here show up under `hermes mcp list` and vice versa. Secrets # in stdio `env` blocks are redacted on read; the agent picks them up from # config.yaml at session start exactly as with CLI-added servers. # --------------------------------------------------------------------------- def _normalize_mcp_server_create( body: MCPServerCreate, ) -> tuple[str, Dict[str, Any], Optional[str]]: """Validate a Dashboard MCP create request and build its safe config. The returned config never contains the submitted Bearer token. Callers persist the token with the shared Bearer helper only after they enter the intended profile scope. Keeping this conversion shared makes the standalone MCP page and the Profile Builder enforce the same transport/auth contract. """ from hermes_cli.mcp_config import ( _bearer_auth_headers, _strip_bearer_prefix, ) from hermes_cli.mcp_security import validate_mcp_server_entry name = (body.name or "").strip() if not name: raise ValueError("Server name is required") url = (body.url or "").strip() command = (body.command or "").strip() auth = (body.auth or "none").strip().lower() bearer_token = ( body.bearer_token.get_secret_value() if body.bearer_token is not None else None ) if bool(url) == bool(command): raise ValueError("Provide exactly one of URL (HTTP/SSE) or command (stdio)") if auth not in {"none", "header", "oauth"}: raise ValueError(f"Unsupported auth mode: {auth}") server_config: Dict[str, Any] = {} if url: if body.args: raise ValueError("Arguments are only supported for stdio MCP servers") if body.env: raise ValueError( "Environment variables are only supported for stdio MCP servers" ) if auth == "header": normalized = _strip_bearer_prefix(bearer_token) if bearer_token else "" if not normalized or normalized.lower() == "bearer": raise ValueError("Bearer token is required") server_config["headers"] = _bearer_auth_headers(name) elif body.bearer_token is not None: raise ValueError("Bearer token requires header authentication") server_config["url"] = url if auth == "oauth": server_config["auth"] = "oauth" else: if auth != "none" or body.bearer_token is not None: raise ValueError( "HTTP authentication is not supported for stdio MCP servers" ) server_config["command"] = command if body.args: server_config["args"] = list(body.args) if body.env: server_config["env"] = dict(body.env) issues = validate_mcp_server_entry(name, server_config) if issues: raise ValueError(f"Server '{name}' rejected: {'; '.join(issues)}") return name, server_config, bearer_token def _redact_mcp_env(env: Dict[str, Any]) -> Dict[str, str]: """Mask secret-shaped MCP env values for read responses.""" out: Dict[str, str] = {} for k, v in (env or {}).items(): try: out[str(k)] = redact_key(str(v)) if v else "" except Exception: out[str(k)] = "***" return out def _mcp_server_summary(name: str, cfg: Dict[str, Any]) -> Dict[str, Any]: transport = "http" if cfg.get("url") else ("stdio" if cfg.get("command") else "unknown") auth = cfg.get("auth") headers = cfg.get("headers") or {} if not auth and isinstance(headers, dict) and any( str(key).lower() == "authorization" for key in headers ): auth = "header" return { "name": name, "transport": transport, "url": cfg.get("url"), "command": cfg.get("command"), "args": list(cfg.get("args") or []), "env": _redact_mcp_env(cfg.get("env") or {}), "auth": auth, "enabled": cfg.get("enabled", True) is not False, # Tool selection: list of enabled tool names, or None = all. "tools": cfg.get("tools"), } from hermes_cli.web_routers import mcp as _mcp_routes # noqa: E402 app.include_router(_mcp_routes.router) from hermes_cli.web_routers.mcp import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. list_mcp_servers, add_mcp_server, replace_mcp_servers, remove_mcp_server, test_mcp_server, auth_mcp_server, mcp_oauth_flow_status, mcp_oauth_callback, set_mcp_server_enabled, list_mcp_catalog, install_mcp_catalog_entry, ) _MCP_DASHBOARD_OAUTH_TTL = 15 * 60 _MAX_PENDING_MCP_OAUTH_FLOWS = 8 _mcp_oauth_flows: dict[str, "DashboardOAuthFlow"] = {} _mcp_oauth_flows_lock = threading.Lock() _mcp_oauth_transactions: dict[tuple[str, str], threading.Lock] = {} _mcp_oauth_transactions_lock = threading.Lock() def _gc_mcp_oauth_flows() -> None: cutoff = time.time() - _MCP_DASHBOARD_OAUTH_TTL with _mcp_oauth_flows_lock: stale = [ flow_id for flow_id, flow in _mcp_oauth_flows.items() if getattr(flow, "created_at", 0) < cutoff ] for flow_id in stale: _mcp_oauth_flows.pop(flow_id, None) def _mcp_oauth_callback_url_from_base(base_url: str, server_name: str) -> str: from urllib.parse import quote return f"{base_url.rstrip('/')}/api/mcp/oauth/callback/{quote(server_name, safe='')}" def _mcp_oauth_callback_url(request: Request, server_name: str) -> str: """Build the externally reachable callback URL for a dashboard flow.""" from urllib.parse import urlparse, urlunparse from hermes_cli.dashboard_auth.prefix import prefix_from_request, resolve_public_url from urllib.parse import quote suffix = f"/api/mcp/oauth/callback/{quote(server_name, safe='')}" public_url = resolve_public_url() if public_url: return f"{public_url}{suffix}" base = urlparse(str(request.base_url)) prefix = prefix_from_request(request) return urlunparse(base._replace(path=f"{prefix}{suffix}", params="", query="", fragment="")) def _mcp_oauth_transaction(flow) -> threading.Lock: key = (flow.hermes_home, flow.server_name) with _mcp_oauth_transactions_lock: return _mcp_oauth_transactions.setdefault(key, threading.Lock()) def _run_dashboard_mcp_oauth(flow, cfg: dict) -> None: """Run the normal MCP probe with dashboard redirect/callback handlers.""" from hermes_cli.mcp_config import ( _oauth_tokens_present, _probe_single_server, _save_mcp_server, ) try: from agent.secret_scope import ( build_profile_secret_scope, reset_secret_scope, set_secret_scope, ) from hermes_constants import reset_hermes_home_override, set_hermes_home_override from tools.mcp_dashboard_oauth import dashboard_oauth_flow from tools.mcp_oauth import HermesTokenStorage, force_interactive_oauth from tools.mcp_oauth_manager import get_manager home_token = set_hermes_home_override(flow.hermes_home) secret_token = set_secret_scope(build_profile_secret_scope(Path(flow.hermes_home))) try: transaction = _mcp_oauth_transaction(flow) with transaction, force_interactive_oauth(), dashboard_oauth_flow(flow): manager = get_manager() storage = HermesTokenStorage(flow.server_name) backup = storage.snapshot() previous_entry = None try: previous_entry = manager.remove( flow.server_name, hermes_home=flow.hermes_home, ) tools = _probe_single_server( flow.server_name, cfg, connect_timeout=max(float(cfg.get("connect_timeout", 0) or 0), 315), ) if not _oauth_tokens_present(flow.server_name): raise RuntimeError( "The server responded, but no OAuth token was obtained — " "this provider may require a manually-registered OAuth client." ) _save_mcp_server(flow.server_name, cfg) flow.tools = [{"name": t, "description": d} for t, d in tools] flow.mark_approved() if flow.reconnect_live: from tools.mcp_tool import reconnect_mcp_server reconnect_mcp_server(flow.server_name) except Exception: storage.restore(backup, only_if_absent=True) manager.restore_entry( flow.server_name, previous_entry, hermes_home=flow.hermes_home, ) raise finally: reset_secret_scope(secret_token) reset_hermes_home_override(home_token) except Exception as exc: msg = str(exc) # Providers that gate RFC 7591 registration to pre-approved clients # (Figma's MCP catalog, etc.) 403 the register call before any # authorization URL exists — surface what's actually happening # instead of a bare "403 Forbidden". try: from tools.mcp_oauth import humanize_oauth_registration_error humanized = humanize_oauth_registration_error( flow.server_name, exc, server_url=cfg.get("url") if isinstance(cfg, dict) else None, ) if humanized: msg = humanized except Exception: pass flow.mark_error(msg) finally: flow.mark_worker_done() def _mcp_install_action_name(name: str) -> str: """Unique per-entry mcp-install action name (+ registered log file), so a re-click or a second catalog install doesn't overwrite the first's tracked process/log while its git clone is still running.""" slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")[:48] or "server" digest = hashlib.sha1(name.encode()).hexdigest()[:8] action = f"mcp-install-{slug}-{digest}" _ACTION_LOG_FILES.setdefault(action, f"action-{action}.log") return action _ACTION_LOG_FILES.setdefault("computer-use-grant", "action-computer-use-grant.log") from hermes_cli.web_routers import ops as _ops_routes # noqa: E402 app.include_router(_ops_routes.router) from hermes_cli.web_routers.ops import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. delete_webhook, list_checkpoints, prune_checkpoints, run_backup, run_doctor, run_import, run_security_audit, start_gateway, ) # --------------------------------------------------------------------------- # Skills hub endpoints — search / install / uninstall / update. # # Search and install touch the network (GitHub, hub sources) and run the same # complex source-router pipeline the CLI uses, so they're spawned as background # actions whose logs the dashboard tails. The already-installed skill list + # enable/disable toggle live in the existing /api/skills endpoints. # --------------------------------------------------------------------------- def _profile_cli_args(profile: Optional[str]) -> List[str]: """Return ``["-p", ]`` for a validated non-default profile. Hub install/uninstall/update run in a fresh ``hermes`` subprocess, and ``_apply_profile_override()`` reads ``-p`` from argv in the child — the only mechanism that reaches import-time-bound globals like ``skills_hub.SKILLS_DIR``. Empty/"current" means the dashboard's own profile (no args, legacy behavior). """ requested = (profile or "").strip() if not requested or requested.lower() in {"current", "default"}: return [] from hermes_cli import profiles as profiles_mod _resolve_profile_dir(requested) return ["-p", profiles_mod.normalize_profile_name(requested)] def _hub_action_name(verb: str, key: str) -> str: """Unique per-skill hub action name (+ registered log file). ``_spawn_hermes_action`` tracks one process/log per name, so a shared "skills-install"/"skills-uninstall" would make concurrent row-level actions overwrite each other's status/log while the UI polls per identifier. Slug (readable) + hash (collision-proof) keys each action to its own row. """ slug = re.sub(r"[^a-z0-9]+", "-", key.lower()).strip("-")[:48] or "skill" digest = hashlib.sha1(key.encode()).hexdigest()[:8] name = f"skills-{verb}-{slug}-{digest}" _ACTION_LOG_FILES.setdefault(name, f"action-{name}.log") return name from hermes_cli.web_routers import skills as _skills_routes # noqa: E402 app.include_router(_skills_routes.hub_router) from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. install_skill_hub, uninstall_skill_hub, update_skills_hub, list_skills_hub_sources, search_skills_hub, preview_skill_hub, scan_skill_hub, ) # Human-readable labels for each hub source id (matches `hermes skills search` # provenance). Keep in sync with create_source_router()'s source list. _SKILL_HUB_SOURCE_LABELS = { "official": "Official (Nous)", "hermes-index": "Hermes Index", "skills-sh": "skills.sh", "well-known": "Well-Known", "url": "Direct URL", "github": "GitHub", "clawhub": "ClawHub", "lobehub": "LobeHub", "browse-sh": "browse.sh", } def _skill_meta_to_payload(m) -> dict: return { "name": m.name, "description": m.description, "source": m.source, "identifier": m.identifier, "trust_level": m.trust_level, "repo": m.repo, "tags": list(m.tags or []), } def _installed_hub_identifiers(profile: Optional[str] = None) -> dict: """Map identifier -> installed lock entry for hub-installed skills. Lets the UI mark search results that are already installed. Scoped to ``profile``'s skills/.hub/lock.json when provided (HubLockFile takes an explicit path, sidestepping the import-time LOCK_FILE binding). Best-effort: returns an empty dict if the lock file can't be read. """ try: from tools.skills_hub import HubLockFile requested = (profile or "").strip() if requested and requested.lower() != "current": profile_dir = _resolve_profile_dir(requested) lock = HubLockFile(profile_dir / "skills" / ".hub" / "lock.json") else: lock = HubLockFile() out = {} for entry in lock.list_installed(): ident = entry.get("identifier") if ident: out[ident] = { "name": entry.get("name"), "trust_level": entry.get("trust_level"), "scan_verdict": entry.get("scan_verdict"), } return out except Exception: return {} # --------------------------------------------------------------------------- # Profile management endpoints (minimal — list/create/rename/delete + SOUL.md) # --------------------------------------------------------------------------- def _profile_attr(info, name: str, default: Any = None) -> Any: try: return getattr(info, name) except Exception: return default def _profile_to_dict(info) -> Dict[str, Any]: return { "name": _profile_attr(info, "name", ""), "path": str(_profile_attr(info, "path", "")), "is_default": bool(_profile_attr(info, "is_default", False)), "model": _profile_attr(info, "model"), "provider": _profile_attr(info, "provider"), "has_env": bool(_profile_attr(info, "has_env", False)), "skill_count": int(_profile_attr(info, "skill_count", 0) or 0), "gateway_running": bool(_profile_attr(info, "gateway_running", False)), "description": _profile_attr(info, "description", "") or "", "description_auto": bool(_profile_attr(info, "description_auto", False)), "display_name": _profile_attr(info, "display_name", "") or "", "distribution_name": _profile_attr(info, "distribution_name"), "distribution_version": _profile_attr(info, "distribution_version"), "distribution_source": _profile_attr(info, "distribution_source"), "has_alias": _profile_attr(info, "alias_path") is not None, } def _fallback_profile_dicts(profiles_mod) -> List[Dict[str, Any]]: def _safe(callable_, default): try: return callable_() except Exception: return default profiles: List[Dict[str, Any]] = [] default_home = profiles_mod._get_default_hermes_home() if default_home.is_dir(): model, provider = _safe(lambda: profiles_mod._read_config_model(default_home), (None, None)) profiles.append({ "name": "default", "path": str(default_home), "is_default": True, "model": model, "provider": provider, "has_env": (default_home / ".env").exists(), "skill_count": _safe(lambda: profiles_mod._count_skills(default_home), 0), "gateway_running": _safe(lambda: profiles_mod._check_gateway_running(default_home), False), "description": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description", ""), ""), "description_auto": _safe(lambda: profiles_mod.read_profile_meta(default_home).get("description_auto", False), False), "distribution_name": None, "distribution_version": None, "distribution_source": None, "has_alias": False, }) profiles_root = profiles_mod._get_profiles_root() if profiles_root.is_dir(): # Use os.scandir (context-managed) instead of Path.iterdir to avoid # leaking directory fds when an exception interrupts iteration — the # sidebar polls every few seconds so an fd leak exhausts RLIMIT_NOFILE # within days (#81547). with os.scandir(profiles_root) as scan: entries = sorted(scan, key=lambda e: e.name) for entry in entries: entry_path = Path(entry.path) if not entry.is_dir() or not profiles_mod._PROFILE_ID_RE.match(entry.name): continue model, provider = _safe(lambda entry=entry_path: profiles_mod._read_config_model(entry), (None, None)) profiles.append({ "name": entry.name, "path": str(entry_path), "is_default": False, "model": model, "provider": provider, "has_env": _safe(lambda entry=entry_path: (entry / ".env").exists(), False), "skill_count": _safe(lambda entry=entry_path: profiles_mod._count_skills(entry), 0), "gateway_running": _safe( lambda entry=entry_path, name=entry.name: ( profiles_mod._check_gateway_running(entry) or profiles_mod._served_by_running_multiplexer(name) ), False, ), "description": _safe(lambda entry=entry_path: profiles_mod.read_profile_meta(entry).get("description", ""), ""), "description_auto": _safe(lambda entry=entry_path: profiles_mod.read_profile_meta(entry).get("description_auto", False), False), "distribution_name": None, "distribution_version": None, "distribution_source": None, "has_alias": False, }) return profiles def _resolve_profile_dir(name: str) -> Path: """Validate ``name`` and resolve to its directory or raise an HTTPException.""" from hermes_cli import profiles as profiles_mod try: profiles_mod.validate_profile_name(name) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) if not profiles_mod.profile_exists(name): raise HTTPException(status_code=404, detail=f"Profile '{name}' does not exist.") return profiles_mod.get_profile_dir(name) def _profile_setup_command(name: str) -> str: """Return the shell command used to configure a profile in the CLI.""" _resolve_profile_dir(name) return "hermes setup" if name == "default" else f"{name} setup" def _write_profile_model(profile_dir: Path, provider: str, model: str) -> None: """Write the main model assignment into a specific profile's config.yaml. Scopes ``load_config``/``save_config`` to ``profile_dir`` via the context-local HERMES_HOME override so the write lands in the target profile's config rather than the dashboard process's active profile. Clears any stale ``base_url`` / ``context_length`` the same way ``POST /api/model/set`` does, since the new model may differ. """ from hermes_constants import set_hermes_home_override, reset_hermes_home_override token = set_hermes_home_override(str(profile_dir)) try: provider, model = _normalize_main_model_assignment(provider, model) cfg = load_config() cfg["model"] = _apply_main_model_assignment(cfg.get("model", {}), provider, model) save_config(cfg) finally: reset_hermes_home_override(token) def _write_profile_mcp_servers(profile_dir: Path, servers: List["MCPServerCreate"]) -> int: """Write MCP server entries into a specific profile's config.yaml. Scopes ``load_config``/``save_config`` to ``profile_dir`` via the context-local HERMES_HOME override (same mechanism as ``_write_profile_model``) so the entries land in the target profile's config rather than the dashboard process's active profile. Mirrors the per-server shape the ``POST /api/mcp/servers`` endpoint builds, but batched so the whole profile-create write is a single config save. Returns the number of servers written. """ from hermes_constants import set_hermes_home_override, reset_hermes_home_override from hermes_cli.mcp_config import _save_bearer_auth_token written = 0 token = set_hermes_home_override(str(profile_dir)) try: cfg = load_config() mcp = cfg.setdefault("mcp_servers", {}) for server in servers: try: name, entry, bearer_token = _normalize_mcp_server_create(server) except ValueError as exc: display_name = (server.name or "").strip() or "" _log.warning( "Profile-create: skipping MCP server '%s': %s", display_name, exc, ) continue if bearer_token is not None: entry["headers"] = _save_bearer_auth_token(name, bearer_token) mcp[name] = entry written += 1 if written: save_config(cfg) elif not mcp: # We created an empty mcp_servers dict but wrote nothing — don't # leave a stray empty key in the new profile's config. cfg.pop("mcp_servers", None) save_config(cfg) finally: reset_hermes_home_override(token) return written def _disable_unselected_skills(profile_dir: Path, keep: List[str]) -> int: """Disable every installed skill in ``profile_dir`` not in ``keep``. Profiles manage skill activation via a *disabled* list — all installed skills are active by default and users opt out. The builder's skill step uses "replace" semantics: the user picks exactly which seeded built-in / optional skills stay active, and everything else gets added to the disabled list. (Hub skills are installed separately via subprocess and are active on install.) Scoped to the profile via the HERMES_HOME override. Returns the number of skills newly disabled. """ from hermes_constants import set_hermes_home_override, reset_hermes_home_override from hermes_cli.skills_config import get_disabled_skills, save_disabled_skills keep_set = {s.strip() for s in keep if s and s.strip()} disabled_count = 0 token = set_hermes_home_override(str(profile_dir)) try: installed: List[str] = [] skills_root = profile_dir / "skills" if skills_root.is_dir(): for md in skills_root.rglob("SKILL.md"): installed.append(md.parent.name) cfg = load_config() disabled = get_disabled_skills(cfg) for name in installed: if name not in keep_set and name not in disabled: disabled.add(name) disabled_count += 1 if disabled_count: save_disabled_skills(cfg, disabled) finally: reset_hermes_home_override(token) return disabled_count app.include_router(_profiles_routes.router) from hermes_cli.web_routers.profiles import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. list_profiles_endpoint, create_profile_endpoint, get_active_profile_endpoint, set_active_profile_endpoint, get_profile_setup_command, open_profile_terminal_endpoint, rename_profile_endpoint, delete_profile_endpoint, get_profile_soul, update_profile_soul, update_profile_description_endpoint, update_profile_model_endpoint, describe_profile_auto_endpoint, ) # --------------------------------------------------------------------------- # Skills & Tools endpoints # # Every read/write below accepts an optional ``profile`` query param so the # dashboard can manage ANY profile's skills/toolsets, not just the profile # the dashboard process happens to be running under. Without this, "Set as # active" on the Profiles page (which only flips the sticky ``active_profile`` # file for FUTURE CLI/gateway invocations) misled users into thinking skill # toggles would land in the activated profile — they silently wrote into the # dashboard's own config instead. See _profile_scope() for the mechanism. # --------------------------------------------------------------------------- _SKILLS_PROFILE_LOCK = threading.RLock() @contextmanager def _profile_scope(profile: Optional[str]): """Scope config + skill-directory resolution to ``profile`` for one request. Two seams must be redirected for skills/toolsets endpoints: 1. ``load_config``/``save_config`` resolve ``get_hermes_home()`` at call time — the context-local override from ``set_hermes_home_override`` reaches them (same pattern as ``_write_profile_model``). 2. ``tools.skills_tool`` and ``tools.skill_manager_tool`` bind ``SKILLS_DIR`` at import time, so the override CANNOT reach them. Like ``_call_cron_for_profile`` does for cron's module globals, temporarily retarget both under a lock and restore them immediately after. ``tools.skills_sync`` (reset/diff/list-modified/opt-in/opt-out/ repair-official) needs NO retargeting: since #65828 its directory lookups resolve at call time through the same contextvar override set in step 1. ``profile`` of None/""/"current" means "the dashboard's own profile" — config resolution is untouched, but the skill-module globals are still retargeted to the *current* ``get_hermes_home()`` so writes land in the live home even when the import-time binding is stale (e.g. the process imported the modules before a HERMES_HOME override, or under test isolation). """ requested = (profile or "").strip() from hermes_constants import ( get_hermes_home, set_hermes_home_override, reset_hermes_home_override, ) from tools import skills_tool as _skills_tool from tools import skill_manager_tool as _skill_mgr token = None if not requested or requested.lower() == "current": profile_dir = get_hermes_home() else: profile_dir = _resolve_profile_dir(requested) token = set_hermes_home_override(str(profile_dir)) with _SKILLS_PROFILE_LOCK: old_home = _skills_tool.HERMES_HOME old_skills_dir = _skills_tool.SKILLS_DIR old_mgr_home = _skill_mgr.HERMES_HOME old_mgr_skills_dir = _skill_mgr.SKILLS_DIR _skills_tool.HERMES_HOME = profile_dir _skills_tool.SKILLS_DIR = profile_dir / "skills" _skill_mgr.HERMES_HOME = profile_dir _skill_mgr.SKILLS_DIR = profile_dir / "skills" try: yield profile_dir if token is not None else None finally: _skills_tool.HERMES_HOME = old_home _skills_tool.SKILLS_DIR = old_skills_dir _skill_mgr.HERMES_HOME = old_mgr_home _skill_mgr.SKILLS_DIR = old_mgr_skills_dir if token is not None: reset_hermes_home_override(token) @contextmanager def _config_profile_scope(profile: Optional[str]): """Await-safe, config-only profile scope for handlers that ``await``. Unlike ``_profile_scope`` this touches ONLY the context-local ``set_hermes_home_override`` contextvar — it does NOT swap the process-global ``skills_tool``/``skill_manager`` module attributes. Those globals are shared across all event-loop tasks, so holding them across an ``await`` lets a concurrent skills request restore THIS request's profile dir on its ``finally`` (cross-contamination). The contextvar override is task-local and survives an ``await`` cleanly, which is all endpoints that resolve ``get_hermes_home()`` at call time (config, env, gateway status) actually need. None/""/"current" means the dashboard's own profile — no override. """ requested = (profile or "").strip() if not requested or requested.lower() == "current": yield None return from hermes_constants import ( set_hermes_home_override, reset_hermes_home_override, ) profile_dir = _resolve_profile_dir(requested) token = set_hermes_home_override(str(profile_dir)) try: yield profile_dir finally: reset_hermes_home_override(token) app.include_router(_skills_routes.router) from hermes_cli.web_routers.skills import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_skills, toggle_skill, get_skill_content, create_skill, update_skill_content, ) def _clear_skills_prompt_cache() -> None: """Best-effort: invalidate the skills system-prompt snapshot after a write. Mirrors what ``skill_manage`` does so a dashboard-authored skill is picked up by the next session without a manual cache reset. """ try: from agent.prompt_builder import clear_skills_system_prompt_cache clear_skills_system_prompt_cache(clear_snapshot=True) except Exception: pass from hermes_cli.web_routers import tools as _tools_routes # noqa: E402 app.include_router(_tools_routes.router) from hermes_cli.web_routers.tools import ( # noqa: E402,F401 — legacy re-exports; tests call these via web_server. get_toolsets, toggle_toolset, get_toolset_config, get_toolset_models, select_toolset_model, select_toolset_provider, save_toolset_env, run_toolset_post_setup, get_terminal_backends, select_terminal_backend, get_computer_use_status, grant_computer_use_permissions, ) # Toolsets whose backends carry a selectable model catalog, mapped to the # config.yaml section their `model` key lives in. Mirrors the CLI's # post-selection model pickers (`_configure_imagegen_model_for_plugin` / # `_configure_videogen_model_for_plugin` in tools_config.py). _MODEL_CATALOG_TOOLSETS = { "image_gen": "image_gen", "video_gen": "video_gen", } def _resolve_toolset_model_plugin(ts_key: str, provider_row: dict) -> Optional[str]: """Map a provider picker row to its model-catalog plugin name. Plugin-backed rows carry ``image_gen_plugin_name`` / ``video_gen_plugin_name``; the managed "Nous Subscription" image row instead carries the legacy ``imagegen_backend: "fal"`` marker (same underlying FAL catalog). """ if ts_key == "image_gen": return provider_row.get("image_gen_plugin_name") or ( "fal" if provider_row.get("imagegen_backend") else None ) if ts_key == "video_gen": return provider_row.get("video_gen_plugin_name") return None def _toolset_model_catalog(ts_key: str, plugin_name: str): """Return ``(catalog_dict, default_model)`` for a toolset's plugin backend.""" from hermes_cli.tools_config import ( _plugin_image_gen_catalog, _plugin_video_gen_catalog, ) if ts_key == "image_gen": return _plugin_image_gen_catalog(plugin_name) return _plugin_video_gen_catalog(plugin_name) def _find_toolset_provider_row(ts_key: str, config: dict, provider: Optional[str]) -> Optional[dict]: """Resolve a provider picker row by name, or the active row when omitted.""" from hermes_cli.tools_config import ( TOOL_CATEGORIES, _is_provider_active, _visible_providers, ) cat = TOOL_CATEGORIES.get(ts_key) if cat is None: return None rows = _visible_providers(cat, config, force_fresh=True) if provider: return next((p for p in rows if p.get("name") == provider), None) return next( (p for p in rows if _is_provider_active(p, config, force_fresh=True)), None ) # --------------------------------------------------------------------------- # Terminal execution backend picker — the GUI counterpart of terminal.backend # in config.yaml. Each row carries a fast, defensive health probe (Docker # daemon reachable, SSH host configured, Modal/Daytona credentials present) so # the Capabilities panel can render Ready / Needs setup guidance instead of a # bare enum (issues #57738 / #63783). Probes must never raise — a probe # failure renders as a status, not a 500. # --------------------------------------------------------------------------- # Table-driven backend metadata — kept in sync with the dispatch ladder in # tools/terminal_tool.py::_create_environment and the terminal.backend enum # surfaced in the desktop raw-config settings. _TERMINAL_BACKENDS: List[Dict[str, str]] = [ { "name": "local", "label": "Local", "description": "Run commands directly on this machine. No isolation.", }, { "name": "docker", "label": "Docker", "description": "Run commands in an isolated Docker container with a persistent workspace.", }, { "name": "singularity", "label": "Singularity / Apptainer", "description": "Run commands in a Singularity/Apptainer container (HPC-friendly, rootless).", }, { "name": "modal", "label": "Modal", "description": "Run commands in a Modal cloud sandbox.", }, { "name": "daytona", "label": "Daytona", "description": "Run commands in a Daytona cloud sandbox.", }, { "name": "ssh", "label": "SSH", "description": "Run commands on a remote host over SSH.", }, ] _TERMINAL_BACKEND_NAMES = {row["name"] for row in _TERMINAL_BACKENDS} def _plugin_terminal_backend_rows() -> List[Dict[str, str]]: """Picker rows for plugin-registered terminal backends (fail-soft).""" rows: List[Dict[str, str]] = [] try: from hermes_cli.plugins import discover_plugins discover_plugins() # idempotent — plugin state may not be loaded yet except Exception: pass try: from agent.terminal_env_registry import list_providers for provider in list_providers(): try: rows.append({ "name": provider.name.strip().lower(), "label": provider.display_name, "description": provider.description, }) except Exception: continue except Exception: return rows return rows def _terminal_backend_rows() -> List[Dict[str, str]]: """Built-in picker rows plus plugin-registered backends (request time). Computed per request (mirrors ``_schema_with_dynamic_provider_options``) so a plugin installed after server start still shows up. """ return [*_TERMINAL_BACKENDS, *_plugin_terminal_backend_rows()] def _terminal_backend_names() -> set: """Valid ``terminal.backend`` values, including plugin backends.""" return {row["name"] for row in _terminal_backend_rows()} def _terminal_cfg_value(terminal_cfg: dict, key: str, env_var: str) -> str: """Read a terminal.* setting from config.yaml, falling back to its env var.""" value = terminal_cfg.get(key) if value is not None and str(value).strip(): return str(value).strip() try: from hermes_cli.config import get_env_value return (get_env_value(env_var) or "").strip() except Exception: return "" def _probe_docker_backend() -> tuple: if not shutil.which("docker"): return ( "needs_setup", "Docker CLI not found — install Docker Desktop or docker-ce.", ) try: proc = subprocess.run( ["docker", "info", "--format", "{{.ServerVersion}}"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=2, ) if proc.returncode == 0: return ("ready", "") return ( "needs_setup", "Docker daemon not reachable — start Docker and retry.", ) except subprocess.TimeoutExpired: return ("needs_setup", "Docker daemon not responding (timed out).") except Exception as exc: return ("unavailable", f"Docker probe failed: {exc}") def _probe_singularity_backend() -> tuple: if shutil.which("singularity") or shutil.which("apptainer"): return ("ready", "") return ( "needs_setup", "Neither singularity nor apptainer found on PATH.", ) def _probe_ssh_backend(terminal_cfg: dict) -> tuple: host = _terminal_cfg_value(terminal_cfg, "ssh_host", "TERMINAL_SSH_HOST") user = _terminal_cfg_value(terminal_cfg, "ssh_user", "TERMINAL_SSH_USER") missing = [] if not host: missing.append("terminal.ssh_host") if not user: missing.append("terminal.ssh_user") if missing: return ( "needs_setup", f"Set {' and '.join(missing)} in config.yaml (or the matching TERMINAL_SSH_* env vars).", ) return ("ready", f"{user}@{host}") def _probe_modal_backend() -> tuple: try: from tools.tool_backend_helpers import has_direct_modal_credentials if has_direct_modal_credentials(): return ("ready", "") except Exception: pass try: from hermes_cli.config import get_env_value if get_env_value("MODAL_TOKEN_ID") and get_env_value("MODAL_TOKEN_SECRET"): return ("ready", "") except Exception: pass return ( "needs_setup", "Modal credentials not found — set MODAL_TOKEN_ID and MODAL_TOKEN_SECRET (or run `modal setup`).", ) def _probe_daytona_backend() -> tuple: try: from hermes_cli.config import get_env_value if get_env_value("DAYTONA_API_KEY"): return ("ready", "") except Exception: pass return ("needs_setup", "Set DAYTONA_API_KEY to use the Daytona backend.") def _probe_terminal_backend(name: str, terminal_cfg: dict) -> tuple: """Return ``(status, detail)`` for one backend. Never raises.""" try: if name == "local": return ("ready", "") if name == "docker": return _probe_docker_backend() if name == "singularity": return _probe_singularity_backend() if name == "ssh": return _probe_ssh_backend(terminal_cfg) if name == "modal": return _probe_modal_backend() if name == "daytona": return _probe_daytona_backend() try: from agent.terminal_env_registry import get_provider provider = get_provider(name) if provider is not None: return provider.probe() except Exception: pass return ("unavailable", f"Unknown backend: {name}") except Exception as exc: # pragma: no cover — belt-and-braces guard return ("unavailable", f"Probe failed: {exc}") # --------------------------------------------------------------------------- # Computer Use (cua-driver) — cross-platform readiness + macOS permission grant # # cua-driver runs on macOS, Windows, and Linux. The desktop card reflects # per-OS readiness: on macOS the Accessibility + Screen Recording TCC grants # (which attach to cua-driver's OWN identity, com.trycua.driver — not Hermes, # so no app entitlement is involved); elsewhere, driver health from # `cua-driver doctor`. The grant flow is macOS-only (no TCC toggles to request # on Windows/Linux). # --------------------------------------------------------------------------- # --------------------------------------------------------------------------- # Raw YAML config endpoint # --------------------------------------------------------------------------- @app.get("/api/config/raw") async def get_config_raw(profile: Optional[str] = None): """Raw config.yaml text plus its resolved path. ``path`` is resolved inside ``_profile_scope`` so the Config page header shows the file the switched profile actually reads/writes — /api/status's ``config_path`` is machine-global and always reports the dashboard process's own profile, which is wrong under the global profile switcher. """ def _run(): with _profile_scope(profile): path = get_config_path() if not path.exists(): return {"yaml": "", "path": str(path)} return {"yaml": path.read_text(encoding="utf-8"), "path": str(path)} return await asyncio.to_thread(_run) @app.put("/api/config/raw") async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None): def _run(): parsed = yaml.safe_load(body.yaml_text) if not isinstance(parsed, dict): raise HTTPException(status_code=400, detail="YAML must be a mapping") approvals_mode_changed = False with _profile_scope(body.profile or profile): # Full-document replacement: the editor owns the whole file; do not # merge omitted sections back from disk (#62723). approvals_mode_changed = _approval_mode_of(parsed) != _approval_mode_of(read_raw_config()) save_config(parsed, merge_existing=False) # Same indicator refresh as the schema-driven save above. if approvals_mode_changed and not _is_other_profile(body.profile or profile): _broadcast_gateway_session_info() return {"ok": True} try: return await asyncio.to_thread(_run) except yaml.YAMLError as e: raise HTTPException(status_code=400, detail=f"Invalid YAML: {e}") # --------------------------------------------------------------------------- # Token / cost analytics endpoint # --------------------------------------------------------------------------- def _aux_usage_rows(db, cutoff: float) -> List[Dict[str, Any]]: """Per-(model, task) auxiliary usage within the window (issue #23270). Reads the task-dimension rows (task != '') that record_auxiliary_usage writes into session_model_usage. Returns [] when the table predates the task column (older DB opened read-only by newer code). """ try: cur = db._conn.execute(""" SELECT u.model, u.task, u.billing_provider, SUM(u.input_tokens) as input_tokens, SUM(u.output_tokens) as output_tokens, SUM(u.cache_read_tokens) as cache_read_tokens, SUM(u.reasoning_tokens) as reasoning_tokens, COALESCE(SUM(u.estimated_cost_usd), 0) as estimated_cost, COUNT(DISTINCT u.session_id) as sessions, SUM(COALESCE(u.api_call_count, 0)) as api_calls, MAX(u.last_seen) as last_used_at FROM session_model_usage u JOIN sessions s ON s.id = u.session_id WHERE s.started_at > ? AND u.task != '' GROUP BY u.model, u.task, u.billing_provider ORDER BY SUM(u.input_tokens) + SUM(u.output_tokens) DESC """, (cutoff,)) return [dict(r) for r in cur.fetchall()] except Exception: # Table predates the task column (older DB opened by newer code) — # aux breakdown is simply unavailable. return [] def _merge_aux_into_by_model( by_model: List[Dict[str, Any]], aux_rows: List[Dict[str, Any]] ) -> List[Dict[str, Any]]: """Fold aux usage rows into the sessions-derived per-model list. Aux usage lives only in session_model_usage (never in the sessions counters), so adding it here cannot double-count. Models that ONLY appear via aux calls (e.g. a dedicated vision model) get their own entry — previously they were entirely invisible. """ if not aux_rows: return by_model merged: Dict[str, Dict[str, Any]] = {} for row in by_model: merged[row.get("model") or "unknown"] = row for aux in aux_rows: model = aux.get("model") or "unknown" target = merged.get(model) if target is None: target = { "model": model, "input_tokens": 0, "output_tokens": 0, "estimated_cost": 0, "sessions": 0, "api_calls": 0, } merged[model] = target target["input_tokens"] = (target.get("input_tokens") or 0) + (aux.get("input_tokens") or 0) target["output_tokens"] = (target.get("output_tokens") or 0) + (aux.get("output_tokens") or 0) target["estimated_cost"] = (target.get("estimated_cost") or 0) + (aux.get("estimated_cost") or 0) target["api_calls"] = (target.get("api_calls") or 0) + (aux.get("api_calls") or 0) tasks = target.setdefault("aux_tasks", []) tasks.append({ "task": aux.get("task") or "", "input_tokens": aux.get("input_tokens") or 0, "output_tokens": aux.get("output_tokens") or 0, "estimated_cost": aux.get("estimated_cost") or 0, "api_calls": aux.get("api_calls") or 0, }) result = list(merged.values()) result.sort( key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0), reverse=True, ) return result def _aux_task_summary(aux_rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """Aggregate aux usage rows across models into a per-task summary.""" by_task: Dict[str, Dict[str, Any]] = {} for aux in aux_rows: task = aux.get("task") or "" d = by_task.setdefault(task, { "task": task, "input_tokens": 0, "output_tokens": 0, "estimated_cost": 0, "api_calls": 0, "models": [], }) d["input_tokens"] += aux.get("input_tokens") or 0 d["output_tokens"] += aux.get("output_tokens") or 0 d["estimated_cost"] += aux.get("estimated_cost") or 0 d["api_calls"] += aux.get("api_calls") or 0 model = aux.get("model") or "unknown" if model not in d["models"]: d["models"].append(model) result = list(by_task.values()) result.sort( key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0), reverse=True, ) return result def _get_usage_analytics(days: int = 30, profile: Optional[str] = None): from agent.insights import InsightsEngine db = _open_session_db_for_profile(profile, read_only=True) try: cutoff = time.time() - (days * 86400) cur = db._conn.execute(""" SELECT date(started_at, 'unixepoch') as day, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cache_read_tokens) as cache_read_tokens, SUM(reasoning_tokens) as reasoning_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as actual_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? GROUP BY day ORDER BY day """, (cutoff,)) daily = [dict(r) for r in cur.fetchall()] cur2 = db._conn.execute(""" SELECT model, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL GROUP BY model ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC """, (cutoff,)) by_model = [dict(r) for r in cur2.fetchall()] # Fold in auxiliary usage (vision, compression, title_generation, ...) # recorded per (model, task) in session_model_usage. Aux calls never # touch the sessions counters, so this is add-only — no double count. # Without it the models list shows only the main agent model even when # aux models are actively burning tokens (issue #23270). aux_rows = _aux_usage_rows(db, cutoff) by_model = _merge_aux_into_by_model(by_model, aux_rows) cur3 = db._conn.execute(""" SELECT SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(cache_read_tokens) as total_cache_read, SUM(reasoning_tokens) as total_reasoning, COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? """, (cutoff,)) totals = dict(cur3.fetchone()) usage = InsightsEngine(db).get_usage_breakdown(days=days) return { "daily": daily, "by_model": by_model, # Aux-task summary across models (vision, compression, ...). Lets # the dashboard answer "what is compression costing me" directly. "by_task": _aux_task_summary(aux_rows), "totals": totals, "period_days": days, "skills": usage["skills"], # Per-tool-name call counts (already computed by InsightsEngine); # the desktop Capabilities page aggregates these per toolset. "tools": usage["tools"], } finally: db.close() @app.get("/api/analytics/usage") async def get_usage_analytics( days: int = Query(30, ge=1, le=365), profile: Optional[str] = None, ): """``days`` is clamped to 1-365 (idea from #74778): huge or non-positive values would force expensive full-history SQL and InsightsEngine work, or produce empty/inverted time windows. The UI only offers 7/30/90-day presets.""" return await asyncio.to_thread(_get_usage_analytics, days, profile) def _get_models_analytics(days: int = 30, profile: Optional[str] = None): """Rich per-model analytics for the Models dashboard page. Returns token/cost/session breakdown per model plus capability metadata from models.dev (context window, vision, tools, reasoning, etc.). """ db = _open_session_db_for_profile(profile, read_only=True) try: cutoff = time.time() - (days * 86400) cur = db._conn.execute(""" SELECT model, billing_provider, SUM(input_tokens) as input_tokens, SUM(output_tokens) as output_tokens, SUM(cache_read_tokens) as cache_read_tokens, SUM(reasoning_tokens) as reasoning_tokens, COALESCE(SUM(estimated_cost_usd), 0) as estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as actual_cost, COUNT(*) as sessions, SUM(COALESCE(api_call_count, 0)) as api_calls, SUM(tool_call_count) as tool_calls, MAX(started_at) as last_used_at, AVG(input_tokens + output_tokens) as avg_tokens_per_session FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' GROUP BY model, billing_provider ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC """, (cutoff,)) raw_rows = [dict(r) for r in cur.fetchall()] # Add auxiliary usage as (model, provider) rows so aux-only models # (dedicated vision/compression models) appear on the Models page # instead of being invisible (issue #23270). Keyed by # model+billing_provider to match the GROUP BY above. for aux in _aux_usage_rows(db, cutoff): raw_rows.append({ "model": aux.get("model") or "unknown", "billing_provider": aux.get("billing_provider") or "", "input_tokens": aux.get("input_tokens") or 0, "output_tokens": aux.get("output_tokens") or 0, "cache_read_tokens": aux.get("cache_read_tokens") or 0, "reasoning_tokens": aux.get("reasoning_tokens") or 0, "estimated_cost": aux.get("estimated_cost") or 0, "actual_cost": 0, "sessions": aux.get("sessions") or 0, "api_calls": aux.get("api_calls") or 0, "tool_calls": 0, "last_used_at": aux.get("last_used_at"), "avg_tokens_per_session": 0, "aux_task": aux.get("task") or "", }) # Session rows can be created before the first billable provider call # finishes. If that early row records only the model name, and a later # row for the same model has real accounting + billing_provider, the # Models page used to show a duplicate "0 tokens / — API calls" card # next to the real provider card. Fold those session-only rows into # the single accounted provider row when the ownership is unambiguous. rows_by_model: Dict[str, List[Dict[str, Any]]] = {} for row in raw_rows: rows_by_model.setdefault(row.get("model") or "", []).append(row) rows: List[Dict[str, Any]] = [] for model_rows in rows_by_model.values(): provider_rows = [r for r in model_rows if r.get("billing_provider")] if len(provider_rows) == 1: target = provider_rows[0] for row in model_rows: if row is target or row.get("billing_provider"): continue has_usage = any( (row.get(key) or 0) != 0 for key in ( "input_tokens", "output_tokens", "cache_read_tokens", "reasoning_tokens", "estimated_cost", "actual_cost", "api_calls", "tool_calls", ) ) if has_usage: continue target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0) target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0) total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0) sessions = target.get("sessions") or 0 target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0 rows.append(target) rows.extend( r for r in model_rows if r is not target and (r.get("billing_provider") or any( (r.get(key) or 0) != 0 for key in ( "input_tokens", "output_tokens", "cache_read_tokens", "reasoning_tokens", "estimated_cost", "actual_cost", "api_calls", "tool_calls", ) )) ) else: rows.extend(model_rows) rows.sort( key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0), reverse=True, ) models = [] for row in rows: provider = row.get("billing_provider") or "" model_name = row["model"] caps = {} try: from agent.models_dev import get_model_capabilities mc = get_model_capabilities(provider=provider, model=model_name) if mc is not None: caps = { "supports_tools": mc.supports_tools, "supports_vision": mc.supports_vision, "supports_reasoning": mc.supports_reasoning, "context_window": mc.context_window, "max_output_tokens": mc.max_output_tokens, "model_family": mc.model_family, } except Exception: pass models.append({ "model": model_name, "provider": provider, "input_tokens": row["input_tokens"], "output_tokens": row["output_tokens"], "cache_read_tokens": row["cache_read_tokens"], "reasoning_tokens": row["reasoning_tokens"], "estimated_cost": row["estimated_cost"], "actual_cost": row["actual_cost"], "sessions": row["sessions"], "api_calls": row["api_calls"], "tool_calls": row["tool_calls"], "last_used_at": row["last_used_at"], "avg_tokens_per_session": row["avg_tokens_per_session"], "capabilities": caps, }) totals_cur = db._conn.execute(""" SELECT COUNT(DISTINCT model) as distinct_models, SUM(input_tokens) as total_input, SUM(output_tokens) as total_output, SUM(cache_read_tokens) as total_cache_read, SUM(reasoning_tokens) as total_reasoning, COALESCE(SUM(estimated_cost_usd), 0) as total_estimated_cost, COALESCE(SUM(actual_cost_usd), 0) as total_actual_cost, COUNT(*) as total_sessions, SUM(COALESCE(api_call_count, 0)) as total_api_calls FROM sessions WHERE started_at > ? AND model IS NOT NULL AND model != '' """, (cutoff,)) totals = dict(totals_cur.fetchone()) return { "models": models, "totals": totals, "period_days": days, } finally: db.close() @app.get("/api/analytics/models") async def get_models_analytics( days: int = Query(30, ge=1, le=365), profile: Optional[str] = None, ): # ``days`` clamped to 1-365 (idea from #74778) — see get_usage_analytics. """Return model analytics without blocking the serving event loop.""" return await asyncio.to_thread(_get_models_analytics, days, profile) # --------------------------------------------------------------------------- # /api/pty — PTY-over-WebSocket bridge for the dashboard "Chat" tab. # # The endpoint spawns the same ``hermes --tui`` binary the CLI uses, behind # a POSIX pseudo-terminal, and forwards bytes + resize escapes across a # WebSocket. The browser renders the ANSI through xterm.js (see # web/src/pages/ChatPage.tsx). # # Auth: ``?token=`` query param (browsers can't set # Authorization on the WS upgrade). Same ephemeral ``_SESSION_TOKEN`` as # REST. Localhost-only — we defensively reject non-loopback clients even # though uvicorn binds to 127.0.0.1. # --------------------------------------------------------------------------- # PTY bridge: POSIX uses pty_bridge (fcntl/termios/ptyprocess); native Windows # uses win_pty_bridge (pywinpty/ConPTY, already a declared dependency). Both # expose the same public surface — spawn/read/write/resize/close/is_available — # so the /api/pty WebSocket handler needs no platform guards. if sys.platform.startswith("win"): try: from hermes_cli.win_pty_bridge import WinPtyBridge as PtyBridge, PtyUnavailableError _PTY_BRIDGE_AVAILABLE = True except ImportError: # pragma: no cover - pywinpty missing PtyBridge = None # type: ignore[assignment] _PTY_BRIDGE_AVAILABLE = False class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] """Stub when win_pty_bridge cannot be imported.""" pass else: try: from hermes_cli.pty_bridge import PtyBridge, PtyUnavailableError _PTY_BRIDGE_AVAILABLE = True except ImportError: # pragma: no cover - dev env without ptyprocess PtyBridge = None # type: ignore[assignment] _PTY_BRIDGE_AVAILABLE = False class PtyUnavailableError(RuntimeError): # type: ignore[no-redef] """Stub on platforms where pty_bridge can't be imported.""" pass _RESIZE_RE = re.compile(rb"\x1b\[RESIZE:(\d+);(\d+)\]") _PTY_READ_CHUNK_TIMEOUT = 0.2 # Back-off delay between idle PTY reads so a quiet terminal does not spin # the event loop. A positive sleep lets other coroutines run and keeps # dashboard idle CPU low (#42627). _PTY_IDLE_BACKOFF = 0.05 # Keep-alive PTY sessions: a terminal connecting with ``?attach=`` is # bound to a process that survives disconnect/refresh and is reattachable. from hermes_cli.pty_session import PtySessionRegistry, RegistryFull, run_reaper # noqa: E402 PTY_REGISTRY = PtySessionRegistry( ttl=30 * 60, max_sessions=16, buffer_cap=1 * 1024 * 1024, read_timeout=_PTY_READ_CHUNK_TIMEOUT, ) async def _legacy_pump(ws: "WebSocket", bridge) -> None: """Original 1:1 socket<->PTY pump: stream until disconnect, then close the bridge. Used when no ``?attach=`` token is supplied (keep-alive opt-in). Behavior is identical to the pre-keep-alive ``pty_ws`` body, including the #54028 half-open-socket protection (reader EOF → close the WS so the writer's ``ws.receive()`` unparks) and the #53227 ``to_thread`` offloads for the blocking ``bridge.close()``. """ loop = asyncio.get_running_loop() # --- reader task: PTY master → WebSocket ---------------------------- async def pump_pty_to_ws() -> None: try: while True: chunk = await loop.run_in_executor( None, bridge.read, _PTY_READ_CHUNK_TIMEOUT ) if chunk is None: # EOF return if not chunk: # no data this tick; yield control and retry await asyncio.sleep(_PTY_IDLE_BACKOFF) continue try: await ws.send_bytes(chunk) except Exception: return finally: # The child has exited (EOF) or the send side broke. Close the # WebSocket so the writer loop's ``ws.receive()`` returns instead # of blocking forever — otherwise, when the browser's socket is # half-open (no FIN delivered, common on macOS/launchd) the # handler never reaches its ``finally`` and the PTY's fds leak. # With dashboard auto-reconnect (#52962) every dropped socket then # stacks a fresh PTY on top of the orphaned one, exhausting fds. # # Reap the bridge here too (close() is idempotent): on child EOF the # writer loop's ``finally`` is the usual closer, but if the handler # task is cancelled the instant we close the WS, that ``finally`` # can be skipped, leaking the PTY. Closing from the EOF path makes # the reap independent of that cancellation race (#54028). try: await asyncio.to_thread(bridge.close) except Exception: pass try: await ws.close() except Exception: pass reader_task = asyncio.create_task(pump_pty_to_ws()) # --- writer loop: WebSocket → PTY master ---------------------------- try: while True: try: msg = await ws.receive() except RuntimeError: # Raised when ws.receive() is called after the socket is # already disconnected (e.g. closed by the reader task above). break if msg.get("type") == "websocket.disconnect": break raw = msg.get("bytes") if raw is None: text = msg.get("text") raw = text.encode("utf-8") if isinstance(text, str) else b"" if not raw: continue # Resize escape is consumed locally, never written to the PTY. match = _RESIZE_RE.match(raw) if match and match.end() == len(raw): bridge.resize(cols=int(match.group(1)), rows=int(match.group(2))) continue bridge.write(raw) except WebSocketDisconnect: pass finally: reader_task.cancel() try: await reader_task except (asyncio.CancelledError, Exception): pass await asyncio.to_thread(bridge.close) _VALID_CHANNEL_RE = re.compile(r"^[A-Za-z0-9._-]{1,128}$") # Starlette's TestClient reports the peer as "testclient"; treat it as # loopback so tests don't need to rewrite request scope. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "::1", "localhost", "testclient"}) def _ws_client_reason(ws: "WebSocket") -> Optional[str]: """Return a rejection reason for the client IP, or None when allowed. Reasons are short machine-parseable tokens logged on the rejection path so a "WS keeps closing" report can be diagnosed from agent.log without a repro. ``None`` means the peer IP passed this gate. See :func:`_ws_client_is_allowed` for the full policy rationale. """ if getattr(app.state, "auth_required", False): return None bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() if bound_host and bound_host not in _LOOPBACK_HOSTS: return None client_host = ws.client.host if ws.client else "" if not client_host: # Fail-closed: a loopback-bound dashboard with auth disabled must # not accept a WebSocket with no identifiable peer. ASGI servers # behind a misconfigured proxy or unix socket can deliver # ws.client == None or "" — treating that as "allowed" would let # an unidentified peer reach a loopback-only surface. return f"missing_or_empty_peer bound={bound_host or '?'}" if client_host in _LOOPBACK_HOSTS: return None return f"peer_not_loopback peer={client_host} bound={bound_host or '?'}" def _ws_client_is_allowed(ws: "WebSocket") -> bool: """Check if the WebSocket client IP is acceptable. Loopback bind: only loopback clients allowed — the legacy ``?token=<_SESSION_TOKEN>`` path is the only auth we have, so we don't want LAN hosts guessing tokens. Explicit non-loopback bind (``--host 0.0.0.0``, ``--host ::``, or a specific address such as a Tailscale/LAN IP, always with ``--insecure``): allow any peer. The operator explicitly opted into non-loopback exposure, so the loopback-only peer restriction does not apply. DNS-rebinding is still blocked by the Host/Origin guard in :func:`_ws_host_origin_is_allowed`, which mirrors the HTTP layer and requires the Host header to match the bound interface — the same defence ``_is_accepted_host`` applies to non-loopback HTTP requests. Gated mode: any peer is allowed — uvicorn's ``proxy_headers=True`` (enabled when the OAuth gate is active so cookies can pick up ``X-Forwarded-Proto``) rewrites ``ws.client.host`` to the X-Forwarded-For value, which is the real internet client IP. The OAuth gate + single-use ``?ticket=`` is the auth at that point; the Host/Origin guard in :func:`_ws_host_origin_is_allowed` is what blocks DNS-rebinding here, not the peer IP. """ if getattr(app.state, "auth_required", False): return True # Any explicit non-loopback bind (0.0.0.0, ::, or a specific LAN / # Tailscale address) means the operator opted into non-loopback # access via --insecure. The loopback-only peer gate only applies to # an actual loopback bind; otherwise the WS handshake is rejected even # though same-bind HTTP requests pass _is_accepted_host. bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() if bound_host and bound_host not in _LOOPBACK_HOSTS: return True client_host = ws.client.host if ws.client else "" if not client_host: # Fail-closed: see _ws_client_reason for rationale. An empty # client_host on a loopback-bound dashboard with auth disabled # must be rejected, not accepted as a default-allow. return False return client_host in _LOOPBACK_HOSTS def _ws_host_origin_reason(ws: "WebSocket") -> Optional[str]: """Return a Host/Origin rejection reason, or None when allowed. Mirrors :func:`_ws_host_origin_is_allowed` but yields a short machine-parseable token (``host_mismatch …`` / ``origin_mismatch …``) on rejection so the close path can log *why* the upgrade was refused. """ bound_host = getattr(app.state, "bound_host", None) if not bound_host: return None trusted_public_hosts = getattr( app.state, "trusted_public_hosts", frozenset() ) host_header = ws.headers.get("host", "") if not _is_accepted_host( host_header, bound_host, trusted_public_hosts ): return f"host_mismatch host={host_header or '?'} bound={bound_host}" origin = ws.headers.get("origin", "") if not origin: return None parsed = urllib.parse.urlparse(origin) if parsed.scheme not in {"http", "https"}: # Non-web origin (packaged Electron: file://, null, app://). The # upstream credential check is the real auth boundary; trust it. # See _ws_host_origin_is_allowed for the full rationale. return None if not parsed.netloc: return f"origin_mismatch origin={origin} bound={bound_host}" if not _is_accepted_host( parsed.netloc, bound_host, trusted_public_hosts ): return f"origin_mismatch origin={origin} bound={bound_host}" return None def _ws_host_origin_is_allowed(ws: "WebSocket") -> bool: """Apply the dashboard Host/Origin guard to WebSocket upgrades. FastAPI HTTP middleware does not run for WebSocket routes, so the DNS-rebinding Host check used for normal dashboard HTTP requests must be repeated here before accepting the upgrade. Browsers also send an Origin header on WebSocket handshakes; when present, require it to target the same bound dashboard host. """ return _ws_host_origin_reason(ws) is None def _ws_request_reason(ws: "WebSocket") -> Optional[str]: """First Host/Origin or peer-IP rejection reason, or None when allowed.""" return _ws_host_origin_reason(ws) or _ws_client_reason(ws) def _ws_request_is_allowed(ws: "WebSocket") -> bool: """Return True when the WebSocket upgrade matches dashboard boundaries.""" return _ws_host_origin_is_allowed(ws) and _ws_client_is_allowed(ws) def _ws_auth_mode() -> str: """Short label for the active WS auth mode — logged on every connection.""" if getattr(app.state, "auth_required", False): return "gated" bound_host = (getattr(app.state, "bound_host", "") or "").strip().lower() if bound_host and bound_host not in _LOOPBACK_HOSTS: return "insecure" return "loopback" _GATEWAY_WS_PROTOCOL = "hermes-gateway-v1" _GATEWAY_WS_TICKET_PROTOCOL_PREFIX = "hermes-gateway-ticket." def _gateway_ws_ticket_from_subprotocol(ws: "WebSocket") -> tuple[str, str]: """Return ``(ticket, reason)`` from an unambiguous gateway protocol set.""" raw = str(ws.headers.get("sec-websocket-protocol", "") or "") protocols = [value.strip() for value in raw.split(",") if value.strip()] ticket_protocols = [ value for value in protocols if value.startswith(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX) ] if not ticket_protocols: return "", "none" if _GATEWAY_WS_PROTOCOL not in protocols or len(ticket_protocols) != 1: return "", "invalid" ticket = ticket_protocols[0][len(_GATEWAY_WS_TICKET_PROTOCOL_PREFIX):] return (ticket, "ok") if ticket else ("", "invalid") def _ws_auth_reason(ws: "WebSocket") -> tuple[Optional[str], str]: """Validate WS-upgrade auth; return ``(reason, credential)``. ``reason`` is None when the credential is accepted, else a short machine-parseable token explaining the rejection (``no_credential``, ``token_mismatch``, ``ticket_invalid``, ``internal_invalid``). ``credential`` names which credential type was presented (``ticket``, ``internal``, ``token``, or ``none``) so the accepted path can log *how* a peer authed, not just that it did. Loopback / ``--insecure``: legacy ``?token=<_SESSION_TOKEN>`` query parameter, constant-time compared. Gated (public bind, no ``--insecure``): one of two credentials — * ``?ticket=`` — a browser-minted, single-use, 30s-TTL ticket consumed against the dashboard-auth ticket store. This is what the SPA (and native clients) use. * ``?internal=`` — the process-lifetime internal credential, used only by WS clients the server spawns itself (the embedded-TUI PTY child attaching to ``/api/ws`` and ``/api/pub``). It is multi-use and never expires so the child can reconnect, and is never injected into the SPA — see ``dashboard_auth.ws_tickets`` for the threat model. The legacy ``?token=`` path is unconditionally rejected in gated mode (the SPA bundle isn't carrying the token any longer, and a leaked ``_SESSION_TOKEN`` must not grant WS access once the gate is engaged). Audit-logs the rejection so operators can debug "WS keeps closing" issues from the log. """ auth_required = bool(getattr(app.state, "auth_required", False)) if auth_required: # Lazy import — keeps this function importable in test harnesses # that don't bring in the dashboard_auth layer. from hermes_cli.dashboard_auth.audit import AuditEvent, audit_log from hermes_cli.dashboard_auth.ws_tickets import ( TicketInvalid, consume_internal_credential, consume_ticket, ) # Server-spawned children (PTY child → /api/ws, /api/pub) present the # multi-use internal credential rather than a single-use ticket, so # they survive reconnects and slow cold boots. internal = ws.query_params.get("internal", "") if internal: try: info = consume_internal_credential(internal) # Stamp the server-minted identity onto the WS object so the # connection (and any transport built from it) can never be # impersonated by RPC params. Internal peers are marked # ``server-internal`` and are excluded from privileged # controller registration downstream. ws._hermes_auth_identity = { "user_id": info.get("user_id"), "provider": info.get("provider"), } return None, "internal" except TicketInvalid as exc: audit_log( AuditEvent.WS_TICKET_REJECTED, reason=f"internal: {exc}", ip=(ws.client.host if ws.client else ""), path=ws.url.path, ) return "internal_invalid", "internal" protocol_ticket, protocol_reason = _gateway_ws_ticket_from_subprotocol(ws) if protocol_reason == "invalid": return "ticket_invalid", "ticket-subprotocol" ticket = protocol_ticket or ws.query_params.get("ticket", "") if not ticket: return "no_credential", "none" try: info = consume_ticket(ticket) # The ticket binds a server-minted {user_id, provider}; stamp it # onto the WS object so ``gateway_ws`` can hand it to the gateway # transport, where it is the sole identity authority for # browser-controller registration. A client can never supply or # spoof this value through RPC params. Only the two identity # fields are carried — bookkeeping (e.g. ``minted_at``) is not # part of the identity contract. ws._hermes_auth_identity = { "user_id": info.get("user_id"), "provider": info.get("provider"), } if protocol_ticket: # Select only the stable public protocol during accept. The # ticket-bearing protocol is a credential and must never be # reflected back to the browser or retained after admission. ws._hermes_ws_subprotocol = _GATEWAY_WS_PROTOCOL return None, "ticket-subprotocol" return None, "ticket" except TicketInvalid as exc: audit_log( AuditEvent.WS_TICKET_REJECTED, reason=str(exc), ip=(ws.client.host if ws.client else ""), path=ws.url.path, ) return "ticket_invalid", "ticket" token = ws.query_params.get("token", "") if not token: return "no_credential", "none" if hmac.compare_digest(token.encode(), _SESSION_TOKEN.encode()): return None, "token" return "token_mismatch", "token" def _ws_auth_ok(ws: "WebSocket") -> bool: """True when the WS-upgrade credential is accepted. See _ws_auth_reason.""" return _ws_auth_reason(ws)[0] is None # Per-channel subscriber registry used by /api/pub (PTY-side gateway → dashboard) # and /api/events (dashboard → browser sidebar). Keyed by an opaque channel id # the chat tab generates on mount; entries auto-evict when the last subscriber # drops AND the publisher has disconnected. # (Channel state and the chat-argv lock are initialised in _lifespan on app # startup — see _get_event_state / _get_chat_argv_lock above.) def _resolve_chat_argv( resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None, active_session_file: Optional[str] = None, ) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve the argv + cwd + env for the chat PTY. Default: whatever ``hermes --tui`` would run. Tests monkeypatch this function to inject a tiny fake command (``cat``, ``sh -c 'printf …'``) so nothing has to build Node or the TUI bundle. Session resume is propagated via the ``HERMES_TUI_RESUME`` env var — matching what ``hermes_cli.main._launch_tui`` does for the CLI path. Appending ``--resume `` to argv doesn't work because ``ui-tui`` does not parse its argv. ``HERMES_TUI_GATEWAY_URL`` is injected so the PTY child can attach to this process's in-memory ``tui_gateway`` instance instead of spawning its own Python gateway subprocess. `sidecar_url` (when set) is forwarded as ``HERMES_TUI_SIDECAR_URL`` so the spawned ``tui_gateway.entry`` can mirror dispatcher emits to the dashboard's ``/api/pub`` endpoint (see :func:`pub_ws`). `active_session_file` (when set) is forwarded as ``HERMES_TUI_ACTIVE_SESSION_FILE``. The TUI writes the current session id there whenever it creates/resumes/switches sessions, giving the dashboard a small cross-process breadcrumb for reconnecting after an unexpected browser WebSocket close. `profile` (when set) scopes the ENTIRE chat to that profile by pointing ``HERMES_HOME`` at the profile dir in the child env. Every spawned process (the TUI and the ``tui_gateway.entry`` it launches) resolves ``get_hermes_home()`` from that env var at its own import, so the child binds the profile's config, skills, memory, and state.db from the start — the same propagation ``hermes -p `` performs. The in-process ``HERMES_TUI_GATEWAY_URL`` attach is SKIPPED for scoped chats: the dashboard's in-memory gateway runs under the dashboard's own profile, so a profile-scoped chat must spawn its own gateway subprocess. """ from hermes_cli.main import PROJECT_ROOT, _apply_tui_python_env, _make_tui_argv profile_dir: Optional[Path] = None requested = (profile or "").strip() if requested and requested.lower() != "current": profile_dir = _resolve_profile_dir(requested) argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False) # Hermes TUI child: build via the single spawn-env factory (profile-home # contract applied; secrets kept — the spawned agent needs provider creds). # An explicit profile scope still overrides HERMES_HOME before config is # bridged into the child environment. from tools.environments.local import build_subprocess_env env = build_subprocess_env(scrub_secrets=False, inherit_profile_home=True) if profile_dir is not None: env["HERMES_HOME"] = str(profile_dir) try: from hermes_cli.config import ( apply_terminal_config_to_env, read_raw_config, terminal_config_owned_env_vars, ) if profile_dir is not None: # The dashboard process already bridged its own terminal config # into os.environ at startup. Remove only keys explicitly owned by # that launch profile before applying the selected profile. Values # exported by the operator for keys omitted from the launch profile # remain valid fallbacks, matching apply_terminal_config_to_env(). raw_launch_terminal = read_raw_config().get("terminal") for env_var in terminal_config_owned_env_vars(raw_launch_terminal): env.pop(env_var, None) with _config_profile_scope(requested): apply_terminal_config_to_env(env=env) else: apply_terminal_config_to_env(env=env) except Exception: _log.warning("Failed to apply terminal config bridge for dashboard chat", exc_info=True) _apply_tui_python_env(env) env.setdefault("NODE_ENV", "production") # Browser-embedded chat should prefer stable wheel-based scrollback over # native terminal mouse tracking. When mouse tracking is enabled, wheel # events are consumed by the TUI and forwarded as terminal input, which # makes browser-side transcript scrolling feel broken. Keep the terminal # build unchanged for native CLI usage; only disable mouse tracking for # the dashboard PTY path. env.setdefault("HERMES_TUI_DISABLE_MOUSE", "1") env.setdefault("HERMES_TUI_INLINE", "1") # The dashboard terminal is xterm.js, which always renders 24-bit RGB. # But chalk inside the TUI child decides its color depth from the # SERVER process env — and hosted/cloud deploys run the dashboard under # a process manager (container init, systemd) with no COLORTERM, so # chalk downgrades every hex color to the xterm 256 palette. The skin's # bronze border #CD7F32 snaps to palette 173 (#D7875F, salmon-red) and # the banner reads red/yellow instead of gold. Local launches dodge # this only because the operator's interactive terminal leaks # COLORTERM=truecolor into os.environ. Backfill it for the PTY child; # setdefault so an explicit operator value still wins. env.setdefault("COLORTERM", "truecolor") env["HERMES_TUI_DASHBOARD"] = "1" if resume: _resume_db = _open_session_db_for_profile( requested if profile_dir is not None else None, read_only=True, ) try: latest_resume, _latest_path = _session_latest_descendant(resume, _resume_db) finally: _resume_db.close() if latest_resume: resume = latest_resume env["HERMES_TUI_RESUME"] = resume if sidecar_url: env["HERMES_TUI_SIDECAR_URL"] = sidecar_url if active_session_file: env["HERMES_TUI_ACTIVE_SESSION_FILE"] = active_session_file # Profile-scoped chats must NOT attach to the dashboard's in-memory # gateway — it runs under the dashboard's own profile. Without the # attach URL, gatewayClient spawns its own `tui_gateway.entry`, which # inherits the profile HERMES_HOME set above. if profile_dir is None: if gateway_ws_url := _build_gateway_ws_url(): env["HERMES_TUI_GATEWAY_URL"] = gateway_ws_url return list(argv), str(cwd) if cwd else None, env # Hosts that mean "listen on every interface" — the server should bind to # them, but an in-container client must NOT dial them: dialing 0.0.0.0 # resolves to "any local interface", which on most platforms routes through # the kernel's wildcard stack and behind a forward proxy (HTTPS_PROXY with # a NO_PROXY that doesn't list 0.0.0.0) gets MITM'd into a failed handshake # (issue #58993). The fix is to use a loopback address for the client # netloc while leaving the bind host alone. _WILDCARD_HOSTS = frozenset({"0.0.0.0", "::"}) def _resolve_client_ws_host() -> Optional[str]: """Return the host the in-container WS client should dial. Resolution order: 1. Explicit ``HERMES_DASHBOARD_WS_HOST`` env var — wins always. Operators running the dashboard behind a forward proxy can pin a routable host (e.g. ``127.0.0.1``, the container's internal IP, or a sidecar DNS name) and bypass auto-detection entirely. 2. The configured bind host — if it's a wildcard (``0.0.0.0`` / ``::``), substitute ``127.0.0.1`` since both the dashboard and its TUI child run in the same container. 3. Any other bind host (loopback or LAN IP) — preserved verbatim. """ explicit = os.environ.get("HERMES_DASHBOARD_WS_HOST", "").strip() if explicit: return explicit host = getattr(app.state, "bound_host", None) if not host: return None if host in _WILDCARD_HOSTS: return "127.0.0.1" return host def _build_gateway_ws_url() -> Optional[str]: """ws:// URL the PTY child should attach to for JSON-RPC gateway traffic. Loopback / ``--insecure``: ``?token=<_SESSION_TOKEN>``. Gated mode: the legacy token path is rejected by ``_ws_auth_ok``, so the server-spawned PTY child authenticates with the process-lifetime internal credential (``?internal=``). It must NOT use a single-use browser ticket: the child reads this URL once at startup and reuses it on every reconnect, and a 30s-TTL ticket can expire before a slow cold boot even dials. """ host = _resolve_client_ws_host() port = getattr(app.state, "bound_port", None) if not host or not port: return None netloc = ( f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" ) if getattr(app.state, "auth_required", False): from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential qs = urllib.parse.urlencode({"internal": internal_ws_credential()}) else: qs = urllib.parse.urlencode({"token": _SESSION_TOKEN}) return f"ws://{netloc}/api/ws?{qs}" async def _resolve_chat_argv_async( resume: Optional[str] = None, sidecar_url: Optional[str] = None, profile: Optional[str] = None, active_session_file: Optional[str] = None, ) -> tuple[list[str], Optional[str], Optional[dict]]: """Resolve chat argv without blocking the dashboard event loop. ``_resolve_chat_argv`` may run ``npm install`` / ``npm run build`` through ``_make_tui_argv``. Keep that synchronous work off the WebSocket event loop so reverse proxies and existing dashboard connections can continue to exchange keepalives while the TUI launch command is prepared. The async lock preserves the previous one-build-at-a-time behavior when multiple browser tabs connect at once without occupying worker threads while queued connections wait. """ kwargs = { "resume": resume, "sidecar_url": sidecar_url, "profile": profile, } if active_session_file is not None: kwargs["active_session_file"] = active_session_file async with _get_chat_argv_lock(app): return await asyncio.to_thread( _resolve_chat_argv, **kwargs, ) def _build_sidecar_url(channel: str) -> Optional[str]: """ws:// URL the PTY child should publish events to, or None when unbound. Loopback / ``--insecure``: uses ``?token=<_SESSION_TOKEN>``. Gated mode: authenticates with the process-lifetime internal credential (``?internal=``), the same one ``_build_gateway_ws_url`` uses. The PTY child is a server-spawned process we trust; the credential is multi-use and never expires, so the child can reconnect ``/api/pub`` without a new URL. (This previously minted a single-use 30s ticket, which meant the child could not reconnect and could miss the window on a slow cold boot.) Connections authenticated this way are recorded under the ``server-internal`` identity in the audit log. """ host = _resolve_client_ws_host() port = getattr(app.state, "bound_port", None) if not host or not port: return None netloc = f"[{host}]:{port}" if ":" in host and not host.startswith("[") else f"{host}:{port}" if getattr(app.state, "auth_required", False): # Gated mode — use the internal credential so the WS upgrade survives # _ws_auth_ok and the child can reconnect. from hermes_cli.dashboard_auth.ws_tickets import internal_ws_credential qs = urllib.parse.urlencode( {"internal": internal_ws_credential(), "channel": channel} ) else: qs = urllib.parse.urlencode({"token": _SESSION_TOKEN, "channel": channel}) return f"ws://{netloc}/api/pub?{qs}" async def _broadcast_event(app: Any, channel: str, payload: str) -> None: """Fan out one publisher frame to every subscriber on `channel`.""" event_channels, event_lock = _get_event_state(app) async with event_lock: subs = list(event_channels.get(channel, ())) for sub in subs: try: await sub.send_text(payload) except Exception: # Subscriber went away mid-send; the /api/events finally clause # will remove it from the registry on its next iteration. _log.warning("broadcast send failed for subscriber on %s", channel, exc_info=True) def _channel_or_close_code(ws: WebSocket) -> Optional[str]: """Return the channel id from the query string or None if invalid.""" channel = ws.query_params.get("channel", "") return channel if _VALID_CHANNEL_RE.match(channel) else None def _active_session_file_for_channel(app: "FastAPI", channel: str) -> Path: """Return the per-channel file where a dashboard TUI writes its active sid.""" files = _get_pty_active_session_files(app) existing = files.get(channel) if existing is not None: return existing fd, raw_path = tempfile.mkstemp(prefix="hermes-pty-active-", suffix=".json") os.close(fd) path = Path(raw_path) files[channel] = path return path def _read_active_session_file(path: Path) -> Optional[str]: try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return None session_id = str(data.get("session_id") or "").strip() return session_id or None def _forget_active_session_file(path: Path) -> None: try: path.unlink(missing_ok=True) except OSError: pass def _ws_close_reason(text: str) -> str: """Clamp a WS close reason to the protocol's 123-byte UTF-8 limit. RFC 6455 caps the close-frame reason at 123 bytes; uvicorn raises if a longer string is passed. Our reasons embed an attacker-controlled origin, so truncate defensively rather than crash the close handler. """ encoded = text.encode("utf-8", "replace") if len(encoded) <= 123: return text return encoded[:120].decode("utf-8", "ignore") + "..." # --------------------------------------------------------------------------- # /api/console — safe Hermes Console command WebSocket. # # Unlike /api/pty, this endpoint never spawns a PTY, shell, or full Hermes CLI # subprocess. It runs the curated console engine in-process and exchanges # structured JSON frames with the dashboard xterm overlay. # --------------------------------------------------------------------------- _CONSOLE_PROMPT = "hermes> " _CONSOLE_COMMAND_TIMEOUT_SECONDS = 60.0 _CONSOLE_OUTPUT_LIMIT = 50000 # Console commands run in a worker thread. On a timeout, asyncio.wait_for cancels # the *awaitable*, but Python threads aren't preemptible, so a genuinely stuck # worker keeps running to completion. To keep that from exhausting the shared # default thread pool (asyncio.to_thread), we run console commands on a small # dedicated, bounded pool: a leaked worker is capped, and concurrent console # execution is bounded to a fixed number of threads regardless of reconnects. _CONSOLE_EXECUTOR_MAX_WORKERS = 4 _console_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None _console_executor_lock = threading.Lock() def _get_console_executor() -> concurrent.futures.ThreadPoolExecutor: """Lazily create the bounded console worker pool (once per process).""" global _console_executor if _console_executor is None: with _console_executor_lock: if _console_executor is None: _console_executor = concurrent.futures.ThreadPoolExecutor( max_workers=_CONSOLE_EXECUTOR_MAX_WORKERS, thread_name_prefix="hermes-console", ) # Ensure the pool is torn down on interpreter exit. Don't wait on # in-flight workers: a stuck 60s console command must not block # shutdown (cancel_futures drops anything not yet started). atexit.register( lambda: _console_executor and _console_executor.shutdown(wait=False, cancel_futures=True) ) return _console_executor def _console_profile_from_ws(ws: WebSocket) -> Optional[str]: profile = (ws.query_params.get("profile") or "").strip() return profile or None def _execute_console_line( engine: Any, line: str, *, confirmed: bool, profile: Optional[str], ) -> Any: # _profile_scope swaps process-global skill module paths; keep it inside # the worker thread and never hold it across awaits. with _profile_scope(profile): return engine.execute(line, confirmed=confirmed) async def _console_send( ws: WebSocket, send_lock: asyncio.Lock, payload: Dict[str, Any], ) -> None: async with send_lock: await ws.send_json(payload) async def _console_send_result( ws: WebSocket, send_lock: asyncio.Lock, result: Any, *, command_id: int, ) -> None: command = result.command or "" status = result.status if status == "ok": if result.output: await _console_send( ws, send_lock, { "type": "output", "id": command_id, "stream": "stdout", "data": result.output, "command": command, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "ok", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "error": await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": result.output or "Command failed.", "command": command, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "error", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "confirm_required": await _console_send( ws, send_lock, { "type": "confirm_required", "id": command_id, "command": command, "message": result.confirmation_message or f"Run `{command}`?", "prompt": _CONSOLE_PROMPT, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "confirm_required", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "clear": await _console_send(ws, send_lock, {"type": "clear", "id": command_id}) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "clear", "command": command, "prompt": _CONSOLE_PROMPT, }, ) return if status == "exit": await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "exit", "command": command, "prompt": "", }, ) return await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": f"Unknown console result status: {status}", "command": command, }, ) def _console_json_payload(msg: Any) -> tuple[Optional[dict[str, Any]], Optional[str]]: raw: str | bytes | None = msg.get("text") if raw is None: raw = msg.get("bytes") if raw is None: return None, None if isinstance(raw, bytes): try: raw = raw.decode("utf-8") except UnicodeDecodeError: return None, "Console frames must be UTF-8 JSON." try: payload = json.loads(raw) except json.JSONDecodeError: return None, "Console frames must be JSON objects." if not isinstance(payload, dict): return None, "Console frames must be JSON objects." return payload, None @app.websocket("/api/console") async def console_ws(ws: WebSocket) -> None: peer = ws.client.host if ws.client else "?" if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: _log.info("console refused: embedded chat disabled peer=%s", peer) await ws.close(code=4404, reason="embedded chat disabled") return auth_reason, cred = _ws_auth_reason(ws) mode = _ws_auth_mode() if auth_reason is not None: _log.warning( "console auth rejected reason=%s mode=%s cred=%s peer=%s", auth_reason, mode, cred, peer, ) await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}")) return host_origin_reason = _ws_host_origin_reason(ws) if host_origin_reason is not None: _log.warning("console refused: %s peer=%s", host_origin_reason, peer) await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason)) return client_reason = _ws_client_reason(ws) if client_reason is not None: _log.warning("console refused: %s", client_reason) await ws.close(code=4408, reason=_ws_close_reason(client_reason)) return await ws.accept() profile = _console_profile_from_ws(ws) send_lock = asyncio.Lock() try: from hermes_cli.console_engine import HermesConsoleEngine engine = HermesConsoleEngine(output_limit=_CONSOLE_OUTPUT_LIMIT) if profile and profile.lower() != "current": _resolve_profile_dir(profile) except HTTPException as exc: await _console_send( ws, send_lock, { "type": "error", "message": str(exc.detail), "prompt": "", }, ) await ws.close(code=4400, reason=_ws_close_reason(str(exc.detail))) return except Exception as exc: _log.exception("console failed to initialize") await _console_send( ws, send_lock, { "type": "error", "message": f"Console unavailable: {exc}", "prompt": "", }, ) await ws.close(code=1011) return _log.info( "console accepted peer=%s mode=%s cred=%s profile=%s", peer, mode, cred, profile or "current", ) await _console_send( ws, send_lock, { "type": "ready", "profile": profile or "current", "prompt": _CONSOLE_PROMPT, }, ) active_task: asyncio.Task | None = None pending_confirmation: Optional[str] = None command_generation = 0 async def run_command(line: str, *, confirmed: bool, command_id: int) -> None: nonlocal active_task, pending_confirmation, command_generation try: loop = asyncio.get_running_loop() result = await asyncio.wait_for( loop.run_in_executor( _get_console_executor(), functools.partial( _execute_console_line, engine, line, confirmed=confirmed, profile=profile, ), ), timeout=_CONSOLE_COMMAND_TIMEOUT_SECONDS, ) except asyncio.CancelledError: raise except asyncio.TimeoutError: if command_id == command_generation: pending_confirmation = None await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": ( "Command timed out. Hermes Console returned to the prompt." ), "command": line, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "timeout", "command": line, "prompt": _CONSOLE_PROMPT, }, ) except Exception as exc: if command_id == command_generation: pending_confirmation = None _log.exception("console command failed") await _console_send( ws, send_lock, { "type": "error", "id": command_id, "message": str(exc) or exc.__class__.__name__, "command": line, }, ) await _console_send( ws, send_lock, { "type": "complete", "id": command_id, "status": "error", "command": line, "prompt": _CONSOLE_PROMPT, }, ) else: if command_id != command_generation: return pending_confirmation = ( result.command if result.status == "confirm_required" else None ) await _console_send_result( ws, send_lock, result, command_id=command_id, ) if result.status == "exit": await ws.close(code=1000) finally: if command_id == command_generation: active_task = None async def start_command(line: str, *, confirmed: bool = False) -> None: nonlocal active_task, command_generation command_generation += 1 command_id = command_generation active_task = asyncio.create_task( run_command(line, confirmed=confirmed, command_id=command_id) ) try: while True: try: msg = await ws.receive() except RuntimeError: break msg_type = msg.get("type") if msg_type == "websocket.disconnect": break payload, error = _console_json_payload(msg) if error: await _console_send( ws, send_lock, { "type": "error", "message": error, "prompt": _CONSOLE_PROMPT, }, ) continue if payload is None: continue frame_type = str(payload.get("type") or "").strip().lower() if frame_type == "ping": await _console_send( ws, send_lock, { "type": "pong", "prompt": _CONSOLE_PROMPT, }, ) continue if frame_type == "cancel": if active_task and not active_task.done(): command_generation += 1 active_task.cancel() active_task = None pending_confirmation = None await _console_send( ws, send_lock, { "type": "complete", "status": "cancelled", "prompt": _CONSOLE_PROMPT, }, ) elif pending_confirmation: pending_confirmation = None await _console_send( ws, send_lock, { "type": "complete", "status": "cancelled", "prompt": _CONSOLE_PROMPT, }, ) else: await _console_send( ws, send_lock, { "type": "complete", "status": "idle", "prompt": _CONSOLE_PROMPT, }, ) continue if active_task and not active_task.done(): await _console_send( ws, send_lock, { "type": "error", "message": "A console command is already running.", "prompt": _CONSOLE_PROMPT, }, ) continue if frame_type == "confirm": command = str(payload.get("command") or pending_confirmation or "").strip() if not pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": "No command is waiting for confirmation.", "prompt": _CONSOLE_PROMPT, }, ) continue if command != pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": "Confirmation does not match the pending command.", "prompt": _CONSOLE_PROMPT, }, ) continue pending_confirmation = None await start_command(command, confirmed=True) continue if frame_type in {"input", "command"}: line = str(payload.get("line") or payload.get("command") or "").strip() if not line: await _console_send( ws, send_lock, { "type": "complete", "status": "ok", "prompt": _CONSOLE_PROMPT, }, ) continue if pending_confirmation: await _console_send( ws, send_lock, { "type": "error", "message": ( "Confirm or cancel the pending command before " "running another one." ), "prompt": _CONSOLE_PROMPT, }, ) continue await start_command(line) continue await _console_send( ws, send_lock, { "type": "error", "message": f"Unsupported console frame: {frame_type or '?'}", "prompt": _CONSOLE_PROMPT, }, ) except WebSocketDisconnect: pass finally: if active_task and not active_task.done(): active_task.cancel() try: await active_task except (asyncio.CancelledError, Exception): pass @app.websocket("/api/pty") async def pty_ws(ws: WebSocket) -> None: peer = ws.client.host if ws.client else "?" if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: _log.info("pty refused: embedded chat disabled peer=%s", peer) await ws.close(code=4404, reason="embedded chat disabled") return # --- auth + host/origin/peer check (before accept so we can close # cleanly AND tell the client WHY via the close code + reason). # Each gate maps to a distinct close code so the log and the # browser banner agree on the cause: # 4401 bad credential 4403 host/origin mismatch # 4408 peer not allowed 4404 chat disabled auth_reason, cred = _ws_auth_reason(ws) mode = _ws_auth_mode() if auth_reason is not None: _log.warning( "pty auth rejected reason=%s mode=%s cred=%s peer=%s", auth_reason, mode, cred, peer, ) await ws.close(code=4401, reason=_ws_close_reason(f"auth: {auth_reason}")) return host_origin_reason = _ws_host_origin_reason(ws) if host_origin_reason is not None: _log.warning("pty refused: %s peer=%s", host_origin_reason, peer) await ws.close(code=4403, reason=_ws_close_reason(host_origin_reason)) return client_reason = _ws_client_reason(ws) if client_reason is not None: _log.warning("pty refused: %s", client_reason) await ws.close(code=4408, reason=_ws_close_reason(client_reason)) return await ws.accept() _log.info("pty accepted peer=%s mode=%s cred=%s", peer, mode, cred) # On native Windows, the POSIX PTY bridge can't be imported. Tell the # client and close cleanly rather than pretending the feature works. if not _PTY_BRIDGE_AVAILABLE: await ws.send_text( "\r\n\x1b[31mChat unavailable: the embedded terminal requires a " "POSIX PTY, which native Windows Python doesn't provide.\x1b[0m\r\n" "\x1b[33mInstall Hermes inside WSL2 to use the dashboard's /chat " "tab — the rest of the dashboard works here.\x1b[0m\r\n" ) await ws.close(code=1011) return # --- spawn PTY ------------------------------------------------------ raw_resume = ws.query_params.get("resume") or None resume = raw_resume profile = ws.query_params.get("profile") or None channel = _channel_or_close_code(ws) sidecar_url = _build_sidecar_url(channel) if channel else None force_fresh = (ws.query_params.get("fresh") or "").strip().lower() in { "1", "true", "yes", "on", } active_session_file: Optional[Path] = None if channel: active_session_file = _active_session_file_for_channel(ws.app, channel) if force_fresh: resume = None _forget_active_session_file(active_session_file) elif not resume: resume = _read_active_session_file(active_session_file) if resume: # The client only knows to pin the viewport to the bottom # when it requested `?resume=`. Tell it a replay is coming # anyway so the implicit active-session fallback gets the # same follow-scroll treatment as an explicit resume (#93518). await ws.send_json({"type": "resume", "id": resume}) resolve_kwargs = { "resume": resume, "sidecar_url": sidecar_url, "profile": profile, } if active_session_file is not None: resolve_kwargs["active_session_file"] = str(active_session_file) try: argv, cwd, env = await _resolve_chat_argv_async(**resolve_kwargs) except HTTPException as exc: # Unknown/invalid profile from _resolve_profile_dir. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc.detail}\x1b[0m\r\n") await ws.close(code=1011) return except SystemExit as exc: # _make_tui_argv calls sys.exit(1) when node/npm is missing. await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return attach_token = ws.query_params.get("attach") or None registry_resume = raw_resume if raw_resume and env: registry_resume = env.get("HERMES_TUI_RESUME") or raw_resume if attach_token is not None and (registry_resume or profile): # Key explicit resumes on their canonical target, never the active-session fallback. attach_token = f"{attach_token}\0{profile or ''}\0{registry_resume or ''}" def _spawn(): return PtyBridge.spawn(argv, cwd=cwd, env=env) if attach_token is None: # Legacy path: 1:1 socket<->PTY, killed on disconnect (unchanged). try: bridge = _spawn() except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return except (FileNotFoundError, OSError) as exc: await ws.send_text(f"\r\n\x1b[31mChat failed to start: {exc}\x1b[0m\r\n") await ws.close(code=1011) return await _legacy_pump(ws, bridge) return # Keep-alive path: the PTY outlives this socket; reattach by token. try: session, _created = await PTY_REGISTRY.attach_or_spawn( attach_token, spawn=_spawn ) except PtyUnavailableError as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return except (FileNotFoundError, OSError, RegistryFull) as exc: await ws.send_text(f"\r\n\x1b[31mChat unavailable: {exc}\x1b[0m\r\n") await ws.close(code=1011) return # A fresh xterm cannot reliably reconstruct the TUI from an arbitrary # bounded tail of alternate-screen, differential ANSI output. Reused PTYs # emit a complete frame after replay so reconnects never reopen blank. await session.attach(ws, force_redraw=not _created) # --- writer loop: WebSocket → PTY master ---------------------------- # No reader task here: the session's drain task (spawned once per PTY, # inside the registry) forwards PTY output to whichever socket is # attached and rings-buffers it while detached. On child EOF the drain # closes the attached socket with 4410, which unparks ``ws.receive()`` # below — same half-open-socket protection the legacy pump has (#54028). try: while True: try: msg = await ws.receive() except RuntimeError: # ws.receive() after the socket is already disconnected # (e.g. closed by the drain task on process exit). break if msg.get("type") == "websocket.disconnect": break raw = msg.get("bytes") if raw is None: text = msg.get("text") raw = text.encode("utf-8") if isinstance(text, str) else b"" if not raw: continue # Resize escape is consumed locally, never written to the PTY. match = _RESIZE_RE.match(raw) if match and match.end() == len(raw): session.bridge.resize(cols=int(match.group(1)), rows=int(match.group(2))) continue session.bridge.write(raw) except WebSocketDisconnect: pass finally: # Detach only — the PTY keeps running for a reattach; the registry # reaper closes it after the TTL (or immediately on process exit). PTY_REGISTRY.detach(attach_token, ws) # --------------------------------------------------------------------------- # /api/ws — JSON-RPC WebSocket sidecar for the dashboard "Chat" tab. # # Drives the same `tui_gateway.dispatch` surface Ink uses over stdio, so the # dashboard can render structured metadata (model badge, tool-call sidebar, # slash launcher, session info) alongside the xterm.js terminal that PTY # already paints. Both transports bind to the same session id when one is # active, so a tool.start emitted by the agent fans out to both sinks. # --------------------------------------------------------------------------- @app.websocket("/api/ws") async def gateway_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return from tui_gateway.ws import handle_ws # The authenticated identity (ticket / internal credential) was stamped # onto the WS object by _ws_auth_reason; carry it into the gateway # transport where it becomes the identity authority for privileged RPCs # (browser.controller.register). None on the legacy token path. await handle_ws( ws, auth_identity=getattr(ws, "_hermes_auth_identity", None), subprotocol=getattr(ws, "_hermes_ws_subprotocol", None), ) # --------------------------------------------------------------------------- # /api/pub + /api/events — chat-tab event broadcast. # # The PTY-side ``tui_gateway.entry`` opens /api/pub at startup (driven by # HERMES_TUI_SIDECAR_URL set in /api/pty's PTY env) and writes every # dispatcher emit through it. The dashboard fans those frames out to any # subscriber that opened /api/events on the same channel id. This is what # gives the React sidebar its tool-call feed without breaking the PTY # child's stdio handshake with Ink. # --------------------------------------------------------------------------- @app.websocket("/api/pub") async def pub_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() try: while True: await _broadcast_event(ws.app, channel, await ws.receive_text()) except WebSocketDisconnect: pass @app.websocket("/api/events") async def events_ws(ws: WebSocket) -> None: if not _DASHBOARD_EMBEDDED_CHAT_ENABLED: await ws.close(code=4403) return if not _ws_auth_ok(ws): await ws.close(code=4401) return if not _ws_request_is_allowed(ws): await ws.close(code=4403) return channel = _channel_or_close_code(ws) if not channel: await ws.close(code=4400) return await ws.accept() event_channels, event_lock = _get_event_state(ws.app) async with event_lock: event_channels.setdefault(channel, set()).add(ws) try: while True: # Subscribers don't speak — the receive() just blocks until # disconnect so the connection stays open as long as the # browser holds it. await ws.receive_text() except WebSocketDisconnect: pass finally: async with event_lock: subs = event_channels.get(channel) if subs is not None: subs.discard(ws) if not subs: event_channels.pop(channel, None) def _normalise_prefix(raw: Optional[str]) -> str: """Normalise an X-Forwarded-Prefix header value. Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix` — the single source of truth lives in the dashboard_auth package so the gate middleware, the OAuth routes, the cookie helpers, and the SPA mount all agree on validation rules. """ from hermes_cli.dashboard_auth.prefix import normalise_prefix return normalise_prefix(raw) def _render_active_theme_bootstrap_css() -> str: """Critical-CSS shim for the active user theme. Returns a ```` escape — current values are well-known # hex/font strings, but this keeps the helper safe if it is # later extended to ship user-authored CSS literals. def _esc(s: str) -> str: return str(s).replace("' ":root{" f"--background-base:{_esc(bg_hex)};" f"--midground-base:{_esc(mg_hex)};" f"--theme-font-sans:{_esc(font_sans)};" f"--theme-base-size:{_esc(base_size)};" "}" "html,body{background-color:var(--background-base);" "color:var(--midground-base);" "font-family:var(--theme-font-sans);" "font-size:var(--theme-base-size);}" "" ) return "" except Exception: _log.debug("theme bootstrap render failed", exc_info=True) return "" # Hashed bundle assets (``/assets/-.``) are immutable # by construction: any content change produces a new filename, and the entry # point (index.html) is served ``no-store`` so it always references the # current hashes. A year-long immutable cache lets browsers skip even the # revalidation round-trip on every dashboard load. _IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" def mount_spa(application: FastAPI): """Mount the built SPA. Falls back to index.html for client-side routing. The session token is injected into index.html via a ``" "Headless backend (hermes serve): web UI disabled — use " "`hermes dashboard` for the browser UI." "", headers={ "Cache-Control": "no-store, no-cache, must-revalidate" }, ) return JSONResponse({"error": _msg}, status_code=404) return # A missing WEB_DIST is deliberately NOT a mount-time terminal state # (#82614): a long-lived `hermes dashboard --skip-build` process that # survives a `git pull` (or starts before the first build) used to # install a permanent no_frontend catch-all here and could never # recover — every route answered 404 "Frontend not built" until the # process was restarted, even after `npm run build` completed. The SPA # routes below all cope with a missing dist per-request (`_serve_index` # returns the same 404 JSON when index.html is unreadable; the asset # mounts use check_dir=False and 404 on missing files), so mounting # them unconditionally makes the dashboard recover the moment a build # appears on disk — no restart needed. _index_path = WEB_DIST / "index.html" def _serve_index(prefix: str = ""): """Return index.html with the session token + base-path injected. ``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``) or empty string when served at root. When the OAuth auth gate is active (``app.state.auth_required``), the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over cookie auth instead. The ``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right auth scheme for /api/pty and /api/ws (ticket vs token). """ try: html = _index_path.read_text(encoding="utf-8") except OSError: # The dist dir existed at mount time but index.html is missing or # unreadable now (partial build, wiped dist, permissions). Without # this guard every request raises FileNotFoundError (500). Return # the same JSON 404 payload mount_spa uses for a fully-missing # dist so clients get a clear, consistent signal. return JSONResponse( {"error": "Frontend not built. Run: cd web && npm run build"}, status_code=404, ) chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" gated = bool(getattr(app.state, "auth_required", False)) gated_js = "true" if gated else "false" if gated: bootstrap_script = ( f"" ) else: bootstrap_script = ( f'" ) if prefix: # Rewrite absolute asset URLs baked into the Vite build so the # browser fetches them through the same proxy prefix. html = html.replace('href="/assets/', f'href="{prefix}/assets/') html = html.replace('src="/assets/', f'src="{prefix}/assets/') html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"') html = html.replace('href="/fonts/', f'href="{prefix}/fonts/') html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/') html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/') # Theme flash mitigation: when the active theme is a user theme # (``HERMES_HOME/dashboard-themes/.yaml``), inject a minimal # critical-CSS block so the first paint uses the target palette. # Without this the SPA paints the default Hermes Teal canvas, then # ``ThemeProvider`` flips the CSS variables once # ``/api/dashboard/themes`` resolves. Built-in themes are already # in the bundle's ``presets.ts`` so no shim is needed for them. theme_bootstrap = _render_active_theme_bootstrap_css() if theme_bootstrap: html = html.replace("", f"{theme_bootstrap}", 1) html = html.replace("", f"{bootstrap_script}", 1) return HTMLResponse( html, headers={"Cache-Control": "no-store, no-cache, must-revalidate"}, ) # When served behind a path-prefix proxy, the built CSS contains # absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references. # Browsers resolve those against the document origin, which means # under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...`` # (the MC Pages app), not the Hermes backend. Intercept CSS asset # requests BEFORE the StaticFiles mount and rewrite the absolute paths # when a prefix is in play. @application.get("/assets/{filename}.css") async def serve_css(filename: str, request: Request): css_path = WEB_DIST / "assets" / f"{filename}.css" if not css_path.is_file() or not css_path.resolve().is_relative_to( WEB_DIST.resolve() ): return JSONResponse({"error": "not found"}, status_code=404) prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) css = css_path.read_text(encoding="utf-8") if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}") css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}") css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}") return Response( content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL}, ) class _ImmutableAssetFiles(StaticFiles): """StaticFiles that marks hashed bundle assets immutable. Everything under ``/assets/`` carries a Vite content hash in its filename, so a given URL's bytes can never change — a rebuild produces a NEW filename referenced by a fresh (``no-store``) index.html. Without this header every dashboard load re-validated each chunk; with it the browser serves reloads straight from its HTTP cache. """ async def get_response(self, path: str, scope): response = await super().get_response(path, scope) if response.status_code == 200: response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL return response application.mount( "/assets", # check_dir=False: the dist (and its assets/ dir) may not exist yet — # the whole point of the dynamic recheck (#82614). StaticFiles then # 404s per-request until a build appears instead of raising at mount. _ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False), name="assets", ) @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) # An unmatched /api/* path is a missing/renamed endpoint, NOT a # client-side route. Falling through to index.html here returns # `` with status 200, which makes JSON clients (the # desktop app's fetchJson, dashboard fetch wrappers) blow up with an # opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON # so the caller sees a clear "no such endpoint" instead. if full_path == "api" or full_path.startswith("api/"): return JSONResponse( {"detail": f"No such API endpoint: /{full_path}"}, status_code=404, ) file_path = WEB_DIST / full_path # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( full_path and file_path.resolve().is_relative_to(WEB_DIST.resolve()) and file_path.exists() and file_path.is_file() ): return FileResponse(file_path) return _serve_index(prefix) # --------------------------------------------------------------------------- # Dashboard theme endpoints # --------------------------------------------------------------------------- # Built-in dashboard themes — label + description only. The actual color # definitions live in the frontend (web/src/themes/presets.ts). _BUILTIN_DASHBOARD_THEMES = [ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, ] def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]: """Normalise a theme layer spec from YAML into `{hex, alpha}` form. Accepts shorthand (a bare hex string) or full dict form. Returns ``None`` on garbage input so the caller can fall back to a built-in default rather than blowing up. """ if value is None: return {"hex": default_hex, "alpha": default_alpha} if isinstance(value, str): return {"hex": value, "alpha": default_alpha} if isinstance(value, dict): hex_val = value.get("hex", default_hex) alpha_val = value.get("alpha", default_alpha) if not isinstance(hex_val, str): return None try: alpha_f = float(alpha_val) except (TypeError, ValueError): alpha_f = default_alpha return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} return None _THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = { "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', "baseSize": "15px", "lineHeight": "1.55", "letterSpacing": "0", } _THEME_DEFAULT_LAYOUT: Dict[str, str] = { "radius": "0.5rem", "density": "comfortable", } _THEME_OVERRIDE_KEYS = { "card", "cardForeground", "popover", "popoverForeground", "primary", "primaryForeground", "secondary", "secondaryForeground", "muted", "mutedForeground", "accent", "accentForeground", "destructive", "destructiveForeground", "success", "warning", "border", "input", "ring", } # Well-known named asset slots themes can populate. Any other keys under # ``assets.custom`` are exposed as ``--theme-asset-custom-`` CSS vars # for plugin/shell use. _THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"} # Component-style buckets themes can override. The value under each bucket # is a mapping from camelCase property name to CSS string; each pair emits # ``--component--`` on :root. The frontend's shell # components (Card, App header, Backdrop, etc.) consume these vars so themes # can restyle chrome (clip-path, border-image, segmented progress, etc.) # without shipping their own CSS. _THEME_COMPONENT_BUCKETS = { "card", "header", "footer", "sidebar", "tab", "progress", "badge", "backdrop", "page", } _THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"} # Cap on customCSS length so a malformed/oversized theme YAML can't blow up # the response payload or the