fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root (#92947)

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 <zeng_wen@foxmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
ZengWen-DT
2026-06-17 02:11:52 +08:00
committed by GitHub
parent 875669e38e
commit cfdcd5cdfd
4 changed files with 137 additions and 5 deletions

View File

@@ -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" };

View File

@@ -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;
}

View File

@@ -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));
});
});

View File

@@ -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
);
}