Client or integration
OpenCodex dashboard
Area
Dashboard
Summary
On a non-loopback bind (hostname: "0.0.0.0", so OPENCODEX_API_AUTH_TOKEN is required), opening the dashboard shows one window.prompt("OpenCodex API token") per management request in flight rather than one per page load. Entering the correct token at the first prompt does not stop the following ones, so the symptom reads as "the token was wrong, try again". After answering every prompt the dashboard works normally, and the whole sequence repeats on the next reload.
Measured. A burst of 14 concurrent /api/* requests produces 14 prompts, and every request ends in 200 with the same token:
requests: 14
prompt calls: 14
final statuses: 200
Expected: one prompt, after which every pending request retries with that token.
GET / and GET /healthz are not gated, which is why the page renders first and the prompts start afterwards.
Root cause
Two things in gui/src/api.ts on main combine.
async function promptForToken(): Promise<string | null> {
if (promptInFlight) return promptInFlight;
promptInFlight = Promise.resolve()
.then(() => window.prompt("OpenCodex API token")?.trim() || null)
.finally(() => { promptInFlight = null; });
return promptInFlight;
}
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
if (!needsApiAuth(input)) return originalFetch(input, init);
const token = readToken(); // captured before the request
const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init];
const response = await originalFetch(firstInput, firstInit);
if (response.status !== 401) return response;
if (token) clearToken();
const nextToken = await promptForToken(); // never re-reads readToken()
...
};
- After the
401, the handler never re-reads readToken(). A request that started before any token existed keeps its captured null and prompts even though another request has already stored a valid token through storeToken().
promptInFlight only deduplicates 401s that land in the same microtask batch. window.prompt blocks the main thread, so any response arriving while a prompt is open is processed only after that prompt closes — and by then .finally has reset promptInFlight to null, so each continuation opens a new prompt.
The prompt count therefore tracks the number of /api/* requests in flight without a token, which on a dashboard page load is its whole initial fan-out.
Why this may not have surfaced earlier
The deduplication gap has been there since gui/src/api.ts was added in c29ee783e (2026-06-27), but it was masked because the token was persisted:
// c29ee783e
function readToken(): string | null {
try {
const token = sessionStorage.getItem(TOKEN_KEY)?.trim();
return token || null;
} catch {
return null;
}
}
function storeToken(token: string): void {
try { sessionStorage.setItem(TOKEN_KEY, token); } catch { /* session storage may be disabled */ }
}
With persistence the storm could happen at most once per browser session, and every later load attached the token to the first attempt.
9c7e922eb (2026-07-25T23:17Z) switched to let memoryToken — "In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage)" — and 138751f78 (2026-07-26) additionally wipes the legacy key without reading it. That hardening looks correct on its own; the consequence is that every page load now starts with no token, so the storm reproduces on every load. 9c7e922eb lands after the 2.7.40 release (published 2026-07-25T14:26Z), so by the npm timeline the amplified behaviour first ships in 2.7.41. This report is from 2.7.42.
This is distinct from #570, which concerns the loopback Host admission gate returning 403 when token auth is not required. Here token auth is enabled, the responses are 401, and the problem is the prompt loop rather than admission.
Reproduction
- Start the proxy with a non-loopback bind so token auth is enforced.
{ "port": 10100, "hostname": "0.0.0.0" }
export OPENCODEX_API_AUTH_TOKEN="$(openssl rand -hex 32)"
ocx start
-
Open the dashboard in a browser and enter the correct token at the first prompt. It keeps prompting; a single page load asked me for the token more than ten times before it settled.
-
To count it without a browser, run the interceptor logic against the running proxy with a synchronous prompt stub. window.prompt is synchronous in a browser, so a synchronous stub preserves the ordering that matters here.
// repro.mjs — node >= 18
// Logic copied from gui/src/api.ts on main. withToken is simplified because this
// harness only passes URL strings, never Request objects.
const BASE = 'http://127.0.0.1:10100';
const TOKEN = process.env.OPENCODEX_API_AUTH_TOKEN;
let promptCalls = 0;
const win = { prompt() { promptCalls += 1; return TOKEN; } };
let promptInFlight = null;
let memoryToken = null;
const readToken = () => memoryToken;
const storeToken = (t) => { memoryToken = t; };
const clearToken = () => { memoryToken = null; };
function withToken(input, init, token) {
const headers = new Headers(init?.headers ?? undefined);
headers.set('X-OpenCodex-API-Key', token);
return [input, { ...init, headers }];
}
async function promptForToken() {
if (promptInFlight) return promptInFlight;
promptInFlight = Promise.resolve()
.then(() => win.prompt('OpenCodex API token')?.trim() || null)
.finally(() => { promptInFlight = null; });
return promptInFlight;
}
const originalFetch = globalThis.fetch;
const authFetch = async (input, init) => {
const token = readToken();
const [firstInput, firstInit] = token ? withToken(input, init, token) : [input, init];
const response = await originalFetch(firstInput, firstInit);
if (response.status !== 401) return response;
if (token) clearToken();
const nextToken = await promptForToken();
if (!nextToken) return response;
storeToken(nextToken);
const [retryInput, retryInit] = withToken(input, init, nextToken);
const retry = await originalFetch(retryInput, retryInit);
if (retry.status === 401) clearToken();
return retry;
};
// A representative management fan-out.
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 results = await Promise.all(endpoints.map(p => authFetch(BASE + p).then(r => r.status)));
console.log('requests: ', endpoints.length);
console.log('prompt calls: ', promptCalls);
console.log('final statuses:', [...new Set(results)].join(','));
- One prompt is issued per request even though the first one already returned a valid token.
Possible fix
Re-read the current token after the 401 and retry with it before prompting, so requests that were already in flight ride on the token another request obtained. Sketch rather than a prescription:
if (token) clearToken();
// Another request may have supplied a token while this one was in flight.
const refreshed = readToken();
if (refreshed && refreshed !== token) {
const [input2, init2] = withToken(input, init, refreshed);
const retry = await originalFetch(input2, init2);
if (retry.status !== 401) return retry;
}
const nextToken = await promptForToken();
Serialising concurrent 401s on a single shared refresh gate, instead of the current promptInFlight that only covers one microtask batch, would remove the race more thoroughly. Either way the memory-only property from 9c7e922eb and 138751f78 is preserved, since the fix only consults the existing in-memory value and never touches storage.
Happy to open a PR against dev if that is welcome. gui/src/api.ts is not in the CODEOWNERS authentication group, but it is auth-adjacent, so I would rather ask first.
Version
2.7.42
Operating system
Ubuntu 24.04.4 LTS (proxy host, Docker); browser client on Windows 11
Logs or error output
$ node repro.mjs
requests: 14
prompt calls: 14
final statuses: 200
Redacted configuration
{
"port": 10100,
"hostname": "0.0.0.0",
"corsAllowOrigins": ["https://opencodex.example.com"]
}
Checks
Client or integration
OpenCodex dashboard
Area
Dashboard
Summary
On a non-loopback bind (
hostname: "0.0.0.0", soOPENCODEX_API_AUTH_TOKENis required), opening the dashboard shows onewindow.prompt("OpenCodex API token")per management request in flight rather than one per page load. Entering the correct token at the first prompt does not stop the following ones, so the symptom reads as "the token was wrong, try again". After answering every prompt the dashboard works normally, and the whole sequence repeats on the next reload.Measured. A burst of 14 concurrent
/api/*requests produces 14 prompts, and every request ends in200with the same token:Expected: one prompt, after which every pending request retries with that token.
GET /andGET /healthzare not gated, which is why the page renders first and the prompts start afterwards.Root cause
Two things in
gui/src/api.tsonmaincombine.401, the handler never re-readsreadToken(). A request that started before any token existed keeps its capturednulland prompts even though another request has already stored a valid token throughstoreToken().promptInFlightonly deduplicates401s that land in the same microtask batch.window.promptblocks the main thread, so any response arriving while a prompt is open is processed only after that prompt closes — and by then.finallyhas resetpromptInFlighttonull, so each continuation opens a new prompt.The prompt count therefore tracks the number of
/api/*requests in flight without a token, which on a dashboard page load is its whole initial fan-out.Why this may not have surfaced earlier
The deduplication gap has been there since
gui/src/api.tswas added inc29ee783e(2026-06-27), but it was masked because the token was persisted:With persistence the storm could happen at most once per browser session, and every later load attached the token to the first attempt.
9c7e922eb(2026-07-25T23:17Z) switched tolet memoryToken— "In-memory only — never write tokens to web storage (XSS can read sessionStorage/localStorage)" — and138751f78(2026-07-26) additionally wipes the legacy key without reading it. That hardening looks correct on its own; the consequence is that every page load now starts with no token, so the storm reproduces on every load.9c7e922eblands after the 2.7.40 release (published 2026-07-25T14:26Z), so by the npm timeline the amplified behaviour first ships in 2.7.41. This report is from 2.7.42.This is distinct from #570, which concerns the loopback
Hostadmission gate returning403when token auth is not required. Here token auth is enabled, the responses are401, and the problem is the prompt loop rather than admission.Reproduction
{ "port": 10100, "hostname": "0.0.0.0" }Open the dashboard in a browser and enter the correct token at the first prompt. It keeps prompting; a single page load asked me for the token more than ten times before it settled.
To count it without a browser, run the interceptor logic against the running proxy with a synchronous
promptstub.window.promptis synchronous in a browser, so a synchronous stub preserves the ordering that matters here.Possible fix
Re-read the current token after the
401and retry with it before prompting, so requests that were already in flight ride on the token another request obtained. Sketch rather than a prescription:Serialising concurrent
401s on a single shared refresh gate, instead of the currentpromptInFlightthat only covers one microtask batch, would remove the race more thoroughly. Either way the memory-only property from9c7e922eband138751f78is preserved, since the fix only consults the existing in-memory value and never touches storage.Happy to open a PR against
devif that is welcome.gui/src/api.tsis not in the CODEOWNERS authentication group, but it is auth-adjacent, so I would rather ask first.Version
2.7.42
Operating system
Ubuntu 24.04.4 LTS (proxy host, Docker); browser client on Windows 11
Logs or error output
Redacted configuration
{ "port": 10100, "hostname": "0.0.0.0", "corsAllowOrigins": ["https://opencodex.example.com"] }Checks