From dc6d55700d6b012303b1306b0d11f1f577349ade Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:22:34 +0200 Subject: [PATCH 1/4] fix(gui): dedupe dashboard API token prompts on concurrent 401s Re-read the in-memory token after a 401 and share one refresh gate so a management fan-out prompts once instead of once per in-flight /api request when OPENCODEX_API_AUTH_TOKEN is required. Fixes #647. --- gui/src/api.ts | 36 ++++++++++++++--- gui/tests/api-auth-memory.test.ts | 64 +++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 6 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index fd6a777bb..c9745641a 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,4 +1,5 @@ let installed = false; +/** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ let promptInFlight: Promise | null = null; function needsApiAuth(input: RequestInfo | URL): boolean { @@ -46,11 +47,25 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke return [input, { ...init, headers }]; } -async function promptForToken(): Promise { +/** + * Resolve a token after a 401. Concurrent callers share one in-flight resolution so a dashboard + * fan-out does not open one window.prompt per /api request (#647). Re-reads memoryToken before + * prompting so waiters that wake after another request already stored a token do not re-prompt. + */ +async function resolveTokenAfter401(failedToken: string | null): Promise { if (promptInFlight) return promptInFlight; - promptInFlight = Promise.resolve() - .then(() => window.prompt("OpenCodex API token")?.trim() || null) - .finally(() => { promptInFlight = null; }); + + promptInFlight = (async () => { + const current = readToken(); + if (current && current !== failedToken) return current; + + const prompted = window.prompt("OpenCodex API token")?.trim() || null; + if (prompted) storeToken(prompted); + return prompted; + })().finally(() => { + promptInFlight = null; + }); + return promptInFlight; } @@ -69,10 +84,19 @@ export function installApiAuthFetch(): void { if (response.status !== 401) return response; if (token) clearToken(); - const nextToken = await promptForToken(); + + // Another request may have stored a token while this one was in flight (or while prompt blocked). + const refreshed = readToken(); + if (refreshed && refreshed !== token) { + const [retryInput, retryInit] = withToken(input, init, refreshed); + const retry = await originalFetch(retryInput, retryInit); + if (retry.status !== 401) return retry; + clearToken(); + } + + const nextToken = await resolveTokenAfter401(token); if (!nextToken) return response; - storeToken(nextToken); const [retryInput, retryInit] = withToken(input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); if (retry.status === 401) clearToken(); diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 4d27d8600..2342a1a42 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -113,6 +113,70 @@ test("cross-origin /api/* requests do not receive the API key or token prompt", expect(promptCalls).toBe(beforeCrossPrompts); }); +test("concurrent 401s share one token prompt and all retry with the stored token", async () => { + // Repro for #647: many /api/* requests start without a token (dashboard fan-out). + // Delivering 401s one-by-one after each auth cycle finishes matches the browser case where + // window.prompt blocks the main thread: each continuation still holds a captured null token + // and must reuse the in-memory token from an earlier request instead of prompting again. + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key") === "shared-token") { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "shared-token"; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = [ + "/api/config", + "/api/providers", + "/api/models", + "/api/selected-models", + "/api/disabled-models", + "/api/effort-caps", + "/api/sidecar-settings", + "/api/injection-model", + "/api/v2", + "/api/keys", + "/api/provider-presets", + "/api/key-providers", + "/api/oauth/providers", + "/api/codex-auth/accounts", + ]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + // Let every request reach the 401 gate before any response is delivered. + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed"; From 2bee4afe9f9287f08004927c2e779d77aac96312 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:27:05 +0200 Subject: [PATCH 2/4] fix(gui): avoid wiping a concurrent API token on 401 clear Only clear memory when it still holds the failed token, so a sibling request that already stored a valid token is not erased before the refresh retry. --- gui/src/api.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index c9745641a..6c35961d3 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -32,6 +32,11 @@ function clearToken(): void { memoryToken = null; } +/** Clear memory only when it still holds `expected` (avoid wiping a newer concurrent store). */ +function clearTokenIfCurrent(expected: string | null): void { + if (expected != null && readToken() === expected) clearToken(); +} + function clearLegacySessionToken(): void { try { sessionStorage.removeItem(LEGACY_TOKEN_KEY); @@ -83,15 +88,15 @@ export function installApiAuthFetch(): void { const response = await originalFetch(firstInput, firstInit); if (response.status !== 401) return response; - if (token) clearToken(); - // Another request may have stored a token while this one was in flight (or while prompt blocked). const refreshed = readToken(); if (refreshed && refreshed !== token) { const [retryInput, retryInit] = withToken(input, init, refreshed); const retry = await originalFetch(retryInput, retryInit); if (retry.status !== 401) return retry; - clearToken(); + clearTokenIfCurrent(refreshed); + } else { + clearTokenIfCurrent(token); } const nextToken = await resolveTokenAfter401(token); @@ -99,7 +104,7 @@ export function installApiAuthFetch(): void { const [retryInput, retryInit] = withToken(input, init, nextToken); const retry = await originalFetch(retryInput, retryInit); - if (retry.status === 401) clearToken(); + if (retry.status === 401) clearTokenIfCurrent(nextToken); return retry; }; } From 92e9677de8129b24a988d1f77dfcb3d1c034abde Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:34:15 +0200 Subject: [PATCH 3/4] fix(gui): suppress repeat API token prompts after cancel If the user dismisses the dashboard token dialog, remember the cancel for this page load so staggered 401 continuations do not reopen the prompt for every remaining /api request. --- gui/src/api.ts | 17 ++++++++++-- gui/tests/api-auth-memory.test.ts | 44 +++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/gui/src/api.ts b/gui/src/api.ts index 6c35961d3..664cde6a9 100644 --- a/gui/src/api.ts +++ b/gui/src/api.ts @@ -1,6 +1,12 @@ let installed = false; /** Shared 401 refresh gate — concurrent waiters join one prompt / token resolution. */ let promptInFlight: Promise | null = null; +/** + * After the user cancels (or submits blank) once, suppress further prompts for this page + * lifetime so a staggered 401 fan-out does not reopen the dialog N times (#647 / Codex). + * A full reload clears module state and allows prompting again. + */ +let promptCancelled = false; function needsApiAuth(input: RequestInfo | URL): boolean { try { @@ -58,15 +64,21 @@ function withToken(input: RequestInfo | URL, init: RequestInit | undefined, toke * prompting so waiters that wake after another request already stored a token do not re-prompt. */ async function resolveTokenAfter401(failedToken: string | null): Promise { + if (promptCancelled) return null; if (promptInFlight) return promptInFlight; promptInFlight = (async () => { + if (promptCancelled) return null; const current = readToken(); if (current && current !== failedToken) return current; const prompted = window.prompt("OpenCodex API token")?.trim() || null; - if (prompted) storeToken(prompted); - return prompted; + if (prompted) { + storeToken(prompted); + return prompted; + } + promptCancelled = true; + return null; })().finally(() => { promptInFlight = null; }); @@ -114,4 +126,5 @@ export function resetApiAuthFetchForTests(): void { installed = false; memoryToken = null; promptInFlight = null; + promptCancelled = false; } diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 2342a1a42..930da2b30 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -177,6 +177,50 @@ test("concurrent 401s share one token prompt and all retry with the stored token expect([...new Set(statuses)]).toEqual([200]); }); +test("canceling the token prompt once does not reopen it for the rest of the 401 fan-out", async () => { + let promptCalls = 0; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + if (headers.get("X-OpenCodex-API-Key")) { + return new Response("{}", { status: 200 }); + } + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return null; + }; + await installMockAuthFetch(mockFetch); + + const endpoints = ["/api/config", "/api/providers", "/api/models", "/api/keys"]; + const pending = endpoints.map((path) => fetch(path).then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < endpoints.length; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(endpoints.length); + + for (let i = 0; i < endpoints.length; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([401]); +}); + test("cross-origin /v1/* requests do not receive the API key or token prompt", async () => { let promptCalls = 0; let phase: "seed" | "cross" = "seed"; From 96726a18dccc96ebe95c299848c1efa404e69e8a Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 28 Jul 2026 21:49:49 +0200 Subject: [PATCH 4/4] test(gui): cover stale concurrent 401 token clear race --- gui/tests/api-auth-memory.test.ts | 58 +++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/gui/tests/api-auth-memory.test.ts b/gui/tests/api-auth-memory.test.ts index 930da2b30..3bc008d70 100644 --- a/gui/tests/api-auth-memory.test.ts +++ b/gui/tests/api-auth-memory.test.ts @@ -177,6 +177,64 @@ test("concurrent 401s share one token prompt and all retry with the stored token expect([...new Set(statuses)]).toEqual([200]); }); +test("stale concurrent 401 does not clear a token refreshed by another request", async () => { + // Codex/CodeRabbit race: request A prompts and stores T2; request B still holding stale T1 + // must not wipe T2 (clearTokenIfCurrent) before its re-read / shared gate join. + let promptCalls = 0; + let acceptV1 = true; + const release401: Array<() => void> = []; + const mockFetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + const headers = new Headers(init?.headers); + const key = headers.get("X-OpenCodex-API-Key"); + if (key === "token-v2") return new Response("{}", { status: 200 }); + if (acceptV1 && key === "token-v1") return new Response("{}", { status: 200 }); + if (key === "token-v1") { + await new Promise((resolve) => { + release401.push(resolve); + }); + return new Response("unauthorized", { status: 401 }); + } + return new Response("unauthorized", { status: 401 }); + }) as typeof fetch; + window.prompt = () => { + promptCalls += 1; + return "token-v1"; + }; + await installMockAuthFetch(mockFetch); + expect((await fetch("/api/config")).status).toBe(200); + expect(promptCalls).toBe(1); + + acceptV1 = false; + promptCalls = 0; + window.prompt = () => { + promptCalls += 1; + return "token-v2"; + }; + + const pending = [fetch("/api/config"), fetch("/api/providers")].map((p) => p.then((r) => r.status)); + for (let i = 0; i < 20 && release401.length < 2; i += 1) { + await Promise.resolve(); + } + expect(release401.length).toBe(2); + + for (let i = 0; i < 2; i += 1) { + const done = pending[i]!; + let settled = false; + void done.then(() => { + settled = true; + }); + release401.shift()!(); + for (let spin = 0; spin < 50 && !settled; spin += 1) { + await Promise.resolve(); + } + expect(settled).toBe(true); + } + + const statuses = await Promise.all(pending); + expect(promptCalls).toBe(1); + expect([...new Set(statuses)]).toEqual([200]); +}); + test("canceling the token prompt once does not reopen it for the rest of the 401 fan-out", async () => { let promptCalls = 0; const release401: Array<() => void> = [];