From 9adcccadb1b00000c641e6e64bf438baa85bee5d Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 15 Feb 2026 11:43:02 -0500 Subject: [PATCH] Outbound: scope core send media roots by agent (#17268) Merged with gates skipped by maintainer request. Prepared head SHA: 663ac49b3a8432b1dab2155351bec84d34775087 --- CHANGELOG.md | 1 + .../message-action-runner.threading.test.ts | 1 + src/infra/outbound/message-action-runner.ts | 1 + src/infra/outbound/message.test.ts | 54 ++++++++++++++++++ src/infra/outbound/message.ts | 3 + .../outbound/outbound-send-service.test.ts | 55 +++++++++++++++++++ src/infra/outbound/outbound-send-service.ts | 3 + 7 files changed, 118 insertions(+) create mode 100644 src/infra/outbound/message.test.ts create mode 100644 src/infra/outbound/outbound-send-service.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c416fda80..0ec0070906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -128,6 +128,7 @@ Docs: https://docs.openclaw.ai - 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. +- Outbound/Media: thread explicit `agentId` through core `sendMessage` direct-delivery path so agent-scoped local media roots apply even when mirror metadata is absent. (#17268) Thanks @gumadeiras. - 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/infra/outbound/message-action-runner.threading.test.ts b/src/infra/outbound/message-action-runner.threading.test.ts index c1b0122ec8..bc4d68c2d9 100644 --- a/src/infra/outbound/message-action-runner.threading.test.ts +++ b/src/infra/outbound/message-action-runner.threading.test.ts @@ -102,6 +102,7 @@ describe("runMessageAction threading auto-injection", () => { }); const call = mocks.executeSendAction.mock.calls[0]?.[0]; + expect(call?.ctx?.agentId).toBe("main"); expect(call?.ctx?.mirror?.sessionKey).toBe("agent:main:slack:channel:c123:thread:111.222"); }); diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index fb6418c730..72a320a68c 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -509,6 +509,7 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise ({ + getChannelPlugin: vi.fn(), + resolveOutboundTarget: vi.fn(), + deliverOutboundPayloads: vi.fn(), +})); + +vi.mock("../../channels/plugins/index.js", () => ({ + normalizeChannelId: (channel?: string) => channel?.trim().toLowerCase() ?? undefined, + getChannelPlugin: mocks.getChannelPlugin, +})); + +vi.mock("./targets.js", () => ({ + resolveOutboundTarget: mocks.resolveOutboundTarget, +})); + +vi.mock("./deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, +})); + +import { sendMessage } from "./message.js"; + +describe("sendMessage", () => { + beforeEach(() => { + mocks.getChannelPlugin.mockReset(); + mocks.resolveOutboundTarget.mockReset(); + mocks.deliverOutboundPayloads.mockReset(); + + mocks.getChannelPlugin.mockReturnValue({ + outbound: { deliveryMode: "direct" }, + }); + mocks.resolveOutboundTarget.mockImplementation(({ to }: { to: string }) => ({ ok: true, to })); + mocks.deliverOutboundPayloads.mockResolvedValue([{ channel: "mattermost", messageId: "m1" }]); + }); + + it("passes explicit agentId to outbound delivery for scoped media roots", async () => { + await sendMessage({ + cfg: {}, + channel: "mattermost", + to: "channel:town-square", + content: "hi", + agentId: "work", + }); + + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + channel: "mattermost", + to: "channel:town-square", + }), + ); + }); +}); diff --git a/src/infra/outbound/message.ts b/src/infra/outbound/message.ts index 93d105a331..4170d88ffc 100644 --- a/src/infra/outbound/message.ts +++ b/src/infra/outbound/message.ts @@ -31,6 +31,8 @@ export type MessageGatewayOptions = { type MessageSendParams = { to: string; content: string; + /** Active agent id for per-agent outbound media root scoping. */ + agentId?: string; channel?: string; mediaUrl?: string; mediaUrls?: string[]; @@ -179,6 +181,7 @@ export async function sendMessage(params: MessageSendParams): Promise ({ + dispatchChannelMessageAction: vi.fn(), + sendMessage: vi.fn(), +})); + +vi.mock("../../channels/plugins/message-actions.js", () => ({ + dispatchChannelMessageAction: (...args: unknown[]) => mocks.dispatchChannelMessageAction(...args), +})); + +vi.mock("./message.js", () => ({ + sendMessage: (...args: unknown[]) => mocks.sendMessage(...args), + sendPoll: vi.fn(), +})); + +import { executeSendAction } from "./outbound-send-service.js"; + +describe("executeSendAction", () => { + beforeEach(() => { + mocks.dispatchChannelMessageAction.mockReset(); + mocks.sendMessage.mockReset(); + }); + + it("forwards ctx.agentId to sendMessage on core outbound path", async () => { + mocks.dispatchChannelMessageAction.mockResolvedValue(null); + mocks.sendMessage.mockResolvedValue({ + channel: "discord", + to: "channel:123", + via: "direct", + mediaUrl: null, + }); + + await executeSendAction({ + ctx: { + cfg: {}, + channel: "discord", + params: {}, + agentId: "work", + dryRun: false, + }, + to: "channel:123", + message: "hello", + }); + + expect(mocks.sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: "work", + channel: "discord", + to: "channel:123", + content: "hello", + }), + ); + }); +}); diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index e2e96986a4..88eda04d87 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -23,6 +23,8 @@ export type OutboundSendContext = { cfg: OpenClawConfig; channel: ChannelId; params: Record; + /** Active agent id for per-agent outbound media root scoping. */ + agentId?: string; accountId?: string | null; gateway?: OutboundGatewayContext; toolContext?: ChannelThreadingToolContext; @@ -93,6 +95,7 @@ export async function executeSendAction(params: { cfg: params.ctx.cfg, to: params.to, content: params.message, + agentId: params.ctx.agentId, mediaUrl: params.mediaUrl || undefined, mediaUrls: params.mediaUrls, channel: params.ctx.channel || undefined,