mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
fix(coding-agent): resolve fd/rg release versions without the GitHub API (#8708)
The fd/ripgrep auto-download resolved the latest version through api.github.com, which shares the anonymous API quota (60 requests/hour per IP). Behind shared egress IPs (corporate proxies, CI runners, NAT) that quota is permanently exhausted by unrelated traffic, so the download failed with 'GitHub API error: 403' on every launch while the actual release download endpoint would have worked fine. Resolve the version from the /releases/latest web redirect instead: it carries the tag in the Location header, is not subject to the API quota, and lives on the same origin as the binary download itself. The redirect body is discarded so the connection can be reused, and the pinned fd version on darwin/x64 now skips the lookup entirely. Also improve download failure diagnostics: status messages include the error cause chain so transport errors surface their actionable detail instead of a bare 'fetch failed', and HTTP download errors include the failing URL. Fixes #8594 Co-authored-by: Terminator666666 <Terminator666666@users.noreply.github.com>
This commit is contained in:
parent
2b768ba42c
commit
57e53b0d79
2 changed files with 138 additions and 15 deletions
|
|
@ -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<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 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<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) {
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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("<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", () => {
|
||||
|
|
@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue