Files
hermes-agent/tests/hermes_cli/test_plugin_dependency_consent.py
Teknium 23a1ae3f37 test(plugins): mark the imported admission_env fixture as an allowed F811
The rebased file trips the pre-push code-health F811 rule: pytest injects the
fixture imported from tests.pm by parameter name, which ruff reads as a
redefinition. Reviewed allowance on the line, same remedy the rule documents.
2026-10-05 16:06:32 -07:00

164 lines
9.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_dashboard_install_publishes_a_plugin_the_config_already_selects(admission_env, monkeypatch): # health: allow F811 -- pytest injects the imported admission_env fixture by parameter name
"""The Desktop/dashboard Install click on a plugin memory.provider already names (a provider that
left core, a synced config, a re-install after remove) publishes like any fresh install instead of
the non-interactive 'Install declined' that a retry can never get past. A forced replacement of an
installed plugin keeps the veto: that is the reinstall the click did not review."""
root, home = admission_env
source = root / 'mem-source'
source.mkdir()
(source / 'plugin.yaml').write_text(yaml.safe_dump({'name': 'mem-twin'}), encoding='utf-8')
(source / '__init__.py').write_text('class Twin(MemoryProvider): ...\n', encoding='utf-8')
(source / 'pyproject.toml').write_text(
'[project]\nname="mem-twin"\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)
config = yaml.safe_load((home / 'config.yaml').read_text(encoding='utf-8'))
config['memory'] = {'provider': 'mem-twin'}
(home / 'config.yaml').write_text(yaml.safe_dump(config), encoding='utf-8')
monkeypatch.setattr(plugins_cmd.sys.stdin, 'isatty', lambda: False)
result = plugins_cmd.dashboard_install_plugin(source.as_uri(), force=False, enable=True)
assert result['ok'], result
assert (home / 'plugins' / 'mem-twin' / 'plugin.yaml').is_file()
again = plugins_cmd.dashboard_install_plugin(source.as_uri(), force=True, enable=True)
assert not again['ok'] and 'Reinstall declined' in again['error']
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'])