From 030822b68daa40301636868e8747bcbc0eae6da5 Mon Sep 17 00:00:00 2001 From: Tianqing Yun Date: Tue, 30 Jun 2026 05:03:31 -0700 Subject: [PATCH] fix: resolve cua-driver from user-local install paths --- tests/tools/test_computer_use.py | 39 +++++++++++++++++- tools/computer_use/cua_backend.py | 67 ++++++++++++++++++++++++++++--- 2 files changed, 98 insertions(+), 8 deletions(-) diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44b5f1a5dbd1..530254b7dd7c 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -148,6 +148,41 @@ class TestRegistration: patch("tools.computer_use.cua_backend.cua_driver_binary_available", return_value=False): assert cu_tool.check_computer_use_requirements() is False + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") + def test_check_fn_finds_user_local_cua_driver_when_path_omits_it(self, tmp_path, monkeypatch): + """Desktop/TUI launched from Finder/Dock can omit ~/.local/bin from PATH. + + The cua-driver installer commonly places the binary there, so the + registry check must still expose the computer_use tool schema. + """ + from tools.computer_use import tool as cu_tool + + driver = tmp_path / ".local" / "bin" / "cua-driver" + driver.parent.mkdir(parents=True) + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.delenv("HERMES_CUA_DRIVER_CMD", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("PATH", "/usr/bin:/bin:/usr/sbin:/sbin") + + with patch("tools.computer_use.tool.sys.platform", "darwin"), \ + patch("tools.computer_use.cua_backend.sys.platform", "darwin"): + assert cu_tool.check_computer_use_requirements() is True + + def test_cua_driver_cmd_env_override_is_resolved_dynamically(self, tmp_path, monkeypatch): + from tools.computer_use import cua_backend + + driver = tmp_path / "custom-cua-driver" + driver.write_text("#!/bin/sh\nexit 0\n") + driver.chmod(0o755) + + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", str(driver)) + monkeypatch.setenv("PATH", "/usr/bin:/bin") + + assert cua_backend.resolve_cua_driver_cmd() == str(driver) + assert cua_backend.cua_driver_binary_available() is True + # --------------------------------------------------------------------------- # Dispatch & action routing @@ -2331,8 +2366,8 @@ class TestCuaEnvironmentScrubbing: return MagicMock() with patch.dict(os.environ, test_env, clear=True), \ - patch("tools.computer_use.cua_backend.cua_driver_binary_available", - return_value=True), \ + patch("tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="cua-driver"), \ patch("tools.computer_use.cua_backend._resolve_mcp_invocation", return_value=("cua-driver", ["mcp"])), \ patch("mcp.StdioServerParameters", side_effect=capture_env), \ diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8c9b4a8ddb9f..641929748a2b 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -137,7 +137,8 @@ def _action_result_from( # only have *looked* like it pinned. For a reproducible version, point # `HERMES_CUA_DRIVER_CMD` at a specific binary instead. -_CUA_DRIVER_CMD = os.environ.get("HERMES_CUA_DRIVER_CMD", "cua-driver") +_CUA_DRIVER_CMD_ENV = "HERMES_CUA_DRIVER_CMD" +_CUA_DRIVER_DEFAULT_CMD = "cua-driver" _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport (fallback when the # driver doesn't expose `manifest` — see # `_resolve_mcp_invocation` below) @@ -375,9 +376,59 @@ def _is_macos() -> bool: return sys.platform == "darwin" +def _has_path_separator(value: str) -> bool: + return os.sep in value or (os.altsep is not None and os.altsep in value) + + +def _candidate_cua_driver_commands() -> List[str]: + """Return candidate cua-driver commands in resolution order. + + Desktop apps launched from Finder/Dock often inherit a narrow PATH that + omits user-local install directories. The upstream cua-driver installer + commonly places the binary under ``~/.local/bin`` on POSIX systems, so a + Hermes Desktop/TUI session can otherwise filter out the `computer_use` + tool even though `hermes computer-use doctor` succeeds from a login shell. + """ + configured = os.environ.get(_CUA_DRIVER_CMD_ENV) + if configured: + # An explicit override is authoritative: if it is wrong, report the + # driver missing instead of silently picking a different binary. + return [configured] + + candidates = [_CUA_DRIVER_DEFAULT_CMD] + home = os.path.expanduser("~") + if sys.platform == "win32": + candidates.extend([ + os.path.join(home, ".local", "bin", "cua-driver.exe"), + os.path.join(home, ".local", "bin", "cua-driver"), + ]) + else: + candidates.extend([ + os.path.join(home, ".local", "bin", "cua-driver"), + os.path.join(home, ".cargo", "bin", "cua-driver"), + "/opt/homebrew/bin/cua-driver", + "/usr/local/bin/cua-driver", + ]) + return candidates + + +def resolve_cua_driver_cmd() -> Optional[str]: + """Resolve the cua-driver executable for PATH-constrained surfaces.""" + for candidate in _candidate_cua_driver_commands(): + expanded = os.path.expanduser(candidate) + if _has_path_separator(expanded): + if shutil.which(expanded): + return expanded + else: + resolved = shutil.which(expanded) + if resolved: + return resolved + return None + + def cua_driver_binary_available() -> bool: - """True if `cua-driver` is on $PATH or HERMES_CUA_DRIVER_CMD resolves.""" - return bool(shutil.which(_CUA_DRIVER_CMD)) + """True if `cua-driver` resolves via env, PATH, or known install paths.""" + return resolve_cua_driver_cmd() is not None def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]: @@ -392,10 +443,13 @@ def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]] ``error`` field is set), or the output didn't parse. Best-effort; never raises. """ + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: + return None try: from tools.environments.local import _sanitize_subprocess_env proc = subprocess.run( - [_CUA_DRIVER_CMD, "check-update", "--json"], + [driver_cmd, "check-update", "--json"], capture_output=True, text=True, timeout=timeout, # Some older drivers don't have the verb and fall through to a # stdin-reading mode rather than erroring — DEVNULL gives them EOF @@ -749,14 +803,15 @@ class _CuaDriverSession: self._startup_phase = "binary-check" try: - if not cua_driver_binary_available(): + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: raise RuntimeError(cua_driver_install_hint()) # Surface 8: ask cua-driver itself which subcommand spawns # the MCP server, instead of hardcoding ["mcp"]. Falls back # transparently for older drivers / any discovery failure. self._startup_phase = "manifest-discovery" - command, args = _resolve_mcp_invocation(_CUA_DRIVER_CMD) + command, args = _resolve_mcp_invocation(driver_cmd) _t_manifest = _time.monotonic() params = StdioServerParameters( command=command,