Skip to content

Commit 561c713

Browse files
authored
fix(cli): bound and redact generated video downloads
Bounds the no-output generated video download path and redacts provider URLs from HTTP failure errors.
1 parent dd6143f commit 561c713

2 files changed

Lines changed: 253 additions & 2 deletions

File tree

src/cli/capability-cli.test.ts

Lines changed: 228 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2078,6 +2078,234 @@ describe("capability cli", () => {
20782078
expectRuntimeErrorContains("Video asset at index 0 has neither buffer nor url");
20792079
});
20802080

2081+
it("fails closed when an url-only generated video exceeds the in-memory byte cap", async () => {
2082+
mocks.loadConfig.mockReturnValue({});
2083+
mocks.generateVideo.mockResolvedValue({
2084+
provider: "vydra",
2085+
model: "veo3",
2086+
attempts: [],
2087+
videos: [
2088+
{
2089+
url: "https://example.com/oversized-video.mp4?sig=secret-presigned-token",
2090+
mimeType: "video/mp4",
2091+
fileName: "provider-name.mp4",
2092+
},
2093+
],
2094+
});
2095+
// Offer far more than the 16 MiB default video cap in 1 MiB chunks so the
2096+
// bounded reader has to cancel mid-stream instead of buffering it all. The
2097+
// source would yield 64 MiB if fully drained; a correct guard stops early.
2098+
const oneMiBChunk = new Uint8Array(1024 * 1024);
2099+
const overCapChunks = 64;
2100+
let enqueued = 0;
2101+
let canceled = false;
2102+
const oversizedBody = new ReadableStream<Uint8Array>({
2103+
pull(controller) {
2104+
if (enqueued >= overCapChunks) {
2105+
controller.close();
2106+
return;
2107+
}
2108+
enqueued += 1;
2109+
controller.enqueue(oneMiBChunk);
2110+
},
2111+
cancel() {
2112+
canceled = true;
2113+
},
2114+
});
2115+
const fetchMock = vi.fn(
2116+
async () =>
2117+
new Response(oversizedBody, {
2118+
status: 200,
2119+
headers: { "content-type": "video/mp4" },
2120+
}),
2121+
);
2122+
vi.stubGlobal("fetch", fetchMock);
2123+
2124+
await expect(
2125+
runRegisteredCli({
2126+
register: registerCapabilityCli as (program: Command) => void,
2127+
// No --output: forces the in-memory buffered fallback path.
2128+
argv: ["capability", "video", "generate", "--prompt", "friendly lobster", "--json"],
2129+
}),
2130+
).rejects.toThrow("exit 1");
2131+
2132+
// Real path was driven: the provider URL was actually fetched...
2133+
const fetchCalls = fetchMock.mock.calls as unknown as Array<[string]>;
2134+
expect(fetchCalls[0]?.[0]).toBe(
2135+
"https://example.com/oversized-video.mp4?sig=secret-presigned-token",
2136+
);
2137+
// ...and the read was rejected (fail-closed) referencing the provider label
2138+
// and the 16 MiB default cap rather than buffering the body.
2139+
expectRuntimeErrorContains("vydra generated video download exceeds 16777216 bytes");
2140+
// Security regression guard: the overflow error must NOT echo the raw
2141+
// provider URL (it may carry signed/tokenized access material). See the
2142+
// sibling generated-media downloaders, which report provider + cap only.
2143+
expect(runtimeErrorMessages().join("\n")).not.toContain("secret-presigned-token");
2144+
expect(runtimeErrorMessages().join("\n")).not.toContain("https://example.com");
2145+
// The reader cancelled shortly after crossing the 16 MiB cap rather than
2146+
// draining the full 64 MiB the source was willing to produce.
2147+
expect(canceled).toBe(true);
2148+
expect(enqueued).toBeLessThan(overCapChunks);
2149+
expect(enqueued).toBeLessThanOrEqual(18);
2150+
});
2151+
2152+
it("redacts provider video URLs when the no-output download fails", async () => {
2153+
mocks.loadConfig.mockReturnValue({});
2154+
mocks.generateVideo.mockResolvedValue({
2155+
provider: "vydra",
2156+
model: "veo3",
2157+
attempts: [],
2158+
videos: [
2159+
{
2160+
url: "https://example.com/private-video.mp4?sig=secret-presigned-token",
2161+
mimeType: "video/mp4",
2162+
fileName: "provider-name.mp4",
2163+
},
2164+
],
2165+
});
2166+
const fetchMock = vi.fn(
2167+
async () =>
2168+
new Response("download forbidden", {
2169+
status: 403,
2170+
statusText: "Forbidden",
2171+
headers: { "content-type": "text/plain" },
2172+
}),
2173+
);
2174+
vi.stubGlobal("fetch", fetchMock);
2175+
2176+
await expect(
2177+
runRegisteredCli({
2178+
register: registerCapabilityCli as (program: Command) => void,
2179+
argv: ["capability", "video", "generate", "--prompt", "friendly lobster", "--json"],
2180+
}),
2181+
).rejects.toThrow("exit 1");
2182+
2183+
expectRuntimeErrorContains("vydra generated video download failed");
2184+
expectRuntimeErrorContains("HTTP 403");
2185+
expect(runtimeErrorMessages().join("\n")).not.toContain("secret-presigned-token");
2186+
expect(runtimeErrorMessages().join("\n")).not.toContain("https://example.com");
2187+
});
2188+
2189+
it("buffers an url-only generated video that stays under the byte cap", async () => {
2190+
mocks.loadConfig.mockReturnValue({});
2191+
mocks.generateVideo.mockResolvedValue({
2192+
provider: "vydra",
2193+
model: "veo3",
2194+
attempts: [],
2195+
videos: [
2196+
{
2197+
url: "https://example.com/small-video.mp4",
2198+
mimeType: "video/mp4",
2199+
fileName: "provider-name.mp4",
2200+
},
2201+
],
2202+
});
2203+
const fetchMock = vi.fn(
2204+
async () =>
2205+
new Response(Buffer.from("small-video-bytes"), {
2206+
status: 200,
2207+
headers: { "content-type": "video/mp4" },
2208+
}),
2209+
);
2210+
vi.stubGlobal("fetch", fetchMock);
2211+
2212+
await runRegisteredCli({
2213+
register: registerCapabilityCli as (program: Command) => void,
2214+
// No --output: in-memory buffered fallback path, under cap.
2215+
argv: ["capability", "video", "generate", "--prompt", "friendly lobster", "--json"],
2216+
});
2217+
2218+
const fetchCalls = fetchMock.mock.calls as unknown as Array<[string]>;
2219+
expect(fetchCalls[0]?.[0]).toBe("https://example.com/small-video.mp4");
2220+
const output = firstJsonOutput();
2221+
expect(output?.capability).toBe("video.generate");
2222+
expect(output?.provider).toBe("vydra");
2223+
expect(output?.outputs as Array<Record<string, unknown>>).toHaveLength(1);
2224+
// No overflow error on the under-cap path.
2225+
expect(runtimeErrorMessages().join("\n")).not.toContain("exceeds");
2226+
});
2227+
2228+
it("honors a smaller configured mediaMaxMb cap on the in-memory video path", async () => {
2229+
// Operators can lower the cap via agents.defaults.mediaMaxMb; the bounded
2230+
// read must respect it (here 2 MiB) and cancel even earlier.
2231+
mocks.loadConfig.mockReturnValue({ agents: { defaults: { mediaMaxMb: 2 } } });
2232+
mocks.generateVideo.mockResolvedValue({
2233+
provider: "vydra",
2234+
model: "veo3",
2235+
attempts: [],
2236+
videos: [
2237+
{
2238+
url: "https://example.com/over-2mb-video.mp4",
2239+
mimeType: "video/mp4",
2240+
fileName: "provider-name.mp4",
2241+
},
2242+
],
2243+
});
2244+
const oneMiBChunk = new Uint8Array(1024 * 1024);
2245+
const totalChunks = 16;
2246+
let enqueued = 0;
2247+
const body = new ReadableStream<Uint8Array>({
2248+
pull(controller) {
2249+
if (enqueued >= totalChunks) {
2250+
controller.close();
2251+
return;
2252+
}
2253+
enqueued += 1;
2254+
controller.enqueue(oneMiBChunk);
2255+
},
2256+
});
2257+
const fetchMock = vi.fn(
2258+
async () => new Response(body, { status: 200, headers: { "content-type": "video/mp4" } }),
2259+
);
2260+
vi.stubGlobal("fetch", fetchMock);
2261+
2262+
await expect(
2263+
runRegisteredCli({
2264+
register: registerCapabilityCli as (program: Command) => void,
2265+
argv: ["capability", "video", "generate", "--prompt", "friendly lobster", "--json"],
2266+
}),
2267+
).rejects.toThrow("exit 1");
2268+
2269+
// Cap resolved from config (2 MiB = 2097152), not the 16 MiB default.
2270+
expectRuntimeErrorContains("vydra generated video download exceeds 2097152 bytes");
2271+
// Cancelled after crossing 2 MiB, far below the 16 MiB the source offered.
2272+
expect(enqueued).toBeLessThanOrEqual(4);
2273+
});
2274+
2275+
it("buffers an empty-body url-only generated video without error", async () => {
2276+
// Boundary: a 0-byte body is trivially under the cap and must not error.
2277+
mocks.loadConfig.mockReturnValue({});
2278+
mocks.generateVideo.mockResolvedValue({
2279+
provider: "vydra",
2280+
model: "veo3",
2281+
attempts: [],
2282+
videos: [
2283+
{
2284+
url: "https://example.com/empty-video.mp4",
2285+
mimeType: "video/mp4",
2286+
fileName: "provider-name.mp4",
2287+
},
2288+
],
2289+
});
2290+
const fetchMock = vi.fn(
2291+
async () =>
2292+
new Response(Buffer.alloc(0), {
2293+
status: 200,
2294+
headers: { "content-type": "video/mp4" },
2295+
}),
2296+
);
2297+
vi.stubGlobal("fetch", fetchMock);
2298+
2299+
await runRegisteredCli({
2300+
register: registerCapabilityCli as (program: Command) => void,
2301+
argv: ["capability", "video", "generate", "--prompt", "friendly lobster", "--json"],
2302+
});
2303+
2304+
const output = firstJsonOutput();
2305+
expect(output?.capability).toBe("video.generate");
2306+
expect(runtimeErrorMessages().join("\n")).not.toContain("exceeds");
2307+
});
2308+
20812309
it("rejects partial image generate count before provider dispatch", async () => {
20822310
await expect(
20832311
runRegisteredCli({

src/cli/capability-cli.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import path from "node:path";
66
import { Readable } from "node:stream";
77
import { pipeline } from "node:stream/promises";
88
import { detectMime, extensionForMime, normalizeMimeType } from "@openclaw/media-core/mime";
9+
import { readResponseWithLimit } from "@openclaw/media-core/read-response-with-limit";
910
import {
1011
normalizeLowercaseStringOrEmpty,
1112
normalizeOptionalString,
@@ -30,6 +31,7 @@ import { resolveMemorySearchConfig } from "../agents/memory-search.js";
3031
import { resolveApiKeyForProvider } from "../agents/model-auth.js";
3132
import { loadModelCatalog } from "../agents/model-catalog.js";
3233
import { canonicalizeCaseOnlyCatalogModelRef } from "../agents/model-selection.js";
34+
import { assertOkOrThrowHttpError } from "../agents/provider-http-errors.js";
3335
import {
3436
completeWithPreparedSimpleCompletionModel,
3537
prepareSimpleCompletionModelForAgent,
@@ -65,6 +67,7 @@ import {
6567
describeVideoFile,
6668
transcribeAudioFile,
6769
} from "../media-understanding/runtime.js";
70+
import { resolveGeneratedMediaMaxBytes } from "../media/configured-max-bytes.js";
6871
import { convertHeicToJpeg, getImageMetadata } from "../media/media-services.js";
6972
import { saveMediaBuffer } from "../media/store.js";
7073
import { createEmbeddingProvider } from "../plugin-sdk/memory-core-bundled-runtime.js";
@@ -1317,7 +1320,10 @@ async function runVideoGenerate(params: {
13171320
if (!videoBuffer && video.url) {
13181321
const response = await fetch(video.url, { signal: AbortSignal.timeout(120_000) });
13191322
if (!response.ok) {
1320-
throw new Error(`Failed to download video from ${video.url}: ${response.status}`);
1323+
await assertOkOrThrowHttpError(
1324+
response,
1325+
`${result.provider} generated video download failed`,
1326+
);
13211327
}
13221328
if (params.output && response.body) {
13231329
const mimeType = normalizeMimeType(video.mimeType);
@@ -1339,7 +1345,24 @@ async function runVideoGenerate(params: {
13391345
const stat = await fs.stat(filePath);
13401346
return { path: filePath, mimeType: video.mimeType, size: stat.size };
13411347
}
1342-
videoBuffer = Buffer.from(await response.arrayBuffer());
1348+
// Provider-supplied video URLs are untrusted external sources, and the
1349+
// in-memory fallback (no --output) must not buffer an unbounded body:
1350+
// generated videos routinely exceed tens of MiB and a hostile/buggy
1351+
// provider could exhaust process memory. Cap the read (fail-closed:
1352+
// overflow cancels the stream and throws rather than silently
1353+
// truncating) using the same shared bounded reader the rest of the
1354+
// media stack relies on. The --output branch above already streams
1355+
// straight to disk, so only this buffered path needs the guard. The
1356+
// overflow error reports only the provider label and byte cap (never
1357+
// the raw URL, which may be signed/tokenized) to match the sibling
1358+
// generated-media downloaders.
1359+
const videoMaxBytes = resolveGeneratedMediaMaxBytes(cfg, "video");
1360+
videoBuffer = await readResponseWithLimit(response, videoMaxBytes, {
1361+
onOverflow: ({ maxBytes }) =>
1362+
new Error(
1363+
`${result.provider} generated video download exceeds ${maxBytes} bytes; pass --output to stream large videos to disk`,
1364+
),
1365+
});
13431366
}
13441367

13451368
return {

0 commit comments

Comments
 (0)