mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 11:55:47 -06:00
fix(cli): reject incomplete hosted video downloads (#117893)
* fix(cli): validate streamed video downloads * test: repair CLI and plugin test gates
This commit is contained in:
committed by
GitHub
parent
d5f265f245
commit
83fa625fb6
@@ -6,11 +6,15 @@ import { pipeline } from "node:stream/promises";
|
||||
import { extensionForMime, normalizeMimeType } from "@openclaw/media-core/mime";
|
||||
import type { Command } from "commander";
|
||||
import { resolveAgentDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
|
||||
import { assertOkOrThrowHttpError } from "../../agents/provider-http-errors.js";
|
||||
import {
|
||||
assertOkOrThrowHttpError,
|
||||
assertProviderBinaryResponseContent,
|
||||
} from "../../agents/provider-http-errors.js";
|
||||
import { getRuntimeConfig } from "../../config/config.js";
|
||||
import { resolveAgentModelPrimaryValue } from "../../config/model-input.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { readResponseWithLimit } from "../../infra/http-body.js";
|
||||
import { writeSiblingTempFile } from "../../infra/sibling-temp-file.js";
|
||||
import { buildMediaUnderstandingRegistry } from "../../media-understanding/provider-registry.js";
|
||||
import { describeVideoFile } from "../../media-understanding/runtime.js";
|
||||
import { resolveGeneratedMediaMaxBytes } from "../../media/configured-max-bytes.js";
|
||||
@@ -41,6 +45,18 @@ import {
|
||||
} from "./shared.js";
|
||||
|
||||
const GENERATED_VIDEO_DOWNLOAD_TIMEOUT_MS = 120_000;
|
||||
const GENERATED_VIDEO_OUTPUT_TEMP_PREFIX = ".openclaw-video-output";
|
||||
|
||||
async function resolveExistingVideoOutputMode(filePath: string): Promise<number | undefined> {
|
||||
try {
|
||||
return (await fs.stat(filePath)).mode & 0o7777;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
return undefined;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeVideoResolution(raw: string | undefined): VideoGenerationResolution | undefined {
|
||||
const normalized = raw?.trim().toUpperCase();
|
||||
@@ -90,6 +106,11 @@ async function fetchGeneratedVideoDownload(params: {
|
||||
result.response,
|
||||
`${params.provider} generated video download failed`,
|
||||
);
|
||||
assertProviderBinaryResponseContent(
|
||||
result.response,
|
||||
`${params.provider} generated video download`,
|
||||
"video",
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
await result.release();
|
||||
@@ -148,20 +169,37 @@ async function runVideoGenerate(params: {
|
||||
const ext =
|
||||
extensionForMime(mimeType) ||
|
||||
path.extname(video.fileName ?? "") ||
|
||||
path.extname(params.output ?? "");
|
||||
path.extname(params.output);
|
||||
const resolvedOutput = path.resolve(params.output);
|
||||
const parsed = path.parse(resolvedOutput);
|
||||
const filePath =
|
||||
result.videos.length <= 1
|
||||
? path.join(parsed.dir, `${parsed.name}${ext}`)
|
||||
: path.join(parsed.dir, `${parsed.name}-${String(index + 1)}${ext}`);
|
||||
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
||||
await pipeline(
|
||||
Readable.fromWeb(response.body as import("node:stream/web").ReadableStream),
|
||||
createWriteStream(filePath),
|
||||
);
|
||||
const stat = await fs.stat(filePath);
|
||||
return { path: filePath, mimeType: video.mimeType, size: stat.size };
|
||||
const dir = path.dirname(filePath);
|
||||
await fs.mkdir(dir, { recursive: true });
|
||||
const mode = await resolveExistingVideoOutputMode(filePath);
|
||||
const { result: size } = await writeSiblingTempFile({
|
||||
dir,
|
||||
chmodDir: false,
|
||||
tempPrefix: GENERATED_VIDEO_OUTPUT_TEMP_PREFIX,
|
||||
...(mode === undefined ? {} : { mode }),
|
||||
writeTemp: async (tempPath) => {
|
||||
await pipeline(
|
||||
Readable.fromWeb(
|
||||
response.body as import("node:stream/web").ReadableStream<Uint8Array>,
|
||||
),
|
||||
createWriteStream(tempPath, { flags: "wx" }),
|
||||
);
|
||||
const writtenSize = (await fs.stat(tempPath)).size;
|
||||
if (writtenSize === 0) {
|
||||
throw new Error("Generated media output is empty.");
|
||||
}
|
||||
return writtenSize;
|
||||
},
|
||||
resolveFinalPath: () => filePath,
|
||||
});
|
||||
return { path: filePath, mimeType: video.mimeType, size };
|
||||
}
|
||||
// Provider-supplied video URLs are untrusted external sources, and the
|
||||
// in-memory fallback (no --output) must not buffer an unbounded body:
|
||||
@@ -181,6 +219,9 @@ async function runVideoGenerate(params: {
|
||||
`${result.provider} generated video download exceeds ${maxBytes} bytes; pass --output to stream large videos to disk`,
|
||||
),
|
||||
});
|
||||
if (videoBuffer.byteLength === 0) {
|
||||
throw new Error("Generated media output is empty.");
|
||||
}
|
||||
} finally {
|
||||
await download.release();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user