diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 163bd705e9..24f9f6375e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -32,28 +32,6 @@ from utils import base_url_hostname, is_truthy_value logger = logging.getLogger(__name__) -def _post_setup_no_window_flags(*, streams_to_console: bool = False) -> int: - """Win32 creationflags that stop post-setup children flashing a console. - - The GUI runs post-setup hooks via a console-less child; on Windows every console grandchild - (npm, pip, powershell) would flash a new window. CREATE_NO_WINDOW suppresses that 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 - # Platforms already warned about an all-invalid platform_toolsets list, so the # runtime check in _get_platform_tools warns once per platform instead of on # every tool resolution for a persistently-corrupt config (#38798). @@ -71,6 +49,37 @@ from hermes_cli.cli_output import ( # noqa: E402 — late import block print_warning as _print_warning, prompt as _prompt, ) +from hermes_cli.tools_config_cua import ( # noqa: F401 — re-exported for hermes_cli.tools_config.X callers and test patches + _post_setup_no_window_flags, + _cua_driver_cmd, + _cua_version_summary, + _resolved_cua_driver_cmd, + _cua_driver_env, + _CUA_DRIVER_CONTRACT_CACHE, + _cua_driver_contract_status, + _cua_driver_install_ready, + _pip_install, + _cua_install_target_writable, + _cua_driver_version, + install_cua_driver, + _CUA_INSTALLER_TIMEOUT, + _CUA_INSTALLER_DRAIN_GRACE, + _CUA_BACKGROUND_UPDATE_TIMEOUT, + _CUA_LOCK_STALE_AFTER, + _cua_install_home, + _cua_install_lock_dir, + _cua_windows_install_lock_file, + _clear_stale_windows_cua_install_lock, + _clear_stale_cua_install_lock, + _cua_install_lock_held, + _cua_release_endpoint_reachable, + _ps_single_quote, + _cua_driver_autostart_registered_windows, + _repair_cua_driver_autostart_windows, + _remove_quietly, + _print_cua_platform_notes, + _run_cua_driver_installer, +) # ─── Toolset Registry ───────────────────────────────────────────────────────── @@ -584,1084 +593,6 @@ TOOLSET_ENV_REQUIREMENTS = { # ─── Post-Setup Hooks ───────────────────────────────────────────────────────── -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: - """Reduce a driver's ``--version`` output to one short status line. - - A ``HERMES_CUA_DRIVER_CMD`` override may print a multi-line banner (e.g. cmd.exe); interpolating - it verbatim shattered the one-line status summary, so keep the first non-empty line, bounded. - """ - 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. - - Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default; user opt-in - via ``computer_use.cua_telemetry``). Falls back to the current environment if the helper can't - be imported, so install/status never break on a telemetry-helper error. - """ - 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. - - Strategy (in order): 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the - venv. 2. ``python -m pip install`` — works on stdlib venvs. 3. ``python -m ensurepip --upgrade`` - then retry pip — covers ``uv venv`` which creates a venv WITHOUT pip. - - Why this exists: the Windows installer creates the venv via ``uv venv``, which doesn't seed pip. - Post-setup hooks that shelled out to ``[sys.executable, '-m', 'pip', 'install', ...]`` failed - with ``No module named pip`` on every fresh install. uv-first sidesteps that. - """ - 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 and prefers a system one when both exist. - # ensure_uv() rather than a pure lookup because this runs during setup, - # where installing uv is in scope — and tier 2 is a pip that the Windows - # installer's `uv venv` does not seed, so failing to find uv here is the - # difference between a working post-setup hook and "No module named pip". - 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 an unrelated reason - # (resolution conflict, network), and pip might handle it. - 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 (it returned the Python -# `cua-agent` package with zero binary assets and made every non-arm64 host -# skip the install), and re-implementing upstream's tag resolution -# (`CUA_DRIVER_RS_BAKED_VERSION`) here would drift. Trust the installer: 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]: - """`` --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 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. We expose two modes: - - * ``upgrade=False`` — keep a compatible Cua Driver 0.20 installation, repair an old or - incomplete installation, and install when missing. Used by the toolset enable flow. * - ``upgrade=True`` — always re-run the installer (or call ``cua-driver update`` if the binary - supports it). - """ - import platform as _plat - - system = _plat.system() - if system not in ("Darwin", "Windows", "Linux"): - if upgrade: - # Silent on unsupported platforms — `hermes update` calls this - # for every user; only macOS/Windows/Linux users care. - return False - _print_warning(" Computer Use (cua-driver) is unsupported on this platform; skipping.") - return False - - is_windows = system == "Windows" - is_linux = system == "Linux" - - # The Windows installer (install.ps1) is fetched via PowerShell's `irm`, - # so it needs PowerShell rather than curl. 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 it is currently broken. - # Do not install or replace the standard system driver: that 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( - " HERMES_CUA_DRIVER_CMD does not resolve to an executable: " - f"{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") - - # An installed driver that fails Hermes' runtime contract (version floor, - # missing manifest verbs) is repaired regardless of the caller's mode. - # Hermes' own minimum requirement IS the confirmation that an upgrade is - # needed, so the ``upgrade=True`` path must not defer to the driver's - # ``check-update`` verb here — a cached/indeterminate "no update" answer - # would otherwise pin users on an unusable driver forever (observed: - # 0.19.3 installs hard-failing every computer_use call after the 0.20 - # contract landed, with `hermes update` declining to refresh). - contract = _cua_driver_contract_status(binary) if binary else None - repair_existing = bool(binary and contract and not contract.get("ready")) - - # A compatible existing installation needs no download. Finish the small - # host-specific setup that the upstream installer 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 " - f"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) - - # Skip the (network) re-install when the driver itself reports it's already - # on the latest release. Best-effort: an older driver (no check-update - # verb) or an offline check returns None. What happens then depends on the - # caller: `hermes update` (require_confirmed_update=True) keeps the - # installed version — an indeterminate check must never cost the user a - # multi-minute silent reinstall on every update. An explicit - # `hermes computer-use install --upgrade` falls through and re-runs the - # installer as before. - confirmed_version = None - if binary and not repair_existing: - _state = None - 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 True - 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 True - if _state is not None and _state.get("update_available"): - # Windows routine upgrades run UNATTENDED-SAFE rather than - # deferring: stdin is closed (a consent Read-Host can't block), - # the version is pinned, the ceiling is - # _CUA_BACKGROUND_UPDATE_TIMEOUT, and _run_cua_driver_installer's - # preflights skip in seconds when the install lock is held or - # GitHub is unreachable. Only contract repairs and fresh installs - # stay interactive-only (guards above/below) — those are the - # paths where upstream legitimately needs a human (first-time - # autostart elevation, SmartScreen). - # Pin the installer to the release check-update just confirmed. - # `latest_version` comes from the GitHub Releases API, so its - # assets are published — unlike the installer script's baked - # version on `main`, which Release Please bumps in the release - # PR *before* the release assets exist. Installing unpinned in - # that window 404s (observed: baked 0.14.0 vs latest published - # 0.13.1). 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 - - if is_windows and require_confirmed_update and not binary: - # Missing-binary path (driver enabled in config but never installed, - # or wiped by a failed install). Same rule as the repair and - # confirmed-update branches above: an automatic Windows update must - # never launch install.ps1, which can demand console/UAC consent the - # hidden updater cannot provide (#87703). - _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 - - # Show before/after version when we have a baseline. Best-effort. - 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 serializes concurrent installs -# with a lock dir at ~/.cua-driver/packages/.install.lock.d and only -# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600 of -# waiting. With a shorter Python-side timeout, a stale lock means every run -# gets killed before the installer's recovery can fire — a permanent -# "always times out" wedge (issue #58762). 660s = 600s lock window + 60s -# headroom for the actual download/swap. -_CUA_INSTALLER_TIMEOUT = 660 - -# Grace period for draining the installer's pipes after a timeout kill. The -# kill is best-effort (see _reap_after_timeout), so this drain has to be -# bounded: a descendant that survived the kill still holds the inherited -# stdout handle, and an unbounded read waits on an EOF that never comes, -# which turns the ceiling above into no ceiling at all (issue #87703). A -# successful kill closes the pipe immediately, so this costs nothing in the -# normal case; it only caps how long a failed one can stall the update. -_CUA_INSTALLER_DRAIN_GRACE = 15 - -# Optional refreshes launched by ``hermes update`` are quiet and unattended. -# Keep their interruption bounded even when upstream waits on Read-Host or a -# consent prompt. Explicit ``computer-use install --upgrade`` runs retain the -# full installer ceiling above. (The lock/network preflights below make a -# legitimate long wait impossible on this path, so a short ceiling is safe.) -_CUA_BACKGROUND_UPDATE_TIMEOUT = 120 - -# Upstream installer's stale-lock threshold (LOCK_STALE_AFTER_SECONDS in -# _install-rust.sh). Used by the pre-clear below to avoid yanking a lock -# that 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. - - The POSIX installer stamps its holder pid into ``~/.cua-driver/packages/.install.lock.d/info``. - The Windows installer instead 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 - # Holder alive → a concurrent install is running; don't touch. - return - except ProcessLookupError: - pass # dead holder → stale, clear below - except PermissionError: - # Alive but owned by someone else — treat as live. - return - else: - # No readable pid. Only clear if the lock is old enough that the - # upstream installer itself would consider it reclaimable. - 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 open with FileShare::None — any - # open attempt fails with a sharing violation while it's held. - # _clear_stale_windows_cua_install_lock() already deleted it if - # it was unheld, so surviving = 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 - lock_dir = _cua_install_lock_dir() - return 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") - - -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" - - if is_windows: - # Mirror the one-liner printed by cua_driver_install_hint(). - ps_oneliner = ( - "irm https://raw.githubusercontent.com/trycua/cua/main/" - "libs/cua-driver/scripts/install.ps1 | iex" - ) - install_cmd = [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-Command", ps_oneliner, - ] - manual_hint = ( - 'powershell -NoProfile -ExecutionPolicy Bypass -Command ' - f'"{ps_oneliner}"' - ) - script_path = None - else: - # 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 the shell-injection surface and a symlink/TOCTOU race on - # multi-user machines. The manual hint stays the upstream one-liner - # since that's what the docs/README teach. - 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 False - install_cmd = ["/bin/bash", script_path] - - 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 (install.sh and install.ps1) honour - # CUA_DRIVER_RS_VERSION over their baked default. - installer_env["CUA_DRIVER_RS_VERSION"] = pin_version - - # A previous timed-out install can leave the upstream installer's - # concurrent-install lock behind; clear it when provably stale so the - # refresh doesn't wedge waiting on a dead holder (issue #58762). - _clear_stale_cua_install_lock() - - # Unattended refreshes (installer_timeout set by `hermes update`) fail - # FAST on the two conditions that otherwise consume the whole 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. That is - # the 11-minute silent hang class (#87703; observed live 2026-08-25: - # "cua-driver refreshing timed out after 660s"). Skip in ~0s instead. - # 2. Release host unreachable (outage/DNS/firewall) — the installer - # would die slowly inside its own retries. A 5s HEAD answers now. - # - # Explicit `computer-use install --upgrade` runs keep upstream's full - # lock-recovery semantics — a human is watching and can wait or Ctrl-C. - if installer_timeout is not None: - 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 False - if not _cua_release_endpoint_reachable(): - _print_info( - " github.com is unreachable — skipping cua-driver " - "refresh (will retry on the next update)." - ) - return False - if is_windows: - # -NoAutoStart skips Register-CuaDriverAutostart entirely — 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 re-registers it. - # scriptblock invocation (instead of `| iex`) is what lets us - # pass the parameter to a piped script. - install_cmd = [ - "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", - "-Command", - "$sc = irm https://raw.githubusercontent.com/trycua/cua/" - "main/libs/cua-driver/scripts/install.ps1; " - "& ([scriptblock]::Create($sc)) -NoAutoStart", - ] - - # POSIX: run the installer in its 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. Otherwise the surviving - # grandchildren keep holding the install lock, wedging every later run. - popen_kwargs = {} - if not is_windows: - popen_kwargs["start_new_session"] = True - - def _kill_installer_tree(proc): - 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. Those descendants inherit stdout - # and can keep both communicate() and install.lock wedged, so - # collect the tree first and kill it 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): - """Kill the installer tree, then drain its pipes under a deadline. - - Draining with no deadline then blocks on an EOF that only arrives when someone kills that - process by hand, so the ``_CUA_INSTALLER_TIMEOUT`` ceiling stops bounding anything and - ``hermes update`` hangs past its own timeout warning (#87703). - """ - _kill_installer_tree(proc) - try: - drained_out, _ = proc.communicate(timeout=_CUA_INSTALLER_DRAIN_GRACE) - # Diagnosability (#87703 post-mortem): the partial output names - # WHERE the installer was stuck (lock wait, consent prompt, - # download) — before this, the answer died with the process and - # the timeout line was unactionable. - 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 here. communicate()'s - # reader threads are still blocked on that handle and closing it - # underneath them races; they are daemon threads, so abandoning - # them does not keep the interpreter alive. - 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) - - try: - # When not verbose (e.g. `hermes update`'s refresh), capture the - # installer's chatty "Next steps" wall instead of dumping it to the - # terminal. The combined output is logged so a failure stays - # debuggable. Verbose installs (interactive `computer-use install`) - # keep streaming 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) - 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) - raise - result = subprocess.CompletedProcess( - install_cmd, proc.returncode, stdout=out, stderr=None - ) - # Preserve the full installer output. During `hermes update`, - # sys.stdout is the mirroring _UpdateOutputStream whose `_log` - # handle is ~/.hermes/logs/update.log — write straight to it so - # the captured "Next steps" wall is kept in full (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 " - f"{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) - - def _ensure_browser_use_cli(*, verbose_hints: bool = False) -> None: """Install the Browser Use CLI if it isn't already runnable. diff --git a/hermes_cli/tools_config_cua.py b/hermes_cli/tools_config_cua.py new file mode 100644 index 0000000000..e6cd4ee73c --- /dev/null +++ b/hermes_cli/tools_config_cua.py @@ -0,0 +1,1076 @@ +"""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. + + The GUI runs post-setup hooks via a console-less child; on Windows every console grandchild + (npm, pip, powershell) would flash a new window. CREATE_NO_WINDOW suppresses that 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: + """Reduce a driver's ``--version`` output to one short status line. + + A ``HERMES_CUA_DRIVER_CMD`` override may print a multi-line banner (e.g. cmd.exe); interpolating + it verbatim shattered the one-line status summary, so keep the first non-empty line, bounded. + """ + 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. + + Delegates to ``cua_backend.cua_driver_child_env`` (telemetry disabled by default; user opt-in + via ``computer_use.cua_telemetry``). Falls back to the current environment if the helper can't + be imported, so install/status never break on a telemetry-helper error. + """ + 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. + + Strategy (in order): 1. ``uv pip install`` if uv is on PATH — fast, doesn't need pip in the + venv. 2. ``python -m pip install`` — works on stdlib venvs. 3. ``python -m ensurepip --upgrade`` + then retry pip — covers ``uv venv`` which creates a venv WITHOUT pip. + + Why this exists: the Windows installer creates the venv via ``uv venv``, which doesn't seed pip. + Post-setup hooks that shelled out to ``[sys.executable, '-m', 'pip', 'install', ...]`` failed + with ``No module named pip`` on every fresh install. uv-first sidesteps that. + """ + 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, and + # tier 2 is a pip that the Windows installer's `uv venv` does not seed. + 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 (it returned a package with zero binary assets and made every non-arm64 +# host skip the install), and re-implementing upstream's tag resolution here would drift. Trust the +# installer: 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]: + """`` --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 the caller should stop with + success (already latest, or indeterminate under ``require_confirmed_update``). Best-effort: an + older driver (no check-update verb) or an offline check yields None; then `hermes update` + keeps the installed version — an indeterminate check must never cost a multi-minute silent + reinstall on every update — while an explicit `computer-use install --upgrade` falls through. + """ + _state = None + 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 rather than deferring: stdin is closed (a + # consent Read-Host can't block), the version is pinned, the ceiling is + # _CUA_BACKGROUND_UPDATE_TIMEOUT, and the installer preflights skip in seconds when the + # lock is held or GitHub is unreachable. Only contract repairs and fresh installs stay + # interactive-only — there upstream legitimately needs a human (autostart elevation, + # SmartScreen). + # Pin to the release check-update just 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 the assets are published and 404s when installed 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( + " HERMES_CUA_DRIVER_CMD does not resolve to an executable: " + f"{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 " + f"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 serializes installs with ~/.cua-driver/packages/.install.lock.d and only +# force-releases a dead holder's lock after LOCK_STALE_AFTER_SECONDS=600. A shorter Python-side +# timeout kills every run before that recovery fires — a permanent "always times out" wedge. +# 660s = 600s lock window + 60s headroom for the download/swap. +_CUA_INSTALLER_TIMEOUT = 660 + +# Grace for draining the installer's pipes after a timeout kill. The kill is best-effort (see +# _reap_after_timeout), so the drain must be bounded: a surviving descendant still holds the +# inherited stdout handle and an unbounded read waits on an EOF that never comes, turning the +# ceiling above into no ceiling. A successful kill closes the pipe immediately, 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. (The +# lock/network preflights make a legitimate long wait impossible here, so a short ceiling is safe.) +_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. + + The POSIX installer stamps its holder pid into ``~/.cua-driver/packages/.install.lock.d/info``. + The Windows installer instead 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. The stale-clear already deleted an unheld file, so surviving + # = 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 + lock_dir = _cua_install_lock_dir() + return 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 both communicate() and install.lock wedged, + # so collect the tree first and kill it 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); without it the timeout line is unactionable. + 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 = ( + 'powershell -NoProfile -ExecutionPolicy Bypass -Command ' + f'"{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 on multi-user machines. The + # manual hint stays the upstream one-liner the docs teach. + 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 now. + + 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 (instead of + # `| iex`) is what lets us pass the parameter to a piped script. + 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 " + f"{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) diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 9740fc5c9f..e6764abda4 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -13,7 +13,7 @@ must: incomplete installations, and install when missing. The pre-install arch probe that used to live alongside this function was -deleted (see top-of-file comment in tools_config.py) — the upstream +deleted (see the release-probe comment in tools_config_cua.py) — the upstream installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors cleanly on missing-arch assets, and the upgrade path uses ``cua_driver_update_check()`` (which shells `cua-driver check-update @@ -61,7 +61,7 @@ def _runtime_manifest(version="0.20.0", *, omit=None): class TestCuaDriverRuntimeContract: def test_current_manifest_is_ready(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config result = SimpleNamespace( returncode=0, @@ -80,7 +80,7 @@ class TestCuaDriverRuntimeContract: @pytest.mark.parametrize("version", ["0.19.4", "bad-version"]) def test_old_or_unversioned_driver_needs_repair(self, version): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config result = SimpleNamespace( returncode=0, @@ -94,7 +94,7 @@ class TestCuaDriverRuntimeContract: assert state["reason"] def test_incomplete_manifest_needs_repair(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config result = SimpleNamespace( returncode=0, @@ -128,7 +128,7 @@ class TestInstallCuaDriverUpgrade: no subprocess, no path handling, no import — so there is nothing underneath the branch for a real host to falsify. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config, "_print_warning") as warn, \ patch("platform.system", return_value="FreeBSD"): @@ -137,7 +137,7 @@ class TestInstallCuaDriverUpgrade: def test_non_upgrade_on_unsupported_platform_warns(self): """Same narrow exception as above — see that test's docstring.""" - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config, "_print_warning") as warn, \ patch("platform.system", return_value="FreeBSD"): @@ -145,7 +145,7 @@ class TestInstallCuaDriverUpgrade: warn.assert_called() def test_upgrade_with_binary_present_runs_installer(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n @@ -164,7 +164,7 @@ class TestInstallCuaDriverUpgrade: assert kwargs.get("verbose") is False def test_upgrade_without_binary_runs_installer(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ @@ -179,7 +179,7 @@ class TestInstallCuaDriverUpgrade: branch, which this lane takes for real.""" from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 1 @@ -212,7 +212,7 @@ class TestInstallCuaDriverUpgrade: """``linux_only``: same POSIX Popen path as the test above.""" from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 1 @@ -245,7 +245,7 @@ class TestInstallCuaDriverUpgrade: import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 1 @@ -280,7 +280,7 @@ class TestInstallCuaDriverUpgrade: fake_proc.communicate.assert_called_once_with(timeout=120) def test_upgrade_can_suppress_installer_progress(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object( tools_config.shutil, @@ -310,7 +310,7 @@ class TestInstallCuaDriverUpgrade: assert runner.call_args.kwargs["show_progress"] is False def test_upgrade_non_writable_install_target_skips_refresh(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n @@ -327,7 +327,7 @@ class TestInstallCuaDriverUpgrade: ) def test_fresh_install_non_writable_install_target_skips_install(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ @@ -353,7 +353,7 @@ class TestInstallCuaDriverUpgrade: """ import os - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config writable = tools_config._cua_install_target_writable() if os.path.isdir("/Applications"): @@ -362,7 +362,7 @@ class TestInstallCuaDriverUpgrade: assert writable is True def test_non_upgrade_with_binary_skips_install(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n @@ -383,7 +383,7 @@ class TestInstallCuaDriverUpgrade: runner.assert_not_called() def test_non_upgrade_repairs_incompatible_existing_driver(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config incompatible = { "ready": False, @@ -416,7 +416,7 @@ class TestInstallCuaDriverUpgrade: assert runner.call_args.kwargs["label"] == "Repairing" def test_incompatible_explicit_override_is_not_replaced(self, monkeypatch): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") incompatible = { @@ -443,7 +443,7 @@ class TestInstallCuaDriverUpgrade: def test_missing_explicit_override_does_not_install_standard_driver( self, monkeypatch, upgrade ): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/missing/custom/cua-driver") with patch.object( @@ -457,7 +457,7 @@ class TestInstallCuaDriverUpgrade: runner.assert_not_called() def test_non_upgrade_without_binary_runs_installer(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ @@ -493,7 +493,7 @@ class TestRequireConfirmedUpdate: """ from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config _which_names = {"curl", "powershell"} if not binary_missing: @@ -637,7 +637,7 @@ class TestRequireConfirmedUpdate: wedge).""" from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config incompatible = { "ready": False, @@ -761,7 +761,7 @@ class TestArchProbeRemoval: """ def test_probe_function_is_gone(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch") assert not hasattr(tools_config, "_latest_cua_driver_rs_release") @@ -771,7 +771,7 @@ class TestArchProbeRemoval: line. install.sh errors cleanly when the arch has no asset, so the probe was duplicate gatekeeping. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config # No platform fake: "does Python hit the GitHub API?" is host-agnostic, # and ``which`` is stubbed so the host's own fetch tool resolves. @@ -793,7 +793,7 @@ class TestArchProbeRemoval: short-circuits the network re-install via the binary's native ``check-update --json`` verb. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n @@ -837,7 +837,7 @@ class TestPosixStaleInstallLockClear: os.environ.pop("CUA_DRIVER_RS_HOME", None) def test_dead_holder_lock_is_cleared(self, tmp_path): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config dead_pid = 4194000 # above default pid_max on most systems lock = self._make_lock(tmp_path, pid=dead_pid) @@ -847,14 +847,14 @@ class TestPosixStaleInstallLockClear: def test_live_holder_lock_is_kept(self, tmp_path): import os - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config lock = self._make_lock(tmp_path, pid=os.getpid()) tools_config._clear_stale_cua_install_lock() assert lock.exists() def test_pidless_fresh_lock_is_kept(self, tmp_path): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config lock = self._make_lock(tmp_path, pid=None) tools_config._clear_stale_cua_install_lock() @@ -863,7 +863,7 @@ class TestPosixStaleInstallLockClear: def test_pidless_old_lock_is_cleared(self, tmp_path): import os import time - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config lock = self._make_lock(tmp_path, pid=None) old = time.time() - (tools_config._CUA_LOCK_STALE_AFTER + 60) @@ -875,7 +875,7 @@ class TestPosixStaleInstallLockClear: def test_no_lock_is_noop(self, tmp_path): import os os.environ["CUA_DRIVER_RS_HOME"] = str(tmp_path / ".cua-driver") - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config tools_config._clear_stale_cua_install_lock() # must not raise @@ -886,7 +886,7 @@ class TestWindowsStaleInstallLockClearDispatch: test — on Linux the faked platform asserted the dispatch and skipped the ``.install.lock.d`` directory that really exists here. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object( tools_config, "_clear_stale_windows_cua_install_lock" @@ -917,7 +917,7 @@ class TestWindowsStaleInstallLockClear: os.environ.pop("CUA_DRIVER_RS_HOME", None) def test_unlocked_lock_file_is_cleared(self, tmp_path): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config lock = self._make_lock(tmp_path) with patch.object(tools_config, "_print_info"): @@ -928,7 +928,7 @@ class TestWindowsStaleInstallLockClear: def test_lock_held_with_file_share_none_is_kept(self, tmp_path): import ctypes from ctypes import wintypes - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config lock = self._make_lock(tmp_path) kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) @@ -979,7 +979,7 @@ class TestInstallerTimeoutKillsProcessGroup: import signal import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config killed = {} sigkill = getattr(signal, "SIGKILL", signal.SIGTERM) @@ -1016,7 +1016,7 @@ class TestInstallerTimeoutKillsProcessGroup: assert fake_proc.communicate.call_count == 2 def test_timeout_ceiling_exceeds_upstream_lock_window(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config # The upstream installer waits up to 600s before reclaiming a stale # lock; our ceiling must give that window room to complete. assert tools_config._CUA_INSTALLER_TIMEOUT > tools_config._CUA_LOCK_STALE_AFTER @@ -1024,7 +1024,7 @@ class TestInstallerTimeoutKillsProcessGroup: @pytest.mark.linux_only def test_installer_runs_in_new_session_on_posix(self): from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config captured = {} fake_proc = MagicMock() @@ -1049,7 +1049,7 @@ class TestInstallerTimeoutKillsProcessGroup: def test_windows_timeout_kills_descendants_and_parent(self): import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config child = MagicMock() parent = MagicMock() @@ -1087,7 +1087,7 @@ class TestInstallerTimeoutKillsProcessGroup: import psutil import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config parent = MagicMock() parent.children.side_effect = psutil.AccessDenied(pid=12345) @@ -1130,7 +1130,7 @@ class TestInstallerTimeoutDrainIsBounded: """ def test_drain_grace_is_short_relative_to_the_run_ceiling(self): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config # This is a grace period for a pipe that a live process is holding # open, not a second budget for the install itself — the install is @@ -1148,7 +1148,7 @@ class TestInstallerTimeoutDrainIsBounded: """ import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 12345 @@ -1185,7 +1185,7 @@ class TestInstallerTimeoutDrainIsBounded: """ import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 12345 @@ -1225,7 +1225,7 @@ class TestInstallerTimeoutDrainIsBounded: import psutil import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config child = MagicMock() child.kill.side_effect = psutil.AccessDenied(pid=999) @@ -1270,7 +1270,7 @@ class TestInstallerTimeoutDrainIsBounded: """ import subprocess from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config parent = MagicMock() parent.children.return_value = [] @@ -1310,7 +1310,7 @@ class TestInstallerNoShell: def _run(self, download_rc=0): from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config calls = [] fake_proc = MagicMock() @@ -1363,7 +1363,7 @@ class TestInstallerNoShell: import os captured = {} from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config fake_proc = MagicMock() fake_proc.pid = 1 @@ -1407,7 +1407,7 @@ class TestConfirmedVersionPinning: """Version pinning also applies to explicit installer runs.""" from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/x/" + n @@ -1477,7 +1477,7 @@ class TestRunInstallerPinEnv: def _run(self, pin_version): from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config captured = {} fake_proc = MagicMock() @@ -1521,7 +1521,7 @@ class TestWindowsAutostartRepair: True unconditionally off Windows, so only the fake made the schtasks probe run at all. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config calls = [] @@ -1547,7 +1547,7 @@ class TestWindowsAutostartRepair: repair hook are both inside the ``is_windows`` branch, so on Linux the fake selected a branch whose `powershell` doesn't exist on PATH.""" from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config captured = {} fake_proc = MagicMock() @@ -1590,7 +1590,7 @@ class TestWindowsAutostartRepair: """``windows_only``: same early return off Windows — the elevated PowerShell command string is only built on a real Windows host. """ - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config calls = [] driver = ( @@ -1639,7 +1639,7 @@ class TestCuaVersionSummary: @staticmethod def _summary(raw, **kw): - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config return tools_config._cua_version_summary(raw, **kw) @@ -1679,7 +1679,7 @@ class TestUnattendedRefreshPreflights: system="Linux"): from unittest.mock import MagicMock - from hermes_cli import tools_config + from hermes_cli import tools_config_cua as tools_config proc = MagicMock() proc.communicate.return_value = ("ok", None) diff --git a/tests/hermes_cli/test_memory_setup_provider_arg.py b/tests/hermes_cli/test_memory_setup_provider_arg.py index 7df85c3156..01fadb1306 100644 --- a/tests/hermes_cli/test_memory_setup_provider_arg.py +++ b/tests/hermes_cli/test_memory_setup_provider_arg.py @@ -64,8 +64,8 @@ class TestInstallDependenciesRunner: # tests/tools/test_lazy_deps.py uses. with _patch.dict(os.environ, {"HERMES_DISABLE_LAZY_INSTALLS": "0"}), \ patch("plugins.memory.find_provider_dir", return_value=tmp_path), \ - patch("hermes_cli.tools_config.shutil.which", side_effect=which_side_effect), \ - patch("hermes_cli.tools_config.subprocess.run", fake_run): + patch("hermes_cli.tools_config_cua.shutil.which", side_effect=which_side_effect), \ + patch("hermes_cli.tools_config_cua.subprocess.run", fake_run): memory_setup._install_dependencies("x") return calls, sys.executable