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
This commit is contained in:
@@ -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
|
||||
|
||||
56
tests/hermes_cli/test_plugin_scan_dunder_dirs.py
Normal file
56
tests/hermes_cli/test_plugin_scan_dunder_dirs.py
Normal file
@@ -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"]
|
||||
Reference in New Issue
Block a user