feat(computer-use): use cua-driver's native check-update instead of a hardcoded version floor

Replace the per-OS MIN_CUA_DRIVER_VERSION soft-warning (hardcoded, rot-prone)
with cua-driver's native check-update — the source-of-truth freshness check
shipped in trycua/cua#1734 (`check-update --json` CLI verb / `check_for_update`
MCP tool).

- cua_backend: cua_driver_update_check() shells `check-update --json`
  (stdin=DEVNULL so a pre-#1734 driver that falls through to a stdin read
  fails fast instead of blocking; timeout-guarded). Returns None when
  indeterminate (verb absent / offline / error payload / unparseable) so
  callers stay quiet. cua_driver_update_nudge() formats the one-liner; the
  startup nudge runs off-thread so the (cached, ~20h) GitHub poll never
  blocks the first computer_use action.
- status: `hermes computer-use status` reports current/latest and an
  "update available" line via the native check.
- update: install_cua_driver(upgrade=True) skips the network re-install when
  the driver reports it's already on the latest release.
- Graceful on pre-#1734 drivers (e.g. 0.2.18 on the dev host): verb absent →
  stay quiet (verified: returns None in ~0.4s).
- tests: TestUpdateCheck replaces TestVersionWarning.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Francesco Bonacci 2026-05-27 16:03:43 +00:00 • committed by Teknium
parent 649f4c8c1a
commit 1542511eb7
No known key found for this signature in database
4 changed files with 153 additions and 125 deletions

View file

@ -11920,13 +11920,19 @@ def main():
else:
print(f"cua-driver: installed at {path}")
try:
from tools.computer_use.cua_backend import cua_driver_version_warning
warn = cua_driver_version_warning()
if warn:
print(f" ⚠ {warn}")
from tools.computer_use.cua_backend import cua_driver_update_check
st = cua_driver_update_check()
if st and st.get("update_available"):
latest = st.get("latest_version") or "?"
print(f" ⬆ Update available: cua-driver {latest}.")
print(" Run: hermes computer-use install --upgrade")
elif st:
print(" ✓ Up to date.")
else:
# Older driver (no check-update verb) or offline.
print(" Refresh to latest: hermes computer-use install --upgrade")
except Exception:
pass
print(" Refresh to latest: hermes computer-use install --upgrade")
print(" Refresh to latest: hermes computer-use install --upgrade")
return
print("cua-driver: not installed")
print(" Run: hermes computer-use install")

View file

@ -800,6 +800,23 @@ def install_cua_driver(upgrade: bool = False) -> bool:
if not _check_cua_driver_asset_for_arch():
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, in which case we fall through and
# re-run the installer as before.
if binary:
try:
from tools.computer_use.cua_backend import cua_driver_update_check
_state = cua_driver_update_check()
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
except Exception:
pass
if binary:
# Show before/after version when we have a baseline. Best-effort.
try:

View file

@ -1127,66 +1127,63 @@ class TestElementLabelParsing:
assert labels[15] == "Search"
class TestVersionWarning:
"""cua_driver_version_warning(): soft, per-OS minimum-version check.
class TestUpdateCheck:
"""cua_driver_update_check() / _nudge(): native `check-update --json`.
macOS (Swift build) and the cross-platform Rust build version
independently, so the floor is keyed by platform. Older → warn; equal /
newer / local-dev / unknown-platform / undeterminable → stay quiet.
Prefers cua-driver's source-of-truth update check over a hardcoded
version floor. Stays quiet (None) when indeterminate: an old driver with
no `check-update` verb, offline, an `error` payload, or unparseable output.
"""
def test_older_than_floor_warns(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.10"), \
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
msg = cua_backend.cua_driver_version_warning()
assert msg is not None
assert "0.2.10" in msg and "0.2.16" in msg # installed + floor
def test_equal_to_floor_is_quiet(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.16"), \
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
assert cua_backend.cua_driver_version_warning() is None
def test_newer_than_floor_is_quiet(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.2.18"), \
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
assert cua_backend.cua_driver_version_warning() is None
def test_local_build_exempt(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.0.0-local-release"), \
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
assert cua_backend.cua_driver_version_warning() is None
def test_macos_floor_independent_of_rust_build(self):
# darwin floor is 0.5.0; a fresh 0.5.x macOS build must not warn even
# though it would be "newer" than the 0.2.x Rust floor.
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.5.1"), \
patch("tools.computer_use.cua_backend.sys.platform", "darwin"):
assert cua_backend.cua_driver_version_warning() is None
def test_unknown_platform_is_quiet(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value="0.0.1"), \
patch("tools.computer_use.cua_backend.sys.platform", "freebsd13"):
assert cua_backend.cua_driver_version_warning() is None
def test_undeterminable_version_is_quiet(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "installed_cua_driver_version", return_value=None), \
patch("tools.computer_use.cua_backend.sys.platform", "win32"):
assert cua_backend.cua_driver_version_warning() is None
def test_installed_version_parses_cli_output(self):
from tools.computer_use import cua_backend
@staticmethod
def _run_returning(stdout: str):
fake = MagicMock()
fake.stdout = "cua-driver 0.2.18\n"
with patch("tools.computer_use.cua_backend.subprocess.run", return_value=fake):
assert cua_backend.installed_cua_driver_version() == "0.2.18"
fake.stdout = stdout
return patch("tools.computer_use.cua_backend.subprocess.run", return_value=fake)
def test_update_available(self):
from tools.computer_use import cua_backend
payload = '{"current_version":"0.3.1","latest_version":"0.3.2","update_available":true}'
with self._run_returning(payload):
st = cua_backend.cua_driver_update_check()
assert st is not None and st["update_available"] is True
msg = cua_backend.cua_driver_update_nudge()
assert msg is not None
assert "0.3.2" in msg and "0.3.1" in msg
def test_up_to_date_is_quiet(self):
from tools.computer_use import cua_backend
payload = '{"current_version":"0.3.2","latest_version":"0.3.2","update_available":false}'
with self._run_returning(payload):
st = cua_backend.cua_driver_update_check()
assert st is not None and st["update_available"] is False
assert cua_backend.cua_driver_update_nudge() is None
def test_error_payload_is_indeterminate(self):
from tools.computer_use import cua_backend
payload = '{"current_version":"0.3.2","update_available":false,"error":"github 503"}'
with self._run_returning(payload):
assert cua_backend.cua_driver_update_check() is None
assert cua_backend.cua_driver_update_nudge() is None
def test_old_driver_without_verb_is_quiet(self):
# Drivers predating trycua/cua#1734 print usage to stderr; stdout empty.
from tools.computer_use import cua_backend
with self._run_returning(""):
assert cua_backend.cua_driver_update_check() is None
assert cua_backend.cua_driver_update_nudge() is None
def test_nonjson_output_is_quiet(self):
from tools.computer_use import cua_backend
with self._run_returning("cua-driver 0.2.18\n"):
assert cua_backend.cua_driver_update_check() is None
def test_subprocess_failure_is_quiet(self):
from tools.computer_use import cua_backend
with patch("tools.computer_use.cua_backend.subprocess.run",
side_effect=FileNotFoundError()):
assert cua_backend.cua_driver_update_check() is None
assert cua_backend.cua_driver_update_nudge() is None
class TestLazyMcpInstall:
@ -1202,7 +1199,7 @@ class TestLazyMcpInstall:
def test_start_lazy_installs_mcp(self):
from tools.computer_use import cua_backend
with patch.object(cua_backend, "_warn_if_cua_driver_outdated"), \
with patch.object(cua_backend, "_maybe_nudge_update"), \
patch("tools.lazy_deps.ensure") as mock_ensure, \
patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start:
cua_backend.CuaDriverBackend().start()
@ -1218,7 +1215,7 @@ class TestLazyMcpInstall:
unavailable = FeatureUnavailable(
"tool.computer_use", ("mcp==1.26.0",), "lazy installs disabled"
)
with patch.object(cua_backend, "_warn_if_cua_driver_outdated"), \
with patch.object(cua_backend, "_maybe_nudge_update"), \
patch("tools.lazy_deps.ensure", side_effect=unavailable), \
patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start:
with pytest.raises(FeatureUnavailable):

View file

@ -55,28 +55,18 @@ logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Version compatibility
# Update checking
# ---------------------------------------------------------------------------
#
# cua-driver ships as two independently-versioned products: the macOS Swift
# build (~0.5.x) and the cross-platform Rust build, cua-driver-rs (~0.2.x),
# used on Windows / Linux. Their version lines do NOT track each other, so the
# minimum-supported version is per-OS.
#
# This is a SOFT floor: Hermes warns — it does not refuse to run — when the
# installed binary is older than the version it was tested against. Newer is
# always fine; local dev builds (0.0.0-local-*) are exempt. Bump these as
# newer drivers are verified.
# cua-driver ships a native `check-update` verb (and a `check_for_update` MCP
# tool) that compares the installed binary against the latest GitHub release —
# the source of truth — and caches the result (~20h). We prefer that over a
# hardcoded version floor, which would rot and can't know what "latest" is.
#
# There is intentionally no version *pin* knob: the upstream installer always
# fetches the latest release, so a `HERMES_CUA_DRIVER_VERSION` env var would
# only have *looked* like it pinned without doing so. For a reproducible
# version, point `HERMES_CUA_DRIVER_CMD` at a specific binary instead.
MIN_CUA_DRIVER_VERSION = {
"darwin": "0.5.0", # macOS Swift build
"win32": "0.2.16", # cua-driver-rs
"linux": "0.2.16", # cua-driver-rs (alpha)
}
# only have *looked* like it pinned. For a reproducible version, point
# `HERMES_CUA_DRIVER_CMD` at a specific binary instead.
_CUA_DRIVER_CMD = os.environ.get("HERMES_CUA_DRIVER_CMD", "cua-driver")
_CUA_DRIVER_ARGS = ["mcp"] # stdio MCP transport
@ -117,61 +107,79 @@ def cua_driver_binary_available() -> bool:
return bool(shutil.which(_CUA_DRIVER_CMD))
def installed_cua_driver_version() -> Optional[str]:
"""Return the installed cua-driver version (e.g. "0.2.18"), or None if it
can't be determined. Best-effort; never raises."""
try:
out = subprocess.run(
[_CUA_DRIVER_CMD, "--version"],
capture_output=True, text=True, timeout=5,
).stdout.strip()
except Exception:
return None
# Output looks like "cua-driver 0.2.18"; pull the first semver token.
m = re.search(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.\-]+)?", out)
return m.group(0) if m else None
def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]:
"""Run ``cua-driver check-update --json`` and return its parsed state.
The payload mirrors the ``check_for_update`` MCP tool:
``{current_version, latest_version, update_available, ...}``.
def cua_driver_version_warning() -> Optional[str]:
"""Return a warning string if the installed cua-driver is older than the
per-OS minimum Hermes is tested against; otherwise None.
Soft check — stays quiet (returns None) when the platform has no declared
floor, the version can't be parsed, or it's a local dev build.
Returns ``None`` (callers should stay quiet) when the result is
indeterminate: the binary is missing, the driver is too old to support
the verb (it predates trycua/cua#1734), the GitHub check failed (an
``error`` field is set), or the output didn't parse. Best-effort; never
raises.
"""
floor = MIN_CUA_DRIVER_VERSION.get(sys.platform)
if not floor:
return None
installed = installed_cua_driver_version()
if not installed:
return None
if installed.startswith("0.0.0") or "local" in installed:
return None # local/dev build — intentionally exempt
try:
from packaging.version import Version
if Version(installed) >= Version(floor):
return None
proc = subprocess.run(
[_CUA_DRIVER_CMD, "check-update", "--json"],
capture_output=True, text=True, timeout=timeout,
# Some older drivers don't have the verb and fall through to a
# stdin-reading mode rather than erroring — DEVNULL gives them EOF
# so they exit fast instead of blocking until the timeout.
stdin=subprocess.DEVNULL,
)
except Exception:
return None # unparseable → don't cry wolf
return None
out = (proc.stdout or "").strip()
if not out:
# Older drivers don't have the verb: usage goes to stderr, stdout empty.
return None
try:
data = json.loads(out)
except (ValueError, TypeError):
return None
if not isinstance(data, dict) or data.get("error"):
# A failed check (exit 1) carries its reason in `error` — indeterminate.
return None
return data
def cua_driver_update_nudge() -> Optional[str]:
"""One-line "an update is available" message, or ``None`` when up to date,
indeterminate, or the driver is too old to report."""
state = cua_driver_update_check()
if not state or not state.get("update_available"):
return None
latest = state.get("latest_version") or "?"
current = state.get("current_version") or "?"
return (
f"cua-driver {installed} is older than {floor}, the minimum Hermes is "
f"tested against on this platform — some computer_use actions may "
f"misbehave. Update with `hermes computer-use install --upgrade`."
f"cua-driver {latest} is available (you have {current}); "
f"update with `hermes computer-use install --upgrade`."
)
_version_warned = False
_update_checked = False
def _warn_if_cua_driver_outdated() -> None:
"""Emit the version-compatibility warning at most once per process."""
global _version_warned
if _version_warned:
def _maybe_nudge_update() -> None:
"""Emit an update nudge at most once per process, off-thread so the
(cached, ~20h) GitHub poll never blocks the first computer_use action."""
global _update_checked
if _update_checked:
return
_version_warned = True
msg = cua_driver_version_warning()
if msg:
logger.warning("computer_use: %s", msg)
_update_checked = True
def _run() -> None:
try:
msg = cua_driver_update_nudge()
except Exception:
return
if msg:
logger.info("computer_use: %s", msg)
threading.Thread(
target=_run, name="cua-driver-update-check", daemon=True
).start()
def cua_driver_install_hint() -> str:
@ -509,7 +517,7 @@ class CuaDriverBackend(ComputerUseBackend):
# ── Lifecycle ──────────────────────────────────────────────────
def start(self) -> None:
_warn_if_cua_driver_outdated()
_maybe_nudge_update()
# The MCP client SDK (`mcp`) is an optional dependency (the
# `computer-use` / `mcp` extras), not part of Hermes' minimal core.
# Lazy-install it on first use — the same pattern every other optional