fix(web): per-attempt snapshot ids, honest recovery copy, connect teardown

Copilot round 3, four items:

- the snapshot "attempt token" was the stored serialization, which is not
  unique: the snapshot records neither the authorization URL nor its
  `state`, so two concurrent redirects for one server with unchanged UI
  serialize identically. `OAuthResumeSnapshot` now carries an optional
  `attemptId`, stamped per write and matched on clear. Optional because
  an older build's snapshot, mid-redirect across an upgrade, has none —
  and treating that as a match would strand a live callback.
- the resume-failure toast promised a retry even when the session had
  ended and nothing was restored. Copy is now chosen by whether anything
  is left pending; `authRecoveryAbandonedMessage` covers the other case.
- a connect-scoped redirect that failed left the connection at
  "connecting" with the active-server lock held, since the redirect was
  what would have ended the attempt. It now disconnects first, as the
  sibling arm in `useConnectionLifecycle` does — scoped to that context,
  because an ambient or step-up failure does not mean the session is dead.
- both terminal EMA step-up failure arms now drop the stored retry. A
  later step-up does not overwrite the ref, so a retained operation could
  be re-run by an unrelated authorization. The `interactive` arm keeps
  it: that one is still on its way to a redirect.

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 02:32:05 -04:00
commit a46a8a6cf8

View file

@ -595,6 +595,47 @@ describe("useOAuthRecovery", () => {
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
});
it("ends a failed connect-scoped attempt instead of leaving it spinning", async () => {
// `connect()` rejecting with an auth-recovery error holds the status at
// "connecting"; the redirect was what would have ended that attempt, so
// if it never happens nothing else does (#2165).
const client = fakeClient({
beginInteractiveAuthorization: vi
.fn()
.mockRejectedValue(new Error("provider state unreadable")),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().prepareOAuthRedirect({
serverId: "a",
authKind: "reauth",
authorizationUrl: AUTH_URL,
preRedirectContext: "connect",
});
});
await waitFor(() => expect(client.disconnect).toHaveBeenCalled());
expect(h.spies.setFailedServerId).toHaveBeenCalledWith("a");
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
});
it("leaves a non-connect attempt's session alone", async () => {
const client = fakeClient({
beginInteractiveAuthorization: vi
.fn()
.mockRejectedValue(new Error("provider state unreadable")),
});
const h = harness({ servers: [entry("a")], activeServerId: "a", client });
await act(async () => {
h.api().prepareOAuthRedirect({
serverId: "a",
authKind: "step_up",
authorizationUrl: AUTH_URL,
});
});
await waitFor(() => expect(h.api().reAuthBanner?.serverId).toBe("a"));
expect(client.disconnect).not.toHaveBeenCalled();
});
it("tolerates having no client at all", () => {
const h = harness({ servers: [entry("a")], client: null });
act(() =>
@ -1172,6 +1213,10 @@ describe("useOAuthRecovery", () => {
expect(toastTitles()).toContain("Could not continue authorization"),
);
// …and says so, rather than promising a retry it will not make.
expect(toastWith("the session it belonged to has ended")).toBeTruthy();
expect(toastWith("will try again")).toBeUndefined();
// Nothing was restored, so returning to the tab resumes nothing.
await act(async () => {
becomeVisible();
@ -1870,6 +1915,61 @@ describe("useOAuthRecovery", () => {
);
});
it("drops the stored retry when an EMA step-up fails", async () => {
// The prompt is already dismissed and a later step-up does not overwrite
// the ref, so a retained operation would be re-run by an unrelated
// authorization (#2165).
const handleAuthChallenge = vi
.fn()
.mockResolvedValueOnce({ kind: "failed", error: new Error("denied") })
.mockResolvedValue({ kind: "satisfied" });
const client = fakeClient({ handleAuthChallenge });
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
const retry = vi.fn().mockResolvedValue(undefined);
await openStepUp(h, "tool", retry);
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(retry).not.toHaveBeenCalled();
// A later, unrelated EMA step-up succeeds — and must not re-run it.
await openStepUp(h, "tool");
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(toastTitles()).toContain("Permissions updated");
expect(retry).not.toHaveBeenCalled();
});
it("drops the stored retry when an EMA step-up rejects", async () => {
const handleAuthChallenge = vi
.fn()
.mockRejectedValueOnce(new Error("IdP unreachable"))
.mockResolvedValue({ kind: "satisfied" });
const client = fakeClient({ handleAuthChallenge });
const h = harness({
servers: [entry("a", {}, true)],
activeServerId: "a",
client,
});
const retry = vi.fn().mockResolvedValue(undefined);
await openStepUp(h, "tool", retry);
await act(async () => {
await h.api().handleStepUpAuthorize();
});
await openStepUp(h, "tool");
await act(async () => {
await h.api().handleStepUpAuthorize();
});
expect(toastTitles()).toContain("Permissions updated");
expect(retry).not.toHaveBeenCalled();
});
it("cancels back to the panel that asked", async () => {
const client = fakeClient();
const h = harness({ servers: [entry("a")], activeServerId: "a", client });