From e0d58d994d54b41eac5caf0016da020e6fcbb2e3 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 19 Jun 2026 16:36:51 +0200 Subject: [PATCH] fix(qqbot): bound stt error bodies --- extensions/qqbot/src/engine/utils/stt.test.ts | 70 ++++++++++++++++++- extensions/qqbot/src/engine/utils/stt.ts | 7 +- 2 files changed, 74 insertions(+), 3 deletions(-) diff --git a/extensions/qqbot/src/engine/utils/stt.test.ts b/extensions/qqbot/src/engine/utils/stt.test.ts index 1452e351b8e4..116063cfe6c8 100644 --- a/extensions/qqbot/src/engine/utils/stt.test.ts +++ b/extensions/qqbot/src/engine/utils/stt.test.ts @@ -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({ + 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); + }); }); diff --git a/extensions/qqbot/src/engine/utils/stt.ts b/extensions/qqbot/src/engine/utils/stt.ts index 9d7e1db22145..054f9772a025 100644 --- a/extensions/qqbot/src/engine/utils/stt.ts +++ b/extensions/qqbot/src/engine/utils/stt.ts @@ -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)}`); }