"""Dashboard theme/font and dashboard-plugin (discovery, hub, install/enable, asset serving) routes. Extracted from ``hermes_cli.web_server``; helpers/state that tests monkeypatch on ``web_server`` stay there and are imported lazily at call time (cycle-safe). """ import logging import asyncio from fastapi import APIRouter from hermes_cli.web_deps import late from fastapi import HTTPException, Request from fastapi.responses import FileResponse from hermes_cli.web_models import ThemeSetBody, FontSetBody, _AgentPluginInstallBody, _PluginProvidersPutBody, _PluginVisibilityBody from pathlib import Path _log = logging.getLogger("hermes_cli.web_server") router = APIRouter() # web_server helpers, late-bound so monkeypatch.setattr(web_server, ...) stays authoritative. _discover_user_themes = late("_discover_user_themes") _get_dashboard_plugins = late("_get_dashboard_plugins") _invalidate_plugins_hub_cache = late("_invalidate_plugins_hub_cache") _merged_plugins_hub = late("_merged_plugins_hub") _normalize_memory_provider_name = late("_normalize_memory_provider_name") _require_memory_provider_ready = late("_require_memory_provider_ready") _require_token = late("_require_token") cfg_get = late("cfg_get") load_config = late("load_config") save_config = late("save_config") @router.get("/api/dashboard/themes") async def get_dashboard_themes(): """Return available themes and the currently active one. Built-in entries ship name/label/description only (the frontend owns their full definitions in `web/src/themes/presets.ts`). User themes from `~/.hermes/dashboard-themes/*.yaml` ship with their full normalised definition under `definition`, so the client can apply them without a stub. """ from hermes_cli.web_server import _BUILTIN_DASHBOARD_THEMES def _run(): config = load_config() active = cfg_get(config, "dashboard", "theme", default="default") user_themes = _discover_user_themes() seen = set() themes = [] for t in _BUILTIN_DASHBOARD_THEMES: seen.add(t["name"]) themes.append(t) for t in user_themes: if t["name"] in seen: continue themes.append({ "name": t["name"], "label": t["label"], "description": t["description"], "definition": t, }) seen.add(t["name"]) return {"themes": themes, "active": active} return await asyncio.to_thread(_run) @router.put("/api/dashboard/theme") async def set_dashboard_theme(body: ThemeSetBody): """Set the active dashboard theme (persists to config.yaml).""" from hermes_cli.web_server import _CONFIG_MUTATION_LOCK def _run(): with _CONFIG_MUTATION_LOCK: config = load_config() if "dashboard" not in config: config["dashboard"] = {} config["dashboard"]["theme"] = body.name save_config(config) return {"ok": True, "theme": body.name} return await asyncio.to_thread(_run) # Curated font-override ids. Kept in sync with FONT_CHOICES in # web/src/themes/fonts.ts — the frontend owns the stacks + webfont URLs; # the backend only needs the id allow-list so it can reject anything not # in the vetted catalog (the font's webfont URL is injected as a , # so we never accept an arbitrary user-supplied id/URL here). _FONT_DEFAULT_ID = "theme" _FONT_CHOICES = frozenset({ "system-sans", "system-serif", "system-mono", "inter", "ibm-plex-sans", "work-sans", "atkinson-hyperlegible", "dm-sans", "spectral", "fraunces", "source-serif", "jetbrains-mono", "ibm-plex-mono", "space-mono", }) @router.get("/api/dashboard/font") async def get_dashboard_font(): """Return the active font override (``"theme"`` = use the theme's font).""" def _run(): config = load_config() font = cfg_get(config, "dashboard", "font", default=_FONT_DEFAULT_ID) if font not in _FONT_CHOICES: font = _FONT_DEFAULT_ID return {"font": font} return await asyncio.to_thread(_run) @router.put("/api/dashboard/font") async def set_dashboard_font(body: FontSetBody): """Set the dashboard font override (persists to config.yaml). Accepts any id in the curated catalog, or ``"theme"`` to clear the override and fall back to the active theme's own font. Unknown ids are coerced to ``"theme"`` rather than 400'd so a stale client can't wedge the picker. """ from hermes_cli.web_server import _CONFIG_MUTATION_LOCK font = body.font if body.font in _FONT_CHOICES else _FONT_DEFAULT_ID def _run(): with _CONFIG_MUTATION_LOCK: config = load_config() if "dashboard" not in config: config["dashboard"] = {} config["dashboard"]["font"] = font save_config(config) return {"ok": True, "font": font} return await asyncio.to_thread(_run) @router.get("/api/dashboard/plugins") async def get_dashboard_plugins(): """Return discovered dashboard plugins (excludes user-hidden and non-enabled ones).""" def _run(): plugins = _get_dashboard_plugins() # Read user's hidden plugins list from config. config = load_config() hidden: list = cfg_get(config, "dashboard", "hidden_plugins", default=[]) or [] # Gate: only serve user plugins that are in plugins.enabled and not # in plugins.disabled. This prevents the frontend from loading JS/CSS # from plugins the user has not explicitly activated. (#46435) 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() return plugins, hidden, enabled_set, disabled_set plugins, hidden, enabled_set, disabled_set = await asyncio.to_thread(_run) def _is_active(p: dict) -> bool: name = p.get("name", "") if name in hidden: return False if p.get("source") == "user": if name in disabled_set: return False if name not in enabled_set: return False elif p.get("source") == "bundled": if name in disabled_set: return False return True # Strip internal fields before sending to frontend. return [ {k: v for k, v in p.items() if not k.startswith("_")} for p in plugins if _is_active(p) ] @router.get("/api/dashboard/plugins/rescan") async def rescan_dashboard_plugins(): """Force re-scan of dashboard plugins.""" plugins = _get_dashboard_plugins(force_rescan=True) return {"ok": True, "count": len(plugins)} @router.get("/api/dashboard/plugins/hub") async def get_plugins_hub(request: Request): """Unified agent plugins + dashboard extension metadata (session protected).""" _require_token(request) try: return _merged_plugins_hub() except Exception as exc: _log.warning("plugins/hub failed: %s", exc) raise HTTPException(status_code=500, detail="Failed to build plugins hub.") from exc @router.post("/api/dashboard/agent-plugins/install") async def post_agent_plugin_install(request: Request, body: _AgentPluginInstallBody): _require_token(request) from hermes_cli.plugins_cmd import dashboard_install_plugin result = dashboard_install_plugin( body.identifier.strip(), force=body.force, enable=body.enable, ) if not result.get("ok"): raise HTTPException( status_code=400, detail=result.get("error") or "Install failed.", ) _get_dashboard_plugins(force_rescan=True) _invalidate_plugins_hub_cache() # Strip internal paths from the response result.pop("after_install_path", None) return result def _validate_plugin_name(name: str) -> str: """Reject path-traversal attempts in plugin name URL parameters.""" name = name.strip("/") if not name or ".." in name or "\\" in name: raise HTTPException(status_code=400, detail="Invalid plugin name.") return name @router.post("/api/dashboard/agent-plugins/{name:path}/enable") async def post_agent_plugin_enable(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled result = dashboard_set_agent_plugin_enabled(name, enabled=True) if not result.get("ok"): raise HTTPException(status_code=400, detail=result.get("error") or "Enable failed.") _invalidate_plugins_hub_cache() return result @router.post("/api/dashboard/agent-plugins/{name:path}/disable") async def post_agent_plugin_disable(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_set_agent_plugin_enabled result = dashboard_set_agent_plugin_enabled(name, enabled=False) if not result.get("ok"): raise HTTPException(status_code=400, detail=result.get("error") or "Disable failed.") _invalidate_plugins_hub_cache() return result @router.post("/api/dashboard/agent-plugins/{name:path}/update") async def post_agent_plugin_update(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_update_user_plugin result = dashboard_update_user_plugin(name) if not result.get("ok"): raise HTTPException(status_code=400, detail=result.get("error") or "Update failed.") _get_dashboard_plugins(force_rescan=True) _invalidate_plugins_hub_cache() return result @router.delete("/api/dashboard/agent-plugins/{name:path}") async def delete_agent_plugin(request: Request, name: str): _require_token(request) name = _validate_plugin_name(name) from hermes_cli.plugins_cmd import dashboard_remove_user_plugin result = dashboard_remove_user_plugin(name) if not result.get("ok"): raise HTTPException(status_code=400, detail=result.get("error") or "Remove failed.") _get_dashboard_plugins(force_rescan=True) _invalidate_plugins_hub_cache() return result @router.put("/api/dashboard/plugin-providers") async def put_plugin_providers(request: Request, body: _PluginProvidersPutBody): """Persist memory provider / context engine selection (writes config.yaml).""" from hermes_cli.web_server import _CONFIG_MUTATION_LOCK _require_token(request) from hermes_cli.plugins_cmd import ( _save_context_engine, _save_memory_provider, ) def _run(): with _CONFIG_MUTATION_LOCK: if body.memory_provider is not None: memory_provider = _normalize_memory_provider_name(body.memory_provider) _require_memory_provider_ready(memory_provider) _save_memory_provider(memory_provider) if body.context_engine is not None: _save_context_engine(body.context_engine) _invalidate_plugins_hub_cache() return {"ok": True} return await asyncio.to_thread(_run) @router.post("/api/dashboard/plugins/{name:path}/visibility") async def post_plugin_visibility(request: Request, name: str, body: _PluginVisibilityBody): """Toggle a plugin's sidebar visibility (persists to config.yaml dashboard.hidden_plugins).""" from hermes_cli.web_server import _CONFIG_MUTATION_LOCK _require_token(request) name = _validate_plugin_name(name) def _run(): with _CONFIG_MUTATION_LOCK: config = load_config() if "dashboard" not in config or not isinstance(config.get("dashboard"), dict): config["dashboard"] = {} hidden_list: list = config["dashboard"].get("hidden_plugins") or [] if not isinstance(hidden_list, list): hidden_list = [] if body.hidden and name not in hidden_list: hidden_list.append(name) elif not body.hidden and name in hidden_list: hidden_list.remove(name) config["dashboard"]["hidden_plugins"] = hidden_list save_config(config) _invalidate_plugins_hub_cache() return {"ok": True, "name": name, "hidden": body.hidden} return await asyncio.to_thread(_run) @router.get("/dashboard-plugins/{plugin_name}/{file_path:path}") async def serve_plugin_asset(plugin_name: str, file_path: str): """Serve static assets from a dashboard plugin directory. Only serves files from the plugin's ``dashboard/`` subdirectory. Path traversal is blocked by checking ``resolve().is_relative_to()``. Restricted to a browser-fetchable suffix allowlist (JS/CSS/JSON/HTML/ SVG/PNG/JPG/WOFF). The dashboard loads plugin JS via ``