diff --git a/scripts/dev/discord-acp-plain-language-smoke.ts b/scripts/dev/discord-acp-plain-language-smoke.ts index 194845dbac7..05979ddc0f7 100644 --- a/scripts/dev/discord-acp-plain-language-smoke.ts +++ b/scripts/dev/discord-acp-plain-language-smoke.ts @@ -15,6 +15,7 @@ import { redactForDevToolLog, redactHomePath, } from "../lib/dev-tooling-safety.ts"; +import { readBoundedResponseText } from "../lib/bounded-response.ts"; function writeStdoutLine(message: string): void { process.stdout.write(`${message}\n`); @@ -135,6 +136,7 @@ type FailureResult = { const DISCORD_API_BASE = "https://discord.com/api/v10"; const DEFAULT_REQUEST_TIMEOUT_MS = 30_000; const DEFAULT_OPENCLAW_CLI_TIMEOUT_MS = 60_000; +const DISCORD_RESPONSE_BODY_MAX_BYTES = 1024 * 1024; const WEBHOOK_CLEANUP_TIMEOUT_MS = 10_000; function sleep(ms: number): Promise { @@ -185,6 +187,41 @@ function parseNumber(value: string | undefined, fallback: number, label: string) return parseStrictIntegerOption({ fallback, label, min: 1, raw: value }); } +function createDiscordResponseTooLargeError(message: string): Error { + const error = new Error(message); + (error as NodeJS.ErrnoException).code = "ETOOBIG"; + return error; +} + +function isTooLargeError(error: unknown): boolean { + return (error as NodeJS.ErrnoException | undefined)?.code === "ETOOBIG"; +} + +async function readDiscordResponseText(params: { + response: Response; + label: string; + signal: AbortSignal; + maxBytes: number; +}): Promise { + return await readBoundedResponseText(params.response, params.label, params.maxBytes, { + createTooLargeError: createDiscordResponseTooLargeError, + signal: params.signal, + }); +} + +async function readDiscordResponseJson(params: { + response: Response; + label: string; + signal: AbortSignal; + maxBytes: number; +}): Promise { + const text = await readDiscordResponseText(params); + if (!text) { + return {}; + } + return JSON.parse(text); +} + function resolveStateDir(): string { const override = process.env.OPENCLAW_STATE_DIR?.trim(); if (override) { @@ -458,12 +495,14 @@ async function requestDiscordJson(params: { retries?: number; timeoutMs?: number; errorPrefix: string; + responseBodyMaxBytes?: number; fetchImpl?: typeof fetch; sleepImpl?: (ms: number) => Promise; }): Promise { const retries = params.retries ?? 6; const fetchImpl = params.fetchImpl ?? fetch; const sleepImpl = params.sleepImpl ?? sleep; + const responseBodyMaxBytes = params.responseBodyMaxBytes ?? DISCORD_RESPONSE_BODY_MAX_BYTES; const deadlineMs = Date.now() + (params.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS); const timeoutError = () => new Error( @@ -488,7 +527,17 @@ async function requestDiscordJson(params: { if (response.status === 429) { const bodyTimeoutMs = remainingTimeoutMs(deadlineMs); const body = (await withTimeout({ - operation: response.json().catch(() => ({})), + operation: readDiscordResponseJson({ + response, + label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`, + signal: controller.signal, + maxBytes: responseBodyMaxBytes, + }).catch((error) => { + if (isTooLargeError(error)) { + throw error; + } + return {}; + }), timeoutMs: bodyTimeoutMs, timeoutError, onTimeout: () => controller.abort(), @@ -501,7 +550,12 @@ async function requestDiscordJson(params: { if (!response.ok) { const bodyTimeoutMs = remainingTimeoutMs(deadlineMs); const text = await withTimeout({ - operation: response.text().catch(() => ""), + operation: readDiscordResponseText({ + response, + label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`, + signal: controller.signal, + maxBytes: responseBodyMaxBytes, + }), timeoutMs: bodyTimeoutMs, timeoutError, onTimeout: () => controller.abort(), @@ -519,7 +573,12 @@ async function requestDiscordJson(params: { const bodyTimeoutMs = remainingTimeoutMs(deadlineMs); return (await withTimeout({ - operation: response.json(), + operation: readDiscordResponseJson({ + response, + label: `${params.errorPrefix} ${params.method} ${redactDiscordApiPath(params.path)}`, + signal: controller.signal, + maxBytes: responseBodyMaxBytes, + }), timeoutMs: bodyTimeoutMs, timeoutError, onTimeout: () => controller.abort(), @@ -988,7 +1047,9 @@ async function main(): Promise { export const testing = { parseDriverMode, parseNumber, + DISCORD_RESPONSE_BODY_MAX_BYTES, redactDiscordApiPath, + readDiscordResponseText, remainingTimeoutMs, requestDiscordJson, resolveStateDir, diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 5f8152fbda4..ec959e41ddc 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -97,12 +97,12 @@ describe("script-specific dev tooling hardening", () => { }); it("times out stalled Discord smoke response body reads", async () => { - const response = { - ok: true, - status: 200, - statusText: "OK", - json: () => new Promise(() => {}), - } as Response; + const response = new Response( + new ReadableStream({ + start() {}, + }), + { status: 200, statusText: "OK" }, + ); const request = discordSmokeTesting.requestDiscordJson({ method: "GET", path: "/channels/123/messages", @@ -118,6 +118,51 @@ describe("script-specific dev tooling hardening", () => { ); }); + it("bounds Discord smoke response bodies by content-length", async () => { + const response = new Response("{}", { + headers: { "content-length": "6" }, + }); + const request = discordSmokeTesting.requestDiscordJson({ + method: "GET", + path: "/channels/123/messages", + headers: {}, + retries: 0, + timeoutMs: 50, + responseBodyMaxBytes: 5, + errorPrefix: "Discord API", + fetchImpl: (() => Promise.resolve(response)) as typeof fetch, + }); + + await expect(request).rejects.toThrow( + "Discord API GET /channels/123/messages response body exceeded 5 bytes", + ); + }); + + it("bounds Discord smoke response bodies by streamed bytes", async () => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new Uint8Array(6)); + controller.close(); + }, + }), + ); + const request = discordSmokeTesting.requestDiscordJson({ + method: "GET", + path: "/channels/123/messages", + headers: {}, + retries: 0, + timeoutMs: 50, + responseBodyMaxBytes: 5, + errorPrefix: "Discord API", + fetchImpl: (() => Promise.resolve(response)) as typeof fetch, + }); + + await expect(request).rejects.toThrow( + "Discord API GET /channels/123/messages response body exceeded 5 bytes", + ); + }); + it("does not launch another Discord smoke retry after the timeout budget expires", async () => { let calls = 0; const response = {