diff --git a/scripts/dev/realtime-talk-live-smoke.ts b/scripts/dev/realtime-talk-live-smoke.ts index ad4eadf0dd97..a3807149a9bb 100644 --- a/scripts/dev/realtime-talk-live-smoke.ts +++ b/scripts/dev/realtime-talk-live-smoke.ts @@ -43,6 +43,16 @@ type OpenAIHttpOptions = { timeoutMs?: number; }; +type OpenAIRealtimeBrowserResponseReader = ( + response: Response, + label: string, + maxBytes: number, +) => Promise; + +type OpenAIWebRtcSmokeGlobal = typeof globalThis & { + openclawReadBoundedRealtimeResponseText?: OpenAIRealtimeBrowserResponseReader; +}; + function getEnv(name: string): string | undefined { const value = process.env[name]?.trim(); return value ? value : undefined; @@ -114,6 +124,63 @@ function compareStrings(left: string | undefined, right: string | undefined): nu return (left ?? "").localeCompare(right ?? ""); } +async function readOpenAIRealtimeBrowserResponseText( + response: Response, + label: string, + maxBytes: number, +): Promise { + const responseBodyTooLargeError = (errorLabel: string, errorMaxBytes: number): Error => + new Error(`${errorLabel} response body exceeded ${errorMaxBytes} bytes`); + const rawContentLength = response.headers.get("content-length"); + if (rawContentLength && /^\d+$/u.test(rawContentLength)) { + const contentLength = Number(rawContentLength); + if (!Number.isSafeInteger(contentLength) || contentLength > maxBytes) { + await response.body?.cancel().catch(() => undefined); + throw responseBodyTooLargeError(label, maxBytes); + } + } + if (!response.body) { + return ""; + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + const chunks: string[] = []; + let totalBytes = 0; + let canceled = false; + + try { + for (;;) { + const { done, value } = await reader.read(); + if (done) { + const tail = decoder.decode(); + if (tail) { + chunks.push(tail); + } + break; + } + + totalBytes += value.byteLength; + if (totalBytes > maxBytes) { + canceled = true; + await reader.cancel().catch(() => undefined); + throw responseBodyTooLargeError(label, maxBytes); + } + chunks.push(decoder.decode(value, { stream: true })); + } + } finally { + if (!canceled) { + reader.releaseLock(); + } + } + + return chunks.join(""); +} + +function openAIRealtimeBrowserResponseReaderInitScript(): string { + return `globalThis.openclawReadBoundedRealtimeResponseText = ${readOpenAIRealtimeBrowserResponseText.toString()};`; +} + async function createOpenAIClientSecret( apiKey: string, options: OpenAIHttpOptions = {}, @@ -219,57 +286,14 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise fn"); + await page.evaluate(openAIRealtimeBrowserResponseReaderInitScript()); const result = await page.evaluate( async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => { - const responseBodyTooLargeError = (label: string, maxBytes: number): Error => - new Error(`${label} response body exceeded ${maxBytes} bytes`); - const readBoundedTextLocal = async ( - response: Response, - label: string, - maxBytes: number, - ): Promise => { - const contentLength = Number(response.headers.get("content-length") ?? ""); - if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) { - await response.body?.cancel().catch(() => undefined); - throw responseBodyTooLargeError(label, maxBytes); - } - if (!response.body) { - return ""; - } - - const reader = response.body.getReader(); - const decoder = new TextDecoder(); - const chunks: string[] = []; - let totalBytes = 0; - let canceled = false; - - try { - for (;;) { - const { done, value } = await reader.read(); - if (done) { - const tail = decoder.decode(); - if (tail) { - chunks.push(tail); - } - break; - } - - totalBytes += value.byteLength; - if (totalBytes > maxBytes) { - canceled = true; - await reader.cancel().catch(() => undefined); - throw responseBodyTooLargeError(label, maxBytes); - } - chunks.push(decoder.decode(value, { stream: true })); - } - } finally { - if (!canceled) { - reader.releaseLock(); - } - } - - return chunks.join(""); - }; + const readBoundedTextLocal = (globalThis as OpenAIWebRtcSmokeGlobal) + .openclawReadBoundedRealtimeResponseText; + if (!readBoundedTextLocal) { + throw new Error("OpenAI Realtime bounded response reader was not installed"); + } const withBrowserTimeout = async ( label: string, run: (signal: AbortSignal) => Promise, @@ -327,12 +351,16 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise { const response = await fetch("https://api.openai.com/v1/realtime/calls", { method: "POST", - body: offer.sdp, + body: offerSdp, headers: { Authorization: `Bearer ${secret}`, "Content-Type": "application/sdp", @@ -761,6 +789,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { export const testing = { OPENAI_HTTP_RESPONSE_MAX_BYTES, createOpenAIClientSecret, + readOpenAIRealtimeBrowserResponseText, readBoundedText, resolveOpenAIHttpTimeoutMs, }; diff --git a/test/scripts/dev-tooling-safety.test.ts b/test/scripts/dev-tooling-safety.test.ts index 5dadd861ced8..a53590be1e6e 100644 --- a/test/scripts/dev-tooling-safety.test.ts +++ b/test/scripts/dev-tooling-safety.test.ts @@ -370,6 +370,30 @@ describe("script-specific dev tooling hardening", () => { ).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`); }); + it("rejects unsafe OpenAI realtime SDP answer content-length values before reading", async () => { + const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES; + const body = { + cancel: vi.fn(() => Promise.resolve()), + getReader: vi.fn(() => { + throw new Error("reader should not be acquired"); + }), + }; + const response = { + headers: new Headers({ "content-length": "9007199254740993" }), + body, + } as unknown as Response; + + await expect( + realtimeSmokeTesting.readOpenAIRealtimeBrowserResponseText( + response, + "OpenAI Realtime SDP answer", + maxBytes, + ), + ).rejects.toThrow(`OpenAI Realtime SDP answer response body exceeded ${maxBytes} bytes`); + expect(body.getReader).not.toHaveBeenCalled(); + expect(body.cancel).toHaveBeenCalledTimes(1); + }); + it("bounds OpenAI realtime smoke response body reads by streamed bytes", async () => { const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES; const response = new Response(