From ced1990c1cab2413e6778d0eb35f526b6b9c1359 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 11 May 2026 17:10:58 -0700 Subject: [PATCH] feat(computer-use): refresh cua-driver on `hermes update` + add `install --upgrade` (#24063) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cua-driver was only installed once on toolset enable: `_run_post_setup` early-returns when the binary is already on PATH, so upstream fixes (e.g. v0.1.6 Safari window-focus fix) never reached existing users without manual reinstall. Two refresh points now: - `hermes update` re-runs the upstream installer at the end of the update if cua-driver is on PATH (macOS-only, no-op otherwise). Ties driver freshness to the user-controlled update cadence — no startup latency, no per-launch GitHub API call. - `hermes computer-use install --upgrade` for manual force-refresh. The upstream `install.sh` always pulls the latest release, so re-running is the canonical upgrade path. No version-comparison logic needed. `hermes computer-use status` now shows the installed version, and points at `--upgrade` for refreshing. --- hermes_cli/main.py | 46 ++++- hermes_cli/tools_config.py | 172 +++++++++++++----- tests/hermes_cli/test_install_cua_driver.py | 115 ++++++++++++ website/docs/reference/cli-commands.md | 8 +- .../docs/user-guide/features/computer-use.md | 17 ++ 5 files changed, 308 insertions(+), 50 deletions(-) create mode 100644 tests/hermes_cli/test_install_cua_driver.py diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 009af00b6ee..f70b7ea9d95 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -7801,6 +7801,22 @@ def _cmd_update_impl(args, gateway_mode: bool): except Exception as e: logger.debug("FHS PATH guard check failed: %s", e) + # Refresh the cua-driver binary used by the Computer Use toolset. + # The upstream installer is gated on macOS and on the binary already + # being on PATH, so this is a no-op for users who don't have it. + # Tying the refresh to ``hermes update`` gives users a predictable + # cadence (matches when they pull new agent code) without adding + # startup latency or a per-launch GitHub API call. + try: + if sys.platform == "darwin" and shutil.which("cua-driver"): + from hermes_cli.tools_config import install_cua_driver + + print() + print("→ Refreshing cua-driver (Computer Use)...") + install_cua_driver(upgrade=True) + except Exception as e: + logger.debug("cua-driver refresh failed: %s", e) + # Write exit code *before* the gateway restart attempt. # When running as ``hermes update --gateway`` (spawned by the gateway's # /update command), this process lives inside the gateway's systemd @@ -10801,10 +10817,19 @@ Examples: ) computer_use_sub = computer_use_parser.add_subparsers(dest="computer_use_action") - computer_use_sub.add_parser( + computer_use_install = computer_use_sub.add_parser( "install", help="Install or repair the cua-driver binary (macOS)", ) + computer_use_install.add_argument( + "--upgrade", + action="store_true", + help=( + "Re-run the upstream installer even if cua-driver is already on " + "PATH. The upstream install.sh always pulls the latest release, " + "so this performs an in-place upgrade." + ), + ) computer_use_sub.add_parser( "status", help="Print whether cua-driver is installed and on PATH", @@ -10813,14 +10838,27 @@ Examples: def cmd_computer_use(args): action = getattr(args, "computer_use_action", None) if action == "install": - from hermes_cli.tools_config import _run_post_setup - _run_post_setup("cua_driver") + from hermes_cli.tools_config import install_cua_driver + install_cua_driver(upgrade=bool(getattr(args, "upgrade", False))) return if action == "status": import shutil + import subprocess path = shutil.which("cua-driver") if path: - print(f"cua-driver: installed at {path}") + version = "" + try: + version = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except Exception: + pass + if version: + print(f"cua-driver: installed at {path} ({version})") + else: + print(f"cua-driver: installed at {path}") + print(" Refresh to latest: hermes computer-use install --upgrade") return print("cua-driver: not installed") print(" Run: hermes computer-use install") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 81e4d327c0b..ba44d03c10e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -591,6 +591,132 @@ def _pip_install( ) +def install_cua_driver(upgrade: bool = False) -> 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`` — original post-setup behaviour: skip if already + installed, install otherwise. Used by the toolset enable flow where + we don't want to surprise the user with a network fetch. + * ``upgrade=True`` — always re-run the installer (or call ``cua-driver + update`` if the binary supports it). Used by ``hermes update`` and + by ``hermes computer-use install --upgrade``. + + Returns True iff cua-driver is installed (or successfully refreshed) + when the function returns. macOS-only — silently returns False on + other platforms. + """ + import platform as _plat + import shutil + import subprocess + + if _plat.system() != "Darwin": + if upgrade: + # Silent on non-macOS — `hermes update` calls this for every + # user; only macOS users with cua-driver care. + return False + _print_warning(" Computer Use (cua-driver) is macOS-only; skipping.") + return False + + binary = shutil.which("cua-driver") + + # Not installed → fresh install path (only when caller asked for it). + if not binary and not upgrade: + if not shutil.which("curl"): + _print_warning(" curl 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") + + # Already installed and caller didn't ask to upgrade → just confirm. + if binary and not upgrade: + try: + version = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + _print_success(f" cua-driver already installed: {version or 'unknown version'}") + except Exception: + _print_success(" cua-driver already installed.") + _print_info(" Grant macOS permissions if not done yet:") + _print_info(" System Settings > Privacy & Security > Accessibility") + _print_info(" System Settings > Privacy & Security > Screen Recording") + return True + + # upgrade=True path — refresh to the latest upstream release. + if not shutil.which("curl"): + _print_warning(" curl not found — cannot refresh cua-driver.") + return bool(binary) + + if binary: + # Show before/after version when we have a baseline. Best-effort. + try: + before = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + except Exception: + before = "" + else: + before = "" + + ok = _run_cua_driver_installer(label="Refreshing", verbose=False) + if ok and before: + try: + after = subprocess.run( + ["cua-driver", "--version"], + capture_output=True, text=True, timeout=5, + ).stdout.strip() + if after and after != before: + _print_success(f" cua-driver upgraded: {before} → {after}") + elif after: + _print_info(f" cua-driver up to date: {after}") + except Exception: + pass + return ok + + +def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool: + """Run the upstream cua-driver install.sh. Returns True on success. + + The script is idempotent: it always downloads the latest release, so + re-running it on an already-installed system performs an upgrade. + """ + import shutil + import subprocess + + install_cmd = ( + "/bin/bash -c \"$(curl -fsSL " + "https://raw.githubusercontent.com/trycua/cua/main/" + "libs/cua-driver/scripts/install.sh)\"" + ) + if verbose: + _print_info(f" {label} cua-driver (macOS background computer-use)...") + else: + _print_info(f" {label} cua-driver...") + try: + result = subprocess.run(install_cmd, shell=True, timeout=300) + if result.returncode == 0 and shutil.which("cua-driver"): + if verbose: + _print_success(" cua-driver installed.") + _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.") + return True + _print_warning(f" cua-driver {label.lower()} did not complete. Re-run manually:") + _print_info(f" {install_cmd}") + return False + except subprocess.TimeoutExpired: + _print_warning(f" cua-driver {label.lower()} timed out. Re-run manually.") + return False + except Exception as e: + _print_warning(f" cua-driver {label.lower()} failed: {e}") + return False + + def _run_post_setup(post_setup_key: str): """Run post-setup hooks for tools that need extra installation steps.""" import shutil @@ -729,51 +855,7 @@ def _run_post_setup(post_setup_key: str): _print_info(" docker run -p 9377:9377 -e CAMOFOX_PORT=9377 jo-inc/camofox-browser") elif post_setup_key == "cua_driver": - # cua-driver provides macOS background computer-use (SkyLight SPIs). - # Install via upstream curl script if the binary isn't on $PATH yet. - import platform as _plat - import subprocess - if _plat.system() != "Darwin": - _print_warning(" Computer Use (cua-driver) is macOS-only; skipping.") - return - if shutil.which("cua-driver"): - try: - version = subprocess.run( - ["cua-driver", "--version"], - capture_output=True, text=True, timeout=5, - ).stdout.strip() - _print_success(f" cua-driver already installed: {version or 'unknown version'}") - except Exception: - _print_success(" cua-driver already installed.") - _print_info(" Grant macOS permissions if not done yet:") - _print_info(" System Settings > Privacy & Security > Accessibility") - _print_info(" System Settings > Privacy & Security > Screen Recording") - return - if not shutil.which("curl"): - _print_warning(" curl not found — install manually:") - _print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md") - return - _print_info(" Installing cua-driver (macOS background computer-use)...") - try: - install_cmd = ( - "/bin/bash -c \"$(curl -fsSL " - "https://raw.githubusercontent.com/trycua/cua/main/" - "libs/cua-driver/scripts/install.sh)\"" - ) - result = subprocess.run(install_cmd, shell=True, timeout=300) - if result.returncode == 0 and shutil.which("cua-driver"): - _print_success(" cua-driver installed.") - _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_warning(" cua-driver install did not complete. Re-run manually:") - _print_info(f" {install_cmd}") - except subprocess.TimeoutExpired: - _print_warning(" cua-driver install timed out. Re-run manually.") - except Exception as e: - _print_warning(f" cua-driver install failed: {e}") + install_cua_driver(upgrade=False) elif post_setup_key == "kittentts": try: diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py new file mode 100644 index 00000000000..42a49e22b5d --- /dev/null +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -0,0 +1,115 @@ +"""Tests for ``install_cua_driver`` upgrade semantics. + +The cua-driver upstream installer always pulls the latest release tag, so +re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)`` +must: + +* Be macOS-only — no-op silently on Linux/Windows so ``hermes update`` can + call it unconditionally without warning every non-macOS user. +* Re-run the installer even when the binary is already on PATH (this is the + fix for the "we only pulled cua-driver once on enable" complaint). +* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow: + skip if installed, install otherwise, warn on non-macOS. +""" + +from __future__ import annotations + +from unittest.mock import patch + + +class TestInstallCuaDriverUpgrade: + def test_upgrade_on_non_macos_is_silent_noop(self): + """``hermes update`` calls install_cua_driver(upgrade=True) for every + user. On Linux/Windows it must return False without printing the + "macOS-only; skipping" warning that the toolset-enable path emits.""" + from hermes_cli import tools_config + + with patch.object(tools_config, "_print_warning") as warn, \ + patch("platform.system", return_value="Linux"): + assert tools_config.install_cua_driver(upgrade=True) is False + warn.assert_not_called() + + def test_non_upgrade_on_non_macos_warns(self): + """The toolset-enable path (upgrade=False) should still warn loudly + when the user tries to enable Computer Use on a non-macOS host.""" + from hermes_cli import tools_config + + with patch.object(tools_config, "_print_warning") as warn, \ + patch("platform.system", return_value="Linux"): + assert tools_config.install_cua_driver(upgrade=False) is False + warn.assert_called() + + def test_upgrade_on_macos_with_binary_runs_installer(self): + """When cua-driver is already on PATH and upgrade=True, we must + re-run the upstream installer (this is the fix for the bug report). + """ + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/local/bin/" + n + if n in ("cua-driver", "curl") else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner, \ + patch("subprocess.run"): + assert tools_config.install_cua_driver(upgrade=True) is True + runner.assert_called_once() + # Refresh path uses non-verbose mode so we don't re-print the + # "grant macOS permissions" block on every `hermes update`. + kwargs = runner.call_args.kwargs + assert kwargs.get("verbose") is False + + def test_upgrade_on_macos_without_binary_runs_installer(self): + """upgrade=True with cua-driver missing must still trigger an + install — equivalent to a fresh install. (Don't silently no-op.)""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner: + assert tools_config.install_cua_driver(upgrade=True) is True + runner.assert_called_once() + + def test_non_upgrade_on_macos_with_binary_skips_install(self): + """Original toolset-enable behaviour: cua-driver already installed + + upgrade=False → confirm and return without re-running installer. + This is the behaviour that ``hermes tools`` (re)enable depends on, + so the new helper must not regress it.""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/local/bin/" + n + if n in ("cua-driver", "curl") else None), \ + patch.object(tools_config, "_run_cua_driver_installer") as runner, \ + patch("subprocess.run"): + assert tools_config.install_cua_driver(upgrade=False) is True + runner.assert_not_called() + + def test_non_upgrade_on_macos_without_binary_runs_installer(self): + """Original fresh-install path must still work.""" + from hermes_cli import tools_config + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", + side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner: + assert tools_config.install_cua_driver(upgrade=False) is True + runner.assert_called_once() + + def test_upgrade_without_curl_does_not_crash(self): + """If curl isn't on PATH we can't refresh — must warn and return + the current install state, not raise.""" + from hermes_cli import tools_config + + # cua-driver present, curl missing. + def _which(name): + return "/usr/local/bin/cua-driver" if name == "cua-driver" else None + + with patch("platform.system", return_value="Darwin"), \ + patch.object(tools_config.shutil, "which", side_effect=_which), \ + patch.object(tools_config, "_print_warning"): + assert tools_config.install_cua_driver(upgrade=True) is True diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index ed15665d661..1079bdf3ca2 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -976,7 +976,8 @@ Subcommands: | Subcommand | Description | |------------|-------------| | `install` | Run the upstream cua-driver installer (macOS only). | -| `status` | Print whether `cua-driver` is on `$PATH`. | +| `install --upgrade` | Re-run the installer even if cua-driver is already on PATH. The upstream script always pulls the latest release, so this performs an in-place upgrade. | +| `status` | Print whether `cua-driver` is on `$PATH` and which version is installed. | `hermes computer-use install` is the stable entry point for installing the [cua-driver](https://github.com/trycua/cua) binary used by the @@ -985,6 +986,11 @@ Subcommands: to use for re-running the install if the toolset toggle didn't trigger it (for example, on returning-user setups). +`hermes update` automatically re-runs the upstream installer at the end +of the update if cua-driver is on PATH, so most users will not need to +call `--upgrade` manually. Use it when upstream ships a fix you want +right now without waiting for the next Hermes update. + ## `hermes sessions` ```bash diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index e4c28586963..d05ff954656 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -57,6 +57,23 @@ After installing, regardless of which path you took: ``` or add `computer_use` to your enabled toolsets in `~/.hermes/config.yaml`. +## Keeping cua-driver up to date + +The cua-driver project ships fixes regularly (e.g. v0.1.6 fixed a Safari +window-focus bug for UTM workflows). Hermes refreshes the binary in two +places so you don't get stuck on a stale release: + +- **`hermes update`** — when you update Hermes itself, if `cua-driver` is + on PATH the upstream installer re-runs at the end of the update. + No-op for non-macOS users and for users without cua-driver installed. +- **`hermes computer-use install --upgrade`** — manual force-refresh. + Re-runs the upstream installer regardless of whether cua-driver is + already installed. Use this when you want the latest fix without + waiting for the next agent update. + +`hermes computer-use status` shows the installed version next to the +binary path. + ## Quick example User prompt: *"Find my latest email from Stripe and summarise what they want me to do."*