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.
1365 lines
52 KiB
Python
1365 lines
52 KiB
Python
"""End-to-end coverage for plugin registration ownership and reload cleanup."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from concurrent.futures import ThreadPoolExecutor, TimeoutError
|
|
from pathlib import Path
|
|
from threading import Event
|
|
from time import monotonic, sleep
|
|
from types import MethodType
|
|
|
|
import hermes_yaml as yaml
|
|
|
|
|
|
def _write_plugin(hermes_home: Path) -> None:
|
|
plugin_dir = hermes_home / "plugins" / "ledger_probe"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"name": "ledger_probe",
|
|
"version": "0.1.0",
|
|
"description": "ownership ledger probe",
|
|
}
|
|
)
|
|
)
|
|
(plugin_dir / "SKILL.md").write_text("# Ledger probe\n")
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"from pathlib import Path\n"
|
|
"\n"
|
|
"def _hook(**kwargs):\n"
|
|
" return {'hook': 'ledger'}\n"
|
|
"\n"
|
|
"def _middleware(**kwargs):\n"
|
|
" return {'middleware': 'ledger'}\n"
|
|
"\n"
|
|
"UNLOADED = []\n"
|
|
"\n"
|
|
"def register(ctx):\n"
|
|
" ctx.register_tool(\n"
|
|
" name='ledger_probe_tool',\n"
|
|
" toolset='plugin_ledger_probe',\n"
|
|
" schema={'name': 'ledger_probe_tool', 'parameters': {'type': 'object', 'properties': {}}},\n"
|
|
" handler=lambda args, **kwargs: 'ledger',\n"
|
|
" )\n"
|
|
" ctx.register_platform(\n"
|
|
" name='ledger_probe_platform',\n"
|
|
" label='Ledger probe',\n"
|
|
" adapter_factory=lambda config: object(),\n"
|
|
" check_fn=lambda: True,\n"
|
|
" )\n"
|
|
" ctx.register_cli_command(\n"
|
|
" 'ledger-probe-cli', 'Ledger CLI', lambda parser: None,\n"
|
|
" handler_fn=lambda args: None,\n"
|
|
" )\n"
|
|
" ctx.register_command(\n"
|
|
" 'ledger-probe-command', lambda args: args,\n"
|
|
" description='Ledger command',\n"
|
|
" )\n"
|
|
" ctx.register_hook('pre_tool_call', _hook)\n"
|
|
" ctx.register_middleware('tool_request', _middleware)\n"
|
|
" ctx.register_auxiliary_task(\n"
|
|
" key='ledger_probe_task',\n"
|
|
" display_name='Ledger probe task',\n"
|
|
" description='Ledger task',\n"
|
|
" )\n"
|
|
" ctx.register_skill(\n"
|
|
" 'ledger-probe', Path(__file__).with_name('SKILL.md'),\n"
|
|
" 'Ledger skill',\n"
|
|
" )\n"
|
|
" ctx.register_system_prompt_section(\n"
|
|
" 'ledger-probe-section', 'ledger probe section content',\n"
|
|
" )\n"
|
|
" ctx.register_approval_transport(\n"
|
|
" 'ledger_probe_transport', lambda request: 'deny',\n"
|
|
" )\n"
|
|
" ctx.on_unload(lambda: UNLOADED.append('ledger_probe'))\n"
|
|
)
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["ledger_probe"]}})
|
|
)
|
|
|
|
|
|
def _write_profile_probe(hermes_home: Path, marker: str) -> None:
|
|
plugin_dir = hermes_home / "plugins" / "profile_probe"
|
|
plugin_dir.mkdir(parents=True)
|
|
(plugin_dir / "plugin.yaml").write_text(
|
|
yaml.safe_dump(
|
|
{
|
|
"name": "profile_probe",
|
|
"version": "0.1.0",
|
|
"description": f"profile probe {marker}",
|
|
}
|
|
)
|
|
)
|
|
(plugin_dir / "__init__.py").write_text(
|
|
"def register(ctx):\n"
|
|
" ctx.register_tool(\n"
|
|
" name='shared_profile_tool',\n"
|
|
" toolset='profile_probe',\n"
|
|
" schema={'name': 'shared_profile_tool', 'parameters': {'type': 'object', 'properties': {}}},\n"
|
|
f" handler=lambda args, **kwargs: {marker!r},\n"
|
|
" )\n"
|
|
" ctx.register_platform(\n"
|
|
" name='shared_profile_platform',\n"
|
|
f" label={marker!r},\n"
|
|
f" adapter_factory=lambda config: {marker!r},\n"
|
|
" check_fn=lambda: True,\n"
|
|
" )\n"
|
|
)
|
|
(hermes_home / "config.yaml").write_text(
|
|
yaml.safe_dump({"plugins": {"enabled": ["profile_probe"]}})
|
|
)
|
|
|
|
|
|
def test_load_force_reload_and_unload_remove_every_manager_registration(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""A real temporary plugin has one live registration after each reload."""
|
|
import hermes_cli.plugins as plugins_mod
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_cli.plugins import PluginManager
|
|
from tools.registry import registry
|
|
|
|
hermes_home = tmp_path / "hermes"
|
|
_write_plugin(hermes_home)
|
|
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
|
monkeypatch.setattr(
|
|
plugins_mod,
|
|
"get_bundled_plugins_dir",
|
|
lambda: tmp_path / "empty-bundled",
|
|
)
|
|
monkeypatch.setattr(PluginManager, "_scan_entry_points", lambda self: [])
|
|
|
|
manager = PluginManager()
|
|
manager.discover_and_load()
|
|
|
|
first_tool = registry.get_entry("ledger_probe_tool")
|
|
first_platform = platform_registry.get("ledger_probe_platform")
|
|
first_hook = manager._hooks["pre_tool_call"][0]
|
|
first_middleware = manager._middleware["tool_request"][0]
|
|
first_command = manager._plugin_commands["ledger-probe-command"]
|
|
first_cli_command = manager._cli_commands["ledger-probe-cli"]
|
|
first_skill = manager._plugin_skills["ledger_probe:ledger-probe"]
|
|
module_name = manager._plugins["ledger_probe"].module.__name__
|
|
|
|
assert first_tool is not None
|
|
assert first_platform is not None
|
|
assert set(registration.kind for registration in manager._ownership_ledger["ledger_probe"]) == {
|
|
"tool",
|
|
"platform",
|
|
"cli_command",
|
|
"command",
|
|
"hook",
|
|
"middleware",
|
|
"auxiliary_task",
|
|
"skill",
|
|
"tool_override_policy",
|
|
"system_prompt_section",
|
|
"approval_transport",
|
|
"on_unload",
|
|
}
|
|
assert "ledger-probe-section" in manager._system_prompt_sections
|
|
assert "ledger_probe_transport" in manager._approval_transports
|
|
|
|
manager.discover_and_load(force=True)
|
|
|
|
second_tool = registry.get_entry("ledger_probe_tool")
|
|
second_platform = platform_registry.get("ledger_probe_platform")
|
|
assert second_tool is not None and second_tool is not first_tool
|
|
assert second_platform is not None and second_platform is not first_platform
|
|
assert second_tool.handler is not first_tool.handler
|
|
assert first_hook not in manager._hooks["pre_tool_call"]
|
|
assert first_middleware not in manager._middleware["tool_request"]
|
|
assert len(manager._hooks["pre_tool_call"]) == 1
|
|
assert len(manager._middleware["tool_request"]) == 1
|
|
assert manager._plugin_commands["ledger-probe-command"] is not first_command
|
|
assert manager._cli_commands["ledger-probe-cli"] is not first_cli_command
|
|
assert manager._plugin_skills["ledger_probe:ledger-probe"] is not first_skill
|
|
assert len(manager._aux_tasks) == 1
|
|
assert [
|
|
entry
|
|
for entry in platform_registry.plugin_entries()
|
|
if entry.name == "ledger_probe_platform"
|
|
] == [second_platform]
|
|
|
|
assert manager.unload("ledger_probe") is True
|
|
assert registry.get_entry("ledger_probe_tool") is None
|
|
assert not platform_registry.is_registered("ledger_probe_platform")
|
|
assert "pre_tool_call" not in manager._hooks
|
|
assert "tool_request" not in manager._middleware
|
|
assert "ledger-probe-command" not in manager._plugin_commands
|
|
assert "ledger-probe-cli" not in manager._cli_commands
|
|
assert "ledger_probe:ledger-probe" not in manager._plugin_skills
|
|
assert "ledger-probe-section" not in manager._system_prompt_sections
|
|
assert "ledger_probe_transport" not in manager._approval_transports
|
|
assert manager._plugins == {} or "ledger_probe" not in manager._plugins
|
|
reloaded_module_name = None
|
|
import sys as _sys
|
|
for _name, _mod in list(_sys.modules.items()):
|
|
if getattr(_mod, "UNLOADED", None) and "ledger_probe" in _mod.UNLOADED:
|
|
reloaded_module_name = _name
|
|
break
|
|
assert reloaded_module_name is not None, "on_unload callback never fired"
|
|
assert manager._aux_tasks == {}
|
|
assert manager._ownership_ledger == {}
|
|
assert registry.snapshot_plugin_override_policy(
|
|
module_name, scope=manager.scope_key
|
|
) is None
|
|
assert registry.plugin_scope_for_module(module_name) == manager.scope_key
|
|
|
|
|
|
def test_reverse_unload_restores_an_overridden_platform_registration():
|
|
"""Reverse teardown reveals an older entry before removing it."""
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
name = "ledger_override_platform"
|
|
scope = platform_registry.current_scope_key()
|
|
previous = platform_registry.snapshot_registration(name, scope=scope)
|
|
manager_a = PluginManager(scope_key=scope)
|
|
manager_b = PluginManager(scope_key=scope)
|
|
context_a = PluginContext(
|
|
PluginManifest(name="ledger_owner_a", key="ledger_owner_a"), manager_a
|
|
)
|
|
context_b = PluginContext(
|
|
PluginManifest(name="ledger_owner_b", key="ledger_owner_b"), manager_b
|
|
)
|
|
|
|
try:
|
|
handle_a = context_a.register_platform(
|
|
name=name,
|
|
label="Ledger A",
|
|
adapter_factory=lambda config: "a",
|
|
check_fn=lambda: True,
|
|
)
|
|
entry_a = platform_registry.get(name)
|
|
handle_b = context_b.register_platform(
|
|
name=name,
|
|
label="Ledger B",
|
|
adapter_factory=lambda config: "b",
|
|
check_fn=lambda: True,
|
|
)
|
|
entry_b = platform_registry.get(name)
|
|
|
|
assert handle_a is not None and handle_b is not None
|
|
assert entry_a is not None and entry_b is not None
|
|
assert entry_a is not entry_b
|
|
|
|
handle_b.dispose()
|
|
assert platform_registry.get(name) is entry_a
|
|
handle_a.dispose()
|
|
assert platform_registry.snapshot_registration(name, scope=scope) == previous
|
|
finally:
|
|
# The test uses a deliberately unique name, but restore any state that
|
|
# a surrounding test may have installed under it.
|
|
current = platform_registry.snapshot_registration(name, scope=scope)
|
|
platform_registry.restore_registration(
|
|
name, current, previous, scope=scope
|
|
)
|
|
|
|
|
|
def test_targeted_unload_does_not_resurrect_an_older_tool_override():
|
|
"""The tool overlay follows the same arbitrary-order ownership contract."""
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
from tools.registry import registry
|
|
|
|
name = "ledger_out_of_order_tool"
|
|
scope = registry.current_scope_key()
|
|
previous = registry.snapshot_registration(name, scope=scope)
|
|
manager_a = PluginManager(scope_key=scope)
|
|
manager_b = PluginManager(scope_key=scope)
|
|
context_a = PluginContext(PluginManifest(name="tool_a", key="tool_a"), manager_a)
|
|
context_b = PluginContext(PluginManifest(name="tool_b", key="tool_b"), manager_b)
|
|
|
|
def register(context, marker):
|
|
return context.register_tool(
|
|
name=name,
|
|
toolset="ledger_test",
|
|
schema={
|
|
"name": name,
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=lambda args, **kwargs: marker,
|
|
)
|
|
|
|
try:
|
|
assert register(context_a, "a") is not None
|
|
old_entry = registry.get_entry(name, scope=scope)
|
|
assert register(context_b, "b") is not None
|
|
new_entry = registry.get_entry(name, scope=scope)
|
|
|
|
assert old_entry is not None and new_entry is not old_entry
|
|
manager_a.unload("tool_a")
|
|
assert registry.get_entry(name, scope=scope) is new_entry
|
|
assert name not in manager_a._plugin_tool_names
|
|
manager_b.unload("tool_b")
|
|
assert registry.get_entry(name, scope=scope) is not old_entry
|
|
assert registry.snapshot_registration(name, scope=scope) is previous
|
|
finally:
|
|
current = registry.snapshot_registration(name, scope=scope)
|
|
if current is not None:
|
|
registry.restore_registration(name, current, previous, scope=scope)
|
|
|
|
|
|
def test_rejected_tool_registration_does_not_claim_global_fallback():
|
|
"""Effective fallback identity cannot masquerade as a successful write."""
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
from tools.registry import registry
|
|
|
|
name = "ledger_rejected_tool"
|
|
previous = registry.snapshot_registration(name)
|
|
|
|
def shared_handler(args, **kwargs):
|
|
return "base"
|
|
|
|
registry.register(
|
|
name=name,
|
|
toolset="ledger_base",
|
|
schema={"name": name, "parameters": {"type": "object", "properties": {}}},
|
|
handler=shared_handler,
|
|
)
|
|
base_entry = registry.snapshot_registration(name)
|
|
manager = PluginManager()
|
|
context = PluginContext(
|
|
PluginManifest(name="rejected_owner", key="rejected_owner"), manager
|
|
)
|
|
try:
|
|
handle = context.register_tool(
|
|
name=name,
|
|
toolset="different_toolset",
|
|
schema={
|
|
"name": name,
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=shared_handler,
|
|
)
|
|
assert handle is None
|
|
assert name not in manager._plugin_tool_names
|
|
assert manager._ownership_ledger == {}
|
|
assert registry.snapshot_registration(name) is base_entry
|
|
finally:
|
|
if base_entry is not None:
|
|
registry.restore_registration(name, base_entry, previous)
|
|
|
|
|
|
def test_plugin_context_cannot_shadow_same_toolset_global_with_core_callable():
|
|
"""Explicit context scope cannot launder an imported/core handler."""
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
from tools.registry import registry
|
|
|
|
name = "ledger_same_toolset_global"
|
|
previous = registry.snapshot_registration(name)
|
|
registry.register(
|
|
name=name,
|
|
toolset="ledger_same_toolset",
|
|
schema={"name": name, "parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kwargs: "base",
|
|
)
|
|
base_entry = registry.snapshot_registration(name)
|
|
manager = PluginManager()
|
|
context = PluginContext(
|
|
PluginManifest(name="core_callable", key="core_callable"), manager
|
|
)
|
|
try:
|
|
assert context.register_tool(
|
|
name=name,
|
|
toolset="ledger_same_toolset",
|
|
schema={
|
|
"name": name,
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=str,
|
|
) is None
|
|
assert registry.get_entry(name, scope=manager.scope_key) is base_entry
|
|
assert registry.snapshot_registration(name, scope=manager.scope_key) is None
|
|
finally:
|
|
if base_entry is not None:
|
|
registry.restore_registration(name, base_entry, previous)
|
|
|
|
|
|
def test_rejected_tool_registration_does_not_claim_local_predecessor():
|
|
"""A same-handler rejection cannot manufacture a replacement lease."""
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
from tools.registry import registry
|
|
|
|
name = "ledger_rejected_local_tool"
|
|
scope = registry.current_scope_key()
|
|
previous = registry.snapshot_registration(name, scope=scope)
|
|
|
|
def shared_handler(args, **kwargs):
|
|
return "shared"
|
|
|
|
manager_a = PluginManager(scope_key=scope)
|
|
manager_b = PluginManager(scope_key=scope)
|
|
context_a = PluginContext(PluginManifest(name="local_owner", key="local_owner"), manager_a)
|
|
context_b = PluginContext(PluginManifest(name="false_owner", key="false_owner"), manager_b)
|
|
try:
|
|
assert context_a.register_tool(
|
|
name=name,
|
|
toolset="owner_toolset",
|
|
schema={"name": name, "parameters": {"type": "object", "properties": {}}},
|
|
handler=shared_handler,
|
|
) is not None
|
|
owner_entry = registry.snapshot_registration(name, scope=scope)
|
|
assert context_b.register_tool(
|
|
name=name,
|
|
toolset="different_toolset",
|
|
schema={"name": name, "parameters": {"type": "object", "properties": {}}},
|
|
handler=shared_handler,
|
|
) is None
|
|
assert manager_b._ownership_ledger == {}
|
|
manager_a.unload("local_owner")
|
|
assert registry.snapshot_registration(name, scope=scope) is previous
|
|
assert owner_entry is not None
|
|
finally:
|
|
current = registry.snapshot_registration(name, scope=scope)
|
|
if current is not None:
|
|
registry.restore_registration(name, current, previous, scope=scope)
|
|
|
|
|
|
def test_scoped_plugin_cannot_deregister_a_process_global_tool():
|
|
"""Profile-local plugin cleanup must never mutate the shared base layer."""
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from tools.registry import ToolRegistry
|
|
|
|
registry = ToolRegistry()
|
|
name = "ledger_global_base"
|
|
registry.register(
|
|
name=name,
|
|
toolset="ledger_base",
|
|
schema={"name": name, "parameters": {"type": "object", "properties": {}}},
|
|
handler=lambda args, **kwargs: "base",
|
|
)
|
|
module_name = "hermes_plugins.scoped_cleanup"
|
|
registry.register_plugin_override_policy(
|
|
module_name, True, scope="/profiles/isolated"
|
|
)
|
|
|
|
with patch.object(ToolRegistry, "_caller_module", return_value=module_name):
|
|
with pytest.raises(PermissionError, match="process-global"):
|
|
registry.deregister(name)
|
|
|
|
assert registry.snapshot_registration(name) is not None
|
|
|
|
|
|
def test_shared_entrypoint_module_uses_the_active_profile_scope(tmp_path):
|
|
"""One pip module can serve A and B without becoming process-global."""
|
|
import pytest
|
|
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from tools.registry import ToolRegistry
|
|
|
|
registry = ToolRegistry()
|
|
module_name = "third_party.shared_hermes_plugin"
|
|
home_a = str((tmp_path / "entrypoint-a").resolve())
|
|
home_b = str((tmp_path / "entrypoint-b").resolve())
|
|
home_c = str((tmp_path / "entrypoint-c").resolve())
|
|
policy_a = registry.register_plugin_override_policy(
|
|
module_name, False, scope=home_a
|
|
)
|
|
policy_b = registry.register_plugin_override_policy(
|
|
module_name, False, scope=home_b
|
|
)
|
|
handler = eval("lambda args, **kwargs: 'shared'", {"__name__": module_name})
|
|
|
|
def register_in(home, name):
|
|
token = set_hermes_home_override(home)
|
|
try:
|
|
registry.register(
|
|
name=name,
|
|
toolset="entrypoint_shared",
|
|
schema={
|
|
"name": name,
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=handler,
|
|
)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
register_in(home_a, "shared_entrypoint_a")
|
|
register_in(home_b, "shared_entrypoint_b")
|
|
assert registry.snapshot_registration("shared_entrypoint_a", scope=home_a) is not None
|
|
assert registry.snapshot_registration("shared_entrypoint_a", scope=home_b) is None
|
|
assert registry.snapshot_registration("shared_entrypoint_b", scope=home_b) is not None
|
|
assert registry.snapshot_registration("shared_entrypoint_b") is None
|
|
|
|
from unittest.mock import patch
|
|
|
|
token = set_hermes_home_override(home_a)
|
|
try:
|
|
with patch.object(ToolRegistry, "_caller_module", return_value=module_name):
|
|
registry.deregister("shared_entrypoint_a")
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
assert registry.snapshot_registration("shared_entrypoint_a", scope=home_a) is None
|
|
assert registry.snapshot_registration("shared_entrypoint_b", scope=home_b) is not None
|
|
|
|
# Policy unload revokes authorization but durable scope attribution keeps
|
|
# a stale delayed callback out of the process-global registry.
|
|
registry.restore_plugin_override_policy(
|
|
module_name, policy_a, None, scope=home_a
|
|
)
|
|
registry.restore_plugin_override_policy(
|
|
module_name, policy_b, None, scope=home_b
|
|
)
|
|
register_in(home_a, "shared_entrypoint_stale")
|
|
assert registry.snapshot_registration("shared_entrypoint_stale", scope=home_a)
|
|
assert registry.snapshot_registration("shared_entrypoint_stale") is None
|
|
with pytest.raises(PermissionError, match="multiple profiles"):
|
|
register_in(home_c, "shared_entrypoint_ambiguous")
|
|
assert registry.snapshot_registration("shared_entrypoint_ambiguous") is None
|
|
|
|
|
|
def test_decorated_plugin_callable_keeps_its_defining_module_scope(tmp_path):
|
|
"""functools.wraps must not replace the plugin wrapper's provenance."""
|
|
import functools
|
|
|
|
from tools.registry import ToolRegistry
|
|
|
|
registry = ToolRegistry()
|
|
module_name = "third_party.decorated_plugin"
|
|
scope = str((tmp_path / "decorated-profile").resolve())
|
|
registry.register_plugin_override_policy(module_name, False, scope=scope)
|
|
namespace = {"__name__": module_name, "functools": functools}
|
|
exec(
|
|
"@functools.wraps(str)\n"
|
|
"def handler(args, **kwargs):\n"
|
|
" return 'decorated'\n",
|
|
namespace,
|
|
)
|
|
handler = namespace["handler"]
|
|
registry.register(
|
|
name="decorated_plugin_tool",
|
|
toolset="decorated_plugin",
|
|
schema={
|
|
"name": "decorated_plugin_tool",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=handler,
|
|
)
|
|
|
|
assert registry.snapshot_registration(
|
|
"decorated_plugin_tool", scope=scope
|
|
) is not None
|
|
assert registry.snapshot_registration("decorated_plugin_tool") is None
|
|
|
|
|
|
def test_entrypoint_policy_uses_the_most_specific_module_prefix(tmp_path):
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from tools.registry import ToolRegistry
|
|
|
|
registry = ToolRegistry()
|
|
broad_scope = str((tmp_path / "broad").resolve())
|
|
narrow_scope = str((tmp_path / "narrow").resolve())
|
|
registry.register_plugin_override_policy("vendor", True, scope=broad_scope)
|
|
registry.register_plugin_override_policy(
|
|
"vendor.plugin", False, scope=narrow_scope
|
|
)
|
|
handler = eval(
|
|
"lambda args, **kwargs: 'narrow'",
|
|
{"__name__": "vendor.plugin.handlers"},
|
|
)
|
|
token = set_hermes_home_override(narrow_scope)
|
|
try:
|
|
registry.register(
|
|
name="specific_entrypoint_tool",
|
|
toolset="specific_entrypoint",
|
|
schema={
|
|
"name": "specific_entrypoint_tool",
|
|
"parameters": {"type": "object", "properties": {}},
|
|
},
|
|
handler=handler,
|
|
)
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
assert registry.snapshot_registration(
|
|
"specific_entrypoint_tool", scope=narrow_scope
|
|
) is not None
|
|
assert registry.snapshot_registration(
|
|
"specific_entrypoint_tool", scope=broad_scope
|
|
) is None
|
|
|
|
|
|
def test_targeted_unload_does_not_resurrect_an_older_override():
|
|
"""Removing A under B tombstones A so B cannot restore it later."""
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
name = "ledger_out_of_order_platform"
|
|
scope = platform_registry.current_scope_key()
|
|
previous = platform_registry.snapshot_registration(name, scope=scope)
|
|
manager_a = PluginManager(scope_key=scope)
|
|
manager_b = PluginManager(scope_key=scope)
|
|
context_a = PluginContext(
|
|
PluginManifest(name="ledger_old", key="ledger_old"), manager_a
|
|
)
|
|
context_b = PluginContext(
|
|
PluginManifest(name="ledger_new", key="ledger_new"), manager_b
|
|
)
|
|
|
|
try:
|
|
context_a.register_platform(
|
|
name=name,
|
|
label="old",
|
|
adapter_factory=lambda config: "old",
|
|
check_fn=lambda: True,
|
|
)
|
|
old_entry = platform_registry.get(name)
|
|
context_b.register_platform(
|
|
name=name,
|
|
label="new",
|
|
adapter_factory=lambda config: "new",
|
|
check_fn=lambda: True,
|
|
)
|
|
new_entry = platform_registry.get(name)
|
|
|
|
assert old_entry is not None and new_entry is not None
|
|
assert manager_a.unload("ledger_old") is True
|
|
assert platform_registry.get(name) is new_entry
|
|
assert name not in manager_a._plugin_platform_names
|
|
assert manager_b.unload("ledger_new") is True
|
|
assert platform_registry.get(name) is not old_entry
|
|
assert platform_registry.snapshot_registration(name, scope=scope) == previous
|
|
finally:
|
|
current = platform_registry.snapshot_registration(name, scope=scope)
|
|
platform_registry.restore_registration(
|
|
name, current, previous, scope=scope
|
|
)
|
|
|
|
|
|
def test_manager_local_override_does_not_resurrect_after_targeted_unload():
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
manager = PluginManager()
|
|
context_a = PluginContext(PluginManifest(name="local_a", key="local_a"), manager)
|
|
context_b = PluginContext(PluginManifest(name="local_b", key="local_b"), manager)
|
|
|
|
context_a.register_cli_command("shared-local", "A", lambda parser: None)
|
|
context_b.register_cli_command("shared-local", "B", lambda parser: None)
|
|
|
|
assert manager.unload("local_a") is True
|
|
assert manager._cli_commands["shared-local"]["plugin"] == "local_b"
|
|
assert manager.unload("local_b") is True
|
|
assert "shared-local" not in manager._cli_commands
|
|
|
|
|
|
def test_provider_overlay_switches_profiles_and_reveals_fresh_global_fallback(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""Provider consumers see A→B→A, and unload never pins a stale base."""
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
import agent.image_gen_registry as image_registry
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
class Provider(ImageGenProvider):
|
|
def __init__(self, marker):
|
|
self.marker = marker
|
|
|
|
@property
|
|
def name(self):
|
|
return "ledger_profile_provider"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kwargs):
|
|
return {"marker": self.marker}
|
|
|
|
name = "ledger_profile_provider"
|
|
baseline = image_registry.snapshot_registration(name)
|
|
global_a = Provider("global-a")
|
|
global_b = Provider("global-b")
|
|
provider_a = Provider("profile-a")
|
|
provider_b = Provider("profile-b")
|
|
home_a = str((tmp_path / "provider-a").resolve())
|
|
home_b = str((tmp_path / "provider-b").resolve())
|
|
manager_a = PluginManager(scope_key=home_a)
|
|
manager_b = PluginManager(scope_key=home_b)
|
|
context_a = PluginContext(PluginManifest(name="provider_a", key="provider_a"), manager_a)
|
|
context_b = PluginContext(PluginManifest(name="provider_b", key="provider_b"), manager_b)
|
|
monkeypatch.setattr(
|
|
"hermes_cli.config.load_config_readonly",
|
|
lambda: {"image_gen": {"provider": name}},
|
|
)
|
|
|
|
def active_for(home):
|
|
token = set_hermes_home_override(home)
|
|
try:
|
|
return image_registry.get_active_provider()
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
image_registry.register_provider(global_a)
|
|
try:
|
|
assert context_a.register_image_gen_provider(provider_a) is not None
|
|
assert context_b.register_image_gen_provider(provider_b) is not None
|
|
assert active_for(home_a) is provider_a
|
|
assert active_for(home_b) is provider_b
|
|
assert active_for(home_a) is provider_a
|
|
|
|
assert manager_a.unload("provider_a") is True
|
|
assert active_for(home_a) is global_a
|
|
image_registry.register_provider(global_b)
|
|
assert active_for(home_a) is global_b
|
|
assert active_for(home_b) is provider_b
|
|
|
|
assert manager_b.unload("provider_b") is True
|
|
assert active_for(home_b) is global_b
|
|
finally:
|
|
manager_a.unload("provider_a")
|
|
manager_b.unload("provider_b")
|
|
current = image_registry.snapshot_registration(name)
|
|
if current is not None:
|
|
image_registry.restore_registration(name, current, baseline)
|
|
|
|
|
|
def test_reused_provider_singleton_keeps_registration_generations_distinct():
|
|
"""Reusing one object after unload must not revive its retired generation."""
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
from agent.image_gen_registry import (
|
|
get_provider,
|
|
restore_registration,
|
|
snapshot_registration,
|
|
)
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
class ProbeProvider(ImageGenProvider):
|
|
def __init__(self, marker):
|
|
self.marker = marker
|
|
|
|
@property
|
|
def name(self):
|
|
return "ledger_generation_provider"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kwargs):
|
|
return {"marker": self.marker}
|
|
|
|
managers = [PluginManager() for _ in range(4)]
|
|
scope = managers[0].scope_key
|
|
baseline = get_provider("ledger_generation_provider", scope=scope)
|
|
baseline_local = snapshot_registration("ledger_generation_provider", scope=scope)
|
|
provider_a = ProbeProvider("a")
|
|
provider_b = ProbeProvider("b")
|
|
provider_c = ProbeProvider("c")
|
|
contexts = [
|
|
PluginContext(
|
|
PluginManifest(name=f"provider_{index}", key=f"provider_{index}"),
|
|
manager,
|
|
)
|
|
for index, manager in enumerate(managers)
|
|
]
|
|
|
|
try:
|
|
contexts[0].register_image_gen_provider(provider_a)
|
|
contexts[1].register_image_gen_provider(provider_b)
|
|
managers[0].unload("provider_0")
|
|
assert get_provider(provider_a.name, scope=scope) is provider_b
|
|
|
|
# A fresh ownership generation deliberately reuses the same singleton.
|
|
contexts[2].register_image_gen_provider(provider_a)
|
|
contexts[3].register_image_gen_provider(provider_c)
|
|
managers[3].unload("provider_3")
|
|
assert get_provider(provider_a.name, scope=scope) is provider_a
|
|
managers[2].unload("provider_2")
|
|
assert get_provider(provider_a.name, scope=scope) is provider_b
|
|
managers[1].unload("provider_1")
|
|
assert get_provider(provider_a.name, scope=scope) is baseline
|
|
finally:
|
|
current = snapshot_registration("ledger_generation_provider", scope=scope)
|
|
if current is not baseline_local and current is not None:
|
|
restore_registration(
|
|
"ledger_generation_provider", current, baseline_local, scope=scope
|
|
)
|
|
|
|
|
|
def test_same_provider_singleton_can_have_two_live_owners():
|
|
"""Retiring an older identical lease must not remove the newer owner."""
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
from agent.image_gen_registry import (
|
|
get_provider,
|
|
restore_registration,
|
|
snapshot_registration,
|
|
)
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
class SharedProvider(ImageGenProvider):
|
|
@property
|
|
def name(self):
|
|
return "ledger_shared_singleton"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kwargs):
|
|
return {"prompt": prompt}
|
|
|
|
provider = SharedProvider()
|
|
manager_a = PluginManager()
|
|
manager_b = PluginManager()
|
|
scope = manager_a.scope_key
|
|
baseline = get_provider("ledger_shared_singleton", scope=scope)
|
|
baseline_local = snapshot_registration("ledger_shared_singleton", scope=scope)
|
|
context_a = PluginContext(PluginManifest(name="shared_a", key="shared_a"), manager_a)
|
|
context_b = PluginContext(PluginManifest(name="shared_b", key="shared_b"), manager_b)
|
|
|
|
try:
|
|
assert context_a.register_image_gen_provider(provider) is not None
|
|
assert context_b.register_image_gen_provider(provider) is not None
|
|
manager_a.unload("shared_a")
|
|
assert get_provider(provider.name, scope=scope) is provider
|
|
manager_b.unload("shared_b")
|
|
assert get_provider(provider.name, scope=scope) is baseline
|
|
finally:
|
|
current = snapshot_registration(provider.name, scope=scope)
|
|
if current is not baseline_local and current is not None:
|
|
restore_registration(
|
|
provider.name, current, baseline_local, scope=scope
|
|
)
|
|
|
|
|
|
def test_provider_cleanup_uses_the_captured_normalized_name():
|
|
"""Whitespace and later name mutation cannot orphan a provider entry."""
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
from agent.image_gen_registry import (
|
|
get_provider,
|
|
restore_registration,
|
|
snapshot_registration,
|
|
)
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
class MutableProvider(ImageGenProvider):
|
|
def __init__(self):
|
|
self._name = " ledger_mutable_provider "
|
|
|
|
@property
|
|
def name(self):
|
|
return self._name
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kwargs):
|
|
return {"prompt": prompt}
|
|
|
|
key = "ledger_mutable_provider"
|
|
provider = MutableProvider()
|
|
manager = PluginManager()
|
|
scope = manager.scope_key
|
|
baseline = get_provider(key, scope=scope)
|
|
baseline_local = snapshot_registration(key, scope=scope)
|
|
context = PluginContext(
|
|
PluginManifest(name="mutable_provider", key="mutable_provider"), manager
|
|
)
|
|
try:
|
|
assert context.register_image_gen_provider(provider) is not None
|
|
assert get_provider(key, scope=scope) is provider
|
|
provider._name = "renamed_after_registration"
|
|
manager.unload("mutable_provider")
|
|
assert get_provider(key, scope=scope) is baseline
|
|
finally:
|
|
current = snapshot_registration(key, scope=scope)
|
|
if current is not baseline_local and current is not None:
|
|
restore_registration(key, current, baseline_local, scope=scope)
|
|
|
|
|
|
def test_registration_transaction_excludes_concurrent_disposal(monkeypatch):
|
|
"""A lease cannot be retired between another generation's write/acquire."""
|
|
from agent.image_gen_provider import ImageGenProvider
|
|
import agent.image_gen_registry as image_registry
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
class Provider(ImageGenProvider):
|
|
def __init__(self, marker):
|
|
self.marker = marker
|
|
|
|
@property
|
|
def name(self):
|
|
return "ledger_transaction_provider"
|
|
|
|
def generate(self, prompt, aspect_ratio="landscape", **kwargs):
|
|
return {"marker": self.marker}
|
|
|
|
provider_a = Provider("a")
|
|
provider_b = Provider("b")
|
|
manager_a = PluginManager()
|
|
manager_b = PluginManager()
|
|
scope = manager_a.scope_key
|
|
baseline = image_registry.get_provider("ledger_transaction_provider", scope=scope)
|
|
baseline_local = image_registry.snapshot_registration(
|
|
"ledger_transaction_provider", scope=scope
|
|
)
|
|
context_a = PluginContext(PluginManifest(name="tx_a", key="tx_a"), manager_a)
|
|
context_b = PluginContext(PluginManifest(name="tx_b", key="tx_b"), manager_b)
|
|
context_a.register_image_gen_provider(provider_a)
|
|
|
|
original_register = image_registry.register_provider
|
|
wrote = Event()
|
|
release = Event()
|
|
|
|
def paused_register(provider, *, scope=None):
|
|
original_register(provider, scope=scope)
|
|
if provider is provider_b:
|
|
wrote.set()
|
|
assert release.wait(timeout=2)
|
|
|
|
monkeypatch.setattr(image_registry, "register_provider", paused_register)
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
registration = pool.submit(context_b.register_image_gen_provider, provider_b)
|
|
assert wrote.wait(timeout=1)
|
|
disposal = pool.submit(manager_a.unload, "tx_a")
|
|
try:
|
|
disposal.result(timeout=0.05)
|
|
except TimeoutError:
|
|
pass
|
|
else:
|
|
raise AssertionError("dispose interleaved with registration transaction")
|
|
release.set()
|
|
assert registration.result(timeout=1) is not None
|
|
assert disposal.result(timeout=1) is True
|
|
|
|
assert image_registry.get_provider(provider_a.name, scope=scope) is provider_b
|
|
manager_b.unload("tx_b")
|
|
assert image_registry.get_provider(provider_a.name, scope=scope) is baseline
|
|
finally:
|
|
release.set()
|
|
current = image_registry.snapshot_registration(
|
|
"ledger_transaction_provider", scope=scope
|
|
)
|
|
if current is not baseline_local and current is not None:
|
|
image_registry.restore_registration(
|
|
"ledger_transaction_provider",
|
|
current,
|
|
baseline_local,
|
|
scope=scope,
|
|
)
|
|
|
|
|
|
def test_deferred_platform_resolution_is_atomic_across_threads():
|
|
"""Concurrent first lookups both observe the materialized adapter."""
|
|
from gateway.platform_registry import PlatformEntry, PlatformRegistry
|
|
|
|
registry = PlatformRegistry()
|
|
entry = PlatformEntry(
|
|
name="threaded_platform",
|
|
label="Threaded",
|
|
adapter_factory=lambda config: object(),
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
)
|
|
loads = []
|
|
started = Event()
|
|
release = Event()
|
|
|
|
def load():
|
|
loads.append(1)
|
|
started.set()
|
|
assert release.wait(timeout=2)
|
|
registry.register(entry)
|
|
|
|
registry.register_deferred("threaded_platform", load)
|
|
with ThreadPoolExecutor(max_workers=3) as pool:
|
|
first = pool.submit(registry.get, "threaded_platform")
|
|
assert started.wait(timeout=1)
|
|
assert registry.is_registered("threaded_platform")
|
|
second = pool.submit(registry.get, "threaded_platform")
|
|
try:
|
|
second.result(timeout=0.05)
|
|
except TimeoutError:
|
|
pass
|
|
else:
|
|
raise AssertionError("concurrent lookup did not wait for materialization")
|
|
enumeration = pool.submit(registry.all_entries)
|
|
try:
|
|
enumeration.result(timeout=0.05)
|
|
except TimeoutError:
|
|
pass
|
|
else:
|
|
raise AssertionError("enumeration omitted an in-flight platform")
|
|
release.set()
|
|
results = [first.result(timeout=1), second.result(timeout=1)]
|
|
assert enumeration.result(timeout=1) == [entry]
|
|
|
|
assert results == [entry, entry]
|
|
assert loads == [1]
|
|
|
|
|
|
def test_deferred_platform_recursive_lookup_does_not_deadlock():
|
|
"""A loader that asks for its own entry fails fast until registration."""
|
|
from gateway.platform_registry import PlatformEntry, PlatformRegistry
|
|
|
|
registry = PlatformRegistry()
|
|
nested_results = []
|
|
entry = PlatformEntry(
|
|
name="recursive_platform",
|
|
label="Recursive",
|
|
adapter_factory=lambda config: object(),
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
)
|
|
|
|
def load():
|
|
nested_results.append(registry.get("recursive_platform"))
|
|
registry.register(entry)
|
|
|
|
registry.register_deferred("recursive_platform", load)
|
|
assert registry.get("recursive_platform") is entry
|
|
assert nested_results == [None]
|
|
|
|
|
|
def test_resolved_deferred_platform_restores_its_displaced_loader():
|
|
"""Deferred-to-concrete loading remains one replacement chain."""
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
name = "ledger_transfer"
|
|
scope = platform_registry.current_scope_key()
|
|
previous = platform_registry.snapshot_registration(name, scope=scope)
|
|
old_loader = lambda: None
|
|
platform_registry.register_deferred(name, old_loader, scope=scope)
|
|
displaced = platform_registry.snapshot_registration(name, scope=scope)
|
|
manager = PluginManager(scope_key=scope)
|
|
manifest = PluginManifest(
|
|
name=f"{name}-platform",
|
|
key=f"{name}-platform",
|
|
source="bundled",
|
|
path="unused",
|
|
)
|
|
|
|
def load_scoped(self, loaded_manifest):
|
|
PluginContext(loaded_manifest, self).register_platform(
|
|
name=name,
|
|
label="Transferred",
|
|
adapter_factory=lambda config: object(),
|
|
check_fn=lambda: True,
|
|
)
|
|
|
|
manager._load_plugin_scoped = MethodType(load_scoped, manager)
|
|
try:
|
|
manager._register_deferred_platform(manifest)
|
|
entry = platform_registry.get(name)
|
|
assert entry is not None and entry.label == "Transferred"
|
|
manager.unload(manifest)
|
|
assert platform_registry.snapshot_registration(name, scope=scope) == displaced
|
|
finally:
|
|
current = platform_registry.snapshot_registration(name, scope=scope)
|
|
platform_registry.restore_registration(
|
|
name, current, previous, scope=scope
|
|
)
|
|
|
|
|
|
def test_unload_cancels_a_deferred_platform_before_module_load():
|
|
"""Losing the in-flight race cannot publish registrations after unload."""
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
name = "ledger_cancel"
|
|
scope = platform_registry.current_scope_key()
|
|
previous = platform_registry.snapshot_registration(name, scope=scope)
|
|
def old_loader():
|
|
from gateway.platform_registry import PlatformEntry
|
|
|
|
platform_registry.register(
|
|
PlatformEntry(
|
|
name=name,
|
|
label="Restored predecessor",
|
|
adapter_factory=lambda config: object(),
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
),
|
|
scope=scope,
|
|
)
|
|
platform_registry.register_deferred(name, old_loader, scope=scope)
|
|
displaced = platform_registry.snapshot_registration(name, scope=scope)
|
|
manager = PluginManager(scope_key=scope)
|
|
manifest = PluginManifest(
|
|
name=f"{name}-platform",
|
|
key=f"{name}-platform",
|
|
source="bundled",
|
|
path="unused",
|
|
)
|
|
|
|
def load_scoped(self, loaded_manifest):
|
|
PluginContext(loaded_manifest, self).register_platform(
|
|
name=name,
|
|
label="Should not publish",
|
|
adapter_factory=lambda config: object(),
|
|
check_fn=lambda: True,
|
|
)
|
|
|
|
manager._load_plugin_scoped = MethodType(load_scoped, manager)
|
|
manager._register_deferred_platform(manifest)
|
|
try:
|
|
with ThreadPoolExecutor(max_workers=1) as pool:
|
|
with manager._discovery_lock:
|
|
lookup = pool.submit(platform_registry.get, name)
|
|
deadline = monotonic() + 1
|
|
while (scope, name) not in platform_registry._inflight:
|
|
if monotonic() >= deadline:
|
|
raise AssertionError("deferred loader never became in-flight")
|
|
sleep(0.001)
|
|
assert manager.unload(manifest) is True
|
|
restored_entry = lookup.result(timeout=1)
|
|
assert restored_entry is not None
|
|
assert restored_entry.label == "Restored predecessor"
|
|
assert platform_registry.snapshot_registration(name, scope=scope)[0] is restored_entry
|
|
assert name not in manager._plugin_platform_names
|
|
finally:
|
|
current = platform_registry.snapshot_registration(name, scope=scope)
|
|
platform_registry.restore_registration(
|
|
name, current, previous, scope=scope
|
|
)
|
|
|
|
|
|
def test_direct_plugin_platform_registration_infers_immutable_scope(tmp_path):
|
|
"""The documented direct registry API cannot leak into another profile."""
|
|
from gateway.platform_registry import PlatformEntry, platform_registry
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from tools.registry import registry as tool_registry
|
|
|
|
home_a = str((tmp_path / "direct-a").resolve())
|
|
home_b = tmp_path / "direct-b"
|
|
module_name = "company.hermes.direct_platform_probe"
|
|
policy = tool_registry.register_plugin_override_policy(
|
|
module_name, False, scope=home_a
|
|
)
|
|
factory = eval(
|
|
"lambda config: 'direct-a'",
|
|
{"__name__": f"{module_name}.handlers"},
|
|
)
|
|
name = "ledger_direct_platform"
|
|
previous_a = platform_registry.snapshot_registration(name, scope=home_a)
|
|
previous_global = platform_registry.snapshot_registration(name)
|
|
token = set_hermes_home_override(home_b)
|
|
try:
|
|
platform_registry.register(
|
|
PlatformEntry(
|
|
name=name,
|
|
label="Direct A",
|
|
adapter_factory=factory,
|
|
check_fn=lambda: True,
|
|
source="plugin",
|
|
)
|
|
)
|
|
assert platform_registry.get(name) is None
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
token = set_hermes_home_override(home_a)
|
|
try:
|
|
assert platform_registry.get(name).label == "Direct A"
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
current_a = platform_registry.snapshot_registration(name, scope=home_a)
|
|
platform_registry.restore_registration(
|
|
name, current_a, previous_a, scope=home_a
|
|
)
|
|
current_global = platform_registry.snapshot_registration(name)
|
|
if current_global != previous_global:
|
|
platform_registry.restore_registration(
|
|
name, current_global, previous_global
|
|
)
|
|
tool_registry.restore_plugin_override_policy(
|
|
module_name, policy, None, scope=home_a
|
|
)
|
|
|
|
|
|
def test_same_name_tool_and_platform_are_isolated_by_hermes_home(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""Real A→B→A profile switching keeps dispatch and adapters isolated."""
|
|
import hermes_cli.plugins as plugins_mod
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from hermes_cli.plugins import PluginManager
|
|
from tools.registry import registry
|
|
|
|
home_a = tmp_path / "profile-a"
|
|
home_b = tmp_path / "profile-b"
|
|
_write_profile_probe(home_a, "profile-a")
|
|
_write_profile_probe(home_b, "profile-b")
|
|
monkeypatch.setattr(
|
|
plugins_mod,
|
|
"get_bundled_plugins_dir",
|
|
lambda: tmp_path / "empty-bundled",
|
|
)
|
|
monkeypatch.setattr(PluginManager, "_scan_entry_points", lambda self: [])
|
|
|
|
def load_profile(home: Path):
|
|
token = set_hermes_home_override(home)
|
|
try:
|
|
manager = plugins_mod.get_plugin_manager()
|
|
manager.discover_and_load()
|
|
tool_entry = registry.get_entry("shared_profile_tool")
|
|
platform_entry = platform_registry.get("shared_profile_platform")
|
|
return manager, tool_entry, platform_entry
|
|
finally:
|
|
reset_hermes_home_override(token)
|
|
|
|
manager_a, tool_a, platform_a = load_profile(home_a)
|
|
manager_b, tool_b, platform_b = load_profile(home_b)
|
|
|
|
assert manager_a is not manager_b
|
|
assert tool_a is not None and tool_b is not None and tool_a is not tool_b
|
|
assert platform_a is not None and platform_b is not None and platform_a is not platform_b
|
|
|
|
token_a = set_hermes_home_override(home_a)
|
|
try:
|
|
assert registry.dispatch("shared_profile_tool", {}) == "profile-a"
|
|
assert platform_registry.get("shared_profile_platform") is platform_a
|
|
finally:
|
|
reset_hermes_home_override(token_a)
|
|
|
|
token_b = set_hermes_home_override(home_b)
|
|
try:
|
|
assert registry.dispatch("shared_profile_tool", {}) == "profile-b"
|
|
assert platform_registry.get("shared_profile_platform") is platform_b
|
|
finally:
|
|
reset_hermes_home_override(token_b)
|
|
|
|
token_a = set_hermes_home_override(home_a)
|
|
try:
|
|
assert registry.dispatch("shared_profile_tool", {}) == "profile-a"
|
|
assert platform_registry.get("shared_profile_platform") is platform_a
|
|
finally:
|
|
reset_hermes_home_override(token_a)
|
|
|
|
|
|
def test_manager_discovery_uses_its_home_not_the_ambient_profile(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""A retained manager cannot scan another concurrently active profile."""
|
|
import hermes_cli.plugins as plugins_mod
|
|
from gateway.platform_registry import platform_registry
|
|
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
|
from hermes_cli.plugins import PluginManager
|
|
from tools.registry import registry
|
|
|
|
home_a = tmp_path / "retained-a"
|
|
home_b = tmp_path / "ambient-b"
|
|
_write_profile_probe(home_a, "retained-a")
|
|
_write_profile_probe(home_b, "ambient-b")
|
|
monkeypatch.setattr(
|
|
plugins_mod,
|
|
"get_bundled_plugins_dir",
|
|
lambda: tmp_path / "empty-bundled",
|
|
)
|
|
monkeypatch.setattr(PluginManager, "_scan_entry_points", lambda self: [])
|
|
|
|
manager_a = PluginManager(scope_key=str(home_a.resolve()))
|
|
ambient = set_hermes_home_override(home_b)
|
|
try:
|
|
manager_a.discover_and_load()
|
|
finally:
|
|
reset_hermes_home_override(ambient)
|
|
|
|
tool_a = registry.get_entry("shared_profile_tool", scope=manager_a.scope_key)
|
|
platform_a = platform_registry.snapshot_registration(
|
|
"shared_profile_platform", scope=manager_a.scope_key
|
|
)[0]
|
|
assert tool_a is not None and tool_a.handler({}) == "retained-a"
|
|
assert platform_a is not None and platform_a.label == "retained-a"
|
|
assert registry.snapshot_registration(
|
|
"shared_profile_tool", scope=str(home_b.resolve())
|
|
) is None
|
|
assert platform_registry.snapshot_registration(
|
|
"shared_profile_platform", scope=str(home_b.resolve())
|
|
) == (None, None)
|
|
|
|
|
|
def test_same_slug_profiles_allocate_distinct_modules_concurrently(
|
|
tmp_path,
|
|
monkeypatch,
|
|
):
|
|
"""Policy binding and import use one atomic profile-specific namespace."""
|
|
import hermes_cli.plugins as plugins_mod
|
|
from hermes_cli.plugins import PluginManager
|
|
|
|
home_a = tmp_path / "concurrent-a"
|
|
home_b = tmp_path / "concurrent-b"
|
|
_write_profile_probe(home_a, "concurrent-a")
|
|
_write_profile_probe(home_b, "concurrent-b")
|
|
monkeypatch.setattr(
|
|
plugins_mod,
|
|
"get_bundled_plugins_dir",
|
|
lambda: tmp_path / "empty-bundled",
|
|
)
|
|
monkeypatch.setattr(PluginManager, "_scan_entry_points", lambda self: [])
|
|
managers = [
|
|
PluginManager(scope_key=str(home_a.resolve())),
|
|
PluginManager(scope_key=str(home_b.resolve())),
|
|
]
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as pool:
|
|
list(pool.map(lambda manager: manager.discover_and_load(), managers))
|
|
|
|
modules = [manager._plugins["profile_probe"].module.__name__ for manager in managers]
|
|
assert modules[0] != modules[1]
|
|
|
|
|
|
def test_spawned_supervised_task_is_cancelled_on_unload():
|
|
"""A plugin-spawned background task is tracked and cancelled on unload."""
|
|
import asyncio
|
|
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
manager = PluginManager()
|
|
manifest = PluginManifest(
|
|
name="task_probe", version="0.1", description="", source="user",
|
|
)
|
|
ctx = PluginContext(manifest, manager)
|
|
cancelled = []
|
|
|
|
async def scenario():
|
|
async def forever():
|
|
try:
|
|
await asyncio.sleep(3600)
|
|
except asyncio.CancelledError:
|
|
cancelled.append(True)
|
|
raise
|
|
|
|
task = ctx.spawn_task(forever(), name="probe-task")
|
|
await asyncio.sleep(0)
|
|
kinds = {
|
|
registration.kind
|
|
for registration in manager._ownership_ledger["task_probe"]
|
|
}
|
|
assert "background_task" in kinds
|
|
assert manager.unload("task_probe") is True
|
|
with __import__("pytest").raises(asyncio.CancelledError):
|
|
await task
|
|
# Done-callback disposal removes the handle from the ledger.
|
|
await asyncio.sleep(0)
|
|
assert "task_probe" not in manager._ownership_ledger
|
|
|
|
asyncio.run(scenario())
|
|
assert cancelled == [True]
|
|
|
|
|
|
def test_on_unload_exception_does_not_block_other_teardown():
|
|
"""A raising on_unload callback is isolated; later cleanup still runs."""
|
|
from hermes_cli.plugins import PluginContext, PluginManager, PluginManifest
|
|
|
|
manager = PluginManager()
|
|
manifest = PluginManifest(
|
|
name="boom_probe", version="0.1", description="", source="user",
|
|
)
|
|
ctx = PluginContext(manifest, manager)
|
|
order = []
|
|
|
|
ctx.on_unload(lambda: order.append("first"))
|
|
|
|
def _boom():
|
|
order.append("boom")
|
|
raise RuntimeError("cleanup failed")
|
|
|
|
ctx.on_unload(_boom)
|
|
ctx.on_unload(lambda: order.append("last"))
|
|
|
|
assert manager.unload("boom_probe") is True
|
|
# Reverse acquisition order, exception isolated.
|
|
assert order == ["last", "boom", "first"]
|
|
assert "boom_probe" not in manager._ownership_ledger
|