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: {
rootDir: string;
targetPath: string;
writeTemp: (tempPath: string) => Promise<void>;
}): Promise<void> {
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,
});
}

View File

@@ -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 });
});

View File

@@ -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(() => {});

View File

@@ -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),

View File

@@ -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}`),
);

View File

@@ -22,6 +22,7 @@ export {
resolveWritablePathWithinRoot,
FsSafeError,
SsrFBlockedError,
writeExternalFileWithinRoot,
writeViaSiblingTempPath,
wrapExternalContent,
} 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 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<string> {
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<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> {
executablePathCache = null;
await closeSharedBrowser();

View File

@@ -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");
});
});

View File

@@ -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<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(
response: Response,
body: { message: string; retry_after: number; global: boolean },
@@ -104,25 +120,28 @@ async function generateWaveformFromPcm(filePath: string): Promise<string> {
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 };
}

View File

@@ -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,

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";
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",

View File

@@ -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 {

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 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 () => {

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";
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<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 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();

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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<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];
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<Buffer> {
// 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);
}

View File

@@ -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);
},
);

View File

@@ -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",

10
pnpm-lock.yaml generated
View File

@@ -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

View File

@@ -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: {

View File

@@ -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;
}

View File

@@ -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);
});
});

View File

@@ -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);
},
);
}

View File

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