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:
parent
d5ddaf8818
commit
1634437b69
1 changed files with 80 additions and 21 deletions
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue