Skip to content

Commit 2f684df

Browse files
committed
fix(runway): bound video create/poll response reads
Rewrite the shared `readRunwayJsonResponse` helper (used by both the create-submit and task-poll success paths) to delegate to the shared byte-bounded `readProviderJsonResponse` (16 MiB cap) instead of an unbounded `await response.json()`, matching the already-merged byteplus/google/qwen video bounds. The download path already uses `readResponseWithLimit`; this closes the remaining unbounded success-path reads so a hostile or buggy endpoint cannot stream an unbounded body and force OpenClaw to buffer it before parsing. The existing isRecord() object guard and the `malformed JSON response` wrapping are preserved. Keep the shared provider-http test mock's JSON reader real so the streaming size cap is exercised under test.
1 parent 6830aa3 commit 2f684df

3 files changed

Lines changed: 91 additions & 91 deletions

File tree

extensions/runway/video-generation-provider.test.ts

Lines changed: 45 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -63,29 +63,44 @@ function streamedVideoResponse(bytes: string): Response {
6363
);
6464
}
6565

66+
// Streams a JSON body through a real ReadableStream so create/poll reads exercise
67+
// the byte-bounded reader (readResponseWithLimit) instead of an unbounded res.json().
68+
function streamedJsonResponse(payload: unknown): Response {
69+
return streamedRawResponse(JSON.stringify(payload));
70+
}
71+
72+
function streamedRawResponse(text: string): Response {
73+
return new Response(
74+
new ReadableStream({
75+
start(controller) {
76+
controller.enqueue(new TextEncoder().encode(text));
77+
controller.close();
78+
},
79+
}),
80+
{ headers: { "content-type": "application/json" } },
81+
);
82+
}
83+
6684
describe("runway video generation provider", () => {
6785
it("declares explicit mode capabilities", () => {
6886
expectExplicitVideoGenerationCapabilities(buildRunwayVideoGenerationProvider());
6987
});
7088

7189
it("submits a text-to-video task, polls it, and downloads the output", async () => {
7290
postJsonRequestMock.mockResolvedValue({
73-
response: {
74-
json: async () => ({
75-
id: "task-1",
76-
}),
77-
},
91+
response: streamedJsonResponse({
92+
id: "task-1",
93+
}),
7894
release: vi.fn(async () => {}),
7995
});
8096
fetchWithTimeoutMock
81-
.mockResolvedValueOnce({
82-
json: async () => ({
97+
.mockResolvedValueOnce(
98+
streamedJsonResponse({
8399
id: "task-1",
84100
status: "SUCCEEDED",
85101
output: ["https://example.com/out.mp4"],
86102
}),
87-
headers: new Headers(),
88-
})
103+
)
89104
.mockResolvedValueOnce({
90105
arrayBuffer: async () => Buffer.from("mp4-bytes"),
91106
headers: new Headers({ "content-type": "video/webm" }),
@@ -130,20 +145,17 @@ describe("runway video generation provider", () => {
130145

131146
it("rejects generated video downloads that exceed the configured media cap", async () => {
132147
postJsonRequestMock.mockResolvedValue({
133-
response: {
134-
json: async () => ({ id: "task-too-large" }),
135-
},
148+
response: streamedJsonResponse({ id: "task-too-large" }),
136149
release: vi.fn(async () => {}),
137150
});
138151
fetchWithTimeoutMock
139-
.mockResolvedValueOnce({
140-
json: async () => ({
152+
.mockResolvedValueOnce(
153+
streamedJsonResponse({
141154
id: "task-too-large",
142155
status: "SUCCEEDED",
143156
output: ["https://example.com/out.mp4"],
144157
}),
145-
headers: new Headers(),
146-
})
158+
)
147159
.mockResolvedValueOnce(streamedVideoResponse("too-large"));
148160

149161
const provider = buildRunwayVideoGenerationProvider();
@@ -159,20 +171,17 @@ describe("runway video generation provider", () => {
159171

160172
it("does not round malformed duration values into create requests", async () => {
161173
postJsonRequestMock.mockResolvedValue({
162-
response: {
163-
json: async () => ({ id: "task-duration" }),
164-
},
174+
response: streamedJsonResponse({ id: "task-duration" }),
165175
release: vi.fn(async () => {}),
166176
});
167177
fetchWithTimeoutMock
168-
.mockResolvedValueOnce({
169-
json: async () => ({
178+
.mockResolvedValueOnce(
179+
streamedJsonResponse({
170180
id: "task-duration",
171181
status: "SUCCEEDED",
172182
output: ["https://example.com/out.mp4"],
173183
}),
174-
headers: new Headers(),
175-
})
184+
)
176185
.mockResolvedValueOnce({
177186
arrayBuffer: async () => Buffer.from("mp4-bytes"),
178187
headers: new Headers({ "content-type": "video/mp4" }),
@@ -194,20 +203,17 @@ describe("runway video generation provider", () => {
194203

195204
it("accepts local image buffers by converting them into data URIs", async () => {
196205
postJsonRequestMock.mockResolvedValue({
197-
response: {
198-
json: async () => ({ id: "task-2" }),
199-
},
206+
response: streamedJsonResponse({ id: "task-2" }),
200207
release: vi.fn(async () => {}),
201208
});
202209
fetchWithTimeoutMock
203-
.mockResolvedValueOnce({
204-
json: async () => ({
210+
.mockResolvedValueOnce(
211+
streamedJsonResponse({
205212
id: "task-2",
206213
status: "SUCCEEDED",
207214
output: ["https://example.com/out.mp4"],
208215
}),
209-
headers: new Headers(),
210-
})
216+
)
211217
.mockResolvedValueOnce({
212218
arrayBuffer: async () => Buffer.from("mp4-bytes"),
213219
headers: new Headers({ "content-type": "video/mp4" }),
@@ -250,11 +256,7 @@ describe("runway video generation provider", () => {
250256
it("reports malformed create JSON with a provider-owned error", async () => {
251257
const release = vi.fn(async () => {});
252258
postJsonRequestMock.mockResolvedValue({
253-
response: {
254-
json: async () => {
255-
throw new SyntaxError("bad json");
256-
},
257-
},
259+
response: streamedRawResponse("{ not json"),
258260
release,
259261
});
260262

@@ -272,18 +274,15 @@ describe("runway video generation provider", () => {
272274

273275
it("rejects status responses missing a task status", async () => {
274276
postJsonRequestMock.mockResolvedValue({
275-
response: {
276-
json: async () => ({ id: "task-missing-status" }),
277-
},
277+
response: streamedJsonResponse({ id: "task-missing-status" }),
278278
release: vi.fn(async () => {}),
279279
});
280-
fetchWithTimeoutMock.mockResolvedValueOnce({
281-
json: async () => ({
280+
fetchWithTimeoutMock.mockResolvedValueOnce(
281+
streamedJsonResponse({
282282
id: "task-missing-status",
283283
output: ["https://example.com/out.mp4"],
284284
}),
285-
headers: new Headers(),
286-
});
285+
);
287286

288287
const provider = buildRunwayVideoGenerationProvider();
289288
await expect(
@@ -298,19 +297,16 @@ describe("runway video generation provider", () => {
298297

299298
it("rejects malformed completed output URLs", async () => {
300299
postJsonRequestMock.mockResolvedValue({
301-
response: {
302-
json: async () => ({ id: "task-malformed-output" }),
303-
},
300+
response: streamedJsonResponse({ id: "task-malformed-output" }),
304301
release: vi.fn(async () => {}),
305302
});
306-
fetchWithTimeoutMock.mockResolvedValueOnce({
307-
json: async () => ({
303+
fetchWithTimeoutMock.mockResolvedValueOnce(
304+
streamedJsonResponse({
308305
id: "task-malformed-output",
309306
status: "SUCCEEDED",
310307
output: "https://example.com/out.mp4",
311308
}),
312-
headers: new Headers(),
313-
});
309+
);
314310

315311
const provider = buildRunwayVideoGenerationProvider();
316312
await expect(

extensions/runway/video-generation-provider.ts

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
fetchProviderDownloadResponse,
1010
fetchProviderOperationResponse,
1111
postJsonRequest,
12+
readProviderJsonResponse,
1213
resolveProviderOperationTimeoutMs,
1314
resolveProviderHttpRequestConfig,
1415
waitProviderOperationPollInterval,
@@ -65,16 +66,13 @@ const VIDEO_MODELS = new Set(["gen4_aleph"]);
6566
const RUNWAY_TEXT_ASPECT_RATIOS = ["16:9", "9:16"] as const;
6667
const RUNWAY_EDIT_ASPECT_RATIOS = ["1:1", "16:9", "9:16", "3:4", "4:3", "21:9"] as const;
6768

68-
async function readRunwayJsonResponse<T>(
69-
response: Pick<Response, "json">,
70-
label: string,
71-
): Promise<T> {
72-
let payload: unknown;
73-
try {
74-
payload = await response.json();
75-
} catch (cause) {
76-
throw new Error(`${label}: malformed JSON response`, { cause });
77-
}
69+
async function readRunwayJsonResponse<T>(response: Response, label: string): Promise<T> {
70+
// Runway submit/poll task bodies are read through the shared byte-bounded reader
71+
// (readResponseWithLimit, via readProviderJsonResponse) so a hostile or buggy endpoint
72+
// that streams an unbounded JSON body cannot force the runtime to buffer the whole
73+
// payload before parsing. Overflow cancels the stream and throws a bounded error;
74+
// malformed JSON keeps the existing `${label}: malformed JSON response` wrapping.
75+
const payload = await readProviderJsonResponse<unknown>(response, label);
7876
if (!isRecord(payload)) {
7977
throw new Error(`${label}: malformed JSON response`);
8078
}

src/plugin-sdk/test-helpers/provider-http-mocks.ts

Lines changed: 38 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -208,39 +208,45 @@ vi.mock("openclaw/plugin-sdk/provider-auth-runtime", () => ({
208208
resolveApiKeyForProvider: providerHttpMocks.resolveApiKeyForProviderMock,
209209
}));
210210

211-
vi.mock("openclaw/plugin-sdk/provider-http", () => ({
212-
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
213-
assertOkOrThrowProviderError: providerHttpMocks.assertOkOrThrowProviderErrorMock,
214-
createProviderOperationDeadline: ({
215-
label,
216-
timeoutMs,
217-
}: {
218-
label: string;
219-
timeoutMs?: number;
220-
}) => ({
221-
label,
222-
timeoutMs,
223-
}),
224-
createProviderOperationTimeoutResolver:
225-
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
226-
() =>
211+
vi.mock("openclaw/plugin-sdk/provider-http", async (importActual) => {
212+
const actual = await importActual<typeof import("../provider-http.js")>();
213+
return {
214+
// Keep the byte-bounded JSON reader REAL so success-path reads exercise the
215+
// streaming size cap (readResponseWithLimit) under test instead of a stub.
216+
readProviderJsonResponse: actual.readProviderJsonResponse,
217+
assertOkOrThrowHttpError: providerHttpMocks.assertOkOrThrowHttpErrorMock,
218+
assertOkOrThrowProviderError: providerHttpMocks.assertOkOrThrowProviderErrorMock,
219+
createProviderOperationDeadline: ({
220+
label,
221+
timeoutMs,
222+
}: {
223+
label: string;
224+
timeoutMs?: number;
225+
}) => ({
226+
label,
227+
timeoutMs,
228+
}),
229+
createProviderOperationTimeoutResolver:
230+
({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
231+
() =>
232+
defaultTimeoutMs,
233+
executeProviderOperationWithRetry: providerHttpMocks.executeProviderOperationWithRetryMock,
234+
fetchProviderDownloadResponse: providerHttpMocks.fetchProviderDownloadResponseMock,
235+
fetchProviderOperationResponse: providerHttpMocks.fetchProviderOperationResponseMock,
236+
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
237+
fetchWithTimeoutGuarded: providerHttpMocks.fetchWithTimeoutGuardedMock,
238+
pollProviderOperationJson: providerHttpMocks.pollProviderOperationJsonMock,
239+
postJsonRequest: providerHttpMocks.postJsonRequestMock,
240+
postMultipartRequest: providerHttpMocks.postMultipartRequestMock,
241+
providerOperationRetryConfig: (_stage: string) => true,
242+
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
227243
defaultTimeoutMs,
228-
executeProviderOperationWithRetry: providerHttpMocks.executeProviderOperationWithRetryMock,
229-
fetchProviderDownloadResponse: providerHttpMocks.fetchProviderDownloadResponseMock,
230-
fetchProviderOperationResponse: providerHttpMocks.fetchProviderOperationResponseMock,
231-
fetchWithTimeout: providerHttpMocks.fetchWithTimeoutMock,
232-
fetchWithTimeoutGuarded: providerHttpMocks.fetchWithTimeoutGuardedMock,
233-
pollProviderOperationJson: providerHttpMocks.pollProviderOperationJsonMock,
234-
postJsonRequest: providerHttpMocks.postJsonRequestMock,
235-
postMultipartRequest: providerHttpMocks.postMultipartRequestMock,
236-
providerOperationRetryConfig: (_stage: string) => true,
237-
resolveProviderOperationTimeoutMs: ({ defaultTimeoutMs }: { defaultTimeoutMs: number }) =>
238-
defaultTimeoutMs,
239-
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
240-
sanitizeConfiguredModelProviderRequest:
241-
providerHttpMocks.sanitizeConfiguredModelProviderRequestMock,
242-
waitProviderOperationPollInterval: async () => {},
243-
}));
244+
resolveProviderHttpRequestConfig: providerHttpMocks.resolveProviderHttpRequestConfigMock,
245+
sanitizeConfiguredModelProviderRequest:
246+
providerHttpMocks.sanitizeConfiguredModelProviderRequestMock,
247+
waitProviderOperationPollInterval: async () => {},
248+
};
249+
});
244250

245251
export function getProviderHttpMocks(): ProviderHttpMocks {
246252
return providerHttpMocks;

0 commit comments

Comments
 (0)