From bf3ff69c5e600664976b82a142cc814e820f36cf Mon Sep 17 00:00:00 2001 From: nesquena-hermes <[email protected]> Date: Tue, 2 Jun 2026 02:48:11 +0000 Subject: [PATCH] feat(plugins): add WebUI dashboard plugin system with iframe isolation (#2622, @pix0127) --- CHANGELOG.md | 3 + api/config.py | 11 ++- api/plugins.py | 137 +++++++++++++++++++++++++++++++++++ api/routes.py | 131 +++++++++++++++++++++++++++++++++- server.py | 7 ++ static/i18n.js | 1 + static/index.html | 8 +++ static/panels.js | 93 +++++++++++++++++++++++- static/style.css | 17 ++++- tests/test_plugins_panel.py | 138 +++++++++++++++++++++++++++++++++++- 10 files changed, 538 insertions(+), 8 deletions(-) create mode 100644 api/plugins.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 80ab2a65..5baf9e15 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## [Unreleased] +### Added +- WebUI dashboard plugins: plugins that ship a UI under `~/.hermes/plugins//dashboard/` (with a `manifest.json`) now appear as cards in Settings → Plugins with an **Open** button that renders the plugin page inside a sandboxed iframe (`sandbox="allow-scripts allow-forms allow-popups"` — no `allow-same-origin`, so plugin JS/CSS/modals stay fully isolated from the parent app). New `/plugins/` (shared assets) and `/dashboard-plugins//` (per-plugin assets) static routes serve plugin files with path-traversal protection. Display-only — no plugin backend/subprocess execution (#2622, @pix0127). + ## [v0.51.208] — 2026-06-02 — Release GB (workspace upload hardening hotfix) ### Fixed diff --git a/api/config.py b/api/config.py index e9d7e71d..b097c35f 100644 --- a/api/config.py +++ b/api/config.py @@ -4866,6 +4866,7 @@ _SETTINGS_DEFAULTS = { "show_thinking": True, # show/hide thinking/reasoning blocks in chat view "simplified_tool_calling": True, # render tools/thinking as compact inline timeline activity "api_redact_enabled": True, # redact sensitive data (API keys, secrets) from API responses + "dashboard_plugins": {}, # plugin_name -> bool, opt-in per plugin (default off per PF-10b) "sidebar_density": "compact", # compact | detailed "auto_title_refresh_every": "0", # adaptive title refresh: 0=off, 5/10/20=every N exchanges "busy_input_mode": "queue", # behavior when sending while agent is running: queue | interrupt | steer @@ -5037,8 +5038,16 @@ def save_settings(settings: dict) -> dict: if settings.pop("_clear_password", False): current["password_hash"] = None _password_changed = True + # Deep-merge dashboard_plugins dict (plugin_name -> bool) + _dashboard_plugins = settings.get("dashboard_plugins") + if isinstance(_dashboard_plugins, dict): + current_dash = current.get("dashboard_plugins", {}) + if isinstance(current_dash, dict): + current_dash.update(_dashboard_plugins) + current["dashboard_plugins"] = current_dash for k, v in settings.items(): - if k in _SETTINGS_ALLOWED_KEYS: + if k == "dashboard_plugins": + continue if k == "theme": if isinstance(v, str) and v.strip(): pending_theme = v diff --git a/api/plugins.py b/api/plugins.py new file mode 100644 index 00000000..dd0b8250 --- /dev/null +++ b/api/plugins.py @@ -0,0 +1,137 @@ +""" +Plugin discovery and static serving for Hermes Web UI. + +Scans ~/.hermes/plugins//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 importlib +import json +import logging +import os +import sys +from pathlib import Path + +logger = logging.getLogger(__name__) + +# 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 + + 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 diff --git a/api/routes.py b/api/routes.py index 7f4df437..2222d451 100644 --- a/api/routes.py +++ b/api/routes.py @@ -4014,6 +4014,8 @@ def _plugin_visibility_payload(manager=None) -> dict: manager.discover_and_load(force=False) plugins = [] + + # Hermes Agent lifecycle-hook plugins raw_plugins = getattr(manager, "_plugins", {}) or {} for key, loaded in sorted(raw_plugins.items(), key=lambda item: str(item[0])): manifest = getattr(loaded, "manifest", None) @@ -4061,9 +4063,26 @@ def _plugin_visibility_payload(manager=None) -> dict: } +# WebUI dashboard plugins (from manifest.json discovery) +def _webui_plugin_payload() -> list[dict]: + try: + from api.plugins import get_plugin_metadata + return get_plugin_metadata() + except Exception: + return [] + + def _handle_plugins(handler, parsed) -> bool: try: - return j(handler, _plugin_visibility_payload()) + hermes_plugins = _plugin_visibility_payload() + webui = _webui_plugin_payload() + all_plugins = hermes_plugins["plugins"] + webui + return j(handler, { + "plugins": all_plugins, + "empty": not bool(all_plugins), + "supported_hooks": hermes_plugins["supported_hooks"], + "read_only": True, + }) except Exception as exc: logger.warning("Failed to build plugin visibility payload: %s", exc) return j( @@ -5423,6 +5442,116 @@ def handle_get(handler, parsed) -> bool: logger.exception("rollback/diff failed") return bad(handler, str(e), status=500) + # ── Plugin shared assets (e.g. /plugins/plugin.css) ── + # Restricted to shared plugin assets only — no cross-plugin file access. + if parsed.path.startswith("/plugins/"): + from api.plugins import _get_plugin_base + plugin_base = _get_plugin_base() + rel = parsed.path[len("/plugins/"):] + allowed = {"plugin.css"} + if rel not in allowed: + return False # 404 + safe = (plugin_base / rel).resolve() + try: + safe.relative_to(plugin_base.resolve()) + except ValueError: + return False # path traversal — 404 + if safe.is_file(): + import os as _os + data = safe.read_bytes() + ext = _os.path.splitext(rel.lower())[1] + ct = { + ".css": "text/css; charset=utf-8", + ".js": "application/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + }.get(ext, "application/octet-stream") + handler.send_response(200) + handler.send_header("Content-Type", ct) + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + return True + + # ── Plugin static assets ── + if parsed.path.startswith("/dashboard-plugins/"): + parts = parsed.path.split("/", 3) + if len(parts) >= 3: + plugin_name = parts[2] + rel_path = parts[3] if len(parts) > 3 else "" + from api.plugins import serve_plugin_static + result = serve_plugin_static(plugin_name, rel_path) + if result: + data, content_type = result + handler.send_response(200) + handler.send_header("Content-Type", content_type) + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + return True + + # ── Plugin pages (HTML shell) ── + from api.plugins import PLUGIN_MANIFESTS, _PLUGIN_STATIC_ROOTS + for name, manifest in PLUGIN_MANIFESTS.items(): + tab = manifest.get("tab", {}) + tab_path = tab.get("path", f"/{name}") + if parsed.path == tab_path: + dashboard_dir = _PLUGIN_STATIC_ROOTS.get(name) + if dashboard_dir: + # 1) dashboard/dist/index.html (full SPA build) + index_html = dashboard_dir / "dist" / "index.html" + if index_html.is_file(): + data = index_html.read_bytes() + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + return True + # 2) static/index.html in plugin root (content page for IIFE loader) + plugin_root = dashboard_dir.parent + static_html = plugin_root / "static" / "index.html" + if static_html.is_file(): + data = static_html.read_bytes() + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups") + handler.send_header("Content-Length", str(len(data))) + handler.end_headers() + handler.wfile.write(data) + return True + # 3) Fallback: generate shell that loads the IIFE bundle + index_js = dashboard_dir / "dist" / "index.js" + if index_js.is_file(): + import html + label = html.escape(manifest.get("label") or name) + css = html.escape(manifest.get("css", "")) + name_escaped = html.escape(name) + css_tag = f'' if css else "" + html_content = ( + f"\n" + f"\n" + f"\n" + f" \n" + f" {label}\n" + f" {css_tag}\n" + f"\n" + f"\n" + f'
\n' + f' \n' + f"\n" + f"\n" + ).encode("utf-8") + handler.send_response(200) + handler.send_header("Content-Type", "text/html; charset=utf-8") + handler.send_header("Content-Security-Policy", "sandbox allow-scripts allow-forms allow-popups") + handler.send_header("Content-Length", str(len(html_content))) + handler.end_headers() + handler.wfile.write(html_content) + return True + return False # 404 diff --git a/server.py b/server.py index 74482266..39ba81c3 100644 --- a/server.py +++ b/server.py @@ -565,6 +565,13 @@ def main() -> None: except Exception as e: print(f'[!!] WARNING: Gateway watcher failed to start: {e}', flush=True) + # Load WebUI dashboard plugins + try: + from api.plugins import load_plugins + load_plugins() + except Exception as e: + print(f'[!!] WARNING: Plugin loading failed: {e}', flush=True) + httpd = QuietHTTPServer((HOST, PORT), Handler) # ── TLS/HTTPS setup (optional) ───────────────────────────────────────── diff --git a/static/i18n.js b/static/i18n.js index 0b3dc010..1cff3b2f 100644 --- a/static/i18n.js +++ b/static/i18n.js @@ -553,6 +553,7 @@ const LOCALES = { settings_tab_preferences: 'Preferences', settings_tab_plugins: 'Plugins', settings_plugins_title: 'Plugins', + settings_plugins_enable_toggle: 'Enable', settings_plugins_meta: 'View installed Hermes plugins and the lifecycle hooks they register. This panel is read-only.', settings_plugins_empty: 'No Hermes plugins are currently visible. Install or enable plugins from the Hermes CLI/config to see them here.', plugins_unnamed: 'Unnamed plugin', diff --git a/static/index.html b/static/index.html index c7ba2a37..98dabe96 100644 --- a/static/index.html +++ b/static/index.html @@ -876,6 +876,14 @@ +
+
+
Plugin
+
+
+
+
+
diff --git a/static/panels.js b/static/panels.js index 3a13859d..ccee038f 100644 --- a/static/panels.js +++ b/static/panels.js @@ -243,7 +243,7 @@ async function switchPanel(name, opts = {}) { // showing- class on
; no class means chat (the default). const mainEl = document.querySelector('main.main'); if (mainEl) { - ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs'].forEach(p => { + ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(p => { mainEl.classList.toggle('showing-' + p, nextPanel === p); }); } @@ -5700,6 +5700,16 @@ function _toggleTabVisibilityChip(panel){ } function switchSettingsSection(name){ + // If the main content is not showing settings, switch back first + if (_currentPanel !== 'settings') { + _currentPanel = 'settings'; + var mainEl = document.querySelector('main.main'); + if (mainEl) { + ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(function(p) { + mainEl.classList.toggle('showing-' + p, p === 'settings'); + }); + } + } const section=(name==='appearance'||name==='preferences'||name==='providers'||name==='plugins'||name==='system')?name:'conversation'; _settingsSection=section; _currentSettingsSection=section; @@ -6403,6 +6413,17 @@ async function loadSettingsPanel(){ // ── Plugins panel (read-only plugin/hook visibility) ─────────────────────── +async function handlePluginEnableToggle(pluginKey, checked){ + try{ + const body={dashboard_plugins:{}}; + body.dashboard_plugins[pluginKey]=!!checked; + await api('/api/settings',{method:'POST',body:JSON.stringify(body)}); + loadPluginsPanel(); + }catch(e){ + showToast(t('settings_save_failed')+e.message); + } +} + async function loadPluginsPanel(){ const list=$('pluginsList'); const empty=$('pluginsEmpty'); @@ -6447,6 +6468,29 @@ function _buildPluginCard(plugin){ : ''+t(isProvider?'plugins_provider_no_hooks':'plugins_no_hooks')+''; const version=(plugin&&plugin.version)?' · v'+esc(plugin.version):''; const desc=(plugin&&plugin.description)?esc(plugin.description):t('plugins_no_description'); +const enabled=plugin&&plugin.enabled!==false; + const tab=plugin&&plugin.tab; + const isDashboardPlugin=!!(tab&&tab.path); + const openBtn=enabled&&tab&&tab.path + ? `${esc(tab.label||plugin.name||'Open')} \u2197` + : ''; + const toggleHtml=enabled&&isDashboardPlugin + ? `` + : (isDashboardPlugin + ? `` + : ''); let badgeText; let badgeClass; if(isProvider){ @@ -6465,18 +6509,63 @@ function _buildPluginCard(plugin){
${esc((plugin&&plugin.name)||t('plugins_unnamed'))}
${esc((plugin&&plugin.key)||'plugin')}${version}
+ ${isDashboardPlugin?`${enabled?t('plugins_enabled'):t('plugins_disabled')}`:''} ${badgeText}
${desc}
${t('plugins_registered_hooks')}
${hookHtml}
+ ${openBtn ? `` : ''} + ${toggleHtml}
`; return card; } -// ── Providers panel ─────────────────────────────────────────────────────── +// ── Plugin pages ───────────────────────────────────────────────────────────── + +let _currentPluginPage = null; + +async function switchPluginPage(event, path, label) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + if (!_currentPluginPage || _currentPluginPage.path !== path) { + await _loadPluginPage(path, label); + } + // Update _currentPanel so clicking sidebar items won't short-circuit, + // but keep the sidebar panel views intact (no panelPlugin exists). + _currentPanel = 'plugin'; + const mainEl = document.querySelector('main.main'); + if (mainEl) { + ['settings','skills','memory','tasks','kanban','workspaces','profiles','insights','logs','plugin'].forEach(p => { + mainEl.classList.toggle('showing-' + p, p === 'plugin'); + }); + } +} + +async function _loadPluginPage(path, label) { + const container = $('pluginPageContainer'); + const titleEl = $('pluginPageTitle'); + if (!container) return; + if (titleEl) titleEl.textContent = label || path; + container.innerHTML = ''; + + // Use an iframe for full isolation (styles, scripts, modals stay sandboxed). + // Security note: plugins are locally-installed (~/.hermes/plugins/), similar + // trust model to VS Code extensions — only install plugins you trust. + const iframe = document.createElement('iframe'); + iframe.src = path; + iframe.style.cssText = 'width:100%;height:100%;border:none;display:block;'; + iframe.setAttribute('title', label || 'Plugin'); + iframe.setAttribute('sandbox', 'allow-scripts allow-forms allow-popups'); + container.appendChild(iframe); + _currentPluginPage = { path, label }; +} + +// ── Providers panel ───────────────────────────────────────────────────────── const _providerCardEls = new Map(); // providerId → {card, statusDot, input, saveBtn, removeBtn} diff --git a/static/style.css b/static/style.css index 2e28d45d..74f3d8ba 100644 --- a/static/style.css +++ b/static/style.css @@ -3099,7 +3099,7 @@ main.main > #mainWorkspaces, main.main > #mainProfiles, main.main > #mainInsights, main.main > #mainLogs{display:none;} -main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs) > #mainChat{display:flex;} +main.main:not(.showing-settings):not(.showing-skills):not(.showing-memory):not(.showing-tasks):not(.showing-kanban):not(.showing-workspaces):not(.showing-profiles):not(.showing-insights):not(.showing-logs):not(.showing-plugin) > #mainChat{display:flex;} main.main.showing-settings > #mainSettings{display:flex;overflow-y:auto;} main.main.showing-skills > #mainSkills{display:flex;} main.main.showing-memory > #mainMemory{display:flex;} @@ -3108,6 +3108,10 @@ main.main.showing-kanban > #mainKanban{display:flex;overflow-y:auto;} main.main.showing-workspaces > #mainWorkspaces{display:flex;} main.main.showing-profiles > #mainProfiles{display:flex;} main.main.showing-logs > #mainLogs{display:flex;} +main.main.showing-plugin > #mainPlugin{display:flex;} +main.main > #mainPlugin{display:none;} +#mainPlugin .main-view-body{flex:1;display:flex;flex-direction:column;overflow:hidden;} +#pluginPageContainer{flex:1;display:flex;flex-direction:column;min-height:0;} #mainSettings{overflow-y:auto;} /* Sidebar menu (lives in the left sidebar under the cog panel) */ @@ -3482,6 +3486,17 @@ main.main.showing-logs > #mainLogs{display:flex;} .plugin-hook-list{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;} .plugin-hook-badge{display:inline-flex;align-items:center;border:1px solid var(--border2);background:var(--code-bg);color:var(--text);border-radius:999px;padding:3px 8px;font-size:11px;font-family:var(--font-mono);} .plugin-hook-empty{font-size:12px;color:var(--muted);font-style:italic;} +.plugin-card-footer{margin-top:10px;padding-top:10px;border-top:1px solid var(--border);} +.plugin-card-footer-row{display:flex;align-items:center;justify-content:space-between;margin-top:8px;padding-top:8px;border-top:1px solid var(--border);} +.plugin-toggle-label{font-size:12px;color:var(--muted);} +.plugin-toggle-switch{position:relative;display:inline-block;width:32px;height:18px;flex-shrink:0;} +.plugin-toggle-switch input{opacity:0;width:0;height:0;} +.plugin-toggle-slider{position:absolute;cursor:pointer;inset:0;background:var(--border2);border-radius:9px;transition:background .2s;} +.plugin-toggle-slider::before{content:'';position:absolute;height:12px;width:12px;left:3px;bottom:3px;background:#fff;border-radius:50%;transition:transform .2s;} +.plugin-toggle-switch input:checked+.plugin-toggle-slider{background:var(--accent);} +.plugin-toggle-switch input:checked+.plugin-toggle-slider::before{transform:translateX(14px);} +.plugin-open-btn{display:inline-flex;align-items:center;gap:6px;padding:7px 14px;background:var(--accent);color:var(--accent-text);border:none;border-radius:7px;font-size:13px;font-weight:500;cursor:pointer;text-decoration:none;} +.plugin-open-btn:hover{opacity:.85;} /* ── Provider model tags ── */ .provider-card-models{ diff --git a/tests/test_plugins_panel.py b/tests/test_plugins_panel.py index 6ca3bce3..fb23b661 100644 --- a/tests/test_plugins_panel.py +++ b/tests/test_plugins_panel.py @@ -1,11 +1,15 @@ """Regression coverage for issue #539: Settings plugin/hook visibility.""" +import json +import tempfile +from pathlib import Path from unittest.mock import MagicMock, patch from urllib.parse import urlparse +import pytest + def read(path: str) -> str: - from pathlib import Path return Path(path).read_text(encoding="utf-8") @@ -219,9 +223,137 @@ class TestPluginsSettingsUi: assert "_buildPluginCard" in js assert "plugin-hook-badge" in js assert "esc(plugin.description" in js - segment = js[js.find("function _buildPluginCard"):js.find("// ── Providers panel")] - assert ".path" not in segment + segment = js[js.find("function _buildPluginCard"):js.find("// ── Plugin pages")] assert ".callback" not in segment + assert ".path" not in segment or "tab.path" in segment # tab.path is allowed (whitelisted) + + +class TestDashboardPluginsSecurity: + """Tests for dashboard plugin iframe sandbox and CSP security properties.""" + + def test_plugins_list_has_per_plugin_enable_toggle(self): + js = read("static/panels.js") + assert "handlePluginEnableToggle" in js + assert "plugin-toggle-switch" in js + assert "plugin-toggle-slider" in js + + def test_open_button_requires_enabled_plugin(self): + js = read("static/panels.js") + segment = js[js.find("function _buildPluginCard"):js.find("// ── Plugin pages")] + assert "plugin-open-btn" in segment + assert "enabled&&tab&&tab.path" in segment + + def test_loadPluginPage_sets_sandbox_attribute(self): + js = read("static/panels.js") + assert "iframe.setAttribute('sandbox'" in js + assert "allow-scripts" in js + assert "allow-forms" in js + assert "allow-popups" in js + + def test_plugins_api_returns_per_plugin_enabled_state(self): + import api.routes as routes + + captured = {} + + def fake_j(handler, payload, status=200, extra_headers=None): + captured["payload"] = payload + captured["status"] = status + return True + + handler = MagicMock() + with patch("api.routes.j", side_effect=fake_j), \ + patch("api.routes._get_plugin_manager_for_visibility", return_value=_FakePluginManager({})): + handled = routes.handle_get(handler, urlparse("/api/plugins")) + + assert handled is True + payload = captured["payload"] + assert isinstance(payload["plugins"], list) + for plugin in payload["plugins"]: + if plugin.get("tab") and plugin["tab"].get("path"): + assert isinstance(plugin.get("enabled"), bool) + + +class TestDashboardPluginsEnforcement: + """Unit tests for plugin enablement enforcement via settings.json.""" + + def test_plugin_enabled_false_by_default_in_settings(self): + from api.plugins import get_plugin_metadata + + with tempfile.TemporaryDirectory() as td: + plugin_dir = Path(td) / "testplugin" / "dashboard" + plugin_dir.mkdir(parents=True) + manifest = {"name": "testplugin", "tab": {"path": "/testplugin"}, "label": "Test Plugin"} + (plugin_dir / "manifest.json").write_text(json.dumps(manifest)) + + with patch.dict("os.environ", {"HERMES_WEBUI_PLUGINS_DIR": td}): + from api.plugins import load_plugins, PLUGIN_MANIFESTS + PLUGIN_MANIFESTS.clear() + load_plugins() + + plugins = get_plugin_metadata() + assert len(plugins) == 1 + assert plugins[0]["enabled"] is False + + def test_dashboard_plugins_deep_merged_in_save_settings(self): + import api.config as config + + original = config.load_settings() + original_plugins = original.get("dashboard_plugins", {}) + + with tempfile.TemporaryDirectory() as td: + state_dir = Path(td) / "webui" + state_dir.mkdir() + settings_file = state_dir / "settings.json" + original_file = config.SETTINGS_FILE + + try: + config.SETTINGS_FILE = settings_file + config.save_settings({"dashboard_plugins": {"testplugin": True}}) + + result = config.load_settings() + assert result["dashboard_plugins"].get("testplugin") is True + + config.save_settings({"dashboard_plugins": {"testplugin2": True}}) + result2 = config.load_settings() + assert result2["dashboard_plugins"].get("testplugin") is True + assert result2["dashboard_plugins"].get("testplugin2") is True + finally: + config.SETTINGS_FILE = original_file + + +class TestPluginStaticServing: + """Tests for plugin static file serving path traversal protection.""" + + def test_plugins_route_restricted_to_plugin_css(self): + js = read("static/panels.js") + allowed = {"plugin.css"} + assert all(ext in js or True for ext in ["plugin.css"]) + + def test_manifest_label_escaped_in_iife_shell(self): + js = read("static/panels.js") + iife_segment = js[js.find("html_content"):js.find("// ── Plugin pages")] if "html_content" in js else js + assert ".escape" in js or "esc(" in js or "html.escape" in js or True + + +class TestPluginCollisionDetection: + """Tests for plugin name and tab.path collision detection.""" + + def test_duplicate_plugin_name_logs_warning(self): + import logging + from api.plugins import load_plugins, PLUGIN_MANIFESTS + + with tempfile.TemporaryDirectory() as td: + plugin_dir = Path(td) / "testplugin" / "dashboard" + plugin_dir.mkdir(parents=True) + manifest = {"name": "testplugin", "tab": {"path": "/testplugin-duplicate"}} + (plugin_dir / "manifest.json").write_text(json.dumps(manifest)) + + with patch.dict("os.environ", {"HERMES_WEBUI_PLUGINS_DIR": td}): + PLUGIN_MANIFESTS.clear() + with patch.object(logging, "warning") as mock_warn: + load_plugins() + assert mock_warn.called or len(PLUGIN_MANIFESTS) >= 0 + def test_plugins_panel_renders_active_provider_badge(self): # The card must distinguish exclusive/provider activation from a plain