mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-24 19:35:28 -06:00
fix(openai): reject malformed video download bodies (#117227)
Co-authored-by: Peter Steinberger <steipete@macos.shared>
This commit is contained in:
committed by
GitHub
parent
443821f49b
commit
b36e9af00a
@@ -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: "<html>render failed</html>" },
|
||||
{ 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<boolean>((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<void>((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<boolean>((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<void>((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<symbol>((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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user