Skip to content

Commit 21bcc0e

Browse files
committed
fix(scripts): cap realtime smoke responses
1 parent a5717c3 commit 21bcc0e

2 files changed

Lines changed: 202 additions & 15 deletions

File tree

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

Lines changed: 170 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ const OPENAI_REALTIME_MODEL =
1616
process.env.OPENCLAW_REALTIME_OPENAI_MODEL?.trim() || "gpt-realtime-2";
1717
const OPENAI_REALTIME_VOICE = process.env.OPENCLAW_REALTIME_OPENAI_VOICE?.trim() || "alloy";
1818
const DEFAULT_OPENAI_HTTP_TIMEOUT_MS = 30_000;
19+
const OPENAI_HTTP_RESPONSE_MAX_BYTES = 256 * 1024;
1920
const GOOGLE_REALTIME_MODEL =
2021
process.env.OPENCLAW_REALTIME_GOOGLE_MODEL?.trim() ||
2122
"gemini-2.5-flash-native-audio-preview-12-2025";
@@ -49,9 +50,104 @@ function shortError(error: unknown): string {
4950
return previewForDevToolLog(error instanceof Error ? error.message : String(error), 800);
5051
}
5152

52-
async function readBoundedText(response: Response): Promise<string> {
53-
const text = await response.text();
54-
return previewForDevToolLog(text, 600);
53+
function responseBodyTooLargeError(label: string, maxBytes: number): Error {
54+
return new Error(`${label} response body exceeded ${maxBytes} bytes`);
55+
}
56+
57+
async function readBoundedText(
58+
response: Response,
59+
label: string,
60+
maxBytes = OPENAI_HTTP_RESPONSE_MAX_BYTES,
61+
signal?: AbortSignal,
62+
): Promise<string> {
63+
const contentLength = Number(response.headers.get("content-length") ?? "");
64+
if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) {
65+
await response.body?.cancel().catch(() => undefined);
66+
throw responseBodyTooLargeError(label, maxBytes);
67+
}
68+
69+
if (!response.body) {
70+
return "";
71+
}
72+
73+
const reader = response.body.getReader();
74+
const decoder = new TextDecoder();
75+
const chunks: string[] = [];
76+
let totalBytes = 0;
77+
let canceled = false;
78+
79+
try {
80+
for (;;) {
81+
const { done, value } = await readResponseChunk(reader, label, signal, () => {
82+
canceled = true;
83+
});
84+
if (done) {
85+
const tail = decoder.decode();
86+
if (tail) {
87+
chunks.push(tail);
88+
}
89+
break;
90+
}
91+
92+
totalBytes += value.byteLength;
93+
if (totalBytes > maxBytes) {
94+
canceled = true;
95+
await reader.cancel().catch(() => undefined);
96+
throw responseBodyTooLargeError(label, maxBytes);
97+
}
98+
chunks.push(decoder.decode(value, { stream: true }));
99+
}
100+
} finally {
101+
if (!canceled) {
102+
reader.releaseLock();
103+
}
104+
}
105+
106+
return chunks.join("");
107+
}
108+
109+
async function readResponseChunk(
110+
reader: ReadableStreamDefaultReader<Uint8Array>,
111+
label: string,
112+
signal: AbortSignal | undefined,
113+
markCanceled: () => void,
114+
): Promise<ReadableStreamReadResult<Uint8Array>> {
115+
if (!signal) {
116+
return await reader.read();
117+
}
118+
if (signal.aborted) {
119+
markCanceled();
120+
await reader.cancel().catch(() => undefined);
121+
throw signal.reason instanceof Error ? signal.reason : new Error(`${label} request aborted`);
122+
}
123+
124+
let removeAbortListener: (() => void) | undefined;
125+
const abortPromise = new Promise<ReadableStreamReadResult<Uint8Array>>((_resolve, reject) => {
126+
const onAbort = () => {
127+
markCanceled();
128+
void reader.cancel().catch(() => undefined);
129+
reject(
130+
signal.reason instanceof Error ? signal.reason : new Error(`${label} request aborted`),
131+
);
132+
};
133+
signal.addEventListener("abort", onAbort, { once: true });
134+
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
135+
});
136+
137+
try {
138+
return await Promise.race([reader.read(), abortPromise]);
139+
} finally {
140+
removeAbortListener?.();
141+
}
142+
}
143+
144+
async function readBoundedJsonResponse(
145+
response: Response,
146+
label: string,
147+
signal?: AbortSignal,
148+
): Promise<Record<string, unknown>> {
149+
const text = await readBoundedText(response, label, OPENAI_HTTP_RESPONSE_MAX_BYTES, signal);
150+
return JSON.parse(text) as Record<string, unknown>;
55151
}
56152

57153
function resolveOpenAIHttpTimeoutMs(
@@ -124,12 +220,18 @@ async function createOpenAIClientSecret(
124220
});
125221
if (!response.ok) {
126222
throw new Error(
127-
`OpenAI Realtime client secret failed (${response.status}): ${await readBoundedText(
128-
response,
223+
`OpenAI Realtime client secret failed (${response.status}): ${previewForDevToolLog(
224+
await readBoundedText(
225+
response,
226+
"OpenAI Realtime client secret error",
227+
OPENAI_HTTP_RESPONSE_MAX_BYTES,
228+
signal,
229+
),
230+
600,
129231
)}`,
130232
);
131233
}
132-
return (await response.json()) as Record<string, unknown>;
234+
return await readBoundedJsonResponse(response, "OpenAI Realtime client secret", signal);
133235
},
134236
});
135237
const nested =
@@ -195,7 +297,56 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
195297
const page = await context.newPage();
196298
await page.evaluate("globalThis.__name = (fn) => fn");
197299
const result = await page.evaluate(
198-
async ({ clientSecret: secret, timeoutMs }) => {
300+
async ({ clientSecret: secret, sdpAnswerMaxBytes, timeoutMs }) => {
301+
const responseBodyTooLargeError = (label: string, maxBytes: number): Error =>
302+
new Error(`${label} response body exceeded ${maxBytes} bytes`);
303+
const readBoundedText = async (
304+
response: Response,
305+
label: string,
306+
maxBytes: number,
307+
): Promise<string> => {
308+
const contentLength = Number(response.headers.get("content-length") ?? "");
309+
if (Number.isSafeInteger(contentLength) && contentLength > maxBytes) {
310+
await response.body?.cancel().catch(() => undefined);
311+
throw responseBodyTooLargeError(label, maxBytes);
312+
}
313+
if (!response.body) {
314+
return "";
315+
}
316+
317+
const reader = response.body.getReader();
318+
const decoder = new TextDecoder();
319+
const chunks: string[] = [];
320+
let totalBytes = 0;
321+
let canceled = false;
322+
323+
try {
324+
for (;;) {
325+
const { done, value } = await reader.read();
326+
if (done) {
327+
const tail = decoder.decode();
328+
if (tail) {
329+
chunks.push(tail);
330+
}
331+
break;
332+
}
333+
334+
totalBytes += value.byteLength;
335+
if (totalBytes > maxBytes) {
336+
canceled = true;
337+
await reader.cancel().catch(() => undefined);
338+
throw responseBodyTooLargeError(label, maxBytes);
339+
}
340+
chunks.push(decoder.decode(value, { stream: true }));
341+
}
342+
} finally {
343+
if (!canceled) {
344+
reader.releaseLock();
345+
}
346+
}
347+
348+
return chunks.join("");
349+
};
199350
const withBrowserTimeout = async <T>(
200351
label: string,
201352
run: (signal: AbortSignal) => Promise<T>,
@@ -268,7 +419,11 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
268419
if (!response.ok) {
269420
throw new Error(`OpenAI Realtime SDP offer failed (${response.status})`);
270421
}
271-
return await response.text();
422+
return await readBoundedText(
423+
response,
424+
"OpenAI Realtime SDP answer",
425+
sdpAnswerMaxBytes,
426+
);
272427
},
273428
);
274429
await peer.setRemoteDescription({ type: "answer", sdp: answer });
@@ -283,7 +438,11 @@ async function smokeOpenAIWebRtc(browser: Browser, apiKey: string): Promise<Smok
283438
media?.getTracks().forEach((track) => track.stop());
284439
}
285440
},
286-
{ clientSecret, timeoutMs: openAIHttpTimeoutMs },
441+
{
442+
clientSecret,
443+
sdpAnswerMaxBytes: OPENAI_HTTP_RESPONSE_MAX_BYTES,
444+
timeoutMs: openAIHttpTimeoutMs,
445+
},
287446
);
288447
return {
289448
name: "openai-webrtc-browser",
@@ -677,6 +836,8 @@ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
677836
}
678837

679838
export const testing = {
839+
OPENAI_HTTP_RESPONSE_MAX_BYTES,
680840
createOpenAIClientSecret,
841+
readBoundedText,
681842
resolveOpenAIHttpTimeoutMs,
682843
};

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

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -161,12 +161,11 @@ describe("script-specific dev tooling hardening", () => {
161161
});
162162

163163
it("times out stalled OpenAI realtime smoke response body reads", async () => {
164-
const response = {
165-
ok: true,
166-
status: 200,
167-
statusText: "OK",
168-
json: () => new Promise(() => {}),
169-
} as Response;
164+
const response = new Response(
165+
new ReadableStream({
166+
start() {},
167+
}),
168+
);
170169
const request = realtimeSmokeTesting.createOpenAIClientSecret("test-key", {
171170
timeoutMs: 5,
172171
fetchImpl: (() => Promise.resolve(response)) as typeof fetch,
@@ -184,6 +183,33 @@ describe("script-specific dev tooling hardening", () => {
184183
);
185184
});
186185

186+
it("bounds OpenAI realtime smoke response body reads by content-length", async () => {
187+
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
188+
const response = new Response("{}", {
189+
headers: { "content-length": String(maxBytes + 1) },
190+
});
191+
192+
await expect(
193+
realtimeSmokeTesting.readBoundedText(response, "OpenAI Realtime test", maxBytes),
194+
).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`);
195+
});
196+
197+
it("bounds OpenAI realtime smoke response body reads by streamed bytes", async () => {
198+
const maxBytes = realtimeSmokeTesting.OPENAI_HTTP_RESPONSE_MAX_BYTES;
199+
const response = new Response(
200+
new ReadableStream({
201+
start(controller) {
202+
controller.enqueue(new Uint8Array(maxBytes + 1));
203+
controller.close();
204+
},
205+
}),
206+
);
207+
208+
await expect(
209+
realtimeSmokeTesting.readBoundedText(response, "OpenAI Realtime test", maxBytes),
210+
).rejects.toThrow(`OpenAI Realtime test response body exceeded ${maxBytes} bytes`);
211+
});
212+
187213
it("rejects absolute-form URLs in the Anthropic capture proxy", () => {
188214
expect(
189215
promptProbeTesting.resolveAnthropicUpstreamUrl(

0 commit comments

Comments
 (0)