Files
hermes-agent/hermes_cli/web_server_dashboard.py

1097 lines
49 KiB
Python

"""Dashboard UI assets: SPA mount, theme normalisation/bootstrap CSS, dashboard-plugin discovery and the plugins-hub merge.
Split out of ``hermes_cli.web_server``; every externally used name is re-imported
there, so ``web_server.<name>`` keeps resolving (and monkeypatching) as before.
Helpers that tests patch on ``web_server`` are reached lazily through it.
"""
import logging
import importlib.util
import json
import os
import sys
import threading
import time
import yaml
from fastapi import FastAPI, Request
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse, Response
from fastapi.staticfiles import StaticFiles
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
# 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.
Thin re-export of :func:`hermes_cli.dashboard_auth.prefix.normalise_prefix`
— the single source of truth lives in the dashboard_auth package so
the gate middleware, the OAuth routes, the cookie helpers, and the
SPA mount all agree on validation rules.
"""
from hermes_cli.dashboard_auth.prefix import normalise_prefix
return normalise_prefix(raw)
def _render_active_theme_bootstrap_css() -> str:
"""Critical-CSS shim for the active user theme.
Returns a ``<style>`` block with the ``:root`` CSS variables that
``ThemeProvider.applyTheme()`` installs once the
``/api/dashboard/themes`` round-trip completes. The goal is to
eliminate the green flash where the first paint shows the bundle's
default Hermes Teal canvas before the SPA flips the configured user
theme into place.
Built-in themes return an empty string — their full definitions live
in ``web/src/themes/presets.ts`` and are applied by the bundle
before paint, so no shim is needed for them.
"""
from hermes_cli.web_server import load_config
try:
config = load_config()
active = cfg_get(config, "dashboard", "theme", default="default")
if not active or not isinstance(active, str):
return ""
# Built-in: the bundle already owns the definition, no flash.
if any(b["name"] == active for b in _BUILTIN_DASHBOARD_THEMES):
return ""
for theme in _discover_user_themes():
if theme.get("name") != active:
continue
palette = theme.get("palette") or {}
bg = palette.get("background") or {}
mg = palette.get("midground") or {}
bg_hex = bg.get("hex", "#0a0a0a") if isinstance(bg, dict) else "#0a0a0a"
mg_hex = mg.get("hex", "#e5e5e5") if isinstance(mg, dict) else "#e5e5e5"
typo = theme.get("typography") or {}
font_sans = typo.get("fontSans") or _THEME_DEFAULT_TYPOGRAPHY["fontSans"]
base_size = typo.get("baseSize") or _THEME_DEFAULT_TYPOGRAPHY["baseSize"]
# Defensive ``</style>`` escape — current values are well-known
# hex/font strings, but this keeps the helper safe if it is
# later extended to ship user-authored CSS literals.
def _esc(s: str) -> str:
return str(s).replace("</", "<\\/")
# Variable names MUST match what the bundle actually consumes:
# - ``--background-base`` / ``--midground-base`` come from
# ``layerVars()`` in ``web/src/themes/context.tsx``.
# - ``--theme-font-sans`` / ``--theme-base-size`` come from
# ``typographyVars()`` there, and ``index.css`` applies them
# via ``html{font-family:var(--theme-font-sans);
# font-size:var(--theme-base-size)}``.
# The ``html,body`` canvas rule references the SAME variables
# instead of literal values so runtime theme switches stay
# live: ``applyTheme()`` writes these vars as inline styles on
# ``documentElement``, which outrank this stylesheet block in
# the cascade — the rule below re-resolves automatically and
# never goes stale when the user picks a different theme.
return (
'<style id="hermes-theme-bootstrap">'
":root{"
f"--background-base:{_esc(bg_hex)};"
f"--midground-base:{_esc(mg_hex)};"
f"--theme-font-sans:{_esc(font_sans)};"
f"--theme-base-size:{_esc(base_size)};"
"}"
"html,body{background-color:var(--background-base);"
"color:var(--midground-base);"
"font-family:var(--theme-font-sans);"
"font-size:var(--theme-base-size);}"
"</style>"
)
return ""
except Exception:
_log.debug("theme bootstrap render failed", exc_info=True)
return ""
# Hashed bundle assets (``/assets/<name>-<contenthash>.<ext>``) are immutable
# by construction: any content change produces a new filename, and the entry
# point (index.html) is served ``no-store`` so it always references the
# current hashes. A year-long immutable cache lets browsers skip even the
# revalidation round-trip on every dashboard load.
_IMMUTABLE_ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable"
def mount_spa(application: FastAPI):
"""Mount the built SPA. Falls back to index.html for client-side routing.
The session token is injected into index.html via a ``<script>`` tag so
the SPA can authenticate against protected API endpoints without a
separate (unauthenticated) token-dispensing endpoint.
When served behind a path-prefix reverse proxy (e.g.
``mission-control.tilos.com/hermes/*`` -> local Caddy -> :9119), the
proxy injects ``X-Forwarded-Prefix: /hermes`` on every request. We
rewrite the served ``index.html`` so absolute asset URLs (``/assets/...``)
and the SPA's runtime ``__HERMES_BASE_PATH__`` honour that prefix
without rebuilding the bundle.
"""
from hermes_cli.web_server import (
WEB_DIST,
_DASHBOARD_EMBEDDED_CHAT_ENABLED,
_SESSION_TOKEN,
app,
)
# `hermes serve` is the headless backend: it must NEVER serve the browser
# SPA, even if a dist is lying around from a prior `dashboard`/build. Take
# the no-frontend path so only the JSON-RPC/WS/API surface is reachable.
_headless = os.environ.get("HERMES_SERVE_HEADLESS") == "1"
if _headless:
_msg = (
"Headless backend (hermes serve): web UI disabled — use "
"`hermes dashboard` for the browser UI."
)
@application.get("/{full_path:path}")
async def no_frontend(full_path: str):
# Desktop token handshake (#94227): the Electron shell boots by
# fetching `/` and extracting ``window.__HERMES_SESSION_TOKEN__``
# for /api/ws auth (apps/desktop/electron/dashboard-token.ts).
# When headless serve 404'd every path, a renderer whose spawn
# token no longer matched the backend's live token (e.g. after
# `hermes update` replaced the backend) had no way to adopt the
# served token — the WS handshake failed and the window
# white-screened (#95575). Serve a minimal token-only page at the
# exact root, but ONLY when the dashboard auth gate is off: on a
# gated (non-loopback/remote) serve the token must never be
# readable without auth, so the 404 JSON stays.
gated = bool(getattr(application.state, "auth_required", False))
if full_path == "" and not gated:
token_js = json.dumps(_SESSION_TOKEN)
return HTMLResponse(
"<!doctype html><html><head><script>"
f"window.__HERMES_SESSION_TOKEN__={token_js};"
"window.__HERMES_AUTH_REQUIRED__=false;"
"</script></head><body>"
"Headless backend (hermes serve): web UI disabled — use "
"`hermes dashboard` for the browser UI."
"</body></html>",
headers={
"Cache-Control": "no-store, no-cache, must-revalidate"
},
)
return JSONResponse({"error": _msg}, status_code=404)
return
# A missing WEB_DIST is deliberately NOT a mount-time terminal state
# (#82614): a long-lived `hermes dashboard --skip-build` process that
# survives a `git pull` (or starts before the first build) used to
# install a permanent no_frontend catch-all here and could never
# recover — every route answered 404 "Frontend not built" until the
# process was restarted, even after `npm run build` completed. The SPA
# routes below all cope with a missing dist per-request (`_serve_index`
# returns the same 404 JSON when index.html is unreadable; the asset
# mounts use check_dir=False and 404 on missing files), so mounting
# them unconditionally makes the dashboard recover the moment a build
# appears on disk — no restart needed.
_index_path = WEB_DIST / "index.html"
def _serve_index(prefix: str = ""):
"""Return index.html with the session token + base-path injected.
``prefix`` is the normalised ``X-Forwarded-Prefix`` (e.g. ``/hermes``)
or empty string when served at root.
When the OAuth auth gate is active (``app.state.auth_required``),
the legacy ``_SESSION_TOKEN`` is NOT injected — the SPA reads
identity from ``/api/auth/me`` over cookie auth instead. The
``__HERMES_AUTH_REQUIRED__`` flag lets the SPA pick the right
auth scheme for /api/pty and /api/ws (ticket vs token).
"""
try:
html = _index_path.read_text(encoding="utf-8")
except OSError:
# The dist dir existed at mount time but index.html is missing or
# unreadable now (partial build, wiped dist, permissions). Without
# this guard every request raises FileNotFoundError (500). Return
# the same JSON 404 payload mount_spa uses for a fully-missing
# dist so clients get a clear, consistent signal.
return JSONResponse(
{"error": "Frontend not built. Run: cd web && npm run build"},
status_code=404,
)
chat_js = "true" if _DASHBOARD_EMBEDDED_CHAT_ENABLED else "false"
gated = bool(getattr(app.state, "auth_required", False))
gated_js = "true" if gated else "false"
if gated:
bootstrap_script = (
f"<script>"
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
else:
bootstrap_script = (
f'<script>window.__HERMES_SESSION_TOKEN__="{_SESSION_TOKEN}";'
f"window.__HERMES_DASHBOARD_EMBEDDED_CHAT__={chat_js};"
f'window.__HERMES_BASE_PATH__="{prefix}";'
f"window.__HERMES_AUTH_REQUIRED__={gated_js};"
f"</script>"
)
if prefix:
# Rewrite absolute asset URLs baked into the Vite build so the
# browser fetches them through the same proxy prefix.
html = html.replace('href="/assets/', f'href="{prefix}/assets/')
html = html.replace('src="/assets/', f'src="{prefix}/assets/')
html = html.replace('href="/favicon.ico"', f'href="{prefix}/favicon.ico"')
html = html.replace('href="/fonts/', f'href="{prefix}/fonts/')
html = html.replace('href="/ds-assets/', f'href="{prefix}/ds-assets/')
html = html.replace('src="/ds-assets/', f'src="{prefix}/ds-assets/')
# Theme flash mitigation: when the active theme is a user theme
# (``HERMES_HOME/dashboard-themes/<name>.yaml``), inject a minimal
# critical-CSS block so the first paint uses the target palette.
# Without this the SPA paints the default Hermes Teal canvas, then
# ``ThemeProvider`` flips the CSS variables once
# ``/api/dashboard/themes`` resolves. Built-in themes are already
# in the bundle's ``presets.ts`` so no shim is needed for them.
theme_bootstrap = _render_active_theme_bootstrap_css()
if theme_bootstrap:
html = html.replace("</head>", f"{theme_bootstrap}</head>", 1)
html = html.replace("</head>", f"{bootstrap_script}</head>", 1)
return HTMLResponse(
html,
headers={"Cache-Control": "no-store, no-cache, must-revalidate"},
)
# When served behind a path-prefix proxy, the built CSS contains
# absolute ``url(/fonts/...)`` and ``url(/ds-assets/...)`` references.
# Browsers resolve those against the document origin, which means
# under ``/hermes`` they'd hit ``mission-control.tilos.com/fonts/...``
# (the MC Pages app), not the Hermes backend. Intercept CSS asset
# requests BEFORE the StaticFiles mount and rewrite the absolute paths
# when a prefix is in play.
@application.get("/assets/{filename}.css")
async def serve_css(filename: str, request: Request):
css_path = WEB_DIST / "assets" / f"{filename}.css"
if not css_path.is_file() or not css_path.resolve().is_relative_to(
WEB_DIST.resolve()
):
return JSONResponse({"error": "not found"}, status_code=404)
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
css = css_path.read_text(encoding="utf-8")
if prefix:
for asset_dir in ("/fonts/", "/fonts-terminal/", "/ds-assets/", "/assets/"):
css = css.replace(f"url({asset_dir}", f"url({prefix}{asset_dir}")
css = css.replace(f"url(\"{asset_dir}", f"url(\"{prefix}{asset_dir}")
css = css.replace(f"url('{asset_dir}", f"url('{prefix}{asset_dir}")
return Response(
content=css,
media_type="text/css",
headers={"Cache-Control": _IMMUTABLE_ASSET_CACHE_CONTROL},
)
class _ImmutableAssetFiles(StaticFiles):
"""StaticFiles that marks hashed bundle assets immutable.
Everything under ``/assets/`` carries a Vite content hash in its
filename, so a given URL's bytes can never change — a rebuild
produces a NEW filename referenced by a fresh (``no-store``)
index.html. Without this header every dashboard load re-validated
each chunk; with it the browser serves reloads straight from its
HTTP cache.
"""
async def get_response(self, path: str, scope):
response = await super().get_response(path, scope)
if response.status_code == 200:
response.headers["Cache-Control"] = _IMMUTABLE_ASSET_CACHE_CONTROL
return response
application.mount(
"/assets",
# check_dir=False: the dist (and its assets/ dir) may not exist yet —
# the whole point of the dynamic recheck (#82614). StaticFiles then
# 404s per-request until a build appears instead of raising at mount.
_ImmutableAssetFiles(directory=WEB_DIST / "assets", check_dir=False),
name="assets",
)
@application.get("/{full_path:path}")
async def serve_spa(full_path: str, request: Request):
prefix = _normalise_prefix(request.headers.get("x-forwarded-prefix"))
# An unmatched /api/* path is a missing/renamed endpoint, NOT a
# client-side route. Falling through to index.html here returns
# `<!doctype html>` with status 200, which makes JSON clients (the
# desktop app's fetchJson, dashboard fetch wrappers) blow up with an
# opaque `SyntaxError: Unexpected token '<'`. Return a real 404 JSON
# so the caller sees a clear "no such endpoint" instead.
if full_path == "api" or full_path.startswith("api/"):
return JSONResponse(
{"detail": f"No such API endpoint: /{full_path}"},
status_code=404,
)
file_path = WEB_DIST / full_path
# Prevent path traversal via url-encoded sequences (%2e%2e/)
if (
full_path
and file_path.resolve().is_relative_to(WEB_DIST.resolve())
and file_path.exists()
and file_path.is_file()
):
return FileResponse(file_path)
return _serve_index(prefix)
# ---------------------------------------------------------------------------
# Dashboard theme endpoints
# ---------------------------------------------------------------------------
# Built-in dashboard themes — label + description only. The actual color
# definitions live in the frontend (web/src/themes/presets.ts).
_BUILTIN_DASHBOARD_THEMES = [
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
{"name": "cyberpunk", "label": "Cyberpunk", "description": "Neon green on black — matrix terminal"},
{"name": "rose", "label": "Rosé", "description": "Soft pink and warm ivory — easy on the eyes"},
]
def _parse_theme_layer(value: Any, default_hex: str, default_alpha: float = 1.0) -> Optional[Dict[str, Any]]:
"""Normalise a theme layer spec from YAML into `{hex, alpha}` form.
Accepts shorthand (a bare hex string) or full dict form. Returns
``None`` on garbage input so the caller can fall back to a built-in
default rather than blowing up.
"""
if value is None:
return {"hex": default_hex, "alpha": default_alpha}
if isinstance(value, str):
return {"hex": value, "alpha": default_alpha}
if isinstance(value, dict):
hex_val = value.get("hex", default_hex)
alpha_val = value.get("alpha", default_alpha)
if not isinstance(hex_val, str):
return None
try:
alpha_f = float(alpha_val)
except (TypeError, ValueError):
alpha_f = default_alpha
return {"hex": hex_val, "alpha": max(0.0, min(1.0, alpha_f))}
return None
_THEME_DEFAULT_TYPOGRAPHY: Dict[str, str] = {
"fontSans": 'system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif',
"fontMono": 'ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas, monospace',
"baseSize": "15px",
"lineHeight": "1.55",
"letterSpacing": "0",
}
_THEME_DEFAULT_LAYOUT: Dict[str, str] = {
"radius": "0.5rem",
"density": "comfortable",
}
_THEME_OVERRIDE_KEYS = {
"card", "cardForeground", "popover", "popoverForeground",
"primary", "primaryForeground", "secondary", "secondaryForeground",
"muted", "mutedForeground", "accent", "accentForeground",
"destructive", "destructiveForeground", "success", "warning",
"border", "input", "ring",
}
# Well-known named asset slots themes can populate. Any other keys under
# ``assets.custom`` are exposed as ``--theme-asset-custom-<key>`` CSS vars
# for plugin/shell use.
_THEME_NAMED_ASSET_KEYS = {"bg", "hero", "logo", "crest", "sidebar", "header"}
# Component-style buckets themes can override. The value under each bucket
# is a mapping from camelCase property name to CSS string; each pair emits
# ``--component-<bucket>-<kebab-property>`` on :root. The frontend's shell
# components (Card, App header, Backdrop, etc.) consume these vars so themes
# can restyle chrome (clip-path, border-image, segmented progress, etc.)
# without shipping their own CSS.
_THEME_COMPONENT_BUCKETS = {
"card", "header", "footer", "sidebar", "tab",
"progress", "badge", "backdrop", "page",
}
_THEME_LAYOUT_VARIANTS = {"standard", "cockpit", "tiled"}
# Cap on customCSS length so a malformed/oversized theme YAML can't blow up
# the response payload or the <style> tag. 32 KiB is plenty for every
# practical reskin (the Strike Freedom demo is ~2 KiB).
_THEME_CUSTOM_CSS_MAX = 32 * 1024
def _normalise_theme_definition(data: Dict[str, Any]) -> Optional[Dict[str, Any]]:
"""Normalise a user theme YAML into the wire format `ThemeProvider`
expects. Returns ``None`` if the theme is unusable.
Accepts both the full schema (palette/typography/layout) and a loose
form with bare hex strings, so hand-written YAMLs stay friendly.
"""
if not isinstance(data, dict):
return None
name = data.get("name")
if not isinstance(name, str) or not name.strip():
return None
# Palette
palette_src = data.get("palette", {}) if isinstance(data.get("palette"), dict) else {}
# Allow top-level `colors.background` as a shorthand too.
colors_src = data.get("colors", {}) if isinstance(data.get("colors"), dict) else {}
def _layer(key: str, default_hex: str, default_alpha: float = 1.0) -> Dict[str, Any]:
spec = palette_src.get(key, colors_src.get(key))
parsed = _parse_theme_layer(spec, default_hex, default_alpha)
return parsed if parsed is not None else {"hex": default_hex, "alpha": default_alpha}
palette = {
"background": _layer("background", "#041c1c", 1.0),
"midground": _layer("midground", "#ffe6cb", 1.0),
"foreground": _layer("foreground", "#ffffff", 0.0),
"warmGlow": palette_src.get("warmGlow") or data.get("warmGlow") or "rgba(255, 189, 56, 0.35)",
"noiseOpacity": 1.0,
}
raw_noise = palette_src.get("noiseOpacity", data.get("noiseOpacity"))
try:
palette["noiseOpacity"] = float(raw_noise) if raw_noise is not None else 1.0
except (TypeError, ValueError):
palette["noiseOpacity"] = 1.0
# Typography
typo_src = data.get("typography", {}) if isinstance(data.get("typography"), dict) else {}
typography = dict(_THEME_DEFAULT_TYPOGRAPHY)
for key in ("fontSans", "fontMono", "fontDisplay", "fontUrl", "baseSize", "lineHeight", "letterSpacing"):
val = typo_src.get(key)
if isinstance(val, str) and val.strip():
typography[key] = val
# Layout
layout_src = data.get("layout", {}) if isinstance(data.get("layout"), dict) else {}
layout = dict(_THEME_DEFAULT_LAYOUT)
radius = layout_src.get("radius")
if isinstance(radius, str) and radius.strip():
layout["radius"] = radius
density = layout_src.get("density")
if isinstance(density, str) and density in {"compact", "comfortable", "spacious"}:
layout["density"] = density
# Color overrides — keep only valid keys with string values.
overrides_src = data.get("colorOverrides", {})
color_overrides: Dict[str, str] = {}
if isinstance(overrides_src, dict):
for key, val in overrides_src.items():
if key in _THEME_OVERRIDE_KEYS and isinstance(val, str) and val.strip():
color_overrides[key] = val
# Assets — named slots + arbitrary user-defined keys. Values must be
# strings (URLs or CSS ``url(...)``/``linear-gradient(...)`` expressions).
# We don't fetch remote assets here; the frontend just injects them as
# CSS vars. Empty values are dropped so a theme can explicitly clear a
# slot by setting ``hero: ""``.
assets_out: Dict[str, Any] = {}
assets_src = data.get("assets", {}) if isinstance(data.get("assets"), dict) else {}
for key in _THEME_NAMED_ASSET_KEYS:
val = assets_src.get(key)
if isinstance(val, str) and val.strip():
assets_out[key] = val
custom_assets_src = assets_src.get("custom")
if isinstance(custom_assets_src, dict):
custom_assets: Dict[str, str] = {}
for key, val in custom_assets_src.items():
if (
isinstance(key, str)
and key.replace("-", "").replace("_", "").isalnum()
and isinstance(val, str)
and val.strip()
):
custom_assets[key] = val
if custom_assets:
assets_out["custom"] = custom_assets
# Custom CSS — raw CSS text the frontend injects as a scoped <style>
# tag on theme apply. Clipped to _THEME_CUSTOM_CSS_MAX to keep the
# payload bounded. We intentionally do NOT parse/sanitise the CSS
# here — the dashboard is localhost-only and themes are user-authored
# YAML in ~/.hermes/, same trust level as the config file itself.
custom_css_val = data.get("customCSS")
custom_css: Optional[str] = None
if isinstance(custom_css_val, str) and custom_css_val.strip():
custom_css = custom_css_val[:_THEME_CUSTOM_CSS_MAX]
# Component style overrides — per-bucket dicts of camelCase CSS
# property -> CSS string. The frontend converts these into CSS vars
# that shell components (Card, App header, Backdrop) consume.
component_styles_src = data.get("componentStyles", {})
component_styles: Dict[str, Dict[str, str]] = {}
if isinstance(component_styles_src, dict):
for bucket, props in component_styles_src.items():
if bucket not in _THEME_COMPONENT_BUCKETS or not isinstance(props, dict):
continue
clean: Dict[str, str] = {}
for prop, value in props.items():
if (
isinstance(prop, str)
and prop.replace("-", "").replace("_", "").isalnum()
and isinstance(value, (str, int, float))
and str(value).strip()
):
clean[prop] = str(value)
if clean:
component_styles[bucket] = clean
layout_variant_src = data.get("layoutVariant")
layout_variant = (
layout_variant_src
if isinstance(layout_variant_src, str) and layout_variant_src in _THEME_LAYOUT_VARIANTS
else "standard"
)
result: Dict[str, Any] = {
"name": name,
"label": data.get("label") or name,
"description": data.get("description", ""),
"palette": palette,
"typography": typography,
"layout": layout,
"layoutVariant": layout_variant,
}
if color_overrides:
result["colorOverrides"] = color_overrides
if assets_out:
result["assets"] = assets_out
if custom_css is not None:
result["customCSS"] = custom_css
if component_styles:
result["componentStyles"] = component_styles
return result
def _discover_user_themes() -> list:
"""Scan ~/.hermes/dashboard-themes/*.yaml for user-created themes.
Returns a list of fully-normalised theme definitions ready to ship
to the frontend, so the client can apply them without a secondary
round-trip or a built-in stub.
Uses the dashboard process launch home, not ``get_hermes_home()``, so a
transient profile override from embedded chat does not hide themes that
live under the server's own ``HERMES_HOME``.
"""
themes_dir = get_process_hermes_home() / "dashboard-themes"
if not themes_dir.is_dir():
return []
result = []
for f in sorted(themes_dir.glob("*.yaml")):
try:
data = yaml.safe_load(f.read_text(encoding="utf-8"))
except Exception:
continue
normalised = _normalise_theme_definition(data)
if normalised is not None:
result.append(normalised)
return result
# ---------------------------------------------------------------------------
# Dashboard plugin system
# ---------------------------------------------------------------------------
def _safe_plugin_api_relpath(api_field: Any, *, dashboard_dir: Path) -> Optional[str]:
"""Validate the manifest's ``api`` field for the plugin loader.
The web server later imports this file as a Python module via
``importlib.util.spec_from_file_location`` (arbitrary code
execution by design — that's how plugins extend the backend).
Pre-#29156 the field was used as-is, which meant:
* An absolute path swallowed the plugin's dashboard directory
entirely — ``Path('safe/dashboard') / '/tmp/evil.py'`` resolves
to ``/tmp/evil.py``, so any attacker-controlled manifest could
point the import at any Python file on disk (GHSA-5qr3-c538-wm9j).
* A ``../..`` traversal could climb out of the plugin into
neighbouring directories on the search path.
Return the original string when the resolved path stays under
``dashboard_dir``; return ``None`` (with a warning logged at the
call site) otherwise so the plugin still loads its static JS/CSS
but its backend ``api`` is rejected.
"""
if not isinstance(api_field, str) or not api_field.strip():
return None
candidate = Path(api_field)
if candidate.is_absolute():
return None
try:
resolved = (dashboard_dir / candidate).resolve()
base = dashboard_dir.resolve()
except (OSError, RuntimeError):
return None
try:
resolved.relative_to(base)
except ValueError:
return None
return api_field
def _discover_dashboard_plugins() -> list:
"""Scan plugins/*/dashboard/manifest.json for dashboard extensions.
Checks three plugin sources (same as hermes_cli.plugins):
1. User plugins: ~/.hermes/plugins/<name>/dashboard/manifest.json
2. Bundled plugins: <repo>/plugins/<name>/dashboard/manifest.json (memory/, etc.)
3. Project plugins: ./.hermes/plugins/ (only if HERMES_ENABLE_PROJECT_PLUGINS)
"""
plugins = []
seen_names: set = set()
from hermes_cli.plugins import get_bundled_plugins_dir
bundled_root = get_bundled_plugins_dir()
# User dashboard plugins are a dashboard-owned asset (same category as
# theme YAML): resolve them from the process launch home so they don't
# vanish when a request is scoped to another profile via a context-local
# HERMES_HOME override (e.g. embedded /chat under --open-profile).
#
# #87197: when the process itself is profile-scoped (``--profile <name>``
# sets ``HERMES_HOME=<root>/profiles/<name>``), the launch home is the
# profile directory, which has no ``plugins/`` — user plugins are
# installed in the hermes root (``~/.hermes/plugins``). Scan the default
# root as well (``get_default_hermes_root()`` unwraps
# ``<root>/profiles/<name>`` → ``<root>`` and returns a custom
# ``HERMES_HOME`` unchanged when it *is* the root), mirroring how
# ``hermes_cli.plugins`` resolves plugin install locations. The
# ``seen_names`` dedupe below keeps profile-local plugins (if any)
# authoritative over same-named root plugins.
from hermes_constants import get_default_hermes_root
user_plugin_roots = [get_process_hermes_home() / "plugins"]
root_plugins = get_default_hermes_root() / "plugins"
if root_plugins.resolve(strict=False) != user_plugin_roots[0].resolve(strict=False):
user_plugin_roots.append(root_plugins)
search_dirs = [(d, "user") for d in user_plugin_roots]
search_dirs += [
(bundled_root / "memory", "bundled"),
(bundled_root, "bundled"),
]
# GHSA-5qr3-c538-wm9j (#29156): the previous ``os.environ.get(...)``
# check treated *any* non-empty string as truthy, so ``=0``, ``=false``,
# and ``=no`` — all of which the agent loader and operators correctly
# read as "disabled" — silently *enabled* the untrusted project source
# in the web server. Combined with the absolute-path RCE primitive on
# the manifest's ``api`` field (now patched below), this turned the
# opt-in into a sticky always-on switch. Use the shared truthy
# semantics (``1`` / ``true`` / ``yes`` / ``on``) so the gate matches
# ``hermes_cli/plugins.py`` and the documented user contract.
if env_var_enabled("HERMES_ENABLE_PROJECT_PLUGINS"):
search_dirs.append((Path.cwd() / ".hermes" / "plugins", "project"))
for plugins_root, source in search_dirs:
if not plugins_root.is_dir():
continue
with os.scandir(plugins_root) as scan:
children = sorted((Path(e.path) for e in scan), key=lambda p: p.name)
for child in children:
if not child.is_dir():
continue
manifest_file = child / "dashboard" / "manifest.json"
if not manifest_file.exists():
continue
try:
data = json.loads(manifest_file.read_text(encoding="utf-8"))
name = data.get("name", child.name)
if name in seen_names:
continue
seen_names.add(name)
# Tab options: ``path`` + ``position`` for a new tab, optional
# ``override`` to replace a built-in route, and ``hidden`` to
# register the plugin component/slots without adding a tab
# (useful for slot-only plugins like a header-crest injector).
raw_tab = data.get("tab", {}) if isinstance(data.get("tab"), dict) else {}
tab_info = {
"path": raw_tab.get("path", f"/{name}"),
"position": raw_tab.get("position", "end"),
}
override_path = raw_tab.get("override")
if isinstance(override_path, str) and override_path.startswith("/"):
tab_info["override"] = override_path
if bool(raw_tab.get("hidden")):
tab_info["hidden"] = True
# Slots: list of named slot locations this plugin populates.
# The frontend exposes ``registerSlot(pluginName, slotName, Component)``
# on window; plugins with non-empty slots call it from their JS bundle.
slots_src = data.get("slots")
slots: List[str] = []
if isinstance(slots_src, list):
slots = [s for s in slots_src if isinstance(s, str) and s]
# Validate ``api`` at discovery time so the value cached
# on the plugin entry is already safe to feed into the
# importer. An attacker-controlled manifest can name
# any absolute path or ``..`` traversal here — the
# web server then imports that file as a Python module
# (RCE, GHSA-5qr3-c538-wm9j).
raw_api = data.get("api")
dashboard_dir = child / "dashboard"
safe_api = _safe_plugin_api_relpath(raw_api, dashboard_dir=dashboard_dir)
if raw_api and safe_api is None:
_log.warning(
"Plugin %s: refusing unsafe api path %r (must be a "
"relative file inside the plugin's dashboard/ "
"directory); backend routes from this plugin will "
"not be mounted",
name, raw_api,
)
plugins.append({
"name": name,
"label": data.get("label", name),
"description": data.get("description", ""),
"icon": data.get("icon", "Puzzle"),
"version": data.get("version", "0.0.0"),
"tab": tab_info,
"slots": slots,
"entry": data.get("entry", "dist/index.js"),
"css": data.get("css"),
"has_api": bool(safe_api),
"source": source,
"_dir": str(dashboard_dir),
"_api_file": safe_api,
})
except Exception as exc:
_log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc)
continue
return plugins
def _strip_dashboard_manifest(p: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in p.items() if not k.startswith("_")}
_PLUGINS_HUB_CACHE_TTL_SECONDS = 5.0
_plugins_hub_cache: Optional[Dict[str, Any]] = None
_plugins_hub_cache_expires_at = 0.0
_plugins_hub_cache_lock = threading.Lock()
def _invalidate_plugins_hub_cache() -> None:
global _plugins_hub_cache, _plugins_hub_cache_expires_at
with _plugins_hub_cache_lock:
_plugins_hub_cache = None
_plugins_hub_cache_expires_at = 0.0
_plugins_hub_probe_inflight: set = set()
_plugins_hub_probe_lock = threading.Lock()
def _schedule_check_fn_probe(fn) -> Optional[threading.Thread]:
"""Warm a cold ``check_fn`` verdict off the request path.
The hub read path only consumes cached availability (never probes
inline). But the only other warmer lives in the tool-schema build, which
a dashboard-only session never runs — so a cold cache would report
``auth_required=False`` forever. Kick a daemon-thread probe on the miss;
the short hub TTL picks up the verdict on the next fetch. Deduplicates
concurrent probes per function. Returns the spawned thread (or ``None``
when a probe for *fn* is already in flight).
"""
with _plugins_hub_probe_lock:
if fn in _plugins_hub_probe_inflight:
return None
_plugins_hub_probe_inflight.add(fn)
def _probe():
try:
from tools.registry import _check_fn_cached
_check_fn_cached(fn)
except Exception:
pass
finally:
with _plugins_hub_probe_lock:
_plugins_hub_probe_inflight.discard(fn)
thread = threading.Thread(
target=_probe, name="plugins-hub-checkfn-probe", daemon=True
)
thread.start()
return thread
def _merged_plugins_hub(force_refresh: bool = False) -> Dict[str, Any]:
"""Agent discovery + dashboard manifests + optional provider picker metadata.
IMPORTANT: this powers a dashboard request path, so it must stay read-only
and cheap. In particular, do not execute tool ``check_fn`` probes here —
those can trigger imports, auth/network checks, and other synchronous work
that starves the root event loop. We only consume last-known cached tool
availability, and we memoize the assembled payload briefly to collapse the
dashboard's bursty duplicate fetches.
"""
from hermes_cli.web_server import (
_discover_memory_provider_statuses,
_get_dashboard_plugins,
_normalize_memory_provider_name,
_schedule_check_fn_probe,
get_hermes_home,
load_config,
)
global _plugins_hub_cache, _plugins_hub_cache_expires_at
now = time.monotonic()
if not force_refresh:
with _plugins_hub_cache_lock:
if _plugins_hub_cache is not None and now < _plugins_hub_cache_expires_at:
return _plugins_hub_cache
started_at = time.monotonic()
from hermes_cli.plugins_cmd import (
_discover_all_plugins,
_get_current_context_engine,
_get_current_memory_provider,
_discover_context_engines,
_get_disabled_set,
_get_enabled_set,
_read_manifest as _read_plugin_manifest_at,
)
dashboard_list = _get_dashboard_plugins()
dash_by_name = {str(p["name"]): p for p in dashboard_list}
disabled_set = _get_disabled_set()
enabled_set = _get_enabled_set()
# Read user-hidden plugins from config for the user_hidden field.
config = load_config()
hidden_plugins: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or []
plugins_root_resolved = (get_hermes_home() / "plugins").resolve()
rows: List[Dict[str, Any]] = []
for name, version, description, source, dir_str, key in _discover_all_plugins():
# Both the path-derived key (nested category plugins) and the bare
# manifest name count for enabled/disabled state, matching the runtime
# loader's back-compat lookup.
aliases = {name}
if key:
aliases.add(key)
if aliases & disabled_set:
runtime_status = "disabled"
elif aliases & enabled_set:
runtime_status = "enabled"
else:
runtime_status = "inactive"
dir_path = Path(dir_str)
dm = dash_by_name.get(name)
has_dash_manifest = dm is not None or (dir_path / "dashboard" / "manifest.json").exists()
under_user_tree = False
try:
dir_path.resolve().relative_to(plugins_root_resolved)
under_user_tree = True
except ValueError:
pass
can_remove_update = (
source in {"user", "git"} and under_user_tree and Path(dir_str).is_dir()
)
# Read-only auth hint: consult only last-known cached tool availability.
# A missing cache entry is treated as "unknown" rather than triggering a
# live probe inside this request path.
auth_required = False
auth_command = ""
manifest_data = _read_plugin_manifest_at(dir_path)
provides_tools = manifest_data.get("provides_tools") or []
if provides_tools:
try:
from tools.registry import get_cached_check_fn_result, registry
for tname in provides_tools:
entry = registry.get_entry(tname)
if not entry or not entry.check_fn:
continue
cached_result = get_cached_check_fn_result(entry.check_fn)
if cached_result is None:
# Cold cache: nothing else warms check_fns on
# dashboard-only sessions, so kick a background
# probe; the short hub TTL surfaces the verdict on
# the next fetch instead of pinning auth_required
# to False forever.
_schedule_check_fn_probe(entry.check_fn)
continue
if cached_result is False:
auth_required = True
auth_command = f"hermes auth {name}"
break
except Exception:
pass
rows.append({
"name": name,
"version": version or "",
"description": description or "",
"source": source,
"runtime_status": runtime_status,
"has_dashboard_manifest": has_dash_manifest,
"dashboard_manifest": _strip_dashboard_manifest(dm) if dm else None,
"path": dir_str,
"can_remove": can_remove_update,
"can_update_git": can_remove_update and (Path(dir_str) / ".git").exists(),
"auth_required": auth_required,
"auth_command": auth_command,
"user_hidden": name in hidden_plugins,
})
agent_names = {r["name"] for r in rows}
orphan_dashboard = [
_strip_dashboard_manifest(p)
for p in dashboard_list
if str(p["name"]) not in agent_names
]
memory_providers = _discover_memory_provider_statuses()
context_engines: List[Dict[str, str]] = []
try:
for n, desc in _discover_context_engines():
context_engines.append({"name": n, "description": desc})
except Exception:
context_engines = []
payload = {
"plugins": rows,
"orphan_dashboard_plugins": orphan_dashboard,
"providers": {
"memory_provider": _normalize_memory_provider_name(_get_current_memory_provider()),
"memory_options": memory_providers,
"context_engine": _get_current_context_engine(),
"context_options": context_engines,
},
}
duration = time.monotonic() - started_at
if duration >= 0.25:
_log.info(
"plugins/hub rebuilt in %.3fs (plugins=%d memory_options=%d)",
duration,
len(rows),
len(memory_providers),
)
with _plugins_hub_cache_lock:
_plugins_hub_cache = payload
_plugins_hub_cache_expires_at = time.monotonic() + _PLUGINS_HUB_CACHE_TTL_SECONDS
return payload
def _mount_plugin_api_routes():
"""Import and mount backend API routes from plugins that declare them.
Each plugin's ``api`` field points to a Python file that must expose
a ``router`` (FastAPI APIRouter). Routes are mounted under
``/api/plugins/<name>/``.
Backend import is restricted to ``bundled`` and ``user`` sources.
Project plugins (``./.hermes/plugins/``) ship with the CWD and are
therefore attacker-controlled in any threat model where the user
opens a malicious repo; they can extend the dashboard UI via
static JS/CSS but their Python ``api`` file is never auto-imported
by the web server. See GHSA-5qr3-c538-wm9j (#29156).
Additionally, user plugins must be explicitly enabled via the
``plugins.enabled`` allow-list in config.yaml before their backend
code is imported. Without this gate, an installed-but-not-enabled
plugin's Python code would execute at dashboard startup — a code
execution vector that bypasses the user's intent. (#46435,
GHSA-mcfc-hp25-cjv7)
"""
from hermes_cli.web_server import _get_dashboard_plugins, app
# Load the enabled/disabled sets once for the loop.
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()
for plugin in _get_dashboard_plugins():
api_file_name = plugin.get("_api_file")
if not api_file_name:
continue
plugin_name = plugin.get("name", "")
# Gate: user plugins must be in plugins.enabled and not in
# plugins.disabled before we import their Python code.
# Bundled plugins are trusted (they ship with the release) but
# still respect an explicit disable.
if plugin.get("source") == "user":
if plugin_name in disabled_set:
_log.debug(
"Plugin %s: skipping API mount (explicitly disabled)",
plugin_name,
)
continue
if plugin_name not in enabled_set:
_log.debug(
"Plugin %s: skipping API mount (not in plugins.enabled)",
plugin_name,
)
continue
elif plugin.get("source") == "bundled":
if plugin_name in disabled_set:
_log.debug(
"Plugin %s: skipping API mount (explicitly disabled)",
plugin_name,
)
continue
if plugin.get("source") == "project":
_log.warning(
"Plugin %s: ignoring backend api=%s (project plugins may "
"not auto-import Python code; move the plugin to "
"~/.hermes/plugins/ if you trust it)",
plugin["name"], api_file_name,
)
continue
dashboard_dir = Path(plugin["_dir"])
api_path = dashboard_dir / api_file_name
try:
resolved_api = api_path.resolve()
resolved_base = dashboard_dir.resolve()
resolved_api.relative_to(resolved_base)
except (OSError, RuntimeError, ValueError):
# Discovery already filters this, but re-check here in case
# ``_dir`` was tampered with after caching or a future caller
# bypasses the validator. Defence in depth keeps the import
# primitive contained even if the upstream check regresses.
_log.warning(
"Plugin %s: refusing to import api file outside its "
"dashboard directory (%s)", plugin["name"], api_path,
)
continue
if not api_path.exists():
_log.warning("Plugin %s declares api=%s but file not found", plugin["name"], api_file_name)
continue
try:
module_name = f"hermes_dashboard_plugin_{plugin['name']}"
spec = importlib.util.spec_from_file_location(module_name, api_path)
if spec is None or spec.loader is None:
continue
mod = importlib.util.module_from_spec(spec)
# Register in sys.modules BEFORE exec_module so pydantic/FastAPI
# can resolve forward references (e.g. models defined in a file
# that uses `from __future__ import annotations`). Without this,
# TypeAdapter lazy-build fails at first request with
# "is not fully defined" because the module namespace isn't
# reachable by name for string-annotation resolution.
sys.modules[module_name] = mod
try:
spec.loader.exec_module(mod)
except Exception:
sys.modules.pop(module_name, None)
raise
router = getattr(mod, "router", None)
if router is None:
_log.warning("Plugin %s api file has no 'router' attribute", plugin["name"])
continue
app.include_router(router, prefix=f"/api/plugins/{plugin['name']}")
_log.info("Mounted plugin API routes: /api/plugins/%s/", plugin["name"])
except Exception as exc:
_log.warning("Failed to load plugin %s API routes: %s", plugin["name"], exc)