Files
hermes-agent/hermes_cli/tools_config_cua.py

940 lines
40 KiB
Python

"""cua-driver installer, lock hygiene and pip-install helper for `hermes tools` / `hermes computer-use install`."""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
import sys
from pathlib import Path
from typing import List, Optional
from hermes_cli.cli_output import (
print_info as _print_info, print_success as _print_success, print_warning as _print_warning,
)
logger = logging.getLogger("hermes_cli.tools_config")
def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int:
"""Win32 creationflags that stop post-setup children flashing a console.
CREATE_NO_WINDOW hides console grandchildren (npm, pip, powershell) while keeping stdio inheritable
(unlike DETACHED_PROCESS). Returns 0 on POSIX. ``streams_to_console`` children are only hidden when
our own stdout is not a console, so live installer output is never swallowed.
"""
from hermes_cli._subprocess_compat import windows_hide_flags
flags = windows_hide_flags()
if not flags:
return 0
if streams_to_console:
try:
if sys.stdout is not None and sys.stdout.isatty():
return 0
except Exception:
pass
return flags
def _cua_driver_cmd() -> str:
"""Return the configured cua-driver override, or the bare default name."""
return os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip() or "cua-driver"
def _cua_version_summary(raw: str, *, limit: int = 120) -> str:
"""First non-empty line of ``--version`` output, bounded (an override may print a multi-line banner)."""
for line in (raw or "").splitlines():
text = line.strip()
if text:
return text[:limit]
return ""
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 (``cua_backend.cua_driver_child_env``).
Falls back to the current environment if the helper can't be imported, so install/status never break.
"""
try:
from tools.computer_use.cua_backend import cua_driver_child_env
return cua_driver_child_env()
except Exception:
return dict(os.environ)
_CUA_DRIVER_CONTRACT_CACHE: dict = {}
def _cua_driver_contract_status(binary: Optional[str] = None) -> dict:
"""Inspect whether an installed driver supports Hermes' runtime contract."""
import time
from tools.computer_use.cua_backend import cua_driver_runtime_contract_status
resolved = binary or _resolved_cua_driver_cmd()
if not resolved:
return cua_driver_runtime_contract_status(None)
try:
stat = os.stat(resolved)
fingerprint = (resolved, stat.st_mtime_ns, stat.st_size)
except OSError:
return cua_driver_runtime_contract_status(resolved)
now = time.monotonic()
if (
_CUA_DRIVER_CONTRACT_CACHE.get("fingerprint") == fingerprint
and now - _CUA_DRIVER_CONTRACT_CACHE.get("checked_at", 0.0) < 30.0
):
return dict(_CUA_DRIVER_CONTRACT_CACHE["state"])
state = cua_driver_runtime_contract_status(resolved)
_CUA_DRIVER_CONTRACT_CACHE.update(fingerprint=fingerprint, checked_at=now, state=dict(state))
return state
def _cua_driver_install_ready() -> bool:
"""Return whether an existing driver needs no install-time repair."""
if not _cua_driver_contract_status().get("ready"):
return False
return sys.platform != "win32" or _cua_driver_autostart_registered_windows()
def _pip_install(args: List[str], *, timeout: int = 300, capture_output: bool = True):
"""Install Python packages from a post-setup hook.
Order: ``uv pip install`` (fast, needs no pip in the venv), then ``python -m pip install``, then
``python -m ensurepip --upgrade`` and retry pip. The last tier exists because the Windows installer
creates the venv via ``uv venv``, which does NOT seed pip, so bare ``-m pip`` failed on fresh installs.
"""
venv_root = Path(sys.executable).parent.parent
uv_env = {**os.environ, "VIRTUAL_ENV": str(venv_root)}
# Managed uv first: $HERMES_HOME/bin is never on PATH, so a bare which() misses the uv Hermes installed;
# ensure_uv() (not a pure lookup) because installing uv is in scope during setup.
from hermes_cli.managed_uv import ensure_uv
uv_bin = ensure_uv()
if uv_bin:
try:
result = subprocess.run(
[uv_bin, "pip", "install", *args],
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
env=uv_env,
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
)
if result.returncode == 0:
return result
# Fall through to pip — uv may have failed for a reason pip can handle.
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
pip_cmd = [sys.executable, "-m", "pip"]
try:
# Probe for pip; bootstrap via ensurepip if missing (uv venv lacks it).
probe = subprocess.run(
pip_cmd + ["--version"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15,
creationflags=_post_setup_no_window_flags(),
)
if probe.returncode != 0:
raise FileNotFoundError("pip not in venv")
except (subprocess.TimeoutExpired, FileNotFoundError):
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, check=True,
creationflags=_post_setup_no_window_flags(),
)
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as e:
# Synthesize a result so callers see a clean failure path.
return subprocess.CompletedProcess(
pip_cmd, returncode=1, stdout="", stderr=f"pip not available and ensurepip failed: {e}",
)
return subprocess.run(
pip_cmd + ["install", *args],
capture_output=capture_output, text=True, encoding="utf-8", errors="replace", timeout=timeout,
creationflags=_post_setup_no_window_flags(streams_to_console=not capture_output),
)
# No pre-install release/asset probe: cua-driver-rs releases are all prereleases, which GitHub's
# `/releases/latest` skips (zero binary assets → every non-arm64 host skipped the install), and
# re-implementing upstream's tag resolution here would drift. Fresh installs run install.sh directly (it
# errors clean on a missing-arch asset); upgrades ask the binary via `cua_driver_update_check()`.
def _cua_install_target_writable() -> bool:
"""Return whether the upstream installer can write its app bundle target."""
if sys.platform != "darwin":
return True
try:
return not os.path.isdir("/Applications") or os.access("/Applications", os.W_OK)
except Exception:
return True
def _cua_driver_version(binary: str) -> Optional[str]:
"""``<binary> --version`` stdout (possibly ""), or None when the probe itself fails."""
try:
return subprocess.run(
[binary, "--version"],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=5, env=_cua_driver_env(),
creationflags=_post_setup_no_window_flags(),
).stdout.strip()
except Exception:
return None
def _confirmed_update_check(driver_cmd: str, require_confirmed_update: bool) -> tuple:
"""Ask the installed driver whether a newer release exists; returns ``(proceed, pin_version)``.
``proceed=False`` means stop with success (already latest, or indeterminate under
``require_confirmed_update``). An old driver (no check-update verb) or offline check yields None:
`hermes update` then keeps the installed version — an indeterminate check must never cost a
multi-minute silent reinstall on every update — while explicit `install --upgrade` falls through.
"""
try:
from tools.computer_use.cua_backend import cua_driver_update_check
_state = cua_driver_update_check()
except Exception:
_state = None
if _state is not None and not _state.get("update_available"):
_print_success(
f" {driver_cmd} is already on the latest release "
f"({_state.get('current_version') or 'unknown'})."
)
return False, None
if _state is None and require_confirmed_update:
_print_info(
f" Could not confirm a newer {driver_cmd} release "
"(offline, rate-limited, or driver too old to check); "
"keeping the installed version."
)
_print_info(" Force a refresh with: hermes computer-use install --upgrade")
return False, None
confirmed_version = None
if _state is not None and _state.get("update_available"):
# Windows routine upgrades run unattended-safe (stdin closed, version pinned, ceiling
# _CUA_BACKGROUND_UPDATE_TIMEOUT, preflights skip in seconds); only contract repairs and fresh
# installs stay interactive-only, where upstream needs a human (autostart elevation, SmartScreen).
# Pin to the release check-update confirmed: `latest_version` comes from the GitHub Releases API so
# its assets exist, unlike the installer's baked version on `main`, which is bumped before assets
# are published and 404s unpinned. Malformed values are ignored → unpinned fallback.
import re as _re
_latest = str(_state.get("latest_version") or "").strip().lstrip("vV")
if _re.fullmatch(r"\d+(\.\d+)*", _latest):
confirmed_version = _latest
return True, confirmed_version
def install_cua_driver(
upgrade: bool = False,
require_confirmed_update: bool = False,
show_installer_progress: bool = True,
) -> bool:
"""Install or refresh the cua-driver binary used by Computer Use.
The upstream installer always pulls the latest release tag, so re-running it is the canonical way to
upgrade. ``upgrade=False`` (toolset enable flow) keeps a compatible installation, repairs an
old/incomplete one and installs when missing; ``upgrade=True`` always refreshes.
"""
import platform as _plat
system = _plat.system()
if system not in ("Darwin", "Windows", "Linux"):
if upgrade:
# Silent: `hermes update` calls this for every user.
return False
_print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.")
return False
is_windows = system == "Windows"
is_linux = system == "Linux"
# install.ps1 is fetched via PowerShell's `irm`; macOS/Linux use curl | bash.
fetch_tool = "powershell" if is_windows else "curl"
driver_cmd = _cua_driver_cmd()
binary = _resolved_cua_driver_cmd()
# An explicit override is authoritative even when broken: installing the standard driver
# cannot repair the configured path and would mutate an unrelated installation.
override = os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip()
if override and not binary:
_print_warning(f" HERMES_CUA_DRIVER_CMD does not resolve to an executable: {override}")
_print_info(" Fix or unset the override before running computer-use install.")
return False
# Not installed → fresh install path (only when caller asked for it).
if not binary and not upgrade:
if not _cua_install_target_writable():
_print_info(" /Applications is not writable; skipping cua-driver install.")
_print_info(" Run from an admin account or install cua-driver manually.")
return False
if not shutil.which(fetch_tool):
_print_warning(f" {fetch_tool} not found — install manually:")
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
return False
return _run_cua_driver_installer(label="Installing")
# A driver failing Hermes' runtime contract (version floor, missing manifest verbs) is repaired
# regardless of mode. Hermes' minimum requirement IS the confirmation an upgrade is needed, so this
# path must not defer to the driver's `check-update` verb — a cached/indeterminate "no update"
# answer would pin users on an unusable driver forever.
contract = _cua_driver_contract_status(binary) if binary else None
repair_existing = bool(binary and contract and not contract.get("ready"))
# Compatible existing install: no download, just the host-specific setup upstream normally owns.
if binary and not upgrade and not repair_existing:
version = _cua_driver_version(binary)
if version is None:
_print_success(f" {driver_cmd} already installed.")
else:
_print_success(f" {driver_cmd} already installed: {version or 'unknown version'}")
if is_windows and not _repair_cua_driver_autostart_windows(binary, verbose=False):
_print_warning(" cua-driver is compatible, but Windows autostart repair failed.")
return False
_print_cua_platform_notes(is_windows, is_linux, fresh_install=False)
return True
if repair_existing:
version = contract.get("version") or "unknown version"
reason = contract.get("reason") or "required runtime features are missing"
_print_warning(
f" Found cua-driver {version}, but Hermes cannot use its current runtime contract: {reason}."
)
if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip():
_print_info(
" Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset "
"the override and run: hermes computer-use install --upgrade"
)
return False
if is_windows and require_confirmed_update:
_print_info(
" Automatic Windows updates cannot safely run cua-driver's interactive repair installer."
)
_print_info(
" Repair it from an interactive terminal with: hermes computer-use install --upgrade"
)
return False
_print_info(" Repairing it with the current upstream installer.")
# upgrade=True path — refresh to the latest upstream release.
if not _cua_install_target_writable():
_print_info(" /Applications is not writable; skipping cua-driver refresh.")
_print_info(" Run `hermes computer-use install --upgrade` from an admin account to update it.")
return bool(binary)
if not shutil.which(fetch_tool):
_print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.")
return bool(binary)
confirmed_version = None
if binary and not repair_existing:
proceed, confirmed_version = _confirmed_update_check(driver_cmd, require_confirmed_update)
if not proceed:
return True
if is_windows and require_confirmed_update and not binary:
# Missing binary (enabled but never installed, or wiped by a failed install): an automatic Windows
# update must never launch install.ps1, which can demand console/UAC consent the hidden updater
# cannot provide.
_print_info(
" cua-driver is not installed; automatic Windows updates "
"cannot safely run its interactive installer."
)
_print_info(" Install it from an interactive terminal with: hermes computer-use install --upgrade")
return False
# Best-effort before/after version display.
before = (_cua_driver_version(binary) or "") if binary else ""
ok = _run_cua_driver_installer(
label="Repairing" if repair_existing else "Refreshing",
verbose=False,
pin_version=confirmed_version,
show_progress=show_installer_progress,
installer_timeout=_CUA_BACKGROUND_UPDATE_TIMEOUT if require_confirmed_update else None,
)
if ok and repair_existing:
repaired = _cua_driver_contract_status()
if not repaired.get("ready"):
_print_warning(
" cua-driver was reinstalled, but its runtime contract is still "
f"unusable: {repaired.get('reason') or 'unknown error'}."
)
_print_info(" Run: hermes computer-use doctor")
return False
if ok and before:
after = _cua_driver_version(binary)
if after and after != before:
_print_success(f" {driver_cmd} upgraded: {before} → {after}")
elif after:
_print_info(f" {driver_cmd} up to date: {after}")
return ok
# Ceiling for one upstream-installer run: must exceed the installer's own stale-lock recovery window
# (_install-rust.sh force-releases a dead holder's lock only after LOCK_STALE_AFTER_SECONDS=600; a shorter
# timeout kills every run before that fires — a permanent wedge). 660s = 600s + 60s headroom.
_CUA_INSTALLER_TIMEOUT = 660
# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (_reap_after_timeout)
# so the drain must be bounded: a surviving descendant holding the inherited stdout handle would otherwise
# make the read wait on an EOF that never comes. A successful kill closes the pipe at once, so this is free.
_CUA_INSTALLER_DRAIN_GRACE = 15
# Quiet unattended refreshes from ``hermes update``: bounded even when upstream waits on Read-Host or a
# consent prompt; explicit ``computer-use install --upgrade`` keeps the full ceiling. Safe because the
# lock/network preflights make a legitimate long wait impossible here.
_CUA_BACKGROUND_UPDATE_TIMEOUT = 120
# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in _install-rust.sh), so the
# pre-clear never yanks a lock a live-but-slow install still holds.
_CUA_LOCK_STALE_AFTER = 600
def _cua_install_home() -> "Path":
"""Package home shared by the upstream POSIX and Windows installers."""
return Path(os.environ.get("CUA_DRIVER_RS_HOME") or str(Path.home() / ".cua-driver"))
def _cua_install_lock_dir() -> "Path":
"""Path of the upstream installer's concurrent-install lock dir."""
return _cua_install_home() / "packages" / ".install.lock.d"
def _cua_windows_install_lock_file() -> "Path":
"""Path of install.ps1's FileShare::None lock file."""
return _cua_install_home() / "install.lock"
def _clear_stale_windows_cua_install_lock() -> None:
"""Delete install.ps1's lock file only when no process still holds it.
install.ps1 locks with ``FileShare::None``; mirror it with a zero-share ``CreateFileW`` probe and
``FILE_FLAG_DELETE_ON_CLOSE`` so an unlocked leftover is removed atomically, with no window in which
a new installer could acquire the file between probe and delete.
"""
lock_file = _cua_windows_install_lock_file()
try:
if not lock_file.is_file():
return
import ctypes as _ctypes
from ctypes import wintypes as _wintypes
# Win32 constants used by install.ps1's FileShare::None equivalent.
delete_access = 0x00010000
generic_read = 0x80000000
generic_write = 0x40000000
open_existing = 3
file_attribute_normal = 0x00000080
file_flag_delete_on_close = 0x04000000
kernel32 = _ctypes.WinDLL("kernel32", use_last_error=True)
create_file = kernel32.CreateFileW
create_file.argtypes = [
_wintypes.LPCWSTR, _wintypes.DWORD, _wintypes.DWORD, _wintypes.LPVOID,
_wintypes.DWORD, _wintypes.DWORD, _wintypes.HANDLE,
]
create_file.restype = _wintypes.HANDLE
close_handle = kernel32.CloseHandle
close_handle.argtypes = [_wintypes.HANDLE]
close_handle.restype = _wintypes.BOOL
handle = create_file(
str(lock_file),
generic_read | generic_write | delete_access,
0, # FileShare::None
None,
open_existing,
file_attribute_normal | file_flag_delete_on_close,
None,
)
invalid_handle = _wintypes.HANDLE(-1).value
if handle == invalid_handle:
logger.debug(
"Windows cua install lock at %s is still held or cannot be removed (winerror %s)",
lock_file, _ctypes.get_last_error(),
)
return
if not close_handle(handle):
logger.debug(
"could not close Windows cua install lock probe at %s (winerror %s)",
lock_file, _ctypes.get_last_error(),
)
return
if lock_file.exists():
logger.debug("Windows cua install lock probe succeeded but %s remains", lock_file)
return
logger.info("Cleared stale Windows cua-driver install lock at %s", lock_file)
_print_info(f" Cleared stale cua-driver install lock ({lock_file}).")
except Exception as e:
logger.debug("stale Windows cua install lock check failed: %s", e)
def _clear_stale_cua_install_lock() -> None:
"""Best-effort: remove a stale installer lock left by a dead holder.
POSIX stamps the holder pid into ``~/.cua-driver/packages/.install.lock.d/info``; Windows holds
``~/.cua-driver/install.lock`` open with ``FileShare::None``. Clear either artifact up front only
when its platform-specific liveness check proves that no install still holds it.
"""
if sys.platform == "win32":
_clear_stale_windows_cua_install_lock()
return
lock_dir = _cua_install_lock_dir()
try:
if not lock_dir.is_dir():
return
holder_pid = None
info = lock_dir / "info"
try:
for line in info.read_text(encoding="utf-8", errors="replace").splitlines():
if line.startswith("pid="):
holder_pid = int(line.split("=", 1)[1].strip())
break
except (OSError, ValueError):
holder_pid = None
if holder_pid is not None:
try:
os.kill(holder_pid, 0) # windows-footgun: ok — function early-returns on win32
return # holder alive → concurrent install running
except ProcessLookupError:
pass # dead holder → stale, clear below
except PermissionError:
return # alive but owned by someone else — treat as live
else:
# No readable pid: only clear if old enough that upstream itself would reclaim it.
import time as _time
try:
age = _time.time() - lock_dir.stat().st_mtime
except OSError:
return
if age < _CUA_LOCK_STALE_AFTER:
return
shutil.rmtree(lock_dir, ignore_errors=True)
logger.info("Cleared stale cua-driver install lock at %s", lock_dir)
_print_info(f" Cleared stale cua-driver install lock ({lock_dir}).")
except Exception as e:
logger.debug("stale cua install lock check failed: %s", e)
def _cua_install_lock_held() -> bool:
"""True when the upstream installer's lock is held by a LIVE process.
Called after ``_clear_stale_cua_install_lock()``: anything provably stale is already gone, so a
surviving lock artifact means a concurrent (or orphaned-but-alive) install owns it.
"""
try:
if sys.platform == "win32":
lock_file = _cua_windows_install_lock_file()
if not lock_file.is_file():
return False
# install.ps1 holds the file with FileShare::None — any open fails with a sharing violation
# while held. Surviving the stale-clear = held; confirm with an open probe.
try:
with open(lock_file, "r+b"):
return False # opened fine → not held (racy leftover)
except OSError: # sharing violation surfaces as PermissionError
return True
return _cua_install_lock_dir().is_dir()
except Exception as e:
logger.debug("cua install lock probe failed: %s", e)
return False
def _cua_release_endpoint_reachable(timeout: float = 5.0) -> bool:
"""Fast probe: can we reach GitHub's release download host at all?
When github.com is down the installer dies slowly inside its own retries and eats the whole unattended
ceiling; a 5s HEAD decides in seconds. Only a connection-level failure counts as unreachable — any
HTTP response (even 4xx/5xx) proves the path works.
"""
import urllib.error
import urllib.request
try:
req = urllib.request.Request("https://github.com/trycua/cua/releases", method="HEAD")
with urllib.request.urlopen(req, timeout=timeout):
return True
except urllib.error.HTTPError:
return True # server answered → reachable
except Exception as e:
logger.debug("cua release endpoint probe failed: %s", e)
return False
def _ps_single_quote(value: str) -> str:
"""Return a PowerShell single-quoted string literal."""
return "'" + value.replace("'", "''") + "'"
def _cua_driver_autostart_registered_windows() -> bool:
"""Return whether the Windows cua-driver scheduled task is registered."""
if sys.platform != "win32":
return False
try:
result = subprocess.run(
["schtasks.exe", "/Query", "/TN", "cua-driver-serve"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10,
)
except Exception:
return False
return result.returncode == 0
def _repair_cua_driver_autostart_windows(driver_cmd: str, *, verbose: bool) -> bool:
"""Best-effort repair for Windows installer autostart quoting failures.
Older install.ps1 builds interpolated the binary path into a PowerShell command string, which split at
the first space. If the scheduled task is missing, retry via Start-Process's structured ``-FilePath`` /
``-ArgumentList`` parameters instead.
"""
if sys.platform != "win32":
return True
if _cua_driver_autostart_registered_windows():
return True
binary = shutil.which(driver_cmd)
if not binary:
return False
ps = shutil.which("powershell") or shutil.which("powershell.exe") or "powershell"
ps_cmd = (
f"$exe = {_ps_single_quote(binary)}; "
"$proc = Start-Process -FilePath $exe "
"-ArgumentList @('autostart','enable') "
"-Verb RunAs -Wait -PassThru -ErrorAction Stop; "
"exit $proc.ExitCode"
)
if verbose:
_print_info(" Registering cua-driver auto-start...")
else:
_print_info(" Repairing cua-driver auto-start registration...")
try:
result = subprocess.run(
[ps, "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_cmd],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300,
env=_cua_driver_env(),
)
except subprocess.TimeoutExpired:
_print_warning(" cua-driver autostart registration timed out.")
return False
except Exception as exc:
_print_warning(f" cua-driver autostart registration failed: {exc}")
return False
if result.returncode == 0:
return True
tail = (result.stderr or result.stdout or "").strip().splitlines()[-3:]
_print_warning(" cua-driver autostart registration failed.")
for line in tail:
_print_info(f" {line[:200]}")
_print_info(" From an elevated shell, run: cua-driver autostart enable")
return False
def _remove_quietly(path: str) -> None:
try:
os.remove(path)
except OSError:
pass
def _print_cua_platform_notes(is_windows: bool, is_linux: bool, *, fresh_install: bool) -> None:
"""Host-specific follow-up notes after an install or a compatible-install check."""
if is_windows:
_print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);")
_print_info(" Windows/SmartScreen may prompt the first time it runs.")
elif is_linux:
_print_warning(" Linux support is alpha.")
elif fresh_install:
_print_info(" IMPORTANT — grant macOS permissions now:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_print_info(" Both must allow the terminal / Hermes process.")
else:
_print_info(" Grant macOS permissions if not done yet:")
_print_info(" System Settings > Privacy & Security > Accessibility")
_print_info(" System Settings > Privacy & Security > Screen Recording")
_CUA_INSTALL_PS1_URL = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1"
def _kill_installer_tree(proc, *, is_windows: bool) -> None:
"""Kill the installer and its descendants (best-effort)."""
import signal as _signal
try:
if not is_windows:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok — POSIX branch only
else:
# PowerShell may leave download/install helpers alive after its direct process is killed. They
# inherit stdout and can keep communicate() and install.lock wedged, so kill the tree leaf-up.
import psutil as _psutil
try:
parent = _psutil.Process(proc.pid)
descendants = parent.children(recursive=True)
except _psutil.NoSuchProcess:
return
except _psutil.Error as e:
logger.debug(
"could not enumerate cua-driver installer tree for pid %s: %s", proc.pid, e
)
proc.kill()
return
for child in reversed(descendants):
try:
child.kill()
except _psutil.NoSuchProcess:
pass
except _psutil.Error as e:
logger.debug("could not kill cua-driver installer child pid %s: %s", child.pid, e)
try:
parent.kill()
except _psutil.NoSuchProcess:
pass
except _psutil.Error as e:
logger.debug("could not kill cua-driver installer parent pid %s: %s", proc.pid, e)
proc.kill()
except (OSError, ProcessLookupError):
proc.kill()
def _reap_after_timeout(proc, *, is_windows: bool) -> None:
"""Kill the installer tree, then drain its pipes under a deadline.
An unbounded drain blocks on an EOF that only arrives when someone kills a surviving descendant by
hand, so ``_CUA_INSTALLER_TIMEOUT`` would stop bounding anything.
"""
_kill_installer_tree(proc, is_windows=is_windows)
try:
drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE)
# The partial output names WHERE the installer was stuck (lock wait, consent prompt, download).
if drained_out:
logger.warning(
"cua-driver installer timed out; last output before kill:\n%s", drained_out[-2000:],
)
except subprocess.TimeoutExpired:
# Deliberately not closing proc.stdout: communicate()'s reader threads are still blocked on that
# handle and closing it underneath them races; they are daemon threads.
logger.debug(
"cua-driver installer pipes still open %ss after the kill — "
"abandoning the drain, a surviving descendant holds the inherited handle",
_CUA_INSTALLER_DRAIN_GRACE,
)
except (OSError, ValueError) as e:
logger.debug("cua-driver installer drain failed: %s", e)
def _cua_installer_command(is_windows: bool):
"""Return ``(install_cmd, manual_hint, script_path)`` or None when the POSIX download fails."""
if is_windows:
# Mirror the one-liner printed by cua_driver_install_hint().
ps_oneliner = f"irm {_CUA_INSTALL_PS1_URL} | iex"
install_cmd = ["powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", "-Command", ps_oneliner]
manual_hint = f'powershell -NoProfile -ExecutionPolicy Bypass -Command "{ps_oneliner}"'
return install_cmd, manual_hint, None
# Download-then-exec instead of `bash -c "$(curl …)"`: no shell=True, no command substitution, and the
# script lands in a mkstemp file (unpredictable name, 0600) rather than a fixed /tmp path — avoiding
# both shell injection and a symlink/TOCTOU race. The manual hint stays the upstream one-liner.
import tempfile as _tempfile
install_url = "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.sh"
manual_hint = f'/bin/bash -c "$(curl -fsSL {install_url})"'
fd, script_path = _tempfile.mkstemp(prefix="cua-driver-install-", suffix=".sh")
os.close(fd)
try:
dl = subprocess.run(
["curl", "-fsSL", "-o", script_path, install_url],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120,
)
failure = None if dl.returncode == 0 else (dl.stderr or "").strip()[:200]
except (subprocess.TimeoutExpired, OSError) as e:
failure = str(e)
if failure is not None:
_print_warning(f" cua-driver installer download failed: {failure}")
_remove_quietly(script_path)
return None
return ["/bin/bash", script_path], manual_hint, script_path
def _unattended_installer_preflight(install_cmd: list, is_windows: bool):
"""Fail FAST on the two conditions that otherwise consume the whole unattended ceiling.
1. Install lock held by a live process — upstream would poll it for up to LOCK_STALE_AFTER_SECONDS=600
before probing the holder (the 11-minute silent hang class).
2. Release host unreachable — the installer would die slowly inside its own retries; a 5s HEAD answers.
Returns the (possibly rewritten) install command, or None to skip this refresh. Explicit
`computer-use install --upgrade` runs never come here and keep upstream's full lock-recovery.
"""
if _cua_install_lock_held():
_print_info(
" Another cua-driver install is in progress (upstream "
"install lock is held) — skipping this refresh."
)
_print_info(" If no install is really running, retry with: hermes computer-use install --upgrade")
return None
if not _cua_release_endpoint_reachable():
_print_info(
" github.com is unreachable — skipping cua-driver refresh (will retry on the next update)."
)
return None
if is_windows:
# -NoAutoStart skips Register-CuaDriverAutostart — the ONLY branch of install.ps1 that self-elevates
# (UAC). Cost: an existing cua-driver-serve task keeps pointing at the previous binary until the
# next interactive upgrade. Scriptblock invocation (not `| iex`) is what lets us pass the parameter.
install_cmd = [
"powershell", "-NoProfile", "-ExecutionPolicy", "Bypass",
"-Command", f"$sc = irm {_CUA_INSTALL_PS1_URL}; & ([scriptblock]::Create($sc)) -NoAutoStart",
]
return install_cmd
def _run_cua_driver_installer(
label: str = "Installing",
verbose: bool = True,
pin_version: Optional[str] = None,
show_progress: bool = True,
installer_timeout: Optional[float] = None,
) -> bool:
"""Run the upstream cua-driver installer for this platform.
The scripts are idempotent: they always download the latest release, so re-running on an
already-installed system performs an upgrade. ``installer_timeout`` lets quiet callers use a shorter
ceiling without weakening the explicit install path's stale-lock recovery window.
"""
import platform as _plat
system = _plat.system()
is_windows = system == "Windows"
is_linux = system == "Linux"
prepared = _cua_installer_command(is_windows)
if prepared is None:
return False
install_cmd, manual_hint, script_path = prepared
if show_progress:
if verbose:
_print_info(f" {label} cua-driver (background computer-use)...")
else:
_print_info(f"→ {label} cua-driver (Computer Use)...")
driver_cmd = _cua_driver_cmd()
timeout = _CUA_INSTALLER_TIMEOUT if installer_timeout is None else installer_timeout
installer_env = _cua_driver_env()
if pin_version:
# Both upstream installers honour CUA_DRIVER_RS_VERSION over their baked default.
installer_env["CUA_DRIVER_RS_VERSION"] = pin_version
# A previous timed-out install can leave upstream's concurrent-install lock behind; clear it when
# provably stale so the refresh doesn't wedge waiting on a dead holder.
_clear_stale_cua_install_lock()
# Unattended refreshes (installer_timeout set by `hermes update`) preflight and may skip.
if installer_timeout is not None:
install_cmd = _unattended_installer_preflight(install_cmd, is_windows)
if install_cmd is None:
return False
# POSIX: own process group so a timeout kill takes out the whole `curl | bash` pipeline (and the exec'd
# _install-rust.sh), not just the outer shell — surviving grandchildren would keep holding the install
# lock and wedge every later run.
popen_kwargs = {}
if not is_windows:
popen_kwargs["start_new_session"] = True
try:
# Non-verbose (`hermes update` refresh): capture the installer's chatty "Next steps" wall and log it
# so a failure stays debuggable. Verbose interactive installs stream live.
if verbose:
proc = subprocess.Popen(
install_cmd, shell=False, env=installer_env,
creationflags=_post_setup_no_window_flags(streams_to_console=True),
**popen_kwargs
)
try:
proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_reap_after_timeout(proc, is_windows=is_windows)
raise
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=None, stderr=None)
else:
proc = subprocess.Popen(
install_cmd, shell=False, env=installer_env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
text=True, encoding="utf-8", errors="replace",
creationflags=_post_setup_no_window_flags(),
**popen_kwargs
)
try:
out, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
_reap_after_timeout(proc, is_windows=is_windows)
raise
result = subprocess.CompletedProcess(install_cmd, proc.returncode, stdout=out, stderr=None)
# During `hermes update`, sys.stdout is the mirroring _UpdateOutputStream whose `_log` handle is
# ~/.hermes/logs/update.log — write straight to it so the full installer output is kept
# (success AND failure) without echoing it to the terminal.
if result.stdout:
_update_log = getattr(sys.stdout, "_log", None)
if _update_log is not None:
try:
_update_log.write("\n--- cua-driver installer output ---\n" + result.stdout + "\n")
_update_log.flush()
except Exception:
pass
if result.returncode != 0:
logger.debug("cua-driver installer output:\n%s", result.stdout)
installed_binary = _resolved_cua_driver_cmd()
if result.returncode == 0 and installed_binary:
if is_windows and not _repair_cua_driver_autostart_windows(installed_binary, verbose=verbose):
_print_warning(" cua-driver installed, but auto-start was not registered.")
if verbose:
_print_success(f" {driver_cmd} installed.")
_print_cua_platform_notes(is_windows, is_linux, fresh_install=True)
return True
_print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:")
_print_info(f" {manual_hint}")
return False
except subprocess.TimeoutExpired:
_print_warning(f" cua-driver {label.lower()} timed out after {timeout}s.")
if not is_windows:
_print_info(
" If this repeats, a stale installer lock may be present — "
f"check {_cua_install_lock_dir()}"
)
_print_info(f" Re-run manually: {manual_hint}")
return False
except Exception as e:
_print_warning(f" cua-driver {label.lower()} failed: {e}")
return False
finally:
if script_path:
_remove_quietly(script_path)