diff --git a/scripts/gh-read.ts b/scripts/gh-read.ts index 6ff3aed12f3..6b6952888c1 100644 --- a/scripts/gh-read.ts +++ b/scripts/gh-read.ts @@ -10,6 +10,7 @@ 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 GITHUB_ERROR_BODY_MAX_CHARS = 4096; const DEFAULT_READ_PERMISSION_KEYS = [ "actions", "checks", @@ -190,6 +191,45 @@ async function withGitHubFetchTimeout( } } +export async function readBoundedGitHubErrorText( + response: Response, + maxChars = GITHUB_ERROR_BODY_MAX_CHARS, +): Promise { + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let text = ""; + let truncated = false; + + try { + while (text.length <= maxChars) { + const { done, value } = await reader.read(); + if (done) { + text += decoder.decode(); + break; + } + + text += decoder.decode(value, { stream: true }); + if (text.length > maxChars) { + text = text.slice(0, maxChars); + truncated = true; + break; + } + } + } finally { + if (truncated) { + await reader.cancel().catch(() => undefined); + } else { + reader.releaseLock(); + } + } + + return truncated ? `${text}\n[truncated]` : text; +} + export async function githubJson( path: string, bearerToken: string, @@ -219,7 +259,7 @@ export async function githubJson( }); if (!response.ok) { - const text = await response.text(); + const text = await readBoundedGitHubErrorText(response); fail(`${init?.method ?? "GET"} ${path} failed (${response.status}): ${text}`); } diff --git a/test/scripts/gh-read.test.ts b/test/scripts/gh-read.test.ts index e194f493683..6876c62826c 100644 --- a/test/scripts/gh-read.test.ts +++ b/test/scripts/gh-read.test.ts @@ -5,6 +5,7 @@ import { normalizeRepo, parsePermissionKeys, parseRepoArg, + readBoundedGitHubErrorText, resolveGitHubFetchTimeoutMs, } from "../../scripts/gh-read.js"; @@ -80,6 +81,19 @@ describe("gh-read helpers", () => { await expect(request).rejects.toThrow(/GitHub API GET \/app\/installations exceeded timeout/u); }); + it("bounds GitHub API error response bodies", async () => { + const tail = "tail-sentinel-should-not-appear"; + const response = new Response(`${"x".repeat(5000)}${tail}`, { + status: 500, + }); + + const text = await readBoundedGitHubErrorText(response); + + expect(text).toContain("[truncated]"); + expect(text).not.toContain(tail); + expect(text.length).toBeLessThan(4200); + }); + it("rejects invalid GitHub API timeout values", () => { expect(resolveGitHubFetchTimeoutMs("1000")).toBe(1000); expect(() => resolveGitHubFetchTimeoutMs("1s")).toThrow(