diff --git a/packages/coding-agent/src/utils/tools-manager.ts b/packages/coding-agent/src/utils/tools-manager.ts index 23a71b5a1..406456006 100644 --- a/packages/coding-agent/src/utils/tools-manager.ts +++ b/packages/coding-agent/src/utils/tools-manager.ts @@ -103,22 +103,40 @@ export function getToolPath(tool: "fd" | "rg"): string | null { return null; } -// Fetch latest release version from GitHub -async function getLatestVersion(repo: string): Promise { +// Resolve the latest release version from the release page redirect. +// The api.github.com releases endpoint counts against the anonymous API +// quota (60 requests/hour per IP), which is permanently exhausted behind +// shared egress IPs such as corporate proxies and CI runners. The web +// endpoint answers with a redirect to the tagged release at no quota cost +// and lives on the same origin as the binary download itself. +export async function getLatestVersion(repo: string): Promise { const response = await fetchWithRetry( - `https://api.github.com/repos/${repo}/releases/latest`, + `https://github.com/${repo}/releases/latest`, { headers: { "User-Agent": `${APP_NAME}-coding-agent` }, + redirect: "manual", }, { timeoutMs: NETWORK_TIMEOUT_MS }, ); - if (!response.ok) { - throw new Error(`GitHub API error: ${response.status}`); + // Only the status and headers matter here. Discard the body so the + // connection can be reused. + try { + await response.body?.cancel(); + } catch { + // Discarding the body is best-effort. } - const data = (await response.json()) as { tag_name: string }; - return data.tag_name.replace(/^v/, ""); + const location = response.status >= 300 && response.status < 400 ? response.headers.get("location") : null; + if (!location) { + throw new Error(`Failed to resolve latest ${repo} release: HTTP ${response.status} without redirect`); + } + + const tag = new URL(location, "https://github.com").pathname.split("/").pop(); + if (!tag || !location.includes("/releases/tag/")) { + throw new Error(`Failed to resolve latest ${repo} release: unexpected redirect to ${location}`); + } + return decodeURIComponent(tag).replace(/^v/, ""); } // Download a file from URL @@ -126,7 +144,7 @@ async function downloadFile(url: string, dest: string): Promise { const response = await fetchWithRetry(url, undefined, { timeoutMs: DOWNLOAD_TIMEOUT_MS }); if (!response.ok) { - throw new Error(`Failed to download: ${response.status}`); + throw new Error(`Download failed with HTTP ${response.status}: ${url}`); } if (!response.body) { @@ -246,11 +264,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise { const plat = platform(); const architecture = arch(); - // Get latest version - let version = await getLatestVersion(config.repo); - if (tool === "fd" && plat === "darwin" && architecture === "x64") { - version = "10.3.0"; - } + // fd is pinned on darwin/x64, so skip the version lookup there. + const version = + tool === "fd" && plat === "darwin" && architecture === "x64" ? "10.3.0" : await getLatestVersion(config.repo); // Get asset name for this platform const assetName = config.getAssetName(version, plat, architecture); @@ -365,9 +381,21 @@ export async function ensureTool( onStatus?.({ type: "info", message: `${config.name} installed to ${path}` }); return path; } catch (e) { + // Include the error cause chain: fetch failures surface as a bare + // "fetch failed" TypeError with the actionable detail (DNS, TLS, + // timeout) hidden in the cause. Depth-capped to guard against + // circular cause chains. + const messages: string[] = []; + for ( + let current: unknown = e, depth = 0; + current instanceof Error && depth < 5; + current = current.cause, depth++ + ) { + if (!messages.includes(current.message)) messages.push(current.message); + } onStatus?.({ type: "warning", - message: `Failed to download ${config.name}: ${e instanceof Error ? e.message : e}`, + message: `Failed to download ${config.name}: ${messages.length > 0 ? messages.join(": ") : String(e)}`, }); return undefined; } diff --git a/packages/coding-agent/test/tools-manager.test.ts b/packages/coding-agent/test/tools-manager.test.ts index fb53baad4..d57de4a2d 100644 --- a/packages/coding-agent/test/tools-manager.test.ts +++ b/packages/coding-agent/test/tools-manager.test.ts @@ -1,7 +1,7 @@ import type * as ChildProcess from "node:child_process"; import type * as Fs from "node:fs"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { ensureTool, type ToolStatus } from "../src/utils/tools-manager.ts"; +import { ensureTool, getLatestVersion, type ToolStatus } from "../src/utils/tools-manager.ts"; const originalOffline = process.env.PI_OFFLINE; @@ -24,6 +24,78 @@ vi.mock("child_process", async (importOriginal) => { afterEach(() => { if (originalOffline === undefined) delete process.env.PI_OFFLINE; else process.env.PI_OFFLINE = originalOffline; + vi.unstubAllGlobals(); +}); + +function redirectResponse(location: string): Response { + return new Response(null, { status: 302, headers: { location } }); +} + +describe("getLatestVersion", () => { + it("resolves the version from the release page redirect", async () => { + const fetchMock = vi.fn(async () => redirectResponse("https://github.com/sharkdp/fd/releases/tag/v10.4.2")); + vi.stubGlobal("fetch", fetchMock); + + await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2"); + expect(fetchMock).toHaveBeenCalledWith( + "https://github.com/sharkdp/fd/releases/latest", + expect.objectContaining({ redirect: "manual" }), + ); + }); + + it("keeps tags without a v prefix intact", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => redirectResponse("https://github.com/BurntSushi/ripgrep/releases/tag/15.2.0")), + ); + + await expect(getLatestVersion("BurntSushi/ripgrep")).resolves.toBe("15.2.0"); + }); + + it("resolves relative redirect targets", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => redirectResponse("/sharkdp/fd/releases/tag/v10.4.2")), + ); + + await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2"); + }); + + it("discards the redirect response body", async () => { + const response = new Response("", { + status: 302, + headers: { location: "https://github.com/sharkdp/fd/releases/tag/v10.4.2" }, + }); + vi.stubGlobal( + "fetch", + vi.fn(async () => response), + ); + + await expect(getLatestVersion("sharkdp/fd")).resolves.toBe("10.4.2"); + expect(response.bodyUsed).toBe(true); + }); + + it("fails clearly when the endpoint does not redirect", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("not found", { status: 404 })), + ); + + await expect(getLatestVersion("sharkdp/fd")).rejects.toThrow( + "Failed to resolve latest sharkdp/fd release: HTTP 404 without redirect", + ); + }); + + it("fails clearly when the redirect does not point at a release tag", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => redirectResponse("https://github.com/login")), + ); + + await expect(getLatestVersion("sharkdp/fd")).rejects.toThrow( + "Failed to resolve latest sharkdp/fd release: unexpected redirect to https://github.com/login", + ); + }); }); describe("ensureTool", () => { @@ -44,4 +116,27 @@ describe("ensureTool", () => { expect(consoleLog).not.toHaveBeenCalled(); consoleLog.mockRestore(); }); + + it("surfaces the error cause chain when a download fails", async () => { + delete process.env.PI_OFFLINE; + const cause = new Error("connect ETIMEDOUT 140.82.113.3:443"); + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new TypeError("fetch failed", { cause }); + }), + ); + const statuses: ToolStatus[] = []; + + const result = await ensureTool("fd", (status) => statuses.push(status)); + + expect(result).toBeUndefined(); + expect(statuses).toEqual([ + { type: "info", message: "fd not found. Downloading..." }, + { + type: "warning", + message: "Failed to download fd: fetch failed: connect ETIMEDOUT 140.82.113.3:443", + }, + ]); + }); });