From b934c0be57c69d32d63956e9229fd37dc85454fb Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Fri, 13 Mar 2026 14:19:34 -0700 Subject: [PATCH] fix(discord): pass real auth to plugin interactions --- .../discord/src/monitor/agent-components.ts | 49 ++++++++- .../discord/src/monitor/monitor.test.ts | 99 +++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/extensions/discord/src/monitor/agent-components.ts b/extensions/discord/src/monitor/agent-components.ts index 5c953352ce1..158446b7823 100644 --- a/extensions/discord/src/monitor/agent-components.ts +++ b/extensions/discord/src/monitor/agent-components.ts @@ -792,6 +792,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: { interaction: AgentComponentInteraction; interactionCtx: ComponentInteractionContext; channelCtx: DiscordChannelContext; + isAuthorizedSender: boolean; data: string; kind: "button" | "select" | "modal"; values?: string[]; @@ -857,7 +858,7 @@ async function dispatchPluginDiscordInteractiveEvent(params: { guildId: params.interactionCtx.rawGuildId, senderId: params.interactionCtx.userId, senderUsername: params.interactionCtx.username, - auth: { isAuthorizedSender: true }, + auth: { isAuthorizedSender: params.isAuthorizedSender }, interaction: { kind: params.kind, messageId: params.messageId, @@ -1211,6 +1212,17 @@ async function handleDiscordComponentEvent(params: { guildEntries: params.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(params.interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(params.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const unauthorizedReply = `You are not authorized to use this ${params.componentLabel}.`; const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction: params.interaction, @@ -1223,7 +1235,7 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { return; @@ -1236,11 +1248,18 @@ async function handleDiscordComponentEvent(params: { replyOpts, componentLabel: params.componentLabel, unauthorizedReply, - allowNameMatching: isDangerousNameMatchingEnabled(params.ctx.discordConfig), + allowNameMatching, }); if (!componentAllowed) { return; } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: params.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); const consumed = resolveDiscordComponentEntry({ id: parsed.componentId, @@ -1277,6 +1296,7 @@ async function handleDiscordComponentEvent(params: { interaction: params.interaction, interactionCtx, channelCtx, + isAuthorizedSender: commandAuthorized, data: consumed.callbackData, kind: consumed.kind === "select" ? "select" : "button", values, @@ -1830,6 +1850,17 @@ class DiscordComponentModal extends Modal { guildEntries: this.ctx.guildEntries, }); const channelCtx = resolveDiscordChannelContext(interaction); + const allowNameMatching = isDangerousNameMatchingEnabled(this.ctx.discordConfig); + const channelConfig = resolveDiscordChannelConfigWithFallback({ + guildInfo, + channelId, + channelName: channelCtx.channelName, + channelSlug: channelCtx.channelSlug, + parentId: channelCtx.parentId, + parentName: channelCtx.parentName, + parentSlug: channelCtx.parentSlug, + scope: channelCtx.isThread ? "thread" : "channel", + }); const memberAllowed = await ensureGuildComponentMemberAllowed({ interaction, guildInfo, @@ -1841,7 +1872,7 @@ class DiscordComponentModal extends Modal { replyOpts, componentLabel: "form", unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig), + allowNameMatching, }); if (!memberAllowed) { return; @@ -1859,11 +1890,18 @@ class DiscordComponentModal extends Modal { replyOpts, componentLabel: "form", unauthorizedReply: "You are not authorized to use this form.", - allowNameMatching: isDangerousNameMatchingEnabled(this.ctx.discordConfig), + allowNameMatching, }); if (!modalAllowed) { return; } + const commandAuthorized = resolveComponentCommandAuthorized({ + ctx: this.ctx, + interactionCtx, + channelConfig, + guildInfo, + allowNameMatching, + }); const consumed = resolveDiscordModalEntry({ id: modalId, @@ -1892,6 +1930,7 @@ class DiscordComponentModal extends Modal { interaction, interactionCtx, channelCtx, + isAuthorizedSender: commandAuthorized, data: consumed.callbackData, kind: "modal", fields, diff --git a/extensions/discord/src/monitor/monitor.test.ts b/extensions/discord/src/monitor/monitor.test.ts index b4d5478f921..adf6223fbb7 100644 --- a/extensions/discord/src/monitor/monitor.test.ts +++ b/extensions/discord/src/monitor/monitor.test.ts @@ -52,6 +52,7 @@ const deliverDiscordReplyMock = vi.hoisted(() => vi.fn()); const recordInboundSessionMock = vi.hoisted(() => vi.fn()); const readSessionUpdatedAtMock = vi.hoisted(() => vi.fn()); const resolveStorePathMock = vi.hoisted(() => vi.fn()); +const dispatchPluginInteractiveHandlerMock = vi.hoisted(() => vi.fn()); let lastDispatchCtx: Record | undefined; vi.mock("../../../../src/pairing/pairing-store.js", () => ({ @@ -88,6 +89,15 @@ vi.mock("../../../../src/config/sessions.js", async (importOriginal) => { }; }); +vi.mock("../../plugins/interactive.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + dispatchPluginInteractiveHandler: (...args: unknown[]) => + dispatchPluginInteractiveHandlerMock(...args), + }; +}); + describe("agent components", () => { const createCfg = (): OpenClawConfig => ({}) as OpenClawConfig; @@ -341,6 +351,11 @@ describe("discord component interactions", () => { recordInboundSessionMock.mockClear().mockResolvedValue(undefined); readSessionUpdatedAtMock.mockClear().mockReturnValue(undefined); resolveStorePathMock.mockClear().mockReturnValue("/tmp/openclaw-sessions-test.json"); + dispatchPluginInteractiveHandlerMock.mockReset().mockResolvedValue({ + matched: false, + handled: false, + duplicate: false, + }); }); it("routes button clicks with reply references", async () => { @@ -499,6 +514,90 @@ describe("discord component interactions", () => { expect(acknowledge).toHaveBeenCalledTimes(1); expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); }); + + it("passes false auth to plugin Discord interactions for non-allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["owner-1"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-1", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: false }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); + + it("passes true auth to plugin Discord interactions for allowlisted guild users", async () => { + registerDiscordComponentEntries({ + entries: [createButtonEntry({ callbackData: "codex:approve" })], + modals: [], + }); + dispatchPluginInteractiveHandlerMock.mockResolvedValue({ + matched: true, + handled: true, + duplicate: false, + }); + + const button = createDiscordComponentButton( + createComponentContext({ + cfg: { + commands: { useAccessGroups: true }, + channels: { discord: { replyToMode: "first" } }, + } as OpenClawConfig, + allowFrom: ["123456789"], + }), + ); + const { interaction } = createComponentButtonInteraction({ + rawData: { + channel_id: "guild-channel", + guild_id: "guild-1", + id: "interaction-guild-plugin-2", + member: { roles: [] }, + } as unknown as ButtonInteraction["rawData"], + guild: { id: "guild-1", name: "Test Guild" } as unknown as ButtonInteraction["guild"], + }); + + await button.run(interaction, { cid: "btn_1" } as ComponentData); + + expect(dispatchPluginInteractiveHandlerMock).toHaveBeenCalledWith( + expect.objectContaining({ + ctx: expect.objectContaining({ + auth: { isAuthorizedSender: true }, + }), + }), + ); + expect(dispatchReplyMock).not.toHaveBeenCalled(); + }); }); describe("resolveDiscordOwnerAllowFrom", () => {