From 5f1d23cfb2c5bae3c76bd36981df0e932940cf06 Mon Sep 17 00:00:00 2001 From: Francesco Bonacci Date: Mon, 22 Jun 2026 07:24:37 -0700 Subject: [PATCH] fix(computer-use): delete broken pre-install asset probe; trust the upstream installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- hermes_cli/tools_config.py | 132 ++----- tests/hermes_cli/test_install_cua_driver.py | 417 +++----------------- 2 files changed, 97 insertions(+), 452 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 741dbb267dd..dfd7c60e744 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -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 diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 27da8d22e06..e05dd42627c 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -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()