fix(media): align outbound sends with fs read capability

This commit is contained in:
Peter Steinberger 2026-04-01 00:07:38 +09:00
parent 56b5ba0dcb
commit 3bb02d3338
No known key found for this signature in database
51 changed files with 375 additions and 25 deletions

View File

@ -38,6 +38,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- 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.
- 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.
- 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.
- 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.

View File

@ -2784,6 +2784,8 @@ Related: [/concepts/oauth](/concepts/oauth) (OAuth flows, token storage, multi-a
- The target channel supports outbound media and isn't blocked by allowlists.
- The file is within the provider's size limits (images are resized to max 2048px).
- `tools.fs.workspaceOnly=true` keeps local-path sends limited to workspace, temp/media-store, and sandbox-validated files.
- `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.
See [Images](/nodes/images).

View File

@ -192,10 +192,13 @@ MEDIA:https://example.com/screenshot.png
OpenClaw extracts these and sends them as media alongside the text.
For local paths, the default allowlist is intentionally narrow: the OpenClaw temp
root, the media cache, agent workspace paths, and sandbox-generated files. If you
need broader local-file attachment roots, configure an explicit channel/plugin
allowlist instead of relying on arbitrary host paths.
Local-path behavior follows the same file-read trust model as the agent:
- 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.
- If `tools.fs.workspaceOnly` is `false`, outbound `MEDIA:` can use host-local files the agent is already allowed to read.
- 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.
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.
## Operations checklist

View File

@ -26,12 +26,14 @@ export async function handleDiscordMessageAction(
| "requesterSenderId"
| "toolContext"
| "mediaLocalRoots"
| "mediaReadFile"
>,
): Promise<AgentToolResult<unknown>> {
const { action, params, cfg } = ctx;
const accountId = ctx.accountId ?? readStringParam(params, "accountId");
const actionOptions = {
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
} as const;
const resolveChannelId = () =>

View File

@ -96,6 +96,7 @@ export async function handleDiscordMessagingAction(
isActionEnabled: ActionGate<DiscordActionConfig>,
options?: {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
cfg?: OpenClawConfig,
): Promise<AgentToolResult<unknown>> {
@ -388,6 +389,7 @@ export async function handleDiscordMessagingAction(
mediaUrl,
filename: filename ?? undefined,
mediaLocalRoots: options?.mediaLocalRoots,
mediaReadFile: options?.mediaReadFile,
replyTo,
components,
embeds,
@ -516,6 +518,7 @@ export async function handleDiscordMessagingAction(
...(accountId ? { accountId } : {}),
mediaUrl,
mediaLocalRoots: options?.mediaLocalRoots,
mediaReadFile: options?.mediaReadFile,
replyTo,
},
);

View File

@ -173,6 +173,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
return await sendDiscordComponentMessage(target, componentSpec, {
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
@ -183,6 +184,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
verbose: false,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
replyTo: ctx.replyToId ?? undefined,
accountId: ctx.accountId ?? undefined,
silent: ctx.silent ?? undefined,
@ -224,6 +226,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,
@ -236,6 +239,7 @@ export const discordOutbound: ChannelOutboundAdapter = {
verbose: false,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
replyTo: replyToId ?? undefined,
accountId: accountId ?? undefined,
silent: silent ?? undefined,

View File

@ -52,6 +52,7 @@ type DiscordComponentSendOpts = {
agentId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
filename?: string;
};
@ -100,6 +101,8 @@ async function buildDiscordComponentPayload(params: {
if (params.opts.mediaUrl) {
const media = await loadWebMedia(params.opts.mediaUrl, {
localRoots: params.opts.mediaLocalRoots,
readFile: params.opts.mediaReadFile,
hostReadCapability: Boolean(params.opts.mediaReadFile),
});
const filenameOverride = params.opts.filename?.trim();
const fileName = filenameOverride || media.fileName || "upload";

View File

@ -50,6 +50,7 @@ type DiscordSendOpts = {
mediaUrl?: string;
filename?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
verbose?: boolean;
rest?: RequestClient;
replyTo?: string;
@ -217,6 +218,7 @@ export async function sendMessageDiscord(
opts.mediaUrl,
opts.filename,
opts.mediaLocalRoots,
opts.mediaReadFile,
mediaMaxBytes,
undefined,
request,
@ -279,6 +281,7 @@ export async function sendMessageDiscord(
opts.mediaUrl,
opts.filename,
opts.mediaLocalRoots,
opts.mediaReadFile,
mediaMaxBytes,
opts.replyTo,
request,

View File

@ -419,6 +419,7 @@ async function sendDiscordMedia(
mediaUrl: string,
filename: string | undefined,
mediaLocalRoots: readonly string[] | undefined,
mediaReadFile: ((filePath: string) => Promise<Buffer>) | undefined,
maxBytes: number | undefined,
replyTo: string | undefined,
request: DiscordRequest,
@ -430,7 +431,7 @@ async function sendDiscordMedia(
) {
const media = await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots }),
buildOutboundMediaLoadOptions({ maxBytes, mediaLocalRoots, mediaReadFile }),
);
const requestedFileName = filename?.trim();
const resolvedFileName =

View File

@ -54,6 +54,7 @@ async function loadGoogleChatActionMedia(params: {
mediaUrl: string;
maxBytes: number;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}) {
const runtime = getGoogleChatRuntime();
return /^https?:\/\//i.test(params.mediaUrl)
@ -64,6 +65,8 @@ async function loadGoogleChatActionMedia(params: {
: await runtime.media.loadWebMedia(params.mediaUrl, {
maxBytes: params.maxBytes,
localRoots: params.mediaLocalRoots?.length ? params.mediaLocalRoots : undefined,
readFile: params.mediaReadFile,
hostReadCapability: Boolean(params.mediaReadFile),
});
}
@ -85,7 +88,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
extractToolSend: ({ args }) => {
return extractToolSend(args, "sendMessage");
},
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots }) => {
handleAction: async ({ action, params, cfg, accountId, mediaLocalRoots, mediaReadFile }) => {
const account = resolveGoogleChatAccount({
cfg: cfg,
accountId,
@ -118,6 +121,7 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = {
mediaUrl,
maxBytes,
mediaLocalRoots,
mediaReadFile,
});
const uploadFileName =
readStringParam(params, "filename") ??

View File

@ -402,6 +402,7 @@ export const googlechatPlugin = createChatChannelPlugin({
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
replyToId,
threadId,
@ -435,6 +436,8 @@ export const googlechatPlugin = createChatChannelPlugin({
: await loadWebMedia(mediaUrl, {
maxBytes: effectiveMaxBytes,
localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
readFile: mediaReadFile,
hostReadCapability: Boolean(mediaReadFile),
});
const { sendGoogleChatMessage, uploadGoogleChatAttachment } =
await loadGoogleChatChannelRuntime();

View File

@ -17,6 +17,7 @@ export type IMessageSendOpts = {
replyToId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
timeoutMs?: number;
chatId?: number;
@ -26,7 +27,10 @@ export type IMessageSendOpts = {
resolveAttachmentImpl?: (
mediaUrl: string,
maxBytes: number,
options?: { localRoots?: readonly string[] },
options?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
},
) => Promise<{ path: string; contentType?: string }>;
createClient?: (params: { cliPath: string; dbPath?: string }) => Promise<IMessageRpcClient>;
};
@ -123,6 +127,7 @@ export async function sendMessageIMessage(
const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl;
const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, {
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
});
filePath = resolved.path;
message = resolveDeliveredIMessageText(message, resolved.contentType ?? undefined);

View File

@ -166,6 +166,8 @@ export async function sendMessageMatrix(
const media = await getCore().media.loadWebMedia(opts.mediaUrl, {
maxBytes,
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
hostReadCapability: Boolean(opts.mediaReadFile),
});
const uploaded = await uploadMediaMaybeEncrypted(client, roomId, media.buffer, {
contentType: media.contentType,

View File

@ -89,6 +89,7 @@ export type MatrixSendOpts = {
cfg?: CoreConfig;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;
replyToId?: string;
threadId?: string | number | null;

View File

@ -35,6 +35,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
deps,
replyToId,
threadId,
@ -49,6 +50,7 @@ export const matrixOutbound: ChannelOutboundAdapter = {
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
replyToId: replyToId ?? undefined,
threadId: resolvedThreadId,
accountId: accountId ?? undefined,

View File

@ -31,6 +31,7 @@ export type MattermostSendOpts = {
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
replyToId?: string;
props?: Record<string, unknown>;
buttons?: Array<unknown>;
@ -417,6 +418,7 @@ export async function sendMessageMattermost(
try {
const media = await loadOutboundMediaFromUrl(mediaUrl, {
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
});
const fileInfo = await uploadMattermostFile(client, {
channelId,

View File

@ -625,6 +625,7 @@ export const msteamsPlugin: ChannelPlugin<ResolvedMSTeamsAccount, ProbeMSTeamsRe
readOptionalTrimmedString(ctx.params, "filename") ??
readOptionalTrimmedString(ctx.params, "title"),
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
});
return jsonActionResultWithDetails(
{

View File

@ -23,11 +23,15 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
((to, text) => sendMessageMSTeams({ cfg, to, text }));
return await send(to, text);
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, deps }) => {
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, mediaReadFile, deps }) => {
type SendFn = (
to: string,
text: string,
opts?: { mediaUrl?: string; mediaLocalRoots?: readonly string[] },
opts?: {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
) => Promise<{ messageId: string; conversationId: string }>;
const send =
resolveOutboundSendDep<SendFn>(deps, "msteams") ??
@ -38,8 +42,9 @@ export const msteamsOutbound: ChannelOutboundAdapter = {
text,
mediaUrl: opts?.mediaUrl,
mediaLocalRoots: opts?.mediaLocalRoots,
mediaReadFile: opts?.mediaReadFile,
}));
return await send(to, text, { mediaUrl, mediaLocalRoots });
return await send(to, text, { mediaUrl, mediaLocalRoots, mediaReadFile });
},
sendPoll: async ({ cfg, to, poll }) => {
const maxSelections = poll.maxSelections ?? 1;

View File

@ -33,6 +33,7 @@ export type SendMSTeamsMessageParams = {
/** Optional filename override for uploaded media/files */
filename?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
export type SendMSTeamsMessageResult = {
@ -98,7 +99,7 @@ export type SendMSTeamsCardResult = {
export async function sendMessageMSTeams(
params: SendMSTeamsMessageParams,
): Promise<SendMSTeamsMessageResult> {
const { cfg, to, text, mediaUrl, filename, mediaLocalRoots } = params;
const { cfg, to, text, mediaUrl, filename, mediaLocalRoots, mediaReadFile } = params;
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "msteams",
@ -129,6 +130,7 @@ export async function sendMessageMSTeams(
const media = await loadOutboundMediaFromUrl(mediaUrl, {
maxBytes: mediaMaxBytes,
mediaLocalRoots,
mediaReadFile,
});
const isLargeFile = media.buffer.length >= FILE_CONSENT_THRESHOLD_BYTES;
const isImage = media.contentType?.startsWith("image/") ?? false;

View File

@ -62,6 +62,7 @@ async function sendSignalOutbound(params: {
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;
deps?: { [channelId: string]: unknown };
}) {
@ -70,6 +71,7 @@ async function sendSignalOutbound(params: {
cfg: params.cfg,
...(params.mediaUrl ? { mediaUrl: params.mediaUrl } : {}),
...(params.mediaLocalRoots?.length ? { mediaLocalRoots: params.mediaLocalRoots } : {}),
...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}),
maxBytes,
accountId: params.accountId ?? undefined,
});
@ -188,6 +190,7 @@ async function sendFormattedSignalMedia(ctx: {
text: string;
mediaUrl: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
deps?: { [channelId: string]: unknown };
abortSignal?: AbortSignal;
@ -213,6 +216,7 @@ async function sendFormattedSignalMedia(ctx: {
cfg: ctx.cfg,
mediaUrl: ctx.mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
...(ctx.mediaReadFile ? { mediaReadFile: ctx.mediaReadFile } : {}),
maxBytes,
accountId: ctx.accountId ?? undefined,
textMode: "plain",
@ -337,6 +341,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
@ -347,6 +352,7 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
@ -362,13 +368,23 @@ export const signalPlugin: ChannelPlugin<ResolvedSignalAccount, SignalProbe> =
accountId: accountId ?? undefined,
deps,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) =>
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) =>
await sendSignalOutbound({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
deps,
}),

View File

@ -71,6 +71,7 @@ export const signalOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
abortSignal,
@ -96,6 +97,7 @@ export const signalOutbound: ChannelOutboundAdapter = {
textMode: "plain",
textStyles: formatted.styles,
mediaLocalRoots,
mediaReadFile,
});
return attachChannelToResult("signal", result);
},
@ -113,7 +115,16 @@ export const signalOutbound: ChannelOutboundAdapter = {
accountId: accountId ?? undefined,
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => {
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
}) => {
const send = resolveSignalSender(deps);
const maxBytes = resolveSignalMaxBytes({
cfg,
@ -125,6 +136,7 @@ export const signalOutbound: ChannelOutboundAdapter = {
maxBytes,
accountId: accountId ?? undefined,
mediaLocalRoots,
mediaReadFile,
});
},
}),

View File

@ -14,6 +14,7 @@ export type SignalSendOpts = {
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
timeoutMs?: number;
textMode?: "markdown" | "plain";
@ -129,6 +130,7 @@ export async function sendMessageSignal(
if (opts.mediaUrl?.trim()) {
const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, {
localRoots: opts.mediaLocalRoots,
readFile: opts.mediaReadFile,
});
attachments = [resolved.path];
const kind = kindFromMime(resolved.contentType ?? undefined);

View File

@ -72,6 +72,7 @@ export type SlackActionContext = {
hasRepliedRef?: { value: boolean };
/** Allowed local media directories for file uploads. */
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
/**
@ -232,6 +233,7 @@ export async function handleSlackAction(
...writeOpts,
mediaUrl: mediaUrl ?? undefined,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
blocks,
});
@ -276,6 +278,7 @@ export async function handleSlackAction(
...writeOpts,
mediaUrl: filePath,
mediaLocalRoots: context?.mediaLocalRoots,
mediaReadFile: context?.mediaReadFile,
threadTs: threadTs ?? undefined,
...(filename ? { uploadFileName: filename } : {}),
...(title ? { uploadTitle: title } : {}),

View File

@ -160,6 +160,7 @@ export async function sendSlackMessage(
opts: SlackActionClientOpts & {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
threadTs?: string;
uploadFileName?: string;
uploadTitle?: string;
@ -171,6 +172,7 @@ export async function sendSlackMessage(
token: opts.token,
mediaUrl: opts.mediaUrl,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
client: opts.client,
threadTs: opts.threadTs,
...(opts.uploadFileName ? { uploadFileName: opts.uploadFileName } : {}),

View File

@ -63,6 +63,7 @@ export function createSlackActions(
: handleSlackAction(action, cfg, {
...(toolContext as SlackActionContext | undefined),
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
})),
});
},

View File

@ -83,6 +83,7 @@ async function sendSlackOutboundMessage(params: {
text: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
blocks?: NonNullable<Parameters<typeof sendMessageSlack>[2]>["blocks"];
accountId?: string | null;
deps?: { [channelId: string]: unknown } | null;
@ -115,7 +116,11 @@ async function sendSlackOutboundMessage(params: {
threadTs,
accountId: params.accountId ?? undefined,
...(params.mediaUrl
? { mediaUrl: params.mediaUrl, mediaLocalRoots: params.mediaLocalRoots }
? {
mediaUrl: params.mediaUrl,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
}
: {}),
...(params.blocks ? { blocks: params.blocks } : {}),
...(slackIdentity ? { identity: slackIdentity } : {}),
@ -184,6 +189,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
accountId: ctx.accountId,
deps: ctx.deps,
replyToId: ctx.replyToId,
@ -196,6 +202,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
to: ctx.to,
text: payload.text ?? "",
mediaLocalRoots: ctx.mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
blocks,
accountId: ctx.accountId,
deps: ctx.deps,
@ -225,6 +232,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,
@ -237,6 +245,7 @@ export const slackOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,

View File

@ -52,6 +52,7 @@ type SlackSendOpts = {
uploadFileName?: string;
uploadTitle?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
client?: WebClient;
threadTs?: string;
identity?: SlackSendIdentity;
@ -233,6 +234,7 @@ async function uploadSlackFile(params: {
uploadFileName?: string;
uploadTitle?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
caption?: string;
threadTs?: string;
maxBytes?: number;
@ -240,6 +242,8 @@ async function uploadSlackFile(params: {
const { buffer, contentType, fileName } = await loadWebMedia(params.mediaUrl, {
maxBytes: params.maxBytes,
localRoots: params.mediaLocalRoots,
readFile: params.mediaReadFile,
hostReadCapability: Boolean(params.mediaReadFile),
});
const uploadFileName = params.uploadFileName ?? fileName ?? "upload";
const uploadTitle = params.uploadTitle ?? uploadFileName;
@ -372,6 +376,7 @@ export async function sendMessageSlack(
uploadFileName: opts.uploadFileName,
uploadTitle: opts.uploadTitle,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
caption: firstChunk,
threadTs: opts.threadTs,
maxBytes: mediaMaxBytes,

View File

@ -214,6 +214,7 @@ export async function handleTelegramAction(
cfg: OpenClawConfig,
options?: {
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
},
): Promise<AgentToolResult<unknown>> {
const { action, accountId } = {
@ -365,6 +366,7 @@ export async function handleTelegramAction(
accountId: accountId ?? undefined,
mediaUrl: mediaUrl || undefined,
mediaLocalRoots: options?.mediaLocalRoots,
mediaReadFile: options?.mediaReadFile,
buttons,
replyToMessageId: replyToMessageId ?? undefined,
messageThreadId: messageThreadId ?? undefined,

View File

@ -142,6 +142,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,
@ -161,6 +162,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
...baseOpts,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
forceDocument: forceDocument ?? false,
});
},
@ -170,6 +172,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
to,
payload,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,
@ -192,6 +195,7 @@ export const telegramOutbound: ChannelOutboundAdapter = {
baseOpts: {
...baseOpts,
mediaLocalRoots,
mediaReadFile,
forceDocument: forceDocument ?? false,
},
});

View File

@ -75,6 +75,7 @@ type TelegramSendOpts = {
verbose?: boolean;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gatewayClientScopes?: readonly string[];
maxBytes?: number;
api?: TelegramApiOverride;
@ -838,6 +839,7 @@ export async function sendMessageTelegram(
buildOutboundMediaLoadOptions({
maxBytes: mediaMaxBytes,
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
optimizeImages: opts.forceDocument ? false : undefined,
}),
);

View File

@ -66,6 +66,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
@ -79,6 +80,7 @@ export const whatsappOutbound: ChannelOutboundAdapter = {
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
});

View File

@ -22,6 +22,7 @@ export async function sendMessageWhatsApp(
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gifPlayback?: boolean;
accountId?: string;
},
@ -63,6 +64,8 @@ export async function sendMessageWhatsApp(
const media = await loadWebMedia(options.mediaUrl, {
maxBytes: resolveWhatsAppMediaMaxBytes(account),
localRoots: options.mediaLocalRoots,
readFile: options.mediaReadFile,
hostReadCapability: Boolean(options.mediaReadFile),
});
const caption = text || undefined;
mediaBuffer = media.buffer;

View File

@ -77,7 +77,7 @@ const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),
});
},
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots }) => {
sendMedia: async ({ to, text, mediaUrl, accountId, cfg, mediaLocalRoots, mediaReadFile }) => {
const account = resolveZalouserAccountSync({ cfg: cfg, accountId });
const target = parseZalouserOutboundTarget(to);
return await sendMessageZalouser(target.threadId, text, {
@ -85,6 +85,7 @@ const zalouserRawSendResultAdapter = createRawChannelSendResultAdapter({
isGroup: target.isGroup,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
textMode: "markdown",
textChunkMode: resolveZalouserOutboundChunkMode(cfg, account.accountId),
textChunkLimit: resolveZalouserOutboundTextChunkLimit(cfg, account.accountId),

View File

@ -61,6 +61,7 @@ export type ZaloSendOptions = {
caption?: string;
isGroup?: boolean;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
textMode?: "markdown" | "plain";
textChunkMode?: "length" | "newline";
textChunkLimit?: number;

View File

@ -1043,6 +1043,7 @@ export async function sendZaloTextMessage(
if (options.mediaUrl?.trim()) {
const media = await loadOutboundMediaFromUrl(options.mediaUrl.trim(), {
mediaLocalRoots: options.mediaLocalRoots,
mediaReadFile: options.mediaReadFile,
});
const fileName = resolveMediaFileName({
mediaUrl: options.mediaUrl,

View File

@ -17,6 +17,7 @@ type DirectSendOptions = {
replyToId?: string | null;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
maxBytes?: number;
};
@ -80,6 +81,7 @@ export function createDirectTextMediaOutbound<
replyToId?: string | null;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
buildOptions: (params: DirectSendOptions) => TOpts;
}) => {
const send = params.resolveSender(sendParams.deps);
@ -94,6 +96,7 @@ export function createDirectTextMediaOutbound<
cfg: sendParams.cfg,
mediaUrl: sendParams.mediaUrl,
mediaLocalRoots: sendParams.mediaLocalRoots,
mediaReadFile: sendParams.mediaReadFile,
accountId: sendParams.accountId,
replyToId: sendParams.replyToId,
maxBytes,
@ -120,13 +123,24 @@ export function createDirectTextMediaOutbound<
buildOptions: params.buildTextOptions,
});
},
sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps, replyToId }) => {
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,
}) => {
return await sendDirect({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
replyToId,

View File

@ -138,6 +138,7 @@ export type ChannelOutboundContext = {
mediaUrl?: string;
audioAsVoice?: boolean;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gifPlayback?: boolean;
/** Send image as document to avoid Telegram compression. */
forceDocument?: boolean;

View File

@ -495,6 +495,7 @@ export type ChannelMessageActionContext = {
cfg: OpenClawConfig;
params: Record<string, unknown>;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
/**
* Trusted sender id from inbound context. This is server-injected and must

View File

@ -30,6 +30,7 @@ type WhatsAppSendMessage = (
cfg?: OpenClawConfig;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gifPlayback?: boolean;
accountId?: string;
},
@ -99,6 +100,7 @@ export function createWhatsAppOutboundBase({
text,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId,
deps,
gifPlayback,
@ -110,6 +112,7 @@ export function createWhatsAppOutboundBase({
cfg,
mediaUrl,
mediaLocalRoots,
mediaReadFile,
accountId: accountId ?? undefined,
gifPlayback,
});

View File

@ -30,6 +30,7 @@ import {
import { hasReplyPayloadContent } from "../../interactive/payload.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js";
import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js";
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
import { throwIfAborted } from "./abort.js";
import { resolveOutboundChannelPlugin } from "./channel-resolution.js";
@ -130,6 +131,7 @@ type ChannelHandlerParams = {
forceDocument?: boolean;
silent?: boolean;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
gatewayClientScopes?: readonly string[];
};
@ -251,6 +253,7 @@ function createChannelOutboundContextBase(
deps: params.deps,
silent: params.silent,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
gatewayClientScopes: params.gatewayClientScopes,
};
}
@ -566,6 +569,10 @@ async function deliverOutboundPayloadsCore(
agentId: params.session?.agentId ?? params.mirror?.agentId,
mediaSources: collectPayloadMediaSources(payloads),
});
const mediaReadFile = createAgentScopedHostMediaReadFile({
cfg,
agentId: params.session?.agentId ?? params.mirror?.agentId,
});
const results: OutboundDeliveryResult[] = [];
const handler = await createChannelHandler({
cfg,
@ -580,6 +587,7 @@ async function deliverOutboundPayloadsCore(
forceDocument: params.forceDocument,
silent: params.silent,
mediaLocalRoots,
mediaReadFile,
gatewayClientScopes: params.gatewayClientScopes,
});
const configuredTextLimit = handler.chunker

View File

@ -102,11 +102,13 @@ export type AttachmentMediaPolicy =
| {
mode: "host";
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
};
export function resolveAttachmentMediaPolicy(params: {
sandboxRoot?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
}): AttachmentMediaPolicy {
const sandboxRoot = params.sandboxRoot?.trim();
if (sandboxRoot) {
@ -118,6 +120,7 @@ export function resolveAttachmentMediaPolicy(params: {
return {
mode: "host",
localRoots: params.mediaLocalRoots,
readFile: params.mediaReadFile,
};
}
@ -132,7 +135,9 @@ function buildAttachmentMediaLoadOptions(params: {
}
| {
maxBytes?: number;
localRoots?: readonly string[];
localRoots?: readonly string[] | "any";
readFile?: (filePath: string) => Promise<Buffer>;
hostReadCapability?: boolean;
} {
if (params.policy.mode === "sandbox") {
const readSandboxFile = createRootScopedReadFile({
@ -146,7 +151,13 @@ function buildAttachmentMediaLoadOptions(params: {
}
return {
maxBytes: params.maxBytes,
localRoots: params.policy.localRoots,
...(params.policy.readFile
? {
localRoots: "any" as const,
readFile: params.policy.readFile,
hostReadCapability: true,
}
: { localRoots: params.policy.localRoots }),
};
}

View File

@ -12,6 +12,11 @@ import {
} from "../../test-utils/channel-plugins.js";
import { resolvePreferredOpenClawTmpDir } from "../tmp-openclaw-dir.js";
const onePixelPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO5m8gAAAABJRU5ErkJggg==",
"base64",
);
vi.mock("../../media/web-media.js", async () => {
const actual = await vi.importActual<typeof import("../../media/web-media.js")>(
"../../media/web-media.js",
@ -260,7 +265,9 @@ describe("runMessageAction media behavior", () => {
const call = vi.mocked(loadWebMedia).mock.calls[0];
expect(call?.[1]).toEqual(
expect.objectContaining({
localRoots: expect.any(Array),
localRoots: "any",
readFile: expect.any(Function),
hostReadCapability: true,
}),
);
expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe(
@ -268,6 +275,67 @@ describe("runMessageAction media behavior", () => {
);
});
it("allows host-local image attachment paths when fs root expansion is enabled", async () => {
await restoreRealMediaLoader();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-attachment-image-"));
try {
const outsidePath = path.join(tempDir, "photo.png");
await fs.writeFile(outsidePath, onePixelPng);
const result = await runMessageAction({
cfg: {
...cfg,
tools: { fs: { workspaceOnly: false } },
},
action: "sendAttachment",
params: {
channel: "bluebubbles",
target: "+15551234567",
media: outsidePath,
message: "caption",
},
});
expect(result.kind).toBe("action");
expect(result.payload).toMatchObject({
ok: true,
filename: "photo.png",
contentType: "image/png",
});
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("rejects host-local text attachments even when fs root expansion is enabled", async () => {
await restoreRealMediaLoader();
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "msg-attachment-text-"));
try {
const outsidePath = path.join(tempDir, "secret.txt");
await fs.writeFile(outsidePath, "secret", "utf8");
await expect(
runMessageAction({
cfg: {
...cfg,
tools: { fs: { workspaceOnly: false } },
},
action: "sendAttachment",
params: {
channel: "bluebubbles",
target: "+15551234567",
media: outsidePath,
message: "caption",
},
}),
).rejects.toThrow(/Host-local media sends only allow/i);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("hydrates buffer and filename from media for bluebubbles upload-file", async () => {
const result = await runMessageAction({
cfg,

View File

@ -19,6 +19,7 @@ import {
getAgentScopedMediaLocalRoots,
getAgentScopedMediaLocalRootsForSources,
} from "../../media/local-roots.js";
import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js";
import { hasPollCreationParams } from "../../poll-params.js";
import { resolvePollMaxSelections } from "../../polls.js";
import { buildChannelAccountBindings } from "../../routing/bindings.js";
@ -273,6 +274,7 @@ type ResolvedActionContext = {
params: Record<string, unknown>;
channel: ChannelId;
mediaLocalRoots: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
dryRun: boolean;
gateway?: MessageActionRunnerGateway;
@ -518,6 +520,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise<MessageActi
channel,
params,
agentId,
mediaReadFile: ctx.mediaReadFile,
accountId: accountId ?? undefined,
gateway,
toolContext: input.toolContext,
@ -673,6 +676,7 @@ async function handlePluginAction(ctx: ResolvedActionContext): Promise<MessageAc
cfg,
params,
mediaLocalRoots,
mediaReadFile: ctx.mediaReadFile,
accountId: accountId ?? undefined,
requesterSenderId: input.requesterSenderId ?? undefined,
sessionKey: input.sessionKey,
@ -749,9 +753,14 @@ export async function runMessageAction(
agentId: resolvedAgentId,
mediaSources: collectActionMediaSourceHints(params),
});
const mediaReadFile = createAgentScopedHostMediaReadFile({
cfg,
agentId: resolvedAgentId,
});
const mediaPolicy = resolveAttachmentMediaPolicy({
sandboxRoot: input.sandboxRoot,
mediaLocalRoots,
mediaReadFile,
});
await hydrateAttachmentParamsForAction({
@ -792,6 +801,7 @@ export async function runMessageAction(
params,
channel,
mediaLocalRoots,
mediaReadFile,
accountId,
dryRun,
gateway,
@ -808,6 +818,7 @@ export async function runMessageAction(
params,
channel,
mediaLocalRoots,
mediaReadFile,
accountId,
dryRun,
gateway,
@ -821,6 +832,7 @@ export async function runMessageAction(
params,
channel,
mediaLocalRoots,
mediaReadFile,
accountId,
dryRun,
gateway,

View File

@ -7,6 +7,7 @@ const mocks = vi.hoisted(() => ({
sendMessage: vi.fn(),
sendPoll: vi.fn(),
getAgentScopedMediaLocalRootsForSources: vi.fn(() => ["/tmp/agent-roots"]),
createAgentScopedHostMediaReadFile: vi.fn(() => async () => Buffer.from("capability")),
appendAssistantMessageToSessionTranscript: vi.fn(async () => ({ ok: true, sessionFile: "x" })),
}));
@ -19,6 +20,10 @@ vi.mock("./message.js", () => ({
sendPoll: mocks.sendPoll,
}));
vi.mock("../../media/read-capability.js", () => ({
createAgentScopedHostMediaReadFile: mocks.createAgentScopedHostMediaReadFile,
}));
vi.mock("../../media/local-roots.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../media/local-roots.js")>();
return {
@ -99,6 +104,7 @@ describe("executeSendAction", () => {
mocks.sendPoll.mockClear();
mocks.getDefaultMediaLocalRoots.mockClear();
mocks.getAgentScopedMediaLocalRootsForSources.mockClear();
mocks.createAgentScopedHostMediaReadFile.mockClear();
mocks.appendAssistantMessageToSessionTranscript.mockClear();
});
@ -204,6 +210,7 @@ describe("executeSendAction", () => {
expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith(
expect.objectContaining({
mediaLocalRoots: ["/tmp/agent-roots"],
mediaReadFile: mocks.createAgentScopedHostMediaReadFile.mock.results[0]?.value,
}),
);
});

View File

@ -4,6 +4,7 @@ import type { ChannelId, ChannelThreadingToolContext } from "../../channels/plug
import type { OpenClawConfig } from "../../config/config.js";
import { appendAssistantMessageToSessionTranscript } from "../../config/sessions.js";
import { getAgentScopedMediaLocalRootsForSources } from "../../media/local-roots.js";
import { createAgentScopedHostMediaReadFile } from "../../media/read-capability.js";
import type { GatewayClientMode, GatewayClientName } from "../../utils/message-channel.js";
import { throwIfAborted } from "./abort.js";
import type { OutboundSendDeps } from "./deliver.js";
@ -27,6 +28,7 @@ export type OutboundSendContext = {
params: Record<string, unknown>;
/** Active agent id for per-agent outbound media root scoping. */
agentId?: string;
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string | null;
gateway?: OutboundGatewayContext;
toolContext?: ChannelThreadingToolContext;
@ -67,12 +69,19 @@ async function tryHandleWithPluginAction(params: {
agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId,
mediaSources: collectActionMediaSources(params.ctx.params),
});
const mediaReadFile =
params.ctx.mediaReadFile ??
createAgentScopedHostMediaReadFile({
cfg: params.ctx.cfg,
agentId: params.ctx.agentId ?? params.ctx.mirror?.agentId,
});
const handled = await dispatchChannelMessageAction({
channel: params.ctx.channel,
action: params.action,
cfg: params.ctx.cfg,
params: params.ctx.params,
mediaLocalRoots,
mediaReadFile,
accountId: params.ctx.accountId ?? undefined,
gateway: params.ctx.gateway,
toolContext: params.ctx.toolContext,

View File

@ -1,12 +1,15 @@
export type OutboundMediaLoadParams = {
maxBytes?: number;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
optimizeImages?: boolean;
};
export type OutboundMediaLoadOptions = {
maxBytes?: number;
localRoots?: readonly string[];
localRoots?: readonly string[] | "any";
readFile?: (filePath: string) => Promise<Buffer>;
hostReadCapability?: boolean;
optimizeImages?: boolean;
};
@ -19,6 +22,15 @@ export function resolveOutboundMediaLocalRoots(
export function buildOutboundMediaLoadOptions(
params: OutboundMediaLoadParams = {},
): OutboundMediaLoadOptions {
if (params.mediaReadFile) {
return {
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
localRoots: "any",
readFile: params.mediaReadFile,
hostReadCapability: true,
...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}),
};
}
const localRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
return {
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),

View File

@ -5,13 +5,17 @@ import { loadWebMedia } from "./web-media.js";
export async function resolveOutboundAttachmentFromUrl(
mediaUrl: string,
maxBytes: number,
options?: { localRoots?: readonly string[] },
options?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
},
): Promise<{ path: string; contentType?: string }> {
const media = await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({
maxBytes,
mediaLocalRoots: options?.localRoots,
mediaReadFile: options?.readFile,
}),
);
const saved = await saveMediaBuffer(

View File

@ -40,6 +40,8 @@ type WebMediaOptions = {
/** Caller already validated the local path (sandbox/other guards); requires readFile override. */
sandboxValidated?: boolean;
readFile?: (filePath: string) => Promise<Buffer>;
/** Host-local fs-policy read piggyback; rejects plaintext-like document sends. */
hostReadCapability?: boolean;
};
function resolveWebMediaOptions(params: {
@ -65,6 +67,24 @@ function resolveWebMediaOptions(params: {
const HEIC_MIME_RE = /^image\/hei[cf]$/i;
const HEIC_EXT_RE = /\.(heic|heif)$/i;
const HOST_READ_ALLOWED_DOCUMENT_MIMES = new Set([
"application/msword",
"application/pdf",
"application/vnd.ms-excel",
"application/vnd.ms-powerpoint",
"application/vnd.openxmlformats-officedocument.presentationml.presentation",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]);
const HOST_READ_ALLOWED_DOCUMENT_EXTS = new Set([
".doc",
".docx",
".pdf",
".ppt",
".pptx",
".xls",
".xlsx",
]);
const MB = 1024 * 1024;
function formatMb(bytes: number, digits = 2): string {
@ -96,6 +116,32 @@ function isHeicSource(opts: { contentType?: string; fileName?: string }): boolea
return false;
}
function assertHostReadMediaAllowed(params: {
contentType?: string;
kind: MediaKind | undefined;
mediaUrl: string;
fileName?: string;
}): void {
if (params.kind !== "document") {
return;
}
const normalizedMime = params.contentType?.trim().toLowerCase();
if (normalizedMime && HOST_READ_ALLOWED_DOCUMENT_MIMES.has(normalizedMime)) {
return;
}
const ext = path
.extname(params.fileName ?? params.mediaUrl)
.trim()
.toLowerCase();
if (ext && HOST_READ_ALLOWED_DOCUMENT_EXTS.has(ext)) {
return;
}
throw new LocalMediaAccessError(
"path-not-allowed",
`Host-local media sends only allow images, audio, video, PDF, and Office documents (got ${normalizedMime ?? "unknown"}).`,
);
}
function toJpegFileName(fileName?: string): string | undefined {
if (!fileName) {
return undefined;
@ -174,6 +220,7 @@ async function loadWebMediaInternal(
localRoots,
sandboxValidated = false,
readFile: readFileOverride,
hostReadCapability = false,
} = options;
// Strip MEDIA: prefix used by agent tools (e.g. TTS) to tag media paths.
// Be lenient: LLM output may add extra whitespace (e.g. " MEDIA : /tmp/x.png").
@ -333,6 +380,14 @@ async function loadWebMediaInternal(
fileName = `${fileName}${ext}`;
}
}
if (hostReadCapability) {
assertHostReadMediaAllowed({
contentType: mime,
kind,
mediaUrl,
fileName,
});
}
return await clampAndFinalize({
buffer: data,
contentType: mime,

View File

@ -10,6 +10,7 @@ type DiscordSendOptionInput = {
type DiscordSendMediaOptionInput = DiscordSendOptionInput & {
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
/** Build the common Discord send options from SDK-level reply payload fields. */
@ -28,6 +29,7 @@ export function buildDiscordSendMediaOptions(input: DiscordSendMediaOptionInput)
...buildDiscordSendOptions(input),
mediaUrl: input.mediaUrl,
mediaLocalRoots: input.mediaLocalRoots,
mediaReadFile: input.mediaReadFile,
};
}

View File

@ -51,4 +51,26 @@ describe("loadOutboundMediaFromUrl", () => {
localRoots: undefined,
});
});
it("prefers host read capability over local roots when provided", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("x"));
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("x"),
kind: "image",
contentType: "image/png",
});
await loadOutboundMediaFromUrl("/Users/peter/Pictures/image.png", {
maxBytes: 2048,
mediaLocalRoots: ["/tmp/workspace-agent"],
mediaReadFile,
});
expect(loadWebMediaMock).toHaveBeenCalledWith("/Users/peter/Pictures/image.png", {
maxBytes: 2048,
localRoots: "any",
readFile: mediaReadFile,
hostReadCapability: true,
});
});
});

View File

@ -1,8 +1,10 @@
import { buildOutboundMediaLoadOptions } from "../media/load-options.js";
import { loadWebMedia } from "./web-media.js";
export type OutboundMediaLoadOptions = {
maxBytes?: number;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};
/** Load outbound media from a remote URL or approved local path using the shared web-media policy. */
@ -10,8 +12,12 @@ export async function loadOutboundMediaFromUrl(
mediaUrl: string,
options: OutboundMediaLoadOptions = {},
) {
return await loadWebMedia(mediaUrl, {
maxBytes: options.maxBytes,
localRoots: options.mediaLocalRoots,
});
return await loadWebMedia(
mediaUrl,
buildOutboundMediaLoadOptions({
maxBytes: options.maxBytes,
mediaLocalRoots: options.mediaLocalRoots,
mediaReadFile: options.mediaReadFile,
}),
);
}