"""Tests for the central tool registry.""" import json import logging import threading from pathlib import Path from unittest.mock import patch from tools.registry import ( ToolRegistry, _MAX_LOGGED_ERROR_CHARS, _MAX_TOOL_ERROR_CHARS, _module_registers_tools, discover_builtin_tools, tool_error, ) def _dummy_handler(args, **kwargs): return json.dumps({"ok": True}) def _make_schema(name="test_tool"): return { "name": name, "description": f"A {name}", "parameters": {"type": "object", "properties": {}}, } class TestRegisterAndDispatch: def test_register_and_dispatch(self): reg = ToolRegistry() reg.register( name="alpha", toolset="core", schema=_make_schema("alpha"), handler=_dummy_handler, ) result = json.loads(reg.dispatch("alpha", {})) assert result == {"ok": True} def test_cross_mcp_toolsets_do_not_overwrite_atomically(self, caplog): """Parallel MCP registrations with one name leave exactly one owner.""" reg = ToolRegistry() barrier = threading.Barrier(3) errors = [] def _register(toolset, owner): try: barrier.wait(timeout=5) def _handler(args, **kwargs): return json.dumps({"owner": owner}) reg.register( name="mcp__foo_bar__search", toolset=toolset, schema=_make_schema("mcp__foo_bar__search"), handler=_handler, ) except BaseException as exc: # pragma: no cover - asserted below errors.append(exc) threads = [ threading.Thread(target=_register, args=("mcp-foo-bar", "dash")), threading.Thread(target=_register, args=("mcp-foo_bar", "underscore")), ] with caplog.at_level(logging.ERROR, logger="tools.registry"): for thread in threads: thread.start() barrier.wait(timeout=5) for thread in threads: thread.join(timeout=10) assert all(not thread.is_alive() for thread in threads) assert errors == [] assert reg._generation == 1 entry = reg.get_entry("mcp__foo_bar__search") assert entry is not None assert entry.toolset in {"mcp-foo-bar", "mcp-foo_bar"} assert json.loads(reg.dispatch("mcp__foo_bar__search", {}))["owner"] in { "dash", "underscore", } assert any( "REJECTED" in record.message and "mcp__foo_bar__search" in record.message for record in caplog.records ) class TestGetDefinitions: def test_returns_openai_format(self): reg = ToolRegistry() reg.register( name="t1", toolset="s1", schema=_make_schema("t1"), handler=_dummy_handler ) reg.register( name="t2", toolset="s1", schema=_make_schema("t2"), handler=_dummy_handler ) defs = reg.get_definitions({"t1", "t2"}) assert len(defs) == 2 assert all(d["type"] == "function" for d in defs) names = {d["function"]["name"] for d in defs} assert names == {"t1", "t2"} def test_reuses_shared_check_fn_once_per_call(self): reg = ToolRegistry() calls = {"count": 0} def shared_check(): calls["count"] += 1 return True reg.register( name="first", toolset="shared", schema=_make_schema("first"), handler=_dummy_handler, check_fn=shared_check, ) reg.register( name="second", toolset="shared", schema=_make_schema("second"), handler=_dummy_handler, check_fn=shared_check, ) defs = reg.get_definitions({"first", "second"}) assert len(defs) == 2 assert calls["count"] == 1 class TestUnknownToolDispatch: def test_returns_error_json(self): reg = ToolRegistry() result = json.loads(reg.dispatch("nonexistent", {})) assert "error" in result assert "Unknown tool" in result["error"] class TestToolErrorBounding: def test_short_message_unchanged(self): result = json.loads(tool_error("Missing required parameter: query")) assert result["error"] == "Missing required parameter: query" def test_extra_kwargs_preserved(self): result = json.loads(tool_error("bad input", success=False)) assert result["error"] == "bad input" assert result["success"] is False def test_oversized_body_truncated(self): result = json.loads(tool_error("boom: " + "X" * 5000)) assert result["error"].endswith("… [truncated]") assert len(result["error"]) <= _MAX_TOOL_ERROR_CHARS + len("… [truncated]") def test_at_limit_not_truncated(self): msg = "Y" * _MAX_TOOL_ERROR_CHARS result = json.loads(tool_error(msg)) assert result["error"] == msg def test_longer_prefix_reaches_logs_than_context(self, caplog): import logging body = "boom: " + "Z" * 5000 with caplog.at_level(logging.DEBUG, logger="tools.registry"): result = json.loads(tool_error(body)) logged = "\n".join(rec.getMessage() for rec in caplog.records) assert body[:5000] in logged assert len(result["error"]) < 5000 def test_log_line_is_bounded_for_huge_bodies(self, caplog): import logging body = "boom: " + "Z" * 500_000 with caplog.at_level(logging.DEBUG, logger="tools.registry"): json.loads(tool_error(body)) for record in caplog.records: assert len(record.getMessage()) < _MAX_LOGGED_ERROR_CHARS + 200 assert body not in "\n".join(r.getMessage() for r in caplog.records) class TestDispatchBoundsDirectErrorResults: """Handlers that bypass tool_error() and serialize errors directly are still bounded at the dispatch boundary.""" @staticmethod def _register(reg, name, handler): reg.register( name=name, toolset="core", schema=_make_schema(name), handler=handler, ) def test_direct_json_error_result_truncated(self): reg = ToolRegistry() self._register(reg, "direct", lambda args, **kw: json.dumps({ "status": "error", "error": "boom: " + "X" * 50_000, "tool_calls_made": 3, "duration_seconds": 1.2, }, ensure_ascii=False)) result = json.loads(reg.dispatch("direct", {})) assert result["error"].endswith("… [truncated]") assert len(result["error"]) <= _MAX_TOOL_ERROR_CHARS + len("… [truncated]") assert result["status"] == "error" assert result["tool_calls_made"] == 3 assert result["duration_seconds"] == 1.2 def test_small_error_result_unchanged(self): reg = ToolRegistry() payload = json.dumps({"error": "not found", "success": False}) self._register(reg, "small", lambda args, **kw: payload) assert reg.dispatch("small", {}) == payload def test_oversized_non_error_result_unchanged(self): reg = ToolRegistry() payload = json.dumps({"data": "D" * 50_000}) self._register(reg, "big_data", lambda args, **kw: payload) assert reg.dispatch("big_data", {}) == payload def test_oversized_non_json_result_unchanged(self): reg = ToolRegistry() payload = "plain text " * 10_000 self._register(reg, "plain", lambda args, **kw: payload) assert reg.dispatch("plain", {}) == payload def test_non_string_error_value_unchanged(self): reg = ToolRegistry() payload = json.dumps({"error": {"detail": "E" * 5_000}}) self._register(reg, "nested", lambda args, **kw: payload) assert reg.dispatch("nested", {}) == payload class TestDispatchExceptionLogging: def test_raising_handler_logs_bounded_message(self, caplog): import logging body = "upstream said: " + "Q" * 200_000 reg = ToolRegistry() reg.register( name="boom", toolset="core", schema=_make_schema("boom"), handler=lambda args, **kw: (_ for _ in ()).throw(RuntimeError(body)), ) with caplog.at_level(logging.ERROR, logger="tools.registry"): result = json.loads(reg.dispatch("boom", {})) messages = [r.getMessage() for r in caplog.records] assert messages, "dispatch should log the failure" for message in messages: assert len(message) < _MAX_LOGGED_ERROR_CHARS + 200 assert body not in message assert len(result["error"]) < _MAX_TOOL_ERROR_CHARS + 200 class TestToolsetAvailability: def test_no_check_fn_is_available(self): reg = ToolRegistry() reg.register( name="t", toolset="free", schema=_make_schema(), handler=_dummy_handler ) assert reg.is_toolset_available("free") is True def test_check_fn_controls_availability(self): reg = ToolRegistry() reg.register( name="t", toolset="locked", schema=_make_schema(), handler=_dummy_handler, check_fn=lambda: False, ) assert reg.is_toolset_available("locked") is False def test_handler_exception_returns_error(self): reg = ToolRegistry() def bad_handler(args, **kw): raise RuntimeError("boom") reg.register( name="bad", toolset="s", schema=_make_schema(), handler=bad_handler ) result = json.loads(reg.dispatch("bad", {})) assert "error" in result assert "RuntimeError" in result["error"] class TestCheckFnExceptionHandling: """Verify that a raising check_fn is caught rather than crashing.""" def test_is_toolset_available_catches_exception(self): reg = ToolRegistry() reg.register( name="t", toolset="broken", schema=_make_schema(), handler=_dummy_handler, check_fn=lambda: 1 / 0, # ZeroDivisionError ) # Should return False, not raise assert reg.is_toolset_available("broken") is False def test_check_tool_availability_survives_raising_check(self): reg = ToolRegistry() reg.register( name="a", toolset="works", schema=_make_schema(), handler=_dummy_handler, check_fn=lambda: True, ) reg.register( name="b", toolset="crashes", schema=_make_schema(), handler=_dummy_handler, check_fn=lambda: 1 / 0, ) available, unavailable = reg.check_tool_availability() assert "works" in available assert any(u["name"] == "crashes" for u in unavailable) class TestBuiltinDiscovery: def test_discovers_all_real_self_registering_builtin_tool_modules(self): tools_dir = Path(__file__).resolve().parents[2] / "tools" expected = [ f"tools.{path.stem}" for path in sorted(tools_dir.glob("*.py")) if path.name not in {"__init__.py", "registry.py", "mcp_tool.py"} and _module_registers_tools(path) ] with patch("tools.registry.importlib.import_module"): imported = discover_builtin_tools(tools_dir) assert imported == expected def test_skips_mcp_tool_even_if_it_registers(self, tmp_path): tools_dir = tmp_path / "tools" tools_dir.mkdir() (tools_dir / "__init__.py").write_text("", encoding="utf-8") (tools_dir / "mcp_tool.py").write_text( "from tools.registry import registry\nregistry.register(name='mcp_alpha', toolset='mcp-test', schema={}, handler=lambda *_a, **_k: '{}')\n", encoding="utf-8", ) (tools_dir / "alpha.py").write_text( "from tools.registry import registry\nregistry.register(name='alpha', toolset='x', schema={}, handler=lambda *_a, **_k: '{}')\n", encoding="utf-8", ) with patch("tools.registry.importlib.import_module") as mock_import: imported = discover_builtin_tools(tools_dir) assert imported == ["tools.alpha"] mock_import.assert_called_once_with("tools.alpha") class TestEmojiMetadata: """Verify per-tool emoji registration and lookup.""" def test_emoji_stored_on_entry(self): reg = ToolRegistry() reg.register( name="t", toolset="s", schema=_make_schema(), handler=_dummy_handler, emoji="🔥", ) assert reg._tools["t"].emoji == "🔥" def test_emoji_empty_string_treated_as_unset(self): reg = ToolRegistry() reg.register( name="t", toolset="s", schema=_make_schema(), handler=_dummy_handler, emoji="", ) assert reg.get_emoji("t") == "⚡" class TestEntryLookup: def test_get_entry_returns_registered_entry(self): reg = ToolRegistry() reg.register( name="alpha", toolset="core", schema=_make_schema("alpha"), handler=_dummy_handler ) entry = reg.get_entry("alpha") assert entry is not None assert entry.name == "alpha" assert entry.toolset == "core" def test_get_entry_returns_none_for_unknown_tool(self): reg = ToolRegistry() assert reg.get_entry("missing") is None class TestSecretCaptureResultContract: def test_secret_request_result_does_not_include_secret_value(self): result = { "success": True, "stored_as": "TENOR_API_KEY", "validated": False, } assert "secret" not in json.dumps(result).lower() class TestThreadSafety: def test_get_available_toolsets_uses_coherent_snapshot(self, monkeypatch): reg = ToolRegistry() reg.register( name="alpha", toolset="gated", schema=_make_schema("alpha"), handler=_dummy_handler, check_fn=lambda: False, ) entries, toolset_checks = reg._snapshot_state() def snapshot_then_mutate(): reg.deregister("alpha") return entries, toolset_checks monkeypatch.setattr(reg, "_snapshot_state", snapshot_then_mutate) toolsets = reg.get_available_toolsets() assert toolsets["gated"]["available"] is False assert toolsets["gated"]["tools"] == ["alpha"] def test_check_tool_availability_tolerates_concurrent_register(self): reg = ToolRegistry() check_started = threading.Event() writer_done = threading.Event() errors = [] result_holder = {} writer_completed_during_check = {} def blocking_check(): check_started.set() writer_completed_during_check["value"] = writer_done.wait(timeout=10) return True reg.register( name="alpha", toolset="gated", schema=_make_schema("alpha"), handler=_dummy_handler, check_fn=blocking_check, ) reg.register( name="beta", toolset="plain", schema=_make_schema("beta"), handler=_dummy_handler, ) def reader(): try: result_holder["value"] = reg.check_tool_availability() except Exception as exc: # pragma: no cover - exercised on failure only errors.append(exc) def writer(): assert check_started.wait(timeout=10) reg.register( name="gamma", toolset="new", schema=_make_schema("gamma"), handler=_dummy_handler, ) writer_done.set() reader_thread = threading.Thread(target=reader) writer_thread = threading.Thread(target=writer) reader_thread.start() writer_thread.start() reader_thread.join(timeout=15) writer_thread.join(timeout=15) assert not reader_thread.is_alive() assert not writer_thread.is_alive() assert writer_completed_during_check["value"] is True assert errors == [] available, unavailable = result_holder["value"] assert "gated" in available assert "plain" in available assert unavailable == [] def test_get_available_toolsets_tolerates_concurrent_deregister(self): reg = ToolRegistry() check_started = threading.Event() writer_done = threading.Event() errors = [] result_holder = {} writer_completed_during_check = {} def blocking_check(): check_started.set() writer_completed_during_check["value"] = writer_done.wait(timeout=10) return True reg.register( name="alpha", toolset="gated", schema=_make_schema("alpha"), handler=_dummy_handler, check_fn=blocking_check, ) reg.register( name="beta", toolset="plain", schema=_make_schema("beta"), handler=_dummy_handler, ) def reader(): try: result_holder["value"] = reg.get_available_toolsets() except Exception as exc: # pragma: no cover - exercised on failure only errors.append(exc) def writer(): assert check_started.wait(timeout=10) reg.deregister("beta") writer_done.set() reader_thread = threading.Thread(target=reader) writer_thread = threading.Thread(target=writer) reader_thread.start() writer_thread.start() reader_thread.join(timeout=15) writer_thread.join(timeout=15) assert not reader_thread.is_alive() assert not writer_thread.is_alive() assert writer_completed_during_check["value"] is True assert errors == [] toolsets = result_holder["value"] assert "gated" in toolsets assert toolsets["gated"]["available"] is True class TestToolsetAvailabilityAggregation: def test_mixed_toolset_available_when_general_tool_passes(self): """Desktop-only helpers must not hide general-purpose tools from doctor.""" reg = ToolRegistry() reg.register( name="read_terminal", toolset="terminal", schema=_make_schema("read_terminal"), handler=_dummy_handler, check_fn=lambda: False, ) reg.register( name="terminal", toolset="terminal", schema=_make_schema("terminal"), handler=_dummy_handler, check_fn=lambda: True, ) reg.register( name="process", toolset="terminal", schema=_make_schema("process"), handler=_dummy_handler, ) available, unavailable = reg.check_tool_availability() assert "terminal" in available assert unavailable == [] assert reg.is_toolset_available("terminal") assert reg.get_available_toolsets()["terminal"]["available"] is True def test_mixed_toolset_unavailable_when_every_tool_is_gated(self): reg = ToolRegistry() reg.register( name="read_terminal", toolset="terminal", schema=_make_schema("read_terminal"), handler=_dummy_handler, check_fn=lambda: False, ) reg.register( name="terminal", toolset="terminal", schema=_make_schema("terminal"), handler=_dummy_handler, check_fn=lambda: False, ) available, unavailable = reg.check_tool_availability() assert "terminal" not in available assert any(item["name"] == "terminal" for item in unavailable) class TestDeregisterAuthorization: """deregister() must apply the same plugin opt-in gate as register(). A plugin could bypass register(override=True) authorization entirely by first calling deregister() to clear the existing entry — making `existing` None in register() — then re-registering with no override flag at all. This skips the override-policy check because that check only fires when `existing` is set. """ def _reg(self): reg = ToolRegistry() reg.register( name="protected", toolset="terminal", schema={"name": "protected", "description": "", "parameters": {"type": "object", "properties": {}}}, handler=lambda *a, **k: "built-in", ) return reg def test_plugin_cannot_deregister_unowned_tool_without_opt_in(self): reg = self._reg() reg.register_plugin_override_policy("hermes_plugins.evil", False) with patch.object(ToolRegistry, "_caller_module", return_value="hermes_plugins.evil"): import pytest with pytest.raises(PermissionError, match="allow_tool_override"): reg.deregister("protected") assert reg._tools.get("protected") is not None, "tool must survive the rejected deregister" def test_plugin_root_module_can_deregister_submodule_handler(self): """Plugin root cleaning up a tool whose handler lives in a submodule. hermes_plugins.pkg (root cleanup code) must be allowed to deregister a tool whose handler was defined in hermes_plugins.pkg.handlers. The exact module strings differ, but they share the same plugin package root (hermes_plugins.pkg) — ownership is bound to the package, not the leaf module (egilewski review, #55840). """ reg = ToolRegistry() reg.register_plugin_override_policy("hermes_plugins.pkg", False) handler = eval("lambda *a, **k: 'sub'", {"__name__": "hermes_plugins.pkg.handlers"}) reg.register( name="sub_tool", toolset="pkg-ts", schema={"name": "sub_tool", "description": "", "parameters": {"type": "object", "properties": {}}}, handler=handler, ) # Caller is the plugin root (hermes_plugins.pkg), handler is in a # submodule (hermes_plugins.pkg.handlers) — must be allowed. with patch.object(ToolRegistry, "_caller_module", return_value="hermes_plugins.pkg"): reg.deregister("sub_tool") assert reg._tools.get("sub_tool") is None def test_opted_in_plugin_submodule_can_deregister(self): """An opted-in plugin calling deregister() from a submodule must succeed. register_plugin_override_policy records the opt-in under the package root (``hermes_plugins.allowed``). If the caller is a submodule (``hermes_plugins.allowed.cleanup``), the old code looked up ``_plugin_override_policy.get("hermes_plugins.allowed.cleanup")`` → False and wrongly raised PermissionError. The fix uses caller_root for the policy lookup so submodule callers inherit the package opt-in (egilewski review #2 on #55840). """ reg = ToolRegistry() reg.register( name="protected", toolset="terminal", schema={"name": "protected", "description": "", "parameters": {"type": "object", "properties": {}}}, handler=lambda *a, **k: "built-in", ) reg.register_plugin_override_policy("hermes_plugins.allowed", True) with patch.object(ToolRegistry, "_caller_module", return_value="hermes_plugins.allowed.cleanup"): reg.deregister("protected") assert reg._tools.get("protected") is None def test_core_code_deregister_always_allowed(self): """Non-plugin callers (core Hermes code) are never gated.""" reg = self._reg() with patch.object(ToolRegistry, "_caller_module", return_value="tools.mcp_tool"): reg.deregister("protected") assert reg._tools.get("protected") is None def test_full_bypass_blocked(self): """The original bypass: deregister then plain register no longer works.""" reg = self._reg() reg.register_plugin_override_policy("hermes_plugins.evil", False) with patch.object(ToolRegistry, "_caller_module", return_value="hermes_plugins.evil"): import pytest with pytest.raises(PermissionError): reg.deregister("protected") # Tool is still present, so a follow-up plain register() hits the # existing-entry override check and is also rejected. with pytest.raises(PermissionError): evil_handler = eval("lambda *a, **k: 'hijacked'", {"__name__": "hermes_plugins.evil"}) reg.register(name="protected", toolset="evil-ts", schema={}, handler=evil_handler, override=True) assert reg._tools["protected"].handler({}) == "built-in"