diff --git a/docs/tools/tts.md b/docs/tools/tts.md
index 1b873a77b6b..b3f315963ed 100644
--- a/docs/tools/tts.md
+++ b/docs/tools/tts.md
@@ -868,6 +868,9 @@ Reply -> TTS enabled?
Command timeout in milliseconds. Default `120000`.
Optional command working directory.
Optional environment overrides for the command.
+
+ Command stdout and generated or converted audio are limited to 50 MiB. Diagnostic stderr is limited to 1 MiB. OpenClaw terminates the command and fails synthesis when either limit is exceeded.
+
diff --git a/extensions/tts-local-cli/speech-provider-stream-error.test.ts b/extensions/tts-local-cli/speech-provider-stream-error.test.ts
index 51ce7050b10..40a0fecd983 100644
--- a/extensions/tts-local-cli/speech-provider-stream-error.test.ts
+++ b/extensions/tts-local-cli/speech-provider-stream-error.test.ts
@@ -38,6 +38,7 @@ function createMockChild(): MockChild {
}
const TEST_CFG = {} as OpenClawConfig;
+const MIB = 1024 * 1024;
type SpeechTarget = SpeechSynthesisRequest["target"];
@@ -193,6 +194,25 @@ describe("CLI TTS provider stream error handling", () => {
);
});
+ it.each([
+ { stream: "stdout", chunkBytes: MIB, repeats: 51, limitBytes: 50 * MIB },
+ { stream: "stderr", chunkBytes: MIB / 2, repeats: 3, limitBytes: MIB },
+ ] as const)("terminates the child when $stream exceeds its byte cap", async (testCase) => {
+ const child = createMockChild();
+ const { promise } = await startSpeech({ child });
+ const chunk = Buffer.alloc(testCase.chunkBytes);
+
+ for (let index = 0; index < testCase.repeats; index += 1) {
+ child[testCase.stream].emit("data", chunk);
+ }
+ expect(child.kill).toHaveBeenCalledTimes(1);
+ child.emit("close", null);
+
+ await expect(promise).rejects.toThrow(
+ `CLI TTS ${testCase.stream} exceeded ${testCase.limitBytes} bytes`,
+ );
+ });
+
it("waits for close before settling a process error", async () => {
const child = createMockChild();
const { promise } = await startSpeech({ child });
diff --git a/extensions/tts-local-cli/speech-provider.test.ts b/extensions/tts-local-cli/speech-provider.test.ts
index 76896e2be52..2ef67d2ec9e 100644
--- a/extensions/tts-local-cli/speech-provider.test.ts
+++ b/extensions/tts-local-cli/speech-provider.test.ts
@@ -1,5 +1,5 @@
// Tts Local Cli tests cover speech provider plugin behavior.
-import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
+import { mkdtempSync, readFileSync, rmSync, truncateSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
@@ -22,6 +22,7 @@ vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
import { buildCliSpeechProvider } from "./speech-provider.js";
const TEST_CFG = {} as OpenClawConfig;
+const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024;
function createCliFixture(): { dir: string; script: string } {
const dir = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-tts-test-"));
@@ -261,6 +262,93 @@ describe("buildCliSpeechProvider", () => {
}
});
+ it("rejects oversized CLI output files before reading them", async () => {
+ const fixture = createCliFixture();
+ try {
+ writeFileSync(
+ fixture.script,
+ `
+import { truncateSync, writeFileSync } from "node:fs";
+const outIndex = process.argv.indexOf("--out");
+const outputPath = process.argv[outIndex + 1];
+writeFileSync(outputPath, "");
+truncateSync(outputPath, ${MAX_AUDIO_OUTPUT_BYTES + 1});
+`,
+ );
+
+ await expect(
+ synthesize({
+ providerConfig: baseProviderConfig(fixture.script, {
+ args: [fixture.script, "--out", "{{OutputPath}}"],
+ outputFormat: "wav",
+ }),
+ }),
+ ).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`);
+ } finally {
+ rmSync(fixture.dir, { recursive: true, force: true });
+ }
+ });
+
+ it("rejects non-file CLI output artifacts", async () => {
+ const fixture = createCliFixture();
+ try {
+ writeFileSync(
+ fixture.script,
+ `
+import { mkdirSync } from "node:fs";
+const outIndex = process.argv.indexOf("--out");
+mkdirSync(process.argv[outIndex + 1]);
+`,
+ );
+
+ await expect(
+ synthesize({
+ providerConfig: baseProviderConfig(fixture.script, {
+ args: [fixture.script, "--out", "{{OutputPath}}"],
+ outputFormat: "wav",
+ }),
+ }),
+ ).rejects.toThrow("path must be a regular file");
+ } finally {
+ rmSync(fixture.dir, { recursive: true, force: true });
+ }
+ });
+
+ it.each(["voice-note", "telephony"] as const)(
+ "rejects oversized ffmpeg output for %s synthesis",
+ async (mode) => {
+ const fixture = createCliFixture();
+ runFfmpegMock.mockImplementation(async (args) => {
+ const outputPath = args.at(-1);
+ if (typeof outputPath !== "string") {
+ throw new Error("missing ffmpeg output path");
+ }
+ writeFileSync(outputPath, "");
+ truncateSync(outputPath, MAX_AUDIO_OUTPUT_BYTES + 1);
+ });
+ try {
+ const providerConfig = baseProviderConfig(fixture.script, {
+ args: [fixture.script, "--out", "{{OutputPath}}"],
+ outputFormat: "wav",
+ });
+ const run =
+ mode === "voice-note"
+ ? synthesize({ providerConfig, target: "voice-note" })
+ : buildCliSpeechProvider().synthesizeTelephony?.({
+ text: "phone reply",
+ cfg: TEST_CFG,
+ providerConfig,
+ providerOverrides: {},
+ timeoutMs: 1000,
+ });
+
+ await expect(run).rejects.toThrow(`File exceeds ${MAX_AUDIO_OUTPUT_BYTES} bytes`);
+ } finally {
+ rmSync(fixture.dir, { recursive: true, force: true });
+ }
+ },
+ );
+
it.each(["synthesize", "synthesizeTelephony"] as const)(
"keeps %s debug previews free of lone surrogates",
async (method) => {
diff --git a/extensions/tts-local-cli/speech-provider.ts b/extensions/tts-local-cli/speech-provider.ts
index 1bf3d7a1be2..1d71ffd94f5 100644
--- a/extensions/tts-local-cli/speech-provider.ts
+++ b/extensions/tts-local-cli/speech-provider.ts
@@ -1,10 +1,13 @@
// Tts Local Cli provider module implements model/runtime integration.
import { spawn } from "node:child_process";
-import { existsSync, readdirSync, readFileSync } from "node:fs";
+import { readdirSync } 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 {
+ readRegularFileSync,
+ writeExternalFileWithinRoot,
+} from "openclaw/plugin-sdk/security-runtime";
import type {
SpeechProviderConfig,
SpeechProviderPlugin,
@@ -30,6 +33,8 @@ type CliConfig = {
};
const DEFAULT_TIMEOUT_MS = 120_000;
+const MAX_AUDIO_OUTPUT_BYTES = 50 * 1024 * 1024;
+const MAX_CLI_STDERR_BYTES = 1024 * 1024;
function asObject(value: unknown): Record | undefined {
return typeof value === "object" && value !== null && !Array.isArray(value)
@@ -172,6 +177,38 @@ function getFileExt(format: string): string {
return ".mp3";
}
+function readAudioFile(filePath: string): Buffer {
+ return readRegularFileSync({ filePath, maxBytes: MAX_AUDIO_OUTPUT_BYTES }).buffer;
+}
+
+function createBoundedBuffer(label: string, maxBytes: number) {
+ const chunks: Buffer[] = [];
+ let totalBytes = 0;
+ let limitError: Error | undefined;
+
+ return {
+ append(chunk: Buffer | string): Error | undefined {
+ if (limitError) {
+ return limitError;
+ }
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
+ const nextTotal = totalBytes + buffer.byteLength;
+ if (nextTotal > maxBytes) {
+ limitError = new Error(`${label} exceeded ${maxBytes} bytes (${nextTotal} bytes received)`);
+ chunks.length = 0;
+ totalBytes = 0;
+ return limitError;
+ }
+ chunks.push(buffer);
+ totalBytes = nextTotal;
+ return undefined;
+ },
+ concat(): Buffer {
+ return Buffer.concat(chunks, totalBytes);
+ },
+ };
+}
+
async function runCli(params: {
command: string;
args: string[];
@@ -231,15 +268,25 @@ async function runCli(params: {
terminateFor(new Error(`CLI TTS timed out after ${params.timeoutMs}ms`));
}, params.timeoutMs);
- const stdoutChunks: Buffer[] = [];
- const stderrChunks: Buffer[] = [];
- proc.stdout.on("data", (c) => stdoutChunks.push(c));
+ const stdout = createBoundedBuffer("CLI TTS stdout", MAX_AUDIO_OUTPUT_BYTES);
+ const stderr = createBoundedBuffer("CLI TTS stderr", MAX_CLI_STDERR_BYTES);
+ proc.stdout.on("data", (chunk: Buffer) => {
+ const error = stdout.append(chunk);
+ if (error) {
+ terminateFor(error);
+ }
+ });
proc.stdout.on("error", (e) => {
// A generated file is authoritative when present. Remember stdout
// failure so only the stdout-audio fallback is rejected after close.
stdoutError ??= new Error(`CLI TTS stdout stream error: ${e.message}`);
});
- proc.stderr.on("data", (c) => stderrChunks.push(c));
+ proc.stderr.on("data", (chunk: Buffer) => {
+ const error = stderr.append(chunk);
+ if (error) {
+ terminateFor(error);
+ }
+ });
proc.stderr.on("error", (e) => {
stderrError ??= new Error(`CLI TTS stderr stream error: ${e.message}`);
});
@@ -260,36 +307,37 @@ async function runCli(params: {
return reject(terminalFailure);
}
if (code !== 0) {
- const stderr = Buffer.concat(stderrChunks).toString("utf8");
+ const stderrText = stderr.concat().toString("utf8");
const diagnostic = stderrError
- ? [stderr, stderrError.message].filter(Boolean).join("; ")
- : stderr;
+ ? [stderrText, stderrError.message].filter(Boolean).join("; ")
+ : stderrText;
return reject(new Error(`CLI TTS exit ${code}: ${diagnostic}`));
}
const audioFile = findAudioFile(params.outputDir, params.filePrefix);
if (audioFile) {
- if (!existsSync(audioFile)) {
- return reject(new Error(`CLI TTS: output file not found at ${audioFile}`));
- }
const format = detectFormat(audioFile);
if (!format) {
return reject(new Error(`CLI TTS: unknown format for ${audioFile}`));
}
- return resolve({
- buffer: readFileSync(audioFile),
- actualFormat: format,
- audioPath: audioFile,
- });
+ try {
+ return resolve({
+ buffer: readAudioFile(audioFile),
+ actualFormat: format,
+ audioPath: audioFile,
+ });
+ } catch (error) {
+ return reject(error instanceof Error ? error : new Error(String(error)));
+ }
}
if (stdoutError) {
return reject(stdoutError);
}
- const stdout = Buffer.concat(stdoutChunks);
- if (stdout.length > 0) {
+ const stdoutBuffer = stdout.concat();
+ if (stdoutBuffer.length > 0) {
// Assume WAV for stdout output; could be MP3 but caller should convert if needed
- return resolve({ buffer: stdout, actualFormat: "wav" });
+ return resolve({ buffer: stdoutBuffer, actualFormat: "wav" });
}
reject(new Error("CLI TTS produced no output"));
});
@@ -302,13 +350,28 @@ async function runCli(params: {
});
}
+async function runFfmpegToBuffer(params: {
+ args: string[];
+ outputDir: string;
+ outputFileName: string;
+}): Promise {
+ const outputPath = path.join(params.outputDir, params.outputFileName);
+ await writeExternalFileWithinRoot({
+ rootDir: params.outputDir,
+ path: params.outputFileName,
+ write: async (tempPath) => {
+ await runFfmpeg([...params.args, tempPath]);
+ },
+ });
+ return readAudioFile(outputPath);
+}
+
async function convertAudio(
inputPath: string,
outputDir: string,
target: OutputFormat,
): Promise {
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", "-f", "opus");
@@ -317,41 +380,26 @@ async function convertAudio(
} else {
args.push("-c:a", "libmp3lame", "-b:a", "128k", "-f", "mp3");
}
- await writeExternalFileWithinRoot({
- rootDir: outputDir,
- path: outputFileName,
- write: async (tempPath) => {
- await runFfmpeg([...args, tempPath]);
- },
- });
- return readFileSync(outputPath);
+ return await runFfmpegToBuffer({ args, outputDir, outputFileName });
}
async function convertToRawPcm(inputPath: string, outputDir: string): Promise {
// Output raw 16kHz mono 16-bit little-endian PCM (no WAV headers)
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);
+ const args = [
+ "-y",
+ "-i",
+ inputPath,
+ "-c:a",
+ "pcm_s16le",
+ "-ar",
+ "16000",
+ "-ac",
+ "1",
+ "-f",
+ "s16le",
+ ];
+ return await runFfmpegToBuffer({ args, outputDir, outputFileName });
}
export function buildCliSpeechProvider(): SpeechProviderPlugin {