diff --git a/scripts/label-open-issues.ts b/scripts/label-open-issues.ts index 8322c2889ee..02247582ad5 100644 --- a/scripts/label-open-issues.ts +++ b/scripts/label-open-issues.ts @@ -4,6 +4,7 @@ import { homedir } from "node:os"; import { dirname, join } from "node:path"; import { pathToFileURL } from "node:url"; import { isRecord } from "../src/utils.js"; +import { readBoundedResponseText as readBoundedBodyText } from "./lib/bounded-response.ts"; import { parseStrictIntegerOption } from "./lib/dev-tooling-safety.ts"; function writeStdoutLine(message = ""): void { @@ -22,6 +23,7 @@ const WORK_BATCH_SIZE = 500; const STATE_VERSION = 1; const DEFAULT_OPENAI_TIMEOUT_MS = 60_000; const OPENAI_ERROR_BODY_MAX_CHARS = 4096; +const OPENAI_RESPONSE_BODY_MAX_BYTES = 256 * 1024; const STATE_FILE_NAME = "issue-labeler-state.json"; const CONFIG_BASE_DIR = process.env.XDG_CONFIG_HOME ?? join(homedir(), ".config"); const STATE_FILE_PATH = join(CONFIG_BASE_DIR, "openclaw", STATE_FILE_NAME); @@ -322,6 +324,19 @@ async function readBoundedResponseText( return truncated ? `${text}\n[truncated]` : text; } +async function readBoundedOpenAIJson( + response: Response, + maxBytes = OPENAI_RESPONSE_BODY_MAX_BYTES, +): Promise { + const text = await readBoundedBodyText(response, "OpenAI classification", maxBytes, { + createTooLargeError: (message) => + Object.assign(new Error(message), { + code: "ETOOBIG", + }), + }); + return JSON.parse(text) as OpenAIResponse; +} + function logHeader(title: string) { writeStdoutLine(`\n${title}`); writeStdoutLine("=".repeat(title.length)); @@ -729,7 +744,7 @@ async function classifyItem( throw new Error(`OpenAI request failed (${response.status}): ${text}`); } - return (await response.json()) as OpenAIResponse; + return await readBoundedOpenAIJson(response); }, ); const rawText = extractResponseText(payload); @@ -993,6 +1008,7 @@ async function main() { export const testing = { classifyItem, normalizeClassification, + readBoundedOpenAIJson, readBoundedResponseText, resolveOpenAITimeoutMs, }; diff --git a/test/scripts/label-open-issues.test.ts b/test/scripts/label-open-issues.test.ts index 564b51dc727..993ef89e6ca 100644 --- a/test/scripts/label-open-issues.test.ts +++ b/test/scripts/label-open-issues.test.ts @@ -10,17 +10,16 @@ const labelItem = { describe("label-open-issues helpers", () => { it("classifies items from OpenAI structured response text", async () => { - const response = { - ok: true, - status: 200, - json: async () => ({ + const response = new Response( + JSON.stringify({ output_text: JSON.stringify({ category: "bug", isSupport: true, isSkillOnly: false, }), }), - } as Response; + { status: 200 }, + ); await expect( testing.classifyItem(labelItem, "issue", { @@ -55,11 +54,7 @@ describe("label-open-issues helpers", () => { }); it("times out stalled OpenAI classification body reads", async () => { - const response = { - ok: true, - status: 200, - json: () => new Promise(() => {}), - } as Response; + const response = new Response(new ReadableStream({}), { status: 200 }); const request = testing.classifyItem(labelItem, "issue", { apiKey: "test-key", model: "test-model", @@ -96,6 +91,53 @@ describe("label-open-issues helpers", () => { expect(message.length).toBeLessThan(4300); }); + it("reads bounded OpenAI classification JSON responses", async () => { + await expect( + testing.readBoundedOpenAIJson(new Response('{"output_text":"{}"}'), 1024), + ).resolves.toEqual({ output_text: "{}" }); + }); + + it("rejects oversized OpenAI classification JSON responses by content length", async () => { + let canceled = false; + const response = new Response( + new ReadableStream({ + cancel() { + canceled = true; + }, + }), + { + headers: { + "content-length": "1025", + }, + }, + ); + + await expect(testing.readBoundedOpenAIJson(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "OpenAI classification response body exceeded 1024 bytes", + }); + expect(canceled).toBe(true); + }); + + it("rejects oversized streamed OpenAI classification JSON responses", async () => { + const encoder = new TextEncoder(); + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('{"output_text":"')); + controller.enqueue(encoder.encode("x".repeat(1024))); + controller.enqueue(encoder.encode('"}')); + controller.close(); + }, + }), + ); + + await expect(testing.readBoundedOpenAIJson(response, 1024)).rejects.toMatchObject({ + code: "ETOOBIG", + message: "OpenAI classification response body exceeded 1024 bytes", + }); + }); + it("rejects invalid OpenAI classification timeout values", () => { expect(testing.resolveOpenAITimeoutMs("250")).toBe(250); expect(() => testing.resolveOpenAITimeoutMs("slow")).toThrow(