From 252a76d25c9344899d67cc1a121a020a2f6531c8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 7 May 2026 04:39:31 +0100 Subject: [PATCH] refactor: stage external output writes through fs-safe --- .../browser/src/browser/output-atomic.ts | 12 ++- .../browser/src/browser/pw-session.test.ts | 20 +++- extensions/browser/src/browser/pw-session.ts | 11 ++- .../src/browser/pw-tools-core.downloads.ts | 10 +- ...-core.waits-next-download-saves-it.test.ts | 21 +++-- .../browser/src/sdk-security-runtime.ts | 1 + extensions/diffs/src/browser.ts | 65 +++++++++---- extensions/discord/src/voice-message.test.ts | 29 +++++- extensions/discord/src/voice-message.ts | 92 ++++++++++++------- extensions/feishu/src/media.ts | 53 ++++++----- .../google/video-generation-provider.test.ts | 40 ++++++++ .../google/video-generation-provider.ts | 16 +++- extensions/microsoft/tts.test.ts | 8 +- extensions/microsoft/tts.ts | 35 ++++++- .../discord/discord-live.runtime.ts | 10 +- .../src/mantis/visual-task.runtime.test.ts | 10 +- .../qa-lab/src/mantis/visual-task.runtime.ts | 48 ++++++++-- extensions/tts-local-cli/speech-provider.ts | 55 +++++++---- .../whatsapp/src/outbound-media-contract.ts | 52 ++++++----- package.json | 2 +- pnpm-lock.yaml | 10 +- src/infra/fs-safe.ts | 5 + src/media-understanding/runner.entries.ts | 32 ++++--- src/media/audio-transcode.test.ts | 2 +- src/media/audio-transcode.ts | 57 +++++++----- src/plugin-sdk/security-runtime.ts | 3 + 26 files changed, 486 insertions(+), 213 deletions(-) diff --git a/extensions/browser/src/browser/output-atomic.ts b/extensions/browser/src/browser/output-atomic.ts index e92c5a6abfd..f0586e81237 100644 --- a/extensions/browser/src/browser/output-atomic.ts +++ b/extensions/browser/src/browser/output-atomic.ts @@ -1,13 +1,15 @@ -import { writeViaSiblingTempPath as writeViaSiblingTempPathBase } from "../sdk-security-runtime.js"; +import fs from "node:fs/promises"; +import { writeExternalFileWithinRoot } from "../sdk-security-runtime.js"; export async function writeViaSiblingTempPath(params: { rootDir: string; targetPath: string; writeTemp: (tempPath: string) => Promise; }): Promise { - await writeViaSiblingTempPathBase({ - ...params, - fallbackFileName: "output.bin", - tempPrefix: ".openclaw-output-", + await fs.mkdir(params.rootDir, { recursive: true }); + await writeExternalFileWithinRoot({ + rootDir: params.rootDir, + path: params.targetPath, + write: params.writeTemp, }); } diff --git a/extensions/browser/src/browser/pw-session.test.ts b/extensions/browser/src/browser/pw-session.test.ts index b4ea01a59db..bb94f039fa2 100644 --- a/extensions/browser/src/browser/pw-session.test.ts +++ b/extensions/browser/src/browser/pw-session.test.ts @@ -138,11 +138,19 @@ describe("pw-session role refs cache", () => { describe("pw-session ensurePageState", () => { it("stores unmanaged downloads under unique managed paths", async () => { const { page, handlers } = fakePage(); - const mkdirSpy = vi.spyOn(fs, "mkdir").mockResolvedValue(undefined); + const mkdirActual = fs.mkdir.bind(fs); + const mkdirSpy = vi.spyOn(fs, "mkdir").mockImplementation(async (target, options) => { + await mkdirActual(target, options); + return undefined; + }); ensurePageState(page); - const saveAsA = vi.fn(async () => {}); - const saveAsB = vi.fn(async () => {}); + const saveAsA = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "download-a", "utf8"); + }); + const saveAsB = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "download-b", "utf8"); + }); const downloadA: MutableDownload = { suggestedFilename: () => "report.pdf", saveAs: saveAsA, @@ -163,8 +171,10 @@ describe("pw-session ensurePageState", () => { expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR); expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/); expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/); - expect(saveAsA).toHaveBeenCalledWith(managedPathA); - expect(saveAsB).toHaveBeenCalledWith(managedPathB); + expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA); + expect(saveAsB.mock.calls[0]?.[0]).not.toBe(managedPathB); + await expect(fs.readFile(managedPathA ?? "", "utf8")).resolves.toBe("download-a"); + await expect(fs.readFile(managedPathB ?? "", "utf8")).resolves.toBe("download-b"); expect(mkdirSpy).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true }); }); diff --git a/extensions/browser/src/browser/pw-session.ts b/extensions/browser/src/browser/pw-session.ts index 6dd1e7ef8dc..31241ed2d7d 100644 --- a/extensions/browser/src/browser/pw-session.ts +++ b/extensions/browser/src/browser/pw-session.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import type { @@ -34,6 +33,7 @@ import { InvalidBrowserNavigationUrlError, withBrowserNavigationPolicy, } from "./navigation-guard.js"; +import { writeViaSiblingTempPath } from "./output-atomic.js"; import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; import { playwrightCore } from "./playwright-core.runtime.js"; import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.js"; @@ -466,8 +466,13 @@ export function ensurePageState(page: Page): PageState { ); const managedPath = buildManagedDownloadPath(suggested); const managedSave = (async () => { - await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); - await download.saveAs?.(managedPath); + await writeViaSiblingTempPath({ + rootDir: DEFAULT_DOWNLOAD_DIR, + targetPath: managedPath, + writeTemp: async (tempPath) => { + await download.saveAs?.(tempPath); + }, + }); return managedPath; })(); managedSave.catch(() => {}); diff --git a/extensions/browser/src/browser/pw-tools-core.downloads.ts b/extensions/browser/src/browser/pw-tools-core.downloads.ts index abecd679d69..6a037fc4ce4 100644 --- a/extensions/browser/src/browser/pw-tools-core.downloads.ts +++ b/extensions/browser/src/browser/pw-tools-core.downloads.ts @@ -1,5 +1,4 @@ import crypto from "node:crypto"; -import fs from "node:fs/promises"; import path from "node:path"; import type { Page } from "playwright-core"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; @@ -93,10 +92,15 @@ async function saveDownloadPayload(download: DownloadPayload, outPath: string) { const suggested = download.suggestedFilename?.() || "download.bin"; const requestedPath = outPath?.trim(); const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested)); - await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true }); if (!requestedPath) { - await download.saveAs?.(resolvedOutPath); + await writeViaSiblingTempPath({ + rootDir: path.dirname(resolvedOutPath), + targetPath: resolvedOutPath, + writeTemp: async (tempPath) => { + await download.saveAs?.(tempPath); + }, + }); } else { await writeViaSiblingTempPath({ rootDir: path.dirname(resolvedOutPath), diff --git a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts index 11f93f9278f..606dd2e92fa 100644 --- a/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts +++ b/extensions/browser/src/browser/pw-tools-core.waits-next-download-saves-it.test.ts @@ -78,7 +78,9 @@ describe("pw-tools-core", () => { suggestedFilename: string; }) { const harness = createDownloadEventHarness(); - const saveAs = vi.fn(async () => {}); + const saveAs = vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "download-content", "utf8"); + }); const p = mod.waitForDownloadViaPlaywright({ cdpUrl: "http://127.0.0.1:18792", @@ -135,8 +137,7 @@ describe("pw-tools-core", () => { const savedPath = params.saveAs.mock.calls[0]?.[0]; expect(typeof savedPath).toBe("string"); expect(savedPath).not.toBe(params.targetPath); - expect(path.basename(String(savedPath))).toContain(".openclaw-output-"); - expect(path.basename(String(savedPath))).toContain(".part"); + expect(path.basename(String(savedPath))).toBe(path.basename(params.targetPath)); expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content); await expect(fs.access(String(savedPath))).rejects.toThrow(); } @@ -189,7 +190,9 @@ describe("pw-tools-core", () => { harness.trigger({ url: () => "https://example.com/file.bin", suggestedFilename: () => "file.bin", - saveAs: vi.fn(async () => {}), + saveAs: vi.fn(async (outPath: string) => { + await fs.writeFile(outPath, "file-content", "utf8"); + }), }); await p; @@ -279,8 +282,9 @@ describe("pw-tools-core", () => { path.join(path.sep, "tmp", "openclaw-preferred", "downloads"), ); const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`; - expect(path.dirname(outPath)).toBe(expectedRootedDownloadsDir); - expect(path.basename(outPath)).toMatch(/-file\.bin$/); + expect(path.dirname(res.path)).toBe(expectedRootedDownloadsDir); + expect(path.basename(outPath)).toBe(path.basename(res.path)); + await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content"); expect(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail)); expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled(); }); @@ -292,10 +296,11 @@ describe("pw-tools-core", () => { suggestedFilename: "../../../../etc/passwd", }); expect(typeof outPath).toBe("string"); - expect(path.dirname(outPath)).toBe( + expect(path.dirname(res.path)).toBe( path.resolve(path.join(path.sep, "tmp", "openclaw-preferred", "downloads")), ); - expect(path.basename(outPath)).toMatch(/-passwd$/); + expect(path.basename(outPath)).toBe(path.basename(res.path)); + await expect(fs.readFile(res.path, "utf8")).resolves.toBe("download-content"); expect(path.normalize(res.path)).toContain( path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`), ); diff --git a/extensions/browser/src/sdk-security-runtime.ts b/extensions/browser/src/sdk-security-runtime.ts index e39265146a4..6ed75ed927b 100644 --- a/extensions/browser/src/sdk-security-runtime.ts +++ b/extensions/browser/src/sdk-security-runtime.ts @@ -22,6 +22,7 @@ export { resolveWritablePathWithinRoot, FsSafeError, SsrFBlockedError, + writeExternalFileWithinRoot, writeViaSiblingTempPath, wrapExternalContent, } from "openclaw/plugin-sdk/security-runtime"; diff --git a/extensions/diffs/src/browser.ts b/extensions/diffs/src/browser.ts index 5c2180b500e..ff66a08e651 100644 --- a/extensions/diffs/src/browser.ts +++ b/extensions/diffs/src/browser.ts @@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { chromium } from "playwright-core"; import type { OpenClawConfig } from "../api.js"; import type { DiffRenderOptions, DiffTheme } from "./types.js"; @@ -61,7 +62,6 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter { theme: DiffTheme; image: DiffRenderOptions["image"]; }): Promise { - await fs.mkdir(path.dirname(params.outputPath), { recursive: true }); const lease = await acquireSharedBrowser({ config: this.config, idleMs: this.browserIdleMs, @@ -189,16 +189,22 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter { throw new Error(IMAGE_SIZE_LIMIT_ERROR); } - await page.pdf({ - path: params.outputPath, - width: `${pdfWidth}px`, - height: `${pdfHeight}px`, - printBackground: true, - margin: { - top: "0", - right: "0", - bottom: "0", - left: "0", + const pageForPdf = page; + await writeExternalArtifactFile({ + outputPath: params.outputPath, + write: async (tempPath) => { + await pageForPdf.pdf({ + path: tempPath, + width: `${pdfWidth}px`, + height: `${pdfHeight}px`, + printBackground: true, + margin: { + top: "0", + right: "0", + bottom: "0", + left: "0", + }, + }); }, }); return params.outputPath; @@ -238,15 +244,21 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter { throw new Error(IMAGE_SIZE_LIMIT_ERROR); } - await page.screenshot({ - path: params.outputPath, - type: "png", - scale: "device", - clip: { - x, - y, - width: cssWidth, - height: cssHeight, + const pageForScreenshot = page; + await writeExternalArtifactFile({ + outputPath: params.outputPath, + write: async (tempPath) => { + await pageForScreenshot.screenshot({ + path: tempPath, + type: "png", + scale: "device", + clip: { + x, + y, + width: cssWidth, + height: cssHeight, + }, + }); }, }); return params.outputPath; @@ -268,6 +280,19 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter { } } +async function writeExternalArtifactFile(params: { + outputPath: string; + write: (tempPath: string) => Promise; +}): Promise { + const rootDir = path.dirname(params.outputPath); + await fs.mkdir(rootDir, { recursive: true }); + await writeExternalFileWithinRoot({ + rootDir, + path: path.basename(params.outputPath), + write: params.write, + }); +} + export async function resetSharedBrowserStateForTests(): Promise { executablePathCache = null; await closeSharedBrowser(); diff --git a/extensions/discord/src/voice-message.test.ts b/extensions/discord/src/voice-message.test.ts index c2c81d79e8c..20e4f966b57 100644 --- a/extensions/discord/src/voice-message.test.ts +++ b/extensions/discord/src/voice-message.test.ts @@ -1,3 +1,4 @@ +import fs from "node:fs/promises"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { RequestClient } from "./internal/discord.js"; @@ -71,7 +72,13 @@ describe("ensureOggOpus", () => { it("re-encodes .ogg opus when sample rate is not 48kHz", async () => { runFfprobeMock.mockResolvedValueOnce("opus,24000\n"); - runFfmpegMock.mockResolvedValueOnce(); + runFfmpegMock.mockImplementationOnce(async (args: string[]) => { + const outputPath = args.at(-1); + if (typeof outputPath !== "string") { + throw new Error("missing ffmpeg output path"); + } + await fs.writeFile(outputPath, "ogg"); + }); const result = await ensureOggOpus("/tmp/input.ogg"); @@ -79,20 +86,34 @@ describe("ensureOggOpus", () => { expect(path.dirname(result.path)).toBe(path.normalize("/tmp")); expect(path.basename(result.path)).toMatch(/^voice-.*\.ogg$/); expect(runFfmpegMock).toHaveBeenCalledWith( - expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg", result.path]), + expect.arrayContaining(["-t", "1200", "-ar", "48000", "/tmp/input.ogg"]), ); + const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); + expect(ffmpegOutputPath).not.toBe(result.path); + expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); it("re-encodes non-ogg input with bounded ffmpeg execution", async () => { - runFfmpegMock.mockResolvedValueOnce(); + runFfmpegMock.mockImplementationOnce(async (args: string[]) => { + const outputPath = args.at(-1); + if (typeof outputPath !== "string") { + throw new Error("missing ffmpeg output path"); + } + await fs.writeFile(outputPath, "ogg"); + }); const result = await ensureOggOpus("/tmp/input.mp3"); expect(result.cleanup).toBe(true); expect(runFfprobeMock).not.toHaveBeenCalled(); expect(runFfmpegMock).toHaveBeenCalledWith( - expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3", result.path]), + expect.arrayContaining(["-vn", "-sn", "-dn", "/tmp/input.mp3"]), ); + const ffmpegOutputPath = (runFfmpegMock.mock.calls[0]?.[0] as string[] | undefined)?.at(-1); + expect(ffmpegOutputPath).not.toBe(result.path); + expect(path.basename(ffmpegOutputPath ?? "")).toBe(path.basename(result.path)); + await expect(fs.readFile(result.path, "utf8")).resolves.toBe("ogg"); }); }); diff --git a/extensions/discord/src/voice-message.ts b/extensions/discord/src/voice-message.ts index 03cb24d6119..696ac448310 100644 --- a/extensions/discord/src/voice-message.ts +++ b/extensions/discord/src/voice-message.ts @@ -22,6 +22,7 @@ import { import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime"; import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; import type { RetryRunner } from "openclaw/plugin-sdk/retry-runtime"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; @@ -33,6 +34,21 @@ const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12; const WAVEFORM_SAMPLES = 256; const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000; +async function runFfmpegToOutput(params: { + outputPath: string; + buildArgs: (tempPath: string) => string[]; +}): Promise { + const rootDir = path.dirname(params.outputPath); + await fs.mkdir(rootDir, { recursive: true }); + await writeExternalFileWithinRoot({ + rootDir, + path: path.basename(params.outputPath), + write: async (tempPath) => { + await runFfmpeg(params.buildArgs(tempPath)); + }, + }); +} + function createRateLimitError( response: Response, body: { message: string; retry_after: number; global: boolean }, @@ -104,25 +120,28 @@ async function generateWaveformFromPcm(filePath: string): Promise { try { // Convert to raw 16-bit signed PCM, mono, 8kHz - await runFfmpeg([ - "-y", - "-i", - filePath, - "-vn", - "-sn", - "-dn", - "-t", - String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), - "-f", - "s16le", - "-acodec", - "pcm_s16le", - "-ac", - "1", - "-ar", - "8000", - tempPcm, - ]); + await runFfmpegToOutput({ + outputPath: tempPcm, + buildArgs: (outputPath) => [ + "-y", + "-i", + filePath, + "-vn", + "-sn", + "-dn", + "-t", + String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), + "-f", + "s16le", + "-acodec", + "pcm_s16le", + "-ac", + "1", + "-ar", + "8000", + outputPath, + ], + }); const pcmData = await fs.readFile(tempPcm); const samples = new Int16Array(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength / 2); @@ -214,23 +233,26 @@ export async function ensureOggOpus(filePath: string): Promise<{ path: string; c const tempDir = resolvePreferredOpenClawTmpDir(); const outputPath = path.join(tempDir, `voice-${crypto.randomUUID()}.ogg`); - await runFfmpeg([ - "-y", - "-i", - filePath, - "-vn", - "-sn", - "-dn", - "-t", - String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), - "-ar", - String(DISCORD_OPUS_SAMPLE_RATE_HZ), - "-c:a", - "libopus", - "-b:a", - "64k", + await runFfmpegToOutput({ outputPath, - ]); + buildArgs: (tempPath) => [ + "-y", + "-i", + filePath, + "-vn", + "-sn", + "-dn", + "-t", + String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), + "-ar", + String(DISCORD_OPUS_SAMPLE_RATE_HZ), + "-c:a", + "libopus", + "-b:a", + "64k", + tempPath, + ], + }); return { path: outputPath, cleanup: true }; } diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index 21e9ffd3f01..32336d0e1f8 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -5,7 +5,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk"; import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; -import { readRegularFile } from "openclaw/plugin-sdk/security-runtime"; +import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace, @@ -757,29 +757,34 @@ async function transcodeToFeishuVoiceOpus(params: { const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName)); const inputExt = ext && ext.length <= 12 ? ext : ".audio"; const inputPath = await workspace.write(`input${inputExt}`, params.buffer); - const outputPath = workspace.path(FEISHU_VOICE_FILE_NAME); - await runFfmpeg([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - inputPath, - "-vn", - "-sn", - "-dn", - "-t", - String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), - "-ar", - String(FEISHU_VOICE_SAMPLE_RATE_HZ), - "-ac", - "1", - "-c:a", - "libopus", - "-b:a", - FEISHU_VOICE_BITRATE, - outputPath, - ]); + await writeExternalFileWithinRoot({ + rootDir: workspace.dir, + path: FEISHU_VOICE_FILE_NAME, + write: async (outputPath) => { + await runFfmpeg([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + inputPath, + "-vn", + "-sn", + "-dn", + "-t", + String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), + "-ar", + String(FEISHU_VOICE_SAMPLE_RATE_HZ), + "-ac", + "1", + "-c:a", + "libopus", + "-b:a", + FEISHU_VOICE_BITRATE, + outputPath, + ]); + }, + }); return { buffer: await workspace.read(FEISHU_VOICE_FILE_NAME), fileName: FEISHU_VOICE_FILE_NAME, diff --git a/extensions/google/video-generation-provider.test.ts b/extensions/google/video-generation-provider.test.ts index 1dd194be513..9ea1489297c 100644 --- a/extensions/google/video-generation-provider.test.ts +++ b/extensions/google/video-generation-provider.test.ts @@ -1,3 +1,5 @@ +import { writeFile } from "node:fs/promises"; +import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } = @@ -195,6 +197,44 @@ describe("google video generation provider", () => { expect(result.videos[0]?.mimeType).toBe("video/mp4"); }); + it("stages SDK file downloads before finalizing generated video bytes", async () => { + vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ + apiKey: "google-key", + source: "env", + mode: "api-key", + }); + generateVideosMock.mockResolvedValue({ + done: true, + response: { + generatedVideos: [ + { + video: { + name: "files/generated-video", + mimeType: "video/mp4", + }, + }, + ], + }, + }); + downloadMock.mockImplementation(async ({ downloadPath }: { downloadPath: string }) => { + await writeFile(downloadPath, "sdk-video"); + }); + + const provider = buildGoogleVideoGenerationProvider(); + const result = await provider.generateVideo({ + provider: "google", + model: "veo-3.1-fast-generate-preview", + prompt: "A tiny robot watering a windowsill garden", + cfg: {}, + durationSeconds: 3, + }); + + const [{ downloadPath }] = downloadMock.mock.calls[0] ?? [{}]; + expect(path.basename(String(downloadPath))).toBe("video-1.mp4"); + expect(result.videos[0]?.buffer).toEqual(Buffer.from("sdk-video")); + expect(result.videos[0]?.fileName).toBe("video-1.mp4"); + }); + it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => { vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ apiKey: "google-key", diff --git a/extensions/google/video-generation-provider.ts b/extensions/google/video-generation-provider.ts index eb8e98838e1..c59e995c507 100644 --- a/extensions/google/video-generation-provider.ts +++ b/extensions/google/video-generation-provider.ts @@ -6,6 +6,7 @@ import { resolveProviderOperationTimeoutMs, waitProviderOperationPollInterval, } from "openclaw/plugin-sdk/provider-http"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; @@ -154,10 +155,17 @@ async function downloadGeneratedVideo(params: { return await withTempWorkspace( { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" }, async ({ dir: tempDir }) => { - const downloadPath = path.join(tempDir, `video-${params.index + 1}.mp4`); - await params.client.files.download({ - file: params.file as never, - downloadPath, + const fileName = `video-${params.index + 1}.mp4`; + const downloadPath = path.join(tempDir, fileName); + await writeExternalFileWithinRoot({ + rootDir: tempDir, + path: fileName, + write: async (downloadPath) => { + await params.client.files.download({ + file: params.file as never, + downloadPath, + }); + }, }); const buffer = await readFile(downloadPath); return { diff --git a/extensions/microsoft/tts.test.ts b/extensions/microsoft/tts.test.ts index 4d9dccbb9d2..ea27de79d1c 100644 --- a/extensions/microsoft/tts.test.ts +++ b/extensions/microsoft/tts.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; @@ -92,8 +92,10 @@ describe("edgeTTS empty audio validation", () => { it("succeeds when the output file has content", async () => { tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-")); const outputPath = path.join(tempDir, "voice.mp3"); + let stagedPath = ""; const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { + stagedPath = filePath; writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); }); @@ -108,6 +110,10 @@ describe("edgeTTS empty audio validation", () => { deps, ), ).resolves.toBeUndefined(); + expect(stagedPath).not.toBe(outputPath); + expect(path.basename(stagedPath)).toBe(path.basename(outputPath)); + expect(readFileSync(outputPath)).toEqual(Buffer.from([0xff, 0xfb, 0x90, 0x00])); + expect(existsSync(stagedPath)).toBe(false); }); it("retries once when the first output file is empty", async () => { diff --git a/extensions/microsoft/tts.ts b/extensions/microsoft/tts.ts index c4521e2d943..82f495dea75 100644 --- a/extensions/microsoft/tts.ts +++ b/extensions/microsoft/tts.ts @@ -1,4 +1,7 @@ -import { statSync } from "node:fs"; +import { statSync, writeFileSync } from "node:fs"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; type EdgeTTSRuntimeConfig = { @@ -99,10 +102,36 @@ export async function edgeTTS( }); for (let attempt = 0; attempt < 2; attempt += 1) { - await tts.ttsPromise(text, outputPath); - if (readOutputSize(outputPath) > 0) { + const outputSize = await writeEdgeTtsOutput({ + outputPath, + ttsPromise: async (tempPath) => { + await tts.ttsPromise(text, tempPath); + }, + }); + if (outputSize > 0) { return; } } throw new Error("Edge TTS produced empty audio file after retry"); } + +async function writeEdgeTtsOutput(params: { + outputPath: string; + ttsPromise: (tempPath: string) => Promise; +}): Promise { + const rootDir = path.dirname(params.outputPath); + await mkdir(rootDir, { recursive: true }); + let outputSize = 0; + await writeExternalFileWithinRoot({ + rootDir, + path: path.basename(params.outputPath), + write: async (tempPath) => { + await params.ttsPromise(tempPath); + outputSize = readOutputSize(tempPath); + if (outputSize === 0) { + writeFileSync(tempPath, ""); + } + }, + }); + return outputSize; +} diff --git a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts index 727c0f7e6a3..e838d858df8 100644 --- a/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts +++ b/extensions/qa-lab/src/live-transports/discord/discord-live.runtime.ts @@ -6,6 +6,7 @@ import { handleDiscordMessageAction, requestDiscord } from "@openclaw/discord/ap import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { chromium } from "playwright-core"; import { z } from "zod"; import { startQaGatewayChild } from "../../gateway-child.js"; @@ -710,7 +711,14 @@ async function writeHtmlScreenshot(params: { htmlPath: string; screenshotPath: s waitUntil: "domcontentloaded", timeout: 15_000, }); - await page.screenshot({ path: params.screenshotPath, fullPage: true }); + await fs.mkdir(path.dirname(params.screenshotPath), { recursive: true }); + await writeExternalFileWithinRoot({ + rootDir: path.dirname(params.screenshotPath), + path: path.basename(params.screenshotPath), + write: async (tempPath) => { + await page.screenshot({ path: tempPath, fullPage: true }); + }, + }); return { screenshotPath: params.screenshotPath }; } finally { await browser.close(); diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts index bcfd258906a..37d385e8089 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.test.ts @@ -79,12 +79,17 @@ describe("mantis visual task runtime", () => { ["/tmp/crabbox", "stop"], ]); const recordArgs = commands.find((entry) => entry.args[0] === "record")?.args ?? []; + const finalVideoPath = path.join( + repoRoot, + ".artifacts/qa-e2e/mantis/visual-task-test/visual-task.mp4", + ); + const stagedVideoPath = recordArgs[recordArgs.indexOf("--output") + 1]; expect(recordArgs).toEqual( expect.arrayContaining([ "--duration", "12s", "--output", - path.join(repoRoot, ".artifacts/qa-e2e/mantis/visual-task-test/visual-task.mp4"), + stagedVideoPath, "--while", "--", "pnpm", @@ -96,6 +101,9 @@ describe("mantis visual task runtime", () => { "visual-driver", ]), ); + expect(stagedVideoPath).not.toBe(finalVideoPath); + expect(path.basename(stagedVideoPath ?? "")).toBe(path.basename(finalVideoPath)); + await expect(fs.stat(stagedVideoPath ?? "")).rejects.toThrow(); await expect(fs.readFile(result.screenshotPath ?? "", "utf8")).resolves.toBe("png"); await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as { diff --git a/extensions/qa-lab/src/mantis/visual-task.runtime.ts b/extensions/qa-lab/src/mantis/visual-task.runtime.ts index 57d0e272bcf..f81d655508d 100644 --- a/extensions/qa-lab/src/mantis/visual-task.runtime.ts +++ b/extensions/qa-lab/src/mantis/visual-task.runtime.ts @@ -2,7 +2,7 @@ import { spawn, type SpawnOptions } from "node:child_process"; import fs from "node:fs/promises"; import path from "node:path"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; -import { pathExists } from "openclaw/plugin-sdk/security-runtime"; +import { pathExists, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js"; export type MantisVisualTaskVisionMode = "image-describe" | "metadata"; @@ -285,6 +285,30 @@ async function runCommand(params: { }); } +async function runCommandWithExternalOutput(params: { + outputPath: string; + buildArgs: (tempPath: string) => readonly string[]; + command: string; + cwd: string; + env: NodeJS.ProcessEnv; + runner: CommandRunner; + stdio?: "inherit" | "pipe"; +}) { + return await writeExternalFileWithinRoot({ + rootDir: path.dirname(params.outputPath), + path: path.basename(params.outputPath), + write: async (tempPath) => + await runCommand({ + command: params.command, + args: params.buildArgs(tempPath), + cwd: params.cwd, + env: params.env, + runner: params.runner, + stdio: params.stdio, + }), + }); +} + async function warmupCrabbox(params: { crabboxBin: string; cwd: string; @@ -629,16 +653,17 @@ export async function runMantisVisualDriver( stdio: "inherit", }); await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS)); - await runCommand({ + await runCommandWithExternalOutput({ command: crabboxBin, - args: [ + outputPath: screenshotPath, + buildArgs: (tempPath) => [ "screenshot", "--provider", provider, "--id", leaseId, "--output", - screenshotPath, + tempPath, "--reclaim", ], cwd: repoRoot, @@ -777,19 +802,24 @@ export async function runMantisVisualTask( runner, }); let recordingError: string | undefined; + const activeLeaseId = leaseId; + if (!activeLeaseId) { + throw new Error("Crabbox lease id missing after warmup."); + } try { - await runCommand({ + await runCommandWithExternalOutput({ command: crabboxBin, - args: [ + outputPath: videoPath, + buildArgs: (tempPath) => [ "record", "--provider", provider, "--id", - leaseId, + activeLeaseId, "--duration", trimToValue(opts.duration) ?? DEFAULT_DURATION, "--output", - videoPath, + tempPath, "--while", "--", "pnpm", @@ -797,7 +827,7 @@ export async function runMantisVisualTask( browserUrl, crabboxBin, expectText, - leaseId, + leaseId: activeLeaseId, outputDir, provider, repoRoot, diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts index 432e109ebe4..aece764806c 100644 --- a/extensions/tts-local-cli/speech-provider.ts +++ b/extensions/tts-local-cli/speech-provider.ts @@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import type { SpeechProviderConfig, SpeechProviderPlugin, @@ -270,36 +271,50 @@ async function convertAudio( outputDir: string, target: OutputFormat, ): Promise { - const outputPath = path.join(outputDir, `converted${getFileExt(target)}`); + const outputFileName = `converted${getFileExt(target)}`; + const outputPath = path.join(outputDir, outputFileName); const args = ["-y", "-i", inputPath]; if (target === "opus") { - args.push("-c:a", "libopus", "-b:a", "64k", outputPath); + args.push("-c:a", "libopus", "-b:a", "64k"); } else if (target === "wav") { - args.push("-c:a", "pcm_s16le", outputPath); + args.push("-c:a", "pcm_s16le"); } else { - args.push("-c:a", "libmp3lame", "-b:a", "128k", outputPath); + args.push("-c:a", "libmp3lame", "-b:a", "128k"); } - await runFfmpeg(args); + await writeExternalFileWithinRoot({ + rootDir: outputDir, + path: outputFileName, + write: async (tempPath) => { + await runFfmpeg([...args, tempPath]); + }, + }); return readFileSync(outputPath); } async function convertToRawPcm(inputPath: string, outputDir: string): Promise { // Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers) - const outputPath = path.join(outputDir, "telephony.pcm"); - await runFfmpeg([ - "-y", - "-i", - inputPath, - "-c:a", - "pcm_s16le", - "-ar", - "16000", - "-ac", - "1", - "-f", - "s16le", - outputPath, - ]); + const outputFileName = "telephony.pcm"; + const outputPath = path.join(outputDir, outputFileName); + await writeExternalFileWithinRoot({ + rootDir: outputDir, + path: outputFileName, + write: async (tempPath) => { + await runFfmpeg([ + "-y", + "-i", + inputPath, + "-c:a", + "pcm_s16le", + "-ar", + "16000", + "-ac", + "1", + "-f", + "s16le", + tempPath, + ]); + }, + }); return readFileSync(outputPath); } diff --git a/extensions/whatsapp/src/outbound-media-contract.ts b/extensions/whatsapp/src/outbound-media-contract.ts index 7ed5d83529d..a40bc576d08 100644 --- a/extensions/whatsapp/src/outbound-media-contract.ts +++ b/extensions/whatsapp/src/outbound-media-contract.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime"; +import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { formatError } from "./session-errors.js"; import { @@ -189,29 +190,34 @@ async function transcodeToWhatsAppVoiceOpus(params: { const ext = path.extname(params.fileName).toLowerCase(); const inputExt = ext && ext.length <= 12 ? ext : ".audio"; const inputPath = await workspace.write(`input${inputExt}`, params.buffer); - const outputPath = workspace.path(WHATSAPP_VOICE_FILE_NAME); - await runFfmpeg([ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - inputPath, - "-vn", - "-sn", - "-dn", - "-t", - String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), - "-ar", - String(WHATSAPP_VOICE_SAMPLE_RATE_HZ), - "-ac", - "1", - "-c:a", - "libopus", - "-b:a", - WHATSAPP_VOICE_BITRATE, - outputPath, - ]); + await writeExternalFileWithinRoot({ + rootDir: workspace.dir, + path: WHATSAPP_VOICE_FILE_NAME, + write: async (outputPath) => { + await runFfmpeg([ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + inputPath, + "-vn", + "-sn", + "-dn", + "-t", + String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), + "-ar", + String(WHATSAPP_VOICE_SAMPLE_RATE_HZ), + "-ac", + "1", + "-c:a", + "libopus", + "-b:a", + WHATSAPP_VOICE_BITRATE, + outputPath, + ]); + }, + }); return await workspace.read(WHATSAPP_VOICE_FILE_NAME); }, ); diff --git a/package.json b/package.json index ede72ba12a8..540f06a98fa 100644 --- a/package.json +++ b/package.json @@ -1695,7 +1695,7 @@ "@mariozechner/pi-tui": "0.73.0", "@modelcontextprotocol/sdk": "1.29.0", "@mozilla/readability": "^0.6.0", - "@openclaw/fs-safe": "github:openclaw/fs-safe#3412e03c09cdfd31c2da04b7d74e39ad7a92d07d", + "@openclaw/fs-safe": "github:openclaw/fs-safe#ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c", "@slack/bolt": "^4.7.2", "@slack/types": "^2.21.0", "@slack/web-api": "^7.15.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fb0915a9093..ad6229d5872 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -105,8 +105,8 @@ importers: specifier: ^0.6.0 version: 0.6.0 '@openclaw/fs-safe': - specifier: github:openclaw/fs-safe#3412e03c09cdfd31c2da04b7d74e39ad7a92d07d - version: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d + specifier: github:openclaw/fs-safe#ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c + version: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c '@slack/bolt': specifier: ^4.7.2 version: 4.7.2(@types/express@5.0.6) @@ -3234,8 +3234,8 @@ packages: cpu: [x64] os: [win32] - '@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d': - resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d} + '@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c': + resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c} version: 0.1.2 engines: {node: '>=20.11'} @@ -10004,7 +10004,7 @@ snapshots: '@openai/codex@0.128.0-win32-x64': optional: true - '@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d': + '@openclaw/fs-safe@https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c': optionalDependencies: jszip: 3.10.1 tar: 7.5.13 diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index 298def7cdbc..ce7eac30f18 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -45,6 +45,11 @@ export { type WalkDirectoryResult, } from "@openclaw/fs-safe/walk"; export { withTimeout } from "@openclaw/fs-safe/advanced"; +export { + writeExternalFileWithinRoot, + type ExternalFileWriteOptions, + type ExternalFileWriteResult, +} from "@openclaw/fs-safe/output"; /** @deprecated Use root(rootDir).read(relativePath, options). */ export async function readFileWithinRoot(params: { diff --git a/src/media-understanding/runner.entries.ts b/src/media-understanding/runner.entries.ts index 1a4f49dec6b..ac1027a7ad2 100644 --- a/src/media-understanding/runner.entries.ts +++ b/src/media-understanding/runner.entries.ts @@ -17,6 +17,7 @@ import type { MediaUnderstandingModelConfig, } from "../config/types.tools.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; +import { writeExternalFileWithinRoot } from "../infra/fs-safe.js"; import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runFfmpeg } from "../media/ffmpeg-exec.js"; @@ -252,18 +253,25 @@ async function resolveCliMediaPath(params: { } const wavPath = path.join(params.outputDir, `${path.parse(params.mediaPath).name}.wav`); - await runFfmpeg([ - "-y", - "-i", - params.mediaPath, - "-ac", - "1", - "-ar", - "16000", - "-c:a", - "pcm_s16le", - wavPath, - ]); + await fs.mkdir(params.outputDir, { recursive: true }); + await writeExternalFileWithinRoot({ + rootDir: params.outputDir, + path: path.basename(wavPath), + write: async (outputPath) => { + await runFfmpeg([ + "-y", + "-i", + params.mediaPath, + "-ac", + "1", + "-ar", + "16000", + "-c:a", + "pcm_s16le", + outputPath, + ]); + }, + }); return wavPath; } diff --git a/src/media/audio-transcode.test.ts b/src/media/audio-transcode.test.ts index e0df3ac1911..b0c28328e17 100644 --- a/src/media/audio-transcode.test.ts +++ b/src/media/audio-transcode.test.ts @@ -94,6 +94,6 @@ describe("transcodeAudioBufferToOpus", () => { const tempRoot = realpathSync(resolvePreferredOpenClawTmpDir()); expect(capturedInputPath?.startsWith(tempRoot)).toBe(true); - expect(capturedOutputPath?.startsWith(tempRoot)).toBe(true); + expect(capturedOutputPath ? existsSync(capturedOutputPath) : true).toBe(false); }); }); diff --git a/src/media/audio-transcode.ts b/src/media/audio-transcode.ts index c512bb5b998..bd838ca0d3e 100644 --- a/src/media/audio-transcode.ts +++ b/src/media/audio-transcode.ts @@ -1,4 +1,5 @@ import path from "node:path"; +import { writeExternalFileWithinRoot } from "../infra/fs-safe.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { runFfmpeg } from "./ffmpeg-exec.js"; @@ -60,31 +61,37 @@ export async function transcodeAudioBufferToOpus(params: { `input${normalizeAudioExtension(params)}`, params.audioBuffer, ); - const outputPath = workspace.path(normalizeOutputFileName(params.outputFileName)); - await runFfmpeg( - [ - "-hide_banner", - "-loglevel", - "error", - "-y", - "-i", - inputPath, - "-vn", - "-sn", - "-dn", - "-c:a", - "libopus", - "-b:a", - params.bitrate ?? DEFAULT_OPUS_BITRATE, - "-ar", - String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ), - "-ac", - String(params.channels ?? DEFAULT_OPUS_CHANNELS), - outputPath, - ], - { timeoutMs: params.timeoutMs }, - ); - return await workspace.read(normalizeOutputFileName(params.outputFileName)); + const outputFileName = normalizeOutputFileName(params.outputFileName); + await writeExternalFileWithinRoot({ + rootDir: workspace.dir, + path: outputFileName, + write: async (outputPath) => { + await runFfmpeg( + [ + "-hide_banner", + "-loglevel", + "error", + "-y", + "-i", + inputPath, + "-vn", + "-sn", + "-dn", + "-c:a", + "libopus", + "-b:a", + params.bitrate ?? DEFAULT_OPUS_BITRATE, + "-ar", + String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ), + "-ac", + String(params.channels ?? DEFAULT_OPUS_CHANNELS), + outputPath, + ], + { timeoutMs: params.timeoutMs }, + ); + }, + }); + return await workspace.read(outputFileName); }, ); } diff --git a/src/plugin-sdk/security-runtime.ts b/src/plugin-sdk/security-runtime.ts index 72e959b6379..ba49f952e2b 100644 --- a/src/plugin-sdk/security-runtime.ts +++ b/src/plugin-sdk/security-runtime.ts @@ -32,7 +32,10 @@ export { resolveRegularFileAppendFlags, root, statRegularFileSync, + writeExternalFileWithinRoot, withTimeout, + type ExternalFileWriteOptions, + type ExternalFileWriteResult, type FsSafeErrorCode as SafeOpenErrorCode, } from "../infra/fs-safe.js";