fix: pin cua-driver refresh to the release check-update confirmed
The upstream cua-driver installer scripts on trycua/cua@main carry a
baked default version that Release Please bumps in the release PR
*before* the release assets are published. During that window an
unpinned installer run 404s on the asset download and the
`hermes update` cua-driver refresh fails with:
error: download failed: The remote server returned an error: (404) Not Found.
⚠ cua-driver refreshing did not complete. Re-run manually: ...
Observed live 2026-07-29: baked version 0.14.0 vs latest published
release 0.13.1 — every `hermes update` run with an out-of-date driver
hit the warning until upstream publishes the assets.
We already know the correct version: `cua-driver check-update --json`
returns `latest_version` straight from the GitHub Releases API, whose
entries by definition have published assets. When the check positively
confirms an update, export that version as CUA_DRIVER_RS_VERSION into
the installer child env (both install.sh and install.ps1 honour it over
their baked default), so the refresh downloads the release that
actually exists instead of racing the upstream release pipeline.
Malformed / missing latest_version values fall back to the previous
unpinned behaviour. The explicit `hermes computer-use install
--upgrade` force path and fresh installs are unchanged.
This commit is contained in:
parent
b6729ba905
commit
5fdc9d2a9d
|
|
@ -1002,6 +1002,7 @@ def install_cua_driver(upgrade: bool = False, require_confirmed_update: bool = F
|
|||
# multi-minute silent reinstall on every update. An explicit
|
||||
# `hermes computer-use install --upgrade` falls through and re-runs the
|
||||
# installer as before.
|
||||
confirmed_version = None
|
||||
if binary:
|
||||
_state = None
|
||||
try:
|
||||
|
|
@ -1025,6 +1026,19 @@ def install_cua_driver(upgrade: bool = False, require_confirmed_update: bool = F
|
|||
" Force a refresh with: hermes computer-use install --upgrade"
|
||||
)
|
||||
return True
|
||||
if _state is not None and _state.get("update_available"):
|
||||
# Pin the installer to the release check-update just confirmed.
|
||||
# `latest_version` comes from the GitHub Releases API, so its
|
||||
# assets are published — unlike the installer script's baked
|
||||
# version on `main`, which Release Please bumps in the release
|
||||
# PR *before* the release assets exist. Installing unpinned in
|
||||
# that window 404s (observed: baked 0.14.0 vs latest published
|
||||
# 0.13.1). Malformed values are ignored → unpinned fallback.
|
||||
import re as _re
|
||||
|
||||
_latest = str(_state.get("latest_version") or "").strip().lstrip("vV")
|
||||
if _re.fullmatch(r"\d+(\.\d+)*", _latest):
|
||||
confirmed_version = _latest
|
||||
|
||||
if binary:
|
||||
# Show before/after version when we have a baseline. Best-effort.
|
||||
|
|
@ -1039,7 +1053,9 @@ def install_cua_driver(upgrade: bool = False, require_confirmed_update: bool = F
|
|||
else:
|
||||
before = ""
|
||||
|
||||
ok = _run_cua_driver_installer(label="Refreshing", verbose=False)
|
||||
ok = _run_cua_driver_installer(
|
||||
label="Refreshing", verbose=False, pin_version=confirmed_version
|
||||
)
|
||||
if ok and before:
|
||||
try:
|
||||
after = subprocess.run(
|
||||
|
|
@ -1226,7 +1242,11 @@ def _clear_stale_cua_install_lock() -> None:
|
|||
logger.debug("stale cua install lock check failed: %s", e)
|
||||
|
||||
|
||||
def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -> bool:
|
||||
def _run_cua_driver_installer(
|
||||
label: str = "Installing",
|
||||
verbose: bool = True,
|
||||
pin_version: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Run the upstream cua-driver installer for this platform.
|
||||
|
||||
The scripts are idempotent: they always download the latest release, so
|
||||
|
|
@ -1235,6 +1255,13 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
|
|||
* macOS / Linux → ``curl -fsSL …/install.sh | /bin/bash``.
|
||||
* Windows → ``powershell -NoProfile -ExecutionPolicy Bypass -Command
|
||||
"irm …/install.ps1 | iex"``.
|
||||
|
||||
``pin_version`` (e.g. ``"0.13.1"``) is exported as
|
||||
``CUA_DRIVER_RS_VERSION`` so the installer downloads that exact release
|
||||
instead of its baked-in default. The baked version on upstream ``main``
|
||||
is bumped by Release Please *before* the release assets are published,
|
||||
so an unpinned run inside that window fails with a 404; pinning to the
|
||||
version ``check-update`` confirmed sidesteps the race entirely.
|
||||
"""
|
||||
import platform as _plat
|
||||
import shutil
|
||||
|
|
@ -1306,6 +1333,12 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
|
|||
_print_info(f" {label} cua-driver...")
|
||||
driver_cmd = _cua_driver_cmd()
|
||||
|
||||
installer_env = _cua_driver_env()
|
||||
if pin_version:
|
||||
# Both upstream installers (install.sh and install.ps1) honour
|
||||
# CUA_DRIVER_RS_VERSION over their baked default.
|
||||
installer_env["CUA_DRIVER_RS_VERSION"] = pin_version
|
||||
|
||||
# A previous timed-out install can leave the upstream installer's
|
||||
# concurrent-install lock behind; clear it when provably stale so the
|
||||
# refresh doesn't wedge waiting on a dead holder (issue #58762).
|
||||
|
|
@ -1378,7 +1411,7 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
|
|||
# keep streaming live.
|
||||
if verbose:
|
||||
proc = subprocess.Popen(
|
||||
install_cmd, shell=use_shell, env=_cua_driver_env(),
|
||||
install_cmd, shell=use_shell, env=installer_env,
|
||||
creationflags=_post_setup_no_window_flags(streams_to_console=True),
|
||||
**popen_kwargs
|
||||
)
|
||||
|
|
@ -1393,7 +1426,7 @@ def _run_cua_driver_installer(label: str = "Installing", verbose: bool = True) -
|
|||
)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
install_cmd, shell=use_shell, env=_cua_driver_env(),
|
||||
install_cmd, shell=use_shell, env=installer_env,
|
||||
stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||||
text=True, encoding="utf-8", errors="replace",
|
||||
creationflags=_post_setup_no_window_flags(),
|
||||
|
|
|
|||
|
|
@ -712,3 +712,116 @@ class TestInstallerNoShell:
|
|||
|
||||
assert "script" in captured
|
||||
assert not os.path.exists(captured["script"])
|
||||
|
||||
|
||||
class TestConfirmedVersionPinning:
|
||||
"""When check-update confirms a newer release, the installer run must be
|
||||
pinned to that exact version via CUA_DRIVER_RS_VERSION.
|
||||
|
||||
The upstream installer scripts on `main` carry a baked version that
|
||||
Release Please bumps in the release PR *before* the release assets are
|
||||
published. An unpinned install inside that window 404s (observed
|
||||
2026-07-29: baked 0.14.0 vs latest published release 0.13.1). Pinning to
|
||||
check-update's `latest_version` — which comes from the Releases API and
|
||||
therefore has published assets — sidesteps the race.
|
||||
"""
|
||||
|
||||
def _install(self, check_state):
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
|
||||
with patch("platform.system", return_value="Windows"), \
|
||||
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/cua-driver.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"):
|
||||
ok = tools_config.install_cua_driver(
|
||||
upgrade=True, require_confirmed_update=True
|
||||
)
|
||||
return ok, runner
|
||||
|
||||
def test_confirmed_update_pins_latest_version(self):
|
||||
state = {"current_version": "0.12.6", "latest_version": "0.13.1",
|
||||
"update_available": True}
|
||||
ok, runner = self._install(state)
|
||||
assert ok is True
|
||||
assert runner.call_args.kwargs.get("pin_version") == "0.13.1"
|
||||
|
||||
def test_v_prefixed_latest_version_is_normalized(self):
|
||||
state = {"current_version": "0.12.6", "latest_version": "v0.13.1",
|
||||
"update_available": True}
|
||||
ok, runner = self._install(state)
|
||||
assert ok is True
|
||||
assert runner.call_args.kwargs.get("pin_version") == "0.13.1"
|
||||
|
||||
def test_malformed_latest_version_falls_back_unpinned(self):
|
||||
state = {"current_version": "0.12.6", "latest_version": "not a version",
|
||||
"update_available": True}
|
||||
ok, runner = self._install(state)
|
||||
assert ok is True
|
||||
assert runner.call_args.kwargs.get("pin_version") is None
|
||||
|
||||
def test_missing_latest_version_falls_back_unpinned(self):
|
||||
state = {"current_version": "0.12.6", "update_available": True}
|
||||
ok, runner = self._install(state)
|
||||
assert ok is True
|
||||
assert runner.call_args.kwargs.get("pin_version") is None
|
||||
|
||||
|
||||
class TestRunInstallerPinEnv:
|
||||
"""_run_cua_driver_installer(pin_version=...) exports CUA_DRIVER_RS_VERSION
|
||||
into the installer child env; unpinned runs leave it untouched."""
|
||||
|
||||
def _run(self, pin_version):
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from hermes_cli import tools_config
|
||||
|
||||
captured = {}
|
||||
fake_proc = MagicMock()
|
||||
fake_proc.pid = 1
|
||||
fake_proc.returncode = 1
|
||||
fake_proc.communicate.return_value = ("", None)
|
||||
|
||||
def fake_popen(cmd, **kw):
|
||||
captured["env"] = kw.get("env")
|
||||
return fake_proc
|
||||
|
||||
def fake_run(cmd, **kw):
|
||||
m = MagicMock(); m.returncode = 0; m.stderr = ""
|
||||
return m
|
||||
|
||||
with patch("platform.system", return_value="Linux"), \
|
||||
patch("subprocess.run", side_effect=fake_run), \
|
||||
patch("subprocess.Popen", side_effect=fake_popen), \
|
||||
patch.object(tools_config, "_cua_driver_env",
|
||||
return_value={"PATH": "/usr/bin"}), \
|
||||
patch.object(tools_config, "_clear_stale_cua_install_lock"), \
|
||||
patch.object(tools_config, "_print_warning"), \
|
||||
patch.object(tools_config, "_print_info"):
|
||||
tools_config._run_cua_driver_installer(
|
||||
label="Refreshing", verbose=False, pin_version=pin_version
|
||||
)
|
||||
return captured.get("env") or {}
|
||||
|
||||
def test_pin_version_exported_to_installer_env(self):
|
||||
env = self._run("0.13.1")
|
||||
assert env.get("CUA_DRIVER_RS_VERSION") == "0.13.1"
|
||||
|
||||
def test_no_pin_leaves_env_untouched(self):
|
||||
env = self._run(None)
|
||||
assert "CUA_DRIVER_RS_VERSION" not in env
|
||||
|
|
|
|||
Loading…
Reference in New Issue