fix: allow workspace-rooted absolute media paths in auto-reply (#66689)

Merged via squash.

Prepared head SHA: 48206b5627
Co-authored-by: joelnishanth <140015627+joelnishanth@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
OfflynAI
2026-04-14 14:04:31 -07:00
committed by GitHub
parent 5bf30d258f
commit d21f07a39e
16 changed files with 572 additions and 261 deletions

View File

@@ -142,6 +142,15 @@ describe("runReplyAgent runtime config", () => {
cfg: freshCfg,
sessionKey: undefined,
workspaceDir: "/tmp",
messageProvider: "telegram",
accountId: undefined,
groupId: undefined,
groupChannel: undefined,
groupSpace: undefined,
requesterSenderId: undefined,
requesterSenderName: undefined,
requesterSenderUsername: undefined,
requesterSenderE164: undefined,
});
expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledWith(
expect.objectContaining({

View File

@@ -605,6 +605,15 @@ export async function runAgentTurnWithFallback(params: {
cfg: runtimeConfig,
sessionKey: params.sessionKey,
workspaceDir: params.followupRun.run.workspaceDir,
messageProvider: params.followupRun.run.messageProvider,
accountId: params.followupRun.originatingAccountId ?? params.followupRun.run.agentAccountId,
groupId: params.followupRun.run.groupId,
groupChannel: params.followupRun.run.groupChannel,
groupSpace: params.followupRun.run.groupSpace,
requesterSenderId: params.followupRun.run.senderId,
requesterSenderName: params.followupRun.run.senderName,
requesterSenderUsername: params.followupRun.run.senderUsername,
requesterSenderE164: params.followupRun.run.senderE164,
});
let didNotifyAgentRunStart = false;
const notifyAgentRunStart = () => {

View File

@@ -16,6 +16,7 @@ const waitForEmbeddedPiRunEndMock = vi.fn();
const enqueueFollowupRunMock = vi.fn();
const scheduleFollowupDrainMock = vi.fn();
const refreshQueuedFollowupSessionMock = vi.fn();
const resolveOutboundAttachmentFromUrlMock = vi.fn();
vi.mock("../../agents/model-fallback.js", () => ({
runWithModelFallback: (params: {
@@ -46,6 +47,11 @@ vi.mock("./queue.js", () => ({
scheduleFollowupDrain: scheduleFollowupDrainMock,
}));
vi.mock("../../media/outbound-attachment.js", () => ({
resolveOutboundAttachmentFromUrl: (...args: unknown[]) =>
resolveOutboundAttachmentFromUrlMock(...args),
}));
let runReplyAgent: typeof import("./agent-runner.js").runReplyAgent;
describe("runReplyAgent media path normalization", () => {
@@ -66,7 +72,11 @@ describe("runReplyAgent media path normalization", () => {
enqueueFollowupRunMock.mockReset();
scheduleFollowupDrainMock.mockReset();
refreshQueuedFollowupSessionMock.mockReset();
resolveOutboundAttachmentFromUrlMock.mockReset();
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({
path: path.join("/tmp/outbound-media", path.basename(mediaUrl)),
}));
runWithModelFallbackMock.mockImplementation(
async ({
provider,
@@ -137,8 +147,17 @@ describe("runReplyAgent media path normalization", () => {
});
expect(result).toMatchObject({
mediaUrl: path.join("/tmp/workspace", "out", "generated.png"),
mediaUrls: [path.join("/tmp/workspace", "out", "generated.png")],
mediaUrl: "/tmp/outbound-media/generated.png",
mediaUrls: ["/tmp/outbound-media/generated.png"],
});
expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith(
path.join("/tmp/workspace", "out", "generated.png"),
5 * 1024 * 1024,
expect.objectContaining({
mediaAccess: expect.objectContaining({
workspaceDir: "/tmp/workspace",
}),
}),
);
});
});

View File

@@ -1031,6 +1031,15 @@ export async function runReplyAgent(params: {
cfg,
sessionKey,
workspaceDir: followupRun.run.workspaceDir,
messageProvider: followupRun.run.messageProvider,
accountId: followupRun.originatingAccountId ?? followupRun.run.agentAccountId,
groupId: followupRun.run.groupId,
groupChannel: followupRun.run.groupChannel,
groupSpace: followupRun.run.groupSpace,
requesterSenderId: followupRun.run.senderId,
requesterSenderName: followupRun.run.senderName,
requesterSenderUsername: followupRun.run.senderUsername,
requesterSenderE164: followupRun.run.senderE164,
});
const blockReplyCoalescing =
blockStreamingEnabled && opts?.onBlockReply

View File

@@ -3,7 +3,7 @@ import type { MsgContext, TemplateContext } from "../templating.js";
import { appendUntrustedContext } from "./untrusted-context.js";
export const REPLY_MEDIA_HINT =
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Avoid absolute paths (MEDIA:/...) and ~ paths - they are blocked for security. Keep caption in the text body.";
"To send an image back, prefer the message tool (media/path/filePath). If you must inline, use MEDIA:https://example.com/image.jpg (spaces ok, quote if needed) or a safe relative path like MEDIA:./image.jpg. Absolute and ~ paths only work when they stay inside your allowed file-read boundary; host file:// URLs are blocked. Keep caption in the text body.";
export function buildReplyPromptBodies(params: {
ctx: MsgContext;

View File

@@ -2,23 +2,19 @@ import path from "node:path";
import { beforeEach, describe, expect, it, vi } from "vitest";
const ensureSandboxWorkspaceForSession = vi.hoisted(() => vi.fn());
const resolvePreferredOpenClawTmpDir = vi.hoisted(() => vi.fn(() => "/private/tmp/openclaw-501"));
const saveMediaSource = vi.hoisted(() => vi.fn());
const resolveOutboundAttachmentFromUrl = vi.hoisted(() => vi.fn());
const resolveAgentScopedOutboundMediaAccess = vi.hoisted(() => vi.fn());
vi.mock("../../agents/sandbox.js", () => ({
ensureSandboxWorkspaceForSession,
}));
vi.mock("../../infra/tmp-openclaw-dir.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/tmp-openclaw-dir.js")>();
return {
...actual,
resolvePreferredOpenClawTmpDir,
};
});
vi.mock("../../media/outbound-attachment.js", () => ({
resolveOutboundAttachmentFromUrl,
}));
vi.mock("../../media/store.js", () => ({
saveMediaSource,
vi.mock("../../media/read-capability.js", () => ({
resolveAgentScopedOutboundMediaAccess,
}));
import { createReplyMediaPathNormalizer } from "./reply-media-paths.js";
@@ -26,12 +22,20 @@ import { createReplyMediaPathNormalizer } from "./reply-media-paths.js";
describe("createReplyMediaPathNormalizer", () => {
beforeEach(() => {
ensureSandboxWorkspaceForSession.mockReset().mockResolvedValue(null);
resolvePreferredOpenClawTmpDir.mockReset().mockReturnValue("/private/tmp/openclaw-501");
saveMediaSource.mockReset();
resolveOutboundAttachmentFromUrl.mockReset().mockImplementation(async (mediaUrl: string) => ({
path: path.join("/tmp/outbound-media", path.basename(mediaUrl.replace(/^file:\/\//i, ""))),
}));
resolveAgentScopedOutboundMediaAccess
.mockReset()
.mockImplementation(({ workspaceDir }: { workspaceDir?: string }) => ({
workspaceDir,
localRoots: workspaceDir ? [workspaceDir] : undefined,
readFile: async () => Buffer.from("image"),
}));
vi.unstubAllEnvs();
});
it("resolves workspace-relative media against the agent workspace", async () => {
it("stages workspace-relative media through shared outbound attachment loading", async () => {
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
@@ -43,12 +47,21 @@ describe("createReplyMediaPathNormalizer", () => {
});
expect(result).toMatchObject({
mediaUrl: path.join("/tmp/agent-workspace", "out", "photo.png"),
mediaUrls: [path.join("/tmp/agent-workspace", "out", "photo.png")],
mediaUrl: "/tmp/outbound-media/photo.png",
mediaUrls: ["/tmp/outbound-media/photo.png"],
});
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
path.join("/tmp/agent-workspace", "out", "photo.png"),
5 * 1024 * 1024,
expect.objectContaining({
mediaAccess: expect.objectContaining({
workspaceDir: "/tmp/agent-workspace",
}),
}),
);
});
it("maps sandbox-relative media back to the host sandbox workspace", async () => {
it("maps sandbox-relative media back to the host sandbox workspace before staging", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
@@ -64,15 +77,70 @@ describe("createReplyMediaPathNormalizer", () => {
});
expect(result).toMatchObject({
mediaUrl: path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
mediaUrls: [
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
path.join("/tmp/sandboxes/session-1", "screens", "final.png"),
],
mediaUrl: "/tmp/outbound-media/photo.png",
mediaUrls: ["/tmp/outbound-media/photo.png", "/tmp/outbound-media/final.png"],
});
expect(resolveOutboundAttachmentFromUrl).toHaveBeenNthCalledWith(
1,
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
5 * 1024 * 1024,
expect.any(Object),
);
expect(resolveOutboundAttachmentFromUrl).toHaveBeenNthCalledWith(
2,
path.join("/tmp/sandboxes/session-1", "screens", "final.png"),
5 * 1024 * 1024,
expect.any(Object),
);
});
it("drops arbitrary host-local media paths when sandbox exists", async () => {
it("drops sandbox-mapped media when staging fails instead of retrying the workspace fallback", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
resolveOutboundAttachmentFromUrl.mockRejectedValueOnce(new Error("media too large"));
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["./out/photo.png"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledTimes(1);
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
path.join("/tmp/sandboxes/session-1", "out", "photo.png"),
5 * 1024 * 1024,
expect.any(Object),
);
});
it("drops host file URLs when no sandbox mapping applies", async () => {
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["file:///Users/peter/Documents/report.pdf"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("drops host file URLs even when sandbox exists", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
@@ -84,40 +152,113 @@ describe("createReplyMediaPathNormalizer", () => {
});
const result = await normalize({
mediaUrls: ["/Users/peter/.openclaw/media/inbound/photo.png"],
mediaUrls: ["file:///Users/peter/Documents/report.pdf"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(saveMediaSource).not.toHaveBeenCalled();
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("drops relative sandbox escapes when tools.fs.workspaceOnly is enabled", async () => {
it("drops absolute host-local media paths when sandbox mapping fails", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
const normalize = createReplyMediaPathNormalizer({
cfg: { tools: { fs: { workspaceOnly: true } } },
cfg: { tools: { fs: { workspaceOnly: false } } },
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["../sandboxes/session-1/screens/final.png"],
mediaUrls: ["/Users/peter/Documents/report.pdf"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(saveMediaSource).not.toHaveBeenCalled();
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("keeps managed generated media under the shared media root", async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
it("stages absolute workspace media paths so the PR scenario now works", async () => {
const absolutePath = "/Users/peter/.openclaw/workspace/exports/images/chart.png";
const normalize = createReplyMediaPathNormalizer({
cfg: { agents: { defaults: { mediaMaxMb: 8 } } },
sessionKey: "session-key",
workspaceDir: "/Users/peter/.openclaw/workspace",
});
const result = await normalize({
mediaUrls: [absolutePath],
});
expect(result).toMatchObject({
mediaUrl: "/tmp/outbound-media/chart.png",
mediaUrls: ["/tmp/outbound-media/chart.png"],
});
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
absolutePath,
8 * 1024 * 1024,
expect.any(Object),
);
});
it("prefers channel account media limits when staging reply attachments", async () => {
const absolutePath = "/Users/peter/.openclaw/workspace/exports/images/chart.png";
const normalize = createReplyMediaPathNormalizer({
cfg: {
channels: {
whatsapp: {
mediaMaxMb: 50,
accounts: {
work: {
mediaMaxMb: 64,
},
},
},
},
agents: { defaults: { mediaMaxMb: 8 } },
},
sessionKey: undefined,
workspaceDir: "/Users/peter/.openclaw/workspace",
messageProvider: "whatsapp",
accountId: "work",
});
await normalize({
mediaUrls: [absolutePath],
});
expect(resolveOutboundAttachmentFromUrl).toHaveBeenCalledWith(
absolutePath,
64 * 1024 * 1024,
expect.any(Object),
);
});
it("drops workspace-relative media paths that escape the agent workspace", async () => {
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["../../etc/passwd"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("drops sandbox-relative media paths that escape both sandbox and workspace", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
@@ -128,6 +269,25 @@ describe("createReplyMediaPathNormalizer", () => {
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["../../etc/passwd"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("keeps managed generated media under the shared media root", async () => {
vi.stubEnv("OPENCLAW_STATE_DIR", "/Users/peter/.openclaw");
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
});
@@ -136,14 +296,13 @@ describe("createReplyMediaPathNormalizer", () => {
mediaUrl: "/Users/peter/.openclaw/media/tool-image-generation/generated.png",
mediaUrls: ["/Users/peter/.openclaw/media/tool-image-generation/generated.png"],
});
expect(saveMediaSource).not.toHaveBeenCalled();
expect(resolveOutboundAttachmentFromUrl).not.toHaveBeenCalled();
});
it("drops absolute file URLs outside managed reply media roots", async () => {
ensureSandboxWorkspaceForSession.mockResolvedValue({
workspaceDir: "/tmp/sandboxes/session-1",
containerWorkdir: "/workspace",
});
it("drops host-local media when shared outbound attachment policy rejects it", async () => {
resolveOutboundAttachmentFromUrl.mockRejectedValueOnce(
new Error("Local media path is not under an allowed directory"),
);
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
@@ -151,7 +310,7 @@ describe("createReplyMediaPathNormalizer", () => {
});
const result = await normalize({
mediaUrls: ["file:///Users/peter/.openclaw/media/inbound/photo.png"],
mediaUrls: ["/Users/peter/secrets/photo.png"],
});
expect(result).toMatchObject({
@@ -160,98 +319,71 @@ describe("createReplyMediaPathNormalizer", () => {
});
});
it("persists volatile agent-state media from the workspace into host outbound media", async () => {
saveMediaSource.mockResolvedValue({
path: "/Users/peter/.openclaw/media/outbound/persisted.png",
});
it("threads requester context into shared outbound media access", async () => {
const normalize = createReplyMediaPathNormalizer({
cfg: { agents: { defaults: { mediaMaxMb: 8 } } },
sessionKey: "session-key",
workspaceDir: "/Users/peter/.openclaw/workspace",
cfg: {},
sessionKey: undefined,
workspaceDir: "/tmp/agent-workspace",
messageProvider: "whatsapp",
accountId: "source-account",
groupId: "ops",
groupChannel: "whatsapp",
groupSpace: "team",
requesterSenderId: "sender-1",
requesterSenderName: "Sender Name",
requesterSenderUsername: "sender-user",
requesterSenderE164: "+15551234567",
});
const result = await normalize({
mediaUrls: [
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
],
await normalize({
mediaUrls: ["./out/photo.png"],
});
expect(saveMediaSource).toHaveBeenCalledWith(
"/Users/peter/.openclaw/workspace/.openclaw/media/tool-image-generation/generated.png",
undefined,
"outbound",
8 * 1024 * 1024,
);
expect(result).toMatchObject({
mediaUrl: "/Users/peter/.openclaw/media/outbound/persisted.png",
mediaUrls: ["/Users/peter/.openclaw/media/outbound/persisted.png"],
expect(resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith({
cfg: {},
agentId: undefined,
workspaceDir: "/tmp/agent-workspace",
mediaSources: [path.join("/tmp/agent-workspace", "out", "photo.png")],
sessionKey: undefined,
messageProvider: "whatsapp",
accountId: "source-account",
requesterSenderId: "sender-1",
requesterSenderName: "Sender Name",
requesterSenderUsername: "sender-user",
requesterSenderE164: "+15551234567",
groupId: "ops",
groupChannel: "whatsapp",
groupSpace: "team",
});
});
it("persists TTS voice output from the preferred OpenClaw temp directory", async () => {
const tmpVoicePath = path.join(
"/private/tmp/openclaw-501",
"tts-abc123",
"voice-1234567890.opus",
);
saveMediaSource.mockResolvedValue({
path: "/Users/peter/.openclaw/media/outbound/tts-voice.opus",
});
it("passes absolute local media sources into shared outbound media access", async () => {
const absolutePath = "/Users/peter/Pictures/chart.png";
const normalize = createReplyMediaPathNormalizer({
cfg: {},
cfg: { tools: { fs: { workspaceOnly: false } } },
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: [tmpVoicePath],
await normalize({
mediaUrls: [absolutePath],
});
expect(saveMediaSource).toHaveBeenCalledWith(tmpVoicePath, undefined, "outbound", undefined);
expect(result).toMatchObject({
mediaUrl: "/Users/peter/.openclaw/media/outbound/tts-voice.opus",
mediaUrls: ["/Users/peter/.openclaw/media/outbound/tts-voice.opus"],
});
});
it("falls back to the original preferred tmp path when persisting TTS media fails", async () => {
const tmpVoicePath = path.join(
"/private/tmp/openclaw-501",
"tts-fallback",
"voice-1234567890.opus",
);
saveMediaSource.mockRejectedValue(new Error("disk full"));
const normalize = createReplyMediaPathNormalizer({
cfg: {},
sessionKey: "session-key",
expect(resolveAgentScopedOutboundMediaAccess).toHaveBeenCalledWith({
cfg: { tools: { fs: { workspaceOnly: false } } },
agentId: expect.any(String),
workspaceDir: "/tmp/agent-workspace",
});
const result = await normalize({
mediaUrls: [tmpVoicePath],
});
expect(result).toMatchObject({
mediaUrl: tmpVoicePath,
mediaUrls: [tmpVoicePath],
});
});
it("drops host tmp paths outside the preferred OpenClaw temp directory", async () => {
const normalize = createReplyMediaPathNormalizer({
cfg: {},
mediaSources: [absolutePath],
sessionKey: "session-key",
workspaceDir: "/tmp/agent-workspace",
messageProvider: undefined,
accountId: undefined,
requesterSenderId: undefined,
requesterSenderName: undefined,
requesterSenderUsername: undefined,
requesterSenderE164: undefined,
groupId: undefined,
groupChannel: undefined,
groupSpace: undefined,
});
const result = await normalize({
mediaUrls: ["/private/tmp/not-openclaw/voice-1234567890.opus"],
});
expect(result).toMatchObject({
mediaUrl: undefined,
mediaUrls: undefined,
});
expect(saveMediaSource).not.toHaveBeenCalled();
});
});

View File

@@ -1,16 +1,15 @@
import path from "node:path";
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { resolvePathFromInput } from "../../agents/path-policy.js";
import { resolvePathFromInput, toRelativeWorkspacePath } from "../../agents/path-policy.js";
import { assertMediaNotDataUrl, resolveSandboxedMediaSource } from "../../agents/sandbox-paths.js";
import { ensureSandboxWorkspaceForSession } from "../../agents/sandbox.js";
import { resolveEffectiveToolFsWorkspaceOnly } from "../../agents/tool-fs-policy.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { logVerbose } from "../../globals.js";
import { resolvePreferredOpenClawTmpDir } from "../../infra/tmp-openclaw-dir.js";
import { resolveConfiguredMediaMaxBytes } from "../../media/configured-max-bytes.js";
import { isPassThroughRemoteMediaSource } from "../../media/media-source-url.js";
import { saveMediaSource } from "../../media/store.js";
import { resolveOutboundAttachmentFromUrl } from "../../media/outbound-attachment.js";
import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js";
import { MEDIA_MAX_BYTES } from "../../media/store.js";
import { resolveConfigDir } from "../../utils.js";
import type { ReplyPayload } from "../types.js";
@@ -18,14 +17,7 @@ const FILE_URL_RE = /^file:\/\//i;
const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/;
const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/;
const HAS_FILE_EXT_RE = /\.\w{1,10}$/;
const AGENT_STATE_MEDIA_DIRNAME = path.join(".openclaw", "media");
const MANAGED_GLOBAL_MEDIA_SUBDIRS = new Set(["outbound"]);
let cachedPreferredTmpRoot: string | null | undefined;
function isPathInside(root: string, candidate: string): boolean {
const relative = path.relative(path.resolve(root), path.resolve(candidate));
return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
}
function isManagedGlobalReplyMediaPath(candidate: string): boolean {
const globalMediaRoot = path.join(resolveConfigDir(), "media");
@@ -37,43 +29,6 @@ function isManagedGlobalReplyMediaPath(candidate: string): boolean {
return MANAGED_GLOBAL_MEDIA_SUBDIRS.has(firstSegment) || firstSegment.startsWith("tool-");
}
function resolvePreferredReplyMediaTmpRoot(): string | undefined {
if (cachedPreferredTmpRoot !== undefined) {
return cachedPreferredTmpRoot ?? undefined;
}
try {
cachedPreferredTmpRoot = path.resolve(resolvePreferredOpenClawTmpDir());
} catch {
cachedPreferredTmpRoot = null;
}
return cachedPreferredTmpRoot ?? undefined;
}
function buildVolatileReplyMediaRoots(params: {
workspaceDir: string;
sandboxRoot?: string;
}): string[] {
const roots = [params.workspaceDir, params.sandboxRoot]
.filter((root): root is string => Boolean(root))
.map((root) => path.join(path.resolve(root), AGENT_STATE_MEDIA_DIRNAME));
const preferredTmpRoot = resolvePreferredReplyMediaTmpRoot();
if (preferredTmpRoot) {
roots.push(preferredTmpRoot);
}
return roots;
}
function isAllowedAbsoluteReplyMediaPath(params: {
candidate: string;
workspaceDir: string;
sandboxRoot?: string;
}): boolean {
if (isManagedGlobalReplyMediaPath(params.candidate)) {
return true;
}
return buildVolatileReplyMediaRoots(params).some((root) => isPathInside(root, params.candidate));
}
function isLikelyLocalMediaSource(media: string): boolean {
return (
FILE_URL_RE.test(media) ||
@@ -92,19 +47,60 @@ function getPayloadMediaList(payload: ReplyPayload): string[] {
return resolveSendableOutboundReplyParts(payload).mediaUrls;
}
function resolveReplyMediaMaxBytes(params: {
cfg: OpenClawConfig;
channel?: string;
accountId?: string;
}): number {
const channelId = params.channel?.trim();
const accountId = params.accountId?.trim();
const channelCfg = channelId ? params.cfg.channels?.[channelId] : undefined;
const channelObj =
channelCfg && typeof channelCfg === "object"
? (channelCfg as Record<string, unknown>)
: undefined;
const channelMediaMax =
typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined;
const accountsObj =
channelObj?.accounts && typeof channelObj.accounts === "object"
? (channelObj.accounts as Record<string, unknown>)
: undefined;
const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined;
const accountMediaMax =
accountCfg && typeof accountCfg === "object"
? (accountCfg as Record<string, unknown>).mediaMaxMb
: undefined;
const limitMb =
(typeof accountMediaMax === "number" ? accountMediaMax : undefined) ??
channelMediaMax ??
params.cfg.agents?.defaults?.mediaMaxMb;
return typeof limitMb === "number" && Number.isFinite(limitMb) && limitMb > 0
? Math.floor(limitMb * 1024 * 1024)
: MEDIA_MAX_BYTES;
}
export function createReplyMediaPathNormalizer(params: {
cfg: OpenClawConfig;
sessionKey?: string;
workspaceDir: string;
messageProvider?: string;
accountId?: string;
groupId?: string;
groupChannel?: string;
groupSpace?: string;
requesterSenderId?: string;
requesterSenderName?: string;
requesterSenderUsername?: string;
requesterSenderE164?: string;
}): (payload: ReplyPayload) => Promise<ReplyPayload> {
const agentId = params.sessionKey
? resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg })
: undefined;
const workspaceOnly = resolveEffectiveToolFsWorkspaceOnly({
const maxBytes = resolveReplyMediaMaxBytes({
cfg: params.cfg,
agentId,
channel: params.messageProvider,
accountId: params.accountId,
});
const configuredMediaMaxBytes = resolveConfiguredMediaMaxBytes(params.cfg);
let sandboxRootPromise: Promise<string | undefined> | undefined;
const persistedMediaBySource = new Map<string, Promise<string>>();
@@ -119,35 +115,52 @@ export function createReplyMediaPathNormalizer(params: {
return await sandboxRootPromise;
};
const persistVolatileReplyMedia = async (media: string): Promise<string> => {
if (!path.isAbsolute(media)) {
const resolveMediaAccessForSource = (media: string) =>
resolveAgentScopedOutboundMediaAccess({
cfg: params.cfg,
agentId,
workspaceDir: params.workspaceDir,
mediaSources: [media],
sessionKey: params.sessionKey,
messageProvider: params.sessionKey ? undefined : params.messageProvider,
accountId: params.accountId,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,
requesterSenderE164: params.requesterSenderE164,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
});
const persistLocalReplyMedia = async (media: string): Promise<string> => {
if (!isLikelyLocalMediaSource(media)) {
return media;
}
const sandboxRoot = await resolveSandboxRoot();
const volatileRoots = buildVolatileReplyMediaRoots({
workspaceDir: params.workspaceDir,
sandboxRoot,
});
if (!volatileRoots.some((root) => isPathInside(root, media))) {
if (path.isAbsolute(media) && isManagedGlobalReplyMediaPath(media)) {
return media;
}
const cached = persistedMediaBySource.get(media);
if (cached) {
return await cached;
}
const persistPromise = saveMediaSource(media, undefined, "outbound", configuredMediaMaxBytes)
const persistPromise = resolveOutboundAttachmentFromUrl(media, maxBytes, {
mediaAccess: resolveMediaAccessForSource(media),
})
.then((saved) => saved.path)
.catch((err) => {
persistedMediaBySource.delete(media);
throw err;
});
persistedMediaBySource.set(media, persistPromise);
try {
return await persistPromise;
} catch (err) {
logVerbose(`failed to persist volatile reply media ${media}: ${String(err)}`);
return media;
}
return await persistPromise;
};
const resolveWorkspaceRelativeMedia = (media: string): string => {
const relativeWorkspacePath = toRelativeWorkspacePath(params.workspaceDir, media, {
cwd: params.workspaceDir,
});
return resolvePathFromInput(relativeWorkspacePath, params.workspaceDir);
};
const normalizeMediaSource = async (raw: string): Promise<string> => {
@@ -159,61 +172,43 @@ export function createReplyMediaPathNormalizer(params: {
if (isPassThroughRemoteMediaSource(media)) {
return media;
}
const isRelativeLocalMedia =
isLikelyLocalMediaSource(media) &&
!FILE_URL_RE.test(media) &&
!media.startsWith("~") &&
!path.isAbsolute(media) &&
!WINDOWS_DRIVE_RE.test(media);
const sandboxRoot = await resolveSandboxRoot();
if (sandboxRoot) {
let sandboxResolvedMedia: string;
try {
return await resolveSandboxedMediaSource({
sandboxResolvedMedia = await resolveSandboxedMediaSource({
media,
sandboxRoot,
});
} catch (err) {
if (!isLikelyLocalMediaSource(media) || FILE_URL_RE.test(media)) {
throw err;
if (FILE_URL_RE.test(media)) {
throw new Error(
"Host-local MEDIA file URLs are blocked in normal replies. Use a safe path or the message tool.",
{ cause: err },
);
}
if (workspaceOnly) {
throw err;
}
if (!path.isAbsolute(media)) {
return resolvePathFromInput(media, params.workspaceDir);
}
if (
isAllowedAbsoluteReplyMediaPath({
candidate: media,
workspaceDir: params.workspaceDir,
sandboxRoot,
})
) {
return media;
}
throw new Error(
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
{ cause: err },
);
throw err;
}
return await persistLocalReplyMedia(sandboxResolvedMedia);
}
if (isRelativeLocalMedia) {
return await persistLocalReplyMedia(resolveWorkspaceRelativeMedia(media));
}
if (!isLikelyLocalMediaSource(media)) {
return media;
}
if (FILE_URL_RE.test(media)) {
throw new Error(
"Absolute host-local MEDIA file URLs are blocked in normal replies. Use a safe relative path or the message tool.",
"Host-local MEDIA file URLs are blocked in normal replies. Use a safe path or the message tool.",
);
}
if (!path.isAbsolute(media)) {
return resolvePathFromInput(media, params.workspaceDir);
}
if (
isAllowedAbsoluteReplyMediaPath({
candidate: media,
workspaceDir: params.workspaceDir,
sandboxRoot,
})
) {
return media;
}
throw new Error(
"Absolute host-local MEDIA paths are blocked in normal replies. Use a safe relative path or the message tool.",
);
return await persistLocalReplyMedia(media);
};
return async (payload) => {
@@ -227,7 +222,7 @@ export function createReplyMediaPathNormalizer(params: {
for (const media of mediaList) {
let normalized: string;
try {
normalized = await persistVolatileReplyMedia(await normalizeMediaSource(media));
normalized = await normalizeMediaSource(media);
} catch (err) {
logVerbose(`dropping blocked reply media ${media}: ${String(err)}`);
continue;

View File

@@ -36,6 +36,24 @@ describe("message action media helpers", () => {
mediaAccess: {
localRoots: ["/tmp/a"],
},
mediaLocalRoots: ["/tmp/a"],
});
});
it("preserves explicit any local roots for host read opt-ins", () => {
const mediaReadFile = async () => Buffer.from("x");
expect(
resolveAttachmentMediaPolicy({
mediaLocalRoots: "any",
mediaReadFile,
}),
).toEqual({
mode: "host",
mediaAccess: {
readFile: mediaReadFile,
},
mediaLocalRoots: "any",
mediaReadFile,
});
});

View File

@@ -8,6 +8,7 @@ import { basenameFromMediaSource } from "../../infra/local-file-access.js";
import {
buildOutboundMediaLoadOptions,
resolveOutboundMediaAccess,
resolveOutboundMediaLocalRoots,
type OutboundMediaAccess,
type OutboundMediaReadFile,
} from "../../media/load-options.js";
@@ -177,12 +178,14 @@ export type AttachmentMediaPolicy =
| {
mode: "host";
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[] | "any";
mediaReadFile?: OutboundMediaReadFile;
};
export function resolveAttachmentMediaPolicy(params: {
sandboxRoot?: string;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaLocalRoots?: readonly string[] | "any";
mediaReadFile?: OutboundMediaReadFile;
}): AttachmentMediaPolicy {
const sandboxRoot = params.sandboxRoot?.trim();
@@ -192,13 +195,20 @@ export function resolveAttachmentMediaPolicy(params: {
sandboxRoot,
};
}
const explicitLocalRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
return {
mode: "host",
mediaAccess: resolveOutboundMediaAccess({
mediaAccess: params.mediaAccess,
mediaLocalRoots: params.mediaLocalRoots,
mediaReadFile: params.mediaReadFile,
mediaLocalRoots: explicitLocalRoots === "any" ? undefined : explicitLocalRoots,
mediaReadFile: params.mediaAccess?.readFile ? undefined : params.mediaReadFile,
}),
...(explicitLocalRoots !== undefined ? { mediaLocalRoots: explicitLocalRoots } : {}),
...(params.mediaAccess?.readFile
? {}
: params.mediaReadFile
? { mediaReadFile: params.mediaReadFile }
: {}),
};
}
@@ -230,6 +240,8 @@ function buildAttachmentMediaLoadOptions(params: {
return buildOutboundMediaLoadOptions({
maxBytes: params.maxBytes,
mediaAccess: params.policy.mediaAccess,
mediaLocalRoots: params.policy.mediaLocalRoots,
mediaReadFile: params.policy.mediaReadFile,
});
}

View File

@@ -335,7 +335,7 @@ describe("runMessageAction media behavior", () => {
const call = vi.mocked(loadWebMedia).mock.calls[0];
expect(call?.[1]).toEqual(
expect.objectContaining({
localRoots: "any",
localRoots: expect.any(Array),
readFile: expect.any(Function),
hostReadCapability: true,
}),

View File

@@ -3,8 +3,8 @@ import { buildOutboundMediaLoadOptions, resolveOutboundMediaLocalRoots } from ".
describe("media load options", () => {
function expectResolvedOutboundMediaRoots(
mediaLocalRoots: readonly string[] | undefined,
expectedLocalRoots: readonly string[] | undefined,
mediaLocalRoots: readonly string[] | "any" | undefined,
expectedLocalRoots: readonly string[] | "any" | undefined,
) {
expect(resolveOutboundMediaLocalRoots(mediaLocalRoots)).toEqual(expectedLocalRoots);
}
@@ -20,6 +20,7 @@ describe("media load options", () => {
{ mediaLocalRoots: undefined, expectedLocalRoots: undefined },
{ mediaLocalRoots: [], expectedLocalRoots: undefined },
{ mediaLocalRoots: ["/tmp/workspace"], expectedLocalRoots: ["/tmp/workspace"] },
{ mediaLocalRoots: "any", expectedLocalRoots: "any" },
] as const)("resolves outbound local roots %#", ({ mediaLocalRoots, expectedLocalRoots }) => {
expectResolvedOutboundMediaRoots(mediaLocalRoots, expectedLocalRoots);
});
@@ -33,7 +34,52 @@ describe("media load options", () => {
params: { maxBytes: 2048, mediaLocalRoots: undefined },
expected: { maxBytes: 2048, localRoots: undefined },
},
{
params: {
maxBytes: 4096,
mediaAccess: {
localRoots: ["/tmp/workspace"],
readFile: async () => Buffer.from("x"),
},
},
expected: {
maxBytes: 4096,
localRoots: ["/tmp/workspace"],
readFile: expect.any(Function),
hostReadCapability: true,
},
},
{
params: {
maxBytes: 4096,
mediaLocalRoots: "any",
mediaReadFile: async () => Buffer.from("x"),
},
expected: {
maxBytes: 4096,
localRoots: "any",
readFile: expect.any(Function),
hostReadCapability: true,
},
},
] as const)("builds outbound media load options %#", ({ params, expected }) => {
expectBuiltOutboundMediaLoadOptions(params, expected);
});
it("rejects host read capability without explicit local roots", () => {
expect(() =>
buildOutboundMediaLoadOptions({
maxBytes: 1024,
mediaAccess: {
readFile: async () => Buffer.from("x"),
},
}),
).toThrow("Host media read requires explicit localRoots");
expect(() =>
buildOutboundMediaLoadOptions({
maxBytes: 1024,
mediaReadFile: async () => Buffer.from("x"),
}),
).toThrow("Host media read requires explicit localRoots");
});
});

View File

@@ -10,7 +10,7 @@ export type OutboundMediaAccess = {
export type OutboundMediaLoadParams = {
maxBytes?: number;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaLocalRoots?: readonly string[] | "any";
mediaReadFile?: OutboundMediaReadFile;
optimizeImages?: boolean;
/** Agent workspace directory for resolving relative MEDIA: paths. */
@@ -28,8 +28,11 @@ export type OutboundMediaLoadOptions = {
};
export function resolveOutboundMediaLocalRoots(
mediaLocalRoots?: readonly string[],
): readonly string[] | undefined {
mediaLocalRoots?: readonly string[] | "any",
): readonly string[] | "any" | undefined {
if (mediaLocalRoots === "any") {
return mediaLocalRoots;
}
return mediaLocalRoots && mediaLocalRoots.length > 0 ? mediaLocalRoots : undefined;
}
@@ -40,9 +43,10 @@ export function resolveOutboundMediaAccess(
mediaReadFile?: OutboundMediaReadFile;
} = {},
): OutboundMediaAccess | undefined {
const localRoots = resolveOutboundMediaLocalRoots(
const resolvedLocalRoots = resolveOutboundMediaLocalRoots(
params.mediaAccess?.localRoots ?? params.mediaLocalRoots,
);
const localRoots = resolvedLocalRoots === "any" ? undefined : resolvedLocalRoots;
const readFile = params.mediaAccess?.readFile ?? params.mediaReadFile;
const workspaceDir = params.mediaAccess?.workspaceDir;
if (!localRoots && !readFile && !workspaceDir) {
@@ -58,19 +62,30 @@ export function resolveOutboundMediaAccess(
export function buildOutboundMediaLoadOptions(
params: OutboundMediaLoadParams = {},
): OutboundMediaLoadOptions {
const mediaAccess = resolveOutboundMediaAccess(params);
const explicitLocalRoots = resolveOutboundMediaLocalRoots(params.mediaLocalRoots);
const mediaAccess = resolveOutboundMediaAccess({
mediaAccess: params.mediaAccess,
mediaLocalRoots: explicitLocalRoots === "any" ? undefined : explicitLocalRoots,
mediaReadFile: params.mediaAccess?.readFile ? undefined : params.mediaReadFile,
});
const workspaceDir = mediaAccess?.workspaceDir ?? params.workspaceDir;
if (mediaAccess?.readFile) {
const readFile = mediaAccess?.readFile ?? params.mediaReadFile;
const localRoots = mediaAccess?.localRoots ?? explicitLocalRoots;
if (readFile) {
if (!localRoots) {
throw new Error(
'Host media read requires explicit localRoots. Pass mediaAccess.localRoots or opt in with localRoots: "any".',
);
}
return {
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
localRoots: "any",
readFile: mediaAccess.readFile,
localRoots,
readFile,
hostReadCapability: true,
...(params.optimizeImages !== undefined ? { optimizeImages: params.optimizeImages } : {}),
...(workspaceDir ? { workspaceDir } : {}),
};
}
const localRoots = mediaAccess?.localRoots;
return {
...(params.maxBytes !== undefined ? { maxBytes: params.maxBytes } : {}),
...(localRoots ? { localRoots } : {}),

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/types.js";
import { resolveAgentScopedOutboundMediaAccess } from "./read-capability.js";
@@ -7,6 +7,10 @@ vi.mock("../channels/plugins/index.js", () => ({
}));
describe("resolveAgentScopedOutboundMediaAccess", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it("preserves caller-provided workspaceDir from mediaAccess", () => {
const result = resolveAgentScopedOutboundMediaAccess({
cfg: {} as OpenClawConfig,
@@ -49,12 +53,14 @@ describe("resolveAgentScopedOutboundMediaAccess", () => {
const result = resolveAgentScopedOutboundMediaAccess({
cfg,
sessionKey: "agent:main:whatsapp:group:ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
// Production call sites set messageProvider: undefined when sessionKey is present;
// resolveGroupToolPolicy derives channel from the session key instead.
requesterSenderId: "attacker",
});
expect(result.readFile).toBeUndefined();
expect(result.localRoots).not.toContain("/Users/peter/Pictures");
});
it("keeps host reads enabled when sender group policy allows read", () => {
@@ -80,10 +86,12 @@ describe("resolveAgentScopedOutboundMediaAccess", () => {
const result = resolveAgentScopedOutboundMediaAccess({
cfg,
sessionKey: "agent:main:whatsapp:group:ops",
mediaSources: ["/Users/peter/Pictures/photo.png"],
requesterSenderId: "trusted-user",
});
expect(result.readFile).toBeTypeOf("function");
expect(result.localRoots).toContain("/Users/peter/Pictures");
});
it("keeps host reads enabled when no group policy applies", () => {

View File

@@ -8,7 +8,10 @@ import type { OpenClawConfig } from "../config/types.js";
import { readLocalFileSafely } from "../infra/fs-safe.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type { OutboundMediaAccess, OutboundMediaReadFile } from "./load-options.js";
import { getAgentScopedMediaLocalRootsForSources } from "./local-roots.js";
import {
getAgentScopedMediaLocalRoots,
getAgentScopedMediaLocalRootsForSources,
} from "./local-roots.js";
type OutboundHostMediaPolicyContext = {
sessionKey?: string;
@@ -87,13 +90,16 @@ export function resolveAgentScopedOutboundMediaAccess(
mediaReadFile?: OutboundMediaReadFile;
} & OutboundHostMediaPolicyContext,
): OutboundMediaAccess {
const hostMediaReadAllowed = isAgentScopedHostMediaReadAllowed(params);
const localRoots =
params.mediaAccess?.localRoots ??
getAgentScopedMediaLocalRootsForSources({
cfg: params.cfg,
agentId: params.agentId,
mediaSources: params.mediaSources,
});
(hostMediaReadAllowed
? getAgentScopedMediaLocalRootsForSources({
cfg: params.cfg,
agentId: params.agentId,
mediaSources: params.mediaSources,
})
: getAgentScopedMediaLocalRoots(params.cfg, params.agentId));
const resolvedWorkspaceDir =
params.workspaceDir ??
params.mediaAccess?.workspaceDir ??
@@ -101,21 +107,23 @@ export function resolveAgentScopedOutboundMediaAccess(
const readFile =
params.mediaAccess?.readFile ??
params.mediaReadFile ??
createAgentScopedHostMediaReadFile({
cfg: params.cfg,
agentId: params.agentId,
workspaceDir: resolvedWorkspaceDir,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.accountId,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,
requesterSenderE164: params.requesterSenderE164,
});
(hostMediaReadAllowed
? createAgentScopedHostMediaReadFile({
cfg: params.cfg,
agentId: params.agentId,
workspaceDir: resolvedWorkspaceDir,
sessionKey: params.sessionKey,
messageProvider: params.messageProvider,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
accountId: params.accountId,
requesterSenderId: params.requesterSenderId,
requesterSenderName: params.requesterSenderName,
requesterSenderUsername: params.requesterSenderUsername,
requesterSenderE164: params.requesterSenderE164,
})
: undefined);
return {
...(localRoots?.length ? { localRoots } : {}),
...(readFile ? { readFile } : {}),

View File

@@ -51,7 +51,7 @@ describe("loadOutboundMediaFromUrl", () => {
expect(loadWebMediaMock).toHaveBeenCalledWith("https://example.com/image.png", {});
});
it("prefers host read capability over local roots when provided", async () => {
it("keeps local roots when host read capability is provided", async () => {
const mediaReadFile = vi.fn(async () => Buffer.from("x"));
loadWebMediaMock.mockResolvedValueOnce({
buffer: Buffer.from("x"),
@@ -65,6 +65,37 @@ describe("loadOutboundMediaFromUrl", () => {
mediaReadFile,
});
expect(loadWebMediaMock).toHaveBeenCalledWith("/Users/peter/Pictures/image.png", {
maxBytes: 2048,
localRoots: ["/tmp/workspace-agent"],
readFile: mediaReadFile,
hostReadCapability: true,
});
});
it("rejects host read capability without explicit local roots", async () => {
await expect(
loadOutboundMediaFromUrl("/Users/peter/Pictures/image.png", {
maxBytes: 2048,
mediaReadFile: async () => Buffer.from("x"),
}),
).rejects.toThrow("Host media read requires explicit localRoots");
});
it("allows explicit any opt-in for host read capability", 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: "any",
mediaReadFile,
});
expect(loadWebMediaMock).toHaveBeenCalledWith("/Users/peter/Pictures/image.png", {
maxBytes: 2048,
localRoots: "any",

View File

@@ -4,7 +4,7 @@ import { loadWebMedia } from "./web-media.js";
export type OutboundMediaLoadOptions = {
maxBytes?: number;
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaLocalRoots?: readonly string[] | "any";
mediaReadFile?: (filePath: string) => Promise<Buffer>;
};