216 lines
13 KiB
Python
216 lines
13 KiB
Python
"""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/<drive>/...`` 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 ``<driver> --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 ``"<verb> <flag>"`` 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`.")
|