fix(discord): pass real auth to plugin interactions

This commit is contained in:
Vincent Koc 2026-03-13 14:19:34 -07:00
parent 6fc600b0f6
commit b934c0be57
2 changed files with 143 additions and 5 deletions

View File

@ -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,

View File

@ -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<string, unknown> | 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<typeof import("../../plugins/interactive.js")>();
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", () => {