fix: allow agent workspace directories in media local roots (#17136)
Merged via /review-pr -> /prepare-pr -> /merge-pr. Prepared head SHA: 7545ef1e1901a5bfd33aaa55a2320e003ea39126 Co-authored-by: MisterGuy420 <255743668+MisterGuy420@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras
This commit is contained in:
parent
0c57f5e62e
commit
e927fd1e35
@ -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-<agentId>`) 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.
|
||||
|
||||
@ -57,15 +57,18 @@ export type RouteReplyResult = {
|
||||
export async function routeReply(params: RouteReplyParams): Promise<RouteReplyResult> {
|
||||
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<RouteReplyRe
|
||||
payloads: [normalized],
|
||||
replyToId: resolvedReplyToId ?? null,
|
||||
threadId: resolvedThreadId,
|
||||
agentId: resolvedAgentId,
|
||||
abortSignal,
|
||||
mirror:
|
||||
params.mirror !== false && params.sessionKey
|
||||
? {
|
||||
sessionKey: params.sessionKey,
|
||||
agentId: resolveSessionAgentId({ sessionKey: params.sessionKey, config: cfg }),
|
||||
agentId: resolvedAgentId,
|
||||
text,
|
||||
mediaUrls,
|
||||
}
|
||||
|
||||
@ -16,11 +16,21 @@ export const discordOutbound: ChannelOutboundAdapter = {
|
||||
});
|
||||
return { channel: "discord", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId, silent }) => {
|
||||
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,
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
|
||||
@ -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 };
|
||||
},
|
||||
|
||||
@ -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 } : {}),
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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<IMessageRpcClient>;
|
||||
};
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 } } };
|
||||
|
||||
@ -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<ChannelHandler> {
|
||||
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,
|
||||
})),
|
||||
};
|
||||
};
|
||||
|
||||
@ -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)}`);
|
||||
|
||||
36
src/media/local-roots.ts
Normal file
36
src/media/local-roots.ts
Normal file
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<string, (ctx: unknown) => Promise<void>>();
|
||||
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<void>) => {
|
||||
commandHandlers.set(name, cb);
|
||||
}),
|
||||
} as unknown as Parameters<typeof registerTelegramNativeCommands>[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.");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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`,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user