From ec50cc97731e712bc305840ccffdc1d1427c7619 Mon Sep 17 00:00:00 2001 From: sergeielkin Date: Fri, 14 Aug 2026 09:22:34 +0300 Subject: [PATCH] proxy: ask GitHub for the asset name CLIProxyAPI actually publishes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two ways picking the release asset went wrong. Wrong name. CLIProxyAPI publishes its 64-bit ARM builds as `aarch64`, not `arm64`. The downloader asked for `arm64`, so it matched nothing on exactly the two platforms Nerve is most likely to run on — an Apple Silicon Mac and an ARM VPS. It surfaced during `nerve init` as "No CLIProxyAPI asset found for darwin_arm64", which reads as a missing build rather than a misnamed one, and pushed the operator toward buying a separate API key instead of fixing a string. Wrong build. Each platform ships twice, a full build and a stripped `_no-plugin` one, so the suffix is a substring of two asset names. The match had no tiebreak and took the first hit, making the winner whatever order GitHub returned — the no-plugin build, in each of the last five releases. Matching the anchored tail `_{suffix}.tar.gz` picks the full build regardless of ordering. The loop moves out of the async, network-bound `_download_binary` into `_select_asset_url` so it can be tested against an asset list rather than a live release. Also drops `"AMD64": "windows_amd64"` from `_ARCH_MAP`. The map is only read inside the `if system == "linux"` branch, and `AMD64` is a spelling `platform.machine()` uses only on Windows, where the function raises before reaching the map. Even reachable it would not have helped: the extractor opens `.tar.gz` and Windows assets are `.zip`. The comment above the map now says what the map is — Linux only — rather than implying Windows support that was never wired up. Co-Authored-By: Claude Opus 5 --- nerve/proxy/service.py | 38 ++++++++---- tests/test_proxy_asset_suffix.py | 101 +++++++++++++++++++++++++++++++ 2 files changed, 128 insertions(+), 11 deletions(-) create mode 100644 tests/test_proxy_asset_suffix.py diff --git a/nerve/proxy/service.py b/nerve/proxy/service.py index e3f3293e..e6705ce7 100644 --- a/nerve/proxy/service.py +++ b/nerve/proxy/service.py @@ -30,12 +30,18 @@ GITHUB_RELEASES_API = "https://api.github.com/repos/router-for-me/CLIProxyAPI/releases/latest" -# Map platform.machine() → GitHub release asset suffix. +# Map platform.machine() → GitHub release asset suffix, for Linux only; darwin +# is resolved directly below. Windows has no entry: the downloader only opens +# .tar.gz and the project ships Windows builds as .zip. +# +# CLIProxyAPI names its 64-bit ARM builds "aarch64", not "arm64". Asking for +# "arm64" matched no asset on an Apple Silicon Mac or an ARM VPS, and the error +# ("No CLIProxyAPI asset found for darwin_arm64") reads as a missing build +# rather than a wrong name. _ARCH_MAP: dict[str, str] = { "x86_64": "linux_amd64", - "aarch64": "linux_arm64", - "arm64": "linux_arm64", # macOS-style - "AMD64": "windows_amd64", # Windows + "aarch64": "linux_aarch64", + "arm64": "linux_aarch64", # macOS-style spelling of the same arch } @@ -45,7 +51,7 @@ def _detect_asset_suffix() -> str: machine = platform.machine() if system == "darwin": - return "darwin_arm64" if machine in ("arm64", "aarch64") else "darwin_amd64" + return "darwin_aarch64" if machine in ("arm64", "aarch64") else "darwin_amd64" if system == "linux": mapped = _ARCH_MAP.get(machine) if mapped: @@ -53,6 +59,21 @@ def _detect_asset_suffix() -> str: raise RuntimeError(f"Unsupported platform: {system}/{machine}") +def _select_asset_url(assets: list[dict[str, Any]], suffix: str) -> str | None: + """Return the download URL of the full build for ``suffix``, if published. + + A release carries both ``CLIProxyAPI__linux_aarch64.tar.gz`` and + ``CLIProxyAPI__linux_aarch64_no-plugin.tar.gz``, so the suffix is a + substring of two assets and a substring match picks whichever GitHub lists + first — lately the stripped no-plugin build. Anchor on the exact tail. + """ + wanted = f"_{suffix}.tar.gz" + for asset in assets: + if asset["name"].endswith(wanted): + return asset["browser_download_url"] + return None + + class ProxyService: """Manages the CLIProxyAPI subprocess lifecycle.""" @@ -91,12 +112,7 @@ async def _download_binary(self, dest: Path) -> None: logger.info("Latest CLIProxyAPI release: %s", tag) # Find matching asset. - asset_url: str | None = None - for asset in release.get("assets", []): - name: str = asset["name"] - if suffix in name and name.endswith(".tar.gz"): - asset_url = asset["browser_download_url"] - break + asset_url = _select_asset_url(release.get("assets", []), suffix) if not asset_url: raise RuntimeError( diff --git a/tests/test_proxy_asset_suffix.py b/tests/test_proxy_asset_suffix.py new file mode 100644 index 00000000..278469fd --- /dev/null +++ b/tests/test_proxy_asset_suffix.py @@ -0,0 +1,101 @@ +"""The release asset the proxy downloader picks out of a CLIProxyAPI release. + +Two ways this went wrong: + +*Wrong name.* CLIProxyAPI publishes 64-bit ARM builds as ``aarch64``; the +downloader asked for ``arm64`` and so found nothing on exactly the two platforms +Nerve is most likely to run on — an Apple Silicon Mac and an ARM VPS. The +failure surfaced during ``nerve init`` as "No CLIProxyAPI asset found for +darwin_arm64", which reads like the build is missing rather than misnamed, and +pushed the operator toward buying a separate API key. + +*Wrong build.* Each platform ships twice — a full build and a stripped +``_no-plugin`` one — and the suffix is a substring of both names. The match had +no tiebreak, so the winner was whichever GitHub listed first: the no-plugin +build, in every one of the last five releases. + +Names verified against release v7.2.131. +""" + +from __future__ import annotations + +import pytest + +from nerve.proxy.service import _detect_asset_suffix, _select_asset_url + +# The tar.gz builds the downloader can actually install. Windows assets ship as +# .zip, which the downloader cannot open — and _detect_asset_suffix never +# returns a windows suffix — so they are deliberately absent. +PUBLISHED = { + "darwin_aarch64", "darwin_amd64", + "linux_aarch64", "linux_amd64", + "freebsd_amd64", +} + +VERSION = "7.2.131" + + +def _asset(name: str) -> dict[str, str]: + return { + "name": name, + "browser_download_url": f"https://example.invalid/{name}", + } + + +@pytest.mark.parametrize( + "system,machine,expected", + [ + ("Darwin", "arm64", "darwin_aarch64"), + ("Darwin", "aarch64", "darwin_aarch64"), + ("Darwin", "x86_64", "darwin_amd64"), + ("Linux", "aarch64", "linux_aarch64"), + ("Linux", "arm64", "linux_aarch64"), + ("Linux", "x86_64", "linux_amd64"), + ], +) +def test_suffix_matches_a_published_asset(monkeypatch, system, machine, expected): + monkeypatch.setattr("platform.system", lambda: system) + monkeypatch.setattr("platform.machine", lambda: machine) + + suffix = _detect_asset_suffix() + assert suffix == expected + assert suffix in PUBLISHED, f"{suffix} is not a name the project publishes" + + +def test_unsupported_platform_still_raises(monkeypatch): + monkeypatch.setattr("platform.system", lambda: "SunOS") + monkeypatch.setattr("platform.machine", lambda: "sparc") + with pytest.raises(RuntimeError): + _detect_asset_suffix() + + +def test_windows_is_unsupported_not_silently_mismatched(monkeypatch): + """Windows ships .zip only, so it must fail loudly rather than half-work.""" + monkeypatch.setattr("platform.system", lambda: "Windows") + monkeypatch.setattr("platform.machine", lambda: "AMD64") + with pytest.raises(RuntimeError): + _detect_asset_suffix() + + +@pytest.mark.parametrize("suffix", ["linux_aarch64", "linux_amd64", "darwin_aarch64"]) +@pytest.mark.parametrize("no_plugin_first", [True, False]) +def test_full_build_wins_over_no_plugin_in_either_order(suffix, no_plugin_first): + full = _asset(f"CLIProxyAPI_{VERSION}_{suffix}.tar.gz") + stripped = _asset(f"CLIProxyAPI_{VERSION}_{suffix}_no-plugin.tar.gz") + pair = [stripped, full] if no_plugin_first else [full, stripped] + + # Padded with the other platforms so the match cannot succeed by luck. + assets = [ + _asset(f"CLIProxyAPI_{VERSION}_{other}.tar.gz") + for other in sorted(PUBLISHED - {suffix}) + ] + assets += pair + assets.append(_asset(f"CLIProxyAPI_{VERSION}_windows_amd64.zip")) + assets.append(_asset("checksums.txt")) + + assert _select_asset_url(assets, suffix) == full["browser_download_url"] + + +def test_missing_asset_returns_none(): + assets = [_asset(f"CLIProxyAPI_{VERSION}_linux_amd64.tar.gz")] + assert _select_asset_url(assets, "linux_aarch64") is None