From 48de727da5607856cd03edfc020557dfc1d239bf Mon Sep 17 00:00:00 2001 From: teknium1 <127238744+teknium1@users.noreply.github.com> Date: Sun, 4 Oct 2026 05:10:39 -0700 Subject: [PATCH] fix(plugins): --install-deps probe imports from the synced environment hermes plugins validate --install-deps synced the plugin's declared dependencies into a newly committed environment, then ran the capability probe with sys.executable, the pre-sync interpreter. Plugins importing a non-core package at load time (evalroute: pyyaml, gone from core since the ruamel move) failed the catalog gate with a masked ImportError. Run the probe with the committed environment's interpreter and activation env. A developer venv without a committed environment keeps the old path. --- hermes_cli/plugin_validate.py | 25 ++++++++++++++------- hermes_cli/plugins_cmd_catalog.py | 10 ++++++++- tests/hermes_cli/test_plugin_validate.py | 28 ++++++++++++++++++++++++ 3 files changed, 54 insertions(+), 9 deletions(-) diff --git a/hermes_cli/plugin_validate.py b/hermes_cli/plugin_validate.py index 131e82f991..3bd52fcfad 100644 --- a/hermes_cli/plugin_validate.py +++ b/hermes_cli/plugin_validate.py @@ -324,20 +324,25 @@ def _probe_options(manifest: dict) -> dict: } -def _run_capability_probe(plugin_dir: Path, manifest: dict) -> Tuple[Optional[dict], str]: +def _run_capability_probe( + plugin_dir: Path, manifest: dict, probe: Optional[Tuple[Path, Dict[str, str]]] = None, +) -> Tuple[Optional[dict], str]: """Run the recording probe in a scratch subprocess. + *probe* is ``(interpreter, env)`` of the dependency environment to import the plugin from; + None probes this interpreter. + Returns ``(recorded, error)`` — exactly one is meaningful: *recorded* is the ``{tools, hooks, middleware, commands, providers}`` dict on success, and *error* is a human-readable failure description otherwise. """ with tempfile.TemporaryDirectory(prefix="hermes-validate-") as scratch: - env = dict(os.environ) + env = dict(probe[1] if probe else os.environ) env["HERMES_HOME"] = scratch try: result = subprocess.run( [ - sys.executable, + str(probe[0]) if probe else sys.executable, "-c", _PROBE_SCRIPT, str(plugin_dir), @@ -379,7 +384,8 @@ def _declared_list(manifest: dict, key: str) -> List[str]: def _check_capabilities( - report: ValidationReport, manifest: dict, plugin_dir: Path + report: ValidationReport, manifest: dict, plugin_dir: Path, + probe: Optional[Tuple[Path, Dict[str, str]]] = None, ) -> Optional[dict]: """Probe actual registrations and diff against declared capabilities. @@ -393,7 +399,7 @@ def _check_capabilities( report.add("capability probe", True, "skipped (no __init__.py)") return None - recorded, error = _run_capability_probe(plugin_dir, manifest) + recorded, error = _run_capability_probe(plugin_dir, manifest, probe) if recorded is None: report.add("capability probe", False, error) return None @@ -472,8 +478,11 @@ def _check_builtin_collisions( # ─── Entry point ───────────────────────────────────────────────────────────── -def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: - """Run every admission check against *plugin_dir* and return the report.""" +def validate_plugin_dir( + plugin_dir: Path, probe: Optional[Tuple[Path, Dict[str, str]]] = None, +) -> ValidationReport: + """Run every admission check against *plugin_dir* and return the report. *probe* is + ``(interpreter, env)`` for the capability probe (see ``_run_capability_probe``).""" report = ValidationReport() plugin_dir = Path(plugin_dir) @@ -520,7 +529,7 @@ def validate_plugin_dir(plugin_dir: Path) -> ValidationReport: _check_requires_env(report, manifest) _check_loadable(report, plugin_dir, manifest) _check_python_dependencies(report, plugin_dir) - recorded = _check_capabilities(report, manifest, plugin_dir) + recorded = _check_capabilities(report, manifest, plugin_dir, probe) _check_builtin_collisions(report, manifest, recorded) _check_trusted_inbound(report, recorded) _check_security_scan(report, plugin_dir) diff --git a/hermes_cli/plugins_cmd_catalog.py b/hermes_cli/plugins_cmd_catalog.py index eb22269414..2e1269a674 100644 --- a/hermes_cli/plugins_cmd_catalog.py +++ b/hermes_cli/plugins_cmd_catalog.py @@ -770,14 +770,22 @@ def cmd_validate(path: str, as_json: bool = False, install_deps: bool = False) - installs the declared Python deps first so the capability probe imports what an install would.""" from hermes_cli.plugin_validate import validate_plugin_dir from hermes_cli.plugins_cmd import _console + probe = None if install_deps: import pm + from pm import paths + from pm.environments import activation_environment, project_python from pm.plugin_inputs import Candidates try: pm.sync_venv(plugins=Candidates([Path(path)])) + # The sync commits a NEW environment that this running process never switches to, + # so the probe must import the plugin from that environment's interpreter. + root = paths.repo_root() + if project_python(root).is_file(): # a developer venv has no committed environment + probe = (project_python(root), activation_environment(root)) except Exception as exc: # validation still runs; the probe reports what is missing print(f"dependency preparation failed: {exc}", file=sys.stderr) - report = validate_plugin_dir(Path(path)) + report = validate_plugin_dir(Path(path), probe) if as_json: print(json.dumps(report.to_dict(), indent=2)) sys.exit(report.exit_code) diff --git a/tests/hermes_cli/test_plugin_validate.py b/tests/hermes_cli/test_plugin_validate.py index 0d374bade2..dbe182d92f 100644 --- a/tests/hermes_cli/test_plugin_validate.py +++ b/tests/hermes_cli/test_plugin_validate.py @@ -450,3 +450,31 @@ def test_core_override_through_a_method_patch_helper(tmp_path): from hermes_cli.plugin_validate_core_override import core_override_findings assert core_override_findings(bad) == ["bind(auxiliary_client, ...) (__init__.py:6)"] + + +def test_install_deps_probe_imports_from_the_synced_environment(tmp_path: Path, monkeypatch, capsys) -> None: + """`--install-deps` commits a new dependency environment this process never switches to; + the probe must import the plugin from that environment, not the validator's own.""" + import os + import sys + + import pm + import pm.environments as environments + from hermes_cli.plugins_cmd_catalog import cmd_validate + + deps = tmp_path / "synced-site-packages" + deps.mkdir() + (deps / "probe_only_dep.py").write_text("VALUE = 1\n", encoding="utf-8") + plugin = _make_plugin(tmp_path, manifest={"name": "needs-dep", "version": "1.0.0", "description": "d"}, + init_py="import probe_only_dep\n\ndef register(ctx):\n pass\n") + synced_env = {**os.environ, "PYTHONPATH": os.pathsep.join([str(Path(__file__).resolve().parents[2]), str(deps)])} + monkeypatch.setattr(pm, "sync_venv", lambda **_kw: None) + monkeypatch.setattr(environments, "project_python", lambda _root: Path(sys.executable)) + monkeypatch.setattr(environments, "activation_environment", lambda _root: synced_env) + + try: + cmd_validate(str(plugin), as_json=True, install_deps=True) + except SystemExit: + pass + checks = {c["name"]: c for c in json.loads(capsys.readouterr().out)["checks"]} + assert checks["capability probe"]["ok"], checks["capability probe"]["detail"]