Files
hermes-agent/tests/hermes_cli/test_plugin_dependency_consent.py
liuhao1024 202bbbe6cf test(plugins): surface the admission refusal cause in the --yes-deps e2e test
An external verification run on Linux saw this test fail with a bare
`assert 'yes-deps-plugin' in []`: the install publishes into plugins/ and
a second PM admission transaction then enables it, and that admission
folds every exception into a refusal (exit 0, plugin installed but
disabled), so the assertion carried no clue about the environment-specific
cause. Verified locally that the mechanism reproduces the exact reported
shape: injecting a failure into the second sync_venv call (the enable
admission) yields the same bare failure while the first (publish) call
succeeds.

Carry the captured CLI output in the failure message so any machine that
reproduces this shows the `Enable ... refused: <cause>` line directly in
the pytest output.
2026-10-02 03:40:17 -07:00

137 lines
7.1 KiB
Python

"""Only declared Python dependencies require Python install consent."""
import json
import os
import subprocess
import pytest
import hermes_yaml as yaml
from hermes_cli import plugins_cmd
from tests.pm.test_plugin_survival_contract import admission_env # noqa: F401
@pytest.mark.parametrize('python_surface', [None, 'pyproject', 'legacy'])
def test_install_only_requests_relevant_python_consent(admission_env, monkeypatch, capsys, python_surface):
root, home = admission_env
source = root / 'plugin-source'
source.mkdir()
manifest = {'name': 'sidecar-plugin'}
if python_surface == 'legacy':
manifest['python_dependencies'] = ['never-install-without-consent==1']
(source / 'plugin.yaml').write_text(yaml.safe_dump(manifest), encoding='utf-8')
(source / '__init__.py').write_text('def register(ctx):\n pass\n', encoding='utf-8')
(source / 'package.json').write_text(json.dumps({'name': 'sidecar-plugin', 'version': '1.0.0'}), encoding='utf-8')
if python_surface == 'pyproject':
(source / 'pyproject.toml').write_text(
'[project]\nname="sidecar-plugin"\nversion="1.0"\nrequires-python=">=3.14"\n', encoding='utf-8')
env = {k: v for k, v in os.environ.items() if k not in {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'}}
for args in [('init', '-q'), ('add', '.'), ('-c', 'user.name=test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')]:
subprocess.run(['git', *args], cwd=source, env=env, check=True, capture_output=True, timeout=30)
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
config_path = home / 'config.yaml'
before = config_path.read_bytes()
plugins_cmd.cmd_install(source.as_uri(), enable=True, allow_removed=True)
installed = home / 'plugins' / 'sidecar-plugin'
assert (installed / 'package.json').is_file()
assert not (installed / 'node_modules').exists()
enabled = yaml.safe_load(config_path.read_text(encoding='utf-8'))['plugins']['enabled']
output = capsys.readouterr().out
if python_surface is None:
assert 'sidecar-plugin' in enabled
assert 'declares Python dependencies' not in output
else:
assert 'sidecar-plugin' not in enabled
assert config_path.read_bytes() == before
assert 'dependency install skipped (non-interactive)' in output
def test_node_sidecar_question_stays_independent(tmp_path, monkeypatch):
from pm import workspace
target = tmp_path / 'plugin'
target.mkdir()
(target / 'package.json').write_text('{}', encoding='utf-8')
prompts, installs, lines = [], [], []
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, 'isatty', lambda: True)
monkeypatch.setattr('builtins.input', lambda prompt: prompts.append(prompt) or 'yes')
monkeypatch.setattr(workspace, 'install_node_sidecar', lambda path, **kwargs: installs.append((path, kwargs)))
from types import SimpleNamespace
result = plugins_cmd._install_plugin_python_deps(
{'name': 'sidecar'}, target, SimpleNamespace(print=lambda *args, **kwargs: lines.extend(args)))
assert result == (True, None)
assert installs == [(target, {'explicit': True})]
assert len(prompts) == 1 and 'node_modules' in prompts[0]
assert not any('Python dependencies' in str(line) for line in lines)
def test_yes_deps_carries_consent_through_non_interactive_install(admission_env, monkeypatch, capsys):
"""--yes-deps (#122134): the flag is the user's own answer to the consent
question, so a non-interactive install publishes AND enables in one run
instead of being refused (the `enable` recovery cannot publish a plugin)."""
root, home = admission_env
source = root / 'plugin-source'
source.mkdir()
(source / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'yes-deps-plugin'}), encoding='utf-8')
(source / '__init__.py').write_text('def register(ctx):\n pass\n', encoding='utf-8')
(source / 'pyproject.toml').write_text(
'[project]\nname="yes-deps-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n', encoding='utf-8')
env = {k: v for k, v in os.environ.items() if k not in {'GIT_DIR', 'GIT_WORK_TREE', 'GIT_INDEX_FILE'}}
for args in [('init', '-q'), ('add', '.'), ('-c', 'user.name=test', '-c', 'user.email=test@example.invalid', 'commit', '-qm', 'fixture')]:
subprocess.run(['git', *args], cwd=source, env=env, check=True, capture_output=True, timeout=30)
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
plugins_cmd.cmd_install(source.as_uri(), enable=True, allow_removed=True, yes_deps=True)
enabled = yaml.safe_load((home / 'config.yaml').read_text(encoding='utf-8'))['plugins']['enabled']
output = capsys.readouterr().out
# The install publishes into plugins/ and THEN a second PM admission transaction
# enables it; that admission catches every exception as a refusal, so an
# environment-sensitive failure there leaves the plugin installed-but-disabled
# with exit 0. Carry the CLI output in the failure message so the refusal's
# cause is visible on any machine instead of a bare `in []`.
assert 'yes-deps-plugin' in enabled, (
'install completed but the plugin was not enabled; CLI output:\n' + output)
assert 'dependency install skipped (non-interactive)' not in output
def test_publish_assume_consent_skips_replacement_veto(tmp_path, monkeypatch):
"""The active-replacement veto at publication honors --yes-deps: with the
explicit answer the PM handoff runs; the default without it stays the
non-interactive refusal (#122134's 'Reinstall declined' half)."""
from hermes_cli import plugins_transaction
from pm import workspace as ws
staged, target = tmp_path / 'staged', tmp_path / 'target'
staged.mkdir()
target.mkdir()
(staged / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'active-plugin'}), encoding='utf-8')
(staged / 'pyproject.toml').write_text(
'[project]\nname="active-plugin"\nversion="1.0"\nrequires-python=">=3.11"\n', encoding='utf-8')
monkeypatch.setattr(ws, 'enabled_plugin_dirs', lambda **kwargs: [target])
syncs = []
monkeypatch.setattr('pm.client.sync_venv', lambda **kwargs: syncs.append(kwargs))
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
plugins_transaction.publish_plugin(
staged, target, {}, {'active-plugin': {}}, require_consent=True, assume_consent=True)
assert len(syncs) == 1
with pytest.raises(plugins_cmd.PluginOperationError, match='Reinstall declined'):
plugins_transaction.publish_plugin(staged, target, {}, {'active-plugin': {}}, require_consent=True)
def test_install_parser_offers_yes_deps_exclusive_with_no_deps():
import argparse
from hermes_cli.subcommands.plugins import build_plugins_parser
parser = argparse.ArgumentParser(prog='hermes')
build_plugins_parser(parser.add_subparsers(), cmd_plugins=lambda args: None)
ns = parser.parse_args(['plugins', 'install', 'x', '--yes-deps'])
assert ns.yes_deps is True
assert ns.no_deps is False
with pytest.raises(SystemExit):
parser.parse_args(['plugins', 'install', 'x', '--no-deps', '--yes-deps'])