mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user