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:
parent
76b75d3759
commit
a46a8a6cf8
2 changed files with 287 additions and 38 deletions
|
|
@ -53,8 +53,10 @@ describe("oauthResume", () => {
|
|||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
};
|
||||
writeOAuthResumeSnapshot(snapshot);
|
||||
expect(consumeOAuthResumeSnapshot()).toEqual(snapshot);
|
||||
const attemptId = writeOAuthResumeSnapshot(snapshot);
|
||||
// The write stamps the per-attempt id `clearOwnOAuthResumeSnapshot`
|
||||
// matches on (#2165); everything else round-trips unchanged.
|
||||
expect(consumeOAuthResumeSnapshot()).toEqual({ ...snapshot, attemptId });
|
||||
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||
expect(consumeOAuthResumeSnapshot()).toBeUndefined();
|
||||
});
|
||||
|
|
@ -87,8 +89,8 @@ describe("oauthResume", () => {
|
|||
},
|
||||
remoteSessionId: "remote-abc",
|
||||
};
|
||||
writeOAuthResumeSnapshot(snapshot);
|
||||
expect(readOAuthResumeSnapshot()).toEqual(snapshot);
|
||||
const attemptId = writeOAuthResumeSnapshot(snapshot);
|
||||
expect(readOAuthResumeSnapshot()).toEqual({ ...snapshot, attemptId });
|
||||
expect(storage.get(OAUTH_PENDING_SERVER_KEY)).toBeUndefined();
|
||||
clearOAuthResumeSnapshot();
|
||||
expect(readOAuthResumeSnapshot()).toBeUndefined();
|
||||
|
|
@ -350,31 +352,47 @@ describe("oauthResume", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it("clearOwnOAuthResumeSnapshot removes only the snapshot it was given", () => {
|
||||
const first = writeOAuthResumeSnapshot({
|
||||
it("clearOwnOAuthResumeSnapshot removes only the attempt it was given", () => {
|
||||
// Deliberately IDENTICAL snapshots: two concurrent redirects for the same
|
||||
// server with the same shell state serialize the same way while carrying
|
||||
// different authorization URLs, which the snapshot does not record. A byte
|
||||
// comparison would call them one attempt and delete the wrong one (#2165).
|
||||
const snapshot = {
|
||||
version: 1,
|
||||
serverId: "a",
|
||||
activeTab: "tools",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
});
|
||||
// A later attempt replaces it before the first one's failure lands.
|
||||
writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
serverId: "b",
|
||||
activeTab: "prompts",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
});
|
||||
} as const;
|
||||
const first = writeOAuthResumeSnapshot({ ...snapshot });
|
||||
const second = writeOAuthResumeSnapshot({ ...snapshot });
|
||||
expect(first).toBeTruthy();
|
||||
expect(second).not.toBe(first);
|
||||
|
||||
expect(clearOwnOAuthResumeSnapshot(first)).toBe(false);
|
||||
expect(readOAuthResumeSnapshot()?.serverId).toBe("b");
|
||||
expect(readOAuthResumeSnapshot()?.attemptId).toBe(second);
|
||||
|
||||
const second = window.sessionStorage.getItem(OAUTH_RESUME_KEY)!;
|
||||
expect(clearOwnOAuthResumeSnapshot(second)).toBe(true);
|
||||
expect(window.sessionStorage.getItem(OAUTH_RESUME_KEY)).toBeNull();
|
||||
});
|
||||
|
||||
it("clearOwnOAuthResumeSnapshot ignores a snapshot with no attemptId", () => {
|
||||
// An older build's snapshot, mid-redirect across an upgrade — dropping it
|
||||
// would strand a live callback.
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_RESUME_KEY,
|
||||
JSON.stringify({
|
||||
version: 1,
|
||||
serverId: "a",
|
||||
activeTab: "tools",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
}),
|
||||
);
|
||||
expect(clearOwnOAuthResumeSnapshot("some-attempt")).toBe(false);
|
||||
expect(readOAuthResumeSnapshot()?.serverId).toBe("a");
|
||||
});
|
||||
|
||||
it("clearOwnOAuthResumeSnapshot is a no-op without a token", () => {
|
||||
writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
|
|
@ -414,7 +432,51 @@ describe("oauthResume", () => {
|
|||
setItem: () => {},
|
||||
removeItem: () => {},
|
||||
});
|
||||
expect(clearOwnOAuthResumeSnapshot("{}")).toBe(false);
|
||||
expect(clearOwnOAuthResumeSnapshot("attempt")).toBe(false);
|
||||
});
|
||||
|
||||
it("clearOwnOAuthResumeSnapshot is a no-op with nothing stored", () => {
|
||||
expect(clearOwnOAuthResumeSnapshot("attempt")).toBe(false);
|
||||
});
|
||||
|
||||
it("writeOAuthResumeSnapshot mints an attemptId per write", () => {
|
||||
const token = writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
serverId: "a",
|
||||
activeTab: "tools",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
});
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(readOAuthResumeSnapshot()?.attemptId).toBe(token);
|
||||
});
|
||||
|
||||
it("writeOAuthResumeSnapshot falls back when randomUUID is unavailable", () => {
|
||||
// `crypto.randomUUID` needs a secure context, which a plain-HTTP
|
||||
// non-loopback host is not.
|
||||
const original = Object.getOwnPropertyDescriptor(
|
||||
globalThis.crypto,
|
||||
"randomUUID",
|
||||
);
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", {
|
||||
configurable: true,
|
||||
value: undefined,
|
||||
});
|
||||
try {
|
||||
const token = writeOAuthResumeSnapshot({
|
||||
version: 1,
|
||||
serverId: "a",
|
||||
activeTab: "tools",
|
||||
authKind: "reauth",
|
||||
tabUi: {},
|
||||
});
|
||||
expect(token).toEqual(expect.any(String));
|
||||
expect(clearOwnOAuthResumeSnapshot(token)).toBe(true);
|
||||
} finally {
|
||||
if (original) {
|
||||
Object.defineProperty(globalThis.crypto, "randomUUID", original);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("clearOAuthResumeSnapshot swallows removeItem failures", () => {
|
||||
|
|
|
|||
|
|
@ -57,6 +57,19 @@ export interface OAuthResumeSnapshot {
|
|||
authChallenge?: AuthChallenge;
|
||||
/** Command-scoped recovery source when redirect was triggered by a user action. */
|
||||
recoverySource?: OAuthRecoverySource;
|
||||
/**
|
||||
* Identifies the redirect attempt that wrote this snapshot (#2165).
|
||||
*
|
||||
* Stamped by {@link writeOAuthResumeSnapshot} and matched by
|
||||
* {@link clearOwnOAuthResumeSnapshot}, so an attempt that fails before
|
||||
* navigating cannot delete a *later* attempt's snapshot. Nothing else reads
|
||||
* it: the callback identifies its server by `serverId`, as before.
|
||||
*
|
||||
* Optional because a snapshot written by an older build (across a redirect
|
||||
* that spans an upgrade) has none, and dropping such a snapshot would strand
|
||||
* a live callback.
|
||||
*/
|
||||
attemptId?: string;
|
||||
}
|
||||
|
||||
export interface LiftedTabUiState {
|
||||
|
|
@ -181,16 +194,33 @@ export function writeOAuthResumeSnapshot(
|
|||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
}
|
||||
const serialized = JSON.stringify(snapshot);
|
||||
const attemptId = snapshot.attemptId ?? newAttemptId();
|
||||
try {
|
||||
window.sessionStorage.setItem(OAUTH_RESUME_KEY, serialized);
|
||||
return serialized;
|
||||
window.sessionStorage.setItem(
|
||||
OAUTH_RESUME_KEY,
|
||||
JSON.stringify({ ...snapshot, attemptId }),
|
||||
);
|
||||
return attemptId;
|
||||
} catch {
|
||||
// Best-effort — privacy mode / quota.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A per-attempt identifier. `randomUUID` where it exists (it needs a secure
|
||||
* context, which a `file://` page or a plain-HTTP non-loopback host is not),
|
||||
* and otherwise a value that only has to be unique among the handful of
|
||||
* redirect attempts one page can have in flight — never a security token.
|
||||
*/
|
||||
function newAttemptId(): string {
|
||||
const uuid = globalThis.crypto?.randomUUID?.bind(globalThis.crypto);
|
||||
if (uuid) {
|
||||
return uuid();
|
||||
}
|
||||
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
export function readOAuthResumeSnapshot(): OAuthResumeSnapshot | undefined {
|
||||
if (typeof window === "undefined") {
|
||||
return undefined;
|
||||
|
|
@ -249,24 +279,30 @@ function readLegacyPendingServerSnapshot(): OAuthResumeSnapshot | undefined {
|
|||
* `prepareOAuthRedirect` has no single-flight guard, so a redirect that fails
|
||||
* before navigating can reject after a *later* attempt has already written its
|
||||
* own snapshot. An unconditional clear there would delete the newer attempt's
|
||||
* callback-routing state, which is a worse failure than the one it is trying
|
||||
* to avoid.
|
||||
* callback-routing state — a worse failure than the stale snapshot it is
|
||||
* trying to avoid, since that redirect is actually in flight.
|
||||
*
|
||||
* The token is the stored serialization, which makes two byte-identical
|
||||
* snapshots indistinguishable — deliberately: identical bytes describe the
|
||||
* same redirect, for the same server, with the same shell state, so clearing
|
||||
* "the other one" reaches the same end state.
|
||||
* Matched on the snapshot's `attemptId` rather than on its bytes: two
|
||||
* concurrent redirects for the same server with the same shell state serialize
|
||||
* identically while carrying *different* authorization URLs, which the
|
||||
* snapshot does not record — so a byte comparison would report them as the
|
||||
* same attempt and delete the wrong one.
|
||||
*
|
||||
* Returns whether anything was removed.
|
||||
*/
|
||||
export function clearOwnOAuthResumeSnapshot(
|
||||
token: string | undefined,
|
||||
attemptId: string | undefined,
|
||||
): boolean {
|
||||
if (typeof window === "undefined" || token === undefined) {
|
||||
if (typeof window === "undefined" || attemptId === undefined) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
if (window.sessionStorage.getItem(OAUTH_RESUME_KEY) !== token) {
|
||||
const raw = window.sessionStorage.getItem(OAUTH_RESUME_KEY);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
const stored = JSON.parse(raw) as Partial<OAuthResumeSnapshot>;
|
||||
if (stored?.attemptId !== attemptId) {
|
||||
return false;
|
||||
}
|
||||
window.sessionStorage.removeItem(OAUTH_RESUME_KEY);
|
||||
|
|
|
|||
Loading…
Reference in a new issue