Dashboard plugin discovery, gating, and nav visibility

NousResearch/hermes-agent · cluster 27631 · dashboard

Data as of

2 open items. Fix merged in #56282. Check it covers your case.

The short answer

2open issues
0open PRs
7items grouped
Known?
Yes. 7 related items grouped here, 1 issue already closed.
Fix event
A fix was merged to main in PR #56282. Check it covers your case. No release data is tracked.
Tracker
The maintainers' tracker marks this unresolved. That is separate from the fix event.
Still open
2 issues and 0 PRs.

These issues and PRs address how dashboard plugins are discovered, enabled via config, and shown in navigation — including fixes to ensure plugins respect the enabled config flag and that plugins without built bundles are hidden from the UI.

Open issues2

  • Issue#27631[Bug]: Dashboard plugin loader bypasses plugins.enabled config
  • Issue#43081Proposal: unify the two plugin loaders — dashboard tabs should be gated on a documented config block

Open PRs0

No open pull requests.

Fixes on record

Merged to main. A stamp counts the issues a fix is linked to.

  • #56282 fix(dashboard): gate user plugin asset serving + backend import on plugins.enabled

    merged to main

    files: hermes_cli/web_server.py, tests/hermes_cli/test_plugin_runtime_disable_gate.py, tests/hermes_cli/test_web_server.py

    Show the diff
    • hermes_cli/web_server.py
      @@ -477,6 +477,73 @@ async def host_header_middleware(request: Request, call_next):
           return await call_next(request)
       
       
      [redacted:email]("http")
      +async def _plugin_api_runtime_gate(request: Request, call_next):
      +    """Block requests to disabled plugin API routes at request time.
      +
      +    :func:`_mount_plugin_api_routes` gates at import time, but if a plugin
      +    is disabled *after* the dashboard is already running, its FastAPI router
      +    remains mounted until restart.  This middleware enforces the enabled/
      +    disabled policy on every request to ``/api/plugins/{name}/...`` so that
      +    runtime config changes take effect immediately.
      +
      +    Registered BEFORE the auth middlewares (so it executes AFTER them): a
      +    request that hasn't cleared auth must get auth's 401 first, never this
      +    gate's 404 — otherwise an unauthenticated caller could fingerprint which
      +    plugins are installed/enabled by reading the status code. We only reach
      +    the enabled/disabled check for a request that auth already let through.
      +    """
      +    path = request.url.path
      +    if path.startswith("/api/plugins/"):
      +        # Only gate authenticated requests. Unauthenticated ones fall
      +        # through so auth_middleware / the OAuth gate return 401 first and
      +        # this route can't be used as a plugin-name oracle.
      +        _authed = (
      +            getattr(request.state, "token_authenticated", False)
      +            or getattr(request.app.state, "auth_required", False)
      +            or _has_valid_session_token(request)
      +            or _has_valid_query_token(request, path)
      +        )
      +        if _authed:
      +            # Extract plugin name from /api/plugins/<name>/...
      +            parts = path.split("/")
      +            # parts: ['', 'api', 'plugins', '<name>', ...]
      +            if len(parts) >= 4:
      +                plugin_name = parts[3]
      +                if plugin_name:
      +                    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()
      +                    # Determine plugin source.  Check the cached plugin list;
      +                    # if not found, assume user plugin (safe default — blocks).
      +                    plugins = _get_dashboard_plugins()
      +                    plugin = next(
      +                        (p for p in plugins if p.get("name") == plugin_name),
      +                        None,
      +                    )
      +                    source = plugin.get("source") if plugin else "user"
      +                    if source == "user":
      +                        if plugin_name in disabled_set or plugin_name not in enabled_set:
      +                            return JSONResponse(
      +                                status_code=404,
      +                                content={"detail": "Plugin not found"},
      +                            )
      +                    elif source == "bundled":
      +                        if plugin_name in disabled_set:
      +                            return JSONResponse(
      +                                status_code=404,
      +                                content={"detail": "Plugin not found"},
      +                            )
      +    return await call_next(request)
      +
      +
       # ---------------------------------------------------------------------------
       # Dashboard OAuth auth gate — engaged only when start_server flags the
       # bind as non-loopback-without-insecure.  No-op pass-through in loopback
      @@ -13453,16 +13520,41 @@ def _get_dashboard_plugins(force_rescan: bool = False) -> list:
       
       @app.get("/api/dashboard/plugins")
       async def get_dashboard_plugins():
      -    """Return discovered dashboard plugins (excludes user-hidden ones)."""
      +    """Return discovered dashboard plugins (excludes user-hidden and non-enabled ones)."""
           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 []
      -    # Strip internal fields before sending to frontend and filter out hidden.
      +    # 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()
      +
      +    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 p["name"] not in hidden
      +        if _is_active(p)
           ]
       
       
      @@ -13759,12 +13851,31 @@ async def serve_plugin_asset(plugin_name: str, file_path: str):
           allowlist, anyone on the loopback port can curl the ``.py`` source
           of a private third-party plugin. Reject everything outside the
           browser-asset set.
      +
      +    User plugins must be in plugins.enabled before their assets are
      +    served. (#46435, GHSA-mcfc-hp25-cjv7)
           """
           plugins = _get_dashboard_plugins()
           plugin = next((p for p in plugins if p["name"] == plugin_name), None)
           if not plugin:
               raise HTTPException(status_code=404, detail="Plugin not found")
       
      +    # Gate: user plugins must be enabled to serve assets;
      +    # bundled plugins must not be explicitly disabled.
      +    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()
      +    if plugin.get("source") == "user":
      +        if plugin_name in disabled_set or plugin_name not in enabled_set:
      +            raise HTTPException(status_code=404, detail="Plugin not found")
      +    elif plugin.get("source") == "bundled":
      +        if plugin_name in disabled_set:
      +            raise HTTPException(status_code=404, detail="Plugin not found")
      +
           base = Path(plugin["_dir"])
           target = (base / file_path).resolve()
       
      @@ -13824,11 +13935,52 @@ def _mount_plugin_api_routes():
           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)
           """
      +    # 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 "
    • tests/hermes_cli/test_plugin_runtime_disable_gate.py
      @@ -0,0 +1,401 @@
      +"""Regression tests for runtime plugin disable gating.
      +
      +Covers two residual bypasses addressed in the PR:
      +
      +1. Plugin API routes mounted at startup remain callable even after the
      +   plugin is added to ``plugins.disabled`` at runtime.  The new
      +   ``_plugin_api_runtime_gate`` middleware blocks these requests.
      +
      +2. Bundled plugin assets were served from the unauthenticated
      +   ``/dashboard-plugins/{name}/{path}`` route even when the bundled
      +   plugin was in ``plugins.disabled``.  The updated ``serve_plugin_asset``
      +   now applies the disabled check to bundled plugins too.
      +"""
      +from __future__ import annotations
      +
      +import json
      +from pathlib import Path
      +from unittest.mock import patch, AsyncMock
      +
      +import pytest
      +
      +from hermes_cli import web_server
      +
      +
      [redacted:email](autouse=True)
      +def _reset_plugin_cache():
      +    """Bust the plugin cache before and after each test."""
      +    web_server._dashboard_plugins_cache = None
      +    yield
      +    web_server._dashboard_plugins_cache = None
      +
      +
      [redacted:email]
      +def test_client(monkeypatch, tmp_path):
      +    """Set up a Starlette TestClient with auth bypassed."""
      +    try:
      +        from starlette.testclient import TestClient
      +    except ImportError:
      +        pytest.skip("fastapi/starlette not installed")
      +
      +    from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
      +
      +    # Isolate HERMES_HOME so config reads go to our tmp.
      +    monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
      +    (tmp_path / "home").mkdir(parents=True)
      +
      +    client = TestClient(app)
      +    client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
      +    return client
      +
      +
      +# ---------------------------------------------------------------------------
      +# Helpers
      +# ---------------------------------------------------------------------------
      +
      +def _make_user_plugin(tmp_path, name="hot"):
      +    """Create a minimal user plugin with a JS asset."""
      +    dashboard_dir = tmp_path / "plugins" / name / "dashboard"
      +    dashboard_dir.mkdir(parents=True)
      +    dist_dir = dashboard_dir / "dist"
      +    dist_dir.mkdir()
      +    (dist_dir / "index.js").write_text("console.log('hello');")
      +    (dashboard_dir / "manifest.json").write_text(json.dumps({
      +        "name": name,
      +        "label": name.title(),
      +        "entry": "dist/index.js",
      +    }))
      +    return dashboard_dir
      +
      +
      +def _make_bundled_plugin(tmp_path, name="bundledx"):
      +    """Create a minimal bundled plugin with a JS asset."""
      +    dashboard_dir = tmp_path / "bundled" / name / "dashboard"
      +    dashboard_dir.mkdir(parents=True)
      +    dist_dir = dashboard_dir / "dist"
      +    dist_dir.mkdir()
      +    (dist_dir / "index.js").write_text("console.log('bundled');")
      +    (dashboard_dir / "manifest.json").write_text(json.dumps({
      +        "name": name,
      +        "label": name.title(),
      +        "entry": "dist/index.js",
      +    }))
      +    return dashboard_dir
      +
      +
      +# ---------------------------------------------------------------------------
      +# Test 1: Runtime-disabled user plugin API routes return 404
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestPluginApiRuntimeGate:
      +    """After a user plugin is disabled at runtime, its mounted API routes
      +    must return 404 — not 200 — even though the router was already
      +    included at startup.  The _plugin_api_runtime_gate middleware enforces
      +    this at request time."""
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_disabled_user_plugin(self):
      +        """Middleware returns 404 for a user plugin added to disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        # Simulate a request to /api/plugins/hot/probe
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value={"hot"}), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value={"hot"}):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_unenabled_user_plugin(self):
      +        """Middleware returns 404 when user plugin not in enabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_enabled_user_plugin(self):
      +        """Middleware passes through for an enabled user plugin."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "hot",
      +            "source": "user",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/hot/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value={"hot"}), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_blocks_disabled_bundled_plugin(self):
      +        """Middleware returns 404 for a bundled plugin in disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "source": "bundled",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/bundledx/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value={"bundledx"}):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_enabled_bundled_plugin(self):
      +        """Middleware passes through for a bundled plugin not in disabled set."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "source": "bundled",
      +        }
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/bundledx/probe",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_passes_non_plugin_api_routes(self):
      +        """Middleware ignores non-plugin API routes."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/status",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        expected_resp = JSONResponse({"ok": True})
      +        call_next = AsyncMock(return_value=expected_resp)
      +
      +        response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response is expected_resp
      +        call_next.assert_called_once()
      +
      +    @pytest.mark.asyncio
      +    async def test_middleware_unknown_plugin_defaults_to_user_blocks(self):
      +        """Unknown plugin name (not in discovery cache) is treated as user
      +        plugin and blocked when not enabled."""
      +        from starlette.requests import Request
      +        from starlette.responses import JSONResponse
      +
      +        scope = {
      +            "type": "http",
      +            "method": "GET",
      +            "path": "/api/plugins/unknown/action",
      +            "query_string": b"",
      +            "headers": [],
      +            "state": {"token_authenticated": True},
      +        }
      +        request = Request(scope)
      +
      +        call_next = AsyncMock(return_value=JSONResponse({"ok": True}))
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[]), \
      +             patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set()), \
      +             patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set()):
      +            response = await web_server._plugin_api_runtime_gate(request, call_next)
      +
      +        assert response.status_code == 404
      +        call_next.assert_not_called()
      +
      +
      +# ---------------------------------------------------------------------------
      +# Test 2: Disabled bundled plugin assets return 404
      +# ---------------------------------------------------------------------------
      +
      +
      +class TestBundledPluginAssetGate:
      +    """Bundled plugins in ``plugins.disabled`` must have their static
      +    assets blocked — not just hidden from the listing endpoint."""
      +
      +    def test_bundled_asset_returns_404_when_disabled(self, test_client, tmp_path, monkeypatch):
      +        """A disabled bundled plugin's JS asset must return 404."""
      +        plugin_dir = _make_bundled_plugin(tmp_path, "bundledx")
      +
      +        fake_plugin = {
      +            "name": "bundledx",
      +            "label": "Bundledx",
      +            "source": "bundled",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            # Sanity: asset is served when not disabled.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
      +                assert resp.status_code == 200, (
      +                    "Sanity: bundled plugin asset should be served when not disabled"
      +                )
      +
      +            # Disable it.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value={"bundledx"}
      +            ):
      +                resp = test_client.get("/dashboard-plugins/bundledx/dist/index.js")
      +                assert resp.status_code == 404, (
      +                    "Disabled bundled plugin asset must return 404"
      +                )
      +
      +    def test_bundled_asset_served_when_not_disabled(self, test_client, tmp_path, monkeypatch):
      +        """Bundled plugin assets are served normally when not in disabled set."""
      +        plugin_dir = _make_bundled_plugin(tmp_path, "goodbundled")
      +
      +        fake_plugin = {
      +            "name": "goodbundled",
      +            "label": "Good Bundled",
      +            "source": "bundled",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/goodbundled/dist/index.js")
      +                assert resp.status_code == 200
      +
      +    def test_user_plugin_asset_still_gated(self, test_client, tmp_path, monkeypatch):
      +        """User plugins still require enabled set membership for assets."""
      +        plugin_dir = _make_user_plugin(tmp_path, "userplugin")
      +
      +        fake_plugin = {
      +            "name": "userplugin",
      +            "label": "User Plugin",
      +            "source": "user",
      +            "entry": "dist/index.js",
      +            "_dir": str(plugin_dir),
      +        }
      +
      +        with patch.object(web_server, "_get_dashboard_plugins", return_value=[fake_plugin]):
      +            # Not in enabled set → 404.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value=set()
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/userplugin/dist/index.js")
      +                assert resp.status_code == 404
      +
      +            # In enabled set → 200.
      +            with patch(
      +                "hermes_cli.plugins_cmd._get_enabled_set", return_value={"userplugin"}
      +            ), patch(
      +                "hermes_cli.plugins_cmd._get_disabled_set", return_value=set()
      +            ):
      +                resp = test_client.get("/dashboard-plugins/userplugin/dist/index.js")
      +                assert resp.status_code == 200
    • tests/hermes_cli/test_web_server.py
      @@ -66,6 +66,24 @@ def _install_example_plugin(_isolate_hermes_home):
               shutil.rmtree(dst)
           shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
       
      +    # The dashboard now gates user-plugin asset serving + backend import
      +    # behind the ``plugins.enabled`` allow-list (GHSA-mcfc-hp25-cjv7).
      +    # An installed-but-not-enabled user plugin has its API mount skipped
      +    # and its assets 404'd — which is the whole point of the gate. These
      +    # fixtures exist to exercise the *serving* paths, so opt the example
      +    # plugin in exactly as a real operator would with `hermes plugins
      +    # enable example`.
      +    from hermes_cli.config import load_config, save_config
      +    _cfg = load_config()
      +    _plugins_cfg = _cfg.setdefault("plugins", {})
      +    _enabled = _plugins_cfg.get("enabled")
      +    if not isinstance(_enabled, list):
      +        _enabled = []
      +    if "example" not in _enabled:
      +        _enabled.append("example")
      +    _plugins_cfg["enabled"] = _enabled
      +    save_config(_cfg)
      +
           # Snapshot the existing routes BEFORE mounting so we can:
           #   1. Identify the routes the mount call appends.
           #   2. Restore the original list on teardown — otherwise leftover

3 PRs closed without merging

From the close comments: done elsewhere 3

  • #19813 fix(dashboard): gate dashboard plugin discovery by config · done elsewhere → #56282, #47491
  • #27638 fix(plugins): dashboard loader respects plugins.enabled opt-in config · done elsewhere → #56282, #47491
  • #28650 fix(dashboard): hide tab for plugins without a built entry (#28609) · done elsewhere
Closed issues · 1
  • Closed#28609[Bug]: example-dashboard plugin is discovered but has no built dist/, shows error banner on every install
How this cluster was grouped
  • Link evidence (partly structural links): some members are connected by structural evidence (shared changed files, or a closing/superseded reference); the rest are joined by an automated relevance judgement over their text.
  • Members outside that structural core may belong to a different underlying problem.
  • Pairwise link density 0.22: the share of item pairs that carry a link, counting a structural link in full and a relevance-only link at 0.36. 1.00 means every pair is linked.

Pick up a Lab task

Check whether a fix covers the open issues here, with your own agent. Bring back evidence for maintainers.

Open the Lab

At a glance

Subsystem
dashboard
Merged PRs
#56282
Closed issues
1
Attempts
3 PRs closed without merging
Fix link checked
1 Jul 2026

For your agent

The same facts, typed.