From 1542511eb7a44fc08c7ee3eb989a5f1ee87812c1 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Wed, 27 May 2026 16:03:43 +0000 Subject: [PATCH] feat(computer-use): use cua-driver's native check-update instead of a hardcoded version floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- hermes_cli/main.py | 18 +++-- hermes_cli/tools_config.py | 17 ++++ tests/tools/test_computer_use.py | 113 +++++++++++++------------- tools/computer_use/cua_backend.py | 130 ++++++++++++++++-------------- 4 files changed, 153 insertions(+), 125 deletions(-) diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 70ef54a42033..e8994ff3abda 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -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") diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 64893273269b..9f2742227070 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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: diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 5ec4f8035354..87b2b01c7bc3 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -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): diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 2c8e3cef8958..ca92a1242f7f 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -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