From 3b5fd979face10c94d5beee5bbd1d68be426a35f Mon Sep 17 00:00:00 2001
From: Alix-007
Date: Fri, 26 Jun 2026 01:47:07 +0800
Subject: [PATCH] fix(byteplus): bound video-generation success response
(#96606)
(cherry picked from commit 7b5ee739eb22b53022a5674a3bf674dc0e6c33d5)
---
.../video-generation-provider.test.ts | 252 ++++++++++++++----
.../byteplus/video-generation-provider.ts | 18 +-
2 files changed, 214 insertions(+), 56 deletions(-)
diff --git a/extensions/byteplus/video-generation-provider.test.ts b/extensions/byteplus/video-generation-provider.test.ts
index acac8b50e868..112439926ac3 100644
--- a/extensions/byteplus/video-generation-provider.test.ts
+++ b/extensions/byteplus/video-generation-provider.test.ts
@@ -1,12 +1,70 @@
// Byteplus tests cover video generation provider plugin behavior.
-import {
- getProviderHttpMocks,
- 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 { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
-const { postJsonRequestMock, fetchWithTimeoutMock } = getProviderHttpMocks();
+// Submit/poll transport is mocked locally so each test can inject the BytePlus task JSON
+// bodies, while readProviderJsonResponse is kept REAL (via importActual) so the byte-bounded
+// reader actually streams and cancels oversized bodies under test instead of a stub.
+const { postJsonRequestMock, fetchWithTimeoutMock, resolveApiKeyForProviderMock } = vi.hoisted(
+ () => ({
+ postJsonRequestMock: vi.fn(),
+ fetchWithTimeoutMock: vi.fn(),
+ resolveApiKeyForProviderMock: vi.fn(async () => ({ apiKey: "provider-key" })),
+ }),
+);
+
+vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
+ resolveApiKeyForProvider: resolveApiKeyForProviderMock,
+}));
+
+vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
+ const actual = await importActual();
+ const resolveTimeoutMs = (timeoutMs: unknown): number =>
+ typeof timeoutMs === "function" ? (timeoutMs() as number) : ((timeoutMs as number) ?? 60_000);
+ return {
+ // REAL byte-bounded JSON reader under test — not stubbed.
+ readProviderJsonResponse: actual.readProviderJsonResponse,
+ postJsonRequest: postJsonRequestMock,
+ fetchProviderOperationResponse: async (params: {
+ url: string;
+ init?: RequestInit;
+ timeoutMs?: unknown;
+ fetchFn: typeof fetch;
+ }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
+ fetchProviderDownloadResponse: async (params: {
+ url: string;
+ init?: RequestInit;
+ timeoutMs?: unknown;
+ fetchFn: typeof fetch;
+ }) => fetchWithTimeoutMock(params.url, params.init ?? {}, resolveTimeoutMs(params.timeoutMs)),
+ assertOkOrThrowHttpError: async () => {},
+ createProviderOperationDeadline: ({
+ label,
+ timeoutMs,
+ }: {
+ label: string;
+ timeoutMs?: number;
+ }) => ({ label, timeoutMs }),
+ createProviderOperationTimeoutResolver:
+ ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
+ () =>
+ defaultTimeoutMs,
+ resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
+ defaultTimeoutMs,
+ resolveProviderHttpRequestConfig: (params: {
+ baseUrl?: string;
+ defaultBaseUrl: string;
+ allowPrivateNetwork?: boolean;
+ defaultHeaders?: Record;
+ }) => ({
+ baseUrl: params.baseUrl ?? params.defaultBaseUrl,
+ allowPrivateNetwork: params.allowPrivateNetwork === true,
+ headers: new Headers(params.defaultHeaders),
+ dispatcherPolicy: undefined,
+ }),
+ waitProviderOperationPollInterval: async () => {},
+ };
+});
let buildBytePlusVideoGenerationProvider: typeof import("./video-generation-provider.js").buildBytePlusVideoGenerationProvider;
@@ -14,20 +72,22 @@ beforeAll(async () => {
({ buildBytePlusVideoGenerationProvider } = await import("./video-generation-provider.js"));
});
-installProviderHttpMockCleanup();
+afterEach(() => {
+ postJsonRequestMock.mockReset();
+ fetchWithTimeoutMock.mockReset();
+ resolveApiKeyForProviderMock.mockClear();
+});
function mockSuccessfulBytePlusTask(params?: { model?: string }) {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({
- id: "task_123",
- }),
- },
+ response: streamedJsonResponse({
+ id: "task_123",
+ }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
@@ -35,7 +95,7 @@ function mockSuccessfulBytePlusTask(params?: { model?: string }) {
},
model: params?.model ?? "seedance-1-0-lite-t2v-250428",
}),
- })
+ )
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/webm" }),
arrayBuffer: async () => Buffer.from("webm-bytes"),
@@ -77,6 +137,53 @@ function streamedVideoResponse(bytes: string): Response {
);
}
+// BytePlus submit/poll task JSON is now read through the byte-bounded reader, so the
+// mocked responses must expose a real readable body (not just a json() shortcut).
+function streamedJsonResponse(payload: unknown): Response {
+ return new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode(JSON.stringify(payload)));
+ controller.close();
+ },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ );
+}
+
+// Builds a JSON body larger than the shared 16 MiB readProviderJsonResponse cap so the
+// bounded reader cancels the stream mid-flight; if the cap were removed the reader would
+// buffer the whole advertised payload before parsing. Tracks how many bytes were pulled
+// and whether the stream was canceled so callers can assert the body was not fully read.
+function makeOversizedJsonStream(): {
+ body: ReadableStream;
+ maxBytes: number;
+ totalBytes: number;
+ state: { bytesPulled: number; canceled: boolean };
+} {
+ const maxBytes = 16 * 1024 * 1024; // matches PROVIDER_JSON_RESPONSE_MAX_BYTES.
+ const ONE_MIB = 1024 * 1024;
+ const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
+ const chunk = new Uint8Array(ONE_MIB);
+ const state = { bytesPulled: 0, canceled: false };
+ let pulled = 0;
+ const body = new ReadableStream({
+ pull(controller) {
+ if (pulled >= TOTAL_CHUNKS) {
+ controller.close();
+ return;
+ }
+ pulled += 1;
+ state.bytesPulled += chunk.length;
+ controller.enqueue(chunk);
+ },
+ cancel() {
+ state.canceled = true;
+ },
+ });
+ return { body, maxBytes, totalBytes: TOTAL_CHUNKS * ONE_MIB, state };
+}
+
describe("byteplus video generation provider", () => {
it("declares explicit mode capabilities", () => {
expectExplicitVideoGenerationCapabilities(buildBytePlusVideoGenerationProvider());
@@ -110,21 +217,19 @@ describe("byteplus video generation provider", () => {
it("rejects generated video downloads that exceed the configured media cap", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task_too_large" }),
- },
+ response: streamedJsonResponse({ id: "task_too_large" }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task_too_large",
status: "succeeded",
content: {
video_url: "https://example.com/too-large.mp4",
},
}),
- })
+ )
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
const provider = buildBytePlusVideoGenerationProvider();
@@ -222,16 +327,14 @@ describe("byteplus video generation provider", () => {
it("drops malformed response duration metadata", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({
- id: "task_123",
- }),
- },
+ response: streamedJsonResponse({
+ id: "task_123",
+ }),
release: vi.fn(async () => {}),
});
fetchWithTimeoutMock
- .mockResolvedValueOnce({
- json: async () => ({
+ .mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task_123",
status: "succeeded",
content: {
@@ -239,7 +342,7 @@ describe("byteplus video generation provider", () => {
},
duration: 1.5,
}),
- })
+ )
.mockResolvedValueOnce({
headers: new Headers({ "content-type": "video/mp4" }),
arrayBuffer: async () => Buffer.from("mp4-bytes"),
@@ -259,11 +362,15 @@ describe("byteplus video generation provider", () => {
it("reports malformed create JSON with a provider-owned error", async () => {
const release = vi.fn(async () => {});
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => {
- throw new SyntaxError("bad json");
- },
- },
+ response: new Response(
+ new ReadableStream({
+ start(controller) {
+ controller.enqueue(new TextEncoder().encode("{ not valid json"));
+ controller.close();
+ },
+ }),
+ { status: 200, headers: { "content-type": "application/json" } },
+ ),
release,
});
@@ -281,19 +388,17 @@ describe("byteplus video generation provider", () => {
it("rejects status responses missing a task status", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task_missing_status" }),
- },
+ response: streamedJsonResponse({ id: "task_missing_status" }),
release: vi.fn(async () => {}),
});
- fetchWithTimeoutMock.mockResolvedValueOnce({
- json: async () => ({
+ fetchWithTimeoutMock.mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task_missing_status",
content: {
video_url: "https://example.com/byteplus.mp4",
},
}),
- });
+ );
const provider = buildBytePlusVideoGenerationProvider();
await expect(
@@ -308,18 +413,16 @@ describe("byteplus video generation provider", () => {
it("rejects malformed completed content", async () => {
postJsonRequestMock.mockResolvedValue({
- response: {
- json: async () => ({ id: "task_malformed_content" }),
- },
+ response: streamedJsonResponse({ id: "task_malformed_content" }),
release: vi.fn(async () => {}),
});
- fetchWithTimeoutMock.mockResolvedValueOnce({
- json: async () => ({
+ fetchWithTimeoutMock.mockResolvedValueOnce(
+ streamedJsonResponse({
id: "task_malformed_content",
status: "succeeded",
content: ["https://example.com/byteplus.mp4"],
}),
- });
+ );
const provider = buildBytePlusVideoGenerationProvider();
await expect(
@@ -331,4 +434,61 @@ describe("byteplus video generation provider", () => {
}),
).rejects.toThrow("BytePlus video generation completed with malformed content");
});
+
+ it("bounds the submit task JSON body and cancels an oversized stream", async () => {
+ const stream = makeOversizedJsonStream();
+ const release = vi.fn(async () => {});
+ postJsonRequestMock.mockResolvedValue({
+ response: new Response(stream.body, {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ release,
+ });
+
+ const provider = buildBytePlusVideoGenerationProvider();
+ await expect(
+ provider.generateVideo({
+ provider: "byteplus",
+ model: "seedance-1-0-lite-t2v-250428",
+ prompt: "oversized submit response",
+ cfg: {},
+ }),
+ ).rejects.toThrow(
+ `BytePlus video generation failed: JSON response exceeds ${stream.maxBytes} bytes`,
+ );
+ expect(stream.state.canceled).toBe(true);
+ // Only the bounded prefix is pulled, never the full advertised stream.
+ expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
+ // The submit request must still be released even though the body overflowed.
+ expect(release).toHaveBeenCalledOnce();
+ });
+
+ it("bounds the poll status JSON body and cancels an oversized stream", async () => {
+ postJsonRequestMock.mockResolvedValue({
+ response: streamedJsonResponse({ id: "task_oversized_poll" }),
+ release: vi.fn(async () => {}),
+ });
+ const stream = makeOversizedJsonStream();
+ fetchWithTimeoutMock.mockResolvedValueOnce(
+ new Response(stream.body, {
+ status: 200,
+ headers: { "content-type": "application/json" },
+ }),
+ );
+
+ const provider = buildBytePlusVideoGenerationProvider();
+ await expect(
+ provider.generateVideo({
+ provider: "byteplus",
+ model: "seedance-1-0-lite-t2v-250428",
+ prompt: "oversized poll response",
+ cfg: {},
+ }),
+ ).rejects.toThrow(
+ `BytePlus video status request failed: JSON response exceeds ${stream.maxBytes} bytes`,
+ );
+ expect(stream.state.canceled).toBe(true);
+ expect(stream.state.bytesPulled).toBeLessThan(stream.totalBytes);
+ });
});
diff --git a/extensions/byteplus/video-generation-provider.ts b/extensions/byteplus/video-generation-provider.ts
index 1c1734e4afee..b8a39e51c0d3 100644
--- a/extensions/byteplus/video-generation-provider.ts
+++ b/extensions/byteplus/video-generation-provider.ts
@@ -11,6 +11,7 @@ import {
fetchProviderDownloadResponse,
fetchProviderOperationResponse,
postJsonRequest,
+ readProviderJsonResponse,
resolveProviderOperationTimeoutMs,
resolveProviderHttpRequestConfig,
waitProviderOperationPollInterval,
@@ -55,16 +56,13 @@ type BytePlusTaskResponse = {
type BytePlusTaskStatus = "running" | "failed" | "queued" | "succeeded" | "cancelled";
-async function readBytePlusJsonResponse(
- response: Pick,
- label: string,
-): Promise {
- let payload: unknown;
- try {
- payload = await response.json();
- } catch (cause) {
- throw new Error(`${label}: malformed JSON response`, { cause });
- }
+async function readBytePlusJsonResponse(response: Response, label: string): Promise {
+ // BytePlus submit/poll task bodies are read through the shared byte-bounded reader
+ // (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
+ // that streams an unbounded JSON body cannot force the runtime to buffer the whole
+ // payload before parsing. Overflow cancels the stream and throws a bounded error;
+ // malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
+ const payload = await readProviderJsonResponse(response, label);
if (!isRecord(payload)) {
throw new Error(`${label}: malformed JSON response`);
}