From cfdcd5cdfdc3e91748f4a9e33e8d26faa8bedb23 Mon Sep 17 00:00:00 2001 From: ZengWen-DT Date: Wed, 17 Jun 2026 02:11:52 +0800 Subject: [PATCH] fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root (#92947) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QQBot is the only channel that root-sandboxes outbound local files. Its three gate sites (resolveOutboundMediaPath, the voice send re-check, and structured-payload validation) only trusted the QQ Bot media storage roots, so framework-generated scratch media written under OpenClaw's hardened temp root (e.g. cron auto-TTS voice files from speech-core) was rejected. The send then returned a no-identity error, the message was silently lost, yet cron still recorded it as delivered. Add one shared resolver (resolveTrustedOutboundMediaPath) that also trusts the preferred OpenClaw temp root — already a sanctioned media root in core (buildMediaLocalRoots) — and route all three gates through it so the trust set agrees everywhere. Fixes #92816. Co-authored-by: zengwen Co-authored-by: Claude Opus 4.8 --- .../engine/messaging/outbound-media-send.ts | 8 ++- .../src/engine/messaging/reply-dispatcher.ts | 5 +- .../messaging/trusted-media-path.test.ts | 70 +++++++++++++++++++ .../engine/messaging/trusted-media-path.ts | 59 ++++++++++++++++ 4 files changed, 137 insertions(+), 5 deletions(-) create mode 100644 extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts create mode 100644 extensions/qqbot/src/engine/messaging/trusted-media-path.ts diff --git a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts index a601e621fde5..f687c6573308 100644 --- a/extensions/qqbot/src/engine/messaging/outbound-media-send.ts +++ b/extensions/qqbot/src/engine/messaging/outbound-media-send.ts @@ -25,7 +25,6 @@ import { getQQBotMediaDir, isLocalPath as isLocalFilePath, normalizePath, - resolveQQBotPayloadLocalFilePath, } from "../utils/platform.js"; import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js"; import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js"; @@ -42,6 +41,7 @@ import { type DeliveryTarget, } from "./sender.js"; import { parseTarget as coreParseTarget } from "./target-parser.js"; +import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; /** Parse a qqbot target into a structured delivery target. */ export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } { @@ -139,7 +139,9 @@ export function resolveOutboundMediaPath( return { ok: true, mediaPath: normalizedPath }; } - const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath); + const allowedPath = resolveTrustedOutboundMediaPath(normalizedPath, { + allowMissing: options.allowMissingLocalPath, + }); if (allowedPath) { return { ok: true, mediaPath: allowedPath }; } @@ -368,7 +370,7 @@ async function sendVoiceFromLocal( } // Re-check containment after the file appears to prevent symlink-race escapes. - const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath); + const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath); if (!safeMediaPath) { debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`); return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" }; diff --git a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts index 67ce67bf7bd6..79680ef7563a 100644 --- a/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts +++ b/extensions/qqbot/src/engine/messaging/reply-dispatcher.ts @@ -17,7 +17,7 @@ import { isMediaPayload, type MediaPayload, } from "../utils/payload.js"; -import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js"; +import { normalizePath } from "../utils/platform.js"; import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js"; import { sanitizeFileName } from "../utils/string-normalize.js"; import { openLocalFile } from "./media-source.js"; @@ -28,6 +28,7 @@ import { buildDeliveryTarget, accountToCreds, } from "./sender.js"; +import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; // ---- Injected dependencies ---- @@ -207,7 +208,7 @@ function validateStructuredPayloadLocalPath( payloadPath: string, mediaType: StructuredPayloadMediaType, ): string | null { - const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath); + const allowedPath = resolveTrustedOutboundMediaPath(payloadPath); if (allowedPath) { return allowedPath; } diff --git a/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts b/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts new file mode 100644 index 000000000000..3f8b2c591473 --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/trusted-media-path.test.ts @@ -0,0 +1,70 @@ +// Qqbot tests cover trusted outbound media-path root resolution. +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; +import { afterEach, describe, expect, it } from "vitest"; +import { resolveOutboundMediaPath } from "./outbound-media-send.js"; +import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js"; + +const cleanupPaths: string[] = []; + +afterEach(() => { + while (cleanupPaths.length > 0) { + const target = cleanupPaths.pop(); + if (target) { + fs.rmSync(target, { recursive: true, force: true }); + } + } +}); + +function makeTtsStyleVoiceFile(): string { + // Mirrors cron auto-TTS: speech-core writes the voice file under the preferred + // OpenClaw temp root, which is outside the QQ Bot media storage tree. + const tmpRoot = resolvePreferredOpenClawTmpDir(); + const ttsDir = fs.mkdtempSync(path.join(tmpRoot, "tts-")); + cleanupPaths.push(ttsDir); + const voicePath = path.join(ttsDir, "voice-123.mp3"); + fs.writeFileSync(voicePath, "audio"); + return voicePath; +} + +describe("resolveTrustedOutboundMediaPath", () => { + it("trusts framework media under OpenClaw's hardened temp root", () => { + const voicePath = makeTtsStyleVoiceFile(); + expect(resolveTrustedOutboundMediaPath(voicePath)).toBe(fs.realpathSync(voicePath)); + }); + + it("rejects local media outside every trusted root", () => { + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "qq-out-of-root-")); + cleanupPaths.push(outsideDir); + const strayPath = path.join(outsideDir, "stray.mp3"); + fs.writeFileSync(strayPath, "audio"); + + expect(resolveTrustedOutboundMediaPath(strayPath)).toBeNull(); + }); + + it("accepts a not-yet-flushed temp file only when allowMissing is set", () => { + const tmpRoot = resolvePreferredOpenClawTmpDir(); + const ttsDir = fs.mkdtempSync(path.join(tmpRoot, "tts-pending-")); + cleanupPaths.push(ttsDir); + const pendingPath = path.join(ttsDir, "voice-pending.mp3"); + + expect(resolveTrustedOutboundMediaPath(pendingPath)).toBeNull(); + expect(resolveTrustedOutboundMediaPath(pendingPath, { allowMissing: true })).not.toBeNull(); + }); +}); + +describe("resolveOutboundMediaPath", () => { + it("resolves a cron/TTS voice file under the temp root end to end", () => { + // Both the initial resolve and the voice send re-check funnel through + // resolveTrustedOutboundMediaPath, so this gate now passes for temp media. + const voicePath = makeTtsStyleVoiceFile(); + const resolved = resolveOutboundMediaPath(voicePath, "voice", { + allowMissingLocalPath: true, + }); + + expect(resolved.ok).toBe(true); + expect(resolved.ok && resolved.mediaPath).toBe(fs.realpathSync(voicePath)); + }); +}); diff --git a/extensions/qqbot/src/engine/messaging/trusted-media-path.ts b/extensions/qqbot/src/engine/messaging/trusted-media-path.ts new file mode 100644 index 000000000000..b48e8a9d2d7c --- /dev/null +++ b/extensions/qqbot/src/engine/messaging/trusted-media-path.ts @@ -0,0 +1,59 @@ +import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox"; +import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime"; +import { resolveQQBotPayloadLocalFilePath } from "../utils/platform.js"; + +// The temp root is process-stable, so resolve it once. Only the success value is +// cached: a transient provisioning failure returns null without poisoning later +// calls. +let cachedTrustedTmpRoot: string | undefined; +function trustedOpenClawTmpRoot(): string | null { + if (cachedTrustedTmpRoot === undefined) { + try { + cachedTrustedTmpRoot = resolvePreferredOpenClawTmpDir(); + } catch { + return null; + } + } + return cachedTrustedTmpRoot; +} + +/** + * Resolve a local outbound media path against every trusted root, returning the + * canonical path or null when it sits outside all of them. + * + * QQBot is the only channel that root-sandboxes outbound local files, and the + * same check runs at three sites (`resolveOutboundMediaPath`, the voice send + * re-check, and structured-payload validation), so they must all agree or a file + * accepted at one gate is rejected at the next. Beyond the QQ Bot media storage + * roots, this also trusts OpenClaw's permission-hardened temp root, where + * framework scratch media is written (e.g. cron auto-TTS voice files). Core + * already treats that temp root as a sanctioned media root (`buildMediaLocalRoots`); + * without it here, auto-routed sends are dropped and cron delivery silently loses + * the message. + * + * `allowMissing` lets callers accept a not-yet-flushed temp file (e.g. TTS still + * writing) under the temp root; existence is then enforced later by the voice + * send re-check before upload. + */ +export function resolveTrustedOutboundMediaPath( + p: string, + options: { allowMissing?: boolean } = {}, +): string | null { + const storageRootPath = resolveQQBotPayloadLocalFilePath(p); + if (storageRootPath) { + return storageRootPath; + } + + const tmpRoot = trustedOpenClawTmpRoot(); + if (!tmpRoot) { + return null; + } + return ( + resolveLocalPathFromRootsSync({ + filePath: p, + roots: [tmpRoot], + label: "OpenClaw temp media root", + allowMissing: options.allowMissing === true, + })?.path ?? null + ); +}