diff --git a/docs/cli/infer.md b/docs/cli/infer.md index 1346a712424d..c00d34d4f890 100644 --- a/docs/cli/infer.md +++ b/docs/cli/infer.md @@ -106,6 +106,7 @@ A good infer-based skill maps common user intents to the right subcommand, inclu - For `image describe`, `--file` accepts local paths and HTTP(S) URLs; remote URLs go through the normal media-fetch SSRF policy. - Stateless execution commands (`model run`, `image *`, `audio *`, `video *`, `web *`, `embedding *`) default to local. Gateway-managed state commands (`tts status`) default to gateway. - The local path never requires the gateway to be running. +- Generated image and video `--output` files are staged beside the destination and replace it only after the complete buffer is written; a failed write leaves an existing destination unchanged. - Local `model run` is a lean one-shot provider completion: it resolves the configured agent model and auth but does not start a chat-agent turn, load tools, or open bundled MCP servers. - `model run --file` attaches image files (auto-detected MIME type) to the prompt; repeat `--file` for multiple images. Non-image files are rejected — use `infer audio transcribe` or `infer video describe` instead. - `model run --gateway` exercises Gateway routing, saved auth, provider selection, and the embedded runtime, but stays a raw model probe: no prior session transcript, bootstrap/AGENTS context, tools, or bundled MCP servers. diff --git a/src/cli/capability-cli.test.ts b/src/cli/capability-cli.test.ts index 4898bee67475..ffe3a77097aa 100644 --- a/src/cli/capability-cli.test.ts +++ b/src/cli/capability-cli.test.ts @@ -2028,6 +2028,76 @@ describe("capability cli", () => { } }); + it.each([ + { kind: "image", extension: ".png", original: "existing-image", byte: 0x49 }, + { kind: "video", extension: ".mp4", original: "existing-video", byte: 0x56 }, + ])( + "preserves an existing buffered $kind --output when publication fails", + async ({ kind, extension, original, byte }) => { + const buffer = Buffer.alloc(2_048, byte); + if (kind === "image") { + mocks.generateImage.mockResolvedValue({ + provider: "openai", + model: "gpt-image-2", + attempts: [], + images: [{ buffer, mimeType: "image/png", fileName: "generated.png" }], + }); + } else { + mocks.generateVideo.mockResolvedValue({ + provider: "openai", + model: "sora-2", + attempts: [], + videos: [{ buffer, mimeType: "video/mp4", fileName: "generated.mp4" }], + }); + } + + const tempDir = tempDirs.make(`openclaw-buffered-${kind}-fail-`); + const outputBase = path.join(tempDir, "result"); + const outputPath = `${outputBase}${extension}`; + await fs.writeFile(outputPath, original); + await fs.chmod(outputPath, 0o640); + + const writeFile = fs.writeFile.bind(fs); + const writeFileSpy = vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { + const [filePath, data, options] = args; + if ( + typeof filePath === "string" && + Buffer.isBuffer(data) && + data.byteLength === buffer.byteLength && + path.dirname(filePath) === tempDir + ) { + await writeFile(filePath, data.subarray(0, 17), options); + throw new Error("injected buffered media write failure"); + } + await writeFile(...args); + }); + + try { + await expect( + runCapability( + kind, + "generate", + "--prompt", + "friendly lobster", + "--output", + outputBase, + "--json", + ), + ).rejects.toThrow("exit 1"); + + expectRuntimeErrorContains("injected buffered media write failure"); + expect(mocks.runtime.writeJson).not.toHaveBeenCalled(); + expect(await fs.readFile(outputPath, "utf8")).toBe(original); + if (process.platform !== "win32") { + expect((await fs.stat(outputPath)).mode & 0o777).toBe(0o640); + } + expect(await fs.readdir(tempDir)).toEqual([`result${extension}`]); + } finally { + writeFileSpy.mockRestore(); + } + }, + ); + it("blocks private-network url-only generated video downloads by default", async () => { mocks.loadConfig.mockReturnValue({}); primeGeneratedVideoUrl("http://127.0.0.2:40123/private-video.mp4?sig=secret-presigned-token"); diff --git a/src/cli/capability-cli/media-output.ts b/src/cli/capability-cli/media-output.ts index 2761b299778d..4972c75b9e81 100644 --- a/src/cli/capability-cli/media-output.ts +++ b/src/cli/capability-cli/media-output.ts @@ -1,8 +1,41 @@ import fs from "node:fs/promises"; import path from "node:path"; import { detectMime, extensionForMime, normalizeMimeType } from "@openclaw/media-core/mime"; +import { writeSiblingTempFile } from "../../infra/sibling-temp-file.js"; import { saveMediaBuffer } from "../../media/store.js"; +const GENERATED_MEDIA_OUTPUT_TEMP_PREFIX = ".openclaw-media-output"; + +async function resolveExistingOutputMode(filePath: string): Promise { + try { + return (await fs.stat(filePath)).mode & 0o7777; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return undefined; + } + throw error; + } +} + +export async function publishOutputFileAtomically(params: { + filePath: string; + writeTemp: (tempPath: string) => Promise; +}): Promise { + const dir = path.dirname(params.filePath); + await fs.mkdir(dir, { recursive: true }); + const mode = await resolveExistingOutputMode(params.filePath); + // Stage beside the destination so producer failures never destroy prior user bytes. + const { result } = await writeSiblingTempFile({ + dir, + chmodDir: false, + tempPrefix: GENERATED_MEDIA_OUTPUT_TEMP_PREFIX, + ...(mode === undefined ? {} : { mode }), + writeTemp: params.writeTemp, + resolveFinalPath: () => params.filePath, + }); + return result; +} + export async function writeOutputAsset(params: { buffer: Buffer; mimeType?: string; @@ -42,8 +75,12 @@ export async function writeOutputAsset(params: { params.outputCount <= 1 ? path.join(parsed.dir, `${parsed.name}${ext}`) : path.join(parsed.dir, `${parsed.name}-${String(params.outputIndex + 1)}${ext}`); - await fs.mkdir(path.dirname(filePath), { recursive: true }); - await fs.writeFile(filePath, params.buffer); + await publishOutputFileAtomically({ + filePath, + writeTemp: async (tempPath) => { + await fs.writeFile(tempPath, params.buffer, { flag: "wx" }); + }, + }); return { path: filePath, mimeType: detectedNormalized ?? params.mimeType, diff --git a/src/cli/capability-cli/video.ts b/src/cli/capability-cli/video.ts index ac897b919571..aa661adb2d10 100644 --- a/src/cli/capability-cli/video.ts +++ b/src/cli/capability-cli/video.ts @@ -14,7 +14,6 @@ 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"; @@ -31,7 +30,7 @@ import { import type { VideoGenerationResolution } from "../../video-generation/types.js"; import { runCommandWithRuntime } from "../cli-utils.js"; import { getModelsCommandSecretTargetIds } from "../command-secret-targets.js"; -import { writeOutputAsset } from "./media-output.js"; +import { publishOutputFileAtomically, writeOutputAsset } from "./media-output.js"; import type { CapabilityEnvelope } from "./metadata.js"; import { emitJsonOrText, @@ -45,18 +44,6 @@ 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 { - 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(); @@ -176,14 +163,8 @@ async function runVideoGenerate(params: { result.videos.length <= 1 ? path.join(parsed.dir, `${parsed.name}${ext}`) : path.join(parsed.dir, `${parsed.name}-${String(index + 1)}${ext}`); - 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 }), + const size = await publishOutputFileAtomically({ + filePath, writeTemp: async (tempPath) => { await pipeline( Readable.fromWeb( @@ -197,7 +178,6 @@ async function runVideoGenerate(params: { } return writtenSize; }, - resolveFinalPath: () => filePath, }); return { path: filePath, mimeType: video.mimeType, size }; }