fix: allow agent workspace directories in media local roots (#17136)

Merged via /review-pr -> /prepare-pr -> /merge-pr.

Prepared head SHA: 7545ef1e19
Co-authored-by: MisterGuy420 <255743668+MisterGuy420@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Mr. Guy
2026-02-15 10:53:45 -05:00
committed by GitHub
parent 0c57f5e62e
commit e927fd1e35
38 changed files with 388 additions and 35 deletions

View File

@@ -85,6 +85,7 @@ describe("deliverWebReply", () => {
it("sends image media with caption and then remaining text", async () => {
const msg = makeMsg();
const mediaLocalRoots = ["/tmp/workspace-work"];
(
loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void }
).mockResolvedValueOnce({
@@ -96,12 +97,18 @@ describe("deliverWebReply", () => {
await deliverWebReply({
replyResult: { text: "aaaaaa", mediaUrl: "http://example.com/img.jpg" },
msg,
mediaLocalRoots,
maxMediaBytes: 1024 * 1024,
textLimit: 3,
replyLogger,
skipLog: true,
});
expect(loadWebMedia).toHaveBeenCalledWith("http://example.com/img.jpg", {
maxBytes: 1024 * 1024,
localRoots: mediaLocalRoots,
});
expect(msg.sendMedia).toHaveBeenCalledWith(
expect.objectContaining({
image: expect.any(Buffer),

View File

@@ -15,6 +15,7 @@ import { elide } from "./util.js";
export async function deliverWebReply(params: {
replyResult: ReplyPayload;
msg: WebInboundMsg;
mediaLocalRoots?: readonly string[];
maxMediaBytes: number;
textLimit: number;
chunkMode?: ChunkMode;
@@ -99,7 +100,10 @@ export async function deliverWebReply(params: {
for (const [index, mediaUrl] of mediaList.entries()) {
const caption = index === 0 ? remainingText.shift() || undefined : undefined;
try {
const media = await loadWebMedia(mediaUrl, maxMediaBytes);
const media = await loadWebMedia(mediaUrl, {
maxBytes: maxMediaBytes,
localRoots: params.mediaLocalRoots,
});
if (shouldLogVerbose()) {
logVerbose(
`Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`,

View File

@@ -26,6 +26,7 @@ import {
resolveStorePath,
} from "../../../config/sessions.js";
import { logVerbose, shouldLogVerbose } from "../../../globals.js";
import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js";
import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js";
import { jidToE164, normalizeE164 } from "../../../utils.js";
import { newConnectionId } from "../../reconnect.js";
@@ -245,6 +246,7 @@ export async function processMessage(params: {
channel: "whatsapp",
accountId: params.route.accountId,
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId);
let didLogHeartbeatStrip = false;
let didSendReply = false;
const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg)
@@ -362,6 +364,7 @@ export async function processMessage(params: {
await deliverWebReply({
replyResult: payload,
msg: params.msg,
mediaLocalRoots,
maxMediaBytes: params.maxMediaBytes,
textLimit,
chunkMode,

View File

@@ -371,4 +371,34 @@ describe("local media root guard", () => {
}),
);
});
it("rejects default OpenClaw state per-agent workspace-* roots without explicit local roots", async () => {
const { STATE_DIR } = await import("../config/paths.js");
const readFile = vi.fn(async () => Buffer.from("generated-media"));
await expect(
loadWebMedia(path.join(STATE_DIR, "workspace-clawdy", "tmp", "render.bin"), {
maxBytes: 1024 * 1024,
readFile,
}),
).rejects.toThrow(/not under an allowed directory/i);
});
it("allows per-agent workspace-* paths with explicit local roots", async () => {
const { STATE_DIR } = await import("../config/paths.js");
const readFile = vi.fn(async () => Buffer.from("generated-media"));
const agentWorkspaceDir = path.join(STATE_DIR, "workspace-clawdy");
await expect(
loadWebMedia(path.join(agentWorkspaceDir, "tmp", "render.bin"), {
maxBytes: 1024 * 1024,
localRoots: [agentWorkspaceDir],
readFile,
}),
).resolves.toEqual(
expect.objectContaining({
kind: "unknown",
}),
);
});
});

View File

@@ -1,9 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import type { SsrFPolicy } from "../infra/net/ssrf.js";
import { STATE_DIR } from "../config/paths.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js";
import { fetchRemoteMedia } from "../media/fetch.js";
@@ -13,6 +11,7 @@ import {
optimizeImageToPng,
resizeToJpeg,
} from "../media/image-ops.js";
import { getDefaultMediaLocalRoots } from "../media/local-roots.js";
import { detectMime, extensionForMime } from "../media/mime.js";
import { resolveUserPath } from "../utils.js";
@@ -35,13 +34,7 @@ type WebMediaOptions = {
};
export function getDefaultLocalRoots(): readonly string[] {
return [
os.tmpdir(),
path.join(STATE_DIR, "media"),
path.join(STATE_DIR, "agents"),
path.join(STATE_DIR, "workspace"),
path.join(STATE_DIR, "sandboxes"),
];
return getDefaultMediaLocalRoots();
}
async function assertLocalMediaAllowed(

View File

@@ -18,6 +18,7 @@ export async function sendMessageWhatsApp(
options: {
verbose: boolean;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
gifPlayback?: boolean;
accountId?: string;
},
@@ -47,7 +48,9 @@ export async function sendMessageWhatsApp(
let mediaType: string | undefined;
let documentFileName: string | undefined;
if (options.mediaUrl) {
const media = await loadWebMedia(options.mediaUrl);
const media = await loadWebMedia(options.mediaUrl, {
localRoots: options.mediaLocalRoots,
});
const caption = text || undefined;
mediaBuffer = media.buffer;
mediaType = media.contentType;