Files
hermes-agent/tests/hermes_cli/test_plugins_cmd_enable_disable_nested.py
Victor Kyriazakos bfb8d3bd64 fix(plugins): picker preselects the effective state and persists only flipped rows
The bare `hermes plugins` picker preselected only rows listed in
plugins.enabled. Bundled platforms, backends and model providers are active
without a list entry, so they opened unticked, and the save on exit wrote every
unticked row into plugins.disabled: opening the picker and leaving without a
change disabled every messaging adapter on the next gateway restart.

Rows now open ticked by the load-time rule (_plugin_status), and only rows the
user flipped are written.

Ported onto the plugins_cmd_toggle sibling and the admission-authority save
(the original targeted the pre-split plugins_cmd.py). The nested
canonical-key composite test now ticks its row explicitly: it handed the menu
a checkbox state that contradicted the config and relied on the old
rebuild-everything save. The original helper-level tests are rewritten
against real admission in a follow-up commit.

(cherry picked from commit 3a0d5f1b8bce3b23b115ba4995b8c23dfbba9ad0)
2026-09-25 21:19:04 +05:30

139 lines
6.3 KiB
Python

"""Canonical keys and privilege consent through real admission."""
import shutil
import pytest
import hermes_yaml as yaml
from tests.hermes_cli.plugin_worker_support import (
plugin_world as plugin_world,
isolated_python as isolated_python,
)
@pytest.mark.parametrize("query", ["trace-leaf", "trace-manifest", "observability/trace-leaf"])
def test_nested_enable_disable_and_composite_use_canonical_key(plugin_world, monkeypatch, query):
from hermes_cli import plugins_cmd
from rich.console import Console
world = plugin_world
origin, _ = world.origin(name="trace-manifest")
key = "observability/trace-leaf"
target = world.home / "plugins" / key
shutil.copytree(origin, target)
(world.home / "config.yaml").write_text(yaml.safe_dump({"plugins": {
"enabled": [], "disabled": [key, "trace-leaf", "trace-manifest"]}}), encoding="utf-8")
world.command("enable", name=query, no_allow_tool_override=True)
assert world.enabled() == [key]
config = yaml.safe_load((world.home / "config.yaml").read_text())
assert config["plugins"]["disabled"] == []
world.imports(key)
world.command("disable", name=query)
assert world.enabled() == []
assert yaml.safe_load((world.home / "config.yaml").read_text())["plugins"]["disabled"] == [key]
# The fallback menu must persist canonical keys too, not labels: the row opens unticked (disabled)
# and the user ticks it.
answers = iter(("1", ""))
monkeypatch.setattr("builtins.input", lambda prompt: next(answers))
plugins_cmd._run_composite_fallback([key], ["trace-manifest"], set(), {key}, [], Console())
assert world.enabled() == [key]
world.imports(key)
def test_ambiguous_and_unknown_names_cannot_change_config(plugin_world):
from hermes_cli import plugins_cmd
world = plugin_world
for category in ("image_gen", "model-providers"):
directory = world.home / "plugins" / category / "same-leaf"
directory.mkdir(parents=True)
(directory / "plugin.yaml").write_text(f"name: {category}-fixture\n", encoding="utf-8")
before = (world.home / "config.yaml").read_bytes()
for query in ("same-leaf", "not-installed"):
with pytest.raises(SystemExit) as exc:
world.command("enable", name=query)
assert exc.value.code == 1
assert plugins_cmd._resolve_plugin_key("image_gen/same-leaf") == "image_gen/same-leaf"
assert (world.home / "config.yaml").read_bytes() == before
def test_dependency_free_enable_no_churn_and_tool_override_fails_closed(plugin_world, monkeypatch):
from hermes_cli import plugins_cmd
from pm import client, receipt
world = plugin_world
client.sync_venv(explicit=True)
selected = world.selected()
origin, sha = world.origin(surface=None)
facts = (world.home / "config.yaml").read_bytes()
world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True)
assert world.selected() == selected
assert (world.home / "config.yaml").read_bytes() == facts
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setattr(plugins_cmd.sys.stdout, "isatty", lambda: True)
def eof(*args, **kwargs):
raise EOFError
monkeypatch.setattr("rich.console.Console.input", eof)
world.command("enable", name="plugin-worker-proof")
config = yaml.safe_load((world.home / "config.yaml").read_text())
# Enabling is not a request for undeclared privileges (#64228): no grant is prompted for or
# written; only an explicit flag persists one.
assert "allow_tool_override" not in config.get("plugins", {}).get("entries", {}).get("plugin-worker-proof", {})
world.command("enable", name="plugin-worker-proof", no_allow_tool_override=True)
config = yaml.safe_load((world.home / "config.yaml").read_text())
assert config["plugins"]["entries"]["plugin-worker-proof"]["allow_tool_override"] is False
assert world.selected() == selected
assert receipt.latest()["venv_rebuild"]["ok"] is False
bundled = world.core / "plugins/trusted-fixture"
bundled.mkdir(parents=True)
(bundled / "plugin.yaml").write_text("name: trusted-fixture\n", encoding="utf-8")
monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: world.core / "plugins")
def unexpected(*args, **kwargs):
pytest.fail("bundled plugin requested privilege consent")
monkeypatch.setattr("rich.console.Console.input", unexpected)
world.command("enable", name="trusted-fixture")
config = yaml.safe_load((world.home / "config.yaml").read_text())
assert "trusted-fixture" not in config["plugins"]["entries"]
assert world.selected() == selected
@pytest.mark.parametrize("config_changes", [False, True])
def test_fallback_compares_the_preinteraction_selection(plugin_world, monkeypatch, config_changes):
from hermes_cli import plugins_cmd
from pm import receipt
world = plugin_world
origin, sha = world.origin()
world.command("install", identifier=origin.as_uri(), ref=sha, no_enable=True, allow_removed=True)
world.command("enable", name="plugin-worker-proof", no_allow_tool_override=True)
config_path = world.home / "config.yaml"
before = config_path.read_bytes()
selected = world.selected()
monkeypatch.setattr("hermes_cli.plugins.get_bundled_plugins_dir", lambda: world.core / "plugins")
monkeypatch.setattr(plugins_cmd, "_provider_categories", lambda: [])
monkeypatch.setattr(plugins_cmd.sys.stdin, "isatty", lambda: True)
monkeypatch.setitem(plugins_cmd.sys.modules, "curses", None)
answers = iter(("1", ""))
edited = before + b"model: edited-during-input\n"
def respond(_prompt):
answer = next(answers)
if config_changes and answer == "1":
config_path.write_bytes(edited)
return answer
monkeypatch.setattr("builtins.input", respond)
plugins_cmd.cmd_toggle()
latest = receipt.latest()
assert latest is not None
if config_changes:
assert config_path.read_bytes() == edited
assert world.enabled() == ["plugin-worker-proof"]
assert world.selected() == selected
assert latest["outcome"] == "failed"
world.imports()
else:
assert world.enabled() == []
assert yaml.safe_load(config_path.read_text())["plugins"]["disabled"] == ["plugin-worker-proof"]
assert latest["outcome"] == "ok"