fix(computer-use): delete broken pre-install asset probe; trust the upstream installer

`hermes computer-use install` refused to install on Linux, Windows, and
macOS x86_64 because the pre-install asset probe was hitting the wrong
GitHub endpoint AND duplicating tag-resolution logic the upstream
installer already does correctly.

`_check_cua_driver_asset_for_arch()` queried
`https://api.github.com/repos/trycua/cua/releases/latest`. On trycua/cua:

- cua-driver-rs releases (the binary the installer fetches) are marked
  **prerelease** on every cut. GitHub's `/releases/latest` explicitly
  skips prereleases.
- The Python package releases (`cua-agent`, `cua-computer`, `cua-train`)
  are non-prerelease and end up as the "latest" instead.

Live API check today:

  $ curl -sf https://api.github.com/repos/trycua/cua/releases/latest \
      | jq '{tag:.tag_name, asset_count: (.assets|length)}'
  { "tag": "agent-v0.8.3", "asset_count": 0 }

The probe sees zero assets, prints "Latest CUA release has no Linux
x86_64 asset", and skips install on every Linux / Windows / macOS-x86_64
host — even though the cua-driver-rs-v0.6.0 release ships 19 binary
assets covering all those platforms.

Filtering `/releases?per_page=N` for the `cua-driver-rs-v*` prefix
fixes the bug, but it duplicates tag-resolution logic the upstream
`_install-rust.sh` already does correctly via `CUA_DRIVER_RS_BAKED_VERSION`
(auto-baked by CD on every release, with a `/releases?per_page=N` API
fallback for dev checkouts). The right answer is to trust that
contract instead of mirroring it in Python where it can drift.

Two paths get the same outcome without the probe:

1. **Fresh install**: run `install.sh` directly. It has the baked
   release tag, fetches the right asset, and errors with a clear
   message on missing-arch downloads. No preflight needed.
2. **Upgrade path**: `cua_driver_update_check()` (separately added)
   shells `cua-driver check-update --json` against the installed
   binary, which returns the canonical update answer from the same
   source the installer uses.

- `hermes_cli/tools_config.py`: delete `_check_cua_driver_asset_for_arch`
  and its two call sites in `install_cua_driver`. Replace with an
  inline comment near the top of the module explaining the rationale.
- `tests/hermes_cli/test_install_cua_driver.py`: drop the
  `TestCheckCuaDriverAssetForArch` block. Add `TestArchProbeRemoval`
  with three regressions:

  - `test_probe_function_is_gone` — asserts the deleted helpers stay
    deleted.
  - `test_fresh_install_does_not_call_github_api` — asserts the
    install path doesn't hit GitHub directly from Python anymore.
  - `test_upgrade_with_binary_does_not_call_github_api_directly` —
    same for the upgrade path.

All 9 `test_install_cua_driver` tests pass.

Reported by @teknium1 while testing on a headed Ubuntu host.
This commit is contained in:
Francesco Bonacci 2026-06-22 07:24:37 -07:00 committed by Teknium
parent f721d2cda9
commit 5f1d23cfb2
2 changed files with 97 additions and 452 deletions

View file

@ -667,102 +667,31 @@ def _pip_install(
def _check_cua_driver_asset_for_arch() -> bool:
"""Check whether the latest CUA release ships an asset for this OS+arch.
Returns True if the asset likely exists (or if we cannot determine it).
Returns False and prints a warning when the asset is confirmed missing,
so callers can skip the install attempt and avoid a raw 404.
Recognizes release-asset names across all supported platforms:
* macOS (``Darwin``) arm64 always ships; x86_64/amd64 probed.
* Windows (``AMD64``/``ARM64``) amd64/x86_64 and arm64 probed.
* Linux (``x86_64``/``aarch64``) x86_64/amd64 and aarch64/arm64 probed.
"""
import platform as _plat
import urllib.request
system = _plat.system()
machine = _plat.machine().lower() # e.g. "x86_64", "arm64", "amd64", "aarch64"
# arm64 (Apple Silicon) macOS assets are always published — short-circuit
# to preserve the original fail-open behaviour and avoid a network call.
if system == "Darwin" and machine == "arm64":
return True
# Map this host's arch to the set of asset-name substrings we'll accept.
# Asset names vary by OS (darwin-x86_64, windows-amd64, linux-aarch64, …),
# so we match on the architecture token only and let any of the common
# aliases satisfy the probe.
if machine in {"x86_64", "amd64", "x64"}:
arch_names = {"x86_64", "amd64", "x64"}
arch_label = "x86_64/amd64"
elif machine in {"arm64", "aarch64"}:
arch_names = {"arm64", "aarch64"}
arch_label = "arm64/aarch64"
else:
# Unknown arch — fail open and let the installer surface the error.
return True
# Probe the cua-driver release for an OS+arch asset before falling through
# to the upstream installer.
#
# The cua-driver-rs binaries are published to the trycua/cua monorepo under
# tag prefix ``cua-driver-rs-v*``. The repo's ``releases/latest`` is NOT
# that — it floats across the monorepo's other components (agent-*,
# computer-*, lume-*, train-*), most of which ship zero binary assets. So
# we list releases and pick the newest ``cua-driver-rs-v*`` tag, matching
# what the upstream install.sh does. Failing to find one => fail open and
# let the installer (which resolves the tag itself) be the source of truth.
driver_tag_prefix = "cua-driver-rs-v"
api_url = (
"https://api.github.com/repos/trycua/cua/releases?per_page=100"
)
try:
req = urllib.request.Request(api_url, headers={"Accept": "application/vnd.github+json"})
with urllib.request.urlopen(req, timeout=10) as resp:
releases = _json.loads(resp.read().decode())
if not isinstance(releases, list):
return True
# GitHub returns releases newest-first; take the first cua-driver-rs tag.
driver_release = next(
(
r for r in releases
if str(r.get("tag_name", "")).startswith(driver_tag_prefix)
),
None,
)
if driver_release is None:
# No cua-driver-rs release surfaced (API hiccup / unexpected shape).
# Fail open — the installer resolves the tag on its own.
return True
tag = driver_release.get("tag_name", "")
assets = driver_release.get("assets", [])
# OS token gates the asset alongside arch so a darwin asset can't
# satisfy a Linux probe (every cua-driver-rs release ships all three
# OSes, so the arch token alone would always match).
os_token = {"Darwin": "darwin", "Windows": "windows", "Linux": "linux"}.get(system, "")
has_asset = any(
os_token in (name := a_info.get("name", "").lower())
and any(a in name for a in arch_names)
for a_info in assets
)
if not has_asset:
_print_warning(
f" Latest cua-driver release ({tag}) has no {system} {arch_label} asset."
)
_print_info(
" CUA Driver may not yet ship a build for this platform."
)
_print_info(
" See: https://github.com/trycua/cua/releases"
)
return False
except Exception:
# Network / API failure — proceed and let the installer handle it.
pass
return True
# The asset-probe that lived here used to hit `/releases/latest` on
# trycua/cua and inspect the release's asset list before piping the
# installer to bash. It was broken in two places:
#
# 1. cua-driver-rs releases are marked **prerelease** on every cut,
# and GitHub's `/releases/latest` endpoint explicitly skips
# prereleases. On the live trycua/cua repo today, `/releases/latest`
# returns the Python `cua-agent v0.8.3` package (zero binary
# assets) instead of `cua-driver-rs-v0.6.0` (19 binary assets).
# The probe then reported "no asset for this arch" and skipped the
# install on every non-arm64 host — Linux x86_64, Windows, macOS
# Intel, Linux arm64 — even when the upstream installer would have
# succeeded.
# 2. Even with the right endpoint, we'd be duplicating tag-resolution
# logic the upstream installer already does correctly via
# `CUA_DRIVER_RS_BAKED_VERSION` (auto-baked by CD on every release,
# with an API fallback). Drift between our probe and theirs is a
# maintenance hazard.
#
# Resolution: trust the upstream installer. For fresh installs, run
# install.sh directly — it errors clean if the target arch has no
# asset. For the upgrade path, `cua_driver_update_check()` (which calls
# `cua-driver check-update --json`) gives us the canonical update
# answer from the binary itself — same tag-resolution as the installer,
# no Python-side duplication.
def install_cua_driver(upgrade: bool = False) -> bool:
@ -811,8 +740,9 @@ def install_cua_driver(upgrade: bool = False) -> bool:
_print_warning(f" {fetch_tool} not found — install manually:")
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
return False
if not _check_cua_driver_asset_for_arch():
return False
# Pre-install asset probe deleted — see comment near the top of
# tools_config.py for why. install.sh has CUA_DRIVER_RS_BAKED_VERSION
# 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.
@ -841,8 +771,10 @@ def install_cua_driver(upgrade: bool = False) -> bool:
_print_warning(f" {fetch_tool} not found — cannot refresh cua-driver.")
return bool(binary)
if not _check_cua_driver_asset_for_arch():
return bool(binary)
# Pre-install asset probe deleted (see top-of-file comment). The
# `cua_driver_update_check()` call further down asks the installed
# cua-driver binary itself whether an update exists — same
# tag-resolution as the installer, no duplication.
# Skip the (network) re-install when the driver itself reports it's already
# on the latest release. Best-effort: an older driver (no check-update

View file

@ -1,42 +1,43 @@
"""Tests for ``install_cua_driver`` upgrade semantics and architecture pre-check.
"""Tests for ``install_cua_driver`` upgrade semantics.
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.
* Be macOS-only no-op silently on Linux/Windows so ``hermes update`` can
call it unconditionally without warning every non-macOS user.
* 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.
skip if installed, install otherwise, warn on non-macOS.
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
installer has CUA_DRIVER_RS_BAKED_VERSION baked in by CD and errors
cleanly on missing-arch assets, and the upgrade path uses
``cua_driver_update_check()`` (which shells `cua-driver check-update
--json` against the already-installed binary).
"""
from __future__ import annotations
import json
from unittest.mock import MagicMock, patch
from unittest.mock import patch
class TestInstallCuaDriverUpgrade:
def test_upgrade_on_unsupported_platform_is_silent_noop(self):
def test_upgrade_on_non_macos_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"):
patch("platform.system", return_value="Linux"):
assert tools_config.install_cua_driver(upgrade=True) is False
warn.assert_not_called()
def test_non_upgrade_on_unsupported_platform_warns(self):
def test_non_upgrade_on_non_macos_warns(self):
from hermes_cli import tools_config
with patch.object(tools_config, "_print_warning") as warn, \
patch("platform.system", return_value="FreeBSD"):
patch("platform.system", return_value="Linux"):
assert tools_config.install_cua_driver(upgrade=False) is False
warn.assert_called()
@ -47,8 +48,6 @@ class TestInstallCuaDriverUpgrade:
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"):
@ -63,8 +62,6 @@ class TestInstallCuaDriverUpgrade:
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
@ -88,359 +85,75 @@ class TestInstallCuaDriverUpgrade:
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
runner.assert_called_once()
class TestCheckCuaDriverAssetForArch:
def test_arm64_macos_always_returns_true(self):
class TestArchProbeRemoval:
"""Regression tests for the deletion of `_check_cua_driver_asset_for_arch`.
The old probe queried ``/releases/latest`` on trycua/cua and inspected
asset names. That was wrong in two ways:
1. cua-driver-rs releases are marked **prerelease** on every cut, so
``/releases/latest`` returns the Python ``cua-agent`` / ``cua-computer``
package instead a release with zero binary assets. The probe then
reported "no asset for $arch" on Linux x86_64, Windows, macOS Intel,
Linux arm64 every non-Apple-Silicon host.
2. Even with the right endpoint, it duplicated tag-resolution the upstream
installer already does correctly via ``CUA_DRIVER_RS_BAKED_VERSION``
(auto-baked by CD on every release).
The fix: stop probing. Trust the upstream installer for fresh installs
(it has the baked version + correct API fallback) and the
``cua-driver check-update --json`` MCP-binary native command for the
upgrade path.
"""
def test_probe_function_is_gone(self):
from hermes_cli import tools_config
assert not hasattr(tools_config, "_check_cua_driver_asset_for_arch")
assert not hasattr(tools_config, "_latest_cua_driver_rs_release")
# 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):
def test_fresh_install_does_not_call_github_api(self):
"""Pre-install no longer probes the GitHub API — the upstream
``install.sh`` resolves the tag from its baked CUA_DRIVER_RS_BAKED_VERSION
line. install.sh errors cleanly when the arch has no asset, so the
probe was duplicate gatekeeping.
"""
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()
patch("urllib.request.urlopen") as urlopen, \
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()
urlopen.assert_not_called()
def test_upgrade_x86_64_no_asset_returns_existing_status(self):
"""On upgrade with no Intel asset, return whether binary existed."""
def test_upgrade_with_binary_does_not_call_github_api_directly(self):
"""The upgrade path no longer hits GitHub from Python — it delegates
to the upstream ``install.sh`` (which has the baked release tag and
the proper API fallback). When cua-driver is already installed,
``cua_driver_update_check()`` (added in a separate change) further
short-circuits the network re-install via the binary's native
``check-update --json`` verb.
"""
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("urllib.request.urlopen") as urlopen, \
patch("subprocess.run"), \
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
# Probe deleted — no direct GitHub API call from Python.
urlopen.assert_not_called()