diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 8db88ec0c4..e228edf9a0 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -1072,10 +1072,16 @@ def install_cua_driver( # baked in by CD and errors cleanly on missing-arch assets. return _run_cua_driver_installer(label="Installing") + # An installed driver that fails Hermes' runtime contract (version floor, + # missing manifest verbs) is repaired regardless of the caller's mode. + # Hermes' own minimum requirement IS the confirmation that an upgrade is + # needed, so the ``upgrade=True`` path must not defer to the driver's + # ``check-update`` verb here — a cached/indeterminate "no update" answer + # would otherwise pin users on an unusable driver forever (observed: + # 0.19.3 installs hard-failing every computer_use call after the 0.20 + # contract landed, with `hermes update` declining to refresh). contract = _cua_driver_contract_status(binary) if binary else None - repair_existing = bool( - binary and not upgrade and contract and not contract.get("ready") - ) + repair_existing = bool(binary and contract and not contract.get("ready")) # A compatible existing installation needs no download. Finish the small # host-specific setup that the upstream installer normally owns. diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index a6a8941b9f..39b997323d 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -149,6 +149,11 @@ class TestInstallCuaDriverUpgrade: with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n if n in {"cua-driver", "curl"} else None), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ patch.object(tools_config, "_run_cua_driver_installer", return_value=True) as runner, \ patch("subprocess.run"): @@ -245,6 +250,11 @@ class TestInstallCuaDriverUpgrade: else None ), ), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ patch.object( tools_config, "_run_cua_driver_installer", @@ -450,6 +460,11 @@ class TestRequireConfirmedUpdate: return_value="/x/cua-driver"), \ patch.object(tools_config, "_cua_install_target_writable", return_value=True), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ patch("tools.computer_use.cua_backend.cua_driver_update_check", return_value=check_state), \ patch.object(tools_config, "_run_cua_driver_installer", @@ -503,6 +518,55 @@ class TestRequireConfirmedUpdate: assert ok is True runner.assert_called_once() + def test_incompatible_driver_repairs_despite_indeterminate_check(self): + """Hermes' own version floor is the confirmation. When the installed + driver fails the runtime contract, the `hermes update` refresh must + repair it even though ``check-update`` can't confirm a newer release + (its ~20h cache routinely lags a same-day floor bump — the 0.19.3 + wedge).""" + from unittest.mock import MagicMock + + from hermes_cli import tools_config + + incompatible = { + "ready": False, + "version": "0.19.3", + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + with 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"), \ + patch.object(tools_config, "_cua_install_target_writable", + return_value=True), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + side_effect=[incompatible, + {"ready": True, "version": "0.20.0", "reason": ""}], + ), \ + patch("tools.computer_use.cua_backend.cua_driver_update_check", + return_value=None) as check, \ + patch.object(tools_config, "_run_cua_driver_installer", + return_value=True) as runner, \ + patch("subprocess.run", + return_value=MagicMock(stdout="cua-driver 0.19.3", + 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 + ) + + assert ok is True + runner.assert_called_once() + assert runner.call_args.kwargs["label"] == "Repairing" + # The confirmed-update gate must not even consult check-update: + # the contract failure already confirmed the need. + check.assert_not_called() + class TestUpdateCheckTimeoutDefaults: """cua_driver_update_check: platform-sensitive default timeout. @@ -623,6 +687,11 @@ class TestArchProbeRemoval: with patch.object(tools_config.shutil, "which", side_effect=lambda n: "/usr/local/bin/" + n if n in ("cua-driver", "curl", "powershell") else None), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ patch("urllib.request.urlopen") as urlopen, \ patch("subprocess.run"), \ patch.object(tools_config, "_run_cua_driver_installer", @@ -1055,6 +1124,11 @@ class TestConfirmedVersionPinning: return_value="/x/cua-driver"), \ patch.object(tools_config, "_cua_install_target_writable", return_value=True), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ patch("tools.computer_use.cua_backend.cua_driver_update_check", return_value=check_state), \ patch.object(tools_config, "_run_cua_driver_installer", diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index f42efa6b56..1da5b0666f 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -819,6 +819,122 @@ class TestLazyMcpInstall: mock_sess_start.assert_not_called() # never reaches the MCP session +class TestContractAutoRepair: + """An installed-but-incompatible driver is repaired automatically, once. + + The 0.20 runtime-contract gate fails closed; when the failure is an old + installed driver (a state Hermes' own version-floor bump created), + start() runs the standard install/repair path once instead of failing + every computer_use call until the user runs the CLI by hand. + """ + + def _incompatible(self): + return { + "ready": False, + "binary": "/usr/local/bin/cua-driver", + "version": "0.19.3", + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + + def test_start_auto_repairs_incompatible_driver(self, monkeypatch): + from unittest.mock import MagicMock, patch + from tools.computer_use import cua_backend + + monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) + backend = cua_backend.CuaDriverBackend() + backend._session = MagicMock() + + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + side_effect=[self._incompatible(), {"ready": True}], + ), \ + patch("hermes_cli.tools_config.install_cua_driver", + return_value=True) as installer, \ + patch.object(cua_backend, "_maybe_nudge_update"), \ + patch("tools.lazy_deps.ensure"): + backend.start() + + installer.assert_called_once_with( + upgrade=False, show_installer_progress=False + ) + backend._session.start.assert_called_once() + + def test_failed_repair_surfaces_original_error(self, monkeypatch): + from unittest.mock import patch + from tools.computer_use import cua_backend + + monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value=self._incompatible(), + ), \ + patch("hermes_cli.tools_config.install_cua_driver", + return_value=False), \ + patch("tools.lazy_deps.ensure") as mock_ensure: + with pytest.raises(RuntimeError, match="0.20.0 or newer"): + cua_backend.CuaDriverBackend().start() + mock_ensure.assert_not_called() + + def test_repair_is_attempted_once_per_process(self, monkeypatch): + from unittest.mock import patch + from tools.computer_use import cua_backend + + monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value=self._incompatible(), + ), \ + patch("hermes_cli.tools_config.install_cua_driver", + return_value=False) as installer, \ + patch("tools.lazy_deps.ensure"): + for _ in range(2): + with pytest.raises(RuntimeError): + cua_backend.CuaDriverBackend().start() + installer.assert_called_once() + + def test_explicit_override_is_never_repaired(self, monkeypatch): + from unittest.mock import patch + from tools.computer_use import cua_backend + + monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value=self._incompatible(), + ), \ + patch("hermes_cli.tools_config.install_cua_driver") as installer, \ + patch("tools.lazy_deps.ensure"): + with pytest.raises(RuntimeError, match="HERMES_CUA_DRIVER_CMD"): + cua_backend.CuaDriverBackend().start() + installer.assert_not_called() + + def test_missing_binary_is_not_repaired(self, monkeypatch): + from unittest.mock import patch + from tools.computer_use import cua_backend + + monkeypatch.setattr(cua_backend, "_contract_repair_attempted", False) + state = { + "ready": False, + "binary": None, + "version": None, + "reason": "cua-driver is not installed", + } + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value=state, + ), \ + patch("hermes_cli.tools_config.install_cua_driver") as installer, \ + patch("tools.lazy_deps.ensure"): + with pytest.raises(RuntimeError, match="not installed"): + cua_backend.CuaDriverBackend().start() + installer.assert_not_called() + + class TestCaptureAfterAppContext: """Bug 2: capture_after=True loses app context after actions. diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index 6b436c0308..09e58772f2 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -1191,6 +1191,52 @@ def cua_driver_update_nudge() -> Optional[str]: _update_checked = False +# One auto-repair attempt per process. The runtime-contract gate in +# ``CuaDriverBackend.start()`` fails closed on an incompatible driver; when +# the incompatibility is something a reinstall fixes (old version, missing +# manifest verbs) we run the standard install/repair path once instead of +# telling the user to do it by hand. Guarded so a failing installer can't +# loop — the second start() in the same process goes straight to the error. +_contract_repair_attempted = False + + +def _maybe_repair_runtime_contract(contract: Dict[str, Any]) -> Dict[str, Any]: + """Try one automatic driver repair for a failed runtime contract. + + Returns the post-repair contract state (or the original state when no + repair was attempted / the repair failed). Never raises. An explicit + ``HERMES_CUA_DRIVER_CMD`` override is authoritative even when broken, and + a missing binary means installation was never requested — both are left + for the caller's error message. + """ + global _contract_repair_attempted + if contract.get("ready"): + return contract + if _contract_repair_attempted: + return contract + if os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip(): + return contract + if not contract.get("binary"): + return contract + _contract_repair_attempted = True + logger.info( + "computer_use: installed cua-driver is not usable (%s); " + "attempting automatic repair", + contract.get("reason") or "runtime contract is incomplete", + ) + try: + from hermes_cli.tools_config import install_cua_driver + + if not install_cua_driver(upgrade=False, show_installer_progress=False): + return contract + except Exception as exc: + logger.warning("computer_use: automatic cua-driver repair failed: %s", exc) + return contract + try: + return cua_driver_runtime_contract_status() + except Exception: + return contract + def _maybe_nudge_update() -> None: """Emit an update nudge at most once per process, off-thread so the @@ -2526,6 +2572,11 @@ class CuaDriverBackend(ComputerUseBackend): # ── Lifecycle ────────────────────────────────────────────────── def start(self) -> None: contract = cua_driver_runtime_contract_status() + if not contract.get("ready"): + # An installed-but-incompatible driver (e.g. predating a Hermes + # version-floor bump) is a state we created — repair it once + # automatically instead of failing every computer_use call. + contract = _maybe_repair_runtime_contract(contract) if not contract.get("ready"): reason = contract.get("reason") or "runtime contract is incomplete" if os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip():