diff --git a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift index 71da05462ca5..c87c50590d5f 100644 --- a/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift +++ b/apps/shared/OpenClawKit/Sources/OpenClawProtocol/GatewayModels.swift @@ -870,6 +870,7 @@ public struct MessageActionParams: Codable, Sendable { public let inboundturnkind: String? public let agentid: String? public let toolcontext: [String: AnyCodable]? + public let conversationreadorigin: String? public let idempotencykey: String public init( @@ -885,6 +886,7 @@ public struct MessageActionParams: Codable, Sendable { inboundturnkind: String? = nil, agentid: String? = nil, toolcontext: [String: AnyCodable]? = nil, + conversationreadorigin: String? = nil, idempotencykey: String) { self.channel = channel @@ -899,6 +901,7 @@ public struct MessageActionParams: Codable, Sendable { self.inboundturnkind = inboundturnkind self.agentid = agentid self.toolcontext = toolcontext + self.conversationreadorigin = conversationreadorigin self.idempotencykey = idempotencykey } @@ -915,6 +918,7 @@ public struct MessageActionParams: Codable, Sendable { case inboundturnkind = "inboundTurnKind" case agentid = "agentId" case toolcontext = "toolContext" + case conversationreadorigin = "conversationReadOrigin" case idempotencykey = "idempotencyKey" } } @@ -6746,6 +6750,7 @@ public struct ToolsInvokeParams: Codable, Sendable { public let agentid: String? public let confirm: Bool? public let idempotencykey: String? + public let conversationreadorigin: String? public init( name: String, @@ -6753,7 +6758,8 @@ public struct ToolsInvokeParams: Codable, Sendable { sessionkey: String? = nil, agentid: String? = nil, confirm: Bool? = nil, - idempotencykey: String? = nil) + idempotencykey: String? = nil, + conversationreadorigin: String? = nil) { self.name = name self.args = args @@ -6761,6 +6767,7 @@ public struct ToolsInvokeParams: Codable, Sendable { self.agentid = agentid self.confirm = confirm self.idempotencykey = idempotencykey + self.conversationreadorigin = conversationreadorigin } private enum CodingKeys: String, CodingKey { @@ -6770,6 +6777,7 @@ public struct ToolsInvokeParams: Codable, Sendable { case agentid = "agentId" case confirm case idempotencykey = "idempotencyKey" + case conversationreadorigin = "conversationReadOrigin" } } diff --git a/docs/channels/googlechat.md b/docs/channels/googlechat.md index 2945643c57d7..9c896dd0ca23 100644 --- a/docs/channels/googlechat.md +++ b/docs/channels/googlechat.md @@ -179,7 +179,6 @@ Use these identifiers for delivery and allowlists: systemPrompt: "Short answers only.", }, }, - actions: { reactions: true }, typingIndicator: "message", mediaMaxMb: 20, }, @@ -193,9 +192,9 @@ Notes: - Default webhook path is `/googlechat` when `webhookPath` is unset; `webhookUrl` can supply the path instead. - Group keys must be stable space ids (`spaces/`). Display-name keys are deprecated and logged as such. - `dangerouslyAllowNameMatching` re-enables mutable email principal matching for allowlists (break-glass compatibility mode); doctor warns about email entries. -- Reactions are enabled by default and exposed through the `reactions` tool and `channels action`; disable with `actions.reactions: false`. +- Google Chat reaction actions are not exposed. The plugin uses service-account authentication, while Google Chat reaction endpoints require user authentication. Existing `actions.reactions` config is accepted for compatibility but has no effect. - Native approval cards use Google Chat `cardsV2` button clicks, not reaction events. Approvers come from `dm.allowFrom` or `defaultTo` and must be stable numeric `users/` values. -- Message actions expose `send` for text and `upload-file` for explicit attachment sends. `upload-file` accepts `media` / `filePath` / `path` plus optional `message`, `filename`, and thread targeting (`threadId` / `replyTo`). +- Message actions expose text `send` only. Google Chat attachment upload requires user authentication, while this plugin uses service-account authentication, so outbound file upload is not exposed. - `typingIndicator`: `message` (default) posts a `_ is typing..._` placeholder and edits it into the first reply; `none` disables it; `reaction` requires user OAuth and currently falls back to `message` with a logged error under service-account auth. - Inbound attachments (first attachment per message) are downloaded through the Chat API into the media pipeline, capped by `mediaMaxMb` (default 20). - Bot-authored messages are ignored by default. With `allowBots: true`, accepted bot messages use shared [bot loop protection](/channels/bot-loop-protection): configure `channels.defaults.botLoopProtection`, then override with `channels.googlechat.botLoopProtection` or `channels.googlechat.groups..botLoopProtection`. @@ -257,5 +256,4 @@ openclaw channels status - [Gateway configuration](/gateway/configuration) - [Groups](/channels/groups) — group chat behavior and mention gating - [Pairing](/channels/pairing) — DM authentication and pairing flow -- [Reactions](/tools/reactions) - [Security](/gateway/security) — access model and hardening diff --git a/docs/channels/msteams.md b/docs/channels/msteams.md index 4b676b08cde4..f25d231f95c6 100644 --- a/docs/channels/msteams.md +++ b/docs/channels/msteams.md @@ -451,14 +451,17 @@ These auth-related config keys can be set via environment variables instead of ` ## Member info action -OpenClaw exposes a Graph-backed `member-info` message action for Microsoft Teams so agents and automations can resolve channel member details (display name, email, job title, UPN, office location) directly from Microsoft Graph. +OpenClaw exposes a Graph-backed `member-info` action for Microsoft Teams so agents and automations can resolve verified roster details for a configured conversation. Requirements: -- `Member.Read.Group` RSC permission (already in the recommended manifest). -- For cross-team lookups: `User.Read.All` Graph Application permission with admin consent. +- `ChannelSettings.Read.Group` and `TeamMember.Read.Group` RSC permissions (already in the recommended manifest). -The action runs whenever Graph credentials are configured; it fails with a Graph auth error when they are not. There is no separate `channels.msteams.actions.memberInfo` toggle. +The action is available whenever Graph credentials are configured; there is no separate `channels.msteams.actions.memberInfo` toggle. +Standard-channel lookups return the matching team-roster identity, display name, email, and roles. +In the current DM or group chat, the action can return the trusted sender's stable user ID. +Private/shared-channel and non-current chat member lookups require additional roster permissions +and are rejected by the default permission baseline. ## History context diff --git a/docs/cli/message.md b/docs/cli/message.md index f3be078f4632..d6c471c0e87b 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -70,8 +70,8 @@ unresolved SecretRef on the selected channel/account fails the action closed. | --------------- | --------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `send` | Discord, Google Chat, iMessage, Matrix, Mattermost (plugin), Microsoft Teams, Signal, Slack, Telegram, WhatsApp | `--target`, plus one of `--message`/`--media`/`--presentation` | See [Send](#send) below. | | `poll` | Discord, Matrix, Microsoft Teams, Telegram, WhatsApp | `--target`, `--poll-question`, `--poll-option` (repeat) | See [Poll](#poll) below. | -| `react` | Discord, Google Chat, Matrix, Nextcloud Talk, Signal, Slack, Telegram, WhatsApp | `--message-id`, `--target` | `--emoji`, `--remove` (needs `--emoji`; omit it to clear own reactions where supported, see [Reactions](/tools/reactions)). WhatsApp: `--participant`, `--from-me`. Signal group reactions require `--target-author` or `--target-author-uuid`. Nextcloud Talk only adds reactions; `--remove` errors. | -| `reactions` | Discord, Google Chat, Matrix, Microsoft Teams, Slack | `--message-id`, `--target` | `--limit`. | +| `react` | Discord, Matrix, Nextcloud Talk, Signal, Slack, Telegram, WhatsApp | `--message-id`, `--target` | `--emoji`, `--remove` (needs `--emoji`; omit it to clear own reactions where supported, see [Reactions](/tools/reactions)). WhatsApp: `--participant`, `--from-me`. Signal group reactions require `--target-author` or `--target-author-uuid`. Nextcloud Talk only adds reactions; `--remove` errors. | +| `reactions` | Discord, Matrix, Microsoft Teams, Slack | `--message-id`, `--target` | `--limit`. | | `read` | Discord, Matrix, Microsoft Teams, Slack | `--target` | `--limit`, `--message-id`, `--before`, `--after`. Discord: `--around`, `--include-thread`. Slack: `--message-id` reads a specific timestamp, combine with `--thread-id` for an exact thread reply. | | `edit` | Discord, Matrix, Microsoft Teams, Slack, Telegram | `--message-id`, `--message`, `--target` | Telegram forum threads use `--thread-id`. | | `delete` | Discord, Matrix, Microsoft Teams, Slack, Telegram | `--message-id`, `--target` | | diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index 415f5fa1bd03..3073e84e1d1d 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -236,6 +236,10 @@ Tools can be required or optional. Required tools are always available when the plugin is enabled. Optional tools need explicit user opt-in before OpenClaw loads the owning plugin runtime. +Tool factories receive trusted runtime context, including `deliveryContext`, +`nativeChannelId` for the active platform conversation when available, and +`requesterSenderId`. + ```typescript register(api) { api.registerTool( diff --git a/docs/tools/reactions.md b/docs/tools/reactions.md index ffa85aef874b..d1a49e006040 100644 --- a/docs/tools/reactions.md +++ b/docs/tools/reactions.md @@ -37,12 +37,6 @@ action. Behavior varies by channel. - - - Empty `emoji` (or `remove: true`) removes the bot's own reactions on the message, filtered to `emoji` when set. - - `remove: true` removes just the specified emoji. - - - - Adding reactions only: `emoji` is required and must be non-empty. - Reaction removal is not wired to a delete call yet; `remove: true` is rejected with an explicit error instead of silently no-oping. diff --git a/extensions/codex/src/app-server/dynamic-tool-build.test.ts b/extensions/codex/src/app-server/dynamic-tool-build.test.ts index e6926b0d929e..670b068dc1c2 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.test.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.test.ts @@ -274,23 +274,32 @@ describe("Codex app-server dynamic tool build", () => { expect(webSearchAllowed).toBe(true); }); - it("forwards the originating client caps into coding tool assembly", async () => { + it("forwards client caps alongside channel authority context", async () => { // Regression: capability-gated tools (requiredClientCaps) vanished on the // Codex app-server path because this harness dropped params.clientCaps. + // Keep that fact composed with the operation-local message context. const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir); params.disableTools = false; params.runtimePlan = createCodexRuntimePlanFixture(); params.clientCaps = ["tool-events", "inline-widgets"]; - let receivedClientCaps: string[] | undefined; + params.chatId = "native-chat-123"; + params.chatType = "direct"; + params.messageActionTurnCapability = "turn-capability-1"; + let receivedOptions: unknown; setOpenClawCodingToolsFactoryForTests((options) => { - receivedClientCaps = (options as { clientCaps?: string[] }).clientCaps; + receivedOptions = options; return [createRuntimeDynamicTool("message")]; }); await buildDynamicToolsForTest(params, workspaceDir); - expect(receivedClientCaps).toEqual(["tool-events", "inline-widgets"]); + expect(receivedOptions).toMatchObject({ + clientCaps: ["tool-events", "inline-widgets"], + chatType: "direct", + nativeChannelId: "native-chat-123", + messageActionTurnCapability: "turn-capability-1", + }); }); it("shares the computer context epoch with dynamic tool assembly", async () => { @@ -980,6 +989,7 @@ describe("Codex app-server dynamic tool build", () => { } satisfies EmbeddedRunAttemptParams["authProfileStore"]; params.disableTools = false; params.authProfileStore = authProfileStore; + params.messageActionTurnCapability = "turn-capability-1"; params.runtimePlan = createCodexRuntimePlanFixture(); const factoryOptions: unknown[] = []; setOpenClawCodingToolsFactoryForTests((options) => { @@ -993,6 +1003,9 @@ describe("Codex app-server dynamic tool build", () => { expect((factoryOptions[0] as { authProfileStore?: unknown }).authProfileStore).toBe( authProfileStore, ); + expect( + (factoryOptions[0] as { messageActionTurnCapability?: unknown }).messageActionTurnCapability, + ).toBe("turn-capability-1"); }); it("passes owner identity into Codex dynamic tool construction", async () => { @@ -1018,6 +1031,8 @@ describe("Codex app-server dynamic tool build", () => { const workspaceDir = path.join(tempDir, "workspace"); const params = createParams(sessionFile, workspaceDir); params.disableTools = false; + params.chatId = "native-chat-123"; + params.chatType = "direct"; params.currentChannelId = "D123"; params.currentMessagingTarget = "user:U123"; params.runtimePlan = createCodexRuntimePlanFixture(); @@ -1030,8 +1045,10 @@ describe("Codex app-server dynamic tool build", () => { await buildDynamicToolsForTest(params, workspaceDir, { sandbox: null as never }); expect(factoryOptions[0]).toMatchObject({ + chatType: "direct", currentChannelId: "D123", currentMessagingTarget: "user:U123", + nativeChannelId: "native-chat-123", }); }); diff --git a/extensions/codex/src/app-server/dynamic-tool-build.ts b/extensions/codex/src/app-server/dynamic-tool-build.ts index e49ad87bbd6f..d72f324b663d 100644 --- a/extensions/codex/src/app-server/dynamic-tool-build.ts +++ b/extensions/codex/src/app-server/dynamic-tool-build.ts @@ -250,9 +250,12 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) { // Capability-gated tools (requiredClientCaps) need the originating client's // declared caps in this sibling harness too, not only the embedded runner. clientCaps: params.clientCaps, + chatType: params.chatType, agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + nativeChannelId: params.chatId, + messageActionTurnCapability: params.messageActionTurnCapability, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/extensions/codex/src/app-server/side-question.test.ts b/extensions/codex/src/app-server/side-question.test.ts index 06605379fd67..d46d8a19079e 100644 --- a/extensions/codex/src/app-server/side-question.test.ts +++ b/extensions/codex/src/app-server/side-question.test.ts @@ -478,8 +478,11 @@ describe("runCodexAppServerSideQuestion", () => { sideParams({ messageChannel: "discord", messageProvider: "discord-voice", + chatId: "discord-native-room", + chatType: "channel", sessionKey: "agent:main:conversation", sandboxSessionKey: "agent:main:runtime-policy", + messageActionTurnCapability: "turn-capability-1", currentChannelId: "voice-room", agentAccountId: "account-1", messageTo: "channel-1", @@ -594,7 +597,9 @@ describe("runCodexAppServerSideQuestion", () => { expect(toolOptions).toHaveProperty("modelId", "gpt-5.5"); expect(toolOptions).toHaveProperty("messageProvider", "discord"); expect(toolOptions).toHaveProperty("toolPolicyMessageProvider", "discord-voice"); + expect(toolOptions).toHaveProperty("chatType", "channel"); expect(toolOptions).toHaveProperty("currentChannelId", "voice-room"); + expect(toolOptions).toHaveProperty("nativeChannelId", "discord-native-room"); expect(toolOptions).toMatchObject({ agentAccountId: "account-1", sessionKey: "agent:main:runtime-policy", @@ -610,6 +615,7 @@ describe("runCodexAppServerSideQuestion", () => { senderUsername: "rosita", senderE164: "+15550001", senderIsOwner: true, + messageActionTurnCapability: "turn-capability-1", }); expect(toolOptions).toHaveProperty("requireExplicitMessageTarget", true); }); diff --git a/extensions/codex/src/app-server/side-question.ts b/extensions/codex/src/app-server/side-question.ts index 3c4cfced9b95..b805048ba9b7 100644 --- a/extensions/codex/src/app-server/side-question.ts +++ b/extensions/codex/src/app-server/side-question.ts @@ -743,9 +743,14 @@ function buildSideRunAttemptParams( agentId: params.agentId, ...(params.messageChannel ? { messageChannel: params.messageChannel } : {}), ...(params.messageProvider ? { messageProvider: params.messageProvider } : {}), + ...(params.chatType ? { chatType: params.chatType } : {}), ...(params.agentAccountId ? { agentAccountId: params.agentAccountId } : {}), ...(params.messageTo ? { messageTo: params.messageTo } : {}), ...(params.messageThreadId !== undefined ? { messageThreadId: params.messageThreadId } : {}), + ...(params.chatId ? { chatId: params.chatId } : {}), + ...(params.messageActionTurnCapability + ? { messageActionTurnCapability: params.messageActionTurnCapability } + : {}), ...(params.groupId !== undefined ? { groupId: params.groupId } : {}), ...(params.groupChannel !== undefined ? { groupChannel: params.groupChannel } : {}), ...(params.groupSpace !== undefined ? { groupSpace: params.groupSpace } : {}), @@ -843,11 +848,16 @@ async function createCodexSideToolBridge(input: { toolPolicyMessageProvider: input.params.messageProvider ?? input.params.messageChannel, } : {}), + ...(input.params.chatType ? { chatType: input.params.chatType } : {}), ...(input.params.agentAccountId ? { agentAccountId: input.params.agentAccountId } : {}), ...(input.params.messageTo ? { messageTo: input.params.messageTo } : {}), ...(input.params.messageThreadId !== undefined ? { messageThreadId: input.params.messageThreadId } : {}), + ...(input.params.chatId ? { nativeChannelId: input.params.chatId } : {}), + ...(input.params.messageActionTurnCapability + ? { messageActionTurnCapability: input.params.messageActionTurnCapability } + : {}), ...(input.params.groupId !== undefined ? { groupId: input.params.groupId } : {}), ...(input.params.groupChannel !== undefined ? { groupChannel: input.params.groupChannel } diff --git a/extensions/copilot/src/tool-bridge.test.ts b/extensions/copilot/src/tool-bridge.test.ts index 848ad4467f23..f0da3eecf24f 100644 --- a/extensions/copilot/src/tool-bridge.test.ts +++ b/extensions/copilot/src/tool-bridge.test.ts @@ -518,6 +518,7 @@ describe("createCopilotToolBridge", () => { runId: "run-1", config, onToolOutcome, + messageActionTurnCapability: "turn-capability-1", } as never, createOpenClawCodingTools, modelId: "gpt-4o", @@ -530,6 +531,7 @@ describe("createCopilotToolBridge", () => { expect(opts.runId).toBe("run-1"); expect(opts.config).toBe(config); expect(opts.onToolOutcome).toBe(onToolOutcome); + expect(opts.messageActionTurnCapability).toBe("turn-capability-1"); }); it("prefers the unscoped toolAuthProfileStore when building OpenClaw tools", async () => { @@ -695,6 +697,27 @@ describe("createCopilotToolBridge", () => { expect(opts.runtimeToolAllowlist).toEqual(["read", "edit"]); }); + it("forwards the native conversation identity from attemptParams", async () => { + const { createOpenClawCodingTools, getOpts } = captureCall(); + + await createCopilotToolBridge({ + agentId: "agent-1", + attemptParams: { + chatId: "oc_native_chat", + chatType: "direct", + } as never, + createOpenClawCodingTools, + modelId: "gpt-4o", + modelProvider: "github-copilot", + sessionId: "session-1", + }); + + expect(getOpts()).toMatchObject({ + chatType: "direct", + nativeChannelId: "oc_native_chat", + }); + }); + it("onYield routes to sessionRef.current.abort() and invokes onYieldDetected when the live session is bound", async () => { const { createOpenClawCodingTools, getOpts } = captureCall(); const abort = vi.fn(); diff --git a/extensions/copilot/src/tool-bridge.ts b/extensions/copilot/src/tool-bridge.ts index 15139c4f8615..64d783646d96 100644 --- a/extensions/copilot/src/tool-bridge.ts +++ b/extensions/copilot/src/tool-bridge.ts @@ -361,9 +361,12 @@ function buildOpenClawCodingToolsOptions( elevated: a.bashElevated, }, messageProvider: a.messageProvider ?? a.messageChannel, + chatType: a.chatType, agentAccountId: a.agentAccountId, messageTo: a.messageTo, messageThreadId: a.messageThreadId, + nativeChannelId: a.chatId, + messageActionTurnCapability: a.messageActionTurnCapability, groupId: a.groupId, groupChannel: a.groupChannel, groupSpace: a.groupSpace, diff --git a/extensions/discord/src/actions/handle-action.guild-admin.ts b/extensions/discord/src/actions/handle-action.guild-admin.ts index d4e993590de2..e403cf028c77 100644 --- a/extensions/discord/src/actions/handle-action.guild-admin.ts +++ b/extensions/discord/src/actions/handle-action.guild-admin.ts @@ -10,6 +10,7 @@ import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-co import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { handleDiscordAction } from "../../action-runtime-api.js"; import { isTrustedRequesterGuildAdminAction } from "../trusted-requester-actions.js"; +import type { DiscordMessagingActionOptions } from "./runtime.messaging.shared.js"; import { isDiscordModerationAction, readDiscordModerationCommand, @@ -54,8 +55,9 @@ function senderParam(senderUserId: string | undefined) { export async function tryHandleDiscordMessageActionGuildAdmin(params: { ctx: Ctx; resolveChannelId: () => string; + readPolicyOptions?: DiscordMessagingActionOptions; }): Promise | undefined> { - const { ctx, resolveChannelId } = params; + const { ctx, resolveChannelId, readPolicyOptions } = params; const { action, params: actionParams, cfg } = ctx; const accountId = ctx.accountId ?? readStringParam(actionParams, "accountId"); const senderUserId = readDiscordRequesterSenderId(ctx); @@ -68,6 +70,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "memberInfo", accountId: accountId ?? undefined, guildId, userId }, cfg, + readPolicyOptions, ); } @@ -78,6 +81,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "roleInfo", accountId: accountId ?? undefined, guildId }, cfg, + readPolicyOptions, ); } @@ -88,6 +92,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "emojiList", accountId: accountId ?? undefined, guildId }, cfg, + readPolicyOptions, ); } @@ -173,6 +178,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "channelInfo", accountId: accountId ?? undefined, channelId }, cfg, + readPolicyOptions, ); } @@ -183,6 +189,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "channelList", accountId: accountId ?? undefined, guildId }, cfg, + readPolicyOptions, ); } @@ -310,6 +317,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "voiceStatus", accountId: accountId ?? undefined, guildId, userId }, cfg, + readPolicyOptions, ); } @@ -320,6 +328,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { return await handleDiscordAction( { action: "eventList", accountId: accountId ?? undefined, guildId }, cfg, + readPolicyOptions, ); } @@ -403,6 +412,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { limit, }, cfg, + readPolicyOptions, ); } @@ -467,6 +477,7 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: { limit: readPositiveIntegerParam(actionParams, "limit"), }, cfg, + readPolicyOptions, ); } diff --git a/extensions/discord/src/actions/handle-action.test.ts b/extensions/discord/src/actions/handle-action.test.ts index fa9ab8a1f2ef..9e6f91442e8a 100644 --- a/extensions/discord/src/actions/handle-action.test.ts +++ b/extensions/discord/src/actions/handle-action.test.ts @@ -302,6 +302,81 @@ describe("handleDiscordMessageAction", () => { }); }); + it("forwards attested current-conversation context to Discord reads", async () => { + const cfg = discordConfig(); + await handleDiscordMessageAction({ + action: "read", + params: { + channelId: "channel:123", + }, + cfg, + accountId: "ops", + requesterAccountId: "ops", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:123", + }, + }); + + expectDiscordActionCall({ + payload: { + action: "readMessages", + accountId: "ops", + channelId: "123", + limit: undefined, + before: undefined, + after: undefined, + around: undefined, + }, + cfg, + options: { + ...defaultActionOptions(), + conversationReadOrigin: "delegated", + readContext: { + requesterAccountId: "ops", + currentChannelProvider: "discord", + currentChannelId: "channel:123", + }, + }, + }); + }); + + it("forwards attested current-conversation context to Discord channel info", async () => { + const cfg = discordConfig({ channelInfo: true }); + await handleDiscordMessageAction({ + action: "channel-info", + params: { + channelId: "123", + }, + cfg, + accountId: "ops", + requesterAccountId: "ops", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:123", + }, + }); + + expectDiscordActionCall({ + payload: { + action: "channelInfo", + accountId: "ops", + channelId: "123", + }, + cfg, + options: { + conversationReadOrigin: "delegated", + readContext: { + requesterAccountId: "ops", + currentChannelProvider: "discord", + currentChannelId: "channel:123", + }, + }, + }); + }); + it("forwards threadName on sends", async () => { const cfg = discordConfig(); await handleDiscordMessageAction({ diff --git a/extensions/discord/src/actions/handle-action.ts b/extensions/discord/src/actions/handle-action.ts index bd99a999b9ad..646389c49401 100644 --- a/extensions/discord/src/actions/handle-action.ts +++ b/extensions/discord/src/actions/handle-action.ts @@ -23,6 +23,7 @@ import { } from "../shared-interactive.js"; import { resolveDiscordChannelId } from "../targets.js"; import { tryHandleDiscordMessageActionGuildAdmin } from "./handle-action.guild-admin.js"; +import type { DiscordMessagingActionOptions } from "./runtime.messaging.shared.js"; const providerId = "discord"; @@ -44,6 +45,7 @@ export async function handleDiscordMessageAction( | "params" | "cfg" | "accountId" + | "requesterAccountId" | "requesterSenderId" | "senderIsOwner" | "toolContext" @@ -52,14 +54,35 @@ export async function handleDiscordMessageAction( | "mediaReadFile" | "sessionKey" | "inboundEventKind" + | "conversationReadOrigin" >, ): Promise> { const { action, params, cfg } = ctx; const accountId = ctx.accountId ?? readStringParam(params, "accountId"); + const readContext = + ctx.requesterAccountId && + ctx.toolContext?.currentChannelProvider && + ctx.toolContext.currentChannelId + ? { + requesterAccountId: ctx.requesterAccountId, + currentChannelProvider: ctx.toolContext.currentChannelProvider, + currentChannelId: ctx.toolContext.currentChannelId, + } + : undefined; + const readPolicyOptions: DiscordMessagingActionOptions | undefined = + ctx.conversationReadOrigin || readContext + ? { + ...(ctx.conversationReadOrigin + ? { conversationReadOrigin: ctx.conversationReadOrigin } + : {}), + ...(readContext ? { readContext } : {}), + } + : undefined; const actionOptions = { mediaAccess: ctx.mediaAccess, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, + ...readPolicyOptions, } as const; const notifyVisibleOutbound = (to: string, fallbackSessionKey?: string) => notifyDiscordInboundEventOutboundSuccess({ @@ -397,6 +420,7 @@ export async function handleDiscordMessageAction( const adminResult = await tryHandleDiscordMessageActionGuildAdmin({ ctx, resolveChannelId, + readPolicyOptions, }); if (adminResult !== undefined) { if (action === "thread-reply") { diff --git a/extensions/discord/src/actions/runtime.guild.ts b/extensions/discord/src/actions/runtime.guild.ts index 6f55f740cfae..b871e510c047 100644 --- a/extensions/discord/src/actions/runtime.guild.ts +++ b/extensions/discord/src/actions/runtime.guild.ts @@ -37,7 +37,10 @@ import { uploadStickerDiscord, resolveEventCoverImage, } from "../send.js"; -import { createDiscordMessagingActionContext } from "./runtime.messaging.shared.js"; +import { + createDiscordMessagingActionContext, + type DiscordMessagingActionOptions, +} from "./runtime.messaging.shared.js"; import { createDiscordActionOptions, readDiscordChannelCreateParams, @@ -357,7 +360,7 @@ export async function handleDiscordGuildAction( params: Record, isActionEnabled: ActionGate, cfg: OpenClawConfig, - options?: { mediaLocalRoots?: readonly string[] }, + options?: DiscordMessagingActionOptions, ): Promise> { const accountId = readStringParam(params, "accountId"); if (!cfg) { @@ -374,9 +377,13 @@ export async function handleDiscordGuildAction( }); const withOpts = (extra?: Record) => createDiscordActionOptions({ cfg, accountId, extra }); - const assertGuildMetadataReadAllowed = async (guildId: string) => { + const assertGuildMetadataReadAllowed = async ( + guildId: string, + readOptions?: { filteredResults?: boolean }, + ) => { await readTargetGate.assertGuildReadTargetAllowed({ guildId, + filteredResults: readOptions?.filteredResults, channelTargetRequiredMessage: "Discord guild metadata reads require a wildcard channel allowlist for this guild.", }); @@ -521,12 +528,13 @@ export async function handleDiscordGuildAction( const guildId = readStringParam(params, "guildId", { required: true, }); - await assertGuildMetadataReadAllowed(guildId); + await assertGuildMetadataReadAllowed(guildId, { filteredResults: true }); const channels = await discordGuildActionRuntime.listGuildChannelsDiscord( guildId, withOpts(), ); - return jsonResult({ ok: true, channels }); + const visibleChannels = await readTargetGate.filterGuildChannelList({ guildId, channels }); + return jsonResult({ ok: true, channels: visibleChannels }); } case "voiceStatus": { if (!isActionEnabled("voiceStatus")) { diff --git a/extensions/discord/src/actions/runtime.messaging.messages.ts b/extensions/discord/src/actions/runtime.messaging.messages.ts index 47f0f6068af0..73acac105ea5 100644 --- a/extensions/discord/src/actions/runtime.messaging.messages.ts +++ b/extensions/discord/src/actions/runtime.messaging.messages.ts @@ -126,6 +126,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging const content = readStringParam(ctx.params, "content", { required: true, }); + await ctx.assertReadTargetAllowed({ channelId }); const message = await discordMessagingActionRuntime.editMessageDiscord( channelId, messageId, @@ -142,6 +143,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging const messageId = readStringParam(ctx.params, "messageId", { required: true, }); + await ctx.assertReadTargetAllowed({ channelId }); await discordMessagingActionRuntime.deleteMessageDiscord( channelId, messageId, @@ -157,6 +159,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging const messageId = readStringParam(ctx.params, "messageId", { required: true, }); + await ctx.assertReadTargetAllowed({ channelId }); await discordMessagingActionRuntime.pinMessageDiscord(channelId, messageId, ctx.withOpts()); return jsonResult({ ok: true }); } @@ -168,6 +171,7 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging const messageId = readStringParam(ctx.params, "messageId", { required: true, }); + await ctx.assertReadTargetAllowed({ channelId }); await discordMessagingActionRuntime.unpinMessageDiscord(channelId, messageId, ctx.withOpts()); return jsonResult({ ok: true }); } diff --git a/extensions/discord/src/actions/runtime.messaging.reactions.ts b/extensions/discord/src/actions/runtime.messaging.reactions.ts index 27221b9e52ee..8589c483a993 100644 --- a/extensions/discord/src/actions/runtime.messaging.reactions.ts +++ b/extensions/discord/src/actions/runtime.messaging.reactions.ts @@ -22,6 +22,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging removeErrorMessage: "Emoji is required to remove a Discord reaction.", }); if (remove) { + await ctx.assertReadTargetAllowed({ channelId }); await discordMessagingActionRuntime.removeReactionDiscord( channelId, messageId, @@ -31,6 +32,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { + await ctx.assertReadTargetAllowed({ channelId }); const removed = await discordMessagingActionRuntime.removeOwnReactionsDiscord( channelId, messageId, @@ -38,6 +40,7 @@ export async function handleDiscordReactionMessagingAction(ctx: DiscordMessaging ); return jsonResult({ ok: true, removed: removed.removed }); } + await ctx.assertReadTargetAllowed({ channelId }); await discordMessagingActionRuntime.reactMessageDiscord( channelId, messageId, diff --git a/extensions/discord/src/actions/runtime.messaging.shared.ts b/extensions/discord/src/actions/runtime.messaging.shared.ts index ffa8249be677..21c3cf0a6d0b 100644 --- a/extensions/discord/src/actions/runtime.messaging.shared.ts +++ b/extensions/discord/src/actions/runtime.messaging.shared.ts @@ -1,3 +1,6 @@ +import { ChannelType } from "discord-api-types/v10"; +import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; // Discord plugin module implements runtime.messaging.shared behavior. import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; import { mergeDiscordAccountConfig, resolveDefaultDiscordAccountId } from "../accounts.js"; @@ -5,6 +8,7 @@ import { createDiscordRuntimeAccountContext } from "../client.js"; import { isDiscordGroupAllowedByPolicy, normalizeDiscordSlug, + resolveGroupDmAllow, resolveDiscordChannelConfigWithFallback, type DiscordGuildEntryResolved, } from "../monitor/allow-list.js"; @@ -19,6 +23,10 @@ import type { DiscordReactOpts } from "../send.types.js"; import { discordMessagingActionRuntime } from "./runtime.messaging.runtime.js"; import { createDiscordActionOptions } from "./runtime.shared.js"; +type ConversationReadInvocationOrigin = NonNullable< + ChannelMessageActionContext["conversationReadOrigin"] +>; + export type DiscordMessagingActionOptions = { mediaAccess?: { localRoots?: readonly string[]; @@ -27,6 +35,12 @@ export type DiscordMessagingActionOptions = { }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; + conversationReadOrigin?: ConversationReadInvocationOrigin; + readContext?: { + requesterAccountId?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + }; }; export type DiscordMessagingActionContext = { @@ -41,7 +55,9 @@ export type DiscordMessagingActionContext = { assertGuildReadTargetAllowed: (params: { guildId: string; channelTargetRequiredMessage?: string; + filteredResults?: boolean; }) => Promise; + filterGuildChannelList: (params: { guildId: string; channels: T[] }) => Promise; resolveReactionChannelId: () => Promise; withOpts: (extra?: Record) => { cfg: OpenClawConfig; accountId?: string }; withReactionRuntimeOptions: = Record>( @@ -103,15 +119,59 @@ function resolveDiscordActionGuildEntry(params: { type DiscordReadTargetContext = { channelId: string; + metadataKnown: boolean; + ancestryComplete: boolean; + channelType?: number; guildId?: string; channelName?: string; channelSlug: string; + ancestors: DiscordReadAncestor[]; parentId?: string; parentName?: string; parentSlug?: string; scope?: "channel" | "thread"; }; +type DiscordReadAncestor = { + channelId: string; + channelName?: string; + channelSlug: string; +}; + +async function resolveDiscordReadAncestry(params: { + channelId: string; + parentId?: string; + loadChannel: (channelId: string) => Promise; +}): Promise<{ ancestors: DiscordReadAncestor[]; complete: boolean }> { + const ancestors: DiscordReadAncestor[] = []; + const visited = new Set([params.channelId]); + let parentId = params.parentId; + // Discord hierarchy is bounded at thread -> channel -> category. Preserve + // that bound so malformed metadata cannot expand authorization-time I/O. + for (let depth = 0; parentId && depth < 2; depth++) { + if (visited.has(parentId)) { + return { ancestors, complete: false }; + } + visited.add(parentId); + const parent = await params.loadChannel(parentId); + if (!parent) { + ancestors.push({ + channelId: parentId, + channelSlug: normalizeDiscordSlug(parentId) || parentId, + }); + return { ancestors, complete: false }; + } + const parentName = readDiscordChannelStringField(parent, "name"); + ancestors.push({ + channelId: parentId, + ...(parentName ? { channelName: parentName } : {}), + channelSlug: parentName ? normalizeDiscordSlug(parentName) : parentId, + }); + parentId = readDiscordChannelStringField(parent, "parent_id", "parentId"); + } + return { ancestors, complete: !parentId }; +} + function readDiscordChannelStringField(value: unknown, ...keys: string[]): string | undefined { if (!value || typeof value !== "object") { return undefined; @@ -139,11 +199,41 @@ function isDiscordThreadChannel(value: unknown): boolean { return type === 10 || type === 11 || type === 12; } +function isDiscordReadAncestryAllowed(params: { + guildInfo: DiscordGuildEntryResolved | null; + target: DiscordReadTargetContext; +}): boolean { + for (const ancestor of params.target.ancestors) { + const config = resolveDiscordChannelConfigWithFallback({ + guildInfo: params.guildInfo, + channelId: ancestor.channelId, + channelName: ancestor.channelName, + channelSlug: ancestor.channelSlug, + }); + if (config?.matchSource === "direct" && !config.allowed) { + return false; + } + } + return ( + params.target.ancestryComplete || + !hasExplicitlyDisabledDiscordChannels(params.guildInfo?.channels) + ); +} + function isDiscordReadTargetAllowedInGuild(params: { groupPolicy: "open" | "disabled" | "allowlist"; guildInfo: DiscordGuildEntryResolved | null; target: DiscordReadTargetContext; }): boolean { + if (!params.target.metadataKnown) { + if (hasExplicitlyDisabledDiscordChannels(params.guildInfo?.channels)) { + return false; + } + return isDiscordReadTargetExplicitlyAllowedById(params); + } + if (!isDiscordReadAncestryAllowed(params)) { + return false; + } const channelConfig = resolveDiscordChannelConfigWithFallback({ guildInfo: params.guildInfo, channelId: params.target.channelId, @@ -182,6 +272,20 @@ function isDiscordReadTargetExplicitlyAllowedById(params: { }); } +function hasExplicitlyDisabledDiscordChannelConfig( + guilds: Record | undefined, +): boolean { + return Object.values(guilds ?? {}).some((guild) => + hasExplicitlyDisabledDiscordChannels(guild?.channels), + ); +} + +function hasExplicitlyDisabledDiscordChannels( + channels: DiscordGuildEntryResolved["channels"] | undefined, +): boolean { + return Object.values(channels ?? {}).some((channel) => channel.enabled === false); +} + export function createDiscordMessagingActionContext(params: { action: string; input: Record; @@ -196,15 +300,36 @@ export function createDiscordMessagingActionContext(params: { accountId ?? resolveDefaultDiscordAccountId(params.cfg), ); const guilds = accountConfig.guilds as Record; - const hasGuildEntries = Object.keys(guilds ?? {}).length > 0; const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: params.cfg.channels?.discord !== undefined, groupPolicy: accountConfig.groupPolicy, defaultGroupPolicy: params.cfg.channels?.defaults?.groupPolicy, }); + const directOperator = params.options?.conversationReadOrigin === "direct-operator"; + const currentReadContext = params.options?.readContext; + const directDmEnabled = + accountConfig.dm?.enabled !== false && + (accountConfig.dmPolicy ?? accountConfig.dm?.policy ?? "pairing") !== "disabled"; const withOpts = (extra?: Record) => createDiscordActionOptions({ cfg: params.cfg, accountId, extra }); const resolvedReactionAccountId = accountId ?? resolveDefaultDiscordAccountId(params.cfg); + const isCurrentReadTarget = (channelId: string): boolean => { + const requesterAccountId = currentReadContext?.requesterAccountId?.trim(); + const currentChannelId = currentReadContext?.currentChannelId?.trim(); + if ( + currentReadContext?.currentChannelProvider?.trim().toLowerCase() !== "discord" || + !requesterAccountId || + !currentChannelId || + normalizeAccountId(requesterAccountId) !== normalizeAccountId(resolvedReactionAccountId) + ) { + return false; + } + try { + return discordMessagingActionRuntime.resolveDiscordChannelId(currentChannelId) === channelId; + } catch { + return false; + } + }; const reactionRuntimeOptions = resolvedReactionAccountId ? createDiscordRuntimeAccountContext({ cfg: params.cfg, @@ -256,6 +381,9 @@ export function createDiscordMessagingActionContext(params: { const fallback: DiscordReadTargetContext = { channelId, channelSlug: normalizeDiscordSlug(channelId) || channelId, + metadataKnown: false, + ancestryComplete: false, + ancestors: [], }; let channelInfo: unknown; try { @@ -270,7 +398,14 @@ export function createDiscordMessagingActionContext(params: { const target: DiscordReadTargetContext = { channelId, channelSlug: channelName ? normalizeDiscordSlug(channelName) : fallback.channelSlug, + metadataKnown: true, + ancestryComplete: true, + ancestors: [], }; + const channelType = readDiscordChannelType(channelInfo); + if (channelType !== undefined) { + target.channelType = channelType; + } const targetGuildId = readDiscordChannelStringField(channelInfo, "guild_id", "guildId"); if (targetGuildId) { target.guildId = targetGuildId; @@ -278,29 +413,84 @@ export function createDiscordMessagingActionContext(params: { if (channelName) { target.channelName = channelName; } - if (!isDiscordThreadChannel(channelInfo)) { + if (isDiscordThreadChannel(channelInfo)) { + target.scope = "thread"; + } + const ancestry = await resolveDiscordReadAncestry({ + channelId, + parentId: readDiscordChannelStringField(channelInfo, "parent_id", "parentId"), + loadChannel: async (parentId) => { + try { + return await discordMessagingActionRuntime.fetchChannelInfoDiscord(parentId, withOpts()); + } catch { + return undefined; + } + }, + }); + target.ancestors = ancestry.ancestors; + target.ancestryComplete = ancestry.complete; + const immediateParent = target.ancestors[0]; + if (!immediateParent) { return target; } - target.scope = "thread"; - target.parentId = readDiscordChannelStringField(channelInfo, "parent_id", "parentId"); - if (!target.parentId) { - return target; - } - try { - const parentInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord( - target.parentId, - withOpts(), - ); - const parentName = readDiscordChannelStringField(parentInfo, "name"); - if (parentName) { - target.parentName = parentName; - target.parentSlug = normalizeDiscordSlug(parentName); - } - } catch { - // Parent id fallback is enough for allowlist checks when the parent fetch is unavailable. + target.parentId = immediateParent.channelId; + if (immediateParent.channelName) { + target.parentName = immediateParent.channelName; } + target.parentSlug = immediateParent.channelSlug; return target; }; + const isExpandedReadTargetEnabled = ( + guildInfo: DiscordGuildEntryResolved | null, + target: DiscordReadTargetContext, + currentConversation: boolean, + ): boolean => { + const groupDmEnabled = + accountConfig.dm?.groupEnabled === true && + (currentConversation || + resolveGroupDmAllow({ + channels: accountConfig.dm?.groupChannels, + channelId: target.channelId, + channelName: target.channelName, + channelSlug: target.channelSlug, + })); + if (!target.metadataKnown) { + // Without provider metadata, the target might be a guild channel, DM, or + // group DM. Every plausible scope must allow it before provider content reads. + return ( + groupPolicy !== "disabled" && + directDmEnabled && + groupDmEnabled && + !hasExplicitlyDisabledDiscordChannelConfig(guilds) + ); + } + if (!target.guildId) { + if (target.channelType === ChannelType.GroupDM) { + return groupDmEnabled; + } + if (target.channelType === ChannelType.DM) { + return directDmEnabled; + } + return directDmEnabled && groupDmEnabled; + } + if (groupPolicy === "disabled") { + return false; + } + if (!isDiscordReadAncestryAllowed({ guildInfo, target })) { + return false; + } + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId: target.channelId, + channelName: target.channelName, + channelSlug: target.channelSlug, + parentId: target.parentId, + parentName: target.parentName, + parentSlug: target.parentSlug, + scope: target.scope, + }); + return !channelConfig?.matchSource || channelConfig.allowed; + }; return { action: params.action, params: params.input, @@ -316,15 +506,19 @@ export function createDiscordMessagingActionContext(params: { ), assertReadTargetAllowed: async ({ guildId, channelId }) => { const targetChannelId = discordMessagingActionRuntime.resolveDiscordChannelId(channelId); - if (!hasGuildEntries && groupPolicy !== "disabled" && groupPolicy !== "allowlist") { - return; - } const target = await resolveReadTargetContext(targetChannelId); + const currentConversation = isCurrentReadTarget(targetChannelId); if (guildId) { - if (target.guildId && target.guildId !== guildId) { + if (target.metadataKnown && target.guildId !== guildId) { throw new Error("Discord read target channel is not allowed."); } const guildInfo = await resolveReadGuildEntry(guildId); + if ( + (directOperator && isExpandedReadTargetEnabled(guildInfo, target, false)) || + (currentConversation && isExpandedReadTargetEnabled(guildInfo, target, true)) + ) { + return; + } if ( !isDiscordReadTargetAllowedInGuild({ groupPolicy, @@ -338,6 +532,12 @@ export function createDiscordMessagingActionContext(params: { } if (target.guildId) { const guildInfo = await resolveReadGuildEntry(target.guildId); + if ( + (directOperator && isExpandedReadTargetEnabled(guildInfo, target, false)) || + (currentConversation && isExpandedReadTargetEnabled(guildInfo, target, true)) + ) { + return; + } if ( !isDiscordReadTargetAllowedInGuild({ groupPolicy, @@ -349,19 +549,41 @@ export function createDiscordMessagingActionContext(params: { } return; } - const allowed = Object.values(guilds ?? {}).some((guildInfo) => - isDiscordReadTargetExplicitlyAllowedById({ - groupPolicy, - guildInfo: guildInfo ?? null, - target, - }), - ); + // Known non-guild targets must never borrow a guild wildcard or channel + // allowlist. Unknown metadata may use only the helper's fail-closed, + // stable-ID path while every plausible non-guild scope remains enabled. + const allowed = + !target.metadataKnown && + Object.values(guilds ?? {}).some((guildInfo) => + isDiscordReadTargetAllowedInGuild({ + groupPolicy, + guildInfo: guildInfo ?? null, + target, + }), + ); + if ( + (directOperator && isExpandedReadTargetEnabled(null, target, false)) || + (currentConversation && isExpandedReadTargetEnabled(null, target, true)) + ) { + return; + } if (!allowed) { throw new Error("Discord read target channel is not allowed."); } }, - assertGuildReadTargetAllowed: async ({ guildId, channelTargetRequiredMessage }) => { + assertGuildReadTargetAllowed: async ({ + guildId, + channelTargetRequiredMessage, + filteredResults, + }) => { const guildInfo = await resolveReadGuildEntry(guildId); + if ( + directOperator && + groupPolicy !== "disabled" && + (filteredResults === true || !hasExplicitlyDisabledDiscordChannels(guildInfo?.channels)) + ) { + return; + } if ( !isDiscordGroupAllowedByPolicy({ groupPolicy, @@ -382,6 +604,70 @@ export function createDiscordMessagingActionContext(params: { ); } }, + filterGuildChannelList: async ({ guildId, channels }) => { + if (!directOperator) { + return channels; + } + const guildInfo = await resolveReadGuildEntry(guildId); + const channelById = new Map( + channels.flatMap((channel) => { + const channelId = readDiscordChannelStringField(channel, "id"); + return channelId ? [[channelId, channel] as const] : []; + }), + ); + const visibleChannels: typeof channels = []; + for (const channel of channels) { + const channelId = readDiscordChannelStringField(channel, "id"); + if (!channelId) { + continue; + } + const channelName = readDiscordChannelStringField(channel, "name"); + const channelType = readDiscordChannelType(channel); + const target: DiscordReadTargetContext = { + channelId, + channelSlug: channelName ? normalizeDiscordSlug(channelName) : channelId, + guildId, + metadataKnown: true, + ancestryComplete: true, + ancestors: [], + ...(channelName ? { channelName } : {}), + ...(channelType !== undefined ? { channelType } : {}), + ...(isDiscordThreadChannel(channel) ? { scope: "thread" as const } : {}), + }; + const ancestry = await resolveDiscordReadAncestry({ + channelId, + parentId: readDiscordChannelStringField(channel, "parent_id", "parentId"), + loadChannel: async (parentId) => channelById.get(parentId), + }); + target.ancestors = ancestry.ancestors; + target.ancestryComplete = ancestry.complete; + const immediateParent = target.ancestors[0]; + if (immediateParent) { + target.parentId = immediateParent.channelId; + if (immediateParent.channelName) { + target.parentName = immediateParent.channelName; + } + target.parentSlug = immediateParent.channelSlug; + } + if (!isDiscordReadAncestryAllowed({ guildInfo, target })) { + continue; + } + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName, + channelSlug: target.channelSlug, + parentId: target.parentId, + parentName: target.parentName, + parentSlug: target.parentSlug, + scope: target.scope, + }); + if (!channelConfig?.matchSource || channelConfig.allowed) { + visibleChannels.push(channel); + } + } + return visibleChannels; + }, resolveReactionChannelId: async () => { const target = readStringParam(params.input, "channelId") ?? diff --git a/extensions/discord/src/actions/runtime.test.ts b/extensions/discord/src/actions/runtime.test.ts index 7dd63d6b2b33..2ccaa31faa2b 100644 --- a/extensions/discord/src/actions/runtime.test.ts +++ b/extensions/discord/src/actions/runtime.test.ts @@ -28,6 +28,14 @@ type DiscordChannelInfoTest = { parent_id?: string; }; +const defaultFetchChannelInfoDiscord = async ( + channelId: string, +): Promise => ({ + id: channelId, + type: ChannelType.GuildText, + guild_id: "G1", +}); + const discordSendMocks = { addRoleDiscord: vi.fn(async () => ({ ok: true })), banMemberDiscord: vi.fn(async () => ({})), @@ -47,9 +55,7 @@ const discordSendMocks = { name: "edited", })), editMessageDiscord: vi.fn(async () => ({})), - fetchChannelInfoDiscord: vi.fn( - async (channelId: string): Promise => ({ id: channelId, type: 0 }), - ), + fetchChannelInfoDiscord: vi.fn(defaultFetchChannelInfoDiscord), fetchChannelPermissionsDiscord: vi.fn(async () => ({})), fetchGuildInfoDiscord: vi.fn(async (guildId: string) => ({ id: guildId, @@ -63,7 +69,7 @@ const discordSendMocks = { fetchRoleInfoDiscord: vi.fn(async () => []), fetchVoiceStatusDiscord: vi.fn(async () => ({})), kickMemberDiscord: vi.fn(async () => ({})), - listGuildChannelsDiscord: vi.fn(async () => []), + listGuildChannelsDiscord: vi.fn(async (): Promise => []), listGuildEmojisDiscord: vi.fn(async () => []), listPinsDiscord: vi.fn(async () => ({})), listScheduledEventsDiscord: vi.fn(async () => []), @@ -185,6 +191,12 @@ function handleMessagingAction( }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; + conversationReadOrigin?: "delegated" | "direct-operator"; + readContext?: { + requesterAccountId?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + }; }, ) { return handleDiscordMessagingAction(action, params, isActionEnabled, cfg, options); @@ -195,7 +207,10 @@ function handleGuildAction( params: Record, isActionEnabled: (key: keyof DiscordActionConfig) => boolean, cfg: OpenClawConfig = DISCORD_TEST_CFG, - options?: { mediaLocalRoots?: readonly string[] }, + options?: { + mediaLocalRoots?: readonly string[]; + conversationReadOrigin?: "delegated" | "direct-operator"; + }, ) { return handleDiscordGuildAction(action, params, isActionEnabled, cfg, options); } @@ -216,6 +231,7 @@ const rolesEnabled = (key: keyof DiscordActionConfig) => key === "roles"; beforeEach(() => { vi.clearAllMocks(); + fetchChannelInfoDiscord.mockImplementation(defaultFetchChannelInfoDiscord); clearPresences(); Object.assign( discordMessagingActionRuntime, @@ -516,9 +532,13 @@ describe("handleDiscordMessagingAction", () => { }); }); - it("resolves Discord DM targets for reaction listing", async () => { + it("resolves Discord DM targets for direct-operator reaction listing", async () => { const resolveReactionTarget = vi.fn(async () => "DM1"); discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget; + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "DM1", + type: ChannelType.DM, + }); await handleMessagingAction( "reactions", @@ -527,6 +547,8 @@ describe("handleDiscordMessagingAction", () => { messageId: "M1", }, enableAllActions, + DISCORD_TEST_CFG, + { conversationReadOrigin: "direct-operator" }, ); expect(resolveReactionTarget).toHaveBeenCalledWith({ @@ -541,6 +563,70 @@ describe("handleDiscordMessagingAction", () => { }); }); + it.each([ + { name: "DM", type: ChannelType.DM }, + { name: "group DM", type: ChannelType.GroupDM }, + ])("blocks delegated reads of arbitrary Discord $name targets", async ({ type }) => { + const resolveReactionTarget = vi.fn(async () => "DM1"); + discordMessagingActionRuntime.resolveDiscordReactionTargetChannelId = resolveReactionTarget; + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "DM1", + type, + }); + + await expect( + handleMessagingAction( + "reactions", + { + to: "user:U1", + messageId: "M1", + }, + enableAllActions, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("rejects a Discord DM paired with a caller-supplied guild ID", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "DM1", + type: ChannelType.DM, + }); + + await expect( + handleMessagingAction( + "fetchMessage", + { + guildId: "G1", + channelId: "DM1", + messageId: "M1", + }, + enableAllActions, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + + expect(fetchMessageDiscord).not.toHaveBeenCalled(); + }); + + it("fails closed when Discord cannot verify a caller-supplied guild target", async () => { + fetchChannelInfoDiscord.mockRejectedValueOnce(new Error("metadata unavailable")); + + await expect( + handleMessagingAction( + "fetchMessage", + { + guildId: "G1", + channelId: "C1", + messageId: "M1", + }, + enableAllActions, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + + expect(fetchMessageDiscord).not.toHaveBeenCalled(); + }); + it("rejects fractional Discord reaction limits before fetching reactions", async () => { await expect( handleMessagingAction( @@ -584,6 +670,442 @@ describe("handleDiscordMessagingAction", () => { expect(fetchReactionsDiscord).not.toHaveBeenCalled(); }); + it.each([ + { + name: "reaction add", + action: "react", + params: { emoji: "✅" }, + providerCall: discordSendMocks.reactMessageDiscord, + }, + { + name: "reaction removal", + action: "react", + params: { emoji: "✅", remove: true }, + providerCall: discordSendMocks.removeReactionDiscord, + }, + { + name: "message edit", + action: "editMessage", + params: { content: "updated" }, + providerCall: discordSendMocks.editMessageDiscord, + }, + { + name: "message deletion", + action: "deleteMessage", + params: {}, + providerCall: discordSendMocks.deleteMessageDiscord, + }, + { + name: "pin", + action: "pinMessage", + params: {}, + providerCall: discordSendMocks.pinMessageDiscord, + }, + { + name: "unpin", + action: "unpinMessage", + params: {}, + providerCall: discordSendMocks.unpinMessageDiscord, + }, + ])("rejects blocked Discord $name before mutation", async ({ action, params, providerCall }) => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + guild_id: "111", + name: "blocked", + type: ChannelType.GuildText, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction( + action, + { + channelId: "444", + messageId: "M1", + ...params, + }, + enableAllActions, + cfg, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + + expect(providerCall).not.toHaveBeenCalled(); + }); + + it("allows a delegated read of the exact current Discord channel and account", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + guild_id: "111", + name: "current-target", + type: ChannelType.GuildText, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { + readContext: { + requesterAccountId: "DEFAULT", + currentChannelProvider: "Discord", + currentChannelId: "channel:444", + }, + }, + ); + + expect(fetchReactionsDiscord).toHaveBeenCalledWith("444", "M1", { + cfg, + accountId: "default", + limit: undefined, + }); + }); + + it("does not borrow current Discord visibility from another account", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + guild_id: "111", + name: "current-target", + type: ChannelType.GuildText, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { + readContext: { + requesterAccountId: "other", + currentChannelProvider: "discord", + currentChannelId: "444", + }, + }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("keeps explicitly disabled current Discord channels blocked", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + guild_id: "111", + name: "current-target", + type: ChannelType.GuildText, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "444": { enabled: false }, + }, + }, + }); + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { + readContext: { + requesterAccountId: "default", + currentChannelProvider: "discord", + currentChannelId: "444", + }, + }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read an unconfigured Discord channel", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + guild_id: "111", + name: "operator-target", + type: ChannelType.GuildText, + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(fetchReactionsDiscord).toHaveBeenCalledWith("444", "M1", { + cfg, + accountId: "default", + limit: undefined, + }); + }); + + it.each([ + { + name: "disabled group scope", + cfg: { + channels: { + discord: { + token: "token", + groupPolicy: "disabled", + }, + }, + } as OpenClawConfig, + channel: { + id: "444", + guild_id: "111", + name: "blocked", + type: ChannelType.GuildText, + }, + }, + { + name: "explicitly disabled channel", + cfg: discordAllowlistCfg({ + "111": { + channels: { + "444": { enabled: false }, + }, + }, + }), + channel: { + id: "444", + guild_id: "111", + name: "blocked", + type: ChannelType.GuildText, + }, + }, + { + name: "disabled direct-message scope", + cfg: { + channels: { + discord: { + defaultAccount: "qa", + accounts: { + qa: { + token: "token", + groupPolicy: "open", + dm: { enabled: false, policy: "disabled" }, + guilds: { + "111": { + channels: { + "*": { enabled: true }, + }, + }, + }, + }, + }, + }, + }, + } as OpenClawConfig, + channel: { + id: "444", + type: ChannelType.DM, + }, + }, + { + name: "disabled group direct-message scope", + cfg: { + channels: { + discord: { + token: "token", + groupPolicy: "open", + dm: { enabled: true, policy: "pairing", groupEnabled: false }, + }, + }, + } as OpenClawConfig, + channel: { + id: "444", + name: "qa-group", + type: ChannelType.GroupDM, + }, + }, + { + name: "group direct-message target outside its allowlist", + cfg: { + channels: { + discord: { + token: "token", + groupPolicy: "open", + dm: { + enabled: true, + policy: "pairing", + groupEnabled: true, + groupChannels: ["allowed-group"], + }, + }, + }, + } as OpenClawConfig, + channel: { + id: "444", + name: "blocked-group", + type: ChannelType.GroupDM, + }, + }, + ])("keeps $name blocked for direct operators", async ({ cfg, channel }) => { + fetchChannelInfoDiscord.mockResolvedValueOnce(channel); + const accountId = cfg.channels?.discord?.defaultAccount; + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1", accountId }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read an enabled, allowlisted Discord group DM", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "444", + name: "allowed-group", + type: ChannelType.GroupDM, + }); + const cfg = { + channels: { + discord: { + token: "token", + groupPolicy: "disabled", + dm: { + enabled: false, + policy: "disabled", + groupEnabled: true, + groupChannels: ["allowed-group"], + }, + }, + }, + } as OpenClawConfig; + + await handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(fetchReactionsDiscord).toHaveBeenCalledWith("444", "M1", { + cfg, + accountId: "default", + limit: undefined, + }); + }); + + it("fails closed across disabled Discord scopes when target metadata is unavailable", async () => { + fetchChannelInfoDiscord.mockRejectedValueOnce(new Error("metadata unavailable")); + const cfg = { + channels: { + discord: { + token: "token", + groupPolicy: "disabled", + dm: { enabled: true, policy: "pairing" }, + }, + }, + } as OpenClawConfig; + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("fails closed when Discord metadata cannot distinguish a disabled group DM", async () => { + fetchChannelInfoDiscord.mockRejectedValueOnce(new Error("metadata unavailable")); + const cfg = { + channels: { + discord: { + token: "token", + groupPolicy: "open", + dm: { + enabled: true, + policy: "pairing", + groupEnabled: false, + }, + }, + }, + } as OpenClawConfig; + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + + it("fails closed around explicit disabled channels when target metadata is unavailable", async () => { + fetchChannelInfoDiscord.mockRejectedValueOnce(new Error("metadata unavailable")); + const cfg = { + channels: { + discord: { + token: "token", + groupPolicy: "open", + dm: { enabled: true, policy: "pairing" }, + guilds: { + "111": { + channels: { + blocked: { enabled: false }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + + await expect( + handleMessagingAction( + "reactions", + { channelId: "444", messageId: "M1" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchReactionsDiscord).not.toHaveBeenCalled(); + }); + it("removes reactions on empty emoji", async () => { await handleMessagingAction( "react", @@ -600,6 +1122,30 @@ describe("handleDiscordMessagingAction", () => { }); }); + it("rejects reaction clearing outside allowlisted Discord channels", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction( + "react", + { + channelId: "444", + messageId: "M1", + emoji: "", + }, + enableAllActions, + cfg, + ), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(removeOwnReactionsDiscord).not.toHaveBeenCalled(); + }); + it("removes reactions when remove flag set", async () => { await handleMessagingAction( "react", @@ -752,6 +1298,11 @@ describe("handleDiscordMessagingAction", () => { }); it("reads from allowlisted Discord target channels", async () => { + fetchChannelInfoDiscord.mockResolvedValueOnce({ + id: "222", + guild_id: "111", + type: ChannelType.GuildText, + }); const cfg = { channels: { discord: { @@ -777,6 +1328,183 @@ describe("handleDiscordMessagingAction", () => { ); }); + it.each([ + { + name: "delegated", + options: undefined, + }, + { + name: "direct operator", + options: { conversationReadOrigin: "direct-operator" as const }, + }, + { + name: "current conversation", + options: { + readContext: { + requesterAccountId: "default", + currentChannelProvider: "discord", + currentChannelId: "333", + }, + }, + }, + ])("rejects $name reads beneath an explicitly disabled category", async ({ options }) => { + fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => { + if (channelId === "333") { + return { + id: channelId, + guild_id: "111", + name: "enabled-child", + parent_id: "222", + type: ChannelType.GuildText, + }; + } + return { + id: "222", + guild_id: "111", + name: "private", + type: ChannelType.GuildCategory, + }; + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + private: { enabled: false }, + "333": { enabled: true }, + }, + }, + }); + const cases = [ + { + action: "permissions", + params: { channelId: "333" }, + runtime: fetchChannelPermissionsDiscord, + }, + { action: "readMessages", params: { channelId: "333" }, runtime: readMessagesDiscord }, + { action: "listPins", params: { channelId: "333" }, runtime: listPinsDiscord }, + { + action: "reactions", + params: { channelId: "333", messageId: "message-1" }, + runtime: fetchReactionsDiscord, + }, + ]; + + for (const testCase of cases) { + await expect( + handleMessagingAction(testCase.action, testCase.params, enableAllActions, cfg, options), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(testCase.runtime).not.toHaveBeenCalled(); + } + }); + + it("rejects thread reads when the thread's category ancestor is disabled", async () => { + fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => { + if (channelId === "444") { + return { + id: channelId, + guild_id: "111", + name: "project-thread", + parent_id: "333", + type: ChannelType.GuildPublicThread, + }; + } + if (channelId === "333") { + return { + id: channelId, + guild_id: "111", + name: "enabled-child", + parent_id: "222", + type: ChannelType.GuildText, + }; + } + return { + id: "222", + guild_id: "111", + name: "private", + type: ChannelType.GuildCategory, + }; + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + private: { enabled: false }, + "333": { enabled: true }, + "444": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction("readMessages", { channelId: "444" }, enableAllActions, cfg), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(fetchChannelInfoDiscord.mock.calls.map((call) => call[0])).toEqual([ + "444", + "333", + "222", + ]); + expect(readMessagesDiscord).not.toHaveBeenCalled(); + }); + + it("fails closed when disabled-channel policy exists and ancestry metadata is incomplete", async () => { + fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => { + if (channelId === "333") { + return { + id: channelId, + guild_id: "111", + name: "enabled-child", + parent_id: "222", + type: ChannelType.GuildText, + }; + } + throw new Error("metadata unavailable"); + }); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + private: { enabled: false }, + "333": { enabled: true }, + }, + }, + }); + + await expect( + handleMessagingAction("readMessages", { channelId: "333" }, enableAllActions, cfg), + ).rejects.toThrow("Discord read target channel is not allowed."); + expect(readMessagesDiscord).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "implicit guild lookup", + action: "readMessages", + params: { channelId: "333" }, + runtime: readMessagesDiscord, + }, + { + name: "explicit guild input", + action: "fetchMessage", + params: { guildId: "111", channelId: "333", messageId: "message-1" }, + runtime: fetchMessageDiscord, + }, + ])( + "fails closed for $name when disabled ancestry cannot be verified", + async ({ action, params, runtime }) => { + fetchChannelInfoDiscord.mockRejectedValueOnce(new Error("metadata unavailable")); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: false }, + "333": { enabled: true }, + }, + }, + }); + + await expect(handleMessagingAction(action, params, enableAllActions, cfg)).rejects.toThrow( + "Discord read target channel is not allowed.", + ); + expect(runtime).not.toHaveBeenCalled(); + }, + ); + it("reads from Discord target channels allowlisted under a guild slug", async () => { fetchChannelInfoDiscord.mockResolvedValueOnce({ id: "222", @@ -958,12 +1686,23 @@ describe("handleDiscordMessagingAction", () => { it("allows Discord message links in threads under allowlisted parent channels", async () => { fetchChannelInfoDiscord.mockImplementation(async (channelId: string) => { if (channelId === "333") { - return { id: "333", name: "incident-thread", parent_id: "222", type: 11 }; + return { + id: "333", + guild_id: "111", + name: "incident-thread", + parent_id: "222", + type: ChannelType.PublicThread, + }; } if (channelId === "222") { - return { id: "222", name: "team-updates", type: 0 }; + return { + id: "222", + guild_id: "111", + name: "team-updates", + type: ChannelType.GuildText, + }; } - return { id: channelId, type: 0 }; + return { id: channelId, guild_id: "111", type: ChannelType.GuildText }; }); const cfg = { channels: { @@ -1176,6 +1915,29 @@ describe("handleDiscordMessagingAction", () => { expect(searchMessagesDiscord).not.toHaveBeenCalled(); }); + it("requires explicit Discord search targets when a direct operator has disabled channels", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + blocked: { enabled: false }, + }, + }, + }); + + await expect( + handleMessagingAction( + "searchMessages", + { guildId: "111", content: "hello" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow( + "Discord message search requires channelId or channelIds so each read target can be authorized.", + ); + expect(searchMessagesDiscord).not.toHaveBeenCalled(); + }); + it("fails closed for Discord guild-wide searches when provider config is missing", async () => { const cfg = {} as OpenClawConfig; @@ -1227,11 +1989,17 @@ describe("handleDiscordMessagingAction", () => { }); it("resolves guildId from channel info when guildId is omitted in searchMessages", async () => { - fetchChannelInfoDiscord.mockResolvedValueOnce({ - id: "C1", - type: 0, - guild_id: "resolved-guild", - }); + fetchChannelInfoDiscord + .mockResolvedValueOnce({ + id: "C1", + type: ChannelType.GuildText, + guild_id: "resolved-guild", + }) + .mockResolvedValueOnce({ + id: "C1", + type: ChannelType.GuildText, + guild_id: "resolved-guild", + }); searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] }); await handleMessagingAction( @@ -1248,11 +2016,17 @@ describe("handleDiscordMessagingAction", () => { }); it("normalizes channel: prefixed channelId before resolving guildId in searchMessages", async () => { - fetchChannelInfoDiscord.mockResolvedValueOnce({ - id: "C1", - type: 0, - guild_id: "resolved-guild", - }); + fetchChannelInfoDiscord + .mockResolvedValueOnce({ + id: "C1", + type: ChannelType.GuildText, + guild_id: "resolved-guild", + }) + .mockResolvedValueOnce({ + id: "C1", + type: ChannelType.GuildText, + guild_id: "resolved-guild", + }); searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] }); await handleMessagingAction( @@ -1766,6 +2540,92 @@ describe("handleDiscordGuildAction", () => { expect(fetchRoleInfoDiscord).toHaveBeenCalledWith("111", { cfg }); }); + it("lets a direct operator read metadata for an unconfigured Discord guild", async () => { + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "*": { enabled: true }, + }, + }, + }); + + await handleGuildAction("roleInfo", { guildId: "333" }, enableAllActions, cfg, { + conversationReadOrigin: "direct-operator", + }); + + expect(fetchRoleInfoDiscord).toHaveBeenCalledWith("333", { cfg }); + }); + + it("omits explicitly disabled channels from a direct operator channel list", async () => { + const channels = [ + { id: "222", name: "configured", type: ChannelType.GuildText }, + { id: "333", name: "disabled", type: ChannelType.GuildText }, + { id: "444", name: "unconfigured", type: ChannelType.GuildText }, + ]; + listGuildChannelsDiscord.mockResolvedValueOnce(channels); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + "222": { enabled: true }, + disabled: { enabled: false }, + }, + }, + }); + + const result = await handleGuildAction( + "channelList", + { guildId: "111" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(result.details).toEqual({ + ok: true, + channels: [channels[0], channels[2]], + }); + }); + + it("omits descendants of disabled parents from a direct operator channel list", async () => { + const channels = [ + { id: "222", name: "private", type: ChannelType.GuildCategory }, + { + id: "333", + name: "private-child", + parent_id: "222", + type: ChannelType.GuildText, + }, + { + id: "555", + name: "private-thread", + parent_id: "333", + type: ChannelType.GuildPublicThread, + }, + { id: "444", name: "public", type: ChannelType.GuildText }, + ]; + listGuildChannelsDiscord.mockResolvedValueOnce(channels); + const cfg = discordAllowlistCfg({ + "111": { + channels: { + private: { enabled: false }, + }, + }, + }); + + const result = await handleGuildAction( + "channelList", + { guildId: "111" }, + enableAllActions, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(result.details).toEqual({ + ok: true, + channels: [channels[3]], + }); + }); + it("rejects Discord channel info reads for non-allowlisted target channels", async () => { fetchChannelInfoDiscord.mockResolvedValue({ id: "333", diff --git a/extensions/discord/src/actions/runtime.ts b/extensions/discord/src/actions/runtime.ts index 7026defa75fe..741bcf952c99 100644 --- a/extensions/discord/src/actions/runtime.ts +++ b/extensions/discord/src/actions/runtime.ts @@ -1,5 +1,6 @@ // Discord plugin module implements runtime behavior. import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { createDiscordActionGate } from "../accounts.js"; import { readStringParam, type OpenClawConfig } from "../runtime-api.js"; import { handleDiscordGuildAction } from "./runtime.guild.js"; @@ -7,6 +8,10 @@ import { handleDiscordMessagingAction } from "./runtime.messaging.js"; import { handleDiscordModerationAction } from "./runtime.moderation.js"; import { handleDiscordPresenceAction } from "./runtime.presence.js"; +type ConversationReadInvocationOrigin = NonNullable< + ChannelMessageActionContext["conversationReadOrigin"] +>; + const messagingActions = new Set([ "react", "reactions", @@ -66,6 +71,12 @@ export async function handleDiscordAction( }; mediaLocalRoots?: readonly string[]; mediaReadFile?: (filePath: string) => Promise; + conversationReadOrigin?: ConversationReadInvocationOrigin; + readContext?: { + requesterAccountId?: string | null; + currentChannelProvider?: string | null; + currentChannelId?: string | null; + }; }, ): Promise> { const action = readStringParam(params, "action", { required: true }); diff --git a/extensions/discord/src/channel-actions.test.ts b/extensions/discord/src/channel-actions.test.ts index f74d4fcc1bf0..00a23dcb2e64 100644 --- a/extensions/discord/src/channel-actions.test.ts +++ b/extensions/discord/src/channel-actions.test.ts @@ -517,12 +517,14 @@ describe("discordMessageActions", () => { params: { to: "channel:123", message: "hello" }, cfg, accountId: "ops", + requesterAccountId: "ops", requesterSenderId: "user-1", senderIsOwner: true, toolContext, mediaAccess, mediaLocalRoots, mediaReadFile, + conversationReadOrigin: "delegated", }); expect(handleDiscordMessageActionMock).toHaveBeenCalledWith({ @@ -530,12 +532,14 @@ describe("discordMessageActions", () => { params: { to: "channel:123", message: "hello" }, cfg, accountId: "ops", + requesterAccountId: "ops", requesterSenderId: "user-1", senderIsOwner: true, toolContext, mediaAccess, mediaLocalRoots, mediaReadFile, + conversationReadOrigin: "delegated", }); }); }); diff --git a/extensions/discord/src/channel-actions.ts b/extensions/discord/src/channel-actions.ts index 10868be529e8..2b114f8195fe 100644 --- a/extensions/discord/src/channel-actions.ts +++ b/extensions/discord/src/channel-actions.ts @@ -243,6 +243,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = { params, cfg, accountId, + requesterAccountId, requesterSenderId, senderIsOwner, toolContext, @@ -251,6 +252,7 @@ export const discordMessageActions: ChannelMessageActionAdapter = { mediaReadFile, sessionKey, inboundEventKind, + conversationReadOrigin, }) => { return await ( await loadDiscordChannelActionsRuntime() @@ -267,6 +269,8 @@ export const discordMessageActions: ChannelMessageActionAdapter = { mediaReadFile, ...(sessionKey ? { sessionKey } : {}), ...(inboundEventKind ? { inboundEventKind } : {}), + ...(requesterAccountId ? { requesterAccountId } : {}), + ...(conversationReadOrigin ? { conversationReadOrigin } : {}), }); }, }; diff --git a/extensions/discord/src/channel.test.ts b/extensions/discord/src/channel.test.ts index 59616080291c..2b81b92d30b4 100644 --- a/extensions/discord/src/channel.test.ts +++ b/extensions/discord/src/channel.test.ts @@ -200,6 +200,33 @@ beforeAll(async () => { }); describe("discordPlugin outbound", () => { + it("builds tool context with separate native and routable DM targets", () => { + const buildToolContext = discordPlugin.threading?.buildToolContext; + if (!buildToolContext) { + throw new Error("Expected discordPlugin.threading.buildToolContext to be defined"); + } + const hasRepliedRef = { value: false }; + + expect( + buildToolContext({ + cfg: {} as OpenClawConfig, + context: { + To: "user:123456789", + NativeChannelId: "987654321", + ChatType: "direct", + CurrentMessageId: "message-1", + }, + hasRepliedRef, + }), + ).toEqual({ + currentChannelId: "987654321", + currentChatType: "direct", + currentMessagingTarget: "user:123456789", + currentMessageId: "message-1", + hasRepliedRef, + }); + }); + it("avoids local require calls for bundled-only sibling modules", async () => { const source = await readFile( resolve(process.cwd(), "extensions/discord/src/channel.ts"), @@ -285,6 +312,63 @@ describe("discordPlugin outbound", () => { }); }); + it("preserves the normalized channel kind for bare current-channel ids", async () => { + const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget; + if (!resolveTarget) { + throw new Error( + "Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined", + ); + } + + await expect( + resolveTarget({ + cfg: createCfg(), + accountId: "default", + input: "1470130713209602050", + normalized: "channel:1470130713209602050", + }), + ).resolves.toEqual({ + to: "channel:1470130713209602050", + kind: "channel", + display: "1470130713209602050", + source: "normalized", + }); + }); + + it("keeps allowlisted bare Discord ids routable as DMs", async () => { + const resolveTarget = discordPlugin.messaging?.targetResolver?.resolveTarget; + if (!resolveTarget) { + throw new Error( + "Expected discordPlugin.messaging.targetResolver.resolveTarget to be defined", + ); + } + + await expect( + resolveTarget({ + cfg: { + channels: { + discord: { + accounts: { + default: { + token: "discord-token", + allowFrom: ["123456789"], + }, + }, + }, + }, + }, + accountId: "default", + input: "123456789", + normalized: "channel:123456789", + }), + ).resolves.toEqual({ + to: "user:123456789", + kind: "user", + display: "123456789", + source: "directory", + }); + }); + it("honors per-account replyToMode overrides", () => { const resolveReplyToMode = discordPlugin.threading?.resolveReplyToMode; if (!resolveReplyToMode) { diff --git a/extensions/discord/src/channel.ts b/extensions/discord/src/channel.ts index 962904ba0c15..9ffacdab7cc0 100644 --- a/extensions/discord/src/channel.ts +++ b/extensions/discord/src/channel.ts @@ -366,17 +366,17 @@ export const discordPlugin: ChannelPlugin looksLikeId: looksLikeDiscordTargetId, hint: "", resolveTarget: async ({ cfg, accountId, input, normalized, preferredKind }) => { + const defaultKind = + preferredKind === "user" || normalized.startsWith("user:") + ? "user" + : preferredKind === "channel" || + preferredKind === "group" || + normalized.startsWith("channel:") + ? "channel" + : undefined; const resolved = await ( await loadDiscordTargetResolverModule() - ).resolveDiscordTarget( - input, - { cfg, accountId }, - preferredKind === "user" - ? { defaultKind: "user" } - : preferredKind === "channel" || preferredKind === "group" - ? { defaultKind: "channel" } - : {}, - ); + ).resolveDiscordTarget(input, { cfg, accountId }, defaultKind ? { defaultKind } : {}); if (!resolved) { return null; } @@ -757,6 +757,23 @@ export const discordPlugin: ChannelPlugin resolveReplyToMode: (account) => account.config.replyToMode, fallback: "off", }, + buildToolContext: ({ context, hasRepliedRef }) => { + const currentMessagingTarget = normalizeOptionalString(context.To); + const currentChatType = + context.ChatType === "direct" || + context.ChatType === "group" || + context.ChatType === "channel" + ? context.ChatType + : undefined; + return { + currentChannelId: + normalizeOptionalString(context.NativeChannelId) ?? currentMessagingTarget, + currentChatType, + currentMessagingTarget, + currentMessageId: context.CurrentMessageId, + hasRepliedRef, + }; + }, }, outbound: { ...discordOutbound, diff --git a/extensions/discord/src/monitor/message-handler.context.test.ts b/extensions/discord/src/monitor/message-handler.context.test.ts index cea8f3ea5f59..9390739f483d 100644 --- a/extensions/discord/src/monitor/message-handler.context.test.ts +++ b/extensions/discord/src/monitor/message-handler.context.test.ts @@ -4,6 +4,17 @@ import { buildDiscordMessageProcessContext } from "./message-handler.context.js" import { createBaseDiscordMessageContext } from "./message-handler.test-harness.js"; describe("discord buildDiscordMessageProcessContext sender bot status", () => { + it("preserves the native Discord channel id for tool authorization", async () => { + const ctx = await createBaseDiscordMessageContext(); + + const result = await buildDiscordMessageProcessContext({ ctx, text: "hi", mediaList: [] }); + if (!result) { + throw new Error("expected a built Discord message context"); + } + + expect(result.ctxPayload.NativeChannelId).toBe(ctx.messageChannelId); + }); + it("forwards bot author status to ctxPayload.SenderIsBot", async () => { const ctx = await createBaseDiscordMessageContext({ author: { id: "U1", username: "alice", discriminator: "0", globalName: "Alice", bot: true }, diff --git a/extensions/discord/src/monitor/message-handler.context.ts b/extensions/discord/src/monitor/message-handler.context.ts index 0550f9e01814..2834af04dbca 100644 --- a/extensions/discord/src/monitor/message-handler.context.ts +++ b/extensions/discord/src/monitor/message-handler.context.ts @@ -343,6 +343,7 @@ export async function buildDiscordMessageProcessContext(params: { conversation: { kind: isDirectMessage ? "direct" : "channel", id: messageChannelId, + nativeChannelId: messageChannelId, label: fromLabel, spaceId: isGuildMessage ? (guildInfo?.id ?? guildSlug) || undefined : undefined, parentId: threadChannel ? threadParentId : undefined, diff --git a/extensions/feishu/src/bot.test.ts b/extensions/feishu/src/bot.test.ts index f05286e0a636..d90ec2cad451 100644 --- a/extensions/feishu/src/bot.test.ts +++ b/extensions/feishu/src/bot.test.ts @@ -2036,6 +2036,7 @@ describe("handleFeishuMessage command authorization", () => { From?: string; OriginatingChannel?: string; OriginatingTo?: string; + NativeChannelId?: string; SenderId?: string; To?: string; }>(mockFinalizeInboundContext, 0, 0); @@ -2044,6 +2045,7 @@ describe("handleFeishuMessage command authorization", () => { expect(finalized.To).toBe("chat:oc-group"); expect(finalized.OriginatingChannel).toBe("feishu"); expect(finalized.OriginatingTo).toBe("chat:oc-group"); + expect(finalized.NativeChannelId).toBe("oc-group"); expect(finalized.SenderId).toBe("ou-allowed"); const groupSessionKey = resolveGroupSessionKey(finalized as never); if (!groupSessionKey) { diff --git a/extensions/feishu/src/bot.ts b/extensions/feishu/src/bot.ts index 2812ad4044e2..43278cc3e439 100644 --- a/extensions/feishu/src/bot.ts +++ b/extensions/feishu/src/bot.ts @@ -1416,6 +1416,7 @@ export async function handleFeishuMessage(params: { conversation: { kind: isGroup ? "group" : "direct", id: ctx.chatId, + nativeChannelId: ctx.chatId, label: isGroup && groupName && !isTopicSessionForThread ? groupName : undefined, threadId: ctx.rootId && isTopicSessionForThread ? ctx.rootId : undefined, }, diff --git a/extensions/feishu/src/card-action.ts b/extensions/feishu/src/card-action.ts index 162608cc36eb..99eda8a982ec 100644 --- a/extensions/feishu/src/card-action.ts +++ b/extensions/feishu/src/card-action.ts @@ -15,6 +15,7 @@ import { FEISHU_APPROVAL_CONFIRM_ACTION, FEISHU_APPROVAL_REQUEST_ACTION, } from "./card-ux-approval.js"; +import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js"; import { createFeishuClient } from "./client.js"; import { sendCardFeishu, sendMessageFeishu } from "./send.js"; @@ -140,9 +141,8 @@ function buildSyntheticMessageEvent( // card-action-c-* IDs are temporary callback tokens, not valid Feishu message IDs. // Using them as reply targets causes "Invalid ids" errors from the streaming reply API. const isTemporaryCardActionId = replyTargetMessageId?.startsWith("card-action-c-"); - const validReplyTargetId = replyTargetMessageId && !isTemporaryCardActionId - ? replyTargetMessageId - : undefined; + const validReplyTargetId = + replyTargetMessageId && !isTemporaryCardActionId ? replyTargetMessageId : undefined; return { sender: { sender_id: { @@ -199,23 +199,6 @@ async function dispatchSyntheticCommand(params: { }); } -// Feishu's im.chat.get returns two fields: -// chat_mode: conversation type — "p2p" | "group" | "topic" -// chat_type: privacy classification — "private" | "public" -// We check chat_mode first because it directly indicates conversation type. -// "private" maps to "p2p" as the safe-failure direction (restrictive DM -// policy) — a private group chat misclassified as p2p is safer than the -// reverse. "topic" and "public" are treated as group semantics. -function normalizeResolvedCardActionChatType(value: unknown): "p2p" | "group" | undefined { - if (value === "group" || value === "topic" || value === "public") { - return "group"; - } - if (value === "p2p" || value === "private") { - return "p2p"; - } - return undefined; -} - const resolvedChatTypeCache = new Map(); const CHAT_TYPE_CACHE_TTL_MS = 30 * 60_000; const CHAT_TYPE_CACHE_MAX_SIZE = 5_000; @@ -273,7 +256,7 @@ async function resolveCardActionChatType(params: { chatType?: "p2p" | "group"; log: (message: string) => void; }): Promise<"p2p" | "group"> { - const explicitChatType = normalizeResolvedCardActionChatType(params.chatType); + const explicitChatType = normalizeFeishuChatType(params.chatType); if (explicitChatType) { return explicitChatType; } @@ -300,9 +283,7 @@ async function resolveCardActionChatType(params: { path: { chat_id: chatId }, })) as { code?: number; msg?: string; data?: { chat_type?: unknown; chat_mode?: unknown } }; if (response.code === 0) { - const resolvedChatType = - normalizeResolvedCardActionChatType(response.data?.chat_mode) ?? - normalizeResolvedCardActionChatType(response.data?.chat_type); + const resolvedChatType = resolveFeishuChatType(response.data ?? {}); if (resolvedChatType) { cacheResolvedCardActionChatType(cacheKey, resolvedChatType, now); return resolvedChatType; diff --git a/extensions/feishu/src/channel.runtime.ts b/extensions/feishu/src/channel.runtime.ts index 5a451cab5e00..aef0f270ebfc 100644 --- a/extensions/feishu/src/channel.runtime.ts +++ b/extensions/feishu/src/channel.runtime.ts @@ -1,5 +1,7 @@ // Feishu plugin module implements channel behavior. import { + assertFeishuChatMember as assertFeishuChatMemberImpl, + buildFeishuDirectChatMembers as buildFeishuDirectChatMembersImpl, getChatInfo as getChatInfoImpl, getChatMembers as getChatMembersImpl, getFeishuMemberInfo as getFeishuMemberInfoImpl, @@ -28,6 +30,8 @@ import { } from "./send.js"; export const feishuChannelRuntime = { + assertFeishuChatMember: assertFeishuChatMemberImpl, + buildFeishuDirectChatMembers: buildFeishuDirectChatMembersImpl, listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveImpl, listFeishuDirectoryPeersLive: listFeishuDirectoryPeersLiveImpl, feishuOutbound: { ...feishuOutboundImpl }, diff --git a/extensions/feishu/src/channel.test.ts b/extensions/feishu/src/channel.test.ts index ca26a42957cc..7c721a90d85c 100644 --- a/extensions/feishu/src/channel.test.ts +++ b/extensions/feishu/src/channel.test.ts @@ -18,6 +18,24 @@ const listPinsFeishuMock = vi.hoisted(() => vi.fn()); const removePinFeishuMock = vi.hoisted(() => vi.fn()); const getChatInfoMock = vi.hoisted(() => vi.fn()); const getChatMembersMock = vi.hoisted(() => vi.fn()); +const buildFeishuDirectChatMembersMock = vi.hoisted(() => + vi.fn( + (authorization: { chatId: string; memberId: string; memberIdType: "open_id" | "user_id" }) => ({ + chat_id: authorization.chatId, + has_more: false, + page_token: undefined, + members: [ + { + member_id: authorization.memberId, + name: undefined, + tenant_key: undefined, + member_id_type: authorization.memberIdType, + }, + ], + }), + ), +); +const assertFeishuChatMemberMock = vi.hoisted(() => vi.fn()); const getFeishuMemberInfoMock = vi.hoisted(() => vi.fn()); const listFeishuDirectoryPeersLiveMock = vi.hoisted(() => vi.fn()); const listFeishuDirectoryGroupsLiveMock = vi.hoisted(() => vi.fn()); @@ -38,6 +56,8 @@ vi.mock("./channel.runtime.js", () => ({ editMessageFeishu: editMessageFeishuMock, getChatInfo: getChatInfoMock, getChatMembers: getChatMembersMock, + buildFeishuDirectChatMembers: buildFeishuDirectChatMembersMock, + assertFeishuChatMember: assertFeishuChatMemberMock, getFeishuMemberInfo: getFeishuMemberInfoMock, getMessageFeishu: getMessageFeishuMock, listFeishuDirectoryGroupsLive: listFeishuDirectoryGroupsLiveMock, @@ -225,6 +245,9 @@ describe("feishuPlugin actions", () => { actions: { reactions: true, }, + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", }, }, } as OpenClawConfig; @@ -232,6 +255,11 @@ describe("feishuPlugin actions", () => { beforeEach(() => { vi.clearAllMocks(); createFeishuClientMock.mockReturnValue({ tag: "client" }); + getChatInfoMock.mockResolvedValue({ + chat_id: "oc_group_1", + chat_mode: "group", + chat_type: "private", + }); }); it("advertises the expanded Feishu action surface", () => { @@ -928,13 +956,15 @@ describe("feishuPlugin actions", () => { it("reads messages", async () => { getMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_1", + chatId: "oc_group_1", + chatType: "group", content: "hello", contentType: "text", }); const result = await feishuPlugin.actions?.handleAction?.({ action: "read", - params: { messageId: "om_1" }, + params: { messageId: "om_1", chatId: "oc_group_1" }, cfg, accountId: undefined, } as never); @@ -951,12 +981,177 @@ describe("feishuPlugin actions", () => { expect(message.content).toBe("hello"); }); + it("reads an explicit group target authorized only by groupAllowFrom", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_group_allow_from", + chatId: "oc_group_allow_from", + chatType: "group", + content: "hello", + contentType: "text", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { + messageId: "om_group_allow_from", + chatId: "oc_group_allow_from", + }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "allowlist", + groupAllowFrom: ["oc_group_allow_from"], + }, + }, + } as OpenClawConfig, + } as never), + ).resolves.toMatchObject({ + details: { + ok: true, + action: "read", + }, + }); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_allow_from"); + expect(getMessageFeishuMock).toHaveBeenCalledTimes(1); + }); + + it.each([ + { + name: "open group policy", + policy: { groupPolicy: "open" as const }, + }, + { + name: "wildcard group allowlist", + policy: { + groupPolicy: "allowlist" as const, + groupAllowFrom: ["*"], + }, + }, + ])("classifies an explicit group target before reading under $name", async ({ policy }) => { + getChatInfoMock.mockResolvedValueOnce({ + chat_id: "oc_open_group", + chat_mode: "group", + chat_type: "private", + }); + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_open_group", + chatId: "oc_open_group", + chatType: "group", + content: "hello", + contentType: "text", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { messageId: "om_open_group", chatId: "oc_open_group" }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + dmPolicy: "pairing", + ...policy, + }, + }, + } as OpenClawConfig, + } as never), + ).resolves.toMatchObject({ + details: { + ok: true, + action: "read", + }, + }); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_open_group"); + expect(getChatInfoMock.mock.invocationCallOrder[0]).toBeLessThan( + getMessageFeishuMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); + }); + + it("resolves an omitted message chat type before authorizing a group read", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_group", + chatId: "oc_group_1", + content: "hello", + contentType: "text", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { messageId: "om_group" }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "open", + dmPolicy: "pairing", + }, + }, + } as OpenClawConfig, + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_group_1", + currentChatType: "group", + }, + } as never), + ).resolves.toMatchObject({ + details: { + ok: true, + action: "read", + }, + }); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1"); + }); + + it("resolves private message visibility before applying read policy", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_private_group", + chatId: "oc_group_1", + chatType: "private", + content: "hidden", + contentType: "text", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "read", + params: { messageId: "om_private_group" }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "disabled", + dmPolicy: "open", + allowFrom: ["*"], + }, + }, + } as OpenClawConfig, + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_group_1", + currentChatType: "direct", + }, + } as never), + ).rejects.toThrow("Feishu read target is not allowed."); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1"); + }); + it("returns an error result when message reads fail", async () => { getMessageFeishuMock.mockResolvedValueOnce(null); const result = await feishuPlugin.actions?.handleAction?.({ action: "read", - params: { messageId: "om_missing" }, + params: { messageId: "om_missing", chatId: "oc_group_1" }, cfg, accountId: undefined, } as never); @@ -968,6 +1163,13 @@ describe("feishuPlugin actions", () => { }); it("edits messages", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_2", + chatId: "oc_group_1", + chatType: "group", + content: "before", + contentType: "text", + }); editMessageFeishuMock.mockResolvedValueOnce({ messageId: "om_2", contentType: "post" }); const result = await feishuPlugin.actions?.handleAction?.({ @@ -975,6 +1177,7 @@ describe("feishuPlugin actions", () => { params: { messageId: "om_2", text: "updated" }, cfg, accountId: undefined, + conversationReadOrigin: "direct-operator", } as never); expect(editMessageFeishuMock).toHaveBeenCalledWith({ @@ -1157,6 +1360,13 @@ describe("feishuPlugin actions", () => { }); it("creates pins", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_pin", + chatId: "oc_group_1", + chatType: "group", + content: "pin me", + contentType: "text", + }); createPinFeishuMock.mockResolvedValueOnce({ messageId: "om_pin", chatId: "oc_group_1" }); const result = await feishuPlugin.actions?.handleAction?.({ @@ -1164,6 +1374,7 @@ describe("feishuPlugin actions", () => { params: { messageId: "om_pin" }, cfg, accountId: undefined, + conversationReadOrigin: "direct-operator", } as never); expect(createPinFeishuMock).toHaveBeenCalledWith({ @@ -1208,11 +1419,19 @@ describe("feishuPlugin actions", () => { }); it("removes pins", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_pin", + chatId: "oc_group_1", + chatType: "group", + content: "unpin me", + contentType: "text", + }); const result = await feishuPlugin.actions?.handleAction?.({ action: "unpin", params: { messageId: "om_pin" }, cfg, accountId: undefined, + conversationReadOrigin: "direct-operator", } as never); expect(removePinFeishuMock).toHaveBeenCalledWith({ @@ -1280,13 +1499,19 @@ describe("feishuPlugin actions", () => { const result = await feishuPlugin.actions?.handleAction?.({ action: "member-info", - params: { memberId: "ou_1" }, + params: { memberId: "ou_1", chatId: "oc_group_1" }, cfg, accountId: undefined, toolContext: {}, } as never); expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_1", "open_id"); + expect(assertFeishuChatMemberMock).toHaveBeenCalledWith( + { tag: "client" }, + "oc_group_1", + "ou_1", + "open_id", + ); const details = resultDetails(result); expect(details.ok).toBe(true); const member = requireRecord(details.member, "member"); @@ -1294,12 +1519,96 @@ describe("feishuPlugin actions", () => { expect(member.name).toBe("Alice"); }); + it("uses the trusted sender identity for current direct-chat member info", async () => { + getChatInfoMock.mockResolvedValueOnce({ + chat_id: "oc_direct", + chat_mode: "p2p", + chat_type: "private", + }); + getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "ou_sender", name: "Alice" }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { memberId: "ou_sender", chatId: "oc_direct" }, + cfg, + accountId: undefined, + requesterAccountId: "default", + requesterSenderId: "ou_sender", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_direct", + }, + } as never); + + expect(assertFeishuChatMemberMock).not.toHaveBeenCalled(); + expect(getFeishuMemberInfoMock).toHaveBeenCalledWith({ tag: "client" }, "ou_sender", "open_id"); + expect(resultDetails(result).ok).toBe(true); + }); + + it("preserves a trusted user_id for current direct-chat member info", async () => { + getChatInfoMock.mockResolvedValueOnce({ + chat_id: "oc_direct", + chat_mode: "p2p", + chat_type: "private", + }); + getFeishuMemberInfoMock.mockResolvedValueOnce({ + member_id: "u_mobile_only", + member_id_type: "user_id", + name: "Mobile User", + }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { memberId: "u_mobile_only", chatId: "oc_direct" }, + cfg, + accountId: undefined, + requesterAccountId: "default", + requesterSenderId: "u_mobile_only", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_direct", + }, + } as never); + + expect(assertFeishuChatMemberMock).not.toHaveBeenCalled(); + expect(getFeishuMemberInfoMock).toHaveBeenCalledWith( + { tag: "client" }, + "u_mobile_only", + "user_id", + ); + expect(resultDetails(result).ok).toBe(true); + }); + + it("rejects unrelated member lookups in current direct chats", async () => { + getChatInfoMock.mockResolvedValueOnce({ + chat_id: "oc_direct", + chat_mode: "p2p", + chat_type: "private", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "member-info", + params: { memberId: "ou_other", chatId: "oc_direct" }, + cfg, + accountId: undefined, + requesterAccountId: "default", + requesterSenderId: "ou_sender", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_direct", + }, + } as never), + ).rejects.toThrow("limited to the current sender"); + expect(getFeishuMemberInfoMock).not.toHaveBeenCalled(); + }); + it("infers user_id lookups from the userId alias", async () => { getFeishuMemberInfoMock.mockResolvedValueOnce({ member_id: "u_1", name: "Alice" }); await feishuPlugin.actions?.handleAction?.({ action: "member-info", - params: { userId: "u_1" }, + params: { userId: "u_1", chatId: "oc_group_1" }, cfg, accountId: undefined, toolContext: {}, @@ -1313,7 +1622,7 @@ describe("feishuPlugin actions", () => { await feishuPlugin.actions?.handleAction?.({ action: "member-info", - params: { userId: "u_1", memberIdType: "open_id" }, + params: { userId: "u_1", memberIdType: "open_id", chatId: "oc_group_1" }, cfg, accountId: undefined, toolContext: {}, @@ -1337,15 +1646,16 @@ describe("feishuPlugin actions", () => { cfg, query: "eng", limit: 5, - fallbackToStatic: false, accountId: undefined, + fallbackToStatic: false, + filter: expect.any(Function), }); expect(listFeishuDirectoryPeersLiveMock).toHaveBeenCalledWith({ cfg, query: "eng", limit: 5, - fallbackToStatic: false, accountId: undefined, + fallbackToStatic: false, }); const details = resultDetails(result); expect(details.ok).toBe(true); @@ -1369,8 +1679,9 @@ describe("feishuPlugin actions", () => { cfg, query: "eng", limit: 5, - fallbackToStatic: false, accountId: undefined, + fallbackToStatic: false, + filter: expect.any(Function), }); }); @@ -1388,8 +1699,9 @@ describe("feishuPlugin actions", () => { cfg, query: "eng", limit: undefined, - fallbackToStatic: false, accountId: undefined, + fallbackToStatic: false, + filter: expect.any(Function), }); }); @@ -1443,15 +1755,50 @@ describe("feishuPlugin actions", () => { ); }); + it("adds a reaction after authorizing the direct operator's ID-only target", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + chatType: "group", + content: "hello", + contentType: "text", + }); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "react", + params: { messageId: "om_msg1", emoji: "THUMBSUP" }, + cfg, + accountId: undefined, + conversationReadOrigin: "direct-operator", + } as never); + + expect(addReactionFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_msg1", + emojiType: "THUMBSUP", + accountId: undefined, + }); + expect(resultDetails(result)).toMatchObject({ ok: true, added: "THUMBSUP" }); + }); + it("allows explicit clearAll=true when removing all bot reactions", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + chatType: "group", + content: "hello", + contentType: "text", + }); listReactionsFeishuMock.mockResolvedValueOnce([ - { reactionId: "r1", operatorType: "app" }, - { reactionId: "r2", operatorType: "app" }, + { reactionId: "r1", operatorType: "app", operatorId: "cli_main" }, + { reactionId: "r2", operatorType: "app", operatorId: "cli_main" }, + { reactionId: "r-other-app", operatorType: "app", operatorId: "cli_other" }, + { reactionId: "r-user", operatorType: "user", operatorId: "ou_user" }, ]); const result = await feishuPlugin.actions?.handleAction?.({ action: "react", - params: { messageId: "om_msg1", clearAll: true }, + params: { messageId: "om_msg1", chatId: "oc_group_1", clearAll: true }, cfg, accountId: undefined, } as never); @@ -1462,11 +1809,324 @@ describe("feishuPlugin actions", () => { accountId: undefined, }); expect(removeReactionFeishuMock).toHaveBeenCalledTimes(2); + expect(removeReactionFeishuMock).toHaveBeenNthCalledWith(1, { + cfg, + messageId: "om_msg1", + reactionId: "r1", + accountId: undefined, + }); + expect(removeReactionFeishuMock).toHaveBeenNthCalledWith(2, { + cfg, + messageId: "om_msg1", + reactionId: "r2", + accountId: undefined, + }); const details = resultDetails(result); expect(details.ok).toBe(true); expect(details.removed).toBe(2); }); + it("removes an own reaction from an authorized Feishu message", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + chatType: "group", + content: "hello", + contentType: "text", + }); + listReactionsFeishuMock.mockResolvedValueOnce([ + { reactionId: "r-other", operatorType: "app", operatorId: "cli_other" }, + { reactionId: "r1", operatorType: "app", operatorId: "cli_main" }, + ]); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "react", + params: { + messageId: "om_msg1", + chatId: "oc_group_1", + emoji: "THUMBSUP", + remove: true, + }, + cfg, + accountId: undefined, + } as never); + + expect(removeReactionFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_msg1", + reactionId: "r1", + accountId: undefined, + }); + expect(resultDetails(result)).toMatchObject({ ok: true, removed: "THUMBSUP" }); + }); + + it("does not remove another app's matching reaction", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + chatType: "group", + content: "hello", + contentType: "text", + }); + listReactionsFeishuMock.mockResolvedValueOnce([ + { reactionId: "r-other", operatorType: "app", operatorId: "cli_other" }, + { reactionId: "r-user", operatorType: "user", operatorId: "ou_user" }, + ]); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "react", + params: { + messageId: "om_msg1", + chatId: "oc_group_1", + emoji: "THUMBSUP", + remove: true, + }, + cfg, + accountId: undefined, + } as never); + + expect(removeReactionFeishuMock).not.toHaveBeenCalled(); + expect(resultDetails(result)).toMatchObject({ ok: true, removed: null }); + }); + + it("lists reactions from an authorized Feishu message", async () => { + const reactions = [{ reactionId: "r1", operatorType: "app", operatorId: "cli_main" }]; + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + chatType: "group", + content: "hello", + contentType: "text", + }); + listReactionsFeishuMock.mockResolvedValueOnce(reactions); + + const result = await feishuPlugin.actions?.handleAction?.({ + action: "reactions", + params: { messageId: "om_msg1", chatId: "oc_group_1" }, + cfg, + accountId: undefined, + } as never); + + expect(listReactionsFeishuMock).toHaveBeenCalledWith({ + cfg, + messageId: "om_msg1", + accountId: undefined, + }); + expect(resultDetails(result)).toMatchObject({ ok: true, reactions }); + }); + + it("resolves an omitted message chat type before clearing group reactions", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_msg1", + chatId: "oc_group_1", + content: "hello", + contentType: "text", + }); + listReactionsFeishuMock.mockResolvedValueOnce([]); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "react", + params: { messageId: "om_msg1", clearAll: true }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "open", + dmPolicy: "pairing", + actions: { reactions: true }, + }, + }, + } as OpenClawConfig, + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_group_1", + currentChatType: "group", + }, + } as never), + ).resolves.toMatchObject({ + details: { + ok: true, + removed: 0, + }, + }); + expect(getChatInfoMock).toHaveBeenCalledWith({ tag: "client" }, "oc_group_1"); + }); + + it.each([ + { + name: "message reads", + action: "read", + params: { messageId: "om_blocked", chatId: "oc_blocked" }, + }, + { + name: "message edits", + action: "edit", + params: { messageId: "om_blocked", chatId: "oc_blocked", text: "blocked" }, + }, + { + name: "reaction addition", + action: "react", + params: { messageId: "om_blocked", chatId: "oc_blocked", emoji: "THUMBSUP" }, + }, + { + name: "reaction removal", + action: "react", + params: { + messageId: "om_blocked", + chatId: "oc_blocked", + emoji: "THUMBSUP", + remove: true, + }, + }, + { + name: "reaction clearing", + action: "react", + params: { messageId: "om_blocked", chatId: "oc_blocked", clearAll: true }, + }, + { + name: "reaction lookup", + action: "reactions", + params: { messageId: "om_blocked", chatId: "oc_blocked" }, + }, + { + name: "pin creation", + action: "pin", + params: { messageId: "om_blocked", chatId: "oc_blocked" }, + }, + { + name: "pin removal", + action: "unpin", + params: { messageId: "om_blocked", chatId: "oc_blocked" }, + }, + { + name: "pin lookup", + action: "list-pins", + params: { chatId: "oc_blocked" }, + }, + { + name: "channel info", + action: "channel-info", + params: { chatId: "oc_blocked" }, + }, + { + name: "member info", + action: "member-info", + params: { chatId: "oc_blocked", memberId: "ou_blocked" }, + }, + ])("rejects blocked Feishu $name before provider content reads", async ({ action, params }) => { + await expect( + feishuPlugin.actions?.handleAction?.({ + action, + params, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "allowlist", + groups: { oc_allowed: {} }, + actions: { reactions: true }, + }, + }, + } as OpenClawConfig, + } as never), + ).rejects.toThrow("Feishu read target is not allowed."); + expect(getChatInfoMock).not.toHaveBeenCalled(); + expect(getMessageFeishuMock).not.toHaveBeenCalled(); + expect(listReactionsFeishuMock).not.toHaveBeenCalled(); + expect(addReactionFeishuMock).not.toHaveBeenCalled(); + expect(removeReactionFeishuMock).not.toHaveBeenCalled(); + expect(editMessageFeishuMock).not.toHaveBeenCalled(); + expect(createPinFeishuMock).not.toHaveBeenCalled(); + expect(removePinFeishuMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "message reads", + action: "read", + params: { messageId: "om_unknown", chatId: "oc_unknown" }, + }, + { + name: "pin lookup", + action: "list-pins", + params: { chatId: "oc_unknown" }, + }, + { + name: "channel info", + action: "channel-info", + params: { chatId: "oc_unknown" }, + }, + { + name: "member info", + action: "member-info", + params: { chatId: "oc_unknown", memberId: "ou_unknown" }, + }, + ])( + "does not expose failed metadata lookup details for ambiguous Feishu $name", + async ({ action, params }) => { + getChatInfoMock.mockRejectedValueOnce(new Error("chat not found")); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action, + params, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "open", + dmPolicy: "pairing", + }, + }, + } as OpenClawConfig, + } as never), + ).rejects.toThrow("Feishu read target is not allowed."); + + expect(getChatInfoMock).toHaveBeenCalledOnce(); + expect(getMessageFeishuMock).not.toHaveBeenCalled(); + expect(listPinsFeishuMock).not.toHaveBeenCalled(); + expect(getChatMembersMock).not.toHaveBeenCalled(); + expect(assertFeishuChatMemberMock).not.toHaveBeenCalled(); + expect(getFeishuMemberInfoMock).not.toHaveBeenCalled(); + }, + ); + + it("rejects a Feishu message returned from a different chat than the authorized target", async () => { + getMessageFeishuMock.mockResolvedValueOnce({ + messageId: "om_other", + chatId: "oc_other", + chatType: "group", + content: "hidden", + contentType: "text", + }); + + await expect( + feishuPlugin.actions?.handleAction?.({ + action: "reactions", + params: { messageId: "om_other", chatId: "oc_allowed" }, + cfg: { + channels: { + feishu: { + appId: "cli_main", + appSecret: "secret_main", + groupPolicy: "allowlist", + groups: { oc_allowed: {} }, + actions: { reactions: true }, + }, + }, + } as OpenClawConfig, + } as never), + ).rejects.toThrow("Feishu message target is not allowed."); + expect(getMessageFeishuMock).toHaveBeenCalledTimes(1); + expect(listReactionsFeishuMock).not.toHaveBeenCalled(); + }); + it("fails for missing params on supported actions", async () => { await expect( feishuPlugin.actions?.handleAction?.({ @@ -1597,6 +2257,30 @@ describe("feishuPlugin.messaging.resolveDeliveryTarget", () => { }); }); +describe("feishuPlugin.threading.buildToolContext", () => { + it("preserves the native chat id separately from the routable user target", () => { + const build = feishuPlugin.threading?.buildToolContext; + if (!build) { + throw new Error("Feishu threading.buildToolContext unavailable"); + } + + expect( + build({ + cfg: {} as OpenClawConfig, + context: { + To: "user:ou_sender", + NativeChannelId: "oc_direct_chat", + ChatType: "direct", + }, + }), + ).toMatchObject({ + currentChannelId: "oc_direct_chat", + currentChatType: "direct", + currentMessagingTarget: "user:ou_sender", + }); + }); +}); + describe("looksLikeFeishuId", () => { it("accepts provider-prefixed user targets", () => { expect(looksLikeFeishuId("feishu:user:ou_123")).toBe(true); diff --git a/extensions/feishu/src/channel.ts b/extensions/feishu/src/channel.ts index 50f35ffd624c..8bdc03013269 100644 --- a/extensions/feishu/src/channel.ts +++ b/extensions/feishu/src/channel.ts @@ -1,6 +1,7 @@ // Feishu plugin module implements channel behavior. import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions"; import { adaptScopedAccountAccessor, createHybridChannelConfigAdapter, @@ -37,7 +38,10 @@ import { import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime"; import { createComputedAccountStatusAdapter } from "openclaw/plugin-sdk/status-helpers"; -import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { + normalizeLowercaseStringOrEmpty, + normalizeOptionalString, +} from "openclaw/plugin-sdk/string-coerce-runtime"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; import type { PluginRuntime } from "../runtime-api.js"; import { @@ -65,6 +69,7 @@ import { DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, } from "./channel-runtime-api.js"; +import { normalizeFeishuChatType, resolveFeishuChatType } from "./chat-type.js"; import { isRecord } from "./comment-shared.js"; import { FeishuConfigSchema } from "./config-schema.js"; import { @@ -74,12 +79,26 @@ import { parseFeishuDirectConversationId, parseFeishuTargetId, } from "./conversation-id.js"; -import { listFeishuDirectoryGroups, listFeishuDirectoryPeers } from "./directory.static.js"; +import { + listAuthorizedFeishuDirectoryGroups, + listAuthorizedFeishuDirectoryPeers, + listFeishuDirectoryGroups, + listFeishuDirectoryPeers, +} from "./directory.static.js"; import { feishuDoctor } from "./doctor.js"; import { messageActionTargetAliases } from "./message-action-contract.js"; import { readNativeFeishuCardJson } from "./native-card.js"; import { resolveFeishuGroupToolPolicy } from "./policy.js"; import { buildFeishuPresentationCard } from "./presentation-card.js"; +import { + assertFeishuChatReadAllowed, + authorizeFeishuChatMemberRead, + canEnumerateAllFeishuGroups, + canEnumerateAllFeishuPeers, + isFeishuGroupReadAllowed, + isFeishuGroupReadEnabled, + resolveFeishuChatReadPreliminaryAuthorization, +} from "./read-policy.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { collectFeishuSecurityAuditFindings } from "./security-audit.js"; import { createFeishuSendReceipt } from "./send-result.js"; @@ -230,6 +249,32 @@ async function createFeishuActionClient(account: ResolvedFeishuAccount) { return createFeishuClient(account); } +async function resolveFeishuChatTypeById(params: { + account: ResolvedFeishuAccount; + chatId: string; + runtime: Awaited>; +}) { + const client = await createFeishuActionClient(params.account); + const chat = await params.runtime.getChatInfo(client, params.chatId); + return resolveFeishuChatType(chat); +} + +async function resolveFeishuMessageChatType(params: { + account: ResolvedFeishuAccount; + message: { chatId: string; chatType?: unknown }; + runtime: Awaited>; +}) { + const knownChatType = normalizeFeishuChatType(params.message.chatType); + if (knownChatType) { + return knownChatType; + } + return resolveFeishuChatTypeById({ + account: params.account, + chatId: params.message.chatId, + runtime: params.runtime, + }); +} + const collectFeishuSecurityWarnings = createAllowlistProviderGroupPolicyWarningCollector<{ cfg: ClawdbotConfig; accountId?: string | null; @@ -655,6 +700,192 @@ function resolveFeishuMessageId(params: Record): string | undef return readFirstString(params, ["messageId", "message_id", "replyTo", "reply_to"]); } +function resolveFeishuMessageReadTarget(ctx: { + params: Record; + toolContext?: { + currentChannelId?: string; + currentChatType?: "direct" | "group" | "channel"; + } | null; +}): { chatId: string; chatType?: "p2p" | "group" } | undefined { + const explicitChatId = resolveFeishuChatId({ params: ctx.params }); + const currentChatId = resolveFeishuChatId({ + params: {}, + toolContext: ctx.toolContext, + }); + const chatId = explicitChatId ?? currentChatId; + if (!chatId) { + return undefined; + } + const normalizedChatId = normalizeFeishuTarget(chatId) ?? chatId.trim(); + const normalizedCurrentChatId = currentChatId + ? (normalizeFeishuTarget(currentChatId) ?? currentChatId.trim()) + : undefined; + if (normalizedChatId !== normalizedCurrentChatId) { + return { chatId: normalizedChatId }; + } + const currentChatType = + ctx.toolContext?.currentChatType === "direct" + ? "p2p" + : ctx.toolContext?.currentChatType === "group" || + ctx.toolContext?.currentChatType === "channel" + ? "group" + : undefined; + return { chatId: normalizedChatId, chatType: currentChatType }; +} + +function assertFeishuMessageMatchesReadTarget(params: { + authorizedChatId: string; + messageChatId: string; +}) { + const messageChatId = normalizeFeishuTarget(params.messageChatId) ?? params.messageChatId.trim(); + if (messageChatId !== params.authorizedChatId) { + throw new ToolAuthorizationError("Feishu message target is not allowed."); + } +} + +async function authorizeFeishuMessageReadTarget(params: { + ctx: ChannelMessageActionContext; + account: ResolvedFeishuAccount; + runtime: Awaited>; + target: NonNullable>; +}) { + const authorize = (chatType?: "p2p" | "group") => + assertFeishuChatReadAllowed({ + cfg: params.ctx.cfg, + account: params.account, + chatId: params.target.chatId, + chatType, + ctx: params.ctx, + }); + if (params.target.chatType) { + return authorize(params.target.chatType); + } + const preliminary = resolveFeishuChatReadPreliminaryAuthorization({ + cfg: params.ctx.cfg, + account: params.account, + chatId: params.target.chatId, + ctx: params.ctx, + }); + if (preliminary.decision === "allow") { + return preliminary.chatId; + } + if (preliminary.decision === "deny") { + throw new ToolAuthorizationError("Feishu read target is not allowed."); + } + // Static policy could not distinguish group from DM. Reuse the shared + // metadata gate so lookup failures cannot become a target-existence oracle. + await getAuthorizedFeishuChatInfo({ + ctx: params.ctx, + account: params.account, + runtime: params.runtime, + chatId: params.target.chatId, + }); + return preliminary.chatId; +} + +async function getAuthorizedFeishuChatInfo(params: { + ctx: ChannelMessageActionContext; + account: ResolvedFeishuAccount; + runtime: Awaited>; + chatId: string; +}) { + const preliminary = resolveFeishuChatReadPreliminaryAuthorization({ + cfg: params.ctx.cfg, + account: params.account, + chatId: params.chatId, + ctx: params.ctx, + }); + if (preliminary.decision === "deny") { + throw new ToolAuthorizationError("Feishu read target is not allowed."); + } + const client = await createFeishuActionClient(params.account); + let chat: Awaited>; + try { + chat = await params.runtime.getChatInfo(client, preliminary.chatId); + } catch (error) { + if (preliminary.decision === "needs-metadata") { + assertFeishuChatReadAllowed({ + cfg: params.ctx.cfg, + account: params.account, + chatId: preliminary.chatId, + ctx: params.ctx, + }); + } + throw error; + } + assertFeishuChatReadAllowed({ + cfg: params.ctx.cfg, + account: params.account, + chatId: preliminary.chatId, + chatType: resolveFeishuChatType(chat), + ctx: params.ctx, + }); + return { chat, client }; +} + +async function getAuthorizedFeishuMessage(params: { + ctx: ChannelMessageActionContext; + account: ResolvedFeishuAccount; + runtime: Awaited>; + messageId: string; +}) { + // An opaque message id cannot authorize its own provider read. Gate an + // independent chat target first, then bind the provider response to it. + // Trusted direct operators may retain ID-only workflows because their + // provider read is not delegated; final account and disabled-scope policy + // still applies after the message resolves its chat. + const target = resolveFeishuMessageReadTarget(params.ctx); + if (!target && params.ctx.conversationReadOrigin !== "direct-operator") { + throw new ToolAuthorizationError( + "Feishu message reads require a chat target or current conversation.", + ); + } + const authorizedChatId = target + ? await authorizeFeishuMessageReadTarget({ + ctx: params.ctx, + account: params.account, + runtime: params.runtime, + target, + }) + : undefined; + const message = await params.runtime.getMessageFeishu({ + cfg: params.ctx.cfg, + messageId: params.messageId, + accountId: params.ctx.accountId ?? undefined, + }); + if (!message) { + return null; + } + if (authorizedChatId) { + assertFeishuMessageMatchesReadTarget({ + authorizedChatId, + messageChatId: message.chatId, + }); + } + assertFeishuChatReadAllowed({ + cfg: params.ctx.cfg, + account: params.account, + chatId: message.chatId, + chatType: await resolveFeishuMessageChatType({ + account: params.account, + message, + runtime: params.runtime, + }), + ctx: params.ctx, + }); + return message; +} + +async function requireAuthorizedFeishuMessage( + params: Parameters[0], +) { + const message = await getAuthorizedFeishuMessage(params); + if (!message) { + throw new Error(`Feishu message not found: ${params.messageId}`); + } + return message; +} + function resolveFeishuMemberId(params: Record): string | undefined { return readFirstString(params, [ "memberId", @@ -671,6 +902,12 @@ function resolveFeishuMemberId(params: Record): string | undefi function resolveFeishuMemberIdType( params: Record, ): "open_id" | "user_id" | "union_id" { + return resolveRequestedFeishuMemberIdType(params) ?? "open_id"; +} + +function resolveRequestedFeishuMemberIdType( + params: Record, +): "open_id" | "user_id" | "union_id" | undefined { const raw = readFirstString(params, [ "memberIdType", "member_id_type", @@ -692,7 +929,10 @@ function resolveFeishuMemberIdType( ) { return "union_id"; } - return "open_id"; + if (readFirstString(params, ["openId", "open_id"])) { + return "open_id"; + } + return undefined; } export const feishuPlugin: ChannelPlugin = @@ -908,11 +1148,12 @@ export const feishuPlugin: ChannelPlugin) : undefined; - const { editMessageFeishu } = await loadFeishuChannelRuntime(); - const result = await editMessageFeishu({ + const runtime = await loadFeishuChannelRuntime(); + await requireAuthorizedFeishuMessage({ + ctx, + account, + runtime, + messageId, + }); + const result = await runtime.editMessageFeishu({ cfg: ctx.cfg, messageId, text, @@ -962,8 +1209,14 @@ export const feishuPlugin: ChannelPlugin isFeishuGroupReadEnabled(ctx.cfg, account, group.id) + : canEnumerateAllFeishuGroups(ctx.cfg, account) + ? (group: { id: string }) => + isFeishuGroupReadAllowed(ctx.cfg, account, group.id, false) + : undefined, + }; if ( scope === "groups" || scope === "group" || scope === "channels" || scope === "channel" ) { - const groups = await runtime.listFeishuDirectoryGroupsLive({ - cfg: ctx.cfg, - query, - limit, - fallbackToStatic: false, - accountId: ctx.accountId ?? undefined, - }); + const groups = await listGroups(groupDirectoryParams); return jsonActionResult({ ok: true, channel: "feishu", @@ -1116,13 +1463,7 @@ export const feishuPlugin: ChannelPlugin entry.operatorType === "app"); + const ownReaction = matches.find( + (entry) => + entry.operatorType === "app" && + Boolean(account.appId) && + entry.operatorId === account.appId, + ); if (!ownReaction) { return jsonActionResult({ ok: true, removed: null }); } - await removeReactionFeishu({ + await runtime.removeReactionFeishu({ cfg: ctx.cfg, messageId, reactionId: ownReaction.reactionId, @@ -1193,16 +1532,27 @@ export const feishuPlugin: ChannelPlugin entry.operatorType === "app")) { - await removeReactionFeishu({ + const ownReactions = reactions.filter( + (entry) => + entry.operatorType === "app" && + Boolean(account.appId) && + entry.operatorId === account.appId, + ); + for (const reaction of ownReactions) { + await runtime.removeReactionFeishu({ cfg: ctx.cfg, messageId, reactionId: reaction.reactionId, @@ -1212,8 +1562,14 @@ export const feishuPlugin: ChannelPlugin ({ + currentChannelId: + normalizeOptionalString(context.NativeChannelId) ?? normalizeOptionalString(context.To), + currentChatType: + context.ChatType === "direct" || + context.ChatType === "group" || + context.ChatType === "channel" + ? context.ChatType + : undefined, + currentMessagingTarget: normalizeOptionalString(context.To), + currentThreadTs: + context.MessageThreadId != null ? String(context.MessageThreadId) : undefined, + hasRepliedRef, + }), + }, outbound: { deliveryMode: "direct", chunker: chunkTextForOutbound, diff --git a/extensions/feishu/src/chat-type.ts b/extensions/feishu/src/chat-type.ts new file mode 100644 index 000000000000..2854c38e067c --- /dev/null +++ b/extensions/feishu/src/chat-type.ts @@ -0,0 +1,28 @@ +export type ResolvedFeishuChatType = "p2p" | "group"; + +export function normalizeFeishuChatType(value: unknown): ResolvedFeishuChatType | undefined { + if (value === "group" || value === "topic_group") { + return "group"; + } + if (value === "p2p") { + return "p2p"; + } + return undefined; +} + +export function normalizeFeishuChatMode(value: unknown): ResolvedFeishuChatType | undefined { + if (value === "group" || value === "topic" || value === "topic_group") { + return "group"; + } + return value === "p2p" ? "p2p" : undefined; +} + +export function resolveFeishuChatType(chat: { + chat_mode?: unknown; + chat_type?: unknown; +}): ResolvedFeishuChatType | undefined { + // im.chat.get uses chat_mode for conversation kind; chat_type is the + // public/private visibility classification. Older response shapes and test + // adapters may still expose p2p/group there; ignore privacy-only values. + return normalizeFeishuChatMode(chat.chat_mode) ?? normalizeFeishuChatType(chat.chat_type); +} diff --git a/extensions/feishu/src/chat.test.ts b/extensions/feishu/src/chat.test.ts index 67563dcc62da..2d0e2d128d03 100644 --- a/extensions/feishu/src/chat.test.ts +++ b/extensions/feishu/src/chat.test.ts @@ -19,6 +19,34 @@ function createFeishuToolRuntime(): PluginRuntime { } describe("registerFeishuChatTools", () => { + function resolveRegisteredTool( + registerTool: ReturnType, + context: { + agentAccountId?: string; + deliveryAccountId?: string; + deliveryTo?: string; + nativeChannelId?: string; + requesterSenderId?: string; + conversationReadOrigin?: "delegated" | "direct-operator"; + } = {}, + ) { + const registered = registerTool.mock.calls[0]?.[0]; + return typeof registered === "function" + ? registered({ + messageChannel: "feishu", + agentAccountId: context.agentAccountId ?? "default", + deliveryContext: { + channel: "feishu", + to: context.deliveryTo ?? "oc_1", + accountId: context.deliveryAccountId ?? context.agentAccountId ?? "default", + }, + nativeChannelId: context.nativeChannelId, + requesterSenderId: context.requesterSenderId, + conversationReadOrigin: context.conversationReadOrigin, + }) + : registered; + } + function createChatToolApi(params: { config: OpenClawPluginApi["config"]; registerTool: OpenClawPluginApi["registerTool"]; @@ -45,6 +73,10 @@ describe("registerFeishuChatTools", () => { beforeEach(() => { vi.clearAllMocks(); + chatGetMock.mockResolvedValue({ + code: 0, + data: { chat_mode: "group", chat_type: "private" }, + }); createFeishuClientMock.mockReturnValue({ im: { chat: { get: chatGetMock }, @@ -67,6 +99,9 @@ describe("registerFeishuChatTools", () => { appId: "app_id", appSecret: "app_secret", // pragma: allowlist secret tools: { chat: true }, + dmPolicy: "open", + allowFrom: ["*"], + groupPolicy: "open", }, }, }, @@ -75,7 +110,10 @@ describe("registerFeishuChatTools", () => { ); expect(registerTool).toHaveBeenCalledTimes(1); - const tool = registerTool.mock.calls[0]?.[0]; + expect(registerTool.mock.calls[0]?.[1]).toEqual({ + name: "feishu_chat", + }); + const tool = resolveRegisteredTool(registerTool); expect(tool?.name).toBe("feishu_chat"); chatGetMock.mockResolvedValueOnce({ @@ -133,9 +171,17 @@ describe("registerFeishuChatTools", () => { }, }, }); + chatMembersGetMock.mockResolvedValueOnce({ + code: 0, + data: { + has_more: false, + items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }], + }, + }); const memberInfoResult = await tool.execute("tc_3", { action: "member_info", member_id: "ou_1", + chat_id: "oc_1", }); expect(memberInfoResult.details).toEqual({ member_id: "ou_1", @@ -168,6 +214,362 @@ describe("registerFeishuChatTools", () => { }); }); + it("allows current direct-chat reads under the default pairing policy", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool, { + deliveryTo: "user:ou_sender", + nativeChannelId: "oc_direct_chat", + }); + chatGetMock.mockResolvedValueOnce({ + code: 0, + data: { chat_mode: "p2p", chat_type: "private" }, + }); + + const result = await tool.execute("tc_current_dm", { + action: "info", + chat_id: "oc_direct_chat", + }); + + expect(result.details).toMatchObject({ + chat_id: "oc_direct_chat", + chat_mode: "p2p", + }); + }); + + it("returns the trusted sender for current direct-chat member reads", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool, { + deliveryTo: "user:ou_sender", + nativeChannelId: "oc_direct_chat", + requesterSenderId: "ou_sender", + }); + chatGetMock.mockResolvedValueOnce({ + code: 0, + data: { chat_mode: "p2p", chat_type: "private" }, + }); + + const result = await tool.execute("tc_current_dm_members", { + action: "members", + chat_id: "oc_direct_chat", + }); + + expect(result.details).toMatchObject({ + chat_id: "oc_direct_chat", + has_more: false, + members: [{ member_id: "ou_sender", member_id_type: "open_id" }], + }); + expect(chatMembersGetMock).not.toHaveBeenCalled(); + }); + + it("preserves a trusted user_id for current direct-chat member reads", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool, { + deliveryTo: "user:u_mobile_only", + nativeChannelId: "oc_direct_chat", + requesterSenderId: "u_mobile_only", + }); + chatGetMock.mockResolvedValue({ + code: 0, + data: { chat_mode: "p2p", chat_type: "private" }, + }); + contactUserGetMock.mockResolvedValueOnce({ + code: 0, + data: { user: { user_id: "u_mobile_only", name: "Mobile User" } }, + }); + + const members = await tool.execute("tc_current_dm_members_user_id", { + action: "members", + chat_id: "oc_direct_chat", + }); + const profile = await tool.execute("tc_current_dm_profile_user_id", { + action: "member_info", + chat_id: "oc_direct_chat", + member_id: "u_mobile_only", + }); + + expect(members.details).toMatchObject({ + members: [{ member_id: "u_mobile_only", member_id_type: "user_id" }], + }); + expect(profile.details).toMatchObject({ + member_id: "u_mobile_only", + member_id_type: "user_id", + }); + expect(contactUserGetMock).toHaveBeenCalledWith({ + path: { user_id: "u_mobile_only" }, + params: { + user_id_type: "user_id", + department_id_type: "open_department_id", + }, + }); + }); + + it("rejects unrelated member profiles in current direct chats", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool, { + deliveryTo: "user:ou_sender", + nativeChannelId: "oc_direct_chat", + requesterSenderId: "ou_sender", + }); + chatGetMock.mockResolvedValueOnce({ + code: 0, + data: { chat_mode: "p2p", chat_type: "private" }, + }); + + const result = await tool.execute("tc_current_dm_other_member", { + action: "member_info", + chat_id: "oc_direct_chat", + member_id: "ou_other", + }); + + expect(result.details.error).toContain("limited to the current sender"); + expect(contactUserGetMock).not.toHaveBeenCalled(); + }); + + it.each(["info", "members", "member_info"] as const)( + "rejects a blocked %s target before reading provider metadata", + async (action) => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + groups: { oc_allowed: {}, oc_blocked: { enabled: false } }, + }, + }, + }, + registerTool, + }), + ); + const tool = resolveRegisteredTool(registerTool); + const input = { + action, + chat_id: "oc_blocked", + ...(action === "member_info" ? { member_id: "ou_member" } : {}), + }; + + const result = await tool.execute(`tc_blocked_${action}`, input); + + expect(result.details.error).toContain("Feishu read target is not allowed."); + expect(chatGetMock).not.toHaveBeenCalled(); + expect(chatMembersGetMock).not.toHaveBeenCalled(); + expect(contactUserGetMock).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + name: "an existing blocked direct chat", + response: { + code: 0, + data: { chat_mode: "p2p", chat_type: "private" }, + }, + }, + { + name: "a failed metadata lookup", + response: { + code: 230001, + msg: "chat not found", + }, + }, + ])("does not expose whether an ambiguous target is $name", async ({ response }) => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "open", + }, + }, + }, + registerTool, + }), + ); + const tool = resolveRegisteredTool(registerTool, { + nativeChannelId: "oc_current", + }); + chatGetMock.mockResolvedValueOnce(response); + + const result = await tool.execute("tc_ambiguous_target", { + action: "info", + chat_id: "oc_other", + }); + + expect(result.details.error).toContain("Feishu read target is not allowed."); + expect(result.details.error).not.toContain("chat not found"); + expect(chatGetMock).toHaveBeenCalledOnce(); + expect(chatMembersGetMock).not.toHaveBeenCalled(); + expect(contactUserGetMock).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read an unconfigured group", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + registerTool, + }), + ); + const tool = resolveRegisteredTool(registerTool, { + conversationReadOrigin: "direct-operator", + }); + chatGetMock.mockResolvedValueOnce({ + code: 0, + data: { chat_mode: "group", name: "operator target" }, + }); + + const result = await tool.execute("tc_direct_operator", { + action: "info", + chat_id: "oc_unconfigured", + }); + + expect(result.details).toMatchObject({ + chat_id: "oc_unconfigured", + name: "operator target", + }); + }); + + it("routes chat reads through the contextual Feishu account", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + defaultAccount: "a", + accounts: { + a: { + appId: "app_a", + appSecret: "secret_a", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + b: { + appId: "app_b", + appSecret: "secret_b", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "allowlist", + }, + }, + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool, { + agentAccountId: "b", + deliveryAccountId: "b", + nativeChannelId: "oc_1", + }); + chatGetMock.mockResolvedValueOnce({ + code: 0, + data: { name: "account b chat" }, + }); + + const result = await tool.execute("tc_account_b", { + action: "info", + chat_id: "oc_1", + }); + + expect(result.details).toMatchObject({ + chat_id: "oc_1", + name: "account b chat", + }); + expect(createFeishuClientMock).toHaveBeenCalledWith( + expect.objectContaining({ accountId: "b" }), + ); + }); + it("advertises and validates member page_size as a positive integer", async () => { const registerTool = vi.fn(); registerFeishuChatTools( @@ -179,6 +581,7 @@ describe("registerFeishuChatTools", () => { appId: "app_id", appSecret: "app_secret", // pragma: allowlist secret tools: { chat: true }, + groupPolicy: "open", }, }, }, @@ -186,7 +589,7 @@ describe("registerFeishuChatTools", () => { }), ); - const tool = registerTool.mock.calls[0]?.[0]; + const tool = resolveRegisteredTool(registerTool); expect(tool?.parameters.properties.page_size).toMatchObject({ type: "integer", minimum: 1, @@ -253,6 +656,7 @@ describe("registerFeishuChatTools", () => { appId: "app_id", appSecret: "app_secret", // pragma: allowlist secret tools: { chat: true }, + groupPolicy: "open", }, }, }, @@ -260,7 +664,14 @@ describe("registerFeishuChatTools", () => { }), ); - const tool = registerTool.mock.calls[0]?.[0]; + const tool = resolveRegisteredTool(registerTool); + chatMembersGetMock.mockResolvedValueOnce({ + code: 0, + data: { + has_more: false, + items: [{ member_id: "ou_1", name: "member1", member_id_type: "open_id" }], + }, + }); contactUserGetMock.mockRejectedValueOnce( Object.assign(new Error("Request failed with status code 400"), { response: { @@ -280,6 +691,7 @@ describe("registerFeishuChatTools", () => { const result = await tool.execute("tc_4", { action: "member_info", member_id: "ou_1", + chat_id: "oc_1", }); expect(result.details.error).toContain('"http_status":400'); @@ -292,4 +704,43 @@ describe("registerFeishuChatTools", () => { '"feishu_troubleshooter":"https://open.feishu.cn/search?log_id=20260429124800CHAT"', ); }); + + it("rejects repeated member-list page tokens", async () => { + const registerTool = vi.fn(); + registerFeishuChatTools( + createChatToolApi({ + config: { + channels: { + feishu: { + enabled: true, + appId: "app_id", + appSecret: "app_secret", // pragma: allowlist secret + tools: { chat: true }, + groupPolicy: "open", + }, + }, + }, + registerTool, + }), + ); + + const tool = resolveRegisteredTool(registerTool); + chatMembersGetMock.mockResolvedValue({ + code: 0, + data: { + has_more: true, + page_token: "same-token", + items: [], + }, + }); + + const result = await tool.execute("tc_repeated_page", { + action: "member_info", + member_id: "ou_missing", + chat_id: "oc_1", + }); + + expect(result.details.error).toContain("pagination repeated token"); + expect(chatMembersGetMock).toHaveBeenCalledTimes(2); + }); }); diff --git a/extensions/feishu/src/chat.ts b/extensions/feishu/src/chat.ts index 49339890e715..b251fc60c6fa 100644 --- a/extensions/feishu/src/chat.ts +++ b/extensions/feishu/src/chat.ts @@ -1,13 +1,21 @@ // Feishu plugin module implements chat behavior. import type * as Lark from "@larksuiteoapi/node-sdk"; import { readPositiveIntegerParam } from "openclaw/plugin-sdk/param-readers"; +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; import { jsonResult as json } from "openclaw/plugin-sdk/tool-results"; import type { OpenClawPluginApi } from "../runtime-api.js"; import { listEnabledFeishuAccounts } from "./accounts.js"; import { FeishuChatSchema, type FeishuChatParams } from "./chat-schema.js"; +import { resolveFeishuChatType } from "./chat-type.js"; import { createFeishuClient } from "./client.js"; import { formatFeishuApiError } from "./comment-shared.js"; -import { resolveToolsConfig } from "./tools-config.js"; +import { + assertFeishuChatReadAllowed, + authorizeFeishuChatMemberRead, + resolveFeishuChatReadPreliminaryAuthorization, + type FeishuChatMemberReadAuthorization, +} from "./read-policy.js"; +import { resolveAnyEnabledFeishuToolsConfig, resolveFeishuToolAccount } from "./tool-account.js"; function readChatPageSize(params: Record): number | undefined { return readPositiveIntegerParam(params, "page_size", { @@ -16,6 +24,24 @@ function readChatPageSize(params: Record): number | undefined { }); } +export function buildFeishuDirectChatMembers( + authorization: Extract, +) { + return { + chat_id: authorization.chatId, + has_more: false, + page_token: undefined, + members: [ + { + member_id: authorization.memberId, + name: undefined, + tenant_key: undefined, + member_id_type: authorization.memberIdType, + }, + ], + }; +} + export async function getChatInfo(client: Lark.Client, chatId: string) { const res = await client.im.chat.get({ path: { chat_id: chatId } }); if (res.code !== 0) { @@ -40,6 +66,69 @@ export async function getChatInfo(client: Lark.Client, chatId: string) { }; } +function authorizeFeishuChatInfo(params: { + cfg: NonNullable; + account: ReturnType; + chatId: string; + chat: Awaited>; + ctx: OpenClawPluginToolContext; +}): void { + assertFeishuChatReadAllowed({ + cfg: params.cfg, + account: params.account, + chatId: params.chatId, + chatType: resolveFeishuChatType(params.chat), + ctx: params.ctx, + }); +} + +async function getAuthorizedFeishuChatInfo(params: { + client: Lark.Client; + cfg: NonNullable; + account: ReturnType; + chatId: string; + ctx: OpenClawPluginToolContext; +}) { + const preliminary = resolveFeishuChatReadPreliminaryAuthorization({ + cfg: params.cfg, + account: params.account, + chatId: params.chatId, + ctx: params.ctx, + }); + if (preliminary.decision === "deny") { + assertFeishuChatReadAllowed({ + cfg: params.cfg, + account: params.account, + chatId: preliminary.chatId, + ctx: params.ctx, + }); + } + let chat: Awaited>; + try { + // Only targets with at least one authorized conversation kind reach metadata. + // Hide lookup failures when type is needed so metadata cannot become an existence oracle. + chat = await getChatInfo(params.client, preliminary.chatId); + } catch (error) { + if (preliminary.decision === "needs-metadata") { + assertFeishuChatReadAllowed({ + cfg: params.cfg, + account: params.account, + chatId: preliminary.chatId, + ctx: params.ctx, + }); + } + throw error; + } + authorizeFeishuChatInfo({ + cfg: params.cfg, + account: params.account, + chatId: preliminary.chatId, + chat, + ctx: params.ctx, + }); + return chat; +} + export async function getChatMembers( client: Lark.Client, chatId: string, @@ -75,6 +164,31 @@ export async function getChatMembers( }; } +export async function assertFeishuChatMember( + client: Lark.Client, + chatId: string, + memberId: string, + memberIdType: "open_id" | "user_id" | "union_id" = "open_id", +): Promise { + let pageToken: string | undefined; + const seenPageTokens = new Set(); + while (true) { + const members = await getChatMembers(client, chatId, 100, pageToken, memberIdType); + if (members.members.some((member) => member.member_id === memberId)) { + return; + } + if (!members.has_more || !members.page_token) { + break; + } + if (seenPageTokens.has(members.page_token)) { + throw new Error(`Feishu chat member pagination repeated token for chat ${chatId}`); + } + seenPageTokens.add(members.page_token); + pageToken = members.page_token; + } + throw new Error(`Member ${memberId} is not a member of chat ${chatId}`); +} + export async function getFeishuMemberInfo( client: Lark.Client, memberId: string, @@ -128,22 +242,20 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { if (!api.config) { return; } + const cfg = api.config; - const accounts = listEnabledFeishuAccounts(api.config); + const accounts = listEnabledFeishuAccounts(cfg); if (accounts.length === 0) { return; } - const firstAccount = accounts[0]; - const toolsCfg = resolveToolsConfig(firstAccount.config.tools); + const toolsCfg = resolveAnyEnabledFeishuToolsConfig(accounts); if (!toolsCfg.chat) { return; } - const getClient = () => createFeishuClient(firstAccount); - api.registerTool( - { + (toolContext: OpenClawPluginToolContext) => ({ name: "feishu_chat", label: "Feishu Chat", description: "Feishu chat operations. Actions: members, info, member_info", @@ -152,12 +264,37 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { const rawParams = params as Record; const p = params as FeishuChatParams; try { - const client = getClient(); + const account = resolveFeishuToolAccount({ + api, + defaultAccountId: toolContext.agentAccountId, + requiredTool: { family: "chat", label: "chat" }, + }); + const client = createFeishuClient(account); switch (p.action) { case "members": if (!p.chat_id) { return json({ error: "chat_id is required for action members" }); } + { + const chat = await getAuthorizedFeishuChatInfo({ + client, + cfg, + account, + chatId: p.chat_id, + ctx: toolContext, + }); + const authorization = authorizeFeishuChatMemberRead({ + cfg, + account, + chatId: p.chat_id, + chatType: resolveFeishuChatType(chat), + ctx: toolContext, + memberIdType: p.member_id_type, + }); + if (authorization.kind === "direct") { + return json(buildFeishuDirectChatMembers(authorization)); + } + } return json( await getChatMembers( client, @@ -171,14 +308,53 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { if (!p.chat_id) { return json({ error: "chat_id is required for action info" }); } - return json(await getChatInfo(client, p.chat_id)); + { + const chat = await getAuthorizedFeishuChatInfo({ + client, + cfg, + account, + chatId: p.chat_id, + ctx: toolContext, + }); + return json(chat); + } case "member_info": if (!p.member_id) { return json({ error: "member_id is required for action member_info" }); } - return json( - await getFeishuMemberInfo(client, p.member_id, p.member_id_type ?? "open_id"), - ); + if (!p.chat_id) { + return json({ error: "chat_id is required for action member_info" }); + } + { + const chat = await getAuthorizedFeishuChatInfo({ + client, + cfg, + account, + chatId: p.chat_id, + ctx: toolContext, + }); + const authorization = authorizeFeishuChatMemberRead({ + cfg, + account, + chatId: p.chat_id, + chatType: resolveFeishuChatType(chat), + ctx: toolContext, + memberId: p.member_id, + memberIdType: p.member_id_type, + }); + if (authorization.kind === "group") { + const memberIdType = p.member_id_type ?? "open_id"; + await assertFeishuChatMember(client, p.chat_id, p.member_id, memberIdType); + return json(await getFeishuMemberInfo(client, p.member_id, memberIdType)); + } + return json( + await getFeishuMemberInfo( + client, + authorization.memberId, + authorization.memberIdType, + ), + ); + } default: return json({ error: `Unknown action: ${String(p.action)}` }); } @@ -186,7 +362,9 @@ export function registerFeishuChatTools(api: OpenClawPluginApi) { return json({ error: formatFeishuApiError(err, { includeNestedErrorLogId: true }) }); } }, + }), + { + name: "feishu_chat", }, - { name: "feishu_chat" }, ); } diff --git a/extensions/feishu/src/directory.static.ts b/extensions/feishu/src/directory.static.ts index 8ed87d6ddc39..ee36fb12a1f8 100644 --- a/extensions/feishu/src/directory.static.ts +++ b/extensions/feishu/src/directory.static.ts @@ -1,10 +1,13 @@ // Feishu plugin module implements directory.static behavior. import { + applyDirectoryQueryAndLimit, listDirectoryGroupEntriesFromMapKeysAndAllowFrom, + listDirectoryUserEntriesFromAllowFrom, listDirectoryUserEntriesFromAllowFromAndMapKeys, } from "openclaw/plugin-sdk/directory-runtime"; import type { ClawdbotConfig } from "../runtime-api.js"; import { resolveFeishuAccount } from "./accounts.js"; +import { isFeishuGroupReadAllowed } from "./read-policy.js"; import { normalizeFeishuTarget } from "./targets.js"; export type FeishuDirectoryPeer = { @@ -60,3 +63,44 @@ export async function listFeishuDirectoryGroups(params: { }); return toFeishuDirectoryGroups(entries.map((entry) => entry.id)); } + +export async function listAuthorizedFeishuDirectoryPeers(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const entries = listDirectoryUserEntriesFromAllowFrom({ + allowFrom: account.config.allowFrom, + query: params.query, + limit: params.limit, + normalizeId: (entry) => normalizeFeishuTarget(entry) ?? entry, + }); + return toFeishuDirectoryPeers(entries.map((entry) => entry.id)); +} + +export async function listAuthorizedFeishuDirectoryGroups(params: { + cfg: ClawdbotConfig; + query?: string; + limit?: number; + accountId?: string; +}): Promise { + const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); + const enabledGroups = Object.fromEntries( + Object.entries(account.config.groups ?? {}).filter(([, group]) => group?.enabled !== false), + ); + const entries = listDirectoryGroupEntriesFromMapKeysAndAllowFrom({ + groups: enabledGroups, + allowFrom: account.config.groupAllowFrom, + }); + const authorizedEntries = entries.filter((entry) => + isFeishuGroupReadAllowed(params.cfg, account, entry.id, false), + ); + return toFeishuDirectoryGroups( + applyDirectoryQueryAndLimit( + authorizedEntries.map((entry) => entry.id), + params, + ), + ); +} diff --git a/extensions/feishu/src/directory.test.ts b/extensions/feishu/src/directory.test.ts index 094f08387a1c..9bde026913a0 100644 --- a/extensions/feishu/src/directory.test.ts +++ b/extensions/feishu/src/directory.test.ts @@ -15,6 +15,11 @@ const { listFeishuDirectoryGroupsLive, listFeishuDirectoryPeersLive } = await im const { listFeishuDirectoryGroups, listFeishuDirectoryPeers } = await importFreshModule< typeof import("./directory.static.js") >(import.meta.url, "./directory.static.js?directory-test"); +const { listAuthorizedFeishuDirectoryGroups, listAuthorizedFeishuDirectoryPeers } = + await importFreshModule( + import.meta.url, + "./directory.static.js?authorized-directory-test", + ); function makeStaticCfg(): ClawdbotConfig { return { @@ -92,6 +97,63 @@ describe("feishu directory (config-backed)", () => { ]); }); + it("lists only read-authorized static peers and enabled groups", async () => { + const cfg = makeStaticCfg(); + const feishu = cfg.channels?.feishu; + if (!feishu) { + throw new Error("Expected Feishu config"); + } + feishu.groups = { + ...feishu.groups, + "chat-disabled": { enabled: false }, + }; + + await expect(listAuthorizedFeishuDirectoryPeers({ cfg })).resolves.toEqual([ + { kind: "user", id: "alice" }, + { kind: "user", id: "bob" }, + ]); + await expect(listAuthorizedFeishuDirectoryGroups({ cfg })).resolves.toEqual([ + { kind: "group", id: "chat-1" }, + { kind: "group", id: "chat-2" }, + ]); + }); + + it("keeps explicitly disabled groups out even when groupAllowFrom includes them", async () => { + const cfg = makeStaticCfg(); + const feishu = cfg.channels?.feishu; + if (!feishu) { + throw new Error("Expected Feishu config"); + } + feishu.groups = { + ...feishu.groups, + "chat-disabled": { enabled: false }, + }; + feishu.groupAllowFrom = [...(feishu.groupAllowFrom ?? []), "chat-disabled"]; + + await expect(listAuthorizedFeishuDirectoryGroups({ cfg })).resolves.toEqual([ + { kind: "group", id: "chat-1" }, + { kind: "group", id: "chat-2" }, + ]); + }); + + it("applies the static group limit after authorization filtering", async () => { + const cfg = { + channels: { + feishu: { + groupPolicy: "allowlist", + groups: { + "chat-blocked": { enabled: false }, + "chat-allowed": {}, + }, + }, + }, + } as ClawdbotConfig; + + await expect(listAuthorizedFeishuDirectoryGroups({ cfg, limit: 1 })).resolves.toEqual([ + { kind: "group", id: "chat-allowed" }, + ]); + }); + it("falls back to static peers on live lookup failure by default", async () => { createFeishuClientMock.mockReturnValueOnce({ contact: { @@ -110,6 +172,66 @@ describe("feishu directory (config-backed)", () => { ]); }); + it("paginates live groups until the filtered result limit is reached", async () => { + const list = vi + .fn() + .mockResolvedValueOnce({ + code: 0, + data: { + items: [{ chat_id: "chat-blocked", name: "Blocked" }], + has_more: true, + page_token: "page-2", + }, + }) + .mockResolvedValueOnce({ + code: 0, + data: { + items: [{ chat_id: "chat-allowed", name: "Allowed" }], + has_more: false, + }, + }); + createFeishuClientMock.mockReturnValueOnce({ + im: { chat: { list } }, + }); + + await expect( + listFeishuDirectoryGroupsLive({ + cfg: makeConfiguredCfg(), + limit: 1, + filter: (group) => group.id !== "chat-blocked", + }), + ).resolves.toEqual([{ kind: "group", id: "chat-allowed", name: "Allowed" }]); + expect(list).toHaveBeenNthCalledWith(2, { + params: { + page_size: 1, + page_token: "page-2", + }, + }); + }); + + it("rejects repeated live group directory page tokens", async () => { + const list = vi.fn().mockResolvedValue({ + code: 0, + data: { + items: [{ chat_id: "chat-blocked", name: "Blocked" }], + has_more: true, + page_token: "repeat", + }, + }); + createFeishuClientMock.mockReturnValueOnce({ + im: { chat: { list } }, + }); + + await expect( + listFeishuDirectoryGroupsLive({ + cfg: makeConfiguredCfg(), + filter: () => false, + fallbackToStatic: false, + }), + ).rejects.toThrow("Feishu live group directory returned a repeated page token"); + expect(list).toHaveBeenCalledTimes(2); + }); + it("surfaces live peer lookup failures when fallback is disabled", async () => { createFeishuClientMock.mockReturnValueOnce({ contact: { diff --git a/extensions/feishu/src/directory.ts b/extensions/feishu/src/directory.ts index 880b0ad1a26f..5e2e217ecf21 100644 --- a/extensions/feishu/src/directory.ts +++ b/extensions/feishu/src/directory.ts @@ -10,6 +10,8 @@ import { type FeishuDirectoryPeer, } from "./directory.static.js"; +const MAX_FEISHU_DIRECTORY_PAGES = 100; + export async function listFeishuDirectoryPeersLive(params: { cfg: ClawdbotConfig; query?: string; @@ -73,6 +75,7 @@ export async function listFeishuDirectoryGroupsLive(params: { limit?: number; accountId?: string; fallbackToStatic?: boolean; + filter?: (group: FeishuDirectoryGroup) => boolean; }): Promise { const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId }); if (!account.configured) { @@ -83,36 +86,52 @@ export async function listFeishuDirectoryGroupsLive(params: { const client = createFeishuClient(account); const groups: FeishuDirectoryGroup[] = []; const limit = params.limit ?? 50; - - const response = await client.im.chat.list({ - params: { - page_size: Math.min(limit, 100), - }, - }); - - if (response.code !== 0) { - throw new Error(response.msg || `code ${response.code}`); - } - const q = normalizeLowercaseStringOrEmpty(params.query); - for (const chat of response.data?.items ?? []) { - if (chat.chat_id) { - const name = chat.name || ""; - if ( - !q || - normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) || - normalizeLowercaseStringOrEmpty(name).includes(q) - ) { - groups.push({ + let pageToken: string | undefined; + let pages = 0; + const seenPageTokens = new Set(); + do { + const response = await client.im.chat.list({ + params: { + page_size: Math.min(limit, 100), + page_token: pageToken, + }, + }); + if (response.code !== 0) { + throw new Error(response.msg || `code ${response.code}`); + } + for (const chat of response.data?.items ?? []) { + if (chat.chat_id) { + const name = chat.name || ""; + const group = { kind: "group", id: chat.chat_id, name: name || undefined, - }); + } satisfies FeishuDirectoryGroup; + const matchesQuery = + !q || + normalizeLowercaseStringOrEmpty(chat.chat_id).includes(q) || + normalizeLowercaseStringOrEmpty(name).includes(q); + if (matchesQuery && (!params.filter || params.filter(group))) { + groups.push(group); + } + } + if (groups.length >= limit) { + break; } } - if (groups.length >= limit) { - break; + pages += 1; + const nextPageToken = response.data?.has_more ? response.data.page_token : undefined; + if (nextPageToken && seenPageTokens.has(nextPageToken)) { + throw new Error("Feishu live group directory returned a repeated page token"); } + if (nextPageToken) { + seenPageTokens.add(nextPageToken); + } + pageToken = nextPageToken; + } while (pageToken && groups.length < limit && pages < MAX_FEISHU_DIRECTORY_PAGES); + if (pageToken && pages >= MAX_FEISHU_DIRECTORY_PAGES) { + throw new Error("Feishu live group directory pagination limit exceeded"); } return groups; diff --git a/extensions/feishu/src/reactions.test.ts b/extensions/feishu/src/reactions.test.ts new file mode 100644 index 000000000000..c5cf132a4c1b --- /dev/null +++ b/extensions/feishu/src/reactions.test.ts @@ -0,0 +1,112 @@ +// Feishu tests cover reactions plugin behavior. +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ClawdbotConfig } from "../runtime-api.js"; + +const listMock = vi.hoisted(() => vi.fn()); + +vi.mock("./accounts.js", () => ({ + resolveFeishuRuntimeAccount: () => ({ + accountId: "default", + configured: true, + appId: "cli_main", + appSecret: "secret", + domain: "feishu", + }), +})); + +vi.mock("./client.js", () => ({ + createFeishuClient: () => ({ + im: { + messageReaction: { + list: listMock, + }, + }, + }), +})); + +import { listReactionsFeishu } from "./reactions.js"; + +describe("listReactionsFeishu", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("reads the SDK's nested operator ownership fields", async () => { + listMock.mockResolvedValue({ + code: 0, + data: { + items: [ + { + reaction_id: "r-app", + reaction_type: { emoji_type: "THUMBSUP" }, + operator: { operator_type: "app", operator_id: "cli_main" }, + }, + { + reaction_id: "r-user", + reaction_type: { emoji_type: "HEART" }, + operator: { operator_type: "user", operator_id: "ou_user" }, + }, + ], + }, + }); + + await expect( + listReactionsFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_message", + }), + ).resolves.toEqual([ + { + reactionId: "r-app", + emojiType: "THUMBSUP", + operatorType: "app", + operatorId: "cli_main", + }, + { + reactionId: "r-user", + emojiType: "HEART", + operatorType: "user", + operatorId: "ou_user", + }, + ]); + }); + + it("fails closed for missing or unrecognized operator metadata", async () => { + listMock.mockResolvedValue({ + code: 0, + data: { + items: [ + { + reaction_id: "r-missing", + reaction_type: { emoji_type: "THUMBSUP" }, + }, + { + reaction_id: "r-unknown", + reaction_type: { emoji_type: "HEART" }, + operator: { operator_type: "tenant", operator_id: "tenant-1" }, + }, + ], + }, + }); + + const reactions = await listReactionsFeishu({ + cfg: {} as ClawdbotConfig, + messageId: "om_message", + }); + + expect(reactions).toEqual([ + { + reactionId: "r-missing", + emojiType: "THUMBSUP", + operatorType: "unknown", + operatorId: "", + }, + { + reactionId: "r-unknown", + emojiType: "HEART", + operatorType: "unknown", + operatorId: "tenant-1", + }, + ]); + }); +}); diff --git a/extensions/feishu/src/reactions.ts b/extensions/feishu/src/reactions.ts index b5de88ed508d..82fd808aa324 100644 --- a/extensions/feishu/src/reactions.ts +++ b/extensions/feishu/src/reactions.ts @@ -6,7 +6,7 @@ import { createFeishuClient } from "./client.js"; type FeishuReaction = { reactionId: string; emojiType: string; - operatorType: "app" | "user"; + operatorType: "app" | "user" | "unknown"; operatorId: string; }; @@ -105,8 +105,10 @@ export async function listReactionsFeishu(params: { items?: Array<{ reaction_id?: string; reaction_type?: { emoji_type?: string }; - operator_type?: string; - operator_id?: { open_id?: string; user_id?: string; union_id?: string }; + operator?: { + operator_type?: string; + operator_id?: string; + }; }>; }; }; @@ -117,8 +119,12 @@ export async function listReactionsFeishu(params: { return items.map((item) => ({ reactionId: item.reaction_id ?? "", emojiType: item.reaction_type?.emoji_type ?? "", - operatorType: item.operator_type === "app" ? "app" : "user", - operatorId: - item.operator_id?.open_id ?? item.operator_id?.user_id ?? item.operator_id?.union_id ?? "", + operatorType: + item.operator?.operator_type === "app" + ? "app" + : item.operator?.operator_type === "user" + ? "user" + : "unknown", + operatorId: item.operator?.operator_id ?? "", })); } diff --git a/extensions/feishu/src/read-policy.test.ts b/extensions/feishu/src/read-policy.test.ts new file mode 100644 index 000000000000..636c4970ec41 --- /dev/null +++ b/extensions/feishu/src/read-policy.test.ts @@ -0,0 +1,369 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import { describe, expect, it } from "vitest"; +import { resolveFeishuAccount } from "./accounts.js"; +import { resolveFeishuChatType } from "./chat-type.js"; +import { + assertFeishuChatReadAllowed, + canEnumerateAllFeishuGroups, + canEnumerateAllFeishuPeers, + resolveFeishuChatReadPreliminaryAuthorization, +} from "./read-policy.js"; +import type { ResolvedFeishuAccount } from "./types.js"; + +const cfg = { channels: { feishu: {} } } as OpenClawConfig; + +function createAccount(): ResolvedFeishuAccount { + return { + accountId: "default", + selectionSource: "fallback", + enabled: true, + configured: true, + domain: "feishu", + config: { + groupPolicy: "allowlist", + dmPolicy: "pairing", + } as ResolvedFeishuAccount["config"], + }; +} + +describe("Feishu read policy", () => { + it("does not derive conversation kind from public/private visibility", () => { + expect(resolveFeishuChatType({ chat_type: "private" })).toBeUndefined(); + expect(resolveFeishuChatType({ chat_type: "public" })).toBeUndefined(); + expect(resolveFeishuChatType({ chat_type: "p2p" })).toBe("p2p"); + expect(resolveFeishuChatType({ chat_type: "group" })).toBe("group"); + expect(resolveFeishuChatType({ chat_mode: "group", chat_type: "private" })).toBe("group"); + expect(resolveFeishuChatType({ chat_mode: "p2p", chat_type: "private" })).toBe("p2p"); + }); + + it("allows only the trusted current chat when the target type is unknown", () => { + const account = createAccount(); + const ctx = { + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_current", + }, + }; + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_current", + ctx, + }), + ).toBe("oc_current"); + expect(() => + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_other", + ctx, + }), + ).toThrow("Feishu read target is not allowed."); + }); + + it("does not treat public delivery routing as trusted current-chat identity", () => { + const account = createAccount(); + + expect(() => + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_unconfigured", + chatType: "group", + ctx: { + agentAccountId: "default", + messageChannel: "feishu", + deliveryContext: { + channel: "feishu", + to: "oc_unconfigured", + accountId: "default", + }, + }, + }), + ).toThrow("Feishu read target is not allowed."); + }); + + it("allows native Feishu ingress to identify the current chat", () => { + const account = createAccount(); + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_current", + chatType: "group", + ctx: { + agentAccountId: "default", + messageChannel: "feishu", + nativeChannelId: "oc_current", + deliveryContext: { + channel: "feishu", + to: "oc_current", + accountId: "default", + }, + }, + }), + ).toBe("oc_current"); + }); + + it("does not treat wildcard group defaults as admission", () => { + const account = createAccount(); + account.config = { + ...account.config, + dmPolicy: "open", + groups: { "*": { requireMention: false } }, + }; + + expect(() => + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_unconfigured", + ctx: {}, + }), + ).toThrow("Feishu read target is not allowed."); + }); + + it("requires an effective wildcard before open policy allows non-current DMs", () => { + const mergedCfg = { + channels: { + feishu: { + appId: "cli_test", + appSecret: "secret_test", + dmPolicy: "allowlist", + allowFrom: ["ou_admin"], + accounts: { + sales: { + dmPolicy: "open", + }, + }, + }, + }, + } as OpenClawConfig; + const account = resolveFeishuAccount({ cfg: mergedCfg, accountId: "sales" }); + + expect(() => + assertFeishuChatReadAllowed({ + cfg: mergedCfg, + account, + chatId: "oc_other", + chatType: "p2p", + ctx: {}, + }), + ).toThrow("Feishu read target is not allowed."); + expect(canEnumerateAllFeishuPeers(account)).toBe(false); + + account.config = { + ...account.config, + allowFrom: ["ou_admin", "feishu:*"], + }; + expect( + assertFeishuChatReadAllowed({ + cfg: mergedCfg, + account, + chatId: "oc_other", + chatType: "p2p", + ctx: {}, + }), + ).toBe("oc_other"); + expect(canEnumerateAllFeishuPeers(account)).toBe(true); + }); + + it.each(["allowlist", "pairing"] as const)( + "honors wildcard DM admission under %s policy", + (dmPolicy) => { + const account = createAccount(); + account.config = { + ...account.config, + dmPolicy, + allowFrom: ["feishu:*"], + }; + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_any", + chatType: "p2p", + ctx: {}, + }), + ).toBe("oc_any"); + expect(canEnumerateAllFeishuPeers(account)).toBe(true); + }, + ); + + it("honors wildcard group admission entries", () => { + const account = createAccount(); + account.config = { + ...account.config, + groupAllowFrom: ["*"], + }; + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_any", + chatType: "group", + ctx: {}, + }), + ).toBe("oc_any"); + expect(canEnumerateAllFeishuGroups(cfg, account)).toBe(true); + }); + + it("uses filtered live enumeration for open groups with explicit denials", () => { + const account = createAccount(); + account.config = { + ...account.config, + groupPolicy: "open", + groups: { + oc_blocked: { enabled: false }, + }, + }; + + expect(canEnumerateAllFeishuGroups(cfg, account)).toBe(true); + }); + + it("uses the global group policy when the provider does not override it", () => { + const account = createAccount(); + account.config = { dmPolicy: "pairing" } as ResolvedFeishuAccount["config"]; + const globalOpenCfg = { + channels: { + defaults: { groupPolicy: "open" }, + feishu: {}, + }, + } as OpenClawConfig; + + expect( + assertFeishuChatReadAllowed({ + cfg: globalOpenCfg, + account, + chatId: "oc_group", + chatType: "group", + ctx: {}, + }), + ).toBe("oc_group"); + }); + + it("allows the trusted current DM when groups are disabled", () => { + const account = createAccount(); + account.config = { + ...account.config, + groupPolicy: "disabled", + dmPolicy: "pairing", + }; + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_current", + chatType: "p2p", + ctx: { + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_current", + }, + }, + }), + ).toBe("oc_current"); + }); + + it("rejects an unclassified current target when group reads are disabled", () => { + const account = createAccount(); + account.config = { + ...account.config, + groupPolicy: "disabled", + dmPolicy: "pairing", + }; + + expect(() => + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_current", + ctx: { + accountId: "default", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "feishu", + currentChannelId: "oc_current", + }, + }, + }), + ).toThrow("Feishu read target is not allowed."); + }); + + it("lets a direct operator read an unconfigured group or DM", () => { + const account = createAccount(); + const ctx = { conversationReadOrigin: "direct-operator" as const }; + + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_group", + chatType: "group", + ctx, + }), + ).toBe("oc_group"); + expect( + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_dm", + chatType: "p2p", + ctx, + }), + ).toBe("oc_dm"); + }); + + it("keeps disabled group targets blocked for direct operators", () => { + const account = createAccount(); + account.config = { + ...account.config, + groups: { oc_blocked: { enabled: false } }, + }; + + expect(() => + assertFeishuChatReadAllowed({ + cfg, + account, + chatId: "oc_blocked", + chatType: "group", + ctx: { conversationReadOrigin: "direct-operator" }, + }), + ).toThrow("Feishu read target is not allowed."); + }); + + it("requires metadata only when an unknown target has mixed scope policy", () => { + const account = createAccount(); + account.config = { + ...account.config, + allowFrom: ["*"], + }; + + expect( + resolveFeishuChatReadPreliminaryAuthorization({ + cfg, + account, + chatId: "oc_unknown", + ctx: {}, + }), + ).toEqual({ chatId: "oc_unknown", decision: "needs-metadata" }); + expect( + resolveFeishuChatReadPreliminaryAuthorization({ + cfg, + account, + chatId: "oc_unknown", + ctx: { conversationReadOrigin: "direct-operator" }, + }), + ).toEqual({ chatId: "oc_unknown", decision: "allow" }); + }); +}); diff --git a/extensions/feishu/src/read-policy.ts b/extensions/feishu/src/read-policy.ts new file mode 100644 index 000000000000..8575c72a7292 --- /dev/null +++ b/extensions/feishu/src/read-policy.ts @@ -0,0 +1,272 @@ +import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; +import type { OpenClawConfig } from "openclaw/plugin-sdk/core"; +import type { OpenClawPluginToolContext } from "openclaw/plugin-sdk/plugin-entry"; +import { + resolveDefaultGroupPolicy, + resolveOpenProviderRuntimeGroupPolicy, +} from "openclaw/plugin-sdk/runtime-group-policy"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import { normalizeFeishuChatType } from "./chat-type.js"; +import { + hasExplicitFeishuGroupConfig, + normalizeFeishuAllowEntry, + resolveFeishuGroupConfig, +} from "./policy.js"; +import { detectIdType, normalizeFeishuTarget } from "./targets.js"; +import type { FeishuChatType, ResolvedFeishuAccount } from "./types.js"; + +type FeishuActionReadContext = Pick< + ChannelMessageActionContext, + | "accountId" + | "conversationReadOrigin" + | "requesterAccountId" + | "requesterSenderId" + | "toolContext" +>; + +type FeishuReadContext = FeishuActionReadContext | OpenClawPluginToolContext; + +function isActionContext(ctx: FeishuReadContext): ctx is FeishuActionReadContext { + return "toolContext" in ctx; +} + +function normalizeChatId(raw?: string | null): string { + if (!raw) { + return ""; + } + return normalizeFeishuTarget(raw) ?? raw.trim(); +} + +function readContextFields(ctx: FeishuReadContext): { + accountId?: string; + currentChannelId?: string; + currentProvider?: string; + requesterAccountId?: string; + requesterSenderId?: string; + directOperator: boolean; +} { + if (isActionContext(ctx)) { + return { + accountId: normalizeOptionalString(ctx.accountId), + currentChannelId: normalizeOptionalString(ctx.toolContext?.currentChannelId), + currentProvider: normalizeOptionalString(ctx.toolContext?.currentChannelProvider), + requesterAccountId: normalizeOptionalString(ctx.requesterAccountId), + requesterSenderId: normalizeOptionalString(ctx.requesterSenderId), + directOperator: ctx.conversationReadOrigin === "direct-operator", + }; + } + return { + accountId: normalizeOptionalString(ctx.agentAccountId), + currentChannelId: normalizeOptionalString(ctx.nativeChannelId), + currentProvider: normalizeOptionalString(ctx.messageChannel ?? ctx.deliveryContext?.channel), + requesterAccountId: normalizeOptionalString(ctx.deliveryContext?.accountId), + requesterSenderId: normalizeOptionalString(ctx.requesterSenderId), + directOperator: ctx.conversationReadOrigin === "direct-operator", + }; +} + +function isCurrentChat(params: { + account: ResolvedFeishuAccount; + chatId: string; + ctx: FeishuReadContext; +}): boolean { + const context = readContextFields(params.ctx); + return ( + context.currentProvider?.toLowerCase() === "feishu" && + context.requesterAccountId === params.account.accountId && + (context.accountId ?? params.account.accountId) === params.account.accountId && + normalizeChatId(context.currentChannelId) === normalizeChatId(params.chatId) + ); +} + +function resolveFeishuReadGroupPolicy(cfg: OpenClawConfig, account: ResolvedFeishuAccount) { + return resolveOpenProviderRuntimeGroupPolicy({ + providerConfigPresent: cfg.channels?.feishu !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy: resolveDefaultGroupPolicy(cfg), + }).groupPolicy; +} + +export function isFeishuGroupReadAllowed( + cfg: OpenClawConfig, + account: ResolvedFeishuAccount, + chatId: string, + current: boolean, +): boolean { + const policy = resolveFeishuReadGroupPolicy(cfg, account); + if (policy === "disabled") { + return false; + } + const group = resolveFeishuGroupConfig({ cfg: account.config, groupId: chatId }); + if (group?.enabled === false) { + return false; + } + if (current) { + return true; + } + if (policy === "open") { + return true; + } + const explicitlyConfigured = hasExplicitFeishuGroupConfig({ + cfg: account.config, + groupId: chatId, + }); + const normalizedChatId = normalizeFeishuAllowEntry(chatId); + return ( + explicitlyConfigured || + (account.config.groupAllowFrom ?? []).some((entry) => { + const normalized = normalizeFeishuAllowEntry(String(entry)); + return normalized === "*" || normalized === normalizedChatId; + }) + ); +} + +export function isFeishuGroupReadEnabled( + cfg: OpenClawConfig, + account: ResolvedFeishuAccount, + chatId: string, +): boolean { + if (resolveFeishuReadGroupPolicy(cfg, account) === "disabled") { + return false; + } + return resolveFeishuGroupConfig({ cfg: account.config, groupId: chatId })?.enabled !== false; +} + +function isDmUniversallyAllowed(account: ResolvedFeishuAccount): boolean { + // Feishu's canonical schema has no disabled DM mode; channel/account enabled owns shutdown. + // Account overrides merge field-by-field, so only an allowFrom wildcard proves + // universal non-current access under every supported ingress policy. + return (account.config.allowFrom ?? []).some( + (entry) => normalizeFeishuAllowEntry(String(entry)) === "*", + ); +} + +export function assertFeishuChatReadAllowed(params: { + cfg: OpenClawConfig; + account: ResolvedFeishuAccount; + chatId: string; + chatType?: FeishuChatType; + ctx: FeishuReadContext; +}): string { + const authorization = resolveFeishuChatReadPreliminaryAuthorization(params); + if (authorization.decision !== "allow") { + throw new ToolAuthorizationError("Feishu read target is not allowed."); + } + return authorization.chatId; +} + +export type FeishuChatReadPreliminaryDecision = "allow" | "deny" | "needs-metadata"; + +export function resolveFeishuChatReadPreliminaryAuthorization(params: { + cfg: OpenClawConfig; + account: ResolvedFeishuAccount; + chatId: string; + chatType?: FeishuChatType; + ctx: FeishuReadContext; +}): { + chatId: string; + decision: FeishuChatReadPreliminaryDecision; +} { + const chatId = normalizeChatId(params.chatId); + const resolvedChatType = normalizeFeishuChatType(params.chatType); + const knownGroup = + resolvedChatType === "group" || + (params.chatType === undefined && + hasExplicitFeishuGroupConfig({ + cfg: params.account.config, + groupId: chatId, + })); + const knownDm = resolvedChatType === "p2p"; + const current = isCurrentChat({ account: params.account, chatId, ctx: params.ctx }); + const directOperator = readContextFields(params.ctx).directOperator; + const groupAllowed = directOperator + ? isFeishuGroupReadEnabled(params.cfg, params.account, chatId) + : isFeishuGroupReadAllowed(params.cfg, params.account, chatId, current); + const dmAllowed = directOperator || current || isDmUniversallyAllowed(params.account); + if (knownGroup) { + return { chatId, decision: groupAllowed ? "allow" : "deny" }; + } + if (knownDm) { + return { chatId, decision: dmAllowed ? "allow" : "deny" }; + } + if (groupAllowed === dmAllowed) { + return { chatId, decision: groupAllowed ? "allow" : "deny" }; + } + return { chatId, decision: "needs-metadata" }; +} + +export type FeishuChatMemberReadAuthorization = + | { kind: "group"; chatId: string } + | { + kind: "direct"; + chatId: string; + memberId: string; + memberIdType: "open_id" | "user_id"; + }; + +export function authorizeFeishuChatMemberRead(params: { + cfg: OpenClawConfig; + account: ResolvedFeishuAccount; + chatId: string; + chatType?: FeishuChatType; + ctx: FeishuReadContext; + memberId?: string; + memberIdType?: "open_id" | "user_id" | "union_id"; +}): FeishuChatMemberReadAuthorization { + const chatId = assertFeishuChatReadAllowed(params); + const chatType = normalizeFeishuChatType(params.chatType); + if (chatType === "group") { + return { kind: "group", chatId }; + } + if (chatType !== "p2p") { + throw new ToolAuthorizationError("Feishu chat member reads require a known chat type."); + } + if (!isCurrentChat({ account: params.account, chatId, ctx: params.ctx })) { + throw new ToolAuthorizationError( + "Feishu direct-chat member reads require the current conversation.", + ); + } + const requesterSenderId = normalizeChatId(readContextFields(params.ctx).requesterSenderId); + if (!requesterSenderId) { + throw new ToolAuthorizationError("Feishu direct-chat member identity is unavailable."); + } + const requesterSenderIdType = detectIdType(requesterSenderId); + if (requesterSenderIdType !== "open_id" && requesterSenderIdType !== "user_id") { + throw new ToolAuthorizationError("Feishu direct-chat member identity type is unavailable."); + } + if (params.memberIdType && params.memberIdType !== requesterSenderIdType) { + throw new ToolAuthorizationError( + "Feishu direct-chat member identifier type must match the current sender.", + ); + } + if (params.memberId && normalizeChatId(params.memberId) !== requesterSenderId) { + throw new ToolAuthorizationError( + "Feishu direct-chat member reads are limited to the current sender.", + ); + } + return { + kind: "direct", + chatId, + memberId: requesterSenderId, + memberIdType: requesterSenderIdType, + }; +} + +export function canEnumerateAllFeishuGroups( + cfg: OpenClawConfig, + account: ResolvedFeishuAccount, +): boolean { + const policy = resolveFeishuReadGroupPolicy(cfg, account); + return ( + policy === "open" || + (policy === "allowlist" && + (account.config.groupAllowFrom ?? []).some( + (entry) => normalizeFeishuAllowEntry(String(entry)) === "*", + )) + ); +} + +export function canEnumerateAllFeishuPeers(account: ResolvedFeishuAccount): boolean { + return isDmUniversallyAllowed(account); +} diff --git a/extensions/googlechat/runtime-api.ts b/extensions/googlechat/runtime-api.ts index 443243787caf..7e735d64ad4e 100644 --- a/extensions/googlechat/runtime-api.ts +++ b/extensions/googlechat/runtime-api.ts @@ -33,11 +33,6 @@ export { warnMissingProviderGroupPolicyFallbackOnce, } from "openclaw/plugin-sdk/runtime-group-policy"; export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime"; -export { - readRemoteMediaBuffer, - resolveChannelMediaMaxBytes, -} from "openclaw/plugin-sdk/media-runtime"; -export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store"; export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; export type { diff --git a/extensions/googlechat/src/actions.test.ts b/extensions/googlechat/src/actions.test.ts index a04eef7e5195..4a3a5e183e9e 100644 --- a/extensions/googlechat/src/actions.test.ts +++ b/extensions/googlechat/src/actions.test.ts @@ -1,16 +1,10 @@ // Googlechat tests cover actions plugin behavior. -import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; const listEnabledGoogleChatAccounts = vi.hoisted(() => vi.fn()); const resolveGoogleChatAccount = vi.hoisted(() => vi.fn()); -const createGoogleChatReaction = vi.hoisted(() => vi.fn()); -const deleteGoogleChatReaction = vi.hoisted(() => vi.fn()); -const listGoogleChatReactions = vi.hoisted(() => vi.fn()); const sendGoogleChatMessage = vi.hoisted(() => vi.fn()); -const uploadGoogleChatAttachment = vi.hoisted(() => vi.fn()); const resolveGoogleChatOutboundSpace = vi.hoisted(() => vi.fn()); -const getGoogleChatRuntime = vi.hoisted(() => vi.fn()); vi.mock("./accounts.js", () => ({ listEnabledGoogleChatAccounts, @@ -18,15 +12,7 @@ vi.mock("./accounts.js", () => ({ })); vi.mock("./api.js", () => ({ - createGoogleChatReaction, - deleteGoogleChatReaction, - listGoogleChatReactions, sendGoogleChatMessage, - uploadGoogleChatAttachment, -})); - -vi.mock("./runtime.js", () => ({ - getGoogleChatRuntime, })); vi.mock("./targets.js", () => ({ @@ -47,18 +33,25 @@ describe("googlechat message actions", () => { afterAll(() => { vi.doUnmock("./accounts.js"); vi.doUnmock("./api.js"); - vi.doUnmock("./runtime.js"); vi.doUnmock("./targets.js"); vi.resetModules(); }); function buildAccount(overrides: Record = {}) { + const overrideConfig = + overrides.config && typeof overrides.config === "object" + ? (overrides.config as Record) + : {}; return { accountId: "default", enabled: true, credentialSource: "service-account", - config: {}, ...overrides, + config: { + groupPolicy: "open", + dm: { policy: "open" }, + ...overrideConfig, + }, }; } @@ -74,7 +67,7 @@ describe("googlechat message actions", () => { }); } - it("describes send and reaction actions only when enabled accounts exist", () => { + it("describes only send actions when enabled accounts exist", () => { listEnabledGoogleChatAccounts.mockReturnValueOnce([]); expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toBeNull(); @@ -87,11 +80,13 @@ describe("googlechat message actions", () => { ]); expect(googlechatMessageActions.describeMessageTool?.({ cfg: {} as never })).toEqual({ - actions: ["send", "upload-file", "react", "reactions"], + actions: ["send"], }); + expect(googlechatMessageActions.supportsAction?.({ action: "send" })).toBe(true); + expect(googlechatMessageActions.supportsAction?.({ action: "upload-file" })).toBe(false); }); - it("honors account-scoped reaction gates during discovery", () => { + it("keeps the legacy reaction gate from changing account-scoped discovery", () => { resolveGoogleChatAccount.mockImplementation(({ accountId }: { accountId?: string | null }) => ({ enabled: true, credentialSource: "service-account", @@ -100,39 +95,19 @@ describe("googlechat message actions", () => { }, })); - expect( - googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "default" }), - ).toEqual({ - actions: ["send", "upload-file"], - }); - expect( - googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId: "work" }), - ).toEqual({ - actions: ["send", "upload-file", "react", "reactions"], - }); + for (const accountId of ["default", "work"]) { + expect( + googlechatMessageActions.describeMessageTool?.({ cfg: {} as never, accountId }), + ).toEqual({ + actions: ["send"], + }); + } }); - it("sends messages with uploaded media through the resolved space", async () => { - const account = buildAccount({ - config: { mediaMaxMb: 5 }, - }); + it("sends text through the resolved space", async () => { + const account = buildAccount(); resolveGoogleChatAccount.mockReturnValue(account); resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/AAA"); - const readRemoteMediaBuffer = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - getGoogleChatRuntime.mockReturnValue({ - channel: { - media: { - readRemoteMediaBuffer, - }, - }, - }); - uploadGoogleChatAttachment.mockResolvedValue({ - attachmentUploadToken: "token-1", - }); sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1", threadName: "spaces/AAA/threads/thread-1", @@ -146,7 +121,6 @@ describe("googlechat message actions", () => { params: { to: "spaces/AAA", message: "caption", - media: "https://example.com/file.png", threadId: "thread-1", }, cfg: {}, @@ -157,23 +131,11 @@ describe("googlechat message actions", () => { account, target: "spaces/AAA", }); - expect(readRemoteMediaBuffer).toHaveBeenCalledWith({ - url: "https://example.com/file.png", - maxBytes: 5 * 1024 * 1024, - }); - expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({ - account, - space: "spaces/AAA", - filename: "remote.png", - buffer: Buffer.from("remote-bytes"), - contentType: "image/png", - }); expect(sendGoogleChatMessage).toHaveBeenCalledWith({ account, space: "spaces/AAA", text: "caption", thread: "thread-1", - attachments: [{ attachmentUploadToken: "token-1", contentName: "remote.png" }], }); expectJsonResult(result, { ok: true, @@ -183,142 +145,73 @@ describe("googlechat message actions", () => { }); }); - it("routes upload-file through the same attachment upload path with filename override", async () => { - const account = buildAccount({ - config: { mediaMaxMb: 5 }, - }); - resolveGoogleChatAccount.mockReturnValue(account); - resolveGoogleChatOutboundSpace.mockResolvedValue("spaces/BBB"); - const localRoot = "/tmp/googlechat-action-test"; - const localPath = path.join(localRoot, "local.md"); - const readFile = vi.fn(async () => Buffer.from("local-bytes")); - getGoogleChatRuntime.mockReturnValue({ - channel: { - media: { - readRemoteMediaBuffer: vi.fn(), - }, + it.each([ + { action: "send", params: { to: "spaces/AAA", message: "caption", media: "remote.png" } }, + { + action: "send", + params: { to: "spaces/AAA", message: "caption", mediaUrl: "remote.png" }, + }, + { + action: "send", + params: { to: "spaces/AAA", message: "caption", mediaUrls: ["remote.png"] }, + }, + { + action: "send", + params: { to: "spaces/AAA", message: "caption", fileUrl: "remote.png" }, + }, + { + action: "send", + params: { + to: "spaces/AAA", + message: "caption", + attachments: [{ url: "remote.png" }], }, - }); - uploadGoogleChatAttachment.mockResolvedValue({ - attachmentUploadToken: "token-2", - }); - sendGoogleChatMessage.mockResolvedValue({ - messageName: "spaces/BBB/messages/msg-2", - threadName: "spaces/BBB/threads/thread-2", - }); - - if (!googlechatMessageActions.handleAction) { - throw new Error("Expected googlechatMessageActions.handleAction to be defined"); - } - const result = await googlechatMessageActions.handleAction({ + }, + { action: "upload-file", - params: { - to: "spaces/BBB", - path: localPath, - message: "notes", - filename: "renamed.txt", - }, - cfg: {}, - accountId: "default", - mediaLocalRoots: [localRoot], - mediaReadFile: readFile, - } as never); + params: { to: "spaces/AAA", message: "caption", path: "local.png" }, + }, + ])( + "rejects outbound attachment action $action before provider access", + async ({ action, params }) => { + if (!googlechatMessageActions.handleAction) { + throw new Error("Expected googlechatMessageActions.handleAction to be defined"); + } + await expect( + googlechatMessageActions.handleAction({ + action, + params, + cfg: {}, + accountId: "default", + } as never), + ).rejects.toThrow( + "Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.", + ); - expect(readFile).toHaveBeenCalledWith(localPath); - expect(uploadGoogleChatAttachment).toHaveBeenCalledWith({ - account, - space: "spaces/BBB", - filename: "renamed.txt", - buffer: Buffer.from("local-bytes"), - contentType: "text/markdown", - }); - expect(sendGoogleChatMessage).toHaveBeenCalledWith({ - account, - space: "spaces/BBB", - text: "notes", - thread: undefined, - attachments: [{ attachmentUploadToken: "token-2", contentName: "renamed.txt" }], - }); - expectJsonResult(result, { - ok: true, - to: "spaces/BBB", - messageName: "spaces/BBB/messages/msg-2", - threadName: "spaces/BBB/threads/thread-2", - }); - }); + expect(resolveGoogleChatAccount).not.toHaveBeenCalled(); + expect(resolveGoogleChatOutboundSpace).not.toHaveBeenCalled(); + expect(sendGoogleChatMessage).not.toHaveBeenCalled(); + }, + ); - it("removes only matching app reactions on react remove", async () => { - const account = buildAccount({ - config: { botUser: "users/app-bot" }, - }); - resolveGoogleChatAccount.mockReturnValue(account); - listGoogleChatReactions.mockResolvedValue([ - { - name: "reactions/1", - emoji: { unicode: "👍" }, - user: { name: "users/app" }, - }, - { - name: "reactions/2", - emoji: { unicode: "👍" }, - user: { name: "users/app-bot" }, - }, - { - name: "reactions/3", - emoji: { unicode: "👍" }, - user: { name: "users/other" }, - }, - ]); + it.each(["react", "reactions"])( + "rejects unsupported %s actions without provider access", + async (action) => { + resolveGoogleChatAccount.mockReturnValue(buildAccount()); - if (!googlechatMessageActions.handleAction) { - throw new Error("Expected googlechatMessageActions.handleAction to be defined"); - } - const result = await googlechatMessageActions.handleAction({ - action: "react", - params: { - messageId: "spaces/AAA/messages/msg-1", - emoji: "👍", - remove: true, - }, - cfg: {}, - accountId: "default", - } as never); + if (!googlechatMessageActions.handleAction) { + throw new Error("Expected googlechatMessageActions.handleAction to be defined"); + } + await expect( + googlechatMessageActions.handleAction({ + action, + params: { messageId: "spaces/AAA/messages/msg-1", emoji: "👍" }, + cfg: {}, + accountId: "default", + } as never), + ).rejects.toThrow(`Action ${action} is not supported for provider googlechat.`); - expect(listGoogleChatReactions).toHaveBeenCalledWith({ - account, - messageName: "spaces/AAA/messages/msg-1", - }); - expect(deleteGoogleChatReaction).toHaveBeenCalledTimes(2); - expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(1, { - account, - reactionName: "reactions/1", - }); - expect(deleteGoogleChatReaction).toHaveBeenNthCalledWith(2, { - account, - reactionName: "reactions/2", - }); - expectJsonResult(result, { ok: true, removed: 2 }); - }); - - it("rejects fractional reaction limits before listing reactions", async () => { - const account = buildAccount(); - resolveGoogleChatAccount.mockReturnValue(account); - - if (!googlechatMessageActions.handleAction) { - throw new Error("Expected googlechatMessageActions.handleAction to be defined"); - } - await expect( - googlechatMessageActions.handleAction({ - action: "reactions", - params: { - messageId: "spaces/AAA/messages/msg-1", - limit: 2.5, - }, - cfg: {}, - accountId: "default", - } as never), - ).rejects.toThrow("limit must be a positive integer"); - - expect(listGoogleChatReactions).not.toHaveBeenCalled(); - }); + expect(sendGoogleChatMessage).not.toHaveBeenCalled(); + }, + ); }); diff --git a/extensions/googlechat/src/actions.ts b/extensions/googlechat/src/actions.ts index 1c1f33fcb02b..461b2fea5370 100644 --- a/extensions/googlechat/src/actions.ts +++ b/extensions/googlechat/src/actions.ts @@ -1,27 +1,14 @@ // Googlechat plugin module implements actions behavior. import { - createActionGate, jsonResult, - readPositiveIntegerParam, - readReactionParams, + readStringArrayParam, readStringParam, } from "openclaw/plugin-sdk/channel-actions"; -import type { - ChannelMessageActionAdapter, - ChannelMessageActionName, -} from "openclaw/plugin-sdk/channel-contract"; +import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media"; import { extractToolSend } from "openclaw/plugin-sdk/tool-send"; import { listEnabledGoogleChatAccounts, resolveGoogleChatAccount } from "./accounts.js"; -import { - createGoogleChatReaction, - deleteGoogleChatReaction, - listGoogleChatReactions, - sendGoogleChatMessage, - uploadGoogleChatAttachment, -} from "./api.js"; -import { getGoogleChatRuntime } from "./runtime.js"; +import { sendGoogleChatMessage } from "./api.js"; import { resolveGoogleChatOutboundSpace } from "./targets.js"; const providerId = "googlechat"; @@ -32,42 +19,28 @@ function listEnabledAccounts(cfg: OpenClawConfig) { ); } -function isReactionsEnabled(accounts: Array<{ config: { actions?: unknown } }>) { - for (const account of accounts) { - const gate = createActionGate(account.config.actions as Record); - if (gate("reactions")) { - return true; - } +const OUTBOUND_MEDIA_KEYS = ["media", "mediaUrl", "path", "filePath", "fileUrl"] as const; +const STRUCTURED_ATTACHMENT_MEDIA_KEYS = [...OUTBOUND_MEDIA_KEYS, "url"] as const; + +function hasGoogleChatOutboundAttachment(params: Record): boolean { + if (OUTBOUND_MEDIA_KEYS.some((key) => readStringParam(params, key) !== undefined)) { + return true; } - return false; -} - -function resolveAppUserNames(account: { config: { botUser?: string | null } }) { - return new Set(["users/app", account.config.botUser?.trim()].filter(Boolean) as string[]); -} - -async function loadGoogleChatActionMedia(params: { - mediaUrl: string; - maxBytes: number; - mediaAccess?: { - localRoots?: readonly string[]; - readFile?: (filePath: string) => Promise; - }; - mediaLocalRoots?: readonly string[]; - mediaReadFile?: (filePath: string) => Promise; -}) { - const runtime = getGoogleChatRuntime(); - return /^https?:\/\//i.test(params.mediaUrl) - ? await runtime.channel.media.readRemoteMediaBuffer({ - url: params.mediaUrl, - maxBytes: params.maxBytes, - }) - : await loadOutboundMediaFromUrl(params.mediaUrl, { - maxBytes: params.maxBytes, - mediaAccess: params.mediaAccess, - mediaLocalRoots: params.mediaLocalRoots, - mediaReadFile: params.mediaReadFile, - }); + if (readStringArrayParam(params, "mediaUrls") !== undefined) { + return true; + } + if (!Array.isArray(params.attachments)) { + return false; + } + return params.attachments.some((attachment) => { + if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) { + return false; + } + const record = attachment as Record; + return STRUCTURED_ATTACHMENT_MEDIA_KEYS.some( + (key) => readStringParam(record, key) !== undefined, + ); + }); } export const googlechatMessageActions: ChannelMessageActionAdapter = { @@ -80,27 +53,26 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { if (accounts.length === 0) { return null; } - const actions = new Set([]); - actions.add("send"); - actions.add("upload-file"); - if (isReactionsEnabled(accounts)) { - actions.add("react"); - actions.add("reactions"); - } - return { actions: Array.from(actions) }; + return { actions: ["send"] }; }, + supportsAction: ({ action }) => action === "send", extractToolSend: ({ args }) => { return extractToolSend(args, "sendMessage"); }, - handleAction: async ({ - action, - params, - cfg, - accountId, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }) => { + handleAction: async ({ action, params, cfg, accountId }) => { + if (action === "upload-file") { + throw new Error( + "Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.", + ); + } + if (action === "send") { + if (hasGoogleChatOutboundAttachment(params)) { + throw new Error( + "Google Chat outbound attachments require user OAuth and are not supported by this service-account channel.", + ); + } + } + const account = resolveGoogleChatAccount({ cfg, accountId, @@ -109,66 +81,15 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { throw new Error("Google Chat credentials are missing."); } - if (action === "send" || action === "upload-file") { + if (action === "send") { const to = readStringParam(params, "to", { required: true }); - const content = - readStringParam(params, "message", { - required: action === "send", - allowEmpty: true, - }) ?? - readStringParam(params, "initialComment", { - allowEmpty: true, - }) ?? - ""; - const mediaUrl = - readStringParam(params, "media", { trim: false }) ?? - readStringParam(params, "filePath", { trim: false }) ?? - readStringParam(params, "path", { trim: false }); + const content = readStringParam(params, "message", { + required: true, + allowEmpty: true, + }); const threadId = readStringParam(params, "threadId") ?? readStringParam(params, "replyTo"); const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - if (mediaUrl) { - const maxBytes = (account.config.mediaMaxMb ?? 20) * 1024 * 1024; - const loaded = await loadGoogleChatActionMedia({ - mediaUrl, - maxBytes, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }); - const uploadFileName = - readStringParam(params, "filename") ?? - readStringParam(params, "title") ?? - loaded.fileName ?? - "attachment"; - const upload = await uploadGoogleChatAttachment({ - account, - space, - filename: uploadFileName, - buffer: loaded.buffer, - contentType: loaded.contentType, - }); - const sent = await sendGoogleChatMessage({ - account, - space, - text: content, - thread: threadId ?? undefined, - attachments: upload.attachmentUploadToken - ? [ - { - attachmentUploadToken: upload.attachmentUploadToken, - contentName: uploadFileName, - }, - ] - : undefined, - }); - return jsonResult({ ok: true, to: space, ...sent }); - } - - if (action === "upload-file") { - throw new Error("upload-file requires media, filePath, or path"); - } - const sent = await sendGoogleChatMessage({ account, space, @@ -178,51 +99,6 @@ export const googlechatMessageActions: ChannelMessageActionAdapter = { return jsonResult({ ok: true, to: space, ...sent }); } - if (action === "react") { - const messageName = readStringParam(params, "messageId", { required: true }); - const { emoji, remove, isEmpty } = readReactionParams(params, { - removeErrorMessage: "Emoji is required to remove a Google Chat reaction.", - }); - if (remove || isEmpty) { - const reactions = await listGoogleChatReactions({ account, messageName }); - const appUsers = resolveAppUserNames(account); - const toRemove = reactions.filter((reaction) => { - const userName = reaction.user?.name?.trim(); - if (appUsers.size > 0 && !appUsers.has(userName ?? "")) { - return false; - } - if (emoji) { - return reaction.emoji?.unicode === emoji; - } - return true; - }); - for (const reaction of toRemove) { - if (!reaction.name) { - continue; - } - await deleteGoogleChatReaction({ account, reactionName: reaction.name }); - } - return jsonResult({ ok: true, removed: toRemove.length }); - } - const reaction = await createGoogleChatReaction({ - account, - messageName, - emoji, - }); - return jsonResult({ ok: true, reaction }); - } - - if (action === "reactions") { - const messageName = readStringParam(params, "messageId", { required: true }); - const limit = readPositiveIntegerParam(params, "limit"); - const reactions = await listGoogleChatReactions({ - account, - messageName, - limit: limit ?? undefined, - }); - return jsonResult({ ok: true, reactions }); - } - throw new Error(`Action ${action} is not supported for provider ${providerId}.`); }, }; diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index 9a374d4a7c2c..7244ec04f4df 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -1,5 +1,4 @@ // Googlechat API module exposes the plugin public contract. -import crypto from "node:crypto"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { parseMediaContentLength, @@ -10,10 +9,9 @@ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; import { shouldSuppressGoogleChatManualExecApprovalFollowupText } from "./approval-card-actions.js"; import { getGoogleChatAccessToken } from "./auth.js"; -import type { GoogleChatCardV2, GoogleChatReaction, GoogleChatSpace } from "./types.js"; +import type { GoogleChatCardV2, GoogleChatSpace } from "./types.js"; const CHAT_API_BASE = "https://chat.googleapis.com/v1"; -const CHAT_UPLOAD_BASE = "https://chat.googleapis.com/upload/v1"; const GOOGLECHAT_API_TIMEOUT_MS = 30_000; const GOOGLECHAT_MEDIA_TIMEOUT_GRACE_MS = 30_000; const GOOGLECHAT_MEDIA_MIN_BYTES_PER_SECOND = 256 * 1024; @@ -184,13 +182,11 @@ export async function sendGoogleChatMessage(params: { text?: string; thread?: string; cardsV2?: GoogleChatCardV2[]; - attachments?: Array<{ attachmentUploadToken: string; contentName?: string }>; }): Promise<{ messageName?: string; threadName?: string } | null> { - const { account, space, text, thread, cardsV2, attachments } = params; + const { account, space, text, thread, cardsV2 } = params; if ( text && (!cardsV2 || cardsV2.length === 0) && - (!attachments || attachments.length === 0) && shouldSuppressGoogleChatManualExecApprovalFollowupText(text) ) { return null; @@ -205,14 +201,6 @@ export async function sendGoogleChatMessage(params: { if (thread) { body.thread = { name: thread }; } - if (attachments && attachments.length > 0) { - body.attachment = attachments.map((item) => - Object.assign( - { attachmentDataRef: { attachmentUploadToken: item.attachmentUploadToken } }, - item.contentName ? { contentName: item.contentName } : {}, - ), - ); - } const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`); if (thread) { urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); @@ -263,52 +251,6 @@ export async function deleteGoogleChatMessage(params: { await fetchOk(account, url, { method: "DELETE" }); } -export async function uploadGoogleChatAttachment(params: { - account: ResolvedGoogleChatAccount; - space: string; - filename: string; - buffer: Buffer; - contentType?: string; -}): Promise<{ attachmentUploadToken?: string }> { - const { account, space, filename, buffer, contentType } = params; - const boundary = `openclaw-${crypto.randomUUID()}`; - const metadata = JSON.stringify({ filename }); - const header = `--${boundary}\r\nContent-Type: application/json; charset=UTF-8\r\n\r\n${metadata}\r\n`; - const mediaHeader = `--${boundary}\r\nContent-Type: ${contentType ?? "application/octet-stream"}\r\n\r\n`; - const footer = `\r\n--${boundary}--\r\n`; - const body = Buffer.concat([ - Buffer.from(header, "utf8"), - Buffer.from(mediaHeader, "utf8"), - buffer, - Buffer.from(footer, "utf8"), - ]); - - const url = `${CHAT_UPLOAD_BASE}/${space}/attachments:upload?uploadType=multipart`; - const payload = await withGoogleChatResponse<{ - attachmentDataRef?: { attachmentUploadToken?: string }; - }>({ - account, - url, - init: { - method: "POST", - headers: { - "Content-Type": `multipart/related; boundary=${boundary}`, - }, - body, - }, - auditContext: "googlechat.upload", - errorPrefix: "Google Chat upload", - timeoutMs: resolveGoogleChatMediaTimeoutMs(body.length), - handleResponse: async (response) => - await readGoogleChatJsonResponse<{ - attachmentDataRef?: { attachmentUploadToken?: string }; - }>(response, "Google Chat upload failed"), - }); - return { - attachmentUploadToken: payload.attachmentDataRef?.attachmentUploadToken, - }; -} - export async function downloadGoogleChatMedia(params: { account: ResolvedGoogleChatAccount; resourceName: string; @@ -319,44 +261,6 @@ export async function downloadGoogleChatMedia(params: { return await fetchBuffer(account, url, undefined, { maxBytes }); } -export async function createGoogleChatReaction(params: { - account: ResolvedGoogleChatAccount; - messageName: string; - emoji: string; -}): Promise { - const { account, messageName, emoji } = params; - const url = `${CHAT_API_BASE}/${messageName}/reactions`; - return await fetchJson(account, url, { - method: "POST", - body: JSON.stringify({ emoji: { unicode: emoji } }), - }); -} - -export async function listGoogleChatReactions(params: { - account: ResolvedGoogleChatAccount; - messageName: string; - limit?: number; -}): Promise { - const { account, messageName, limit } = params; - const url = new URL(`${CHAT_API_BASE}/${messageName}/reactions`); - if (limit && limit > 0) { - url.searchParams.set("pageSize", String(limit)); - } - const result = await fetchJson<{ reactions?: GoogleChatReaction[] }>(account, url.toString(), { - method: "GET", - }); - return result.reactions ?? []; -} - -export async function deleteGoogleChatReaction(params: { - account: ResolvedGoogleChatAccount; - reactionName: string; -}): Promise { - const { account, reactionName } = params; - const url = `${CHAT_API_BASE}/${reactionName}`; - await fetchOk(account, url, { method: "DELETE" }); -} - export async function findGoogleChatDirectMessage(params: { account: ResolvedGoogleChatAccount; userName: string; diff --git a/extensions/googlechat/src/channel-base.ts b/extensions/googlechat/src/channel-base.ts index 4a8f592e7956..01f4e3978439 100644 --- a/extensions/googlechat/src/channel-base.ts +++ b/extensions/googlechat/src/channel-base.ts @@ -96,8 +96,9 @@ export function createGoogleChatPluginBase( setupWizard: googlechatSetupWizard, capabilities: { chatTypes: ["direct", "group", "thread"], - reactions: true, threads: true, + // Inbound attachment download remains supported even though service-account + // authentication cannot use Google Chat's user-auth-only upload endpoint. media: true, nativeCommands: false, blockStreaming: true, diff --git a/extensions/googlechat/src/channel-config.test.ts b/extensions/googlechat/src/channel-config.test.ts index de4ccf873d2b..91bc73649fbd 100644 --- a/extensions/googlechat/src/channel-config.test.ts +++ b/extensions/googlechat/src/channel-config.test.ts @@ -21,6 +21,25 @@ describe("googlechatPlugin config adapter", () => { expect(googlechatSetupPlugin.capabilities?.chatTypes).toEqual( googlechatPlugin.capabilities?.chatTypes, ); + expect(googlechatPlugin.capabilities?.media).toBe(true); + expect(googlechatPlugin.capabilities?.reactions).toBeUndefined(); + }); + + it("does not advertise user-auth-only actions", () => { + const cfg = { + channels: { + googlechat: { + serviceAccount: { client_email: "bot@example.com" }, + actions: { reactions: true }, + }, + }, + } as OpenClawConfig; + + expect(googlechatPlugin.actions?.describeMessageTool?.({ cfg })).toEqual({ + actions: ["send"], + }); + expect(googlechatPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true); + expect(googlechatPlugin.actions?.supportsAction?.({ action: "upload-file" })).toBe(false); }); it("registers an exec-capable native approval runtime", () => { diff --git a/extensions/googlechat/src/channel.adapters.ts b/extensions/googlechat/src/channel.adapters.ts index 00445661897a..3d25ab046acf 100644 --- a/extensions/googlechat/src/channel.adapters.ts +++ b/extensions/googlechat/src/channel.adapters.ts @@ -20,7 +20,6 @@ import { listResolvedDirectoryUserEntriesFromAllowFrom, } from "openclaw/plugin-sdk/directory-runtime"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; -import type { OutboundMediaLoadOptions } from "openclaw/plugin-sdk/outbound-media"; import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime"; import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking"; @@ -29,13 +28,10 @@ import { formatGoogleChatAllowFromEntry } from "./channel-base.js"; import { type ResolvedGoogleChatAccount, chunkTextForOutbound, - readRemoteMediaBuffer, isGoogleChatUserTarget, - loadOutboundMediaFromUrl, missingTargetError, normalizeGoogleChatTarget, PAIRING_APPROVED_MESSAGE, - resolveChannelMediaMaxBytes, resolveGoogleChatAccount, resolveGoogleChatOutboundSpace, type OpenClawConfig, @@ -260,92 +256,6 @@ export const googlechatOutboundAdapter = { receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "text" }), }; }, - sendMedia: async ({ - cfg, - to, - text, - mediaUrl, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - accountId, - replyToId, - threadId, - }: { - cfg: OpenClawConfig; - to: string; - text?: string; - mediaUrl?: string; - mediaAccess?: OutboundMediaLoadOptions["mediaAccess"]; - mediaLocalRoots?: OutboundMediaLoadOptions["mediaLocalRoots"]; - mediaReadFile?: OutboundMediaLoadOptions["mediaReadFile"]; - accountId?: string | null; - replyToId?: string | null; - threadId?: string | number | null; - }) => { - if (!mediaUrl) { - throw new Error("Google Chat mediaUrl is required."); - } - const account = resolveGoogleChatAccount({ - cfg, - accountId, - }); - const space = await resolveGoogleChatOutboundSpace({ account, target: to }); - const thread = - typeof threadId === "number" ? String(threadId) : (threadId ?? replyToId ?? undefined); - const maxBytes = resolveChannelMediaMaxBytes({ - cfg, - resolveChannelLimitMb: ({ cfg: cfgLocal, accountId: accountIdLocal }) => - ( - cfgLocal.channels?.googlechat as - | { accounts?: Record; mediaMaxMb?: number } - | undefined - )?.accounts?.[accountIdLocal]?.mediaMaxMb ?? - (cfgLocal.channels?.googlechat as { mediaMaxMb?: number } | undefined)?.mediaMaxMb, - accountId, - }); - const effectiveMaxBytes = maxBytes ?? (account.config.mediaMaxMb ?? 20) * 1024 * 1024; - const loaded = /^https?:\/\//i.test(mediaUrl) - ? await readRemoteMediaBuffer({ - url: mediaUrl, - maxBytes: effectiveMaxBytes, - }) - : await loadOutboundMediaFromUrl(mediaUrl, { - maxBytes: effectiveMaxBytes, - mediaAccess, - mediaLocalRoots, - mediaReadFile, - }); - const { sendGoogleChatMessage, uploadGoogleChatAttachment } = - await loadGoogleChatChannelRuntime(); - const upload = await uploadGoogleChatAttachment({ - account, - space, - filename: loaded.fileName ?? "attachment", - buffer: loaded.buffer, - contentType: loaded.contentType, - }); - const result = await sendGoogleChatMessage({ - account, - space, - text, - thread, - attachments: upload.attachmentUploadToken - ? [ - { - attachmentUploadToken: upload.attachmentUploadToken, - contentName: loaded.fileName, - }, - ] - : undefined, - }); - const messageId = result?.messageName ?? ""; - return { - messageId, - chatId: space, - receipt: createGoogleChatSendReceipt({ messageId, chatId: space, kind: "media" }), - }; - }, }, }; @@ -354,13 +264,11 @@ export const googlechatMessageAdapter = defineChannelMessageAdapter({ durableFinal: { capabilities: { text: true, - media: true, thread: true, messageSendingHooks: true, }, }, send: { text: googlechatOutboundAdapter.attachedResults.sendText, - media: googlechatOutboundAdapter.attachedResults.sendMedia, }, }); diff --git a/extensions/googlechat/src/channel.deps.runtime.ts b/extensions/googlechat/src/channel.deps.runtime.ts index f3115f55b221..f3689f3b4ecc 100644 --- a/extensions/googlechat/src/channel.deps.runtime.ts +++ b/extensions/googlechat/src/channel.deps.runtime.ts @@ -3,14 +3,10 @@ export { buildChannelConfigSchema, chunkTextForOutbound, DEFAULT_ACCOUNT_ID, - readRemoteMediaBuffer, GoogleChatConfigSchema, - loadOutboundMediaFromUrl, missingTargetError, PAIRING_APPROVED_MESSAGE, - resolveChannelMediaMaxBytes, type ChannelMessageActionAdapter, - type ChannelMessageActionName, type ChannelStatusIssue, type OpenClawConfig, } from "../runtime-api.js"; diff --git a/extensions/googlechat/src/channel.runtime.ts b/extensions/googlechat/src/channel.runtime.ts index b1adeb151226..219e6dbfacde 100644 --- a/extensions/googlechat/src/channel.runtime.ts +++ b/extensions/googlechat/src/channel.runtime.ts @@ -2,7 +2,6 @@ import { probeGoogleChat as probeGoogleChatImpl, sendGoogleChatMessage as sendGoogleChatMessageImpl, - uploadGoogleChatAttachment as uploadGoogleChatAttachmentImpl, } from "./api.js"; import { resolveGoogleChatWebhookPath as resolveGoogleChatWebhookPathImpl, @@ -12,7 +11,6 @@ import { export const googleChatChannelRuntime = { probeGoogleChat: probeGoogleChatImpl, sendGoogleChatMessage: sendGoogleChatMessageImpl, - uploadGoogleChatAttachment: uploadGoogleChatAttachmentImpl, resolveGoogleChatWebhookPath: resolveGoogleChatWebhookPathImpl, startGoogleChatMonitor: startGoogleChatMonitorImpl, }; diff --git a/extensions/googlechat/src/channel.test.ts b/extensions/googlechat/src/channel.test.ts index 451ff8850948..8559b3ea6263 100644 --- a/extensions/googlechat/src/channel.test.ts +++ b/extensions/googlechat/src/channel.test.ts @@ -15,12 +15,9 @@ import { googlechatThreadingAdapter, } from "./channel.adapters.js"; -const uploadGoogleChatAttachmentMock = vi.hoisted(() => vi.fn()); const sendGoogleChatMessageMock = vi.hoisted(() => vi.fn()); const resolveGoogleChatAccountMock = vi.hoisted(() => vi.fn()); const resolveGoogleChatOutboundSpaceMock = vi.hoisted(() => vi.fn()); -const readRemoteMediaBufferMock = vi.hoisted(() => vi.fn()); -const loadOutboundMediaFromUrlMock = vi.hoisted(() => vi.fn()); const probeGoogleChatMock = vi.hoisted(() => vi.fn()); const startGoogleChatMonitorMock = vi.hoisted(() => vi.fn()); @@ -77,19 +74,6 @@ function mockGoogleChatOutboundSpaceResolution() { }); } -function mockGoogleChatMediaLoaders() { - loadOutboundMediaFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ - buffer: Buffer.from("default-bytes"), - fileName: mediaUrl.split("/").pop() || "attachment", - contentType: "application/octet-stream", - })); - readRemoteMediaBufferMock.mockImplementation(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); -} - vi.mock("./channel.runtime.js", () => { return { googleChatChannelRuntime: { @@ -97,7 +81,6 @@ vi.mock("./channel.runtime.js", () => { resolveGoogleChatWebhookPath: () => "/googlechat/webhook", sendGoogleChatMessage: (...args: unknown[]) => sendGoogleChatMessageMock(...args), startGoogleChatMonitor: (...args: unknown[]) => startGoogleChatMonitorMock(...args), - uploadGoogleChatAttachment: (...args: unknown[]) => uploadGoogleChatAttachmentMock(...args), }, }; }); @@ -128,7 +111,6 @@ vi.mock("./channel.deps.runtime.js", () => { return chunks; }, createAccountStatusSink: () => () => {}, - readRemoteMediaBuffer: (...args: unknown[]) => readRemoteMediaBufferMock(...args), getChatChannelMeta: (id: string) => ({ id, name: id }), isGoogleChatSpaceTarget: (value: string) => value.toLowerCase().startsWith("spaces/"), isGoogleChatUserTarget: (value: string) => value.toLowerCase().startsWith("users/"), @@ -136,25 +118,10 @@ vi.mock("./channel.deps.runtime.js", () => { const ids = Object.keys(cfg.channels?.googlechat?.accounts ?? {}); return ids.length > 0 ? ids : ["default"]; }, - loadOutboundMediaFromUrl: (...args: unknown[]) => loadOutboundMediaFromUrlMock(...args), missingTargetError: (channel: string, hint: string) => new Error(`${channel} target is required (${hint})`), normalizeGoogleChatTarget, PAIRING_APPROVED_MESSAGE: "approved", - resolveChannelMediaMaxBytes: (params: { - cfg: OpenClawConfig; - resolveChannelLimitMb: (args: { - cfg: OpenClawConfig; - accountId?: string; - }) => number | undefined; - accountId?: string; - }) => { - const limitMb = params.resolveChannelLimitMb({ - cfg: params.cfg, - accountId: params.accountId, - }); - return typeof limitMb === "number" ? limitMb * 1024 * 1024 : undefined; - }, resolveDefaultGoogleChatAccountId: () => "default", resolveGoogleChatAccount: (...args: Parameters) => resolveGoogleChatAccountMock(...args), @@ -167,13 +134,11 @@ vi.mock("./channel.deps.runtime.js", () => { resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl); mockGoogleChatOutboundSpaceResolution(); -mockGoogleChatMediaLoaders(); afterEach(() => { vi.clearAllMocks(); resolveGoogleChatAccountMock.mockImplementation(resolveGoogleChatAccountImpl); mockGoogleChatOutboundSpaceResolution(); - mockGoogleChatMediaLoaders(); }); afterAll(() => { @@ -198,24 +163,6 @@ function createGoogleChatCfg(): OpenClawConfig { }; } -function setupRuntimeMediaMocks(params: { loadFileName: string; loadBytes: string }) { - const loadOutboundMediaFromUrl = vi.fn(async () => ({ - buffer: Buffer.from(params.loadBytes), - fileName: params.loadFileName, - contentType: "image/png", - })); - const readRemoteMediaBuffer = vi.fn(async () => ({ - buffer: Buffer.from("remote-bytes"), - fileName: "remote.png", - contentType: "image/png", - })); - - loadOutboundMediaFromUrlMock.mockImplementation(loadOutboundMediaFromUrl); - readRemoteMediaBufferMock.mockImplementation(readRemoteMediaBuffer); - - return { loadOutboundMediaFromUrl, readRemoteMediaBuffer }; -} - function requireMockArg(mock: ReturnType, callIndex = 0, argIndex = 0): unknown { const call = mock.mock.calls[callIndex]; if (!call) { @@ -224,22 +171,11 @@ function requireMockArg(mock: ReturnType, callIndex = 0, argIndex return call[argIndex]; } -function requireMockArgs(mock: ReturnType, callIndex = 0): unknown[] { - const call = mock.mock.calls[callIndex]; - if (!call) { - throw new Error(`expected mock call ${callIndex}`); - } - return call; -} - -describe("googlechatPlugin outbound sendMedia", () => { - it("declares message adapter durable text, media, and thread with receipt proofs", async () => { +describe("googlechatPlugin outbound", () => { + it("declares durable text and thread delivery with receipt proofs", async () => { sendGoogleChatMessageMock.mockResolvedValue({ messageName: "spaces/AAA/messages/msg-1", }); - uploadGoogleChatAttachmentMock.mockResolvedValue({ - attachmentUploadToken: "token-1", - }); const cfg = createGoogleChatCfg(); @@ -256,16 +192,6 @@ describe("googlechatPlugin outbound sendMedia", () => { expect(result?.receipt.parts[0]?.kind).toBe("text"); expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]); }, - media: async () => { - const result = await googlechatMessageAdapter.send?.media?.({ - cfg, - to: "spaces/AAA", - text: "image", - mediaUrl: "https://example.com/img.png", - }); - expect(result?.receipt.parts[0]?.kind).toBe("media"); - expect(result?.receipt.platformMessageIds).toEqual(["spaces/AAA/messages/msg-1"]); - }, thread: async () => { sendGoogleChatMessageMock.mockClear(); await googlechatMessageAdapter.send?.text?.({ @@ -288,7 +214,7 @@ describe("googlechatPlugin outbound sendMedia", () => { }); expect(proofs).toStrictEqual([ { capability: "text", status: "verified" }, - { capability: "media", status: "verified" }, + { capability: "media", status: "not_declared" }, { capability: "poll", status: "not_declared" }, { capability: "payload", status: "not_declared" }, { capability: "silent", status: "not_declared" }, @@ -308,105 +234,6 @@ describe("googlechatPlugin outbound sendMedia", () => { expect(chunker("alpha beta", 5)).toEqual(["alpha", "beta"]); }); - - it("loads local media with mediaLocalRoots via runtime media loader", async () => { - const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({ - loadFileName: "image.png", - loadBytes: "image-bytes", - }); - - uploadGoogleChatAttachmentMock.mockResolvedValue({ - attachmentUploadToken: "token-1", - }); - sendGoogleChatMessageMock.mockResolvedValue({ - messageName: "spaces/AAA/messages/msg-1", - }); - - const cfg = createGoogleChatCfg(); - - const result = await googlechatOutboundAdapter.attachedResults.sendMedia({ - cfg, - to: "spaces/AAA", - text: "caption", - mediaUrl: "/tmp/workspace/image.png", - mediaLocalRoots: ["/tmp/workspace"], - accountId: "default", - }); - - const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [ - string, - { mediaLocalRoots?: string[] }, - ]; - expect(mediaUrl).toBe("/tmp/workspace/image.png"); - expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]); - expect(readRemoteMediaBuffer).not.toHaveBeenCalled(); - const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as { - space?: string; - filename?: string; - contentType?: string; - }; - expect(uploadRequest.space).toBe("spaces/AAA"); - expect(uploadRequest.filename).toBe("image.png"); - expect(uploadRequest.contentType).toBe("image/png"); - const sendRequest = requireMockArg(sendGoogleChatMessageMock) as { - space?: string; - text?: string; - }; - expect(sendRequest.space).toBe("spaces/AAA"); - expect(sendRequest.text).toBe("caption"); - expect(result.messageId).toBe("spaces/AAA/messages/msg-1"); - expect(result.chatId).toBe("spaces/AAA"); - expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-1"); - }); - - it("keeps remote URL media fetch on readRemoteMediaBuffer with maxBytes cap", async () => { - const { loadOutboundMediaFromUrl, readRemoteMediaBuffer } = setupRuntimeMediaMocks({ - loadFileName: "unused.png", - loadBytes: "should-not-be-used", - }); - - uploadGoogleChatAttachmentMock.mockResolvedValue({ - attachmentUploadToken: "token-2", - }); - sendGoogleChatMessageMock.mockResolvedValue({ - messageName: "spaces/AAA/messages/msg-2", - }); - - const cfg = createGoogleChatCfg(); - - const result = await googlechatOutboundAdapter.attachedResults.sendMedia({ - cfg, - to: "spaces/AAA", - text: "caption", - mediaUrl: "https://example.com/image.png", - accountId: "default", - }); - - const remoteRequest = requireMockArg(readRemoteMediaBuffer) as { - url?: string; - maxBytes?: number; - }; - expect(remoteRequest.url).toBe("https://example.com/image.png"); - expect(remoteRequest.maxBytes).toBe(20 * 1024 * 1024); - expect(loadOutboundMediaFromUrl).not.toHaveBeenCalled(); - const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as { - space?: string; - filename?: string; - contentType?: string; - }; - expect(uploadRequest.space).toBe("spaces/AAA"); - expect(uploadRequest.filename).toBe("remote.png"); - expect(uploadRequest.contentType).toBe("image/png"); - const sendRequest = requireMockArg(sendGoogleChatMessageMock) as { - space?: string; - text?: string; - }; - expect(sendRequest.space).toBe("spaces/AAA"); - expect(sendRequest.text).toBe("caption"); - expect(result.messageId).toBe("spaces/AAA/messages/msg-2"); - expect(result.chatId).toBe("spaces/AAA"); - expect(result.receipt.primaryPlatformMessageId).toBe("spaces/AAA/messages/msg-2"); - }); }); describe("googlechatPlugin threading", () => { @@ -643,106 +470,6 @@ describe("googlechatPlugin outbound cfg threading", () => { expect(request.space).toBe("spaces/AAA"); expect(request.text).toBe("hello"); }); - - it("threads resolved cfg into sendMedia account and media loading path", async () => { - const cfg = { - channels: { - googlechat: { - serviceAccount: { - type: "service_account", - }, - mediaMaxMb: 8, - }, - }, - }; - const account = { - accountId: "default", - config: { mediaMaxMb: 20 }, - credentialSource: "inline" as const, - }; - const { readRemoteMediaBuffer } = setupRuntimeMediaMocks({ - loadFileName: "unused.png", - loadBytes: "should-not-be-used", - }); - - resolveGoogleChatAccountMock.mockReturnValue(account); - resolveGoogleChatOutboundSpaceMock.mockResolvedValue("spaces/AAA"); - uploadGoogleChatAttachmentMock.mockResolvedValue({ - attachmentUploadToken: "token-1", - }); - sendGoogleChatMessageMock.mockResolvedValue({ - messageName: "spaces/AAA/messages/msg-2", - }); - - await googlechatOutboundAdapter.attachedResults.sendMedia({ - cfg: cfg as never, - to: "users/123", - text: "photo", - mediaUrl: "https://example.com/file.png", - accountId: "default", - }); - - expect(resolveGoogleChatAccountMock).toHaveBeenCalledWith({ - cfg, - accountId: "default", - }); - const remoteRequest = requireMockArg(readRemoteMediaBuffer) as { - url?: string; - maxBytes?: number; - }; - expect(remoteRequest.url).toBe("https://example.com/file.png"); - expect(remoteRequest.maxBytes).toBe(8 * 1024 * 1024); - const uploadRequest = requireMockArg(uploadGoogleChatAttachmentMock) as { - account?: unknown; - space?: string; - filename?: string; - }; - expect(uploadRequest.account).toBe(account); - expect(uploadRequest.space).toBe("spaces/AAA"); - expect(uploadRequest.filename).toBe("remote.png"); - const sendRequest = requireMockArg(sendGoogleChatMessageMock) as { - account?: unknown; - attachments?: Array<{ attachmentUploadToken: string; contentName: string }>; - }; - expect(sendRequest.account).toBe(account); - expect(sendRequest.attachments).toEqual([ - { attachmentUploadToken: "token-1", contentName: "remote.png" }, - ]); - }); - - it("sends media without requiring Google Chat runtime initialization", async () => { - const { loadOutboundMediaFromUrl } = setupRuntimeMediaMocks({ - loadFileName: "image.png", - loadBytes: "image-bytes", - }); - - uploadGoogleChatAttachmentMock.mockResolvedValue({ - attachmentUploadToken: "token-cold", - }); - sendGoogleChatMessageMock.mockResolvedValue({ - messageName: "spaces/AAA/messages/msg-cold", - }); - - const cfg = createGoogleChatCfg(); - - const result = await googlechatOutboundAdapter.attachedResults.sendMedia({ - cfg, - to: "spaces/AAA", - text: "caption", - mediaUrl: "/tmp/workspace/image.png", - mediaLocalRoots: ["/tmp/workspace"], - accountId: "default", - }); - expect(result.messageId).toBe("spaces/AAA/messages/msg-cold"); - expect(result.chatId).toBe("spaces/AAA"); - - const [mediaUrl, mediaOptions] = requireMockArgs(loadOutboundMediaFromUrl) as [ - string, - { mediaLocalRoots?: string[] }, - ]; - expect(mediaUrl).toBe("/tmp/workspace/image.png"); - expect(mediaOptions.mediaLocalRoots).toEqual(["/tmp/workspace"]); - }); }); describe("googlechat directory", () => { diff --git a/extensions/googlechat/src/channel.ts b/extensions/googlechat/src/channel.ts index caec31581ae5..c4b7e0a0cd6c 100644 --- a/extensions/googlechat/src/channel.ts +++ b/extensions/googlechat/src/channel.ts @@ -1,5 +1,4 @@ // Googlechat plugin module implements channel behavior. -import type { ChannelMessageActionName } from "openclaw/plugin-sdk/channel-contract"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared"; import { createLazyRuntimeNamedExport } from "openclaw/plugin-sdk/lazy-runtime"; @@ -61,13 +60,9 @@ const googlechatActions: ChannelMessageActionAdapter = { if (accounts.length === 0) { return null; } - const actions = new Set(["send", "upload-file"]); - if (accounts.some((account) => account.config.actions?.reactions !== false)) { - actions.add("react"); - actions.add("reactions"); - } - return { actions: Array.from(actions) }; + return { actions: ["send"] }; }, + supportsAction: ({ action }) => action === "send", extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async (ctx) => { const { googlechatMessageActions } = await import("./actions.js"); diff --git a/extensions/googlechat/src/monitor-access.ts b/extensions/googlechat/src/monitor-access.ts index 7291fd6840ce..cd31865a61c9 100644 --- a/extensions/googlechat/src/monitor-access.ts +++ b/extensions/googlechat/src/monitor-access.ts @@ -49,7 +49,7 @@ function normalizeGoogleChatStableEntry(entry: string): string | null { return withoutProvider.startsWith("users/") ? normalizeUserId(withoutProvider) : withoutProvider; } -function normalizeGoogleChatEmailEntry(entry: string): string | null { +export function normalizeGoogleChatEmailEntry(entry: string): string | null { const withoutProvider = normalizeEntryValue(entry).replace( /^(googlechat|google-chat|gchat):/i, "", @@ -89,7 +89,7 @@ type GoogleChatGroupEntry = { systemPrompt?: string; }; -function resolveGroupConfig(params: { +export function resolveGoogleChatGroupConfig(params: { groupId: string; groupName?: string | null; groups?: Record; @@ -249,7 +249,7 @@ export async function applyGoogleChatInboundAccessPolicy(params: { log: logVerbose, }); warnMutableGroupKeysConfigured(logVerbose, account.config.groups ?? undefined); - const groupConfigResolved = resolveGroupConfig({ + const groupConfigResolved = resolveGoogleChatGroupConfig({ groupId: spaceId, groupName: space.displayName ?? null, groups: account.config.groups ?? undefined, diff --git a/extensions/googlechat/src/monitor-reply-delivery.ts b/extensions/googlechat/src/monitor-reply-delivery.ts index 47daacde101d..9544ae8e2ba1 100644 --- a/extensions/googlechat/src/monitor-reply-delivery.ts +++ b/extensions/googlechat/src/monitor-reply-delivery.ts @@ -1,16 +1,8 @@ // Googlechat plugin module implements monitor reply delivery behavior. -import { - deliverTextOrMediaReply, - resolveSendableOutboundReplyParts, -} from "openclaw/plugin-sdk/reply-payload"; +import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload"; import type { OpenClawConfig } from "../runtime-api.js"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; -import { - deleteGoogleChatMessage, - sendGoogleChatMessage, - updateGoogleChatMessage, - uploadGoogleChatAttachment, -} from "./api.js"; +import { deleteGoogleChatMessage, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js"; import type { GoogleChatCoreRuntime, GoogleChatRuntimeEnv } from "./monitor-types.js"; export async function deliverGoogleChatReply(params: { @@ -33,40 +25,26 @@ export async function deliverGoogleChatReply(params: { // text delivery can keep retrying a dead message and drop content. let typingMessageName = params.typingMessageName; const reply = resolveSendableOutboundReplyParts(payload); - const mediaCount = reply.mediaCount; - const hasMedia = reply.hasMedia; const text = reply.text; let firstTextChunk = true; - let suppressCaption = false; - if (hasMedia && typingMessageName) { + if (reply.hasMedia) { + runtime.error?.( + "Google Chat outbound attachments require user OAuth and are not supported by this service-account channel; sending text fallback only.", + ); + } + + if (reply.hasMedia && !reply.hasText) { try { - await deleteGoogleChatMessage({ - account, - messageName: typingMessageName, - }); - typingMessageName = undefined; + if (typingMessageName) { + await deleteGoogleChatMessage({ account, messageName: typingMessageName }); + } } catch (err) { runtime.error?.(`Google Chat typing cleanup failed: ${String(err)}`); - if (typingMessageName) { - const fallbackText = reply.hasText - ? text - : mediaCount > 1 - ? "Sent attachments." - : "Sent attachment."; - try { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: fallbackText, - }); - suppressCaption = Boolean(text.trim()); - } catch (updateErr) { - runtime.error?.(`Google Chat typing update failed: ${String(updateErr)}`); - typingMessageName = undefined; - } - } } + throw new Error( + "Google Chat outbound attachments require user OAuth and no text fallback is available.", + ); } const chunkLimit = account.config.textChunkLimit ?? 4000; @@ -79,84 +57,36 @@ export async function deliverGoogleChatReply(params: { thread: payload.replyToId, }); }; - await deliverTextOrMediaReply({ - payload, - text: suppressCaption ? "" : reply.text, - chunkText: (value) => core.channel.text.chunkMarkdownTextWithMode(value, chunkLimit, chunkMode), - sendText: async (chunk) => { - try { - if (firstTextChunk && typingMessageName) { - await updateGoogleChatMessage({ - account, - messageName: typingMessageName, - text: chunk, - }); - } else { + const chunks = core.channel.text.chunkMarkdownTextWithMode(text, chunkLimit, chunkMode); + for (const chunk of chunks) { + if (!chunk) { + continue; + } + try { + if (firstTextChunk && typingMessageName) { + await updateGoogleChatMessage({ + account, + messageName: typingMessageName, + text: chunk, + }); + } else { + await sendTextMessage(chunk); + } + firstTextChunk = false; + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.(`Google Chat message send failed: ${String(err)}`); + if (firstTextChunk && typingMessageName) { + typingMessageName = undefined; + try { await sendTextMessage(chunk); - } - firstTextChunk = false; - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat message send failed: ${String(err)}`); - if (firstTextChunk && typingMessageName) { - typingMessageName = undefined; - try { - await sendTextMessage(chunk); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (fallbackErr) { - runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`); - } finally { - firstTextChunk = false; - } + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (fallbackErr) { + runtime.error?.(`Google Chat message fallback send failed: ${String(fallbackErr)}`); + } finally { + firstTextChunk = false; } } - }, - sendMedia: async ({ mediaUrl, caption }) => { - try { - const loaded = await core.channel.media.readRemoteMediaBuffer({ - url: mediaUrl, - maxBytes: (account.config.mediaMaxMb ?? 20) * 1024 * 1024, - }); - const upload = await uploadAttachmentForReply({ - account, - spaceId, - buffer: loaded.buffer, - contentType: loaded.contentType, - filename: loaded.fileName ?? "attachment", - }); - if (!upload.attachmentUploadToken) { - throw new Error("missing attachment upload token"); - } - await sendGoogleChatMessage({ - account, - space: spaceId, - text: caption, - thread: payload.replyToId, - attachments: [ - { attachmentUploadToken: upload.attachmentUploadToken, contentName: loaded.fileName }, - ], - }); - statusSink?.({ lastOutboundAt: Date.now() }); - } catch (err) { - runtime.error?.(`Google Chat attachment send failed: ${String(err)}`); - } - }, - }); -} - -async function uploadAttachmentForReply(params: { - account: ResolvedGoogleChatAccount; - spaceId: string; - buffer: Buffer; - contentType?: string; - filename: string; -}) { - const { account, spaceId, buffer, contentType, filename } = params; - return await uploadGoogleChatAttachment({ - account, - space: spaceId, - filename, - buffer, - contentType, - }); + } + } } diff --git a/extensions/googlechat/src/monitor.reply-delivery.test.ts b/extensions/googlechat/src/monitor.reply-delivery.test.ts index c085c96560ae..dbb422f01d5e 100644 --- a/extensions/googlechat/src/monitor.reply-delivery.test.ts +++ b/extensions/googlechat/src/monitor.reply-delivery.test.ts @@ -8,14 +8,12 @@ const mocks = vi.hoisted(() => ({ deleteGoogleChatMessage: vi.fn(), sendGoogleChatMessage: vi.fn(), updateGoogleChatMessage: vi.fn(), - uploadGoogleChatAttachment: vi.fn(), })); vi.mock("./api.js", () => ({ deleteGoogleChatMessage: mocks.deleteGoogleChatMessage, sendGoogleChatMessage: mocks.sendGoogleChatMessage, updateGoogleChatMessage: mocks.updateGoogleChatMessage, - uploadGoogleChatAttachment: mocks.uploadGoogleChatAttachment, })); const account = { @@ -106,14 +104,11 @@ describe("Google Chat reply delivery", () => { ); }); - it("does not update a deleted typing message before sending media with a caption", async () => { + it("uses text fallback without loading outbound media", async () => { const core = createCore({ media: { buffer: Buffer.from("image"), contentType: "image/png", fileName: "reply.png" }, }); const runtime = createRuntime(); - mocks.deleteGoogleChatMessage.mockResolvedValue(undefined); - mocks.uploadGoogleChatAttachment.mockResolvedValue({ attachmentUploadToken: "upload-token" }); - mocks.sendGoogleChatMessage.mockResolvedValue({ messageName: "spaces/AAA/messages/media" }); await deliverGoogleChatReply({ payload: { @@ -129,17 +124,46 @@ describe("Google Chat reply delivery", () => { typingMessageName: "spaces/AAA/messages/typing", }); + expect(mocks.updateGoogleChatMessage).toHaveBeenCalledWith({ + account, + messageName: "spaces/AAA/messages/typing", + text: "caption", + }); + expect(core.channel.media.readRemoteMediaBuffer).not.toHaveBeenCalled(); + expect(mocks.deleteGoogleChatMessage).not.toHaveBeenCalled(); + expect(mocks.sendGoogleChatMessage).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith( + "Google Chat outbound attachments require user OAuth and are not supported by this service-account channel; sending text fallback only.", + ); + }); + + it("cleans up typing and rejects media-only replies without provider upload access", async () => { + const core = createCore(); + const runtime = createRuntime(); + + await expect( + deliverGoogleChatReply({ + payload: { + mediaUrl: "https://example.invalid/reply.png", + replyToId: "spaces/AAA/threads/root", + }, + account, + spaceId: "spaces/AAA", + runtime, + core, + config, + typingMessageName: "spaces/AAA/messages/typing", + }), + ).rejects.toThrow( + "Google Chat outbound attachments require user OAuth and no text fallback is available.", + ); + expect(mocks.deleteGoogleChatMessage).toHaveBeenCalledWith({ account, messageName: "spaces/AAA/messages/typing", }); + expect(core.channel.media.readRemoteMediaBuffer).not.toHaveBeenCalled(); expect(mocks.updateGoogleChatMessage).not.toHaveBeenCalled(); - expect(mocks.sendGoogleChatMessage).toHaveBeenCalledWith({ - account, - space: "spaces/AAA", - text: "caption", - thread: "spaces/AAA/threads/root", - attachments: [{ attachmentUploadToken: "upload-token", contentName: "reply.png" }], - }); + expect(mocks.sendGoogleChatMessage).not.toHaveBeenCalled(); }); }); diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index a079bdc5fd8b..879da19788ac 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -1,12 +1,7 @@ // Googlechat tests cover targets plugin behavior. import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import type { ResolvedGoogleChatAccount } from "./accounts.js"; -import { - downloadGoogleChatMedia, - sendGoogleChatMessage, - updateGoogleChatMessage, - uploadGoogleChatAttachment, -} from "./api.js"; +import { downloadGoogleChatMedia, sendGoogleChatMessage, updateGoogleChatMessage } from "./api.js"; import { clearGoogleChatApprovalCardBindingsForTest, registerGoogleChatManualApprovalFollowupSuppression, @@ -365,7 +360,7 @@ describe("downloadGoogleChatMedia", () => { }); }); -describe("uploadGoogleChatAttachment", () => { +describe("supported Google Chat request bounds", () => { afterEach(() => { authTesting.resetGoogleChatAuthForTests(); mocks.fetchWithSsrFGuard.mockClear(); @@ -377,34 +372,33 @@ describe("uploadGoogleChatAttachment", () => { vi.stubGlobal( "fetch", vi.fn().mockResolvedValue( - new Response(JSON.stringify({ attachmentDataRef: { attachmentUploadToken: "token" } }), { + new Response(new Uint8Array([1, 2, 3]), { status: 200, + headers: { "content-type": "application/octet-stream" }, }), ), ); - await uploadGoogleChatAttachment({ + await downloadGoogleChatMedia({ account, - space: "spaces/AAA", - filename: "recording.wav", - buffer: Buffer.alloc(1024 * 1024), + resourceName: "media/123", + maxBytes: 1024 * 1024, }); - expect(lastGuardedFetchOptions().timeoutMs).toBeGreaterThan(34_000); + expect(lastGuardedFetchOptions().timeoutMs).toBe(34_000); }); - it("cancels a stalled upload response body", async () => { + it("cancels a stalled JSON response body", async () => { vi.useFakeTimers(); vi.stubGlobal("fetch", vi.fn().mockResolvedValue(createStalledResponse())); const result = expect( - uploadGoogleChatAttachment({ + sendGoogleChatMessage({ account, space: "spaces/AAA", - filename: "recording.wav", - buffer: Buffer.alloc(1024), + text: "hello", }), - ).rejects.toThrow("Google Chat upload failed: response body stalled after 30000ms"); + ).rejects.toThrow("Google Chat API request failed: response body stalled after 30000ms"); await vi.advanceTimersByTimeAsync(30_001); await result; }); diff --git a/extensions/googlechat/src/types.ts b/extensions/googlechat/src/types.ts index bb4c6e009fc6..bdf9966f0d99 100644 --- a/extensions/googlechat/src/types.ts +++ b/extensions/googlechat/src/types.ts @@ -91,12 +91,6 @@ export type GoogleChatEvent = { }; }; -export type GoogleChatReaction = { - name?: string; - user?: GoogleChatUser; - emoji?: { unicode?: string }; -}; - type GoogleChatTextParagraphWidget = { textParagraph: { text: string; diff --git a/extensions/imessage/src/actions.test.ts b/extensions/imessage/src/actions.test.ts index d4fbd2f7af8a..23317eac4417 100644 --- a/extensions/imessage/src/actions.test.ts +++ b/extensions/imessage/src/actions.test.ts @@ -116,6 +116,35 @@ describe("imessage message actions", () => { loggerMock.warn.mockReset(); }); + it.each([ + "react", + "edit", + "unsend", + "renameGroup", + "setGroupIcon", + "addParticipant", + "removeParticipant", + "leaveGroup", + ] as const)("resolves %s chat aliases to the canonical delivery target", (action) => { + const aliasSpec = imessageMessageActions.messageActionTargetAliases?.[action]; + + expect(aliasSpec?.deliveryTargetAliases).toStrictEqual([ + "chatGuid", + "chatIdentifier", + "chatId", + ]); + if (action === "react") { + expect(aliasSpec?.aliases).toContain("messageId"); + } + expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatGuid: "iMessage;+;chat0000" } })).toBe( + "chat_guid:iMessage;+;chat0000", + ); + expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatIdentifier: "team-thread" } })).toBe( + "chat_identifier:team-thread", + ); + expect(aliasSpec?.resolveDeliveryTarget?.({ args: { chatId: 42 } })).toBe("chat_id:42"); + }); + it("does not advertise private API actions when the bridge is known unavailable", () => { probeMock.getCachedIMessagePrivateApiStatus.mockReturnValue({ available: false, diff --git a/extensions/imessage/src/actions.ts b/extensions/imessage/src/actions.ts index 24bb3800871f..af33a59a413d 100644 --- a/extensions/imessage/src/actions.ts +++ b/extensions/imessage/src/actions.ts @@ -24,6 +24,7 @@ import { DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS } from "./constants.js"; import { describeIMessageMessageTool } from "./message-tool-api.js"; import { findLatestIMessageEntryForChat, + isIMessageCurrentMessageInChat, rememberIMessageReplyCache, type IMessageChatContext, } from "./monitor-reply-cache.js"; @@ -71,6 +72,40 @@ function resolveIMessageDeliveryTarget(args: Record): string | return targets[0]; } +const IMESSAGE_DELIVERY_TARGET_ALIASES = ["chatGuid", "chatIdentifier", "chatId"]; + +function matchesIMessageCurrentConversation(params: { + args: Record; + accountId: string; + toolContext: { + currentMessageId?: string | number; + }; +}): boolean { + const currentMessageId = params.toolContext.currentMessageId; + if (currentMessageId === undefined) { + return false; + } + return isIMessageCurrentMessageInChat({ + accountId: params.accountId, + currentMessageId, + chatContext: { + chatGuid: readStringParam(params.args, "chatGuid"), + chatIdentifier: readStringParam(params.args, "chatIdentifier"), + chatId: readPositiveIntegerParam(params.args, "chatId"), + }, + }); +} + +function createIMessageTargetAliases(resourceAliases: string[] = []) { + return { + aliases: [...IMESSAGE_DELIVERY_TARGET_ALIASES, ...resourceAliases], + deliveryTargetAliases: [...IMESSAGE_DELIVERY_TARGET_ALIASES], + resolveDeliveryTarget: ({ args }: { args: Record }) => + resolveIMessageDeliveryTarget(args), + matchesCurrentConversation: matchesIMessageCurrentConversation, + }; +} + function rememberOutboundBridgeMessage(params: { accountId: string; messageId?: string; @@ -418,44 +453,20 @@ export const imessageMessageActions: ChannelMessageActionAdapter = { normalizeOptionalLowercaseString(toolContext?.currentChannelProvider) === "imessage" && GROUP_MANAGEMENT_ACTIONS.has(action), messageActionTargetAliases: { - react: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, - edit: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] }, - unsend: { aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"] }, - reply: { - aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - sendWithEffect: { - aliases: ["chatGuid", "chatIdentifier", "chatId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - sendAttachment: { - aliases: ["chatGuid", "chatIdentifier", "chatId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - poll: { - aliases: ["chatGuid", "chatIdentifier", "chatId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - "poll-vote": { - aliases: ["chatGuid", "chatIdentifier", "chatId", "pollId", "messageId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - "upload-file": { - aliases: ["chatGuid", "chatIdentifier", "chatId"], - deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], - resolveDeliveryTarget: ({ args }) => resolveIMessageDeliveryTarget(args), - }, - renameGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, - setGroupIcon: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, - addParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, - removeParticipant: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, - leaveGroup: { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, + react: createIMessageTargetAliases(["messageId"]), + edit: createIMessageTargetAliases(["messageId"]), + unsend: createIMessageTargetAliases(["messageId"]), + reply: createIMessageTargetAliases(["messageId"]), + sendWithEffect: createIMessageTargetAliases(), + sendAttachment: createIMessageTargetAliases(), + poll: createIMessageTargetAliases(), + "poll-vote": createIMessageTargetAliases(["pollId", "messageId"]), + "upload-file": createIMessageTargetAliases(), + renameGroup: createIMessageTargetAliases(), + setGroupIcon: createIMessageTargetAliases(), + addParticipant: createIMessageTargetAliases(), + removeParticipant: createIMessageTargetAliases(), + leaveGroup: createIMessageTargetAliases(), }, extractToolSend: ({ args }) => extractToolSend(args, "sendMessage"), handleAction: async ({ diff --git a/extensions/imessage/src/monitor-reply-cache.test.ts b/extensions/imessage/src/monitor-reply-cache.test.ts index 8fa29c954ba5..28426ee10f61 100644 --- a/extensions/imessage/src/monitor-reply-cache.test.ts +++ b/extensions/imessage/src/monitor-reply-cache.test.ts @@ -1,10 +1,11 @@ // Imessage tests cover monitor reply cache plugin behavior. import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { - resetIMessageShortIdState, findLatestIMessageEntryForChat, + isIMessageCurrentMessageInChat, isKnownFromMeIMessageMessageId, rememberIMessageReplyCache, + resetIMessageShortIdState, resolveIMessageMessageId, } from "./monitor-reply-cache.js"; import { installIMessageStateRuntimeForTest } from "./test-support/runtime.js"; @@ -383,6 +384,70 @@ describe("hydrate-on-resolve (post-restart short-id persistence)", () => { }); }); +describe("current-message chat binding", () => { + it.each([{ chatGuid: "any;-;+12069106512" }, { chatIdentifier: "+12069106512" }, { chatId: 42 }])( + "matches a trusted current message through $chatGuid$chatIdentifier$chatId", + (chatContext) => { + const entry = rememberIMessageReplyCache({ + accountId: "work", + messageId: "current-guid", + chatGuid: "any;-;+12069106512", + chatIdentifier: "+12069106512", + chatId: 42, + timestamp: Date.now(), + }); + + expect( + isIMessageCurrentMessageInChat({ + accountId: "work", + currentMessageId: entry.shortId, + chatContext, + }), + ).toBe(true); + expect( + isIMessageCurrentMessageInChat({ + accountId: "work", + currentMessageId: "current-guid", + chatContext, + }), + ).toBe(true); + }, + ); + + it("fails closed for wrong accounts, chats, and unknown current messages", () => { + rememberIMessageReplyCache({ + accountId: "work", + messageId: "current-guid", + chatGuid: "any;-;+12069106512", + chatIdentifier: "+12069106512", + chatId: 42, + timestamp: Date.now(), + }); + + expect( + isIMessageCurrentMessageInChat({ + accountId: "other", + currentMessageId: "current-guid", + chatContext: { chatId: 42 }, + }), + ).toBe(false); + expect( + isIMessageCurrentMessageInChat({ + accountId: "work", + currentMessageId: "current-guid", + chatContext: { chatId: 99 }, + }), + ).toBe(false); + expect( + isIMessageCurrentMessageInChat({ + accountId: "work", + currentMessageId: "unknown-guid", + chatContext: { chatId: 42 }, + }), + ).toBe(false); + }); +}); + describe("hydrate counter advancement (rowid-collision protection)", () => { it("advances the short-id counter past a corrupt persisted line so new allocations don't collide", () => { // Direct hydrate isn't easy to invoke without disk fixtures; instead diff --git a/extensions/imessage/src/monitor-reply-cache.ts b/extensions/imessage/src/monitor-reply-cache.ts index 78ae44a422f8..f02e5d76fcd5 100644 --- a/extensions/imessage/src/monitor-reply-cache.ts +++ b/extensions/imessage/src/monitor-reply-cache.ts @@ -538,6 +538,34 @@ function isPositiveChatMatch(entry: IMessageReplyCacheEntry, ctx: IMessageChatCo return false; } +export function isIMessageCurrentMessageInChat(params: { + accountId: string; + currentMessageId: string | number; + chatContext: IMessageChatContext; +}): boolean { + if (!params.accountId || !hasChatScope(params.chatContext)) { + return false; + } + const currentMessageId = normalizeOptionalString(String(params.currentMessageId)); + if (!currentMessageId) { + return false; + } + hydrateFromStoreOnce(); + const fullMessageId = /^\d+$/.test(currentMessageId) + ? imessageShortIdToUuid.get(currentMessageId) + : currentMessageId; + if (!fullMessageId) { + return false; + } + const entry = imessageReplyCacheByMessageId.get(fullMessageId); + return Boolean( + entry && + entry.accountId === params.accountId && + Date.now() - entry.timestamp <= REPLY_CACHE_TTL_MS && + isPositiveChatMatch(entry, params.chatContext), + ); +} + export function resetIMessageShortIdState(options: { clearPersistent?: boolean } = {}): void { imessageReplyCacheByMessageId.clear(); imessageShortIdToUuid.clear(); diff --git a/extensions/matrix/src/actions.account-propagation.test.ts b/extensions/matrix/src/actions.account-propagation.test.ts index 30518d761732..77652a8e6b7c 100644 --- a/extensions/matrix/src/actions.account-propagation.test.ts +++ b/extensions/matrix/src/actions.account-propagation.test.ts @@ -73,7 +73,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.action).toBe("sendMessage"); expect(call.input.accountId).toBe("ops"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); }); it("forwards accountId for permissions actions", async () => { @@ -91,7 +91,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.action).toBe("verificationList"); expect(call.input.accountId).toBe("ops"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); }); it("forwards accountId for self-profile updates", async () => { @@ -113,7 +113,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.displayName).toBe("Ops Bot"); expect(call.input.avatarUrl).toBe("mxc://example/avatar"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); }); it("rejects self-profile updates without sender owner context", async () => { @@ -167,7 +167,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.accountId).toBe("ops"); expect(call.input.avatarPath).toBe("/tmp/avatar.jpg"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); }); it("forwards mediaLocalRoots for media sends", async () => { @@ -189,7 +189,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.accountId).toBe("ops"); expect(call.input.mediaUrl).toBe("file:///tmp/photo.png"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }); + expect(call.options).toMatchObject({ mediaLocalRoots: ["/tmp/openclaw-matrix-test"] }); }); it("allows media-only sends without requiring a message body", async () => { @@ -210,7 +210,7 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.content).toBeUndefined(); expect(call.input.mediaUrl).toBe("file:///tmp/photo.png"); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); }); it("accepts shared media aliases and forwards voice-send intent", async () => { @@ -233,6 +233,35 @@ describe("matrixMessageActions account propagation", () => { expect(call.input.mediaUrl).toBe("/tmp/clip.mp3"); expect(call.input.audioAsVoice).toBe(true); expect(call.cfg).toBeTypeOf("object"); - expect(call.options).toEqual({ mediaLocalRoots: undefined }); + expect(call.options).toMatchObject({ mediaLocalRoots: undefined }); + }); + + it("forwards trusted conversation context for read authorization", async () => { + await matrixMessageActions.handleAction?.( + createContext({ + action: "reactions", + accountId: "ops", + requesterAccountId: "ops", + params: { + roomId: "!dm:example.org", + messageId: "$event", + }, + toolContext: { + currentChannelId: "room:!dm:example.org", + currentChannelProvider: "matrix", + currentChatType: "direct", + }, + }), + ); + + expect(matrixActionCall().options).toMatchObject({ + readContext: { + accountId: "ops", + requesterAccountId: "ops", + currentChannelId: "room:!dm:example.org", + currentChannelProvider: "matrix", + currentChatType: "direct", + }, + }); }); }); diff --git a/extensions/matrix/src/actions.ts b/extensions/matrix/src/actions.ts index ed67c75d12fb..4922a6fd9b2a 100644 --- a/extensions/matrix/src/actions.ts +++ b/extensions/matrix/src/actions.ts @@ -160,7 +160,17 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { ...(accountId ? { accountId } : {}), }, cfg as CoreConfig, - { mediaLocalRoots }, + { + mediaLocalRoots, + readContext: { + accountId, + requesterAccountId: ctx.requesterAccountId, + currentChannelId: ctx.toolContext?.currentChannelId, + currentChannelProvider: ctx.toolContext?.currentChannelProvider, + currentChatType: ctx.toolContext?.currentChatType, + conversationReadOrigin: ctx.conversationReadOrigin, + }, + }, ); const resolveRoomId = () => readStringParam(params, "roomId") ?? @@ -297,7 +307,7 @@ export const matrixMessageActions: ChannelMessageActionAdapter = { return await dispatch({ action: "memberInfo", userId, - roomId: readStringParam(params, "roomId") ?? readStringParam(params, "channelId"), + roomId: resolveRoomId(), }); } diff --git a/extensions/matrix/src/matrix/actions/room.test.ts b/extensions/matrix/src/matrix/actions/room.test.ts index 89c24c19607b..ce6ad57de017 100644 --- a/extensions/matrix/src/matrix/actions/room.test.ts +++ b/extensions/matrix/src/matrix/actions/room.test.ts @@ -16,10 +16,7 @@ function createRoomClient() { throw new Error(`unexpected state event ${eventType}`); } }); - const getJoinedRoomMembers = vi.fn(async () => [ - { user_id: "@alice:example.org" }, - { user_id: "@bot:example.org" }, - ]); + const getJoinedRoomMembers = vi.fn(async () => ["@alice:example.org", "@bot:example.org"]); const getUserProfile = vi.fn(async () => ({ displayname: "Alice", avatar_url: "mxc://example.org/alice", @@ -56,7 +53,7 @@ describe("matrix room actions", () => { }); }); - it("resolves optional room ids when looking up member info", async () => { + it("requires room membership when looking up member info", async () => { const { client, getUserProfile } = createRoomClient(); const result = await getMatrixMemberInfo("@alice:example.org", { @@ -77,4 +74,16 @@ describe("matrix room actions", () => { roomId: "!ops:example.org", }); }); + + it("rejects profiles for users outside the room", async () => { + const { client, getUserProfile } = createRoomClient(); + + await expect( + getMatrixMemberInfo("@mallory:example.org", { + client, + roomId: "room:!ops:example.org", + }), + ).rejects.toThrow("User @mallory:example.org is not a member of room !ops:example.org"); + expect(getUserProfile).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/matrix/src/matrix/actions/room.ts b/extensions/matrix/src/matrix/actions/room.ts index c562f1e26e3c..694abbbd304f 100644 --- a/extensions/matrix/src/matrix/actions/room.ts +++ b/extensions/matrix/src/matrix/actions/room.ts @@ -5,10 +5,14 @@ import { EventType, type MatrixActionClientOpts } from "./types.js"; export async function getMatrixMemberInfo( userId: string, - opts: MatrixActionClientOpts & { roomId?: string } = {}, + opts: MatrixActionClientOpts & { roomId: string }, ) { return await withResolvedActionClient(opts, async (client) => { - const roomId = opts.roomId ? await resolveMatrixRoomId(client, opts.roomId) : undefined; + const roomId = await resolveMatrixRoomId(client, opts.roomId); + const members = await client.getJoinedRoomMembers(roomId); + if (!members.includes(userId)) { + throw new Error(`User ${userId} is not a member of room ${roomId}`); + } const profile = await client.getUserProfile(userId); // Membership and power levels are not included in profile calls; fetch state separately if needed. return { @@ -20,7 +24,7 @@ export async function getMatrixMemberInfo( membership: null, // Would need separate room state query powerLevel: null, // Would need separate power levels state query displayName: profile?.displayname ?? null, - roomId: roomId ?? null, + roomId, }; }); } diff --git a/extensions/matrix/src/matrix/client/config.test.ts b/extensions/matrix/src/matrix/client/config.test.ts index c8f7983289c6..03e1deae0087 100644 --- a/extensions/matrix/src/matrix/client/config.test.ts +++ b/extensions/matrix/src/matrix/client/config.test.ts @@ -541,6 +541,47 @@ describe("Matrix auth/config live surfaces", () => { ).toThrow(/Matrix account "typo" is not configured/i); }); + it("rejects invalid explicit account ids instead of borrowing the default account", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://legacy.example.org", + accessToken: "legacy-token", + }, + }, + } as CoreConfig; + + expect(() => + resolveMatrixAuthContext({ cfg, env: {} as NodeJS.ProcessEnv, accountId: "!!!" }), + ).toThrow(/Matrix account id "!!!" is invalid/i); + }); + + it("rejects explicitly selected disabled accounts instead of borrowing another account", () => { + const cfg = { + channels: { + matrix: { + homeserver: "https://legacy.example.org", + accessToken: "legacy-token", + accounts: { + disabled: { + enabled: false, + homeserver: "https://disabled.example.org", + accessToken: "disabled-token", + }, + }, + }, + }, + } as CoreConfig; + + expect(() => + resolveMatrixAuthContext({ + cfg, + env: {} as NodeJS.ProcessEnv, + accountId: "disabled", + }), + ).toThrow(/Matrix account "disabled" is disabled/i); + }); + it("allows explicit non-default account ids backed only by scoped env vars", () => { const cfg = { channels: { diff --git a/extensions/matrix/src/matrix/client/config.ts b/extensions/matrix/src/matrix/client/config.ts index 06886e230ef2..0af6826eb366 100644 --- a/extensions/matrix/src/matrix/client/config.ts +++ b/extensions/matrix/src/matrix/client/config.ts @@ -540,7 +540,11 @@ export function resolveMatrixAuthContext(params: { } { const cfg = requireRuntimeConfig(params.cfg, "Matrix auth context") as CoreConfig; const env = params?.env ?? process.env; + const requestedAccountId = params?.accountId?.trim(); const explicitAccountId = normalizeOptionalAccountId(params?.accountId); + if (requestedAccountId && !explicitAccountId) { + throw new Error(`Matrix account id "${requestedAccountId}" is invalid.`); + } const effectiveAccountId = explicitAccountId ?? resolveImplicitMatrixAccountId(cfg, env); if (!effectiveAccountId) { throw new Error( @@ -557,6 +561,11 @@ export function resolveMatrixAuthContext(params: { `Matrix account "${explicitAccountId}" is not configured. Add channels.matrix.accounts.${explicitAccountId} or define scoped ${getMatrixScopedEnvVarNames(explicitAccountId).accessToken.replace(/_ACCESS_TOKEN$/, "")}_* variables.`, ); } + const matrix = resolveMatrixBaseConfig(cfg); + const account = findMatrixAccountConfig(cfg, effectiveAccountId); + if (matrix.enabled === false || account?.enabled === false) { + throw new Error(`Matrix account "${effectiveAccountId}" is disabled.`); + } const resolved = resolveMatrixConfigForAccount(cfg, effectiveAccountId, env); return { diff --git a/extensions/matrix/src/matrix/monitor/room-info.ts b/extensions/matrix/src/matrix/monitor/room-info.ts index 18448ed15967..9068fd04f26d 100644 --- a/extensions/matrix/src/matrix/monitor/room-info.ts +++ b/extensions/matrix/src/matrix/monitor/room-info.ts @@ -121,6 +121,7 @@ export function createMatrixRoomInfoResolver(client: MatrixClient) { }; return { + getRoomAliases, getRoomInfo, getMemberDisplayName, }; diff --git a/extensions/matrix/src/matrix/read-policy.test.ts b/extensions/matrix/src/matrix/read-policy.test.ts new file mode 100644 index 000000000000..ff35092cdc16 --- /dev/null +++ b/extensions/matrix/src/matrix/read-policy.test.ts @@ -0,0 +1,611 @@ +import { describe, expect, it, vi } from "vitest"; +import type { CoreConfig } from "../types.js"; +import { withAuthorizedMatrixReadTarget } from "./read-policy.js"; +import type { MatrixClient } from "./sdk.js"; + +function createClient( + members: string[], + directFlag: boolean | null = null, + aliases: { canonicalAlias?: string; altAliases?: string[] } = {}, + roomName?: string, + overrides: Partial = {}, +): MatrixClient { + return { + dms: { + update: vi.fn(async () => false), + isDm: vi.fn(() => false), + }, + getJoinedRoomMembers: vi.fn(async () => members), + getRoomStateEvent: vi.fn(async (_roomId: string, eventType: string) => { + if (eventType === "m.room.canonical_alias") { + return { alias: aliases.canonicalAlias, alt_aliases: aliases.altAliases }; + } + if (eventType === "m.room.name") { + return roomName ? { name: roomName } : {}; + } + return directFlag === null ? {} : { is_direct: directFlag }; + }), + getUserId: vi.fn(async () => "@bot:example.org"), + stop: vi.fn(), + ...overrides, + } as unknown as MatrixClient; +} + +describe("Matrix read policy", () => { + it("allows configured rooms and rejects other rooms before the read", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]); + const cfg = { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "!allowed:example.org": {}, + }, + }, + }, + } as CoreConfig; + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg, + roomId: "!allowed:example.org", + opts: { client }, + run: read, + }), + ).resolves.toBe("ok"); + await expect( + withAuthorizedMatrixReadTarget({ + cfg, + roomId: "!blocked:example.org", + opts: { client }, + run: read, + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(read).toHaveBeenCalledTimes(1); + }); + + it("authorizes direct rooms by their remote member", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org"], true); + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + dm: { + policy: "allowlist", + allowFrom: ["@alice:example.org"], + }, + }, + }, + } as CoreConfig, + roomId: "!dm:example.org", + opts: { client }, + run: read, + }), + ).resolves.toBe("ok"); + }); + + it("keeps a restrictive DM allowlist effective under open policy", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org"], true); + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + dm: { + policy: "open", + allowFrom: ["@bob:example.org"], + }, + }, + }, + } as CoreConfig, + roomId: "!dm:example.org", + opts: { client }, + run: read, + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(read).not.toHaveBeenCalled(); + }); + + it("allows wildcard DM reads under any non-disabled policy", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org"], true); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + dm: { + policy: "pairing", + allowFrom: ["matrix:*"], + }, + }, + }, + } as CoreConfig, + roomId: "!dm:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it("does not guess that an unmarked two-member room is a DM", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org"]); + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "open", + dm: { policy: "allowlist", allowFrom: [] }, + }, + }, + } as CoreConfig, + roomId: "!ambiguous:example.org", + opts: { client }, + run: read, + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(read).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "member lookup fails", + overrides: { + getJoinedRoomMembers: vi.fn(async () => { + throw new Error("members unavailable"); + }), + }, + }, + { + name: "self lookup fails", + overrides: { + getUserId: vi.fn(async () => { + throw new Error("whoami unavailable"); + }), + }, + }, + ])("fails closed when $name", async ({ overrides }) => { + const client = createClient( + ["@bot:example.org", "@alice:example.org"], + true, + {}, + undefined, + overrides, + ); + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "open", + dm: { policy: "disabled" }, + }, + }, + } as CoreConfig, + roomId: "!unknown:example.org", + opts: { client }, + run: read, + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(read).not.toHaveBeenCalled(); + }); + + it("allows the trusted current room without broadening other targets", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]); + const read = vi.fn(async () => "ok"); + const cfg = { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: {}, + }, + }, + } as CoreConfig; + + await expect( + withAuthorizedMatrixReadTarget({ + cfg, + roomId: "!current:example.org", + context: { + currentChannelProvider: "matrix", + currentChannelId: "!current:example.org", + requesterAccountId: "default", + }, + opts: { client }, + run: read, + }), + ).resolves.toBe("ok"); + await expect( + withAuthorizedMatrixReadTarget({ + cfg, + roomId: "!other:example.org", + context: { + currentChannelProvider: "matrix", + currentChannelId: "!current:example.org", + requesterAccountId: "default", + }, + opts: { client }, + run: read, + }), + ).rejects.toThrow("Matrix read target is not allowed."); + }); + + it("preserves the trusted direct type for the current room", async () => { + const getJoinedRoomMembers = vi.fn(async () => ["@bot:example.org", "@alice:example.org"]); + const client = createClient([], null, {}, undefined, { getJoinedRoomMembers }); + const read = vi.fn(async () => "ok"); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "disabled", + dm: { policy: "pairing", allowFrom: [] }, + }, + }, + } as CoreConfig, + roomId: "!current-dm:example.org", + context: { + currentChannelProvider: "matrix", + currentChannelId: "room:!current-dm:example.org", + currentChatType: "direct", + requesterAccountId: "default", + }, + opts: { client }, + run: read, + }), + ).resolves.toBe("ok"); + expect(getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + it("uses the global group policy when the account does not override it", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + defaults: { groupPolicy: "open" }, + matrix: {}, + }, + } as CoreConfig, + roomId: "!global-open:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it("allows unmatched group rooms under an open group policy", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org", "@bob:example.org"]); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "open", + groups: { + "!other:example.org": {}, + }, + }, + }, + } as CoreConfig, + roomId: "!unmatched:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it("matches configured room aliases before applying direct-message policy", async () => { + const client = createClient( + ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + null, + { + canonicalAlias: "#ops:example.org", + }, + ); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "#ops:example.org": {}, + }, + dm: { policy: "disabled" }, + }, + }, + } as CoreConfig, + roomId: "!ops:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it("resolves aliases before applying a disabled wildcard room policy", async () => { + const resolveRoom = vi.fn(async () => "!ops:example.org"); + const client = createClient( + ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + null, + { + canonicalAlias: "#ops:example.org", + }, + undefined, + { resolveRoom }, + ); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "!ops:example.org": {}, + "*": { enabled: false }, + }, + }, + }, + } as CoreConfig, + roomId: "#ops:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + expect(resolveRoom).toHaveBeenCalledWith("#ops:example.org"); + }); + + it("treats explicitly configured two-member rooms as groups like ingress", async () => { + const getJoinedRoomMembers = vi.fn(async () => ["@bot:example.org", "@alice:example.org"]); + const client = createClient( + [], + true, + { + canonicalAlias: "#ops:example.org", + }, + undefined, + { getJoinedRoomMembers }, + ); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "#ops:example.org": {}, + }, + dm: { policy: "disabled" }, + }, + }, + } as CoreConfig, + roomId: "!ops:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + expect(getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + it("does not let wildcard room config override direct-message policy", async () => { + const client = createClient(["@bot:example.org", "@alice:example.org"], true); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "allowlist", + groups: { + "*": {}, + }, + dm: { policy: "disabled" }, + }, + }, + } as CoreConfig, + roomId: "!dm:example.org", + opts: { client }, + run: async () => "ok", + }), + ).rejects.toThrow("Matrix read target is not allowed."); + }); + + it("matches configured room names only when mutable matching is enabled", async () => { + const client = createClient( + ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + null, + {}, + "General", + ); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + dangerouslyAllowNameMatching: true, + groupPolicy: "allowlist", + groups: { + General: {}, + }, + }, + }, + } as CoreConfig, + roomId: "!general:example.org", + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it.each([ + "!blocked:example.org", + "room:!blocked:example.org", + "matrix:room:!blocked:example.org", + "channel:!blocked:example.org", + ])("rejects explicitly disabled room target %s before provider access", async (roomId) => { + const getRoomStateEvent = vi.fn(async () => ({})); + const getJoinedRoomMembers = vi.fn(async () => [ + "@bot:example.org", + "@alice:example.org", + "@bob:example.org", + ]); + const client = createClient([], null, {}, undefined, { + getRoomStateEvent, + getJoinedRoomMembers, + }); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "open", + groups: { + "!blocked:example.org": { enabled: false }, + }, + }, + }, + } as CoreConfig, + roomId, + context: { + currentChannelProvider: "matrix", + currentChannelId: "!blocked:example.org", + requesterAccountId: "default", + }, + opts: { client }, + run: async () => "ok", + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(getRoomStateEvent).not.toHaveBeenCalled(); + expect(getJoinedRoomMembers).not.toHaveBeenCalled(); + }); + + it.each(["!other-account:example.org", "matrix:channel:!other-account:example.org"])( + "rejects wrong-account room target %s before provider access", + async (roomId) => { + const getRoomStateEvent = vi.fn(async () => ({})); + const getJoinedRoomMembers = vi.fn(async () => [ + "@bot:example.org", + "@alice:example.org", + "@bob:example.org", + ]); + const client = createClient([], null, {}, undefined, { + getRoomStateEvent, + getJoinedRoomMembers, + }); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "open", + groups: { + "!other-account:example.org": { account: "other" }, + }, + }, + }, + } as CoreConfig, + accountId: "default", + roomId, + opts: { client }, + run: async () => "ok", + }), + ).rejects.toThrow("Matrix read target is not allowed."); + expect(getRoomStateEvent).not.toHaveBeenCalled(); + expect(getJoinedRoomMembers).not.toHaveBeenCalled(); + }, + ); + + it.each([ + { + name: "group", + members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + directFlag: null, + }, + { + name: "direct room", + members: ["@bot:example.org", "@alice:example.org"], + directFlag: true, + }, + ])("lets a direct operator read an unconfigured $name", async ({ members, directFlag }) => { + const client = createClient(members, directFlag); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { + channels: { + matrix: { + groupPolicy: "allowlist", + dm: { policy: "pairing", allowFrom: [] }, + }, + }, + } as CoreConfig, + roomId: "!operator:example.org", + context: { conversationReadOrigin: "direct-operator" }, + opts: { client }, + run: async () => "ok", + }), + ).resolves.toBe("ok"); + }); + + it.each([ + { + name: "disabled room", + cfg: { + groupPolicy: "open", + groups: { "!blocked:example.org": { enabled: false } }, + }, + members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + }, + { + name: "wrong-account room", + cfg: { + groupPolicy: "open", + groups: { "!blocked:example.org": { account: "other" } }, + }, + members: ["@bot:example.org", "@alice:example.org", "@bob:example.org"], + }, + { + name: "disabled direct-message scope", + cfg: { + groupPolicy: "open", + dm: { policy: "disabled" }, + }, + members: ["@bot:example.org", "@alice:example.org"], + directFlag: true, + }, + ])("keeps $name blocked for direct operators", async ({ cfg, members, directFlag }) => { + const client = createClient(members, directFlag ?? null); + + await expect( + withAuthorizedMatrixReadTarget({ + cfg: { channels: { matrix: cfg } } as CoreConfig, + accountId: "default", + roomId: "!blocked:example.org", + context: { conversationReadOrigin: "direct-operator" }, + opts: { client }, + run: async () => "ok", + }), + ).rejects.toThrow("Matrix read target is not allowed."); + }); +}); diff --git a/extensions/matrix/src/matrix/read-policy.ts b/extensions/matrix/src/matrix/read-policy.ts new file mode 100644 index 000000000000..7f9aaf5cb492 --- /dev/null +++ b/extensions/matrix/src/matrix/read-policy.ts @@ -0,0 +1,251 @@ +import { normalizeAccountId } from "openclaw/plugin-sdk/account-id"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; +import { + resolveAllowlistProviderRuntimeGroupPolicy, + resolveDefaultGroupPolicy, + ToolAuthorizationError, +} from "../runtime-api.js"; +import type { CoreConfig } from "../types.js"; +import { resolveMatrixBaseConfig } from "./account-config.js"; +import { resolveMatrixAccount } from "./accounts.js"; +import { withResolvedActionClient } from "./actions/client.js"; +import type { MatrixActionClientOpts } from "./actions/types.js"; +import { + hasDirectMatrixMemberFlag, + isStrictDirectMembership, + readJoinedMatrixMembers, +} from "./direct-room.js"; +import { createMatrixRoomInfoResolver } from "./monitor/room-info.js"; +import { resolveMatrixRoomConfig } from "./monitor/rooms.js"; +import type { MatrixClient } from "./sdk.js"; +import { resolveMatrixRoomId } from "./send/targets.js"; +import { normalizeMatrixResolvableTarget } from "./target-ids.js"; + +type ConversationReadInvocationOrigin = NonNullable< + ChannelMessageActionContext["conversationReadOrigin"] +>; + +export type MatrixReadContext = { + accountId?: string | null; + currentChannelId?: string | null; + currentChannelProvider?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; + requesterAccountId?: string | null; + conversationReadOrigin?: ConversationReadInvocationOrigin; +}; + +function normalizeRoomId(raw?: string | null): string { + return raw?.trim().replace(/^room:/i, "") ?? ""; +} + +function isCurrentRoom(params: { + accountId: string; + context?: MatrixReadContext; + roomId: string; +}): boolean { + return ( + params.context?.currentChannelProvider?.trim().toLowerCase() === "matrix" && + params.context.requesterAccountId?.trim() === params.accountId && + normalizeRoomId(params.context.currentChannelId) === normalizeRoomId(params.roomId) + ); +} + +function includesEntry(entries: Array | undefined, value: string): boolean { + const normalized = value.trim().toLowerCase(); + return (entries ?? []).some((entry) => { + const candidate = String(entry) + .replace(/^matrix:/i, "") + .trim() + .toLowerCase(); + return candidate === "*" || candidate === normalized; + }); +} + +function hasWildcardEntry(entries: Array | undefined): boolean { + return (entries ?? []).some( + (entry) => + String(entry) + .replace(/^matrix:/i, "") + .trim() === "*", + ); +} + +type MatrixRoomClassification = + | { kind: "direct"; remoteUserId: string } + | { kind: "group" } + | { kind: "unknown" }; + +function resolveMatrixReadRoomPolicy(params: { + account: ReturnType; + baseConfig: ReturnType; + roomId: string; + aliases: string[]; +}) { + const configuredRooms = params.account.config.groups ?? params.account.config.rooms; + const room = resolveMatrixRoomConfig({ + rooms: configuredRooms, + roomId: params.roomId, + aliases: params.aliases, + }); + const baseRoom = resolveMatrixRoomConfig({ + rooms: params.baseConfig.groups ?? params.baseConfig.rooms, + roomId: params.roomId, + aliases: params.aliases, + }); + const baseRoomAccount = baseRoom.config?.account; + const explicitlyScopedToAnotherAccount = + room.config === undefined && + baseRoom.matchSource === "direct" && + typeof baseRoomAccount === "string" && + normalizeAccountId(baseRoomAccount) !== params.account.accountId; + const accountMatches = !room.config?.account || room.config.account === params.account.accountId; + const configuredRoomBlocked = room.config !== undefined && (!room.allowed || !accountMatches); + const blocked = explicitlyScopedToAnotherAccount || configuredRoomBlocked; + const blockedBeforeProviderAccess = + explicitlyScopedToAnotherAccount || (room.matchSource === "direct" && configuredRoomBlocked); + return { blocked, blockedBeforeProviderAccess, room }; +} + +async function classifyMatrixReadRoom(params: { + client: MatrixClient; + roomId: string; +}): Promise { + const members = await readJoinedMatrixMembers(params.client, params.roomId); + if (!members) { + return { kind: "unknown" }; + } + if (members.length >= 3) { + return { kind: "group" }; + } + if (members.length !== 2) { + return { kind: "unknown" }; + } + const selfUserId = await params.client.getUserId().catch(() => null); + if (!selfUserId || !members.includes(selfUserId)) { + return { kind: "unknown" }; + } + const remoteUserId = members.find((member) => member !== selfUserId); + if ( + !isStrictDirectMembership({ + selfUserId, + remoteUserId, + joinedMembers: members, + }) || + !remoteUserId + ) { + return { kind: "unknown" }; + } + const memberStateFlag = await hasDirectMatrixMemberFlag(params.client, params.roomId, selfUserId); + await params.client.dms.update().catch(() => false); + if (memberStateFlag === true || params.client.dms.isDm(params.roomId)) { + return { kind: "direct", remoteUserId }; + } + return memberStateFlag === false ? { kind: "group" } : { kind: "unknown" }; +} + +export async function withAuthorizedMatrixReadTarget(params: { + cfg: CoreConfig; + accountId?: string | null; + roomId: string; + context?: MatrixReadContext; + opts: MatrixActionClientOpts; + run: (target: { client: MatrixClient; roomId: string }) => Promise; +}): Promise { + const account = resolveMatrixAccount({ cfg: params.cfg, accountId: params.accountId }); + const baseConfig = resolveMatrixBaseConfig(params.cfg); + const preliminaryRoomId = normalizeMatrixResolvableTarget(params.roomId); + const preliminaryPolicy = resolveMatrixReadRoomPolicy({ + account, + baseConfig, + roomId: preliminaryRoomId, + aliases: [], + }); + if (preliminaryPolicy.blockedBeforeProviderAccess) { + throw new ToolAuthorizationError("Matrix read target is not allowed."); + } + return await withResolvedActionClient(params.opts, async (client) => { + const roomId = await resolveMatrixRoomId(client, params.roomId); + const inputAlias = params.roomId.trim().startsWith("#") ? params.roomId.trim() : undefined; + const { getRoomInfo } = createMatrixRoomInfoResolver(client); + const roomInfo = await getRoomInfo(roomId, { includeAliases: true }); + const mutableRoomName = + account.config.dangerouslyAllowNameMatching === true ? roomInfo.name : undefined; + const aliases = [ + inputAlias, + roomInfo.canonicalAlias, + ...roomInfo.altAliases, + mutableRoomName, + ].filter((value): value is string => Boolean(value)); + const finalPolicy = resolveMatrixReadRoomPolicy({ + account, + baseConfig, + roomId, + aliases, + }); + const room = finalPolicy.room; + const current = isCurrentRoom({ + accountId: account.accountId, + context: params.context, + roomId, + }); + const currentChatType = params.context?.currentChatType?.trim().toLowerCase(); + const trustedCurrentClassification = + currentChatType === "direct" + ? ({ kind: "direct", remoteUserId: "" } as const) + : currentChatType === "group" || currentChatType === "channel" + ? ({ kind: "group" } as const) + : null; + // Ingress treats an explicitly configured room or alias as a group before + // Matrix DM heuristics. Otherwise preserve its trusted type for the current room. + const classification = + room.matchSource === "direct" + ? ({ kind: "group" } as const) + : current && trustedCurrentClassification + ? trustedCurrentClassification + : await classifyMatrixReadRoom({ client, roomId }); + const resolvedGroupPolicy = resolveAllowlistProviderRuntimeGroupPolicy({ + providerConfigPresent: params.cfg.channels?.matrix !== undefined, + groupPolicy: account.config.groupPolicy, + defaultGroupPolicy: resolveDefaultGroupPolicy(params.cfg), + }).groupPolicy; + const groupPolicy = + account.config.allowlistOnly && resolvedGroupPolicy === "open" + ? "allowlist" + : resolvedGroupPolicy; + const dmPolicy = account.config.allowlistOnly + ? account.config.dm?.policy === "disabled" + ? "disabled" + : "allowlist" + : (account.config.dm?.policy ?? "pairing"); + const directOperator = params.context?.conversationReadOrigin === "direct-operator"; + const allowed = finalPolicy.blocked + ? false + : directOperator + ? classification.kind === "direct" + ? account.config.dm?.enabled !== false && dmPolicy !== "disabled" + : classification.kind === "group" + ? groupPolicy !== "disabled" + : groupPolicy !== "disabled" && + dmPolicy !== "disabled" && + account.config.dm?.enabled !== false + : classification.kind === "direct" + ? account.config.dm?.enabled !== false && + dmPolicy !== "disabled" && + (current || includesEntry(account.config.dm?.allowFrom, classification.remoteUserId)) + : classification.kind === "group" + ? groupPolicy !== "disabled" && + (current || groupPolicy === "open" || room.config !== undefined) + : current + ? groupPolicy !== "disabled" && + dmPolicy !== "disabled" && + account.config.dm?.enabled !== false + : groupPolicy === "open" && + dmPolicy !== "disabled" && + account.config.dm?.enabled !== false && + hasWildcardEntry(account.config.dm?.allowFrom); + if (!allowed) { + throw new ToolAuthorizationError("Matrix read target is not allowed."); + } + return await params.run({ client, roomId }); + }); +} diff --git a/extensions/matrix/src/matrix/sdk.test.ts b/extensions/matrix/src/matrix/sdk.test.ts index e26ef05560c7..f4a7a97b922a 100644 --- a/extensions/matrix/src/matrix/sdk.test.ts +++ b/extensions/matrix/src/matrix/sdk.test.ts @@ -190,6 +190,7 @@ type MatrixJsClientStub = { getJoinedRoomMembers: ReturnType; getStateEvent: ReturnType; getAccountData: ReturnType; + getAccountDataFromServer: ReturnType; setAccountData: ReturnType; getRoomIdForAlias: ReturnType; sendMessage: ReturnType; @@ -226,6 +227,7 @@ function createMatrixJsClientStub(): MatrixJsClientStub { client.getJoinedRoomMembers = vi.fn(async () => ({ joined: {} })); client.getStateEvent = vi.fn(async () => ({})); client.getAccountData = vi.fn(() => undefined); + client.getAccountDataFromServer = vi.fn(async () => null); client.setAccountData = vi.fn(async () => {}); client.getRoomIdForAlias = vi.fn(async () => ({ room_id: "!resolved:example.org" })); client.sendMessage = vi.fn(async () => ({ event_id: "$sent" })); @@ -324,6 +326,19 @@ describe("MatrixClient request hardening", () => { resetPluginStateStoreForTests(); }); + it("reads account data through the server-aware SDK path before initial sync", async () => { + matrixJsClient.getAccountDataFromServer.mockResolvedValue({ + "@alice:example.org": ["!dm:example.org"], + }); + const client = new MatrixClient("https://matrix.example.org", "token"); + + await expect(client.getAccountData("m.direct")).resolves.toEqual({ + "@alice:example.org": ["!dm:example.org"], + }); + expect(matrixJsClient.getAccountDataFromServer).toHaveBeenCalledWith("m.direct"); + expect(matrixJsClient.getAccountData).not.toHaveBeenCalled(); + }); + it("blocks absolute endpoints unless explicitly allowed", async () => { const fetchMock = vi.fn(async () => { return new Response("{}", { diff --git a/extensions/matrix/src/matrix/sdk.ts b/extensions/matrix/src/matrix/sdk.ts index 674aac7edc68..ed23a1f6d08d 100644 --- a/extensions/matrix/src/matrix/sdk.ts +++ b/extensions/matrix/src/matrix/sdk.ts @@ -902,8 +902,12 @@ export class MatrixClient { } async getAccountData(eventType: string): Promise | undefined> { - const event = this.client.getAccountData(eventType as never); - return (event?.getContent() as Record | undefined) ?? undefined; + return ( + ((await this.client.getAccountDataFromServer(eventType as never)) as Record< + string, + unknown + > | null) ?? undefined + ); } async setAccountData(eventType: string, content: Record): Promise { diff --git a/extensions/matrix/src/tool-actions.test.ts b/extensions/matrix/src/tool-actions.test.ts index 0d01efe27f9e..fd8529f795fc 100644 --- a/extensions/matrix/src/tool-actions.test.ts +++ b/extensions/matrix/src/tool-actions.test.ts @@ -6,20 +6,34 @@ import type { CoreConfig } from "./types.js"; const mocks = vi.hoisted(() => ({ voteMatrixPoll: vi.fn(), reactMatrixMessage: vi.fn(), + editMatrixMessage: vi.fn(), + deleteMatrixMessage: vi.fn(), listMatrixReactions: vi.fn(), removeMatrixReactions: vi.fn(), sendMatrixMessage: vi.fn(), + pinMatrixMessage: vi.fn(), + unpinMatrixMessage: vi.fn(), listMatrixPins: vi.fn(), getMatrixMemberInfo: vi.fn(), getMatrixRoomInfo: vi.fn(), applyMatrixProfileUpdate: vi.fn(), + matrixClient: { id: "matrix-client" }, + withAuthorizedMatrixReadTarget: vi.fn(), +})); + +vi.mock("./matrix/read-policy.js", () => ({ + withAuthorizedMatrixReadTarget: mocks.withAuthorizedMatrixReadTarget, })); vi.mock("./matrix/actions.js", () => { return { + deleteMatrixMessage: mocks.deleteMatrixMessage, + editMatrixMessage: mocks.editMatrixMessage, getMatrixMemberInfo: mocks.getMatrixMemberInfo, getMatrixRoomInfo: mocks.getMatrixRoomInfo, listMatrixReactions: mocks.listMatrixReactions, + pinMatrixMessage: mocks.pinMatrixMessage, + unpinMatrixMessage: mocks.unpinMatrixMessage, listMatrixPins: mocks.listMatrixPins, removeMatrixReactions: mocks.removeMatrixReactions, sendMatrixMessage: mocks.sendMatrixMessage, @@ -40,6 +54,16 @@ vi.mock("./profile-update.js", () => ({ describe("handleMatrixAction pollVote", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.withAuthorizedMatrixReadTarget.mockImplementation( + async (params: { + roomId: string; + run: (target: { client: unknown; roomId: string }) => Promise; + }) => + await params.run({ + client: mocks.matrixClient, + roomId: params.roomId.replace(/^room:/, ""), + }), + ); mocks.voteMatrixPoll.mockResolvedValue({ eventId: "evt-poll-vote", roomId: "!room:example", @@ -50,11 +74,14 @@ describe("handleMatrixAction pollVote", () => { }); mocks.listMatrixReactions.mockResolvedValue([{ key: "👍", count: 1, users: ["@u:example"] }]); mocks.listMatrixPins.mockResolvedValue({ pinned: ["$pin"], events: [] }); + mocks.pinMatrixMessage.mockResolvedValue({ pinned: ["$existing", "$pin"] }); + mocks.unpinMatrixMessage.mockResolvedValue({ pinned: ["$existing"] }); mocks.removeMatrixReactions.mockResolvedValue({ removed: 1 }); mocks.sendMatrixMessage.mockResolvedValue({ messageId: "$sent", roomId: "!room:example", }); + mocks.editMatrixMessage.mockResolvedValue({ eventId: "$edited" }); mocks.getMatrixMemberInfo.mockResolvedValue({ userId: "@u:example" }); mocks.getMatrixRoomInfo.mockResolvedValue({ roomId: "!room:example" }); mocks.applyMatrixProfileUpdate.mockResolvedValue({ @@ -91,6 +118,7 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", { cfg, accountId: "main", + client: mocks.matrixClient, optionIds: ["a2", "a1"], optionIndexes: [1, 2], }); @@ -160,11 +188,32 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.voteMatrixPoll).toHaveBeenCalledWith("!room:example", "$poll", { cfg, + client: mocks.matrixClient, optionIds: [], optionIndexes: [1], }); }); + it("authorizes the room before reading the poll", async () => { + mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce( + new Error("Matrix read target is not allowed."), + ); + + await expect( + handleMatrixAction( + { + action: "pollVote", + roomId: "!blocked:example", + pollId: "$poll", + pollOptionIndex: 1, + }, + {} as CoreConfig, + ), + ).rejects.toThrow("Matrix read target is not allowed."); + + expect(mocks.voteMatrixPoll).not.toHaveBeenCalled(); + }); + it("passes account-scoped opts to add reactions", async () => { const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; await handleMatrixAction( @@ -181,9 +230,56 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.reactMatrixMessage).toHaveBeenCalledWith("!room:example", "$msg", "👍", { cfg, accountId: "ops", + client: mocks.matrixClient, }); }); + it.each([ + { + action: "react", + params: { emoji: "👍" }, + providerCall: mocks.reactMatrixMessage, + }, + { + action: "editMessage", + params: { content: "updated" }, + providerCall: mocks.editMatrixMessage, + }, + { + action: "deleteMessage", + params: {}, + providerCall: mocks.deleteMatrixMessage, + }, + ])("rejects blocked $action before mutating Matrix", async ({ action, params, providerCall }) => { + mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce( + new Error("Matrix read target is not allowed."), + ); + const cfg = { + channels: { + matrix: { + actions: { + messages: true, + reactions: true, + }, + }, + }, + } as CoreConfig; + + await expect( + handleMatrixAction( + { + action, + roomId: "!blocked:example", + messageId: "$msg", + ...params, + }, + cfg, + ), + ).rejects.toThrow("Matrix read target is not allowed."); + + expect(providerCall).not.toHaveBeenCalled(); + }); + it("passes account-scoped opts to remove reactions", async () => { const cfg = { channels: { matrix: { actions: { reactions: true } } } } as CoreConfig; await handleMatrixAction( @@ -201,6 +297,7 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.removeMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { cfg, accountId: "ops", + client: mocks.matrixClient, emoji: "👍", }); }); @@ -221,6 +318,7 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.listMatrixReactions).toHaveBeenCalledWith("!room:example", "$msg", { cfg, accountId: "ops", + client: mocks.matrixClient, limit: 5, }); expect(result.details).toEqual({ @@ -353,9 +451,69 @@ describe("handleMatrixAction pollVote", () => { expect(mocks.listMatrixPins).toHaveBeenCalledWith("!room:example", { cfg, accountId: "ops", + client: mocks.matrixClient, }); }); + it.each([ + { + action: "pinMessage", + expected: mocks.pinMatrixMessage, + expectedPinned: ["$existing", "$pin"], + }, + { + action: "unpinMessage", + expected: mocks.unpinMatrixMessage, + expectedPinned: ["$existing"], + }, + ])( + "authorizes $action before reading pinned state", + async ({ action, expected, expectedPinned }) => { + const cfg = { channels: { matrix: { actions: { pins: true } } } } as CoreConfig; + const result = await handleMatrixAction( + { + action, + accountId: "ops", + roomId: "room:!room:example", + messageId: "$pin", + }, + cfg, + ); + + expect(expected).toHaveBeenCalledWith("!room:example", "$pin", { + cfg, + accountId: "ops", + client: mocks.matrixClient, + }); + expect(result.details).toEqual({ ok: true, pinned: expectedPinned }); + }, + ); + + it.each(["pinMessage", "unpinMessage"])( + "rejects blocked %s before reading or mutating pinned state", + async (action) => { + mocks.withAuthorizedMatrixReadTarget.mockRejectedValueOnce( + new Error("Matrix read target is not allowed."), + ); + const cfg = { channels: { matrix: { actions: { pins: true } } } } as CoreConfig; + + await expect( + handleMatrixAction( + { + action, + roomId: "!blocked:example", + messageId: "$pin", + }, + cfg, + ), + ).rejects.toThrow("Matrix read target is not allowed."); + + expect(mocks.pinMatrixMessage).not.toHaveBeenCalled(); + expect(mocks.unpinMatrixMessage).not.toHaveBeenCalled(); + expect(mocks.listMatrixPins).not.toHaveBeenCalled(); + }, + ); + it("passes account-scoped opts to member and room info actions", async () => { const memberCfg = { channels: { matrix: { actions: { memberInfo: true } } }, @@ -383,10 +541,12 @@ describe("handleMatrixAction pollVote", () => { cfg: memberCfg, accountId: "ops", roomId: "!room:example", + client: mocks.matrixClient, }); expect(mocks.getMatrixRoomInfo).toHaveBeenCalledWith("!room:example", { cfg: roomCfg, accountId: "ops", + client: mocks.matrixClient, }); }); diff --git a/extensions/matrix/src/tool-actions.ts b/extensions/matrix/src/tool-actions.ts index cd4385dd6d74..6225d3289e4c 100644 --- a/extensions/matrix/src/tool-actions.ts +++ b/extensions/matrix/src/tool-actions.ts @@ -33,6 +33,8 @@ import { voteMatrixPoll, verifyMatrixRecoveryKey, } from "./matrix/actions.js"; +import { withAuthorizedMatrixReadTarget, type MatrixReadContext } from "./matrix/read-policy.js"; +import type { MatrixClient } from "./matrix/sdk.js"; import { reactMatrixMessage } from "./matrix/send.js"; import { applyMatrixProfileUpdate } from "./profile-update.js"; import { @@ -147,7 +149,7 @@ function readPositiveIntegerArrayParam(params: Record, key: str export async function handleMatrixAction( params: Record, cfg: CoreConfig, - opts: { mediaLocalRoots?: readonly string[] } = {}, + opts: { mediaLocalRoots?: readonly string[]; readContext?: MatrixReadContext } = {}, ): Promise> { const action = readStringParam(params, "action", { required: true }); const accountId = readStringParam(params, "accountId") ?? undefined; @@ -156,6 +158,18 @@ export async function handleMatrixAction( cfg, ...(accountId ? { accountId } : {}), }; + const withReadTarget = async ( + roomId: string, + run: (target: { roomId: string; client: MatrixClient }) => Promise, + ) => + await withAuthorizedMatrixReadTarget({ + cfg, + accountId, + roomId, + context: opts.readContext, + opts: clientOpts, + run, + }); if (reactionActions.has(action)) { if (!isActionEnabled("reactions")) { @@ -168,21 +182,32 @@ export async function handleMatrixAction( removeErrorMessage: "Emoji is required to remove a Matrix reaction.", }); if (remove || isEmpty) { - const result = await removeMatrixReactions(roomId, messageId, { - ...clientOpts, - emoji: remove ? emoji : undefined, + const result = await withReadTarget(roomId, async (target) => { + return await removeMatrixReactions(target.roomId, messageId, { + ...clientOpts, + client: target.client, + emoji: remove ? emoji : undefined, + }); }); return jsonResult({ ok: true, removed: result.removed }); } - await reactMatrixMessage(roomId, messageId, emoji, clientOpts); + await withReadTarget(roomId, async (target) => { + await reactMatrixMessage(target.roomId, messageId, emoji, { + ...clientOpts, + client: target.client, + }); + }); return jsonResult({ ok: true, added: emoji }); } const limit = readPositiveIntegerParam(params, "limit", { message: "limit must be a positive integer.", }); - const reactions = await listMatrixReactions(roomId, messageId, { - ...clientOpts, - limit: limit ?? undefined, + const reactions = await withReadTarget(roomId, async (target) => { + return await listMatrixReactions(target.roomId, messageId, { + ...clientOpts, + client: target.client, + limit: limit ?? undefined, + }); }); return jsonResult({ ok: true, reactions }); } @@ -205,10 +230,13 @@ export async function handleMatrixAction( ...readPositiveIntegerArrayParam(params, "pollOptionIndexes"), ...(optionIndex !== undefined ? [optionIndex] : []), ]; - const result = await voteMatrixPoll(roomId, pollId, { - ...clientOpts, - optionIds, - optionIndexes, + const result = await withReadTarget(roomId, async (target) => { + return await voteMatrixPoll(target.roomId, pollId, { + ...clientOpts, + client: target.client, + optionIds, + optionIndexes, + }); }); return jsonResult({ ok: true, result }); } @@ -252,16 +280,24 @@ export async function handleMatrixAction( const roomId = readRoomId(params); const messageId = readStringParam(params, "messageId", { required: true }); const content = readStringParam(params, "content", { required: true }); - const result = await editMatrixMessage(roomId, messageId, content, clientOpts); + const result = await withReadTarget(roomId, async (target) => { + return await editMatrixMessage(target.roomId, messageId, content, { + ...clientOpts, + client: target.client, + }); + }); return jsonResult({ ok: true, result }); } case "deleteMessage": { const roomId = readRoomId(params); const messageId = readStringParam(params, "messageId", { required: true }); const reason = readStringParam(params, "reason"); - await deleteMatrixMessage(roomId, messageId, { - reason: reason ?? undefined, - ...clientOpts, + await withReadTarget(roomId, async (target) => { + await deleteMatrixMessage(target.roomId, messageId, { + reason: reason ?? undefined, + ...clientOpts, + client: target.client, + }); }); return jsonResult({ ok: true, deleted: true }); } @@ -273,12 +309,15 @@ export async function handleMatrixAction( const before = readStringParam(params, "before"); const after = readStringParam(params, "after"); const threadId = readStringParam(params, "threadId"); - const result = await readMatrixMessages(roomId, { - limit: limit ?? undefined, - before: before ?? undefined, - after: after ?? undefined, - threadId: threadId ?? undefined, - ...clientOpts, + const result = await withReadTarget(roomId, async (target) => { + return await readMatrixMessages(target.roomId, { + limit: limit ?? undefined, + before: before ?? undefined, + after: after ?? undefined, + threadId: threadId ?? undefined, + ...clientOpts, + client: target.client, + }); }); return jsonResult({ ok: true, ...result }); } @@ -292,18 +331,34 @@ export async function handleMatrixAction( throw new Error("Matrix pins are disabled."); } const roomId = readRoomId(params); - if (action === "pinMessage") { - const messageId = readStringParam(params, "messageId", { required: true }); - const result = await pinMatrixMessage(roomId, messageId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned }); - } - if (action === "unpinMessage") { - const messageId = readStringParam(params, "messageId", { required: true }); - const result = await unpinMatrixMessage(roomId, messageId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned }); - } - const result = await listMatrixPins(roomId, clientOpts); - return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); + const request = + action === "pinMessage" + ? { + kind: "pin" as const, + messageId: readStringParam(params, "messageId", { required: true }), + } + : action === "unpinMessage" + ? { + kind: "unpin" as const, + messageId: readStringParam(params, "messageId", { required: true }), + } + : { kind: "list" as const }; + return await withReadTarget(roomId, async (target) => { + const actionOpts = { + ...clientOpts, + client: target.client, + }; + if (request.kind === "pin") { + const result = await pinMatrixMessage(target.roomId, request.messageId, actionOpts); + return jsonResult({ ok: true, pinned: result.pinned }); + } + if (request.kind === "unpin") { + const result = await unpinMatrixMessage(target.roomId, request.messageId, actionOpts); + return jsonResult({ ok: true, pinned: result.pinned }); + } + const result = await listMatrixPins(target.roomId, actionOpts); + return jsonResult({ ok: true, pinned: result.pinned, events: result.events }); + }); } if (profileActions.has(action)) { @@ -330,10 +385,13 @@ export async function handleMatrixAction( throw new Error("Matrix member info is disabled."); } const userId = readStringParam(params, "userId", { required: true }); - const roomId = readStringParam(params, "roomId") ?? readStringParam(params, "channelId"); - const result = await getMatrixMemberInfo(userId, { - roomId: roomId ?? undefined, - ...clientOpts, + const roomId = readRoomId(params); + const result = await withReadTarget(roomId, async (target) => { + return await getMatrixMemberInfo(userId, { + roomId: target.roomId, + ...clientOpts, + client: target.client, + }); }); return jsonResult({ ok: true, member: result }); } @@ -343,7 +401,12 @@ export async function handleMatrixAction( throw new Error("Matrix room info is disabled."); } const roomId = readRoomId(params); - const result = await getMatrixRoomInfo(roomId, clientOpts); + const result = await withReadTarget(roomId, async (target) => { + return await getMatrixRoomInfo(target.roomId, { + ...clientOpts, + client: target.client, + }); + }); return jsonResult({ ok: true, room: result }); } diff --git a/extensions/msteams/src/channel.actions.test.ts b/extensions/msteams/src/channel.actions.test.ts index b488a0dec481..e1353368daa4 100644 --- a/extensions/msteams/src/channel.actions.test.ts +++ b/extensions/msteams/src/channel.actions.test.ts @@ -38,7 +38,6 @@ const { sendMessageMSTeamsMock: vi.fn(), unpinMessageMSTeamsMock: vi.fn(), })); - vi.mock("./channel.runtime.js", () => ({ msTeamsChannelRuntime: { addParticipantMSTeams: addParticipantMSTeamsMock, @@ -79,6 +78,9 @@ const actionMocks = [ unpinMessageMSTeamsMock, ]; const currentChannelId = "conversation:19:ctx@thread.tacv2"; +const graphTeamId = "11111111-1111-1111-1111-111111111111"; +const graphChannelId = "19:channel-1@thread.tacv2"; +const graphChannelTarget = `${graphTeamId}/${graphChannelId}`; const reactChannelId = "conversation:19:react@thread.tacv2"; const targetChannelId = "conversation:19:target@thread.tacv2"; const editedConversationId = "19:edited@thread.tacv2"; @@ -124,6 +126,8 @@ function requireMSTeamsHandleAction() { async function runAction(params: { action: string; cfg?: Record; + accountId?: string; + requesterAccountId?: string; params?: Record; toolContext?: Record; mediaLocalRoots?: readonly string[]; @@ -137,6 +141,8 @@ async function runAction(params: { channel: "msteams", action: params.action, cfg: params.cfg ?? {}, + accountId: params.accountId, + requesterAccountId: params.requesterAccountId, params: params.params ?? {}, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, @@ -187,9 +193,10 @@ function expectActionSuccess( function expectActionRuntimeCall( mockFn: ReturnType, params: Record, + cfg: Record = {}, ) { expect(mockFn).toHaveBeenCalledWith({ - cfg: {}, + cfg, ...params, }); } @@ -198,6 +205,9 @@ async function expectSuccessfulAction(params: { mockFn: ReturnType; mockResult: unknown; action: Parameters[0]["action"]; + cfg?: Parameters[0]["cfg"]; + accountId?: Parameters[0]["accountId"]; + requesterAccountId?: Parameters[0]["requesterAccountId"]; actionParams?: Parameters[0]["params"]; toolContext?: Parameters[0]["toolContext"]; mediaLocalRoots?: Parameters[0]["mediaLocalRoots"]; @@ -212,6 +222,9 @@ async function expectSuccessfulAction(params: { params.mockFn.mockResolvedValue(params.mockResult); const result = await runAction({ action: params.action, + cfg: params.cfg, + accountId: params.accountId, + requesterAccountId: params.requesterAccountId, params: params.actionParams, mediaLocalRoots: params.mediaLocalRoots, mediaReadFile: params.mediaReadFile, @@ -220,11 +233,19 @@ async function expectSuccessfulAction(params: { senderIsOwner: params.senderIsOwner, gatewayClientScopes: params.gatewayClientScopes, }); - expectActionRuntimeCall(params.mockFn, params.runtimeParams); + expectActionRuntimeCall(params.mockFn, params.runtimeParams, params.cfg); expectActionSuccess(result, params.details, params.contentDetails); } describe("msteamsPlugin message actions", () => { + const unrestrictedReadCfg = { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "open", + }, + }, + }; beforeEach(() => { for (const mockFn of actionMocks) { mockFn.mockReset(); @@ -241,7 +262,18 @@ describe("msteamsPlugin message actions", () => { }, toolContext: { currentChannelId: padded(currentChannelId), + currentChannelProvider: "msteams", }, + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + dmPolicy: "pairing", + }, + }, + }, + accountId: "default", + requesterAccountId: "default", runtimeParams: { to: currentChannelId, messageId: "msg-1", @@ -258,6 +290,287 @@ describe("msteamsPlugin message actions", () => { }); }); + it("allows the trusted current paired DM target", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + to: "user:aad-user-1", + messageId: "msg-1", + }, + toolContext: { + currentChannelId: "user:aad-user-1", + currentChannelProvider: "msteams", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + dmPolicy: "pairing", + }, + }, + }, + accountId: "default", + requesterAccountId: "default", + runtimeParams: { + to: "user:aad-user-1", + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("uses the global group policy when Teams does not override it", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + to: graphChannelTarget, + messageId: "msg-1", + }, + cfg: { + channels: { + defaults: { groupPolicy: "open" }, + msteams: {}, + }, + }, + runtimeParams: { + to: graphChannelTarget, + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("allows the trusted current channel under allowlist policy", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + messageId: "msg-1", + }, + toolContext: { + currentChannelProvider: "msteams", + currentMessagingTarget: "team-1/channel-1", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + groupAllowFrom: ["aad-user-1"], + }, + }, + }, + accountId: "default", + requesterAccountId: "default", + runtimeParams: { + to: "team-1/channel-1", + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("does not route channel Graph actions through a Bot Framework conversation id", async () => { + await expectActionError( + { + action: "read", + params: { messageId: "msg-1" }, + toolContext: { + currentChannelId: "conversation:19:channel@thread.tacv2", + currentChatType: "channel", + }, + }, + "Read requires a target (to) and messageId.", + ); + expect(getMessageMSTeamsMock).not.toHaveBeenCalled(); + }); + + it("allows the trusted current group chat when DMs are disabled", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + messageId: "msg-1", + }, + toolContext: { + currentChannelProvider: "msteams", + currentChannelId: "conversation:19:group@thread.v2", + currentChatType: "group", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "disabled", + }, + }, + }, + accountId: "default", + requesterAccountId: "default", + runtimeParams: { + to: "conversation:19:group@thread.v2", + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("allows a bare trusted current group target when DMs are disabled", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + messageId: "msg-1", + }, + toolContext: { + currentChannelProvider: "msteams", + currentChannelId: "19:group@thread.v2", + currentChatType: "group", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "disabled", + }, + }, + }, + accountId: "default", + requesterAccountId: "default", + runtimeParams: { + to: "19:group@thread.v2", + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("requires both scopes for a non-current opaque chat target", async () => { + getMessageMSTeamsMock.mockResolvedValue(readMessage); + + await expect( + runAction({ + action: "read", + params: { + to: "conversation:19:direct@thread.v2", + messageId: "msg-1", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "pairing", + }, + }, + }, + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + expect(getMessageMSTeamsMock).not.toHaveBeenCalled(); + }); + + it("allows a non-current opaque chat target when both scopes are open", async () => { + await expectSuccessfulAction({ + mockFn: getMessageMSTeamsMock, + mockResult: readMessage, + action: "read", + actionParams: { + to: "conversation:19:opaque@thread.v2", + messageId: "msg-1", + }, + cfg: { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "open", + }, + }, + }, + runtimeParams: { + to: "conversation:19:opaque@thread.v2", + messageId: "msg-1", + }, + details: okMSTeamsActionDetails("read", { + message: readMessage, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "read", + message: readMessage, + }, + }); + }); + + it("does not treat per-DM history config as read authorization", async () => { + getMessageMSTeamsMock.mockResolvedValue(readMessage); + + await expect( + runAction({ + action: "read", + params: { + to: "user:aad-user-1", + messageId: "msg-1", + }, + cfg: { + channels: { + msteams: { + dmPolicy: "allowlist", + allowFrom: [], + dms: { "aad-user-1": { historyLimit: 5 } }, + }, + }, + }, + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + expect(getMessageMSTeamsMock).not.toHaveBeenCalled(); + }); + it("advertises upload-file in the message tool surface", () => { expect( msteamsPlugin.actions?.describeMessageTool?.({ @@ -319,8 +632,48 @@ describe("msteamsPlugin message actions", () => { mockFn: getMemberInfoMSTeamsMock, mockResult: { member: { id: "user-1" } }, action: "member-info", - actionParams: { userId: " user-1 " }, - runtimeParams: { userId: "user-1" }, + cfg: unrestrictedReadCfg, + actionParams: { userId: " user-1 ", to: graphChannelTarget }, + runtimeParams: { + to: graphChannelTarget, + userId: "user-1", + currentRequesterId: undefined, + }, + details: okMSTeamsActionDetails("member-info", { + member: { id: "user-1" }, + }), + contentDetails: { + ok: true, + channel: "msteams", + action: "member-info", + member: { id: "user-1" }, + }, + }); + }); + + it("passes the trusted requester only for current Teams chats", async () => { + await expectSuccessfulAction({ + mockFn: getMemberInfoMSTeamsMock, + mockResult: { member: { id: "user-1" } }, + action: "member-info", + cfg: unrestrictedReadCfg, + accountId: "default", + requesterAccountId: "default", + requesterSenderId: "user-1", + toolContext: { + currentChannelProvider: "msteams", + currentChannelId: "conversation:19:group@thread.v2", + currentChatType: "group", + }, + actionParams: { + userId: "user-1", + to: "conversation:19:group@thread.v2", + }, + runtimeParams: { + to: "conversation:19:group@thread.v2", + userId: "user-1", + currentRequesterId: "user-1", + }, details: okMSTeamsActionDetails("member-info", { member: { id: "user-1" }, }), @@ -338,8 +691,9 @@ describe("msteamsPlugin message actions", () => { mockFn: listChannelsMSTeamsMock, mockResult: { channels: [{ id: "channel-1" }] }, action: "channel-list", - actionParams: { teamId: " team-1 " }, - runtimeParams: { teamId: "team-1" }, + cfg: unrestrictedReadCfg, + actionParams: { teamId: ` ${graphTeamId} ` }, + runtimeParams: { teamId: graphTeamId }, details: okMSTeamsActionDetails("channel-list", { channels: [{ id: "channel-1" }], }), @@ -357,13 +711,14 @@ describe("msteamsPlugin message actions", () => { mockFn: getChannelInfoMSTeamsMock, mockResult: { channel: { id: "channel-1" } }, action: "channel-info", + cfg: unrestrictedReadCfg, actionParams: { - teamId: " team-1 ", - channelId: " channel-1 ", + teamId: ` ${graphTeamId} `, + channelId: ` ${graphChannelId} `, }, runtimeParams: { - teamId: "team-1", - channelId: "channel-1", + teamId: graphTeamId, + channelId: graphChannelId, }, details: okMSTeamsActionDetails("channel-info", { channelInfo: { id: "channel-1" }, @@ -523,6 +878,7 @@ describe("msteamsPlugin message actions", () => { mockFn: pinMessageMSTeamsMock, mockResult: { ok: true, pinnedMessageId: "pin-1" }, action: "pin", + cfg: unrestrictedReadCfg, actionParams: { target: padded(targetChannelId), messageId: padded("msg-2"), @@ -542,6 +898,7 @@ describe("msteamsPlugin message actions", () => { mockFn: editMessageMSTeamsMock, mockResult: { conversationId: editedConversationId }, action: "edit", + cfg: unrestrictedReadCfg, actionParams: { to: targetChannelId, messageId: editedMessageId, @@ -569,6 +926,7 @@ describe("msteamsPlugin message actions", () => { mockFn: unpinMessageMSTeamsMock, mockResult: { ok: true }, action: "unpin", + cfg: unrestrictedReadCfg, actionParams: { target: padded(targetChannelId), messageId: padded("pin-2"), @@ -586,6 +944,7 @@ describe("msteamsPlugin message actions", () => { mockFn: unpinMessageMSTeamsMock, mockResult: { ok: true }, action: "unpin", + cfg: unrestrictedReadCfg, actionParams: { target: padded(targetChannelId), pinnedMessageId: padded("pinned-resource-99"), @@ -634,6 +993,9 @@ describe("msteamsPlugin message actions", () => { mockFn: reactMessageMSTeamsMock, mockResult: { ok: true }, action: "react", + cfg: unrestrictedReadCfg, + accountId: "default", + requesterAccountId: "default", actionParams: { messageId: padded("msg-3"), emoji: padded(reactionType), @@ -786,36 +1148,116 @@ describe("msteamsPlugin message actions", () => { }); it("requires a non-empty search query after trimming", async () => { - await expectActionParamError( - "search", + await expectActionError( { - to: targetChannelId, - query: " ", + action: "search", + cfg: unrestrictedReadCfg, + params: { + to: targetChannelId, + query: " ", + }, }, searchMissingQueryError, ); }); - it("routes channel fallback targets via teamId/channelId for react actions", async () => { - // When an action is invoked in a Teams channel context and `target` is - // omitted, the action handler falls back to `toolContext.currentChannelId`. - // For channel turns, buildToolContext populates that field with the - // compound `teamId/channelId` form (see buildToolContext below), so the - // runtime call must receive that compound form — NOT a bare - // `conversation:` — so Graph API routes through - // `/teams/{teamId}/channels/{channelId}` rather than `/chats/{id}`. + it("rejects reads outside configured Teams channels before calling Graph", async () => { + await expect( + runAction({ + action: "read", + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "team-1": { + channels: { + "channel-1": { enabled: true }, + }, + }, + }, + }, + }, + }, + params: { to: "team-1/channel-2", messageId: "msg-1" }, + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + expect(getMessageMSTeamsMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + action: "edit", + params: { to: targetChannelId, messageId: "msg-1", content: "updated" }, + runtimeMock: editMessageMSTeamsMock, + }, + { + action: "delete", + params: { to: targetChannelId, messageId: "msg-1" }, + runtimeMock: deleteMessageMSTeamsMock, + }, + { + action: "pin", + params: { to: targetChannelId, messageId: "msg-1" }, + runtimeMock: pinMessageMSTeamsMock, + }, + { + action: "unpin", + params: { to: targetChannelId, pinnedMessageId: "pin-1" }, + runtimeMock: unpinMessageMSTeamsMock, + }, + { + action: "react", + params: { to: targetChannelId, messageId: "msg-1", emoji: "like" }, + runtimeMock: reactMessageMSTeamsMock, + }, + ])("rejects a blocked $action target before the provider operation", async (testCase) => { + await expect( + runAction({ + action: testCase.action, + cfg: { + channels: { + msteams: { + groupPolicy: "allowlist", + dmPolicy: "pairing", + }, + }, + }, + accountId: "default", + requesterAccountId: "default", + params: testCase.params, + toolContext: { + currentChannelProvider: "msteams", + currentChannelId, + currentChatType: "group", + }, + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + expect(testCase.runtimeMock).not.toHaveBeenCalled(); + }); + + it("restores the Graph route from a core-materialized channel target", async () => { + // Core materializes an omitted target from currentChannelId before plugin + // dispatch. Teams must restore the prepared Graph target for channel turns. const teamChannelTarget = "team-1/19:channel-abc@thread.tacv2"; + const conversationTarget = "conversation:19:channel-abc@thread.tacv2"; await expectSuccessfulAction({ mockFn: reactMessageMSTeamsMock, mockResult: { ok: true }, action: "react", + cfg: unrestrictedReadCfg, + accountId: "default", + requesterAccountId: "default", actionParams: { + target: conversationTarget, messageId: "msg-channel-react", emoji: reactionType, }, toolContext: { - currentChannelId: "conversation:19:channel-abc@thread.tacv2", - currentGraphChannelId: teamChannelTarget, + currentChannelProvider: "msteams", + currentChannelId: conversationTarget, + currentChatType: "channel", + currentMessagingTarget: teamChannelTarget, }, runtimeParams: { to: teamChannelTarget, @@ -837,12 +1279,13 @@ describe("msteamsPlugin message actions", () => { it("preserves explicit teamId/channelId target over toolContext fallback", async () => { // Even in a channel context with a compound currentChannelId, an // explicit `target` param must take precedence. - const teamChannelTarget = "team-2/19:channel-def@thread.tacv2"; - const explicitTarget = "team-explicit/19:other@thread.tacv2"; + const teamChannelTarget = "22222222-2222-2222-2222-222222222222/19:channel-def@thread.tacv2"; + const explicitTarget = "33333333-3333-3333-3333-333333333333/19:other@thread.tacv2"; await expectSuccessfulAction({ mockFn: reactMessageMSTeamsMock, mockResult: { ok: true }, action: "react", + cfg: unrestrictedReadCfg, actionParams: { target: explicitTarget, messageId: "msg-explicit", @@ -878,12 +1321,14 @@ describe("msteamsPlugin message actions", () => { mockFn: reactMessageMSTeamsMock, mockResult: { ok: true }, action: "react", + cfg: unrestrictedReadCfg, actionParams: { messageId: "msg-dm-react", emoji: reactionType, }, toolContext: { currentChannelId: dmFallback, + currentChatType: "direct", }, runtimeParams: { to: dmFallback, @@ -905,6 +1350,7 @@ describe("msteamsPlugin message actions", () => { describe("msteamsPlugin.threading.buildToolContext", () => { function callBuildToolContext(context: { + ChatType?: string; To?: string; NativeChannelId?: string; ReplyToId?: string; @@ -920,34 +1366,43 @@ describe("msteamsPlugin.threading.buildToolContext", () => { }); } - it("uses NativeChannelId for channel turns so actions route via teamId/channelId", () => { - // Teams channel inbound messages carry the compound `teamId/channelId` + it("uses NativeChannelId for channel turns so actions route via Graph team/channel ids", () => { + // Teams channel inbound messages carry the compound Graph target // on NativeChannelId. buildToolContext must prefer it over the bare // `conversation:` in To so action fallbacks route via // `/teams/{teamId}/channels/{channelId}`. const result = callBuildToolContext({ + ChatType: "channel", To: "conversation:19:channel-abc@thread.tacv2", - NativeChannelId: "team-1/19:channel-abc@thread.tacv2", + NativeChannelId: "graph-team-1/19:channel-abc@thread.tacv2", ReplyToId: "reply-1", }); expect(result?.currentChannelId).toBe("conversation:19:channel-abc@thread.tacv2"); - expect(result?.currentGraphChannelId).toBe("team-1/19:channel-abc@thread.tacv2"); + expect(result?.currentChatType).toBe("channel"); + expect(result?.currentMessagingTarget).toBe("graph-team-1/19:channel-abc@thread.tacv2"); + expect(result?.currentGraphChannelId).toBe("graph-team-1/19:channel-abc@thread.tacv2"); expect(result?.currentThreadTs).toBe("reply-1"); }); it("falls back to To for DM turns (no NativeChannelId)", () => { const result = callBuildToolContext({ + ChatType: "direct", To: "user:aad-user-1", }); expect(result?.currentChannelId).toBe("user:aad-user-1"); + expect(result?.currentChatType).toBe("direct"); + expect(result?.currentMessagingTarget).toBeUndefined(); expect(result?.currentGraphChannelId).toBeUndefined(); }); it("falls back to To for group chat turns (no NativeChannelId)", () => { const result = callBuildToolContext({ + ChatType: "group", To: "conversation:19:groupchat@thread.v2", }); expect(result?.currentChannelId).toBe("conversation:19:groupchat@thread.v2"); + expect(result?.currentChatType).toBe("group"); + expect(result?.currentMessagingTarget).toBeUndefined(); expect(result?.currentGraphChannelId).toBeUndefined(); }); @@ -960,6 +1415,7 @@ describe("msteamsPlugin.threading.buildToolContext", () => { NativeChannelId: "19:chat@thread.v2", }); expect(result?.currentChannelId).toBe("conversation:19:chat@thread.v2"); + expect(result?.currentMessagingTarget).toBeUndefined(); expect(result?.currentGraphChannelId).toBeUndefined(); }); }); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 6a29738b49ea..14dc6ab7763f 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -48,6 +48,11 @@ import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import { buildMSTeamsPresentationCard, MSTEAMS_PRESENTATION_CAPABILITIES } from "./presentation.js"; import type { ProbeMSTeamsResult } from "./probe.js"; +import { + assertMSTeamsReadTargetAllowed, + assertMSTeamsTeamEnumerationAllowed, + isCurrentMSTeamsReadTarget, +} from "./read-policy.js"; import { normalizeMSTeamsMessagingTarget, normalizeMSTeamsUserInput, @@ -211,8 +216,38 @@ function resolveGraphActionTarget( params: Record, currentChannelId?: string | null, currentGraphChannelId?: string | null, + currentChatType?: "direct" | "group" | "channel" | null, ): string { - return resolveActionTarget(params, currentGraphChannelId ?? currentChannelId); + const explicitTarget = resolveActionTarget(params); + const currentChannelTarget = currentChannelId?.trim(); + const currentGraphTarget = currentGraphChannelId?.trim(); + if (explicitTarget) { + // Core materializes omitted action targets as currentChannelId before + // plugin dispatch. Restore the prepared Graph route for channel actions. + if ( + currentChatType === "channel" && + currentGraphTarget && + currentChannelTarget && + explicitTarget === currentChannelTarget + ) { + return currentGraphTarget; + } + return explicitTarget; + } + if (currentGraphTarget) { + return currentGraphTarget; + } + return currentChatType === "channel" ? "" : (currentChannelTarget ?? ""); +} + +function resolveCurrentGraphActionTarget(toolContext?: { + currentGraphChannelId?: string; + currentMessagingTarget?: string; +}): string | undefined { + return ( + normalizeOptionalString(toolContext?.currentGraphChannelId) ?? + normalizeOptionalString(toolContext?.currentMessagingTarget) + ); } function resolveActionMessageId(params: Record): string { @@ -265,6 +300,7 @@ function resolveRequiredActionTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; }): string | ReturnType { const to = params.graphOnly @@ -272,6 +308,7 @@ function resolveRequiredActionTarget(params: { params.toolParams, params.currentChannelId, params.currentGraphChannelId, + params.currentChatType, ) : resolveActionTarget(params.toolParams, params.currentChannelId); if (!to) { @@ -285,6 +322,7 @@ function resolveRequiredActionMessageTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; }): { to: string; messageId: string } | ReturnType { const to = params.graphOnly @@ -292,6 +330,7 @@ function resolveRequiredActionMessageTarget(params: { params.toolParams, params.currentChannelId, params.currentGraphChannelId, + params.currentChatType, ) : resolveActionTarget(params.toolParams, params.currentChannelId); const messageId = resolveActionMessageId(params.toolParams); @@ -306,6 +345,7 @@ function resolveRequiredActionPinnedMessageTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; }): { to: string; pinnedMessageId: string } | ReturnType { const to = params.graphOnly @@ -313,6 +353,7 @@ function resolveRequiredActionPinnedMessageTarget(params: { params.toolParams, params.currentChannelId, params.currentGraphChannelId, + params.currentChatType, ) : resolveActionTarget(params.toolParams, params.currentChannelId); const pinnedMessageId = resolveActionPinnedMessageId(params.toolParams); @@ -327,6 +368,7 @@ async function runWithRequiredActionTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; run: (to: string) => Promise; }): Promise> { @@ -335,6 +377,7 @@ async function runWithRequiredActionTarget(params: { toolParams: params.toolParams, currentChannelId: params.currentChannelId, currentGraphChannelId: params.currentGraphChannelId, + currentChatType: params.currentChatType, graphOnly: params.graphOnly, }); if (typeof to !== "string") { @@ -348,6 +391,7 @@ async function runWithRequiredActionMessageTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; run: (target: { to: string; messageId: string }) => Promise; }): Promise> { @@ -356,6 +400,7 @@ async function runWithRequiredActionMessageTarget(params: { toolParams: params.toolParams, currentChannelId: params.currentChannelId, currentGraphChannelId: params.currentGraphChannelId, + currentChatType: params.currentChatType, graphOnly: params.graphOnly, }); if ("isError" in target) { @@ -369,6 +414,7 @@ async function runWithRequiredActionPinnedMessageTarget(params: { toolParams: Record; currentChannelId?: string | null; currentGraphChannelId?: string | null; + currentChatType?: "direct" | "group" | "channel" | null; graphOnly?: boolean; run: (target: { to: string; pinnedMessageId: string }) => Promise; }): Promise> { @@ -377,6 +423,7 @@ async function runWithRequiredActionPinnedMessageTarget(params: { toolParams: params.toolParams, currentChannelId: params.currentChannelId, currentGraphChannelId: params.currentGraphChannelId, + currentChatType: params.currentChatType, graphOnly: params.graphOnly, }); if ("isError" in target) { @@ -800,10 +847,15 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { editMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await editMessageMSTeams({ cfg: ctx.cfg, - to: target.to, + to, activityId: target.messageId, text: content, }); @@ -818,10 +870,15 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { deleteMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await deleteMessageMSTeams({ cfg: ctx.cfg, - to: target.to, + to, activityId: target.messageId, }); return jsonMSTeamsConversationResult(result.conversationId); @@ -834,13 +891,19 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { getMessageMSTeams } = await loadMSTeamsChannelRuntime(); const message = await getMessageMSTeams({ cfg: ctx.cfg, - to: target.to, + to, messageId: target.messageId, }); return jsonMSTeamsOkActionResult("read", { message }); @@ -853,13 +916,19 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { pinMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await pinMessageMSTeams({ cfg: ctx.cfg, - to: target.to, + to, messageId: target.messageId, }); return jsonMSTeamsActionResult("pin", result); @@ -872,13 +941,19 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { unpinMessageMSTeams } = await loadMSTeamsChannelRuntime(); const result = await unpinMessageMSTeams({ cfg: ctx.cfg, - to: target.to, + to, pinnedMessageId: target.pinnedMessageId, }); return jsonMSTeamsActionResult("unpin", result); @@ -891,11 +966,17 @@ export const msteamsPlugin: ChannelPlugin { + const allowedTarget = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: to, + }); const { listPinsMSTeams } = await loadMSTeamsChannelRuntime(); - const result = await listPinsMSTeams({ cfg: ctx.cfg, to }); + const result = await listPinsMSTeams({ cfg: ctx.cfg, to: allowedTarget }); return jsonMSTeamsOkActionResult("list-pins", result); }, }); @@ -906,7 +987,8 @@ export const msteamsPlugin: ChannelPlugin { const emoji = typeof ctx.params.emoji === "string" ? ctx.params.emoji.trim() : ""; @@ -926,11 +1008,16 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: target.to, + }); const { listReactionsMSTeams } = await loadMSTeamsChannelRuntime(); const result = await listReactionsMSTeams({ cfg: ctx.cfg, - to: target.to, + to, messageId: target.messageId, }); return jsonMSTeamsOkActionResult("reactions", result); @@ -979,9 +1072,15 @@ export const msteamsPlugin: ChannelPlugin { + const allowedTarget = await assertMSTeamsReadTargetAllowed({ + cfg: ctx.cfg, + ctx, + target: to, + }); const query = resolveActionQuery(ctx.params); if (!query) { return actionError("Search requires a target (to) and query."); @@ -992,7 +1091,7 @@ export const msteamsPlugin: ChannelPlugin { + const to = await assertMSTeamsReadTargetAllowed({ cfg: ctx.cfg, ctx, target }); + const currentRequesterId = isCurrentMSTeamsReadTarget({ ctx, target: to }) + ? ctx.requesterSenderId + : undefined; + const { getMemberInfoMSTeams } = await loadMSTeamsChannelRuntime(); + const result = await getMemberInfoMSTeams({ + cfg: ctx.cfg, + to, + userId, + currentRequesterId, + }); + return jsonMSTeamsOkActionResult("member-info", result); + }, + }); } if (ctx.action === "channel-list") { @@ -1017,8 +1135,13 @@ export const msteamsPlugin: ChannelPlugin { + const conversationId = await resolveGraphConversationId(params.to); + const conversation = resolveConversationPath(conversationId); + const collection = + conversation.kind === "channel" && params.includeIndirectChannelMembers + ? "allMembers" + : "members"; + let nextPath: string | undefined = `${conversation.basePath}/${collection}`; + let pages = 0; + let member: MSTeamsConversationMember | undefined; + + while (nextPath && pages < MAX_CONVERSATION_MEMBER_PAGES && !member) { + const response: GraphConversationMembersPage = + await fetchGraphJson({ + token: params.token, + path: nextPath, + }); + const userId = params.userId.trim().toLowerCase(); + member = (response.value ?? []).find( + (candidate) => + candidate.userId?.trim().toLowerCase() === userId || + candidate.email?.trim().toLowerCase() === userId, + ); + nextPath = response["@odata.nextLink"]?.replace("https://graph.microsoft.com/v1.0", ""); + pages += 1; + } + if (nextPath && !member) { + throw new Error("MS Teams conversation member pagination limit exceeded"); + } + + return { conversationId, member }; +} diff --git a/extensions/msteams/src/graph-group-management.test.ts b/extensions/msteams/src/graph-group-management.test.ts index 51f0655605d3..ddd5c09746b6 100644 --- a/extensions/msteams/src/graph-group-management.test.ts +++ b/extensions/msteams/src/graph-group-management.test.ts @@ -56,7 +56,7 @@ describe("addParticipantMSTeams", () => { mockState.resolveGraphToken.mockResolvedValue(TOKEN); }); - it("adds member to a chat with default role", async () => { + it("maps the default chat member role to Graph owner", async () => { mockState.postGraphJson.mockResolvedValue({}); const result = await addParticipantMSTeams({ @@ -71,7 +71,7 @@ describe("addParticipantMSTeams", () => { path: `/chats/${encodeURIComponent(CHAT_ID)}/members`, body: { "@odata.type": "#microsoft.graph.aadUserConversationMember", - roles: ["member"], + roles: ["owner"], "user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-1')", }, }); @@ -163,7 +163,7 @@ describe("addParticipantMSTeams", () => { ); }); - it("adds member to a channel", async () => { + it("maps the default channel member role to an empty Graph role list", async () => { mockState.postGraphJson.mockResolvedValue({}); const result = await addParticipantMSTeams({ @@ -178,11 +178,32 @@ describe("addParticipantMSTeams", () => { path: "/teams/team-id-1/channels/channel-id-1/members", body: { "@odata.type": "#microsoft.graph.aadUserConversationMember", - roles: ["member"], + roles: [], "user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-3')", }, }); }); + + it("preserves the owner role for a channel", async () => { + mockState.postGraphJson.mockResolvedValue({}); + + await addParticipantMSTeams({ + cfg: {} as OpenClawConfig, + to: CHANNEL_TO, + userId: "user-aad-id-4", + role: "owner", + }); + + expect(mockState.postGraphJson).toHaveBeenCalledWith({ + token: TOKEN, + path: "/teams/team-id-1/channels/channel-id-1/members", + body: { + "@odata.type": "#microsoft.graph.aadUserConversationMember", + roles: ["owner"], + "user@odata.bind": "https://graph.microsoft.com/v1.0/users('user-aad-id-4')", + }, + }); + }); }); describe("removeParticipantMSTeams", () => { diff --git a/extensions/msteams/src/graph-group-management.ts b/extensions/msteams/src/graph-group-management.ts index 7dafe6043b86..a43ba5fad4ae 100644 --- a/extensions/msteams/src/graph-group-management.ts +++ b/extensions/msteams/src/graph-group-management.ts @@ -1,10 +1,10 @@ // Msteams plugin module implements graph group management behavior. import type { OpenClawConfig } from "../runtime-api.js"; +import { findMSTeamsConversationMember } from "./graph-conversation-members.js"; import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js"; import { deleteGraphRequest, escapeOData, - fetchGraphJson, patchGraphJson, postGraphJson, resolveGraphToken, @@ -38,6 +38,19 @@ function normalizeConversationMemberRole(role: string | undefined): Conversation throw new Error('MS Teams participant role must be "member" or "owner".'); } +function resolveConversationMemberRoles( + role: string | undefined, + kind: "chat" | "channel", +): ConversationMemberRole[] { + const normalized = normalizeConversationMemberRole(role); + if (kind === "chat") { + // Graph accepts chat additions only as owners; "member" is the public + // convenience role and maps to the provider's required representation. + return ["owner"]; + } + return normalized === "owner" ? ["owner"] : []; +} + /** * Add a user to a chat or channel via Graph API. */ @@ -50,7 +63,7 @@ export async function addParticipantMSTeams( const body = { "@odata.type": "#microsoft.graph.aadUserConversationMember", - roles: [normalizeConversationMemberRole(params.role)], + roles: resolveConversationMemberRoles(params.role, conv.kind), "user@odata.bind": `https://graph.microsoft.com/v1.0/users('${escapeOData(params.userId)}')`, }; @@ -77,16 +90,6 @@ type RemoveParticipantMSTeamsResult = { removed: { userId: string; chatId: string }; }; -type GraphConversationMember = { - id?: string; - userId?: string; -}; - -type GraphConversationMemberResponse = { - value?: GraphConversationMember[]; - "@odata.nextLink"?: string; -}; - /** * Remove a user from a chat or channel via Graph API. * Lists members first to resolve the membership ID, then deletes. @@ -95,35 +98,15 @@ export async function removeParticipantMSTeams( params: RemoveParticipantMSTeamsParams, ): Promise { const token = await resolveGraphToken(params.cfg); - const conversationId = await resolveGraphConversationId(params.to); - const conv = resolveConversationPath(conversationId); - - // List members to find the membership ID for the target user. Graph can - // paginate large chats/channels, so walk `@odata.nextLink` before concluding - // the user is missing. - const MAX_PAGES = 10; - let nextPath: string | undefined = `${conv.basePath}/members`; - let page = 0; - let member: GraphConversationMember | undefined; - while (nextPath && page < MAX_PAGES && !member) { - const membersRes: GraphConversationMemberResponse = - await fetchGraphJson({ - token, - path: nextPath, - }); - member = (membersRes.value ?? []).find( - (candidate: GraphConversationMember) => candidate.userId === params.userId, - ); - if (member) { - break; - } - const nextLink: string | undefined = membersRes["@odata.nextLink"]; - nextPath = nextLink ? nextLink.replace("https://graph.microsoft.com/v1.0", "") : undefined; - page++; - } + const { conversationId, member } = await findMSTeamsConversationMember({ + token, + to: params.to, + userId: params.userId, + }); if (!member?.id) { throw new Error(`User ${params.userId} is not a member of this conversation`); } + const conv = resolveConversationPath(conversationId); await deleteGraphRequest({ token, diff --git a/extensions/msteams/src/graph-members.test.ts b/extensions/msteams/src/graph-members.test.ts index 4de3540d938c..57684d3482dd 100644 --- a/extensions/msteams/src/graph-members.test.ts +++ b/extensions/msteams/src/graph-members.test.ts @@ -23,18 +23,23 @@ describe("getMemberInfoMSTeams", () => { mockState.resolveGraphToken.mockResolvedValue(TOKEN); }); - it("fetches user profile and maps all fields", async () => { - mockState.fetchGraphJson.mockResolvedValue({ - id: "user-123", - displayName: "Alice Smith", - mail: "alice@contoso.com", - jobTitle: "Engineer", - userPrincipalName: "alice@contoso.com", - officeLocation: "Building 1", - }); + it("returns verified standard-channel roster fields", async () => { + mockState.fetchGraphJson + .mockResolvedValueOnce({ membershipType: "standard" }) + .mockResolvedValueOnce({ + value: [ + { + userId: "user-123", + displayName: "Alice Smith", + email: "alice@contoso.com", + roles: ["owner"], + }, + ], + }); const result = await getMemberInfoMSTeams({ cfg: {} as OpenClawConfig, + to: "graph-team-1/channel-1", userId: "user-123", }); @@ -43,25 +48,61 @@ describe("getMemberInfoMSTeams", () => { id: "user-123", displayName: "Alice Smith", mail: "alice@contoso.com", - jobTitle: "Engineer", + jobTitle: undefined, userPrincipalName: "alice@contoso.com", - officeLocation: "Building 1", + officeLocation: undefined, + roles: ["owner"], }, }); - expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ + expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(1, { token: TOKEN, - path: `/users/${encodeURIComponent("user-123")}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`, + path: "/teams/graph-team-1/channels/channel-1?$select=membershipType", }); + expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(2, { + token: TOKEN, + path: "/teams/graph-team-1/members", + }); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2); + }); + + it("keeps roster-backed fields for the current requester in a channel", async () => { + mockState.fetchGraphJson + .mockResolvedValueOnce({ membershipType: "standard" }) + .mockResolvedValueOnce({ + value: [ + { + userId: "user-123", + displayName: "Alice Smith", + email: "alice@contoso.com", + }, + ], + }); + + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "graph-team-1/channel-1", + userId: "user-123", + currentRequesterId: "user-123", + }), + ).resolves.toMatchObject({ + user: { + id: "user-123", + displayName: "Alice Smith", + mail: "alice@contoso.com", + }, + }); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2); }); it("handles sparse data with some fields undefined", async () => { - mockState.fetchGraphJson.mockResolvedValue({ - id: "user-456", - displayName: "Bob", - }); + mockState.fetchGraphJson + .mockResolvedValueOnce({ membershipType: "standard" }) + .mockResolvedValueOnce({ value: [{ userId: "user-456", displayName: "Bob" }] }); const result = await getMemberInfoMSTeams({ cfg: {} as OpenClawConfig, + to: "team-1/channel-1", userId: "user-456", }); @@ -73,18 +114,118 @@ describe("getMemberInfoMSTeams", () => { jobTitle: undefined, userPrincipalName: undefined, officeLocation: undefined, + roles: [], }, }); }); + it("canonicalizes a user principal name before checking conversation membership", async () => { + mockState.fetchGraphJson + .mockResolvedValueOnce({ membershipType: "standard" }) + .mockResolvedValueOnce({ + value: [ + { + userId: "aad-user-123", + email: "alice@contoso.com", + }, + ], + }); + + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "team-1/channel-1", + userId: "alice@contoso.com", + }), + ).resolves.toMatchObject({ + user: { + id: "aad-user-123", + userPrincipalName: "alice@contoso.com", + }, + }); + expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(1, { + token: TOKEN, + path: "/teams/team-1/channels/channel-1?$select=membershipType", + }); + expect(mockState.fetchGraphJson).toHaveBeenNthCalledWith(2, { + token: TOKEN, + path: "/teams/team-1/members", + }); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2); + }); + it("propagates Graph API errors", async () => { mockState.fetchGraphJson.mockRejectedValue(new Error("Graph API 404: user not found")); await expect( getMemberInfoMSTeams({ cfg: {} as OpenClawConfig, + to: "team-1/channel-1", userId: "nonexistent-user", }), ).rejects.toThrow("Graph API 404: user not found"); }); + + it("does not return profiles for users outside the conversation", async () => { + mockState.fetchGraphJson + .mockResolvedValueOnce({ membershipType: "standard" }) + .mockResolvedValueOnce({ value: [] }); + + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "team-1/channel-1", + userId: "user-789", + }), + ).rejects.toThrow("User user-789 is not a member of this conversation"); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(2); + }); + + it("rejects private channels when the baseline cannot prove channel membership", async () => { + mockState.fetchGraphJson.mockResolvedValueOnce({ membershipType: "private" }); + + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "team-1/channel-private", + userId: "user-123", + }), + ).rejects.toThrow("requires a standard channel"); + expect(mockState.fetchGraphJson).toHaveBeenCalledTimes(1); + }); + + it("returns the trusted requester identity in the current chat without Graph reads", async () => { + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "user:user-123", + userId: "teams:user-123", + currentRequesterId: "user-123", + }), + ).resolves.toMatchObject({ + user: { + id: "user-123", + displayName: undefined, + mail: undefined, + jobTitle: undefined, + userPrincipalName: undefined, + officeLocation: undefined, + roles: [], + }, + }); + expect(mockState.resolveGraphToken).not.toHaveBeenCalled(); + expect(mockState.fetchGraphJson).not.toHaveBeenCalled(); + }); + + it("rejects unrelated profiles in chats before fetching a user", async () => { + await expect( + getMemberInfoMSTeams({ + cfg: {} as OpenClawConfig, + to: "conversation:19:chat@thread.v2", + userId: "user-456", + currentRequesterId: "user-123", + }), + ).rejects.toThrow("User user-456 is not a member of this conversation"); + expect(mockState.fetchGraphJson).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/msteams/src/graph-members.ts b/extensions/msteams/src/graph-members.ts index 03969e871be6..907d0a7e7000 100644 --- a/extensions/msteams/src/graph-members.ts +++ b/extensions/msteams/src/graph-members.ts @@ -1,19 +1,13 @@ // Msteams plugin module implements graph members behavior. import type { OpenClawConfig } from "../runtime-api.js"; +import { resolveConversationPath, resolveGraphConversationId } from "./graph-messages.js"; import { fetchGraphJson, resolveGraphToken } from "./graph.js"; -type GraphUserProfile = { - id?: string; - displayName?: string; - mail?: string; - jobTitle?: string; - userPrincipalName?: string; - officeLocation?: string; -}; - type GetMemberInfoMSTeamsParams = { cfg: OpenClawConfig; + to: string; userId: string; + currentRequesterId?: string | null; }; type GetMemberInfoMSTeamsResult = { @@ -24,26 +18,122 @@ type GetMemberInfoMSTeamsResult = { jobTitle: string | undefined; userPrincipalName: string | undefined; officeLocation: string | undefined; + roles: string[]; }; }; +type GraphConversationMember = { + displayName?: string; + userId?: string; + email?: string; + roles?: string[]; +}; + +type GraphConversationMembersPage = { + value?: GraphConversationMember[]; + "@odata.nextLink"?: string; +}; + +const MAX_TEAM_MEMBER_PAGES = 100; + +function normalizeUserId(value?: string | null): string { + return ( + value + ?.replace(/^(msteams|teams|user):/i, "") + .trim() + .toLowerCase() ?? "" + ); +} + +async function findStandardChannelMember(params: { + token: string; + to: string; + userId: string; +}): Promise { + const conversationId = await resolveGraphConversationId(params.to); + const conversation = resolveConversationPath(conversationId); + if (conversation.kind !== "channel" || !conversation.teamId) { + return undefined; + } + const channel = await fetchGraphJson<{ membershipType?: string }>({ + token: params.token, + path: `${conversation.basePath}?$select=membershipType`, + }); + if (channel.membershipType !== "standard") { + throw new Error( + "Microsoft Teams member-info requires a standard channel when using the configured permission baseline.", + ); + } + + const requestedUserId = normalizeUserId(params.userId); + let nextPath: string | undefined = `/teams/${encodeURIComponent(conversation.teamId)}/members`; + let pages = 0; + while (nextPath && pages < MAX_TEAM_MEMBER_PAGES) { + const response: GraphConversationMembersPage = + await fetchGraphJson({ + token: params.token, + path: nextPath, + }); + const member = (response.value ?? []).find( + (candidate) => + normalizeUserId(candidate.userId) === requestedUserId || + normalizeUserId(candidate.email) === requestedUserId, + ); + if (member) { + return member; + } + nextPath = response["@odata.nextLink"]?.replace("https://graph.microsoft.com/v1.0", ""); + pages += 1; + } + if (nextPath) { + throw new Error("Microsoft Teams team member pagination limit exceeded"); + } + return undefined; +} + /** * Fetch a user profile from Microsoft Graph by user ID. */ export async function getMemberInfoMSTeams( params: GetMemberInfoMSTeamsParams, ): Promise { - const token = await resolveGraphToken(params.cfg); - const path = `/users/${encodeURIComponent(params.userId)}?$select=id,displayName,mail,jobTitle,userPrincipalName,officeLocation`; - const user = await fetchGraphJson({ token, path }); + const isCurrentRequester = + normalizeUserId(params.userId) === normalizeUserId(params.currentRequesterId); + if (isCurrentRequester && resolveConversationPath(params.to).kind === "chat") { + return { + user: { + id: params.currentRequesterId ?? undefined, + displayName: undefined, + mail: undefined, + jobTitle: undefined, + userPrincipalName: undefined, + officeLocation: undefined, + roles: [], + }, + }; + } + const conversationId = await resolveGraphConversationId(params.to); + const conversation = resolveConversationPath(conversationId); + const member = + conversation.kind === "channel" + ? await findStandardChannelMember({ + token: await resolveGraphToken(params.cfg), + to: params.to, + userId: params.userId, + }) + : undefined; + if (!member?.userId) { + throw new Error(`User ${params.userId} is not a member of this conversation`); + } return { user: { - id: user.id, - displayName: user.displayName, - mail: user.mail, - jobTitle: user.jobTitle, - userPrincipalName: user.userPrincipalName, - officeLocation: user.officeLocation, + id: member.userId, + displayName: member.displayName, + mail: member.email, + jobTitle: undefined, + userPrincipalName: member.email, + officeLocation: undefined, + roles: member.roles ?? [], }, }; } diff --git a/extensions/msteams/src/graph-messages.actions.test.ts b/extensions/msteams/src/graph-messages.actions.test.ts index 5f9983b53039..e6ce1334f8a9 100644 --- a/extensions/msteams/src/graph-messages.actions.test.ts +++ b/extensions/msteams/src/graph-messages.actions.test.ts @@ -133,7 +133,7 @@ describe("reactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, - body: { reactionType: "like" }, + body: { reactionType: "👍" }, }); }); @@ -151,11 +151,11 @@ describe("reactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/setReaction", - body: { reactionType: "heart" }, + body: { reactionType: "❤️" }, }); }); - it("normalizes reaction type to lowercase", async () => { + it("normalizes a case-insensitive reaction name to Unicode", async () => { mockState.postGraphBetaJson.mockResolvedValue(undefined); await reactMessageMSTeams({ @@ -168,14 +168,12 @@ describe("reactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/setReaction`, - body: { reactionType: "laugh" }, + body: { reactionType: "😆" }, }); }); it("passes through non-well-known reaction types (e.g. Unicode emoji)", async () => { - // Graph setReaction accepts arbitrary Unicode emoji plus the legacy - // well-known types; normalizeReactionType only lowercases the legacy set - // and lets any other non-empty value through unchanged. + // Graph setReaction accepts Unicode values outside the named convenience set. mockState.postGraphBetaJson.mockResolvedValue(undefined); await reactMessageMSTeams({ @@ -210,7 +208,7 @@ describe("reactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages/msg-1/setReaction`, - body: { reactionType: "like" }, + body: { reactionType: "👍" }, }); }); }); @@ -230,7 +228,7 @@ describe("unreactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: `/chats/${encodeURIComponent(CHAT_ID)}/messages/msg-1/unsetReaction`, - body: { reactionType: "sad" }, + body: { reactionType: "😢" }, }); }); @@ -248,7 +246,7 @@ describe("unreactMessageMSTeams", () => { expect(mockState.postGraphBetaJson).toHaveBeenCalledWith({ token: TOKEN, path: "/teams/team-id-1/channels/channel-id-1/messages/msg-2/unsetReaction", - body: { reactionType: "angry" }, + body: { reactionType: "😡" }, }); }); }); diff --git a/extensions/msteams/src/graph-messages.search.test.ts b/extensions/msteams/src/graph-messages.search.test.ts index 943e5822bab7..cd4344a39a1b 100644 --- a/extensions/msteams/src/graph-messages.search.test.ts +++ b/extensions/msteams/src/graph-messages.search.test.ts @@ -4,6 +4,7 @@ import type { OpenClawConfig } from "../runtime-api.js"; import { CHANNEL_TO, CHAT_ID, + TOKEN, type GraphMessagesTestModule, getGraphMessagesMockState, installGraphMessagesMockDefaults, @@ -19,27 +20,27 @@ beforeAll(async () => { }); function readFirstGraphPath(): string { - const [call] = mockState.fetchGraphJson.mock.calls; - if (!call) { - throw new Error("Expected Graph fetch call"); - } - const [request] = call; - if (!request || typeof request !== "object" || typeof request.path !== "string") { + const request = mockState.fetchGraphJson.mock.calls[0]?.[0]; + if (!request || typeof request.path !== "string") { throw new Error("Expected Graph fetch request path"); } return request.path; } describe("searchMessagesMSTeams", () => { - it("searches chat messages with query string", async () => { + it("filters chat messages locally and normalizes HTML content", async () => { mockState.fetchGraphJson.mockResolvedValue({ value: [ { id: "msg-1", - body: { content: "Meeting notes from Monday" }, + body: { content: "

Meeting notes from Monday

", contentType: "html" }, from: { user: { id: "u1", displayName: "Alice" } }, createdDateTime: "2026-03-25T10:00:00Z", }, + { + id: "msg-2", + body: { content: "Unrelated update", contentType: "text" }, + }, ], }); @@ -49,32 +50,23 @@ describe("searchMessagesMSTeams", () => { query: "meeting notes", }); - expect(result.messages).toEqual([ - { - id: "msg-1", - text: "Meeting notes from Monday", - from: { user: { id: "u1", displayName: "Alice" } }, - createdAt: "2026-03-25T10:00:00Z", - }, - ]); - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain(`/chats/${encodeURIComponent(CHAT_ID)}/messages?`); - expect(calledPath).toContain("$search="); - expect(calledPath).toContain("$top=25"); - const decoded = decodeURIComponent(calledPath); - expect(decoded).toContain('$search="meeting notes"'); - }); - - it("searches channel messages", async () => { - mockState.fetchGraphJson.mockResolvedValue({ - value: [ + expect(result).toEqual({ + messages: [ { - id: "msg-2", - body: { content: "Sprint review" }, - from: { user: { id: "u2", displayName: "Bob" } }, - createdDateTime: "2026-03-25T11:00:00Z", + id: "msg-1", + text: "

Meeting notes from Monday

", + from: { user: { id: "u1", displayName: "Alice" } }, + createdAt: "2026-03-25T10:00:00Z", }, ], + truncated: false, + }); + expect(readFirstGraphPath()).toBe(`/chats/${encodeURIComponent(CHAT_ID)}/messages?$top=50`); + }); + + it("keeps channel search scoped to the selected channel", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [{ id: "msg-2", body: { content: "Sprint review" } }], }); const result = await searchMessagesMSTeams({ @@ -84,129 +76,139 @@ describe("searchMessagesMSTeams", () => { }); expect(result.messages).toHaveLength(1); - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain("/teams/team-id-1/channels/channel-id-1/messages?"); + expect(readFirstGraphPath()).toBe("/teams/team-id-1/channels/channel-id-1/messages?$top=50"); }); - it("applies limit parameter", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "test", - limit: 10, + it("follows target-scoped pagination and applies sender matching locally", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [ + { + id: "wrong-sender", + body: { content: "budget update" }, + from: { user: { id: "u1", displayName: "Bob" } }, + }, + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/next-page", }); - - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain("$top=10"); - }); - - it("clamps limit to max 50", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "test", - limit: 100, + mockState.fetchGraphAbsoluteUrl.mockResolvedValue({ + value: [ + { + id: "right-sender", + body: { content: "Budget update" }, + from: { application: { id: "app-1", displayName: "Finance Bot" } }, + }, + ], }); - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain("$top=50"); - }); - - it("clamps limit to min 1", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "test", - limit: 0, - }); - - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain("$top=1"); - }); - - it("applies from filter", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "budget", - from: "Alice", - }); - - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain("$filter="); - const decoded = decodeURIComponent(calledPath); - expect(decoded).toContain("from/user/displayName eq 'Alice'"); - }); - - it("escapes single quotes in from filter", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "test", - from: "O'Brien", - }); - - const calledPath = readFirstGraphPath(); - const decoded = decodeURIComponent(calledPath); - expect(decoded).toContain("O''Brien"); - }); - - it("strips double quotes from query to prevent injection", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: 'say "hello" world', - }); - - const calledPath = readFirstGraphPath(); - const decoded = decodeURIComponent(calledPath); - expect(decoded).toContain('$search="say hello world"'); - expect(decoded).not.toContain('""'); - }); - - it("passes ConsistencyLevel: eventual header", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - - await searchMessagesMSTeams({ - cfg: {} as OpenClawConfig, - to: CHAT_ID, - query: "test", - }); - - expect(mockState.fetchGraphJson).toHaveBeenCalledWith({ - token: "test-graph-token", - path: `/chats/${encodeURIComponent(CHAT_ID)}/messages?$search=${encodeURIComponent( - '"test"', - )}&$top=25`, - headers: { ConsistencyLevel: "eventual" }, - }); - }); - - it("returns empty array when no messages match", async () => { - mockState.fetchGraphJson.mockResolvedValue({ value: [] }); - const result = await searchMessagesMSTeams({ cfg: {} as OpenClawConfig, to: CHAT_ID, - query: "nonexistent", + query: "BUDGET", + from: "finance bot", }); - expect(result.messages).toStrictEqual([]); + expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledWith({ + token: TOKEN, + url: "https://graph.microsoft.com/v1.0/next-page", + }); + expect(result).toEqual({ + messages: [ + { + id: "right-sender", + text: "Budget update", + from: { application: { id: "app-1", displayName: "Finance Bot" } }, + createdAt: undefined, + }, + ], + truncated: false, + }); }); - it("resolves user: target through conversation store", async () => { + it("matches the sender by stable ID", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [ + { + id: "msg-1", + body: { content: "hello" }, + from: { user: { id: "aad-user-1", displayName: "Alice" } }, + }, + ], + }); + + const result = await searchMessagesMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + query: "hello", + from: "AAD-USER-1", + }); + + expect(result.messages).toHaveLength(1); + }); + + it("stops at the requested result limit and reports remaining pages", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [ + { id: "msg-1", body: { content: "match" } }, + { id: "msg-2", body: { content: "match" } }, + ], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/next-page", + }); + + const result = await searchMessagesMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + query: "match", + limit: 1, + }); + + expect(result.messages.map((message) => message.id)).toEqual(["msg-1"]); + expect(result.truncated).toBe(true); + expect(mockState.fetchGraphAbsoluteUrl).not.toHaveBeenCalled(); + }); + + it("clamps a non-finite limit to the default", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: Array.from({ length: 30 }, (_, index) => ({ + id: `msg-${index}`, + body: { content: "match" }, + })), + }); + + const result = await searchMessagesMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + query: "match", + limit: Number.POSITIVE_INFINITY, + }); + + expect(result.messages).toHaveLength(25); + expect(result.truncated).toBe(true); + }); + + it("reports truncation after the bounded ten-page scan", async () => { + mockState.fetchGraphJson.mockResolvedValue({ + value: [], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/page-2", + }); + mockState.fetchGraphAbsoluteUrl.mockImplementation(async ({ url }: { url: string }) => { + const page = Number(url.match(/page-(\d+)/)?.[1] ?? "2"); + return { + value: [], + "@odata.nextLink": `https://graph.microsoft.com/v1.0/page-${page + 1}`, + }; + }); + + const result = await searchMessagesMSTeams({ + cfg: {} as OpenClawConfig, + to: CHAT_ID, + query: "missing", + }); + + expect(mockState.fetchGraphAbsoluteUrl).toHaveBeenCalledTimes(9); + expect(result).toEqual({ messages: [], truncated: true }); + }); + + it("resolves user targets before reading messages", async () => { mockState.findPreferredDmByUserId.mockResolvedValue({ conversationId: "19:dm-chat@thread.tacv2", reference: {}, @@ -220,9 +222,8 @@ describe("searchMessagesMSTeams", () => { }); expect(mockState.findPreferredDmByUserId).toHaveBeenCalledWith("aad-user-1"); - const calledPath = readFirstGraphPath(); - expect(calledPath).toContain( - `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?`, + expect(readFirstGraphPath()).toBe( + `/chats/${encodeURIComponent("19:dm-chat@thread.tacv2")}/messages?$top=50`, ); }); }); diff --git a/extensions/msteams/src/graph-messages.ts b/extensions/msteams/src/graph-messages.ts index 75fcb4fa75e7..403d9fb2368d 100644 --- a/extensions/msteams/src/graph-messages.ts +++ b/extensions/msteams/src/graph-messages.ts @@ -1,16 +1,16 @@ // Msteams plugin module implements graph messages behavior. import type { OpenClawConfig } from "../runtime-api.js"; import { createMSTeamsConversationStoreState } from "./conversation-store-state.js"; +import { stripHtmlFromTeamsMessage } from "./graph-thread.js"; import { - type GraphResponse, deleteGraphRequest, - escapeOData, fetchGraphAbsoluteUrl, fetchGraphJson, postGraphBetaJson, postGraphJson, resolveGraphToken, } from "./graph.js"; +import { getMSTeamsReactionEmoji, resolveMSTeamsReactionEmoji } from "./reaction-types.js"; type GraphMessageBody = { content?: string; @@ -298,9 +298,6 @@ export async function listPinsMSTeams( // Reactions // --------------------------------------------------------------------------- -const TEAMS_REACTION_TYPES = ["like", "heart", "laugh", "surprised", "sad", "angry"] as const; -type TeamsReactionType = (typeof TEAMS_REACTION_TYPES)[number]; - type GraphReaction = { reactionType?: string; user?: { id?: string; displayName?: string }; @@ -324,16 +321,6 @@ type ListReactionsMSTeamsParams = { messageId: string; }; -/** Map well-known reaction type names to representative emoji for CLI display. */ -const REACTION_TYPE_EMOJI: Record = { - like: "\u{1F44D}", - heart: "\u2764\uFE0F", - laugh: "\u{1F606}", - surprised: "\u{1F62E}", - sad: "\u{1F622}", - angry: "\u{1F621}", -}; - type ReactionSummary = { reactionType: string; /** Display name for the reaction (matches reactionType for known types). */ @@ -348,25 +335,6 @@ type ListReactionsMSTeamsResult = { reactions: ReactionSummary[]; }; -/** - * Normalize a reaction type string. Graph setReaction/unsetReaction accepts - * the well-known legacy names (like, heart, laugh, surprised, sad, angry) - * as well as Unicode emoji values — so we pass unknown types through rather - * than rejecting them. - */ -function normalizeReactionType(raw: string): string { - const normalized = raw.trim(); - if (!normalized) { - throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`); - } - // Lowercase only the well-known names; Unicode emoji should pass through as-is - const lowered = normalized.toLowerCase(); - if (TEAMS_REACTION_TYPES.includes(lowered as TeamsReactionType)) { - return lowered; - } - return normalized; -} - /** * Add an emoji reaction to a message via Graph API (beta). * @@ -378,7 +346,7 @@ function normalizeReactionType(raw: string): string { export async function reactMessageMSTeams( params: ReactMessageMSTeamsParams, ): Promise<{ ok: true }> { - const reactionType = normalizeReactionType(params.reactionType); + const reactionType = resolveMSTeamsReactionEmoji(params.reactionType); const token = await resolveGraphToken(params.cfg, { preferDelegated: true }); const conversationId = await resolveGraphConversationId(params.to); const { basePath } = resolveConversationPath(conversationId); @@ -396,7 +364,7 @@ export async function reactMessageMSTeams( export async function unreactMessageMSTeams( params: ReactMessageMSTeamsParams, ): Promise<{ ok: true }> { - const reactionType = normalizeReactionType(params.reactionType); + const reactionType = resolveMSTeamsReactionEmoji(params.reactionType); const token = await resolveGraphToken(params.cfg, { preferDelegated: true }); const conversationId = await resolveGraphConversationId(params.to); const { basePath } = resolveConversationPath(conversationId); @@ -442,7 +410,7 @@ export async function listReactionsMSTeams( const reactions: ReactionSummary[] = Array.from(grouped.entries()).map(([type, group]) => ({ reactionType: type, name: type, - emoji: REACTION_TYPE_EMOJI[type], + emoji: getMSTeamsReactionEmoji(type), count: group.count, users: group.users, })); @@ -469,14 +437,41 @@ type SearchMessagesMSTeamsResult = { from: GraphMessageFrom | undefined; createdAt: string | undefined; }>; + truncated: boolean; }; const SEARCH_DEFAULT_LIMIT = 25; const SEARCH_MAX_LIMIT = 50; +const SEARCH_PAGE_SIZE = 50; +const SEARCH_MAX_PAGES = 10; + +type GraphMessagesPage = { + value?: GraphMessage[]; + "@odata.nextLink"?: string; +}; + +function normalizeSearchText(message: GraphMessage): string { + const content = message.body?.content ?? ""; + return message.body?.contentType?.toLowerCase() === "html" + ? stripHtmlFromTeamsMessage(content) + : content.trim(); +} + +function matchesSearchSender(message: GraphMessage, from: string | undefined): boolean { + const normalized = from?.trim().toLowerCase(); + if (!normalized) { + return true; + } + const sender = message.from?.user ?? message.from?.application; + return [sender?.id, sender?.displayName].some( + (value) => value?.trim().toLowerCase() === normalized, + ); +} /** - * Search messages in a chat or channel by content via Graph API. - * Uses `$search` for full-text body search and optional `$filter` for sender. + * Search messages within one already-authorized chat or channel. + * Graph does not support collection `$search` here, so filter bounded pages + * locally without widening the read to the account's global message index. */ export async function searchMessagesMSTeams( params: SearchMessagesMSTeamsParams, @@ -489,34 +484,43 @@ export async function searchMessagesMSTeams( const top = Number.isFinite(rawLimit) ? Math.min(Math.max(Math.floor(rawLimit), 1), SEARCH_MAX_LIMIT) : SEARCH_DEFAULT_LIMIT; + const query = params.query.trim().toLowerCase(); + const messages: SearchMessagesMSTeamsResult["messages"] = []; + let nextUrl: string | undefined; + let truncated = false; - // Strip double quotes from the query to prevent OData $search injection - const sanitizedQuery = params.query.replace(/"/g, ""); + for (let page = 0; page < SEARCH_MAX_PAGES; page++) { + const response: GraphMessagesPage = nextUrl + ? await fetchGraphAbsoluteUrl({ token, url: nextUrl }) + : await fetchGraphJson({ + token, + path: `${basePath}/messages?$top=${SEARCH_PAGE_SIZE}`, + }); - // Build query string manually (not URLSearchParams) to preserve literal $ - // in OData parameter names, consistent with other Graph calls in this module. - const parts = [`$search=${encodeURIComponent(`"${sanitizedQuery}"`)}`]; - parts.push(`$top=${top}`); - if (params.from) { - parts.push( - `$filter=${encodeURIComponent(`from/user/displayName eq '${escapeOData(params.from)}'`)}`, - ); + for (const message of response.value ?? []) { + const searchText = normalizeSearchText(message); + if (searchText.toLowerCase().includes(query) && matchesSearchSender(message, params.from)) { + if (messages.length >= top) { + return { messages, truncated: true }; + } + messages.push({ + id: message.id ?? "", + text: message.body?.content, + from: message.from, + createdAt: message.createdDateTime, + }); + } + } + + nextUrl = response["@odata.nextLink"]; + if (messages.length >= top) { + return { messages, truncated: Boolean(nextUrl) }; + } + if (!nextUrl) { + return { messages, truncated: false }; + } + truncated = page === SEARCH_MAX_PAGES - 1; } - const path = `${basePath}/messages?${parts.join("&")}`; - // ConsistencyLevel: eventual is required by Graph API for $search queries - const res = await fetchGraphJson>({ - token, - path, - headers: { ConsistencyLevel: "eventual" }, - }); - - const messages = (res.value ?? []).map((msg) => ({ - id: msg.id ?? "", - text: msg.body?.content, - from: msg.from, - createdAt: msg.createdDateTime, - })); - - return { messages }; + return { messages, truncated }; } diff --git a/extensions/msteams/src/graph-users.ts b/extensions/msteams/src/graph-users.ts index 56a6e3314ed1..119e237dcdc6 100644 --- a/extensions/msteams/src/graph-users.ts +++ b/extensions/msteams/src/graph-users.ts @@ -1,5 +1,12 @@ // Msteams plugin module implements graph users behavior. -import { escapeOData, fetchGraphJson, type GraphResponse, type GraphUser } from "./graph.js"; +import { + escapeOData, + fetchAllGraphPages, + fetchGraphJson, + type GraphResponse, + type GraphUser, + type PaginatedResult, +} from "./graph.js"; export async function searchGraphUsers(params: { token: string; @@ -28,3 +35,21 @@ export async function searchGraphUsers(params: { }); return res.value ?? []; } + +export async function findGraphUsersByExactIdentity(params: { + token: string; + query: string; +}): Promise> { + const query = params.query.trim(); + if (!query) { + return { items: [], truncated: false }; + } + const escaped = escapeOData(query); + const filter = + `(displayName eq '${escaped}' or mail eq '${escaped}' or ` + + `userPrincipalName eq '${escaped}')`; + const path = + `/users?$filter=${encodeURIComponent(filter)}` + + "&$select=id,displayName,mail,userPrincipalName"; + return await fetchAllGraphPages({ token: params.token, path }); +} diff --git a/extensions/msteams/src/graph.test.ts b/extensions/msteams/src/graph.test.ts index e1f7a6497285..3c410c3229ff 100644 --- a/extensions/msteams/src/graph.test.ts +++ b/extensions/msteams/src/graph.test.ts @@ -44,7 +44,7 @@ vi.mock("../runtime-api.js", async (importOriginal) => { }; }); -import { searchGraphUsers } from "./graph-users.js"; +import { findGraphUsersByExactIdentity, searchGraphUsers } from "./graph-users.js"; import { deleteGraphRequest, escapeOData, @@ -52,7 +52,9 @@ import { fetchGraphAbsoluteUrl, fetchGraphJson, listChannelsForTeam, + listChannelsForTeamWithPageInfo, listTeamsByName, + listTeamsByNameWithPageInfo, normalizeQuery, postGraphBetaJson, postGraphJson, @@ -222,6 +224,17 @@ describe("msteams graph helpers", () => { expect(escapeOData("alice.o'hara")).toBe("alice.o''hara"); }); + it("lets the shared SSRF guard select the Graph transport", async () => { + mockGraphCollection(); + + await fetchGraphJson({ + token: graphToken, + path: "/groups", + }); + + expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl"); + }); + it("fetches Graph JSON and surfaces Graph errors with response text", async () => { mockGraphCollection(groupOne); @@ -426,6 +439,51 @@ describe("msteams graph helpers", () => { expectFetchPathContains(1, "/teams/team%2Fops/channels?$select=id,displayName"); }); + it("exposes pagination completeness for authorization lookups", async () => { + mockFetch(async (input) => { + const url = requestUrl(input); + if (url.includes("/groups?$skip=1")) { + return jsonResponse(graphCollection({ id: "team-2", displayName: "Ops" })); + } + if (url.includes("/groups?")) { + return jsonResponse({ + value: [opsTeam], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/groups?$skip=1", + }); + } + if (url.includes("/channels?$skip=1")) { + return jsonResponse(graphCollection({ id: "channel-2", displayName: "Incidents" })); + } + return jsonResponse({ + value: [deploymentsChannel], + "@odata.nextLink": "https://graph.microsoft.com/v1.0/teams/team-1/channels?$skip=1", + }); + }); + + await expect(listTeamsByNameWithPageInfo(graphToken, "Ops")).resolves.toEqual({ + items: [opsTeam, { id: "team-2", displayName: "Ops" }], + truncated: false, + }); + await expect(listChannelsForTeamWithPageInfo(graphToken, "team-1")).resolves.toEqual({ + items: [deploymentsChannel, { id: "channel-2", displayName: "Incidents" }], + truncated: false, + }); + }); + + it("builds an exact identity filter for authorization user lookup", async () => { + mockGraphCollection(userOne); + + await expect( + findGraphUsersByExactIdentity({ + token: graphToken, + query: "Alice O'Hara", + }), + ).resolves.toEqual({ items: [userOne], truncated: false }); + expect(fetchCallSearchParam(0, "$filter")).toBe( + "(displayName eq 'Alice O''Hara' or mail eq 'Alice O''Hara' or userPrincipalName eq 'Alice O''Hara')", + ); + }); + it("returns no graph users for blank queries", async () => { mockJsonFetchResponse({}); await expectSearchGraphUsers(" ", [], { token: "token-1" }); diff --git a/extensions/msteams/src/graph.ts b/extensions/msteams/src/graph.ts index 7bfbc53bb1aa..3dae75b07e75 100644 --- a/extensions/msteams/src/graph.ts +++ b/extensions/msteams/src/graph.ts @@ -24,12 +24,12 @@ export type GraphUser = { mail?: string; }; -type GraphGroup = { +export type GraphGroup = { id?: string; displayName?: string; }; -type GraphChannel = { +export type GraphChannel = { id?: string; displayName?: string; }; @@ -56,10 +56,8 @@ async function requestGraph(params: { }): Promise { const hasBody = params.body !== undefined; const url = `${params.root ?? GRAPH_ROOT}${params.path}`; - const currentFetch = globalThis.fetch; const { response, release } = await fetchWithSsrFGuard({ url, - fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), init: { method: params.method, headers: { @@ -159,7 +157,7 @@ type GraphPagedResponse = { }; /** Result of a paginated Graph API fetch. */ -type PaginatedResult = { +export type PaginatedResult = { items: T[]; truncated: boolean; found?: T; @@ -254,11 +252,17 @@ export async function resolveGraphToken( } export async function listTeamsByName(token: string, query: string): Promise { + return (await listTeamsByNameWithPageInfo(token, query)).items; +} + +export async function listTeamsByNameWithPageInfo( + token: string, + query: string, +): Promise> { const escaped = escapeOData(query); const filter = `resourceProvisioningOptions/Any(x:x eq 'Team') and startsWith(displayName,'${escaped}')`; const path = `/groups?$filter=${encodeURIComponent(filter)}&$select=id,displayName`; - const { items } = await fetchAllGraphPages({ token, path, maxPages: 5 }); - return items; + return await fetchAllGraphPages({ token, path }); } export async function postGraphJson(params: { @@ -317,7 +321,13 @@ export async function patchGraphJson(params: { } export async function listChannelsForTeam(token: string, teamId: string): Promise { - const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; - const { items } = await fetchAllGraphPages({ token, path, maxPages: 10 }); - return items; + return (await listChannelsForTeamWithPageInfo(token, teamId)).items; +} + +export async function listChannelsForTeamWithPageInfo( + token: string, + teamId: string, +): Promise> { + const path = `/teams/${encodeURIComponent(teamId)}/channels?$select=id,displayName`; + return await fetchAllGraphPages({ token, path }); } diff --git a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts index 0a14371481ad..1da3dbe8a370 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.authz.test.ts @@ -21,7 +21,9 @@ type TestAttachment = { const runtimeApiMockState = getRuntimeApiMockState(); const graphThreadMockState = vi.hoisted(() => ({ - resolveTeamGroupId: vi.fn(async () => "group-1"), + resolveTeamGroupId: vi.fn( + async (params: { aadGroupId?: string }) => params.aadGroupId?.trim() || "group-1", + ), fetchChannelMessage: vi.fn< ( token: string, @@ -267,8 +269,8 @@ describe("msteams monitor handler authz", () => { conversationType: "channel", }, channelData: { - team: { id: "team123", name: "Team 123" }, - channel: { name: "General" }, + team: { id: "team123", name: "Team 123", aadGroupId: "graph-team-123" }, + channel: { id: "19:graph-channel@thread.tacv2", name: "General" }, }, extraActivity: { replyToId: "parent-msg" }, attachments: params?.attachments ?? [], @@ -928,6 +930,7 @@ describe("msteams monitor handler authz", () => { "[Thread history]\nAlice: Allowed context\n[/Thread history]\n\nCurrent message", ); expect(ctxPayload.GroupSpace).toBe("team123"); + expect(ctxPayload.NativeChannelId).toBe("graph-team-123/19:graph-channel@thread.tacv2"); expect(String((dispatched.ctxPayload as { BodyForAgent?: string }).BodyForAgent)).not.toContain( "Mallory", ); diff --git a/extensions/msteams/src/monitor-handler/message-handler.media.test.ts b/extensions/msteams/src/monitor-handler/message-handler.media.test.ts index db7c7f623eb6..91967aaaa62e 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.media.test.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.media.test.ts @@ -202,7 +202,7 @@ describe("msteams message handler Graph media recovery", () => { }); }); - it("uses the canonical AAD group ID for ordinary channel action context", async () => { + it("uses canonical Graph team and channel IDs for ordinary channel action context", async () => { inboundMediaMockState.resolve.mockResolvedValue([]); const { deps, getTeamDetails } = createMessageHandlerDeps(cfg); const handler = createMSTeamsMessageHandler(deps); @@ -222,7 +222,7 @@ describe("msteams message handler Graph media recovery", () => { expect(getTeamDetails).toHaveBeenCalledWith("19:raw-team@thread.skype"); expect(firstDispatchedContext()).toMatchObject({ - NativeChannelId: "team-aad-group/19:general@thread.tacv2", + NativeChannelId: "team-aad-group/19:channel@thread.tacv2", }); expect(JSON.stringify(firstDispatchedContext())).not.toContain("19:raw-team@thread.skype/"); }); diff --git a/extensions/msteams/src/monitor-handler/message-handler.ts b/extensions/msteams/src/monitor-handler/message-handler.ts index dc7bac8af097..2a052ec947e3 100644 --- a/extensions/msteams/src/monitor-handler/message-handler.ts +++ b/extensions/msteams/src/monitor-handler/message-handler.ts @@ -266,6 +266,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { const conversationMessageId = extractMSTeamsConversationMessageId(rawConversationId); const conversationType = conversation?.conversationType ?? "personal"; const teamId = activity.channelData?.team?.id; + const graphChannelId = activity.channelData?.channel?.id?.trim() || conversationId; // For channel thread messages, resolve the thread root message ID so outbound // replies land in the correct thread. The root ID comes from the `messageid=` // portion of conversation.id (preferred) or from activity.replyToId. @@ -699,11 +700,11 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { channelGroupId, conversationId, threadParentId, - (token, groupId, graphChannelId, messageId) => + (token, groupId, requestedChannelId, messageId) => fetchChannelMessage( token, groupId, - graphChannelId, + requestedChannelId, messageId, preprocessingDeadline, ), @@ -846,7 +847,7 @@ export function createMSTeamsMessageHandler(deps: MSTeamsMessageHandlerDeps) { // The bare conversation id (`19:...@thread.tacv2`) is insufficient on its // own because channel Graph endpoints require the owning team id too. const nativeChannelId = - isChannel && teamAadGroupId ? `${teamAadGroupId}/${conversationId}` : undefined; + isChannel && teamAadGroupId ? `${teamAadGroupId}/${graphChannelId}` : undefined; const ctxPayload = buildChannelInboundEventContext({ channel: "msteams", finalize: core.channel.reply.finalizeInboundContext, diff --git a/extensions/msteams/src/monitor-handler/reaction-handler.ts b/extensions/msteams/src/monitor-handler/reaction-handler.ts index 328c1815caa6..3f39f084e75a 100644 --- a/extensions/msteams/src/monitor-handler/reaction-handler.ts +++ b/extensions/msteams/src/monitor-handler/reaction-handler.ts @@ -1,28 +1,11 @@ // Msteams plugin module implements reaction handler behavior. import { normalizeMSTeamsConversationId } from "../inbound.js"; import type { MSTeamsMessageHandlerDeps } from "../monitor-handler.types.js"; +import { resolveMSTeamsReactionEmoji } from "../reaction-types.js"; import { getMSTeamsRuntime } from "../runtime.js"; import type { MSTeamsTurnContext } from "../sdk-types.js"; import { resolveMSTeamsSenderAccess } from "./access.js"; -/** Teams reaction type names → Unicode emoji. */ -const TEAMS_REACTION_EMOJI: Record = { - like: "👍", - heart: "❤️", - laugh: "😆", - surprised: "😮", - sad: "😢", - angry: "😡", -}; - -/** - * Map a Teams reaction type string to a Unicode emoji. - * Falls back to the raw type if not recognized. - */ -function mapReactionEmoji(reactionType: string): string { - return TEAMS_REACTION_EMOJI[reactionType] ?? reactionType; -} - type ReactionDirection = "added" | "removed"; /** @@ -100,7 +83,7 @@ export function createMSTeamsReactionHandler(deps: MSTeamsMessageHandlerDeps) { for (const reaction of reactions) { const reactionType = reaction.type ?? "unknown"; - const emoji = mapReactionEmoji(reactionType); + const emoji = resolveMSTeamsReactionEmoji(reactionType); const label = direction === "added" ? `Teams reaction ${emoji} added by ${senderName} on message ${targetMessageId}` diff --git a/extensions/msteams/src/monitor.lifecycle.test.ts b/extensions/msteams/src/monitor.lifecycle.test.ts index 3da0caacb7a0..4c790ed7021d 100644 --- a/extensions/msteams/src/monitor.lifecycle.test.ts +++ b/extensions/msteams/src/monitor.lifecycle.test.ts @@ -14,23 +14,21 @@ type FakeServer = EventEmitter & { headersTimeout: number; }; -type MSTeamsChannelResolution = { - input: string; - resolved: boolean; - teamId?: string; - channelId?: string; -}; - type MSTeamsUserResolution = { input: string; resolved: boolean; id?: string; }; -type ResolveMSTeamsChannelAllowlistMock = (params: { +type ResolveMSTeamsTeamsConfigMock = (params: { cfg: unknown; - entries: string[]; -}) => Promise; + teamIdMode: "bot-framework" | "graph"; + teams: Record; +}) => Promise<{ + teams: Record; + mapping: string[]; + unresolved: string[]; +}>; type ResolveMSTeamsUserAllowlistMock = (params: { cfg: unknown; @@ -171,12 +169,17 @@ vi.mock("./file-consent-invoke.js", () => ({ })); const resolveAllowlistMocks = vi.hoisted(() => ({ - resolveMSTeamsChannelAllowlist: vi.fn(async () => []), + resolveMSTeamsTeamsConfig: vi.fn(async ({ teams }) => ({ + teams, + mapping: [], + unresolved: [], + })), resolveMSTeamsUserAllowlist: vi.fn(async () => []), })); -vi.mock("./resolve-allowlist.js", () => ({ - resolveMSTeamsChannelAllowlist: resolveAllowlistMocks.resolveMSTeamsChannelAllowlist, +vi.mock("./resolve-allowlist.js", async (importOriginal) => ({ + ...(await importOriginal()), + resolveMSTeamsTeamsConfig: resolveAllowlistMocks.resolveMSTeamsTeamsConfig, resolveMSTeamsUserAllowlist: resolveAllowlistMocks.resolveMSTeamsUserAllowlist, })); @@ -281,7 +284,9 @@ describe("monitorMSTeamsProvider lifecycle", () => { expressControl.mode.value = "listening"; expressControl.apps.length = 0; isDangerousNameMatchingEnabled.mockReset().mockReturnValue(false); - resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockReset().mockResolvedValue([]); + resolveAllowlistMocks.resolveMSTeamsTeamsConfig + .mockReset() + .mockImplementation(async ({ teams }) => ({ teams, mapping: [], unresolved: [] })); resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockReset().mockResolvedValue([]); isSigninInvokeAuthorized.mockReset().mockResolvedValue(true); isCardActionInvokeAuthorized.mockReset().mockResolvedValue(true); @@ -930,14 +935,17 @@ describe("monitorMSTeamsProvider lifecycle", () => { }, }, }); - resolveAllowlistMocks.resolveMSTeamsChannelAllowlist.mockResolvedValueOnce([ - { - input: "Product/Roadmap", - resolved: true, - teamId: "team-id", - channelId: "channel-id", + resolveAllowlistMocks.resolveMSTeamsTeamsConfig.mockResolvedValueOnce({ + teams: { + "team-id": { + channels: { + "channel-id": {}, + }, + }, }, - ]); + mapping: ["Product/Roadmap→team-id/channel-id"], + unresolved: [], + }); const task = monitorMSTeamsProvider({ cfg, @@ -952,22 +960,32 @@ describe("monitorMSTeamsProvider lifecycle", () => { }); expect(resolveAllowlistMocks.resolveMSTeamsUserAllowlist).not.toHaveBeenCalled(); - expect(resolveAllowlistMocks.resolveMSTeamsChannelAllowlist).toHaveBeenCalledWith({ + expect(resolveAllowlistMocks.resolveMSTeamsTeamsConfig).toHaveBeenCalledWith({ cfg, - entries: ["Product/Roadmap"], + teamIdMode: "bot-framework", + teams: { + Product: { + channels: { + Roadmap: {}, + }, + }, + }, }); const registeredCfg = requireRegisteredMSTeamsConfig(); expect(registeredCfg.channels?.msteams?.allowFrom).toEqual([ - "Alice", - "user:40a1a0ed-4ff2-4164-a219-55518990c197", "40a1a0ed-4ff2-4164-a219-55518990c197", ]); expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual([ - "Bob", - "msteams:user:50a1a0ed-4ff2-4164-a219-55518990c198", "50a1a0ed-4ff2-4164-a219-55518990c198", ]); + expect(registeredCfg.channels?.msteams?.teams).toEqual({ + "team-id": { + channels: { + "channel-id": {}, + }, + }, + }); abort.abort(); await task; @@ -1009,8 +1027,64 @@ describe("monitorMSTeamsProvider lifecycle", () => { }); const registeredCfg = requireRegisteredMSTeamsConfig(); - expect(registeredCfg.channels?.msteams?.allowFrom).toEqual(["Alice", "alice-aad"]); - expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual(["Bob", "bob-aad"]); + expect(registeredCfg.channels?.msteams?.allowFrom).toEqual(["alice-aad"]); + expect(registeredCfg.channels?.msteams?.groupAllowFrom).toEqual(["bob-aad"]); + + abort.abort(); + await task; + }); + + it("keeps only stable allowlist entries when Graph resolution fails", async () => { + isDangerousNameMatchingEnabled.mockReturnValue(true); + resolveAllowlistMocks.resolveMSTeamsUserAllowlist.mockRejectedValueOnce( + new Error("Graph unavailable"), + ); + const runtime = createRuntime(); + const abort = new AbortController(); + const cfg = createConfig(0); + updateMSTeamsConfig(cfg, { + dangerouslyAllowNameMatching: true, + allowFrom: ["Alice", "accessGroup:operators", "user:40a1a0ed-4ff2-4164-a219-55518990c197"], + teams: { + Mutable: { + channels: { + Roadmap: {}, + }, + }, + "19:stable-team@thread.tacv2": { + channels: { + "19:stable-channel@thread.tacv2": {}, + }, + }, + }, + }); + + const task = monitorMSTeamsProvider({ + cfg, + runtime, + abortSignal: abort.signal, + conversationStore: createStores().conversationStore, + pollStore: createStores().pollStore, + }); + + await vi.waitFor(() => { + expect(registerMSTeamsHandlers).toHaveBeenCalled(); + }); + + expect(requireRegisteredMSTeamsConfig().channels?.msteams?.allowFrom).toEqual([ + "accessGroup:operators", + "40a1a0ed-4ff2-4164-a219-55518990c197", + ]); + expect(requireRegisteredMSTeamsConfig().channels?.msteams?.teams).toEqual({ + "19:stable-team@thread.tacv2": { + channels: { + "19:stable-channel@thread.tacv2": {}, + }, + }, + }); + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("mutable allowlist entries are disabled"), + ); abort.abort(); await task; diff --git a/extensions/msteams/src/monitor.ts b/extensions/msteams/src/monitor.ts index 85f1f5a3668a..1eab35667624 100644 --- a/extensions/msteams/src/monitor.ts +++ b/extensions/msteams/src/monitor.ts @@ -29,7 +29,9 @@ import { type MSTeamsPollStore, } from "./polls.js"; import { - resolveMSTeamsChannelAllowlist, + projectStableMSTeamsUserAllowlist, + projectStableMSTeamsTeamsConfig, + resolveMSTeamsTeamsConfig, resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; @@ -86,9 +88,11 @@ export async function monitorMSTeamsProvider( }, }; - let allowFrom = msteamsCfg.allowFrom; - let groupAllowFrom = msteamsCfg.groupAllowFrom; - let teamsConfig = msteamsCfg.teams; + const configuredAllowFrom = msteamsCfg.allowFrom; + const configuredGroupAllowFrom = msteamsCfg.groupAllowFrom; + let allowFrom = projectStableMSTeamsUserAllowlist(configuredAllowFrom); + let groupAllowFrom = projectStableMSTeamsUserAllowlist(configuredGroupAllowFrom); + let teamsConfig = projectStableMSTeamsTeamsConfig(msteamsCfg.teams); const allowNameMatching = isDangerousNameMatchingEnabled(msteamsCfg); const cleanAllowEntry = (entry: string) => @@ -99,10 +103,8 @@ export async function monitorMSTeamsProvider( const isStableUserId = (entry: string) => /^[0-9a-fA-F-]{16,}$/.test(entry); const cleanAllowEntries = (entries?: string[]) => entries?.map((entry) => cleanAllowEntry(entry)).filter((entry) => entry && entry !== "*") ?? []; - const mergeStableUserIds = (entries?: string[]) => { - const additions = cleanAllowEntries(entries).filter((entry) => isStableUserId(entry)); - return additions.length > 0 ? mergeAllowlist({ existing: entries, additions }) : entries; - }; + const isMutableUserEntry = (entry: string) => + !isStableUserId(entry) && !/^accessGroup:/i.test(entry); const resolveAllowlistUsers = async (label: string, entries: string[]) => { if (entries.length === 0) { @@ -126,22 +128,15 @@ export async function monitorMSTeamsProvider( }; try { - allowFrom = mergeStableUserIds(allowFrom); - if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { - groupAllowFrom = mergeStableUserIds(groupAllowFrom); - } - if (allowNameMatching) { - const allowEntries = cleanAllowEntries(allowFrom).filter((entry) => !isStableUserId(entry)); + const allowEntries = cleanAllowEntries(configuredAllowFrom).filter(isMutableUserEntry); if (allowEntries.length > 0) { const { additions } = await resolveAllowlistUsers("msteams users", allowEntries); allowFrom = mergeAllowlist({ existing: allowFrom, additions }); } - if (Array.isArray(groupAllowFrom) && groupAllowFrom.length > 0) { - const groupEntries = cleanAllowEntries(groupAllowFrom).filter( - (entry) => !isStableUserId(entry), - ); + if (Array.isArray(configuredGroupAllowFrom) && configuredGroupAllowFrom.length > 0) { + const groupEntries = cleanAllowEntries(configuredGroupAllowFrom).filter(isMutableUserEntry); if (groupEntries.length > 0) { const { additions } = await resolveAllowlistUsers("msteams group users", groupEntries); groupAllowFrom = mergeAllowlist({ existing: groupAllowFrom, additions }); @@ -149,85 +144,20 @@ export async function monitorMSTeamsProvider( } } - if (teamsConfig && Object.keys(teamsConfig).length > 0) { - const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = []; - for (const [teamKey, teamCfg] of Object.entries(teamsConfig)) { - if (teamKey === "*") { - continue; - } - const channels = teamCfg?.channels ?? {}; - const channelKeys = Object.keys(channels).filter((key) => key !== "*"); - if (channelKeys.length === 0) { - entries.push({ input: teamKey, teamKey }); - continue; - } - for (const channelKey of channelKeys) { - entries.push({ - input: `${teamKey}/${channelKey}`, - teamKey, - channelKey, - }); - } - } - - if (entries.length > 0) { - const resolved = await resolveMSTeamsChannelAllowlist({ - cfg, - entries: entries.map((entry) => entry.input), - }); - const mapping: string[] = []; - const unresolved: string[] = []; - const nextTeams = { ...teamsConfig }; - - resolved.forEach((entry, idx) => { - const source = entries[idx]; - if (!source) { - return; - } - const sourceTeam = teamsConfig?.[source.teamKey] ?? {}; - if (!entry.resolved || !entry.teamId) { - unresolved.push(entry.input); - return; - } - mapping.push( - entry.channelId - ? `${entry.input}→${entry.teamId}/${entry.channelId}` - : `${entry.input}→${entry.teamId}`, - ); - const existing = nextTeams[entry.teamId] ?? {}; - const mergedChannels = { - ...sourceTeam.channels, - ...existing.channels, - }; - const mergedTeam = { ...sourceTeam, ...existing, channels: mergedChannels }; - nextTeams[entry.teamId] = mergedTeam; - if (source.channelKey && entry.channelId) { - const sourceChannel = sourceTeam.channels?.[source.channelKey]; - if (sourceChannel) { - nextTeams[entry.teamId] = { - ...mergedTeam, - channels: { - ...mergedChannels, - [entry.channelId]: { - ...sourceChannel, - ...mergedChannels?.[entry.channelId], - }, - }, - }; - } - } - }); - - teamsConfig = nextTeams; - summarizeMapping("msteams channels", mapping, unresolved, runtime); - } + if (msteamsCfg.teams && Object.keys(msteamsCfg.teams).length > 0) { + const resolved = await resolveMSTeamsTeamsConfig({ + cfg, + teamIdMode: "bot-framework", + teams: msteamsCfg.teams, + }); + teamsConfig = resolved.teams; + summarizeMapping("msteams channels", resolved.mapping, resolved.unresolved, runtime); } } catch (err) { - // Allowlist Graph resolution is security-sensitive — surface failures at - // error level so operators notice the degraded state where Graph-resolved - // IDs are missing (#77674). + // Graph-resolved aliases are authorization inputs. Keep only the stable + // projection when resolution fails so mutable names never become active. runtime.error?.( - `msteams resolve failed; falling back to raw config entries — allowlist members resolved via Graph may be missing. ${formatUnknownError(err)}`, + `msteams resolve failed; mutable allowlist entries are disabled. ${formatUnknownError(err)}`, ); } diff --git a/extensions/msteams/src/oauth.test.ts b/extensions/msteams/src/oauth.test.ts index 39135a87187e..7247b368e8b8 100644 --- a/extensions/msteams/src/oauth.test.ts +++ b/extensions/msteams/src/oauth.test.ts @@ -2,20 +2,26 @@ import { createHash } from "node:crypto"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +const fetchWithSsrFGuardMock = vi.hoisted(() => + vi.fn( + async (params: { + url: string; + init?: RequestInit; + fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; + }) => { + const fetchImpl = params.fetchImpl ?? globalThis.fetch; + const response = await fetchImpl(params.url, params.init); + return { + response, + finalUrl: params.url, + release: async () => {}, + }; + }, + ), +); + vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({ - fetchWithSsrFGuard: async (params: { - url: string; - init?: RequestInit; - fetchImpl?: (input: RequestInfo | URL, init?: RequestInit) => Promise; - }) => { - const fetchImpl = params.fetchImpl ?? globalThis.fetch; - const response = await fetchImpl(params.url, params.init); - return { - response, - finalUrl: params.url, - release: async () => {}, - }; - }, + fetchWithSsrFGuard: fetchWithSsrFGuardMock, })); import { @@ -209,6 +215,7 @@ describe("exchangeMSTeamsCodeForTokens", () => { expect(body.get("code")).toBe("auth-code"); expect(body.get("code_verifier")).toBe("pkce-verifier"); expect(body.get("redirect_uri")).toBe(MSTEAMS_OAUTH_REDIRECT_URI); + expect(fetchWithSsrFGuardMock.mock.calls[0]?.[0]).not.toHaveProperty("fetchImpl"); }); it("throws on a 400 error response", async () => { diff --git a/extensions/msteams/src/oauth.token.ts b/extensions/msteams/src/oauth.token.ts index ca5d38759b84..3763f592fba0 100644 --- a/extensions/msteams/src/oauth.token.ts +++ b/extensions/msteams/src/oauth.token.ts @@ -75,10 +75,8 @@ async function fetchMSTeamsTokens(params: { auditContext: string; failureLabel: string; }): Promise { - const currentFetch = globalThis.fetch; const { response, release } = await fetchWithSsrFGuard({ url: params.tokenUrl, - fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), init: { method: "POST", headers: { diff --git a/extensions/msteams/src/policy.test.ts b/extensions/msteams/src/policy.test.ts index fb65fb3dd3fc..18853d02765c 100644 --- a/extensions/msteams/src/policy.test.ts +++ b/extensions/msteams/src/policy.test.ts @@ -1,7 +1,11 @@ // Msteams tests cover policy plugin behavior. import { describe, expect, it } from "vitest"; import type { MSTeamsConfig } from "../runtime-api.js"; -import { resolveMSTeamsReplyPolicy, resolveMSTeamsRouteConfig } from "./policy.js"; +import { + resolveMSTeamsGroupToolPolicy, + resolveMSTeamsReplyPolicy, + resolveMSTeamsRouteConfig, +} from "./policy.js"; function resolveNamedTeamRouteConfig(allowNameMatching = false) { const cfg: MSTeamsConfig = { @@ -154,4 +158,44 @@ describe("msteams policy", () => { expect(policy).toEqual({ requireMention: false, replyStyle: "thread" }); }); }); + + describe("resolveMSTeamsGroupToolPolicy", () => { + it("uses stable projected keys and never raw mutable names", () => { + const cfg = { + channels: { + msteams: { + dangerouslyAllowNameMatching: true, + teams: { + "Mutable Team": { + channels: { + "Mutable Channel": { tools: { allow: ["exec"] } }, + }, + }, + "19:stable-team@thread.tacv2": { + channels: { + "19:stable-channel@thread.tacv2": { tools: { allow: ["read"] } }, + }, + }, + }, + }, + }, + }; + + expect( + resolveMSTeamsGroupToolPolicy({ + cfg, + groupId: "19:unknown@thread.tacv2", + groupChannel: "Mutable Channel", + groupSpace: "Mutable Team", + }), + ).toBeUndefined(); + expect( + resolveMSTeamsGroupToolPolicy({ + cfg, + groupId: "19:stable-channel@thread.tacv2", + groupSpace: "19:stable-team@thread.tacv2", + }), + ).toEqual({ allow: ["read"] }); + }); + }); }); diff --git a/extensions/msteams/src/policy.ts b/extensions/msteams/src/policy.ts index 18736d7857f2..7554ba9f27f0 100644 --- a/extensions/msteams/src/policy.ts +++ b/extensions/msteams/src/policy.ts @@ -15,7 +15,6 @@ import { resolveToolsBySender, resolveChannelEntryMatchWithFallback, resolveNestedAllowlistDecision, - isDangerousNameMatchingEnabled, } from "../runtime-api.js"; type MSTeamsResolvedRouteConfig = { @@ -100,17 +99,12 @@ export function resolveMSTeamsGroupToolPolicy( return undefined; } const groupId = params.groupId?.trim(); - const groupChannel = params.groupChannel?.trim(); const groupSpace = params.groupSpace?.trim(); - const allowNameMatching = isDangerousNameMatchingEnabled(cfg); const resolved = resolveMSTeamsRouteConfig({ cfg, teamId: groupSpace, - teamName: groupSpace, conversationId: groupId, - channelName: groupChannel, - allowNameMatching, }); if (resolved.channelConfig) { @@ -159,11 +153,7 @@ export function resolveMSTeamsGroupToolPolicy( return undefined; } - const channelCandidates = buildChannelKeyCandidates( - groupId, - allowNameMatching ? groupChannel : undefined, - allowNameMatching && groupChannel ? normalizeChannelSlug(groupChannel) : undefined, - ); + const channelCandidates = buildChannelKeyCandidates(groupId, undefined, undefined); for (const teamConfig of Object.values(cfg.teams ?? {})) { const match = resolveChannelEntryMatchWithFallback({ entries: teamConfig?.channels ?? {}, diff --git a/extensions/msteams/src/reaction-types.ts b/extensions/msteams/src/reaction-types.ts new file mode 100644 index 000000000000..b6604f699814 --- /dev/null +++ b/extensions/msteams/src/reaction-types.ts @@ -0,0 +1,23 @@ +// Msteams plugin module implements reaction type normalization. +const TEAMS_REACTION_EMOJI: Record = { + like: "\u{1F44D}", + heart: "\u2764\uFE0F", + laugh: "\u{1F606}", + surprised: "\u{1F62E}", + sad: "\u{1F622}", + angry: "\u{1F621}", +}; + +export const TEAMS_REACTION_TYPES = Object.keys(TEAMS_REACTION_EMOJI); + +export function getMSTeamsReactionEmoji(raw: string): string | undefined { + return TEAMS_REACTION_EMOJI[raw.trim().toLowerCase()]; +} + +export function resolveMSTeamsReactionEmoji(raw: string): string { + const normalized = raw.trim(); + if (!normalized) { + throw new Error(`Reaction type is required. Common types: ${TEAMS_REACTION_TYPES.join(", ")}`); + } + return getMSTeamsReactionEmoji(normalized) ?? normalized; +} diff --git a/extensions/msteams/src/read-policy.test.ts b/extensions/msteams/src/read-policy.test.ts new file mode 100644 index 000000000000..cb62203d44ec --- /dev/null +++ b/extensions/msteams/src/read-policy.test.ts @@ -0,0 +1,473 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../runtime-api.js"; + +const mocks = vi.hoisted(() => ({ + listChannelsForTeamWithPageInfo: vi.fn(), + resolveGraphToken: vi.fn(), + resolveMSTeamsChannelAllowlist: vi.fn(), + resolveMSTeamsTeamsConfig: vi.fn(), + resolveMSTeamsUserAllowlist: vi.fn(), +})); + +vi.mock("./graph.js", () => ({ + listChannelsForTeamWithPageInfo: mocks.listChannelsForTeamWithPageInfo, + resolveGraphToken: mocks.resolveGraphToken, +})); + +vi.mock("./resolve-allowlist.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveMSTeamsChannelAllowlist: mocks.resolveMSTeamsChannelAllowlist, + resolveMSTeamsTeamsConfig: mocks.resolveMSTeamsTeamsConfig, + resolveMSTeamsUserAllowlist: mocks.resolveMSTeamsUserAllowlist, + }; +}); + +import { + assertMSTeamsReadTargetAllowed, + assertMSTeamsTeamEnumerationAllowed, +} from "./read-policy.js"; + +const ctx = { + accountId: "default", + requesterAccountId: "default", + toolContext: {}, +}; + +beforeEach(() => { + mocks.listChannelsForTeamWithPageInfo.mockReset(); + mocks.resolveGraphToken.mockReset(); + mocks.resolveMSTeamsChannelAllowlist.mockReset(); + mocks.resolveMSTeamsTeamsConfig.mockReset(); + mocks.resolveMSTeamsUserAllowlist.mockReset(); + mocks.resolveGraphToken.mockResolvedValue("token"); +}); + +describe("Microsoft Teams read policy", () => { + it("uses startup-equivalent resolved channel policy for stable action targets", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + Product: { + channels: { + Roadmap: { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.resolveMSTeamsTeamsConfig.mockResolvedValue({ + teams: { + Product: { + channels: { + Roadmap: { requireMention: true }, + }, + }, + "11111111-1111-1111-1111-111111111111": { + channels: { + "19:roadmap@thread.tacv2": { requireMention: true }, + }, + }, + }, + mapping: [], + unresolved: [], + }); + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2"); + expect(mocks.resolveMSTeamsTeamsConfig).toHaveBeenCalledWith({ + cfg, + teamIdMode: "graph", + teams: cfg.channels?.msteams?.teams, + }); + }); + + it("maps stable Bot Framework team keys to Graph channel targets", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "19:general@thread.tacv2": { + channels: { + "19:roadmap@thread.tacv2": { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.listChannelsForTeamWithPageInfo.mockResolvedValue({ + items: [ + { id: "19:general@thread.tacv2", displayName: "Allgemein" }, + { id: "19:roadmap@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2"); + expect(mocks.listChannelsForTeamWithPageInfo).toHaveBeenCalledWith( + "token", + "11111111-1111-1111-1111-111111111111", + ); + }); + + it("rejects an ambiguous Bot Framework team mapping", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "19:general@thread.tacv2": { + channels: { + "19:roadmap@thread.tacv2": {}, + }, + }, + "19:other@thread.tacv2": { + channels: { + "19:roadmap@thread.tacv2": {}, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.listChannelsForTeamWithPageInfo.mockResolvedValue({ + items: [ + { id: "19:general@thread.tacv2", displayName: "General" }, + { id: "19:other@thread.tacv2", displayName: "Other" }, + { id: "19:roadmap@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + }); + + it("rejects an incomplete Bot Framework team mapping", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "19:general@thread.tacv2": { + channels: { + "19:roadmap@thread.tacv2": {}, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.listChannelsForTeamWithPageInfo.mockResolvedValue({ + items: [{ id: "19:general@thread.tacv2", displayName: "General" }], + truncated: true, + }); + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + }); + + it("resolves mutable DM identities only when explicitly enabled", async () => { + const cfg = { + channels: { + msteams: { + dmPolicy: "allowlist", + allowFrom: ["Alice"], + dangerouslyAllowNameMatching: true, + }, + }, + } as OpenClawConfig; + mocks.resolveMSTeamsUserAllowlist.mockResolvedValue([ + { + input: "alice@example.com", + resolved: true, + id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + }, + { input: "alice", resolved: true, id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa" }, + ]); + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "user:alice@example.com", + }), + ).resolves.toBe("user:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + }); + + it.each([ + "19:abc@thread.tacv2", + "19:abc@thread.skype", + "19:user_app@unq.gbl.spaces", + "a:1abc123", + "8:orgid:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + ])( + "allows a supported bare conversation target when both scopes are open (%s)", + async (target) => { + const cfg = { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "open", + }, + }, + } as OpenClawConfig; + + await expect(assertMSTeamsReadTargetAllowed({ cfg, ctx, target })).resolves.toBe(target); + }, + ); + + it("does not classify a bare Bot Framework user id as a conversation", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "open", + }, + }, + } as OpenClawConfig; + + await expect( + assertMSTeamsReadTargetAllowed({ cfg, ctx, target: "29:user-id" }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + }); + + it("rejects mutable DM identities when name matching is disabled", async () => { + const cfg = { + channels: { + msteams: { + dmPolicy: "allowlist", + allowFrom: ["alice@example.com"], + }, + }, + } as OpenClawConfig; + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "user:alice@example.com", + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + expect(mocks.resolveMSTeamsUserAllowlist).not.toHaveBeenCalled(); + }); + + it("resolves mutable channel targets only when explicitly enabled", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + dangerouslyAllowNameMatching: true, + teams: { + Product: { + channels: { + Roadmap: { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.resolveMSTeamsChannelAllowlist.mockResolvedValue([ + { + input: "Product/Roadmap", + resolved: true, + teamId: "19:general@thread.tacv2", + graphTeamId: "11111111-1111-1111-1111-111111111111", + channelId: "19:roadmap@thread.tacv2", + }, + ]); + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx, + target: "Product/Roadmap", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2"); + }); + + it("requires team-wide access before channel enumeration", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + Product: { + channels: { + "*": { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.resolveMSTeamsTeamsConfig.mockResolvedValue({ + teams: { + "11111111-1111-1111-1111-111111111111": { + channels: { + "*": { requireMention: true }, + }, + }, + }, + mapping: [], + unresolved: [], + }); + + await expect( + assertMSTeamsTeamEnumerationAllowed({ + cfg, + teamId: "11111111-1111-1111-1111-111111111111", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111"); + expect(mocks.resolveMSTeamsTeamsConfig).toHaveBeenCalledWith({ + cfg, + teamIdMode: "graph", + teams: cfg.channels?.msteams?.teams, + }); + }); + + it("maps stable Bot Framework team keys before channel enumeration", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + teams: { + "19:general@thread.tacv2": { + channels: { + "*": { requireMention: true }, + }, + }, + }, + }, + }, + } as OpenClawConfig; + mocks.listChannelsForTeamWithPageInfo.mockResolvedValue({ + items: [ + { id: "19:general@thread.tacv2", displayName: "General" }, + { id: "19:roadmap@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); + + await expect( + assertMSTeamsTeamEnumerationAllowed({ + cfg, + teamId: "11111111-1111-1111-1111-111111111111", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111"); + }); + + it("lets a direct operator read stable unconfigured channel and DM targets", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + dmPolicy: "pairing", + }, + }, + } as OpenClawConfig; + const directCtx = { + ...ctx, + conversationReadOrigin: "direct-operator" as const, + }; + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx: directCtx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2"); + await expect( + assertMSTeamsReadTargetAllowed({ + cfg, + ctx: directCtx, + target: "user:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + }), + ).resolves.toBe("user:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"); + }); + + it("keeps disabled Teams scopes blocked for direct operators", async () => { + const directCtx = { + ...ctx, + conversationReadOrigin: "direct-operator" as const, + }; + + await expect( + assertMSTeamsReadTargetAllowed({ + cfg: { + channels: { + msteams: { + groupPolicy: "disabled", + dmPolicy: "open", + }, + }, + } as OpenClawConfig, + ctx: directCtx, + target: "11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + await expect( + assertMSTeamsReadTargetAllowed({ + cfg: { + channels: { + msteams: { + groupPolicy: "open", + dmPolicy: "disabled", + }, + }, + } as OpenClawConfig, + ctx: directCtx, + target: "user:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + }), + ).rejects.toThrow("Microsoft Teams read target is not allowed."); + }); + + it("lets a direct operator enumerate an unconfigured stable team", async () => { + const cfg = { + channels: { + msteams: { + groupPolicy: "allowlist", + }, + }, + } as OpenClawConfig; + + await expect( + assertMSTeamsTeamEnumerationAllowed({ + cfg, + ctx: { + ...ctx, + conversationReadOrigin: "direct-operator", + }, + teamId: "11111111-1111-1111-1111-111111111111", + }), + ).resolves.toBe("11111111-1111-1111-1111-111111111111"); + }); +}); diff --git a/extensions/msteams/src/read-policy.ts b/extensions/msteams/src/read-policy.ts new file mode 100644 index 000000000000..3ea4ca1e2deb --- /dev/null +++ b/extensions/msteams/src/read-policy.ts @@ -0,0 +1,416 @@ +import { ToolAuthorizationError } from "openclaw/plugin-sdk/channel-actions"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; +import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { OpenClawConfig } from "../runtime-api.js"; +import { isDangerousNameMatchingEnabled, resolveDefaultGroupPolicy } from "../runtime-api.js"; +import { listChannelsForTeamWithPageInfo, resolveGraphToken } from "./graph.js"; +import { resolveMSTeamsRouteConfig } from "./policy.js"; +import { + normalizeMSTeamsMessagingTarget, + looksLikeMSTeamsConversationId, + resolveMSTeamsChannelAllowlist, + resolveMSTeamsTeamsConfig, + resolveMSTeamsUserAllowlist, +} from "./resolve-allowlist.js"; + +type MSTeamsReadContext = Pick< + ChannelMessageActionContext, + "accountId" | "conversationReadOrigin" | "requesterAccountId" | "toolContext" +>; + +function normalizeTarget(raw?: string | null): string { + return raw ? (normalizeMSTeamsMessagingTarget(raw) ?? "") : ""; +} + +function sameAccount(ctx: MSTeamsReadContext): boolean { + const requested = normalizeOptionalString(ctx.accountId) ?? "default"; + const requester = normalizeOptionalString(ctx.requesterAccountId); + return requester !== undefined && requester === requested; +} + +export function isCurrentMSTeamsReadTarget(params: { + ctx: MSTeamsReadContext; + target: string; +}): boolean { + if ( + normalizeOptionalString(params.ctx.toolContext?.currentChannelProvider)?.toLowerCase() !== + "msteams" || + !sameAccount(params.ctx) + ) { + return false; + } + const candidates = [ + params.ctx.toolContext?.currentChannelId, + params.ctx.toolContext?.currentMessagingTarget, + params.ctx.toolContext?.currentGraphChannelId, + ]; + const target = normalizeTarget(params.target); + return candidates.some((candidate) => normalizeTarget(candidate) === target); +} + +function normalizeUserTarget(target: string): string { + return target + .replace(/^user:/i, "") + .trim() + .toLowerCase(); +} + +function isStableUserId(value: string): boolean { + return /^[0-9a-f-]{16,}$/i.test(value); +} + +async function resolveAllowedDmTarget( + cfg: OpenClawConfig, + target: string, +): Promise { + const teams = cfg.channels?.msteams; + if (teams?.dmPolicy === "disabled") { + return undefined; + } + const userId = normalizeUserTarget(target); + if (!userId) { + return undefined; + } + const allowFrom = teams?.allowFrom ?? []; + const normalizedEntries = allowFrom.map((entry) => + normalizeUserTarget(entry.replace(/^(msteams|teams):/i, "")), + ); + const allowAll = (teams?.dmPolicy ?? "pairing") === "open" || normalizedEntries.includes("*"); + if (isStableUserId(userId)) { + return allowAll || normalizedEntries.some((entry) => entry === userId) + ? `user:${userId}` + : undefined; + } + if (!isDangerousNameMatchingEnabled(teams)) { + return undefined; + } + try { + const [resolvedTarget, ...resolvedEntries] = await resolveMSTeamsUserAllowlist({ + cfg, + entries: [userId, ...normalizedEntries.filter((entry) => entry !== "*")], + }); + if (!resolvedTarget?.resolved || !resolvedTarget.id) { + return undefined; + } + const allowed = + allowAll || + resolvedEntries.some( + (entry) => entry.resolved && entry.id?.toLowerCase() === resolvedTarget.id?.toLowerCase(), + ); + return allowed ? `user:${resolvedTarget.id}` : undefined; + } catch { + return undefined; + } +} + +async function resolveDirectDmTarget( + cfg: OpenClawConfig, + target: string, +): Promise { + if (cfg.channels?.msteams?.dmPolicy === "disabled") { + return undefined; + } + const userId = normalizeUserTarget(target); + if (!userId) { + return undefined; + } + if (isStableUserId(userId)) { + return `user:${userId}`; + } + if (!isDangerousNameMatchingEnabled(cfg.channels?.msteams)) { + return undefined; + } + try { + const [resolved] = await resolveMSTeamsUserAllowlist({ cfg, entries: [userId] }); + return resolved?.resolved && resolved.id ? `user:${resolved.id}` : undefined; + } catch { + return undefined; + } +} + +function resolveMSTeamsReadGroupPolicy(cfg: OpenClawConfig) { + const teams = cfg.channels?.msteams; + return teams ? (teams.groupPolicy ?? resolveDefaultGroupPolicy(cfg) ?? "allowlist") : "disabled"; +} + +function isStableChannelKey(value: string): boolean { + return /^[0-9a-f-]{16,}$/i.test(value) || /^19:.+@thread\./i.test(value); +} + +function isStableGraphTeamId(value: string): boolean { + return /^[0-9a-f-]{16,}$/i.test(value); +} + +function isStableGraphChannelTarget(target: string): boolean { + const [teamId, channelId] = target.split("/", 2); + return Boolean( + teamId && channelId && isStableGraphTeamId(teamId) && isStableChannelKey(channelId), + ); +} + +function hasMutableChannelConfig(cfg: OpenClawConfig): boolean { + const teams = cfg.channels?.msteams?.teams ?? {}; + return Object.entries(teams).some(([teamKey, teamConfig]) => { + if (teamKey !== "*" && !isStableChannelKey(teamKey)) { + return true; + } + return Object.keys(teamConfig?.channels ?? {}).some( + (channelKey) => channelKey !== "*" && !isStableChannelKey(channelKey), + ); + }); +} + +async function resolveConfiguredBotFrameworkTeamKey( + cfg: OpenClawConfig, + graphTeamId: string, +): Promise { + const configuredTeams = cfg.channels?.msteams?.teams; + if (!configuredTeams) { + return undefined; + } + const stableConfiguredKeys = Object.keys(configuredTeams).filter( + (teamKey) => teamKey !== "*" && /^19:.+@thread\./i.test(teamKey), + ); + if (stableConfiguredKeys.length === 0) { + return undefined; + } + // Bot Framework identifies a team with a channel conversation id, while + // Graph reads use the Entra group id. Roster membership proves the mapping + // without relying on the localized General channel display name. + const token = await resolveGraphToken(cfg); + const channelResult = await listChannelsForTeamWithPageInfo(token, graphTeamId); + if (channelResult.truncated) { + return undefined; + } + const channelIds = new Set( + channelResult.items + .map((channel) => channel.id?.trim()) + .filter((channelId): channelId is string => Boolean(channelId)), + ); + const matches = stableConfiguredKeys.filter((teamKey) => channelIds.has(teamKey)); + return matches.length === 1 ? matches[0] : undefined; +} + +async function resolveStableChannelTarget( + cfg: OpenClawConfig, + target: string, +): Promise { + if (isStableGraphChannelTarget(target)) { + return target; + } + if (!isDangerousNameMatchingEnabled(cfg.channels?.msteams)) { + return undefined; + } + try { + const [resolved] = await resolveMSTeamsChannelAllowlist({ cfg, entries: [target] }); + return resolved?.resolved && resolved.graphTeamId && resolved.channelId + ? `${resolved.graphTeamId}/${resolved.channelId}` + : undefined; + } catch { + return undefined; + } +} + +async function resolveAllowedChannelTarget( + cfg: OpenClawConfig, + target: string, +): Promise { + const teams = cfg.channels?.msteams; + const groupPolicy = resolveMSTeamsReadGroupPolicy(cfg); + if (groupPolicy === "disabled") { + return undefined; + } + const [teamId, channelId] = target.split("/", 2); + if (!teamId || !channelId) { + return undefined; + } + const directRoute = resolveMSTeamsRouteConfig({ + cfg: teams, + teamId, + teamName: teamId, + conversationId: channelId, + channelName: channelId, + allowNameMatching: isDangerousNameMatchingEnabled(teams), + }); + const stableTarget = await resolveStableChannelTarget(cfg, target); + if (directRoute.allowed) { + return stableTarget; + } + if (!directRoute.allowlistConfigured) { + return groupPolicy === "open" ? stableTarget : undefined; + } + if (!stableTarget || !teams?.teams) { + return undefined; + } + const [stableTeamId, stableChannelId] = stableTarget.split("/", 2); + if (!stableTeamId || !stableChannelId) { + return undefined; + } + try { + const botFrameworkTeamKey = await resolveConfiguredBotFrameworkTeamKey(cfg, stableTeamId); + if (botFrameworkTeamKey) { + const allowed = resolveMSTeamsRouteConfig({ + cfg: teams, + teamId: botFrameworkTeamKey, + conversationId: stableChannelId, + }).allowed; + if (allowed) { + return stableTarget; + } + } + if (!hasMutableChannelConfig(cfg)) { + return undefined; + } + const resolved = await resolveMSTeamsTeamsConfig({ + cfg, + teamIdMode: "graph", + teams: teams.teams, + }); + const allowed = resolveMSTeamsRouteConfig({ + cfg: { ...teams, teams: resolved.teams }, + teamId: stableTeamId, + conversationId: stableChannelId, + }).allowed; + return allowed ? stableTarget : undefined; + } catch { + return undefined; + } +} + +function bothUnknownScopesAllowed(cfg: OpenClawConfig): boolean { + const teams = cfg.channels?.msteams; + return resolveMSTeamsReadGroupPolicy(cfg) === "open" && (teams?.dmPolicy ?? "pairing") === "open"; +} + +export async function assertMSTeamsReadTargetAllowed(params: { + cfg: OpenClawConfig; + ctx: MSTeamsReadContext; + target: string; +}): Promise { + const target = normalizeTarget(params.target); + const isChannel = target.includes("/"); + const isDm = /^user:/i.test(target); + const isChat = looksLikeMSTeamsConversationId(target); + const current = isCurrentMSTeamsReadTarget({ ctx: params.ctx, target }); + const directOperator = params.ctx.conversationReadOrigin === "direct-operator"; + const currentChatType = params.ctx.toolContext?.currentChatType; + const allowedTarget = directOperator + ? isChannel + ? resolveMSTeamsReadGroupPolicy(params.cfg) !== "disabled" + ? await resolveStableChannelTarget(params.cfg, target) + : undefined + : isDm + ? await resolveDirectDmTarget(params.cfg, target) + : isChat && + resolveMSTeamsReadGroupPolicy(params.cfg) !== "disabled" && + params.cfg.channels?.msteams?.dmPolicy !== "disabled" + ? target + : undefined + : current + ? isChannel + ? resolveMSTeamsReadGroupPolicy(params.cfg) !== "disabled" + ? target + : undefined + : isDm + ? params.cfg.channels?.msteams?.dmPolicy !== "disabled" + ? target + : undefined + : currentChatType === "direct" + ? params.cfg.channels?.msteams?.dmPolicy !== "disabled" + ? target + : undefined + : currentChatType === "group" || currentChatType === "channel" + ? resolveMSTeamsReadGroupPolicy(params.cfg) !== "disabled" + ? target + : undefined + : resolveMSTeamsReadGroupPolicy(params.cfg) !== "disabled" && + params.cfg.channels?.msteams?.dmPolicy !== "disabled" + ? target + : undefined + : isChannel + ? await resolveAllowedChannelTarget(params.cfg, target) + : isDm + ? await resolveAllowedDmTarget(params.cfg, target) + : isChat + ? bothUnknownScopesAllowed(params.cfg) + ? target + : undefined + : false; + if (!allowedTarget) { + throw new ToolAuthorizationError("Microsoft Teams read target is not allowed."); + } + return allowedTarget; +} + +export async function assertMSTeamsTeamEnumerationAllowed(params: { + cfg: OpenClawConfig; + ctx?: MSTeamsReadContext; + teamId: string; +}): Promise { + const teams = params.cfg.channels?.msteams; + const groupPolicy = resolveMSTeamsReadGroupPolicy(params.cfg); + if (groupPolicy === "disabled") { + throw new ToolAuthorizationError("Microsoft Teams channel list is not allowed."); + } + const directRoute = resolveMSTeamsRouteConfig({ + cfg: teams, + teamId: params.teamId, + teamName: params.teamId, + conversationId: "__openclaw_all_channels__", + allowNameMatching: isDangerousNameMatchingEnabled(teams), + }); + const stableTeamId = isStableGraphTeamId(params.teamId) + ? params.teamId + : isDangerousNameMatchingEnabled(teams) + ? ( + await resolveMSTeamsChannelAllowlist({ + cfg: params.cfg, + entries: [params.teamId], + }) + )[0]?.graphTeamId + : undefined; + if (!stableTeamId) { + throw new ToolAuthorizationError( + "Microsoft Teams channel list requires access to every channel in the team.", + ); + } + if (params.ctx?.conversationReadOrigin === "direct-operator") { + return stableTeamId; + } + let allowed = directRoute.allowlistConfigured ? directRoute.allowed : groupPolicy === "open"; + if (!allowed && teams?.teams) { + try { + const botFrameworkTeamKey = await resolveConfiguredBotFrameworkTeamKey( + params.cfg, + stableTeamId, + ); + if (botFrameworkTeamKey) { + allowed = resolveMSTeamsRouteConfig({ + cfg: teams, + teamId: botFrameworkTeamKey, + conversationId: "__openclaw_all_channels__", + }).allowed; + } + if (!allowed && hasMutableChannelConfig(params.cfg)) { + const resolved = await resolveMSTeamsTeamsConfig({ + cfg: params.cfg, + teamIdMode: "graph", + teams: teams.teams, + }); + allowed = resolveMSTeamsRouteConfig({ + cfg: { ...teams, teams: resolved.teams }, + teamId: stableTeamId, + conversationId: "__openclaw_all_channels__", + }).allowed; + } + } catch { + allowed = false; + } + } + if (!allowed) { + throw new ToolAuthorizationError( + "Microsoft Teams channel list requires access to every channel in the team.", + ); + } + return stableTeamId; +} diff --git a/extensions/msteams/src/resolve-allowlist.test.ts b/extensions/msteams/src/resolve-allowlist.test.ts index 221092bf74d2..df540b469521 100644 --- a/extensions/msteams/src/resolve-allowlist.test.ts +++ b/extensions/msteams/src/resolve-allowlist.test.ts @@ -2,42 +2,44 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const { - listTeamsByName, - listChannelsForTeam, + listTeamsByNameWithPageInfo, + listChannelsForTeamWithPageInfo, normalizeQuery, resolveGraphToken, - searchGraphUsers, + findGraphUsersByExactIdentity, } = vi.hoisted(() => ({ - listTeamsByName: vi.fn(), - listChannelsForTeam: vi.fn(), + listTeamsByNameWithPageInfo: vi.fn(), + listChannelsForTeamWithPageInfo: vi.fn(), normalizeQuery: vi.fn((value: string) => value.trim().toLowerCase()), resolveGraphToken: vi.fn(async () => "graph-token"), - searchGraphUsers: vi.fn(), + findGraphUsersByExactIdentity: vi.fn(), })); vi.mock("./graph.js", () => ({ - listTeamsByName, - listChannelsForTeam, + listTeamsByNameWithPageInfo, + listChannelsForTeamWithPageInfo, normalizeQuery, resolveGraphToken, })); vi.mock("./graph-users.js", () => ({ - searchGraphUsers, + findGraphUsersByExactIdentity, })); import { looksLikeMSTeamsTargetId, + projectStableMSTeamsUserAllowlist, resolveMSTeamsChannelAllowlist, + resolveMSTeamsTeamsConfig, resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; beforeEach(() => { - listTeamsByName.mockReset(); - listChannelsForTeam.mockReset(); + listTeamsByNameWithPageInfo.mockReset(); + listChannelsForTeamWithPageInfo.mockReset(); normalizeQuery.mockImplementation((value: string) => value.trim().toLowerCase()); resolveGraphToken.mockReset().mockResolvedValue("graph-token"); - searchGraphUsers.mockReset(); + findGraphUsersByExactIdentity.mockReset(); }); describe("resolveMSTeamsUserAllowlist", () => { @@ -46,20 +48,81 @@ describe("resolveMSTeamsUserAllowlist", () => { expect(result).toEqual({ input: " ", resolved: false }); }); - it("resolves first Graph user match", async () => { - searchGraphUsers.mockResolvedValueOnce([ - { id: "user-1", displayName: "Alice One" }, - { id: "user-2", displayName: "Alice Two" }, - ]); - const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["alice"] }); + it("resolves one exact Graph user identity", async () => { + findGraphUsersByExactIdentity.mockResolvedValueOnce({ + items: [ + { id: "user-1", displayName: "Alice" }, + { id: "user-2", displayName: "Alice Two" }, + ], + truncated: false, + }); + const [result] = await resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["Alice"] }); expect(result).toEqual({ - input: "alice", + input: "Alice", resolved: true, id: "user-1", - name: "Alice One", - note: "multiple matches; chose first", + name: "Alice", }); }); + + it("rejects ambiguous and incomplete Graph user identities", async () => { + findGraphUsersByExactIdentity + .mockResolvedValueOnce({ + items: [ + { id: "user-1", displayName: "Alice" }, + { id: "user-2", mail: "alice" }, + ], + truncated: false, + }) + .mockResolvedValueOnce({ + items: [{ id: "user-1", displayName: "Alice" }], + truncated: true, + }); + + await expect(resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["Alice"] })).resolves.toEqual([ + { + input: "Alice", + resolved: false, + note: "user identity is ambiguous", + }, + ]); + await expect(resolveMSTeamsUserAllowlist({ cfg: {}, entries: ["Alice"] })).resolves.toEqual([ + { + input: "Alice", + resolved: false, + note: "user lookup incomplete", + }, + ]); + }); + + it("keeps stable user IDs without acquiring a Graph token", async () => { + await expect( + resolveMSTeamsUserAllowlist({ + cfg: {}, + entries: ["user:40a1a0ed-4ff2-4164-a219-55518990c197"], + }), + ).resolves.toEqual([ + { + input: "user:40a1a0ed-4ff2-4164-a219-55518990c197", + resolved: true, + id: "40a1a0ed-4ff2-4164-a219-55518990c197", + }, + ]); + expect(resolveGraphToken).not.toHaveBeenCalled(); + }); +}); + +describe("projectStableMSTeamsUserAllowlist", () => { + it("keeps stable IDs, wildcards, and access groups while dropping mutable identities", () => { + expect( + projectStableMSTeamsUserAllowlist([ + "*", + "accessGroup:operators", + "msteams:user:40a1a0ed-4ff2-4164-a219-55518990c197", + "Alice Example", + ]), + ).toEqual(["*", "accessGroup:operators", "40a1a0ed-4ff2-4164-a219-55518990c197"]); + }); }); describe("resolveMSTeamsChannelAllowlist", () => { @@ -78,8 +141,8 @@ describe("resolveMSTeamsChannelAllowlist", () => { channelName: "19:roadmap@thread.skype", }); expect(resolveGraphToken).not.toHaveBeenCalled(); - expect(listTeamsByName).not.toHaveBeenCalled(); - expect(listChannelsForTeam).not.toHaveBeenCalled(); + expect(listTeamsByNameWithPageInfo).not.toHaveBeenCalled(); + expect(listChannelsForTeamWithPageInfo).not.toHaveBeenCalled(); }); it("normalizes conversation-prefixed configured channel IDs", async () => { @@ -102,11 +165,17 @@ describe("resolveMSTeamsChannelAllowlist", () => { it("resolves team/channel by team name + channel display name", async () => { // After the fix, listChannelsForTeam is called once and reused for both // General channel resolution and channel matching. - listTeamsByName.mockResolvedValueOnce([{ id: "team-guid-1", displayName: "Product Team" }]); - listChannelsForTeam.mockResolvedValueOnce([ - { id: "19:general-conv-id@thread.tacv2", displayName: "General" }, - { id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" }, - ]); + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "team-guid-1", displayName: "Product Team" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "19:general-conv-id@thread.tacv2", displayName: "General" }, + { id: "19:roadmap-conv-id@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); const [result] = await resolveMSTeamsChannelAllowlist({ cfg: {}, @@ -119,21 +188,27 @@ describe("resolveMSTeamsChannelAllowlist", () => { input: "Product Team/Roadmap", resolved: true, teamId: "19:general-conv-id@thread.tacv2", + graphTeamId: "team-guid-1", teamName: "Product Team", channelId: "19:roadmap-conv-id@thread.tacv2", channelName: "Roadmap", - note: "multiple channels; chose first", }); }); it("uses General channel conversation ID as team key for team-only entry", async () => { // When no channel is specified we still resolve the General channel so the // stored key matches what Bot Framework sends as channelData.team.id. - listTeamsByName.mockResolvedValueOnce([{ id: "guid-engineering", displayName: "Engineering" }]); - listChannelsForTeam.mockResolvedValueOnce([ - { id: "19:eng-general@thread.tacv2", displayName: "General" }, - { id: "19:eng-standups@thread.tacv2", displayName: "Standups" }, - ]); + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "guid-engineering", displayName: "Engineering" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "19:eng-general@thread.tacv2", displayName: "General" }, + { id: "19:eng-standups@thread.tacv2", displayName: "Standups" }, + ], + truncated: false, + }); const [result] = await resolveMSTeamsChannelAllowlist({ cfg: {}, @@ -144,16 +219,17 @@ describe("resolveMSTeamsChannelAllowlist", () => { input: "Engineering", resolved: true, teamId: "19:eng-general@thread.tacv2", + graphTeamId: "guid-engineering", teamName: "Engineering", }); }); - it("falls back to Graph GUID when listChannelsForTeam throws", async () => { - // Edge case: API call fails (rate limit, network error). We fall back to - // the Graph GUID as the team key — the pre-fix behavior — so resolution - // still succeeds instead of propagating the error. - listTeamsByName.mockResolvedValueOnce([{ id: "guid-flaky", displayName: "Flaky Team" }]); - listChannelsForTeam.mockRejectedValueOnce(new Error("429 Too Many Requests")); + it("fails closed when channel lookup fails", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "guid-flaky", displayName: "Flaky Team" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockRejectedValueOnce(new Error("429 Too Many Requests")); const [result] = await resolveMSTeamsChannelAllowlist({ cfg: {}, @@ -162,20 +238,23 @@ describe("resolveMSTeamsChannelAllowlist", () => { expect(result).toEqual({ input: "Flaky Team", - resolved: true, - teamId: "guid-flaky", - teamName: "Flaky Team", + resolved: false, + note: "channel lookup failed", }); }); - it("falls back to Graph GUID when General channel is not found", async () => { - // Edge case: General channel was renamed or deleted. We fall back to the - // Graph GUID so resolution still succeeds rather than silently breaking. - listTeamsByName.mockResolvedValueOnce([{ id: "guid-ops", displayName: "Operations" }]); - listChannelsForTeam.mockResolvedValueOnce([ - { id: "19:ops-announce@thread.tacv2", displayName: "Announcements" }, - { id: "19:ops-random@thread.tacv2", displayName: "Random" }, - ]); + it("fails closed when the Bot Framework team key cannot be identified", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "guid-ops", displayName: "Operations" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "19:ops-announce@thread.tacv2", displayName: "Announcements" }, + { id: "19:ops-random@thread.tacv2", displayName: "Random" }, + ], + truncated: false, + }); const [result] = await resolveMSTeamsChannelAllowlist({ cfg: {}, @@ -184,11 +263,191 @@ describe("resolveMSTeamsChannelAllowlist", () => { expect(result).toEqual({ input: "Operations", - resolved: true, - teamId: "guid-ops", + resolved: false, + graphTeamId: "guid-ops", teamName: "Operations", + note: "General channel not found", }); }); + + it("does not enumerate channels for a Graph-keyed team-only projection", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "guid-ops", displayName: "Operations" }], + truncated: false, + }); + + await expect( + resolveMSTeamsChannelAllowlist({ + cfg: {}, + entries: ["Operations"], + teamIdMode: "graph", + }), + ).resolves.toEqual([ + { + input: "Operations", + resolved: true, + teamId: "guid-ops", + graphTeamId: "guid-ops", + teamName: "Operations", + }, + ]); + expect(listChannelsForTeamWithPageInfo).not.toHaveBeenCalled(); + }); + + it("rejects partial, ambiguous, and incomplete team or channel matches", async () => { + listTeamsByNameWithPageInfo + .mockResolvedValueOnce({ + items: [{ id: "team-1", displayName: "Product Team Extended" }], + truncated: false, + }) + .mockResolvedValueOnce({ + items: [ + { id: "team-1", displayName: "Product Team" }, + { id: "team-2", displayName: "product team" }, + ], + truncated: false, + }) + .mockResolvedValueOnce({ + items: [{ id: "team-1", displayName: "Product Team" }], + truncated: true, + }) + .mockResolvedValueOnce({ + items: [{ id: "team-1", displayName: "Product Team" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "general", displayName: "General" }, + { id: "channel-1", displayName: "Roadmap" }, + { id: "channel-2", displayName: "roadmap" }, + ], + truncated: false, + }); + + const results = await resolveMSTeamsChannelAllowlist({ + cfg: {}, + entries: ["Product Team", "Product Team", "Product Team", "Product Team/Roadmap"], + }); + + expect(results.map((result) => result.note)).toEqual([ + "team not found", + "team name is ambiguous", + "team lookup incomplete", + "channel name is ambiguous", + ]); + expect(results.every((result) => !result.resolved)).toBe(true); + }); +}); + +describe("resolveMSTeamsTeamsConfig", () => { + it("adds resolved stable keys while preserving the configured policy", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "team-guid-1", displayName: "Product Team" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "19:general@thread.tacv2", displayName: "General" }, + { id: "19:roadmap@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); + + const result = await resolveMSTeamsTeamsConfig({ + cfg: {}, + teamIdMode: "bot-framework", + teams: { + "Product Team": { + requireMention: false, + channels: { + Roadmap: { requireMention: true }, + }, + }, + }, + }); + + expect(result.mapping).toEqual([ + "Product Team/Roadmap→19:general@thread.tacv2/19:roadmap@thread.tacv2", + ]); + expect(result.teams["19:general@thread.tacv2"]).toMatchObject({ + requireMention: false, + channels: { + "19:roadmap@thread.tacv2": { requireMention: true }, + }, + }); + expect(result.teams["Product Team"]).toBeUndefined(); + }); + + it("builds a Graph-keyed projection for action routing", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [{ id: "11111111-1111-1111-1111-111111111111", displayName: "Product Team" }], + truncated: false, + }); + listChannelsForTeamWithPageInfo.mockResolvedValueOnce({ + items: [ + { id: "19:general@thread.tacv2", displayName: "General" }, + { id: "19:roadmap@thread.tacv2", displayName: "Roadmap" }, + ], + truncated: false, + }); + + const result = await resolveMSTeamsTeamsConfig({ + cfg: {}, + teamIdMode: "graph", + teams: { + "Product Team": { + channels: { + Roadmap: { requireMention: true }, + }, + }, + }, + }); + + expect(result.mapping).toEqual([ + "Product Team/Roadmap→11111111-1111-1111-1111-111111111111/19:roadmap@thread.tacv2", + ]); + expect(result.teams["11111111-1111-1111-1111-111111111111"]).toMatchObject({ + channels: { + "19:roadmap@thread.tacv2": { requireMention: true }, + }, + }); + }); + + it("drops unresolved mutable keys while retaining wildcard and stable policy", async () => { + listTeamsByNameWithPageInfo.mockResolvedValueOnce({ + items: [], + truncated: false, + }); + + const result = await resolveMSTeamsTeamsConfig({ + cfg: {}, + teamIdMode: "bot-framework", + teams: { + "*": { + channels: { + "*": { requireMention: true }, + Mutable: { requireMention: false }, + "19:stable@thread.tacv2": { requireMention: false }, + }, + }, + Missing: { + channels: { + Roadmap: { requireMention: false }, + }, + }, + }, + }); + + expect(result.teams).toEqual({ + "*": { + channels: { + "*": { requireMention: true }, + "19:stable@thread.tacv2": { requireMention: false }, + }, + }, + }); + expect(result.unresolved).toEqual(["*/Mutable", "Missing/Roadmap"]); + }); }); describe("looksLikeMSTeamsTargetId", () => { diff --git a/extensions/msteams/src/resolve-allowlist.ts b/extensions/msteams/src/resolve-allowlist.ts index 74435f1ffdbc..1f9d27e39f97 100644 --- a/extensions/msteams/src/resolve-allowlist.ts +++ b/extensions/msteams/src/resolve-allowlist.ts @@ -1,21 +1,23 @@ // Msteams plugin module implements resolve allowlist behavior. import { mapAllowlistResolutionInputs } from "openclaw/plugin-sdk/allow-from"; +import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; +import type { MSTeamsConfig } from "../runtime-api.js"; +import { findGraphUsersByExactIdentity } from "./graph-users.js"; import { - normalizeLowercaseStringOrEmpty, - normalizeOptionalLowercaseString, -} from "openclaw/plugin-sdk/string-coerce-runtime"; -import { searchGraphUsers } from "./graph-users.js"; -import { - listChannelsForTeam, - listTeamsByName, + listChannelsForTeamWithPageInfo, + listTeamsByNameWithPageInfo, normalizeQuery, resolveGraphToken, + type GraphChannel, + type GraphGroup, + type GraphUser, } from "./graph.js"; type MSTeamsChannelResolution = { input: string; resolved: boolean; teamId?: string; + graphTeamId?: string; teamName?: string; channelId?: string; channelName?: string; @@ -30,6 +32,74 @@ type MSTeamsUserResolution = { note?: string; }; +type StableMSTeamsTeamIdMode = "bot-framework" | "graph"; + +function normalizeExactMatch(value?: string | null): string { + return normalizeLowercaseStringOrEmpty(value ?? ""); +} + +function uniqueItemsById(items: T[]): T[] { + const byId = new Map(); + for (const item of items) { + const id = item.id?.trim(); + if (id && !byId.has(id)) { + byId.set(id, item); + } + } + return [...byId.values()]; +} + +function findExactTeams(items: GraphGroup[], query: string): GraphGroup[] { + const normalized = normalizeExactMatch(query); + return uniqueItemsById( + items.filter((item) => normalizeExactMatch(item.displayName) === normalized), + ); +} + +function findExactChannels(items: GraphChannel[], query: string): GraphChannel[] { + const normalized = normalizeExactMatch(query); + return uniqueItemsById( + items.filter((item) => normalizeExactMatch(item.displayName) === normalized), + ); +} + +function findExactUsers(items: GraphUser[], query: string): GraphUser[] { + const normalized = normalizeExactMatch(query); + return uniqueItemsById( + items.filter((item) => + [item.displayName, item.mail, item.userPrincipalName].some( + (value) => normalizeExactMatch(value) === normalized, + ), + ), + ); +} + +function isStableMSTeamsUserId(raw: string): boolean { + return /^[0-9a-fA-F-]{16,}$/.test(normalizeMSTeamsUserInput(raw)); +} + +function normalizeStaticMSTeamsAllowEntry(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) { + return undefined; + } + if (trimmed === "*" || /^accessGroup:/i.test(trimmed)) { + return trimmed; + } + const id = normalizeMSTeamsUserInput(trimmed); + return isStableMSTeamsUserId(id) ? id : undefined; +} + +export function projectStableMSTeamsUserAllowlist(entries?: string[]): string[] | undefined { + if (!entries) { + return undefined; + } + const projected = entries + .map((entry) => normalizeStaticMSTeamsAllowEntry(entry)) + .filter((entry): entry is string => Boolean(entry)); + return [...new Map(projected.map((entry) => [normalizeExactMatch(entry), entry])).values()]; +} + function stripProviderPrefix(raw: string): string { return raw.replace(/^(msteams|teams):/i, ""); } @@ -67,23 +137,17 @@ export function parseMSTeamsConversationId(raw: string): string | null { } /** - * Detect whether a raw target string looks like a Microsoft Teams conversation - * or user id that cron announce delivery and other explicit-target paths can - * forward verbatim to the channel adapter. + * Detect whether a raw target string is a supported Microsoft Teams + * conversation id. * * Accepts both prefixed and bare formats: * - `conversation:` — explicit conversation prefix - * - `user:` — user id (16+ hex chars, UUID-like) * - `19:abc@thread.tacv2` / `19:abc@thread.skype` — channel / legacy group * - `19:{userId}_{appId}@unq.gbl.spaces` — Graph 1:1 chat thread format * - `a:1xxx` — Bot Framework personal (1:1) chat id * - `8:orgid:xxx` — Bot Framework org-scoped personal chat id - * - `29:xxx` — Bot Framework user id - * - * Display-name user targets such as `user:John Smith` intentionally return - * false so that the Graph API directory lookup still runs for them. */ -export function looksLikeMSTeamsTargetId(raw: string): boolean { +export function looksLikeMSTeamsConversationId(raw: string): boolean { const trimmed = raw.trim(); if (!trimmed) { return false; @@ -91,12 +155,6 @@ export function looksLikeMSTeamsTargetId(raw: string): boolean { if (/^conversation:/i.test(trimmed)) { return true; } - if (/^user:/i.test(trimmed)) { - // Only treat as an id when the value after `user:` looks like a UUID; - // display names must fall through to directory lookup. - const id = trimmed.slice("user:".length).trim(); - return /^[0-9a-fA-F-]{16,}$/.test(id); - } // Bare Bot Framework / Graph conversation id formats. // Channel / group ids always start with `19:` and include an `@thread.*` // suffix (`@thread.tacv2` or the legacy `@thread.skype`). Personal chat @@ -115,14 +173,29 @@ export function looksLikeMSTeamsTargetId(raw: string): boolean { if (/^8:orgid:[A-Za-z0-9-]+$/i.test(trimmed)) { return true; } - if (/^29:[A-Za-z0-9_-]+$/i.test(trimmed)) { - return true; - } // Fallback: anything containing @thread is still treated as a conversation // id so the current matches for tenant-specific suffixes remain accepted. return /@thread\b/i.test(trimmed); } +/** + * Detect conversation ids plus stable user ids that explicit-target delivery + * can forward verbatim to the channel adapter. + */ +export function looksLikeMSTeamsTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (looksLikeMSTeamsConversationId(trimmed)) { + return true; + } + if (/^user:/i.test(trimmed)) { + // Only treat as an id when the value after `user:` looks like a UUID; + // display names must fall through to directory lookup. + const id = trimmed.slice("user:".length).trim(); + return /^[0-9a-fA-F-]{16,}$/.test(id); + } + return /^29:[A-Za-z0-9_-]+$/i.test(trimmed); +} + function normalizeMSTeamsTeamKey(raw: string): string | undefined { const trimmed = stripProviderPrefix(raw) .replace(/^team:/i, "") @@ -145,6 +218,46 @@ function looksLikeMSTeamsThreadConversationId(raw: string): boolean { return /^19:.+@thread\./i.test(normalized); } +function isStableMSTeamsTeamKey(raw: string): boolean { + return /^[0-9a-fA-F-]{16,}$/.test(raw.trim()) || looksLikeMSTeamsThreadConversationId(raw); +} + +function projectStableMSTeamsChannels( + channels: NonNullable[string]["channels"], +) { + const projected: NonNullable = {}; + for (const [channelKey, channelConfig] of Object.entries(channels ?? {})) { + if (channelKey === "*") { + projected[channelKey] = channelConfig; + continue; + } + if (looksLikeMSTeamsThreadConversationId(channelKey)) { + projected[normalizeMSTeamsConversationTargetId(channelKey)] = channelConfig; + } + } + return projected; +} + +export function projectStableMSTeamsTeamsConfig( + teams: MSTeamsConfig["teams"], +): NonNullable | undefined { + if (!teams) { + return undefined; + } + const projected: NonNullable = {}; + for (const [teamKey, teamConfig] of Object.entries(teams)) { + if (teamKey !== "*" && !isStableMSTeamsTeamKey(teamKey)) { + continue; + } + const stableKey = teamKey === "*" ? teamKey : normalizeMSTeamsConversationTargetId(teamKey); + projected[stableKey] = { + ...teamConfig, + channels: projectStableMSTeamsChannels(teamConfig.channels), + }; + } + return projected; +} + export function parseMSTeamsTeamChannelInput(raw: string): { team?: string; channel?: string } { const trimmed = stripProviderPrefix(raw).trim(); if (!trimmed) { @@ -176,6 +289,7 @@ export function parseMSTeamsTeamEntry( export async function resolveMSTeamsChannelAllowlist(params: { cfg: unknown; entries: string[]; + teamIdMode?: StableMSTeamsTeamIdMode; }): Promise { let tokenPromise: Promise | undefined; const getToken = () => { @@ -214,75 +328,198 @@ export async function resolveMSTeamsChannelAllowlist(params: { }; } const token = await getToken(); - const teams = /^[0-9a-fA-F-]{16,}$/.test(team) - ? [{ id: team, displayName: team }] - : await listTeamsByName(token, team); - if (teams.length === 0) { - return { input, resolved: false, note: "team not found" }; + let teamMatch: GraphGroup; + if (/^[0-9a-fA-F-]{16,}$/.test(team)) { + teamMatch = { id: team, displayName: team }; + } else { + const result = await listTeamsByNameWithPageInfo(token, team); + if (result.truncated) { + return { input, resolved: false, note: "team lookup incomplete" }; + } + const exactTeams = findExactTeams(result.items, team); + if (exactTeams.length === 0) { + return { input, resolved: false, note: "team not found" }; + } + if (exactTeams.length > 1) { + return { input, resolved: false, note: "team name is ambiguous" }; + } + teamMatch = exactTeams[0]; } - const teamMatch = teams[0]; const graphTeamId = teamMatch.id?.trim(); const teamName = teamMatch.displayName?.trim() || team; if (!graphTeamId) { return { input, resolved: false, note: "team id missing" }; } - // Bot Framework sends the General channel's conversation ID as - // channelData.team.id at runtime, NOT the Graph API group GUID. - // Fetch channels upfront so we can resolve the correct key format for - // runtime matching and reuse the list for channel lookups. - let teamChannels: Awaited> = []; - try { - teamChannels = await listChannelsForTeam(token, graphTeamId); - } catch { - // API failure (rate limit, network error) — fall back to Graph GUID as team key + const needsChannels = params.teamIdMode !== "graph" || Boolean(channel); + if (!needsChannels) { + return { + input, + resolved: true, + teamId: graphTeamId, + graphTeamId, + teamName, + }; } - const generalChannel = teamChannels.find( - (ch) => normalizeOptionalLowercaseString(ch.displayName) === "general", - ); - // Use the General channel's conversation ID as the team key — this - // matches what Bot Framework sends at runtime. Fall back to the Graph - // GUID if the General channel isn't found (renamed or deleted). - const teamId = generalChannel?.id?.trim() || graphTeamId; + let teamChannels: GraphChannel[]; + try { + const result = await listChannelsForTeamWithPageInfo(token, graphTeamId); + if (result.truncated) { + return { input, resolved: false, note: "channel lookup incomplete" }; + } + teamChannels = result.items; + } catch { + return { input, resolved: false, note: "channel lookup failed" }; + } + const generalChannels = findExactChannels(teamChannels, "general"); + if (params.teamIdMode !== "graph" && generalChannels.length !== 1) { + return { + input, + resolved: false, + graphTeamId, + teamName, + note: + generalChannels.length > 1 + ? "General channel is ambiguous" + : "General channel not found", + }; + } + const teamId = generalChannels[0]?.id?.trim() || graphTeamId; if (!channel) { return { input, resolved: true, teamId, + graphTeamId, teamName, - note: teams.length > 1 ? "multiple teams; chose first" : undefined, }; } - // Reuse teamChannels — already fetched above - const normalizedChannel = normalizeOptionalLowercaseString(channel); - const channelMatch = - teamChannels.find((item) => item.id === channel) ?? - teamChannels.find( - (item) => normalizeOptionalLowercaseString(item.displayName) === normalizedChannel, - ) ?? - teamChannels.find((item) => - normalizeLowercaseStringOrEmpty(item.displayName ?? "").includes(normalizedChannel ?? ""), - ); - if (!channelMatch?.id) { + const channelById = teamChannels.find((item) => item.id === channel); + const exactChannels = channelById ? [channelById] : findExactChannels(teamChannels, channel); + if (exactChannels.length === 0) { return { input, resolved: false, note: "channel not found" }; } + if (exactChannels.length > 1) { + return { input, resolved: false, note: "channel name is ambiguous" }; + } + const channelMatch = exactChannels[0]; + if (!channelMatch?.id) { + return { input, resolved: false, note: "channel id missing" }; + } return { input, resolved: true, teamId, + graphTeamId, teamName, channelId: channelMatch.id, channelName: channelMatch.displayName ?? channel, - note: teamChannels.length > 1 ? "multiple channels; chose first" : undefined, }; }, }); } +export async function resolveMSTeamsTeamsConfig(params: { + cfg: unknown; + teamIdMode: StableMSTeamsTeamIdMode; + teams: NonNullable; +}): Promise<{ + teams: NonNullable; + mapping: string[]; + unresolved: string[]; +}> { + const entries: Array<{ input: string; teamKey: string; channelKey?: string }> = []; + const unresolved: string[] = []; + for (const [teamKey, teamCfg] of Object.entries(params.teams)) { + if (teamKey === "*") { + for (const channelKey of Object.keys(teamCfg?.channels ?? {})) { + if (channelKey !== "*" && !looksLikeMSTeamsThreadConversationId(channelKey)) { + unresolved.push(`${teamKey}/${channelKey}`); + } + } + continue; + } + const channelKeys = Object.keys(teamCfg?.channels ?? {}).filter((key) => key !== "*"); + if (channelKeys.length === 0) { + entries.push({ input: teamKey, teamKey }); + continue; + } + for (const channelKey of channelKeys) { + entries.push({ + input: `${teamKey}/${channelKey}`, + teamKey, + channelKey, + }); + } + } + if (entries.length === 0) { + return { + teams: projectStableMSTeamsTeamsConfig(params.teams) ?? {}, + mapping: [], + unresolved, + }; + } + + const resolved = await resolveMSTeamsChannelAllowlist({ + cfg: params.cfg, + entries: entries.map((entry) => entry.input), + teamIdMode: params.teamIdMode, + }); + const mapping: string[] = []; + const teams = projectStableMSTeamsTeamsConfig(params.teams) ?? {}; + + resolved.forEach((entry, index) => { + const source = entries[index]; + if (!source) { + return; + } + const sourceTeam = params.teams[source.teamKey] ?? {}; + const resolvedTeamId = params.teamIdMode === "graph" ? entry.graphTeamId : entry.teamId; + if (!entry.resolved || !resolvedTeamId) { + unresolved.push(entry.input); + return; + } + mapping.push( + entry.channelId + ? `${entry.input}→${resolvedTeamId}/${entry.channelId}` + : `${entry.input}→${resolvedTeamId}`, + ); + const existing = teams[resolvedTeamId] ?? {}; + const { channels: _sourceChannels, ...sourceTeamPolicy } = sourceTeam; + const mergedChannels = { + ...projectStableMSTeamsChannels(sourceTeam.channels), + ...existing.channels, + }; + const mergedTeam = { ...sourceTeamPolicy, ...existing, channels: mergedChannels }; + teams[resolvedTeamId] = mergedTeam; + if (source.channelKey && entry.channelId) { + const sourceChannel = sourceTeam.channels?.[source.channelKey]; + if (sourceChannel) { + teams[resolvedTeamId] = { + ...mergedTeam, + channels: { + ...mergedChannels, + [entry.channelId]: { + ...sourceChannel, + ...mergedChannels?.[entry.channelId], + }, + }, + }; + } + } + }); + + return { teams, mapping, unresolved }; +} + export async function resolveMSTeamsUserAllowlist(params: { cfg: unknown; entries: string[]; }): Promise { - const token = await resolveGraphToken(params.cfg); + let tokenPromise: Promise | undefined; + const getToken = () => { + tokenPromise ??= resolveGraphToken(params.cfg); + return tokenPromise; + }; return await mapAllowlistResolutionInputs({ inputs: params.entries, mapInput: async (input): Promise => { @@ -293,17 +530,26 @@ export async function resolveMSTeamsUserAllowlist(params: { if (/^[0-9a-fA-F-]{16,}$/.test(query)) { return { input, resolved: true, id: query }; } - const users = await searchGraphUsers({ token, query, top: 10 }); - const match = users[0]; - if (!match?.id) { - return { input, resolved: false }; + const result = await findGraphUsersByExactIdentity({ + token: await getToken(), + query, + }); + if (result.truncated) { + return { input, resolved: false, note: "user lookup incomplete" }; } + const users = findExactUsers(result.items, query); + if (users.length === 0) { + return { input, resolved: false, note: "user not found" }; + } + if (users.length > 1) { + return { input, resolved: false, note: "user identity is ambiguous" }; + } + const match = users[0]; return { input, resolved: true, id: match.id, name: match.displayName ?? undefined, - note: users.length > 1 ? "multiple matches; chose first" : undefined, }; }, }); diff --git a/extensions/msteams/src/send.test.ts b/extensions/msteams/src/send.test.ts index 41ad3b5c4f6c..c63d5f866d2a 100644 --- a/extensions/msteams/src/send.test.ts +++ b/extensions/msteams/src/send.test.ts @@ -260,6 +260,7 @@ describe("sendMessageMSTeams", () => { it("loads media through shared helper and forwards mediaLocalRoots", async () => { const mediaBuffer = Buffer.from("tiny-image"); + mockState.sendMSTeamsMessages.mockResolvedValueOnce(["message-text", "message-media"]); mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({ buffer: mediaBuffer, contentType: "image/png", @@ -285,14 +286,19 @@ describe("sendMessageMSTeams", () => { const sendPayload = firstObjectArg(mockState.sendMSTeamsMessages); const messages = sendPayload.messages as Array>; - expect(messages).toHaveLength(1); + expect(messages).toHaveLength(2); expect(messages[0]?.text).toBe("hello"); - expect(messages[0]?.mediaUrl).toBe(`data:image/png;base64,${mediaBuffer.toString("base64")}`); - expect(result.receipt?.primaryPlatformMessageId).toBe("message-1"); - expect(result.receipt?.platformMessageIds).toEqual(["message-1"]); - expect(result.receipt?.parts).toHaveLength(1); - expect(result.receipt?.parts[0]?.platformMessageId).toBe("message-1"); - expect(result.receipt?.parts[0]?.kind).toBe("media"); + expect(messages[0]?.mediaUrl).toBeUndefined(); + expect(messages[1]?.text).toBeUndefined(); + expect(messages[1]?.mediaUrl).toBe(`data:image/png;base64,${mediaBuffer.toString("base64")}`); + expect(result.messageId).toBe("message-text"); + expect(result.receipt?.primaryPlatformMessageId).toBe("message-text"); + expect(result.receipt?.platformMessageIds).toEqual(["message-text", "message-media"]); + expect(result.receipt?.parts).toHaveLength(2); + expect(result.receipt?.parts[0]?.platformMessageId).toBe("message-text"); + expect(result.receipt?.parts[1]?.platformMessageId).toBe("message-media"); + expect(result.receipt?.parts[0]?.kind).toBe("text"); + expect(result.receipt?.parts[1]?.kind).toBe("media"); }); it("sends with provided cfg even when Teams runtime text helpers are unavailable", async () => { diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index 6bcf4af8766e..57f72b4104b4 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -2,6 +2,7 @@ import { createMessageReceiptFromOutboundResults, type MessageReceipt, + type MessageReceiptPart, type MessageReceiptPartKind, } from "openclaw/plugin-sdk/channel-outbound"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; @@ -67,8 +68,9 @@ function createMSTeamsSendReceipt(params: { conversationId: string; platformMessageIds: readonly string[]; kind: MessageReceiptPartKind; + kinds?: readonly MessageReceiptPartKind[]; }) { - return createMessageReceiptFromOutboundResults({ + const receipt = createMessageReceiptFromOutboundResults({ kind: params.kind, results: params.platformMessageIds.map((messageId) => ({ channel: "msteams", @@ -76,6 +78,30 @@ function createMSTeamsSendReceipt(params: { conversationId: params.conversationId, })), }); + if (!params.kinds) { + return receipt; + } + const kinds = params.kinds; + return { + ...receipt, + parts: receipt.parts.map((part, index) => { + const nextPart: MessageReceiptPart = { + platformMessageId: part.platformMessageId, + kind: kinds[index] ?? params.kind, + index: part.index, + }; + if (part.threadId) { + nextPart.threadId = part.threadId; + } + if (part.replyToId) { + nextPart.replyToId = part.replyToId; + } + if (part.raw) { + nextPart.raw = part.raw; + } + return nextPart; + }), + }; } function createMSTeamsSendResult(params: { @@ -350,6 +376,8 @@ async function sendTextWithMedia( mediaMaxBytes, replyStyle, } = ctx; + const messages = + text && mediaUrl ? [{ text }, { mediaUrl }] : [{ text: text || undefined, mediaUrl }]; let platformMessageIds: string[]; try { @@ -358,7 +386,7 @@ async function sendTextWithMedia( app, appId, conversationRef: ref, - messages: [{ text: text || undefined, mediaUrl }], + messages, retry: {}, onRetry: (event) => { log.debug?.("retrying send", { conversationId, ...event }); @@ -388,6 +416,7 @@ async function sendTextWithMedia( conversationId, platformMessageIds, kind: mediaUrl ? "media" : "text", + ...(text && mediaUrl ? { kinds: ["text", "media"] } : {}), }), }; } diff --git a/extensions/nextcloud-talk/src/core.test.ts b/extensions/nextcloud-talk/src/core.test.ts index a0f6af6b9c66..8d4fd6067cbb 100644 --- a/extensions/nextcloud-talk/src/core.test.ts +++ b/extensions/nextcloud-talk/src/core.test.ts @@ -88,6 +88,7 @@ describe("nextcloud talk core", () => { expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true); expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true); + expect(looksLikeNextcloudTalkTargetId("room:opsroom1")).toBe(true); expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true); expect(looksLikeNextcloudTalkTargetId("")).toBe(false); }); diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts index 42316ca360f4..4bb2047b0519 100644 --- a/extensions/nextcloud-talk/src/normalize.ts +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -37,7 +37,7 @@ export function looksLikeNextcloudTalkTargetId(raw: string): boolean { return false; } - if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) { + if (/^(nextcloud-talk|nc-talk|nc|room):/i.test(trimmed)) { return true; } diff --git a/extensions/slack/src/accounts.runtime.ts b/extensions/slack/src/accounts.runtime.ts index 7d9cd829b91e..61f4186d27de 100644 --- a/extensions/slack/src/accounts.runtime.ts +++ b/extensions/slack/src/accounts.runtime.ts @@ -1,2 +1,2 @@ // Slack plugin module implements accounts behavior. -export { resolveSlackAccount } from "./accounts.js"; +export { resolveSlackAccount, resolveSlackAccountAllowFrom } from "./accounts.js"; diff --git a/extensions/slack/src/action-runtime.test.ts b/extensions/slack/src/action-runtime.test.ts index f11d5ee2031c..6701f07c6028 100644 --- a/extensions/slack/src/action-runtime.test.ts +++ b/extensions/slack/src/action-runtime.test.ts @@ -19,8 +19,14 @@ const reactSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); const readSlackMessages = vi.fn(async (..._args: unknown[]) => ({})); const removeOwnSlackReactions = vi.fn(async (..._args: unknown[]) => ["thumbsup"]); const removeSlackReaction = vi.fn(async (..._args: unknown[]) => ({})); -const resolveSlackConversationName = vi.fn( - async (..._args: unknown[]): Promise => undefined, +const resolveSlackConversationInfo = vi.fn( + async ( + ..._args: unknown[] + ): Promise<{ + type: "channel" | "group" | "dm" | "unknown"; + name?: string; + user?: string; + }> => ({ type: "channel" }), ); const sendSlackMessage = vi.fn(async (..._args: unknown[]) => ({ channelId: "C123" })); const unpinSlackMessage = vi.fn(async (..._args: unknown[]) => ({})); @@ -216,7 +222,7 @@ describe("handleSlackAction", () => { beforeEach(() => { vi.clearAllMocks(); - resolveSlackConversationName.mockReset().mockResolvedValue(undefined); + resolveSlackConversationInfo.mockReset().mockResolvedValue({ type: "channel" }); Object.assign(slackActionRuntime, originalSlackActionRuntime, { deleteSlackMessage, downloadSlackFile, @@ -231,7 +237,7 @@ describe("handleSlackAction", () => { readSlackMessages, removeOwnSlackReactions, removeSlackReaction, - resolveSlackConversationName, + resolveSlackConversationInfo, sendSlackMessage, unpinSlackMessage, }); @@ -277,6 +283,28 @@ describe("handleSlackAction", () => { expect(removeOwnSlackReactions).toHaveBeenCalledWith("C1", "123.456", { cfg }); }); + it("rejects reaction clearing outside allowlisted Slack channels", async () => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_ALLOWED: { enabled: true }, + }, + }); + + await expect( + handleSlackAction( + { + action: "react", + channelId: "C_OTHER", + messageId: "123.456", + emoji: "", + }, + cfg, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(removeOwnSlackReactions).not.toHaveBeenCalled(); + }); + it("removes reactions when remove flag set", async () => { const cfg = slackConfig(); await handleSlackAction( @@ -335,6 +363,699 @@ describe("handleSlackAction", () => { expect(listSlackReactions).not.toHaveBeenCalled(); }); + it.each([ + { + name: "reaction add", + params: { action: "react", emoji: "✅" }, + providerCall: reactSlackMessage, + }, + { + name: "reaction removal", + params: { action: "react", emoji: "✅", remove: true }, + providerCall: removeSlackReaction, + }, + { + name: "message edit", + params: { action: "editMessage", content: "updated" }, + providerCall: editSlackMessage, + }, + { + name: "message deletion", + params: { action: "deleteMessage" }, + providerCall: deleteSlackMessage, + }, + { + name: "pin", + params: { action: "pinMessage" }, + providerCall: pinSlackMessage, + }, + { + name: "unpin", + params: { action: "unpinMessage" }, + providerCall: unpinSlackMessage, + }, + ])("rejects blocked Slack $name before mutation", async ({ params, providerCall }) => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_ALLOWED: { enabled: true }, + }, + }); + + await expect( + handleSlackAction( + { + channelId: "C_BLOCKED", + messageId: "123.456", + ...params, + }, + cfg, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + + expect(providerCall).not.toHaveBeenCalled(); + }); + + it("allows a delegated read of the exact current Slack channel and account", async () => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_ALLOWED: { enabled: true }, + }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "C_CURRENT", + messageId: "123.456", + }, + cfg, + { + requesterAccountId: "DEFAULT", + currentChannelProvider: "Slack", + currentChannelId: "C_CURRENT", + }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("C_CURRENT", "123.456", { cfg }); + }); + + it("does not borrow current Slack visibility from another account", async () => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_ALLOWED: { enabled: true }, + }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_CURRENT", + messageId: "123.456", + }, + cfg, + { + requesterAccountId: "other", + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("allows delegated member info for the current Slack requester and account", async () => { + const cfg = slackConfig(); + + await handleSlackAction({ action: "memberInfo", userId: "U123" }, cfg, { + conversationReadOrigin: "delegated", + requesterAccountId: "DEFAULT", + requesterSenderId: "u123", + currentChannelProvider: "Slack", + }); + + expect(getSlackMemberInfo).toHaveBeenCalledWith("U123", { cfg }); + }); + + it.each([ + { + name: "another user", + context: { + conversationReadOrigin: "delegated" as const, + requesterAccountId: "default", + requesterSenderId: "U123", + currentChannelProvider: "slack", + }, + userId: "U999", + }, + { + name: "another account", + context: { + conversationReadOrigin: "delegated" as const, + requesterAccountId: "other", + requesterSenderId: "U123", + currentChannelProvider: "slack", + }, + userId: "U123", + }, + { + name: "another provider", + context: { + conversationReadOrigin: "delegated" as const, + requesterAccountId: "default", + requesterSenderId: "U123", + currentChannelProvider: "telegram", + }, + userId: "U123", + }, + { + name: "missing trusted context", + context: undefined, + userId: "U123", + }, + ])("rejects delegated member info for $name before provider access", async (testCase) => { + await expect( + handleSlackAction( + { action: "memberInfo", userId: testCase.userId }, + slackConfig(), + testCase.context, + ), + ).rejects.toThrow("Delegated Slack member info is limited to the current requester."); + + expect(getSlackMemberInfo).not.toHaveBeenCalled(); + }); + + it("allows a direct operator to inspect another Slack member", async () => { + const cfg = slackConfig(); + + await handleSlackAction({ action: "memberInfo", userId: "U999" }, cfg, { + conversationReadOrigin: "direct-operator", + }); + + expect(getSlackMemberInfo).toHaveBeenCalledWith("U999", { cfg }); + }); + + it("keeps explicitly disabled current Slack channels blocked", async () => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_CURRENT: { enabled: false }, + }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_CURRENT", + messageId: "123.456", + }, + cfg, + { + requesterAccountId: "default", + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read an unconfigured Slack channel", async () => { + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_ALLOWED: { enabled: true }, + }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "C_OTHER", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("C_OTHER", "123.456", { cfg }); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C_OTHER", + operation: "read", + }); + }); + + it("keeps name-disabled Slack channels blocked for direct operators", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "blocked-channel", + }); + const cfg = slackConfig({ + groupPolicy: "allowlist", + dangerouslyAllowNameMatching: true, + channels: { + "#blocked-channel": { enabled: false }, + }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_BLOCKED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C_BLOCKED", + operation: "read", + requireFreshName: true, + }); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("keeps wildcard-disabled Slack channels blocked for direct operators", async () => { + const cfg = slackConfig({ + groupPolicy: "open", + channels: { + "*": { enabled: false }, + }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_BLOCKED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("lets an explicit name allow override a wildcard denial for direct operators", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "allowed-channel", + }); + const cfg = slackConfig({ + groupPolicy: "allowlist", + dangerouslyAllowNameMatching: true, + channels: { + "*": { enabled: false }, + "#allowed-channel": { enabled: true }, + }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "C_ALLOWED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("C_ALLOWED", "123.456", { cfg }); + }); + + it("does not make direct reads depend on unrelated named allows", async () => { + const cfg = slackConfig({ + groupPolicy: "open", + dangerouslyAllowNameMatching: true, + channels: { + "#announcements": { enabled: true }, + }, + dm: { groupEnabled: true }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "C_OTHER", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); + expect(listSlackReactions).toHaveBeenCalledWith("C_OTHER", "123.456", { cfg }); + }); + + it("does not bypass a wildcard denial when Slack name lookup is unresolved", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "unknown" }); + const cfg = slackConfig({ + groupPolicy: "open", + dangerouslyAllowNameMatching: true, + channels: { + "*": { enabled: false }, + "#allowed-channel": { enabled: true }, + }, + dm: { groupEnabled: true }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_UNRESOLVED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("does not bypass a name denial when Slack metadata lookup is unresolved", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "unknown" }); + const cfg = slackConfig({ + groupPolicy: "open", + dangerouslyAllowNameMatching: true, + channels: { + "#blocked-channel": { enabled: false }, + }, + dm: { groupEnabled: true }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_UNRESOLVED", + messageId: "123.456", + }, + cfg, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read a DM when group reads are disabled", async () => { + const cfg = slackConfig({ + groupPolicy: "disabled", + dmPolicy: "pairing", + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "D_OTHER", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("D_OTHER", "123.456", { cfg }); + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); + }); + + it("lets a delegated model read its current Slack DM", async () => { + const cfg = slackConfig({ + groupPolicy: "disabled", + dmPolicy: "pairing", + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "D_CURRENT", + messageId: "123.456", + }, + cfg, + { + conversationReadOrigin: "delegated", + requesterAccountId: "default", + currentChannelProvider: "slack", + currentChannelId: "D_CURRENT", + }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("D_CURRENT", "123.456", { cfg }); + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); + }); + + it.each([ + { + name: "allowFrom peer", + overrides: { dmPolicy: "allowlist", allowFrom: ["slack:U0ALLOWED"] }, + }, + { + name: "per-DM peer", + overrides: { dmPolicy: "pairing", dms: { U0ALLOWED: { historyLimit: 5 } } }, + }, + { + name: "default target peer", + overrides: { dmPolicy: "pairing", defaultTo: "user:U0ALLOWED" }, + }, + ])( + "lets a delegated model read an explicitly configured Slack DM via $name", + async (testCase) => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "dm", user: "U0ALLOWED" }); + const cfg = slackConfig(testCase.overrides); + + await handleSlackAction( + { + action: "reactions", + channelId: "D_ALLOWED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "delegated" }, + ); + + expect(resolveSlackConversationInfo).toHaveBeenCalledOnce(); + expect(listSlackReactions).toHaveBeenCalledWith("D_ALLOWED", "123.456", { cfg }); + }, + ); + + it("blocks an unconfigured delegated Slack DM before provider content access", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "dm", user: "U0OTHER01" }); + const cfg = slackConfig({ + dmPolicy: "pairing", + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "D_OTHER", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "delegated" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + + expect(resolveSlackConversationInfo).toHaveBeenCalledOnce(); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("does not treat an open-DM wildcard as a configured read target", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "dm", user: "U0OTHER01" }); + const cfg = slackConfig({ + dmPolicy: "open", + allowFrom: ["*"], + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "D_OTHER", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "delegated" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("fails closed when Slack cannot resolve a delegated DM peer", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "dm" }); + const cfg = slackConfig({ + dmPolicy: "allowlist", + allowFrom: ["U0ALLOWED"], + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "D_UNKNOWN", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "delegated" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("lets a direct operator read an enabled Slack group DM", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "group" }); + const cfg = slackConfig({ + groupPolicy: "disabled", + dm: { + groupEnabled: true, + groupChannels: ["G_ALLOWED"], + }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "G_ALLOWED", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("G_ALLOWED", "123.456", { cfg }); + }); + + it("blocks a C-prefixed MPIM when direct group-DM reads are disabled", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "group" }); + const cfg = slackConfig({ + groupPolicy: "open", + dm: { groupEnabled: false }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_MPIM", + messageId: "123.456", + }, + cfg, + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("blocks a C-prefixed MPIM from delegated channel allowlists", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "group" }); + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_MPIM: { enabled: true }, + }, + dm: { groupEnabled: false }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_MPIM", + messageId: "123.456", + }, + cfg, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("rejects unknown Slack topology unless both possible read policies allow it", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "unknown" }); + const cfg = slackConfig({ + groupPolicy: "allowlist", + channels: { + C_AMBIGUOUS: { enabled: true }, + }, + dm: { groupEnabled: false }, + }); + + await expect( + handleSlackAction( + { + action: "reactions", + channelId: "C_AMBIGUOUS", + messageId: "123.456", + }, + cfg, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + + it("allows unknown Slack topology when both possible read policies allow it", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: "unknown" }); + const cfg = slackConfig({ + groupPolicy: "open", + dm: { groupEnabled: true }, + }); + + await handleSlackAction( + { + action: "reactions", + channelId: "C_AMBIGUOUS", + messageId: "123.456", + }, + cfg, + ); + + expect(listSlackReactions).toHaveBeenCalledWith("C_AMBIGUOUS", "123.456", { cfg }); + }); + + it.each([ + { + name: "disabled scope", + overrides: { groupPolicy: "disabled" }, + channelId: "C_OTHER", + channelType: undefined, + }, + { + name: "explicitly disabled channel", + overrides: { + groupPolicy: "allowlist", + channels: { C_BLOCKED: { enabled: false } }, + }, + channelId: "C_BLOCKED", + channelType: undefined, + }, + { + name: "disabled DM scope", + overrides: { + groupPolicy: "open", + dmPolicy: "disabled", + }, + channelId: "D_BLOCKED", + channelType: undefined, + }, + { + name: "disabled group DM scope", + overrides: { + groupPolicy: "open", + dm: { groupEnabled: false }, + }, + channelId: "G_BLOCKED", + channelType: "group" as const, + }, + ])("keeps $name blocked for direct operators", async ({ overrides, channelId, channelType }) => { + if (channelType) { + resolveSlackConversationInfo.mockResolvedValueOnce({ type: channelType }); + } + await expect( + handleSlackAction( + { + action: "reactions", + channelId, + messageId: "123.456", + }, + slackConfig(overrides), + { conversationReadOrigin: "direct-operator" }, + ), + ).rejects.toThrow("Slack read target channel is not allowed."); + expect(listSlackReactions).not.toHaveBeenCalled(); + }); + it("passes threadTs to sendSlackMessage for thread replies", async () => { const cfg = slackConfig(); await handleSlackAction( @@ -1118,7 +1839,10 @@ describe("handleSlackAction", () => { }); it("resolves name-allowlisted reads from a core-shaped Slack threading context", async () => { - resolveSlackConversationName.mockResolvedValueOnce("allowed-channel"); + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "allowed-channel", + }); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); const cfg = slackConfig({ @@ -1140,12 +1864,21 @@ describe("handleSlackAction", () => { await handleSlackAction({ action: "readMessages", channelId: "C0123456789" }, cfg, context); - expect(resolveSlackConversationName).toHaveBeenCalledWith("C0123456789", { cfg }); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C0123456789", + operation: "read", + requireFreshName: true, + }); expect(requireMockArg(readSlackMessages, "readSlackMessages", 0, 0)).toBe("C0123456789"); }); it("does not treat the core Channel provider value as a Slack room name", async () => { - resolveSlackConversationName.mockResolvedValueOnce("actual-room"); + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "actual-room", + }); const cfg = slackConfig({ groupPolicy: "allowlist", @@ -1167,12 +1900,21 @@ describe("handleSlackAction", () => { await expect( handleSlackAction({ action: "readMessages", channelId: "C0123456789" }, cfg, context), ).rejects.toThrow("Slack read target channel is not allowed."); - expect(resolveSlackConversationName).toHaveBeenCalledWith("C0123456789", { cfg }); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C0123456789", + operation: "read", + requireFreshName: true, + }); expect(readSlackMessages).not.toHaveBeenCalled(); }); it("does not authorize different Slack targets with the current context channel ID", async () => { - resolveSlackConversationName.mockResolvedValueOnce("other-channel"); + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "other-channel", + }); const cfg = slackConfig({ groupPolicy: "allowlist", @@ -1187,12 +1929,21 @@ describe("handleSlackAction", () => { currentChannelId: "C0123456789", }), ).rejects.toThrow("Slack read target channel is not allowed."); - expect(resolveSlackConversationName).toHaveBeenCalledWith("C9876543210", { cfg }); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C9876543210", + operation: "read", + requireFreshName: true, + }); expect(readSlackMessages).not.toHaveBeenCalled(); }); - it("uses the configured user read token to resolve name-allowlisted channels", async () => { - resolveSlackConversationName.mockResolvedValueOnce("allowed-channel"); + it("requests read-scoped metadata for name-allowlisted channels", async () => { + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "allowed-channel", + }); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); const cfg = slackConfig({ @@ -1205,15 +1956,21 @@ describe("handleSlackAction", () => { }); await handleSlackAction({ action: "readMessages", channelId: "C0123456789" }, cfg); - expect(resolveSlackConversationName).toHaveBeenCalledWith("C0123456789", { + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ cfg, - token: "xoxp-reader", + accountId: "default", + channelId: "C0123456789", + operation: "read", + requireFreshName: true, }); expect(requireMockArg(readSlackMessages, "readSlackMessages", 0, 0)).toBe("C0123456789"); }); it("resolves Slack target channel names before applying wildcard fallback denial", async () => { - resolveSlackConversationName.mockResolvedValueOnce("allowed-channel"); + resolveSlackConversationInfo.mockResolvedValueOnce({ + type: "channel", + name: "allowed-channel", + }); readSlackMessages.mockResolvedValueOnce({ messages: [], hasMore: false }); const cfg = slackConfig({ @@ -1226,7 +1983,13 @@ describe("handleSlackAction", () => { }); await handleSlackAction({ action: "readMessages", channelId: "C0123456789" }, cfg); - expect(resolveSlackConversationName).toHaveBeenCalledWith("C0123456789", { cfg }); + expect(resolveSlackConversationInfo).toHaveBeenCalledWith({ + cfg, + accountId: "default", + channelId: "C0123456789", + operation: "read", + requireFreshName: true, + }); expect(requireMockArg(readSlackMessages, "readSlackMessages", 0, 0)).toBe("C0123456789"); }); @@ -1243,12 +2006,12 @@ describe("handleSlackAction", () => { await expect( handleSlackAction({ action: "readMessages", channelId: "C0123456789" }, cfg), ).rejects.toThrow("Slack read target channel is not allowed."); - expect(resolveSlackConversationName).not.toHaveBeenCalled(); + expect(resolveSlackConversationInfo).not.toHaveBeenCalled(); expect(readSlackMessages).not.toHaveBeenCalled(); }); it("fails closed before reading when Slack cannot resolve the target name", async () => { - resolveSlackConversationName.mockRejectedValueOnce(new Error("missing_scope")); + resolveSlackConversationInfo.mockRejectedValueOnce(new Error("missing_scope")); const cfg = slackConfig({ groupPolicy: "allowlist", dangerouslyAllowNameMatching: true, diff --git a/extensions/slack/src/action-runtime.ts b/extensions/slack/src/action-runtime.ts index c59759c4e551..9da18c45b669 100644 --- a/extensions/slack/src/action-runtime.ts +++ b/extensions/slack/src/action-runtime.ts @@ -1,11 +1,15 @@ // Slack plugin module implements action runtime behavior. +import { normalizeAccountId } from "openclaw/plugin-sdk/account-resolution"; import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; import { readBooleanParam } from "openclaw/plugin-sdk/boolean-param"; +import type { ChannelMessageActionContext } from "openclaw/plugin-sdk/channel-contract"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import { isSingleUseReplyToMode } from "openclaw/plugin-sdk/reply-reference"; import { resolveOpenProviderRuntimeGroupPolicy } from "openclaw/plugin-sdk/runtime-group-policy"; +import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime"; import type { ResolvedSlackAccount } from "./accounts.js"; import { parseSlackBlocksInput } from "./blocks-input.js"; +import type { SlackConversationInfo } from "./channel-type.js"; import { resolveSlackChannelConfig } from "./monitor/channel-config.js"; import { isSlackChannelAllowedByPolicy } from "./monitor/policy.js"; import { @@ -18,7 +22,11 @@ import { type OpenClawConfig, withNormalizedTimestamp, } from "./runtime-api.js"; -import { resolveSlackChannelId, slackContextTargetsMatch } from "./targets.js"; +import { parseSlackTarget, resolveSlackChannelId, slackContextTargetsMatch } from "./targets.js"; + +type ConversationReadInvocationOrigin = NonNullable< + ChannelMessageActionContext["conversationReadOrigin"] +>; const messagingActions = new Set([ "sendMessage", @@ -37,6 +45,7 @@ type SlackActionsRuntimeModule = typeof import("./actions.runtime.js"); const loadSlackActionsRuntime = createLazyRuntimeModule(() => import("./actions.runtime.js")); const loadSlackAccountsRuntime = createLazyRuntimeModule(() => import("./accounts.runtime.js")); +const loadSlackChannelTypeRuntime = createLazyRuntimeModule(() => import("./channel-type.js")); function createLazySlackAction( key: K, @@ -63,11 +72,27 @@ export const slackActionRuntime = { removeOwnSlackReactions: createLazySlackAction("removeOwnSlackReactions"), removeSlackReaction: createLazySlackAction("removeSlackReaction"), resolveSlackConversationName: createLazySlackAction("resolveSlackConversationName"), + resolveSlackConversationInfo: async (params: { + cfg: OpenClawConfig; + accountId?: string | null; + channelId: string; + operation?: "read" | "write"; + requireFreshName?: boolean; + }) => (await loadSlackChannelTypeRuntime()).resolveSlackConversationInfo(params), + resolveSlackChannelType: async (params: { + cfg: OpenClawConfig; + accountId?: string | null; + channelId: string; + }) => (await loadSlackChannelTypeRuntime()).resolveSlackChannelType(params), sendSlackMessage: createLazySlackAction("sendSlackMessage"), unpinSlackMessage: createLazySlackAction("unpinSlackMessage"), }; export type SlackActionContext = { + conversationReadOrigin?: ConversationReadInvocationOrigin; + requesterAccountId?: string; + requesterSenderId?: string; + currentChannelProvider?: string; /** Current channel ID for auto-threading. */ currentChannelId?: string; /** Routable target for the current conversation when it differs from the channel ID. */ @@ -144,14 +169,114 @@ function isImageContentType(value: string | undefined): boolean { return value?.trim().toLowerCase().startsWith("image/") === true; } -type SlackReadTargetDecision = "allow" | "deny" | "resolve-name"; +function hasPotentialSlackNamedPolicy(params: { + channels: ResolvedSlackAccount["config"]["channels"]; + allowNameMatching?: boolean; + decision: "allow" | "deny"; +}): boolean { + if (params.allowNameMatching !== true) { + return false; + } + return Object.entries(params.channels ?? {}).some(([key, entry]) => { + if (entry == null || key === "*") { + return false; + } + const named = !/^(?:channel:)?[CDG][A-Z0-9]+$/i.test(key); + const entryAllows = entry.enabled !== false; + return named && (params.decision === "allow" ? entryAllows : !entryAllows); + }); +} -function resolveSlackReadTargetDecision(params: { +function resolveSlackDmReadAllowed(account: ResolvedSlackAccount): boolean { + const dmPolicy = account.config.dmPolicy ?? account.config.dm?.policy ?? "pairing"; + return account.config.dm?.enabled !== false && dmPolicy !== "disabled"; +} + +function normalizeConfiguredSlackDmUserId(value: unknown): string | undefined { + const target = parseSlackTarget(String(value), { defaultKind: "user" }); + if (target?.kind !== "user") { + return undefined; + } + const userId = target.id.trim().toLowerCase(); + return /^[uw][a-z0-9]+$/i.test(userId) ? userId : undefined; +} + +async function isSlackDmTargetConfigured(params: { + account: ResolvedSlackAccount; + cfg: OpenClawConfig; + channelId: string; + userId?: string; +}): Promise { + const defaultTo = params.account.config.defaultTo?.trim(); + if ( + defaultTo && + slackContextTargetsMatch(params.channelId, { + currentChannelId: defaultTo, + }) + ) { + return true; + } + const userId = normalizeConfiguredSlackDmUserId(params.userId); + if (!userId) { + return false; + } + const { resolveSlackAccountAllowFrom } = await loadSlackAccountsRuntime(); + const configuredUsers = [ + ...(resolveSlackAccountAllowFrom({ + cfg: params.cfg, + accountId: params.account.accountId, + }) ?? []), + ...Object.keys(params.account.config.dms ?? {}), + ...(defaultTo ? [defaultTo] : []), + ]; + return configuredUsers.some((entry) => normalizeConfiguredSlackDmUserId(entry) === userId); +} + +function isCurrentSlackReadTarget(params: { + account: ResolvedSlackAccount; + channelId: string; + context?: SlackActionContext; +}): boolean { + const requesterAccountId = params.context?.requesterAccountId?.trim(); + return Boolean( + normalizeOptionalLowercaseString(params.context?.currentChannelProvider) === "slack" && + requesterAccountId && + normalizeAccountId(requesterAccountId) === normalizeAccountId(params.account.accountId) && + params.context && + slackContextTargetsMatch(params.channelId, params.context), + ); +} + +function assertSlackMemberInfoAllowed(params: { + account: ResolvedSlackAccount; + context?: SlackActionContext; + userId: string; +}) { + if (params.context?.conversationReadOrigin === "direct-operator") { + return; + } + const requesterAccountId = params.context?.requesterAccountId?.trim(); + const requesterSenderId = normalizeOptionalLowercaseString(params.context?.requesterSenderId); + if ( + normalizeOptionalLowercaseString(params.context?.currentChannelProvider) !== "slack" || + !requesterAccountId || + normalizeAccountId(requesterAccountId) !== normalizeAccountId(params.account.accountId) || + !requesterSenderId || + requesterSenderId !== normalizeOptionalLowercaseString(params.userId) + ) { + throw new Error("Delegated Slack member info is limited to the current requester."); + } +} + +function resolveSlackChannelReadPolicy(params: { account: ResolvedSlackAccount; cfg: OpenClawConfig; channelId: string; channelName?: string; -}): SlackReadTargetDecision { + conversationReadOrigin?: ConversationReadInvocationOrigin; + metadataResolved?: boolean; + currentConversation?: boolean; +}) { const channels = params.account.config.channels; const channelKeys = Object.keys(channels ?? {}); const channelConfig = resolveSlackChannelConfig({ @@ -163,6 +288,8 @@ function resolveSlackReadTargetDecision(params: { defaultRequireMention: params.account.config.requireMention, }); const channelAllowed = channelConfig?.allowed !== false; + const channelExplicitlyDisabled = !channelAllowed && channelConfig?.matchSource === "direct"; + const channelWildcardDisabled = !channelAllowed && channelConfig?.matchSource === "wildcard"; const { groupPolicy } = resolveOpenProviderRuntimeGroupPolicy({ providerConfigPresent: params.cfg.channels?.slack !== undefined, groupPolicy: params.account.config.groupPolicy, @@ -173,39 +300,167 @@ function resolveSlackReadTargetDecision(params: { channelAllowlistConfigured: channelKeys.length > 0, channelAllowed, }); - if (policyAllowed) { - return !channelAllowed && (groupPolicy !== "open" || channelConfig?.matchSource) - ? "deny" - : "allow"; - } - - const canResolveName = - groupPolicy === "allowlist" && - channelKeys.length > 0 && - params.account.config.dangerouslyAllowNameMatching === true && + const delegatedChannelAllowed = + policyAllowed && !(!channelAllowed && (groupPolicy !== "open" || channelConfig?.matchSource)); + const directChannelAllowed = + groupPolicy !== "disabled" && !channelExplicitlyDisabled && !channelWildcardDisabled; + const baseChannelAllowed = + params.conversationReadOrigin === "direct-operator" || params.currentConversation + ? directChannelAllowed + : delegatedChannelAllowed; + const allowNameMatching = params.account.config.dangerouslyAllowNameMatching; + const shouldResolveName = + !params.metadataResolved && !params.channelName && - (channelConfig?.matchSource === undefined || channelConfig.matchSource === "wildcard"); - return canResolveName ? "resolve-name" : "deny"; + ((baseChannelAllowed && + channelConfig?.matchSource !== "direct" && + hasPotentialSlackNamedPolicy({ + channels, + allowNameMatching, + decision: "deny", + })) || + (!baseChannelAllowed && + groupPolicy !== "disabled" && + !channelExplicitlyDisabled && + hasPotentialSlackNamedPolicy({ + channels, + allowNameMatching, + decision: "allow", + }))); + return { + channelAllowed: baseChannelAllowed, + channelExplicitlyDisabled, + groupDmAllowed: + params.account.config.dm?.enabled !== false && + params.account.config.dm?.groupEnabled === true && + (params.currentConversation || + isSlackGroupDmTargetConfigured(params.account, params.channelId)), + shouldResolveName, + }; } async function assertSlackReadTargetAllowed(params: { account: ResolvedSlackAccount; cfg: OpenClawConfig; channelId: string; - resolveChannelName: () => Promise; + conversationReadOrigin?: ConversationReadInvocationOrigin; + context?: SlackActionContext; }) { - const direct = resolveSlackReadTargetDecision(params); - if (direct === "allow") { - return; - } - if (direct === "resolve-name") { - const channelName = await params.resolveChannelName(); - if (channelName && resolveSlackReadTargetDecision({ ...params, channelName }) === "allow") { + const deny = () => { + throw new Error("Slack read target channel is not allowed."); + }; + const currentConversation = isCurrentSlackReadTarget({ + account: params.account, + channelId: params.channelId, + context: params.context, + }); + const directOperator = params.conversationReadOrigin === "direct-operator"; + if (/^D/i.test(params.channelId)) { + if (!resolveSlackDmReadAllowed(params.account)) { + deny(); + } + if (directOperator || currentConversation) { return; } + const info = await slackActionRuntime.resolveSlackConversationInfo({ + cfg: params.cfg, + accountId: params.account.accountId, + channelId: params.channelId, + operation: "read", + }); + if ( + info.type !== "dm" || + !(await isSlackDmTargetConfigured({ + ...params, + userId: info.user, + })) + ) { + deny(); + } + return; } - throw new Error("Slack read target channel is not allowed."); + const preliminary = resolveSlackChannelReadPolicy({ + ...params, + currentConversation, + }); + if (preliminary.channelExplicitlyDisabled) { + deny(); + } + const needsMetadata = + preliminary.shouldResolveName || preliminary.channelAllowed !== preliminary.groupDmAllowed; + if (!needsMetadata) { + if (!preliminary.channelAllowed) { + deny(); + } + return; + } + + const info: SlackConversationInfo = await slackActionRuntime.resolveSlackConversationInfo({ + cfg: params.cfg, + accountId: params.account.accountId, + channelId: params.channelId, + operation: "read", + ...(preliminary.shouldResolveName ? { requireFreshName: true } : {}), + }); + if ( + preliminary.shouldResolveName && + (info.type === "channel" || info.type === "unknown") && + !info.name + ) { + deny(); + } + const resolved = resolveSlackChannelReadPolicy({ + ...params, + channelName: info.name, + metadataResolved: true, + currentConversation, + }); + if (resolved.channelExplicitlyDisabled) { + deny(); + } + if (info.type === "dm") { + if ( + !resolveSlackDmReadAllowed(params.account) || + (!directOperator && + !currentConversation && + !(await isSlackDmTargetConfigured({ + ...params, + userId: info.user, + }))) + ) { + deny(); + } + return; + } + const allowed = + info.type === "channel" + ? resolved.channelAllowed + : info.type === "group" + ? resolved.groupDmAllowed + : resolved.channelAllowed && resolved.groupDmAllowed; + if (!allowed) { + deny(); + } +} + +function isSlackGroupDmTargetConfigured(account: ResolvedSlackAccount, channelId: string): boolean { + const entries = account.config.dm?.groupChannels ?? []; + if (entries.length === 0) { + return true; + } + const target = channelId.trim().toLowerCase(); + return entries.some((entry) => { + const candidate = String(entry).trim().toLowerCase(); + return ( + candidate === "*" || + candidate === target || + candidate === `slack:${target}` || + candidate === `channel:${target}` || + candidate === `group:${target}` || + candidate === `mpim:${target}` + ); + }); } export async function handleSlackAction( @@ -260,10 +515,8 @@ export async function handleSlackAction( account, cfg, channelId, - // Use the same credential that will perform the authorized read. Slack - // exposes conversation metadata according to the presented token's access. - resolveChannelName: async () => - await slackActionRuntime.resolveSlackConversationName(channelId, readOpts), + conversationReadOrigin: context?.conversationReadOrigin, + context, }); if (reactionsActions.has(action)) { @@ -277,6 +530,7 @@ export async function handleSlackAction( removeErrorMessage: "Emoji is required to remove a Slack reaction.", }); if (remove) { + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.removeSlackReaction(channelId, messageId, emoji, writeOpts); } else { @@ -285,11 +539,13 @@ export async function handleSlackAction( return jsonResult({ ok: true, removed: emoji }); } if (isEmpty) { + await assertReadTargetAllowed(channelId); const removed = writeOpts ? await slackActionRuntime.removeOwnSlackReactions(channelId, messageId, writeOpts) : await slackActionRuntime.removeOwnSlackReactions(channelId, messageId); return jsonResult({ ok: true, removed }); } + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.reactSlackMessage(channelId, messageId, emoji, writeOpts); } else { @@ -420,6 +676,7 @@ export async function handleSlackAction( if (!content && !blocks) { throw new Error("Slack editMessage requires content or blocks."); } + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.editSlackMessage(channelId, messageId, content ?? "", { ...writeOpts, @@ -437,6 +694,7 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.deleteSlackMessage(channelId, messageId, writeOpts); } else { @@ -541,6 +799,7 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.pinSlackMessage(channelId, messageId, writeOpts); } else { @@ -552,6 +811,7 @@ export async function handleSlackAction( const messageId = readStringParam(params, "messageId", { required: true, }); + await assertReadTargetAllowed(channelId); if (writeOpts) { await slackActionRuntime.unpinSlackMessage(channelId, messageId, writeOpts); } else { @@ -580,6 +840,7 @@ export async function handleSlackAction( throw new Error("Slack member info is disabled."); } const userId = readStringParam(params, "userId", { required: true }); + assertSlackMemberInfoAllowed({ account, context, userId }); const info = writeOpts ? await slackActionRuntime.getSlackMemberInfo(userId, readOpts) : await slackActionRuntime.getSlackMemberInfo(userId); diff --git a/extensions/slack/src/channel-actions.ts b/extensions/slack/src/channel-actions.ts index bda366147e6a..987447528e41 100644 --- a/extensions/slack/src/channel-actions.ts +++ b/extensions/slack/src/channel-actions.ts @@ -1,6 +1,9 @@ // Slack plugin module implements channel actions behavior. import type { AgentToolResult } from "openclaw/plugin-sdk/agent-core"; -import type { ChannelMessageActionAdapter } from "openclaw/plugin-sdk/channel-contract"; +import type { + ChannelMessageActionAdapter, + ChannelMessageActionContext, +} from "openclaw/plugin-sdk/channel-contract"; import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime"; import type { SlackActionContext } from "./action-runtime.js"; import { handleSlackMessageAction } from "./message-action-dispatch.js"; @@ -8,6 +11,10 @@ import { extractSlackToolSend } from "./message-actions.js"; import { describeSlackMessageTool } from "./message-tool-api.js"; import { resolveSlackChannelId } from "./targets.js"; +type ConversationReadInvocationOrigin = NonNullable< + ChannelMessageActionContext["conversationReadOrigin"] +>; + type SlackActionInvoke = ( action: Record, cfg: unknown, @@ -30,14 +37,29 @@ function resolveSlackActionContext(params: { toolContext: unknown; mediaLocalRoots: readonly string[] | undefined; mediaReadFile: ((filePath: string) => Promise) | undefined; + conversationReadOrigin?: ConversationReadInvocationOrigin; + requesterAccountId?: string | null; + requesterSenderId?: string | null; }): SlackActionContext | undefined { - if (!params.toolContext && !params.mediaLocalRoots && !params.mediaReadFile) { + if ( + !params.toolContext && + !params.mediaLocalRoots && + !params.mediaReadFile && + !params.conversationReadOrigin && + !params.requesterAccountId && + !params.requesterSenderId + ) { return undefined; } return { ...(params.toolContext as SlackActionContext | undefined), ...(params.mediaLocalRoots ? { mediaLocalRoots: params.mediaLocalRoots } : {}), ...(params.mediaReadFile ? { mediaReadFile: params.mediaReadFile } : {}), + // Authority comes only from the host-owned action context. Overwrite any + // structurally compatible fields carried by generic tool context. + conversationReadOrigin: params.conversationReadOrigin, + requesterAccountId: params.requesterAccountId ?? undefined, + requesterSenderId: params.requesterSenderId ?? undefined, }; } @@ -62,6 +84,9 @@ export function createSlackActions( toolContext, mediaLocalRoots: ctx.mediaLocalRoots, mediaReadFile: ctx.mediaReadFile, + conversationReadOrigin: ctx.conversationReadOrigin, + requesterAccountId: ctx.requesterAccountId, + requesterSenderId: ctx.requesterSenderId, }); return await (options?.invoke ? options.invoke(action, cfg, actionContext) diff --git a/extensions/slack/src/channel-type.test.ts b/extensions/slack/src/channel-type.test.ts index fef35eaca9a8..8c14c330782c 100644 --- a/extensions/slack/src/channel-type.test.ts +++ b/extensions/slack/src/channel-type.test.ts @@ -6,22 +6,35 @@ import { resolveSlackConversationInfo, } from "./channel-type.js"; -const conversationsInfoMock = vi.fn(); -const conversationsOpenMock = vi.fn(); +const slackClientMocks = vi.hoisted(() => { + const conversationsInfo = vi.fn(); + const conversationsOpen = vi.fn(); + return { + conversationsInfo, + conversationsOpen, + createSlackWebClient: vi.fn(() => ({ + conversations: { + info: conversationsInfo, + open: conversationsOpen, + }, + })), + }; +}); +const { + conversationsInfo: conversationsInfoMock, + conversationsOpen: conversationsOpenMock, + createSlackWebClient: createSlackWebClientMock, +} = slackClientMocks; vi.mock("./client.js", () => ({ - createSlackWebClient: vi.fn(() => ({ - conversations: { - info: conversationsInfoMock, - open: conversationsOpenMock, - }, - })), + createSlackWebClient: slackClientMocks.createSlackWebClient, })); describe("resolveSlackChannelType", () => { beforeEach(() => { conversationsInfoMock.mockReset(); conversationsOpenMock.mockReset(); + createSlackWebClientMock.mockClear(); resetSlackChannelTypeCacheForTest(); }); @@ -50,8 +63,6 @@ describe("resolveSlackChannelType", () => { defaultAccount: "work", accounts: { work: { - botToken: "xoxb-work", - appToken: "xapp-work", dm: { groupChannels: [channelId], }, @@ -99,6 +110,121 @@ describe("resolveSlackChannelType", () => { expect(conversationsInfoMock).not.toHaveBeenCalled(); }); + it("uses the read credential to classify C-prefixed MPIMs and returns their name", async () => { + conversationsInfoMock.mockResolvedValueOnce({ + channel: { + id: "C0MPIM", + is_mpim: true, + name: "mpdm-alice--bob-1", + }, + }); + + await expect( + resolveSlackConversationInfo({ + cfg: { + channels: { + slack: { + botToken: "xoxb-writer", + userToken: "xoxp-reader", + }, + }, + } as never, + channelId: "C0MPIM", + operation: "read", + }), + ).resolves.toEqual({ + type: "group", + name: "mpdm-alice--bob-1", + }); + expect(createSlackWebClientMock).toHaveBeenCalledWith("xoxp-reader"); + expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: "C0MPIM" }); + }); + + it("does not reuse cached metadata across Slack credential rotation", async () => { + conversationsInfoMock + .mockResolvedValueOnce({ + channel: { + id: "C0CHANNEL", + name: "before-rotation", + }, + }) + .mockResolvedValueOnce({ + channel: { + id: "C0CHANNEL", + name: "after-rotation", + }, + }); + + await expect( + resolveSlackConversationInfo({ + cfg: { + channels: { + slack: { + botToken: "xoxb-before", + }, + }, + } as never, + channelId: "C0CHANNEL", + }), + ).resolves.toMatchObject({ name: "before-rotation" }); + await expect( + resolveSlackConversationInfo({ + cfg: { + channels: { + slack: { + botToken: "xoxb-after", + }, + }, + } as never, + channelId: "C0CHANNEL", + }), + ).resolves.toMatchObject({ name: "after-rotation" }); + + expect(createSlackWebClientMock).toHaveBeenNthCalledWith(1, "xoxb-before"); + expect(createSlackWebClientMock).toHaveBeenNthCalledWith(2, "xoxb-after"); + expect(conversationsInfoMock).toHaveBeenCalledTimes(2); + }); + + it("refreshes names used for authorization instead of caching them", async () => { + conversationsInfoMock + .mockResolvedValueOnce({ + channel: { + id: "C0CHANNEL", + name: "old-name", + }, + }) + .mockResolvedValueOnce({ + channel: { + id: "C0CHANNEL", + name: "new-name", + }, + }); + const cfg = { + channels: { + slack: { + botToken: "xoxb-test", + }, + }, + } as never; + + await expect( + resolveSlackConversationInfo({ + cfg, + channelId: "C0CHANNEL", + requireFreshName: true, + }), + ).resolves.toMatchObject({ name: "old-name" }); + await expect( + resolveSlackConversationInfo({ + cfg, + channelId: "C0CHANNEL", + requireFreshName: true, + }), + ).resolves.toMatchObject({ name: "new-name" }); + + expect(conversationsInfoMock).toHaveBeenCalledTimes(2); + }); + it("keeps D-prefixed channels typed as dm when Slack lookup fails", async () => { conversationsOpenMock.mockRejectedValueOnce(new Error("missing_scope")); @@ -118,6 +244,76 @@ describe("resolveSlackChannelType", () => { }); }); + it.each([ + { + name: "group DM", + channelId: "C0MPIM", + slackConfig: { + dm: { + groupChannels: ["C0MPIM"], + }, + }, + }, + { + name: "channel", + channelId: "C0CHANNEL", + slackConfig: { + channels: { + C0CHANNEL: {}, + }, + }, + }, + ])( + "does not use configured $name entries as topology proof when Slack lookup fails", + async ({ channelId, slackConfig }) => { + conversationsInfoMock.mockRejectedValueOnce(new Error("missing_scope")); + + await expect( + resolveSlackConversationInfo({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + ...slackConfig, + }, + }, + } as never, + channelId, + }), + ).resolves.toEqual({ + type: "unknown", + }); + expect(conversationsInfoMock).toHaveBeenCalledWith({ channel: channelId }); + }, + ); + + it("keeps successful Slack metadata authoritative over configured fallback", async () => { + conversationsInfoMock.mockResolvedValueOnce({ + channel: { + id: "C0CHANNEL", + is_mpim: false, + }, + }); + + await expect( + resolveSlackConversationInfo({ + cfg: { + channels: { + slack: { + botToken: "xoxb-test", + dm: { + groupChannels: ["C0CHANNEL"], + }, + }, + }, + } as never, + channelId: "C0CHANNEL", + }), + ).resolves.toEqual({ + type: "channel", + }); + }); + it("does not cache incomplete native IM channel lookups", async () => { conversationsOpenMock .mockRejectedValueOnce(new Error("temporary_failure")) diff --git a/extensions/slack/src/channel-type.ts b/extensions/slack/src/channel-type.ts index fe48a30e8f63..6e707d7ff33b 100644 --- a/extensions/slack/src/channel-type.ts +++ b/extensions/slack/src/channel-type.ts @@ -1,16 +1,18 @@ // Slack plugin module implements channel type behavior. +import { createHash } from "node:crypto"; import { pruneMapToMaxSize } from "openclaw/plugin-sdk/collection-runtime"; import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, } from "openclaw/plugin-sdk/string-coerce-runtime"; -import { resolveSlackAccount } from "./accounts.js"; +import { resolveSlackAccount, resolveSlackOperationToken } from "./accounts.js"; import { createSlackWebClient } from "./client.js"; import { normalizeAllowListLower } from "./monitor/allow-list.js"; import type { OpenClawConfig } from "./runtime-api.js"; -type SlackConversationInfo = { +export type SlackConversationInfo = { type: "channel" | "group" | "dm" | "unknown"; + name?: string; user?: string; }; @@ -35,97 +37,111 @@ function setCachedSlackConversationInfo( pruneMapToMaxSize(SLACK_CONVERSATION_INFO_CACHE, SLACK_CONVERSATION_INFO_CACHE_MAX_ENTRIES); } +function fingerprintSlackCredential(token: string): string { + return createHash("sha256").update(token).digest("hex"); +} + +function resolveConfiguredSlackConversationInfo(params: { + account: ReturnType; + channelId: string; +}): SlackConversationInfo { + if (/^D/i.test(params.channelId)) { + return { type: "dm" }; + } + const channelIdLower = normalizeLowercaseStringOrEmpty(params.channelId); + const groupChannels = normalizeAllowListLower(params.account.dm?.groupChannels); + if ( + groupChannels.includes(channelIdLower) || + groupChannels.includes(`slack:${channelIdLower}`) || + groupChannels.includes(`channel:${channelIdLower}`) || + groupChannels.includes(`group:${channelIdLower}`) || + groupChannels.includes(`mpim:${channelIdLower}`) + ) { + return { type: "group" }; + } + const configuredChannel = Object.keys(params.account.channels ?? {}).some((key) => { + const normalized = normalizeLowercaseStringOrEmpty(key); + return ( + normalized === channelIdLower || + normalized === `channel:${channelIdLower}` || + normalized.replace(/^#/, "") === channelIdLower + ); + }); + return { type: configuredChannel ? "channel" : "unknown" }; +} + export async function resolveSlackConversationInfo(params: { cfg: OpenClawConfig; accountId?: string | null; channelId: string; + operation?: "read" | "write"; + requireFreshName?: boolean; }): Promise { const channelId = params.channelId.trim(); if (!channelId) { return { type: "unknown" }; } const account = resolveSlackAccount({ cfg: params.cfg, accountId: params.accountId }); - const cacheKey = `${account.accountId}:${channelId}`; - const cached = getCachedSlackConversationInfo(cacheKey); - if (cached) { - return cached; + const operation = params.operation ?? "read"; + const token = resolveSlackOperationToken(account, operation); + const userToken = normalizeOptionalString(account.userToken); + const credentialRole = token ? (token === userToken ? "user" : "bot") : "none"; + const credentialFingerprint = token ? fingerprintSlackCredential(token) : "none"; + const cacheKey = `${account.accountId}:${operation}:${credentialRole}:${credentialFingerprint}:${channelId}`; + if (!params.requireFreshName) { + const cached = getCachedSlackConversationInfo(cacheKey); + if (cached) { + return cached; + } } const isNativeImChannel = /^D/i.test(channelId); - const groupChannels = normalizeAllowListLower(account.dm?.groupChannels); - const channelIdLower = normalizeLowercaseStringOrEmpty(channelId); - if ( - !isNativeImChannel && - (groupChannels.includes(channelIdLower) || - groupChannels.includes(`slack:${channelIdLower}`) || - groupChannels.includes(`channel:${channelIdLower}`) || - groupChannels.includes(`group:${channelIdLower}`) || - groupChannels.includes(`mpim:${channelIdLower}`)) - ) { - const result = { type: "group" } as const; - setCachedSlackConversationInfo(cacheKey, result); - return result; - } - - const channelKeys = Object.keys(account.channels ?? {}); - if ( - !isNativeImChannel && - channelKeys.some((key) => { - const normalized = normalizeLowercaseStringOrEmpty(key); - return ( - normalized === channelIdLower || - normalized === `channel:${channelIdLower}` || - normalized.replace(/^#/, "") === channelIdLower - ); - }) - ) { - const result = { type: "channel" } as const; - setCachedSlackConversationInfo(cacheKey, result); - return result; - } - - const token = - normalizeOptionalString(account.botToken) ?? - normalizeOptionalString(account.config.userToken) ?? - ""; - if (!token) { - const result = { type: isNativeImChannel ? "dm" : "unknown" } as const; - if (!isNativeImChannel) { - setCachedSlackConversationInfo(cacheKey, result); - } - return result; - } - - try { - const client = createSlackWebClient(token); - if (isNativeImChannel) { - const opened = await client.conversations.open({ - channel: channelId, - prevent_creation: true, - return_im: true, - }); - const user = - typeof opened.channel?.user === "string" && opened.channel.user.trim() - ? opened.channel.user.trim() - : undefined; - const result: SlackConversationInfo = user ? { type: "dm", user } : { type: "dm" }; - if (user) { - setCachedSlackConversationInfo(cacheKey, result); + const configuredInfo = resolveConfiguredSlackConversationInfo({ account, channelId }); + if (token) { + try { + const client = createSlackWebClient(token); + if (isNativeImChannel) { + const opened = await client.conversations.open({ + channel: channelId, + prevent_creation: true, + return_im: true, + }); + const user = + typeof opened.channel?.user === "string" && opened.channel.user.trim() + ? opened.channel.user.trim() + : undefined; + const result: SlackConversationInfo = user ? { type: "dm", user } : { type: "dm" }; + if (user) { + setCachedSlackConversationInfo(cacheKey, result); + } + return result; } + const info = await client.conversations.info({ channel: channelId }); + const channel = info.channel as + | { is_im?: boolean; is_mpim?: boolean; name?: string; user?: string } + | undefined; + const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; + const name = normalizeOptionalString(channel?.name); + const user = normalizeOptionalString(channel?.user); + const result: SlackConversationInfo = { + type, + ...(name ? { name } : {}), + ...(user ? { user } : {}), + }; + setCachedSlackConversationInfo(cacheKey, { + type, + ...(user ? { user } : {}), + }); return result; + } catch { + return { type: isNativeImChannel ? "dm" : "unknown" }; } - const info = await client.conversations.info({ channel: channelId }); - const channel = info.channel as { is_im?: boolean; is_mpim?: boolean } | undefined; - const type = channel?.is_im ? "dm" : channel?.is_mpim ? "group" : "channel"; - const result = { type } as const; - setCachedSlackConversationInfo(cacheKey, result); - return result; - } catch { - const result = { type: isNativeImChannel ? "dm" : "unknown" } as const; - if (!isNativeImChannel) { - setCachedSlackConversationInfo(cacheKey, result); - } - return result; } + + const result = configuredInfo; + if (!isNativeImChannel) { + setCachedSlackConversationInfo(cacheKey, result); + } + return result; } export async function resolveSlackChannelType(params: { diff --git a/extensions/slack/src/message-action-dispatch.test.ts b/extensions/slack/src/message-action-dispatch.test.ts index 9840873616d8..3d23c1f425bd 100644 --- a/extensions/slack/src/message-action-dispatch.test.ts +++ b/extensions/slack/src/message-action-dispatch.test.ts @@ -456,6 +456,36 @@ describe("handleSlackMessageAction", () => { expect(firstInvokeCall(invoke)[1]).toEqual({}); }); + it.each(["react", "reactions", "read", "list-pins"] as const)( + "forwards trusted tool context for %s authorization", + async (action) => { + const invoke = createInvokeSpy(); + const toolContext = { + currentChannelProvider: "slack", + currentChannelId: "C1", + }; + + await handleSlackMessageAction({ + providerId: "slack", + ctx: { + action, + cfg: {}, + params: { + channelId: "C1", + ...(action === "react" + ? { messageId: "1712345678.654321", emoji: "white_check_mark" } + : {}), + ...(action === "reactions" ? { messageId: "1712345678.654321" } : {}), + }, + toolContext, + } as never, + invoke: invoke as never, + }); + + expect(firstInvokeCall(invoke)[2]).toBe(toolContext); + }, + ); + it("rejects fractional read limits before invoking Slack actions", async () => { const invoke = createInvokeSpy(); @@ -640,6 +670,7 @@ describe("handleSlackMessageAction", () => { expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ action: "memberInfo", userId: "U123" }), expect.any(Object), + expect.objectContaining({ currentChannelProvider: " Slack " }), ); }); @@ -662,6 +693,7 @@ describe("handleSlackMessageAction", () => { expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ action: "memberInfo", userId: "U123" }), expect.any(Object), + expect.objectContaining({ currentChannelProvider: "slack" }), ); }); @@ -711,6 +743,7 @@ describe("handleSlackMessageAction", () => { accountId: "other", requesterAccountId: "default", requesterSenderId: "U123", + conversationReadOrigin: "direct-operator", toolContext: { currentChannelProvider: "telegram" }, } as never, invoke: invoke as never, @@ -719,6 +752,7 @@ describe("handleSlackMessageAction", () => { expect(invoke).toHaveBeenCalledWith( expect.objectContaining({ action: "memberInfo", userId: "U999" }), expect.any(Object), + expect.objectContaining({ currentChannelProvider: "telegram" }), ); }); }); diff --git a/extensions/slack/src/message-action-dispatch.ts b/extensions/slack/src/message-action-dispatch.ts index 82d408e78a95..d9a64d1e6abd 100644 --- a/extensions/slack/src/message-action-dispatch.ts +++ b/extensions/slack/src/message-action-dispatch.ts @@ -123,6 +123,7 @@ export async function handleSlackMessageAction(params: { accountId, }, cfg, + ctx.toolContext, ); } @@ -142,6 +143,7 @@ export async function handleSlackMessageAction(params: { accountId, }, cfg, + ctx.toolContext, ); } @@ -161,7 +163,7 @@ export async function handleSlackMessageAction(params: { if (includeReadThreadId) { readAction.threadId = readStringParam(actionParams, "threadId"); } - return await invoke(readAction, cfg); + return await invoke(readAction, cfg, ctx.toolContext); } if (action === "edit") { @@ -185,6 +187,7 @@ export async function handleSlackMessageAction(params: { accountId, }, cfg, + ctx.toolContext, ); } @@ -200,6 +203,7 @@ export async function handleSlackMessageAction(params: { accountId, }, cfg, + ctx.toolContext, ); } @@ -216,6 +220,7 @@ export async function handleSlackMessageAction(params: { accountId, }, cfg, + ctx.toolContext, ); } @@ -234,7 +239,7 @@ export async function handleSlackMessageAction(params: { if (!userId) { throw new Error("member-info requires a userId outside a current Slack conversation."); } - return await invoke({ action: "memberInfo", userId, accountId }, cfg); + return await invoke({ action: "memberInfo", userId, accountId }, cfg, ctx.toolContext); } if (action === "emoji-list") { diff --git a/extensions/slack/src/message-tools.test.ts b/extensions/slack/src/message-tools.test.ts index d9bf38f8a38a..c73ec570fc65 100644 --- a/extensions/slack/src/message-tools.test.ts +++ b/extensions/slack/src/message-tools.test.ts @@ -1,6 +1,6 @@ // Slack tests cover message tools plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { createSlackActions } from "./channel-actions.js"; import { listSlackMessageActions } from "./message-actions.js"; import { describeSlackMessageTool } from "./message-tool-api.js"; @@ -25,6 +25,42 @@ function requireSchemaProperty( } describe("Slack message tools", () => { + it("forwards trusted current-conversation and requester-account context", async () => { + const invoke = vi.fn(async () => ({ content: [], details: { ok: true } })); + const actions = createSlackActions("slack", { invoke }); + if (!actions.handleAction) { + throw new Error("Slack message actions must provide an executor."); + } + const toolContext = { + currentChannelProvider: "slack" as const, + currentChannelId: "C_CURRENT", + }; + + await actions.handleAction({ + channel: "slack", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "C_CURRENT" }, + requesterAccountId: "work", + requesterSenderId: "U123", + toolContext, + }); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ + action: "readMessages", + channelId: "C_CURRENT", + }), + expect.any(Object), + expect.objectContaining({ + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + requesterAccountId: "work", + requesterSenderId: "U123", + }), + ); + }); + it("classifies provider-native mutation actions", () => { const actions = createSlackActions("slack"); for (const action of ["sendMessage", "editMessage", "deleteMessage", "pinMessage"]) { @@ -35,6 +71,71 @@ describe("Slack message tools", () => { } }); + it("forwards complete trusted context for current-requester member info", async () => { + const invoke = vi.fn(async () => ({ content: [], details: { ok: true } })); + const actions = createSlackActions("slack", { invoke }); + if (!actions.handleAction) { + throw new Error("Slack message actions must provide an executor."); + } + + await actions.handleAction({ + channel: "slack", + action: "member-info", + cfg: {} as OpenClawConfig, + params: {}, + requesterAccountId: "default", + requesterSenderId: "U123", + conversationReadOrigin: "delegated", + toolContext: { currentChannelProvider: "slack", currentChannelId: "C_CURRENT" }, + }); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ action: "memberInfo", userId: "U123" }), + expect.any(Object), + expect.objectContaining({ + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + conversationReadOrigin: "delegated", + requesterAccountId: "default", + requesterSenderId: "U123", + }), + ); + }); + + it("does not accept Slack read authority from generic tool context", async () => { + const invoke = vi.fn(async () => ({ content: [], details: { ok: true } })); + const actions = createSlackActions("slack", { invoke }); + if (!actions.handleAction) { + throw new Error("Slack message actions must provide an executor."); + } + + await actions.handleAction({ + channel: "slack", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "C_CURRENT" }, + toolContext: { + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + conversationReadOrigin: "direct-operator", + requesterAccountId: "default", + requesterSenderId: "U999", + } as never, + }); + + expect(invoke).toHaveBeenCalledWith( + expect.objectContaining({ action: "readMessages", channelId: "C_CURRENT" }), + expect.any(Object), + expect.objectContaining({ + currentChannelProvider: "slack", + currentChannelId: "C_CURRENT", + conversationReadOrigin: undefined, + requesterAccountId: undefined, + requesterSenderId: undefined, + }), + ); + }); + it("describes configured Slack message actions without loading channel runtime", () => { const discovery = describeSlackMessageTool({ cfg: { diff --git a/packages/gateway-protocol/src/schema/agent.test.ts b/packages/gateway-protocol/src/schema/agent.test.ts index bd03583bd7a2..85212b73698f 100644 --- a/packages/gateway-protocol/src/schema/agent.test.ts +++ b/packages/gateway-protocol/src/schema/agent.test.ts @@ -1,7 +1,7 @@ // Gateway Protocol tests cover agent behavior. import { Value } from "typebox/value"; import { describe, expect, it } from "vitest"; -import { AgentParamsSchema } from "./agent.js"; +import { AgentParamsSchema, MessageActionParamsSchema } from "./agent.js"; /** * Regression coverage for agent-run schema payloads that carry internal @@ -81,3 +81,39 @@ describe("AgentParamsSchema", () => { expect(Value.Check(AgentParamsSchema, params)).toBe(false); }); }); + +describe("MessageActionParamsSchema", () => { + const baseParams = { + channel: "matrix", + action: "read", + params: {}, + idempotencyKey: "idem-1", + }; + + it("accepts only the operation-local direct-operator marker", () => { + expect( + Value.Check(MessageActionParamsSchema, { + ...baseParams, + conversationReadOrigin: "direct-operator", + }), + ).toBe(true); + expect( + Value.Check(MessageActionParamsSchema, { + ...baseParams, + conversationReadOrigin: "delegated", + }), + ).toBe(false); + }); + + it("rejects caller-supplied current chat classification", () => { + expect( + Value.Check(MessageActionParamsSchema, { + ...baseParams, + toolContext: { + currentChannelId: "!room:example.org", + currentChatType: "direct", + }, + }), + ).toBe(false); + }); +}); diff --git a/packages/gateway-protocol/src/schema/agent.ts b/packages/gateway-protocol/src/schema/agent.ts index a87d5c6092f0..5357598bfb19 100644 --- a/packages/gateway-protocol/src/schema/agent.ts +++ b/packages/gateway-protocol/src/schema/agent.ts @@ -67,7 +67,7 @@ export const AgentEventSchema = Type.Object( { additionalProperties: false }, ); -/** Channel context injected into message actions so tools can reply in-place. */ +/** Caller-supplied routing hints. Authorization must use trusted runtime context. */ export const MessageActionToolContextSchema = Type.Object( { currentChannelId: Type.Optional(Type.String()), @@ -117,6 +117,11 @@ export const MessageActionParamsSchema = Type.Object( inboundTurnKind: Type.Optional(Type.String({ enum: ["user_request", "room_event"] })), agentId: Type.Optional(Type.String()), toolContext: Type.Optional(MessageActionToolContextSchema), + /** + * Explicit operation-local marker for an authenticated direct operator. + * Missing values remain delegated, and agent runtime identity wins server-side. + */ + conversationReadOrigin: Type.Optional(Type.Literal("direct-operator")), idempotencyKey: NonEmptyString, }, { additionalProperties: false }, diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts index fc1b74a9120f..843cbbde4862 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.test.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.test.ts @@ -7,6 +7,7 @@ import { SkillsProposalInspectResultSchema, SkillsProposalRequestRevisionResultSchema, ToolsEffectiveResultSchema, + ToolsInvokeParamsSchema, } from "./agents-models-skills.js"; /** @@ -99,6 +100,23 @@ describe("ToolsEffectiveResultSchema", () => { }); }); +describe("ToolsInvokeParamsSchema", () => { + it("accepts only the operation-local direct-operator marker", () => { + expect( + Value.Check(ToolsInvokeParamsSchema, { + name: "message", + conversationReadOrigin: "direct-operator", + }), + ).toBe(true); + expect( + Value.Check(ToolsInvokeParamsSchema, { + name: "message", + conversationReadOrigin: "delegated", + }), + ).toBe(false); + }); +}); + describe("SkillsProposalInspectResultSchema", () => { it("accepts update proposal support file target metadata", () => { const result = { diff --git a/packages/gateway-protocol/src/schema/agents-models-skills.ts b/packages/gateway-protocol/src/schema/agents-models-skills.ts index 7bf917276efc..a1dde7de1180 100644 --- a/packages/gateway-protocol/src/schema/agents-models-skills.ts +++ b/packages/gateway-protocol/src/schema/agents-models-skills.ts @@ -916,6 +916,11 @@ export const ToolsInvokeParamsSchema = Type.Object( agentId: Type.Optional(NonEmptyString), confirm: Type.Optional(Type.Boolean()), idempotencyKey: Type.Optional(NonEmptyString), + /** + * Explicit operation-local marker for an authenticated direct operator. + * Missing values remain delegated, and agent runtime identity wins server-side. + */ + conversationReadOrigin: Type.Optional(Type.Literal("direct-operator")), }, { additionalProperties: false }, ); diff --git a/packages/sdk/src/client.ts b/packages/sdk/src/client.ts index 3cb3b69c4c17..6a93e9890756 100644 --- a/packages/sdk/src/client.ts +++ b/packages/sdk/src/client.ts @@ -895,6 +895,7 @@ export class ToolsNamespace extends RpcNamespace { async invoke(name: string, params?: ToolInvokeParams): Promise { return await this.call("invoke", { name, + conversationReadOrigin: "direct-operator", ...(params?.args ? { args: params.args } : {}), ...(params?.sessionKey ? { sessionKey: params.sessionKey } : {}), ...(params?.agentId ? { agentId: params.agentId } : {}), diff --git a/packages/sdk/src/index.test.ts b/packages/sdk/src/index.test.ts index 8a58e32473e6..12bb80698bfe 100644 --- a/packages/sdk/src/index.test.ts +++ b/packages/sdk/src/index.test.ts @@ -575,6 +575,7 @@ describe("OpenClaw SDK", () => { method: "tools.invoke", params: { name: "demo", + conversationReadOrigin: "direct-operator", args: { mode: "test" }, sessionKey: "agent:main:main", confirm: false, diff --git a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts index 17251f7aec72..fb063980acce 100644 --- a/src/agents/agent-tools.create-openclaw-coding-tools.test.ts +++ b/src/agents/agent-tools.create-openclaw-coding-tools.test.ts @@ -703,7 +703,7 @@ describe("createOpenClawCodingTools", () => { expect(createOpenClawToolsMock).not.toHaveBeenCalled(); }); - it("forwards active model metadata to plugin-only tool construction", () => { + it("forwards prepared run facts to plugin-only tool construction", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); const resolvePluginToolsSpy = vi @@ -717,6 +717,7 @@ describe("createOpenClawCodingTools", () => { runtimeToolAllowlist: ["memory_search"], modelProvider: "openrouter", modelId: "openrouter/auto", + nativeChannelId: "oc_native_chat", toolConstructionPlan: { includeBaseCodingTools: false, includeShellTools: false, @@ -731,6 +732,7 @@ describe("createOpenClawCodingTools", () => { const pluginToolOptions = resolvePluginToolsSpy.mock.calls[0]?.[0].options; expect(pluginToolOptions?.modelProvider).toBe("openrouter"); expect(pluginToolOptions?.modelId).toBe("openrouter/auto"); + expect(pluginToolOptions?.nativeChannelId).toBe("oc_native_chat"); } finally { resolvePluginToolsSpy.mockRestore(); } @@ -763,6 +765,24 @@ describe("createOpenClawCodingTools", () => { } }); + it("forwards the native channel id through standard tool construction", () => { + const createOpenClawToolsMock = vi.mocked(createOpenClawTools); + createOpenClawToolsMock.mockClear(); + + createOpenClawCodingTools({ + config: testConfig, + chatType: "group", + nativeChannelId: "oc_native_chat", + messageActionTurnCapability: "turn-capability-1", + }); + + expect(latestCreateOpenClawToolsOptions().nativeChannelId).toBe("oc_native_chat"); + expect(latestCreateOpenClawToolsOptions().currentChatType).toBe("group"); + expect(latestCreateOpenClawToolsOptions().messageActionTurnCapability).toBe( + "turn-capability-1", + ); + }); + it("forwards auth profiles to plugin-only tool construction", () => { const createOpenClawToolsMock = vi.mocked(createOpenClawTools); createOpenClawToolsMock.mockClear(); diff --git a/src/agents/agent-tools.ts b/src/agents/agent-tools.ts index 83dbb4abd5b6..4634d1b15d7f 100644 --- a/src/agents/agent-tools.ts +++ b/src/agents/agent-tools.ts @@ -328,6 +328,10 @@ export function createOpenClawCodingTools(options?: { agentAccountId?: string; messageTo?: string; messageThreadId?: string | number; + /** Trusted platform-native conversation id for the active inbound turn. */ + nativeChannelId?: string; + /** Opaque host-issued capability for current-turn channel message actions. */ + messageActionTurnCapability?: string; sandbox?: SandboxContext | null; sessionKey?: string; /** @@ -852,6 +856,7 @@ export function createOpenClawCodingTools(options?: { agentAccountId: options?.agentAccountId, agentTo: options?.messageTo, agentThreadId: options?.messageThreadId, + nativeChannelId: options?.nativeChannelId, agentDir: options?.agentDir, workspaceDir: workspaceRoot, config: options?.config, @@ -938,6 +943,8 @@ export function createOpenClawCodingTools(options?: { agentAccountId: options?.agentAccountId, agentTo: options?.messageTo, agentThreadId: options?.messageThreadId, + nativeChannelId: options?.nativeChannelId, + messageActionTurnCapability: options?.messageActionTurnCapability, agentGroupId: options?.groupId ?? null, agentGroupChannel: options?.groupChannel ?? null, agentGroupSpace: options?.groupSpace ?? null, @@ -963,6 +970,7 @@ export function createOpenClawCodingTools(options?: { ? cronCreatorToolAllowlist : undefined, currentChannelId: options?.currentChannelId, + currentChatType: options?.chatType, currentMessagingTarget: options?.currentMessagingTarget, currentThreadTs: options?.currentThreadTs, currentMessageId: options?.currentMessageId, diff --git a/src/agents/btw.ts b/src/agents/btw.ts index a9c2d3392eb6..30e3858550bf 100644 --- a/src/agents/btw.ts +++ b/src/agents/btw.ts @@ -7,6 +7,7 @@ import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/st import type { GetReplyOptions } from "../auto-reply/get-reply-options.types.js"; import type { ReplyPayload } from "../auto-reply/reply-payload.js"; import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js"; +import type { ChatType } from "../channels/chat-type.js"; import type { SessionEntry as StoredSessionEntry } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js"; @@ -380,9 +381,12 @@ type RunBtwSideQuestionParams = { isNewSession: boolean; messageChannel?: string; messageProvider?: string; + chatType?: ChatType; agentAccountId?: string; messageTo?: string; messageThreadId?: string | number; + chatId?: string; + messageActionTurnCapability?: string; groupId?: string | null; groupChannel?: string | null; groupSpace?: string | null; diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index 762e979efb9b..e237f94e142a 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -2171,6 +2171,7 @@ async function runEmbeddedAgentInternal( agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + messageActionTurnCapability: params.messageActionTurnCapability, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts index 29abe6a481f6..9f62c084d1c7 100644 --- a/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts +++ b/src/agents/embedded-agent-runner/run/attempt.cwd-split.test.ts @@ -71,6 +71,7 @@ describe("runEmbeddedAttempt cwd/workspace split", () => { sessionKey: "agent:main:slack:direct:U123", tempPaths, attemptOverrides: { + chatId: "oc_native_chat", currentChannelId: "D123", currentMessagingTarget: "user:U123", disableTools: false, @@ -78,11 +79,16 @@ describe("runEmbeddedAttempt cwd/workspace split", () => { }); const toolsCall = hoisted.createOpenClawCodingToolsMock.mock.calls[0]?.[0] as - | { currentChannelId?: string; currentMessagingTarget?: string } + | { + currentChannelId?: string; + currentMessagingTarget?: string; + nativeChannelId?: string; + } | undefined; expect(toolsCall).toMatchObject({ currentChannelId: "D123", currentMessagingTarget: "user:U123", + nativeChannelId: "oc_native_chat", }); }); diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts index 1c91c72075eb..f7ab5acb56d6 100644 --- a/src/agents/embedded-agent-runner/run/attempt.ts +++ b/src/agents/embedded-agent-runner/run/attempt.ts @@ -1366,6 +1366,8 @@ export async function runEmbeddedAttempt( agentAccountId: params.agentAccountId, messageTo: params.messageTo, messageThreadId: params.messageThreadId, + nativeChannelId: params.chatId, + messageActionTurnCapability: params.messageActionTurnCapability, groupId: params.groupId, groupChannel: params.groupChannel, groupSpace: params.groupSpace, diff --git a/src/agents/embedded-agent-runner/run/params.ts b/src/agents/embedded-agent-runner/run/params.ts index 18a1ad92b69f..967fae40cd24 100644 --- a/src/agents/embedded-agent-runner/run/params.ts +++ b/src/agents/embedded-agent-runner/run/params.ts @@ -94,6 +94,8 @@ export type RunEmbeddedAgentParams = { groupSpace?: string | null; /** Trusted provider role ids for the requester in this group turn. */ memberRoleIds?: string[]; + /** Opaque host-issued capability for current-turn channel message actions. */ + messageActionTurnCapability?: string; /** Parent session key for subagent policy inheritance. */ spawnedBy?: string | null; /** Whether workspaceDir points at the canonical agent workspace for bootstrap purposes. */ diff --git a/src/agents/embedded-agent-runner/usage-reporting.test.ts b/src/agents/embedded-agent-runner/usage-reporting.test.ts index 1383e3000009..520153b5e55c 100644 --- a/src/agents/embedded-agent-runner/usage-reporting.test.ts +++ b/src/agents/embedded-agent-runner/usage-reporting.test.ts @@ -130,6 +130,27 @@ describe("runEmbeddedAgent usage reporting", () => { expect(attemptInput.senderE164).toBe("+15551234567"); }); + it("forwards the current-turn message action capability into embedded attempts", async () => { + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: ["Response 1"], + }), + ); + + await runEmbeddedAgent({ + sessionId: "test-session", + sessionKey: "test-key", + sessionFile: "/tmp/session.json", + workspaceDir: "/tmp/workspace", + prompt: "hello", + timeoutMs: 30000, + runId: "run-message-action-capability", + messageActionTurnCapability: "turn-capability", + }); + + expect(firstAttemptInput().messageActionTurnCapability).toBe("turn-capability"); + }); + it("forwards memory flush write paths into memory-triggered attempts", async () => { mockedRunEmbeddedAttempt.mockResolvedValueOnce( makeAttemptResult({ diff --git a/src/agents/harness/types.ts b/src/agents/harness/types.ts index f8a9f7d89550..b44a0af1720e 100644 --- a/src/agents/harness/types.ts +++ b/src/agents/harness/types.ts @@ -52,9 +52,12 @@ export type AgentHarnessSideQuestionParams = { workspaceDir?: string; messageChannel?: string; messageProvider?: string; + chatType?: import("../../channels/chat-type.js").ChatType; agentAccountId?: string; messageTo?: string; messageThreadId?: string | number; + chatId?: string; + messageActionTurnCapability?: string; groupId?: string | null; groupChannel?: string | null; groupSpace?: string | null; diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index 6572d2e232a6..5b54c51c9f31 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -1386,9 +1386,16 @@ describe("main-session-restart-recovery", () => { expect(result).toEqual({ recovered: 0, failed: 1, skipped: 0 }); expect(callGateway).toHaveBeenCalledOnce(); const gatewayCall = vi.mocked(callGateway).mock.calls[0]?.[0] as - | { method?: string; params?: Record } + | { + method?: string; + params?: Record; + clientName?: string; + mode?: string; + } | undefined; expect(gatewayCall?.method).toBe("message.action"); + expect(gatewayCall?.clientName).toBe("gateway-client"); + expect(gatewayCall?.mode).toBe("backend"); expect(gatewayCall?.params).toMatchObject({ channel: "discord", action: "send", diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index 2eec32ff8983..c7a809548ad8 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -6,6 +6,10 @@ import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../packages/gateway-protocol/src/client-info.js"; import { sanitizePendingFinalDeliveryText } from "../auto-reply/reply/pending-final-delivery.js"; import { resolveStateDir } from "../config/paths.js"; import { @@ -514,6 +518,8 @@ async function sendUnresumableSessionNotice(params: { method: "message.action", params: actionParams, timeoutMs: 10_000, + clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, }); log.info( `sent interrupted main session recovery notice: ${params.sessionKey} (${params.reason})`, diff --git a/src/agents/openclaw-tools.plugin-context.test.ts b/src/agents/openclaw-tools.plugin-context.test.ts index f693244420d6..d90fcdbc6285 100644 --- a/src/agents/openclaw-tools.plugin-context.test.ts +++ b/src/agents/openclaw-tools.plugin-context.test.ts @@ -31,6 +31,43 @@ describe("openclaw plugin tool context", () => { expect(result.context.senderIsOwner).toBe(true); }); + it("forwards the trusted native conversation id", () => { + const result = resolveOpenClawPluginToolInputs({ + options: { + config: {} as never, + nativeChannelId: "oc_native_chat", + }, + }); + + expect(result.context.nativeChannelId).toBe("oc_native_chat"); + }); + + it("defaults missing and unknown conversation-read origins to delegated", () => { + const missing = resolveOpenClawPluginToolInputs({ + options: { config: {} as never }, + }); + const unknown = resolveOpenClawPluginToolInputs({ + options: { + config: {} as never, + conversationReadOrigin: "forged" as never, + }, + }); + + expect(missing.context.conversationReadOrigin).toBe("delegated"); + expect(unknown.context.conversationReadOrigin).toBe("delegated"); + }); + + it("preserves a server-owned direct-operator origin", () => { + const result = resolveOpenClawPluginToolInputs({ + options: { + config: {} as never, + conversationReadOrigin: "direct-operator", + }, + }); + + expect(result.context.conversationReadOrigin).toBe("direct-operator"); + }); + it("forwards fs policy for plugin tool sandbox enforcement", () => { const result = resolveOpenClawPluginToolInputs({ options: { diff --git a/src/agents/openclaw-tools.plugin-context.ts b/src/agents/openclaw-tools.plugin-context.ts index fe2d8ea1b984..420e068a5502 100644 --- a/src/agents/openclaw-tools.plugin-context.ts +++ b/src/agents/openclaw-tools.plugin-context.ts @@ -1,3 +1,7 @@ +import { + normalizeConversationReadInvocationOrigin, + type ConversationReadInvocationOrigin, +} from "../channels/plugins/conversation-read-origin.js"; /** * Runtime context resolver for OpenClaw plugin tools. * @@ -18,6 +22,7 @@ export type OpenClawPluginToolOptions = { agentAccountId?: string; agentTo?: string; agentThreadId?: string | number; + nativeChannelId?: string; agentDir?: string; workspaceDir?: string; config?: OpenClawConfig; @@ -26,6 +31,7 @@ export type OpenClawPluginToolOptions = { modelId?: string; requesterSenderId?: string | null; senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; requesterAgentIdOverride?: string; sessionId?: string; /** @@ -95,8 +101,12 @@ export function resolveOpenClawPluginToolInputs(params: { messageChannel: options?.agentChannel, agentAccountId: options?.agentAccountId, deliveryContext, + nativeChannelId: options?.nativeChannelId, requesterSenderId: options?.requesterSenderId ?? undefined, senderIsOwner: options?.senderIsOwner, + conversationReadOrigin: normalizeConversationReadInvocationOrigin( + options?.conversationReadOrigin, + ), sandboxed: options?.sandboxed, oneShotCliRun: options?.oneShotCliRun, }, diff --git a/src/agents/openclaw-tools.ts b/src/agents/openclaw-tools.ts index b8aed1bd2f96..17c0be77b401 100644 --- a/src/agents/openclaw-tools.ts +++ b/src/agents/openclaw-tools.ts @@ -8,7 +8,9 @@ import type { SourceReplyDeliveryMode, TaskSuggestionDeliveryMode, } from "../auto-reply/get-reply-options.types.js"; +import type { ChatType } from "../channels/chat-type.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; +import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; import { selectApplicableRuntimeConfig } from "../config/config.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { callGateway } from "../gateway/call.js"; @@ -123,6 +125,10 @@ export function createOpenClawTools( agentTo?: string; /** Thread/topic identifier for routing replies to the originating thread. */ agentThreadId?: string | number; + /** Trusted platform-native conversation id for the active inbound turn. */ + nativeChannelId?: string; + /** Opaque host-issued capability for current-turn channel message actions. */ + messageActionTurnCapability?: string; agentDir?: string; sandboxRoot?: string; sandboxContainerWorkdir?: string; @@ -138,6 +144,8 @@ export function createOpenClawTools( cronCreatorToolAllowlist?: CronCreatorToolAllowlistEntry[]; /** Current channel ID for auto-threading. */ currentChannelId?: string; + /** Trusted normalized conversation kind for the active inbound turn. */ + currentChatType?: ChatType; /** Routable target for the current conversation when it differs from the native channel ID. */ currentMessagingTarget?: string; /** Current thread timestamp for auto-threading. */ @@ -173,6 +181,8 @@ export function createOpenClawTools( requesterAgentIdOverride?: string; /** Trusted sender identity bit for channel action auth. */ senderIsOwner?: boolean; + /** Server-owned operation-local origin for conversation-read visibility policy. */ + conversationReadOrigin?: ConversationReadInvocationOrigin; /** Restrict the cron tool to self-removing this active cron job. */ cronSelfRemoveOnlyJobId?: string; /** Require explicit message targets (no implicit last-route sends). */ @@ -389,8 +399,10 @@ export function createOpenClawTools( runId: options?.runId, agentId: sessionAgentId, sessionId: options?.sessionId, + messageActionTurnCapability: options?.messageActionTurnCapability, config: options?.config, currentChannelId: options?.currentChannelId, + currentChatType: options?.currentChatType, currentMessagingTarget: options?.currentMessagingTarget, currentChannelProvider: options?.agentChannel, currentThreadTs: options?.currentThreadTs, @@ -407,6 +419,7 @@ export function createOpenClawTools( inboundEventKind: options?.inboundEventKind, requesterSenderId: options?.requesterSenderId ?? undefined, senderIsOwner: options?.senderIsOwner, + conversationReadOrigin: options?.conversationReadOrigin, }); const heartbeatTool = options?.enableHeartbeatTool ? createHeartbeatResponseTool() : null; options?.recordToolPrepStage?.("openclaw-tools:message-tool"); diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index c72e5637a9ab..562da0d34f12 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -1,11 +1,21 @@ // Gateway call helper tests pin URL override, token, and RPC scope behavior for // agent tools that route through the local gateway client. import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { verifyAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js"; import type { CallGatewayOptions } from "../../gateway/call.js"; +import { + mintMessageActionTurnCapability, + resetMessageActionTurnCapabilitiesForTest, +} from "../../gateway/message-action-turn-capability.js"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { withGatewayToolCallerIdentity } from "./gateway-caller-context.js"; -import { callGatewayTool, readGatewayCallOptions, resolveGatewayOptions } from "./gateway.js"; +import { + callGatewayTool, + readGatewayCallOptions, + resolveGatewayOptions, + resolveMessageActionAgentRuntimeIdentityToken, +} from "./gateway.js"; const mocks = vi.hoisted(() => ({ callGateway: vi.fn(), @@ -67,6 +77,7 @@ describe("gateway tool defaults", () => { mocks.deviceIdentityError = undefined; mocks.persistedDeviceIdentity = undefined; mocks.configState.value = {}; + resetMessageActionTurnCapabilitiesForTest(); setActivePluginRegistry(createEmptyPluginRegistry()); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.OPENCLAW_GATEWAY_URL; @@ -462,6 +473,69 @@ describe("gateway tool defaults", () => { expect(call.agentRuntimeIdentityToken).toEqual(expect.any(String)); }); + it("mints message action identity only for an admitted turn on the managed local gateway", async () => { + const turnCapability = mintMessageActionTurnCapability({ + agentId: "ops", + runId: "run-1", + sessionKey: "agent:ops:telegram:group:room-1", + sessionId: "session-1", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "room-1", + currentChatType: "group", + }, + }); + await withGatewayToolCallerIdentity( + { agentId: "ops", sessionKey: "agent:ops:telegram:group:room-1" }, + async () => { + const token = await resolveMessageActionAgentRuntimeIdentityToken({ + opts: {}, + target: "local", + turnCapability, + runId: "run-1", + sessionId: "session-1", + }); + expect(token).toEqual(expect.any(String)); + expect(verifyAgentRuntimeIdentityToken(token)).toMatchObject({ + messageActionContext: { + sessionId: "session-1", + requesterAccountId: "default", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "room-1", + currentChatType: "group", + }, + }, + }); + expect( + await resolveMessageActionAgentRuntimeIdentityToken({ + opts: {}, + target: "local", + }), + ).toBeUndefined(); + expect( + await resolveMessageActionAgentRuntimeIdentityToken({ + opts: {}, + target: "remote", + turnCapability, + runId: "run-1", + sessionId: "session-1", + }), + ).toBeUndefined(); + expect( + await resolveMessageActionAgentRuntimeIdentityToken({ + opts: { gatewayToken: "explicit" }, + target: "local", + turnCapability, + runId: "run-1", + sessionId: "session-1", + }), + ).toBeUndefined(); + }, + ); + }); + it("explains stale gateway cron connection metadata rejections", async () => { mocks.callGateway.mockRejectedValueOnce( new Error( diff --git a/src/agents/tools/gateway.ts b/src/agents/tools/gateway.ts index b07a99d98002..16a945cda9cf 100644 --- a/src/agents/tools/gateway.ts +++ b/src/agents/tools/gateway.ts @@ -17,6 +17,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { mintAgentRuntimeIdentityToken } from "../../gateway/agent-runtime-identity-token.js"; import { callGateway } from "../../gateway/call.js"; import { resolveGatewayCredentialsFromConfig, trimToUndefined } from "../../gateway/credentials.js"; +import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; import { resolveLeastPrivilegeOperatorScopesForMethod, type OperatorScope, @@ -327,6 +328,38 @@ async function resolveAgentRuntimeIdentityTokenForGatewayTool(params: { return await mintAgentRuntimeIdentityToken(identity); } +export async function resolveMessageActionAgentRuntimeIdentityToken(params: { + opts: GatewayCallOptions; + target: "local" | "remote"; + turnCapability?: string; + runId?: string; + sessionId?: string; +}): Promise { + const identity = getGatewayToolCallerIdentity(); + if (!identity) { + return undefined; + } + const hasGatewayUrlOverride = trimToUndefined(params.opts.gatewayUrl) !== undefined; + const hasGatewayTokenOverride = trimToUndefined(params.opts.gatewayToken) !== undefined; + if (hasGatewayUrlOverride || hasGatewayTokenOverride || params.target !== "local") { + return undefined; + } + const messageActionContext = resolveMessageActionTurnCapability({ + token: params.turnCapability, + agentId: identity.agentId, + runId: params.runId, + sessionKey: identity.sessionKey, + sessionId: params.sessionId, + }); + if (!messageActionContext) { + return undefined; + } + return await mintAgentRuntimeIdentityToken({ + ...identity, + messageActionContext, + }); +} + function isStaleGatewayAgentRuntimeIdentityRejection(error: unknown): boolean { const message = formatErrorMessage(error); if ( diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 6f37a661c435..ed2cc9695a88 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -9,6 +9,10 @@ import { import type { ChannelMessageAdapterShape } from "../../channels/message/types.js"; import type { ChannelMessageCapability } from "../../channels/plugins/message-capabilities.js"; import type { ChannelMessageActionName, ChannelPlugin } from "../../channels/plugins/types.js"; +import { + mintMessageActionTurnCapability, + resetMessageActionTurnCapabilitiesForTest, +} from "../../gateway/message-action-turn-capability.js"; import type { MessageActionRunResult } from "../../infra/outbound/message-action-runner.js"; import { resetDiagnosticSessionStateForTest } from "../../logging/diagnostic-session-state.js"; import { wrapToolWithBeforeToolCallHook } from "../agent-tools.before-tool-call.js"; @@ -130,18 +134,26 @@ vi.mock("../../channels/plugins/bundled.js", async () => { type RunMessageActionInput = { agentId?: string; cfg?: unknown; + conversationReadOrigin?: "delegated" | "direct-operator"; defaultAccountId?: string; gateway?: { timeoutMs?: unknown; }; params?: Record; + requesterAccountId?: string; requesterSenderId?: string; + messageActionAuthorization?: { + requesterAccountId?: string; + requesterSenderId?: string; + toolContext?: RunMessageActionInput["toolContext"]; + }; sandboxRoot?: string; sessionKey?: string; sourceReplyDeliveryMode?: string; inboundAudio?: boolean; toolContext?: { currentChannelId?: string; + currentChatType?: string; currentMessagingTarget?: string; currentChannelProvider?: string; currentThreadTs?: string; @@ -336,6 +348,7 @@ beforeAll(async () => { beforeEach(() => { resetPluginRuntimeStateForTest(); + resetMessageActionTurnCapabilitiesForTest(); resetDiagnosticSessionStateForTest(); mocks.runMessageAction.mockReset(); mocks.getRuntimeConfig.mockReset().mockReturnValue({}); @@ -805,6 +818,24 @@ describe("message tool secret scoping", () => { expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); }); + it("keeps direct operator authority on the in-process action only", async () => { + mockSendResult(); + + const direct = await executeSend({ + action: { message: "direct" }, + toolOptions: { conversationReadOrigin: "direct-operator" }, + }); + const delegated = await executeSend({ + action: { message: "delegated" }, + toolOptions: { conversationReadOrigin: "delegated" }, + }); + + expect(direct?.conversationReadOrigin).toBe("direct-operator"); + expect(direct?.gateway).toBeUndefined(); + expect(delegated?.conversationReadOrigin).toBe("delegated"); + expect(delegated?.gateway).toMatchObject({ timeoutMs: expect.any(Number) }); + }); + it("reads steered inbound audio when the message action runs", async () => { mockSendResult(); let hasCurrentInboundAudio = false; @@ -1009,12 +1040,34 @@ describe("message tool secret scoping", () => { expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(input?.toolContext?.currentChannelProvider).toBe("telegram"); expect(input?.toolContext?.currentChannelId).toBe("-5150615830"); + expect(input?.toolContext?.currentChatType).toBe("group"); expect(input?.params).toEqual({ action: "send", message: "hi" }); const secretResolveCall = latestSecretResolveCall(); expect(Array.from(secretResolveCall.targetIds ?? [])).toEqual(["channels.telegram.botToken"]); }); + it("preserves a routable current target that differs from the channel id", async () => { + mockSendResult(); + + const input = await executeSend({ + action: { message: "hi" }, + toolOptions: { + currentChannelProvider: "msteams", + currentChannelId: "conversation:19:channel@thread.tacv2", + currentChatType: "channel", + currentMessagingTarget: "graph-team/19:channel@thread.tacv2", + }, + }); + + expect(input?.toolContext).toMatchObject({ + currentChannelProvider: "msteams", + currentChannelId: "conversation:19:channel@thread.tacv2", + currentChatType: "channel", + currentMessagingTarget: "graph-team/19:channel@thread.tacv2", + }); + }); + it("preserves empty opaque target segments in inferred session delivery", async () => { mockSendResult(); @@ -1128,6 +1181,7 @@ describe("message tool secret scoping", () => { expect(input?.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(input?.toolContext?.currentChannelProvider).toBe("msteams"); expect(input?.toolContext?.currentChannelId).toBe("user:user-1"); + expect(input?.toolContext?.currentChatType).toBe("direct"); expect(input?.params).toEqual({ action: "send", message: "hi" }); const secretResolveCall = latestSecretResolveCall(); @@ -1555,6 +1609,7 @@ describe("message tool agent routing", () => { config: {} as never, agentChannel: "slack", currentChannelId: "D123", + currentChatType: "direct", currentMessagingTarget: "user:U123", currentThreadTs: "111.222", replyToMode: "all", @@ -1574,6 +1629,7 @@ describe("message tool agent routing", () => { const call = firstRunMessageActionInput(); expect(call?.toolContext).toMatchObject({ currentChannelId: "D123", + currentChatType: "direct", currentMessagingTarget: "user:U123", currentChannelProvider: "slack", currentThreadTs: "111.222", @@ -3256,17 +3312,95 @@ describe("message tool sandbox passthrough", () => { expect(call?.sandboxRoot).toBe(expected); }); - it("forwards trusted requesterSenderId to runMessageAction", async () => { + it("does not trust ambient current-turn identity without a capability", async () => { mockSendResult({ to: "discord:123" }); const call = await executeSend({ - toolOptions: { requesterSenderId: "1234567890" }, + toolOptions: { + agentId: "main", + agentSessionKey: "agent:main:runtime-policy", + runId: "run-1", + sessionId: "session-1", + agentAccountId: "forged-account", + requesterSenderId: "forged-sender", + currentChannelProvider: "discord", + currentChannelId: "forged-current", + }, action: { target: "discord:123", message: "hi", }, }); - expect(call?.requesterSenderId).toBe("1234567890"); + expect(call?.requesterAccountId).toBeUndefined(); + expect(call?.requesterSenderId).toBeUndefined(); + expect(call?.toolContext).toMatchObject({ + currentChannelProvider: "discord", + currentChannelId: "forged-current", + }); + expect(call?.messageActionAuthorization).toEqual({ + requesterAccountId: undefined, + requesterSenderId: undefined, + toolContext: undefined, + }); + }); + + it("forwards capability-bound current-turn identity to local actions", async () => { + mockSendResult({ to: "discord:123" }); + const token = mintMessageActionTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:runtime-policy", + sessionId: "session-1", + requesterAccountId: "trusted-account", + requesterSenderId: "trusted-sender", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "trusted-current", + currentChatType: "channel", + }, + }); + + const call = await executeSend({ + toolOptions: { + agentId: "main", + agentSessionKey: "agent:main:runtime-policy", + runId: "run-1", + sessionId: "session-1", + messageActionTurnCapability: token, + agentAccountId: "forged-account", + requesterSenderId: "forged-sender", + currentChannelProvider: "discord", + currentChannelId: "forged-current", + }, + action: { + target: "discord:123", + message: "hi", + }, + }); + + expect(call?.requesterAccountId).toBe("trusted-account"); + expect(call?.requesterSenderId).toBe("trusted-sender"); + expect(call?.toolContext).toMatchObject({ + currentChannelProvider: "discord", + currentChannelId: "forged-current", + }); + expect(call?.messageActionAuthorization).toMatchObject({ + requesterAccountId: "trusted-account", + requesterSenderId: "trusted-sender", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "trusted-current", + currentChatType: "channel", + }, + }); + expect(call?.messageActionAuthorization?.toolContext).not.toMatchObject({ + currentChannelId: "forged-current", + }); + expect(call?.toolContext).toMatchObject({ + currentChannelProvider: "discord", + currentChannelId: "forged-current", + skipCrossContextDecoration: true, + }); }); }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index b4f083130d21..107af2555912 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -18,7 +18,9 @@ import { hasInboundMetadataSentinel, stripInboundMetadata, } from "../../auto-reply/reply/strip-inbound-meta.js"; +import type { ChatType } from "../../channels/chat-type.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; +import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { getChannelPlugin, getLoadedChannelPlugin, @@ -43,6 +45,7 @@ import { getBootEchoContextForSession, stripBootEchoFromOutboundText, } from "../../gateway/boot-echo-guard.js"; +import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; import { createAbortError } from "../../infra/abort-signal.js"; import { sha256Base64UrlPrefix } from "../../infra/crypto-digest.js"; import { @@ -81,6 +84,7 @@ import { gatewayCallOptionSchemaProperties } from "./gateway-schema.js"; import { readGatewayCallOptions, resolveGatewayOptions, + resolveMessageActionAgentRuntimeIdentityToken, type GatewayCallOptions, } from "./gateway.js"; import { isPollVoteEchoText } from "./poll-vote-echo.js"; @@ -192,6 +196,7 @@ function resolvePollVoteEchoRoute(params: { channel?: string | null; accountId?: string; currentChannelId?: string; + currentChatType?: ChatType; currentMessagingTarget?: string; }): string | undefined { const channel = normalizeMessageChannel(params.channel); @@ -941,7 +946,9 @@ type MessageToolOptions = { resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway; runMessageAction?: typeof runMessageAction; currentChannelId?: string; + currentChatType?: ChatType; currentMessagingTarget?: string; + messageActionTurnCapability?: string; currentChannelProvider?: string; currentThreadTs?: string; agentThreadId?: string | number; @@ -957,6 +964,7 @@ type MessageToolOptions = { inboundEventKind?: InboundEventKind; requesterSenderId?: string; senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; }; type MessageToolDiscoveryParams = { @@ -981,6 +989,7 @@ type MessageActionDiscoveryInput = Omit + resolveMessageActionAgentRuntimeIdentityToken({ + opts: gatewayOpts, + target: gatewayResolved.target, + turnCapability: options?.messageActionTurnCapability, + runId: options?.runId, + sessionId: options?.sessionId, + }), + }; const hasCurrentMessageId = typeof options?.currentMessageId === "number" || (typeof options?.currentMessageId === "string" && @@ -1499,6 +1547,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { const toolContext = effectiveCurrentChannel.currentChannelId || + effectiveCurrentChannel.currentChatType || effectiveCurrentChannel.currentChannelProvider || effectiveCurrentChannel.currentMessagingTarget || currentThreadTs || @@ -1508,6 +1557,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { options?.sameChannelThreadRequired ? { currentChannelId: effectiveCurrentChannel.currentChannelId, + currentChatType: effectiveCurrentChannel.currentChatType, currentMessagingTarget: effectiveCurrentChannel.currentMessagingTarget, currentChannelProvider: effectiveCurrentChannel.currentChannelProvider, currentThreadTs, @@ -1550,9 +1600,15 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool { action, params: actionParams, defaultAccountId: accountId ?? undefined, - requesterAccountId: agentAccountId, - requesterSenderId: options?.requesterSenderId, + requesterAccountId: trustedTurnContext?.requesterAccountId, + requesterSenderId: trustedTurnContext?.requesterSenderId, + messageActionAuthorization: { + requesterAccountId: trustedTurnContext?.requesterAccountId, + requesterSenderId: trustedTurnContext?.requesterSenderId, + toolContext: trustedTurnContext?.toolContext, + }, senderIsOwner: options?.senderIsOwner, + conversationReadOrigin: options?.conversationReadOrigin, gateway, toolContext, sessionKey: options?.agentSessionKey, diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index 7434c76281d6..9e7dd98e57c8 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -75,6 +75,11 @@ import { resolveGroupSessionKey, type SessionEntry } from "../../config/sessions import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; import { resolveSilentReplyPolicy } from "../../config/silent-reply.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { + isTrustedMessageActionTurnIngress, + mintMessageActionTurnCapability, + revokeMessageActionTurnCapability, +} from "../../gateway/message-action-turn-capability.js"; import { logVerbose } from "../../globals.js"; import { captureAgentRunLifecycleGeneration, @@ -93,9 +98,9 @@ import { defaultRuntime } from "../../runtime.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; import { isMarkdownCapableMessageChannel, + isInternalMessageChannel, resolveMessageChannel, } from "../../utils/message-channel.js"; -import { isInternalMessageChannel } from "../../utils/message-channel.js"; import { stripHeartbeatToken } from "../heartbeat.js"; import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js"; import type { TemplateContext } from "../templating.js"; @@ -2290,6 +2295,37 @@ async function runAgentTurnWithFallbackInternal( (agentHarnessPolicy.runtime === "openclaw" && embeddedRunProvider !== provider ? "openclaw" : undefined); + const messageActionCapabilitySessionKey = + params.runtimePolicySessionKey ?? embeddedContext.sessionKey; + const messageActionTurnCapability = + isTrustedMessageActionTurnIngress(params.sessionCtx.Provider) && + !params.isHeartbeat && + embeddedContext.agentId && + messageActionCapabilitySessionKey && + embeddedContext.messageProvider && + embeddedContext.currentChannelId + ? mintMessageActionTurnCapability({ + agentId: embeddedContext.agentId, + runId, + sessionKey: messageActionCapabilitySessionKey, + sessionId: embeddedContext.sessionId, + requesterAccountId: embeddedContext.agentAccountId, + requesterSenderId: senderContext.senderId, + toolContext: { + currentChannelId: embeddedContext.currentChannelId, + currentChatType: embeddedContext.chatType, + currentMessagingTarget: embeddedContext.currentMessagingTarget, + currentGraphChannelId: embeddedContext.currentGraphChannelId, + currentChannelProvider: embeddedContext.currentChannelProvider, + currentThreadTs: embeddedContext.currentThreadTs, + currentMessageId: embeddedContext.currentMessageId, + replyToMode: embeddedContext.replyToMode, + hasRepliedRef: embeddedContext.hasRepliedRef, + sameChannelThreadRequired: embeddedContext.sameChannelThreadRequired, + }, + ttlMs: runBaseParams.timeoutMs + 60_000, + }) + : undefined; return (async () => { let attemptCompactionCount = 0; const lifecycleBackstop = createAgentLifecycleTerminalBackstop({ @@ -2319,6 +2355,7 @@ async function runAgentTurnWithFallbackInternal( const result = await agentTurnTiming.measure("embedded_run", () => runEmbeddedAgent({ ...embeddedContext, + messageActionTurnCapability, lifecycleGeneration, allowGatewaySubagentBinding: true, trigger: params.isHeartbeat ? "heartbeat" : "user", @@ -2799,6 +2836,7 @@ async function runAgentTurnWithFallbackInternal( return result; } finally { autoCompactionCount += attemptCompactionCount; + revokeMessageActionTurnCapability(messageActionTurnCapability); } })(); }, diff --git a/src/auto-reply/reply/commands-btw.test.ts b/src/auto-reply/reply/commands-btw.test.ts index ee138de40e00..24f82e0a1730 100644 --- a/src/auto-reply/reply/commands-btw.test.ts +++ b/src/auto-reply/reply/commands-btw.test.ts @@ -1,6 +1,7 @@ // Tests background side-question command routing and typing controller integration. import { describe, expect, it, vi, beforeEach } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; +import { resolveMessageActionTurnCapability } from "../../gateway/message-action-turn-capability.js"; import { expectObjectFields, mockCall, @@ -137,7 +138,21 @@ describe("handleBtwCommand", () => { parentSessionKey: "agent:main:parent", updatedAt: Date.now(), }; - runBtwSideQuestionMock.mockResolvedValue({ text: "nothing important" }); + let resolvedTurnContext: ReturnType | undefined; + runBtwSideQuestionMock.mockImplementation(async (input: Record) => { + const opts = input.opts as { runId?: string } | undefined; + resolvedTurnContext = resolveMessageActionTurnCapability({ + token: + typeof input.messageActionTurnCapability === "string" + ? input.messageActionTurnCapability + : undefined, + agentId: "main", + runId: opts?.runId, + sessionKey: "agent:main:runtime-policy", + sessionId: "session-1", + }); + return { text: "nothing important" }; + }); const result = await handleBtwCommand(params, true); @@ -163,6 +178,15 @@ describe("handleBtwCommand", () => { senderIsOwner: true, }); expect(String(runnerArgs.agentDir)).toContain("/agents/main/agent"); + expect(runnerArgs.messageActionTurnCapability).toEqual(expect.any(String)); + expect(runnerArgs.opts).toMatchObject({ runId: expect.any(String) }); + expect(resolvedTurnContext).toMatchObject({ + requesterAccountId: "account-1", + requesterSenderId: "sender-1", + toolContext: { + currentChannelProvider: "whatsapp", + }, + }); expect(result).toEqual({ shouldContinue: false, reply: { text: "nothing important", btw: { question: "what changed?" } }, @@ -172,6 +196,8 @@ describe("handleBtwCommand", () => { it("uses the originating target before the command transport target", async () => { const params = buildParams("/btw what changed?"); params.ctx.OriginatingTo = "channel:source"; + params.ctx.NativeChannelId = "native:source"; + params.ctx.ChatType = "channel"; params.command.to = "slash:transport"; params.agentDir = "/tmp/agent"; params.sessionEntry = { @@ -183,7 +209,10 @@ describe("handleBtwCommand", () => { await handleBtwCommand(params, true); expectObjectFields(mockFirstObjectArg(runBtwSideQuestionMock), { - currentChannelId: "channel:source", + chatId: "native:source", + chatType: "channel", + messageTo: "channel:source", + currentChannelId: "native:source", }); }); @@ -208,6 +237,25 @@ describe("handleBtwCommand", () => { }); }); + it("does not mint current-turn context for Gateway chat with an explicit origin", async () => { + const params = buildParams("/btw what changed?"); + params.ctx.Provider = "webchat"; + params.ctx.OriginatingChannel = "matrix"; + params.ctx.OriginatingTo = "!room:example.org"; + params.command.channel = "matrix"; + params.command.to = "!room:example.org"; + params.agentDir = "/tmp/agent"; + params.sessionEntry = { + sessionId: "session-1", + updatedAt: Date.now(), + }; + runBtwSideQuestionMock.mockResolvedValue({ text: "origin answer" }); + + await handleBtwCommand(params, true); + + expect(mockFirstObjectArg(runBtwSideQuestionMock).messageActionTurnCapability).toBeUndefined(); + }); + it("accepts /side as a /btw alias", async () => { const params = buildParams("/side what changed?"); params.agentDir = "/tmp/agent"; diff --git a/src/auto-reply/reply/commands-btw.ts b/src/auto-reply/reply/commands-btw.ts index fa4d91c99968..5fe4379e8c12 100644 --- a/src/auto-reply/reply/commands-btw.ts +++ b/src/auto-reply/reply/commands-btw.ts @@ -1,7 +1,15 @@ /** Handles /btw side-question commands against the active session context. */ +import { randomUUID } from "node:crypto"; import { resolveAgentDir, resolveSessionAgentId } from "../../agents/agent-scope.js"; import { runBtwSideQuestion } from "../../agents/btw.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; +import { normalizeAnyChannelId } from "../../channels/registry.js"; import { resolveGroupSessionKey } from "../../config/sessions/group.js"; +import { + isTrustedMessageActionTurnIngress, + mintMessageActionTurnCapability, + revokeMessageActionTurnCapability, +} from "../../gateway/message-action-turn-capability.js"; import { extractBtwQuestion } from "./btw-command.js"; import { rejectUnauthorizedCommand } from "./command-gates.js"; import type { CommandHandler } from "./commands-types.js"; @@ -55,62 +63,101 @@ export const handleBtwCommand: CommandHandler = async (params, allowTextCommands try { await params.typing?.startTypingLoop(); - const currentChannelId = + const messageTo = params.ctx.OriginatingTo?.trim() || params.command.to || params.command.channelId; + const nativeChannelId = + params.ctx.NativeChannelId?.trim() || params.ctx.ChatId?.trim() || undefined; + const currentChannelId = nativeChannelId ?? messageTo; + const chatType = normalizeChatType(params.ctx.ChatType); const groupId = resolveGroupSessionKey(params.ctx)?.id ?? targetSessionEntry.groupId; - const reply = await runBtwSideQuestion({ - cfg: params.cfg, - agentDir, - provider: params.provider, - model: params.model, - question, - sessionEntry: targetSessionEntry, - sessionStore: params.sessionStore, - sessionKey: params.sessionKey, - ...(params.ctx.RuntimePolicySessionKey - ? { sandboxSessionKey: params.ctx.RuntimePolicySessionKey } - : {}), - storePath: params.storePath, - // BTW is intentionally a quick side question, so do not inherit slower - // session-level think/reasoning settings from the main run. - resolvedThinkLevel: "off", - resolvedReasoningLevel: "off", - blockReplyChunking: params.blockReplyChunking, - resolvedBlockStreamingBreak: params.resolvedBlockStreamingBreak, - opts: params.opts, - isNewSession: false, - ...(params.command.channel ? { messageChannel: params.command.channel } : {}), - ...(params.command.channel ? { messageProvider: params.command.channel } : {}), - ...(params.ctx.AccountId ? { agentAccountId: params.ctx.AccountId } : {}), - ...(currentChannelId ? { messageTo: currentChannelId } : {}), - ...(params.ctx.MessageThreadId !== undefined - ? { messageThreadId: params.ctx.MessageThreadId } - : params.ctx.TransportThreadId !== undefined - ? { messageThreadId: params.ctx.TransportThreadId } + const runId = params.opts?.runId ?? `btw-${randomUUID()}`; + const currentChannelProvider = normalizeAnyChannelId(params.ctx.Provider); + const capabilitySessionKey = params.ctx.RuntimePolicySessionKey ?? params.sessionKey; + const messageActionTurnCapability = + isTrustedMessageActionTurnIngress(params.ctx.Provider) && + sessionAgentId && + capabilitySessionKey && + currentChannelProvider && + currentChannelId + ? mintMessageActionTurnCapability({ + agentId: sessionAgentId, + runId, + sessionKey: capabilitySessionKey, + sessionId: targetSessionEntry.sessionId, + requesterAccountId: params.ctx.AccountId, + requesterSenderId: params.ctx.SenderId ?? params.command.senderId, + toolContext: { + currentChannelId, + currentChatType: chatType, + currentMessagingTarget: messageTo, + currentChannelProvider, + currentMessageId: params.ctx.MessageSidFull ?? params.ctx.MessageSid, + }, + }) + : undefined; + let reply: Awaited>; + try { + reply = await runBtwSideQuestion({ + cfg: params.cfg, + agentDir, + provider: params.provider, + model: params.model, + question, + sessionEntry: targetSessionEntry, + sessionStore: params.sessionStore, + sessionKey: params.sessionKey, + ...(params.ctx.RuntimePolicySessionKey + ? { sandboxSessionKey: params.ctx.RuntimePolicySessionKey } : {}), - ...(groupId ? { groupId } : {}), - ...(params.ctx.GroupChannel || params.ctx.GroupSubject || targetSessionEntry.groupChannel - ? { - groupChannel: - params.ctx.GroupChannel ?? params.ctx.GroupSubject ?? targetSessionEntry.groupChannel, - } - : {}), - ...(params.ctx.GroupSpace || targetSessionEntry.space - ? { groupSpace: params.ctx.GroupSpace ?? targetSessionEntry.space } - : {}), - ...(params.ctx.MemberRoleIds ? { memberRoleIds: params.ctx.MemberRoleIds } : {}), - ...(targetSessionEntry.parentSessionKey - ? { spawnedBy: targetSessionEntry.parentSessionKey } - : {}), - ...(params.ctx.SenderId || params.command.senderId - ? { senderId: params.ctx.SenderId ?? params.command.senderId } - : {}), - ...(params.ctx.SenderName ? { senderName: params.ctx.SenderName } : {}), - ...(params.ctx.SenderUsername ? { senderUsername: params.ctx.SenderUsername } : {}), - ...(params.ctx.SenderE164 ? { senderE164: params.ctx.SenderE164 } : {}), - senderIsOwner: params.command.senderIsOwner, - ...(currentChannelId ? { currentChannelId } : {}), - }); + storePath: params.storePath, + // BTW is intentionally a quick side question, so do not inherit slower + // session-level think/reasoning settings from the main run. + resolvedThinkLevel: "off", + resolvedReasoningLevel: "off", + blockReplyChunking: params.blockReplyChunking, + resolvedBlockStreamingBreak: params.resolvedBlockStreamingBreak, + opts: { ...params.opts, runId }, + isNewSession: false, + ...(params.command.channel ? { messageChannel: params.command.channel } : {}), + ...(params.command.channel ? { messageProvider: params.command.channel } : {}), + ...(chatType ? { chatType } : {}), + ...(params.ctx.AccountId ? { agentAccountId: params.ctx.AccountId } : {}), + ...(messageTo ? { messageTo } : {}), + ...(params.ctx.MessageThreadId !== undefined + ? { messageThreadId: params.ctx.MessageThreadId } + : params.ctx.TransportThreadId !== undefined + ? { messageThreadId: params.ctx.TransportThreadId } + : {}), + ...(nativeChannelId ? { chatId: nativeChannelId } : {}), + ...(messageActionTurnCapability ? { messageActionTurnCapability } : {}), + ...(groupId ? { groupId } : {}), + ...(params.ctx.GroupChannel || params.ctx.GroupSubject || targetSessionEntry.groupChannel + ? { + groupChannel: + params.ctx.GroupChannel ?? + params.ctx.GroupSubject ?? + targetSessionEntry.groupChannel, + } + : {}), + ...(params.ctx.GroupSpace || targetSessionEntry.space + ? { groupSpace: params.ctx.GroupSpace ?? targetSessionEntry.space } + : {}), + ...(params.ctx.MemberRoleIds ? { memberRoleIds: params.ctx.MemberRoleIds } : {}), + ...(targetSessionEntry.parentSessionKey + ? { spawnedBy: targetSessionEntry.parentSessionKey } + : {}), + ...(params.ctx.SenderId || params.command.senderId + ? { senderId: params.ctx.SenderId ?? params.command.senderId } + : {}), + ...(params.ctx.SenderName ? { senderName: params.ctx.SenderName } : {}), + ...(params.ctx.SenderUsername ? { senderUsername: params.ctx.SenderUsername } : {}), + ...(params.ctx.SenderE164 ? { senderE164: params.ctx.SenderE164 } : {}), + senderIsOwner: params.command.senderIsOwner, + ...(currentChannelId ? { currentChannelId } : {}), + }); + } finally { + revokeMessageActionTurnCapability(messageActionTurnCapability); + } return { shouldContinue: false, reply: reply ? { ...reply, btw: { question } } : reply, diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index 28d26996ee72..28480a393887 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -847,6 +847,7 @@ describe("handleInlineActions", () => { const ctx = buildTestCtx({ Body: "/set_profile display name", CommandBody: "/set_profile display name", + NativeChannelId: "oc_native_chat", }); const skillCommands: SkillCommandSpec[] = [ { @@ -887,6 +888,7 @@ describe("handleInlineActions", () => { expect(result).toEqual({ kind: "reply", reply: { text: "✅ Done." } }); const toolsArgs = mockObjectArg(createOpenClawToolsMock, "createOpenClawTools"); expect(toolsArgs).not.toHaveProperty("senderIsOwner"); + expect(toolsArgs.nativeChannelId).toBe("oc_native_chat"); expect(toolsArgs.beforeToolCallHookContext).toMatchObject({ cwd: "/tmp", workspaceDir: "/tmp", diff --git a/src/auto-reply/reply/get-reply-inline-actions.ts b/src/auto-reply/reply/get-reply-inline-actions.ts index 2b68375fa4ba..e4a143113819 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.ts @@ -360,6 +360,7 @@ export async function handleInlineActions(params: { senderE164: ctx.SenderE164, originatingTo: ctx.OriginatingTo, to: ctx.To, + nativeChannelId: ctx.NativeChannelId, messageThreadId: ctx.MessageThreadId, memberRoleIds: ctx.MemberRoleIds, }, diff --git a/src/channels/plugins/conversation-read-origin.test.ts b/src/channels/plugins/conversation-read-origin.test.ts new file mode 100644 index 000000000000..3e11a42d18a9 --- /dev/null +++ b/src/channels/plugins/conversation-read-origin.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; +import { normalizeConversationReadInvocationOrigin } from "./conversation-read-origin.js"; + +describe("normalizeConversationReadInvocationOrigin", () => { + it.each([ + [undefined, "delegated"], + [null, "delegated"], + ["delegated", "delegated"], + ["DIRECT-OPERATOR", "delegated"], + ["unknown", "delegated"], + [{}, "delegated"], + ["direct-operator", "direct-operator"], + ] as const)("normalizes %j to %s", (value, expected) => { + expect(normalizeConversationReadInvocationOrigin(value)).toBe(expected); + }); +}); diff --git a/src/channels/plugins/conversation-read-origin.ts b/src/channels/plugins/conversation-read-origin.ts new file mode 100644 index 000000000000..9845c2122322 --- /dev/null +++ b/src/channels/plugins/conversation-read-origin.ts @@ -0,0 +1,13 @@ +/** + * Server-owned origin for one tool or message-action invocation. + * + * Missing and unknown values must remain delegated; callers must never derive + * this from model arguments, provider parameters, config, or persisted state. + */ +export type ConversationReadInvocationOrigin = "delegated" | "direct-operator"; + +export function normalizeConversationReadInvocationOrigin( + value: unknown, +): ConversationReadInvocationOrigin { + return value === "direct-operator" ? "direct-operator" : "delegated"; +} diff --git a/src/channels/plugins/message-action-dispatch.ts b/src/channels/plugins/message-action-dispatch.ts index fb0a41a2e876..c724e44d1e99 100644 --- a/src/channels/plugins/message-action-dispatch.ts +++ b/src/channels/plugins/message-action-dispatch.ts @@ -4,11 +4,418 @@ * Runs plugin-owned message actions from the shared agent tool with sender trust checks. */ import type { AgentToolResult } from "../../agents/runtime/index.js"; -import { getChannelPlugin } from "./index.js"; -import type { ChannelMessageActionContext } from "./types.public.js"; +import { normalizeOptionalAccountId, normalizeAccountId } from "../../routing/account-id.js"; +import { normalizeChatType, type ChatType } from "../chat-type.js"; +import { normalizeConversationReadInvocationOrigin } from "./conversation-read-origin.js"; +import { resolveChannelPluginRegistration } from "./registry.js"; +import type { + ChannelMessageActionContext, + ChannelMessageActionName, + ChannelPlugin, +} from "./types.js"; -function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boolean { - const plugin = getChannelPlugin(ctx.channel); +const READ_DEPENDENT_ACTIONS = new Set([ + "poll-vote", + "react", + "reactions", + "read", + "edit", + "unsend", + "delete", + "pin", + "unpin", + "list-pins", + "permissions", + "thread-list", + "search", + "sticker-search", + "member-info", + "role-info", + "emoji-list", + "channel-info", + "channel-list", + "voice-status", + "event-list", + "download-file", +]); + +// These bundled adapters have host-reviewed provider-side current/configured +// gates. Other bundled adapters retain the exact-current compatibility limit. +const BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES = new Set([ + "discord", + "feishu", + "matrix", + "msteams", + "slack", +]); + +type HostConversationTargetKind = + | "user" + | "channel" + | "room" + | "chat" + | "group" + | "dm" + | "conversation"; + +type HostConversationTarget = { + id: string; + kind?: HostConversationTargetKind; +}; + +const HOST_TARGET_KIND_PREFIXES = new Set([ + "user", + "channel", + "room", + "chat", + "group", + "dm", + "conversation", +]); + +function stripHostProviderPrefix(params: { + value: string; + channel: string; + providerPrefixes?: readonly string[]; +}): string { + const prefixes = [params.channel, ...(params.providerPrefixes ?? [])] + .map((prefix) => prefix.trim().toLowerCase()) + .filter( + (prefix): prefix is string => + Boolean(prefix) && !HOST_TARGET_KIND_PREFIXES.has(prefix as HostConversationTargetKind), + ); + const lowered = params.value.toLowerCase(); + const prefix = prefixes.find((candidate) => lowered.startsWith(`${candidate}:`)); + return prefix ? params.value.slice(prefix.length + 1).trim() : params.value; +} + +function normalizeHostConversationTarget(params: { + value: unknown; + channel: string; + impliedKind?: HostConversationTargetKind; + normalizeTarget?: (raw: string) => string | undefined; + providerPrefixes?: readonly string[]; +}): HostConversationTarget | undefined { + if (typeof params.value !== "string") { + return undefined; + } + const rawValue = params.value.trim(); + const value = params.normalizeTarget ? params.normalizeTarget(rawValue)?.trim() : rawValue; + if (!value) { + return undefined; + } + const withoutProvider = stripHostProviderPrefix({ + value, + channel: params.channel, + providerPrefixes: params.providerPrefixes, + }); + if (!withoutProvider) { + return undefined; + } + const typedTarget = withoutProvider.match( + /^(user|channel|room|chat|group|dm|conversation):(.*)$/i, + ); + if (typedTarget) { + const id = typedTarget[2]?.trim(); + if (!id) { + return undefined; + } + return { + id, + kind: typedTarget[1]?.toLowerCase() as HostConversationTargetKind, + }; + } + return { + id: withoutProvider, + ...(params.impliedKind ? { kind: params.impliedKind } : {}), + }; +} + +function targetKey(target: HostConversationTarget): string { + return `${target.kind ?? ""}\0${target.id}`; +} + +function addHostConversationTarget( + targets: Map, + target: HostConversationTarget | undefined, +): void { + if (target) { + targets.set(targetKey(target), target); + } +} + +function hasConflictingTargetKinds(targets: HostConversationTarget[]): boolean { + const kindsById = new Map>(); + for (const target of targets) { + if (!target.kind) { + continue; + } + const kinds = kindsById.get(target.id) ?? new Set(); + kinds.add(target.kind); + kindsById.set(target.id, kinds); + } + return Array.from(kindsById.values()).some((kinds) => kinds.size > 1); +} + +function currentTargetsMatchRequested(params: { + currentTargets: HostConversationTarget[]; + requestedTargets: HostConversationTarget[]; + requestedTarget: HostConversationTarget; + currentChatType?: ChatType; +}): boolean { + const sameId = params.currentTargets.filter( + (currentTarget) => currentTarget.id === params.requestedTarget.id, + ); + if (sameId.length === 0 || !params.requestedTarget.kind) { + return sameId.length > 0; + } + const typedCurrentTargets = sameId.filter((currentTarget) => currentTarget.kind); + if (typedCurrentTargets.length === 0) { + const hasCanonicalSibling = params.requestedTargets.some( + (requestedTarget) => + requestedTarget.id === params.requestedTarget.id && !requestedTarget.kind, + ); + if (!hasCanonicalSibling) { + return false; + } + if (params.currentChatType === "direct") { + return params.requestedTarget.kind === "user" || params.requestedTarget.kind === "dm"; + } + if (params.currentChatType === "group") { + return params.requestedTarget.kind === "group" || params.requestedTarget.kind === "room"; + } + if (params.currentChatType === "channel") { + return params.requestedTarget.kind === "channel"; + } + return false; + } + return typedCurrentTargets.some( + (currentTarget) => currentTarget.kind === params.requestedTarget.kind, + ); +} + +function hasMatchingCurrentAccountContext(ctx: ChannelMessageActionContext): boolean { + const rawAccountId = ctx.accountId?.trim() ?? ""; + const rawRequesterAccountId = ctx.requesterAccountId?.trim() ?? ""; + if (!rawRequesterAccountId) { + return false; + } + if ( + (rawAccountId && !normalizeOptionalAccountId(rawAccountId)) || + !normalizeOptionalAccountId(rawRequesterAccountId) + ) { + return false; + } + return normalizeAccountId(rawAccountId) === normalizeAccountId(rawRequesterAccountId); +} + +function hasMatchingCurrentProviderContext(ctx: ChannelMessageActionContext): boolean { + const currentProvider = ctx.toolContext?.currentChannelProvider?.trim().toLowerCase(); + return Boolean(currentProvider && currentProvider === ctx.channel.trim().toLowerCase()); +} + +function hasCurrentConversationTarget(ctx: ChannelMessageActionContext): boolean { + return [ctx.toolContext?.currentChannelId, ctx.toolContext?.currentMessagingTarget].some( + (value) => typeof value === "string" && Boolean(value.trim()), + ); +} + +function hasTargetInput(value: unknown): boolean { + if (typeof value === "string") { + return Boolean(value.trim()); + } + return typeof value === "number" && Number.isFinite(value); +} + +function isExactCurrentConversation(params: { + ctx: ChannelMessageActionContext; + plugin: ChannelPlugin; + pluginOrigin: string | undefined; +}): boolean { + if ( + !hasMatchingCurrentProviderContext(params.ctx) || + !hasMatchingCurrentAccountContext(params.ctx) + ) { + return false; + } + const normalizeTarget = + params.pluginOrigin === "bundled" ? params.plugin.messaging?.normalizeTarget : undefined; + const providerPrefixes = params.plugin.messaging?.targetPrefixes; + const aliasSpec = + params.pluginOrigin === "bundled" + ? params.plugin.actions?.messageActionTargetAliases?.[params.ctx.action] + : undefined; + const deliveryTargetAliases = new Set(aliasSpec?.deliveryTargetAliases ?? []); + const requestedTargets = new Map(); + for (const [key, impliedKind] of [ + ["target", undefined], + ["to", undefined], + ["channelId", "channel"], + ["roomId", "room"], + ["chatId", "chat"], + ] as const) { + const rawTarget = params.ctx.params[key]; + if (deliveryTargetAliases.has(key)) { + continue; + } + const normalizedTarget = normalizeHostConversationTarget({ + value: rawTarget, + channel: params.ctx.channel, + impliedKind, + normalizeTarget, + providerPrefixes, + }); + if (hasTargetInput(rawTarget) && !normalizedTarget) { + return false; + } + addHostConversationTarget(requestedTargets, normalizedTarget); + } + let hasDeliveryAliasInput = false; + let normalizedAliasTarget: HostConversationTarget | undefined; + if (params.pluginOrigin === "bundled") { + hasDeliveryAliasInput = (aliasSpec?.deliveryTargetAliases ?? []).some((alias) => + hasTargetInput(params.ctx.params[alias]), + ); + const resolvedAliasTarget = aliasSpec?.resolveDeliveryTarget?.({ args: params.ctx.params }); + normalizedAliasTarget = normalizeHostConversationTarget({ + value: resolvedAliasTarget, + channel: params.ctx.channel, + normalizeTarget, + providerPrefixes, + }); + if ( + (hasDeliveryAliasInput && !resolvedAliasTarget) || + (resolvedAliasTarget !== undefined && !normalizedAliasTarget) + ) { + return false; + } + addHostConversationTarget(requestedTargets, normalizedAliasTarget); + } + const normalizedAliasTargetKey = normalizedAliasTarget + ? targetKey(normalizedAliasTarget) + : undefined; + // Normalization mirrors a delivery alias into target/to. Treat that exact + // canonical value as the alias itself; distinct sibling targets still block. + const nonAliasRequestedTargets = Array.from(requestedTargets.values()).filter( + (target) => targetKey(target) !== normalizedAliasTargetKey, + ); + const requestedTargetList = Array.from(requestedTargets.values()); + if (hasConflictingTargetKinds(requestedTargetList)) { + return false; + } + const currentTargets = new Map(); + for (const value of [ + params.ctx.toolContext?.currentChannelId, + params.ctx.toolContext?.currentMessagingTarget, + ]) { + addHostConversationTarget( + currentTargets, + normalizeHostConversationTarget({ + value, + channel: params.ctx.channel, + normalizeTarget, + providerPrefixes, + }), + ); + } + const currentTargetList = Array.from(currentTargets.values()); + if (currentTargetList.length === 0 || hasConflictingTargetKinds(currentTargetList)) { + return false; + } + if (requestedTargetList.length === 0) { + return false; + } + const currentChatType = normalizeChatType(params.ctx.toolContext?.currentChatType); + const matchesCurrentTarget = (requestedTarget: HostConversationTarget) => + currentTargetsMatchRequested({ + currentTargets: currentTargetList, + requestedTargets: requestedTargetList, + requestedTarget, + currentChatType, + }); + if (requestedTargetList.every(matchesCurrentTarget)) { + return true; + } + if ( + params.pluginOrigin !== "bundled" || + !hasDeliveryAliasInput || + !params.ctx.toolContext || + !aliasSpec?.matchesCurrentConversation || + !nonAliasRequestedTargets.every(matchesCurrentTarget) + ) { + return false; + } + return aliasSpec.matchesCurrentConversation({ + args: params.ctx.params, + accountId: normalizeAccountId(params.ctx.accountId), + toolContext: params.ctx.toolContext, + }); +} + +function assertConversationReadAllowed(params: { + ctx: ChannelMessageActionContext; + plugin: ChannelPlugin; + pluginOrigin: string | undefined; +}): void { + const usesBundledProviderReadGate = + params.pluginOrigin === "bundled" && + BUNDLED_CHANNELS_WITH_PROVIDER_READ_GATES.has(params.ctx.channel); + if ( + normalizeConversationReadInvocationOrigin(params.ctx.conversationReadOrigin) === + "direct-operator" || + usesBundledProviderReadGate || + !READ_DEPENDENT_ACTIONS.has(params.ctx.action) + ) { + return; + } + const isBundledCurrentContextCacheRead = + params.pluginOrigin === "bundled" && + params.ctx.action === "sticker-search" && + hasMatchingCurrentProviderContext(params.ctx) && + hasMatchingCurrentAccountContext(params.ctx) && + hasCurrentConversationTarget(params.ctx); + if ( + isBundledCurrentContextCacheRead || + isExactCurrentConversation({ + ctx: params.ctx, + plugin: params.plugin, + pluginOrigin: params.pluginOrigin, + }) + ) { + return; + } + throw new Error( + `Delegated ${params.ctx.channel}:${params.ctx.action} requires the exact current conversation and account for this plugin.`, + ); +} + +function canonicalizeExternalExactCurrentTarget(params: { + ctx: ChannelMessageActionContext; + pluginOrigin: string | undefined; +}): void { + if ( + params.pluginOrigin === "bundled" || + normalizeConversationReadInvocationOrigin(params.ctx.conversationReadOrigin) === + "direct-operator" || + !READ_DEPENDENT_ACTIONS.has(params.ctx.action) + ) { + return; + } + const target = params.ctx.params.target; + const resolvedTarget = [params.ctx.params.to, params.ctx.params.channelId].find( + (value): value is string => typeof value === "string" && Boolean(value.trim()), + ); + if (typeof target === "string" && target.trim() && resolvedTarget) { + // Authorization used the raw spelling. Plugin execution receives the + // resolved destination so it cannot reinterpret an accepted kind alias. + params.ctx.params.target = resolvedTarget; + } +} + +function requiresTrustedRequesterSender( + ctx: ChannelMessageActionContext, + plugin: ChannelPlugin, +): boolean { return Boolean( plugin?.actions?.requiresTrustedRequesterSender?.({ action: ctx.action, @@ -23,21 +430,37 @@ function requiresTrustedRequesterSender(ctx: ChannelMessageActionContext): boole export async function dispatchChannelMessageAction( ctx: ChannelMessageActionContext, ): Promise | null> { + const registration = resolveChannelPluginRegistration(ctx.channel); + if (!registration) { + return null; + } + const { plugin } = registration; + const actions = plugin.actions; + if (!actions?.handleAction) { + return null; + } + // Loader provenance is host-owned. External and legacy registrations must + // prove the exact current conversation before any plugin callback can run. + assertConversationReadAllowed({ + ctx, + plugin, + pluginOrigin: registration.origin, + }); + canonicalizeExternalExactCurrentTarget({ + ctx, + pluginOrigin: registration.origin, + }); // Some plugin actions depend on the sender identity to enforce channel-local - // trust. Reject tool-driven calls before invoking the plugin without it. - if (requiresTrustedRequesterSender(ctx) && !ctx.requesterSenderId?.trim()) { + // trust. Reject tool-driven calls before invoking the action without it. + if (requiresTrustedRequesterSender(ctx, plugin) && !ctx.requesterSenderId?.trim()) { throw new Error( `Trusted sender identity is required for ${ctx.channel}:${ctx.action} in tool-driven contexts.`, ); } - const plugin = getChannelPlugin(ctx.channel); - if (!plugin?.actions?.handleAction) { - return null; - } // `handleAction` may be broad; `supportsAction` lets plugins cheaply decline // action names before the dispatcher enters channel-specific behavior. - if (plugin.actions.supportsAction && !plugin.actions.supportsAction({ action: ctx.action })) { + if (actions.supportsAction && !actions.supportsAction({ action: ctx.action })) { return null; } - return await plugin.actions.handleAction(ctx); + return await actions.handleAction(ctx); } diff --git a/src/channels/plugins/message-actions.security.test.ts b/src/channels/plugins/message-actions.security.test.ts index 007521f14d8f..9932191a8083 100644 --- a/src/channels/plugins/message-actions.security.test.ts +++ b/src/channels/plugins/message-actions.security.test.ts @@ -8,9 +8,9 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { dispatchChannelMessageAction } from "./message-action-dispatch.js"; -import type { ChannelPlugin } from "./types.js"; +import type { ChannelMessageActionContext, ChannelPlugin } from "./types.js"; -const handleAction = vi.fn(async () => jsonResult({ ok: true })); +const handleAction = vi.fn(async (_ctx: ChannelMessageActionContext) => jsonResult({ ok: true })); const emptyRegistry = createTestRegistry([]); @@ -81,3 +81,1090 @@ describe("dispatchChannelMessageAction trusted sender guard", () => { expect(handleAction).toHaveBeenCalledOnce(); }); }); + +describe("dispatchChannelMessageAction conversation-read provenance", () => { + const supportsAction = vi.fn(() => true); + const requiresTrustedRequesterSender = vi.fn(() => false); + + function setReadPlugin(params?: { + channel?: ChannelPlugin["id"]; + origin?: string; + strayPolicy?: string; + normalizeTarget?: (raw: string) => string | undefined; + targetPrefixes?: readonly string[]; + messageActionTargetAliases?: NonNullable< + NonNullable["messageActionTargetAliases"] + >; + }) { + const channel = params?.channel ?? "discord"; + const plugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: channel, + label: channel, + capabilities: { chatTypes: ["direct", "group"] }, + config: { + listAccountIds: () => ["default"], + }, + }), + ...(params?.normalizeTarget || params?.targetPrefixes + ? { + messaging: { + normalizeTarget: params.normalizeTarget, + targetPrefixes: params.targetPrefixes, + }, + } + : {}), + actions: { + ...(params?.strayPolicy + ? ({ conversationReadPolicy: params.strayPolicy } as Record) + : {}), + describeMessageTool: () => ({ actions: ["read", "send"] }), + supportsAction, + requiresTrustedRequesterSender, + messageActionTargetAliases: params?.messageActionTargetAliases, + handleAction, + }, + }; + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: channel, + source: "test", + plugin, + ...(params?.origin ? { origin: params.origin as never } : {}), + }, + ]), + ); + } + + beforeEach(() => { + handleAction.mockClear(); + supportsAction.mockClear(); + requiresTrustedRequesterSender.mockClear(); + }); + + afterEach(() => { + setActivePluginRegistry(emptyRegistry); + }); + + it("allows a non-bundled delegated read of the exact current conversation and account", async () => { + setReadPlugin(); + + await dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "channel:current" }, + accountId: "Work", + requesterAccountId: "work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "discord:channel:current", + }, + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("matches a sanitized channelId to a typed current-channel target", async () => { + setReadPlugin(); + + await dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "current", + channelId: "current", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:current", + }, + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "cross-conversation target", + params: { channelId: "other" }, + accountId: "default", + requesterAccountId: "default", + }, + { + name: "missing target", + params: {}, + accountId: "default", + requesterAccountId: "default", + }, + { + name: "wrong account", + params: { channelId: "current" }, + accountId: "other", + requesterAccountId: "default", + }, + { + name: "missing requester account", + params: { channelId: "current" }, + accountId: "default", + requesterAccountId: undefined, + }, + { + name: "invalid account", + params: { channelId: "current" }, + accountId: "!!!", + requesterAccountId: "default", + }, + { + name: "missing current provider", + params: { channelId: "current" }, + accountId: "default", + requesterAccountId: "default", + currentChannelProvider: undefined, + }, + { + name: "different current provider", + params: { channelId: "current" }, + accountId: "default", + requesterAccountId: "default", + currentChannelProvider: "slack", + }, + ])("rejects a non-bundled delegated read with $name before plugin code", async (testCase) => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: testCase.params, + accountId: testCase.accountId, + requesterAccountId: testCase.requesterAccountId, + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: + "currentChannelProvider" in testCase ? testCase.currentChannelProvider : "discord", + currentChannelId: "current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(supportsAction).not.toHaveBeenCalled(); + expect(requiresTrustedRequesterSender).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("allows direct operators through a non-bundled adapter", async () => { + setReadPlugin(); + + await dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "other" }, + conversationReadOrigin: "direct-operator", + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("does not confuse user and channel targets that share an identifier", async () => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "channel:123" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentMessagingTarget: "user:123", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not match a typed request to an untyped current target", async () => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { target: "user:123" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "123", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not let a bare current-channel alias erase a trusted target kind", async () => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "channel:123", + channelId: "123", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "123", + currentMessagingTarget: "user:123", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("fails closed when trusted current targets disagree on semantic kind", async () => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "123", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:123", + currentMessagingTarget: "user:123", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("rejects conflicting target aliases even when one names the current conversation", async () => { + setReadPlugin(); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { + channelId: "current", + target: "channel:other", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("keeps non-read actions compatible on a non-bundled adapter", async () => { + setReadPlugin(); + + await dispatchChannelMessageAction({ + channel: "discord", + action: "send", + cfg: {} as OpenClawConfig, + params: { to: "other" }, + conversationReadOrigin: "delegated", + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("delegates configured-target policy to a bundled adapter", async () => { + setReadPlugin({ origin: "bundled" }); + + await dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "configured" }, + conversationReadOrigin: "delegated", + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("keeps unaudited bundled adapters on the exact-current host limit", async () => { + setReadPlugin({ channel: "telegram", origin: "bundled" }); + + await expect( + dispatchChannelMessageAction({ + channel: "telegram", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "configured" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("uses bundled provider target normalization for equivalent exact-current forms", async () => { + const normalizeTarget = vi.fn((raw: string) => { + const room = raw + .trim() + .replace(/^(?:nextcloud-talk|nc-talk|nc):/i, "") + .replace(/^room:/i, "") + .trim(); + return room ? `nextcloud-talk:${room.toLowerCase()}` : undefined; + }); + setReadPlugin({ + channel: "nextcloud-talk", + origin: "bundled", + normalizeTarget, + }); + + await dispatchChannelMessageAction({ + channel: "nextcloud-talk", + action: "read", + cfg: {} as OpenClawConfig, + params: { to: "nc:room:Current" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "nextcloud-talk", + currentChannelId: "nextcloud-talk:current", + }, + }); + + expect(normalizeTarget).toHaveBeenCalled(); + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("does not use an external provider normalizer to widen delegated reads", async () => { + const normalizeTarget = vi.fn(() => "discord:channel:current"); + setReadPlugin({ + channel: "discord", + origin: "workspace", + normalizeTarget, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "other" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "channel:current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(normalizeTarget).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it.each(["nextcloud-talk:current", "nc-talk:current", "nc:current", "room:current"])( + "allows the external exact-current provider spelling %s", + async (target) => { + const normalizeTarget = vi.fn(() => "nextcloud-talk:other"); + setReadPlugin({ + channel: "nextcloud-talk", + origin: "workspace", + targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"], + normalizeTarget, + }); + + await dispatchChannelMessageAction({ + channel: "nextcloud-talk", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target, + to: "nextcloud-talk:current", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "nextcloud-talk", + currentChannelId: "nextcloud-talk:current", + currentChatType: "group", + }, + }); + + expect(normalizeTarget).not.toHaveBeenCalled(); + expect(handleAction.mock.calls[0]?.[0].params.target).toBe("nextcloud-talk:current"); + expect(handleAction).toHaveBeenCalledOnce(); + }, + ); + + it("does not let an external provider prefix erase a conflicting target kind", async () => { + setReadPlugin({ + channel: "nextcloud-talk", + origin: "workspace", + targetPrefixes: ["user"], + }); + + await expect( + dispatchChannelMessageAction({ + channel: "nextcloud-talk", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "user:current", + to: "nextcloud-talk:current", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "nextcloud-talk", + currentChannelId: "nextcloud-talk:current", + currentChatType: "group", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("requires a canonical sibling before accepting a typed external room target", async () => { + setReadPlugin({ + channel: "nextcloud-talk", + origin: "workspace", + }); + + await expect( + dispatchChannelMessageAction({ + channel: "nextcloud-talk", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "room:current", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "nextcloud-talk", + currentChannelId: "nextcloud-talk:current", + currentChatType: "group", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not confuse group and channel targets that share an identifier", async () => { + setReadPlugin({ + channel: "nextcloud-talk", + origin: "workspace", + }); + + await expect( + dispatchChannelMessageAction({ + channel: "nextcloud-talk", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "group:current", + to: "nextcloud-talk:current", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "nextcloud-talk", + currentChannelId: "nextcloud-talk:current", + currentChatType: "channel", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not let failed bundled target normalization fall through as resource-only", async () => { + setReadPlugin({ + channel: "imessage", + origin: "bundled", + normalizeTarget: (raw) => (raw.includes("current") ? raw : undefined), + messageActionTargetAliases: { + read: { + aliases: ["messageId"], + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "malformed-target", + to: "chat_guid:iMessage;+;current", + messageId: "current-message", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("uses bundled delivery aliases for an exact-current provider target", async () => { + const resolveDeliveryTarget = vi.fn(({ args }: { args: Record }) => { + const chatGuid = typeof args.chatGuid === "string" ? args.chatGuid.trim() : ""; + return chatGuid ? `chat_guid:${chatGuid}` : undefined; + }); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + normalizeTarget: (raw) => raw.trim() || undefined, + messageActionTargetAliases: { + read: { + aliases: ["chatGuid", "messageId"], + deliveryTargetAliases: ["chatGuid"], + resolveDeliveryTarget, + }, + }, + }); + + await dispatchChannelMessageAction({ + channel: "imessage", + action: "read", + cfg: {} as OpenClawConfig, + params: { chatGuid: "iMessage;+;current" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }); + + expect(resolveDeliveryTarget).toHaveBeenCalledOnce(); + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("uses a bundled numeric chatId delivery alias for an exact-current provider target", async () => { + const resolveDeliveryTarget = vi.fn(({ args }: { args: Record }) => + typeof args.chatId === "number" && Number.isInteger(args.chatId) && args.chatId > 0 + ? `chat_id:${args.chatId}` + : undefined, + ); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + normalizeTarget: (raw) => raw.trim() || undefined, + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget, + }, + }, + }); + + await dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatId: 42, messageId: "current-message" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_id:42", + }, + }); + + expect(resolveDeliveryTarget).toHaveBeenCalledOnce(); + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("uses a bundled owner matcher for equivalent provider-native current targets", async () => { + const matchesCurrentConversation = vi.fn(() => true); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + normalizeTarget: (raw) => raw.trim() || undefined, + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget: ({ args }) => `chat_id:${String(args.chatId)}`, + matchesCurrentConversation, + }, + }, + }); + + await dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatId: 42, messageId: "current-message" }, + accountId: "Work", + requesterAccountId: "work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "imessage:current-handle", + currentMessageId: "current-message", + }, + }); + + expect(matchesCurrentConversation).toHaveBeenCalledWith({ + args: { chatId: 42, messageId: "current-message" }, + accountId: "work", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "imessage:current-handle", + currentMessageId: "current-message", + }, + }); + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("does not mistake a normalized delivery alias target for a conflicting target", async () => { + const matchesCurrentConversation = vi.fn(() => true); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget: ({ args }) => `chat_id:${String(args.chatId)}`, + matchesCurrentConversation, + }, + }, + }); + + const normalizedAliasTarget = "chat_id:42"; + await dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { + target: normalizedAliasTarget, + to: normalizedAliasTarget, + chatId: 42, + messageId: "current-message", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "current-handle", + currentMessageId: "current-message", + }, + }); + + expect(matchesCurrentConversation).toHaveBeenCalledOnce(); + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it("fails closed when a bundled owner matcher cannot prove alias equivalence", async () => { + const matchesCurrentConversation = vi.fn(() => false); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget: ({ args }) => `chat_id:${String(args.chatId)}`, + matchesCurrentConversation, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatId: 42, messageId: "current-message" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "current-handle", + currentMessageId: "current-message", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(matchesCurrentConversation).toHaveBeenCalledOnce(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not consult an external plugin owner matcher", async () => { + const matchesCurrentConversation = vi.fn(() => true); + setReadPlugin({ + channel: "imessage", + origin: "workspace", + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget: ({ args }) => `chat_id:${String(args.chatId)}`, + matchesCurrentConversation, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { chatId: 42, messageId: "current-message" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "current-handle", + currentMessageId: "current-message", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(matchesCurrentConversation).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not let an alias matcher override a conflicting canonical target", async () => { + const matchesCurrentConversation = vi.fn(() => true); + setReadPlugin({ + channel: "imessage", + origin: "bundled", + messageActionTargetAliases: { + react: { + aliases: ["chatId", "messageId"], + deliveryTargetAliases: ["chatId"], + resolveDeliveryTarget: ({ args }) => `chat_id:${String(args.chatId)}`, + matchesCurrentConversation, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "react", + cfg: {} as OpenClawConfig, + params: { + target: "other-handle", + chatId: 42, + messageId: "current-message", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "current-handle", + currentMessageId: "current-message", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(matchesCurrentConversation).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("rejects an unnormalizable bundled delivery alias even with a valid sibling target", async () => { + setReadPlugin({ + channel: "imessage", + origin: "bundled", + normalizeTarget: (raw) => (raw.includes("current") ? raw : undefined), + messageActionTargetAliases: { + read: { + aliases: ["chatGuid"], + deliveryTargetAliases: ["chatGuid"], + resolveDeliveryTarget: ({ args }) => + typeof args.chatGuid === "string" ? `chat_guid:${args.chatGuid}` : undefined, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "read", + cfg: {} as OpenClawConfig, + params: { + to: "chat_guid:iMessage;+;current", + chatGuid: "iMessage;+;other", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it.each([ + { action: "react" as const, params: { messageId: "current-message" } }, + { action: "edit" as const, params: { messageId: "current-message" } }, + { action: "unsend" as const, params: { messageId: "current-message" } }, + { action: "poll-vote" as const, params: { pollId: "current-poll" } }, + ])( + "does not treat bundled $action resource-only input as conversation authority", + async (testCase) => { + setReadPlugin({ + channel: "imessage", + origin: "bundled", + messageActionTargetAliases: { + [testCase.action]: { + aliases: Object.keys(testCase.params), + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: testCase.action, + cfg: {} as OpenClawConfig, + params: testCase.params, + accountId: "work", + requesterAccountId: "work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }, + ); + + it("does not let a bundled resource id override an explicit cross-conversation target", async () => { + setReadPlugin({ + channel: "imessage", + origin: "bundled", + messageActionTargetAliases: { + read: { + aliases: ["messageId"], + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "read", + cfg: {} as OpenClawConfig, + params: { + target: "chat_guid:iMessage;+;other", + messageId: "current-message", + }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not let an external resource alias opt into targetless delegated reads", async () => { + const resolveDeliveryTarget = vi.fn(() => "chat_guid:iMessage;+;current"); + setReadPlugin({ + channel: "imessage", + origin: "workspace", + messageActionTargetAliases: { + read: { + aliases: ["messageId"], + resolveDeliveryTarget, + }, + }, + }); + + await expect( + dispatchChannelMessageAction({ + channel: "imessage", + action: "read", + cfg: {} as OpenClawConfig, + params: { messageId: "current-message" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "imessage", + currentChannelId: "chat_guid:iMessage;+;current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(resolveDeliveryTarget).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("allows bundled targetless sticker-cache reads only in matching current context", async () => { + setReadPlugin({ channel: "telegram", origin: "bundled" }); + + await dispatchChannelMessageAction({ + channel: "telegram", + action: "sticker-search", + cfg: {} as OpenClawConfig, + params: { query: "party", limit: 5 }, + accountId: "work", + requesterAccountId: "work", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "telegram", + currentChannelId: "123", + }, + }); + + expect(handleAction).toHaveBeenCalledOnce(); + }); + + it.each([ + { + name: "missing current provider", + accountId: "default", + requesterAccountId: "default", + currentChannelProvider: undefined, + }, + { + name: "wrong current provider", + accountId: "default", + requesterAccountId: "default", + currentChannelProvider: "discord", + }, + { + name: "wrong account", + accountId: "other", + requesterAccountId: "default", + currentChannelProvider: "telegram", + currentChannelId: "123", + }, + { + name: "missing current target", + accountId: "default", + requesterAccountId: "default", + currentChannelProvider: "telegram", + currentChannelId: undefined, + }, + ])("rejects bundled targetless sticker-cache reads with $name", async (testCase) => { + setReadPlugin({ channel: "telegram", origin: "bundled" }); + + await expect( + dispatchChannelMessageAction({ + channel: "telegram", + action: "sticker-search", + cfg: {} as OpenClawConfig, + params: { query: "party", limit: 5 }, + accountId: testCase.accountId, + requesterAccountId: testCase.requesterAccountId, + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: testCase.currentChannelProvider, + currentChannelId: "currentChannelId" in testCase ? testCase.currentChannelId : "123", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it("does not let an external adapter opt into bundled behavior with a stray property", async () => { + setReadPlugin({ + origin: "workspace", + strayPolicy: "current-or-configured-v1", + }); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "configured" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }); + + it.each([undefined, "unknown", "global", "workspace", "config"] as const)( + "treats %s channel provenance as non-bundled", + async (origin) => { + setReadPlugin(origin ? { origin } : undefined); + + await expect( + dispatchChannelMessageAction({ + channel: "discord", + action: "read", + cfg: {} as OpenClawConfig, + params: { channelId: "configured" }, + accountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelProvider: "discord", + currentChannelId: "current", + }, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + }, + ); +}); diff --git a/src/channels/plugins/registry.test.ts b/src/channels/plugins/registry.test.ts index ba8d28e2d060..ff6c5420e7b7 100644 --- a/src/channels/plugins/registry.test.ts +++ b/src/channels/plugins/registry.test.ts @@ -3,7 +3,11 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js"; import type { PluginRegistry } from "../../plugins/registry.js"; import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; -import { getChannelPlugin, listChannelPlugins } from "./registry.js"; +import { + getChannelPlugin, + listChannelPlugins, + resolveChannelPluginRegistration, +} from "./registry.js"; vi.mock("./bundled.js", () => ({ getBundledChannelPlugin: (id: string) => @@ -37,6 +41,37 @@ describe("listChannelPlugins", () => { setActivePluginRegistry(createEmptyPluginRegistry()); expect(getChannelPlugin("fallback")?.meta.label).toBe("fallback"); + expect(resolveChannelPluginRegistration("fallback")).toMatchObject({ + origin: "bundled", + plugin: { + id: "fallback", + }, + }); + }); + + it("does not let a loaded external override inherit bundled fallback provenance", () => { + const registry = createEmptyPluginRegistry(); + registry.channels = [ + { + pluginId: "external-fallback", + plugin: { + id: "fallback", + meta: { label: "external fallback" }, + } as never, + origin: "config", + source: "test", + }, + ]; + setActivePluginRegistry(registry); + + expect(resolveChannelPluginRegistration("fallback")).toMatchObject({ + origin: "config", + plugin: { + meta: { + label: "external fallback", + }, + }, + }); }); it("rebuilds channel lookups when the active registry object changes without a version bump", () => { diff --git a/src/channels/plugins/registry.ts b/src/channels/plugins/registry.ts index b8a377c8b0d0..c550a2955ee6 100644 --- a/src/channels/plugins/registry.ts +++ b/src/channels/plugins/registry.ts @@ -44,16 +44,34 @@ export function getLoadedChannelPluginOrigin(id: ChannelId): string | undefined } /** - * Returns the active channel plugin, with bundled fallback for built-in channels. + * Resolves the active channel implementation together with host-owned provenance. */ -export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { +export function resolveChannelPluginRegistration( + id: ChannelId, +): { plugin: ChannelPlugin; origin?: string } | undefined { const resolvedId = normalizeOptionalString(id) ?? ""; if (!resolvedId) { return undefined; } - // Loaded plugins win over bundled fallbacks so installed plugin state can pin - // or override a bundled channel during runtime. - return getLoadedChannelPlugin(resolvedId) ?? getBundledChannelPlugin(resolvedId); + // Resolve implementation and provenance together. Loaded overrides win and + // must never borrow bundled authority from the fallback with the same id. + const loadedEntry = getLoadedChannelPluginEntryById(resolvedId); + if (loadedEntry) { + const origin = normalizeOptionalString(loadedEntry.origin) ?? undefined; + return { + plugin: loadedEntry.plugin as ChannelPlugin, + ...(origin ? { origin } : {}), + }; + } + const plugin = getBundledChannelPlugin(resolvedId); + return plugin ? { plugin, origin: "bundled" } : undefined; +} + +/** + * Returns the active channel plugin, with bundled fallback for built-in channels. + */ +export function getChannelPlugin(id: ChannelId): ChannelPlugin | undefined { + return resolveChannelPluginRegistration(id)?.plugin; } /** diff --git a/src/channels/plugins/types.core.ts b/src/channels/plugins/types.core.ts index be5705310fc6..4818e6156ed7 100644 --- a/src/channels/plugins/types.core.ts +++ b/src/channels/plugins/types.core.ts @@ -19,6 +19,7 @@ import type { PollInput } from "../../polls.js"; import type { ChatType } from "../chat-type.js"; import type { InboundEventKind } from "../inbound-event/kind.js"; import type { ChannelId } from "./channel-id.types.js"; +import type { ConversationReadInvocationOrigin } from "./conversation-read-origin.js"; import type { ChannelMessageActionName as ChannelMessageActionNameFromList } from "./message-action-names.js"; import type { ChannelMessageCapability } from "./message-capabilities.js"; @@ -474,6 +475,8 @@ export type ChannelThreadingContext = { export type ChannelThreadingToolContext = { currentChannelId?: string; + /** Trusted normalized conversation kind for the active inbound turn. */ + currentChatType?: ChatType; /** Routable messaging target when it differs from the platform-native channel id. */ currentMessagingTarget?: string; currentGraphChannelId?: string; @@ -714,6 +717,11 @@ export type ChannelMessageActionContext = { requesterSenderId?: string | null; /** Trusted owner identity bit from command/channel-action auth. */ senderIsOwner?: boolean; + /** + * Server-owned origin for this operation. Missing values are delegated. + * Plugins must use it only for conversation-read visibility policy. + */ + conversationReadOrigin?: ConversationReadInvocationOrigin; sessionKey?: string | null; sessionId?: string | null; inboundEventKind?: InboundEventKind; @@ -777,6 +785,15 @@ export type ChannelMessageActionAdapter = { deliveryTargetAliases?: string[]; /** Convert typed owner fields such as chatId into the canonical shared target shape. */ resolveDeliveryTarget?: (params: { args: Record }) => string | undefined; + /** + * Prove that provider-native aliases name the trusted current conversation. + * Core consults this only for host-owned bundled registrations. + */ + matchesCurrentConversation?: (params: { + args: Record; + accountId: string; + toolContext: ChannelThreadingToolContext; + }) => boolean; } > >; diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index 3b5592681b79..6fca0f3012bc 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -10,6 +10,7 @@ type RunMessageActionParams = { params: Record; agentId?: string; senderIsOwner?: boolean; + conversationReadOrigin?: "delegated" | "direct-operator"; gateway?: { clientName?: string; mode?: string; @@ -206,6 +207,7 @@ describe("messageCommand", () => { expect(actionCall.params.message).toBe("hi"); expect(actionCall.agentId).toBe("main"); expect(actionCall.senderIsOwner).toBe(true); + expect(actionCall.conversationReadOrigin).toBe("direct-operator"); expect(actionCall.gateway?.clientName).toBe("cli"); expect(actionCall.gateway?.mode).toBe("cli"); expect(actionCall.cfg).not.toBe(rawConfig); diff --git a/src/commands/message.ts b/src/commands/message.ts index 3d236cc8586f..04af99fd3093 100644 --- a/src/commands/message.ts +++ b/src/commands/message.ts @@ -106,6 +106,7 @@ export async function messageCommand( deps: outboundDeps, agentId: resolveDefaultAgentId(cfg), senderIsOwner: opts.senderIsOwner !== false, + conversationReadOrigin: "direct-operator", gateway: { clientName: GATEWAY_CLIENT_NAMES.CLI, mode: GATEWAY_CLIENT_MODES.CLI, diff --git a/src/config/types.googlechat.ts b/src/config/types.googlechat.ts index 71f08b23ef34..5d181e792dfa 100644 --- a/src/config/types.googlechat.ts +++ b/src/config/types.googlechat.ts @@ -33,6 +33,7 @@ export type GoogleChatGroupConfig = { }; export type GoogleChatActionConfig = { + /** @deprecated Accepted for config compatibility; service-account auth cannot use reaction APIs. */ reactions?: boolean; }; diff --git a/src/gateway/agent-runtime-identity-token.test.ts b/src/gateway/agent-runtime-identity-token.test.ts index 7c011448b805..4fa09d7c84aa 100644 --- a/src/gateway/agent-runtime-identity-token.test.ts +++ b/src/gateway/agent-runtime-identity-token.test.ts @@ -93,4 +93,42 @@ describe("agent runtime identity token", () => { expect(secondToken).not.toBe(token); expect(secondProcess.verifyAgentRuntimeIdentityToken(token)).toBeUndefined(); }); + + it("round-trips signed message action context and rejects it after expiry", async () => { + useTempHome(); + const runtimeToken = await importRuntimeTokenModule(); + const token = await runtimeToken.mintAgentRuntimeIdentityToken({ + agentId: "main", + sessionKey: "session-1", + messageActionContext: { + expiresAtMs: 5000, + sessionId: "session-id-1", + requesterAccountId: "ops", + requesterSenderId: "sender-1", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "!room:example.org", + currentChatType: "direct", + }, + }, + }); + + expect(runtimeToken.verifyAgentRuntimeIdentityToken(token, 4000)).toMatchObject({ + kind: "agentRuntime", + agentId: "main", + sessionKey: "session-1", + messageActionContext: { + expiresAtMs: 5000, + sessionId: "session-id-1", + requesterAccountId: "ops", + requesterSenderId: "sender-1", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "!room:example.org", + currentChatType: "direct", + }, + }, + }); + expect(runtimeToken.verifyAgentRuntimeIdentityToken(token, 5000)).toBeUndefined(); + }); }); diff --git a/src/gateway/agent-runtime-identity-token.ts b/src/gateway/agent-runtime-identity-token.ts index 9f2661f466ae..265f017eae19 100644 --- a/src/gateway/agent-runtime-identity-token.ts +++ b/src/gateway/agent-runtime-identity-token.ts @@ -1,7 +1,12 @@ // Purpose-scoped local agent runtime identity token for Gateway clients. import { createHmac, timingSafeEqual } from "node:crypto"; +import { isRecord } from "@openclaw/normalization-core/record-coerce"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import { normalizeChatType } from "../channels/chat-type.js"; +import type { ChannelId, ChannelThreadingToolContext } from "../channels/plugins/types.public.js"; import { ensureExecApprovalsSnapshot, loadExecApprovals } from "../infra/exec-approvals.js"; import { normalizeAgentId } from "../routing/session-key.js"; +import type { AgentRuntimeMessageActionContext } from "./message-action-turn-capability.js"; const AGENT_RUNTIME_IDENTITY_TOKEN_CONTEXT = "openclaw:gateway-agent-runtime-identity-token:v1"; const AGENT_RUNTIME_IDENTITY_TOKEN_KIND = "agent-runtime"; @@ -10,12 +15,14 @@ export type AgentRuntimeIdentity = { kind: "agentRuntime"; agentId: string; sessionKey: string; + messageActionContext?: AgentRuntimeMessageActionContext; }; type AgentRuntimeIdentityTokenPayload = { kind: typeof AGENT_RUNTIME_IDENTITY_TOKEN_KIND; agentId: string; sessionKey: string; + messageActionContext?: AgentRuntimeMessageActionContext; }; function readSharedAgentRuntimeIdentitySecret(): string | null { @@ -50,7 +57,83 @@ function encodePayload(payload: AgentRuntimeIdentityTokenPayload): string { return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); } -function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefined { +function decodeMessageActionContext( + value: unknown, + nowMs: number, +): AgentRuntimeMessageActionContext | undefined { + if ( + !isRecord(value) || + typeof value.expiresAtMs !== "number" || + !Number.isFinite(value.expiresAtMs) || + nowMs >= value.expiresAtMs + ) { + return undefined; + } + const rawToolContext = value.toolContext; + if (rawToolContext !== undefined && !isRecord(rawToolContext)) { + return undefined; + } + const rawCurrentChatType = rawToolContext?.currentChatType; + const currentChatType = normalizeChatType( + typeof rawCurrentChatType === "string" ? rawCurrentChatType : undefined, + ); + const currentMessageId = rawToolContext?.currentMessageId; + const replyToMode = rawToolContext?.replyToMode; + const hasRepliedRef = rawToolContext?.hasRepliedRef; + if ( + (currentMessageId !== undefined && + typeof currentMessageId !== "string" && + typeof currentMessageId !== "number") || + (replyToMode !== undefined && + replyToMode !== "off" && + replyToMode !== "first" && + replyToMode !== "all" && + replyToMode !== "batched") || + (hasRepliedRef !== undefined && + (!isRecord(hasRepliedRef) || typeof hasRepliedRef.value !== "boolean")) + ) { + return undefined; + } + const readOptionalBoolean = (key: string): boolean | undefined => { + const candidate = rawToolContext?.[key]; + return typeof candidate === "boolean" ? candidate : undefined; + }; + const toolContext: ChannelThreadingToolContext | undefined = rawToolContext + ? ({ + currentChannelId: normalizeOptionalString(rawToolContext.currentChannelId), + currentChatType, + currentMessagingTarget: normalizeOptionalString(rawToolContext.currentMessagingTarget), + currentGraphChannelId: normalizeOptionalString(rawToolContext.currentGraphChannelId), + currentChannelProvider: normalizeOptionalString(rawToolContext.currentChannelProvider) as + | ChannelId + | undefined, + currentThreadTs: normalizeOptionalString(rawToolContext.currentThreadTs), + currentMessageId, + replyToMode: + replyToMode === "off" || + replyToMode === "first" || + replyToMode === "all" || + replyToMode === "batched" + ? replyToMode + : undefined, + hasRepliedRef: + isRecord(hasRepliedRef) && typeof hasRepliedRef.value === "boolean" + ? { value: hasRepliedRef.value } + : undefined, + sameChannelThreadRequired: readOptionalBoolean("sameChannelThreadRequired"), + skipCrossContextDecoration: readOptionalBoolean("skipCrossContextDecoration"), + } satisfies ChannelThreadingToolContext) + : undefined; + return { + expiresAtMs: value.expiresAtMs, + sessionId: normalizeOptionalString(value.sessionId), + requesterAccountId: normalizeOptionalString(value.requesterAccountId), + requesterSenderId: normalizeOptionalString(value.requesterSenderId), + toolContext, + }; +} + +function decodePayload(value: string, nowMs: number): AgentRuntimeIdentityTokenPayload | undefined { try { const parsed = JSON.parse(Buffer.from(value, "base64url").toString("utf8")) as unknown; if (!parsed || typeof parsed !== "object") { @@ -60,6 +143,7 @@ function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefi kind?: unknown; agentId?: unknown; sessionKey?: unknown; + messageActionContext?: unknown; }; if ( raw.kind !== AGENT_RUNTIME_IDENTITY_TOKEN_KIND || @@ -73,7 +157,19 @@ function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefi if (!agentId || !sessionKey) { return undefined; } - return { kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId, sessionKey }; + const messageActionContext = + raw.messageActionContext === undefined + ? undefined + : decodeMessageActionContext(raw.messageActionContext, nowMs); + if (raw.messageActionContext !== undefined && !messageActionContext) { + return undefined; + } + return { + kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, + agentId, + sessionKey, + ...(messageActionContext ? { messageActionContext } : {}), + }; } catch { return undefined; } @@ -83,11 +179,13 @@ function decodePayload(value: string): AgentRuntimeIdentityTokenPayload | undefi export async function mintAgentRuntimeIdentityToken(params: { agentId: string; sessionKey: string; + messageActionContext?: AgentRuntimeMessageActionContext; }): Promise { const payload = encodePayload({ kind: AGENT_RUNTIME_IDENTITY_TOKEN_KIND, agentId: normalizeAgentId(params.agentId), sessionKey: params.sessionKey.trim(), + ...(params.messageActionContext ? { messageActionContext: params.messageActionContext } : {}), }); const signature = signPayload(await requireSharedAgentRuntimeIdentitySecret(), payload); return `${payload}.${signature}`; @@ -96,6 +194,7 @@ export async function mintAgentRuntimeIdentityToken(params: { /** Validate a presented agent runtime token and return the internal caller identity. */ export function verifyAgentRuntimeIdentityToken( value: string | null | undefined, + nowMs: number = Date.now(), ): AgentRuntimeIdentity | undefined { const token = value?.trim(); if (!token) { @@ -105,7 +204,7 @@ export function verifyAgentRuntimeIdentityToken( if (!payloadPart || !signature || extra.length > 0) { return undefined; } - const payload = decodePayload(payloadPart); + const payload = decodePayload(payloadPart, nowMs); if (!payload) { return undefined; } @@ -117,5 +216,6 @@ export function verifyAgentRuntimeIdentityToken( kind: "agentRuntime", agentId: payload.agentId, sessionKey: payload.sessionKey, + ...(payload.messageActionContext ? { messageActionContext: payload.messageActionContext } : {}), }; } diff --git a/src/gateway/conversation-read-origin.test.ts b/src/gateway/conversation-read-origin.test.ts new file mode 100644 index 000000000000..9c2eb75d408e --- /dev/null +++ b/src/gateway/conversation-read-origin.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from "vitest"; +import { resolveGatewayConversationReadOrigin } from "./conversation-read-origin.js"; + +describe("resolveGatewayConversationReadOrigin", () => { + it("honors the operation-local direct-operator marker", () => { + expect( + resolveGatewayConversationReadOrigin({ + client: undefined, + requestedOrigin: "direct-operator", + }), + ).toBe("direct-operator"); + }); + + it.each([undefined, null, "delegated", "unknown"])( + "keeps missing or unknown operation origins delegated", + (requestedOrigin) => { + expect( + resolveGatewayConversationReadOrigin({ + client: undefined, + requestedOrigin, + }), + ).toBe("delegated"); + }, + ); + + it("does not infer direct authority from CLI connection metadata", () => { + expect( + resolveGatewayConversationReadOrigin({ + client: { + connect: { + client: { + id: "cli", + mode: "cli", + }, + }, + } as never, + }), + ).toBe("delegated"); + }); + + it("keeps an agent runtime delegated even with a direct-operator marker", () => { + expect( + resolveGatewayConversationReadOrigin({ + client: { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:main", + }, + }, + } as never, + requestedOrigin: "direct-operator", + }), + ).toBe("delegated"); + }); +}); diff --git a/src/gateway/conversation-read-origin.ts b/src/gateway/conversation-read-origin.ts new file mode 100644 index 000000000000..82dcd87bac54 --- /dev/null +++ b/src/gateway/conversation-read-origin.ts @@ -0,0 +1,19 @@ +import { + normalizeConversationReadInvocationOrigin, + type ConversationReadInvocationOrigin, +} from "../channels/plugins/conversation-read-origin.js"; +import type { GatewayClient } from "./server-methods/types.js"; + +/** + * Resolves one RPC's requested operator origin. Connection metadata is not an + * authority signal, and a server-attested agent runtime always stays delegated. + */ +export function resolveGatewayConversationReadOrigin(params: { + client: GatewayClient | null | undefined; + requestedOrigin?: unknown; +}): ConversationReadInvocationOrigin { + if (params.client?.internal?.agentRuntimeIdentity) { + return "delegated"; + } + return normalizeConversationReadInvocationOrigin(params.requestedOrigin); +} diff --git a/src/gateway/mcp-http.runtime.ts b/src/gateway/mcp-http.runtime.ts index 34eb65adc6a1..0fb8e437bdf4 100644 --- a/src/gateway/mcp-http.runtime.ts +++ b/src/gateway/mcp-http.runtime.ts @@ -80,6 +80,7 @@ export function resolveMcpLoopbackScopedTools(params: McpLoopbackScopeParams): { } const scoped = resolveGatewayScopedTools({ ...params, + conversationReadOrigin: "delegated", surface: "loopback", excludeToolNames, includeNodeExecTool: params.nodeExecAllowed === true, diff --git a/src/gateway/mcp-http.test.ts b/src/gateway/mcp-http.test.ts index 009d9e731962..7c9f81c2a500 100644 --- a/src/gateway/mcp-http.test.ts +++ b/src/gateway/mcp-http.test.ts @@ -61,6 +61,7 @@ type ScopedToolsCall = { taskSuggestionDeliveryMode?: string; requireExplicitMessageTarget?: boolean; senderIsOwner?: boolean; + conversationReadOrigin?: "delegated" | "direct-operator"; surface?: string; excludeToolNames?: Iterable; includeNodeExecTool?: boolean; @@ -893,6 +894,7 @@ describe("mcp loopback server", () => { expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); expect(call.taskSuggestionDeliveryMode).toBe("gateway"); expect(call.requireExplicitMessageTarget).toBe(true); + expect(call.conversationReadOrigin).toBe("delegated"); expect(call.surface).toBe("loopback"); expect(call.includeNodeExecTool).toBe(false); expect(Array.from(call.excludeToolNames ?? [])).toEqual([ @@ -962,6 +964,7 @@ describe("mcp loopback server", () => { expect(call.currentThreadTs).toBeUndefined(); expect(call.sourceReplyDeliveryMode).toBeUndefined(); expect(call.inboundEventKind).toBeUndefined(); + expect(call.conversationReadOrigin).toBe("delegated"); expect(call.includeNodeExecTool).toBe(false); expect(Array.from(call.excludeToolNames ?? [])).toContain("exec"); }); diff --git a/src/gateway/message-action-turn-capability.test.ts b/src/gateway/message-action-turn-capability.test.ts new file mode 100644 index 000000000000..9da7c3f81608 --- /dev/null +++ b/src/gateway/message-action-turn-capability.test.ts @@ -0,0 +1,149 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + isTrustedMessageActionTurnIngress, + mintMessageActionTurnCapability, + resetMessageActionTurnCapabilitiesForTest, + resolveMessageActionTurnCapability, + revokeMessageActionTurnCapability, +} from "./message-action-turn-capability.js"; + +afterEach(() => { + resetMessageActionTurnCapabilitiesForTest(); +}); + +describe("message action turn capability", () => { + it("admits channel ingress but rejects Gateway and internal run sources", () => { + expect(isTrustedMessageActionTurnIngress("whatsapp")).toBe(true); + expect(isTrustedMessageActionTurnIngress("matrix")).toBe(true); + expect(isTrustedMessageActionTurnIngress("webchat")).toBe(false); + expect(isTrustedMessageActionTurnIngress("cron")).toBe(false); + expect(isTrustedMessageActionTurnIngress(undefined)).toBe(false); + }); + + it("resolves only for the exact admitted run identity", () => { + const token = mintMessageActionTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:matrix:direct:room-1", + sessionId: "session-1", + requesterAccountId: "ops", + requesterSenderId: "@sender:example.org", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "!room-1:example.org", + currentChatType: "direct", + }, + nowMs: 1000, + ttlMs: 5000, + }); + + expect( + resolveMessageActionTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:matrix:direct:room-1", + sessionId: "session-1", + nowMs: 2000, + }), + ).toMatchObject({ + expiresAtMs: 6000, + sessionId: "session-1", + requesterAccountId: "ops", + requesterSenderId: "@sender:example.org", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "!room-1:example.org", + currentChatType: "direct", + }, + }); + + for (const mismatch of [ + { agentId: "other" }, + { runId: "run-2" }, + { sessionKey: "agent:main:matrix:direct:room-2" }, + { sessionId: "session-2" }, + ]) { + expect( + resolveMessageActionTurnCapability({ + token, + agentId: mismatch.agentId ?? "main", + runId: mismatch.runId ?? "run-1", + sessionKey: mismatch.sessionKey ?? "agent:main:matrix:direct:room-1", + sessionId: mismatch.sessionId ?? "session-1", + nowMs: 2000, + }), + ).toBeUndefined(); + } + }); + + it("preserves reply-to-first state across capability resolutions", () => { + const hasRepliedRef = { value: false }; + const token = mintMessageActionTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:matrix:group:room", + sessionId: "session-1", + toolContext: { + currentChannelProvider: "matrix", + currentChannelId: "!room:example.org", + replyToMode: "first", + hasRepliedRef, + }, + }); + + const first = resolveMessageActionTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:matrix:group:room", + sessionId: "session-1", + }); + expect(first?.toolContext?.hasRepliedRef).toBe(hasRepliedRef); + first!.toolContext!.hasRepliedRef!.value = true; + + const second = resolveMessageActionTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "agent:main:matrix:group:room", + sessionId: "session-1", + }); + expect(second?.toolContext?.hasRepliedRef).toBe(hasRepliedRef); + expect(second?.toolContext?.hasRepliedRef?.value).toBe(true); + }); + + it("expires and revokes capabilities fail closed", () => { + const token = mintMessageActionTurnCapability({ + agentId: "main", + runId: "run-1", + sessionKey: "session-1", + nowMs: 1000, + ttlMs: 1000, + }); + expect( + resolveMessageActionTurnCapability({ + token, + agentId: "main", + runId: "run-1", + sessionKey: "session-1", + nowMs: 2000, + }), + ).toBeUndefined(); + + const revoked = mintMessageActionTurnCapability({ + agentId: "main", + runId: "run-2", + sessionKey: "session-2", + }); + expect(revokeMessageActionTurnCapability(revoked)).toBe(true); + expect( + resolveMessageActionTurnCapability({ + token: revoked, + agentId: "main", + runId: "run-2", + sessionKey: "session-2", + }), + ).toBeUndefined(); + }); +}); diff --git a/src/gateway/message-action-turn-capability.ts b/src/gateway/message-action-turn-capability.ts new file mode 100644 index 000000000000..6b4d008dd441 --- /dev/null +++ b/src/gateway/message-action-turn-capability.ts @@ -0,0 +1,169 @@ +import { randomBytes } from "node:crypto"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; +import type { ChannelThreadingToolContext } from "../channels/plugins/types.public.js"; +import { normalizeAgentId } from "../routing/session-key.js"; +import { + isDeliverableMessageChannel, + normalizeMessageChannel, +} from "../utils/message-channel-normalize.js"; + +const DEFAULT_TTL_MS = 15 * 60_000; +const MAX_TTL_MS = 24 * 60 * 60_000; +const MAX_ACTIVE_CAPABILITIES = 4096; + +export type AgentRuntimeMessageActionContext = { + expiresAtMs: number; + sessionId?: string; + requesterAccountId?: string; + requesterSenderId?: string; + toolContext?: ChannelThreadingToolContext; +}; + +type MessageActionTurnCapability = AgentRuntimeMessageActionContext & { + agentId: string; + runId: string; + sessionKey: string; +}; + +const capabilitiesByToken = new Map(); + +export function isTrustedMessageActionTurnIngress(provider: string | null | undefined): boolean { + const normalized = normalizeMessageChannel(provider); + return normalized !== undefined && isDeliverableMessageChannel(normalized); +} + +function resolveTtlMs(value: number | undefined): number { + if (!Number.isFinite(value) || value === undefined || value <= 0) { + return DEFAULT_TTL_MS; + } + return Math.min(Math.trunc(value), MAX_TTL_MS); +} + +function copyToolContext( + context: ChannelThreadingToolContext | undefined, +): ChannelThreadingToolContext | undefined { + if (!context) { + return undefined; + } + return { + currentChannelId: normalizeOptionalString(context.currentChannelId), + currentChatType: context.currentChatType, + currentMessagingTarget: normalizeOptionalString(context.currentMessagingTarget), + currentGraphChannelId: normalizeOptionalString(context.currentGraphChannelId), + currentChannelProvider: context.currentChannelProvider, + currentThreadTs: normalizeOptionalString(context.currentThreadTs), + currentMessageId: context.currentMessageId, + replyToMode: context.replyToMode, + // Reply-to-first state is intentionally shared across actions in one turn. + // Preserve only this trusted process-local mutable reference. + hasRepliedRef: context.hasRepliedRef, + sameChannelThreadRequired: context.sameChannelThreadRequired, + skipCrossContextDecoration: context.skipCrossContextDecoration, + }; +} + +function evictOldestCapability(): void { + const oldest = capabilitiesByToken.keys().next().value; + if (typeof oldest === "string") { + capabilitiesByToken.delete(oldest); + } +} + +export function sweepExpiredMessageActionTurnCapabilities(nowMs: number = Date.now()): number { + let removed = 0; + for (const [token, capability] of capabilitiesByToken) { + if (nowMs >= capability.expiresAtMs) { + capabilitiesByToken.delete(token); + removed += 1; + } + } + return removed; +} + +/** + * Mint an opaque current-turn capability from trusted channel ingress. + * Public Gateway agent requests never receive this token. + */ +export function mintMessageActionTurnCapability(params: { + agentId: string; + runId: string; + sessionKey: string; + sessionId?: string; + requesterAccountId?: string; + requesterSenderId?: string; + toolContext?: ChannelThreadingToolContext; + ttlMs?: number; + nowMs?: number; +}): string { + const agentId = normalizeAgentId(params.agentId); + const runId = params.runId.trim(); + const sessionKey = params.sessionKey.trim(); + if (!agentId || !runId || !sessionKey) { + throw new Error("message action turn capability requires agent, run, and session identity"); + } + const nowMs = params.nowMs ?? Date.now(); + sweepExpiredMessageActionTurnCapabilities(nowMs); + while (capabilitiesByToken.size >= MAX_ACTIVE_CAPABILITIES) { + // A bounded fail-closed store prevents abandoned long-running turns from + // growing process memory without creating a second persistent state path. + evictOldestCapability(); + } + const token = randomBytes(32).toString("base64url"); + capabilitiesByToken.set(token, { + agentId, + runId, + sessionKey, + expiresAtMs: nowMs + resolveTtlMs(params.ttlMs), + sessionId: normalizeOptionalString(params.sessionId), + requesterAccountId: normalizeOptionalString(params.requesterAccountId), + requesterSenderId: normalizeOptionalString(params.requesterSenderId), + toolContext: copyToolContext(params.toolContext), + }); + return token; +} + +export function resolveMessageActionTurnCapability(params: { + token?: string; + agentId: string; + runId?: string; + sessionKey: string; + sessionId?: string; + nowMs?: number; +}): AgentRuntimeMessageActionContext | undefined { + const token = params.token?.trim(); + if (!token) { + return undefined; + } + const capability = capabilitiesByToken.get(token); + if (!capability) { + return undefined; + } + const nowMs = params.nowMs ?? Date.now(); + if (nowMs >= capability.expiresAtMs) { + capabilitiesByToken.delete(token); + return undefined; + } + if ( + capability.agentId !== normalizeAgentId(params.agentId) || + capability.runId !== params.runId?.trim() || + capability.sessionKey !== params.sessionKey.trim() || + (capability.sessionId && capability.sessionId !== normalizeOptionalString(params.sessionId)) + ) { + return undefined; + } + return { + expiresAtMs: capability.expiresAtMs, + sessionId: capability.sessionId, + requesterAccountId: capability.requesterAccountId, + requesterSenderId: capability.requesterSenderId, + toolContext: copyToolContext(capability.toolContext), + }; +} + +export function revokeMessageActionTurnCapability(token: string | undefined): boolean { + return token ? capabilitiesByToken.delete(token) : false; +} + +export function resetMessageActionTurnCapabilitiesForTest(): void { + capabilitiesByToken.clear(); +} diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7884e5b51858..84a99231298c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -4,16 +4,15 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../../packages/gateway-protocol/src/client-info.js"; import { jsonResult } from "../../agents/tools/common.js"; import type { ChannelPlugin } from "../../channels/plugins/types.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; -import { - getPluginRuntimeGatewayRequestScope, - withPluginRuntimeGatewayRequestScope, -} from "../../plugins/runtime/gateway-request-scope.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { captureEnv, setTestEnvValue } from "../../test-utils/env.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../../utils/message-channel.js"; import type { GatewayRequestContext } from "./types.js"; type ResolveOutboundTarget = typeof import("../../infra/outbound/targets.js").resolveOutboundTarget; @@ -75,6 +74,37 @@ function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): return "main"; } +function messageActionContextFromSessionKeyForTests(sessionKey: string): { + expiresAtMs: number; + toolContext?: { + currentChannelProvider?: string; + currentChannelId?: string; + currentChatType?: "direct" | "group" | "channel"; + }; +} { + const parts = sessionKey.split(":"); + const provider = parts[2]; + const peerKind = parts[3]; + const peerId = parts.slice(4).join(":"); + const currentChatType = + peerKind === "direct" || peerKind === "dm" + ? "direct" + : peerKind === "group" || peerKind === "channel" + ? peerKind + : undefined; + return { + expiresAtMs: Date.now() + 60_000, + toolContext: + provider && peerId + ? { + currentChannelProvider: provider, + currentChannelId: peerId, + currentChatType, + } + : undefined, + }; +} + vi.mock("../../agents/agent-scope.js", () => ({ resolveSessionAgentId: ({ sessionKey, @@ -207,22 +237,97 @@ async function runMessageActionRequest( client?: { connect?: { scopes?: string[]; - client?: { id?: string; mode?: string }; + client?: { id: string; mode: string }; + }; + internal?: { + agentRuntimeIdentity?: { + kind: "agentRuntime"; + agentId: string; + sessionKey: string; + messageActionContext?: { + expiresAtMs: number; + sessionId?: string; + requesterAccountId?: string; + requesterSenderId?: string; + toolContext?: Record; + }; + }; }; } | null, ) { const respond = vi.fn(); + const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey : undefined; + const agentId = + typeof params.agentId === "string" + ? params.agentId + : sessionKey + ? resolveAgentIdFromSessionKeyForTests({ sessionKey }) + : undefined; + const effectiveClient = + client === undefined && sessionKey && agentId + ? { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime" as const, + agentId, + sessionKey, + messageActionContext: { + expiresAtMs: Date.now() + 60_000, + sessionId: typeof params.sessionId === "string" ? params.sessionId : undefined, + requesterAccountId: + typeof params.requesterAccountId === "string" + ? params.requesterAccountId + : undefined, + requesterSenderId: + typeof params.requesterSenderId === "string" + ? params.requesterSenderId + : undefined, + toolContext: { + ...messageActionContextFromSessionKeyForTests(sessionKey).toolContext, + ...(params.toolContext && typeof params.toolContext === "object" + ? params.toolContext + : {}), + }, + }, + }, + }, + } + : client; await sendHandlers["message.action"]({ params: params as never, respond, context: makeContext(), req: { type: "req", id: "1", method: "message.action" }, - client: (client ?? null) as never, + client: (effectiveClient ?? null) as never, isWebchatConnect: () => false, }); return { respond }; } +function directCliClient() { + return { + connect: { + client: { + id: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + }, + }; +} + +function agentRuntimeClient(sessionKey: string, agentId = "main") { + return { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime" as const, + agentId, + sessionKey, + messageActionContext: messageActionContextFromSessionKeyForTests(sessionKey), + }, + }, + } as never; +} + async function withTempOpenClawStateDir(test: (stateDir: string) => Promise): Promise { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "gateway-send-state-")); @@ -649,6 +754,91 @@ describe("gateway send mirroring", () => { expect(secondCall?.[3]?.cached).toBe(true); }); + it("does not share message.action idempotency results across authority origins", async () => { + const context = makeContext(); + const directRespond = vi.fn(); + const delegatedRespond = vi.fn(); + const firstDeferred = createDeferred<{ details: { action: string } }>(); + const secondDeferred = createDeferred<{ details: { action: string } }>(); + mocks.dispatchChannelMessageAction + .mockReturnValueOnce(firstDeferred.promise) + .mockReturnValueOnce(secondDeferred.promise); + const params = { + channel: "slack", + action: "read", + params: { channelId: "C1", limit: 1 }, + idempotencyKey: "idem-action-mixed-authority", + }; + + const directRequest = sendHandlers["message.action"]({ + params: { + ...params, + conversationReadOrigin: "direct-operator", + } as never, + respond: directRespond, + context, + req: { type: "req", id: "direct", method: "message.action" }, + client: null as never, + isWebchatConnect: () => false, + }); + const delegatedRequest = sendHandlers["message.action"]({ + params: params as never, + respond: delegatedRespond, + context, + req: { type: "req", id: "delegated", method: "message.action" }, + client: directCliClient() as never, + isWebchatConnect: () => false, + }); + + await Promise.resolve(); + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledTimes(2); + expect(mocks.dispatchChannelMessageAction.mock.calls[0]?.[0]).toMatchObject({ + conversationReadOrigin: "direct-operator", + }); + expect(mocks.dispatchChannelMessageAction.mock.calls[1]?.[0]).toMatchObject({ + conversationReadOrigin: "delegated", + }); + + firstDeferred.resolve({ details: { action: "direct" } }); + secondDeferred.resolve({ details: { action: "delegated" } }); + await Promise.all([directRequest, delegatedRequest]); + expect(firstRespondCall(directRespond)?.[1]).toEqual({ action: "direct" }); + expect(firstRespondCall(delegatedRespond)?.[1]).toEqual({ action: "delegated" }); + expect(mocks.appendAssistantMessageToSessionTranscript).not.toHaveBeenCalled(); + }); + + it("keeps an agent runtime delegated even with a direct-operator marker", async () => { + const sessionKey = "agent:main:slack:channel:C1"; + mocks.dispatchChannelMessageAction.mockResolvedValueOnce({ + details: { action: "handled" }, + }); + + await runMessageActionRequest( + { + channel: "slack", + action: "read", + params: { channelId: "C1", limit: 1 }, + sessionKey, + agentId: "main", + conversationReadOrigin: "direct-operator", + idempotencyKey: "idem-agent-cli-identity", + }, + { + ...directCliClient(), + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey, + messageActionContext: messageActionContextFromSessionKeyForTests(sessionKey), + }, + }, + }, + ); + + expect(lastDispatchChannelMessageActionCall()?.conversationReadOrigin).toBe("delegated"); + }); + it("dedupes concurrent send requests while inflight", async () => { const context = makeContext(); const firstRespond = vi.fn(); @@ -1579,6 +1769,7 @@ describe("gateway send mirroring", () => { requesterAccountId, requesterSenderId, currentMessageId: toolContext?.currentMessageId, + currentChatType: toolContext?.currentChatType, currentMessagingTarget: toolContext?.currentMessagingTarget, currentGraphChannelId: toolContext?.currentGraphChannelId, replyToMode: toolContext?.replyToMode, @@ -1606,6 +1797,7 @@ describe("gateway send mirroring", () => { requesterAccountId: "default", requesterSenderId: "trusted-user", currentMessageId: "wamid.1", + currentChatType: "direct", currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", replyToMode: "first", @@ -1615,6 +1807,7 @@ describe("gateway send mirroring", () => { }), ); + const sessionKey = "agent:main:whatsapp:direct:15551234567"; const { respond } = await runMessageActionRequest( { channel: "whatsapp", @@ -1627,6 +1820,8 @@ describe("gateway send mirroring", () => { requesterAccountId: "default", requesterSenderId: "trusted-user", inboundTurnKind: "room_event", + sessionKey, + agentId: "main", toolContext: { currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", @@ -1640,11 +1835,28 @@ describe("gateway send mirroring", () => { idempotencyKey: "idem-message-action", }, { - connect: { - scopes: ["operator.admin"], - client: { - id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - mode: GATEWAY_CLIENT_MODES.BACKEND, + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey, + messageActionContext: { + expiresAtMs: Date.now() + 60_000, + requesterAccountId: "default", + requesterSenderId: "trusted-user", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "15551234567", + currentChatType: "direct", + currentMessagingTarget: "user:15551234567", + currentGraphChannelId: "graph:team/chan", + currentMessageId: "wamid.1", + replyToMode: "first", + hasRepliedRef: { value: true }, + sameChannelThreadRequired: true, + skipCrossContextDecoration: true, + }, + }, }, }, }, @@ -1658,6 +1870,7 @@ describe("gateway send mirroring", () => { requesterAccountId: "default", requesterSenderId: "trusted-user", currentMessageId: "wamid.1", + currentChatType: "direct", currentMessagingTarget: "user:15551234567", currentGraphChannelId: "graph:team/chan", replyToMode: "first", @@ -1672,144 +1885,159 @@ describe("gateway send mirroring", () => { expect.objectContaining({ inboundEventKind: "room_event", requesterAccountId: "default", - requesterSenderId: "trusted-user", - gatewayClientScopes: ["operator.write"], + toolContext: expect.objectContaining({ + currentChatType: "direct", + currentMessagingTarget: "user:15551234567", + }), }), ); }); - it("drops caller-supplied requester identity from operator.write message actions", async () => { - const reactPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp action dispatch test plugin.", - }, - capabilities: { chatTypes: ["direct"], reactions: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ enabled: true }), - isConfigured: () => true, - }, + it("strips current-turn context from unauthenticated message action callers", async () => { + mocks.getChannelPlugin.mockReturnValue({ actions: { - describeMessageTool: () => ({ actions: ["react"] }), - supportsAction: ({ action }) => action === "react", - handleAction: async () => jsonResult({ ok: true }), + handleAction: vi.fn(), }, - }; - mocks.getChannelPlugin.mockReturnValue(reactPlugin); - setActivePluginRegistry( - createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), - "send-test-message-action-untrusted-requester", - ); + }); mocks.dispatchChannelMessageAction.mockResolvedValueOnce(jsonResult({ ok: true })); + const { respond } = await runMessageActionRequest({ + channel: "whatsapp", + action: "react", + params: { messageId: "wamid.1", emoji: "ok" }, + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "user:15551234567", + }, + idempotencyKey: "idem-untrusted-message-action", + }); + + expect(firstRespondCall(respond)[0]).toBe(true); + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith( + expect.objectContaining({ + requesterAccountId: undefined, + requesterSenderId: undefined, + toolContext: undefined, + }), + ); + }); + + it("strips forged current-turn context from agent runs without an ingress capability", async () => { + mocks.getChannelPlugin.mockReturnValue({ + actions: { + handleAction: vi.fn(), + }, + }); + mocks.dispatchChannelMessageAction.mockResolvedValueOnce(jsonResult({ ok: true })); + + const sessionKey = "agent:main:whatsapp:direct:alice"; const { respond } = await runMessageActionRequest( { channel: "whatsapp", action: "react", - params: { - chatJid: "+15551234567", - messageId: "wamid.1", - emoji: "✅", - }, + params: { messageId: "wamid.1", emoji: "ok" }, requesterAccountId: "default", - requesterSenderId: "spoofed-admin-user", - senderIsOwner: true, - idempotencyKey: "idem-message-action-untrusted-requester", + requesterSenderId: "forged-sender", + sessionKey, + agentId: "main", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "user:alice", + }, + idempotencyKey: "idem-forged-agent-message-action", + }, + { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey, + }, + }, }, - { connect: { scopes: ["operator.write"] } }, ); expect(firstRespondCall(respond)[0]).toBe(true); - expect(lastDispatchChannelMessageActionCall()).toEqual( + expect(mocks.dispatchChannelMessageAction).toHaveBeenCalledWith( expect.objectContaining({ requesterAccountId: undefined, requesterSenderId: undefined, - senderIsOwner: false, - gatewayClientScopes: ["operator.write"], + toolContext: undefined, }), ); }); - it("down-scopes backend bridge actions that carry explicit non-owner provenance", async () => { - const reactPlugin: ChannelPlugin = { - id: "whatsapp", - meta: { - id: "whatsapp", - label: "WhatsApp", - selectionLabel: "WhatsApp", - docsPath: "/channels/whatsapp", - blurb: "WhatsApp action dispatch test plugin.", + it("rejects ingress-issued message action context for a different session", async () => { + const { respond } = await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { messageId: "wamid.1", emoji: "ok" }, + sessionKey: "agent:main:whatsapp:direct:bob", + agentId: "main", + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "user:bob", + }, + idempotencyKey: "idem-mismatched-message-action", }, - capabilities: { chatTypes: ["direct"], reactions: true }, - config: { - listAccountIds: () => ["default"], - resolveAccount: () => ({ enabled: true }), - isConfigured: () => true, - }, - actions: { - describeMessageTool: () => ({ actions: ["react"] }), - supportsAction: ({ action }) => action === "react", - handleAction: async () => jsonResult({ ok: true }), - }, - }; - mocks.getChannelPlugin.mockReturnValue(reactPlugin); - setActivePluginRegistry( - createTestRegistry([{ pluginId: "whatsapp", source: "test", plugin: reactPlugin }]), - "send-test-message-action-backend-non-owner", - ); - let scopedGatewayClientScopes: readonly string[] | undefined; - mocks.dispatchChannelMessageAction.mockImplementationOnce(async () => { - scopedGatewayClientScopes = - getPluginRuntimeGatewayRequestScope()?.client?.connect?.scopes ?? []; - return jsonResult({ ok: true }); - }); - - const backendClient = { - connect: { - scopes: ["operator.admin"], - client: { - id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - mode: GATEWAY_CLIENT_MODES.BACKEND, + { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey: "agent:main:whatsapp:direct:alice", + messageActionContext: { + expiresAtMs: Date.now() + 60_000, + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "user:alice", + }, + }, + }, }, }, - }; - const { respond } = await withPluginRuntimeGatewayRequestScope( - { - client: backendClient as never, - isWebchatConnect: () => false, - }, - async () => - await runMessageActionRequest( - { - channel: "whatsapp", - action: "react", - params: { - chatJid: "+15551234567", - messageId: "wamid.1", - emoji: "✅", - }, - senderIsOwner: false, - idempotencyKey: "idem-message-action-backend-non-owner", - }, - backendClient, - ), ); - expect(firstRespondCall(respond)[0]).toBe(true); - expect(scopedGatewayClientScopes).toEqual(["operator.write"]); - expect(lastDispatchChannelMessageActionCall()).toEqual( - expect.objectContaining({ - requesterAccountId: undefined, - requesterSenderId: undefined, - senderIsOwner: false, - gatewayClientScopes: ["operator.write"], - }), + expect(firstRespondCall(respond)[0]).toBe(false); + expect(firstRespondCall(respond)[2]?.message).toContain( + "agent runtime identity does not match the requested session", ); + expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); + }); + + it("rejects ingress-issued message action context after expiry", async () => { + const sessionKey = "agent:main:whatsapp:direct:alice"; + const { respond } = await runMessageActionRequest( + { + channel: "whatsapp", + action: "react", + params: { messageId: "wamid.1", emoji: "ok" }, + sessionKey, + agentId: "main", + idempotencyKey: "idem-expired-message-action", + }, + { + internal: { + agentRuntimeIdentity: { + kind: "agentRuntime", + agentId: "main", + sessionKey, + messageActionContext: { + expiresAtMs: Date.now() - 1, + toolContext: { + currentChannelProvider: "whatsapp", + currentChannelId: "user:alice", + }, + }, + }, + }, + }, + ); + + expect(firstRespondCall(respond)[0]).toBe(false); + expect(firstRespondCall(respond)[2]?.message).toContain("agent runtime context has expired"); + expect(mocks.dispatchChannelMessageAction).not.toHaveBeenCalled(); }); it("mirrors successful source-conversation message.action sends into the assistant transcript", async () => { @@ -2170,7 +2398,7 @@ describe("gateway send mirroring", () => { respond, context: makeContext(), req: { type: "req", id: "1", method: "message.action" }, - client: null, + client: agentRuntimeClient("agent:main:telegram:direct:chat-123"), isWebchatConnect: () => false, }); @@ -2241,7 +2469,7 @@ describe("gateway send mirroring", () => { respond: firstRespond, context: makeContext(), req: { type: "req", id: "1", method: "message.action" }, - client: null, + client: agentRuntimeClient("agent:main:telegram:direct:chat-123"), isWebchatConnect: () => false, }); await vi.waitFor(() => { @@ -2266,7 +2494,7 @@ describe("gateway send mirroring", () => { respond: secondRespond, context: makeContext(), req: { type: "req", id: "2", method: "message.action" }, - client: null, + client: agentRuntimeClient("agent:main:telegram:direct:chat-123"), isWebchatConnect: () => false, }); diff --git a/src/gateway/server-methods/send.ts b/src/gateway/server-methods/send.ts index e43c33a8daf0..423c6fd9dc45 100644 --- a/src/gateway/server-methods/send.ts +++ b/src/gateway/server-methods/send.ts @@ -16,7 +16,9 @@ import { } from "../../../packages/gateway-protocol/src/index.js"; import { resolveSessionAgentId } from "../../agents/agent-scope.js"; import { sendDurableMessageBatch } from "../../channels/message/runtime.js"; +import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; +import type { ChannelThreadingToolContext } from "../../channels/plugins/types.public.js"; import { createOutboundSendDeps } from "../../cli/deps.js"; import { getRuntimeConfigSnapshot, @@ -44,22 +46,16 @@ import { maybeResolveIdLikeTarget } from "../../infra/outbound/target-resolver.j import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { getAgentScopedMediaLocalRoots } from "../../media/local-roots.js"; import { extractToolPayload } from "../../plugin-sdk/tool-payload.js"; -import { - getPluginRuntimeGatewayRequestScope, - withPluginRuntimeGatewayRequestScope, -} from "../../plugins/runtime/gateway-request-scope.js"; import { normalizePollInput } from "../../polls.js"; +import { normalizeAgentId } from "../../routing/session-key.js"; import { normalizeSessionKeyPreservingOpaquePeerIds, + parseAgentSessionKey, parseThreadSessionSuffix, } from "../../sessions/session-key-utils.js"; -import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, - INTERNAL_MESSAGE_CHANNEL, - normalizeMessageChannel, -} from "../../utils/message-channel.js"; -import { ADMIN_SCOPE, WRITE_SCOPE } from "../operator-scopes.js"; +import { INTERNAL_MESSAGE_CHANNEL, normalizeMessageChannel } from "../../utils/message-channel.js"; +import { resolveGatewayConversationReadOrigin } from "../conversation-read-origin.js"; +import { ADMIN_SCOPE } from "../operator-scopes.js"; import { resolveGatewayPluginConfig } from "../runtime-plugin-config.js"; import { formatForLog } from "../ws-log.js"; import type { GatewayRequestContext, GatewayRequestHandlers, RespondFn } from "./types.js"; @@ -71,31 +67,78 @@ type InflightResult = { meta?: Record; }; +type MessageActionToolContext = Omit; + +function resolveTrustedMessageActionToolContext(params: { + client: Parameters[0]["client"]; + request: { + agentId?: string; + sessionKey?: string; + sessionId?: string; + }; +}): + | { + ok: true; + toolContext: ChannelThreadingToolContext | undefined; + requesterAccountId: string | undefined; + requesterSenderId: string | undefined; + } + | { ok: false; error: ReturnType } { + // Current-turn metadata can relax channel read policy. It must come from the + // signed ingress-issued turn context, never from message.action request fields. + const identity = params.client?.internal?.agentRuntimeIdentity; + const messageActionContext = identity?.messageActionContext; + if (!identity || !messageActionContext) { + return { + ok: true, + toolContext: undefined, + requesterAccountId: undefined, + requesterSenderId: undefined, + }; + } + if (Date.now() >= messageActionContext.expiresAtMs) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "message.action agent runtime context has expired", + ), + }; + } + const requestSessionKey = normalizeSessionKeyPreservingOpaquePeerIds(params.request.sessionKey); + const identitySessionKey = normalizeSessionKeyPreservingOpaquePeerIds(identity.sessionKey); + const identityAgentId = normalizeAgentId(identity.agentId); + const requestAgentId = normalizeOptionalString(params.request.agentId); + const sessionAgentId = parseAgentSessionKey(requestSessionKey)?.agentId; + const requestSessionId = normalizeOptionalString(params.request.sessionId); + if ( + !requestSessionKey || + requestSessionKey !== identitySessionKey || + (requestAgentId && normalizeAgentId(requestAgentId) !== identityAgentId) || + (sessionAgentId && normalizeAgentId(sessionAgentId) !== identityAgentId) || + (messageActionContext.sessionId && requestSessionId !== messageActionContext.sessionId) + ) { + return { + ok: false, + error: errorShape( + ErrorCodes.INVALID_REQUEST, + "message.action agent runtime identity does not match the requested session", + ), + }; + } + return { + ok: true, + toolContext: messageActionContext.toolContext, + requesterAccountId: messageActionContext.requesterAccountId, + requesterSenderId: messageActionContext.requesterSenderId, + }; +} + const inflightByContext = new WeakMap< GatewayRequestContext, Map> >(); -const TRUSTED_MESSAGE_ACTION_BRIDGE_SCOPES = [WRITE_SCOPE]; - -async function withMessageActionGatewayClientScopes( - scopes: readonly string[], - run: () => Promise, -): Promise { - const current = getPluginRuntimeGatewayRequestScope(); - if (!current?.client?.connect) { - return await run(); - } - const client = { - ...current.client, - connect: { - ...current.client.connect, - scopes: [...scopes], - }, - }; - return await withPluginRuntimeGatewayRequestScope({ ...current, client }, run); -} - const getInflightMap = (context: GatewayRequestContext) => { let inflight = inflightByContext.get(context); if (!inflight) { @@ -136,6 +179,7 @@ function resolveGatewayInflightRequest(params: { prefix: "message.action" | "poll" | "send"; idempotencyKey: string; respond: RespondFn; + conversationReadOrigin?: ConversationReadInvocationOrigin; }): | { kind: "ready"; @@ -148,7 +192,10 @@ function resolveGatewayInflightRequest(params: { done: Promise; } { const idem = params.idempotencyKey; - const dedupeKey = `${params.prefix}:${idem}`; + const dedupeKey = + params.prefix === "message.action" + ? `${params.prefix}:${params.conversationReadOrigin ?? "delegated"}:${idem}` + : `${params.prefix}:${idem}`; const inflight = resolveGatewayInflightMap({ context: params.context, dedupeKey, @@ -487,25 +534,25 @@ export const sendHandlers: GatewayRequestHandlers = { sessionId?: string; inboundTurnKind?: "user_request" | "room_event"; agentId?: string; - toolContext?: { - currentChannelId?: string; - currentMessagingTarget?: string; - currentGraphChannelId?: string; - currentChannelProvider?: string; - currentThreadTs?: string; - currentMessageId?: string | number; - replyToMode?: "off" | "first" | "all" | "batched"; - hasRepliedRef?: { value: boolean }; - sameChannelThreadRequired?: boolean; - skipCrossContextDecoration?: boolean; - }; + toolContext?: MessageActionToolContext; + conversationReadOrigin?: "direct-operator"; idempotencyKey: string; }; + const trustedContext = resolveTrustedMessageActionToolContext({ client, request }); + if (!trustedContext.ok) { + respond(false, undefined, trustedContext.error); + return; + } + const conversationReadOrigin = resolveGatewayConversationReadOrigin({ + client, + requestedOrigin: request.conversationReadOrigin, + }); const inflight = resolveGatewayInflightRequest({ context, prefix: "message.action", idempotencyKey: request.idempotencyKey, respond, + conversationReadOrigin, }); if (inflight.kind === "handled") { await inflight.done; @@ -554,50 +601,27 @@ export const sendHandlers: GatewayRequestHandlers = { }); } const gatewayClientScopes = client?.connect?.scopes ?? []; - // Requester provenance is trusted channel context, not public RPC input. - // Only full-scope callers may bridge server-injected sender identity. - const canSupplyTrustedRequester = gatewayClientScopes.includes(ADMIN_SCOPE); - const requesterAccountId = canSupplyTrustedRequester - ? (normalizeOptionalString(request.requesterAccountId) ?? undefined) - : undefined; - const requesterSenderId = canSupplyTrustedRequester - ? (normalizeOptionalString(request.requesterSenderId) ?? undefined) - : undefined; - const senderIsOwner = canSupplyTrustedRequester ? request.senderIsOwner === true : false; - const hasTrustedRequesterProvenance = - requesterAccountId !== undefined || - requesterSenderId !== undefined || - (canSupplyTrustedRequester && request.senderIsOwner !== undefined); - const isTrustedBackendBridge = - canSupplyTrustedRequester && - client?.connect?.client?.id === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && - client.connect.client.mode === GATEWAY_CLIENT_MODES.BACKEND && - hasTrustedRequesterProvenance; - const dispatchGatewayClientScopes = isTrustedBackendBridge - ? TRUSTED_MESSAGE_ACTION_BRIDGE_SCOPES - : gatewayClientScopes; - const handled = await withMessageActionGatewayClientScopes( - dispatchGatewayClientScopes, - async () => - await dispatchChannelMessageAction({ - channel, - action: request.action as never, - cfg, - params: request.params, - accountId, - requesterAccountId, - requesterSenderId, - senderIsOwner, - sessionKey, - sessionId: normalizeOptionalString(request.sessionId) ?? undefined, - inboundEventKind: request.inboundTurnKind, - agentId, - mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId), - toolContext: request.toolContext, - dryRun: false, - gatewayClientScopes: dispatchGatewayClientScopes, - }), - ); + const handled = await dispatchChannelMessageAction({ + channel, + action: request.action as never, + cfg, + params: request.params, + accountId, + requesterAccountId: trustedContext.requesterAccountId, + requesterSenderId: trustedContext.requesterSenderId, + senderIsOwner: gatewayClientScopes.includes(ADMIN_SCOPE) + ? request.senderIsOwner === true + : false, + conversationReadOrigin, + sessionKey, + sessionId: normalizeOptionalString(request.sessionId) ?? undefined, + inboundEventKind: request.inboundTurnKind, + agentId, + mediaLocalRoots: getAgentScopedMediaLocalRoots(cfg, agentId), + toolContext: trustedContext.toolContext, + dryRun: false, + gatewayClientScopes, + }); if (!handled) { const error = errorShape( ErrorCodes.INVALID_REQUEST, @@ -616,7 +640,7 @@ export const sendHandlers: GatewayRequestHandlers = { cfg, sessionKey, agentId, - toolContext: request.toolContext, + toolContext: trustedContext.toolContext, idempotencyKey: request.idempotencyKey, deliveredPayload: payload, }, diff --git a/src/gateway/server-methods/tools-invoke.ts b/src/gateway/server-methods/tools-invoke.ts index a5f30f1f5907..97dc7f87fe8f 100644 --- a/src/gateway/server-methods/tools-invoke.ts +++ b/src/gateway/server-methods/tools-invoke.ts @@ -8,6 +8,7 @@ import { validateToolsInvokeParams, type ToolsInvokeResult, } from "../../../packages/gateway-protocol/src/index.js"; +import { resolveGatewayConversationReadOrigin } from "../conversation-read-origin.js"; import { invokeGatewayTool } from "../tools-invoke-shared.js"; import type { GatewayRequestHandlers } from "./types.js"; @@ -63,6 +64,10 @@ export const toolsInvokeHandlers: GatewayRequestHandlers = { input: params, senderIsOwner: client?.connect?.scopes?.includes("operator.admin"), clientCaps: client?.connect?.caps, + conversationReadOrigin: resolveGatewayConversationReadOrigin({ + client, + requestedOrigin: params.conversationReadOrigin, + }), toolCallIdPrefix: "rpc", approvalMode: params.confirm === true ? "request" : "report", }); diff --git a/src/gateway/tool-resolution.ts b/src/gateway/tool-resolution.ts index 98ef37242e85..1e559f182207 100644 --- a/src/gateway/tool-resolution.ts +++ b/src/gateway/tool-resolution.ts @@ -45,6 +45,7 @@ import type { TaskSuggestionDeliveryMode, } from "../auto-reply/get-reply-options.types.js"; import type { InboundEventKind } from "../channels/inbound-event/kind.js"; +import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveEventSessionRoutingPolicy } from "../infra/event-session-routing.js"; import { logWarn } from "../logger.js"; @@ -83,6 +84,7 @@ export function resolveGatewayScopedTools(params: { agentTo?: string; agentThreadId?: string; senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; allowGatewaySubagentBinding?: boolean; allowMediaInvokeCommands?: boolean; surface?: GatewayScopedToolSurface; @@ -273,6 +275,7 @@ export function resolveGatewayScopedTools(params: { onYield: params.onYield, requireExplicitMessageTarget: params.requireExplicitMessageTarget, senderIsOwner: params.senderIsOwner, + conversationReadOrigin: params.conversationReadOrigin, allowGatewaySubagentBinding: params.allowGatewaySubagentBinding, allowMediaInvokeCommands: params.allowMediaInvokeCommands, disablePluginTools: params.disablePluginTools, diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index e5999b7364b8..a9a8c9e4e1b1 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -3,6 +3,10 @@ import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, +} from "../../packages/gateway-protocol/src/client-info.js"; import type { runBeforeToolCallHook as runBeforeToolCallHookType } from "../agents/agent-tools.before-tool-call.js"; type RunBeforeToolCallHook = typeof runBeforeToolCallHookType; @@ -409,13 +413,25 @@ const firstHookCallArg = () => { return call[0]; }; -const invokeToolsRpc = async (params: Record, scopes = ["operator.write"]) => { +const invokeToolsRpc = async ( + params: Record, + scopes = ["operator.write"], + clientInfo?: { id: string; mode: string }, + caps?: string[], +) => { const respond = vi.fn(); await toolsInvokeHandlers["tools.invoke"]({ params, respond, context: { getRuntimeConfig: () => cfg } as never, - client: { connect: { role: "operator", scopes } } as never, + client: { + connect: { + role: "operator", + scopes, + ...(clientInfo ? { client: clientInfo } : {}), + ...(caps ? { caps } : {}), + }, + } as never, req: { type: "req", id: "req-rpc-1", method: "tools.invoke" }, isWebchatConnect: () => false, }); @@ -458,6 +474,7 @@ describe("POST /tools/invoke", () => { expect(body).toHaveProperty("result"); expect(lastCreateOpenClawToolsContext?.allowMediaInvokeCommands).toBe(true); expect(lastCreateOpenClawToolsContext?.disablePluginTools).toBe(true); + expect(lastCreateOpenClawToolsContext?.conversationReadOrigin).toBe("direct-operator"); const hookArg = firstHookCallArg(); expect(hookArg.toolName).toBe("agents_list"); const hookCtx = hookArg.ctx; @@ -1061,7 +1078,7 @@ describe("tools.invoke Gateway RPC", () => { const hookArg = firstHookCallArg(); expect(hookArg.approvalMode).toBe("report"); expect(hookArg.toolName).toBe("agents_list"); - expect(hookArg.toolCallId).toBe("rpc-rpc-tool-test"); + expect(hookArg.toolCallId).toBe("rpc-delegated-rpc-tool-test"); const hookCtx = hookArg.ctx; if (!hookCtx) { throw new Error("Expected before-tool-call hook context"); @@ -1069,6 +1086,42 @@ describe("tools.invoke Gateway RPC", () => { expect(hookCtx.agentId).toBe("main"); expect(hookCtx.config).toBe(cfg); expect(hookCtx.sessionKey).toBe("agent:main:main"); + expect(lastCreateOpenClawToolsContext?.conversationReadOrigin).toBe("delegated"); + }); + + it("requires an operation-local marker for direct conversation reads", async () => { + allowAgentsListForMain(); + + await invokeToolsRpc( + { + name: "agents_list", + args: {}, + sessionKey: "main", + conversationReadOrigin: "direct-operator", + }, + ["operator.write"], + { + id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + mode: GATEWAY_CLIENT_MODES.BACKEND, + }, + ["tool-events", "inline-widgets"], + ); + expect(lastCreateOpenClawToolsContext?.conversationReadOrigin).toBe("direct-operator"); + expect(lastCreateOpenClawToolsContext?.clientCaps).toEqual(["tool-events", "inline-widgets"]); + + await invokeToolsRpc( + { + name: "agents_list", + args: {}, + sessionKey: "main", + }, + ["operator.write"], + { + id: GATEWAY_CLIENT_NAMES.CLI, + mode: GATEWAY_CLIENT_MODES.CLI, + }, + ); + expect(lastCreateOpenClawToolsContext?.conversationReadOrigin).toBe("delegated"); }); it("keeps owner-only tools unavailable to non-owner RPC callers despite gateway.tools.allow", async () => { diff --git a/src/gateway/tools-invoke-http.ts b/src/gateway/tools-invoke-http.ts index 20b5b5b5f3d9..46d1e6b5adb3 100644 --- a/src/gateway/tools-invoke-http.ts +++ b/src/gateway/tools-invoke-http.ts @@ -84,6 +84,7 @@ export async function handleToolsInvokeHttpRequest( agentTo, agentThreadId, senderIsOwner, + conversationReadOrigin: "direct-operator", toolCallIdPrefix: "http", }); if (outcome.ok) { diff --git a/src/gateway/tools-invoke-shared.ts b/src/gateway/tools-invoke-shared.ts index c5083dbd7492..3abf1138b95f 100644 --- a/src/gateway/tools-invoke-shared.ts +++ b/src/gateway/tools-invoke-shared.ts @@ -9,6 +9,10 @@ import { resolveToolLoopDetectionConfig } from "../agents/agent-tools.js"; import { getChannelAgentToolMeta } from "../agents/channel-tools.js"; import { isKnownCoreToolId } from "../agents/tool-catalog.js"; import { ToolInputError, type AnyAgentTool } from "../agents/tools/common.js"; +import { + normalizeConversationReadInvocationOrigin, + type ConversationReadInvocationOrigin, +} from "../channels/plugins/conversation-read-origin.js"; import { resolveMainSessionKey } from "../config/sessions.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { logWarn } from "../logger.js"; @@ -156,9 +160,13 @@ export async function invokeGatewayTool(params: { agentThreadId?: string; senderIsOwner?: boolean; clientCaps?: string[]; + conversationReadOrigin?: ConversationReadInvocationOrigin; toolCallIdPrefix: string; approvalMode?: "request" | "report"; }): Promise { + const conversationReadOrigin = normalizeConversationReadInvocationOrigin( + params.conversationReadOrigin, + ); const toolName = normalizeOptionalString(params.input.name ?? params.input.tool) ?? ""; if (!toolName) { return { @@ -207,6 +215,7 @@ export async function invokeGatewayTool(params: { agentThreadId: params.agentThreadId, senderIsOwner: params.senderIsOwner, clientCaps: params.clientCaps, + conversationReadOrigin, allowGatewaySubagentBinding: true, allowMediaInvokeCommands: true, surface: "http", @@ -244,8 +253,8 @@ export async function invokeGatewayTool(params: { const gatewayTool: AnyAgentTool = tool; const idempotencyKey = normalizeOptionalString(params.input.idempotencyKey); const toolCallId = idempotencyKey - ? `${params.toolCallIdPrefix}-${idempotencyKey}` - : `${params.toolCallIdPrefix}-${Date.now()}`; + ? `${params.toolCallIdPrefix}-${conversationReadOrigin}-${idempotencyKey}` + : `${params.toolCallIdPrefix}-${conversationReadOrigin}-${Date.now()}`; const toolArgs = mergeActionIntoArgsIfSupported({ toolSchema: gatewayTool.parameters, action, diff --git a/src/infra/outbound/message-action-normalization.test.ts b/src/infra/outbound/message-action-normalization.test.ts index 76a412267a3e..405d2d29ab5b 100644 --- a/src/infra/outbound/message-action-normalization.test.ts +++ b/src/infra/outbound/message-action-normalization.test.ts @@ -136,6 +136,78 @@ describe("normalizeMessageActionInput", () => { expectedFields: { messageId: "msg_123" }, absentFields: ["target", "to"], }, + { + input: { + action: "react", + args: { + channel: "imessage", + messageId: "msg_123", + }, + toolContext: { + currentChannelId: "chat_guid:iMessage;+;chat0000", + currentChannelProvider: "imessage", + }, + }, + expectedFields: { + target: "chat_guid:iMessage;+;chat0000", + to: "chat_guid:iMessage;+;chat0000", + messageId: "msg_123", + }, + }, + { + input: { + action: "edit", + args: { + channel: "imessage", + messageId: "msg_123", + }, + toolContext: { + currentChannelId: "chat_guid:iMessage;+;chat0000", + currentChannelProvider: "imessage", + }, + }, + expectedFields: { + target: "chat_guid:iMessage;+;chat0000", + to: "chat_guid:iMessage;+;chat0000", + messageId: "msg_123", + }, + }, + { + input: { + action: "unsend", + args: { + channel: "imessage", + messageId: "msg_123", + }, + toolContext: { + currentChannelId: "chat_guid:iMessage;+;chat0000", + currentChannelProvider: "imessage", + }, + }, + expectedFields: { + target: "chat_guid:iMessage;+;chat0000", + to: "chat_guid:iMessage;+;chat0000", + messageId: "msg_123", + }, + }, + { + input: { + action: "poll-vote", + args: { + channel: "imessage", + pollId: "poll_123", + }, + toolContext: { + currentChannelId: "chat_guid:iMessage;+;chat0000", + currentChannelProvider: "imessage", + }, + }, + expectedFields: { + target: "chat_guid:iMessage;+;chat0000", + to: "chat_guid:iMessage;+;chat0000", + pollId: "poll_123", + }, + }, { input: { action: "pin", @@ -230,6 +302,21 @@ describe("normalizeMessageActionInput", () => { ).toThrow(/requires a target/); }); + it.each([ + { action: "react" as const, args: { channel: "imessage", messageId: "msg_123" } }, + { action: "poll-vote" as const, args: { channel: "imessage", pollId: "poll_123" } }, + ])( + "throws when $action has only a resource reference and no current target", + ({ action, args }) => { + expect(() => + normalizeMessageActionInput({ + action, + args, + }), + ).toThrow(/requires a target/); + }, + ); + it("rejects conflicting canonical and plugin delivery targets", () => { expect(() => normalizeMessageActionInput({ diff --git a/src/infra/outbound/message-action-normalization.ts b/src/infra/outbound/message-action-normalization.ts index 457041668cb9..e6d70a10e4a3 100644 --- a/src/infra/outbound/message-action-normalization.ts +++ b/src/infra/outbound/message-action-normalization.ts @@ -11,6 +11,7 @@ import { } from "../../utils/message-channel.js"; import { applyTargetToParams } from "./channel-target.js"; import { + actionHasResourceReference, actionHasTarget, actionRequiresTarget, resolveActionDeliveryTargetAlias, @@ -44,6 +45,10 @@ export function normalizeMessageActionInput(params: { channel: inferredChannel, aliasSpec: params.targetAliasSpec, }); + const hasResourceReference = actionHasResourceReference(action, normalizedArgs, { + channel: inferredChannel, + aliasSpec: params.targetAliasSpec, + }); if (deliveryAliasTarget && explicitTarget && deliveryAliasTarget !== explicitTarget) { throw new Error(`Action ${action} received conflicting target and delivery alias values.`); @@ -67,7 +72,7 @@ export function normalizeMessageActionInput(params: { !hasLegacyTarget && !deliveryAliasTarget && actionRequiresTarget(action) && - !actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) + (hasResourceReference || !actionHasTarget(action, normalizedArgs, { channel: inferredChannel })) ) { const inferredTarget = normalizeOptionalString(toolContext?.currentChannelId) ?? @@ -92,9 +97,15 @@ export function normalizeMessageActionInput(params: { } applyTargetToParams({ action, args: normalizedArgs }); + const hasCanonicalTarget = [ + normalizedArgs.target, + normalizedArgs.to, + normalizedArgs.channelId, + ].some((value) => Boolean(normalizeOptionalString(value))); if ( actionRequiresTarget(action) && - !actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) + (!actionHasTarget(action, normalizedArgs, { channel: inferredChannel }) || + (hasResourceReference && !hasCanonicalTarget)) ) { throw new Error(`Action ${action} requires a target.`); } diff --git a/src/infra/outbound/message-action-runner.media.test.ts b/src/infra/outbound/message-action-runner.media.test.ts index fc3d2300c162..f632137c6499 100644 --- a/src/infra/outbound/message-action-runner.media.test.ts +++ b/src/infra/outbound/message-action-runner.media.test.ts @@ -317,6 +317,62 @@ describe("runMessageAction media behavior", () => { expect(sendArgs.asVoice).toBe(true); }); + it("rejects plugin-declined attachment actions before loading media", async () => { + const handleAction = vi.fn(async () => jsonResult({ ok: true })); + const textOnlyPlugin: ChannelPlugin = { + ...createChannelTestPluginBase({ + id: "textonly", + label: "TextOnly", + config: { + listAccountIds: () => ["default"], + resolveAccount: () => ({ enabled: true }), + isConfigured: () => true, + }, + }), + outbound: { + deliveryMode: "direct", + resolveTarget: ({ to }) => ({ ok: true, to: to?.trim() ?? "" }), + sendText: async () => ({ channel: "textonly", messageId: "msg-test" }), + sendMedia: async () => ({ channel: "textonly", messageId: "msg-test" }), + }, + actions: { + describeMessageTool: () => ({ actions: ["send"] }), + supportsAction: ({ action }) => action === "send", + handleAction, + }, + }; + setActivePluginRegistry( + createTestRegistry([ + { + pluginId: "textonly", + source: "test", + plugin: textOnlyPlugin, + }, + ]), + ); + vi.mocked(loadWebMedia).mockResolvedValue({ + buffer: Buffer.from("should not load"), + contentType: "image/png", + kind: "image", + fileName: "pic.png", + }); + + await expect( + runMessageAction({ + cfg: { channels: { textonly: { enabled: true } } } as OpenClawConfig, + action: "upload-file", + params: { + channel: "textonly", + target: "room-1", + media: "https://example.com/pic.png", + }, + }), + ).rejects.toThrow("Message action upload-file not supported for channel textonly."); + + expect(loadWebMedia).not.toHaveBeenCalled(); + expect(handleAction).not.toHaveBeenCalled(); + }); + it("materializes buffer-only send attachments into outbound media paths", async () => { setActivePluginRegistry( createTestRegistry([ diff --git a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts index 40a0fb13787d..70d9e2ed9496 100644 --- a/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts +++ b/src/infra/outbound/message-action-runner.plugin-dispatch.test.ts @@ -334,14 +334,21 @@ describe("runMessageAction plugin dispatch", () => { capabilities: { chatTypes: ["direct", "channel"] }, config: createAlwaysConfiguredPluginConfig(), messaging: { + targetPrefixes: ["actionhub", "actionhub-alias"], + normalizeTarget: (raw) => raw.replace(/^actionhub-alias:/i, "actionhub:"), targetResolver: { looksLikeId: () => true, }, }, actions: { - describeMessageTool: () => ({ actions: ["pin", "list-pins", "member-info"] }), + describeMessageTool: () => ({ + actions: ["pin", "list-pins", "member-info", "channel-info"], + }), supportsAction: ({ action }) => - action === "pin" || action === "list-pins" || action === "member-info", + action === "pin" || + action === "list-pins" || + action === "member-info" || + action === "channel-info", handleAction, }, }; @@ -366,6 +373,7 @@ describe("runMessageAction plugin dispatch", () => { }); it("dispatches messageId/chatId-based plugin actions through the shared runner", async () => { + const resolveAgentRuntimeIdentityToken = vi.fn(async () => "unused-agent-runtime-token"); await runMessageAction({ cfg: { channels: { @@ -379,6 +387,12 @@ describe("runMessageAction plugin dispatch", () => { channel: "actionhub", messageId: "om_123", }, + gateway: { + resolveAgentRuntimeIdentityToken, + clientName: "gateway-client", + mode: "backend", + }, + conversationReadOrigin: "direct-operator", dryRun: false, }); @@ -395,11 +409,16 @@ describe("runMessageAction plugin dispatch", () => { channel: "actionhub", chatId: "oc_123", }, + conversationReadOrigin: "direct-operator", dryRun: false, }); const pinCall = readPluginCall(handleAction, 0); - expectRecordFields(pinCall, { action: "pin" }, "pin call"); + expectRecordFields( + pinCall, + { action: "pin", conversationReadOrigin: "direct-operator" }, + "pin call", + ); expectRecordFields( readRecordField(pinCall, "params", "pin call params"), { messageId: "om_123" }, @@ -412,6 +431,7 @@ describe("runMessageAction plugin dispatch", () => { { chatId: "oc_123" }, "list pins call params", ); + expect(resolveAgentRuntimeIdentityToken).not.toHaveBeenCalled(); }); it("routes execution context ids into plugin handleAction", async () => { @@ -435,6 +455,7 @@ describe("runMessageAction plugin dispatch", () => { defaultAccountId: "ops", requesterAccountId: "ops", requesterSenderId: "trusted-user", + conversationReadOrigin: "direct-operator", sessionKey: "agent:alpha:main", sessionId: "session-123", agentId: "alpha", @@ -456,6 +477,7 @@ describe("runMessageAction plugin dispatch", () => { accountId: "ops", requesterAccountId: "ops", requesterSenderId: "trusted-user", + conversationReadOrigin: "direct-operator", sessionKey: "agent:alpha:main", sessionId: "session-123", inboundEventKind: "room_event", @@ -478,6 +500,153 @@ describe("runMessageAction plugin dispatch", () => { }); }); + it("uses capability authorization instead of ambient routing for local plugin actions", async () => { + const cfg = { + channels: { + actionhub: { + enabled: true, + }, + }, + } as OpenClawConfig; + + await expect( + runMessageAction({ + cfg, + action: "pin", + params: { + channel: "actionhub", + messageId: "om_123", + target: "forged-current", + }, + requesterAccountId: "forged-account", + requesterSenderId: "forged-sender", + toolContext: { + currentChannelId: "forged-current", + currentChannelProvider: "actionhub", + }, + messageActionAuthorization: {}, + dryRun: false, + }), + ).rejects.toThrow("requires the exact current conversation and account"); + expect(handleAction).not.toHaveBeenCalled(); + + await runMessageAction({ + cfg, + action: "pin", + params: { + channel: "actionhub", + messageId: "om_123", + target: "trusted-current", + }, + defaultAccountId: "trusted-account", + requesterAccountId: "forged-account", + requesterSenderId: "forged-sender", + toolContext: { + currentChannelId: "forged-current", + currentChannelProvider: "actionhub", + }, + messageActionAuthorization: { + requesterAccountId: "trusted-account", + requesterSenderId: "trusted-sender", + toolContext: { + currentChannelId: "trusted-current", + currentChannelProvider: "actionhub", + }, + }, + dryRun: false, + }); + + const trustedCall = readPluginCall(handleAction, 0); + expectRecordFields( + trustedCall, + { + requesterAccountId: "trusted-account", + requesterSenderId: "trusted-sender", + }, + "trusted plugin action call", + ); + expectRecordFields( + readRecordField(trustedCall, "toolContext", "trusted plugin tool context"), + { + currentChannelId: "trusted-current", + currentChannelProvider: "actionhub", + }, + "trusted plugin tool context", + ); + }); + + it("canonicalizes channelId-backed execution targets after host authorization", async () => { + await runMessageAction({ + cfg: { + channels: { + actionhub: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "channel-info", + params: { + channel: "actionhub", + target: "actionhub-alias:current", + }, + defaultAccountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelId: "actionhub:current", + currentChannelProvider: "actionhub", + currentChatType: "channel", + }, + dryRun: false, + }); + + const call = readFirstPluginCall(handleAction); + expectRecordFields( + readRecordField(call, "params", "normalized plugin params"), + { + target: "actionhub:current", + channelId: "actionhub:current", + }, + "normalized plugin params", + ); + }); + + it("canonicalizes the execution target only after host authorization", async () => { + await runMessageAction({ + cfg: { + channels: { + actionhub: { + enabled: true, + }, + }, + } as OpenClawConfig, + action: "pin", + params: { + channel: "actionhub", + target: "actionhub-alias:current", + messageId: "om_123", + }, + defaultAccountId: "default", + requesterAccountId: "default", + conversationReadOrigin: "delegated", + toolContext: { + currentChannelId: "actionhub:current", + currentChannelProvider: "actionhub", + }, + dryRun: false, + }); + + const call = readFirstPluginCall(handleAction); + expectRecordFields( + readRecordField(call, "params", "normalized plugin params"), + { + target: "actionhub:current", + to: "actionhub:current", + }, + "normalized plugin params", + ); + }); + it("preserves no-context owner Discord admin actions through the shared runner", async () => { const handleDiscordAction = vi.fn(async (ctx: ChannelMessageActionContext) => { const currentProvider = ctx.toolContext?.currentChannelProvider?.trim().toLowerCase(); @@ -522,7 +691,14 @@ describe("runMessageAction plugin dispatch", () => { } as OpenClawConfig; setActivePluginRegistry( - createTestRegistry([{ pluginId: "discord", source: "test", plugin: discordPlugin }]), + createTestRegistry([ + { + pluginId: "discord", + source: "test", + origin: "bundled", + plugin: discordPlugin, + }, + ]), ); await runMessageAction({ @@ -613,11 +789,12 @@ describe("runMessageAction plugin dispatch", () => { }, ]), ); - mocks.callGateway.mockResolvedValue({ + mocks.callGatewayLeastPrivilege.mockResolvedValue({ ok: true, added: "✅", }); + const resolveAgentRuntimeIdentityToken = vi.fn(async () => "agent-runtime-token"); const result = await runMessageAction({ cfg: { channels: { @@ -644,25 +821,27 @@ describe("runMessageAction plugin dispatch", () => { currentMessageId: "wamid.1", }, gateway: { - clientName: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - mode: GATEWAY_CLIENT_MODES.BACKEND, + resolveAgentRuntimeIdentityToken, + clientName: "cli", + mode: "cli", }, dryRun: false, }); - const gatewayCall = readMockCallArg(mocks.callGateway, "trusted gateway call"); - expectRecordFields( - gatewayCall, - { method: "message.action", scopes: ["operator.admin"] }, - "gateway call", + const gatewayCall = readMockCallArg( + mocks.callGatewayLeastPrivilege, + "gateway least privilege call", ); + expectRecordFields(gatewayCall, { method: "message.action" }, "gateway call"); + expect(gatewayCall.agentRuntimeIdentityToken).toBe("agent-runtime-token"); + expect(resolveAgentRuntimeIdentityToken).toHaveBeenCalledTimes(1); const gatewayParams = readRecordField(gatewayCall, "params", "gateway call params"); + expect(gatewayParams).not.toHaveProperty("conversationReadOrigin"); expectRecordFields( gatewayParams, { channel: "gatewaychat", action: "react", - requesterSenderId: "trusted-user", sessionKey: "agent:alpha:main", sessionId: "session-123", agentId: "alpha", @@ -671,15 +850,9 @@ describe("runMessageAction plugin dispatch", () => { }, "gateway call params", ); - expectRecordFields( - readRecordField(gatewayParams, "toolContext", "gateway tool context"), - { - currentChannelProvider: "gatewaychat", - currentMessageId: "wamid.1", - }, - "gateway tool context", - ); - expect(mocks.callGatewayLeastPrivilege).not.toHaveBeenCalled(); + expect(gatewayParams).not.toHaveProperty("requesterAccountId"); + expect(gatewayParams).not.toHaveProperty("requesterSenderId"); + expect(gatewayParams).not.toHaveProperty("toolContext"); expect(handleActionEntry).not.toHaveBeenCalled(); expectRecordFields( result, @@ -950,6 +1123,7 @@ describe("runMessageAction plugin dispatch", () => { }, } as OpenClawConfig, action: "send", + conversationReadOrigin: "direct-operator", params: { channel: "gatewaychat", target: "user-123", @@ -973,6 +1147,7 @@ describe("runMessageAction plugin dispatch", () => { { channel: "gatewaychat", action: "send", + conversationReadOrigin: "direct-operator", idempotencyKey: "idem-gateway-action", }, "gateway call params", diff --git a/src/infra/outbound/message-action-runner.ts b/src/infra/outbound/message-action-runner.ts index 8e3e2224811f..a3121ce69037 100644 --- a/src/infra/outbound/message-action-runner.ts +++ b/src/infra/outbound/message-action-runner.ts @@ -19,6 +19,10 @@ import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import { resolveResponsePrefixTemplate } from "../../auto-reply/reply/response-prefix-template.js"; import { normalizeChatType, type ChatType } from "../../channels/chat-type.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; +import { + normalizeConversationReadInvocationOrigin, + type ConversationReadInvocationOrigin, +} from "../../channels/plugins/conversation-read-origin.js"; import { getChannelPlugin } from "../../channels/plugins/index.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { @@ -47,8 +51,6 @@ import { stripUnsupportedCitationControlMarkers } from "../../shared/text/citati import { stripFormattedReasoningMessage } from "../../shared/text/formatted-reasoning-message.js"; import { parseInlineDirectives } from "../../utils/directive-tags.js"; import { - GATEWAY_CLIENT_MODES, - GATEWAY_CLIENT_NAMES, INTERNAL_MESSAGE_CHANNEL, type GatewayClientMode, type GatewayClientName, @@ -108,6 +110,7 @@ export type MessageActionRunnerGateway = { url?: string; token?: string; timeoutMs?: number; + resolveAgentRuntimeIdentityToken?: () => Promise; clientName: GatewayClientName; clientDisplayName?: string; mode: GatewayClientMode; @@ -130,6 +133,16 @@ export type RunMessageActionParams = { requesterSenderUsername?: string | null; requesterSenderE164?: string | null; senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; + /** + * Authorization facts resolved from the host-issued current-turn capability. + * Presence means ambient routing fields must not be used as identity. + */ + messageActionAuthorization?: { + requesterAccountId?: string; + requesterSenderId?: string; + toolContext?: ChannelThreadingToolContext; + }; sessionId?: string; toolContext?: ChannelThreadingToolContext; gateway?: MessageActionRunnerGateway; @@ -209,9 +222,10 @@ async function callGatewayMessageAction(params: { gateway?: MessageActionRunnerGateway; actionParams: Record; }): Promise { - const { callGateway, callGatewayLeastPrivilege } = await loadMessageActionGatewayRuntime(); + const { callGatewayLeastPrivilege } = await loadMessageActionGatewayRuntime(); const gateway = resolveGatewayActionOptions(params.gateway); - const callParams = { + const agentRuntimeIdentityToken = await params.gateway?.resolveAgentRuntimeIdentityToken?.(); + return await callGatewayLeastPrivilege({ url: gateway.url, token: gateway.token, method: "message.action", @@ -220,26 +234,7 @@ async function callGatewayMessageAction(params: { clientName: gateway.clientName, clientDisplayName: gateway.clientDisplayName, mode: gateway.mode, - }; - const isTrustedBackendBridge = - gateway.clientName === GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT && - gateway.mode === GATEWAY_CLIENT_MODES.BACKEND; - const requesterAccountId = normalizeOptionalString(params.actionParams.requesterAccountId); - const requesterSenderId = normalizeOptionalString(params.actionParams.requesterSenderId); - const carriesTrustedRequester = - isTrustedBackendBridge && - (requesterAccountId !== undefined || - requesterSenderId !== undefined || - params.actionParams.senderIsOwner !== undefined); - if (!carriesTrustedRequester) { - return await callGatewayLeastPrivilege(callParams); - } - // Trusted requester fields come from inbound server context. The RPC needs - // admin scope to prove provenance; the Gateway recognizes this backend bridge - // and keeps channel-handler authorization at message.action least privilege. - return await callGateway({ - ...callParams, - scopes: ["operator.admin"], + agentRuntimeIdentityToken, }); } @@ -687,6 +682,9 @@ async function runGatewayPluginMessageActionOrNull(params: { if (executionMode !== "gateway") { return null; } + const conversationReadOrigin = normalizeConversationReadInvocationOrigin( + params.input.conversationReadOrigin, + ); const payload = await callGatewayMessageAction({ gateway: params.gateway, actionParams: { @@ -694,14 +692,12 @@ async function runGatewayPluginMessageActionOrNull(params: { action: params.action, params: params.params, accountId: params.accountId ?? undefined, - requesterAccountId: params.input.requesterAccountId ?? undefined, - requesterSenderId: params.input.requesterSenderId ?? undefined, senderIsOwner: params.input.senderIsOwner, sessionKey: params.input.sessionKey, sessionId: params.input.sessionId, inboundTurnKind: params.input.inboundEventKind, agentId: params.agentId, - toolContext: params.input.toolContext, + ...(conversationReadOrigin === "direct-operator" ? { conversationReadOrigin } : {}), idempotencyKey: await resolveGatewayActionIdempotencyKey( normalizeOptionalString(params.params.idempotencyKey), ), @@ -721,6 +717,7 @@ function resolveGateway(input: RunMessageActionParams): MessageActionRunnerGatew clientName: input.gateway.clientName, clientDisplayName: input.gateway.clientDisplayName, mode: input.gateway.mode, + resolveAgentRuntimeIdentityToken: input.gateway.resolveAgentRuntimeIdentityToken, }; } @@ -1252,6 +1249,9 @@ async function handleSendAction(ctx: ResolvedActionContext): Promise ({ getBootstrapChannelPlugin: ( @@ -59,6 +63,12 @@ describe("actionHasTarget", () => { { action: "react", params: { chatGuid: "chat-guid" }, expected: true }, { action: "react", params: { chatIdentifier: "chat-id" }, expected: true }, { action: "react", params: { chatId: 42 }, expected: true }, + { + action: "react", + params: { messageId: "msg_123" }, + ctx: { channel: "imessage" }, + expected: true, + }, { action: "upload-file", params: { chatIdentifier: "chat-id" }, @@ -91,3 +101,40 @@ describe("actionHasTarget", () => { expect(actionHasTarget(action as never, params, ctx)).toBe(expected); }); }); + +describe("actionHasResourceReference", () => { + it.each([ + { + action: "react" as const, + params: { messageId: "msg_123" }, + channel: "imessage", + expected: true, + }, + { + action: "poll-vote" as const, + params: { pollId: "poll_123" }, + channel: "imessage", + expected: true, + }, + { + action: "react" as const, + params: { chatGuid: "iMessage;+;chat0000" }, + channel: "imessage", + expected: false, + }, + { + action: "react" as const, + params: { messageId: "msg_123" }, + channel: undefined, + expected: false, + }, + { + action: "pin" as const, + params: { messageId: "msg_123" }, + channel: "pinboard", + expected: false, + }, + ])("$action resource classification is $expected", ({ action, params, channel, expected }) => { + expect(actionHasResourceReference(action, params, { channel })).toBe(expected); + }); +}); diff --git a/src/infra/outbound/message-action-spec.ts b/src/infra/outbound/message-action-spec.ts index a0ca44577a98..2aeaa8fd8f7f 100644 --- a/src/infra/outbound/message-action-spec.ts +++ b/src/infra/outbound/message-action-spec.ts @@ -6,7 +6,10 @@ import { normalizeOptionalStringifiedId, } from "@openclaw/normalization-core/string-coerce"; import { getBootstrapChannelPlugin } from "../../channels/plugins/bootstrap-registry.js"; -import type { ChannelMessageActionName } from "../../channels/plugins/types.public.js"; +import type { + ChannelMessageActionName, + ChannelThreadingToolContext, +} from "../../channels/plugins/types.public.js"; import { hasPotentialPluginActionParam } from "./message-action-param-keys.js"; /** @@ -81,9 +84,14 @@ type ActionTargetAliasSpec = { aliases: string[]; }; -export type ActionDeliveryTargetAliasSpec = { +export type ActionDeliveryTargetAliasSpec = ActionTargetAliasSpec & { deliveryTargetAliases?: string[]; resolveDeliveryTarget?: (params: { args: Record }) => string | undefined; + matchesCurrentConversation?: (params: { + args: Record; + accountId: string; + toolContext: ChannelThreadingToolContext; + }) => boolean; }; const ACTION_TARGET_ALIASES: Partial> = { @@ -147,6 +155,37 @@ export function resolveActionDeliveryTargetAlias( return targets[0]; } +/** Reports whether a plugin alias identifies an existing resource rather than a conversation. */ +export function actionHasResourceReference( + action: ChannelMessageActionName, + params: Record, + options?: { channel?: string; aliasSpec?: ActionDeliveryTargetAliasSpec }, +): boolean { + const channel = normalizeOptionalLowercaseString(options?.channel); + if (!channel || !hasPotentialPluginActionParam(params)) { + return false; + } + const aliases = + options?.aliasSpec ?? + getBootstrapChannelPlugin(channel)?.actions?.messageActionTargetAliases?.[action]; + // Legacy alias specs do not distinguish conversations from resources. + // Do not infer ambient authority unless the owner explicitly partitions them. + if (!aliases?.deliveryTargetAliases) { + return false; + } + const deliveryAliases = new Set(aliases.deliveryTargetAliases); + return aliases.aliases.some((alias) => { + if (deliveryAliases.has(alias)) { + return false; + } + const value = params[alias]; + if (typeof value === "string") { + return Boolean(normalizeOptionalString(value)); + } + return typeof value === "number" && Number.isFinite(value); + }); +} + /** * Reports whether an action normally needs a destination target. */ diff --git a/src/infra/outbound/message-action-test-fixtures.ts b/src/infra/outbound/message-action-test-fixtures.ts index ed4069d65212..9ea971334418 100644 --- a/src/infra/outbound/message-action-test-fixtures.ts +++ b/src/infra/outbound/message-action-test-fixtures.ts @@ -29,6 +29,21 @@ export function createPinboardMessageActionBootstrapRegistryMock() { return { actions: { messageActionTargetAliases: { + react: { + aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"], + deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], + resolveDeliveryTarget: resolveIMessageTarget, + }, + edit: { + aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"], + deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], + resolveDeliveryTarget: resolveIMessageTarget, + }, + unsend: { + aliases: ["chatGuid", "chatIdentifier", "chatId", "messageId"], + deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], + resolveDeliveryTarget: resolveIMessageTarget, + }, "upload-file": { aliases: ["chatGuid", "chatIdentifier", "chatId"] }, poll: { aliases: ["chatGuid", "chatIdentifier", "chatId"], @@ -36,7 +51,7 @@ export function createPinboardMessageActionBootstrapRegistryMock() { resolveDeliveryTarget: resolveIMessageTarget, }, "poll-vote": { - aliases: ["chatGuid", "chatIdentifier", "chatId"], + aliases: ["chatGuid", "chatIdentifier", "chatId", "pollId", "messageId"], deliveryTargetAliases: ["chatGuid", "chatIdentifier", "chatId"], resolveDeliveryTarget: resolveIMessageTarget, }, diff --git a/src/infra/outbound/outbound-send-service.ts b/src/infra/outbound/outbound-send-service.ts index 5386ff58f0ce..3ffee0047957 100644 --- a/src/infra/outbound/outbound-send-service.ts +++ b/src/infra/outbound/outbound-send-service.ts @@ -3,6 +3,7 @@ import type { AgentToolResult } from "../../agents/runtime/index.js"; import type { ReplyPayload } from "../../auto-reply/reply-payload.js"; import type { InboundEventKind } from "../../channels/inbound-event/kind.js"; +import type { ConversationReadInvocationOrigin } from "../../channels/plugins/conversation-read-origin.js"; import { dispatchChannelMessageAction } from "../../channels/plugins/message-action-dispatch.js"; import type { ChannelId, @@ -52,6 +53,7 @@ export type OutboundSendContext = { requesterSenderUsername?: string; requesterSenderE164?: string; senderIsOwner?: boolean; + conversationReadOrigin?: ConversationReadInvocationOrigin; mediaAccess?: OutboundMediaAccess; mediaReadFile?: OutboundMediaReadFile; accountId?: string | null; @@ -213,6 +215,7 @@ function createChannelActionContext(params: { requesterAccountId: params.ctx.requesterAccountId, requesterSenderId: params.ctx.requesterSenderId, senderIsOwner: params.ctx.senderIsOwner, + conversationReadOrigin: params.ctx.conversationReadOrigin, sessionKey: params.ctx.sessionKey, sessionId: params.ctx.sessionId, inboundEventKind: params.ctx.inboundEventKind, diff --git a/src/plugins/channel-registry-state.types.ts b/src/plugins/channel-registry-state.types.ts index 7970791e76b6..1ebc525cd106 100644 --- a/src/plugins/channel-registry-state.types.ts +++ b/src/plugins/channel-registry-state.types.ts @@ -22,7 +22,7 @@ export type ActiveChannelPluginRuntimeShape = { export type ActivePluginChannelRegistration = { plugin: ActiveChannelPluginRuntimeShape; pluginId?: string | null; - origin?: string | null; + origin?: import("./plugin-origin.types.js").PluginOrigin | null; }; /** Active runtime channel registry snapshot. */ diff --git a/src/plugins/compat/conversation-read-tools.ts b/src/plugins/compat/conversation-read-tools.ts new file mode 100644 index 000000000000..f93869c03547 --- /dev/null +++ b/src/plugins/compat/conversation-read-tools.ts @@ -0,0 +1,32 @@ +import type { PluginManifestRecord } from "../manifest-registry.js"; +import type { PluginToolRegistration } from "../registry-types.js"; + +const HOST_RESTRICTED_CONVERSATION_READ_TOOLS = new Set(["feishu:feishu_chat"]); + +function normalizeContractName(value: string): string { + return value.trim().toLowerCase(); +} + +export function isHostRestrictedConversationReadTool(params: { + pluginId: string; + toolName: string; +}): boolean { + return HOST_RESTRICTED_CONVERSATION_READ_TOOLS.has( + `${normalizeContractName(params.pluginId)}:${normalizeContractName(params.toolName)}`, + ); +} + +export function registrationIncludesHostRestrictedConversationReadTool( + entry: PluginToolRegistration, +): boolean { + return [...entry.names, ...(entry.declaredNames ?? [])].some((toolName) => + isHostRestrictedConversationReadTool({ pluginId: entry.pluginId, toolName }), + ); +} + +export function isBundledConversationReadToolRegistration(params: { + entry: PluginToolRegistration; + manifestPlugin: PluginManifestRecord | undefined; +}): boolean { + return params.entry.origin === "bundled" && params.manifestPlugin?.origin === "bundled"; +} diff --git a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts index 82dcefae643e..b6496ca9ee6d 100644 --- a/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts +++ b/src/plugins/contracts/plugin-sdk-runtime-api-guardrails.test.ts @@ -98,8 +98,6 @@ const RUNTIME_API_EXPORT_GUARDS: Record = { 'export { GoogleChatConfigSchema } from "openclaw/plugin-sdk/bundled-channel-config-schema";', 'export { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";', 'export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";', - 'export { readRemoteMediaBuffer, resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";', - 'export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";', 'export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";', 'export { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";', 'export type { GoogleChatAccountConfig, GoogleChatConfig } from "openclaw/plugin-sdk/config-contracts";', diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index 535eb23e4827..3b6e54d8d299 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -4082,6 +4082,61 @@ module.exports = { id: "throws-after-import", register() {} };`, delete (globalThis as Record)[marker]; }); + it("ignores plugin-supplied conversation-read authority claims", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "conversation-read-provenance-test", + filename: "conversation-read-provenance-test.cjs", + body: `module.exports = { + id: "conversation-read-provenance-test", + register(api) { + const createTool = (name) => () => ({ + name, + description: name, + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + api.registerTool(createTool("attested_tool"), { + name: "attested_tool", + conversationReadPolicy: "current-or-configured-v1", + supportsConversationReadPolicyV1: true, + }); + api.registerTool(createTool("unknown_policy_tool"), { + name: "unknown_policy_tool", + conversationReadPolicy: "future-policy", + }); + }, + };`, + }); + updatePluginManifest(plugin, { + contracts: { tools: ["attested_tool", "unknown_policy_tool"] }, + }); + + const registry = loadOpenClawPlugins({ + activate: false, + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["conversation-read-provenance-test"], + }, + }, + }); + + expect(registry.tools).toHaveLength(2); + expect(registry.tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ names: ["attested_tool"], origin: "config" }), + expect.objectContaining({ names: ["unknown_policy_tool"], origin: "config" }), + ]), + ); + for (const entry of registry.tools) { + expect(entry).not.toHaveProperty("conversationReadPolicy"); + expect(entry).not.toHaveProperty("supportsConversationReadPolicyV1"); + } + }); + it("rejects plugin tool registration without manifest tool ownership", () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 4ef93fdc5994..e96ce4290254 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -82,6 +82,8 @@ export type PluginToolRegistration = { names: string[]; declaredNames?: string[]; optional: boolean; + /** Loader-owned provenance. Missing values are conservative legacy registrations. */ + origin?: PluginOrigin; source: string; rootDir?: string; }; @@ -126,6 +128,8 @@ export type PluginChannelRegistration = { pluginId: string; pluginName?: string; plugin: ChannelPlugin; + /** Loader-owned provenance. Missing values are conservative legacy registrations. */ + origin?: PluginOrigin; source: string; rootDir?: string; }; @@ -134,6 +138,8 @@ export type PluginChannelSetupRegistration = { pluginId: string; pluginName?: string; plugin: ChannelPlugin; + /** Loader-owned provenance. Missing values are conservative legacy registrations. */ + origin?: PluginOrigin; source: string; enabled: boolean; rootDir?: string; diff --git a/src/plugins/registry.channel-guard.test.ts b/src/plugins/registry.channel-guard.test.ts index 77d77c44dbbe..38b4fab9316d 100644 --- a/src/plugins/registry.channel-guard.test.ts +++ b/src/plugins/registry.channel-guard.test.ts @@ -129,9 +129,43 @@ describe("plugin registry channel guard", () => { expect(pluginRegistry.registry.channelSetups[0]).toMatchObject({ pluginId: "trusted-workspace-shadow", enabled: true, + origin: "workspace", }); expect(pluginRegistry.registry.channelSetups[0]?.plugin.id).toBe("telegram"); expect(pluginRegistry.registry.channels).toHaveLength(0); expect(record.channelIds).toEqual(["telegram"]); }); + + it.each(["bundled", "global", "workspace", "config"] as const)( + "copies loader-owned %s provenance into channel registrations", + (origin) => { + const pluginRegistry = createTestRegistry(); + const record = createPluginRecord({ + id: `${origin}-channel-owner`, + source: `/plugins/${origin}-channel-owner/index.ts`, + origin, + enabled: true, + }); + + pluginRegistry.registry.plugins.push(record); + pluginRegistry + .createApi(record, { config: {} as OpenClawConfig, registrationMode: "full" }) + .registerChannel({ + plugin: createChannelPlugin("telegram", `${origin} Telegram`), + }); + + expect(pluginRegistry.registry.channels).toEqual([ + expect.objectContaining({ + pluginId: `${origin}-channel-owner`, + origin, + }), + ]); + expect(pluginRegistry.registry.channelSetups).toEqual([ + expect.objectContaining({ + pluginId: `${origin}-channel-owner`, + origin, + }), + ]); + }, + ); }); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index b36bd7f41f78..e7917821663f 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -188,6 +188,7 @@ import type { OpenClawPluginService, OpenClawPluginToolContext, OpenClawPluginToolFactory, + OpenClawPluginToolOptions, PluginHookHandlerMap, PluginHookName, PluginHookRegistration as TypedPluginHookRegistration, @@ -602,7 +603,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { const registerTool = ( record: PluginRecord, tool: AnyAgentTool | OpenClawPluginToolFactory, - opts?: { name?: string; names?: string[]; optional?: boolean }, + opts?: OpenClawPluginToolOptions, ) => { if (pluginsWithChannelRegistrationConflict.has(record.id)) { return; @@ -650,6 +651,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { names: normalized, declaredNames, optional, + origin: record.origin, source: record.source, rootDir: record.rootDir, }); @@ -974,12 +976,14 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (existingRuntime.pluginId === record.id) { existingRuntime.plugin = plugin; existingRuntime.pluginName = record.name; + existingRuntime.origin = record.origin; existingRuntime.source = record.source; existingRuntime.rootDir = record.rootDir; const existingSetup = registry.channelSetups.find((entry) => entry.plugin.id === id); if (existingSetup) { existingSetup.plugin = plugin; existingSetup.pluginName = record.name; + existingSetup.origin = record.origin; existingSetup.source = record.source; existingSetup.enabled = record.enabled; existingSetup.rootDir = record.rootDir; @@ -1000,6 +1004,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (existingSetup.pluginId === record.id) { existingSetup.plugin = plugin; existingSetup.pluginName = record.name; + existingSetup.origin = record.origin; existingSetup.source = record.source; existingSetup.enabled = record.enabled; existingSetup.rootDir = record.rootDir; @@ -1021,6 +1026,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, pluginName: record.name, plugin, + origin: record.origin, source: record.source, enabled: record.enabled, rootDir: record.rootDir, @@ -1032,6 +1038,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginId: record.id, pluginName: record.name, plugin, + origin: record.origin, source: record.source, rootDir: record.rootDir, }); diff --git a/src/plugins/tool-descriptor-cache.test.ts b/src/plugins/tool-descriptor-cache.test.ts index cb8f6b42552d..54a828edde4a 100644 --- a/src/plugins/tool-descriptor-cache.test.ts +++ b/src/plugins/tool-descriptor-cache.test.ts @@ -165,6 +165,29 @@ describe("plugin tool descriptor cache keys", () => { expect(ownerKey).not.toBe(nonOwnerKey); }); + it("varies descriptor keys by native channel identity", () => { + const base = { + pluginId: "demo", + source: "/tmp/demo.js", + contractToolNames: ["demo"], + ctx: { + messageChannel: "feishu", + nativeChannelId: "oc_first", + }, + }; + + const firstKey = buildPluginToolDescriptorCacheKey(base); + const secondKey = buildPluginToolDescriptorCacheKey({ + ...base, + ctx: { + ...base.ctx, + nativeChannelId: "oc_second", + }, + }); + + expect(firstKey).not.toBe(secondKey); + }); + it("keeps descriptor keys stable across config bookkeeping writes", () => { const firstConfig = { id: "runtime", diff --git a/src/plugins/tool-descriptor-cache.ts b/src/plugins/tool-descriptor-cache.ts index 4d2ced0530d6..2768c1cbd84d 100644 --- a/src/plugins/tool-descriptor-cache.ts +++ b/src/plugins/tool-descriptor-cache.ts @@ -6,7 +6,7 @@ import type { JsonObject, ToolDescriptor } from "../tools/types.js"; import type { PluginLoadOptions } from "./loader.js"; import type { OpenClawPluginToolContext } from "./types.js"; -const PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION = 1; +const PLUGIN_TOOL_DESCRIPTOR_CACHE_VERSION = 3; const PLUGIN_TOOL_DESCRIPTOR_CACHE_LIMIT = 256; /** Cached display descriptor for one plugin-created tool. */ @@ -111,6 +111,7 @@ function buildDescriptorContextCacheKey(params: { browser: ctx.browser ?? null, messageChannel: ctx.messageChannel ?? null, agentAccountId: ctx.agentAccountId ?? null, + nativeChannelId: ctx.nativeChannelId ?? null, deliveryContext: ctx.deliveryContext ?? null, requesterSenderId: ctx.requesterSenderId ?? null, senderIsOwner: ctx.senderIsOwner ?? null, diff --git a/src/plugins/tool-types.ts b/src/plugins/tool-types.ts index d0ee53c34ceb..b2a37fd6556d 100644 --- a/src/plugins/tool-types.ts +++ b/src/plugins/tool-types.ts @@ -1,6 +1,7 @@ // Defines plugin tool metadata and filesystem policy types. import type { ToolFsPolicy } from "../agents/tool-fs-policy.types.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import type { ConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { HookEntry } from "../hooks/types.js"; import type { DeliveryContext } from "../utils/delivery-context.types.js"; @@ -44,10 +45,17 @@ export type OpenClawPluginToolContext = { resolveApiKeyForProvider?: (providerId: string) => Promise; /** Trusted ambient delivery route for the active agent/session. */ deliveryContext?: DeliveryContext; + /** Trusted platform-native conversation id for the active inbound turn. */ + nativeChannelId?: string; /** Trusted sender id from inbound context (runtime-provided, not tool args). */ requesterSenderId?: string; /** Trusted owner bit from inbound context (runtime-provided, not tool args). */ senderIsOwner?: boolean; + /** + * Server-owned origin for this operation. Missing values are delegated. + * Plugins must use it only for conversation-read visibility policy. + */ + conversationReadOrigin?: ConversationReadInvocationOrigin; sandboxed?: boolean; /** * True for explicit one-shot local CLI runs that must release plugin-owned diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index ec3cea1ca48b..d4c83b463a90 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -9,6 +9,7 @@ import { createEmptyPluginRegistry } from "./registry-empty.js"; type MockRegistryToolEntry = { pluginId: string; optional: boolean; + origin?: "bundled" | "global" | "workspace" | "config"; source: string; names: string[]; declaredNames?: string[]; @@ -61,7 +62,7 @@ function createContext() { config: { plugins: { enabled: true, - allow: ["optional-demo", "message", "multi"], + allow: ["optional-demo", "message", "multi", "feishu"], load: { paths: ["/tmp/plugin.js"] }, slots: { memory: "none" }, }, @@ -92,7 +93,11 @@ function createResolveToolsParams(params?: { function createToolRegistry(entries: MockRegistryToolEntry[]) { return { - plugins: entries.map((entry) => ({ id: entry.pluginId, status: "loaded" })), + plugins: entries.map((entry) => ({ + id: entry.pluginId, + origin: entry.origin ?? "bundled", + status: "loaded", + })), tools: entries, diagnostics: [] as Array<{ level: string; @@ -112,7 +117,7 @@ function setRegistry(entries: MockRegistryToolEntry[]) { plugins: entries .map((entry) => ({ id: entry.pluginId, - origin: "bundled", + origin: entry.origin ?? "bundled", enabledByDefault: true, channels: [], providers: [], @@ -1056,7 +1061,11 @@ describe("resolvePluginTools optional tools", () => { ], }); const partialRegistry = createToolRegistry([multiEntry]); - partialRegistry.plugins.push({ id: "optional-demo", status: "loaded" }); + partialRegistry.plugins.push({ + id: "optional-demo", + origin: "bundled", + status: "loaded", + }); const fullRegistry = createToolRegistry([multiEntry, optionalEntry]); setActivePluginRegistry?.( partialRegistry as never, @@ -1169,9 +1178,17 @@ describe("resolvePluginTools optional tools", () => { ], }); const staleRegistry = createToolRegistry([multiEntry]); - staleRegistry.plugins.push({ id: "optional-demo", status: "loaded" }); + staleRegistry.plugins.push({ + id: "optional-demo", + origin: "bundled", + status: "loaded", + }); const freshRegistry = createToolRegistry([optionalEntry]); - freshRegistry.plugins.push({ id: "multi", status: "loaded" }); + freshRegistry.plugins.push({ + id: "multi", + origin: "bundled", + status: "loaded", + }); setActivePluginRegistry?.( staleRegistry as never, "partial-test-tool-registry", @@ -2175,6 +2192,371 @@ describe("resolvePluginTools optional tools", () => { expect(factory).toHaveBeenCalledTimes(2); }); + it.each([ + ["direct-operator", "delegated"], + ["delegated", "direct-operator"], + ] as const)( + "reconstructs cached plugin executors with the current %s then %s origin", + async (firstOrigin, secondOrigin) => { + const factory = vi.fn((rawCtx: unknown) => { + const ctx = rawCtx as { conversationReadOrigin?: string }; + return { + ...makeTool("cached_origin_tool"), + async execute() { + return { + content: [ + { + type: "text", + text: ctx.conversationReadOrigin ?? "missing", + }, + ], + }; + }, + }; + }); + setRegistry([ + { + pluginId: "cache-origin-test", + optional: false, + source: "/tmp/cache-origin-test.js", + names: ["cached_origin_tool"], + factory, + }, + ]); + + resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: firstOrigin, + }, + }), + ); + const second = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: secondOrigin, + }, + }), + ); + + expect(factory).toHaveBeenCalledTimes(1); + await expect(second[0]?.execute("call", {}, undefined)).resolves.toEqual({ + content: [{ type: "text", text: secondOrigin }], + }); + expect(factory).toHaveBeenCalledTimes(2); + }, + ); + + it("hides a non-bundled conversation-read tool from delegated resolution before factory execution", () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "workspace", + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ); + + expectResolvedToolNames(tools, []); + expect(factory).not.toHaveBeenCalled(); + }); + + it("keeps a non-bundled conversation-read tool available to direct operators", () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "workspace", + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "direct-operator", + }, + }), + ); + + expectResolvedToolNames(tools, ["feishu_chat"]); + expect(factory).toHaveBeenCalledOnce(); + }); + + it("keeps the bundled Feishu conversation-read tool available to delegated calls", () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "bundled", + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ); + + expectResolvedToolNames(tools, ["feishu_chat"]); + expect(factory).toHaveBeenCalledOnce(); + }); + + it.each([undefined, "unknown"] as const)( + "fails closed for %s conversation-read tool registration provenance", + (origin) => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + ...(origin ? { origin: origin as never } : {}), + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ); + + expectResolvedToolNames(tools, []); + expect(factory).not.toHaveBeenCalled(); + }, + ); + + it("does not let an external override inherit bundled Feishu provenance", () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "config", + source: "/tmp/external-feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ); + + expectResolvedToolNames(tools, []); + expect(factory).not.toHaveBeenCalled(); + }); + + it("rejects a stale bundled registration when the current manifest owner is external", () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "bundled", + source: "/tmp/bundled-feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + installToolManifestSnapshot({ + config: createContext().config, + plugin: { + id: "feishu", + origin: "config", + enabledByDefault: true, + channels: ["feishu"], + providers: [], + contracts: { tools: ["feishu_chat"] }, + }, + }); + + const tools = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ); + + expectResolvedToolNames(tools, []); + expect(factory).not.toHaveBeenCalled(); + }); + + it.each([ + ["direct-operator", "delegated", ["feishu_chat"], []], + ["delegated", "direct-operator", [], ["feishu_chat"]], + ] as const)( + "does not leak a non-bundled conversation-read tool through cached %s then %s resolution", + (firstOrigin, secondOrigin, firstNames, secondNames) => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "workspace", + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const first = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: firstOrigin, + }, + }), + ); + const second = resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: secondOrigin, + }, + }), + ); + + expectResolvedToolNames(first, [...firstNames]); + expectResolvedToolNames(second, [...secondNames]); + expect(factory).toHaveBeenCalledTimes(1); + }, + ); + + it("keeps concurrent direct and delegated non-bundled resolutions isolated", async () => { + const factory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "workspace", + source: "/tmp/feishu.js", + names: ["feishu_chat"], + factory, + }, + ]); + + const [direct, delegated] = await Promise.all([ + Promise.resolve().then(() => + resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "direct-operator", + }, + }), + ), + ), + Promise.resolve().then(() => + resolvePluginTools( + createResolveToolsParams({ + context: { + ...createContext(), + conversationReadOrigin: "delegated", + }, + }), + ), + ), + ]); + + expectResolvedToolNames(direct, ["feishu_chat"]); + expectResolvedToolNames(delegated, []); + expect(factory).toHaveBeenCalledOnce(); + }); + + it("does not retain bundled authority in a cached executable after owner replacement", async () => { + const bundledFactory = vi.fn(() => makeTool("feishu_chat")); + setRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "bundled", + source: "/tmp/bundled-feishu.js", + names: ["feishu_chat"], + factory: bundledFactory, + }, + ]); + const context = { + ...createContext(), + conversationReadOrigin: "delegated" as const, + }; + resolvePluginTools(createResolveToolsParams({ context })); + const [cachedTool] = resolvePluginTools(createResolveToolsParams({ context })); + expect(cachedTool?.name).toBe("feishu_chat"); + expect(bundledFactory).toHaveBeenCalledOnce(); + + const externalFactory = vi.fn(() => makeTool("feishu_chat")); + const externalRegistry = createToolRegistry([ + { + pluginId: "feishu", + optional: false, + origin: "config", + source: "/tmp/external-feishu.js", + names: ["feishu_chat"], + factory: externalFactory, + }, + ]); + setActivePluginRegistry?.( + externalRegistry as never, + "external-feishu", + "gateway-bindable", + "/tmp", + ); + installToolManifestSnapshot({ + config: createContext().config, + plugin: { + id: "feishu", + origin: "config", + enabledByDefault: true, + channels: ["feishu"], + providers: [], + contracts: { tools: ["feishu_chat"] }, + }, + }); + + await expect(cachedTool?.execute("call", {}, undefined)).rejects.toThrow( + "plugin tool runtime missing", + ); + expect(externalFactory).not.toHaveBeenCalled(); + }); + it("retains cold-loaded plugin tools for cached descriptor execution after active registry replacement", async () => { const factory = vi.fn(() => makeTool("cached_lifecycle_tool")); const gatewayRegistry = setRegistry([ @@ -2210,7 +2592,11 @@ describe("resolvePluginTools optional tools", () => { factory: () => makeTool("unrelated_live_tool"), }; const replacementRegistry = createToolRegistry([unrelatedEntry]); - replacementRegistry.plugins.push({ id: "cache-lifecycle-test", status: "loaded" }); + replacementRegistry.plugins.push({ + id: "cache-lifecycle-test", + origin: "bundled", + status: "loaded", + }); setActivePluginRegistry?.(replacementRegistry as never, "provider-runtime", "default", "/tmp"); resolveRuntimePluginRegistryMock.mockReturnValue(undefined); loadOpenClawPluginsMock.mockReset(); @@ -2819,6 +3205,10 @@ describe("resolvePluginTools optional tools", () => { plugins: [], tools: [], diagnostics: [], + sessionExtensions: [], + runtimeLifecycles: [], + agentEventSubscriptions: [], + sessionSchedulerJobs: [], } as never, "provider-runtime", "default", diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index df897cc49b31..37e7b5257f02 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -7,8 +7,14 @@ import { import { compileGlobPatterns, matchesAnyGlobPattern } from "../agents/glob-pattern.js"; import { DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, normalizeToolName } from "../agents/tool-policy.js"; import type { AnyAgentTool } from "../agents/tools/common.js"; +import { normalizeConversationReadInvocationOrigin } from "../channels/plugins/conversation-read-origin.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { getLoadedRuntimePluginRegistry } from "./active-runtime-registry.js"; +import { + isBundledConversationReadToolRegistration, + isHostRestrictedConversationReadTool, + registrationIncludesHostRestrictedConversationReadTool, +} from "./compat/conversation-read-tools.js"; import { applyTestPluginDefaults, normalizePluginsConfig } from "./config-state.js"; import type { PluginLoadOptions } from "./loader.js"; import { @@ -207,6 +213,63 @@ function resolvePluginToolFactory(entry: PluginToolRegistration, ctx: OpenClawPl ); } +function blocksHostRestrictedConversationReadTool(params: { + pluginId: string; + toolNames: readonly string[]; + bundledOwner: boolean; + ctx: OpenClawPluginToolContext; +}): boolean { + if ( + normalizeConversationReadInvocationOrigin(params.ctx.conversationReadOrigin) === + "direct-operator" || + params.bundledOwner + ) { + return false; + } + return params.toolNames.some((toolName) => + isHostRestrictedConversationReadTool({ pluginId: params.pluginId, toolName }), + ); +} + +function blocksHostRestrictedConversationReadRegistration(params: { + entry: PluginToolRegistration; + manifestPlugin: PluginManifestRecord | undefined; + ctx: OpenClawPluginToolContext; +}): boolean { + return ( + registrationIncludesHostRestrictedConversationReadTool(params.entry) && + blocksHostRestrictedConversationReadTool({ + pluginId: params.entry.pluginId, + toolNames: [...params.entry.names, ...(params.entry.declaredNames ?? [])], + bundledOwner: isBundledConversationReadToolRegistration({ + entry: params.entry, + manifestPlugin: params.manifestPlugin, + }), + ctx: params.ctx, + }) + ); +} + +function resolveCurrentManifestPlugin(params: { + pluginId: string; + ctx: OpenClawPluginToolContext; + loadContext: ReturnType; +}): PluginManifestRecord | undefined { + let config = params.ctx.runtimeConfig ?? params.ctx.config ?? params.loadContext.config; + if (params.ctx.getRuntimeConfig) { + try { + config = params.ctx.getRuntimeConfig() ?? config; + } catch { + return undefined; + } + } + return loadManifestContractSnapshot({ + config, + workspaceDir: params.loadContext.workspaceDir, + env: params.loadContext.env, + }).plugins.find((plugin) => plugin.id === params.pluginId); +} + /** * Builds a collision-proof key for plugin-owned tool metadata lookups. */ @@ -724,6 +787,20 @@ function createCachedDescriptorPluginTool(params: { const resolveCandidateTool = ( candidate: PluginToolRegistration, ): AnyAgentTool | undefined => { + const manifestPlugin = resolveCurrentManifestPlugin({ + pluginId, + ctx: params.ctx, + loadContext: params.loadContext, + }); + if ( + blocksHostRestrictedConversationReadRegistration({ + entry: candidate, + manifestPlugin, + ctx: params.ctx, + }) + ) { + return undefined; + } const resolved = resolvePluginToolFactory(candidate, params.ctx); const listRaw: unknown[] = Array.isArray(resolved) ? resolved : resolved ? [resolved] : []; for (const toolRaw of listRaw) { @@ -855,6 +932,16 @@ function resolveCachedPluginTools(params: { let hasNameConflict = false; const localNormalizedNames = new Set(); for (const cachedDescriptor of cached) { + if ( + blocksHostRestrictedConversationReadTool({ + pluginId: plugin.id, + toolNames: [cachedDescriptor.descriptor.name], + bundledOwner: plugin.origin === "bundled", + ctx: params.ctx, + }) + ) { + continue; + } if ( !cachedDescriptor.optional && !availableToolNames.some( @@ -1263,6 +1350,15 @@ export function resolvePluginTools(params: { ) { continue; } + if ( + blocksHostRestrictedConversationReadRegistration({ + entry, + manifestPlugin, + ctx: params.context, + }) + ) { + continue; + } const factoryResult = resolvePluginToolFactoryEntry({ entry, ctx: params.context, diff --git a/src/skills/runtime/tool-dispatch.test.ts b/src/skills/runtime/tool-dispatch.test.ts index e8ec14bac422..0df2120135f2 100644 --- a/src/skills/runtime/tool-dispatch.test.ts +++ b/src/skills/runtime/tool-dispatch.test.ts @@ -7,6 +7,7 @@ type CreateOpenClawToolsArg = { skillCommand?: { skillFile?: string }; }; cronCreatorToolAllowlist?: Array; + nativeChannelId?: string; }; const hoisted = vi.hoisted(() => { @@ -36,7 +37,11 @@ import { resolveSkillDispatchTools } from "./tool-dispatch.js"; describe("resolveSkillDispatchTools", () => { it("passes final filtered tool surface to cron jobs", () => { const tools = resolveSkillDispatchTools({ - message: { surface: "telegram", senderId: "user-1" }, + message: { + surface: "telegram", + senderId: "user-1", + nativeChannelId: "native-room-1", + }, cfg: { tools: { allow: ["read", "cron"] }, } as OpenClawConfig, @@ -50,6 +55,7 @@ describe("resolveSkillDispatchTools", () => { const args = hoisted.createOpenClawToolsMock.mock.calls[0]?.[0]; expect(tools.map((tool) => tool.name)).toEqual(["read", "cron"]); expect(args?.cronCreatorToolAllowlist).toEqual([{ name: "read" }, { name: "cron" }]); + expect(args?.nativeChannelId).toBe("native-room-1"); }); it("carries command skill file identity into tool diagnostics", () => { diff --git a/src/skills/runtime/tool-dispatch.ts b/src/skills/runtime/tool-dispatch.ts index 6d699af477e2..b788fbcedf5a 100644 --- a/src/skills/runtime/tool-dispatch.ts +++ b/src/skills/runtime/tool-dispatch.ts @@ -47,6 +47,7 @@ type SkillDispatchMessageContext = { senderE164?: string; originatingTo?: string; to?: string; + nativeChannelId?: string; messageThreadId?: string | number; memberRoleIds?: string[]; }; @@ -184,6 +185,7 @@ export function resolveSkillDispatchTools(params: { agentAccountId: params.message.accountId, agentTo: params.message.originatingTo ?? params.message.to, agentThreadId: params.message.messageThreadId ?? undefined, + nativeChannelId: params.message.nativeChannelId, agentGroupId: groupId, agentGroupChannel: params.sessionEntry?.groupChannel, agentGroupSpace: params.sessionEntry?.space, diff --git a/src/test-utils/channel-plugins.ts b/src/test-utils/channel-plugins.ts index 13f86ee1b948..4164f169166d 100644 --- a/src/test-utils/channel-plugins.ts +++ b/src/test-utils/channel-plugins.ts @@ -14,6 +14,7 @@ export type TestChannelRegistration = { pluginId: string; plugin: unknown; source: string; + origin?: "bundled" | "global" | "workspace" | "config"; }; export const createTestRegistry = (channels: TestChannelRegistration[] = []): PluginRegistry => ({ @@ -22,6 +23,7 @@ export const createTestRegistry = (channels: TestChannelRegistration[] = []): Pl channelSetups: channels.map((entry) => ({ pluginId: entry.pluginId, plugin: entry.plugin as PluginRegistry["channelSetups"][number]["plugin"], + ...(entry.origin ? { origin: entry.origin } : {}), source: entry.source, enabled: true, })),