From 9af62e7ef01c892bb340dc146f3efa0dc8482e33 Mon Sep 17 00:00:00 2001 From: KoNit-K <124019182+KoNit-K@users.noreply.github.com> Date: Tue, 15 Sep 2026 18:14:36 +0800 Subject: [PATCH] fix(dashboard): skip unreadable plugin manifests --- hermes_cli/web_server_dashboard.py | 17 +++++++---- tests/hermes_cli/test_web_server.py | 45 +++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/hermes_cli/web_server_dashboard.py b/hermes_cli/web_server_dashboard.py index 5fdbb2e0b6..e99ef1c773 100644 --- a/hermes_cli/web_server_dashboard.py +++ b/hermes_cli/web_server_dashboard.py @@ -540,21 +540,28 @@ def _discover_dashboard_plugins() -> list: plugins = [] seen_names: set = set() for plugins_root, source in _dashboard_plugin_search_dirs(): - if not plugins_root.is_dir(): + try: + 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) + except OSError as exc: + _log.warning("Skipping unreadable dashboard plugin root %s: %s", plugins_root, exc) 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: manifest_file = child / "dashboard" / "manifest.json" - if not child.is_dir() or not manifest_file.exists(): - continue try: + if not child.is_dir() or not manifest_file.exists(): + continue 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) plugins.append(_dashboard_plugin_entry(data, name, child / "dashboard", source)) + except OSError as exc: + _log.warning("Skipping unreadable dashboard plugin %s: %s", manifest_file, exc) + continue except Exception as exc: _log.warning("Bad dashboard plugin manifest %s: %s", manifest_file, exc) continue diff --git a/tests/hermes_cli/test_web_server.py b/tests/hermes_cli/test_web_server.py index 74bddd8d26..f035b483b2 100644 --- a/tests/hermes_cli/test_web_server.py +++ b/tests/hermes_cli/test_web_server.py @@ -4540,6 +4540,51 @@ class TestDashboardPluginManifestExtensions: assert len(entries) == 1 assert entries[0]["tab"]["path"] == "/from-profile" + def test_unreadable_plugin_paths_do_not_block_discovery(self, tmp_path, monkeypatch, caplog): + """A denied plugin directory or manifest must not prevent valid plugins loading.""" + from pathlib import Path + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + self._write_plugin(tmp_path, "valid", { + "name": "valid", + "label": "Valid Plugin", + "entry": "dist/index.js", + }) + denied_root = tmp_path / "denied-root" + denied_root.mkdir() + denied_plugin = tmp_path / "plugins" / "denied" + (denied_plugin / "dashboard").mkdir(parents=True) + (denied_plugin / "dashboard" / "manifest.json").write_text("{}", encoding="utf-8") + + from hermes_cli import web_server_dashboard + original_search_dirs = web_server_dashboard._dashboard_plugin_search_dirs + original_scandir = web_server_dashboard.os.scandir + original_exists = Path.exists + + def search_dirs(): + return [(denied_root, "user"), *original_search_dirs()] + + def guarded_scandir(path): + if Path(path) == denied_root: + raise PermissionError("[WinError 5] Access is denied") + return original_scandir(path) + + def guarded_exists(path): + if path == denied_plugin / "dashboard" / "manifest.json": + raise PermissionError("[WinError 5] Access is denied") + return original_exists(path) + + monkeypatch.setattr(web_server_dashboard, "_dashboard_plugin_search_dirs", search_dirs) + monkeypatch.setattr(web_server_dashboard.os, "scandir", guarded_scandir) + monkeypatch.setattr(Path, "exists", guarded_exists) + + plugins = web_server_dashboard._discover_dashboard_plugins() + + assert "valid" in {plugin["name"] for plugin in plugins} + assert "denied" not in {plugin["name"] for plugin in plugins} + assert "Skipping unreadable dashboard plugin root" in caplog.text + assert "Skipping unreadable dashboard plugin" in caplog.text +