diff --git a/CHANGELOG.md b/CHANGELOG.md index d271b6756af..8ec5a51b207 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -125,6 +125,7 @@ Docs: https://docs.openclaw.ai - Sandbox/Prompts: show the sandbox container workdir as the prompt working directory and clarify host-path usage for file tools, preventing host-path `exec` failures in sandbox sessions. (#16790) Thanks @carrotRakko. - Media/Security: allow local media reads from OpenClaw state `workspace/` and `sandboxes/` roots by default so generated workspace media can be delivered without unsafe global path bypasses. (#15541) Thanks @lanceji. - Media/Security: harden local media allowlist bypasses by requiring an explicit `readFile` override when callers mark paths as validated, and reject filesystem-root `localRoots` entries. (#16739) +- Media/Security: allow outbound local media reads from the active agent workspace (including `workspace-`) via agent-scoped local roots, avoiding broad global allowlisting of all per-agent workspaces. (#17136) Thanks @MisterGuy420. - Discord/Security: harden voice message media loading (SSRF + allowed-local-root checks) so tool-supplied paths/URLs cannot be used to probe internal URLs or read arbitrary local files. - Security/BlueBubbles: require explicit `mediaLocalRoots` allowlists for local outbound media path reads to prevent local file disclosure. (#16322) Thanks @mbelinky. - Security/BlueBubbles: reject ambiguous shared-path webhook routing when multiple webhook targets match the same guid/password. diff --git a/src/auto-reply/reply/route-reply.ts b/src/auto-reply/reply/route-reply.ts index c540f268d78..4ff7f4893cb 100644 --- a/src/auto-reply/reply/route-reply.ts +++ b/src/auto-reply/reply/route-reply.ts @@ -57,15 +57,18 @@ export type RouteReplyResult = { export async function routeReply(params: RouteReplyParams): Promise { const { payload, channel, to, accountId, threadId, cfg, abortSignal } = params; const normalizedChannel = normalizeMessageChannel(channel); + const resolvedAgentId = params.sessionKey + ? resolveSessionAgentId({ + sessionKey: params.sessionKey, + config: cfg, + }) + : undefined; // Debug: `pnpm test src/auto-reply/reply/route-reply.test.ts` const responsePrefix = params.sessionKey ? resolveEffectiveMessagesConfig( cfg, - resolveSessionAgentId({ - sessionKey: params.sessionKey, - config: cfg, - }), + resolvedAgentId ?? resolveSessionAgentId({ config: cfg }), { channel: normalizedChannel, accountId }, ).responsePrefix : cfg.messages?.responsePrefix === "auto" @@ -123,12 +126,13 @@ export async function routeReply(params: RouteReplyParams): Promise { + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + silent, + }) => { const send = deps?.sendDiscord ?? sendMessageDiscord; const result = await send(to, text, { verbose: false, mediaUrl, + mediaLocalRoots, replyTo: replyToId ?? undefined, accountId: accountId ?? undefined, silent: silent ?? undefined, diff --git a/src/channels/plugins/outbound/imessage.ts b/src/channels/plugins/outbound/imessage.ts index 2cfd122bd6f..8aab8c5f916 100644 --- a/src/channels/plugins/outbound/imessage.ts +++ b/src/channels/plugins/outbound/imessage.ts @@ -23,7 +23,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { }); return { channel: "imessage", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendIMessage ?? sendMessageIMessage; const maxBytes = resolveChannelMediaMaxBytes({ cfg, @@ -36,6 +36,7 @@ export const imessageOutbound: ChannelOutboundAdapter = { mediaUrl, maxBytes, accountId: accountId ?? undefined, + mediaLocalRoots, }); return { channel: "imessage", ...result }; }, diff --git a/src/channels/plugins/outbound/signal.ts b/src/channels/plugins/outbound/signal.ts index 8f880745fe7..45544b417da 100644 --- a/src/channels/plugins/outbound/signal.ts +++ b/src/channels/plugins/outbound/signal.ts @@ -22,7 +22,7 @@ export const signalOutbound: ChannelOutboundAdapter = { }); return { channel: "signal", ...result }; }, - sendMedia: async ({ cfg, to, text, mediaUrl, accountId, deps }) => { + sendMedia: async ({ cfg, to, text, mediaUrl, mediaLocalRoots, accountId, deps }) => { const send = deps?.sendSignal ?? sendMessageSignal; const maxBytes = resolveChannelMediaMaxBytes({ cfg, @@ -34,6 +34,7 @@ export const signalOutbound: ChannelOutboundAdapter = { mediaUrl, maxBytes, accountId: accountId ?? undefined, + mediaLocalRoots, }); return { channel: "signal", ...result }; }, diff --git a/src/channels/plugins/outbound/slack.ts b/src/channels/plugins/outbound/slack.ts index 6fc2a569956..d7c05ea8d7f 100644 --- a/src/channels/plugins/outbound/slack.ts +++ b/src/channels/plugins/outbound/slack.ts @@ -77,7 +77,17 @@ export const slackOutbound: ChannelOutboundAdapter = { }); return { channel: "slack", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId, identity }) => { + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + identity, + }) => { const send = deps?.sendSlack ?? sendMessageSlack; // Use threadId fallback so routed tool notifications stay in the Slack thread. const threadTs = replyToId ?? (threadId != null ? String(threadId) : undefined); @@ -100,6 +110,7 @@ export const slackOutbound: ChannelOutboundAdapter = { const slackIdentity = resolveSlackSendIdentity(identity); const result = await send(to, hookResult.text, { mediaUrl, + mediaLocalRoots, threadTs, accountId: accountId ?? undefined, ...(slackIdentity ? { identity: slackIdentity } : {}), diff --git a/src/channels/plugins/outbound/telegram.ts b/src/channels/plugins/outbound/telegram.ts index 52548f3c5c8..49865e3ca61 100644 --- a/src/channels/plugins/outbound/telegram.ts +++ b/src/channels/plugins/outbound/telegram.ts @@ -24,7 +24,16 @@ export const telegramOutbound: ChannelOutboundAdapter = { }); return { channel: "telegram", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, threadId }) => { + sendMedia: async ({ + to, + text, + mediaUrl, + mediaLocalRoots, + accountId, + deps, + replyToId, + threadId, + }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const messageThreadId = parseTelegramThreadId(threadId); @@ -35,10 +44,11 @@ export const telegramOutbound: ChannelOutboundAdapter = { messageThreadId, replyToMessageId, accountId: accountId ?? undefined, + mediaLocalRoots, }); return { channel: "telegram", ...result }; }, - sendPayload: async ({ to, payload, accountId, deps, replyToId, threadId }) => { + sendPayload: async ({ to, payload, mediaLocalRoots, accountId, deps, replyToId, threadId }) => { const send = deps?.sendTelegram ?? sendMessageTelegram; const replyToMessageId = parseTelegramReplyToMessageId(replyToId); const messageThreadId = parseTelegramThreadId(threadId); @@ -60,6 +70,7 @@ export const telegramOutbound: ChannelOutboundAdapter = { replyToMessageId, quoteText, accountId: accountId ?? undefined, + mediaLocalRoots, }; if (mediaUrls.length === 0) { diff --git a/src/channels/plugins/outbound/whatsapp.ts b/src/channels/plugins/outbound/whatsapp.ts index 625051d58d1..0bb94dac60e 100644 --- a/src/channels/plugins/outbound/whatsapp.ts +++ b/src/channels/plugins/outbound/whatsapp.ts @@ -22,12 +22,13 @@ export const whatsappOutbound: ChannelOutboundAdapter = { }); return { channel: "whatsapp", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, gifPlayback }) => { + sendMedia: async ({ to, text, mediaUrl, mediaLocalRoots, accountId, deps, gifPlayback }) => { const send = deps?.sendWhatsApp ?? (await import("../../../web/outbound.js")).sendMessageWhatsApp; const result = await send(to, text, { verbose: false, mediaUrl, + mediaLocalRoots, accountId: accountId ?? undefined, gifPlayback, }); diff --git a/src/channels/plugins/types.adapters.ts b/src/channels/plugins/types.adapters.ts index dc8ee43bab2..837a00a0609 100644 --- a/src/channels/plugins/types.adapters.ts +++ b/src/channels/plugins/types.adapters.ts @@ -76,6 +76,7 @@ export type ChannelOutboundContext = { to: string; text: string; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; replyToId?: string | null; threadId?: string | number | null; diff --git a/src/commands/agent/delivery.ts b/src/commands/agent/delivery.ts index 5f5cb66924b..0fe99b295aa 100644 --- a/src/commands/agent/delivery.ts +++ b/src/commands/agent/delivery.ts @@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { AgentCommandOpts } from "./types.js"; +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { AGENT_LANE_NESTED } from "../../agents/lanes.js"; import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js"; import { createOutboundSendDeps, type CliDeps } from "../../cli/outbound-send-deps.js"; @@ -178,12 +179,18 @@ export async function deliverAgentCommandResult(params: { } if (deliver && deliveryChannel && !isInternalMessageChannel(deliveryChannel)) { if (deliveryTarget) { + const deliveryAgentId = + opts.agentId ?? + (opts.sessionKey + ? resolveSessionAgentId({ sessionKey: opts.sessionKey, config: cfg }) + : undefined); await deliverOutboundPayloads({ cfg, channel: deliveryChannel, to: deliveryTarget, accountId: resolvedAccountId, payloads: deliveryPayloads, + agentId: deliveryAgentId, replyToId: resolvedReplyToId ?? null, threadId: resolvedThreadTarget ?? null, bestEffort: bestEffortDeliver, diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts index c10e66b17e2..116507f8e4e 100644 --- a/src/cron/isolated-agent/run.ts +++ b/src/cron/isolated-agent/run.ts @@ -741,6 +741,7 @@ export async function runCronIsolatedAgentTurn(params: { accountId: resolvedDelivery.accountId, threadId: resolvedDelivery.threadId, payloads: payloadsForDelivery, + agentId, identity, bestEffort: deliveryBestEffort, deps: createOutboundSendDeps(params.deps), diff --git a/src/discord/monitor/native-command.ts b/src/discord/monitor/native-command.ts index bb50f971276..62e2fb03c89 100644 --- a/src/discord/monitor/native-command.ts +++ b/src/discord/monitor/native-command.ts @@ -34,6 +34,7 @@ import { finalizeInboundContext } from "../../auto-reply/reply/inbound-context.j import { dispatchReplyWithDispatcher } from "../../auto-reply/reply/provider-dispatcher.js"; import { resolveCommandAuthorizedFromAuthorizers } from "../../channels/command-gating.js"; import { createReplyPrefixOptions } from "../../channels/reply-prefix.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { buildPairingReply } from "../../pairing/pairing-messages.js"; import { readChannelAllowFromStore, @@ -814,6 +815,7 @@ async function dispatchDiscordCommandInteraction(params: { channel: "discord", accountId: route.accountId, }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); let didReply = false; await dispatchReplyWithDispatcher({ @@ -827,6 +829,7 @@ async function dispatchDiscordCommandInteraction(params: { await deliverDiscordInteractionReply({ interaction, payload, + mediaLocalRoots, textLimit: resolveTextChunkLimit(cfg, "discord", accountId, { fallbackLimit: 2000, }), @@ -861,6 +864,7 @@ async function dispatchDiscordCommandInteraction(params: { async function deliverDiscordInteractionReply(params: { interaction: CommandInteraction | ButtonInteraction; payload: ReplyPayload; + mediaLocalRoots?: readonly string[]; textLimit: number; maxLinesPerMessage?: number; preferFollowUp: boolean; @@ -899,7 +903,9 @@ async function deliverDiscordInteractionReply(params: { if (mediaList.length > 0) { const media = await Promise.all( mediaList.map(async (url) => { - const loaded = await loadWebMedia(url); + const loaded = await loadWebMedia(url, { + localRoots: params.mediaLocalRoots, + }); return { name: loaded.fileName ?? "upload", data: loaded.buffer, diff --git a/src/discord/send.outbound.ts b/src/discord/send.outbound.ts index cbbadcc2f20..af013ede5df 100644 --- a/src/discord/send.outbound.ts +++ b/src/discord/send.outbound.ts @@ -39,6 +39,7 @@ type DiscordSendOpts = { token?: string; accountId?: string; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; verbose?: boolean; rest?: RequestClient; replyTo?: string; @@ -140,6 +141,7 @@ export async function sendMessageDiscord( threadId, mediaCaption ?? "", opts.mediaUrl, + opts.mediaLocalRoots, undefined, request, accountInfo.config.maxLinesPerMessage, @@ -203,6 +205,7 @@ export async function sendMessageDiscord( channelId, textWithTables, opts.mediaUrl, + opts.mediaLocalRoots, opts.replyTo, request, accountInfo.config.maxLinesPerMessage, diff --git a/src/discord/send.shared.ts b/src/discord/send.shared.ts index 30b9377b009..11feaaa8a0c 100644 --- a/src/discord/send.shared.ts +++ b/src/discord/send.shared.ts @@ -312,6 +312,7 @@ async function sendDiscordMedia( channelId: string, text: string, mediaUrl: string, + mediaLocalRoots: readonly string[] | undefined, replyTo: string | undefined, request: DiscordRequest, maxLinesPerMessage?: number, @@ -319,7 +320,7 @@ async function sendDiscordMedia( chunkMode?: ChunkMode, silent?: boolean, ) { - const media = await loadWebMedia(mediaUrl); + const media = await loadWebMedia(mediaUrl, { localRoots: mediaLocalRoots }); const chunks = text ? buildDiscordTextChunks(text, { maxLinesPerMessage, chunkMode }) : []; const caption = chunks[0] ?? ""; const hasCaption = caption.trim().length > 0; diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 0abccf60385..ea094ab7a72 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -199,6 +199,9 @@ export const sendHandlers: GatewayRequestHandlers = { to: resolved.to, accountId, payloads: [{ text: message, mediaUrl, mediaUrls }], + agentId: providedSessionKey + ? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg }) + : derivedAgentId, gifPlayback: request.gifPlayback, deps: outboundDeps, mirror: providedSessionKey diff --git a/src/gateway/server-restart-sentinel.ts b/src/gateway/server-restart-sentinel.ts index 901465b5684..a4b5bc046c8 100644 --- a/src/gateway/server-restart-sentinel.ts +++ b/src/gateway/server-restart-sentinel.ts @@ -1,4 +1,5 @@ import type { CliDeps } from "../cli/deps.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; import { resolveAnnounceTargetFromKey } from "../agents/tools/sessions-send-helpers.js"; import { normalizeChannelId } from "../channels/plugins/index.js"; import { resolveMainSessionKeyFromConfig } from "../config/sessions.js"; @@ -92,6 +93,7 @@ export async function scheduleRestartSentinelWake(_params: { deps: CliDeps }) { accountId: origin?.accountId, threadId, payloads: [{ text: message }], + agentId: resolveSessionAgentId({ sessionKey, config: cfg }), bestEffort: true, }); } catch (err) { diff --git a/src/imessage/send.ts b/src/imessage/send.ts index 7684f81f717..03d4544d154 100644 --- a/src/imessage/send.ts +++ b/src/imessage/send.ts @@ -14,6 +14,7 @@ export type IMessageSendOpts = { region?: string; accountId?: string; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; maxBytes?: number; timeoutMs?: number; chatId?: number; @@ -23,6 +24,7 @@ export type IMessageSendOpts = { resolveAttachmentImpl?: ( mediaUrl: string, maxBytes: number, + options?: { localRoots?: readonly string[] }, ) => Promise<{ path: string; contentType?: string }>; createClient?: (params: { cliPath: string; dbPath?: string }) => Promise; }; @@ -76,7 +78,9 @@ export async function sendMessageIMessage( if (opts.mediaUrl?.trim()) { const resolveAttachmentFn = opts.resolveAttachmentImpl ?? resolveOutboundAttachmentFromUrl; - const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes); + const resolved = await resolveAttachmentFn(opts.mediaUrl.trim(), maxBytes, { + localRoots: opts.mediaLocalRoots, + }); filePath = resolved.path; if (!message.trim()) { const kind = mediaKindFromMime(resolved.contentType ?? undefined); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 89c2ff4f0b9..5063463a67a 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -532,6 +532,7 @@ export async function runHeartbeatOnce(opts: { to: delivery.to, accountId: delivery.accountId, payloads: [{ text: heartbeatOkText }], + agentId, deps: opts.deps, }); return true; @@ -710,6 +711,7 @@ export async function runHeartbeatOnce(opts: { channel: delivery.channel, to: delivery.to, accountId: deliveryAccountId, + agentId, payloads: [ ...reasoningPayloads, ...(shouldSkipMain diff --git a/src/infra/outbound/deliver.test.ts b/src/infra/outbound/deliver.test.ts index afe94f5bf49..aece7cd9a9f 100644 --- a/src/infra/outbound/deliver.test.ts +++ b/src/infra/outbound/deliver.test.ts @@ -1,8 +1,10 @@ +import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { signalOutbound } from "../../channels/plugins/outbound/signal.js"; import { telegramOutbound } from "../../channels/plugins/outbound/telegram.js"; import { whatsappOutbound } from "../../channels/plugins/outbound/whatsapp.js"; +import { STATE_DIR } from "../../config/paths.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { markdownToSignalTextChunks } from "../../signal/format.js"; import { createOutboundTestPlugin, createTestRegistry } from "../../test-utils/channel-plugins.js"; @@ -116,6 +118,31 @@ describe("deliverOutboundPayloads", () => { ); }); + it("scopes media local roots to the active agent workspace when agentId is provided", async () => { + const sendTelegram = vi.fn().mockResolvedValue({ messageId: "m1", chatId: "c1" }); + const cfg: OpenClawConfig = { + channels: { telegram: { botToken: "tok-1", textChunkLimit: 2 } }, + }; + + await deliverOutboundPayloads({ + cfg, + channel: "telegram", + to: "123", + agentId: "work", + payloads: [{ text: "hi", mediaUrl: "file:///tmp/f.png" }], + deps: { sendTelegram }, + }); + + expect(sendTelegram).toHaveBeenCalledWith( + "123", + "hi", + expect.objectContaining({ + mediaUrl: "file:///tmp/f.png", + mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]), + }), + ); + }); + it("uses signal media maxBytes from config", async () => { const sendSignal = vi.fn().mockResolvedValue({ messageId: "s1", timestamp: 123 }); const cfg: OpenClawConfig = { channels: { signal: { mediaMaxMb: 2 } } }; diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 82b06998fdb..9d2e2107d62 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -22,6 +22,7 @@ import { appendAssistantMessageToSessionTranscript, resolveMirroredTranscriptText, } from "../../config/sessions.js"; +import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js"; import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js"; import { sendMessageSignal } from "../../signal/send.js"; @@ -90,6 +91,7 @@ async function createChannelHandler(params: { deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; + mediaLocalRoots?: readonly string[]; }): Promise { const outbound = await loadChannelOutboundAdapter(params.channel); if (!outbound?.sendText || !outbound?.sendMedia) { @@ -107,6 +109,7 @@ async function createChannelHandler(params: { deps: params.deps, gifPlayback: params.gifPlayback, silent: params.silent, + mediaLocalRoots: params.mediaLocalRoots, }); if (!handler) { throw new Error(`Outbound not configured for channel: ${params.channel}`); @@ -126,6 +129,7 @@ function createPluginHandler(params: { deps?: OutboundSendDeps; gifPlayback?: boolean; silent?: boolean; + mediaLocalRoots?: readonly string[]; }): ChannelHandler | null { const outbound = params.outbound; if (!outbound?.sendText || !outbound?.sendMedia) { @@ -153,6 +157,7 @@ function createPluginHandler(params: { gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, + mediaLocalRoots: params.mediaLocalRoots, payload, }) : undefined, @@ -168,6 +173,7 @@ function createPluginHandler(params: { gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, + mediaLocalRoots: params.mediaLocalRoots, }), sendMedia: async (caption, mediaUrl) => sendMedia({ @@ -182,6 +188,7 @@ function createPluginHandler(params: { gifPlayback: params.gifPlayback, deps: params.deps, silent: params.silent, + mediaLocalRoots: params.mediaLocalRoots, }), }; } @@ -203,6 +210,8 @@ type DeliverOutboundPayloadsCoreParams = { bestEffort?: boolean; onError?: (err: unknown, payload: NormalizedOutboundPayload) => void; onPayload?: (payload: NormalizedOutboundPayload) => void; + /** Active agent id for media local-root scoping. */ + agentId?: string; mirror?: { sessionKey: string; agentId?: string; @@ -286,6 +295,10 @@ async function deliverOutboundPayloadsCore( const deps = params.deps; const abortSignal = params.abortSignal; const sendSignal = params.deps?.sendSignal ?? sendMessageSignal; + const mediaLocalRoots = getAgentScopedMediaLocalRoots( + cfg, + params.agentId ?? params.mirror?.agentId, + ); const results: OutboundDeliveryResult[] = []; const handler = await createChannelHandler({ cfg, @@ -298,6 +311,7 @@ async function deliverOutboundPayloadsCore( identity: params.identity, gifPlayback: params.gifPlayback, silent: params.silent, + mediaLocalRoots, }); const textLimit = handler.chunker ? resolveTextChunkLimit(cfg, channel, accountId, { @@ -400,6 +414,7 @@ async function deliverOutboundPayloadsCore( accountId: accountId ?? undefined, textMode: "plain", textStyles: formatted.styles, + mediaLocalRoots, })), }; }; diff --git a/src/infra/session-maintenance-warning.ts b/src/infra/session-maintenance-warning.ts index adb8d2e23c7..8fa46cf27e9 100644 --- a/src/infra/session-maintenance-warning.ts +++ b/src/infra/session-maintenance-warning.ts @@ -1,5 +1,6 @@ import type { OpenClawConfig } from "../config/config.js"; import type { SessionEntry, SessionMaintenanceWarning } from "../config/sessions.js"; +import { resolveSessionAgentId } from "../agents/agent-scope.js"; import { isDeliverableMessageChannel, normalizeMessageChannel } from "../utils/message-channel.js"; import { resolveSessionDeliveryTarget } from "./outbound/targets.js"; import { enqueueSystemEvent } from "./system-events.js"; @@ -100,6 +101,7 @@ export async function deliverSessionMaintenanceWarning(params: WarningParams): P accountId: target.accountId, threadId: target.threadId, payloads: [{ text }], + agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: params.cfg }), }); } catch (err) { console.warn(`Failed to deliver session maintenance warning: ${String(err)}`); diff --git a/src/media/local-roots.ts b/src/media/local-roots.ts new file mode 100644 index 00000000000..b43e2d0f53c --- /dev/null +++ b/src/media/local-roots.ts @@ -0,0 +1,36 @@ +import os from "node:os"; +import path from "node:path"; +import type { OpenClawConfig } from "../config/config.js"; +import { resolveAgentWorkspaceDir } from "../agents/agent-scope.js"; +import { STATE_DIR } from "../config/paths.js"; + +const STATIC_LOCAL_ROOTS = [ + os.tmpdir(), + path.join(STATE_DIR, "media"), + path.join(STATE_DIR, "agents"), + path.join(STATE_DIR, "workspace"), + path.join(STATE_DIR, "sandboxes"), +] as const; + +export function getDefaultMediaLocalRoots(): readonly string[] { + return STATIC_LOCAL_ROOTS; +} + +export function getAgentScopedMediaLocalRoots( + cfg: OpenClawConfig, + agentId?: string, +): readonly string[] { + const roots = [...STATIC_LOCAL_ROOTS]; + if (!agentId?.trim()) { + return roots; + } + const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId); + if (!workspaceDir) { + return roots; + } + const normalizedWorkspaceDir = path.resolve(workspaceDir); + if (!roots.includes(normalizedWorkspaceDir)) { + roots.push(normalizedWorkspaceDir); + } + return roots; +} diff --git a/src/media/outbound-attachment.ts b/src/media/outbound-attachment.ts index 85abcd714c3..59ab560931b 100644 --- a/src/media/outbound-attachment.ts +++ b/src/media/outbound-attachment.ts @@ -4,8 +4,12 @@ import { saveMediaBuffer } from "./store.js"; export async function resolveOutboundAttachmentFromUrl( mediaUrl: string, maxBytes: number, + options?: { localRoots?: readonly string[] }, ): Promise<{ path: string; contentType?: string }> { - const media = await loadWebMedia(mediaUrl, maxBytes); + const media = await loadWebMedia(mediaUrl, { + maxBytes, + localRoots: options?.localRoots, + }); const saved = await saveMediaBuffer( media.buffer, media.contentType ?? undefined, diff --git a/src/signal/send.ts b/src/signal/send.ts index 82b343cbd39..9b73d7d8629 100644 --- a/src/signal/send.ts +++ b/src/signal/send.ts @@ -12,6 +12,7 @@ export type SignalSendOpts = { account?: string; accountId?: string; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; maxBytes?: number; timeoutMs?: number; textMode?: "markdown" | "plain"; @@ -125,7 +126,9 @@ export async function sendMessageSignal( let attachments: string[] | undefined; if (opts.mediaUrl?.trim()) { - const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes); + const resolved = await resolveOutboundAttachmentFromUrl(opts.mediaUrl.trim(), maxBytes, { + localRoots: opts.mediaLocalRoots, + }); attachments = [resolved.path]; const kind = mediaKindFromMime(resolved.contentType ?? undefined); if (!message && kind) { diff --git a/src/slack/send.ts b/src/slack/send.ts index 200b77bf32c..34eac1732fa 100644 --- a/src/slack/send.ts +++ b/src/slack/send.ts @@ -37,6 +37,7 @@ type SlackSendOpts = { token?: string; accountId?: string; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; client?: WebClient; threadTs?: string; identity?: SlackSendIdentity; @@ -170,6 +171,7 @@ async function uploadSlackFile(params: { client: WebClient; channelId: string; mediaUrl: string; + mediaLocalRoots?: readonly string[]; caption?: string; threadTs?: string; maxBytes?: number; @@ -178,7 +180,10 @@ async function uploadSlackFile(params: { buffer, contentType: _contentType, fileName, - } = await loadWebMedia(params.mediaUrl, params.maxBytes); + } = await loadWebMedia(params.mediaUrl, { + maxBytes: params.maxBytes, + localRoots: params.mediaLocalRoots, + }); const basePayload = { channel_id: params.channelId, file: buffer, @@ -256,6 +261,7 @@ export async function sendMessageSlack( client, channelId, mediaUrl: opts.mediaUrl, + mediaLocalRoots: opts.mediaLocalRoots, caption: firstChunk, threadTs: opts.threadTs, maxBytes: mediaMaxBytes, diff --git a/src/telegram/bot-message-dispatch.test.ts b/src/telegram/bot-message-dispatch.test.ts index 035ea83f1db..2548902072b 100644 --- a/src/telegram/bot-message-dispatch.test.ts +++ b/src/telegram/bot-message-dispatch.test.ts @@ -1,5 +1,7 @@ import type { Bot } from "grammy"; +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; +import { STATE_DIR } from "../config/paths.js"; const createTelegramDraftStream = vi.hoisted(() => vi.fn()); const dispatchReplyWithBufferedBlockDispatcher = vi.hoisted(() => vi.fn()); @@ -137,7 +139,11 @@ describe("dispatchTelegramMessage draft streaming", () => { ); deliverReplies.mockResolvedValue({ delivered: true }); - const context = createContext(); + const context = createContext({ + route: { + agentId: "work", + } as unknown as TelegramMessageContext["route"], + }); await dispatchWithContext({ context }); expect(createTelegramDraftStream).toHaveBeenCalledWith( @@ -150,6 +156,7 @@ describe("dispatchTelegramMessage draft streaming", () => { expect(deliverReplies).toHaveBeenCalledWith( expect.objectContaining({ thread: { id: 777, scope: "dm" }, + mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]), }), ); expect(dispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalledWith( diff --git a/src/telegram/bot-message-dispatch.ts b/src/telegram/bot-message-dispatch.ts index 21171cf611b..3caef30df57 100644 --- a/src/telegram/bot-message-dispatch.ts +++ b/src/telegram/bot-message-dispatch.ts @@ -21,6 +21,7 @@ import { createReplyPrefixOptions } from "../channels/reply-prefix.js"; import { createTypingCallbacks } from "../channels/typing.js"; import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { danger, logVerbose } from "../globals.js"; +import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; import { deliverReplies } from "./bot/delivery.js"; import { resolveTelegramDraftStreamingChunking } from "./draft-chunking.js"; import { createTelegramDraftStream } from "./draft-stream.js"; @@ -105,6 +106,7 @@ export const dispatchTelegramMessage = async ({ ? resolveTelegramDraftStreamingChunking(cfg, route.accountId) : undefined; const draftChunker = draftChunking ? new EmbeddedBlockChunker(draftChunking) : undefined; + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); let lastPartialText = ""; let draftText = ""; const updateDraftFromPartial = (text?: string) => { @@ -303,6 +305,7 @@ export const dispatchTelegramMessage = async ({ token: opts.token, runtime, bot, + mediaLocalRoots, replyToMode, textLimit, thread: threadSpec, @@ -357,6 +360,7 @@ export const dispatchTelegramMessage = async ({ token: opts.token, runtime, bot, + mediaLocalRoots, replyToMode, textLimit, thread: threadSpec, diff --git a/src/telegram/bot-native-commands.test.ts b/src/telegram/bot-native-commands.test.ts index 4f1f6f30781..424456a994a 100644 --- a/src/telegram/bot-native-commands.test.ts +++ b/src/telegram/bot-native-commands.test.ts @@ -1,20 +1,46 @@ +import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { TelegramAccountConfig } from "../config/types.js"; import type { RuntimeEnv } from "../runtime.js"; +import { STATE_DIR } from "../config/paths.js"; import { registerTelegramNativeCommands } from "./bot-native-commands.js"; const { listSkillCommandsForAgents } = vi.hoisted(() => ({ listSkillCommandsForAgents: vi.fn(() => []), })); +const pluginCommandMocks = vi.hoisted(() => ({ + getPluginCommandSpecs: vi.fn(() => []), + matchPluginCommand: vi.fn(() => null), + executePluginCommand: vi.fn(async () => ({ text: "ok" })), +})); +const deliveryMocks = vi.hoisted(() => ({ + deliverReplies: vi.fn(async () => ({ delivered: true })), +})); vi.mock("../auto-reply/skill-commands.js", () => ({ listSkillCommandsForAgents, })); +vi.mock("../plugins/commands.js", () => ({ + getPluginCommandSpecs: pluginCommandMocks.getPluginCommandSpecs, + matchPluginCommand: pluginCommandMocks.matchPluginCommand, + executePluginCommand: pluginCommandMocks.executePluginCommand, +})); +vi.mock("./bot/delivery.js", () => ({ + deliverReplies: deliveryMocks.deliverReplies, +})); describe("registerTelegramNativeCommands", () => { beforeEach(() => { listSkillCommandsForAgents.mockReset(); + pluginCommandMocks.getPluginCommandSpecs.mockReset(); + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([]); + pluginCommandMocks.matchPluginCommand.mockReset(); + pluginCommandMocks.matchPluginCommand.mockReturnValue(null); + pluginCommandMocks.executePluginCommand.mockReset(); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ text: "ok" }); + deliveryMocks.deliverReplies.mockReset(); + deliveryMocks.deliverReplies.mockResolvedValue({ delivered: true }); }); const buildParams = (cfg: OpenClawConfig, accountId = "default") => ({ @@ -118,4 +144,62 @@ describe("registerTelegramNativeCommands", () => { "Telegram limits bots to 100 commands. 120 configured; registering first 100. Use channels.telegram.commands.native: false to disable, or reduce plugin/skill/custom commands.", ); }); + + it("passes agent-scoped media roots for plugin command replies with media", async () => { + const commandHandlers = new Map Promise>(); + const sendMessage = vi.fn().mockResolvedValue(undefined); + const cfg: OpenClawConfig = { + agents: { + list: [{ id: "main", default: true }, { id: "work" }], + }, + bindings: [{ agentId: "work", match: { channel: "telegram", accountId: "default" } }], + }; + + pluginCommandMocks.getPluginCommandSpecs.mockReturnValue([ + { + name: "plug", + description: "Plugin command", + }, + ]); + pluginCommandMocks.matchPluginCommand.mockReturnValue({ + command: { key: "plug", requireAuth: false }, + args: undefined, + }); + pluginCommandMocks.executePluginCommand.mockResolvedValue({ + text: "with media", + mediaUrl: "/tmp/workspace-work/render.png", + }); + + registerTelegramNativeCommands({ + ...buildParams(cfg), + bot: { + api: { + setMyCommands: vi.fn().mockResolvedValue(undefined), + sendMessage, + }, + command: vi.fn((name: string, cb: (ctx: unknown) => Promise) => { + commandHandlers.set(name, cb); + }), + } as unknown as Parameters[0]["bot"], + }); + + const handler = commandHandlers.get("plug"); + expect(handler).toBeTruthy(); + await handler?.({ + match: "", + message: { + message_id: 1, + date: Math.floor(Date.now() / 1000), + chat: { id: 123, type: "private" }, + from: { id: 456, username: "alice" }, + }, + }); + + expect(deliveryMocks.deliverReplies).toHaveBeenCalledWith( + expect.objectContaining({ + mediaLocalRoots: expect.arrayContaining([path.join(STATE_DIR, "workspace-work")]), + }), + ); + expect(sendMessage).not.toHaveBeenCalledWith(123, "Command not found."); + }); }); diff --git a/src/telegram/bot-native-commands.ts b/src/telegram/bot-native-commands.ts index 61fd189423c..73041e3bf32 100644 --- a/src/telegram/bot-native-commands.ts +++ b/src/telegram/bot-native-commands.ts @@ -28,6 +28,7 @@ import { resolveMarkdownTableMode } from "../config/markdown-tables.js"; import { resolveTelegramCustomCommands } from "../config/telegram-custom-commands.js"; import { danger, logVerbose } from "../globals.js"; import { getChildLogger } from "../logging.js"; +import { getAgentScopedMediaLocalRoots } from "../media/local-roots.js"; import { readChannelAllowFromStore } from "../pairing/pairing-store.js"; import { executePluginCommand, @@ -465,6 +466,7 @@ export const registerTelegramNativeCommands = ({ }, parentPeer, }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const baseSessionKey = route.sessionKey; // DMs: use raw messageThreadId for thread sessions (not resolvedThreadId which is for forums) const dmThreadId = threadSpec.scope === "dm" ? threadSpec.id : undefined; @@ -554,6 +556,7 @@ export const registerTelegramNativeCommands = ({ token: opts.token, runtime, bot, + mediaLocalRoots, replyToMode, textLimit, thread: threadSpec, @@ -587,6 +590,7 @@ export const registerTelegramNativeCommands = ({ token: opts.token, runtime, bot, + mediaLocalRoots, replyToMode, textLimit, thread: threadSpec, @@ -634,13 +638,25 @@ export const registerTelegramNativeCommands = ({ if (!auth) { return; } - const { senderId, commandAuthorized, isGroup, isForum } = auth; + const { senderId, commandAuthorized, isGroup, isForum, resolvedThreadId } = auth; const messageThreadId = (msg as { message_thread_id?: number }).message_thread_id; const threadSpec = resolveTelegramThreadSpec({ isGroup, isForum, messageThreadId, }); + const parentPeer = buildTelegramParentPeer({ isGroup, resolvedThreadId, chatId }); + const route = resolveAgentRoute({ + cfg, + channel: "telegram", + accountId, + peer: { + kind: isGroup ? "group" : "direct", + id: isGroup ? buildTelegramGroupPeerId(chatId, resolvedThreadId) : String(chatId), + }, + parentPeer, + }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(cfg, route.agentId); const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`; @@ -662,9 +678,9 @@ export const registerTelegramNativeCommands = ({ const tableMode = resolveMarkdownTableMode({ cfg, channel: "telegram", - accountId, + accountId: route.accountId, }); - const chunkMode = resolveChunkMode(cfg, "telegram", accountId); + const chunkMode = resolveChunkMode(cfg, "telegram", route.accountId); await deliverReplies({ replies: [result], @@ -672,6 +688,7 @@ export const registerTelegramNativeCommands = ({ token: opts.token, runtime, bot, + mediaLocalRoots, replyToMode, textLimit, thread: threadSpec, diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 036f4e7175b..7d61f05284c 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -110,6 +110,37 @@ describe("deliverReplies", () => { ); }); + it("passes mediaLocalRoots to media loading", async () => { + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendPhoto = vi.fn().mockResolvedValue({ + message_id: 12, + chat: { id: "123" }, + }); + const bot = { api: { sendPhoto } } as unknown as Bot; + const mediaLocalRoots = ["/tmp/workspace-work"]; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("image"), + contentType: "image/jpeg", + fileName: "photo.jpg", + }); + + await deliverReplies({ + replies: [{ mediaUrl: "/tmp/workspace-work/photo.jpg" }], + chatId: "123", + token: "tok", + runtime, + bot, + mediaLocalRoots, + replyToMode: "off", + textLimit: 4000, + }); + + expect(loadWebMedia).toHaveBeenCalledWith("/tmp/workspace-work/photo.jpg", { + localRoots: mediaLocalRoots, + }); + }); + it("includes link_preview_options when linkPreview is false", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendMessage = vi.fn().mockResolvedValue({ diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 34a78b6d98d..a70e554f38d 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -39,6 +39,7 @@ export async function deliverReplies(params: { token: string; runtime: RuntimeEnv; bot: Bot; + mediaLocalRoots?: readonly string[]; replyToMode: ReplyToMode; textLimit: number; thread?: TelegramThreadSpec | null; @@ -142,7 +143,9 @@ export async function deliverReplies(params: { let pendingFollowUpText: string | undefined; for (const mediaUrl of mediaList) { const isFirstMedia = first; - const media = await loadWebMedia(mediaUrl); + const media = await loadWebMedia(mediaUrl, { + localRoots: params.mediaLocalRoots, + }); const kind = mediaKindFromMime(media.contentType ?? undefined); const isGif = isGifMedia({ contentType: media.contentType, diff --git a/src/telegram/send.ts b/src/telegram/send.ts index d82814bef1e..d4c4dd0e9b9 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -36,6 +36,7 @@ type TelegramSendOpts = { accountId?: string; verbose?: boolean; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; maxBytes?: number; api?: Bot["api"]; retry?: RetryConfig; @@ -384,7 +385,10 @@ export async function sendMessageTelegram( }; if (mediaUrl) { - const media = await loadWebMedia(mediaUrl, opts.maxBytes); + const media = await loadWebMedia(mediaUrl, { + maxBytes: opts.maxBytes, + localRoots: opts.mediaLocalRoots, + }); const kind = mediaKindFromMime(media.contentType ?? undefined); const isGif = isGifMedia({ contentType: media.contentType, diff --git a/src/web/auto-reply/deliver-reply.test.ts b/src/web/auto-reply/deliver-reply.test.ts index 0a328ba607b..6a001369537 100644 --- a/src/web/auto-reply/deliver-reply.test.ts +++ b/src/web/auto-reply/deliver-reply.test.ts @@ -85,6 +85,7 @@ describe("deliverWebReply", () => { it("sends image media with caption and then remaining text", async () => { const msg = makeMsg(); + const mediaLocalRoots = ["/tmp/workspace-work"]; ( loadWebMedia as unknown as { mockResolvedValueOnce: (v: unknown) => void } ).mockResolvedValueOnce({ @@ -96,12 +97,18 @@ describe("deliverWebReply", () => { await deliverWebReply({ replyResult: { text: "aaaaaa", mediaUrl: "http://example.com/img.jpg" }, msg, + mediaLocalRoots, maxMediaBytes: 1024 * 1024, textLimit: 3, replyLogger, skipLog: true, }); + expect(loadWebMedia).toHaveBeenCalledWith("http://example.com/img.jpg", { + maxBytes: 1024 * 1024, + localRoots: mediaLocalRoots, + }); + expect(msg.sendMedia).toHaveBeenCalledWith( expect.objectContaining({ image: expect.any(Buffer), diff --git a/src/web/auto-reply/deliver-reply.ts b/src/web/auto-reply/deliver-reply.ts index cee7e1b79aa..0f9064e7b2c 100644 --- a/src/web/auto-reply/deliver-reply.ts +++ b/src/web/auto-reply/deliver-reply.ts @@ -15,6 +15,7 @@ import { elide } from "./util.js"; export async function deliverWebReply(params: { replyResult: ReplyPayload; msg: WebInboundMsg; + mediaLocalRoots?: readonly string[]; maxMediaBytes: number; textLimit: number; chunkMode?: ChunkMode; @@ -99,7 +100,10 @@ export async function deliverWebReply(params: { for (const [index, mediaUrl] of mediaList.entries()) { const caption = index === 0 ? remainingText.shift() || undefined : undefined; try { - const media = await loadWebMedia(mediaUrl, maxMediaBytes); + const media = await loadWebMedia(mediaUrl, { + maxBytes: maxMediaBytes, + localRoots: params.mediaLocalRoots, + }); if (shouldLogVerbose()) { logVerbose( `Web auto-reply media size: ${(media.buffer.length / (1024 * 1024)).toFixed(2)}MB`, diff --git a/src/web/auto-reply/monitor/process-message.ts b/src/web/auto-reply/monitor/process-message.ts index a461b2d70c6..90e857474da 100644 --- a/src/web/auto-reply/monitor/process-message.ts +++ b/src/web/auto-reply/monitor/process-message.ts @@ -26,6 +26,7 @@ import { resolveStorePath, } from "../../../config/sessions.js"; import { logVerbose, shouldLogVerbose } from "../../../globals.js"; +import { getAgentScopedMediaLocalRoots } from "../../../media/local-roots.js"; import { readChannelAllowFromStore } from "../../../pairing/pairing-store.js"; import { jidToE164, normalizeE164 } from "../../../utils.js"; import { newConnectionId } from "../../reconnect.js"; @@ -245,6 +246,7 @@ export async function processMessage(params: { channel: "whatsapp", accountId: params.route.accountId, }); + const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.route.agentId); let didLogHeartbeatStrip = false; let didSendReply = false; const commandAuthorized = shouldComputeCommandAuthorized(params.msg.body, params.cfg) @@ -362,6 +364,7 @@ export async function processMessage(params: { await deliverWebReply({ replyResult: payload, msg: params.msg, + mediaLocalRoots, maxMediaBytes: params.maxMediaBytes, textLimit, chunkMode, diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 4a04d28cbbb..86fe6c59c06 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -371,4 +371,34 @@ describe("local media root guard", () => { }), ); }); + + it("rejects default OpenClaw state per-agent workspace-* roots without explicit local roots", async () => { + const { STATE_DIR } = await import("../config/paths.js"); + const readFile = vi.fn(async () => Buffer.from("generated-media")); + + await expect( + loadWebMedia(path.join(STATE_DIR, "workspace-clawdy", "tmp", "render.bin"), { + maxBytes: 1024 * 1024, + readFile, + }), + ).rejects.toThrow(/not under an allowed directory/i); + }); + + it("allows per-agent workspace-* paths with explicit local roots", async () => { + const { STATE_DIR } = await import("../config/paths.js"); + const readFile = vi.fn(async () => Buffer.from("generated-media")); + const agentWorkspaceDir = path.join(STATE_DIR, "workspace-clawdy"); + + await expect( + loadWebMedia(path.join(agentWorkspaceDir, "tmp", "render.bin"), { + maxBytes: 1024 * 1024, + localRoots: [agentWorkspaceDir], + readFile, + }), + ).resolves.toEqual( + expect.objectContaining({ + kind: "unknown", + }), + ); + }); }); diff --git a/src/web/media.ts b/src/web/media.ts index c6dd11d05de..f777dabdbbb 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -1,9 +1,7 @@ import fs from "node:fs/promises"; -import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; import type { SsrFPolicy } from "../infra/net/ssrf.js"; -import { STATE_DIR } from "../config/paths.js"; import { logVerbose, shouldLogVerbose } from "../globals.js"; import { type MediaKind, maxBytesForKind, mediaKindFromMime } from "../media/constants.js"; import { fetchRemoteMedia } from "../media/fetch.js"; @@ -13,6 +11,7 @@ import { optimizeImageToPng, resizeToJpeg, } from "../media/image-ops.js"; +import { getDefaultMediaLocalRoots } from "../media/local-roots.js"; import { detectMime, extensionForMime } from "../media/mime.js"; import { resolveUserPath } from "../utils.js"; @@ -35,13 +34,7 @@ type WebMediaOptions = { }; export function getDefaultLocalRoots(): readonly string[] { - return [ - os.tmpdir(), - path.join(STATE_DIR, "media"), - path.join(STATE_DIR, "agents"), - path.join(STATE_DIR, "workspace"), - path.join(STATE_DIR, "sandboxes"), - ]; + return getDefaultMediaLocalRoots(); } async function assertLocalMediaAllowed( diff --git a/src/web/outbound.ts b/src/web/outbound.ts index e09981541d1..5d3e84ba401 100644 --- a/src/web/outbound.ts +++ b/src/web/outbound.ts @@ -18,6 +18,7 @@ export async function sendMessageWhatsApp( options: { verbose: boolean; mediaUrl?: string; + mediaLocalRoots?: readonly string[]; gifPlayback?: boolean; accountId?: string; }, @@ -47,7 +48,9 @@ export async function sendMessageWhatsApp( let mediaType: string | undefined; let documentFileName: string | undefined; if (options.mediaUrl) { - const media = await loadWebMedia(options.mediaUrl); + const media = await loadWebMedia(options.mediaUrl, { + localRoots: options.mediaLocalRoots, + }); const caption = text || undefined; mediaBuffer = media.buffer; mediaType = media.contentType;