Skip to content

Commit d97574a

Browse files
authored
fix(dev): bound realtime SDP answer reads
Keep the OpenAI Realtime WebRTC smoke's SDP offer request in the browser fetch path while moving the browser-side SDP answer reader into a testable helper. Reject unsafe decimal Content-Length values before acquiring a body reader and preserve streamed byte limiting for responses without a safe declared length. Proof: direct bounded-reader repro rejects unsafe content-length before getReader and cancels the body; node --check --experimental-strip-types scripts/dev/realtime-talk-live-smoke.ts; node --check --experimental-strip-types test/scripts/dev-tooling-safety.test.ts; git diff --check origin/main...HEAD; autoreview clean overall 0.84; exact-head release gate succeeded at https://github.com/openclaw/openclaw/actions/runs/27848673438.
1 parent a54a56f commit d97574a

2 files changed

Lines changed: 103 additions & 50 deletions

File tree

scripts/dev/realtime-talk-live-smoke.ts

Lines changed: 79 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,16 @@ type OpenAIHttpOptions = {
4343
timeoutMs?: number;
4444
};
4545

46+
type OpenAIRealtimeBrowserResponseReader = (
47+
response: Response,
48+
label: string,
49+
maxBytes: number,
50+
) => Promise<string>;
51+
52+
type OpenAIWebRtcSmokeGlobal = typeof globalThis & {
53+
openclawReadBoundedRealtimeResponseText?: OpenAIRealtimeBrowserResponseReader;
54+
};
55+
4656
function getEnv(name: string): string | undefined {
4757
const value = process.env[name]?.trim();
4858
return value ? value : undefined;
@@ -114,6 +124,63 @@ function compareStrings(left: string | undefined, right: string | undefined): nu
114124
return (left ?? "").localeCompare(right ?? "");
115125
}
116126

127+
async function readOpenAIRealtimeBrowserResponseText(
128+
response: Response,
129+
label: string,
130+
maxBytes: number,
131+
): Promise<string> {
132+
const responseBodyTooLargeError = (errorLabel: string, errorMaxBytes: number): Error =>
133+
new Error(`${errorLabel} response body exceeded ${errorMaxBytes} bytes`);
134+
const rawContentLength = response.headers.get("content-length");
135+
if (rawContentLength && /^\d+$/u.test(rawContentLength)) {
136+
const contentLength = Number(rawContentLength);
137+
if (!Number.isSafeInteger(contentLength) || contentLength > maxBytes) {
138+
await response.body?.cancel().catch(() => undefined);
139+
throw responseBodyTooLargeError(label, maxBytes);
140+
}
141+
}
142+
if (!response.body) {
143+
return "";
144+
}
145+
146+
const reader = response.body.getReader();
147+
const decoder = new TextDecoder();
148+
const chunks: string[] = [];
149+
let totalBytes = 0;
150+
let canceled = false;
151+
152+
try {
153+
for (;;) {
154+
const { done, value } = await reader.read();
155+
if (done) {
156+
const tail = decoder.decode();
157+
if (tail) {
158+
chunks.push(tail);
159+
}
160+
break;
161+
}
162+
163+
totalBytes += value.byteLength;
164+
if (totalBytes > maxBytes) {
165+
canceled = true;
166+
await reader.cancel().catch(() => undefined);
167+
throw responseBodyTooLargeError(label, maxBytes);
168+
}
169+
chunks.push(decoder.decode(value, { stream: true }));
170+
}
171+
} finally {
172+
if (!canceled) {
173+
reader.releaseLock();
174+
}
175+
}
176+
177+
return chunks.join("");
178+
}
179+
180+
function openAIRealtimeBrowserResponseReaderInitScript(): string {
181+
return `globalThis.openclawReadBoundedRealtimeResponseText = ${readOpenAIRealtimeBrowserResponseText.toString()};`;
182+
}
183+
117184
async function createOpenAIClientSecret(
118185
apiKey: string,
119186
options: OpenAIHttpOptions = {},
@@ -219,57 +286,14 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
219286
try {
220287
const page = await context.newPage();
221288
await page.evaluate("globalThis.__name = (fn) => fn");
289+
await page.evaluate(openAIRealtimeBrowserResponseReaderInitScript());
222290
const result = await page.evaluate(
223291
async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => {
224-
const responseBodyTooLargeError = (label: string, maxBytes: number): Error =>
225-
new Error(`${label} response body exceeded ${maxBytes} bytes`);
226-
const readBoundedTextLocal = async (
227-
response: Response,
228-
label: string,
229-
maxBytes: number,
230-
): Promise<string> => {
231-
const contentLength = Number(response.headers.get("content-length") ?? "");
232-
if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) {
233-
await response.body?.cancel().catch(() => undefined);
234-
throw responseBodyTooLargeError(label, maxBytes);
235-
}
236-
if (!response.body) {
237-
return "";
238-
}
239-
240-
const reader = response.body.getReader();
241-
const decoder = new TextDecoder();
242-
const chunks: string[] = [];
243-
let totalBytes = 0;
244-
let canceled = false;
245-
246-
try {
247-
for (;;) {
248-
const { done, value } = await reader.read();
249-
if (done) {
250-
const tail = decoder.decode();
251-
if (tail) {
252-
chunks.push(tail);
253-
}
254-
break;
255-
}
256-
257-
totalBytes += value.byteLength;
258-
if (totalBytes > maxBytes) {
259-
canceled = true;
260-
await reader.cancel().catch(() => undefined);
261-
throw responseBodyTooLargeError(label, maxBytes);
262-
}
263-
chunks.push(decoder.decode(value, { stream: true }));
264-
}
265-
} finally {
266-
if (!canceled) {
267-
reader.releaseLock();
268-
}
269-
}
270-
271-
return chunks.join("");
272-
};
292+
const readBoundedTextLocal = (globalThis as OpenAIWebRtcSmokeGlobal)
293+
.openclawReadBoundedRealtimeResponseText;
294+
if (!readBoundedTextLocal) {
295+
throw new Error("OpenAI Realtime bounded response reader was not installed");
296+
}
273297
const withBrowserTimeout = async <T>(
274298
label: string,
275299
run: (signal: AbortSignal) => Promise<T>,
@@ -327,12 +351,16 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
327351
});
328352
const offer = await peer.createOffer();
329353
await peer.setLocalDescription(offer);
354+
const offerSdp = offer.sdp;
355+
if (!offerSdp) {
356+
throw new Error("OpenAI Realtime SDP offer did not include SDP");
357+
}
330358
const answer = await withBrowserTimeout(
331359
"OpenAI Realtime SDP offer request",
332360
async (signal) => {
333361
const response = await fetch("https://api.openai.com/v1/realtime/calls", {
334362
method: "POST",
335-
body: offer.sdp,
363+
body: offerSdp,
336364
headers: {
337365
Authorization: `Bearer ${secret}`,
338366
"Content-Type": "application/sdp",
@@ -761,6 +789,7 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
761789
export const testing = {
762790
OPENAI_HTTP_RESPONSE_MAX_BYTES,
763791
createOpenAIClientSecret,
792+
readOpenAIRealtimeBrowserResponseText,
764793
readBoundedText,
765794
resolveOpenAIHttpTimeoutMs,
766795
};

test/scripts/dev-tooling-safety.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,30 @@ describe("script-specific dev tooling hardening", () => {
370370
).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`);
371371
});
372372

373+
it("rejects unsafe OpenAI realtime SDP answer content-length values before reading", async () => {
374+
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
375+
const body = {
376+
cancel: vi.fn(() => Promise.resolve()),
377+
getReader: vi.fn(() => {
378+
throw new Error("reader should not be acquired");
379+
}),
380+
};
381+
const response = {
382+
headers: new Headers({ "content-length": "9007199254740993" }),
383+
body,
384+
} as unknown as Response;
385+
386+
await expect(
387+
realtimeSmokeTesting.readOpenAIRealtimeBrowserResponseText(
388+
response,
389+
"OpenAI Realtime SDP answer",
390+
maxBytes,
391+
),
392+
).rejects.toThrow(`OpenAI Realtime SDP answer response body exceeded ${maxBytes} bytes`);
393+
expect(body.getReader).not.toHaveBeenCalled();
394+
expect(body.cancel).toHaveBeenCalledTimes(1);
395+
});
396+
373397
it("bounds OpenAI realtime smoke response body reads by streamed bytes", async () => {
374398
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
375399
const response = new Response(

0 commit comments

Comments
 (0)