diff --git a/clients/web/src/hooks/useOAuthRecovery.test.tsx b/clients/web/src/hooks/useOAuthRecovery.test.tsx index 1cc704ab..91b3f10d 100644 --- a/clients/web/src/hooks/useOAuthRecovery.test.tsx +++ b/clients/web/src/hooks/useOAuthRecovery.test.tsx @@ -2059,6 +2059,32 @@ describe("useOAuthRecovery", () => { expect(firstRetry).not.toHaveBeenCalled(); }); + it("keeps the open prompt's retry when a second step-up is refused", async () => { + // `trySetPendingStepUp` refuses the second prompt, so the second + // command's operation must not replace the first's — authorizing the + // open prompt would otherwise run the command the user was just told + // could not start (#2165). + const client = fakeClient({ + handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }), + }); + const h = harness({ + servers: [entry("a", {}, true)], + activeServerId: "a", + client, + }); + const firstRetry = vi.fn().mockResolvedValue(undefined); + const refusedRetry = vi.fn().mockResolvedValue(undefined); + await openStepUp(h, "tool", firstRetry); + await openStepUp(h, "prompt", refusedRetry); + expect(toastTitles()).toContain("Step-up authorization in progress"); + + await act(async () => { + await h.api().handleStepUpAuthorize(); + }); + expect(firstRetry).toHaveBeenCalledTimes(1); + expect(refusedRetry).not.toHaveBeenCalled(); + }); + it("cancels back to the panel that asked", async () => { const client = fakeClient(); const h = harness({ servers: [entry("a")], activeServerId: "a", client }); diff --git a/clients/web/src/hooks/useOAuthRecovery.ts b/clients/web/src/hooks/useOAuthRecovery.ts index f64cd6db..428352b7 100644 --- a/clients/web/src/hooks/useOAuthRecovery.ts +++ b/clients/web/src/hooks/useOAuthRecovery.ts @@ -690,14 +690,23 @@ export function useOAuthRecovery({ ) { return true; } - pendingStepUpRetryRef.current = options.retryOperation ?? null; - trySetPendingStepUp({ - challenge: error.authChallenge, - authorizationUrl: error.authorizationUrl, - serverId: options.serverId, - source: options.source, - enterpriseManaged: isEmaStepUp(error.authChallenge, server), - }); + // The retry belongs to the prompt, so it is installed only once the + // prompt is actually open (#2165). `trySetPendingStepUp` REFUSES a + // second prompt while one is already up — writing the ref first meant + // the refused command's operation replaced the open prompt's, and + // authorizing that prompt then ran the command the user was just told + // could not start. + if ( + trySetPendingStepUp({ + challenge: error.authChallenge, + authorizationUrl: error.authorizationUrl, + serverId: options.serverId, + source: options.source, + enterpriseManaged: isEmaStepUp(error.authChallenge, server), + }) + ) { + pendingStepUpRetryRef.current = options.retryOperation ?? null; + } return false; }