refactor(outbound): centralize attachment media policy

This commit is contained in:
Peter Steinberger
2026-02-24 23:28:46 +00:00
parent 54648a9cf1
commit 5c2a483375
3 changed files with 84 additions and 26 deletions

View File

@@ -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<Buffer>;
}
| {
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: { async function hydrateAttachmentPayload(params: {
cfg: OpenClawConfig; cfg: OpenClawConfig;
channel: ChannelId; channel: ChannelId;
@@ -178,8 +231,7 @@ async function hydrateAttachmentPayload(params: {
contentTypeParam?: string | null; contentTypeParam?: string | null;
mediaHint?: string | null; mediaHint?: string | null;
fileHint?: string | null; fileHint?: string | null;
sandboxRoot?: string; mediaPolicy: AttachmentMediaPolicy;
mediaLocalRoots?: readonly string[];
}) { }) {
const contentTypeParam = params.contentTypeParam ?? undefined; const contentTypeParam = params.contentTypeParam ?? undefined;
const rawBuffer = readStringParam(params.args, "buffer", { trim: false }); const rawBuffer = readStringParam(params.args, "buffer", { trim: false });
@@ -203,17 +255,10 @@ async function hydrateAttachmentPayload(params: {
channel: params.channel, channel: params.channel,
accountId: params.accountId, accountId: params.accountId,
}); });
const sandboxRoot = params.sandboxRoot?.trim(); const media = await loadWebMedia(
const media = sandboxRoot mediaSource,
? await loadWebMedia(mediaSource, { buildAttachmentMediaLoadOptions({ policy: params.mediaPolicy, maxBytes }),
maxBytes, );
sandboxValidated: true,
readFile: (filePath: string) => fs.readFile(filePath),
})
: await loadWebMedia(mediaSource, {
maxBytes,
localRoots: params.mediaLocalRoots,
});
params.args.buffer = media.buffer.toString("base64"); params.args.buffer = media.buffer.toString("base64");
if (!contentTypeParam && media.contentType) { if (!contentTypeParam && media.contentType) {
params.args.contentType = media.contentType; params.args.contentType = media.contentType;
@@ -287,8 +332,7 @@ async function hydrateAttachmentActionPayload(params: {
dryRun?: boolean; dryRun?: boolean;
/** If caption is missing, copy message -> caption. */ /** If caption is missing, copy message -> caption. */
allowMessageCaptionFallback?: boolean; allowMessageCaptionFallback?: boolean;
sandboxRoot?: string; mediaPolicy: AttachmentMediaPolicy;
mediaLocalRoots?: readonly string[];
}): Promise<void> { }): Promise<void> {
const mediaHint = readStringParam(params.args, "media", { trim: false }); const mediaHint = readStringParam(params.args, "media", { trim: false });
const fileHint = const fileHint =
@@ -314,8 +358,7 @@ async function hydrateAttachmentActionPayload(params: {
contentTypeParam, contentTypeParam,
mediaHint, mediaHint,
fileHint, fileHint,
sandboxRoot: params.sandboxRoot, mediaPolicy: params.mediaPolicy,
mediaLocalRoots: params.mediaLocalRoots,
}); });
} }
@@ -326,8 +369,7 @@ export async function hydrateSetGroupIconParams(params: {
args: Record<string, unknown>; args: Record<string, unknown>;
action: ChannelMessageActionName; action: ChannelMessageActionName;
dryRun?: boolean; dryRun?: boolean;
sandboxRoot?: string; mediaPolicy: AttachmentMediaPolicy;
mediaLocalRoots?: readonly string[];
}): Promise<void> { }): Promise<void> {
if (params.action !== "setGroupIcon") { if (params.action !== "setGroupIcon") {
return; return;
@@ -342,8 +384,7 @@ export async function hydrateSendAttachmentParams(params: {
args: Record<string, unknown>; args: Record<string, unknown>;
action: ChannelMessageActionName; action: ChannelMessageActionName;
dryRun?: boolean; dryRun?: boolean;
sandboxRoot?: string; mediaPolicy: AttachmentMediaPolicy;
mediaLocalRoots?: readonly string[];
}): Promise<void> { }): Promise<void> {
if (params.action !== "sendAttachment") { if (params.action !== "sendAttachment") {
return; return;

View File

@@ -512,6 +512,15 @@ describe("runMessageAction sendAttachment hydration", () => {
expect((result.payload as { buffer?: string }).buffer).toBe( expect((result.payload as { buffer?: string }).buffer).toBe(
Buffer.from("hello").toString("base64"), 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 () => { it("rewrites sandboxed media paths for sendAttachment", async () => {
@@ -530,6 +539,11 @@ describe("runMessageAction sendAttachment hydration", () => {
const call = vi.mocked(loadWebMedia).mock.calls[0]; const call = vi.mocked(loadWebMedia).mock.calls[0];
expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png")); expect(call?.[0]).toBe(path.join(sandboxDir, "data", "pic.png"));
expect(call?.[1]).toEqual(
expect.objectContaining({
sandboxValidated: true,
}),
);
}); });
}); });

View File

@@ -36,6 +36,7 @@ import {
parseCardParam, parseCardParam,
parseComponentsParam, parseComponentsParam,
readBooleanParam, readBooleanParam,
resolveAttachmentMediaPolicy,
resolveSlackAutoThreadId, resolveSlackAutoThreadId,
resolveTelegramAutoThreadId, resolveTelegramAutoThreadId,
} from "./message-action-params.js"; } from "./message-action-params.js";
@@ -759,10 +760,14 @@ export async function runMessageAction(
} }
const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun")); const dryRun = Boolean(input.dryRun ?? readBooleanParam(params, "dryRun"));
const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId); const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, resolvedAgentId);
const mediaPolicy = resolveAttachmentMediaPolicy({
sandboxRoot: input.sandboxRoot,
mediaLocalRoots,
});
await normalizeSandboxMediaParams({ await normalizeSandboxMediaParams({
args: params, args: params,
sandboxRoot: input.sandboxRoot, sandboxRoot: mediaPolicy.mode === "sandbox" ? mediaPolicy.sandboxRoot : undefined,
}); });
await hydrateSendAttachmentParams({ await hydrateSendAttachmentParams({
@@ -772,8 +777,7 @@ export async function runMessageAction(
args: params, args: params,
action, action,
dryRun, dryRun,
sandboxRoot: input.sandboxRoot, mediaPolicy,
mediaLocalRoots,
}); });
await hydrateSetGroupIconParams({ await hydrateSetGroupIconParams({
@@ -783,8 +787,7 @@ export async function runMessageAction(
args: params, args: params,
action, action,
dryRun, dryRun,
sandboxRoot: input.sandboxRoot, mediaPolicy,
mediaLocalRoots,
}); });
const resolvedTarget = await resolveActionTarget({ const resolvedTarget = await resolveActionTarget({