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 {