mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-03 16:34:07 +00:00
fix(dev): cap Discord smoke response bodies
This commit is contained in:
@@ -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<void> {
|
||||
@@ -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<string> {
|
||||
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<unknown> {
|
||||
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<T>(params: {
|
||||
retries?: number;
|
||||
timeoutMs?: number;
|
||||
errorPrefix: string;
|
||||
responseBodyMaxBytes?: number;
|
||||
fetchImpl?: typeof fetch;
|
||||
sleepImpl?: (ms: number) => Promise<void>;
|
||||
}): Promise<T> {
|
||||
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<T>(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<T>(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<T>(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<number> {
|
||||
export const testing = {
|
||||
parseDriverMode,
|
||||
parseNumber,
|
||||
DISCORD_RESPONSE_BODY_MAX_BYTES,
|
||||
redactDiscordApiPath,
|
||||
readDiscordResponseText,
|
||||
remainingTimeoutMs,
|
||||
requestDiscordJson,
|
||||
resolveStateDir,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user