Follow-up to the salvaged manifest guard (#90859): the doctor's temporary HERMES_HOME now enters the ExitStack before the staging copytree, so ENOSPC / KeyboardInterrupt / any exception during staging deterministically removes the hermes-plugin-doctor-* directory instead of relying on the TemporaryDirectory GC finalizer (which cannot run while the traceback pins the frame). Regression test proven via sabotage run against the old code.
262 lines
8.4 KiB
Python
262 lines
8.4 KiB
Python
from __future__ import annotations
|
|
|
|
import argparse
|
|
|
|
import pytest
|
|
from pathlib import Path
|
|
|
|
from hermes_cli.subcommands.plugins import build_plugins_parser
|
|
|
|
|
|
def _parse_plugins_args(*argv: str):
|
|
parser = argparse.ArgumentParser()
|
|
subparsers = parser.add_subparsers(dest="command")
|
|
build_plugins_parser(subparsers, cmd_plugins=lambda args: None)
|
|
return parser.parse_args(["plugins", *argv])
|
|
|
|
|
|
def test_plugins_parser_exposes_doctor() -> None:
|
|
doctor = _parse_plugins_args("doctor", "sample", "--ci")
|
|
|
|
assert (doctor.plugins_action, doctor.target, doctor.ci) == (
|
|
"doctor",
|
|
"sample",
|
|
True,
|
|
)
|
|
|
|
|
|
def test_doctor_uses_registration_to_reject_bad_hook_and_callback_signature(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
from hermes_cli.plugin_dev import doctor_plugin
|
|
|
|
plugin = tmp_path / "bad-plugin"
|
|
plugin.mkdir()
|
|
(plugin / "plugin.yaml").write_text(
|
|
"\n".join(
|
|
[
|
|
"name: bad-plugin",
|
|
"version: 0.1.0",
|
|
"description: broken contract",
|
|
"provides_hooks:",
|
|
" - typo_hook",
|
|
" - pre_tool_call",
|
|
]
|
|
)
|
|
+ "\n",
|
|
encoding="utf-8",
|
|
)
|
|
(plugin / "__init__.py").write_text(
|
|
"def callback(tool_name):\n"
|
|
" return None\n\n"
|
|
"def register(ctx):\n"
|
|
" ctx.register_hook('typo_hook', callback)\n"
|
|
" ctx.register_hook('pre_tool_call', callback)\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = doctor_plugin(plugin)
|
|
messages = "\n".join(f.message for f in report.findings)
|
|
assert report.ok is False
|
|
assert "unknown hook 'typo_hook'" in messages
|
|
assert "must accept **kwargs" in messages
|
|
|
|
|
|
def test_doctor_accepts_manifest_defaults_from_runtime_parser(tmp_path: Path) -> None:
|
|
from hermes_cli.plugin_dev import doctor_plugin
|
|
|
|
plugin = tmp_path / "minimal"
|
|
plugin.mkdir()
|
|
(plugin / "plugin.yaml").write_text("name: minimal\n", encoding="utf-8")
|
|
(plugin / "__init__.py").write_text(
|
|
"def register(ctx):\n pass\n", encoding="utf-8"
|
|
)
|
|
|
|
report = doctor_plugin(plugin)
|
|
assert report.ok, report.format_text()
|
|
assert report.manifest is not None
|
|
assert report.manifest.kind == "standalone"
|
|
|
|
|
|
def test_doctor_restores_global_tool_policy_and_module_state(tmp_path: Path) -> None:
|
|
import sys
|
|
|
|
from hermes_cli.plugin_dev import doctor_plugin
|
|
from tools.registry import registry
|
|
|
|
target = tmp_path / "cleanup-plugin"
|
|
target.mkdir()
|
|
(target / "plugin.yaml").write_text(
|
|
"name: cleanup-plugin\nprovides_tools: [cleanup_plugin_ping]\n",
|
|
encoding="utf-8",
|
|
)
|
|
(target / "__init__.py").write_text(
|
|
"import json\n\n"
|
|
"def ping(args, **kwargs):\n return json.dumps({'ok': True})\n\n"
|
|
"def register(ctx):\n"
|
|
" ctx.register_tool(name='cleanup_plugin_ping', toolset='cleanup', "
|
|
"schema={'name': 'cleanup_plugin_ping', 'description': 'test', "
|
|
"'parameters': {'type': 'object'}}, handler=ping)\n",
|
|
encoding="utf-8",
|
|
)
|
|
before_policy = dict(registry._plugin_override_policy)
|
|
before_modules = {
|
|
name
|
|
for name in sys.modules
|
|
if name == "hermes_plugins" or name.startswith("hermes_plugins.")
|
|
}
|
|
|
|
report = doctor_plugin(target)
|
|
|
|
assert report.ok, report.format_text()
|
|
assert report.registered_tools == ("cleanup_plugin_ping",)
|
|
assert registry.get_entry("cleanup_plugin_ping") is None
|
|
assert registry._plugin_override_policy == before_policy
|
|
after_modules = {
|
|
name
|
|
for name in sys.modules
|
|
if name == "hermes_plugins" or name.startswith("hermes_plugins.")
|
|
}
|
|
assert after_modules == before_modules
|
|
|
|
|
|
def test_doctor_blocks_live_network(tmp_path: Path) -> None:
|
|
from hermes_cli.plugin_dev import doctor_plugin
|
|
|
|
plugin = tmp_path / "network-plugin"
|
|
plugin.mkdir()
|
|
(plugin / "plugin.yaml").write_text("name: network-plugin\n", encoding="utf-8")
|
|
(plugin / "__init__.py").write_text(
|
|
"import socket\n\n"
|
|
"def register(ctx):\n"
|
|
" socket.create_connection(('example.com', 443))\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
report = doctor_plugin(plugin)
|
|
assert report.ok is False
|
|
assert "network access is disabled while Plugin Doctor runs" in report.format_text()
|
|
|
|
|
|
def test_resolve_rejects_directory_without_manifest(tmp_path: Path) -> None:
|
|
"""A non-plugin directory must not resolve — Doctor copies what it resolves."""
|
|
from hermes_cli.plugin_dev import resolve_plugin_path
|
|
|
|
not_a_plugin = tmp_path / "home"
|
|
(not_a_plugin / "Documents").mkdir(parents=True)
|
|
(not_a_plugin / "Documents" / "notes.txt").write_text("x", encoding="utf-8")
|
|
|
|
with pytest.raises(FileNotFoundError) as excinfo:
|
|
resolve_plugin_path(not_a_plugin)
|
|
|
|
assert "holds no plugin manifest" in str(excinfo.value)
|
|
|
|
|
|
def test_doctor_default_target_does_not_copy_cwd(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""``hermes plugins doctor`` with no argument defaults to ``.``.
|
|
|
|
Before the manifest guard, that copied the whole working directory into
|
|
a temporary HERMES_HOME — running it from ``$HOME`` copied the home
|
|
directory, cloud-storage placeholders included.
|
|
"""
|
|
import os
|
|
import shutil
|
|
|
|
from hermes_cli import plugin_dev
|
|
|
|
workdir = tmp_path / "workdir"
|
|
(workdir / "big").mkdir(parents=True)
|
|
(workdir / "big" / "payload.bin").write_text("x" * 1024, encoding="utf-8")
|
|
monkeypatch.chdir(workdir)
|
|
|
|
copied: list[tuple[str, str]] = []
|
|
real_copytree = shutil.copytree
|
|
|
|
def _tracking_copytree(src, dst, *args, **kwargs):
|
|
copied.append((os.fspath(src), os.fspath(dst)))
|
|
return real_copytree(src, dst, *args, **kwargs)
|
|
|
|
monkeypatch.setattr(plugin_dev.shutil, "copytree", _tracking_copytree)
|
|
|
|
report = plugin_dev.doctor_plugin()
|
|
|
|
assert report.ok is False
|
|
assert copied == []
|
|
|
|
|
|
def test_resolve_accepts_category_layout(tmp_path: Path) -> None:
|
|
"""A category directory holds no manifest itself but discovery finds one."""
|
|
from hermes_cli.plugin_dev import resolve_plugin_path
|
|
|
|
category = tmp_path / "image_gen"
|
|
plugin = category / "openai"
|
|
plugin.mkdir(parents=True)
|
|
(plugin / "plugin.yaml").write_text("name: openai\n", encoding="utf-8")
|
|
|
|
assert resolve_plugin_path(category) == category.resolve()
|
|
|
|
|
|
def test_resolve_prefers_installed_id_over_unrelated_local_dir(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A same-named local directory must not shadow the installed plugin."""
|
|
from hermes_cli import plugin_dev
|
|
|
|
hermes_home = tmp_path / "hermes-home"
|
|
installed = hermes_home / "plugins" / "sample"
|
|
installed.mkdir(parents=True)
|
|
(installed / "plugin.yaml").write_text("name: sample\n", encoding="utf-8")
|
|
|
|
workdir = tmp_path / "workdir"
|
|
(workdir / "sample").mkdir(parents=True)
|
|
monkeypatch.chdir(workdir)
|
|
monkeypatch.setattr(plugin_dev, "get_hermes_home", lambda: hermes_home)
|
|
|
|
assert plugin_dev.resolve_plugin_path("sample") == installed.resolve()
|
|
|
|
|
|
def test_doctor_removes_temp_home_when_staging_copy_fails(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A copy failure (e.g. ENOSPC) must not strand a hermes-plugin-doctor-* dir."""
|
|
import errno
|
|
import shutil
|
|
import tempfile
|
|
|
|
|
|
|
|
from hermes_cli import plugin_dev
|
|
|
|
plugin = tmp_path / "sample"
|
|
plugin.mkdir()
|
|
(plugin / "plugin.yaml").write_text("name: sample\n", encoding="utf-8")
|
|
(plugin / "__init__.py").write_text(
|
|
"def register(ctx):\n pass\n", encoding="utf-8"
|
|
)
|
|
|
|
scratch = tmp_path / "scratch-tmp"
|
|
scratch.mkdir()
|
|
monkeypatch.setattr(tempfile, "tempdir", str(scratch))
|
|
|
|
def _enospc(*_args, **_kwargs):
|
|
raise OSError(errno.ENOSPC, "No space left on device")
|
|
|
|
monkeypatch.setattr(shutil, "copytree", _enospc)
|
|
|
|
caught: OSError | None = None
|
|
try:
|
|
with plugin_dev._doctor_runtime(plugin):
|
|
pass # pragma: no cover - staging fails before yield
|
|
except OSError as exc:
|
|
caught = exc
|
|
|
|
assert caught is not None and caught.errno == errno.ENOSPC
|
|
# Check for leftovers WHILE the exception (and its traceback frames) is
|
|
# still referenced: the old code relied on TemporaryDirectory's GC
|
|
# finalizer, which cannot run while the traceback pins the frame — the
|
|
# exact window where a stranded hermes-plugin-doctor-* dir was observed.
|
|
leftovers = list(scratch.glob("hermes-plugin-doctor-*"))
|
|
assert leftovers == [], f"stranded doctor temp dirs: {leftovers}"
|