From f8dfa68fb39b21e503e78eeb55358705df84d35b Mon Sep 17 00:00:00 2001 From: Christopher <210261288+Christopher-Schulze@users.noreply.github.com> Date: Sat, 15 Aug 2026 15:23:25 +0200 Subject: [PATCH] fix(plugins): skip dunder dirs and contain plugin-scan OSErrors Walking __pycache__ and other dunder children let an unreadable directory raise out of plugin discovery and fail unrelated tool calls. Skip those names and treat iterdir/is_dir OSError as an empty scan for that node. Fixes #86996 --- hermes_cli/plugins_discovery.py | 12 +++- .../test_plugin_scan_dunder_dirs.py | 56 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) create mode 100644 tests/hermes_cli/test_plugin_scan_dunder_dirs.py diff --git a/hermes_cli/plugins_discovery.py b/hermes_cli/plugins_discovery.py index de17dec580..8c2a8451dd 100644 --- a/hermes_cli/plugins_discovery.py +++ b/hermes_cli/plugins_discovery.py @@ -109,7 +109,17 @@ def scan_directory( manifests: List[PluginManifest] = [] if not path.is_dir(): return manifests - for child in sorted(path.iterdir()): + try: + children = sorted(path.iterdir()) + except OSError as exc: + logger.warning("Failed to scan plugin directory %s: %s", path, exc) + return manifests + for child in children: + # Cache/dunder dirs (__pycache__, __MACOSX__, …) are never + # plugins. Walking them can raise PermissionError and take + # down every subsequent tool call (#86996). + if child.name.startswith("__") and child.name.endswith("__"): + continue try: if not child.is_dir() or (depth == 0 and skip_names and child.name in skip_names): continue diff --git a/tests/hermes_cli/test_plugin_scan_dunder_dirs.py b/tests/hermes_cli/test_plugin_scan_dunder_dirs.py new file mode 100644 index 0000000000..f409a4be18 --- /dev/null +++ b/tests/hermes_cli/test_plugin_scan_dunder_dirs.py @@ -0,0 +1,56 @@ +"""Plugin directory scans must skip dunder dirs and survive OSError (#86996).""" + +from __future__ import annotations + +from pathlib import Path + +from hermes_cli.plugins import PluginManager + + +def test_scan_skips_dunder_dirs_and_still_loads_real_plugin(tmp_path: Path): + root = tmp_path / "plugins" + demo = root / "demo" + demo.mkdir(parents=True) + (demo / "plugin.yaml").write_text("name: demo\nversion: 0.1.0\ndescription: x\n") + (demo / "__pycache__").mkdir() + pycache = root / "__pycache__" + pycache.mkdir() + pycache.chmod(0o000) + try: + found = PluginManager()._scan_directory(root, "user") + finally: + pycache.chmod(0o700) + + assert [manifest.name for manifest in found] == ["demo"] + + +def test_scan_survives_iterdir_oserror(tmp_path: Path, monkeypatch): + root = tmp_path / "plugins" + root.mkdir() + + def _boom(_self): + raise PermissionError("unreadable plugin root") + + monkeypatch.setattr(Path, "iterdir", _boom, raising=False) + found = PluginManager()._scan_directory(root, "user") + assert found == [] + + +def test_scan_skips_child_whose_is_dir_raises(tmp_path: Path, monkeypatch): + root = tmp_path / "plugins" + demo = root / "demo" + demo.mkdir(parents=True) + (demo / "plugin.yaml").write_text("name: demo\nversion: 0.1.0\ndescription: x\n") + spooky = root / "spooky" + spooky.mkdir() + + original_is_dir = Path.is_dir + + def _is_dir(self): + if self.name == "spooky": + raise PermissionError("stat failed") + return original_is_dir(self) + + monkeypatch.setattr(Path, "is_dir", _is_dir) + found = PluginManager()._scan_directory(root, "user") + assert [manifest.name for manifest in found] == ["demo"]