diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 77652a8e6b7c..8afb03fde4eb 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -171,15 +171,21 @@ describe("matrixMessageActions account propagation", () => { }); it("forwards mediaLocalRoots for media sends", async () => { + const mediaAccess = { + localRoots: ["/tmp/openclaw-matrix-test"], + readFile: async () => Buffer.from("chart"), + workspaceDir: "/tmp/openclaw-matrix-test", + }; await matrixMessageActions.handleAction?.( createContext({ action: "send", accountId: "ops", - mediaLocalRoots: ["/tmp/openclaw-matrix-test"], + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, params: { to: "room:!room:example", message: "hello", - media: "file:///tmp/photo.png", + media: "chart.png", }, }), ); @@ -187,8 +193,9 @@ describe("matrixMessageActions account propagation", () => { const call = matrixActionCall(); expect(call.input.action).toBe("sendMessage"); expect(call.input.accountId).toBe("ops"); - expect(call.input.mediaUrl).toBe("file:///tmp/photo.png"); + expect(call.input.mediaUrl).toBe("chart.png"); expect(call.cfg).toBeTypeOf("object"); + expect(call.options.mediaAccess).toBe(mediaAccess); expect(call.options).toMatchObject({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }); }); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index cde69547f9bd..1f58ae56988a 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -176,6 +176,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { }, cfg as CoreConfig, { + ...(action === "send" && ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}), mediaLocalRoots, readContext: { accountId, diff --git a/extensions/matrix/src/matrix/actions/messages.test.ts b/extensions/matrix/src/matrix/actions/messages.test.ts index 6f45a094b6a9..31060e62bc23 100644 --- a/extensions/matrix/src/matrix/actions/messages.test.ts +++ b/extensions/matrix/src/matrix/actions/messages.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it, vi } from "vitest"; import { setMatrixRuntime } from "../../runtime.js"; import type { MatrixClient } from "../sdk.js"; import * as sendModule from "../send.js"; -import { editMatrixMessage, readMatrixMessages } from "./messages.js"; +import { editMatrixMessage, readMatrixMessages, sendMatrixMessage } from "./messages.js"; const MATRIX_ACTION_TEST_CFG = { channels: { @@ -177,6 +177,34 @@ function mockCallArg( } describe("matrix message actions", () => { + it("preserves workspace media access through the shared Matrix send helper", async () => { + const mediaAccess = { + localRoots: ["/tmp/openclaw-matrix-test"], + readFile: async () => Buffer.from("chart"), + workspaceDir: "/tmp/openclaw-matrix-test", + }; + const sendSpy = vi.spyOn(sendModule, "sendMessageMatrix").mockResolvedValue({ + messageId: "$sent", + roomId: "!room:example.org", + } as never); + + try { + await sendMatrixMessage("!room:example.org", "caption", { + cfg: MATRIX_ACTION_TEST_CFG, + mediaUrl: "chart.png", + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, + }); + + const options = sendSpy.mock.calls[0]?.[2]; + expect(options?.mediaUrl).toBe("chart.png"); + expect(options?.mediaAccess).toBe(mediaAccess); + expect(options?.mediaLocalRoots).toBe(mediaAccess.localRoots); + } finally { + sendSpy.mockRestore(); + } + }); + it("forwards timeoutMs to the shared Matrix edit helper", async () => { const editSpy = vi.spyOn(sendModule, "editMessageMatrix").mockResolvedValue("evt-edit"); diff --git a/extensions/matrix/src/matrix/actions/messages.ts b/extensions/matrix/src/matrix/actions/messages.ts index 4e47d0fb2dfb..0242721bbb68 100644 --- a/extensions/matrix/src/matrix/actions/messages.ts +++ b/extensions/matrix/src/matrix/actions/messages.ts @@ -88,6 +88,7 @@ export async function sendMatrixMessage( return await sendMessageMatrix(to, content, { cfg: opts.cfg, mediaUrl: opts.mediaUrl, + ...(opts.mediaAccess ? { mediaAccess: opts.mediaAccess } : {}), mediaLocalRoots: opts.mediaLocalRoots, replyToId: opts.replyToId, threadId: opts.threadId, diff --git a/extensions/matrix/src/matrix/actions/types.ts b/extensions/matrix/src/matrix/actions/types.ts index 714da2b29a9e..58d97337d2f5 100644 --- a/extensions/matrix/src/matrix/actions/types.ts +++ b/extensions/matrix/src/matrix/actions/types.ts @@ -1,4 +1,5 @@ // Matrix type declarations define plugin contracts. +import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime"; import type { CoreConfig } from "../../types.js"; import { MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js"; import type { MatrixClient, MessageEventContent } from "../sdk.js"; @@ -30,6 +31,7 @@ export type RoomPinnedEventsEventContent = { export type MatrixActionClientOpts = { client?: MatrixClient; cfg?: CoreConfig; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; timeoutMs?: number; accountId?: string | null; diff --git a/extensions/matrix/src/matrix/send.test.ts b/extensions/matrix/src/matrix/send.test.ts index 11a867478ec4..d82042b8a36b 100644 --- a/extensions/matrix/src/matrix/send.test.ts +++ b/extensions/matrix/src/matrix/send.test.ts @@ -510,15 +510,32 @@ describe("sendMessageMatrix media", () => { it("uploads media with url payloads", async () => { const { client, sendMessage, uploadContent } = makeClient(); + const mediaAccess = { + localRoots: ["/tmp/openclaw"], + workspaceDir: "/tmp/openclaw", + }; await sendMessageMatrix("room:!room:example", "caption", { client, cfg: {} as never, - mediaUrl: "file:///tmp/photo.png", + mediaUrl: "chart.png", + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, }); + expect(mockCallArg(loadOutboundMediaFromUrlMock, "loadOutboundMediaFromUrl", 0)).toBe( + "chart.png", + ); + const mediaOptions = requireRecord( + mockCallArg(loadOutboundMediaFromUrlMock, "loadOutboundMediaFromUrl", 1), + "outbound media options", + ); + expect(mediaOptions.mediaAccess).toBe(mediaAccess); + expect(mediaOptions.mediaLocalRoots).toBe(mediaAccess.localRoots); + const uploadArg = mockCallArg(uploadContent, "uploadContent", 0); expect(Buffer.isBuffer(uploadArg)).toBe(true); + expect(uploadArg).toEqual(Buffer.from("media")); const content = sentContent(sendMessage) as { url?: string; diff --git a/extensions/matrix/src/matrix/send/types.ts b/extensions/matrix/src/matrix/send/types.ts index bf0bc5441772..d32b78c8afb4 100644 --- a/extensions/matrix/src/matrix/send/types.ts +++ b/extensions/matrix/src/matrix/send/types.ts @@ -1,5 +1,6 @@ // Matrix type declarations define plugin contracts. import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound"; +import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime"; import type { CoreConfig } from "../../types.js"; import { MATRIX_ANNOTATION_RELATION_TYPE, MATRIX_REACTION_EVENT_TYPE } from "../reaction-common.js"; import type { @@ -90,10 +91,7 @@ export type MatrixSendOpts = { cfg: CoreConfig; client?: import("../sdk.js").MatrixClient; mediaUrl?: string; - mediaAccess?: { - localRoots?: readonly string[]; - readFile?: (filePath: string) => Promise; - }; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; accountId?: string; diff --git a/extensions/matrix/src/outbound.test.ts b/extensions/matrix/src/outbound.test.ts index 319e6618a2e1..55d0c1c1a3f3 100644 --- a/extensions/matrix/src/outbound.test.ts +++ b/extensions/matrix/src/outbound.test.ts @@ -99,13 +99,18 @@ describe("matrixOutbound cfg threading", () => { }, }, } as OpenClawConfig; + const mediaAccess = { + localRoots: ["/tmp/openclaw"], + workspaceDir: "/tmp/openclaw", + }; await matrixOutbound.sendMedia!({ cfg, to: "room:!room:example", text: "caption", - mediaUrl: "file:///tmp/cat.png", - mediaLocalRoots: ["/tmp/openclaw"], + mediaUrl: "chart.png", + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, accountId: "default", audioAsVoice: true, }); @@ -115,7 +120,8 @@ describe("matrixOutbound cfg threading", () => { expect(call[1]).toBe("caption"); const options = mockOptions(mocks.sendMessageMatrix, "sendMessageMatrix"); expect(options.cfg).toBe(cfg); - expect(options.mediaUrl).toBe("file:///tmp/cat.png"); + expect(options.mediaUrl).toBe("chart.png"); + expect(options.mediaAccess).toBe(mediaAccess); expect(options.mediaLocalRoots).toEqual(["/tmp/openclaw"]); expect(options.audioAsVoice).toBe(true); }); diff --git a/extensions/matrix/src/outbound.ts b/extensions/matrix/src/outbound.ts index a1efb2a4dd6b..64f50e6618f5 100644 --- a/extensions/matrix/src/outbound.ts +++ b/extensions/matrix/src/outbound.ts @@ -245,6 +245,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { mediaUrl, mediaLocalRoots, mediaReadFile, + mediaAccess, deps, replyToId, threadId, @@ -265,6 +266,7 @@ export const matrixOutbound: ChannelOutboundAdapter = { mediaUrl, mediaLocalRoots, mediaReadFile, + mediaAccess, replyToId: replyToId ?? undefined, threadId: resolvedThreadId, accountId: accountId ?? undefined, diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index bc0fc5da3504..126bd9e934ec 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -444,25 +444,32 @@ describe("handleMatrixAction pollVote", () => { it("accepts media-only message sends", async () => { const cfg = { channels: { matrix: { actions: { messages: true } } } } as CoreConfig; + const mediaAccess = { + localRoots: ["/tmp/openclaw-matrix-test"], + readFile: async () => Buffer.from("chart"), + workspaceDir: "/tmp/openclaw-matrix-test", + }; await handleMatrixAction( { action: "sendMessage", accountId: "ops", to: "room:!room:example", - mediaUrl: "file:///tmp/photo.png", + mediaUrl: "chart.png", }, cfg, - { mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }, + { mediaAccess, mediaLocalRoots: mediaAccess.localRoots }, ); expect(mocks.sendMatrixMessage).toHaveBeenCalledWith("room:!room:example", undefined, { cfg, accountId: "ops", - mediaUrl: "file:///tmp/photo.png", + mediaUrl: "chart.png", + mediaAccess, mediaLocalRoots: ["/tmp/openclaw-matrix-test"], replyToId: undefined, threadId: undefined, }); + expect(mocks.sendMatrixMessage.mock.lastCall?.[2]?.mediaAccess).toBe(mediaAccess); }); it("accepts shared media aliases and voice-send flags", async () => { diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index a5584c944e1a..0eff85b4279a 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -33,7 +33,7 @@ import { voteMatrixPoll, verifyMatrixRecoveryKey, } from "./matrix/actions.js"; -import type { MatrixMessageSummary } from "./matrix/actions/types.js"; +import type { MatrixActionClientOpts, MatrixMessageSummary } from "./matrix/actions/types.js"; import { withAuthorizedMatrixReadTarget, type MatrixReadContext } from "./matrix/read-policy.js"; import type { MatrixClient } from "./matrix/sdk.js"; import { reactMatrixMessage } from "./matrix/send.js"; @@ -164,7 +164,9 @@ function readPositiveIntegerArrayParam(params: Record, key: str export async function handleMatrixAction( params: Record, cfg: CoreConfig, - opts: { mediaLocalRoots?: readonly string[]; readContext?: MatrixReadContext } = {}, + opts: Pick & { + readContext?: MatrixReadContext; + } = {}, ): Promise> { const action = readStringParam(params, "action", { required: true }); const accountId = readStringParam(params, "accountId") ?? undefined; @@ -283,6 +285,7 @@ export async function handleMatrixAction( : undefined; const result = await sendMatrixMessage(to, content, { mediaUrl: mediaUrl ?? undefined, + ...(opts.mediaAccess ? { mediaAccess: opts.mediaAccess } : {}), mediaLocalRoots: opts.mediaLocalRoots, replyToId: replyToId ?? undefined, threadId: threadId ?? undefined, diff --git a/extensions/telegram/src/action-runtime.test.ts b/extensions/telegram/src/action-runtime.test.ts index e207863054a7..fab3c94b704a 100644 --- a/extensions/telegram/src/action-runtime.test.ts +++ b/extensions/telegram/src/action-runtime.test.ts @@ -74,6 +74,7 @@ const sendDurableMessageBatch = vi.fn( mediaAccess?: { localRoots?: readonly string[]; readFile?: (filePath: string) => Promise; + workspaceDir?: string; }; }) => { const payload = params.payloads[0] ?? {}; @@ -111,6 +112,7 @@ const sendDurableMessageBatch = vi.fn( asVideoNote: payload.videoAsNote, silent: params.silent, forceDocument: params.forceDocument, + ...(params.mediaAccess ? { mediaAccess: params.mediaAccess } : {}), mediaLocalRoots: params.mediaAccess?.localRoots, mediaReadFile: params.mediaAccess?.readFile, }; @@ -1392,22 +1394,64 @@ describe("handleTelegramAction", () => { expect(options.silent).toBe(true); }); - it("forwards trusted mediaLocalRoots into sendMessageTelegram", async () => { + it("preserves host-owned workspace media access and legacy roots", async () => { + const mediaReadFile = vi.fn(async (_filePath: string) => Buffer.from("chart")); + const mediaAccess = { + localRoots: ["/tmp/agent-root"], + readFile: mediaReadFile, + workspaceDir: "/tmp/agent-root", + }; await handleTelegramAction( { action: "sendMessage", to: "@testchannel", content: "Hello with local media", + mediaUrl: "chart.png", + mediaAccess: { localRoots: ["/tmp/model-root"], workspaceDir: "/tmp/model-root" }, }, telegramConfig(), - { mediaLocalRoots: ["/tmp/agent-root"] }, + { + mediaAccess, + mediaLocalRoots: ["/tmp/conflicting-root"], + mediaReadFile: vi.fn(async (_filePath: string) => Buffer.from("untrusted")), + }, ); + const durableOptions = requireRecord( + mockCall(sendDurableMessageBatch, 0, "workspace media access")[0], + "workspace media access batch", + ); + expect(durableOptions.mediaAccess).toBe(mediaAccess); + expect(durableOptions.session).toBeUndefined(); const call = mockCall(sendMessageTelegram, 0, "local media roots"); expect(call[0]).toBe("@testchannel"); expect(call[1]).toBe("Hello with local media"); - expect(requireRecord(call[2], "local media roots options").mediaLocalRoots).toEqual([ - "/tmp/agent-root", - ]); + const sendOptions = requireRecord(call[2], "local media roots options"); + expect(sendOptions.mediaUrl).toBe("chart.png"); + expect(sendOptions.mediaAccess).toBe(mediaAccess); + expect(sendOptions.mediaLocalRoots).toEqual(["/tmp/agent-root"]); + expect(sendOptions.mediaReadFile).toBe(mediaReadFile); + + await handleTelegramAction( + { + action: "sendMessage", + to: "@testchannel", + content: "Hello with legacy media roots", + mediaUrl: "legacy-chart.png", + }, + telegramConfig(), + { mediaLocalRoots: ["/tmp/legacy-root"] }, + ); + const legacyDurableOptions = requireRecord( + mockCall(sendDurableMessageBatch, 1, "legacy media access")[0], + "legacy media access batch", + ); + expect(legacyDurableOptions.mediaAccess).toEqual({ localRoots: ["/tmp/legacy-root"] }); + expect( + requireRecord( + mockCall(sendMessageTelegram, 1, "legacy media roots")[2], + "legacy media options", + ).mediaLocalRoots, + ).toEqual(["/tmp/legacy-root"]); }); it("forwards gateway client scopes into Telegram send target resolution", async () => { diff --git a/extensions/telegram/src/action-runtime.ts b/extensions/telegram/src/action-runtime.ts index 7c38358291f4..5c4567cbf598 100644 --- a/extensions/telegram/src/action-runtime.ts +++ b/extensions/telegram/src/action-runtime.ts @@ -346,6 +346,7 @@ export async function handleTelegramAction( params: Record, cfg: OpenClawConfig, options?: { + mediaAccess?: ChannelMessageActionContext["mediaAccess"]; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; sessionKey?: string | null; @@ -562,12 +563,13 @@ export async function handleTelegramAction( quoteText, }); const mediaAccess = - options?.mediaLocalRoots || options?.mediaReadFile + options?.mediaAccess ?? + (options?.mediaLocalRoots || options?.mediaReadFile ? { ...(options.mediaLocalRoots ? { localRoots: options.mediaLocalRoots } : {}), ...(options.mediaReadFile ? { readFile: options.mediaReadFile } : {}), } - : undefined; + : undefined); const outboundSession = buildOutboundSessionContext({ cfg, sessionKey: options?.sessionKey, diff --git a/extensions/telegram/src/channel-actions.test.ts b/extensions/telegram/src/channel-actions.test.ts index 7f859262c1cf..75f0f26463e4 100644 --- a/extensions/telegram/src/channel-actions.test.ts +++ b/extensions/telegram/src/channel-actions.test.ts @@ -44,6 +44,10 @@ describe("telegramMessageActions", () => { }); it("forwards only host-owned mutation context to the runtime", async () => { + const mediaAccess = { + localRoots: ["/tmp/agent-root"], + workspaceDir: "/tmp/agent-root", + }; await telegramMessageActions.handleAction?.({ channel: "telegram", action: "delete", @@ -51,9 +55,12 @@ describe("telegramMessageActions", () => { messageId: "9001", to: "-1001:topic:77", conversationReadOrigin: "direct-operator", + mediaAccess: { localRoots: ["/tmp/forged-root"], workspaceDir: "/tmp/forged-root" }, }, cfg: { channels: { telegram: { botToken: "tok" } } } as OpenClawConfig, accountId: "work", + mediaAccess, + mediaLocalRoots: ["/tmp/conflicting-root"], requesterAccountId: "work", conversationReadOrigin: "delegated", toolContext: { @@ -68,6 +75,7 @@ describe("telegramMessageActions", () => { expect.anything(), expect.objectContaining({ conversationReadOrigin: "delegated", + mediaAccess, requesterAccountId: "work", toolContext: expect.objectContaining({ currentMessageId: "9001" }), }), @@ -76,6 +84,8 @@ describe("telegramMessageActions", () => { action: "deleteMessage", messageId: "9001", }); + expect(handleTelegramActionMock.mock.calls[0]?.[0]).not.toHaveProperty("mediaAccess"); + expect(handleTelegramActionMock.mock.calls[0]?.[2]?.mediaAccess).toBe(mediaAccess); }); it("allows interactive-only sends", async () => { diff --git a/extensions/telegram/src/channel-actions.ts b/extensions/telegram/src/channel-actions.ts index f5324211c149..3165c237ed71 100644 --- a/extensions/telegram/src/channel-actions.ts +++ b/extensions/telegram/src/channel-actions.ts @@ -248,6 +248,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { params, cfg, accountId, + mediaAccess, mediaLocalRoots, mediaReadFile, sessionKey, @@ -263,6 +264,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { } const { conversationReadOrigin: _modelConversationReadOrigin, + mediaAccess: _modelMediaAccess, requesterAccountId: _modelRequesterAccountId, toolContext: _modelToolContext, ...runtimeParams @@ -282,6 +284,7 @@ export const telegramMessageActions: ChannelMessageActionAdapter = { }, cfg, { + ...(mediaAccess !== undefined ? { mediaAccess } : {}), mediaLocalRoots, mediaReadFile, sessionKey, diff --git a/extensions/telegram/src/outbound-adapter.test.ts b/extensions/telegram/src/outbound-adapter.test.ts index 657e4d052075..e8558cd931f9 100644 --- a/extensions/telegram/src/outbound-adapter.test.ts +++ b/extensions/telegram/src/outbound-adapter.test.ts @@ -72,15 +72,20 @@ describe("telegramOutbound", () => { sendLocationTelegramMock.mockReset(); }); - it("forwards mediaLocalRoots in direct media sends", async () => { + it("forwards workspace-scoped media access in direct media sends", async () => { sendMessageTelegramMock.mockResolvedValueOnce({ messageId: "tg-media" }); + const mediaReadFile = vi.fn(async (_filePath: string) => Buffer.from("chart")); + const workspaceDir = "/tmp/agent-root"; + const mediaAccess = { localRoots: [workspaceDir], readFile: mediaReadFile, workspaceDir }; const result = await telegramOutbound.sendMedia!({ cfg: {} as never, to: "12345", text: "hello", - mediaUrl: "/tmp/image.png", - mediaLocalRoots: ["/tmp/agent-root"], + mediaUrl: "chart.png", + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, + mediaReadFile, accountId: "ops", replyToId: "900", threadId: "12", @@ -95,11 +100,15 @@ describe("telegramOutbound", () => { accountId: "ops", silent: undefined, gatewayClientScopes: undefined, - mediaUrl: "/tmp/image.png", + mediaUrl: "chart.png", + mediaAccess, mediaLocalRoots: ["/tmp/agent-root"], - mediaReadFile: undefined, + mediaReadFile, forceDocument: false, }); + expect(lastCallOptions(sendMessageTelegramMock, "12345", "hello").mediaAccess).toBe( + mediaAccess, + ); expect(result).toEqual({ channel: "telegram", messageId: "tg-media" }); }); @@ -107,6 +116,7 @@ describe("telegramOutbound", () => { sendMessageTelegramMock .mockResolvedValueOnce({ messageId: "tg-1", chatId: "12345" }) .mockResolvedValueOnce({ messageId: "tg-2", chatId: "12345" }); + const mediaAccess = { localRoots: ["/tmp/media"], workspaceDir: "/tmp/media" }; const result = await telegramOutbound.sendPayload!({ cfg: {} as never, @@ -114,7 +124,7 @@ describe("telegramOutbound", () => { text: "", payload: { text: "Approval required", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + mediaUrls: ["chart.png", "chart-2.png"], channelData: { telegram: { quoteText: "quoted", @@ -123,20 +133,22 @@ describe("telegramOutbound", () => { transcriptMessageId: "assistant-media", deliverySignature: resolveTelegramPromptContextDeliverySignature({ text: "Approval required", - mediaUrls: ["https://example.com/1.jpg", "https://example.com/2.jpg"], + mediaUrls: ["chart.png", "chart-2.png"], }), }, }, }, }, - mediaLocalRoots: ["/tmp/media"], + mediaAccess, + mediaLocalRoots: mediaAccess.localRoots, accountId: "ops", deps: { sendTelegram: sendMessageTelegramMock }, }); expect(sendMessageTelegramMock).toHaveBeenCalledTimes(2); const firstOptions = callOptionsAt(sendMessageTelegramMock, 0, "12345", "Approval required"); - expect(firstOptions.mediaUrl).toBe("https://example.com/1.jpg"); + expect(firstOptions.mediaUrl).toBe("chart.png"); + expect(firstOptions.mediaAccess).toBe(mediaAccess); expect(firstOptions.mediaLocalRoots).toEqual(["/tmp/media"]); expect(firstOptions.quoteText).toBe("quoted"); expect(firstOptions.buttons).toEqual([ @@ -153,7 +165,8 @@ describe("telegramOutbound", () => { finalPart: false, }); const secondOptions = callOptionsAt(sendMessageTelegramMock, 1, "12345", ""); - expect(secondOptions.mediaUrl).toBe("https://example.com/2.jpg"); + expect(secondOptions.mediaUrl).toBe("chart-2.png"); + expect(secondOptions.mediaAccess).toBe(mediaAccess); expect(secondOptions.mediaLocalRoots).toEqual(["/tmp/media"]); expect(secondOptions.quoteText).toBe("quoted"); expect(secondOptions.buttons).toBeUndefined(); diff --git a/extensions/telegram/src/outbound-adapter.ts b/extensions/telegram/src/outbound-adapter.ts index 368e358f9e74..060b86cf9557 100644 --- a/extensions/telegram/src/outbound-adapter.ts +++ b/extensions/telegram/src/outbound-adapter.ts @@ -521,6 +521,7 @@ export function createTelegramOutboundAdapter( return await send(outboundTo, params.text, { ...baseOpts, mediaUrl: params.mediaUrl, + ...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}), mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, forceDocument: params.forceDocument ?? false, @@ -541,6 +542,7 @@ export function createTelegramOutboundAdapter( payload: params.payload, baseOpts: { ...baseOpts, + ...(params.mediaAccess !== undefined ? { mediaAccess: params.mediaAccess } : {}), mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, forceDocument: params.forceDocument ?? false, diff --git a/extensions/telegram/src/send-message-types.ts b/extensions/telegram/src/send-message-types.ts index f58b355404f7..d9e90a547540 100644 --- a/extensions/telegram/src/send-message-types.ts +++ b/extensions/telegram/src/send-message-types.ts @@ -1,5 +1,6 @@ import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound"; import type { MarkdownTableMode, ReplyToMode } from "openclaw/plugin-sdk/config-contracts"; +import type { OutboundMediaAccess } from "openclaw/plugin-sdk/media-runtime"; import type { RetryConfig } from "openclaw/plugin-sdk/retry-runtime"; import type { TelegramInlineButtons } from "./button-types.js"; import type { createTelegramPromptContextProjectionCursor } from "./prompt-context-projection.js"; @@ -12,6 +13,7 @@ export type TelegramSendOpts = { accountId?: string; verbose?: boolean; mediaUrl?: string; + mediaAccess?: OutboundMediaAccess; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; gatewayClientScopes?: readonly string[]; diff --git a/extensions/telegram/src/send-message.ts b/extensions/telegram/src/send-message.ts index 42a49cf733b2..596127b441e8 100644 --- a/extensions/telegram/src/send-message.ts +++ b/extensions/telegram/src/send-message.ts @@ -223,6 +223,7 @@ async function sendMessageTelegramWithContext( mediaUrl, buildOutboundMediaLoadOptions({ maxBytes: mediaMaxBytes, + mediaAccess: opts.mediaAccess, mediaLocalRoots: opts.mediaLocalRoots, mediaReadFile: opts.mediaReadFile, optimizeImages: opts.forceDocument ? false : undefined, diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index f569324bcc6f..d32b19ecab15 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -3803,6 +3803,10 @@ describe("sendMessageTelegram", () => { it("defaults outbound media uploads to 100MB", async () => { const chatId = "123"; + const mediaAccess = { + localRoots: ["/tmp/agent-root"], + workspaceDir: "/tmp/agent-root", + }; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 60, chat: { id: chatId }, @@ -3821,15 +3825,19 @@ describe("sendMessageTelegram", () => { cfg: TELEGRAM_TEST_CFG, token: "tok", api, - mediaUrl: "https://example.com/photo.jpg", + mediaUrl: "chart.png", + mediaAccess, }); const [mediaUrl, options] = requireMockCall( firstMockCall(loadWebMedia, "loadWebMedia call"), "load web media call", ); - expect(mediaUrl).toBe("https://example.com/photo.jpg"); - expect(requireRecord(options, "load web media options").maxBytes).toBe(100 * 1024 * 1024); + expect(mediaUrl).toBe("chart.png"); + const loadOptions = requireRecord(options, "load web media options"); + expect(loadOptions.maxBytes).toBe(100 * 1024 * 1024); + expect(loadOptions.localRoots).toEqual(mediaAccess.localRoots); + expect(loadOptions.workspaceDir).toBe(mediaAccess.workspaceDir); }); it("uses configured telegram mediaMaxMb for outbound uploads", async () => { diff --git a/qa/scenarios/channels/channel-workspace-relative-media.yaml b/qa/scenarios/channels/channel-workspace-relative-media.yaml new file mode 100644 index 000000000000..a74a383f6b85 --- /dev/null +++ b/qa/scenarios/channels/channel-workspace-relative-media.yaml @@ -0,0 +1,110 @@ +title: Channel sends preserve workspace-relative media authority + +scenario: + id: channel-workspace-relative-media + surface: channels + coverage: + primary: + - channels.outbound-direct-text-media-sends + secondary: + - channels.media-roots + objective: Deliver a workspace-relative image through direct Gateway and plugin-action sends. + execution: + kind: flow + channels: + - matrix + - telegram + timeoutMs: 90000 + +flow: + steps: + - name: delivers workspace-relative images through direct and plugin-action sends + actions: + - call: waitForGatewayHealthy + args: [{ ref: env }, 60000] + - call: waitForTransportReady + args: [{ ref: env }, 60000] + - set: marker + value: + expr: "`QA-WORKSPACE-RELATIVE-MEDIA-${randomUUID()}`" + - set: filename + value: + expr: "`${marker}.png`" + - set: fixturePath + value: + expr: "path.join(env.gateway.workspaceDir, filename)" + - set: delivery + value: + expr: "transport.buildAgentDelivery({ target: 'group:main' })" + - set: outboundStartIndex + value: + expr: "state.getSnapshot().messages.filter((message) => message.direction === 'outbound').length" + - set: matrixRecordIndex + value: + expr: "transport.id === 'matrix' ? scenarioContext.faultProxyObserver.records().length : 0" + - try: + actions: + - call: fs.writeFile + args: + - ref: fixturePath + - expr: "Buffer.from(imageUnderstandingValidPngBase64, 'base64')" + - call: env.gateway.call + saveAs: sent + args: + - send + - channel: { expr: delivery.channel } + to: { expr: delivery.to } + accountId: { expr: transport.accountId } + agentId: qa + message: { ref: marker } + mediaUrl: { expr: "`./${filename}`" } + idempotencyKey: { expr: randomUUID() } + - timeoutMs: 60000 + - assert: + expr: "Boolean(sent?.messageId) && sent.channel === delivery.channel" + message: Gateway did not return a successful channel delivery receipt + - waitForOutbound: + textIncludes: { ref: marker } + sinceIndex: { ref: outboundStartIndex } + timeoutMs: 60000 + saveAs: outbound + - assert: + expr: "state.getSnapshot().messages.filter((message) => message.accountId === transport.accountId && message.direction === 'outbound' && message.text === marker).length === 1" + message: workspace-relative image caption was not delivered exactly once + - call: env.gateway.call + saveAs: actionSent + args: + - message.action + - channel: { expr: delivery.channel } + action: send + accountId: { expr: transport.accountId } + agentId: qa + idempotencyKey: { expr: randomUUID() } + params: + to: { expr: delivery.to } + message: { expr: "`${marker}-ACTION`" } + media: { expr: "`./${filename}`" } + - timeoutMs: 60000 + - assert: + expr: "actionSent?.ok === true && Boolean(actionSent.messageId ?? actionSent.result?.messageId)" + message: channel plugin action did not return a successful delivery receipt + - waitForOutbound: + textIncludes: { expr: "`${marker}-ACTION`" } + sinceIndex: { ref: outboundStartIndex } + timeoutMs: 60000 + saveAs: actionOutbound + - assert: + expr: "state.getSnapshot().messages.filter((message) => message.accountId === transport.accountId && message.direction === 'outbound' && message.text === `${marker}-ACTION`).length === 1" + message: plugin-action image caption was not delivered exactly once + - if: + expr: "transport.id === 'matrix'" + then: + - assert: + expr: "[marker, `${marker}-ACTION`].every((caption) => scenarioContext.observedEvents.some((event) => event.roomId === delivery.to && event.sender === scenarioContext.sutUserId && event.body === caption && event.msgtype === 'm.image')) && scenarioContext.faultProxyObserver.records().slice(matrixRecordIndex).filter((record) => record.request.method === 'POST' && record.request.route.includes('/media/') && record.request.route.endsWith('/upload') && record.response.status >= 200 && record.response.status < 300).length >= 2" + message: Matrix did not upload and deliver both native image events + finally: + - call: fs.rm + args: + - ref: fixturePath + - force: true + detailsExpr: "JSON.stringify({ channel: delivery.channel, relativeMediaUrl: `./${filename}`, messageId: sent.messageId, outboundId: outbound.id, actionMessageId: actionSent.messageId ?? actionSent.result?.messageId, actionOutboundId: actionOutbound.id, nativeImagesVerified: transport.id === 'matrix' })" diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index d8befe7f5024..d699b5a5cf2c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -140,6 +140,7 @@ vi.mock("../../agents/agent-scope.js", () => ({ config?: unknown; agentId?: string; }) => resolveAgentIdFromSessionKeyForTests({ sessionKey }), + resolveAgentConfig: () => undefined, resolveDefaultAgentId: () => "main", resolveAgentWorkspaceDir: () => TEST_AGENT_WORKSPACE, })); @@ -3898,9 +3899,87 @@ describe("gateway send mirroring", () => { expect(firstRespondCall(respond)[0]).toBe(true); const actionCall = lastDispatchChannelMessageActionCall(); expect(actionCall?.mediaLocalRoots).toContain(TEST_AGENT_WORKSPACE); + expect(actionCall).not.toHaveProperty("mediaAccess"); + expect(actionCall).not.toHaveProperty("mediaReadFile"); expect(actionCall?.gatewayClientScopes).toEqual(["operator.write"]); }); + it("passes reader-free workspace media access only to gateway send actions", async () => { + registerMessageActionPlugin({ registrySuffix: "message-action-workspace-media-access" }); + + const { respond } = await runMessageActionRequest( + { + channel: "telegram", + action: "send", + params: { to: "123", message: "chart", mediaUrl: "chart.png" }, + agentId: "work", + idempotencyKey: "idem-message-action-workspace-media-access", + }, + { connect: { scopes: ["operator.write"] } }, + ); + + expect(firstRespondCall(respond)[0]).toBe(true); + const actionCall = lastDispatchChannelMessageActionCall(); + expect(actionCall?.mediaAccess).toMatchObject({ + localRoots: expect.arrayContaining([TEST_AGENT_WORKSPACE]), + workspaceDir: TEST_AGENT_WORKSPACE, + }); + expect(actionCall?.mediaAccess.localRoots).toBe(actionCall?.mediaLocalRoots); + expect(actionCall?.mediaAccess).not.toHaveProperty("readFile"); + expect(actionCall).not.toHaveProperty("mediaReadFile"); + }); + + it("uses signed sender group policy without granting gateway send host reads", async () => { + const plugin = registerMessageActionPlugin({ + chatType: "group", + registrySuffix: "message-action-signed-sender-media-policy", + }); + const resolveToolPolicy = vi.fn(({ senderId }: { senderId?: string | null }) => + senderId === "blocked-sender" ? { deny: ["read"] } : undefined, + ); + plugin.groups = { resolveToolPolicy }; + const sessionKey = "agent:work:telegram:group:ops"; + + const { respond } = await runMessageActionRequest( + { + channel: "telegram", + action: "send", + params: { to: "ops", message: "chart", mediaUrl: "chart.png" }, + requesterSenderId: "forged-allowed-sender", + sessionKey, + agentId: "work", + idempotencyKey: "idem-message-action-signed-sender-media-policy", + }, + { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "work", + sessionKey, + messageActionContext: { + expiresAtMs: Date.now() + 60_000, + requesterSenderId: "blocked-sender", + }, + }, + }, + }, + { + ...makeContext(), + getRuntimeConfig: () => ({ tools: { allow: ["read"] } }), + } as GatewayRequestContext, + ); + + expect(firstRespondCall(respond)[0]).toBe(true); + expect(resolveToolPolicy).toHaveBeenCalledWith( + expect.objectContaining({ groupId: "ops", senderId: "blocked-sender" }), + ); + const actionCall = lastDispatchChannelMessageActionCall(); + expect(actionCall?.requesterSenderId).toBe("blocked-sender"); + expect(actionCall?.mediaAccess.workspaceDir).toBe(TEST_AGENT_WORKSPACE); + expect(actionCall?.mediaAccess).not.toHaveProperty("readFile"); + expect(actionCall).not.toHaveProperty("mediaReadFile"); + }); + it("materializes buffer-only message.action sends on the gateway before plugin dispatch", async () => { registerMessageActionPlugin({ registrySuffix: "message-action-buffer-materialize" }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index 0670f4a3ca21..7e043146e6f2 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -55,6 +55,7 @@ import { import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.js"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; +import { resolveAgentScopedOutboundMediaAccess } from "../../media/read-capability.js"; import { KeyedAsyncQueue } from "../../plugin-sdk/keyed-async-queue.js"; import { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; import { normalizePollInput } from "../../polls.js"; @@ -956,6 +957,29 @@ export const sendHandlers: GatewayRequestHandlers = { if (accountId) { request.params.accountId = accountId; } + const resolvedMediaAccess = + request.action === "send" + ? resolveAgentScopedOutboundMediaAccess({ + cfg, + agentId, + sessionKey, + messageProvider: sessionKey ? undefined : channel, + accountId: sessionKey + ? (trustedContext.requesterAccountId ?? accountId) + : accountId, + requesterSenderId: trustedContext.requesterSenderId, + }) + : undefined; + // Gateway identities omit trusted sender aliases; expose roots/workspace + // only so a host reader cannot bypass alias-based group read policy. + const mediaAccess = resolvedMediaAccess + ? { + localRoots: resolvedMediaAccess.localRoots, + ...(resolvedMediaAccess.workspaceDir + ? { workspaceDir: resolvedMediaAccess.workspaceDir } + : {}), + } + : undefined; if (request.action === "send") { await hydrateAttachmentParamsForAction({ cfg, @@ -1006,7 +1030,9 @@ export const sendHandlers: GatewayRequestHandlers = { sessionId: normalizeOptionalString(request.sessionId) ?? undefined, inboundEventKind: request.inboundTurnKind, agentId, - mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId), + ...(mediaAccess + ? { mediaAccess, mediaLocalRoots: mediaAccess.localRoots } + : { mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId) }), toolContext: trustedContext.toolContext, dryRun: false, gatewayClientScopes,