hermes-agent/tests/hermes_cli/test_install_cua_driver.py
teknium1 38c56a1e86 fix(computer_use): probe cua-driver-rs release tag, not monorepo releases/latest
The install pre-flight asset probe queried trycua/cua's `releases/latest`,
which floats across the monorepo's components (agent-*, computer-*, lume-*,
train-*) — most ship zero binary assets. So the probe false-negatived and
hard-blocked `install_cua_driver` (line 770: `if not probe: return False`)
BEFORE the upstream installer ran, on Linux, Windows, and Intel macOS — even
though the installer it gates resolves the right tag and would have succeeded.

Net effect: the normal enable path (`hermes tools` → Computer Use post-setup,
and `hermes computer-use install`) refused to install on every platform this
PR claims to support.

Fix: list `/releases?per_page=100`, pick the newest `cua-driver-rs-v*` tag,
and match its assets on OS-token + arch — mirroring what the upstream
`install.sh` already does. Fail open if no driver release surfaces (installer
remains the source of truth). Adds an OS-token gate so a darwin asset can't
satisfy a Linux probe.

Tests: updated the install-probe fixtures to the list-of-releases shape with
`cua-driver-rs-v*` tags + OS-token asset names; added a regression guard
(`test_releases_latest_tag_ignored_picks_driver_rs_tag`) for the monorepo
floating-latest case. 25/25 install + 192 computer_use tests green.

Verified live: probe returns True for all six platform/arch combos against
the real GitHub releases API.
2026-06-22 06:42:30 -07:00

446 lines
21 KiB
Python

"""Tests for ``install_cua_driver`` upgrade semantics and architecture pre-check.
The cua-driver upstream installer always pulls the latest release tag, so
re-running it is the canonical upgrade path. ``install_cua_driver(upgrade=True)``
must:
* Be cross-platform — run on macOS, Windows, and Linux. Only genuinely
unsupported platforms no-op silently on upgrade so ``hermes update`` can
call it unconditionally without warning those users.
* Choose the right installer per OS: ``install.sh`` via ``curl | bash`` on
macOS/Linux, ``install.ps1`` via PowerShell ``irm | iex`` on Windows.
* 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.
* Pre-check architecture compatibility before downloading to avoid raw 404
errors when the upstream release lacks an asset for this OS+arch.
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
class TestInstallCuaDriverUpgrade:
def test_upgrade_on_unsupported_platform_is_silent_noop(self):
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="FreeBSD"):
assert tools_config.install_cua_driver(upgrade=True) is False
warn.assert_not_called()
def test_non_upgrade_on_unsupported_platform_warns(self):
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="FreeBSD"):
assert tools_config.install_cua_driver(upgrade=False) is False
warn.assert_called()
def test_upgrade_on_macos_with_binary_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
kwargs = runner.call_args.kwargs
assert kwargs.get("verbose") is False
def test_upgrade_on_macos_without_binary_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
def test_non_upgrade_on_macos_with_binary_skips_install(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_run_cua_driver_installer") as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=False) is True
runner.assert_not_called()
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=False) is True
class TestCheckCuaDriverAssetForArch:
def test_arm64_macos_always_returns_true(self):
from hermes_cli import tools_config
# Apple Silicon assets are always published — short-circuits without
# a network probe.
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="arm64"):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_x86_64_with_asset_returns_true(self):
from hermes_cli import tools_config
releases = [{
"tag_name": "cua-driver-rs-v0.1.6",
"assets": [
{"name": "cua-driver-rs-0.1.6-darwin-arm64.tar.gz"},
{"name": "cua-driver-rs-0.1.6-darwin-x86_64.tar.gz"},
],
}]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(releases).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_x86_64_without_asset_returns_false(self):
from hermes_cli import tools_config
releases = [{
"tag_name": "cua-driver-rs-v0.1.6",
"assets": [
{"name": "cua-driver-rs-0.1.6-darwin-arm64.tar.gz"},
{"name": "cua-driver-rs.tar.gz"},
],
}]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(releases).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.system", return_value="Darwin"), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning") as warn, \
patch.object(tools_config, "_print_info"):
assert tools_config._check_cua_driver_asset_for_arch() is False
warn.assert_called_once()
assert "no Intel" in warn.call_args[0][0].lower() or "x86_64" in warn.call_args[0][0]
def test_x86_64_api_failure_returns_true(self):
"""Network failure should fail open — let the installer handle it."""
from hermes_cli import tools_config
with patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", side_effect=Exception("timeout")):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_fresh_install_x86_64_no_asset_skips_installer(self):
"""When the latest release has no Intel asset, skip the installer."""
from hermes_cli import tools_config
releases = [{
"tag_name": "cua-driver-rs-v0.1.6",
"assets": [{"name": "cua-driver-rs-0.1.6-darwin-arm64.tar.gz"}],
}]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(releases).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
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_upgrade_x86_64_no_asset_returns_existing_status(self):
"""On upgrade with no Intel asset, return whether binary existed."""
from hermes_cli import tools_config
releases = [{
"tag_name": "cua-driver-rs-v0.1.6",
"assets": [{"name": "cua-driver-rs-0.1.6-darwin-arm64.tar.gz"}],
}]
mock_resp = MagicMock()
mock_resp.read.return_value = json.dumps(releases).encode()
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
# With binary installed — returns True (binary exists)
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in ("cua-driver", "curl") else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_not_called()
# Without binary — returns False
with patch("platform.system", return_value="Darwin"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(tools_config, "_print_warning"), \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=True) is False
runner.assert_not_called()
class TestInstallCuaDriverWindows:
"""install_cua_driver dispatch on Windows hosts."""
def test_fresh_install_runs_installer(self):
from hermes_cli import tools_config
# PowerShell present, cua-driver not yet installed.
with patch("platform.system", return_value="Windows"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: r"C:\\Windows\\powershell.exe"
if n == "powershell" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=False) is True
runner.assert_called_once()
def test_fresh_install_without_powershell_fails(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Windows"), \
patch.object(tools_config.shutil, "which", lambda n: None), \
patch.object(tools_config, "_print_warning") as warn, \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_run_cua_driver_installer") as runner:
assert tools_config.install_cua_driver(upgrade=False) is False
runner.assert_not_called()
# The warning should name the missing fetch tool (powershell).
assert "powershell" in warn.call_args[0][0].lower()
def test_upgrade_with_binary_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Windows"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: r"C:\\bin\\" + n
if n in {"cua-driver", "powershell"} else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
assert runner.call_args.kwargs.get("verbose") is False
def test_installer_uses_powershell_irm_command(self):
"""_run_cua_driver_installer must shell out to PowerShell irm|iex."""
from hermes_cli import tools_config
completed = MagicMock(returncode=0)
with patch("platform.system", return_value="Windows"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: r"C:\\bin\\" + n
if n == "cua-driver" else None), \
patch("subprocess.run", return_value=completed) as run, \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_print_success"), \
patch.object(tools_config, "_print_warning"):
assert tools_config._run_cua_driver_installer() is True
cmd = run.call_args[0][0]
# Argument list (shell=False), not a string.
assert isinstance(cmd, list)
assert cmd[0] == "powershell"
assert run.call_args.kwargs.get("shell") is False
joined = " ".join(cmd)
assert "install.ps1" in joined
assert "iex" in joined
class TestInstallCuaDriverLinux:
"""install_cua_driver dispatch on Linux hosts (alpha)."""
def test_fresh_install_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Linux"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner:
assert tools_config.install_cua_driver(upgrade=False) is True
runner.assert_called_once()
def test_upgrade_with_binary_runs_installer(self):
from hermes_cli import tools_config
with patch("platform.system", return_value="Linux"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n in {"cua-driver", "curl"} else None), \
patch.object(tools_config, "_check_cua_driver_asset_for_arch",
return_value=True), \
patch.object(tools_config, "_run_cua_driver_installer",
return_value=True) as runner, \
patch("subprocess.run"):
assert tools_config.install_cua_driver(upgrade=True) is True
runner.assert_called_once()
def test_installer_uses_curl_bash_command(self):
"""_run_cua_driver_installer must shell out to curl | bash install.sh."""
from hermes_cli import tools_config
completed = MagicMock(returncode=0)
with patch("platform.system", return_value="Linux"), \
patch.object(tools_config.shutil, "which",
side_effect=lambda n: "/usr/local/bin/" + n
if n == "cua-driver" else None), \
patch("subprocess.run", return_value=completed) as run, \
patch.object(tools_config, "_print_info"), \
patch.object(tools_config, "_print_success"), \
patch.object(tools_config, "_print_warning"):
assert tools_config._run_cua_driver_installer() is True
cmd = run.call_args[0][0]
assert isinstance(cmd, str) # shell string on POSIX
assert run.call_args.kwargs.get("shell") is True
assert "install.sh" in cmd
assert "curl" in cmd
class TestCheckCuaDriverAssetCrossPlatform:
"""_check_cua_driver_asset_for_arch recognizes Windows/Linux asset names."""
@staticmethod
def _mock_release(asset_names):
# The probe lists /releases and picks the newest cua-driver-rs-v* tag,
# so the mock returns a LIST of releases with that tag prefix.
releases = [{"tag_name": "cua-driver-rs-v0.5.0",
"assets": [{"name": n} for n in asset_names]}]
resp = MagicMock()
resp.read.return_value = json.dumps(releases).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
return resp
def test_windows_amd64_with_asset_returns_true(self):
from hermes_cli import tools_config
resp = self._mock_release([
"cua-driver-rs-0.5.0-windows-x86_64.zip",
"cua-driver-rs-0.5.0-darwin-arm64.tar.gz",
])
with patch("platform.system", return_value="Windows"), \
patch("platform.machine", return_value="AMD64"), \
patch("urllib.request.urlopen", return_value=resp):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_windows_arm64_without_asset_returns_false(self):
from hermes_cli import tools_config
resp = self._mock_release([
"cua-driver-rs-0.5.0-windows-x86_64.zip",
])
with patch("platform.system", return_value="Windows"), \
patch("platform.machine", return_value="ARM64"), \
patch("urllib.request.urlopen", return_value=resp), \
patch.object(tools_config, "_print_warning") as warn, \
patch.object(tools_config, "_print_info"):
assert tools_config._check_cua_driver_asset_for_arch() is False
warn.assert_called_once()
assert "arm64" in warn.call_args[0][0].lower()
def test_linux_x86_64_with_asset_returns_true(self):
from hermes_cli import tools_config
resp = self._mock_release([
"cua-driver-rs-0.5.0-linux-x86_64.tar.gz",
])
with patch("platform.system", return_value="Linux"), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=resp):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_linux_aarch64_with_asset_returns_true(self):
from hermes_cli import tools_config
resp = self._mock_release([
"cua-driver-rs-0.5.0-linux-arm64.tar.gz",
])
with patch("platform.system", return_value="Linux"), \
patch("platform.machine", return_value="aarch64"), \
patch("urllib.request.urlopen", return_value=resp):
assert tools_config._check_cua_driver_asset_for_arch() is True
def test_linux_aarch64_without_asset_returns_false(self):
from hermes_cli import tools_config
resp = self._mock_release([
"cua-driver-rs-0.5.0-linux-x86_64.tar.gz",
])
with patch("platform.system", return_value="Linux"), \
patch("platform.machine", return_value="aarch64"), \
patch("urllib.request.urlopen", return_value=resp), \
patch.object(tools_config, "_print_warning") as warn, \
patch.object(tools_config, "_print_info"):
assert tools_config._check_cua_driver_asset_for_arch() is False
warn.assert_called_once()
def test_releases_latest_tag_ignored_picks_driver_rs_tag(self):
"""A non-driver tag at the head of the list must not gate the probe.
Regression guard: the monorepo's newest release is often a Python
component (agent-*, computer-*) with zero binary assets. The probe
must skip past it to the newest cua-driver-rs-v* release.
"""
from hermes_cli import tools_config
releases = [
{"tag_name": "agent-v0.8.3", "assets": []},
{"tag_name": "computer-v0.5.19", "assets": []},
{"tag_name": "cua-driver-rs-v0.6.0",
"assets": [{"name": "cua-driver-rs-0.6.0-linux-x86_64-binary.tar.gz"}]},
]
resp = MagicMock()
resp.read.return_value = json.dumps(releases).encode()
resp.__enter__ = lambda s: s
resp.__exit__ = MagicMock(return_value=False)
with patch("platform.system", return_value="Linux"), \
patch("platform.machine", return_value="x86_64"), \
patch("urllib.request.urlopen", return_value=resp):
assert tools_config._check_cua_driver_asset_for_arch() is True