fix(computer-use): auto-repair an installed driver that fails the runtime contract
A same-day version-floor bump (0.20 runtime contract) left every install with an older cua-driver hard-failing on all computer_use calls: the start() gate fails closed, while the `hermes update` refresh defers to the driver's own check-update verb — whose ~20h cache routinely answers "no update available" right after we raise the floor. Hermes knew it required 0.20+ but never acted on that knowledge. Two changes: - tools_config.install_cua_driver(): a contract-failed installed driver is repaired on the upgrade=True path too (previously only upgrade=False). The contract failure itself is the confirmation, so the require_confirmed_update gate and the check-update short-circuit are bypassed for repairs — an indeterminate or stale-cached check can no longer pin users on an unusable driver. - cua_backend.CuaDriverBackend.start(): when the contract gate fails on an installed binary, attempt one automatic repair per process via the standard install path, then re-probe. HERMES_CUA_DRIVER_CMD overrides are never repaired (explicit override is authoritative even when broken) and a missing binary still just reports the install hint. A failing installer can't loop: the second start() surfaces the original error. Tests: contract-repair coverage in test_computer_use.py (auto-repair success, failed repair surfaces the original error, once-per-process guard, override never repaired, missing binary never repaired) and test_install_cua_driver.py (incompatible driver repairs despite an indeterminate check-update, check-update not consulted). All new tests verified to fail against the unfixed source (sabotage run).
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user