diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index fdf6f343ed8df..56b5d9d8c6521 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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(), diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index bd532ac7fb814..78cfbb25bd6da 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -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