fix(update): never blind-reinstall cua-driver during hermes update

'Refreshing cua-driver (Computer Use)...' could hang for minutes on
Windows: when the driver's native check-update verb returned an
indeterminate result (old driver without the verb, offline, GitHub
rate-limited, or the probe timing out), install_cua_driver(upgrade=True)
fell through to the full upstream installer — a silent, output-captured
run with a 660s ceiling, plus install.ps1's 600s concurrency-lock wait
on Windows on top. Every 'hermes update' paid that cost.

Two changes:

- install_cua_driver() grows require_confirmed_update: with it set, an
  indeterminate check keeps the installed version and returns fast,
  printing the force path (hermes computer-use install --upgrade).
  'hermes update' passes it; the explicit --upgrade CLI keeps the old
  fall-through so a force refresh still works when the check can't
  answer.
- cua_driver_update_check() default timeout is now 25s on Windows
  (8s unchanged on POSIX): first-spawn of the exe under Defender /
  SmartScreen routinely exceeds 8s, and a false timeout is exactly the
  indeterminate result that used to trigger the multi-minute reinstall.
This commit is contained in:
teknium1 2026-07-26 13:06:11 -07:00 • committed by Teknium
parent 3dc2decd43
commit 920facdc4c
4 changed files with 190 additions and 12 deletions

View file

@ -12533,7 +12533,14 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("→ Refreshing cua-driver (Computer Use)...")
install_cua_driver(upgrade=True)
# require_confirmed_update: only run the (multi-minute,
# silent) upstream installer when the driver's native
# check-update verb positively reports a newer release.
# An indeterminate check (offline, rate-limited, old
# driver) keeps the installed version — `hermes update`
# must stay fast; `hermes computer-use install --upgrade`
# remains the force path.
install_cua_driver(upgrade=True, require_confirmed_update=True)
except Exception as e:
logger.debug("cua-driver refresh failed: %s", e)

View file

@ -795,7 +795,7 @@ def _cua_install_target_writable() -> bool:
return True
def install_cua_driver(upgrade: bool = False) -> bool:
def install_cua_driver(upgrade: bool = False, require_confirmed_update: 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
@ -808,6 +808,19 @@ def install_cua_driver(upgrade: bool = False) -> bool:
update`` if the binary supports it). Used by ``hermes update`` and
by ``hermes computer-use install --upgrade``.
``require_confirmed_update`` (only meaningful with ``upgrade=True`` and
an installed binary): when the driver's native ``check-update`` verb
can't positively confirm that a newer release exists — the driver is
too old for the verb, the GitHub check failed, we're offline, or the
probe timed out — keep the installed version and return instead of
falling through to the full upstream installer. ``hermes update`` sets
this so a broken update check costs seconds, not a multi-minute silent
reinstall on every update (the upstream installer runs up to
``_CUA_INSTALLER_TIMEOUT`` and install.ps1's concurrency lock can add
a further ~600s wait on Windows). ``hermes computer-use install
--upgrade`` leaves it False — an explicit upgrade request should still
reinstall when the check is indeterminate.
Returns True iff cua-driver is installed (or successfully refreshed)
when the function returns. Supported on macOS, Windows, and Linux
(Linux is alpha). Silently returns False on unsupported platforms.
@ -897,20 +910,35 @@ def install_cua_driver(upgrade: bool = False) -> bool:
# 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.
# 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.
if binary:
_state = None
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
_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 binary:
# Show before/after version when we have a baseline. Best-effort.

View file

@ -126,6 +126,141 @@ class TestInstallCuaDriverUpgrade:
runner.assert_called_once()
class TestRequireConfirmedUpdate:
"""`hermes update` passes require_confirmed_update=True: the full
upstream installer (multi-minute, output captured, plus install.ps1's
600s lock window on Windows) may only run when the driver's native
``check-update`` verb positively confirms a newer release. An
indeterminate check (old driver, offline, GitHub rate-limited, probe
timeout) keeps the installed version and returns fast.
Explicit `hermes computer-use install --upgrade` keeps the old
fall-through (require_confirmed_update=False): a force-refresh should
still reinstall when the check can't answer.
"""
def _install(self, system, check_state, require_confirmed):
from unittest.mock import MagicMock
from hermes_cli import tools_config
exe = "cua-driver" + (".exe" if system == "Windows" else "")
with patch("platform.system", return_value=system), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/x/" + n
if n in {"cua-driver", "curl", "powershell"} else None), \
patch.object(tools_config, "_resolved_cua_driver_cmd",
return_value="/x/" + exe), \
patch.object(tools_config, "_cua_install_target_writable",
return_value=True), \
patch("tools.computer_use.cua_backend.cua_driver_update_check",
return_value=check_state), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run",
return_value=MagicMock(stdout="cua-driver 0.5.0", returncode=0)), \
patch.object(tools_config, "_print_success"), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info") as info:
ok = tools_config.install_cua_driver(
upgrade=True, require_confirmed_update=require_confirmed
)
return ok, runner, info
def test_indeterminate_check_keeps_installed_version(self):
ok, runner, info = self._install("Windows", None, require_confirmed=True)
assert ok is True
runner.assert_not_called()
assert any(
"keeping the installed version" in call.args[0]
for call in info.call_args_list
)
def test_indeterminate_check_points_at_force_path(self):
ok, runner, info = self._install("Darwin", None, require_confirmed=True)
assert ok is True
runner.assert_not_called()
assert any(
"computer-use install --upgrade" in call.args[0]
for call in info.call_args_list
)
def test_confirmed_update_still_runs_installer(self):
state = {"current_version": "0.5.0", "latest_version": "0.6.0",
"update_available": True}
ok, runner, _ = self._install("Windows", state, require_confirmed=True)
assert ok is True
runner.assert_called_once()
def test_up_to_date_short_circuits(self):
state = {"current_version": "0.6.0", "latest_version": "0.6.0",
"update_available": False}
ok, runner, _ = self._install("Windows", state, require_confirmed=True)
assert ok is True
runner.assert_not_called()
def test_explicit_upgrade_still_falls_through_on_indeterminate(self):
# `hermes computer-use install --upgrade` (default flag): the old
# behaviour — indeterminate check re-runs the installer.
ok, runner, _ = self._install("Darwin", None, require_confirmed=False)
assert ok is True
runner.assert_called_once()
class TestUpdateCheckTimeoutDefaults:
"""cua_driver_update_check: platform-sensitive default timeout.
8s is fine on POSIX but too tight for Windows first-spawn (Defender /
SmartScreen scanning), and a false timeout is what used to trigger the
full reinstall fall-through during `hermes update`.
"""
def _captured_timeout(self, platform_name):
from unittest.mock import MagicMock
from tools.computer_use import cua_backend
captured = {}
def fake_run(cmd, **kw):
captured["timeout"] = kw.get("timeout")
m = MagicMock()
m.stdout = '{"update_available": false, "current_version": "1.0"}'
return m
with patch("tools.computer_use.cua_backend.resolve_cua_driver_cmd",
return_value="/x/cua-driver"), \
patch("tools.computer_use.cua_backend.sys.platform", platform_name), \
patch("tools.computer_use.cua_backend.subprocess.run",
side_effect=fake_run):
cua_backend.cua_driver_update_check()
return captured.get("timeout")
def test_windows_default_is_generous(self):
assert self._captured_timeout("win32") == 25.0
def test_posix_default_unchanged(self):
assert self._captured_timeout("linux") == 8.0
def test_explicit_timeout_wins(self):
from unittest.mock import MagicMock
from tools.computer_use import cua_backend
captured = {}
def fake_run(cmd, **kw):
captured["timeout"] = kw.get("timeout")
m = MagicMock()
m.stdout = "{}"
return m
with patch("tools.computer_use.cua_backend.resolve_cua_driver_cmd",
return_value="/x/cua-driver"), \
patch("tools.computer_use.cua_backend.subprocess.run",
side_effect=fake_run):
cua_backend.cua_driver_update_check(timeout=3.0)
assert captured.get("timeout") == 3.0
class TestArchProbeRemoval:
"""Regression tests for the deletion of `_check_cua_driver_asset_for_arch`.

View file

@ -557,18 +557,26 @@ def cua_driver_binary_available() -> bool:
return resolve_cua_driver_cmd() is not None
def cua_driver_update_check(*, timeout: float = 8.0) -> Optional[Dict[str, Any]]:
def cua_driver_update_check(*, timeout: Optional[float] = None) -> 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, ...}``.
``timeout`` defaults to 8s on POSIX and 25s on Windows — first-spawn of
the exe there routinely eats several seconds in Defender/SmartScreen
scanning, and a false timeout is expensive: callers treat ``None`` as
indeterminate, and the ``install_cua_driver(upgrade=True)`` path used to
fall through to a full multi-minute reinstall on it.
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.
"""
if timeout is None:
timeout = 25.0 if sys.platform == "win32" else 8.0
driver_cmd = resolve_cua_driver_cmd()
if not driver_cmd:
return None