"""Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge. """ import asyncio import logging import json import os import threading import time from contextlib import contextmanager import hermes_yaml as yaml from fastapi import Depends, FastAPI, HTTPException, Request, WebSocket, WebSocketException from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response from fastapi.staticfiles import StaticFiles from starlette import status from starlette._utils import get_route_path from starlette.requests import HTTPConnection from pathlib import Path from typing import Any, Dict, List, Optional from hermes_cli.config import cfg_get, get_process_hermes_home from utils import env_var_enabled # Serve assets with explicit MIME types: the host's map (on Windows the # registry can map .js -> text/plain) must not decide the Content-Type of # dashboard bundles (#28987). Runs before any StaticFiles/FileResponse below # serves a request, and is idempotent. from hermes_cli.web_asset_mime_types import normalize_web_asset_mime_types normalize_web_asset_mime_types() # Same logger the code used before extraction (record parity). _log = logging.getLogger("hermes_cli.web_server") def _normalise_prefix(raw: Optional[str]) -> str: """Normalise an X-Forwarded-Prefix header value (single source of truth lives in ``hermes_cli.dashboard_auth.prefix`` so gate, OAuth, cookies and SPA mount agree).""" from hermes_cli.dashboard_auth.prefix import normalise_prefix return normalise_prefix(raw) def _layer_hex(palette: dict[str, Any], key: str, default: str) -> str: layer = palette.get(key) or {} return layer.get("hex", default) if isinstance(layer, dict) else default def _render_active_theme_bootstrap_css() -> str: """Critical-CSS ```` escape return str(s).replace("", "<\\/") return ( '" ) return "" except Exception: _log.debug("theme bootstrap render failed", exc_info=True) return "" # Hashed bundle assets are immutable by construction (content hash in the filename; index.html # is served ``no-store`` and always references the current hashes). _IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable" _NO_STORE = {"Cache-Control": "no-store, no-cache, must-revalidate"} _HEADLESS_MSG = ( "Headless backend (hermes serve): web UI disabled — use " "`hermes dashboard` for the browser UI." ) def mount_spa(application: FastAPI): """Mount the built SPA; unmatched paths fall back to index.html for client-side routing. Dashboard injects its session token into index.html; Webapp instead receives the same credential through an operator-only launch fragment. Behind a path-prefix reverse proxy (``X-Forwarded-Prefix: /hermes``) the served index.html is rewritten so absolute asset URLs and the runtime ``__HERMES_BASE_PATH__`` honour that prefix without a rebuild. A missing WEB_DIST is deliberately NOT a mount-time terminal state: every route copes with a missing dist per-request (404 JSON / ``check_dir=False``), so a long-lived ``--skip-build`` process recovers the moment a build appears on disk — no restart. The dist is also chosen per request: this mount runs at import, and a host that serves another renderer (the Webapp's ``dist-webapp``) records it on ``application.state.web_dist`` later. """ from hermes_cli.web_server import WEB_DIST, _DASHBOARD_EMBEDDED_CHAT_ENABLED from hermes_cli.web_deps import _server from hermes_cli.web_server_surface import policy from hermes_cli.web_server_static_assets import is_static_asset_path, public_font_file # `hermes serve` is the headless backend: it must NEVER serve the browser SPA, even if a # dist is lying around, so only the JSON-RPC/WS/API surface is reachable. if os.environ.get("HERMES_SERVE_HEADLESS") == "1": @application.get("/{full_path:path}") async def no_frontend(full_path: str): # Desktop token handshake: the Electron shell boots by fetching `/` and reading # ``window.__HERMES_SESSION_TOKEN__`` for /api/ws auth. When headless 404'd every # path, a renderer whose spawn token no longer matched (e.g. after `hermes update`) # white-screened. Serve a token-only page at the exact root, but ONLY when the auth # gate is off: on a gated serve the token must never be readable without auth. # See #94227, #95575. gated = bool(getattr(application.state, "auth_required", False)) withheld = gated or bool(getattr(_server().app.state, "withhold_session_token", False)) if full_path == "" and not withheld: return HTMLResponse( "
{_HEADLESS_MSG}", headers=_NO_STORE, ) return JSONResponse({"error": _HEADLESS_MSG}, status_code=404) return # A missing WEB_DIST is deliberately NOT a mount-time terminal state (#82614): a long-lived `hermes # dashboard --skip-build` process that survives a `git pull` (or starts before the first build) used to # install a permanent no_frontend catch-all here and could never recover — every route answered 404 # "Frontend not built" until the process was restarted, even after `npm run build` completed. The SPA # routes below all cope with a missing dist per-request (`_serve_index` returns the same 404 JSON when # index.html is unreadable; the asset mounts use check_dir=False and 404 on missing files), so mounting # them unconditionally makes the dashboard recover the moment a build appears on disk — no restart # needed. def web_dist() -> Path: return getattr(application.state, "web_dist", None) or WEB_DIST def _serve_index(request: Request, prefix: str = ""): """index.html with the session token + base-path injected. When the OAuth auth gate is active (``app.state.auth_required``), the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads identity from ``/api/auth/me`` over cookie auth; ``__HERMES_AUTH_REQUIRED__`` tells it which scheme to use for /api/pty and /api/ws (ticket vs token). """ try: html = (web_dist() / "index.html").read_text(encoding="utf-8-sig") except OSError: # Partial build / wiped dist / permissions: same JSON 404 as a fully-missing dist. The # Webapp's bundle is built by `hermes webapp`, not by the dashboard's web project. webapp = getattr(application.state, "ui_surface", "dashboard") == "webapp" missing = ("The Webapp is not built. Run: hermes webapp" if webapp else "Frontend not built. Run: pnpm --dir web run build") return JSONResponse({"error": missing}, status_code=404) if getattr(application.state, "ui_surface", "dashboard") == "webapp": # A bundle `hermes update` could not rebuild was built for the code before the update: # refuse it until `hermes webapp` rebuilds it. from gateway.webapp_listener import bundle_stale_reason stale = bundle_stale_reason(web_dist()) if stale: return JSONResponse({"error": stale}, status_code=503, headers=_NO_STORE) chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false" gated = bool(getattr(application.state, "auth_required", False)) # Only a host that records its surface (the gateway's Webapp) names it to the page. surface = getattr(application.state, "ui_surface", None) surface_js = "" if surface is None else f"window.__HERMES_UI_SURFACE__={json.dumps(surface)};" # Webapp grants host files and shell.exec as well as PTYs: its existing # session token must come from the operator, never anonymous HTML. The # gateway-hosted listener (gateway/run_api.py) never publishes it either: # local clients use tickets. withheld = (gated or bool(getattr(_server().app.state, "withhold_session_token", False)) or not policy(application.state).html_token) token_js = "" if withheld else f'window.__HERMES_SESSION_TOKEN__="{_server()._SESSION_TOKEN}";' # Launcher-preselected profile (``--open-profile``): the SPA's fallback scope when the URL # omits ``?profile=`` (#73085). ```` escaped so a hostile name cannot close the script tag. initial_profile_js = json.dumps(str(getattr(application.state, "initial_profile", "") or "")).replace("", "<\\/") # This backend's OWN profile name (empty when it cannot be named unambiguously). The SPA # falls back to it when neither the URL nor --open-profile names one, so requests carry an # explicit scope from the first paint: destructive routes 400 on an unnamed profile as soon # as the host serves more than one, and the switcher shows the same profile it writes. The # Webapp reads it as the profile its gateway socket and REST answer for when a request names # none, so a tab on any other profile names that profile on every request. from hermes_cli.web_server_profiles import serving_profile_name as _serving_profile_name serving_profile_js = json.dumps(_serving_profile_name()).replace("", "<\\/") bootstrap_script = ( f"" ) if prefix: # Rewrite absolute asset URLs baked into the Vite build to go through the proxy. for attr in ('href="/assets/', 'src="/assets/', 'href="/favicon.ico"', 'href="/fonts/', 'href="/ds-assets/', 'src="/ds-assets/'): html = html.replace(attr, attr.replace('"/', f'"{prefix}/', 1)) theme_bootstrap = _render_active_theme_bootstrap_css() if theme_bootstrap: html = html.replace("", f"{theme_bootstrap}", 1) html = html.replace("", f"{bootstrap_script}", 1) from hermes_cli.webapp_page_policy import page_headers return HTMLResponse(html, headers=page_headers(request, html, _NO_STORE)) # Built CSS contains absolute ``url(/fonts/...)`` / ``url(/ds-assets/...)`` references that # browsers resolve against the document origin — wrong under a proxy prefix. Intercept CSS # BEFORE the StaticFiles mount and rewrite when a prefix is in play. @application.get("/assets/{filename}.css") async def serve_css(filename: str, request: Request): dist = web_dist() css_path = dist / "assets" / f"{filename}.css" if not css_path.is_file() or not css_path.resolve().is_relative_to(dist.resolve()): return JSONResponse({"error": "not found"}, status_code=404) prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) css = css_path.read_text(encoding="utf-8-sig") if prefix: for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"): for quote in ("", '"', "'"): css = css.replace(f"url({quote}{asset_dir}", f"url({quote}{prefix}{asset_dir}") return Response( content=css, media_type="text/css", headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL} ) class _ImmutableAssetFiles(StaticFiles): """StaticFiles that marks hashed bundle assets immutable so reloads skip revalidation.""" async def get_response(self, path: str, scope): response = await super().get_response(path, scope) if response.status_code == 200: response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL return response asset_files: dict[Path, StaticFiles] = {} async def serve_assets(scope, receive, send): # check_dir=False: the dist may not exist yet; StaticFiles 404s per-request until it does. directory = web_dist() / "assets" if directory not in asset_files: asset_files[directory] = _ImmutableAssetFiles(directory=directory, check_dir=False) await asset_files[directory](scope, receive, send) from hermes_cli.webapp_previews import mount_preview_assets mount_preview_assets(application) application.mount("/assets", serve_assets, name="assets") @application.get("/{full_path:path}") async def serve_spa(full_path: str, request: Request): prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix")) # An unmatched /api/* path is a missing endpoint, not a client-side route: return a # real 404 JSON instead of index.html (which breaks JSON clients with a SyntaxError). if full_path == "api" or full_path.startswith("api/"): return JSONResponse({"detail": f"No such API endpoint: /{full_path}"}, status_code=404) dist = web_dist() file_path = dist / full_path index = dist / "index.html" if (not policy(application.state).html_token and file_path.is_file() and index.is_file() and file_path.samefile(index)): # No spelling of the entry document may bypass bootstrap or policy. return _serve_index(request, prefix) # Prevent path traversal via url-encoded sequences (%2e%2e/) if ( full_path and file_path.resolve().is_relative_to(dist.resolve()) and file_path.exists() and file_path.is_file() ): return FileResponse(file_path) if is_static_asset_path(full_path): font = public_font_file(dist, WEB_DIST, full_path) if font is not None: return FileResponse(font) return JSONResponse({"error": "not found"}, status_code=404) return _serve_index(request, prefix) # --------------------------------------------------------------------------- # Dashboard themes # --------------------------------------------------------------------------- # Built-in themes — label + description only; colors live in web/src/themes/presets.ts. _BUILTIN_DASHBOARD_THEMES = [ {"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"}, {"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"}, {"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"}, {"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"}, {"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"}, {"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"}, {"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"}, {"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"}, ] def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[dict[str, Any]]: """Normalise a theme layer spec (bare hex shorthand or ``{hex, alpha}`` dict); ``None`` on garbage so the caller falls back to a built-in default.""" if value is None: return {"hex": default_hex, "alpha": default_alpha} if isinstance(value, str): return {"hex": value, "alpha": default_alpha} if not isinstance(value, dict): return None hex_val = value.get("hex", default_hex) if not isinstance(hex_val, str): return None try: alpha_f = float(value.get("alpha", default_alpha)) except (TypeError, ValueError): alpha_f = default_alpha return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))} _THEME_DEFAULT_TYPOGRAPHY: dict[str, str] = { "fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif', "fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace', "baseSize": "15px", "lineHeight": "1.55", "letterSpacing": "0", } _THEME_DEFAULT_LAYOUT: dict[str, str] = { "radius": "0.5rem", "density": "comfortable" } _THEME_OVERRIDE_KEYS = { "card", "cardForeground", "popover", "popoverForeground", "primary", "primaryForeground", "secondary", "secondaryForeground", "muted", "mutedForeground", "accent", "accentForeground", "destructive", "destructiveForeground", "success", "warning", "border", "input", "ring", } # Named asset slots; other keys under ``assets.custom`` become ``--theme-asset-custom-