fix(qqbot): bound stt error bodies

This commit is contained in:
Vincent Koc
2026-06-19 16:36:51 +02:00
parent dc16aedd2e
commit e0d58d994d
2 changed files with 74 additions and 3 deletions

View File

@@ -1,8 +1,8 @@
// Qqbot tests cover stt plugin behavior.
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createTempDirTracker } from "../../../../../test/helpers/temp-dir.js";
const ssrfRuntimeMocks = vi.hoisted(() => ({
fetchWithSsrFGuard: vi.fn(),
@@ -19,6 +19,30 @@ afterAll(() => {
import { resolveSTTConfig, transcribeAudio } from "./stt.js";
const tempDirs = createTempDirTracker();
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
function requireFirstSsrfRequest(): {
url?: unknown;
auditContext?: unknown;
@@ -47,6 +71,7 @@ describe("engine/utils/stt", () => {
});
afterEach(() => {
tempDirs.cleanup();
ssrfRuntimeMocks.fetchWithSsrFGuard.mockReset();
vi.unstubAllGlobals();
});
@@ -110,7 +135,7 @@ describe("engine/utils/stt", () => {
});
it("posts audio to OpenAI-compatible transcription endpoint", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-qqbot-stt-"));
const tmpDir = tempDirs.make("openclaw-qqbot-stt-");
const audioPath = path.join(tmpDir, "voice.wav");
fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4]));
@@ -153,4 +178,45 @@ describe("engine/utils/stt", () => {
);
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds STT error bodies without using response.text()", async () => {
const tmpDir = tempDirs.make("openclaw-qqbot-stt-error-");
const audioPath = path.join(tmpDir, "voice.wav");
fs.writeFileSync(audioPath, Buffer.from([1, 2, 3, 4]));
const release = vi.fn(async () => {});
const tracked = cancelTrackedResponse(`${"stt provider unavailable ".repeat(1024)}tail`, {
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "text/plain" },
});
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
ssrfRuntimeMocks.fetchWithSsrFGuard.mockResolvedValueOnce({
response: tracked.response,
release,
});
let error: unknown;
try {
await transcribeAudio(audioPath, {
channels: {
qqbot: {
stt: {
baseUrl: "https://api.example.test/v1/",
apiKey: "secret",
model: "whisper-1",
},
},
},
});
} catch (caught) {
error = caught;
}
expect(String(error)).toContain("STT failed (HTTP 503): stt provider unavailable");
expect(String(error)).not.toContain("tail");
expect(tracked.wasCanceled()).toBe(true);
expect(textSpy).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
});

View File

@@ -8,6 +8,7 @@
import * as fs from "node:fs";
import path from "node:path";
import { mimeTypeFromFilePath } from "openclaw/plugin-sdk/media-mime";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeOptionalString,
@@ -16,6 +17,8 @@ import {
sanitizeFileName,
} from "./string-normalize.js";
const STT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
interface STTConfig {
baseUrl: string;
apiKey: string;
@@ -91,7 +94,9 @@ export async function transcribeAudio(
});
try {
if (!resp.ok) {
const detail = await resp.text().catch(() => "");
const detail = await readResponseTextLimited(resp, STT_ERROR_BODY_LIMIT_BYTES).catch(
() => "",
);
throw new Error(`STT failed (HTTP ${resp.status}): ${detail.slice(0, 300)}`);
}