diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 12f79b2208..4e4f3e9ae3 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -12697,6 +12697,7 @@ def main(): return if action == "status": import subprocess + from hermes_cli.tools_config import _cua_driver_contract_status from tools.computer_use.cua_backend import ( cua_driver_update_check, resolve_cua_driver_cmd, @@ -12719,6 +12720,14 @@ def main(): print(f"cua-driver: installed at {path} ({version})") else: print(f"cua-driver: installed at {path}") + contract = _cua_driver_contract_status(path) + if not contract.get("ready"): + print( + " ⚠ Repair required: " + + (contract.get("reason") or "runtime contract is incomplete") + ) + print(" Run: hermes computer-use install") + return try: st = cua_driver_update_check() if st and st.get("update_available"): diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 530c8b7b37..1a65dcc1bb 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -793,6 +793,49 @@ def _cua_driver_env() -> dict: return dict(os.environ) +_CUA_DRIVER_CONTRACT_CACHE: dict = {} + + +def _cua_driver_contract_status(binary: Optional[str] = None) -> dict: + """Inspect whether an installed driver supports Hermes' runtime contract.""" + import time + + from tools.computer_use.cua_backend import cua_driver_runtime_contract_status + + resolved = binary or _resolved_cua_driver_cmd() + if not resolved: + return cua_driver_runtime_contract_status(None) + try: + stat = os.stat(resolved) + fingerprint = (resolved, stat.st_mtime_ns, stat.st_size) + except OSError: + return cua_driver_runtime_contract_status(resolved) + + now = time.monotonic() + if ( + _CUA_DRIVER_CONTRACT_CACHE.get("fingerprint") == fingerprint + and now - _CUA_DRIVER_CONTRACT_CACHE.get("checked_at", 0.0) < 30.0 + ): + return dict(_CUA_DRIVER_CONTRACT_CACHE["state"]) + + state = cua_driver_runtime_contract_status(resolved) + _CUA_DRIVER_CONTRACT_CACHE.update( + fingerprint=fingerprint, + checked_at=now, + state=dict(state), + ) + return state + + +def _cua_driver_install_ready() -> bool: + """Return whether an existing driver needs no install-time repair.""" + if not _cua_driver_contract_status().get("ready"): + return False + if sys.platform == "win32": + return _cua_driver_autostart_registered_windows() + return True + + def _pip_install( args: List[str], *, @@ -928,9 +971,9 @@ def install_cua_driver( The upstream installer always pulls the latest release tag, so re-running it is the canonical way to upgrade. We expose two modes: - * ``upgrade=False`` — original post-setup behaviour: skip if already - installed, install otherwise. Used by the toolset enable flow where - we don't want to surprise the user with a network fetch. + * ``upgrade=False`` — keep a compatible Cua Driver 0.20 installation, + repair an old or incomplete installation, and install when missing. + Used by the toolset enable flow. * ``upgrade=True`` — always re-run the installer (or call ``cua-driver update`` if the binary supports it). Used by ``hermes update`` and by ``hermes computer-use install --upgrade``. @@ -998,8 +1041,14 @@ def install_cua_driver( # baked in by CD and errors cleanly on missing-arch assets. return _run_cua_driver_installer(label="Installing") - # Already installed and caller didn't ask to upgrade → just confirm. - if binary and not upgrade: + contract = _cua_driver_contract_status(binary) if binary else None + repair_existing = bool( + binary and not upgrade 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. + if binary and not upgrade and not repair_existing: try: version = subprocess.run( [binary, "--version"], @@ -1010,6 +1059,11 @@ def install_cua_driver( except Exception: _print_success(f" {driver_cmd} already installed.") if is_windows: + if not _repair_cua_driver_autostart_windows(binary, verbose=False): + _print_warning( + " cua-driver is compatible, but Windows autostart repair failed." + ) + return False _print_info(" cua-driver may spawn a UIAccess worker (cua-driver-uia.exe);") _print_info(" Windows/SmartScreen may prompt the first time it runs.") elif is_linux: @@ -1020,6 +1074,21 @@ def install_cua_driver( _print_info(" System Settings > Privacy & Security > Screen Recording") return True + if repair_existing: + version = contract.get("version") or "unknown version" + reason = contract.get("reason") or "required runtime features are missing" + _print_warning( + f" Found cua-driver {version}, but Hermes cannot use its current " + f"runtime contract: {reason}." + ) + if os.environ.get("HERMES_CUA_DRIVER_CMD", "").strip(): + _print_info( + " Update the binary selected by HERMES_CUA_DRIVER_CMD, or unset " + "the override and run: hermes computer-use install --upgrade" + ) + return False + _print_info(" Repairing it with the current upstream installer.") + # upgrade=True path — refresh to the latest upstream release. if not _cua_install_target_writable(): _print_info( @@ -1048,7 +1117,7 @@ def install_cua_driver( # `hermes computer-use install --upgrade` falls through and re-runs the # installer as before. confirmed_version = None - if binary: + if binary and not repair_existing: _state = None try: from tools.computer_use.cua_backend import cua_driver_update_check @@ -1099,11 +1168,20 @@ def install_cua_driver( before = "" ok = _run_cua_driver_installer( - label="Refreshing", + label="Repairing" if repair_existing else "Refreshing", verbose=False, pin_version=confirmed_version, show_progress=show_installer_progress, ) + if ok and repair_existing: + repaired = _cua_driver_contract_status() + if not repaired.get("ready"): + _print_warning( + " cua-driver was reinstalled, but its runtime contract is still " + f"unusable: {repaired.get('reason') or 'unknown error'}." + ) + _print_info(" Run: hermes computer-use doctor") + return False if ok and before: try: after = subprocess.run( @@ -1597,9 +1675,10 @@ def _run_cua_driver_installer( pass if result.returncode != 0: logger.debug("cua-driver installer output:\n%s", result.stdout) - if result.returncode == 0 and shutil.which(driver_cmd): + installed_binary = _resolved_cua_driver_cmd() + if result.returncode == 0 and installed_binary: if is_windows and not _repair_cua_driver_autostart_windows( - driver_cmd, verbose=verbose + installed_binary, verbose=verbose ): _print_warning( " cua-driver installed, but auto-start was not registered." @@ -3283,9 +3362,9 @@ _POST_SETUP_INSTALLED: dict = { # Only entries here are gated; other post_setup hooks (kittentts, # piper, agent_browser, etc.) keep their existing behaviour. Add an # entry when (a) the post_setup is the ONLY install side-effect for - # a no-key provider, and (b) an installed-state check is cheap and - # doesn't trigger a heavy import. - "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, + # a no-key provider, and (b) an installed-state check is local, bounded, + # and doesn't trigger a heavy import. + "cua_driver": lambda: _cua_driver_install_ready(), } @@ -3395,7 +3474,7 @@ _POST_SETUP_READY: dict = { "agent_browser": lambda: _agent_browser_installed(), "browserbase": lambda: _cloud_agent_browser_installed(), "camofox": lambda: _camofox_installed(), - "cua_driver": lambda: _resolved_cua_driver_cmd() is not None, + "cua_driver": lambda: _cua_driver_install_ready(), } diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 07300b514a..2087df1e9c 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -3552,6 +3552,57 @@ function Install-BrowserUseCli { } } +function Test-CuaDriverRuntimeContract { + param([Parameter(Mandatory = $true)][string]$DriverPath) + + try { + $versionOutput = (& $DriverPath --version 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0) { + return $false + } + $versionMatch = [regex]::Match($versionOutput, '(\d+\.\d+\.\d+)') + if (-not $versionMatch.Success) { + return $false + } + if ([version]($versionMatch.Groups[1].Value) -lt [version]'0.20.0') { + return $false + } + + $manifestOutput = (& $DriverPath manifest 2>$null | Out-String).Trim() + if ($LASTEXITCODE -ne 0 -or -not $manifestOutput) { + return $false + } + $manifest = $manifestOutput | ConvertFrom-Json + if (-not $manifest.mcp_invocation.args) { + return $false + } + + $required = @{ + mcp = @('--socket', '--grant') + serve = @( + '--socket', '--permission-mode', '--capability-manifest', + '--approve-capability-manifest', '--embedded' + ) + stop = @('--socket') + } + foreach ($commandName in $required.Keys) { + $command = $manifest.subcommands | Where-Object { $_.name -eq $commandName } + if (-not $command) { + return $false + } + $argNames = @($command.args | ForEach-Object { $_.name }) + foreach ($requiredArg in $required[$commandName]) { + if ($requiredArg -notin $argNames) { + return $false + } + } + } + return $true + } catch { + return $false + } +} + # cua-driver powers the computer_use toolset (background desktop control). # Provision it at install time so enabling the tool later -- via `hermes # tools`, the dashboard, or the desktop app -- is a config flip, not a @@ -3563,9 +3614,13 @@ function Install-CuaDriver { Write-Info "Skipping Computer Use (cua-driver) install (-SkipComputerUse)" return } - if (Get-Command cua-driver -ErrorAction SilentlyContinue) { - Write-Success "Computer Use driver (cua-driver) already installed" - return + $existingCuaDriver = Get-Command cua-driver -ErrorAction SilentlyContinue + if ($existingCuaDriver) { + if (Test-CuaDriverRuntimeContract -DriverPath $existingCuaDriver.Source) { + Write-Success "Computer Use driver (cua-driver) already installed and compatible" + return + } + Write-Warn "Existing cua-driver is old or incomplete; repairing it" } Write-Info "Installing Computer Use driver (cua-driver)..." diff --git a/scripts/install.sh b/scripts/install.sh index 1b00b4df97..9496397c52 100755 --- a/scripts/install.sh +++ b/scripts/install.sh @@ -2501,6 +2501,37 @@ install_browser_use_cli() { fi } +cua_driver_runtime_compatible() { + local driver_path version_output manifest_output + local major minor + driver_path="$(command -v cua-driver 2>/dev/null)" || return 1 + version_output="$("$driver_path" --version 2>/dev/null)" || return 1 + if [[ ! "$version_output" =~ ([0-9]+)\.([0-9]+)\.([0-9]+) ]]; then + return 1 + fi + major="${BASH_REMATCH[1]}" + minor="${BASH_REMATCH[2]}" + if (( major == 0 && minor < 20 )); then + return 1 + fi + manifest_output="$("$driver_path" manifest 2>/dev/null)" || return 1 + local required + for required in \ + '"mcp_invocation"' \ + '"--socket"' \ + '"--grant"' \ + '"--permission-mode"' \ + '"--capability-manifest"' \ + '"--approve-capability-manifest"' \ + '"--embedded"'; do + case "$manifest_output" in + *"$required"*) ;; + *) return 1 ;; + esac + done + return 0 +} + install_computer_use_driver() { # cua-driver powers the computer_use toolset (background desktop control). # Provision it at install time so enabling the tool later — via @@ -2519,8 +2550,11 @@ install_computer_use_driver() { ;; esac if command -v cua-driver >/dev/null 2>&1; then - log_success "Computer Use driver (cua-driver) already installed" - return 0 + if cua_driver_runtime_compatible; then + log_success "Computer Use driver (cua-driver) already installed and compatible" + return 0 + fi + log_warn "Existing cua-driver is old or incomplete; repairing it" fi # Non-admin macOS accounts can't receive the CuaDriver.app bundle in # /Applications; skip cleanly instead of failing loudly (#47865 class). diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 55d82408a4..bc9152e123 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -8,8 +8,8 @@ must: can call it unconditionally without warning unsupported-platform users. * Re-run the installer even when the binary is already on PATH (this is the fix for the "we only pulled cua-driver once on enable" complaint). -* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow: - skip if installed, install otherwise, warn on unsupported platforms. +* For ``upgrade=False``, keep compatible installations, repair old or + incomplete installations, and install when missing. The pre-install arch probe that used to live alongside this function was deleted (see top-of-file comment in tools_config.py) — the upstream @@ -21,6 +21,7 @@ cleanly on missing-arch assets, and the upgrade path uses from __future__ import annotations +import json import sys from types import SimpleNamespace from unittest.mock import patch @@ -28,6 +29,86 @@ from unittest.mock import patch import pytest +def _runtime_manifest(version="0.20.0", *, omit=None): + omit = set(omit or ()) + required = { + "mcp": {"--socket", "--grant"}, + "serve": { + "--socket", + "--permission-mode", + "--capability-manifest", + "--approve-capability-manifest", + "--embedded", + }, + "stop": {"--socket"}, + } + return { + "binary_version": version, + "mcp_invocation": {"command": "/opt/cua-driver", "args": ["mcp"]}, + "subcommands": [ + { + "name": command, + "args": [ + {"name": arg} + for arg in sorted(args - omit) + ], + } + for command, args in required.items() + ], + } + + +class TestCuaDriverRuntimeContract: + def test_current_manifest_is_ready(self): + from hermes_cli import tools_config + + result = SimpleNamespace( + returncode=0, + stdout=json.dumps(_runtime_manifest()), + stderr="", + ) + with patch("subprocess.run", return_value=result): + state = tools_config._cua_driver_contract_status("/opt/cua-driver") + + assert state == { + "ready": True, + "binary": "/opt/cua-driver", + "version": "0.20.0", + "reason": "", + } + + @pytest.mark.parametrize("version", ["0.19.4", "bad-version"]) + def test_old_or_unversioned_driver_needs_repair(self, version): + from hermes_cli import tools_config + + result = SimpleNamespace( + returncode=0, + stdout=json.dumps(_runtime_manifest(version)), + stderr="", + ) + with patch("subprocess.run", return_value=result): + state = tools_config._cua_driver_contract_status("/opt/cua-driver") + + assert state["ready"] is False + assert state["reason"] + + def test_incomplete_manifest_needs_repair(self): + from hermes_cli import tools_config + + result = SimpleNamespace( + returncode=0, + stdout=json.dumps( + _runtime_manifest(omit={"--approve-capability-manifest"}) + ), + stderr="", + ) + with patch("subprocess.run", return_value=result): + state = tools_config._cua_driver_contract_status("/opt/cua-driver") + + assert state["ready"] is False + assert "serve --approve-capability-manifest" in state["reason"] + + class TestInstallCuaDriverUpgrade: # ``install_cua_driver`` supports macOS, Windows AND Linux. For everything # below except the two unsupported-platform cases, the Linux host takes a @@ -236,10 +317,77 @@ class TestInstallCuaDriverUpgrade: side_effect=lambda n: "/usr/local/bin/" + n if n in {"cua-driver", "curl"} else None), \ patch.object(tools_config, "_run_cua_driver_installer") as runner, \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value={"ready": True, "version": "0.20.0", "reason": ""}, + ), \ + patch.object( + tools_config, + "_repair_cua_driver_autostart_windows", + return_value=True, + ), \ patch("subprocess.run"): assert tools_config.install_cua_driver(upgrade=False) is True runner.assert_not_called() + def test_non_upgrade_repairs_incompatible_existing_driver(self): + from hermes_cli import tools_config + + incompatible = { + "ready": False, + "version": "0.19.4", + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + repaired = {"ready": True, "version": "0.20.0", "reason": ""} + with patch.object( + tools_config.shutil, + "which", + side_effect=lambda name: f"/usr/bin/{name}", + ), \ + patch.object( + tools_config, + "_resolved_cua_driver_cmd", + return_value="/usr/bin/cua-driver", + ), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + side_effect=[incompatible, repaired], + ), \ + patch.object( + tools_config, + "_run_cua_driver_installer", + return_value=True, + ) as runner: + assert tools_config.install_cua_driver(upgrade=False) is True + + assert runner.call_args.kwargs["label"] == "Repairing" + + def test_incompatible_explicit_override_is_not_replaced(self, monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setenv("HERMES_CUA_DRIVER_CMD", "/opt/custom/cua-driver") + incompatible = { + "ready": False, + "version": "0.19.4", + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + with patch.object( + tools_config, + "_resolved_cua_driver_cmd", + return_value="/opt/custom/cua-driver", + ), \ + patch.object( + tools_config, + "_cua_driver_contract_status", + return_value=incompatible, + ), \ + patch.object(tools_config, "_run_cua_driver_installer") as runner: + assert tools_config.install_cua_driver(upgrade=False) is False + + runner.assert_not_called() + def test_non_upgrade_without_binary_runs_installer(self): from hermes_cli import tools_config @@ -1049,7 +1197,10 @@ class TestWindowsAutostartRepair: assert captured["cmd"][:4] == [ "powershell", "-NoProfile", "-ExecutionPolicy", "Bypass", ] - repair.assert_called_once_with("cua-driver", verbose=False) + repair.assert_called_once_with( + r"C:\Users\Ha Trung\AppData\Local\Programs\Cua\cua-driver\bin\cua-driver.exe", + verbose=False, + ) @pytest.mark.windows_only def test_autostart_repair_quotes_username_space_path_via_file_path(self): diff --git a/tests/hermes_cli/test_post_setup_gating.py b/tests/hermes_cli/test_post_setup_gating.py index ea1a62a42b..713df383ec 100644 --- a/tests/hermes_cli/test_post_setup_gating.py +++ b/tests/hermes_cli/test_post_setup_gating.py @@ -27,6 +27,24 @@ class TestPostSetupGate: "computer_use", {} ) is True + def test_incompatible_cua_driver_forces_setup(self, monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: False) + + assert tools_config._toolset_needs_configuration_prompt( + "computer_use", {} + ) is True + + def test_compatible_cua_driver_skips_setup(self, monkeypatch): + from hermes_cli import tools_config + + monkeypatch.setattr(tools_config, "_cua_driver_install_ready", lambda: True) + + assert tools_config._toolset_needs_configuration_prompt( + "computer_use", {} + ) is False + def test_post_setup_predicate_exception_does_not_block(self, monkeypatch): """A predicate that raises must be treated as 'satisfied' so a @@ -39,4 +57,3 @@ class TestPostSetupGate: monkeypatch.setitem(tools_config._POST_SETUP_INSTALLED, "cua_driver", _boom) assert tools_config._post_setup_already_installed("cua_driver") is True - diff --git a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py index 7a7b8e3b45..f4d840d6a2 100644 --- a/tests/hermes_cli/test_web_routers_tools_install_on_enable.py +++ b/tests/hermes_cli/test_web_routers_tools_install_on_enable.py @@ -55,6 +55,9 @@ class TestToggleToolsetInstallOnEnable: monkeypatch.setattr( tools_config, "_resolved_cua_driver_cmd", lambda: None ) + monkeypatch.setattr( + tools_config, "_cua_driver_install_ready", lambda: False + ) resp = self.client.put( "/api/tools/toolsets/computer_use", json={"enabled": True} @@ -77,6 +80,9 @@ class TestToggleToolsetInstallOnEnable: monkeypatch.setattr( tools_config, "_resolved_cua_driver_cmd", lambda: "/usr/bin/cua-driver" ) + monkeypatch.setattr( + tools_config, "_cua_driver_install_ready", lambda: True + ) resp = self.client.put( "/api/tools/toolsets/computer_use", json={"enabled": True} @@ -92,6 +98,9 @@ class TestToggleToolsetInstallOnEnable: monkeypatch.setattr( tools_config, "_resolved_cua_driver_cmd", lambda: None ) + monkeypatch.setattr( + tools_config, "_cua_driver_install_ready", lambda: False + ) resp = self.client.put( "/api/tools/toolsets/computer_use", json={"enabled": False} @@ -107,6 +116,9 @@ class TestToggleToolsetInstallOnEnable: monkeypatch.setattr( tools_config, "_resolved_cua_driver_cmd", lambda: None ) + monkeypatch.setattr( + tools_config, "_cua_driver_install_ready", lambda: False + ) def _boom(subcommand, name, **kwargs): raise RuntimeError("spawn exploded") diff --git a/tests/tools/test_computer_use.py b/tests/tools/test_computer_use.py index 6a61c1d25b..f42efa6b56 100644 --- a/tests/tools/test_computer_use.py +++ b/tests/tools/test_computer_use.py @@ -768,13 +768,35 @@ class TestLazyMcpInstall: def test_start_lazy_installs_mcp(self): from tools.computer_use import cua_backend - with patch.object(cua_backend, "_maybe_nudge_update"), \ + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value={"ready": True}, + ), \ + patch.object(cua_backend, "_maybe_nudge_update"), \ patch("tools.lazy_deps.ensure") as mock_ensure, \ patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start: cua_backend.CuaDriverBackend().start() mock_ensure.assert_called_once_with("tool.computer_use", prompt=False) mock_sess_start.assert_called_once() + def test_start_reports_incompatible_existing_driver_before_mcp_setup(self): + from tools.computer_use import cua_backend + + state = { + "ready": False, + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value=state, + ), patch("tools.lazy_deps.ensure") as mock_ensure: + with pytest.raises(RuntimeError, match="hermes computer-use install"): + cua_backend.CuaDriverBackend().start() + + mock_ensure.assert_not_called() + def test_start_propagates_feature_unavailable(self): """When mcp can't be installed (lazy installs off / network), start() surfaces the actionable FeatureUnavailable rather than a session that @@ -784,7 +806,12 @@ class TestLazyMcpInstall: unavailable = FeatureUnavailable( "tool.computer_use", ("mcp==1.28.1",), "lazy installs disabled" ) - with patch.object(cua_backend, "_maybe_nudge_update"), \ + with patch.object( + cua_backend, + "cua_driver_runtime_contract_status", + return_value={"ready": True}, + ), \ + patch.object(cua_backend, "_maybe_nudge_update"), \ patch("tools.lazy_deps.ensure", side_effect=unavailable), \ patch.object(cua_backend._CuaDriverSession, "start") as mock_sess_start: with pytest.raises(FeatureUnavailable): @@ -2071,7 +2098,10 @@ class TestSessionLifecycle: # Stub the optional-dep lazy-install so start() runs end-to-end # without trying to pip-install anything. - with patch("tools.lazy_deps.ensure"): + with patch( + "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", + return_value={"ready": True}, + ), patch("tools.lazy_deps.ensure"): backend.start() # First call_tool after _session.start() must be start_session @@ -2083,9 +2113,7 @@ class TestSessionLifecycle: def test_session_lifecycle_failures_are_non_fatal(self): - """If start_session raises (older cua-driver build, anonymous - path), backend.start() must still succeed — the rest of the - wrapper works fine in anonymous mode.""" + """A lifecycle-label failure does not discard an otherwise valid runtime.""" from unittest.mock import MagicMock, patch from tools.computer_use.cua_backend import CuaDriverBackend @@ -2097,7 +2125,10 @@ class TestSessionLifecycle: RuntimeError("older cua-driver — start_session unknown"), ] - with patch("tools.lazy_deps.ensure"): + with patch( + "tools.computer_use.cua_backend.cua_driver_runtime_contract_status", + return_value={"ready": True}, + ), patch("tools.lazy_deps.ensure"): backend.start() # must not raise diff --git a/tools/computer_use/cua_backend.py b/tools/computer_use/cua_backend.py index c0a899c3c2..f866e47aeb 100644 --- a/tools/computer_use/cua_backend.py +++ b/tools/computer_use/cua_backend.py @@ -928,6 +928,136 @@ def cua_driver_binary_available() -> bool: return resolve_cua_driver_cmd() is not None +_CUA_DRIVER_RUNTIME_CONTRACT_MIN = (0, 20, 0) +_CUA_DRIVER_RUNTIME_CONTRACT_ARGS = { + "mcp": {"--socket", "--grant"}, + "serve": { + "--socket", + "--permission-mode", + "--capability-manifest", + "--approve-capability-manifest", + "--embedded", + }, + "stop": {"--socket"}, +} + + +def cua_driver_runtime_contract_status(binary: Optional[str] = None) -> Dict[str, Any]: + """Report whether a local driver can host Hermes' 0.20 integration.""" + resolved = binary or resolve_cua_driver_cmd() + if not resolved: + return { + "ready": False, + "binary": None, + "version": None, + "reason": "cua-driver is not installed", + } + + try: + from tools.environments.local import _sanitize_subprocess_env + + result = subprocess.run( + [resolved, "manifest"], + capture_output=True, + text=True, + encoding="utf-8", + errors="replace", + timeout=15.0 if sys.platform == "win32" else 5.0, + stdin=subprocess.DEVNULL, + env=_sanitize_subprocess_env(cua_driver_child_env()), + creationflags=windows_hide_flags(), + ) + except (OSError, subprocess.SubprocessError) as exc: + return { + "ready": False, + "binary": resolved, + "version": None, + "reason": f"manifest check failed: {exc}", + } + + if result.returncode != 0: + detail = (result.stderr or result.stdout or "manifest command failed").strip() + return { + "ready": False, + "binary": resolved, + "version": None, + "reason": detail.splitlines()[-1][:200], + } + + try: + manifest = json.loads(result.stdout or "") + except (TypeError, ValueError): + manifest = None + if not isinstance(manifest, dict): + return { + "ready": False, + "binary": resolved, + "version": None, + "reason": "driver manifest is missing or invalid", + } + + raw_version = str(manifest.get("binary_version") or "").strip() + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?", raw_version) + if not match: + return { + "ready": False, + "binary": resolved, + "version": raw_version or None, + "reason": "driver manifest does not report a semantic version", + } + version = tuple(int(part) for part in match.groups()) + if version < _CUA_DRIVER_RUNTIME_CONTRACT_MIN: + return { + "ready": False, + "binary": resolved, + "version": raw_version, + "reason": "Hermes computer use requires cua-driver 0.20.0 or newer", + } + + invocation = manifest.get("mcp_invocation") + invocation_args = invocation.get("args") if isinstance(invocation, dict) else None + if not ( + isinstance(invocation_args, list) + and invocation_args + and all(isinstance(arg, str) for arg in invocation_args) + ): + return { + "ready": False, + "binary": resolved, + "version": raw_version, + "reason": "driver manifest does not provide an MCP launch command", + } + + advertised: Dict[str, set[str]] = {} + for command in manifest.get("subcommands") or []: + if not isinstance(command, dict) or not isinstance(command.get("name"), str): + continue + advertised[command["name"]] = { + arg["name"] + for arg in command.get("args") or [] + if isinstance(arg, dict) and isinstance(arg.get("name"), str) + } + + missing = [] + for command, required_args in _CUA_DRIVER_RUNTIME_CONTRACT_ARGS.items(): + for arg in sorted(required_args - advertised.get(command, set())): + missing.append(f"{command} {arg}") + if missing: + return { + "ready": False, + "binary": resolved, + "version": raw_version, + "reason": "driver manifest is missing: " + ", ".join(missing), + } + + return { + "ready": True, + "binary": resolved, + "version": raw_version, + "reason": "", + } + + def cua_driver_update_check(*, timeout: Optional[float] = None) -> Optional[Dict[str, Any]]: """Run ``cua-driver check-update --json`` and return its parsed state. @@ -2295,10 +2425,8 @@ class CuaDriverBackend(ComputerUseBackend): # We mint a UUID4-based id once per CuaDriverBackend instance — # one Hermes run = one backend = one label — and pass it as # `session` on every cua-driver tool call. Labels are an - # additive feature on the cua-driver side: when our id is - # unknown to the driver (older builds), the tool calls - # degrade to the anonymous / unsynced path documented in the - # MCP server instructions. + # part of the required Cua Driver 0.20 runtime contract checked at + # backend startup. self._session_id: str = f"hermes-{uuid.uuid4().hex[:12]}" self._typed_browser = CuaTypedBrowserRoute( session_id=self._session_id, @@ -2328,6 +2456,17 @@ class CuaDriverBackend(ComputerUseBackend): # ── Lifecycle ────────────────────────────────────────────────── def start(self) -> None: + contract = cua_driver_runtime_contract_status() + if not contract.get("ready"): + reason = contract.get("reason") or "runtime contract is incomplete" + if os.environ.get(_CUA_DRIVER_CMD_ENV, "").strip(): + repair = ( + "Update the binary selected by HERMES_CUA_DRIVER_CMD or " + "remove that override." + ) + else: + repair = "Run `hermes computer-use install` to repair it." + raise RuntimeError(f"cua-driver is not ready: {reason}. {repair}") _maybe_nudge_update() # The MCP client SDK (`mcp`) is an optional dependency (the # `computer-use` / `mcp` extras), not part of Hermes' minimal core. diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 5a388fa62f..49cbf54615 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -1433,6 +1433,13 @@ Subcommands: to use for re-running the install if the toolset toggle didn't trigger it (for example, on returning-user setups). +If cua-driver is already present, Hermes checks its version and runtime +manifest. A compatible 0.20.0 or newer installation is left in place. An old or +incomplete standard installation is repaired with the current upstream +installer. Hermes never replaces a custom binary selected through +`HERMES_CUA_DRIVER_CMD`; update that binary directly or remove the override. +`hermes computer-use status` reports when repair is required. + The built-in `computer_use` toolset is the recommended Hermes integration. Registering raw Cua MCP tools is an alternative when you need Cua's low-level tool vocabulary. `cua-driver skills install` detects Hermes and links Cua's diff --git a/website/docs/user-guide/features/computer-use.md b/website/docs/user-guide/features/computer-use.md index 11bacd6fc2..da0e2e1912 100644 --- a/website/docs/user-guide/features/computer-use.md +++ b/website/docs/user-guide/features/computer-use.md @@ -62,6 +62,13 @@ This fetches and runs the upstream cua-driver installer — `install.sh` on macOS/Linux, `install.ps1` on Windows. Use `hermes computer-use status` to verify the install. +Already have cua-driver? Hermes reuses it when it supports the 0.20 runtime +contract. During setup and toolset enablement, Hermes checks the local version +and manifest. It repairs an old or incomplete standard installation through +the upstream installer. A binary selected with `HERMES_CUA_DRIVER_CMD` stays +under your control, so Hermes reports the incompatibility and leaves it +unchanged. + If you install Cua Driver first, `cua-driver skills install` detects Hermes and links Cua's skill pack into the Hermes skills directory automatically. You can also register raw Cua MCP tools as a custom MCP server, but that is an