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.
This commit is contained in:
teknium1
2026-10-04 05:10:39 -07:00
committed by Teknium
parent d62fbea7e1
commit 48de727da5
3 changed files with 54 additions and 9 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]