diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index b0587504d1..32489ccd48 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1190,12 +1190,19 @@ def install_cua_driver( return True if _state is not None and _state.get("update_available"): if is_windows and require_confirmed_update: - _latest = _state.get("latest_version") or "a newer version" - _print_info( - f" {driver_cmd} {_latest} is available; keeping the " - "installed version because its Windows installer may " - "require interactive consent." - ) + _latest = _state.get("latest_version") + if _latest: + _print_info( + f" {driver_cmd} {_latest} is available; keeping the " + "installed version because its Windows installer may " + "require interactive consent." + ) + else: + _print_info( + f" A newer {driver_cmd} release is available; " + "keeping the installed version because its Windows " + "installer may require interactive consent." + ) _print_info( " Update it from an interactive terminal with: " "hermes computer-use install --upgrade" @@ -1214,6 +1221,22 @@ def install_cua_driver( if _re.fullmatch(r"\d+(\.\d+)*", _latest): confirmed_version = _latest + if is_windows and require_confirmed_update and not binary: + # Missing-binary path (driver enabled in config but never installed, + # or wiped by a failed install). Same rule as the repair and + # confirmed-update branches above: an automatic Windows update must + # never launch install.ps1, which can demand console/UAC consent the + # hidden updater cannot provide (#87703). + _print_info( + " cua-driver is not installed; automatic Windows updates " + "cannot safely run its interactive installer." + ) + _print_info( + " Install it from an interactive terminal with: " + "hermes computer-use install --upgrade" + ) + return False + if binary: # Show before/after version when we have a baseline. Best-effort. try: diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 85da84c96e..1d2b62e4ab 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -440,7 +440,8 @@ class TestRequireConfirmedUpdate: still reinstall when the check can't answer. """ - def _install(self, check_state, require_confirmed, contract_status=None): + def _install(self, check_state, require_confirmed, contract_status=None, + binary_missing=False): """Drive ``install_cua_driver`` on the host, whatever it is. The old signature took a ``system`` string and faked @@ -454,11 +455,15 @@ class TestRequireConfirmedUpdate: from hermes_cli import tools_config + _which_names = {"curl", "powershell"} + if not binary_missing: + _which_names.add("cua-driver") with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/x/" + n - if n in {"cua-driver", "curl", "powershell"} else None), \ + if n in _which_names else None), \ patch.object(tools_config, "_resolved_cua_driver_cmd", - return_value="/x/cua-driver"), \ + return_value=None if binary_missing + else "/x/cua-driver"), \ patch.object(tools_config, "_cua_install_target_writable", return_value=True), \ patch.object( @@ -546,6 +551,38 @@ class TestRequireConfirmedUpdate: for call in info.call_args_list ) + @pytest.mark.windows_only + def test_windows_missing_binary_defers_interactive_install(self): + """Driver enabled but never installed (or wiped by a failed install): + the automatic update must not launch install.ps1 either — this path + reached the installer before the top-level guard (#94296 review).""" + ok, runner, info = self._install( + None, + require_confirmed=True, + binary_missing=True, + ) + + assert ok is False + runner.assert_not_called() + assert any( + "computer-use install --upgrade" in call.args[0] + for call in info.call_args_list + ) + + @pytest.mark.skipif( + sys.platform == "win32", + reason="POSIX installers are non-interactive; missing binary installs", + ) + def test_posix_missing_binary_still_installs(self): + ok, runner, _ = self._install( + None, + require_confirmed=True, + binary_missing=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}