diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts index 89c47192a4f..6ff3aed12f3 100644 --- a/scripts/gh-read.ts +++ b/scripts/gh-read.ts @@ -2,12 +2,14 @@ import { execFileSync, spawnSync } from "node:child_process"; import { createPrivateKey, createSign } from "node:crypto"; import { readFileSync } from "node:fs"; import { pathToFileURL } from "node:url"; +import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts"; const APP_ID_ENV = "OPENCLAW_GH_READ_APP_ID"; const KEY_FILE_ENV = "OPENCLAW_GH_READ_PRIVATE_KEY_FILE"; const INSTALLATION_ID_ENV = "OPENCLAW_GH_READ_INSTALLATION_ID"; const PERMISSIONS_ENV = "OPENCLAW_GH_READ_PERMISSIONS"; const API_VERSION = "2022-11-28"; +const DEFAULT_GITHUB_FETCH_TIMEOUT_MS = 30_000; const DEFAULT_READ_PERMISSION_KEYS = [ "actions", "checks", @@ -32,6 +34,11 @@ type AccessTokenResponse = { token: string; }; +type GitHubJsonOptions = { + fetchImpl?: typeof fetch; + timeoutMs?: number; +}; + export function parseRepoArg(args: string[]): string | null { for (let i = 0; i < args.length; i += 1) { const arg = args[i]; @@ -91,6 +98,15 @@ export function buildReadPermissions( return permissions; } +export function resolveGitHubFetchTimeoutMs(raw = process.env.OPENCLAW_GH_READ_FETCH_TIMEOUT_MS) { + return parseStrictIntegerOption({ + fallback: DEFAULT_GITHUB_FETCH_TIMEOUT_MS, + label: "OPENCLAW_GH_READ_FETCH_TIMEOUT_MS", + min: 1, + raw, + }); +} + function isMainModule() { const entry = process.argv[1]; return entry ? import.meta.url === pathToFileURL(entry).href : false; @@ -151,32 +167,65 @@ function createAppJwt(appId: string, privateKeyPem: string) { return `${signingInput}.${base64UrlEncode(signature)}`; } -async function githubJson( +async function withGitHubFetchTimeout( + label: string, + timeoutMs: number, + run: (signal: AbortSignal) => Promise, +): Promise { + const controller = new AbortController(); + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + const error = new Error(`${label} exceeded timeout of ${timeoutMs}ms`); + reject(error); + controller.abort(error); + }, timeoutMs); + }); + try { + return await Promise.race([run(controller.signal), timeoutPromise]); + } finally { + if (timeout) { + clearTimeout(timeout); + } + } +} + +export async function githubJson( path: string, bearerToken: string, init?: { method?: "GET" | "POST"; body?: unknown; }, + options: GitHubJsonOptions = {}, ): Promise { - const response = await fetch(`https://api.github.com${path}`, { - method: init?.method ?? "GET", - headers: { - Accept: "application/vnd.github+json", - Authorization: `Bearer ${bearerToken}`, - "Content-Type": "application/json", - "User-Agent": "openclaw-gh-read", - "X-GitHub-Api-Version": API_VERSION, + const fetchImpl = options.fetchImpl ?? fetch; + const timeoutMs = options.timeoutMs ?? resolveGitHubFetchTimeoutMs(); + return await withGitHubFetchTimeout( + `GitHub API ${init?.method ?? "GET"} ${path}`, + timeoutMs, + async (signal) => { + const response = await fetchImpl(`https://api.github.com${path}`, { + method: init?.method ?? "GET", + headers: { + Accept: "application/vnd.github+json", + Authorization: `Bearer ${bearerToken}`, + "Content-Type": "application/json", + "User-Agent": "openclaw-gh-read", + "X-GitHub-Api-Version": API_VERSION, + }, + body: init?.body === undefined ? undefined : JSON.stringify(init.body), + signal, + }); + + if (!response.ok) { + const text = await response.text(); + fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); + } + + return (await response.json()) as T; }, - body: init?.body === undefined ? undefined : JSON.stringify(init.body), - }); - - if (!response.ok) { - const text = await response.text(); - fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); - } - - return (await response.json()) as T; + ); } async function resolveInstallation( diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index e5272a6515b..e194f493683 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from "vitest"; import { buildReadPermissions, + githubJson, normalizeRepo, parsePermissionKeys, parseRepoArg, + resolveGitHubFetchTimeoutMs, } from "../../scripts/gh-read.js"; describe("gh-read helpers", () => { @@ -49,4 +51,39 @@ describe("gh-read helpers", () => { "issues", ]); }); + + it("aborts stalled GitHub API fetches at the request timeout", async () => { + let signal: AbortSignal | undefined; + const request = githubJson("/app", "token", undefined, { + timeoutMs: 5, + fetchImpl: ((_url, init) => { + signal = init?.signal ?? undefined; + return new Promise(() => {}); + }) as typeof fetch, + }); + + await expect(request).rejects.toThrow(/GitHub API GET \/app exceeded timeout/u); + expect(signal?.aborted).toBe(true); + }); + + it("times out stalled GitHub API response body reads", async () => { + const response = { + ok: true, + status: 200, + json: () => new Promise(() => {}), + } as Response; + const request = githubJson("/app/installations", "token", undefined, { + timeoutMs: 5, + fetchImpl: (() => Promise.resolve(response)) as typeof fetch, + }); + + await expect(request).rejects.toThrow(/GitHub API GET \/app\/installations exceeded timeout/u); + }); + + it("rejects invalid GitHub API timeout values", () => { + expect(resolveGitHubFetchTimeoutMs("1000")).toBe(1000); + expect(() => resolveGitHubFetchTimeoutMs("1s")).toThrow( + /OPENCLAW_GH_READ_FETCH_TIMEOUT_MS must be an integer/u, + ); + }); });