mirror of
https://github.com/nesquena/hermes-webui.git
synced 2026-07-06 07:30:26 +00:00
944e739b40
CRITICAL (Opus HALT on prior commit): the PR's edit to save_settings() replaced 'if k in _SETTINGS_ALLOWED_KEYS' with 'if k=="dashboard_plugins": continue' and orphaned the whole validation body under the continue. Effects: (a) settings save broken for every key except dashboard_plugins; (b) the allowlist security boundary gone -> any client key (password_hash, signing_key_*) became settable. Restored the guard + correct indentation; dashboard_plugins handled by the deep-merge above. Verified in-process: language persists, password_hash/signing_key injection rejected, dashboard_plugins still deep-merges. Also (Opus SHOULD-FIX #3): validate plugin name against ^[a-z][a-z0-9_-]{0,63}$ so a manifest name like '../foo' can't make the URL-space ambiguous. Regression tests added for both (the allowlist bug had ZERO coverage).
148 lines
5.1 KiB
Python
148 lines
5.1 KiB
Python
"""
|
|
Plugin discovery and static serving for Hermes Web UI.
|
|
|
|
Scans ~/.hermes/plugins/<name>/dashboard/ for manifest.json files,
|
|
matching the official Hermes dashboard plugin format.
|
|
|
|
Each plugin may have:
|
|
dashboard/
|
|
manifest.json -- tab definition and entry point
|
|
dist/
|
|
index.js -- plugin JS bundle (IIFE)
|
|
style.css -- optional plugin stylesheet
|
|
plugin_api.py -- optional backend API (not used in WebUI MVP)
|
|
"""
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Valid dashboard-plugin name: a safe slug (it becomes a URL path component and
|
|
# a settings key). Lowercase alnum + - / _, 1-64 chars, must start with a letter.
|
|
_VALID_PLUGIN_NAME = re.compile(r"^[a-z][a-z0-9_-]{0,63}$")
|
|
|
|
# plugin_name -> manifest dict (as loaded from manifest.json)
|
|
PLUGIN_MANIFESTS: dict[str, dict] = {}
|
|
|
|
# plugin_name -> resolved static root dir
|
|
_PLUGIN_STATIC_ROOTS: dict[str, Path] = {}
|
|
|
|
|
|
def _get_plugin_base() -> Path:
|
|
return Path(os.environ.get("HERMES_WEBUI_PLUGINS_DIR", str(Path.home() / ".hermes" / "plugins")))
|
|
|
|
|
|
def load_plugins() -> None:
|
|
"""Scan plugin directories and load manifest.json for each dashboard plugin."""
|
|
plugin_base = _get_plugin_base()
|
|
if not plugin_base.is_dir():
|
|
logger.debug("No plugins directory at %s", plugin_base)
|
|
return
|
|
|
|
for entry in sorted(plugin_base.iterdir()):
|
|
if not entry.is_dir():
|
|
continue
|
|
manifest_path = entry / "dashboard" / "manifest.json"
|
|
if not manifest_path.is_file():
|
|
continue
|
|
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text())
|
|
except Exception:
|
|
logger.exception("Failed to parse manifest for plugin %s", entry.name)
|
|
continue
|
|
|
|
name = manifest.get("name") or entry.name
|
|
|
|
# Validate the plugin name: it becomes a URL path component
|
|
# (/dashboard-plugins/<name>/...) and a settings key. Restrict to a safe
|
|
# slug so a manifest like name:"../foo" can't make the URL-space ambiguous.
|
|
if not _VALID_PLUGIN_NAME.match(str(name)):
|
|
logger.warning("Skipping plugin with invalid name %r (must match %s)", name, _VALID_PLUGIN_NAME.pattern)
|
|
continue
|
|
|
|
tab = manifest.get("tab", {})
|
|
tab_path = tab.get("path", f"/{name}")
|
|
|
|
if name in PLUGIN_MANIFESTS:
|
|
logger.warning("Duplicate plugin name skipped: %s (already loaded)", name)
|
|
continue
|
|
if tab_path in (m.get("tab", {}).get("path") for m in PLUGIN_MANIFESTS.values()):
|
|
logger.warning("Plugin %s tab.path %r conflicts with another plugin; skipped", name, tab_path)
|
|
continue
|
|
|
|
PLUGIN_MANIFESTS[name] = manifest
|
|
logger.info("Loaded dashboard plugin: %s (label=%s)", name, manifest.get("label", ""))
|
|
|
|
# Pre-compute static root for fast serving (points to dashboard/)
|
|
dashboard_dir = entry / "dashboard"
|
|
if dashboard_dir.is_dir():
|
|
_PLUGIN_STATIC_ROOTS[name] = dashboard_dir.resolve()
|
|
|
|
|
|
def serve_plugin_static(plugin_name: str, rel_path: str) -> tuple[bytes, str] | None:
|
|
"""
|
|
Serve a static file from a plugin's dist/ directory.
|
|
|
|
Returns (file_bytes, content_type) on success, None on not found.
|
|
"""
|
|
root = _PLUGIN_STATIC_ROOTS.get(plugin_name)
|
|
if not root:
|
|
return None
|
|
|
|
safe = (root / rel_path.lstrip("/")).resolve()
|
|
try:
|
|
safe.relative_to(root)
|
|
except ValueError:
|
|
return None # path traversal attempt
|
|
|
|
if not safe.is_file():
|
|
return None
|
|
|
|
data = safe.read_bytes()
|
|
ext = os.path.splitext(rel_path.lower())[1]
|
|
content_type = {
|
|
".js": "application/javascript; charset=utf-8",
|
|
".css": "text/css; charset=utf-8",
|
|
".html": "text/html; charset=utf-8",
|
|
".json": "application/json; charset=utf-8",
|
|
".png": "image/png",
|
|
".svg": "image/svg+xml",
|
|
".ico": "image/x-icon",
|
|
}.get(ext, "application/octet-stream")
|
|
|
|
return data, content_type
|
|
|
|
|
|
def get_plugin_metadata() -> list[dict]:
|
|
"""
|
|
Return a list of plugin metadata suitable for the Settings → Plugins tab.
|
|
Each entry includes name, key, version, description, and tab info for linking.
|
|
|
|
Per-plugin enabled state is stored in settings.json under `dashboard_plugins`.
|
|
A plugin is enabled only if the user has explicitly toggled it on (default off).
|
|
"""
|
|
from api.config import load_settings
|
|
|
|
plugin_settings = load_settings().get("dashboard_plugins", {})
|
|
plugins = []
|
|
for name, manifest in sorted(PLUGIN_MANIFESTS.items()):
|
|
tab = manifest.get("tab", {})
|
|
path = tab.get("path", f"/{name}")
|
|
plugins.append({
|
|
"name": manifest.get("label") or manifest.get("name") or name,
|
|
"key": name,
|
|
"version": manifest.get("version", "0.0.0"),
|
|
"description": manifest.get("description", ""),
|
|
"tab": {
|
|
"path": path,
|
|
"label": tab.get("label") or manifest.get("label") or name,
|
|
},
|
|
"enabled": bool(plugin_settings.get(name, False)),
|
|
"hooks": [],
|
|
})
|
|
return plugins
|