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: {
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<void> {
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<string, unknown>;
action: ChannelMessageActionName;
dryRun?: boolean;
sandboxRoot?: string;
mediaLocalRoots?: readonly string[];
mediaPolicy: AttachmentMediaPolicy;
}): Promise<void> {
if (params.action !== "setGroupIcon") {
return;
@@ -342,8 +384,7 @@ export async function hydrateSendAttachmentParams(params: {
args: Record<string, unknown>;
action: ChannelMessageActionName;
dryRun?: boolean;
sandboxRoot?: string;
mediaLocalRoots?: readonly string[];
mediaPolicy: AttachmentMediaPolicy;
}): Promise<void> {
if (params.action !== "sendAttachment") {
return;

View File

@@ -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,
}),
);
});
});

View File

@@ -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({