fix(channels): preserve workspace media access in gateway sends (#117711)

This commit is contained in:
Peter Steinberger
2026-08-01 18:09:17 -07:00
committed by GitHub
parent 120fa5d11c
commit 28e681c92f
23 changed files with 410 additions and 38 deletions
@@ -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"] });
});
+1
View File
@@ -176,6 +176,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = {
},
cfg as CoreConfig,
{
...(action === "send" && ctx.mediaAccess ? { mediaAccess: ctx.mediaAccess } : {}),
mediaLocalRoots,
readContext: {
accountId,
@@ -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");
@@ -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,
@@ -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;
+18 -1
View File
@@ -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;
+2 -4
View File
@@ -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<Buffer>;
};
mediaAccess?: OutboundMediaAccess;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
accountId?: string;
+9 -3
View File
@@ -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);
});
+2
View File
@@ -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,
+10 -3
View File
@@ -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 () => {
+5 -2
View File
@@ -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<string, unknown>, key: str
export async function handleMatrixAction(
params: Record<string, unknown>,
cfg: CoreConfig,
opts: { mediaLocalRoots?: readonly string[]; readContext?: MatrixReadContext } = {},
opts: Pick<MatrixActionClientOpts, "mediaAccess" | "mediaLocalRoots"> & {
readContext?: MatrixReadContext;
} = {},
): Promise<AgentToolResult<unknown>> {
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,
+49 -5
View File
@@ -74,6 +74,7 @@ const sendDurableMessageBatch = vi.fn(
mediaAccess?: {
localRoots?: readonly string[];
readFile?: (filePath: string) => Promise<Buffer>;
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 () => {
+4 -2
View File
@@ -346,6 +346,7 @@ export async function handleTelegramAction(
params: Record<string, unknown>,
cfg: OpenClawConfig,
options?: {
mediaAccess?: ChannelMessageActionContext["mediaAccess"];
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
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,
@@ -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 () => {
@@ -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,
@@ -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();
@@ -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,
@@ -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<Buffer>;
gatewayClientScopes?: readonly string[];
+1
View File
@@ -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,
+11 -3
View File
@@ -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 () => {
@@ -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' })"
+79
View File
@@ -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" });
+27 -1
View File
@@ -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,