feat(computer-use): refresh cua-driver on hermes update + add install --upgrade (#24063)

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.
This commit is contained in:
Teknium 2026-05-11 17:10:58 -07:00 committed by GitHub
parent 97a0e69df0
commit ced1990c1c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 308 additions and 50 deletions

View file

@ -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")

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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."*