Skip to content

Commit 5656716

Browse files
Zeng-wenclaude
andcommitted
fix(qqbot): deliver cron auto-TTS voice by trusting OpenClaw temp root
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: Claude Opus 4.8 <[email protected]>
1 parent 8ae1adf commit 5656716

4 files changed

Lines changed: 137 additions & 5 deletions

File tree

extensions/qqbot/src/engine/messaging/outbound-media-send.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ import {
2525
getQQBotMediaDir,
2626
isLocalPath as isLocalFilePath,
2727
normalizePath,
28-
resolveQQBotPayloadLocalFilePath,
2928
} from "../utils/platform.js";
3029
import { normalizeLowercaseStringOrEmpty, sanitizeFileName } from "../utils/string-normalize.js";
3130
import { audioFileToSilkBase64, shouldTranscodeVoice, waitForFile } from "./outbound-audio-port.js";
@@ -42,6 +41,7 @@ import {
4241
type DeliveryTarget,
4342
} from "./sender.js";
4443
import { parseTarget as coreParseTarget } from "./target-parser.js";
44+
import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js";
4545

4646
/** Parse a qqbot target into a structured delivery target. */
4747
export function parseTarget(to: string): { type: "c2c" | "group" | "channel"; id: string } {
@@ -139,7 +139,9 @@ export function resolveOutboundMediaPath(
139139
return { ok: true, mediaPath: normalizedPath };
140140
}
141141

142-
const allowedPath = resolveQQBotPayloadLocalFilePath(normalizedPath);
142+
const allowedPath = resolveTrustedOutboundMediaPath(normalizedPath, {
143+
allowMissing: options.allowMissingLocalPath,
144+
});
143145
if (allowedPath) {
144146
return { ok: true, mediaPath: allowedPath };
145147
}
@@ -368,7 +370,7 @@ async function sendVoiceFromLocal(
368370
}
369371

370372
// Re-check containment after the file appears to prevent symlink-race escapes.
371-
const safeMediaPath = resolveQQBotPayloadLocalFilePath(mediaPath);
373+
const safeMediaPath = resolveTrustedOutboundMediaPath(mediaPath);
372374
if (!safeMediaPath) {
373375
debugWarn(`sendVoice: blocked local voice path outside QQ Bot media storage`);
374376
return { channel: "qqbot", error: "Voice path must be inside QQ Bot media storage" };

extensions/qqbot/src/engine/messaging/reply-dispatcher.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
isMediaPayload,
1818
type MediaPayload,
1919
} from "../utils/payload.js";
20-
import { normalizePath, resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
20+
import { normalizePath } from "../utils/platform.js";
2121
import { normalizeLowercaseStringOrEmpty } from "../utils/string-normalize.js";
2222
import { sanitizeFileName } from "../utils/string-normalize.js";
2323
import { openLocalFile } from "./media-source.js";
@@ -28,6 +28,7 @@ import {
2828
buildDeliveryTarget,
2929
accountToCreds,
3030
} from "./sender.js";
31+
import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js";
3132

3233
// ---- Injected dependencies ----
3334

@@ -207,7 +208,7 @@ function validateStructuredPayloadLocalPath(
207208
payloadPath: string,
208209
mediaType: StructuredPayloadMediaType,
209210
): string | null {
210-
const allowedPath = resolveQQBotPayloadLocalFilePath(payloadPath);
211+
const allowedPath = resolveTrustedOutboundMediaPath(payloadPath);
211212
if (allowedPath) {
212213
return allowedPath;
213214
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// Qqbot tests cover trusted outbound media-path root resolution.
2+
import fs from "node:fs";
3+
import os from "node:os";
4+
import path from "node:path";
5+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
6+
import { afterEach, describe, expect, it } from "vitest";
7+
import { resolveOutboundMediaPath } from "./outbound-media-send.js";
8+
import { resolveTrustedOutboundMediaPath } from "./trusted-media-path.js";
9+
10+
const cleanupPaths: string[] = [];
11+
12+
afterEach(() => {
13+
while (cleanupPaths.length > 0) {
14+
const target = cleanupPaths.pop();
15+
if (target) {
16+
fs.rmSync(target, { recursive: true, force: true });
17+
}
18+
}
19+
});
20+
21+
function makeTtsStyleVoiceFile(): string {
22+
// Mirrors cron auto-TTS: speech-core writes the voice file under the preferred
23+
// OpenClaw temp root, which is outside the QQ Bot media storage tree.
24+
const tmpRoot = resolvePreferredOpenClawTmpDir();
25+
const ttsDir = fs.mkdtempSync(path.join(tmpRoot, "tts-"));
26+
cleanupPaths.push(ttsDir);
27+
const voicePath = path.join(ttsDir, "voice-123.mp3");
28+
fs.writeFileSync(voicePath, "audio");
29+
return voicePath;
30+
}
31+
32+
describe("resolveTrustedOutboundMediaPath", () => {
33+
it("trusts framework media under OpenClaw's hardened temp root", () => {
34+
const voicePath = makeTtsStyleVoiceFile();
35+
expect(resolveTrustedOutboundMediaPath(voicePath)).toBe(fs.realpathSync(voicePath));
36+
});
37+
38+
it("rejects local media outside every trusted root", () => {
39+
const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "qq-out-of-root-"));
40+
cleanupPaths.push(outsideDir);
41+
const strayPath = path.join(outsideDir, "stray.mp3");
42+
fs.writeFileSync(strayPath, "audio");
43+
44+
expect(resolveTrustedOutboundMediaPath(strayPath)).toBeNull();
45+
});
46+
47+
it("accepts a not-yet-flushed temp file only when allowMissing is set", () => {
48+
const tmpRoot = resolvePreferredOpenClawTmpDir();
49+
const ttsDir = fs.mkdtempSync(path.join(tmpRoot, "tts-pending-"));
50+
cleanupPaths.push(ttsDir);
51+
const pendingPath = path.join(ttsDir, "voice-pending.mp3");
52+
53+
expect(resolveTrustedOutboundMediaPath(pendingPath)).toBeNull();
54+
expect(resolveTrustedOutboundMediaPath(pendingPath, { allowMissing: true })).not.toBeNull();
55+
});
56+
});
57+
58+
describe("resolveOutboundMediaPath", () => {
59+
it("resolves a cron/TTS voice file under the temp root end to end", () => {
60+
// Both the initial resolve and the voice send re-check funnel through
61+
// resolveTrustedOutboundMediaPath, so this gate now passes for temp media.
62+
const voicePath = makeTtsStyleVoiceFile();
63+
const resolved = resolveOutboundMediaPath(voicePath, "voice", {
64+
allowMissingLocalPath: true,
65+
});
66+
67+
expect(resolved.ok).toBe(true);
68+
expect(resolved.ok && resolved.mediaPath).toBe(fs.realpathSync(voicePath));
69+
});
70+
});
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/sandbox";
2+
import { resolveLocalPathFromRootsSync } from "openclaw/plugin-sdk/security-runtime";
3+
import { resolveQQBotPayloadLocalFilePath } from "../utils/platform.js";
4+
5+
// The temp root is process-stable, so resolve it once. Only the success value is
6+
// cached: a transient provisioning failure returns null without poisoning later
7+
// calls.
8+
let cachedTrustedTmpRoot: string | undefined;
9+
function trustedOpenClawTmpRoot(): string | null {
10+
if (cachedTrustedTmpRoot === undefined) {
11+
try {
12+
cachedTrustedTmpRoot = resolvePreferredOpenClawTmpDir();
13+
} catch {
14+
return null;
15+
}
16+
}
17+
return cachedTrustedTmpRoot;
18+
}
19+
20+
/**
21+
* Resolve a local outbound media path against every trusted root, returning the
22+
* canonical path or null when it sits outside all of them.
23+
*
24+
* QQBot is the only channel that root-sandboxes outbound local files, and the
25+
* same check runs at three sites (`resolveOutboundMediaPath`, the voice send
26+
* re-check, and structured-payload validation), so they must all agree or a file
27+
* accepted at one gate is rejected at the next. Beyond the QQ Bot media storage
28+
* roots, this also trusts OpenClaw's permission-hardened temp root, where
29+
* framework scratch media is written (e.g. cron auto-TTS voice files). Core
30+
* already treats that temp root as a sanctioned media root (`buildMediaLocalRoots`);
31+
* without it here, auto-routed sends are dropped and cron delivery silently loses
32+
* the message.
33+
*
34+
* `allowMissing` lets callers accept a not-yet-flushed temp file (e.g. TTS still
35+
* writing) under the temp root; existence is then enforced later by the voice
36+
* send re-check before upload.
37+
*/
38+
export function resolveTrustedOutboundMediaPath(
39+
p: string,
40+
options: { allowMissing?: boolean } = {},
41+
): string | null {
42+
const storageRootPath = resolveQQBotPayloadLocalFilePath(p);
43+
if (storageRootPath) {
44+
return storageRootPath;
45+
}
46+
47+
const tmpRoot = trustedOpenClawTmpRoot();
48+
if (!tmpRoot) {
49+
return null;
50+
}
51+
return (
52+
resolveLocalPathFromRootsSync({
53+
filePath: p,
54+
roots: [tmpRoot],
55+
label: "OpenClaw temp media root",
56+
allowMissing: options.allowMissing === true,
57+
})?.path ?? null
58+
);
59+
}

0 commit comments

Comments
 (0)