From 4a2fa77c15f6a645289bffd4ac4eecb08f047366 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Wed, 13 May 2026 05:06:25 +0800 Subject: [PATCH] fix(cli): pre-check CUA release asset for Intel macOS before install The upstream cua-driver installer resolves the latest release and attempts to download an architecture-specific asset. When the release only ships arm64 builds (as of v0.1.6), the installer fails with a raw 404 on Intel macOS with no clear path forward. Add _check_cua_driver_asset_for_arch() that probes the GitHub Releases API before running the installer. If the latest release has no x86_64/amd64 asset, print a clear warning and link to the upstream issue. On arm64 or API failure, fail open and let the installer proceed as before. Fixes #24530 --- hermes_cli/tools_config.py | 54 +++++++ tests/hermes_cli/test_install_cua_driver.py | 153 ++++++++++++++++---- 2 files changed, 179 insertions(+), 28 deletions(-) diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 89771291b20..8fd7cd4ea5e 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -550,6 +550,55 @@ def _pip_install( ) + +def _check_cua_driver_asset_for_arch() -> bool: + """Check whether the latest CUA release ships an asset for this architecture. + + 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. + """ + import platform as _plat + import urllib.request + + machine = _plat.machine() # "x86_64" or "arm64" + if machine == "arm64": + # arm64 (Apple Silicon) assets are always published. + return True + + # x86_64 / Intel — probe the latest release for an architecture-specific + # asset before falling through to the upstream installer. + api_url = ( + "https://api.github.com/repos/trycua/cua/releases/latest" + ) + try: + req = urllib.request.Request(api_url, headers={"Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(req, timeout=10) as resp: + release = _json.loads(resp.read().decode()) + tag = release.get("tag_name", "") + assets = release.get("assets", []) + arch_names = {"x86_64", "amd64"} + has_asset = any( + any(a in a_info.get("name", "").lower() for a in arch_names) + for a_info in assets + ) + if not has_asset: + _print_warning( + f" Latest CUA release ({tag}) has no Intel (x86_64) asset." + ) + _print_info( + " CUA Driver currently only ships Apple Silicon builds." + ) + _print_info( + " See: https://github.com/trycua/cua/issues/1493" + ) + return False + except Exception: + # Network / API failure — proceed and let the installer handle it. + pass + return True + + def install_cua_driver(upgrade: bool = False) -> bool: """Install or refresh the cua-driver binary used by Computer Use. @@ -587,6 +636,8 @@ def install_cua_driver(upgrade: bool = False) -> bool: _print_warning(" curl 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 return _run_cua_driver_installer(label="Installing") # Already installed and caller didn't ask to upgrade → just confirm. @@ -609,6 +660,9 @@ def install_cua_driver(upgrade: bool = False) -> bool: _print_warning(" curl not found — cannot refresh cua-driver.") return bool(binary) + if not _check_cua_driver_asset_for_arch(): + return bool(binary) + if binary: # Show before/after version when we have a baseline. Best-effort. try: diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index 6cd50261694..aa7fd68fec9 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -1,4 +1,4 @@ -"""Tests for ``install_cua_driver`` upgrade semantics. +"""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)`` @@ -10,18 +10,18 @@ must: 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 non-macOS. +* Pre-check architecture compatibility before downloading to avoid raw 404 + errors on Intel macOS when the upstream release lacks x86_64 assets. """ from __future__ import annotations -from unittest.mock import patch +import json +from unittest.mock import MagicMock, patch class TestInstallCuaDriverUpgrade: def test_upgrade_on_non_macos_is_silent_noop(self): - """``hermes update`` calls install_cua_driver(upgrade=True) for every - user. On Linux/Windows it must return False without printing the - "macOS-only; skipping" warning that the toolset-enable path emits.""" from hermes_cli import tools_config with patch.object(tools_config, "_print_warning") as warn, \ @@ -30,8 +30,6 @@ class TestInstallCuaDriverUpgrade: warn.assert_not_called() def test_non_upgrade_on_non_macos_warns(self): - """The toolset-enable path (upgrade=False) should still warn loudly - when the user tries to enable Computer Use on a non-macOS host.""" from hermes_cli import tools_config with patch.object(tools_config, "_print_warning") as warn, \ @@ -40,43 +38,36 @@ class TestInstallCuaDriverUpgrade: warn.assert_called() def test_upgrade_on_macos_with_binary_runs_installer(self): - """When cua-driver is already on PATH and upgrade=True, we must - re-run the upstream installer (this is the fix for the bug report). - """ 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() - # Refresh path uses non-verbose mode so we don't re-print the - # "grant macOS permissions" block on every `hermes update`. kwargs = runner.call_args.kwargs assert kwargs.get("verbose") is False def test_upgrade_on_macos_without_binary_runs_installer(self): - """upgrade=True with cua-driver missing must still trigger an - install — equivalent to a fresh install. (Don't silently no-op.)""" 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): - """Original toolset-enable behaviour: cua-driver already installed - + upgrade=False → confirm and return without re-running installer. - This is the behaviour that ``hermes tools`` (re)enable depends on, - so the new helper must not regress it.""" from hermes_cli import tools_config with patch("platform.system", return_value="Darwin"), \ @@ -89,27 +80,133 @@ class TestInstallCuaDriverUpgrade: runner.assert_not_called() def test_non_upgrade_on_macos_without_binary_runs_installer(self): - """Original fresh-install path must still work.""" 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 - runner.assert_called_once() - def test_upgrade_without_curl_does_not_crash(self): - """If curl isn't on PATH we can't refresh — must warn and return - the current install state, not raise.""" + +class TestCheckCuaDriverAssetForArch: + def test_arm64_always_returns_true(self): from hermes_cli import tools_config - # cua-driver present, curl missing. - def _which(name): - return "/usr/local/bin/cua-driver" if name == "cua-driver" else None + with 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 + + release = { + "tag_name": "cua-driver-v0.1.6", + "assets": [ + {"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}, + {"name": "cua-driver-0.1.6-darwin-x86_64.tar.gz"}, + ], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(release).encode() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with 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 + + release = { + "tag_name": "cua-driver-v0.1.6", + "assets": [ + {"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}, + {"name": "cua-driver.tar.gz"}, + ], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(release).encode() + mock_resp.__enter__ = lambda s: s + mock_resp.__exit__ = MagicMock(return_value=False) + + with 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 + + release = { + "tag_name": "cua-driver-v0.1.6", + "assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(release).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=_which), \ - patch.object(tools_config, "_print_warning"): + 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 + + release = { + "tag_name": "cua-driver-v0.1.6", + "assets": [{"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(release).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()