diff --git a/contributors/emails/adrian.soto6@gmail.com b/contributors/emails/adrian.soto6@gmail.com new file mode 100644 index 000000000000..4f3d6b503411 --- /dev/null +++ b/contributors/emails/adrian.soto6@gmail.com @@ -0,0 +1,2 @@ +adriansotomora +# PR #58831 salvage diff --git a/contributors/emails/yuntianqing@yahoo.com b/contributors/emails/yuntianqing@yahoo.com new file mode 100644 index 000000000000..cedb9d102bae --- /dev/null +++ b/contributors/emails/yuntianqing@yahoo.com @@ -0,0 +1,2 @@ +zengzheqing +# PR #55631 salvage diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 9bdb87691df1..01eb6a758a57 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -14456,14 +14456,14 @@ def main(): install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))) return if action == "status": - import shutil import subprocess - from hermes_cli.tools_config import _cua_driver_cmd - # Honor HERMES_CUA_DRIVER_CMD for local-build testing — same - # resolver `install_cua_driver` and the runtime backend use, - # so `status` reports what `computer_use` will actually invoke. - driver_cmd = _cua_driver_cmd() - path = shutil.which(driver_cmd) + from tools.computer_use.cua_backend import ( + cua_driver_update_check, + resolve_cua_driver_cmd, + ) + # Must match the runtime resolver: Desktop/TUI processes can omit + # ~/.local/bin even though the official installer put the driver there. + path = resolve_cua_driver_cmd() if path: version = "" try: @@ -14480,7 +14480,6 @@ def main(): else: print(f"cua-driver: installed at {path}") try: - from tools.computer_use.cua_backend import cua_driver_update_check st = cua_driver_update_check() if st and st.get("update_available"): latest = st.get("latest_version") or "?" diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 9c82d5a17257..f3abfb4d9a94 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -649,10 +649,17 @@ TOOLSET_ENV_REQUIREMENTS = { def _cua_driver_cmd() -> str: - """Return the cua-driver executable name/path, honoring non-empty overrides.""" + """Return the configured cua-driver override, or the bare default name.""" return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" +def _resolved_cua_driver_cmd() -> Optional[str]: + """Resolve cua-driver exactly as the runtime and Desktop status do.""" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd + + return resolve_cua_driver_cmd() + + def _cua_driver_env() -> dict: """cua-driver child env with the Hermes telemetry policy applied. @@ -824,7 +831,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: fetch_tool = "powershell" if is_windows else "curl" driver_cmd = _cua_driver_cmd() - binary = shutil.which(driver_cmd) + binary = _resolved_cua_driver_cmd() # Not installed → fresh install path (only when caller asked for it). if not binary and not upgrade: @@ -849,7 +856,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: if binary and not upgrade: try: version = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -907,7 +914,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: # Show before/after version when we have a baseline. Best-effort. try: before = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -920,7 +927,7 @@ def install_cua_driver(upgrade: bool = False) -> bool: if ok and before: try: after = subprocess.run( - [driver_cmd, "--version"], + [binary, "--version"], capture_output=True, text=True, timeout=5, env=_cua_driver_env(), creationflags=_post_setup_no_window_flags(), ).stdout.strip() @@ -2672,7 +2679,7 @@ _POST_SETUP_INSTALLED: dict = { # entry when (a) the post_setup is the ONLY install side-effect for # a no-key provider, and (b) an installed-state check is cheap and # doesn't trigger a heavy import. - "cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())), + "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, } @@ -2730,7 +2737,7 @@ _POST_SETUP_READY: dict = { "agent_browser": lambda: _agent_browser_installed(), "browserbase": lambda: _cloud_agent_browser_installed(), "camofox": lambda: _camofox_installed(), - "cua_driver": lambda: bool(shutil.which(_cua_driver_cmd())), + "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, } diff --git a/tests/computer_use/test_cua_cli_fallback_env.py b/tests/computer_use/test_cua_cli_fallback_env.py index 1f3180dfe4fc..5a4b04fdda32 100644 --- a/tests/computer_use/test_cua_cli_fallback_env.py +++ b/tests/computer_use/test_cua_cli_fallback_env.py @@ -31,8 +31,12 @@ def _fake_completed_process(stdout: str) -> MagicMock: def test_cli_fallback_strips_provider_secret_from_subprocess_env(monkeypatch): - monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-super-secret-should-not-leak") + monkeypatch.setenv("ANTHROPIC_API_KEY", "«redacted:sk-…»") monkeypatch.setenv("PATH", "/usr/bin:/bin") + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) captured = {} @@ -56,6 +60,10 @@ def test_cli_fallback_applies_telemetry_policy(monkeypatch): """The env should also go through cua_driver_child_env(), like every other cua-driver spawn site, not just _sanitize_subprocess_env alone.""" monkeypatch.delenv("HERMES_CUA_TELEMETRY", raising=False) + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) captured = {} def fake_run(cmd, **kwargs): diff --git a/tests/computer_use/test_cua_spawn_env_sanitization.py b/tests/computer_use/test_cua_spawn_env_sanitization.py index 98be4176bf5c..2d69e0f1a53f 100644 --- a/tests/computer_use/test_cua_spawn_env_sanitization.py +++ b/tests/computer_use/test_cua_spawn_env_sanitization.py @@ -76,6 +76,11 @@ def test_update_check_sanitizes_env(monkeypatch): "latest_version": "1.0.0", "update_available": False, }) + # PATH is pinned to /usr/bin:/bin above, so the driver won't resolve; + # pin it so the check reaches the (sanitized) subprocess spawn. + monkeypatch.setattr( + cua_backend, "resolve_cua_driver_cmd", lambda *a, **k: "cua-driver" + ) monkeypatch.setattr( cua_backend.subprocess, "run", _capture_run(captured, stdout=payload) ) diff --git a/tests/computer_use/test_doctor.py b/tests/computer_use/test_doctor.py index edd2b24b20db..a912176138e7 100644 --- a/tests/computer_use/test_doctor.py +++ b/tests/computer_use/test_doctor.py @@ -20,9 +20,12 @@ shape. from __future__ import annotations import json +import sys from io import StringIO from unittest.mock import MagicMock, patch +import pytest + # ── helpers ──────────────────────────────────────────────────────────────── @@ -323,3 +326,23 @@ class TestDriverCmdResolution: doctor.run_doctor() # First (and only) which call should have used the env var. which_mock.assert_called_with("/env/path/cua-driver") + + @pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") + def test_user_local_driver_is_found_when_path_omits_it(self, tmp_path, monkeypatch): + """Doctor must inspect the same user-local driver as the runtime.""" + from tools.computer_use import doctor + + 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.doctor._drive_health_report", return_value=_ok_report()) as health, \ + patch("sys.stdout", new_callable=StringIO): + assert doctor.run_doctor() == 0 + + health.assert_called_once_with(str(driver), include=(), skip=()) diff --git a/tests/computer_use/test_permissions_resolution.py b/tests/computer_use/test_permissions_resolution.py new file mode 100644 index 000000000000..c51fc9987f2c --- /dev/null +++ b/tests/computer_use/test_permissions_resolution.py @@ -0,0 +1,32 @@ +"""Regression tests for Computer Use readiness under a thin GUI PATH.""" + +from __future__ import annotations + +import sys +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX user-local path regression") +def test_status_finds_user_local_driver_when_path_omits_it(tmp_path, monkeypatch): + """Desktop status must agree with the runtime resolver, not bare PATH.""" + from tools.computer_use import permissions + + 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.permissions.sys.platform", "darwin"), \ + patch("tools.computer_use.cua_backend.sys.platform", "darwin"), \ + patch.object(permissions, "_run", return_value=MagicMock(stdout="0.0.0")), \ + patch.object(permissions, "_doctor", return_value={"ok": True, "checks": []}), \ + patch.object(permissions, "_mac_permissions"): + status = permissions.computer_use_status() + + assert status["installed"] is True diff --git a/tests/hermes_cli/test_tools_config.py b/tests/hermes_cli/test_tools_config.py index 23d4a0956a52..0b3e84251185 100644 --- a/tests/hermes_cli/test_tools_config.py +++ b/tests/hermes_cli/test_tools_config.py @@ -1228,7 +1228,7 @@ def test_computer_use_blank_custom_driver_command_falls_back_to_default(): def test_computer_use_post_setup_respects_custom_driver_command_when_installed(): - """post_setup already-installed checks should version-probe the override.""" + """post_setup already-installed checks should version-probe the resolved override.""" def fake_which(name: str): return "/opt/bin/custom-cua" if name == "custom-cua" else None @@ -1241,7 +1241,9 @@ def test_computer_use_post_setup_respects_custom_driver_command_when_installed() _run_post_setup("cua_driver") run.assert_called_once() - assert run.call_args.args[0] == ["custom-cua", "--version"] + # Probe the resolved absolute path so thin GUI PATHs cannot reintroduce a + # bare-command miss after HERMES_CUA_DRIVER_CMD was looked up via which(). + assert run.call_args.args[0] == ["/opt/bin/custom-cua", "--version"] def test_computer_use_post_setup_missing_override_does_not_accept_default_binary(): diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 44b5f1a5dbd1..a22d325a10e4 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 @@ -1198,6 +1233,16 @@ class TestUpdateCheck: no `check-update` verb, offline, an `error` payload, or unparseable output. """ + @pytest.fixture(autouse=True) + def _driver_resolves(self): + # The update check now short-circuits to None when no driver + # resolves; CI has none installed, so pin a resolved path. + with patch( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="/usr/local/bin/cua-driver", + ): + yield + @staticmethod def _run_returning(stdout: str): fake = MagicMock() @@ -1619,13 +1664,18 @@ class TestCuaDriverSessionReconnect: assert cli_calls == [("get_window_state", {"pid": 1, "window_id": 2})] assert result["images"] == ["B64PNG"] - def test_cli_fallback_reads_screenshot_from_file(self, tmp_path): + def test_cli_fallback_reads_screenshot_from_file(self, tmp_path, monkeypatch): """_call_tool_via_cli must base64-read a screenshot written to disk (screenshot_out_file path) when no inline base64 is present.""" import base64 as _b64 from typing import Any, cast from tools.computer_use.cua_backend import _CuaDriverSession + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) + png_bytes = b"\x89PNG\r\n\x1a\nFAKEDATA" shot = tmp_path / "shot.png" shot.write_bytes(png_bytes) @@ -1666,6 +1716,10 @@ class TestCuaDriverSessionReconnect: from tools.computer_use.cua_backend import _CuaDriverSession session = cast(Any, _CuaDriverSession.__new__(_CuaDriverSession)) + monkeypatch.setattr( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + lambda: "/resolved/cua-driver", + ) class FakeProc: stdout = '{"isError": true, "message": "bad target"}' @@ -2331,8 +2385,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), \ @@ -2390,6 +2444,30 @@ class TestCuaEnvironmentScrubbing: "At least one safe environment variable should be preserved" +class TestCuaCliFallbackResolution: + def test_cli_fallback_uses_resolved_driver_under_thin_path(self): + """CLI transport must use the same resolved path as MCP startup. + + The CLI fallback runs after an MCP bridge error, precisely when a + Finder/Dock-launched Desktop process may have a PATH without + ``~/.local/bin``. Falling back to the bare ``cua-driver`` command + would reintroduce the original bug at runtime. + """ + from tools.computer_use.cua_backend import _AsyncBridge, _CuaDriverSession + + proc = MagicMock(stdout="{}", stderr="", returncode=0) + session = _CuaDriverSession(_AsyncBridge()) + with patch( + "tools.computer_use.cua_backend.resolve_cua_driver_cmd", + return_value="/Users/example/.local/bin/cua-driver", + ), patch("subprocess.run", return_value=proc) as run: + session._call_tool_via_cli("click", {"x": 1, "y": 2}, timeout=0.1) + + assert run.call_args.args[0][:3] == [ + "/Users/example/.local/bin/cua-driver", "call", "click" + ] + + class TestClickButtonPassthrough: """Surface 5 (NousResearch/hermes-agent#47072) — `middle_click` must actually reach cua-driver as a middle button, not silently degrade to @@ -2821,6 +2899,21 @@ class TestMcpInvocationResolution: assert cmd == "/opt/cua-driver" assert args == ["mcp"] + def test_manifest_bare_command_keeps_resolved_driver_path(self): + """A bare manifest command must not reintroduce the narrow-PATH bug.""" + from unittest.mock import patch + from tools.computer_use.cua_backend import _resolve_mcp_invocation + + manifest = ( + '{"mcp_invocation":' + '{"command":"cua-driver","args":["mcp-stdio","--strict"]}}' + ) + driver = "/Users/example/.local/bin/cua-driver" + with patch("subprocess.run", new=self._fake_run(stdout=manifest)): + cmd, args = _resolve_mcp_invocation(driver) + assert cmd == driver + assert args == ["mcp-stdio", "--strict"] + def test_future_renamed_subcommand_is_honored(self): """The whole point: a future cua-driver that exposes `mcp-stdio` instead of `mcp` keeps working without a Hermes patch.""" diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 8c9b4a8ddb9f..6f75f15922ef 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) @@ -334,6 +335,11 @@ def _resolve_mcp_invocation( # The driver knows the subcommand but didn't surface its own path. # Keep our resolved driver_cmd; the args are still authoritative. return driver_cmd, args + if not _has_path_separator(command): + # A manifest may legitimately retain the generic ``cua-driver`` name. + # Under a GUI's thin PATH that would lose the resolved user-local path + # and fail at MCP spawn, so preserve the concrete command we verified. + return driver_cmd, args return command, args # Regex to parse element lines from get_window_state AX tree markdown. @@ -375,9 +381,68 @@ 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(override: Optional[str] = None) -> List[str]: + """Return candidate cua-driver commands in resolution order. + + ``override`` is authoritative when supplied. Otherwise a non-empty + ``HERMES_CUA_DRIVER_CMD`` is authoritative; only when neither is set do we + use PATH and canonical install locations. + + 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 = (override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "")).strip() + 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(override: Optional[str] = None) -> Optional[str]: + """Resolve the cua-driver executable for every runtime/status surface. + + A supplied override (or ``HERMES_CUA_DRIVER_CMD``) is never silently + replaced by another binary. Otherwise resolve PATH first, then canonical + user-local installation locations used by the official installer. + """ + for candidate in _candidate_cua_driver_commands(override): + 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 +457,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 +817,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, @@ -1063,7 +1132,10 @@ class _CuaDriverSession: os.close(fd) call_args["screenshot_out_file"] = shot_file - cmd = [_CUA_DRIVER_CMD, "call", name, json.dumps(call_args)] + driver_cmd = resolve_cua_driver_cmd() + if not driver_cmd: + raise RuntimeError(cua_driver_install_hint()) + cmd = [driver_cmd, "call", name, json.dumps(call_args)] attempts = 4 backoff = 0.5 parsed: Any = None diff --git a/tools/computer_use/doctor.py b/tools/computer_use/doctor.py index 38c1b43e91d8..54fad0666844 100644 --- a/tools/computer_use/doctor.py +++ b/tools/computer_use/doctor.py @@ -17,7 +17,6 @@ from __future__ import annotations import json import os -import shutil import subprocess import sys from typing import Any, Dict, List, Optional, Sequence @@ -240,9 +239,8 @@ def run_doctor( ) -> int: """Resolve the cua-driver binary, call `health_report`, render the result. - Honors `HERMES_CUA_DRIVER_CMD` via the same `_cua_driver_cmd()` resolver - that `install_cua_driver` + the runtime backend use, so the doctor - diagnoses what your `computer_use` toolset will actually invoke. + Honors `HERMES_CUA_DRIVER_CMD` via the shared runtime resolver, so the + doctor diagnoses what your `computer_use` toolset will actually invoke. """ # Windows ships stdout/stderr wrapped with the system ANSI codec # (`cp1252` on a US locale, `cp936` on zh-CN, etc.). The check-matrix @@ -255,16 +253,12 @@ def run_doctor( stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr] except (AttributeError, OSError): pass - if driver_cmd is None: - try: - from hermes_cli.tools_config import _cua_driver_cmd - driver_cmd = _cua_driver_cmd() - except Exception: - driver_cmd = os.environ.get("HERMES_CUA_DRIVER_CMD") or "cua-driver" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd - binary = shutil.which(driver_cmd) + binary = resolve_cua_driver_cmd(driver_cmd) if not binary: - print(f"cua-driver: not installed (looked for {driver_cmd!r}).") + looked_for = driver_cmd or "cua-driver (PATH and canonical install paths)" + print(f"cua-driver: not installed (looked for {looked_for!r}).") print(" Run: hermes computer-use install") return 2 diff --git a/tools/computer_use/permissions.py b/tools/computer_use/permissions.py index d6fd0f3f91fd..4cccf2d683d5 100644 --- a/tools/computer_use/permissions.py +++ b/tools/computer_use/permissions.py @@ -25,7 +25,6 @@ from __future__ import annotations import json import os -import shutil import subprocess import sys from typing import Any, Dict, List, Optional @@ -35,15 +34,11 @@ _RUNTIME_PLATFORMS = frozenset({"darwin", "win32", "linux"}) _BOOLS = ("accessibility", "screen_recording", "screen_recording_capturable") -def _driver_cmd(override: Optional[str]) -> str: - if override: - return override - try: - from hermes_cli.tools_config import _cua_driver_cmd +def _resolve_driver_cmd(override: Optional[str]) -> Optional[str]: + """Use the runtime resolver for UI status and permission commands too.""" + from tools.computer_use.cua_backend import resolve_cua_driver_cmd - return _cua_driver_cmd() - except Exception: - return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver" + return resolve_cua_driver_cmd(override) def _child_env() -> Dict[str, str]: @@ -128,7 +123,7 @@ def computer_use_status(driver_cmd: Optional[str] = None) -> Dict[str, Any]: unknown (binary missing / probe failed). ``can_grant`` is macOS-only. """ plat = sys.platform - binary = shutil.which(_driver_cmd(driver_cmd)) + binary = _resolve_driver_cmd(driver_cmd) out: Dict[str, Any] = { "platform": plat, "platform_supported": plat in _RUNTIME_PLATFORMS, @@ -175,7 +170,7 @@ def request_permissions_grant(driver_cmd: Optional[str] = None) -> int: print("Computer Use permissions are a macOS concept; nothing to grant here.") return 64 - binary = shutil.which(_driver_cmd(driver_cmd)) + binary = _resolve_driver_cmd(driver_cmd) if not binary: print("cua-driver: not installed. Run: hermes computer-use install") return 2