fix(tts-local-cli): cap local CLI speech outputs (#101510)

* fix(tts-local-cli): bound local speech output

Co-authored-by: mikasa0818 <0668001030@xydigit.com>

* chore(tts-local-cli): keep changelog release-owned

* docs(tts-local-cli): format output-limit note

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
mikasa
2026-07-09 19:42:41 +08:00
committed by GitHub
parent 292e1cfa1b
commit 18fd6ee9e6
4 changed files with 211 additions and 52 deletions
+3
View File
@@ -868,6 +868,9 @@ Reply -> TTS enabled?
<ParamField path="timeoutMs" type="number">Command timeout in milliseconds. Default `120000`.</ParamField>
<ParamField path="cwd" type="string">Optional command working directory.</ParamField>
<ParamField path="env" type="Record<string, string>">Optional environment overrides for the command.</ParamField>
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.
</Accordion>
<Accordion title="Microsoft (no API key)">
@@ -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 });
@@ -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) => {
+99 -51
View File
@@ -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<string, unknown> | 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<Buffer> {
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<Buffer> {
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<Buffer> {
// 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 {