Files
hermes-agent/tests/hermes_cli/test_plugins_cmd_activation_keys.py
teknium1 05df0382a5 fix(plugins): re-enabling a plugin restores its toolset in a saved platform_toolsets list
The Desktop/TUI/dashboard toggle edited the saved list before the plugin
loaded, so a backend that scanned while the plugin was off found no toolset
key and left the toolset out: the plugin showed enabled with its tools off.
Enable now edits the list after activation; disable resolves the key before
the disable is written. hermes plugins enable/disable make the same edit.
Only saved lists are touched; a fresh config still gets no list.
2026-10-08 15:15:47 -07:00

237 lines
12 KiB
Python

"""Plugin activation writes and reads the SAME key on every surface (#27548, #73131, #82898).
Runs the real ``hermes_cli.plugins_cmd`` commands against a temp HERMES_HOME and checks the verdict
through the loader's own gate (``plugins_discovery.gate_manifest``) — never by re-reading the list
the command just wrote.
"""
from __future__ import annotations
import pytest
from hermes_cli import plugins_cmd
from hermes_cli.config import load_config, save_config
from hermes_cli.plugins_discovery import collect_directory_manifests, gate_manifest
def _write_plugin(root, rel, name, extra=""):
d = root / rel
d.mkdir(parents=True)
(d / "plugin.yaml").write_text(f"name: {name}\nversion: '1.0'\n{extra}", encoding="utf-8")
(d / "__init__.py").write_text("def register(ctx):\n pass\n", encoding="utf-8")
return d
def _write_tool_plugin(root, name):
"""User plugin registering one tool in its own toolset, WITHOUT ``provides_tools`` in plugin.yaml."""
d = _write_plugin(root, name, name)
(d / "__init__.py").write_text(
"def register(ctx):\n"
f" ctx.register_tool(name='{name}_tool', toolset='{name}_ts', handler=lambda args, **kw: '{{}}',\n"
f" schema={{'name': '{name}_tool', 'description': 'probe', 'parameters': {{'type': 'object', 'properties': {{}}}}}})\n",
encoding="utf-8")
return d
@pytest.fixture
def home(tmp_path, monkeypatch):
hermes_home = tmp_path / "hermes-home"
(hermes_home / "plugins").mkdir(parents=True)
(hermes_home / "config.yaml").write_text("plugins:\n enabled: []\n disabled: []\n", encoding="utf-8")
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
return hermes_home
@pytest.fixture(autouse=True)
def _commit_plugin_selection_without_building_an_environment(monkeypatch):
"""These tests cover selection keys; PM's real publication path has its own integration tests."""
def admit(enabled, disabled, **_kwargs):
cfg = load_config()
cfg["plugins"] = {"enabled": sorted(enabled), "disabled": sorted(disabled)}
save_config(cfg)
monkeypatch.setattr("hermes_cli.plugins_admission.admit_plugin_set_change", admit)
def _lists():
plugins = load_config().get("plugins") or {}
return set(plugins.get("enabled") or []), set(plugins.get("disabled") or [])
def test_disable_bundled_platform_by_manifest_name_gates_the_loader(home):
"""`hermes plugins disable photon-platform` must produce a key the loader's gate matches; the
CLI wrote ``platforms/photon`` while discovery keyed the adapter ``photon-platform``."""
plugins_cmd.cmd_disable("photon-platform")
enabled, disabled = _lists()
photon = next(m for m in collect_directory_manifests() if m.name == "photon-platform")
gate = gate_manifest(photon, disabled, enabled)
assert gate.action == "placeholder" and gate.error == "disabled via config"
# And the key the CLI persists IS the loader's key — not merely an alias of it.
assert photon.key in disabled
def test_dashboard_toggle_writes_canonical_key_and_clears_stale_aliases(home):
"""A toggle by bare leaf or manifest name must land on the canonical key; a stale key in
``disabled`` outranks a manifest-name entry in ``enabled``, so writing the raw identifier
reported success while the plugin stayed off."""
_write_plugin(home / "plugins", "obs/zzprobe", "zz-probe-manifest")
cfg = load_config()
cfg["plugins"]["disabled"] = ["obs/zzprobe"]
save_config(cfg)
result = plugins_cmd.dashboard_set_agent_plugin_enabled("zzprobe", enabled=True)
# The enable also loads the plugin now (#87770): with no gateway answering, a restart is still the
# honest hint and the activation summary rides along.
assert {k: result[k] for k in ("ok", "name", "unchanged", "restart_required")} == {
"ok": True, "name": "obs/zzprobe", "unchanged": False, "restart_required": True}
assert result["gateway_reloaded"] is False
enabled, disabled = _lists()
assert enabled == {"obs/zzprobe"} and disabled == set()
manifest = next(m for m in collect_directory_manifests() if m.name == "zz-probe-manifest")
assert gate_manifest(manifest, disabled, enabled).action == "load"
# Same state requested again by manifest name: nothing to write, no restart to announce.
again = plugins_cmd.dashboard_set_agent_plugin_enabled("zz-probe-manifest", enabled=True)
assert again["unchanged"] is True and again["restart_required"] is False
def test_dashboard_enable_on_a_fresh_config_keeps_the_core_tools(home):
"""Desktop/TUI/dashboard enable with no saved ``platform_toolsets`` must not write one: the old
seed ``platform_toolsets.cli: [<plugin_ts>]`` replaced the ``hermes-cli`` composite, so the model
lost every built-in tool (27 -> 2, no ``terminal``). The plugin toolset is on by default anyway."""
from hermes_cli.tools_config import _get_platform_tools
from tools.registry import registry
_write_tool_plugin(home / "plugins", "zzseed")
try:
assert plugins_cmd.dashboard_set_agent_plugin_enabled("zzseed", enabled=True)["ok"] is True
cfg = load_config()
assert not cfg.get("platform_toolsets")
assert {"terminal", "file", "zzseed_ts"} <= _get_platform_tools(cfg, "cli")
finally:
registry.deregister("zzseed_tool")
def _restart_backend():
"""A new process: no plugin manager has scanned yet and no plugin tool is registered."""
from hermes_cli.plugins import _reset_plugin_managers_for_tests
from tools.registry import registry
_reset_plugin_managers_for_tests()
registry.deregister("zzre_tool")
def _saved_cli_list():
return load_config()["platform_toolsets"]["cli"]
@pytest.fixture
def saved_list_plugin(home):
"""An enabled tool plugin on a config whose tool picker was saved (explicit ``platform_toolsets``)."""
_write_tool_plugin(home / "plugins", "zzre")
cfg = load_config()
cfg["plugins"]["enabled"] = ["zzre"]
cfg["platform_toolsets"] = {"cli": ["hermes-cli", "zzre_ts"]}
save_config(cfg)
yield
_restart_backend()
def test_dashboard_reenable_restores_the_toolset_in_a_saved_list(saved_list_plugin):
"""Desktop/TUI/dashboard disable drops the plugin toolset from a saved list; re-enabling from a
backend that scanned while the plugin was off must add it back. The toggle ran before the plugin
loaded, found no registered tool, and left the plugin enabled with its tools off."""
from hermes_cli.plugins import discover_plugins
discover_plugins() # long-lived backend: scanned while the plugin was on
assert plugins_cmd.dashboard_set_agent_plugin_enabled("zzre", enabled=False)["ok"] is True
assert _saved_cli_list() == ["hermes-cli"]
_restart_backend()
discover_plugins() # the backend now holds only a placeholder for the disabled plugin
assert plugins_cmd.dashboard_set_agent_plugin_enabled("zzre", enabled=True)["ok"] is True
assert _saved_cli_list() == ["hermes-cli", "zzre_ts"]
def test_cli_disable_and_enable_round_trip_the_toolset_in_a_saved_list(saved_list_plugin):
"""``hermes plugins disable`` / ``enable`` edit a saved list the same way the dashboard does, each
in its own process; they never touched it, so a toolset the dashboard dropped stayed off."""
plugins_cmd.cmd_disable("zzre")
assert _saved_cli_list() == ["hermes-cli"]
_restart_backend()
plugins_cmd.cmd_enable("zzre")
assert _saved_cli_list() == ["hermes-cli", "zzre_ts"]
def test_cli_enable_on_a_fresh_config_writes_no_toolset_list(home):
"""No saved ``platform_toolsets``: enable must not seed one (it would replace the core composite)."""
_write_tool_plugin(home / "plugins", "zzre")
plugins_cmd.cmd_enable("zzre")
plugins_cmd.cmd_disable("zzre")
assert not load_config().get("platform_toolsets")
_restart_backend()
def test_status_reports_bundled_defaults_and_the_live_memory_provider(home):
"""Bundled backends (auto-load) and the plugin selected by ``memory.provider`` run without a
``plugins.enabled`` entry; status must not call them "not enabled" (#73131, #82898)."""
_write_plugin(home / "plugins", "fakemem", "fakemem", "kind: exclusive\n")
cfg = load_config()
cfg["memory"] = {"provider": "fakemem"}
save_config(cfg)
enabled, disabled = plugins_cmd._get_enabled_set(), plugins_cmd._get_disabled_set()
active = plugins_cmd._category_active_names()
by_key = {e[5]: e for e in plugins_cmd._discover_all_plugins()}
for key in ("web/firecrawl", "fakemem"):
name, _v, _d, source, dir_path, _k = by_key[key]
status = plugins_cmd._plugin_status(name, enabled, disabled, key=key, source=source, dir_path=dir_path,
active=active)
assert status == "enabled", key
# An explicit disable still wins over both defaults.
assert plugins_cmd._plugin_status("fakemem", enabled, {"fakemem"}, key="fakemem", source="user",
active=active) == "disabled"
def test_status_matches_the_loader_for_a_user_installed_model_provider(home):
"""``gate_manifest`` loads a model provider from any source without a ``plugins.enabled`` entry, so
status (and the Desktop Plugins switch) must not report a user-installed one as "not enabled"; a
user standalone plugin stays opt-in."""
_write_plugin(home / "plugins", "fakeprov", "fakeprov", "kind: model-provider\n")
_write_plugin(home / "plugins", "fakestand", "fakestand", "")
enabled, disabled = plugins_cmd._get_enabled_set(), plugins_cmd._get_disabled_set()
by_key = {e[5]: e for e in plugins_cmd._discover_all_plugins()}
status = {}
for key in ("fakeprov", "fakestand"):
name, _v, _d, source, dir_path, _k = by_key[key]
status[key] = plugins_cmd._plugin_status(name, enabled, disabled, key=key, source=source, dir_path=dir_path)
assert status == {"fakeprov": "enabled", "fakestand": "not enabled"}
name, _v, _d, source, dir_path, _k = by_key["fakeprov"]
assert plugins_cmd._plugin_status(name, enabled, {"fakeprov"}, key="fakeprov", source=source,
dir_path=dir_path) == "disabled"
def test_status_enables_a_user_model_provider_only_where_provider_discovery_imports_it(home):
"""providers/ discovery imports every ``plugins/model-providers/<name>/`` child, but a flat
``plugins/<name>/`` one only when it declares exactly ``kind: model-provider``. A provider in a
category directory, or flat with a differently-cased kind, never loads, so status must not call
it enabled. A non-provider under ``model-providers/`` is imported but never registered."""
import providers
for rel, kind in (("flatprov", "model-provider"), ("model-providers/subprov", "model-provider"),
("model-providers/subcaps", "MODEL-PROVIDER"), ("custom/nestedprov", "model-provider"),
("model-providers/substand", "standalone"), ("capsprov", "MODEL-PROVIDER")):
d = _write_plugin(home / "plugins", rel, rel.rsplit("/", 1)[-1], f"kind: {kind}\n")
(d / "__init__.py").write_text(f"open({str(d / 'imported')!r}, 'w').close()\n", encoding="utf-8")
providers._scan_home_layer(providers._HomeLayer(), str(home))
rows = {e[5]: e for e in plugins_cmd._discover_all_plugins() if e[3] == "user"}
imported = {key: (row[4] / "imported").exists() for key, row in rows.items()}
assert imported == {"flatprov": True, "model-providers/subprov": True, "model-providers/subcaps": True,
"model-providers/substand": True, "custom/nestedprov": False, "capsprov": False}
status = {key: plugins_cmd._plugin_status(row[0], set(), set(), key=key, source=row[3], dir_path=row[4])
== "enabled" for key, row in rows.items()}
assert status == {**imported, "model-providers/substand": False}