Skip to content

Commit cd6b675

Browse files
zhangguiping-xydtsliverpclaude
authored
fix(qqbot): allow scoped sandbox media sends (#92872)
* fix(qqbot): allow scoped sandbox media sends * fix(qqbot): thread scoped media access through all outbound paths Cover the seven QQBot outbound media paths that dropped core scoped sandbox media access: gateway block/tool/streaming deliver, direct text qqmedia tags, structured QQBOT_PAYLOAD, and the scoped workspace relative/missing-voice path resolution. The low-level sendPhoto / sendVideoMsg / sendDocument now consume core mediaAccess.readFile via the shared outbound loader with explicit localRoots guard, so host-mediated workspace media can be sent without widening QQBot FS access. Voice stays on the existing SILK transcode path to avoid bypassing QQ-required format conversion. * fix(qqbot): thread workspaceDir into gateway reply media context Gateway dispatchOutbound previously built gatewayMediaContext with only mediaLocalRoots, so a sandboxed block reply that carried a relative media tag (e.g. <qqmedia>report.docx</qqmedia>) reached the downstream sender without mediaAccess.workspaceDir and was rejected by containment checks before the file could be resolved against the agent workspace. Resolve the agent workspace dir via the public plugin-sdk agent-runtime helper and pass it through mediaAccess.workspaceDir alongside the existing mediaLocalRoots so the gateway block reply, tool forwarding, QQBOT_PAYLOAD, and official C2C streaming paths all share the same scoped workspace resolution as direct outbound sends. No public plugin SDK boundary is widened; the helper is an existing export. Add a regression test that exercises a relative <qqmedia> tag in a gateway block reply and confirms it resolves against the configured agent workspace. * fix(qqbot): wrap host-read media sends with structured error handling * fix(qqbot): preserve host-read media quota errors * fix(qqbot): preserve host-read quota fallback path * fix(qqbot): resolve host-read quota fallback path * test(qqbot): satisfy host-read media lint * fix(qqbot): cover host-read voice media * fix(qqbot): satisfy host-read voice lint * Drop incidental formatting from PR merge Keep the conflict-resolution update scoped to the QQBot media send files. * fix(qqbot): map sandbox workspace media paths * fix(qqbot): preserve host-read media roots * fix(qqbot): scope sandbox media delivery roots * fix(qqbot): map virtual media roots before host read Co-Authored-By: Claude Opus 4.7 <[email protected]> * fix(qqbot): scope structured payload media roots * fix(qqbot): keep auto media helper internal * docs(changelog): note QQBot scoped sandbox media fix (#92872) (thanks @zhangguiping-xydt) --------- Co-authored-by: sliverp <[email protected]> Co-authored-by: Claude Opus 4.7 <[email protected]>
1 parent e878efe commit cd6b675

17 files changed

Lines changed: 2881 additions & 180 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,13 +36,15 @@ Docs: https://docs.openclaw.ai
3636
- **Mobile and UI stability:** preserve iOS chat line breaks and final replies, improve Android pairing and TLS recovery, hide expired pairing cards, and keep workspace file rails scrollable. (#98304, #98117, #98366, #98439, #98483, #98049, #98646, #98611) Thanks @joshavant, @Jabato01, @ooiuuii, @wuqxuan, @645648406-max, and @zw-xysk.
3737
- **Codex and approval flows:** report ChatGPT authentication correctly, rename destructive approval mode to `ask`, classify dynamic goal and session tool results accurately, and derive terminal-idle timeouts from the explicit run deadline. (#91240, #98501, #98659, #96856, #85296) Thanks @849261680, @ukstem, @kevinslin, @yetval, @nxmxbbd, @alkor2000, and @vincentkoc.
3838
- **Configuration and plugin health:** surface unloadable channel plugins, preserve defaulted provider base URLs during patches, validate bundled plugin updates by manifest contract, and retain legacy ClawHub families where required. (#96397, #98396, #98010, #98249) Thanks @849261680, @momothemage, @weltmaister, @LiLan0125, @herove, and @Patrick-Erichsen.
39+
- **QQBot media delivery:** scope sandbox-generated media sends to the active session's workspace so `/workspace/...` and relative generated-file paths resolve safely across QQBot media tags, structured payloads, and streaming delivery. (#92872) Thanks @zhangguiping-xydt.
3940

4041
### Complete contribution record
4142

42-
This audited record covers the complete 66e676d29b92d040716376a75aca32bad655cfac..3e50f41dd6ea3446b5c98a2f19ec70982ac908e6 history: 211 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
43+
This audited record covers the complete 66e676d29b92d040716376a75aca32bad655cfac..3e50f41dd6ea3446b5c98a2f19ec70982ac908e6 history: 212 merged PRs. The generation manifest also supplies direct commits as editorial input; the grouped notes above prioritize user impact.
4344

4445
#### Pull requests
4546

47+
- **PR #92872** fix(qqbot): allow scoped sandbox media sends. Thanks @zhangguiping-xydt.
4648
- **PR #96502** fix(moonshot): bound video description JSON response reads. Thanks @hugenshen and @cursoragent.
4749
- **PR #98249** Preserve legacy ClawHub family for selected plugins. Thanks @Patrick-Erichsen.
4850
- **PR #93767** fix(reasoning-tags): strip MiniMax `mm:` namespaced reasoning tags. Thanks @DrHack1.

extensions/qqbot/skills/qqbot-media/SKILL.md

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
99
## 用法
1010

1111
```
12-
<qqmedia>路径或URL</qqmedia>
12+
<qqmedia>{实际路径或URL}</qqmedia>
1313
```
1414

1515
系统根据文件扩展名自动识别类型并路由:
@@ -18,7 +18,8 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
1818
- `.silk/.wav/.mp3/.ogg/.aac/.flac` 等 → 语音
1919
- `.mp4/.mov/.avi/.mkv/.webm` 等 → 视频
2020
- 其他扩展名 → 文件
21-
- 无扩展名的 URL → 默认按图片处理
21+
- 无扩展名的当前会话本地/host-read 媒体 → 按加载出的实际媒体类型路由
22+
- 无扩展名的远程 URL → 可能按文件发送;如需图片/语音/视频,请提供能识别类型的 URL/路径或使用明确媒体标签
2223

2324
## 接收媒体
2425

@@ -29,12 +30,14 @@ metadata: { "openclaw": { "emoji": "📸", "requires": { "config": ["channels.qq
2930

3031
## 规则
3132

32-
1. **路径必须是绝对路径**(以 `/``http` 开头)
33-
2. **标签必须用开闭标签包裹路径**`<qqmedia>路径</qqmedia>`
34-
3. **待发送的本地文件须落在 OpenClaw 媒体目录下**:生成、下载或复制出的文件应写入 **`~/.openclaw/media/qqbot/`**(或其子目录),再写进 `<qqmedia>`。不要只放在 `~/.openclaw/workspace/` 等工作区根目录——平台安全策略只允许从 `~/.openclaw/media/`(含 `media/qqbot`)等受信根路径上传,否则会拦截、发不出去。
35-
4. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
36-
5. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
37-
6. 发送语音时不要重复语音中已朗读的文字
38-
7. 多个媒体用多个标签
39-
8. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)
40-
9. 不要扫描或发送上下文之外的本地文件;只使用用户提供、工具生成,或明确位于受信 media 目录中的路径
33+
1. **标签必须用开闭标签包裹实际路径或 URL**`<qqmedia>{实际路径或URL}</qqmedia>`
34+
2. **使用你实际看到的文件路径**:刚创建文件时,用创建结果显示的路径;只有当沙箱 workspace-write 创建结果实际显示 `/workspace/...` 时,才按原样使用该路径,例如 `<qqmedia>/workspace/report.pdf</qqmedia>`
35+
3. **附件路径直接使用上下文给出的路径**:如果路径来自会话【附件】上下文,不要改写成 `/workspace/...`
36+
4. **URL 可以直接发送**:例如 `<qqmedia>https://example.com/image.png</qqmedia>`
37+
5. **本地路径仍受安全根限制**:只能发送当前会话授权的 agent workspace、scoped media roots、OpenClaw 媒体目录或 QQBot 媒体目录内的文件;不要使用 `..` 逃出工作区。
38+
6. **不要扫描或主动发送上下文之外的本地文件**:只使用用户提供、工具刚生成,或当前会话上下文明确给出的路径。
39+
7. **文件大小上限**:图片 30MB / 视频 100MB / 文件 100MB / 语音 20MB
40+
8. **你有能力发送本地图片/文件**,直接用标签包裹路径即可,**不要说"无法发送"**
41+
9. 发送语音时不要重复语音中已朗读的文字
42+
10. 多个媒体用多个标签
43+
11. 以会话上下文中的能力说明为准(如未启用语音则不要发语音)

extensions/qqbot/src/channel.message-adapter.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,12 +30,26 @@ type SentTextParams = {
3030
to?: string;
3131
text?: string;
3232
replyToId?: string | null;
33+
mediaAccess?: {
34+
localRoots?: readonly string[];
35+
workspaceDir?: string;
36+
readFile?: (filePath: string) => Promise<Buffer>;
37+
};
38+
mediaLocalRoots?: readonly string[];
39+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
3340
};
3441

3542
type SentMediaParams = {
3643
to?: string;
3744
text?: string;
3845
mediaUrl?: string;
46+
mediaAccess?: {
47+
localRoots?: readonly string[];
48+
workspaceDir?: string;
49+
readFile?: (filePath: string) => Promise<Buffer>;
50+
};
51+
mediaLocalRoots?: readonly string[];
52+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
3953
};
4054

4155
function latestMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
@@ -82,16 +96,24 @@ describe("qqbot message adapter", () => {
8296
expect(result?.receipt.platformMessageIds).toEqual(["qq-text-1"]);
8397
},
8498
media: async () => {
99+
const mediaAccess = {
100+
localRoots: ["/tmp/openclaw-sandbox"],
101+
workspaceDir: "/tmp/workspace",
102+
};
85103
const result = await qqbotPlugin.message?.send?.media?.({
86104
cfg,
87105
to: "qqbot:c2c:user-1",
88106
text: "image",
89107
mediaUrl: "https://example.com/image.png",
108+
mediaAccess,
109+
mediaLocalRoots: ["/tmp/openclaw-sandbox"],
90110
});
91111
const sent = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams;
92112
expect(sent.to).toBe("qqbot:c2c:user-1");
93113
expect(sent.text).toBe("image");
94114
expect(sent.mediaUrl).toBe("https://example.com/image.png");
115+
expect(sent.mediaAccess).toBe(mediaAccess);
116+
expect(sent.mediaLocalRoots).toEqual(["/tmp/openclaw-sandbox"]);
95117
expect(result?.receipt.platformMessageIds).toEqual(["qq-media-1"]);
96118
},
97119
replyTo: async () => {
@@ -164,4 +186,44 @@ describe("qqbot message adapter", () => {
164186
}),
165187
).rejects.toThrow("QQBot message adapter send did not return a platform message id");
166188
});
189+
190+
it("forwards scoped media access through outbound text and media sends", async () => {
191+
const mediaReadFile = vi.fn(async () => Buffer.from("report"));
192+
const mediaAccess = {
193+
localRoots: ["/tmp/openclaw-sandbox"],
194+
workspaceDir: "/tmp/workspace",
195+
readFile: mediaReadFile,
196+
};
197+
const mediaLocalRoots = ["/tmp/openclaw-sandbox"];
198+
199+
sendTextMock.mockResolvedValueOnce({ messageId: "qq-text-media-1" });
200+
await qqbotPlugin.outbound?.sendText?.({
201+
cfg,
202+
to: "qqbot:c2c:user-1",
203+
text: "<qqmedia>/tmp/openclaw-sandbox/report.docx</qqmedia>",
204+
mediaAccess,
205+
mediaLocalRoots,
206+
mediaReadFile,
207+
});
208+
const sentText = latestMockArg(sendTextMock, "sendText") as SentTextParams;
209+
expect(sentText.mediaAccess).toBe(mediaAccess);
210+
expect(sentText.mediaLocalRoots).toBe(mediaLocalRoots);
211+
expect(sentText.mediaReadFile).toBe(mediaReadFile);
212+
213+
sendMediaMock.mockResolvedValueOnce({ messageId: "qq-media-local-1" });
214+
await qqbotPlugin.outbound?.sendMedia?.({
215+
cfg,
216+
to: "qqbot:c2c:user-1",
217+
text: "report",
218+
mediaUrl: "/tmp/openclaw-sandbox/report.docx",
219+
mediaAccess,
220+
mediaLocalRoots,
221+
mediaReadFile,
222+
});
223+
const sentMedia = latestMockArg(sendMediaMock, "sendMedia") as SentMediaParams;
224+
expect(sentMedia.mediaUrl).toBe("/tmp/openclaw-sandbox/report.docx");
225+
expect(sentMedia.mediaAccess).toBe(mediaAccess);
226+
expect(sentMedia.mediaLocalRoots).toBe(mediaLocalRoots);
227+
expect(sentMedia.mediaReadFile).toBe(mediaReadFile);
228+
});
167229
});

extensions/qqbot/src/channel.ts

Lines changed: 52 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { qqbotDoctor } from "./doctor.js";
2828
import { loadCredentialBackup, saveCredentialBackup } from "./engine/config/credential-backup.js";
2929
import { clearAccountCredentials } from "./engine/config/credentials.js";
3030
import { chunkQQBotMarkdownText } from "./engine/messaging/markdown-table-chunking.js";
31+
import type { OutboundMediaAccessContext } from "./engine/messaging/outbound-types.js";
3132
import {
3233
normalizeTarget as coreNormalizeTarget,
3334
looksLikeQQBotTarget,
@@ -61,13 +62,15 @@ function createQQBotSendReceipt(params: {
6162
});
6263
}
6364

64-
async function sendQQBotText(params: {
65-
cfg: OpenClawConfig;
66-
to: string;
67-
text: string;
68-
accountId?: string | null;
69-
replyToId?: string | null;
70-
}) {
65+
async function sendQQBotText(
66+
params: {
67+
cfg: OpenClawConfig;
68+
to: string;
69+
text: string;
70+
accountId?: string | null;
71+
replyToId?: string | null;
72+
} & OutboundMediaAccessContext,
73+
) {
7174
// Ensure bridge/gateway.ts module-level registrations (audio adapter factory,
7275
// platform adapter, etc.) have executed before engine code runs.
7376
await loadGatewayModule();
@@ -79,6 +82,9 @@ async function sendQQBotText(params: {
7982
accountId: params.accountId,
8083
replyToId: params.replyToId,
8184
account: toGatewayAccount(account),
85+
...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}),
86+
...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
87+
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
8288
});
8389
return {
8490
channel: "qqbot" as const,
@@ -92,14 +98,16 @@ async function sendQQBotText(params: {
9298
};
9399
}
94100

95-
async function sendQQBotMedia(params: {
96-
cfg: OpenClawConfig;
97-
to: string;
98-
text?: string | null;
99-
mediaUrl?: string | null;
100-
accountId?: string | null;
101-
replyToId?: string | null;
102-
}) {
101+
async function sendQQBotMedia(
102+
params: {
103+
cfg: OpenClawConfig;
104+
to: string;
105+
text?: string | null;
106+
mediaUrl?: string | null;
107+
accountId?: string | null;
108+
replyToId?: string | null;
109+
} & OutboundMediaAccessContext,
110+
) {
103111
// Same guard as sendText — ensure adapters are registered.
104112
await loadGatewayModule();
105113
const account = resolveQQBotAccount(params.cfg, params.accountId);
@@ -111,6 +119,9 @@ async function sendQQBotMedia(params: {
111119
accountId: params.accountId,
112120
replyToId: params.replyToId,
113121
account: toGatewayAccount(account),
122+
...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}),
123+
...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
124+
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
114125
});
115126
return {
116127
channel: "qqbot" as const,
@@ -124,6 +135,15 @@ async function sendQQBotMedia(params: {
124135
};
125136
}
126137

138+
function resolveQQBotOutboundMediaAccessContext(ctx: unknown): OutboundMediaAccessContext {
139+
const record = ctx && typeof ctx === "object" ? (ctx as OutboundMediaAccessContext) : undefined;
140+
return {
141+
...(record?.mediaAccess ? { mediaAccess: record.mediaAccess } : {}),
142+
...(record?.mediaLocalRoots ? { mediaLocalRoots: record.mediaLocalRoots } : {}),
143+
...(record?.mediaReadFile ? { mediaReadFile: record.mediaReadFile } : {}),
144+
};
145+
}
146+
127147
function toQQBotMessageSendResult(result: Awaited<ReturnType<typeof sendQQBotText>>) {
128148
if (result.meta?.error) {
129149
throw new Error(result.meta.error);
@@ -155,6 +175,7 @@ const qqbotMessageAdapter = defineChannelMessageAdapter({
155175
text: ctx.text,
156176
accountId: ctx.accountId,
157177
replyToId: ctx.replyToId,
178+
...resolveQQBotOutboundMediaAccessContext(ctx),
158179
}),
159180
),
160181
media: async (ctx) =>
@@ -166,6 +187,7 @@ const qqbotMessageAdapter = defineChannelMessageAdapter({
166187
mediaUrl: ctx.mediaUrl,
167188
accountId: ctx.accountId,
168189
replyToId: ctx.replyToId,
190+
...resolveQQBotOutboundMediaAccessContext(ctx),
169191
}),
170192
),
171193
},
@@ -267,22 +289,24 @@ export const qqbotPlugin: ChannelPlugin<ResolvedQQBotAccount> = {
267289
payload,
268290
hint,
269291
}),
270-
sendText: async ({ to, text, accountId, replyToId, cfg }) =>
292+
sendText: async (ctx) =>
271293
await sendQQBotText({
272-
cfg,
273-
to,
274-
text,
275-
accountId,
276-
replyToId,
294+
cfg: ctx.cfg,
295+
to: ctx.to,
296+
text: ctx.text,
297+
accountId: ctx.accountId,
298+
replyToId: ctx.replyToId,
299+
...resolveQQBotOutboundMediaAccessContext(ctx),
277300
}),
278-
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId, cfg }) =>
301+
sendMedia: async (ctx) =>
279302
await sendQQBotMedia({
280-
cfg,
281-
to,
282-
text,
283-
mediaUrl,
284-
accountId,
285-
replyToId,
303+
cfg: ctx.cfg,
304+
to: ctx.to,
305+
text: ctx.text,
306+
mediaUrl: ctx.mediaUrl,
307+
accountId: ctx.accountId,
308+
replyToId: ctx.replyToId,
309+
...resolveQQBotOutboundMediaAccessContext(ctx),
286310
}),
287311
},
288312
gateway: {

0 commit comments

Comments
 (0)