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
This commit is contained in:
@@ -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:
|
def install_cua_driver(upgrade: bool = False) -> bool:
|
||||||
"""Install or refresh the cua-driver binary used by Computer Use.
|
"""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_warning(" curl not found — install manually:")
|
||||||
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
|
_print_info(" https://github.com/trycua/cua/blob/main/libs/cua-driver/README.md")
|
||||||
return False
|
return False
|
||||||
|
if not _check_cua_driver_asset_for_arch():
|
||||||
|
return False
|
||||||
return _run_cua_driver_installer(label="Installing")
|
return _run_cua_driver_installer(label="Installing")
|
||||||
|
|
||||||
# Already installed and caller didn't ask to upgrade → just confirm.
|
# 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.")
|
_print_warning(" curl not found — cannot refresh cua-driver.")
|
||||||
return bool(binary)
|
return bool(binary)
|
||||||
|
|
||||||
|
if not _check_cua_driver_asset_for_arch():
|
||||||
|
return bool(binary)
|
||||||
|
|
||||||
if binary:
|
if binary:
|
||||||
# Show before/after version when we have a baseline. Best-effort.
|
# Show before/after version when we have a baseline. Best-effort.
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -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
|
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)``
|
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).
|
fix for the "we only pulled cua-driver once on enable" complaint).
|
||||||
* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:
|
* Preserve original ``upgrade=False`` behaviour for the toolset-enable flow:
|
||||||
skip if installed, install otherwise, warn on non-macOS.
|
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 __future__ import annotations
|
||||||
|
|
||||||
from unittest.mock import patch
|
import json
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
|
||||||
class TestInstallCuaDriverUpgrade:
|
class TestInstallCuaDriverUpgrade:
|
||||||
def test_upgrade_on_non_macos_is_silent_noop(self):
|
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
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch.object(tools_config, "_print_warning") as warn, \
|
with patch.object(tools_config, "_print_warning") as warn, \
|
||||||
@@ -30,8 +30,6 @@ class TestInstallCuaDriverUpgrade:
|
|||||||
warn.assert_not_called()
|
warn.assert_not_called()
|
||||||
|
|
||||||
def test_non_upgrade_on_non_macos_warns(self):
|
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
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch.object(tools_config, "_print_warning") as warn, \
|
with patch.object(tools_config, "_print_warning") as warn, \
|
||||||
@@ -40,43 +38,36 @@ class TestInstallCuaDriverUpgrade:
|
|||||||
warn.assert_called()
|
warn.assert_called()
|
||||||
|
|
||||||
def test_upgrade_on_macos_with_binary_runs_installer(self):
|
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
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch("platform.system", return_value="Darwin"), \
|
with patch("platform.system", return_value="Darwin"), \
|
||||||
patch.object(tools_config.shutil, "which",
|
patch.object(tools_config.shutil, "which",
|
||||||
side_effect=lambda n: "/usr/local/bin/" + n
|
side_effect=lambda n: "/usr/local/bin/" + n
|
||||||
if n in {"cua-driver", "curl"} else None), \
|
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",
|
patch.object(tools_config, "_run_cua_driver_installer",
|
||||||
return_value=True) as runner, \
|
return_value=True) as runner, \
|
||||||
patch("subprocess.run"):
|
patch("subprocess.run"):
|
||||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||||
runner.assert_called_once()
|
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
|
kwargs = runner.call_args.kwargs
|
||||||
assert kwargs.get("verbose") is False
|
assert kwargs.get("verbose") is False
|
||||||
|
|
||||||
def test_upgrade_on_macos_without_binary_runs_installer(self):
|
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
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch("platform.system", return_value="Darwin"), \
|
with patch("platform.system", return_value="Darwin"), \
|
||||||
patch.object(tools_config.shutil, "which",
|
patch.object(tools_config.shutil, "which",
|
||||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
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",
|
patch.object(tools_config, "_run_cua_driver_installer",
|
||||||
return_value=True) as runner:
|
return_value=True) as runner:
|
||||||
assert tools_config.install_cua_driver(upgrade=True) is True
|
assert tools_config.install_cua_driver(upgrade=True) is True
|
||||||
runner.assert_called_once()
|
runner.assert_called_once()
|
||||||
|
|
||||||
def test_non_upgrade_on_macos_with_binary_skips_install(self):
|
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
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch("platform.system", return_value="Darwin"), \
|
with patch("platform.system", return_value="Darwin"), \
|
||||||
@@ -89,27 +80,133 @@ class TestInstallCuaDriverUpgrade:
|
|||||||
runner.assert_not_called()
|
runner.assert_not_called()
|
||||||
|
|
||||||
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
|
def test_non_upgrade_on_macos_without_binary_runs_installer(self):
|
||||||
"""Original fresh-install path must still work."""
|
|
||||||
from hermes_cli import tools_config
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
with patch("platform.system", return_value="Darwin"), \
|
with patch("platform.system", return_value="Darwin"), \
|
||||||
patch.object(tools_config.shutil, "which",
|
patch.object(tools_config.shutil, "which",
|
||||||
side_effect=lambda n: "/usr/bin/curl" if n == "curl" else None), \
|
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",
|
patch.object(tools_config, "_run_cua_driver_installer",
|
||||||
return_value=True) as runner:
|
return_value=True) as runner:
|
||||||
assert tools_config.install_cua_driver(upgrade=False) is True
|
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
|
class TestCheckCuaDriverAssetForArch:
|
||||||
the current install state, not raise."""
|
def test_arm64_always_returns_true(self):
|
||||||
from hermes_cli import tools_config
|
from hermes_cli import tools_config
|
||||||
|
|
||||||
# cua-driver present, curl missing.
|
with patch("platform.machine", return_value="arm64"):
|
||||||
def _which(name):
|
assert tools_config._check_cua_driver_asset_for_arch() is True
|
||||||
return "/usr/local/bin/cua-driver" if name == "cua-driver" else None
|
|
||||||
|
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"), \
|
with patch("platform.system", return_value="Darwin"), \
|
||||||
patch.object(tools_config.shutil, "which", side_effect=_which), \
|
patch.object(tools_config.shutil, "which",
|
||||||
patch.object(tools_config, "_print_warning"):
|
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
|
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()
|
||||||
|
|||||||
Reference in New Issue
Block a user