diff --git a/gateway/platform_registry.py b/gateway/platform_registry.py index 541fb75696..e5554e160e 100644 --- a/gateway/platform_registry.py +++ b/gateway/platform_registry.py @@ -580,6 +580,10 @@ class PlatformRegistry: self._resolve_all() return [e for e in self.all_entries() if e.source == "plugin"] + def registered_names(self) -> set[str]: + """Return concrete and deferred platform names without loading adapters.""" + return self._entries.keys() | self._deferred.keys() + def is_registered(self, name: str) -> bool: # A deferred (not-yet-imported) platform still counts as registered -- # the loader will materialize it on first real use. This keeps cheap diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index e3ff5a9778..65e9f9e36e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -5515,6 +5515,27 @@ def _print_tools_list(enabled_toolsets: set, mcp_servers: dict, platform: str = _print_info(f"{srv_name} {color('all tools enabled', Colors.DIM)}") +def _known_tool_platforms() -> set[str]: + """Return built-in plus discovered plugin platform names. + + Plugin platforms are registered at runtime rather than in the static CLI + display registry. Tool introspection/configuration must recognize those + names too, otherwise an active plugin platform cannot audit its authority. + """ + known = set(PLATFORMS) + try: + from hermes_cli.plugins import discover_plugins + from gateway.platform_registry import platform_registry + + discover_plugins() # idempotent + known.update(platform_registry.registered_names()) + except Exception: + # Plugin discovery is optional. Preserve the built-in CLI path when a + # third-party plugin is malformed or its dependencies are unavailable. + pass + return known + + def tools_disable_enable_command(args): """Enable, disable, or list tools for a platform. @@ -5525,8 +5546,9 @@ def tools_disable_enable_command(args): platform = getattr(args, "platform", "cli") config = load_config() - if platform not in PLATFORMS: - _print_error(f"Unknown platform '{platform}'. Valid: {', '.join(PLATFORMS)}") + valid_platforms = _known_tool_platforms() + if platform not in valid_platforms: + _print_error(f"Unknown platform '{platform}'. Valid: {', '.join(sorted(valid_platforms))}") return if action == "list": diff --git a/tests/gateway/test_platform_registry.py b/tests/gateway/test_platform_registry.py index 8a0442cb02..24040c8ba3 100644 --- a/tests/gateway/test_platform_registry.py +++ b/tests/gateway/test_platform_registry.py @@ -99,6 +99,18 @@ class TestPlatformRegistry: reg.register(entry) assert reg.create_adapter("novalidate", MagicMock()) is mock_adapter + def test_registered_names_includes_deferred_without_materializing(self): + reg = PlatformRegistry() + entry, _ = self._make_entry("concrete") + loader = MagicMock() + reg.register(entry) + reg.register_deferred("deferred", loader) + + assert reg.registered_names() == {"concrete", "deferred"} + loader.assert_not_called() + assert reg.get("concrete") is entry + assert reg.is_registered("deferred") + class TestEnsureDepsFn: """check_fn (PASSIVE probe) vs ensure_deps_fn (ACTIVE installer) split. diff --git a/tests/hermes_cli/test_tools_disable_enable.py b/tests/hermes_cli/test_tools_disable_enable.py index 0b462645bb..9d5684840c 100644 --- a/tests/hermes_cli/test_tools_disable_enable.py +++ b/tests/hermes_cli/test_tools_disable_enable.py @@ -1,7 +1,10 @@ """Tests for hermes tools disable/enable/list command (backend).""" from argparse import Namespace -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import pytest + +from gateway.platform_registry import platform_registry from hermes_cli.tools_config import tools_disable_enable_command @@ -79,3 +82,40 @@ class TestToolsValidation: saved = mock_save.call_args[0][0] assert "web" not in saved["platform_toolsets"]["cli"] assert "memory" in saved["platform_toolsets"]["cli"] + + +@pytest.mark.parametrize("action", ["list", "enable", "disable"]) +def test_tools_action_accepts_deferred_plugin_without_materializing(action, capsys): + platform = "deferred-tools-test" + loader = MagicMock() + configured_tools = ["memory", "web"] if action == "disable" else ["memory"] + config = {"platform_toolsets": {platform: configured_tools}} + args = Namespace(tools_action=action, platform=platform) + if action != "list": + args.names = ["web"] + + def discover_deferred_platform(): + platform_registry.register_deferred(platform, loader) + + try: + with patch( + "hermes_cli.plugins.discover_plugins", + side_effect=discover_deferred_platform, + ) as discover, \ + patch("hermes_cli.tools_config.load_config", return_value=config), \ + patch("hermes_cli.tools_config.save_config") as save: + tools_disable_enable_command(args) + + out = capsys.readouterr().out + assert "Unknown platform" not in out + discover.assert_called() + loader.assert_not_called() + if action == "list": + assert f"Built-in toolsets ({platform}):" in out + save.assert_not_called() + else: + save.assert_called() + saved_tools = save.call_args.args[0]["platform_toolsets"][platform] + assert ("web" in saved_tools) is (action == "enable") + finally: + platform_registry.unregister(platform)