Files
hermes-agent/tests/tools/test_computer_use_backend_plugins.py
Teknium 63f5e0ea45 feat(computer-use): pluggable single-select computer-use providers
Third-party drivers (e.g. a Codex-style computer-use reimplementation) could
only replace cua-driver by patching core. computer_use now follows the
single-select provider model Hermes uses for memory providers, context
engines and cron schedulers:

- plugins/computer_use/__init__.py discovers bundled
  plugins/computer_use/<name>/ then user $HERMES_HOME/plugins/<name>/
  (marker: register_computer_use_provider / ComputerUseProvider) and loads
  ONLY the provider computer_use.backend names (default "cua"), read per
  call so each profile gets its own. Unselected providers are never
  imported; listing reads plugin.yaml only. An unknown/unloadable name
  returns an error naming it and the installed providers; no fallback.
- The built-in cua-driver is an ordinary bundled provider at
  plugins/computer_use/cua/ (driver implementation stays in
  tools/computer_use). tools/computer_use/backend.py gains
  ComputerUseProvider (create_backend(permission_mode=...), is_available(),
  optional doctor()).
- The tool's check_fn and backend construction go through the selected
  provider; the model-facing schema is unchanged for every provider
  (prompt-cache invariant). The test-only HERMES_COMPUTER_USE_BACKEND env
  switch is gone (tests patch _new_backend).
- General plugin discovery treats computer-use providers as exclusive
  (not eagerly imported, not listed as toggleable) and skips the bundled
  computer_use/ category, same as memory/, context_engine/, cron_providers/.
- hermes tools and the Desktop toolset panel list installed providers next
  to cua; computer-use doctor/status/permissions and the Desktop status
  payload skip cua-driver/TCC probes when another provider is selected.
2026-10-05 17:20:30 -07:00

131 lines
6.0 KiB
Python

"""Computer-use provider plugins: ``computer_use.backend`` selects exactly one provider per profile, only that one
is imported, the built-in ``cua`` stays the default, and an unknown name fails loudly instead of falling back."""
from __future__ import annotations
import json
import sys
from pathlib import Path
import pytest
import hermes_yaml as yaml
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
_PLUGIN = '''
from pathlib import Path
from tools.computer_use.backend import ActionResult, ComputerUseBackend, ComputerUseProvider
TAG = Path(__file__).parent.name
CALLS = []
class FakeBackend(ComputerUseBackend):
def __init__(self, mode): self.mode = mode
def start(self): CALLS.append(("start", self.mode))
def stop(self): CALLS.append(("stop", None))
def is_available(self): return True
def capture(self, mode="som", app=None, pid=None, window_id=None): raise NotImplementedError
def click(self, **kw):
CALLS.append(("click", kw.get("element")))
return ActionResult(ok=True, action="click")
def drag(self, **kw): return ActionResult(ok=True, action="drag")
def scroll(self, **kw): return ActionResult(ok=True, action="scroll")
def type_text(self, text, **kw): return ActionResult(ok=True, action="type")
def key(self, keys, **kw): return ActionResult(ok=True, action="key")
def list_apps(self): return [{"backend": TAG, "file": __file__}]
def focus_app(self, app, raise_window=False): return ActionResult(ok=True, action="focus_app")
def set_value(self, value, element=None): return ActionResult(ok=True, action="set_value")
class FakeProvider(ComputerUseProvider):
name = TAG
def create_backend(self, *, permission_mode): return FakeBackend(permission_mode)
def register(ctx):
ctx.register_computer_use_provider(FakeProvider())
'''
def _home(home: Path, plugins: tuple, backend: str | None) -> Path:
for plugin in plugins:
(plugin_dir := home / "plugins" / plugin).mkdir(parents=True, exist_ok=True)
(plugin_dir / "plugin.yaml").write_text(yaml.safe_dump({"name": plugin, "description": f"{plugin} driver"}))
(plugin_dir / "__init__.py").write_text(_PLUGIN)
home.mkdir(parents=True, exist_ok=True)
(home / "config.yaml").write_text(yaml.safe_dump({"computer_use": {"backend": backend}} if backend else {}))
return home
def _list_apps(session_id: str = "s1") -> list:
from tools.computer_use.tool import handle_computer_use
out = json.loads(handle_computer_use({"action": "list_apps"}, session_id=session_id))
assert "apps" in out, out
return out["apps"]
@pytest.fixture(autouse=True)
def _clean():
from tools.computer_use.tool import reset_backend_for_tests
reset_backend_for_tests()
yield
reset_backend_for_tests()
for name in [m for m in sys.modules if m.startswith("_hermes_user_computer_use.")]:
del sys.modules[name]
def test_selected_plugin_receives_tool_calls_and_unselected_stays_dormant(tmp_path, grant_computer_use_approvals):
home = _home(tmp_path / "home", ("cu-alpha", "cu-beta"), "cu-beta")
tok = set_hermes_home_override(str(home))
try:
from tools.computer_use.tool import check_computer_use_requirements, handle_computer_use
assert check_computer_use_requirements() is True
assert _list_apps()[0]["backend"] == "cu-beta"
assert json.loads(handle_computer_use({"action": "click", "element": 3}, session_id="s1"))["ok"] is True
calls = sys.modules["_hermes_user_computer_use.cu-beta"].CALLS
assert ("start", "standard") in calls and ("click", 3) in calls
assert "_hermes_user_computer_use.cu-alpha" not in sys.modules # installed, not selected: never imported
from hermes_cli.tools_config_providers import _computer_use_provider_rows # `hermes tools` / Desktop rows
assert [r["computer_use_backend"] for r in _computer_use_provider_rows()] == ["cu-alpha", "cu-beta"]
assert "_hermes_user_computer_use.cu-alpha" not in sys.modules # listing does not import either
finally:
reset_hermes_home_override(tok)
def test_unknown_backend_errors_naming_configured_and_available(tmp_path):
home = _home(tmp_path / "home", (), "nope")
tok = set_hermes_home_override(str(home))
try:
from tools.computer_use.tool import check_computer_use_requirements, handle_computer_use
out = json.loads(handle_computer_use({"action": "list_apps"}, session_id="s1"))
assert "'nope'" in out["error"] and "available: cua" in out["error"]
assert "hint" not in out # the cua-driver install hint would mislead here
assert check_computer_use_requirements() is True # the tool stays visible so the call can say why
finally:
reset_hermes_home_override(tok)
@pytest.mark.parametrize("backend", [None, "cua"])
def test_builtin_cua_is_default_and_plugin_never_self_activates(tmp_path, backend):
home = _home(tmp_path / "home", ("cu-alpha",), backend) # installed but not selected
tok = set_hermes_home_override(str(home))
try:
from plugins.computer_use import get_active_provider
from tools.computer_use.cua_backend import CuaDriverBackend
from tools.computer_use.tool import _new_backend
assert get_active_provider().name == "cua"
assert isinstance(_new_backend("standard"), CuaDriverBackend)
assert "_hermes_user_computer_use.cu-alpha" not in sys.modules
finally:
reset_hermes_home_override(tok)
def test_each_profile_gets_its_own_configured_backend(tmp_path):
home_a = _home(tmp_path / "profiles" / "a", ("cu-alpha",), "cu-alpha")
home_b = _home(tmp_path / "profiles" / "b", ("cu-beta",), "cu-beta")
for home, expected in ((home_a, "cu-alpha"), (home_b, "cu-beta"), (home_a, "cu-alpha")):
tok = set_hermes_home_override(str(home))
try:
app = _list_apps(session_id="shared")[0]
assert app["backend"] == expected and str(home) in app["file"]
finally:
reset_hermes_home_override(tok)