From 0eeedcb41dbd8fffd60a91a1cb4c977a25c829de Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Mon, 29 Jun 2026 12:57:14 +0800
Subject: [PATCH] fix(xai): bound video response body reads
(cherry picked from commit 46e119074ef0f953b82401d08dd76e62031cf78d)
---
.../xai/video-generation-provider.test.ts | 151 ++++++++++++++++--
extensions/xai/video-generation-provider.ts | 10 +-
2 files changed, 149 insertions(+), 12 deletions(-)
diff --git a/extensions/xai/video-generation-provider.test.ts b/extensions/xai/video-generation-provider.test.ts
index 12350bcfd18d..80d6bcc234e4 100644
--- a/extensions/xai/video-generation-provider.test.ts
+++ b/extensions/xai/video-generation-provider.test.ts
@@ -4,9 +4,10 @@ import {
installProviderHttpMockCleanup,
} from "openclaw/plugin-sdk/provider-http-test-mocks";
import { expectExplicitVideoGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
-import { beforeAll, describe, expect, it, vi } from "vitest";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
+const { postJsonRequestMock, fetchWithTimeoutMock, readProviderJsonResponseMock } =
+ getProviderHttpMocks();
let buildXaiVideoGenerationProvider: typeof import("./video-generation-provider.js").buildXaiVideoGenerationProvider;
@@ -16,6 +17,51 @@ beforeAll(async () => {
installProviderHttpMockCleanup();
+beforeEach(() => {
+ readProviderJsonResponseMock.mockImplementation(async (response: Response, label: string) => {
+ const maxBytes = 16 * 1024 * 1024;
+ if (!response.body) {
+ try {
+ return (await response.json()) as T;
+ } catch (cause) {
+ throw new Error(`${label}: malformed JSON response`, { cause });
+ }
+ }
+
+ const reader = response.body.getReader();
+ const chunks: Uint8Array[] = [];
+ let totalBytes = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ totalBytes += value.byteLength;
+ if (totalBytes > maxBytes) {
+ await reader.cancel();
+ throw new Error(`${label}: JSON response exceeds ${maxBytes} bytes`);
+ }
+ chunks.push(value);
+ }
+ } finally {
+ reader.releaseLock();
+ }
+
+ const body = new Uint8Array(totalBytes);
+ let offset = 0;
+ for (const chunk of chunks) {
+ body.set(chunk, offset);
+ offset += chunk.byteLength;
+ }
+ try {
+ return JSON.parse(new TextDecoder().decode(body)) as T;
+ } catch (cause) {
+ throw new Error(`${label}: malformed JSON response`, { cause });
+ }
+ });
+});
+
function requirePostJsonCall(index = 0): {
url?: string;
body?: Record;
@@ -52,7 +98,7 @@ function requireFetchInitCall(index: number): {
};
}
-function streamedVideoResponse(bytes: string): Response {
+function streamedVideoResponse(bytes: string, contentType = "video/mp4"): Response {
return new Response(
new ReadableStream({
start(controller) {
@@ -60,10 +106,56 @@ function streamedVideoResponse(bytes: string): Response {
controller.close();
},
}),
- { headers: { "content-type": "video/mp4" } },
+ { headers: { "content-type": contentType } },
);
}
+function streamedJsonResponse(payload: unknown): Response {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
+ controller.close();
+ },
+ }),
+ { headers: { "content-type": "application/json" } },
+ );
+}
+
+// Drives an unbounded JSON body (>16 MiB, no Content-Length) so the bounded
+// reader has to cancel the stream instead of buffering it all. The 1 MiB
+// chunks are emitted lazily on `pull`, and a hard ceiling guards the test from
+// hanging if the reader ever fails to cancel.
+function oversizedJsonResponse(): {
+ response: Response;
+ state: { canceled: boolean; enqueuedBytes: number };
+} {
+ const state = { canceled: false, enqueuedBytes: 0 };
+ const chunk = 1024 * 1024;
+ // 64 MiB ceiling: 4x the 16 MiB cap, so the bounded reader must cancel long
+ // before we run out of chunks.
+ const maxChunks = 64;
+ let emitted = 0;
+ const response = new Response(
+ new ReadableStream({
+ pull(controller) {
+ if (emitted >= maxChunks) {
+ controller.close();
+ return;
+ }
+ emitted += 1;
+ state.enqueuedBytes += chunk;
+ controller.enqueue(new Uint8Array(chunk));
+ },
+ cancel() {
+ state.canceled = true;
+ },
+ }),
+ { headers: { "content-type": "application/json" } },
+ );
+ return { response, state };
+}
+
describe("xai video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildXaiVideoGenerationProvider());
@@ -148,6 +240,49 @@ describe("xai video generation provider", () => {
).rejects.toThrow("xAI generated video download exceeds 1 bytes");
});
+ it("bounds an unbounded successful xAI create JSON body and cancels the stream", async () => {
+ const oversized = oversizedJsonResponse();
+ postJsonRequestMock.mockResolvedValue({
+ response: oversized.response,
+ release: vi.fn(async () => {}),
+ });
+
+ const provider = buildXaiVideoGenerationProvider();
+ await expect(
+ provider.generateVideo({
+ provider: "xai",
+ model: "grok-imagine-video",
+ prompt: "oversized create body",
+ cfg: {},
+ }),
+ ).rejects.toThrow("xAI video generation response: JSON response exceeds 16777216 bytes");
+ // The bounded reader cancelled the stream rather than buffering the whole
+ // body, and stopped reading well before the 64 MiB ceiling.
+ expect(oversized.state.canceled).toBe(true);
+ expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
+ });
+
+ it("bounds an unbounded successful xAI poll JSON body and cancels the stream", async () => {
+ const oversized = oversizedJsonResponse();
+ postJsonRequestMock.mockResolvedValue({
+ response: streamedJsonResponse({ request_id: "req_poll_oversized" }),
+ release: vi.fn(async () => {}),
+ });
+ fetchWithTimeoutMock.mockResolvedValueOnce(oversized.response);
+
+ const provider = buildXaiVideoGenerationProvider();
+ await expect(
+ provider.generateVideo({
+ provider: "xai",
+ model: "grok-imagine-video",
+ prompt: "oversized poll body",
+ cfg: {},
+ }),
+ ).rejects.toThrow("xAI video generation response: JSON response exceeds 16777216 bytes");
+ expect(oversized.state.canceled).toBe(true);
+ expect(oversized.state.enqueuedBytes).toBeLessThan(64 * 1024 * 1024);
+ });
+
it("wraps malformed successful xAI create responses", async () => {
postJsonRequestMock.mockResolvedValue({
response: {
@@ -169,11 +304,9 @@ describe("xai video generation provider", () => {
it("wraps non-JSON successful xAI create responses", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => {
- throw new SyntaxError("Unexpected token < in JSON");
- },
- },
+ response: new Response("Unexpected token < in JSON", {
+ headers: { "content-type": "text/html" },
+ }),
release: vi.fn(async () => {}),
});
diff --git a/extensions/xai/video-generation-provider.ts b/extensions/xai/video-generation-provider.ts
index dffeffe2d10d..7ea62d6de260 100644
--- a/extensions/xai/video-generation-provider.ts
+++ b/extensions/xai/video-generation-provider.ts
@@ -9,6 +9,7 @@ import {
fetchProviderDownloadResponse,
fetchProviderOperationResponse,
postJsonRequest,
+ readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
@@ -69,9 +70,12 @@ type VideoGenerationSourceInput = {
async function readXaiVideoJson(response: Response): Promise> {
let payload: unknown;
try {
- payload = await response.json();
- } catch {
- throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);
+ payload = await readProviderJsonResponse(response, "xAI video generation response");
+ } catch (error) {
+ if (error instanceof Error && error.message.endsWith(": malformed JSON response")) {
+ throw new Error(XAI_VIDEO_MALFORMED_RESPONSE, { cause: error });
+ }
+ throw error;
}
if (!isRecord(payload)) {
throw new Error(XAI_VIDEO_MALFORMED_RESPONSE);