fix(web): drop the step-up retry when its prompt is cleared

The retry closure belongs to the prompt, but the two paths that clear the
step-up slot as a *consequence* of something else — the server-switch reset
and `resetOAuthRecoveryState` on disconnect — left `pendingStepUpRetryRef`
installed. The next prompt to open without a retry of its own would inherit
it, so authorizing an ambient step-up on server B ran the command server A was
left mid-way through.

Clear it in the existing mirror effect whenever the slot is null, so no arm has
to remember. A ref write in an effect, since the render-phase reset must stay
pure.

Copilot on #2237.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S4WZSxx6XMKzRdgPce9CAd
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-03 20:27:16 -04:00
commit 3c8fdb0814
2 changed files with 56 additions and 0 deletions

View file

@ -391,6 +391,49 @@ describe("useOAuthRecovery", () => {
});
});
it("does not carry the left server's retry into a later ambient step-up", async () => {
const client = fakeClient({
handleAuthChallenge: vi.fn().mockResolvedValue({ kind: "satisfied" }),
});
const servers = [entry("a", {}, true), entry("b", {}, true)];
const h = harness({ servers, activeServerId: "a", client });
const retryA = vi.fn().mockResolvedValue(undefined);
await act(async () => {
await h
.api()
.handleCommandScopedAuthRecovery(
new AuthRecoveryRequiredError(
AUTH_URL,
challenge("insufficient_scope"),
),
{ serverId: "a", source: "tool", retryOperation: retryA },
);
});
expect(h.api().pendingStepUp?.serverId).toBe("a");
await act(async () => {
h.rerender({ servers, activeServerId: "b", client });
});
expect(h.api().pendingStepUp).toBeNull();
// Server B raises its own step-up, which carries no retry of its own.
await act(async () => {
client.emit("authChallengeInteractive", {
challenge: challenge("insufficient_scope"),
authorizationUrl: AUTH_URL,
});
});
await waitFor(() => expect(h.api().pendingStepUp?.serverId).toBe("b"));
await act(async () => {
await h.api().handleStepUpAuthorize();
});
// Authorizing B must not run the command A was left mid-way through.
expect(retryA).not.toHaveBeenCalled();
expect(toastTitles()).toContain("Permissions updated");
});
it("refuses a second step-up while one is open", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });

View file

@ -317,6 +317,19 @@ export function useOAuthRecovery({
useEffect(() => {
sessionRef.current.pendingStepUp = pendingStepUp;
sessionRef.current.pendingReauth = pendingReauth;
// The retry belongs to the prompt, so it dies with it. Every arm that
// dismisses a prompt deliberately drops the retry inline — but the two
// that clear the slot as a *consequence* of something else (the
// server-switch reset below, and `resetOAuthRecoveryState` on disconnect)
// would otherwise leave it installed, and the next prompt to open without
// one of its own would inherit it: authorizing an ambient step-up on
// server B would run the command server A was left mid-way through
// (Copilot on #2237). Clearing it here covers both without any of them
// having to remember. It is a ref write in an effect, which is where a ref
// write belongs — the render-phase reset above must stay pure.
if (pendingStepUp === null) {
pendingStepUpRetryRef.current = null;
}
});
// Both slots are server-scoped, so switching servers has to clear whichever