refactor: stage external output writes through fs-safe

This commit is contained in:
Peter Steinberger
2026-05-07 04:39:31 +01:00
parent 759965a316
commit 252a76d25c
26 changed files with 486 additions and 213 deletions

View File

@@ -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: { export async function writeViaSiblingTempPath(params: {
rootDir: string; rootDir: string;
targetPath: string; targetPath: string;
writeTemp: (tempPath: string) => Promise<void>; writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> { }): Promise<void> {
await writeViaSiblingTempPathBase({ await fs.mkdir(params.rootDir, { recursive: true });
...params, await writeExternalFileWithinRoot({
fallbackFileName: "output.bin", rootDir: params.rootDir,
tempPrefix: ".openclaw-output-", path: params.targetPath,
write: params.writeTemp,
}); });
} }

View File

@@ -138,11 +138,19 @@ describe("pw-session role refs cache", () => {
describe("pw-session ensurePageState", () => { describe("pw-session ensurePageState", () => {
it("stores unmanaged downloads under unique managed paths", async () => { it("stores unmanaged downloads under unique managed paths", async () => {
const { page, handlers } = fakePage(); 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); ensurePageState(page);
const saveAsA = vi.fn(async () => {}); const saveAsA = vi.fn(async (outPath: string) => {
const saveAsB = vi.fn(async () => {}); await fs.writeFile(outPath, "download-a", "utf8");
});
const saveAsB = vi.fn(async (outPath: string) => {
await fs.writeFile(outPath, "download-b", "utf8");
});
const downloadA: MutableDownload = { const downloadA: MutableDownload = {
suggestedFilename: () => "report.pdf", suggestedFilename: () => "report.pdf",
saveAs: saveAsA, saveAs: saveAsA,
@@ -163,8 +171,10 @@ describe("pw-session ensurePageState", () => {
expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR); expect(path.dirname(managedPathB ?? "")).toBe(DEFAULT_DOWNLOAD_DIR);
expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/); expect(path.basename(managedPathA ?? "")).toMatch(/-report\.pdf$/);
expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/); expect(path.basename(managedPathB ?? "")).toMatch(/-report\.pdf$/);
expect(saveAsA).toHaveBeenCalledWith(managedPathA); expect(saveAsA.mock.calls[0]?.[0]).not.toBe(managedPathA);
expect(saveAsB).toHaveBeenCalledWith(managedPathB); 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 }); expect(mkdirSpy).toHaveBeenCalledWith(DEFAULT_DOWNLOAD_DIR, { recursive: true });
}); });

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import type { import type {
@@ -34,6 +33,7 @@ import {
InvalidBrowserNavigationUrlError, InvalidBrowserNavigationUrlError,
withBrowserNavigationPolicy, withBrowserNavigationPolicy,
} from "./navigation-guard.js"; } from "./navigation-guard.js";
import { writeViaSiblingTempPath } from "./output-atomic.js";
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js"; import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
import { playwrightCore } from "./playwright-core.runtime.js"; import { playwrightCore } from "./playwright-core.runtime.js";
import { BROWSER_REF_MARKER_ATTRIBUTE, withPageScopedCdpClient } from "./pw-session.page-cdp.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 managedPath = buildManagedDownloadPath(suggested);
const managedSave = (async () => { const managedSave = (async () => {
await fs.mkdir(DEFAULT_DOWNLOAD_DIR, { recursive: true }); await writeViaSiblingTempPath({
await download.saveAs?.(managedPath); rootDir: DEFAULT_DOWNLOAD_DIR,
targetPath: managedPath,
writeTemp: async (tempPath) => {
await download.saveAs?.(tempPath);
},
});
return managedPath; return managedPath;
})(); })();
managedSave.catch(() => {}); managedSave.catch(() => {});

View File

@@ -1,5 +1,4 @@
import crypto from "node:crypto"; import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import type { Page } from "playwright-core"; import type { Page } from "playwright-core";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; 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 suggested = download.suggestedFilename?.() || "download.bin";
const requestedPath = outPath?.trim(); const requestedPath = outPath?.trim();
const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested)); const resolvedOutPath = path.resolve(requestedPath || buildTempDownloadPath(suggested));
await fs.mkdir(path.dirname(resolvedOutPath), { recursive: true });
if (!requestedPath) { if (!requestedPath) {
await download.saveAs?.(resolvedOutPath); await writeViaSiblingTempPath({
rootDir: path.dirname(resolvedOutPath),
targetPath: resolvedOutPath,
writeTemp: async (tempPath) => {
await download.saveAs?.(tempPath);
},
});
} else { } else {
await writeViaSiblingTempPath({ await writeViaSiblingTempPath({
rootDir: path.dirname(resolvedOutPath), rootDir: path.dirname(resolvedOutPath),

View File

@@ -78,7 +78,9 @@ describe("pw-tools-core", () => {
suggestedFilename: string; suggestedFilename: string;
}) { }) {
const harness = createDownloadEventHarness(); 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({ const p = mod.waitForDownloadViaPlaywright({
cdpUrl: "http://127.0.0.1:18792", cdpUrl: "http://127.0.0.1:18792",
@@ -135,8 +137,7 @@ describe("pw-tools-core", () => {
const savedPath = params.saveAs.mock.calls[0]?.[0]; const savedPath = params.saveAs.mock.calls[0]?.[0];
expect(typeof savedPath).toBe("string"); expect(typeof savedPath).toBe("string");
expect(savedPath).not.toBe(params.targetPath); expect(savedPath).not.toBe(params.targetPath);
expect(path.basename(String(savedPath))).toContain(".openclaw-output-"); expect(path.basename(String(savedPath))).toBe(path.basename(params.targetPath));
expect(path.basename(String(savedPath))).toContain(".part");
expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content); expect(await fs.readFile(params.targetPath, "utf8")).toBe(params.content);
await expect(fs.access(String(savedPath))).rejects.toThrow(); await expect(fs.access(String(savedPath))).rejects.toThrow();
} }
@@ -189,7 +190,9 @@ describe("pw-tools-core", () => {
harness.trigger({ harness.trigger({
url: () => "https://example.com/file.bin", url: () => "https://example.com/file.bin",
suggestedFilename: () => "file.bin", suggestedFilename: () => "file.bin",
saveAs: vi.fn(async () => {}), saveAs: vi.fn(async (outPath: string) => {
await fs.writeFile(outPath, "file-content", "utf8");
}),
}); });
await p; await p;
@@ -279,8 +282,9 @@ describe("pw-tools-core", () => {
path.join(path.sep, "tmp", "openclaw-preferred", "downloads"), path.join(path.sep, "tmp", "openclaw-preferred", "downloads"),
); );
const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`; const expectedDownloadsTail = `${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`;
expect(path.dirname(outPath)).toBe(expectedRootedDownloadsDir); expect(path.dirname(res.path)).toBe(expectedRootedDownloadsDir);
expect(path.basename(outPath)).toMatch(/-file\.bin$/); 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(path.normalize(res.path)).toContain(path.normalize(expectedDownloadsTail));
expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled(); expect(tmpDirMocks.resolvePreferredOpenClawTmpDir).toHaveBeenCalled();
}); });
@@ -292,10 +296,11 @@ describe("pw-tools-core", () => {
suggestedFilename: "../../../../etc/passwd", suggestedFilename: "../../../../etc/passwd",
}); });
expect(typeof outPath).toBe("string"); 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")), 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( expect(path.normalize(res.path)).toContain(
path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`), path.normalize(`${path.join("tmp", "openclaw-preferred", "downloads")}${path.sep}`),
); );

View File

@@ -22,6 +22,7 @@ export {
resolveWritablePathWithinRoot, resolveWritablePathWithinRoot,
FsSafeError, FsSafeError,
SsrFBlockedError, SsrFBlockedError,
writeExternalFileWithinRoot,
writeViaSiblingTempPath, writeViaSiblingTempPath,
wrapExternalContent, wrapExternalContent,
} from "openclaw/plugin-sdk/security-runtime"; } from "openclaw/plugin-sdk/security-runtime";

View File

@@ -2,6 +2,7 @@ import { constants as fsConstants } from "node:fs";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import type { OpenClawConfig } from "../api.js"; import type { OpenClawConfig } from "../api.js";
import type { DiffRenderOptions, DiffTheme } from "./types.js"; import type { DiffRenderOptions, DiffTheme } from "./types.js";
@@ -61,7 +62,6 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
theme: DiffTheme; theme: DiffTheme;
image: DiffRenderOptions["image"]; image: DiffRenderOptions["image"];
}): Promise<string> { }): Promise<string> {
await fs.mkdir(path.dirname(params.outputPath), { recursive: true });
const lease = await acquireSharedBrowser({ const lease = await acquireSharedBrowser({
config: this.config, config: this.config,
idleMs: this.browserIdleMs, idleMs: this.browserIdleMs,
@@ -189,16 +189,22 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
throw new Error(IMAGE_SIZE_LIMIT_ERROR); throw new Error(IMAGE_SIZE_LIMIT_ERROR);
} }
await page.pdf({ const pageForPdf = page;
path: params.outputPath, await writeExternalArtifactFile({
width: `${pdfWidth}px`, outputPath: params.outputPath,
height: `${pdfHeight}px`, write: async (tempPath) => {
printBackground: true, await pageForPdf.pdf({
margin: { path: tempPath,
top: "0", width: `${pdfWidth}px`,
right: "0", height: `${pdfHeight}px`,
bottom: "0", printBackground: true,
left: "0", margin: {
top: "0",
right: "0",
bottom: "0",
left: "0",
},
});
}, },
}); });
return params.outputPath; return params.outputPath;
@@ -238,15 +244,21 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
throw new Error(IMAGE_SIZE_LIMIT_ERROR); throw new Error(IMAGE_SIZE_LIMIT_ERROR);
} }
await page.screenshot({ const pageForScreenshot = page;
path: params.outputPath, await writeExternalArtifactFile({
type: "png", outputPath: params.outputPath,
scale: "device", write: async (tempPath) => {
clip: { await pageForScreenshot.screenshot({
x, path: tempPath,
y, type: "png",
width: cssWidth, scale: "device",
height: cssHeight, clip: {
x,
y,
width: cssWidth,
height: cssHeight,
},
});
}, },
}); });
return params.outputPath; return params.outputPath;
@@ -268,6 +280,19 @@ export class PlaywrightDiffScreenshotter implements DiffScreenshotter {
} }
} }
async function writeExternalArtifactFile(params: {
outputPath: string;
write: (tempPath: string) => Promise<void>;
}): Promise<void> {
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<void> { export async function resetSharedBrowserStateForTests(): Promise<void> {
executablePathCache = null; executablePathCache = null;
await closeSharedBrowser(); await closeSharedBrowser();

View File

@@ -1,3 +1,4 @@
import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { RequestClient } from "./internal/discord.js"; 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 () => { it("re-encodes .ogg opus when sample rate is not 48kHz", async () => {
runFfprobeMock.mockResolvedValueOnce("opus,24000\n"); 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"); const result = await ensureOggOpus("/tmp/input.ogg");
@@ -79,20 +86,34 @@ describe("ensureOggOpus", () => {
expect(path.dirname(result.path)).toBe(path.normalize("/tmp")); expect(path.dirname(result.path)).toBe(path.normalize("/tmp"));
expect(path.basename(result.path)).toMatch(/^voice-.*\.ogg$/); expect(path.basename(result.path)).toMatch(/^voice-.*\.ogg$/);
expect(runFfmpegMock).toHaveBeenCalledWith( 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 () => { 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"); const result = await ensureOggOpus("/tmp/input.mp3");
expect(result.cleanup).toBe(true); expect(result.cleanup).toBe(true);
expect(runFfprobeMock).not.toHaveBeenCalled(); expect(runFfprobeMock).not.toHaveBeenCalled();
expect(runFfmpegMock).toHaveBeenCalledWith( 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");
}); });
}); });

View File

@@ -22,6 +22,7 @@ import {
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS } from "openclaw/plugin-sdk/media-runtime";
import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime"; import { unlinkIfExists } from "openclaw/plugin-sdk/media-runtime";
import type { RetryRunner } from "openclaw/plugin-sdk/retry-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 { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path"; import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
@@ -33,6 +34,21 @@ const SUPPRESS_NOTIFICATIONS_FLAG = 1 << 12;
const WAVEFORM_SAMPLES = 256; const WAVEFORM_SAMPLES = 256;
const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000; const DISCORD_OPUS_SAMPLE_RATE_HZ = 48_000;
async function runFfmpegToOutput(params: {
outputPath: string;
buildArgs: (tempPath: string) => string[];
}): Promise<void> {
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( function createRateLimitError(
response: Response, response: Response,
body: { message: string; retry_after: number; global: boolean }, body: { message: string; retry_after: number; global: boolean },
@@ -104,25 +120,28 @@ async function generateWaveformFromPcm(filePath: string): Promise<string> {
try { try {
// Convert to raw 16-bit signed PCM, mono, 8kHz // Convert to raw 16-bit signed PCM, mono, 8kHz
await runFfmpeg([ await runFfmpegToOutput({
"-y", outputPath: tempPcm,
"-i", buildArgs: (outputPath) => [
filePath, "-y",
"-vn", "-i",
"-sn", filePath,
"-dn", "-vn",
"-t", "-sn",
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), "-dn",
"-f", "-t",
"s16le", String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
"-acodec", "-f",
"pcm_s16le", "s16le",
"-ac", "-acodec",
"1", "pcm_s16le",
"-ar", "-ac",
"8000", "1",
tempPcm, "-ar",
]); "8000",
outputPath,
],
});
const pcmData = await fs.readFile(tempPcm); const pcmData = await fs.readFile(tempPcm);
const samples = new Int16Array(pcmData.buffer, pcmData.byteOffset, pcmData.byteLength / 2); 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 tempDir = resolvePreferredOpenClawTmpDir();
const outputPath = path.join(tempDir, `voice-${crypto.randomUUID()}.ogg`); const outputPath = path.join(tempDir, `voice-${crypto.randomUUID()}.ogg`);
await runFfmpeg([ await runFfmpegToOutput({
"-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",
outputPath, 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 }; return { path: outputPath, cleanup: true };
} }

View File

@@ -5,7 +5,7 @@ import type * as Lark from "@larksuiteoapi/node-sdk";
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message"; import type { MessageReceipt } from "openclaw/plugin-sdk/channel-message";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime"; import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; 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 { import {
resolvePreferredOpenClawTmpDir, resolvePreferredOpenClawTmpDir,
withTempWorkspace, withTempWorkspace,
@@ -757,29 +757,34 @@ async function transcodeToFeishuVoiceOpus(params: {
const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName)); const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
const inputExt = ext && ext.length <= 12 ? ext : ".audio"; const inputExt = ext && ext.length <= 12 ? ext : ".audio";
const inputPath = await workspace.write(`input${inputExt}`, params.buffer); const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
const outputPath = workspace.path(FEISHU_VOICE_FILE_NAME); await writeExternalFileWithinRoot({
await runFfmpeg([ rootDir: workspace.dir,
"-hide_banner", path: FEISHU_VOICE_FILE_NAME,
"-loglevel", write: async (outputPath) => {
"error", await runFfmpeg([
"-y", "-hide_banner",
"-i", "-loglevel",
inputPath, "error",
"-vn", "-y",
"-sn", "-i",
"-dn", inputPath,
"-t", "-vn",
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), "-sn",
"-ar", "-dn",
String(FEISHU_VOICE_SAMPLE_RATE_HZ), "-t",
"-ac", String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
"1", "-ar",
"-c:a", String(FEISHU_VOICE_SAMPLE_RATE_HZ),
"libopus", "-ac",
"-b:a", "1",
FEISHU_VOICE_BITRATE, "-c:a",
outputPath, "libopus",
]); "-b:a",
FEISHU_VOICE_BITRATE,
outputPath,
]);
},
});
return { return {
buffer: await workspace.read(FEISHU_VOICE_FILE_NAME), buffer: await workspace.read(FEISHU_VOICE_FILE_NAME),
fileName: FEISHU_VOICE_FILE_NAME, fileName: FEISHU_VOICE_FILE_NAME,

View File

@@ -1,3 +1,5 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } = const { createGoogleGenAIMock, downloadMock, generateVideosMock, getVideosOperationMock } =
@@ -195,6 +197,44 @@ describe("google video generation provider", () => {
expect(result.videos[0]?.mimeType).toBe("video/mp4"); 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 () => { it("falls back to REST predictLongRunning when text-only SDK video generation returns 404", async () => {
vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({ vi.spyOn(providerAuthRuntime, "resolveApiKeyForProvider").mockResolvedValue({
apiKey: "google-key", apiKey: "google-key",

View File

@@ -6,6 +6,7 @@ import {
resolveProviderOperationTimeoutMs, resolveProviderOperationTimeoutMs,
waitProviderOperationPollInterval, waitProviderOperationPollInterval,
} from "openclaw/plugin-sdk/provider-http"; } from "openclaw/plugin-sdk/provider-http";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path"; import { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
@@ -154,10 +155,17 @@ async function downloadGeneratedVideo(params: {
return await withTempWorkspace( return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" }, { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "openclaw-google-video-" },
async ({ dir: tempDir }) => { async ({ dir: tempDir }) => {
const downloadPath = path.join(tempDir, `video-${params.index + 1}.mp4`); const fileName = `video-${params.index + 1}.mp4`;
await params.client.files.download({ const downloadPath = path.join(tempDir, fileName);
file: params.file as never, await writeExternalFileWithinRoot({
downloadPath, rootDir: tempDir,
path: fileName,
write: async (downloadPath) => {
await params.client.files.download({
file: params.file as never,
downloadPath,
});
},
}); });
const buffer = await readFile(downloadPath); const buffer = await readFile(downloadPath);
return { return {

View File

@@ -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 { tmpdir } from "node:os";
import path from "node:path"; import path from "node:path";
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; 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 () => { it("succeeds when the output file has content", async () => {
tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-")); tempDir = mkdtempSync(path.join(tmpdir(), "tts-test-"));
const outputPath = path.join(tempDir, "voice.mp3"); const outputPath = path.join(tempDir, "voice.mp3");
let stagedPath = "";
const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => { const deps = createEdgeTTSDeps(async (_text: string, filePath: string) => {
stagedPath = filePath;
writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00])); writeFileSync(filePath, Buffer.from([0xff, 0xfb, 0x90, 0x00]));
}); });
@@ -108,6 +110,10 @@ describe("edgeTTS empty audio validation", () => {
deps, deps,
), ),
).resolves.toBeUndefined(); ).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 () => { it("retries once when the first output file is empty", async () => {

View File

@@ -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"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
type EdgeTTSRuntimeConfig = { type EdgeTTSRuntimeConfig = {
@@ -99,10 +102,36 @@ export async function edgeTTS(
}); });
for (let attempt = 0; attempt < 2; attempt += 1) { for (let attempt = 0; attempt < 2; attempt += 1) {
await tts.ttsPromise(text, outputPath); const outputSize = await writeEdgeTtsOutput({
if (readOutputSize(outputPath) > 0) { outputPath,
ttsPromise: async (tempPath) => {
await tts.ttsPromise(text, tempPath);
},
});
if (outputSize > 0) {
return; return;
} }
} }
throw new Error("Edge TTS produced empty audio file after retry"); throw new Error("Edge TTS produced empty audio file after retry");
} }
async function writeEdgeTtsOutput(params: {
outputPath: string;
ttsPromise: (tempPath: string) => Promise<void>;
}): Promise<number> {
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;
}

View File

@@ -6,6 +6,7 @@ import { handleDiscordMessageAction, requestDiscord } from "@openclaw/discord/ap
import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback"; import { DEFAULT_EMOJIS } from "openclaw/plugin-sdk/channel-feedback";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-types";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { chromium } from "playwright-core"; import { chromium } from "playwright-core";
import { z } from "zod"; import { z } from "zod";
import { startQaGatewayChild } from "../../gateway-child.js"; import { startQaGatewayChild } from "../../gateway-child.js";
@@ -710,7 +711,14 @@ async function writeHtmlScreenshot(params: { htmlPath: string; screenshotPath: s
waitUntil: "domcontentloaded", waitUntil: "domcontentloaded",
timeout: 15_000, 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 }; return { screenshotPath: params.screenshotPath };
} finally { } finally {
await browser.close(); await browser.close();

View File

@@ -79,12 +79,17 @@ describe("mantis visual task runtime", () => {
["/tmp/crabbox", "stop"], ["/tmp/crabbox", "stop"],
]); ]);
const recordArgs = commands.find((entry) => entry.args[0] === "record")?.args ?? []; 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(recordArgs).toEqual(
expect.arrayContaining([ expect.arrayContaining([
"--duration", "--duration",
"12s", "12s",
"--output", "--output",
path.join(repoRoot, ".artifacts/qa-e2e/mantis/visual-task-test/visual-task.mp4"), stagedVideoPath,
"--while", "--while",
"--", "--",
"pnpm", "pnpm",
@@ -96,6 +101,9 @@ describe("mantis visual task runtime", () => {
"visual-driver", "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.screenshotPath ?? "", "utf8")).resolves.toBe("png");
await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4"); await expect(fs.readFile(result.videoPath ?? "", "utf8")).resolves.toBe("mp4");
const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as { const summary = JSON.parse(await fs.readFile(result.summaryPath, "utf8")) as {

View File

@@ -2,7 +2,7 @@ import { spawn, type SpawnOptions } from "node:child_process";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import path from "node:path"; import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; 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"; import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "../cli-paths.js";
export type MantisVisualTaskVisionMode = "image-describe" | "metadata"; 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: { async function warmupCrabbox(params: {
crabboxBin: string; crabboxBin: string;
cwd: string; cwd: string;
@@ -629,16 +653,17 @@ export async function runMantisVisualDriver(
stdio: "inherit", stdio: "inherit",
}); });
await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS)); await new Promise((resolve) => setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS));
await runCommand({ await runCommandWithExternalOutput({
command: crabboxBin, command: crabboxBin,
args: [ outputPath: screenshotPath,
buildArgs: (tempPath) => [
"screenshot", "screenshot",
"--provider", "--provider",
provider, provider,
"--id", "--id",
leaseId, leaseId,
"--output", "--output",
screenshotPath, tempPath,
"--reclaim", "--reclaim",
], ],
cwd: repoRoot, cwd: repoRoot,
@@ -777,19 +802,24 @@ export async function runMantisVisualTask(
runner, runner,
}); });
let recordingError: string | undefined; let recordingError: string | undefined;
const activeLeaseId = leaseId;
if (!activeLeaseId) {
throw new Error("Crabbox lease id missing after warmup.");
}
try { try {
await runCommand({ await runCommandWithExternalOutput({
command: crabboxBin, command: crabboxBin,
args: [ outputPath: videoPath,
buildArgs: (tempPath) => [
"record", "record",
"--provider", "--provider",
provider, provider,
"--id", "--id",
leaseId, activeLeaseId,
"--duration", "--duration",
trimToValue(opts.duration) ?? DEFAULT_DURATION, trimToValue(opts.duration) ?? DEFAULT_DURATION,
"--output", "--output",
videoPath, tempPath,
"--while", "--while",
"--", "--",
"pnpm", "pnpm",
@@ -797,7 +827,7 @@ export async function runMantisVisualTask(
browserUrl, browserUrl,
crabboxBin, crabboxBin,
expectText, expectText,
leaseId, leaseId: activeLeaseId,
outputDir, outputDir,
provider, provider,
repoRoot, repoRoot,

View File

@@ -3,6 +3,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env"; import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import type { import type {
SpeechProviderConfig, SpeechProviderConfig,
SpeechProviderPlugin, SpeechProviderPlugin,
@@ -270,36 +271,50 @@ async function convertAudio(
outputDir: string, outputDir: string,
target: OutputFormat, target: OutputFormat,
): Promise<Buffer> { ): Promise<Buffer> {
const outputPath = path.join(outputDir, `converted${getFileExt(target)}`); const outputFileName = `converted${getFileExt(target)}`;
const outputPath = path.join(outputDir, outputFileName);
const args = ["-y", "-i", inputPath]; const args = ["-y", "-i", inputPath];
if (target === "opus") { if (target === "opus") {
args.push("-c:a", "libopus", "-b:a", "64k", outputPath); args.push("-c:a", "libopus", "-b:a", "64k");
} else if (target === "wav") { } else if (target === "wav") {
args.push("-c:a", "pcm_s16le", outputPath); args.push("-c:a", "pcm_s16le");
} else { } 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); return readFileSync(outputPath);
} }
async function convertToRawPcm(inputPath: string, outputDir: string): Promise<Buffer> { async function convertToRawPcm(inputPath: string, outputDir: string): Promise<Buffer> {
// Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers) // Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers)
const outputPath = path.join(outputDir, "telephony.pcm"); const outputFileName = "telephony.pcm";
await runFfmpeg([ const outputPath = path.join(outputDir, outputFileName);
"-y", await writeExternalFileWithinRoot({
"-i", rootDir: outputDir,
inputPath, path: outputFileName,
"-c:a", write: async (tempPath) => {
"pcm_s16le", await runFfmpeg([
"-ar", "-y",
"16000", "-i",
"-ac", inputPath,
"1", "-c:a",
"-f", "pcm_s16le",
"s16le", "-ar",
outputPath, "16000",
]); "-ac",
"1",
"-f",
"s16le",
tempPath,
]);
},
});
return readFileSync(outputPath); return readFileSync(outputPath);
} }

View File

@@ -1,6 +1,7 @@
import path from "node:path"; import path from "node:path";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-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 { resolvePreferredOpenClawTmpDir, withTempWorkspace } from "openclaw/plugin-sdk/temp-path";
import { formatError } from "./session-errors.js"; import { formatError } from "./session-errors.js";
import { import {
@@ -189,29 +190,34 @@ async function transcodeToWhatsAppVoiceOpus(params: {
const ext = path.extname(params.fileName).toLowerCase(); const ext = path.extname(params.fileName).toLowerCase();
const inputExt = ext && ext.length <= 12 ? ext : ".audio"; const inputExt = ext && ext.length <= 12 ? ext : ".audio";
const inputPath = await workspace.write(`input${inputExt}`, params.buffer); const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
const outputPath = workspace.path(WHATSAPP_VOICE_FILE_NAME); await writeExternalFileWithinRoot({
await runFfmpeg([ rootDir: workspace.dir,
"-hide_banner", path: WHATSAPP_VOICE_FILE_NAME,
"-loglevel", write: async (outputPath) => {
"error", await runFfmpeg([
"-y", "-hide_banner",
"-i", "-loglevel",
inputPath, "error",
"-vn", "-y",
"-sn", "-i",
"-dn", inputPath,
"-t", "-vn",
String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS), "-sn",
"-ar", "-dn",
String(WHATSAPP_VOICE_SAMPLE_RATE_HZ), "-t",
"-ac", String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
"1", "-ar",
"-c:a", String(WHATSAPP_VOICE_SAMPLE_RATE_HZ),
"libopus", "-ac",
"-b:a", "1",
WHATSAPP_VOICE_BITRATE, "-c:a",
outputPath, "libopus",
]); "-b:a",
WHATSAPP_VOICE_BITRATE,
outputPath,
]);
},
});
return await workspace.read(WHATSAPP_VOICE_FILE_NAME); return await workspace.read(WHATSAPP_VOICE_FILE_NAME);
}, },
); );

View File

@@ -1695,7 +1695,7 @@
"@mariozechner/pi-tui": "0.73.0", "@mariozechner/pi-tui": "0.73.0",
"@modelcontextprotocol/sdk": "1.29.0", "@modelcontextprotocol/sdk": "1.29.0",
"@mozilla/readability": "^0.6.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/bolt": "^4.7.2",
"@slack/types": "^2.21.0", "@slack/types": "^2.21.0",
"@slack/web-api": "^7.15.2", "@slack/web-api": "^7.15.2",

10
pnpm-lock.yaml generated
View File

@@ -105,8 +105,8 @@ importers:
specifier: ^0.6.0 specifier: ^0.6.0
version: 0.6.0 version: 0.6.0
'@openclaw/fs-safe': '@openclaw/fs-safe':
specifier: github:openclaw/fs-safe#3412e03c09cdfd31c2da04b7d74e39ad7a92d07d specifier: github:openclaw/fs-safe#ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c
version: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d version: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c
'@slack/bolt': '@slack/bolt':
specifier: ^4.7.2 specifier: ^4.7.2
version: 4.7.2(@types/express@5.0.6) version: 4.7.2(@types/express@5.0.6)
@@ -3234,8 +3234,8 @@ packages:
cpu: [x64] cpu: [x64]
os: [win32] os: [win32]
'@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':
resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/3412e03c09cdfd31c2da04b7d74e39ad7a92d07d} resolution: {tarball: https://codeload.github.com/openclaw/fs-safe/tar.gz/ce4137f028ca4b09f26a93ffa3e0d32fbfbd516c}
version: 0.1.2 version: 0.1.2
engines: {node: '>=20.11'} engines: {node: '>=20.11'}
@@ -10004,7 +10004,7 @@ snapshots:
'@openai/codex@0.128.0-win32-x64': '@openai/codex@0.128.0-win32-x64':
optional: true 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: optionalDependencies:
jszip: 3.10.1 jszip: 3.10.1
tar: 7.5.13 tar: 7.5.13

View File

@@ -45,6 +45,11 @@ export {
type WalkDirectoryResult, type WalkDirectoryResult,
} from "@openclaw/fs-safe/walk"; } from "@openclaw/fs-safe/walk";
export { withTimeout } from "@openclaw/fs-safe/advanced"; 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). */ /** @deprecated Use root(rootDir).read(relativePath, options). */
export async function readFileWithinRoot(params: { export async function readFileWithinRoot(params: {

View File

@@ -17,6 +17,7 @@ import type {
MediaUnderstandingModelConfig, MediaUnderstandingModelConfig,
} from "../config/types.tools.js"; } from "../config/types.tools.js";
import { logVerbose, shouldLogVerbose } from "../globals.js"; import { logVerbose, shouldLogVerbose } from "../globals.js";
import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js"; import { resolveProxyFetchFromEnv } from "../infra/net/proxy-fetch.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { runFfmpeg } from "../media/ffmpeg-exec.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`); const wavPath = path.join(params.outputDir, `${path.parse(params.mediaPath).name}.wav`);
await runFfmpeg([ await fs.mkdir(params.outputDir, { recursive: true });
"-y", await writeExternalFileWithinRoot({
"-i", rootDir: params.outputDir,
params.mediaPath, path: path.basename(wavPath),
"-ac", write: async (outputPath) => {
"1", await runFfmpeg([
"-ar", "-y",
"16000", "-i",
"-c:a", params.mediaPath,
"pcm_s16le", "-ac",
wavPath, "1",
]); "-ar",
"16000",
"-c:a",
"pcm_s16le",
outputPath,
]);
},
});
return wavPath; return wavPath;
} }

View File

@@ -94,6 +94,6 @@ describe("transcodeAudioBufferToOpus", () => {
const tempRoot = realpathSync(resolvePreferredOpenClawTmpDir()); const tempRoot = realpathSync(resolvePreferredOpenClawTmpDir());
expect(capturedInputPath?.startsWith(tempRoot)).toBe(true); expect(capturedInputPath?.startsWith(tempRoot)).toBe(true);
expect(capturedOutputPath?.startsWith(tempRoot)).toBe(true); expect(capturedOutputPath ? existsSync(capturedOutputPath) : true).toBe(false);
}); });
}); });

View File

@@ -1,4 +1,5 @@
import path from "node:path"; import path from "node:path";
import { writeExternalFileWithinRoot } from "../infra/fs-safe.js";
import { withTempWorkspace } from "../infra/private-temp-workspace.js"; import { withTempWorkspace } from "../infra/private-temp-workspace.js";
import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js"; import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { runFfmpeg } from "./ffmpeg-exec.js"; import { runFfmpeg } from "./ffmpeg-exec.js";
@@ -60,31 +61,37 @@ export async function transcodeAudioBufferToOpus(params: {
`input${normalizeAudioExtension(params)}`, `input${normalizeAudioExtension(params)}`,
params.audioBuffer, params.audioBuffer,
); );
const outputPath = workspace.path(normalizeOutputFileName(params.outputFileName)); const outputFileName = normalizeOutputFileName(params.outputFileName);
await runFfmpeg( await writeExternalFileWithinRoot({
[ rootDir: workspace.dir,
"-hide_banner", path: outputFileName,
"-loglevel", write: async (outputPath) => {
"error", await runFfmpeg(
"-y", [
"-i", "-hide_banner",
inputPath, "-loglevel",
"-vn", "error",
"-sn", "-y",
"-dn", "-i",
"-c:a", inputPath,
"libopus", "-vn",
"-b:a", "-sn",
params.bitrate ?? DEFAULT_OPUS_BITRATE, "-dn",
"-ar", "-c:a",
String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ), "libopus",
"-ac", "-b:a",
String(params.channels ?? DEFAULT_OPUS_CHANNELS), params.bitrate ?? DEFAULT_OPUS_BITRATE,
outputPath, "-ar",
], String(params.sampleRateHz ?? DEFAULT_OPUS_SAMPLE_RATE_HZ),
{ timeoutMs: params.timeoutMs }, "-ac",
); String(params.channels ?? DEFAULT_OPUS_CHANNELS),
return await workspace.read(normalizeOutputFileName(params.outputFileName)); outputPath,
],
{ timeoutMs: params.timeoutMs },
);
},
});
return await workspace.read(outputFileName);
}, },
); );
} }

View File

@@ -32,7 +32,10 @@ export {
resolveRegularFileAppendFlags, resolveRegularFileAppendFlags,
root, root,
statRegularFileSync, statRegularFileSync,
writeExternalFileWithinRoot,
withTimeout, withTimeout,
type ExternalFileWriteOptions,
type ExternalFileWriteResult,
type FsSafeErrorCode as SafeOpenErrorCode, type FsSafeErrorCode as SafeOpenErrorCode,
} from "../infra/fs-safe.js"; } from "../infra/fs-safe.js";