From b36e9af00a8c003786e4157d5ad15490cc142ecb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 2 Aug 2026 19:39:46 -0700 Subject: [PATCH] fix(openai): reject malformed video download bodies (#117227) Co-authored-by: Peter Steinberger --- .../openai/video-generation-provider.test.ts | 274 +++++++++++++++++- .../openai/video-generation-provider.ts | 11 + 2 files changed, 281 insertions(+), 4 deletions(-) diff --git a/extensions/openai/video-generation-provider.test.ts b/extensions/openai/video-generation-provider.test.ts index 12eedbda2f88..9e05cf08cf98 100644 --- a/extensions/openai/video-generation-provider.test.ts +++ b/extensions/openai/video-generation-provider.test.ts @@ -1,5 +1,7 @@ // Openai tests cover video generation provider plugin behavior. import fs from "node:fs"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; import os from "node:os"; import path from "node:path"; import { @@ -262,6 +264,7 @@ describe("openai video generation provider", () => { it("downloads an immediately completed OpenAI submission without polling it again", async () => { const release = vi.fn(async () => {}); + const cancel = vi.fn(); postMultipartRequestMock.mockResolvedValueOnce({ response: streamedJsonResponse({ id: "vid_completed", @@ -272,10 +275,18 @@ describe("openai video generation provider", () => { }), release, }); - fetchWithTimeoutMock.mockResolvedValueOnce({ - headers: new Headers({ "content-type": "video/mp4" }), - arrayBuffer: async () => Buffer.from("completed-video"), - }); + fetchWithTimeoutMock.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("completed-video")); + controller.close(); + }, + cancel, + }), + { headers: { "content-type": "video/mp4" } }, + ), + ); const result = await buildOpenAIVideoGenerationProvider().generateVideo({ provider: "openai", @@ -290,9 +301,264 @@ describe("openai video generation provider", () => { model: "sora-2", metadata: { seconds: "4", size: "720x1280", status: "completed", videoId: "vid_completed" }, }); + expect(cancel).not.toHaveBeenCalled(); expect(release).toHaveBeenCalledOnce(); }); + it.each([ + { + label: "JSON error", + contentType: "application/json", + body: JSON.stringify({ error: "not a rendered video" }), + }, + { + label: "problem JSON error", + contentType: "application/problem+json", + body: JSON.stringify({ detail: "render failed" }), + }, + { label: "plain-text error", contentType: "text/plain", body: "render failed" }, + { label: "HTML error", contentType: "text/html", body: "render failed" }, + { label: "empty video", contentType: "video/mp4", body: "" }, + ])( + "rejects a successful $label download and releases both requests", + async ({ contentType, body }) => { + const submissionRelease = vi.fn(async () => {}); + const downloadRelease = vi.fn(async () => {}); + postMultipartRequestMock.mockResolvedValueOnce({ + response: streamedJsonResponse({ + id: "vid_malformed", + model: "sora-2", + status: "completed", + }), + release: submissionRelease, + }); + fetchWithTimeoutGuardedMock.mockResolvedValueOnce({ + response: new Response(body, { headers: { "content-type": contentType } }), + finalUrl: "http://127.0.0.1:44080/v1/videos/vid_malformed/content?variant=video", + release: downloadRelease, + }); + + await expect( + buildOpenAIVideoGenerationProvider().generateVideo({ + provider: "openai", + model: "sora-2", + prompt: "Reject an invalid generated video", + cfg: { + models: { + providers: { + openai: { + baseUrl: "http://127.0.0.1:44080/v1", + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + }, + }), + ).rejects.toThrow("OpenAI generated video download: malformed video response"); + + expect(pollProviderOperationJsonMock).not.toHaveBeenCalled(); + expect(submissionRelease).toHaveBeenCalledOnce(); + expect(downloadRelease).toHaveBeenCalledOnce(); + }, + ); + + it.each([ + { mode: "public", allowPrivateNetwork: false }, + { mode: "guarded private", allowPrivateNetwork: true }, + ])( + "cancels unread malformed $mode video responses and closes their upstream socket", + async ({ allowPrivateNetwork }) => { + let notifySocketClosed: ((closed: boolean) => void) | undefined; + const socketClosed = new Promise((resolve) => { + notifySocketClosed = resolve; + }); + const server = createServer((request, response) => { + request.socket.once("close", () => notifySocketClosed?.(true)); + response.writeHead(200, { "content-type": "application/json" }); + response.write('{"error":"still streaming'); + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + try { + postMultipartRequestMock.mockResolvedValueOnce({ + response: streamedJsonResponse({ id: "vid_unread", status: "completed" }), + release: vi.fn(async () => {}), + }); + const { port } = server.address() as AddressInfo; + const upstreamUrl = `http://127.0.0.1:${port}/videos/vid_unread/content`; + const downloadRelease = vi.fn(async () => {}); + if (allowPrivateNetwork) { + fetchWithTimeoutGuardedMock.mockImplementationOnce(async () => ({ + response: await fetch(upstreamUrl), + finalUrl: upstreamUrl, + release: downloadRelease, + })); + } else { + fetchWithTimeoutMock.mockImplementationOnce(async () => await fetch(upstreamUrl)); + } + + await expect( + buildOpenAIVideoGenerationProvider().generateVideo({ + provider: "openai", + model: "sora-2", + prompt: "Reject an unending public video error response", + cfg: allowPrivateNetwork + ? { + models: { + providers: { + openai: { + baseUrl: `http://127.0.0.1:${port}/v1`, + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + } + : {}, + }), + ).rejects.toThrow("OpenAI generated video download: malformed video response"); + + await expect( + Promise.race([ + socketClosed, + new Promise((resolve) => { + setTimeout(() => resolve(false), 250); + }), + ]), + ).resolves.toBe(true); + if (allowPrivateNetwork) { + expect(fetchWithTimeoutGuardedMock).toHaveBeenCalledOnce(); + expect(downloadRelease).toHaveBeenCalledOnce(); + } else { + expect(fetchWithTimeoutGuardedMock).not.toHaveBeenCalled(); + } + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } + }, + ); + + it.each([ + { mode: "public", allowPrivateNetwork: false }, + { mode: "guarded private", allowPrivateNetwork: true }, + ])( + "rejects cloned endless $mode video errors without waiting for capture cancellation", + async ({ allowPrivateNetwork }) => { + const response = new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('{"error":"still streaming')); + }, + }), + { headers: { "content-type": "application/json" } }, + ); + const captureClone = response.clone(); + const submissionRelease = vi.fn(async () => {}); + const downloadRelease = vi.fn(async () => {}); + postMultipartRequestMock.mockResolvedValueOnce({ + response: streamedJsonResponse({ id: "vid_cloned", status: "completed" }), + release: submissionRelease, + }); + if (allowPrivateNetwork) { + fetchWithTimeoutGuardedMock.mockResolvedValueOnce({ + response, + finalUrl: "http://127.0.0.1:44080/v1/videos/vid_cloned/content", + release: downloadRelease, + }); + } else { + fetchWithTimeoutMock.mockResolvedValueOnce(response); + } + + const generation = buildOpenAIVideoGenerationProvider().generateVideo({ + provider: "openai", + model: "sora-2", + prompt: "Reject a cloned, unending video error", + cfg: allowPrivateNetwork + ? { + models: { + providers: { + openai: { + baseUrl: "http://127.0.0.1:44080/v1", + request: { allowPrivateNetwork: true }, + models: [], + }, + }, + }, + } + : {}, + }); + const captureCancellationPending = Symbol("capture cancellation pending"); + + try { + const result = await Promise.race([ + generation.then( + () => undefined, + (error: unknown) => error, + ), + new Promise((resolve) => { + setImmediate(() => resolve(captureCancellationPending)); + }), + ]); + + expect(result).not.toBe(captureCancellationPending); + expect(result).toMatchObject({ + message: "OpenAI generated video download: malformed video response", + }); + expect(submissionRelease).toHaveBeenCalledOnce(); + if (allowPrivateNetwork) { + expect(downloadRelease).toHaveBeenCalledOnce(); + } + } finally { + void captureClone.body?.cancel().catch(() => undefined); + await generation.catch(() => undefined); + } + }, + ); + + it.each(["application/json", "text/plain"])( + "keeps the malformed public video error when %s body cancellation fails", + async (contentType) => { + const cancel = vi.fn(async () => { + throw new Error("upstream cancellation failed"); + }); + const submissionRelease = vi.fn(async () => {}); + postMultipartRequestMock.mockResolvedValueOnce({ + response: streamedJsonResponse({ id: "vid_cancel_failed", status: "completed" }), + release: submissionRelease, + }); + fetchWithTimeoutMock.mockResolvedValueOnce( + new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("still streaming")); + }, + cancel, + }), + { headers: { "content-type": contentType } }, + ), + ); + + await expect( + buildOpenAIVideoGenerationProvider().generateVideo({ + provider: "openai", + model: "sora-2", + prompt: "Preserve the malformed public video response error", + cfg: {}, + }), + ).rejects.toThrow("OpenAI generated video download: malformed video response"); + + expect(cancel).toHaveBeenCalledOnce(); + expect(submissionRelease).toHaveBeenCalledOnce(); + expect(fetchWithTimeoutGuardedMock).not.toHaveBeenCalled(); + }, + ); + it("rejects generated video downloads that exceed the configured media cap", async () => { postMultipartRequestMock.mockResolvedValueOnce({ response: streamedJsonResponse({ diff --git a/extensions/openai/video-generation-provider.ts b/extensions/openai/video-generation-provider.ts index c5da2ec595da..c9977a9093d3 100644 --- a/extensions/openai/video-generation-provider.ts +++ b/extensions/openai/video-generation-provider.ts @@ -5,6 +5,7 @@ import { isProviderApiKeyConfigured } from "openclaw/plugin-sdk/provider-auth"; import { resolveApiKeyForProvider } from "openclaw/plugin-sdk/provider-auth-runtime"; import { assertOkOrThrowHttpError, + assertProviderBinaryResponseContent, createProviderOperationDeadline, createProviderOperationTimeoutResolver, executeProviderOperationWithRetry, @@ -260,6 +261,13 @@ async function downloadOpenAIVideo( dispatcherPolicy: params.dispatcherPolicy, }); try { + try { + assertProviderBinaryResponseContent(response, deadline.label, "video"); + } catch (error) { + // Capture can tee this unread body; awaiting its cancellation deadlocks before release. + void response.body?.cancel().catch(() => undefined); + throw error; + } const mimeType = normalizeOptionalString(response.headers.get("content-type")) ?? "video/mp4"; const buffer = await readResponseWithLimit(response, params.maxBytes, { timeoutMs, @@ -270,6 +278,9 @@ async function downloadOpenAIVideo( onOverflow: ({ maxBytes }) => new Error(`OpenAI generated video download exceeds ${maxBytes} bytes`), }); + if (buffer.byteLength === 0) { + throw new Error(`${deadline.label}: malformed video response`); + } return { buffer, mimeType,