mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-08 02:52:15 +00:00
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:
@@ -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" };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
59
extensions/qqbot/src/engine/messaging/trusted-media-path.ts
Normal file
59
extensions/qqbot/src/engine/messaging/trusted-media-path.ts
Normal 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
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user