fix(computer-use): reconcile existing cua-driver installs

This commit is contained in:
Francesco Bonacci
2026-08-16 06:56:48 -05:00
committed by Teknium
parent a403fe6f92
commit 81af2ef013
11 changed files with 574 additions and 33 deletions
+9
View File
@@ -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"):
+92 -13
View File
@@ -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(),
}
+58 -3
View File
@@ -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)..."
+36 -2
View File
@@ -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).
+154 -3
View File
@@ -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):
+18 -1
View File
@@ -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
@@ -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")
+38 -7
View File
@@ -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
+143 -4
View File
@@ -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.
+7
View File
@@ -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
@@ -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