"""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 the facade.""" 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 = { # key order feeds the "manifest is missing" text "mcp": {"--socket", "--grant"}, "serve": {"--socket", "--permission-mode", "--capability-manifest", "--approve-capability-manifest", "--embedded"}, "stop": {"--socket"}, } _SEMVER_RE = re.compile(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?") _UPSTREAM_SCRIPTS = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts" def _cb(): """Facade module (config/policy helpers), looked up lazily to avoid the import cycle.""" 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.""" proc = _cb()._run_driver(driver_cmd, *args, timeout=timeout, swallow=Exception) out = (proc.stdout or "").strip() if proc is not None else "" return None if not out or (require_ok and proc.returncode != 0) else _json_object(out) def _json_object(text: str) -> Optional[Dict[str, Any]]: """``json.loads`` that yields a dict or None (unparseable / non-object).""" try: data = json.loads(text) except (ValueError, TypeError): return None return data if isinstance(data, dict) else None def _valid_mcp_args(invocation: Any) -> Optional[List[str]]: """``mcp_invocation.args`` when it is a list of strings (possibly empty), else None.""" args = invocation.get("args") if isinstance(invocation, dict) else None return args if isinstance(args, list) and all(isinstance(a, str) for a in args) else None 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 wsl = is_wsl() except Exception: wsl = False win = PureWindowsPath(path) drive = (win.drive or "").rstrip(":").lower() return os.path.join("/mnt", drive, *(str(part) for part in win.parts[1:])) if wsl and drive else path 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") return [_CUA_DRIVER_DEFAULT_CMD, 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")] return [_CUA_DRIVER_DEFAULT_CMD, 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"] 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.""" for expanded in map(os.path.expanduser, _candidate_cua_driver_commands(override)): 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 resolve_cua_driver_cmd() is not None def cua_driver_install_hint() -> str: installer = (f" irm {_UPSTREAM_SCRIPTS}/install.ps1 | iex" if sys.platform == "win32" else f' /bin/bash -c "$(curl -fsSL {_UPSTREAM_SCRIPTS}/install.sh)"') return ("cua-driver is not installed. Install with one of:\n hermes computer-use install\n" f"Or run the upstream installer directly:\n{installer}\n" "Or run `hermes tools` and enable the Computer Use toolset to install it automatically.") 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.""" on = _cb()._cua_no_overlay() and _cua_driver_supports_no_overlay(driver_cmd) return [*args, "--no-overlay"] if on else 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. Surface 8 of NousResearch/hermes-agent#47072: instead of hardcoding ``["mcp"]`` we ask the driver itself via ``cua-driver manifest`` (trycua/cua#1961). The manifest carries a stable ``mcp_invocation`` pointer with both ``command`` and ``args``, so a future cua-driver that renames or relocates the subcommand keeps working without a Hermes patch. When ``computer_use.no_overlay`` is enabled (or auto-detected — macOS, headless/WSL2/X11 Linux), ``--no-overlay`` is appended to suppress the cursor overlay rendering loop that can consume CPU indefinitely when idle (#28152, #47032). Older drivers that don't recognise the flag will reject it; callers should fall back to the no-overlay invocation on spawn failure. """ manifest = _driver_json(driver_cmd, "manifest", timeout=timeout, require_ok=True) or {} invocation = manifest.get("mcp_invocation") args = _valid_mcp_args(invocation) command = invocation.get("command") if args is not None and isinstance(invocation, dict) else None args = list(_CUA_DRIVER_ARGS) if args is None else args # 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 isinstance(command, str) and command else "" command = command if command and _has_path_separator(command) else driver_cmd return command, _mcp_args_with_overlay_flag(args, driver_cmd=command) def _manifest_contract_reason(manifest: Optional[Dict[str, Any]]) -> str: """Why a parsed manifest fails the 0.20 contract, or ``""`` when it passes (version floor, MCP launch command, then the ``" "`` entries the advertised subcommands lack).""" if manifest is None: return "driver manifest is missing or invalid" match = _SEMVER_RE.fullmatch(str(manifest.get("binary_version") or "").strip()) if not match: return "driver manifest does not report a semantic version" if tuple(int(part) for part in match.groups()) < _CUA_DRIVER_RUNTIME_CONTRACT_MIN: return "Hermes computer use requires cua-driver 0.20.0 or newer" if not _valid_mcp_args(manifest.get("mcp_invocation")): return "driver manifest does not provide an MCP launch command" 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 in _CUA_DRIVER_RUNTIME_CONTRACT_ARGS.items() for arg in sorted(required - advertised.get(command, set()))] return "driver manifest is missing: " + ", ".join(missing) if missing else "" 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 resolve_cua_driver_cmd() version: Optional[str] = None reason = "cua-driver is not installed" if resolved: try: result = _cb()._run_driver(resolved, "manifest", timeout=15.0 if sys.platform == "win32" else 5.0) except (OSError, subprocess.SubprocessError) as exc: result, reason = None, f"manifest check failed: {exc}" if result is not None and result.returncode != 0: result, reason = None, (result.stderr or result.stdout or "manifest command failed").strip().splitlines()[-1][:200] if result is not None: manifest = _json_object(result.stdout or "") reason = _manifest_contract_reason(manifest) version = str(manifest.get("binary_version") or "").strip() or None if manifest is not None else None return {"ready": not reason, "binary": resolved, "version": version, "reason": reason} def cua_driver_update_check(*, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]: """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. Returns the ``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). See #1734. """ timeout = (25.0 if sys.platform == "win32" else 8.0) if timeout is None else timeout driver_cmd = resolve_cua_driver_cmd() data = _driver_json(driver_cmd, "check-update", "--json", timeout=timeout, require_ok=False) if driver_cmd else None 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 driver too old.""" state = cua_driver_update_check() if not state or not state.get("update_available"): return None return (f"cua-driver {state.get('latest_version') or '?'} is available " f"(you have {state.get('current_version') or '?'}); update with `hermes computer-use install --upgrade`.")