fix: resolve cua-driver from user-local install paths

This commit is contained in:
Tianqing Yun 2026-06-30 05:03:31 -07:00 committed by Brooklyn Nicholson
parent a8e6c0f853
commit 030822b68d
2 changed files with 98 additions and 8 deletions

View file

@ -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), \

View file

@ -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,