""" 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", }) # Directory names whose entire subtree is credential material. Both canonical # guards deny these as directory trees, not basenames: # * gateway.platforms.base._ROOT_CREDENTIAL_DIRS = {"pairing", "mcp-tokens"} # * agent.file_safety.get_read_block_error (mcp-tokens/ prefix match) # The managed-files API lets the browser descend into subdirs, so a # basename-only guard would still expose e.g. ``mcp-tokens/.json`` # (live MCP OAuth tokens) and ``pairing/``. We match on ANY path component # so these trees are blocked wherever they appear under the browsable root, # without needing to resolve them relative to HERMES_HOME. _SENSITIVE_MANAGED_DIR_NAMES = frozenset({ "mcp-tokens", "pairing", }) def _is_sensitive_filename(name: str) -> bool: """Return True for a basename the managed-files API must never expose. Covers ``.env`` / ``.env.`` / ``.envrc`` variants plus the canonical Hermes credential-store basenames (see ``_SENSITIVE_MANAGED_FILE_BASENAMES`` above). Case-insensitive so ``.ENV`` / ``.Env.local`` / ``Auth.JSON`` on case-insensitive filesystems (macOS/Windows mounts) can't slip past the guard. Basename-only: for the directory-tree credential stores (``mcp-tokens/``, ``pairing/``) that the canonical guards also deny, use :func:`_is_sensitive_path`, which the API call sites route through. """ lowered = name.lower() if lowered == ".env" or lowered.startswith(".env.") or lowered == ".envrc": return True return lowered in _SENSITIVE_MANAGED_FILE_BASENAMES def _is_sensitive_path(path: Path) -> bool: """Return True for any path the managed-files API must never expose. Combines the basename denylist (:func:`_is_sensitive_filename`) with a credential-directory-tree check: a path is sensitive if its own basename is sensitive OR any of its path components is a credential directory (``mcp-tokens`` / ``pairing``). The component match is case-insensitive and needs no HERMES_HOME resolution, so it blocks these trees wherever they sit under the operator-configured managed root — closing the gap the canonical guards cover as directory trees but a basename-only check would miss. Read-side only: this guards list/read/download (the #57505 exfil surface). The write endpoints (upload/mkdir/delete) are a separate threat class handled by the write-path checks; extending this guard to them is out of scope for this fix. """ if _is_sensitive_filename(path.name): return True return any(part.lower() in _SENSITIVE_MANAGED_DIR_NAMES for part in path.parts) _FS_DATA_URL_MAX_BYTES = 16 * 1024 * 1024 _FS_TEXT_SOURCE_MAX_BYTES = 64 * 1024 * 1024 _FS_TEXT_PREVIEW_MAX_BYTES = 512 * 1024 # Upper bound for the in-app spot editor's save. The editor only opens # non-truncated text (<= the preview cap), so this is a safety ceiling against # a pasted-in megablob, not the expected payload size. _FS_TEXT_WRITE_MAX_BYTES = 8 * 1024 * 1024 _FS_PREVIEW_LANGUAGE_BY_EXT = { ".c": "c", ".conf": "ini", ".cpp": "cpp", ".css": "css", ".csv": "csv", ".go": "go", ".graphql": "graphql", ".h": "c", ".hpp": "cpp", ".html": "html", ".java": "java", ".js": "javascript", ".json": "json", ".jsx": "jsx", ".kt": "kotlin", ".lua": "lua", ".md": "markdown", ".mjs": "javascript", ".py": "python", ".rb": "ruby", ".rs": "rust", ".sh": "shell", ".sql": "sql", ".svg": "xml", ".toml": "toml", ".ts": "typescript", ".tsx": "tsx", ".txt": "text", ".xml": "xml", ".yaml": "yaml", ".yml": "yaml", ".zsh": "shell", } _FS_MIME_TYPES = { ".avi": "video/x-msvideo", ".bmp": "image/bmp", ".flac": "audio/flac", ".gif": "image/gif", ".jpeg": "image/jpeg", ".jpg": "image/jpeg", ".m4a": "audio/mp4", ".mkv": "video/x-matroska", ".mov": "video/quicktime", ".mp3": "audio/mpeg", ".mp4": "video/mp4", ".ogg": "audio/ogg", ".opus": "audio/ogg; codecs=opus", ".png": "image/png", ".svg": "image/svg+xml", ".wav": "audio/wav", ".webm": "video/webm", ".webp": "image/webp", } 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 _fs_mime_type(path: Path) -> str: suffix = path.suffix.lower() if suffix in _FS_MIME_TYPES: return _FS_MIME_TYPES[suffix] guessed, _ = mimetypes.guess_type(str(path)) return guessed or "application/octet-stream" def _fs_looks_binary(data: bytes) -> bool: if not data: return False if b"\0" in data: return True suspicious = sum(1 for byte in data if byte < 32 and byte not in {9, 10, 13}) return suspicious / len(data) > 0.12 def _fs_regular_file(path: Path) -> tuple[Path, os.stat_result]: target = _fs_path(str(path)) try: st = target.stat() except FileNotFoundError: raise HTTPException(status_code=404, detail="File not found") except NotADirectoryError: raise HTTPException(status_code=404, detail="File not found") except PermissionError: raise HTTPException(status_code=403, detail="File is not readable") except OSError as exc: raise HTTPException(status_code=400, detail=str(exc) or "Invalid path") if stat.S_ISDIR(st.st_mode): raise HTTPException(status_code=400, detail="Path points to a directory") if not stat.S_ISREG(st.st_mode): raise HTTPException(status_code=400, detail="Only regular files can be read") return target, st def _fs_find_git_root(start: Path) -> str | None: directory = start for _ in range(50): try: if (directory / ".git").exists(): return str(directory) except OSError: return None parent = directory.parent if parent == directory: return None directory = parent return None def _fs_default_cwd() -> str: cfg_terminal = load_config().get("terminal") or {} raw = str(cfg_terminal.get("cwd") or os.environ.get("TERMINAL_CWD") or "").strip() if raw and raw not in {".", "auto", "cwd"}: try: candidate = Path(raw).expanduser().resolve(strict=False) if candidate.is_dir(): return str(candidate) except (OSError, RuntimeError): pass return str(Path.cwd()) def _fs_git_branch(cwd: str) -> str: try: run_kwargs: Dict[str, Any] = { "capture_output": True, "text": True, "timeout": 2, "check": False, } if sys.platform == "win32": run_kwargs["creationflags"] = windows_hide_flags() result = subprocess.run( ["git", "-C", cwd, "branch", "--show-current"], **run_kwargs, ) return result.stdout.strip() if result.returncode == 0 else "" except Exception: return "" def _media_serve_roots() -> list[Path]: """Directories ``GET /api/media`` is allowed to read from. Confined to where the agent and attach pipeline actually write media on the gateway host — its images dir and cache subtree. This stops an authenticated client from reading image-extension files anywhere on disk (e.g. a renamed key or a screenshot outside the cache) merely because the suffix passes the allowlist. """ home = get_hermes_home() roots = [home / "images", home / "screenshots", home / "cache"] out: list[Path] = [] for root in roots: try: out.append(root.resolve()) except (OSError, RuntimeError): continue return out @app.get("/api/media") async def get_media(path: str): """Return a gateway-local image file as a base64 data URL. Lets remote clients (the desktop app over the network, or the web dashboard in a browser) display images the agent wrote to *this* machine's filesystem — they can't read the gateway's local disk directly. Auth-gated by the session token like every other /api route. Restricted to an image-extension allowlist, a size cap, AND the gateway's own media roots (resolved, symlink-safe) so it can't be used to read arbitrary files. """ try: target = Path(path).expanduser().resolve() except (OSError, RuntimeError): raise HTTPException(status_code=400, detail="Invalid path") if target.suffix.lower() not in _MEDIA_CONTENT_TYPES: raise HTTPException(status_code=415, detail="Unsupported media type") roots = _media_serve_roots() if not any(target == root or root in target.parents for root in roots): raise HTTPException(status_code=403, detail="Path outside media roots") if not target.is_file(): raise HTTPException(status_code=404, detail="File not found") if target.stat().st_size > _MEDIA_MAX_BYTES: raise HTTPException(status_code=413, detail="File too large") encoded = base64.b64encode(target.read_bytes()).decode("ascii") return {"data_url": f"data:{_MEDIA_CONTENT_TYPES[target.suffix.lower()]};base64,{encoded}"} 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 _local_dashboard_request(request: Request) -> bool: if getattr(request.app.state, "auth_required", False): return False host = (request.url.hostname or "").lower() client_host = (request.client.host if request.client else "").lower() local_hosts = {"", "localhost", "127.0.0.1", "::1", "testserver", "testclient"} return host in local_hosts or client_host in local_hosts 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, } def _decode_data_url(data_url: str) -> tuple[bytes, str]: text = (data_url or "").strip() if not text.startswith("data:") or "," not in text: raise HTTPException(status_code=400, detail="Upload payload must be a data URL") header, encoded = text.split(",", 1) mime_type = header[5:].split(";", 1)[0] or "application/octet-stream" if ";base64" not in header: raise HTTPException(status_code=400, detail="Upload payload must be base64 encoded") try: data = base64.b64decode(encoded, validate=True) except (binascii.Error, ValueError): raise HTTPException(status_code=400, detail="Upload payload is not valid base64") if len(data) > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") return data, mime_type _CHAT_IMAGE_UPLOAD_MAX_BYTES = 25 * 1024 * 1024 _CHAT_IMAGE_ALLOWED_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp"}) _CHAT_IMAGE_MAGIC: tuple[tuple[bytes, str], ...] = ( (b"\x89PNG\r\n\x1a\n", ".png"), (b"\xff\xd8\xff", ".jpg"), (b"GIF87a", ".gif"), (b"GIF89a", ".gif"), (b"BM", ".bmp"), ) def _sanitize_chat_image_filename(filename: str | None) -> str: candidate = Path(str(filename or "").strip()).name candidate = re.sub(r"[\x00-\x1f]+", "_", candidate) candidate = candidate.strip().strip(".") return candidate or "pasted-image" def _chat_image_extension(data: bytes) -> str | None: head = data[:16] if head.startswith(b"RIFF") and head[8:12] == b"WEBP": return ".webp" for sig, ext in _CHAT_IMAGE_MAGIC: if head.startswith(sig): return ext return None def _decode_chat_image_upload(payload: ChatImageUpload) -> tuple[bytes, str, str]: data, mime_type = _decode_data_url(payload.data_url) if not mime_type.lower().startswith("image/"): raise HTTPException(status_code=400, detail="Upload payload must be an image") if len(data) > _CHAT_IMAGE_UPLOAD_MAX_BYTES: mb = _CHAT_IMAGE_UPLOAD_MAX_BYTES // (1024 * 1024) raise HTTPException(status_code=413, detail=f"Image is too large; cap is {mb} MB") ext = _chat_image_extension(data) if ext not in _CHAT_IMAGE_ALLOWED_EXTENSIONS: raise HTTPException(status_code=400, detail="Unsupported image type") return data, mime_type, ext @app.post("/api/chat/image-upload") async def upload_chat_image(payload: ChatImageUpload, profile: Optional[str] = None): """Persist a browser-provided chat image where the embedded TUI can read it. The dashboard /chat page runs Hermes inside an xterm.js PTY. Browser clipboard image bytes are not visible to the server-side clipboard, so the page uploads them here, then drives the TUI's ``/image `` command with the returned gateway-visible path. Files land under ``HERMES_HOME/images/`` — the same directory ``clipboard.paste`` / ``image.attach`` already use. """ def _run(): data, mime_type, ext = _decode_chat_image_upload(payload) with _profile_scope(profile) as scoped_home: home = scoped_home or get_hermes_home() img_dir = Path(home) / "images" try: img_dir.mkdir(parents=True, exist_ok=True) except PermissionError: raise HTTPException(status_code=403, detail="Image directory is not writable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not create image directory: {exc}") stem = Path(_sanitize_chat_image_filename(payload.filename)).stem or "pasted-image" stem = re.sub(r"[^A-Za-z0-9_.-]+", "_", stem).strip("._-") or "pasted-image" ts = datetime.now().strftime("%Y%m%d_%H%M%S") target = img_dir / f"dashboard_{ts}_{secrets.token_hex(4)}_{stem}{ext}" try: target.write_bytes(data) except PermissionError: raise HTTPException(status_code=403, detail="Image directory is not writable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not write image: {exc}") return { "ok": True, "path": str(target), "name": target.name, "bytes": len(data), "mime_type": mime_type, } # _profile_scope acquires _SKILLS_PROFILE_LOCK and the body does file I/O — # keep both off the event loop (asyncio.to_thread copies the contextvar # context, so the profile override stays scoped to the worker thread). return await asyncio.to_thread(_run) @app.get("/api/files") async def list_managed_files(request: Request, path: Optional[str] = None): policy, target, display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="Path not found") if not target.is_dir(): raise HTTPException(status_code=400, detail="Path is not a directory") try: with os.scandir(target) as scan: entries = [ _managed_file_entry(policy, Path(entry.path)) for entry in scan if not _is_sensitive_path(Path(entry.path)) ] except PermissionError: raise HTTPException(status_code=403, detail="Directory is not readable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not read directory: {exc}") entries.sort(key=lambda item: (not item["is_directory"], str(item["name"]).lower())) locked_root = policy.locked_root parent = None if target.parent != target and (locked_root is None or target != locked_root): parent = str(target.parent) return { "path": display_path, "parent": parent, "entries": entries, **_managed_response_meta(policy), } @app.get("/api/files/read") async def read_managed_file(request: Request, path: str): policy, target, display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") if _is_sensitive_path(target): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") try: size = target.stat().st_size except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") if size > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" try: encoded = base64.b64encode(target.read_bytes()).decode("ascii") except PermissionError: raise HTTPException(status_code=403, detail="File is not readable") except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not read file: {exc}") return { "name": target.name, "path": display_path, "size": size, "mime_type": mime_type, "data_url": f"data:{mime_type};base64,{encoded}", **_managed_response_meta(policy), } def _managed_file_response( request: Request, path: str, *, content_disposition_type: str, media_only: bool = False, ) -> FileResponse: """Build a range-aware response after applying managed-file policy.""" policy, target, _display_path = _resolve_managed_path(path, request) if not target.exists(): raise HTTPException(status_code=404, detail="File not found") if not target.is_file(): raise HTTPException(status_code=400, detail="Path is not a file") if _is_sensitive_path(target): raise HTTPException(status_code=403, detail="Access to sensitive files is not allowed") if media_only and target.suffix.lower() not in _STREAMABLE_MEDIA_EXTENSIONS: raise HTTPException(status_code=415, detail="Unsupported media type") try: size = target.stat().st_size except OSError as exc: raise HTTPException(status_code=500, detail=f"Could not stat file: {exc}") if size > _MANAGED_FILE_MAX_BYTES: raise HTTPException(status_code=413, detail="File is too large") mime_type = mimetypes.guess_type(target.name)[0] or "application/octet-stream" return FileResponse( path=str(target), media_type=mime_type, filename=target.name, content_disposition_type=content_disposition_type, headers={"X-Content-Type-Options": "nosniff"} if media_only else None, ) @app.get("/api/files/download") async def download_managed_file(request: Request, path: str): """Stream a managed file as an attachment download. Remote clients (desktop app, browser dashboard) open agent-written files that live on *this* gateway's disk, not theirs. Auth-gated like every other managed-files route — ``auth_middleware`` additionally accepts the session token as a ``?token=`` query param here so a shell/browser-opened download (which can't set the session header) still authenticates. See ``/api/pty`` for the same query-token precedent. Chromium identifies ``