Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 42 additions & 14 deletions packages/coding-agent/src/utils/tools-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -103,30 +103,48 @@ export function getToolPath(tool: "fd" | "rg"): string | null {
return null;
}

// Fetch latest release version from GitHub
async function getLatestVersion(repo: string): Promise<string> {
// 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<string> {
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 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 data = (await response.json()) as { tag_name: string };
return data.tag_name.replace(/^v/, "");
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
async function downloadFile(url: string, dest: string): Promise<void> {
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) {
Expand Down Expand Up @@ -246,11 +264,9 @@ async function downloadTool(tool: "fd" | "rg"): Promise<string> {
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);
Expand Down Expand Up @@ -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;
}
Expand Down
97 changes: 96 additions & 1 deletion packages/coding-agent/test/tools-manager.test.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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("<html></html>", {
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", () => {
Expand All @@ -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",
},
]);
});
});