fix(computer-use): reconcile existing cua-driver installs
This commit is contained in:
committed by
Teknium
parent
a403fe6f92
commit
81af2ef013
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user