"""cua-driver binary resolution, MCP-invocation discovery, the 0.20 runtime contract gate, and the update check. Config-derived policy (``_cua_no_overlay``, ``_run_driver`` ...) is looked up lazily through ``tools.computer_use.cua_backend`` so tests that patch it there keep working; logger name parity is kept for the same reason. """ from __future__ import annotations import functools import json import logging import os import re import shutil import subprocess import sys from pathlib import PureWindowsPath from typing import Any, Dict, List, Optional, Tuple logger = logging.getLogger("tools.computer_use.cua_backend") # No version *pin* knob on purpose: the upstream installer always fetches the # latest release, so a pin var would only LOOK like it pinned. Point # HERMES_CUA_DRIVER_CMD at a specific binary instead. _CUA_DRIVER_CMD_ENV = "HERMES_CUA_DRIVER_CMD" _CUA_DRIVER_DEFAULT_CMD = "cua-driver" _CUA_DRIVER_ARGS = ["mcp"] # stdio MCP; fallback when the driver has no `manifest` verb _CUA_DRIVER_RUNTIME_CONTRACT_MIN = (0, 20, 0) _CUA_DRIVER_RUNTIME_CONTRACT_ARGS = { "mcp": {"--socket", "--grant"}, "serve": {"--socket", "--permission-mode", "--capability-manifest", "--approve-capability-manifest", "--embedded"}, "stop": {"--socket"}, } _SEMVER_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?") def _cb(): """Origin module, looked up lazily so ``patch("tools.computer_use.cua_backend.X")`` applies.""" from tools.computer_use import cua_backend return cua_backend def _driver_json(driver_cmd: str, *args: str, timeout: float, require_ok: bool) -> Optional[Dict[str, Any]]: """Run a driver verb and parse its stdout as a JSON object; None on spawn failure, empty stdout (older drivers print usage to stderr), unparseable or non-object output — and, with ``require_ok``, on a non-zero exit.""" try: proc = _cb()._run_driver(driver_cmd, *args, timeout=timeout) except Exception: return None out = (proc.stdout or "").strip() if not out or (require_ok and proc.returncode != 0): return None try: data = json.loads(out) except (ValueError, TypeError): return None return data if isinstance(data, dict) else None # --------------------------------------------------------------------------- # Binary resolution # --------------------------------------------------------------------------- def _has_path_separator(value: str) -> bool: return os.sep in value or (os.altsep is not None and os.altsep in value) def _wsl_windows_path_to_posix(path: str) -> str: """Translate a Windows absolute manifest command to its DrvFS ``/mnt//...`` form when Hermes runs in WSL (a Windows cua-driver manifest can report ``C:\\...`` while Hermes spawns via POSIX). Non-Windows paths and non-WSL hosts are returned unchanged.""" if not re.match(r"^[A-Za-z]:[\\/]", path): return path try: from hermes_constants import is_wsl if not is_wsl(): return path except Exception: return path win = PureWindowsPath(path) drive = (win.drive or "").rstrip(":").lower() if not drive: return path return os.path.join("/mnt", drive, *(str(part) for part in win.parts[1:])) def _candidate_cua_driver_commands(override: Optional[str] = None) -> List[str]: """Candidate commands in resolution order. ``override`` / a non-empty ``HERMES_CUA_DRIVER_CMD`` is authoritative (if wrong, report the driver missing rather than silently picking another binary). Otherwise PATH, then canonical installer locations — Finder/Dock-launched apps inherit a narrow PATH without ``~/.local/bin``; fresh Windows sessions inherit a stale one.""" configured = (override if override is not None else os.environ.get(_CUA_DRIVER_CMD_ENV, "")).strip() if configured: return [configured] home = os.path.expanduser("~") if sys.platform == "win32": local_app_data = os.environ.get("LOCALAPPDATA") or os.path.join(home, "AppData", "Local") installed = [ os.path.join(local_app_data, "Programs", "Cua", "cua-driver", "bin", "cua-driver.exe"), os.path.join(home, ".local", "bin", "cua-driver.exe"), os.path.join(home, ".local", "bin", "cua-driver"), ] else: installed = [ 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 [_CUA_DRIVER_DEFAULT_CMD, *installed] def resolve_cua_driver_cmd(override: Optional[str] = None) -> Optional[str]: """Resolve the cua-driver executable for every runtime/status surface. An override is never silently replaced by another binary.""" for candidate in _candidate_cua_driver_commands(override): expanded = os.path.expanduser(candidate) resolved = shutil.which(expanded) if resolved: return expanded if _has_path_separator(expanded) else resolved return None def cua_driver_binary_available() -> bool: """True if `cua-driver` resolves via env, PATH, or known install paths.""" return _cb().resolve_cua_driver_cmd() is not None def cua_driver_install_hint() -> str: scripts = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts" if sys.platform == "win32": installer = f" irm {scripts}/install.ps1 | iex" else: installer = f' /bin/bash -c "$(curl -fsSL {scripts}/install.sh)"' return ( "cua-driver is not installed. Install with one of:\n" " hermes computer-use install\n" "Or run the upstream installer directly:\n" f"{installer}\n" "Or run `hermes tools` and enable the Computer Use toolset to install it automatically." ) # --------------------------------------------------------------------------- # MCP invocation # --------------------------------------------------------------------------- def _mcp_args_with_overlay_flag( args: List[str], driver_cmd: str = _CUA_DRIVER_DEFAULT_CMD, ) -> List[str]: """Return *args* with ``--no-overlay`` appended when configured and supported.""" if _cb()._cua_no_overlay() and _cb()._cua_driver_supports_no_overlay(driver_cmd): return [*args, "--no-overlay"] return list(args) @functools.lru_cache(maxsize=1) def _cua_driver_supports_no_overlay(driver_cmd: str) -> bool: """True if `` --help`` mentions ``--no-overlay`` (probed once). Older drivers reject unknown flags, which would crash the MCP spawn.""" try: proc = _cb()._run_driver(driver_cmd, "--help", timeout=3.0) return "--no-overlay" in (proc.stdout or "") + (proc.stderr or "") except Exception: return False def _resolve_mcp_invocation(driver_cmd: str, *, timeout: float = 6.0) -> Tuple[str, List[str]]: """``(command, args)`` that spawn cua-driver's stdio MCP server, asked of the driver itself via ``cua-driver manifest`` (``mcp_invocation``) so a subcommand rename keeps working. Falls back to ``(driver_cmd, ["mcp"])`` on older drivers or any discovery failure — the wrapper must not refuse to start over a failed discovery hop. ``--no-overlay`` appended when allowed.""" manifest = _driver_json(driver_cmd, "manifest", timeout=timeout, require_ok=True) or {} invocation = manifest.get("mcp_invocation") invocation = invocation if isinstance(invocation, dict) else {} args = invocation.get("args") valid_args = isinstance(args, list) and all(isinstance(a, str) for a in args) if not valid_args: args = list(_CUA_DRIVER_ARGS) command = invocation.get("command") if valid_args else None if isinstance(command, str) and command: # Translate a Windows ``C:\...`` command for WSL BEFORE the separator # check (backslash is not a separator on POSIX). A generic ``cua-driver`` # name would lose the resolved user-local path under a GUI's thin PATH, # so only a concrete (path-bearing) command replaces the one we verified # — and THAT binary is probed for `--no-overlay`, not the system one. command = _wsl_windows_path_to_posix(command) if _has_path_separator(command): return command, _mcp_args_with_overlay_flag(args, driver_cmd=command) return driver_cmd, _mcp_args_with_overlay_flag(args, driver_cmd=driver_cmd) # --------------------------------------------------------------------------- # Runtime contract + update checking # --------------------------------------------------------------------------- # # cua-driver's native `check-update` verb compares the installed binary against # the latest GitHub release (cached ~20h); we prefer it over a hardcoded floor. def cua_driver_runtime_contract_status(binary: Optional[str] = None) -> Dict[str, Any]: """Report whether a local driver can host Hermes' 0.20 integration.""" resolved = binary or _cb().resolve_cua_driver_cmd() def _not_ready(reason: str, version: Optional[str] = None) -> Dict[str, Any]: return {"ready": False, "binary": resolved, "version": version, "reason": reason} if not resolved: return _not_ready("cua-driver is not installed") try: result = _cb()._run_driver(resolved, "manifest", timeout=15.0 if sys.platform == "win32" else 5.0) except (OSError, subprocess.SubprocessError) as exc: return _not_ready(f"manifest check failed: {exc}") if result.returncode != 0: detail = (result.stderr or result.stdout or "manifest command failed").strip() return _not_ready(detail.splitlines()[-1][:200]) try: manifest = json.loads(result.stdout or "") except (TypeError, ValueError): manifest = None if not isinstance(manifest, dict): return _not_ready("driver manifest is missing or invalid") raw_version = str(manifest.get("binary_version") or "").strip() match = _SEMVER_RE.fullmatch(raw_version) if not match: return _not_ready("driver manifest does not report a semantic version", raw_version or None) if tuple(int(part) for part in match.groups()) < _CUA_DRIVER_RUNTIME_CONTRACT_MIN: return _not_ready("Hermes computer use requires cua-driver 0.20.0 or newer", raw_version) invocation = manifest.get("mcp_invocation") invocation_args = invocation.get("args") if isinstance(invocation, dict) else None if not (invocation_args and isinstance(invocation_args, list) and all(isinstance(arg, str) for arg in invocation_args)): return _not_ready("driver manifest does not provide an MCP launch command", raw_version) advertised: Dict[str, set[str]] = { command["name"]: { arg["name"] for arg in command.get("args") or [] if isinstance(arg, dict) and isinstance(arg.get("name"), str) } for command in manifest.get("subcommands") or [] if isinstance(command, dict) and isinstance(command.get("name"), str) } missing = [ f"{command} {arg}" for command, required_args in _CUA_DRIVER_RUNTIME_CONTRACT_ARGS.items() for arg in sorted(required_args - advertised.get(command, set())) ] if missing: return _not_ready("driver manifest is missing: " + ", ".join(missing), raw_version) return {"ready": True, "binary": resolved, "version": raw_version, "reason": ""} def cua_driver_update_check(*, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]: """``cua-driver check-update --json`` payload (``{current_version, latest_version, update_available, ...}``), or ``None`` when the binary is missing, the driver predates the verb, the GitHub check failed (``error`` set) or the output didn't parse. Never raises. ``timeout`` defaults to 8s on POSIX / 25s on Windows: first spawn of the exe routinely eats seconds in Defender scanning, and callers treat ``None`` as indeterminate (the upgrade path used to fall through to a full reinstall on a false timeout).""" if timeout is None: timeout = 25.0 if sys.platform == "win32" else 8.0 driver_cmd = _cb().resolve_cua_driver_cmd() if not driver_cmd: return None data = _driver_json(driver_cmd, "check-update", "--json", timeout=timeout, require_ok=False) return None if data is None or data.get("error") else data def cua_driver_update_nudge() -> Optional[str]: """One-line "an update is available" message, or ``None`` when up to date, indeterminate, or the driver is too old to report.""" state = _cb().cua_driver_update_check() if not state or not state.get("update_available"): return None latest = state.get("latest_version") or "?" current = state.get("current_version") or "?" return ( f"cua-driver {latest} is available (you have {current}); " f"update with `hermes computer-use install --upgrade`." )