From cd0d45b184b7a7a610d31ce8807645a2e05e4375 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:44:25 +0000 Subject: [PATCH 1/4] fix(discord): fail-fast when bot identity fetch fails When fetchUser('@me') fails during Discord provider startup (e.g. due to transient network issues), the bot previously continued running with botUserId = undefined. This caused three security issues: 1. Mention gating bypassed: the guard 'if (botId && mentionGate.shouldSkip)' short-circuited when botId was undefined, letting all guild messages through even with requireMention: true. 2. Self-message filtering disabled: 'author.id === botUserId' never matched, risking self-reply loops. 3. Reply detection broken: replies to bot messages weren't recognized as implicit mentions. Fix: - Throw on fetchUser failure so auto-restart retries (matching the existing pattern for fetchDiscordApplicationId). - Also throw when fetchUser returns no id. - Defense-in-depth: remove the redundant 'botId &&' guard in the mention gate check so mentionGate.shouldSkip is evaluated regardless. Fixes #42219 --- .../src/monitor/message-handler.preflight.ts | 2 +- .../discord/src/monitor/provider.test.ts | 28 +++++++++++++++++++ extensions/discord/src/monitor/provider.ts | 9 ++++++ 3 files changed, 38 insertions(+), 1 deletion(-) diff --git a/extensions/discord/src/monitor/message-handler.preflight.ts b/extensions/discord/src/monitor/message-handler.preflight.ts index 9094cabb645..56b4569a118 100644 --- a/extensions/discord/src/monitor/message-handler.preflight.ts +++ b/extensions/discord/src/monitor/message-handler.preflight.ts @@ -788,7 +788,7 @@ export async function preflightDiscordMessage( `[discord-preflight] shouldRequireMention=${shouldRequireMention} baseRequireMention=${shouldRequireMentionByConfig} boundThreadSession=${isBoundThreadSession} mentionGate.shouldSkip=${mentionGate.shouldSkip} wasMentioned=${wasMentioned}`, ); if (isGuildMessage && shouldRequireMention) { - if (botId && mentionGate.shouldSkip) { + if (mentionGate.shouldSkip) { logDebug(`[discord-preflight] drop: no-mention`); logVerbose(`discord: drop guild message (mention required, botId=${botId})`); logger.info( diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index ff6fb310464..c43cbee1fd7 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -627,4 +627,32 @@ describe("monitorDiscordProvider", () => { const messages = vi.mocked(runtime.log).mock.calls.map((call) => String(call[0])); expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); }); + + it("throws when fetchUser('@me') fails to prevent running without bot identity (#42219)", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + clientFetchUserMock.mockRejectedValueOnce(new Error("network timeout")); + + await expect( + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }), + ).rejects.toThrow("discord: cannot start without bot identity"); + }); + + it("throws when fetchUser('@me') returns no user id", async () => { + const { monitorDiscordProvider } = await import("./provider.js"); + const runtime = baseRuntime(); + + clientFetchUserMock.mockResolvedValueOnce({ id: undefined, username: "NoId" }); + + await expect( + monitorDiscordProvider({ + config: baseConfig(), + runtime, + }), + ).rejects.toThrow("discord: fetchUser('@me') returned no user id"); + }); }); diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index 8dbb6df29f5..aa38bf6cfc3 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -880,6 +880,15 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { gateway: lifecycleGateway, details: String(err), }); + // Fail-fast: without botUserId, mention gating is bypassed (all guild + // messages pass through), self-message filtering is disabled (risk of + // self-reply loops), and reply detection is broken. Let auto-restart + // retry instead of running in a degraded state. See #42219. + throw new Error(`discord: cannot start without bot identity: ${String(err)}`); + } + if (!botUserId) { + // fetchUser succeeded but returned no id — equally unsafe to continue. + throw new Error("discord: fetchUser('@me') returned no user id"); } if (voiceEnabled) { From ef9e8d0619ee0dbbf3d9106b7c55e9c034a6d349 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Tue, 17 Mar 2026 20:06:33 +0000 Subject: [PATCH 2/4] fix: preserve error cause, fix test mock typing - Add { cause: err } to thrown Error for upstream debugging - Use 'as unknown as string' cast for undefined-id test mock --- extensions/discord/src/monitor/provider.test.ts | 2 +- extensions/discord/src/monitor/provider.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index c43cbee1fd7..d2437ddb32d 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -646,7 +646,7 @@ describe("monitorDiscordProvider", () => { const { monitorDiscordProvider } = await import("./provider.js"); const runtime = baseRuntime(); - clientFetchUserMock.mockResolvedValueOnce({ id: undefined, username: "NoId" }); + clientFetchUserMock.mockResolvedValueOnce({ id: undefined as unknown as string, username: "NoId" }); await expect( monitorDiscordProvider({ diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index aa38bf6cfc3..cba7bb0b2d5 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -884,7 +884,7 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { // messages pass through), self-message filtering is disabled (risk of // self-reply loops), and reply detection is broken. Let auto-restart // retry instead of running in a degraded state. See #42219. - throw new Error(`discord: cannot start without bot identity: ${String(err)}`); + throw new Error(`discord: cannot start without bot identity: ${String(err)}`, { cause: err }); } if (!botUserId) { // fetchUser succeeded but returned no id — equally unsafe to continue. From ba47789b70fbb9318aaadc794127a69aeb925840 Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 20:43:17 +0000 Subject: [PATCH 3/4] fix: fall back to applicationId when fetchUser fails instead of aborting Transient REST/proxy failures no longer take down the whole Discord channel. applicationId is the same snowflake as the bot user id, so mention gating and self-message filtering remain intact. This also avoids leaving orphaned gateway connections from pre-lifecycle throws. --- extensions/discord/src/monitor/provider.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/extensions/discord/src/monitor/provider.ts b/extensions/discord/src/monitor/provider.ts index cba7bb0b2d5..7e342d39692 100644 --- a/extensions/discord/src/monitor/provider.ts +++ b/extensions/discord/src/monitor/provider.ts @@ -880,14 +880,19 @@ export async function monitorDiscordProvider(opts: MonitorDiscordOpts = {}) { gateway: lifecycleGateway, details: String(err), }); - // Fail-fast: without botUserId, mention gating is bypassed (all guild - // messages pass through), self-message filtering is disabled (risk of - // self-reply loops), and reply detection is broken. Let auto-restart - // retry instead of running in a degraded state. See #42219. - throw new Error(`discord: cannot start without bot identity: ${String(err)}`, { cause: err }); + // Transient REST/proxy failures should not take down the whole channel. + // applicationId is the same snowflake as the bot user id and was already + // resolved above (with a token-decoding fallback), so we can use it for + // mention gating and self-message filtering. botUserName is only used for + // logging and is non-critical. + botUserId = applicationId; + runtime.warn?.( + `discord: using applicationId as botUserId fallback (fetchUser failed: ${String(err)})`, + ); } if (!botUserId) { - // fetchUser succeeded but returned no id — equally unsafe to continue. + // fetchUser succeeded but returned no id and applicationId is also missing — + // this should not happen in practice. throw new Error("discord: fetchUser('@me') returned no user id"); } From a87796d596178b271082c1d8743ef4dfdb95707e Mon Sep 17 00:00:00 2001 From: Benedikt Schackenberg <6381261+BenediktSchackenberg@users.noreply.github.com> Date: Thu, 19 Mar 2026 21:05:11 +0000 Subject: [PATCH 4/4] test: update fetchUser failure test for applicationId fallback The provider now catches fetchUser('@me') errors and falls back to applicationId instead of throwing. Update test to verify no rejection. --- extensions/discord/src/monitor/provider.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/extensions/discord/src/monitor/provider.test.ts b/extensions/discord/src/monitor/provider.test.ts index d2437ddb32d..08c626da886 100644 --- a/extensions/discord/src/monitor/provider.test.ts +++ b/extensions/discord/src/monitor/provider.test.ts @@ -628,18 +628,22 @@ describe("monitorDiscordProvider", () => { expect(messages.some((msg) => msg.includes("discord startup ["))).toBe(false); }); - it("throws when fetchUser('@me') fails to prevent running without bot identity (#42219)", async () => { + it("falls back to applicationId when fetchUser('@me') fails (#42219)", async () => { const { monitorDiscordProvider } = await import("./provider.js"); const runtime = baseRuntime(); clientFetchUserMock.mockRejectedValueOnce(new Error("network timeout")); - await expect( + // Should NOT throw — the code now falls back to applicationId as botUserId + // instead of aborting. We race with a short timeout to confirm no immediate rejection. + const result = await Promise.race([ monitorDiscordProvider({ config: baseConfig(), runtime, - }), - ).rejects.toThrow("discord: cannot start without bot identity"); + }).then(() => "resolved", (err: Error) => `rejected: ${err.message}`), + new Promise((r) => setTimeout(() => r("still-running"), 200)), + ]); + expect(result).toBe("still-running"); }); it("throws when fetchUser('@me') returns no user id", async () => {