Activation reaches plugin discovery before the application dependencies exist. Give PM its own locked Python project and runtime so it can install or repair the application without importing that dependency tree. Keep PM outside the application workspace. A shared uv workspace resolves the application graph and cannot provide this isolation. Route mutations through an isolated worker and preserve transaction callbacks, cancellation, custom package registrations, and correlated receipts. Use the same runtime builder for source installs and packaged payloads. Keep offline wheelhouse support in that builder. Nix builds the independent PM lock as a separate derivation. Refuse lazy-disabled bootstrap before installing tools or dependencies. Move first-party YAML readers and writers to ruamel. Keep the application lock's transitive PyYAML requirements for third-party packages. Verification: - Focused canonical Python suite: 177 passed, 1 host-gated skip. - Electron backend probes: 12 passed. Electron typecheck passed. - Both uv locks, scoped lint, Bash syntax, and whitespace checks passed. - Cold activation, corrupt-app repair, offline staging, and relocation ran. - Built and exercised the Nix PM runtime and standalone YAML merge script. Six broader caller test files retain the same 24 failing test IDs as an archive of HEAD. The existing real-home guard blocks those tests before they can exercise the affected paths. No full-suite pass is claimed. Native Windows signing and full Bionic package execution remain unverified.
243 lines
11 KiB
Python
243 lines
11 KiB
Python
"""Tests for nested/alias-normalized enable & disable flows.
|
|
|
|
Companion to test_plugins_cmd_category_discovery.py. That file covers the
|
|
*listing* side of nested category plugins (issue #41066). These tests cover
|
|
the *mutation* side: `hermes plugins enable/disable` must resolve a bare name
|
|
OR a full path-derived key (e.g. `observability/trace_sink`) to the canonical
|
|
registry key and write THAT — the same string PluginManager gates on — so a
|
|
nested bundled plugin can actually be toggled.
|
|
"""
|
|
|
|
import sys # noqa: F401
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _capture_selection(monkeypatch):
|
|
import hermes_cli.plugins_cmd as pc
|
|
from hermes_cli import plugins_admission
|
|
def save(enabled, disabled, **kwargs):
|
|
pc._save_enabled_set(enabled)
|
|
pc._save_disabled_set(disabled)
|
|
monkeypatch.setattr(plugins_admission, "admit_plugin_set_change", save)
|
|
|
|
|
|
def _make_plugin_dir(parent: Path, name: str, manifest: dict) -> Path:
|
|
d = parent / name
|
|
d.mkdir(parents=True, exist_ok=True)
|
|
import hermes_yaml as yaml
|
|
(d / "plugin.yaml").write_text(yaml.safe_dump(manifest), encoding="utf-8")
|
|
(d / "__init__.py").write_text("def register(ctx): pass\n", encoding="utf-8")
|
|
return d
|
|
|
|
|
|
def _make_category_plugin(parent: Path, category: str, name: str, manifest: dict) -> Path:
|
|
return _make_plugin_dir(parent / category, name, manifest)
|
|
|
|
|
|
@pytest.fixture
|
|
def nested_plugin_env(tmp_path):
|
|
"""A user-plugins dir containing one nested and one flat plugin, with the
|
|
bundled dir pointed at an empty path. Returns the tmp_path."""
|
|
_make_category_plugin(tmp_path, "observability", "trace_sink", {
|
|
"name": "trace_sink", "version": "1.0.0", "description": "trace sink"
|
|
})
|
|
_make_plugin_dir(tmp_path, "disk-cleanup", {
|
|
"name": "disk-cleanup", "version": "1.0.0"
|
|
})
|
|
return tmp_path
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _resolve_plugin_key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestResolvePluginKey:
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_full_key_resolves_to_itself(self, mock_user, mock_bundled, nested_plugin_env):
|
|
from hermes_cli.plugins_cmd import _resolve_plugin_key
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
assert _resolve_plugin_key("observability/trace_sink") == "observability/trace_sink"
|
|
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_unknown_returns_none(self, mock_user, mock_bundled, nested_plugin_env):
|
|
from hermes_cli.plugins_cmd import _resolve_plugin_key
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
assert _resolve_plugin_key("does-not-exist") is None
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_ambiguous_leaf_name_returns_none(self, mock_user, mock_bundled, tmp_path):
|
|
"""Same leaf name under two categories must NOT silently pick one."""
|
|
from hermes_cli.plugins_cmd import _resolve_plugin_key
|
|
_make_category_plugin(tmp_path, "image_gen", "openai", {"name": "image-gen-openai"})
|
|
_make_category_plugin(tmp_path, "model-providers", "openai", {"name": "mp-openai"})
|
|
mock_user.return_value = tmp_path
|
|
mock_bundled.return_value = tmp_path / "nonexistent"
|
|
# Bare "openai" is ambiguous -> None; the full key still resolves.
|
|
assert _resolve_plugin_key("openai") is None
|
|
assert _resolve_plugin_key("image_gen/openai") == "image_gen/openai"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_enable / cmd_disable — write the canonical key
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEnableDisableNested:
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._save_disabled_set")
|
|
@patch("hermes_cli.plugins_cmd._save_enabled_set")
|
|
@patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set())
|
|
@patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set())
|
|
def test_enable_bare_name_writes_key(
|
|
self, mock_en, mock_dis, mock_save_en, mock_save_dis,
|
|
mock_user, mock_bundled, nested_plugin_env,
|
|
):
|
|
from hermes_cli.plugins_cmd import cmd_enable
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
|
|
cmd_enable("trace_sink", allow_tool_override=False) # bare name
|
|
|
|
saved = mock_save_en.call_args[0][0]
|
|
# The canonical key — NOT the bare name — must be persisted, because
|
|
# that is what PluginManager matches when deciding to load.
|
|
assert "observability/trace_sink" in saved
|
|
assert "trace_sink" not in saved or "observability/trace_sink" in saved
|
|
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
def test_enable_unknown_plugin_exits(self, mock_user, mock_bundled, nested_plugin_env):
|
|
from hermes_cli.plugins_cmd import cmd_enable
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
with pytest.raises(SystemExit):
|
|
cmd_enable("does-not-exist")
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._save_disabled_set")
|
|
@patch("hermes_cli.plugins_cmd._save_enabled_set")
|
|
@patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set())
|
|
@patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set())
|
|
def test_enable_flat_plugin_unchanged(
|
|
self, mock_en, mock_dis, mock_save_en, mock_save_dis,
|
|
mock_user, mock_bundled, nested_plugin_env,
|
|
):
|
|
"""Flat plugins keep writing their bare name (key == name) — no regression."""
|
|
from hermes_cli.plugins_cmd import cmd_enable
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
|
|
cmd_enable("disk-cleanup", allow_tool_override=False)
|
|
saved = mock_save_en.call_args[0][0]
|
|
assert "disk-cleanup" in saved
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# cmd_enable — built-in tool override consent (issue #29249)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestEnableToolOverrideConsent:
|
|
"""Enabling a non-bundled plugin must surface a consent decision about the
|
|
privileged ``allow_tool_override`` capability, and persist the operator's
|
|
choice under ``plugins.entries.<key>.allow_tool_override``."""
|
|
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._set_plugin_entry_flag")
|
|
@patch("hermes_cli.plugins_cmd._save_disabled_set")
|
|
@patch("hermes_cli.plugins_cmd._save_enabled_set")
|
|
@patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set())
|
|
@patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set())
|
|
def test_interactive_eof_defaults_to_deny(
|
|
self, mock_en, mock_dis, mock_save_en, mock_save_dis, mock_set_flag,
|
|
mock_user, mock_bundled, nested_plugin_env,
|
|
):
|
|
"""Non-interactive stdin (EOFError) must fail closed to deny."""
|
|
from hermes_cli.plugins_cmd import cmd_enable
|
|
mock_user.return_value = nested_plugin_env
|
|
mock_bundled.return_value = nested_plugin_env / "nonexistent"
|
|
|
|
with patch("rich.console.Console.input", side_effect=EOFError):
|
|
cmd_enable("disk-cleanup")
|
|
|
|
mock_set_flag.assert_called_once_with(
|
|
"disk-cleanup", "allow_tool_override", False
|
|
)
|
|
|
|
@patch("hermes_cli.plugins.get_bundled_plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
|
@patch("hermes_cli.plugins_cmd._set_plugin_entry_flag")
|
|
@patch("hermes_cli.plugins_cmd._save_disabled_set")
|
|
@patch("hermes_cli.plugins_cmd._save_enabled_set")
|
|
@patch("hermes_cli.plugins_cmd._get_disabled_set", return_value=set())
|
|
@patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set())
|
|
def test_bundled_plugin_never_prompts_or_writes_entry(
|
|
self, mock_en, mock_dis, mock_save_en, mock_save_dis, mock_set_flag,
|
|
mock_user, mock_bundled, tmp_path,
|
|
):
|
|
"""Bundled plugins are trusted — no consent prompt, no entry write."""
|
|
from hermes_cli.plugins_cmd import cmd_enable
|
|
# Bundled dir holds the plugin; user dir is empty.
|
|
_make_plugin_dir(tmp_path / "bundled", "trusted_bundled", {
|
|
"name": "trusted_bundled", "version": "1.0.0",
|
|
})
|
|
mock_user.return_value = tmp_path / "empty"
|
|
mock_bundled.return_value = tmp_path / "bundled"
|
|
|
|
# Console.input would raise if called — proving no prompt fired.
|
|
with patch("rich.console.Console.input", side_effect=AssertionError("prompted")):
|
|
cmd_enable("trusted_bundled")
|
|
|
|
mock_set_flag.assert_not_called()
|
|
|
|
|
|
class TestCompositeMenuWritesCanonicalKey:
|
|
"""#40190 follow-up: the interactive `hermes plugins` menu must persist
|
|
the CANONICAL KEY (``web/firecrawl``), never the bare manifest name
|
|
(``web-firecrawl``), so its disabled-list entries stay aligned with what
|
|
``cmd_enable`` clears and what PluginManager gates on. Writing the bare
|
|
name is what silently vetoed a bundled backend forever (pi314).
|
|
"""
|
|
|
|
@patch("hermes_cli.plugins_cmd._save_disabled_set")
|
|
@patch("hermes_cli.plugins_cmd._save_enabled_set")
|
|
@patch("hermes_cli.plugins_cmd._get_enabled_set", return_value=set())
|
|
def test_fallback_unchecked_plugin_disables_by_key_not_name(
|
|
self, mock_en, mock_save_en, mock_save_dis,
|
|
):
|
|
from hermes_cli.plugins_cmd import _run_composite_fallback
|
|
from rich.console import Console
|
|
|
|
# key differs from the manifest name, mirroring web/firecrawl.
|
|
plugin_keys = ["web/firecrawl"]
|
|
plugin_labels = ["web-firecrawl — firecrawl [bundled]"]
|
|
plugin_selected = set() # unchecked → should be disabled
|
|
|
|
# First input() toggles nothing (blank Enter confirms immediately),
|
|
# second (category prompt) is skipped with blank Enter.
|
|
with patch("builtins.input", return_value=""):
|
|
_run_composite_fallback(
|
|
plugin_keys, plugin_labels, plugin_selected,
|
|
set(), [], Console(),
|
|
)
|
|
|
|
saved_dis = mock_save_dis.call_args[0][0]
|
|
assert "web/firecrawl" in saved_dis # canonical key persisted
|
|
assert "web-firecrawl" not in saved_dis # never the bare name
|