diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index f6579dd36..f8e134435 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -4,7 +4,7 @@ ### Fixed -- Fixed direct binary updates trusting an executable that only reported the expected version. The updater now selects one exact asset from the tagged GitHub release, requires its published SHA-256 digest and size, and verifies both while streaming the download before installation. +- Fixed direct binary updates trusting an executable that only reported the expected version. The updater now selects one exact asset from the tagged GitHub release, requires its published SHA-256 digest and size, and verifies both while streaming the download before installation. GitHub release metadata requests use `GITHUB_TOKEN` or `GH_TOKEN` when available, allowing users behind an exhausted anonymous rate limit to authenticate. ## [17.1.3] - 2026-07-24 diff --git a/packages/coding-agent/src/cli/update-cli.ts b/packages/coding-agent/src/cli/update-cli.ts index 09073a487..d3f8ad791 100644 --- a/packages/coding-agent/src/cli/update-cli.ts +++ b/packages/coding-agent/src/cli/update-cli.ts @@ -10,7 +10,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { Transform } from "node:stream"; import { pipeline } from "node:stream/promises"; -import { $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; +import { $env, $which, APP_NAME, isEnoent, VERSION } from "@oh-my-pi/pi-utils"; import { $ } from "bun"; import chalk from "chalk"; import { theme } from "../modes/theme/theme"; @@ -137,15 +137,19 @@ async function getReleaseBinaryAsset( expectedVersion: string, binaryName: string, fetchImpl: Fetch = fetch, + githubToken: string | undefined = $env.GITHUB_TOKEN || $env.GH_TOKEN, ): Promise { const tag = `v${expectedVersion}`; + const headers: Record = { + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + }; + if (githubToken) headers.Authorization = `Bearer ${githubToken}`; + let response: Response; try { response = await fetchImpl(`${GITHUB_API}/repos/${REPO}/releases/tags/${encodeURIComponent(tag)}`, { - headers: { - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, + headers, signal: withTimeoutSignal(RELEASE_METADATA_TIMEOUT_MS), }); } catch (err) { @@ -154,6 +158,11 @@ async function getReleaseBinaryAsset( } throw err; } + if ((response.status === 403 && !githubToken) || response.status === 429) { + throw new Error( + "GitHub API rate limit exceeded while fetching release metadata; retry later or set GITHUB_TOKEN or GH_TOKEN", + ); + } if (!response.ok) { throw new Error(`Failed to fetch GitHub release metadata: ${response.statusText}`); } @@ -1062,6 +1071,7 @@ export async function updateViaBinaryAt( options: { binaryName?: string; fetchImpl?: Fetch; + githubToken?: string; verifyInstalledVersion?: typeof verifyInstalledVersion; } = {}, ): Promise { @@ -1072,7 +1082,7 @@ export async function updateViaBinaryAt( // would force the move-aside rename to overwrite it. pid + timestamp keeps // two forced updates in the same millisecond from colliding. const backupPath = `${targetPath}.${Date.now()}.${process.pid}.bak`; - const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl); + const asset = await getReleaseBinaryAsset(expectedVersion, binaryName, options.fetchImpl, options.githubToken); console.log(chalk.dim(`Downloading ${binaryName}…`)); await downloadVerifiedBinary({ url: asset.url, diff --git a/packages/coding-agent/src/commands/update.ts b/packages/coding-agent/src/commands/update.ts index 88462f550..34276938a 100644 --- a/packages/coding-agent/src/commands/update.ts +++ b/packages/coding-agent/src/commands/update.ts @@ -15,6 +15,12 @@ export default class Update extends Command { plugins: Flags.boolean({ char: "l", description: "Update installed plugins", default: false }), }; + static examples = [ + "omp update", + "omp update --check", + "# If GitHub rate-limits release metadata, set GITHUB_TOKEN or GH_TOKEN\n GITHUB_TOKEN=... omp update", + ]; + async run(): Promise { const { flags } = await this.parse(Update); await initTheme(); diff --git a/packages/coding-agent/test/update-cli.test.ts b/packages/coding-agent/test/update-cli.test.ts index b0c81bc0e..7235b6352 100644 --- a/packages/coding-agent/test/update-cli.test.ts +++ b/packages/coding-agent/test/update-cli.test.ts @@ -454,9 +454,11 @@ describe("update-cli release binary integrity", () => { await Bun.write(targetPath, installed); await fs.chmod(targetPath, 0o755); - const fetchImpl = async (input: string | URL | Request): Promise => { + const metadataAuthorizations: Array = []; + const fetchImpl = async (input: string | URL | Request, init?: RequestInit): Promise => { const requestUrl = String(input); if (requestUrl.startsWith("https://api.github.com/")) { + metadataAuthorizations.push(new Headers(init?.headers).get("Authorization")); return new Response( JSON.stringify( releaseAsset({ @@ -470,12 +472,38 @@ describe("update-cli release binary integrity", () => { throw new Error(`Unexpected request: ${requestUrl}`); }; - await expect(updateViaBinaryAt(targetPath, "17.1.2", { binaryName, fetchImpl })).rejects.toThrow( - "digest mismatch", - ); - expect(await Bun.file(targetPath).text()).toBe(installed); - expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755); - expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false); + const previousGitHubToken = Bun.env.GITHUB_TOKEN; + Bun.env.GITHUB_TOKEN = "test-token"; + try { + await expect( + updateViaBinaryAt(targetPath, "17.1.2", { + binaryName, + fetchImpl, + }), + ).rejects.toThrow("digest mismatch"); + expect(metadataAuthorizations).toEqual(["Bearer test-token"]); + expect(await Bun.file(targetPath).text()).toBe(installed); + expect((await fs.stat(targetPath)).mode & 0o777).toBe(0o755); + expect(await Bun.file(`${targetPath}.new`).exists()).toBe(false); + } finally { + if (previousGitHubToken === undefined) delete Bun.env.GITHUB_TOKEN; + else Bun.env.GITHUB_TOKEN = previousGitHubToken; + } + }); + + it("explains how to authenticate after an anonymous GitHub API rate limit", async () => { + const dir = await makeTempDir(); + const targetPath = path.join(dir, binaryName); + const fetchImpl = async () => new Response(null, { status: 403, statusText: "rate limit exceeded" }); + + await expect( + updateViaBinaryAt(targetPath, "17.1.2", { + binaryName, + fetchImpl, + githubToken: "", + }), + ).rejects.toThrow("retry later or set GITHUB_TOKEN or GH_TOKEN"); + expect(await Bun.file(targetPath).exists()).toBe(false); }); });