Skip to content

Commit 3bb02d3

Browse files
committed
fix(media): align outbound sends with fs read capability
1 parent 56b5ba0 commit 3bb02d3

51 files changed

Lines changed: 375 additions & 25 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
3838

3939
### Fixes
4040

41+
- Outbound media/local files: piggyback host-local `MEDIA:` reads on the configured fs policy instead of a separate media-root check, so generated files outside the workspace can send when `tools.fs.workspaceOnly=false` while plaintext-like host files stay blocked by the outbound media allowlist.
4142
- Gateway/auth: reject mismatched browser `Origin` headers on trusted-proxy HTTP operator requests while keeping origin-less headless proxy clients working. Thanks @AntAISecurityLab and @vincentkoc.
4243
- Plugins/startup: block workspace `.env` from overriding `OPENCLAW_BUNDLED_PLUGINS_DIR`, so bundled plugin trust roots only come from inherited runtime env or package resolution instead of repo-local dotenv files. Thanks @nexrin and @vincentkoc.
4344
- Image generation/build: write stable runtime alias files into `dist/` and route provider-auth runtime lookups through those aliases so image-generation providers keep resolving auth/runtime modules after rebuilds instead of crashing on missing hashed chunk files.

docs/help/faq.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2784,6 +2784,8 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a
27842784

27852785
- The target channel supports outbound media and isn't blocked by allowlists.
27862786
- The file is within the provider's size limits (images are resized to max 2048px).
2787+
- `tools.fs.workspaceOnly=true` keeps local-path sends limited to workspace, temp/media-store, and sandbox-validated files.
2788+
- `tools.fs.workspaceOnly=false` lets `MEDIA:` send host-local files the agent can already read, but only for media plus safe document types (images, audio, video, PDF, and Office docs). Plain text and secret-like files are still blocked.
27872789

27882790
See [Images](/nodes/images).
27892791

docs/start/openclaw.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -192,10 +192,13 @@ MEDIA:https://example.com/screenshot.png
192192

193193
OpenClaw extracts these and sends them as media alongside the text.
194194

195-
For local paths, the default allowlist is intentionally narrow: the OpenClaw temp
196-
root, the media cache, agent workspace paths, and sandbox-generated files. If you
197-
need broader local-file attachment roots, configure an explicit channel/plugin
198-
allowlist instead of relying on arbitrary host paths.
195+
Local-path behavior follows the same file-read trust model as the agent:
196+
197+
- If `tools.fs.workspaceOnly` is `true`, outbound `MEDIA:` local paths stay restricted to the OpenClaw temp root, the media cache, agent workspace paths, and sandbox-generated files.
198+
- If `tools.fs.workspaceOnly` is `false`, outbound `MEDIA:` can use host-local files the agent is already allowed to read.
199+
- Host-local sends still only allow media and safe document types (images, audio, video, PDF, and Office documents). Plain text and secret-like files are not treated as sendable media.
200+
201+
That means generated images/files outside the workspace can now send when your fs policy already allows those reads, without reopening arbitrary host-text attachment exfiltration.
199202

200203
## Operations checklist
201204

extensions/discord/src/actions/handle-action.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ export async function handleDiscordMessageAction(
2626
| "requesterSenderId"
2727
| "toolContext"
2828
| "mediaLocalRoots"
29+
| "mediaReadFile"
2930
>,
3031
): Promise<AgentToolResult<unknown>> {
3132
const { action, params, cfg } = ctx;
3233
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
3334
const actionOptions = {
3435
mediaLocalRoots: ctx.mediaLocalRoots,
36+
mediaReadFile: ctx.mediaReadFile,
3537
} as const;
3638

3739
const resolveChannelId = () =>

extensions/discord/src/actions/runtime.messaging.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,7 @@ export async function handleDiscordMessagingAction(
9696
isActionEnabled: ActionGate<DiscordActionConfig>,
9797
options?: {
9898
mediaLocalRoots?: readonly string[];
99+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
99100
},
100101
cfg?: OpenClawConfig,
101102
): Promise<AgentToolResult<unknown>> {
@@ -388,6 +389,7 @@ export async function handleDiscordMessagingAction(
388389
mediaUrl,
389390
filename: filename ?? undefined,
390391
mediaLocalRoots: options?.mediaLocalRoots,
392+
mediaReadFile: options?.mediaReadFile,
391393
replyTo,
392394
components,
393395
embeds,
@@ -516,6 +518,7 @@ export async function handleDiscordMessagingAction(
516518
...(accountId ? { accountId } : {}),
517519
mediaUrl,
518520
mediaLocalRoots: options?.mediaLocalRoots,
521+
mediaReadFile: options?.mediaReadFile,
519522
replyTo,
520523
},
521524
);

extensions/discord/src/outbound-adapter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
173173
return await sendDiscordComponentMessage(target, componentSpec, {
174174
mediaUrl,
175175
mediaLocalRoots: ctx.mediaLocalRoots,
176+
mediaReadFile: ctx.mediaReadFile,
176177
replyTo: ctx.replyToId ?? undefined,
177178
accountId: ctx.accountId ?? undefined,
178179
silent: ctx.silent ?? undefined,
@@ -183,6 +184,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
183184
verbose: false,
184185
mediaUrl,
185186
mediaLocalRoots: ctx.mediaLocalRoots,
187+
mediaReadFile: ctx.mediaReadFile,
186188
replyTo: ctx.replyToId ?? undefined,
187189
accountId: ctx.accountId ?? undefined,
188190
silent: ctx.silent ?? undefined,
@@ -224,6 +226,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
224226
text,
225227
mediaUrl,
226228
mediaLocalRoots,
229+
mediaReadFile,
227230
accountId,
228231
deps,
229232
replyToId,
@@ -236,6 +239,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
236239
verbose: false,
237240
mediaUrl,
238241
mediaLocalRoots,
242+
mediaReadFile,
239243
replyTo: replyToId ?? undefined,
240244
accountId: accountId ?? undefined,
241245
silent: silent ?? undefined,

extensions/discord/src/send.components.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ type DiscordComponentSendOpts = {
5252
agentId?: string;
5353
mediaUrl?: string;
5454
mediaLocalRoots?: readonly string[];
55+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
5556
filename?: string;
5657
};
5758

@@ -100,6 +101,8 @@ async function buildDiscordComponentPayload(params: {
100101
if (params.opts.mediaUrl) {
101102
const media = await loadWebMedia(params.opts.mediaUrl, {
102103
localRoots: params.opts.mediaLocalRoots,
104+
readFile: params.opts.mediaReadFile,
105+
hostReadCapability: Boolean(params.opts.mediaReadFile),
103106
});
104107
const filenameOverride = params.opts.filename?.trim();
105108
const fileName = filenameOverride || media.fileName || "upload";

extensions/discord/src/send.outbound.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ type DiscordSendOpts = {
5050
mediaUrl?: string;
5151
filename?: string;
5252
mediaLocalRoots?: readonly string[];
53+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
5354
verbose?: boolean;
5455
rest?: RequestClient;
5556
replyTo?: string;
@@ -217,6 +218,7 @@ export async function sendMessageDiscord(
217218
opts.mediaUrl,
218219
opts.filename,
219220
opts.mediaLocalRoots,
221+
opts.mediaReadFile,
220222
mediaMaxBytes,
221223
undefined,
222224
request,
@@ -279,6 +281,7 @@ export async function sendMessageDiscord(
279281
opts.mediaUrl,
280282
opts.filename,
281283
opts.mediaLocalRoots,
284+
opts.mediaReadFile,
282285
mediaMaxBytes,
283286
opts.replyTo,
284287
request,

extensions/discord/src/send.shared.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,7 @@ async function sendDiscordMedia(
419419
mediaUrl: string,
420420
filename: string | undefined,
421421
mediaLocalRoots: readonly string[] | undefined,
422+
mediaReadFile: ((filePath: string) => Promise<Buffer>) | undefined,
422423
maxBytes: number | undefined,
423424
replyTo: string | undefined,
424425
request: DiscordRequest,
@@ -430,7 +431,7 @@ async function sendDiscordMedia(
430431
) {
431432
const media = await loadWebMedia(
432433
mediaUrl,
433-
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots }),
434+
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots, mediaReadFile }),
434435
);
435436
const requestedFileName = filename?.trim();
436437
const resolvedFileName =

extensions/googlechat/src/actions.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ async function loadGoogleChatActionMedia(params: {
5454
mediaUrl: string;
5555
maxBytes: number;
5656
mediaLocalRoots?: readonly string[];
57+
mediaReadFile?: (filePath: string) => Promise<Buffer>;
5758
}) {
5859
const runtime = getGoogleChatRuntime();
5960
return /^https?:\/\//i.test(params.mediaUrl)
@@ -64,6 +65,8 @@ async function loadGoogleChatActionMedia(params: {
6465
: await runtime.media.loadWebMedia(params.mediaUrl, {
6566
maxBytes: params.maxBytes,
6667
localRoots: params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined,
68+
readFile: params.mediaReadFile,
69+
hostReadCapability: Boolean(params.mediaReadFile),
6770
});
6871
}
6972

@@ -85,7 +88,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
8588
extractToolSend: ({ args }) => {
8689
return extractToolSend(args, "sendMessage");
8790
},
88-
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
91+
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, mediaReadFile }) => {
8992
const account = resolveGoogleChatAccount({
9093
cfg: cfg,
9194
accountId,
@@ -118,6 +121,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
118121
mediaUrl,
119122
maxBytes,
120123
mediaLocalRoots,
124+
mediaReadFile,
121125
});
122126
const uploadFileName =
123127
readStringParam(params, "filename") ??

0 commit comments

Comments
 (0)