diff --git a/hermes_cli/tools_config.py b/hermes_cli/tools_config.py index 8cfb8198a46..d3afb61a035 100644 --- a/hermes_cli/tools_config.py +++ b/hermes_cli/tools_config.py @@ -689,24 +689,52 @@ def _check_cua_driver_asset_for_arch() -> bool: # Unknown arch — fail open and let the installer surface the error. return True - # Probe the latest release for an OS+arch asset before falling through to - # the upstream installer. + # 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/latest" + "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: - release = _json.loads(resp.read().decode()) - tag = release.get("tag_name", "") - assets = release.get("assets", []) + 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( - any(a in a_info.get("name", "").lower() for a in arch_names) + 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 release ({tag}) has no {system} {arch_label} asset." + 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." diff --git a/tests/hermes_cli/test_install_cua_driver.py b/tests/hermes_cli/test_install_cua_driver.py index bda86f5af13..27da8d22e06 100644 --- a/tests/hermes_cli/test_install_cua_driver.py +++ b/tests/hermes_cli/test_install_cua_driver.py @@ -108,38 +108,40 @@ class TestCheckCuaDriverAssetForArch: def test_x86_64_with_asset_returns_true(self): from hermes_cli import tools_config - release = { - "tag_name": "cua-driver-v0.1.6", + releases = [{ + "tag_name": "cua-driver-rs-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"}, + {"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(release).encode() + 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.machine", return_value="x86_64"), \ + 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 - release = { - "tag_name": "cua-driver-v0.1.6", + releases = [{ + "tag_name": "cua-driver-rs-v0.1.6", "assets": [ - {"name": "cua-driver-0.1.6-darwin-arm64.tar.gz"}, - {"name": "cua-driver.tar.gz"}, + {"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(release).encode() + 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.machine", return_value="x86_64"), \ + 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"): @@ -159,12 +161,12 @@ class TestCheckCuaDriverAssetForArch: """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"}], - } + 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(release).encode() + mock_resp.read.return_value = json.dumps(releases).encode() mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = MagicMock(return_value=False) @@ -183,12 +185,12 @@ class TestCheckCuaDriverAssetForArch: """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"}], - } + 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(release).encode() + mock_resp.read.return_value = json.dumps(releases).encode() mock_resp.__enter__ = lambda s: s mock_resp.__exit__ = MagicMock(return_value=False) @@ -346,10 +348,12 @@ class TestCheckCuaDriverAssetCrossPlatform: @staticmethod def _mock_release(asset_names): - release = {"tag_name": "cua-driver-v0.5.0", - "assets": [{"name": n} for n in 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(release).encode() + resp.read.return_value = json.dumps(releases).encode() resp.__enter__ = lambda s: s resp.__exit__ = MagicMock(return_value=False) return resp @@ -358,8 +362,8 @@ class TestCheckCuaDriverAssetCrossPlatform: from hermes_cli import tools_config resp = self._mock_release([ - "cua-driver-0.5.0-windows-amd64.zip", - "cua-driver-0.5.0-darwin-arm64.tar.gz", + "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"), \ @@ -370,7 +374,7 @@ class TestCheckCuaDriverAssetCrossPlatform: from hermes_cli import tools_config resp = self._mock_release([ - "cua-driver-0.5.0-windows-amd64.zip", + "cua-driver-rs-0.5.0-windows-x86_64.zip", ]) with patch("platform.system", return_value="Windows"), \ patch("platform.machine", return_value="ARM64"), \ @@ -385,7 +389,7 @@ class TestCheckCuaDriverAssetCrossPlatform: from hermes_cli import tools_config resp = self._mock_release([ - "cua-driver-0.5.0-linux-x86_64.tar.gz", + "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"), \ @@ -396,7 +400,7 @@ class TestCheckCuaDriverAssetCrossPlatform: from hermes_cli import tools_config resp = self._mock_release([ - "cua-driver-0.5.0-linux-aarch64.tar.gz", + "cua-driver-rs-0.5.0-linux-arm64.tar.gz", ]) with patch("platform.system", return_value="Linux"), \ patch("platform.machine", return_value="aarch64"), \ @@ -407,7 +411,7 @@ class TestCheckCuaDriverAssetCrossPlatform: from hermes_cli import tools_config resp = self._mock_release([ - "cua-driver-0.5.0-linux-x86_64.tar.gz", + "cua-driver-rs-0.5.0-linux-x86_64.tar.gz", ]) with patch("platform.system", return_value="Linux"), \ patch("platform.machine", return_value="aarch64"), \ @@ -416,3 +420,27 @@ class TestCheckCuaDriverAssetCrossPlatform: 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