diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index 90e518f8129..5c53e4970fc 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -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({ diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index c1e6c37c245..7b0eb3acece 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -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 = () => { diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 644cc6431d2..600a5ce4e5a 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -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", + }), + }), + ); }); }); diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index b8dc87cab25..a169e62fe2d 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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 diff --git a/src/auto-reply/reply/prompt-prelude.ts b/src/auto-reply/reply/prompt-prelude.ts index 4c63cef79ad..804dc208c7c 100644 --- a/src/auto-reply/reply/prompt-prelude.ts +++ b/src/auto-reply/reply/prompt-prelude.ts @@ -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; diff --git a/src/auto-reply/reply/reply-media-paths.test.ts b/src/auto-reply/reply/reply-media-paths.test.ts index e6258c230ef..d7aca2428fa 100644 --- a/src/auto-reply/reply/reply-media-paths.test.ts +++ b/src/auto-reply/reply/reply-media-paths.test.ts @@ -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(); - 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(); }); }); diff --git a/src/auto-reply/reply/reply-media-paths.ts b/src/auto-reply/reply/reply-media-paths.ts index b4fa59849e8..2f59a8b37e4 100644 --- a/src/auto-reply/reply/reply-media-paths.ts +++ b/src/auto-reply/reply/reply-media-paths.ts @@ -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) + : undefined; + const channelMediaMax = + typeof channelObj?.mediaMaxMb === "number" ? channelObj.mediaMaxMb : undefined; + const accountsObj = + channelObj?.accounts && typeof channelObj.accounts === "object" + ? (channelObj.accounts as Record) + : undefined; + const accountCfg = accountId && accountsObj ? accountsObj[accountId] : undefined; + const accountMediaMax = + accountCfg && typeof accountCfg === "object" + ? (accountCfg as Record).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 { 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 | undefined; const persistedMediaBySource = new Map>(); @@ -119,35 +115,52 @@ export function createReplyMediaPathNormalizer(params: { return await sandboxRootPromise; }; - const persistVolatileReplyMedia = async (media: string): Promise => { - 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 => { + 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 => { @@ -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; diff --git a/src/infra/outbound/message-action-params.test.ts b/src/infra/outbound/message-action-params.test.ts index cbcc65b1898..ad418b5aa69 100644 --- a/src/infra/outbound/message-action-params.test.ts +++ b/src/infra/outbound/message-action-params.test.ts @@ -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, }); }); diff --git a/src/infra/outbound/message-action-params.ts b/src/infra/outbound/message-action-params.ts index 3b891b0f283..a81ba45d99a 100644 --- a/src/infra/outbound/message-action-params.ts +++ b/src/infra/outbound/message-action-params.ts @@ -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, }); } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index cc524e53111..e7e692014f6 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -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, }), diff --git a/src/media/load-options.test.ts b/src/media/load-options.test.ts index ca10935d45a..16d0a5f396d 100644 --- a/src/media/load-options.test.ts +++ b/src/media/load-options.test.ts @@ -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"); + }); }); diff --git a/src/media/load-options.ts b/src/media/load-options.ts index e50f4398fa4..a4aebf1128f 100644 --- a/src/media/load-options.ts +++ b/src/media/load-options.ts @@ -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 } : {}), diff --git a/src/media/read-capability.test.ts b/src/media/read-capability.test.ts index a6f5a20ed61..f2000ac4e90 100644 --- a/src/media/read-capability.test.ts +++ b/src/media/read-capability.test.ts @@ -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", () => { diff --git a/src/media/read-capability.ts b/src/media/read-capability.ts index 3c3b3506d37..6e5b45be0b6 100644 --- a/src/media/read-capability.ts +++ b/src/media/read-capability.ts @@ -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 } : {}), diff --git a/src/plugin-sdk/outbound-media.test.ts b/src/plugin-sdk/outbound-media.test.ts index 7dd21e6c217..bcd877a475f 100644 --- a/src/plugin-sdk/outbound-media.test.ts +++ b/src/plugin-sdk/outbound-media.test.ts @@ -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", diff --git a/src/plugin-sdk/outbound-media.ts b/src/plugin-sdk/outbound-media.ts index 580f96f4849..6ea962e631d 100644 --- a/src/plugin-sdk/outbound-media.ts +++ b/src/plugin-sdk/outbound-media.ts @@ -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; };