fix(plugins): one unreadable plugin child no longer aborts iter_plugin_dirs or memory-provider discovery

iter_plugin_dirs stat'd <child>/__init__.py inside every plugin dir, so a
single mode-000 / ACL-denied $HERMES_HOME/plugins/<x> still raised
PermissionError out of the loader, memory-provider discovery (dashboard
memory settings, hermes memory setup, plugins memory picker) and the
user cron-provider scan. Catch OSError per child and log the same
'Skipping unreadable plugin directory' warning the list path already emits.

Part of #111804
This commit is contained in:
teknium1
2026-09-15 14:12:43 -07:00
committed by Teknium
parent b915405f92
commit e3a90079b4
4 changed files with 70 additions and 5 deletions
@@ -219,3 +219,27 @@ def test_activation_is_not_gated_on_plugins_enabled(tmp_path, monkeypatch):
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
assert memory_plugins.load_memory_provider("gatedmem") is not None
def test_unreadable_user_plugin_does_not_abort_memory_discovery(tmp_path, monkeypatch):
"""One mode-000 / ACL-denied ``$HERMES_HOME/plugins/<x>`` must not hide the bundled
providers or its readable siblings from the dashboard / ``hermes memory`` pickers (#111804)."""
plugins_root = tmp_path / "plugins"
_write_provider_dir(plugins_root, "goodmem")
denied = plugins_root / "denied"
denied.mkdir()
(denied / "__init__.py").write_text("", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
real_stat = Path.stat # chmod 000 does not bite as root; fail the child's stat instead
def stat(self, *args, **kwargs):
if self.parent == denied:
raise PermissionError(13, "Permission denied", str(self))
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", stat)
names = memory_plugins.list_memory_provider_names()
assert "goodmem" in names
assert "denied" not in names
assert memory_plugins.find_provider_dir("denied") is None
@@ -0,0 +1,30 @@
"""One mode-000 / ACL-denied child under a plugin root must not abort the listing (#111804)."""
from __future__ import annotations
from pathlib import Path
from plugins import plugin_loader
def _deny(monkeypatch, denied: Path) -> None:
"""chmod 000 does not bite as root, so fail the stat of the denied child's ``__init__.py``."""
real_stat = Path.stat
def stat(self, *args, **kwargs):
if self.parent == denied:
raise PermissionError(13, "Permission denied", str(self))
return real_stat(self, *args, **kwargs)
monkeypatch.setattr(Path, "stat", stat)
def test_iter_plugin_dirs_skips_unreadable_child(tmp_path, monkeypatch, caplog):
for name in ("denied", "good"):
(tmp_path / name).mkdir()
(tmp_path / name / "__init__.py").write_text("", encoding="utf-8")
_deny(monkeypatch, tmp_path / "denied")
with caplog.at_level("WARNING", logger="plugins.plugin_loader"):
assert plugin_loader.iter_plugin_dirs(tmp_path) == [tmp_path / "good"]
assert "Skipping unreadable plugin directory" in caplog.text