diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index e9672841e1c..b24146cb97c 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -169,6 +169,59 @@ function normalizeBase64Payload(params: { base64?: string; contentType?: string }; } +export type AttachmentMediaPolicy = + | { + mode: "sandbox"; + sandboxRoot: string; + } + | { + mode: "host"; + localRoots?: readonly string[]; + }; + +export function resolveAttachmentMediaPolicy(params: { + sandboxRoot?: string; + mediaLocalRoots?: readonly string[]; +}): AttachmentMediaPolicy { + const sandboxRoot = params.sandboxRoot?.trim(); + if (sandboxRoot) { + return { + mode: "sandbox", + sandboxRoot, + }; + } + return { + mode: "host", + localRoots: params.mediaLocalRoots, + }; +} + +function buildAttachmentMediaLoadOptions(params: { + policy: AttachmentMediaPolicy; + maxBytes?: number; +}): + | { + maxBytes?: number; + sandboxValidated: true; + readFile: (filePath: string) => Promise; + } + | { + maxBytes?: number; + localRoots?: readonly string[]; + } { + if (params.policy.mode === "sandbox") { + return { + maxBytes: params.maxBytes, + sandboxValidated: true, + readFile: (filePath: string) => fs.readFile(filePath), + }; + } + return { + maxBytes: params.maxBytes, + localRoots: params.policy.localRoots, + }; +} + async function hydrateAttachmentPayload(params: { cfg: OpenClawConfig; channel: ChannelId; @@ -178,8 +231,7 @@ async function hydrateAttachmentPayload(params: { contentTypeParam?: string | null; mediaHint?: string | null; fileHint?: string | null; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }) { const contentTypeParam = params.contentTypeParam ?? undefined; const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); @@ -203,17 +255,10 @@ async function hydrateAttachmentPayload(params: { channel: params.channel, accountId: params.accountId, }); - const sandboxRoot = params.sandboxRoot?.trim(); - const media = sandboxRoot - ? await loadWebMedia(mediaSource, { - maxBytes, - sandboxValidated: true, - readFile: (filePath: string) => fs.readFile(filePath), - }) - : await loadWebMedia(mediaSource, { - maxBytes, - localRoots: params.mediaLocalRoots, - }); + const media = await loadWebMedia( + mediaSource, + buildAttachmentMediaLoadOptions({ policy: params.mediaPolicy, maxBytes }), + ); params.args.buffer = media.buffer.toString("base64"); if (!contentTypeParam && media.contentType) { params.args.contentType = media.contentType; @@ -287,8 +332,7 @@ async function hydrateAttachmentActionPayload(params: { dryRun?: boolean; /** If caption is missing, copy message -> caption. */ allowMessageCaptionFallback?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { const mediaHint = readStringParam(params.args, "media", { trim: false }); const fileHint = @@ -314,8 +358,7 @@ async function hydrateAttachmentActionPayload(params: { contentTypeParam, mediaHint, fileHint, - sandboxRoot: params.sandboxRoot, - mediaLocalRoots: params.mediaLocalRoots, + mediaPolicy: params.mediaPolicy, }); } @@ -326,8 +369,7 @@ export async function hydrateSetGroupIconParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { if (params.action !== "setGroupIcon") { return; @@ -342,8 +384,7 @@ export async function hydrateSendAttachmentParams(params: { args: Record; action: ChannelMessageActionName; dryRun?: boolean; - sandboxRoot?: string; - mediaLocalRoots?: readonly string[]; + mediaPolicy: AttachmentMediaPolicy; }): Promise { if (params.action !== "sendAttachment") { return; diff --git a/src/infra/outbound/message-action-runner.test.ts b/src/infra/outbound/message-action-runner.test.ts index 054350a4043..127a3838031 100644 --- a/src/infra/outbound/message-action-runner.test.ts +++ b/src/infra/outbound/message-action-runner.test.ts @@ -512,6 +512,15 @@ describe("runMessageAction sendAttachment hydration", () => { expect((result.payload as { buffer?: string }).buffer).toBe( Buffer.from("hello").toString("base64"), ); + const call = vi.mocked(loadWebMedia).mock.calls[0]; + expect(call?.[1]).toEqual( + expect.objectContaining({ + localRoots: expect.any(Array), + }), + ); + expect((call?.[1] as { sandboxValidated?: boolean } | undefined)?.sandboxValidated).not.toBe( + true, + ); }); it("rewrites sandboxed media paths for sendAttachment", async () => { @@ -530,6 +539,11 @@ describe("runMessageAction sendAttachment hydration", () => { const call = vi.mocked(loadWebMedia).mock.calls[0]; expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); + expect(call?.[1]).toEqual( + expect.objectContaining({ + sandboxValidated: true, + }), + ); }); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 5031a2cdead..68a75f0c0a3 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -36,6 +36,7 @@ import { parseCardParam, parseComponentsParam, readBooleanParam, + resolveAttachmentMediaPolicy, resolveSlackAutoThreadId, resolveTelegramAutoThreadId, } from "./message-action-params.js"; @@ -759,10 +760,14 @@ export async function runMessageAction( } const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId); + const mediaPolicy = resolveAttachmentMediaPolicy({ + sandboxRoot: input.sandboxRoot, + mediaLocalRoots, + }); await normalizeSandboxMediaParams({ args: params, - sandboxRoot: input.sandboxRoot, + sandboxRoot: mediaPolicy.mode === "sandbox" ? mediaPolicy.sandboxRoot : undefined, }); await hydrateSendAttachmentParams({ @@ -772,8 +777,7 @@ export async function runMessageAction( args: params, action, dryRun, - sandboxRoot: input.sandboxRoot, - mediaLocalRoots, + mediaPolicy, }); await hydrateSetGroupIconParams({ @@ -783,8 +787,7 @@ export async function runMessageAction( args: params, action, dryRun, - sandboxRoot: input.sandboxRoot, - mediaLocalRoots, + mediaPolicy, }); const resolvedTarget = await resolveActionTarget({