fix(web): give each EMA step-up attempt ownership of its command retry

Copilot round 5. `pendingStepUpRetryRef` is shared, and the EMA branch
dismisses the prompt *before* its awaits — so a newer command can open
its own step-up and install its own retry while the first authorization
is in flight. Every arm that read or cleared the ref afterwards was
acting on that newer operation: running it under the wrong
authorization and reporting its failure to the wrong source, or
deleting it outright.

The attempt now takes its retry out of the ref before any await, and no
arm touches the ref again. That covers all three sites at once and
removed the interactive-arm clear added last round — the value is
already out by then. The standard step-up path redirects and clears
synchronously, so it has no such window.

Also rewrites the `writeOAuthResumeSnapshot` docstring, which still
described the byte-comparison token it no longer returns.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Quk1hBUtyhkXowwMXzAnAY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-29 03:03:02 -04:00
commit 1634437b69

View file

@ -2004,6 +2004,61 @@ describe("useOAuthRecovery", () => {
expect(retry).not.toHaveBeenCalled();
});
it("never touches a retry installed by a newer step-up", async () => {
// Dismissing the prompt frees the slot, so a newer command can open its
// own step-up while this authorization is still in flight. Reading the
// shared ref afterwards would run that newer command under this
// authorization; clearing it would delete it (#2165).
let settleFirst: ((outcome: unknown) => void) | undefined;
const handleAuthChallenge = vi
.fn()
.mockImplementationOnce(
() =>
new Promise((resolve) => {
settleFirst = resolve;
}),
)
.mockResolvedValue({ kind: "satisfied" });
const client = fakeClient({ handleAuthChallenge });
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
const firstRetry = vi.fn().mockResolvedValue(undefined);
await openStepUp(h, "tool", firstRetry);
let authorize: Promise<void> | undefined;
await act(async () => {
authorize = h.api().handleStepUpAuthorize();
});
await waitFor(() => expect(settleFirst).toBeDefined());
// A newer command opens its own step-up mid-flight.
const secondRetry = vi.fn().mockResolvedValue(undefined);
await openStepUp(h, "prompt", secondRetry);
await act(async () => {
settleFirst!({ kind: "failed", error: new Error("denied") });
await authorize;
});
// The old attempt reported against its own source and ran nothing.
expect(h.spies.setSourceScopedError).toHaveBeenCalledWith(
"tool",
expect.stringContaining("denied"),
);
expect(firstRetry).not.toHaveBeenCalled();
expect(secondRetry).not.toHaveBeenCalled();
// …and the newer prompt's retry survived to run on its own authorization.
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(secondRetry).toHaveBeenCalledTimes(1);
expect(firstRetry).not.toHaveBeenCalled();
});
it("cancels back to the panel that asked", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });