fix(voice): track OAuth login lifecycle and storage readiness

This commit is contained in:
devv-shayan committed 2026-10-04 17:24:30 +05:00
1 parent 6c88b78140
commit 8f5710e7cb
10 files changed
+906 -106

No files matched your search

+100 -9
View File
@@ -61,6 +61,19 @@ test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable cr
}),
current,
);
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
}),
current,
'GEV does not require or consume the vendor refresh token',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
@@ -70,11 +83,6 @@ test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable cr
OPENAI_API_KEY: 'sk-fixture',
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
}),
() =>
JSON.stringify({
auth_mode: 'chatgpt',
tokens: { access_token: current },
}),
() => auth('opaque-token'),
() => auth('header.not-json.signature'),
() => auth(jwt({ exp: 'tomorrow' })),
@@ -92,13 +100,18 @@ test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable cr
test('Codex OAuth reader enforces expiry margin and bounds tokens without exp by file age', () => {
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
assert.throws(
() =>
() => {
readCodexOAuthAccessToken({
authPath: '/unused',
nowMs,
readFile: () => auth(jwt({ exp })),
}),
/sign-in has expired/,
});
},
(error) => {
assert.match(error.message, /sign-in has expired/);
assert.equal(error.code, 'CODEX_OAUTH_REAUTH_REQUIRED');
return true;
},
);
}
const noExp = jwt({ sub: 'fixture' });
@@ -162,10 +175,88 @@ test('Codex login resolves an installed executable before PATH and starts browse
return child;
},
});
assert.deepEqual(await pending, { executable: '/fixture/codex' });
const login = await pending;
assert.equal(unref, 1);
assert.equal(calls[0].command, '/fixture/codex');
assert.deepEqual(calls[0].args, ['login']);
assert.equal(calls[0].options.detached, true);
assert.equal(calls[0].options.stdio, 'ignore');
assert.equal(
calls[0].options.env.CODEX_HOME,
path.join('/home/fixture', '.codex'),
);
child.emit('exit', 1);
assert.deepEqual(await login.completion, { exitCode: 1 });
});
test('Codex login refuses a read/write storage mismatch before spawning', async () => {
await assert.rejects(
startCodexChatGptLogin({
environment: { CODEX_AUTH_JSON: '/tmp/another-auth.json' },
home: '/home/fixture',
executable: 'codex',
spawnImpl: () => assert.fail('mismatched storage must not launch login'),
}),
{ code: 'CODEX_OAUTH_STORAGE_MISMATCH' },
);
});
test('Codex login resolves relative CODEX_HOME before changing the child directory', async () => {
const child = new EventEmitter();
const environment = { CODEX_HOME: './fixture-codex' };
const login = await startCodexChatGptLogin({
environment,
executable: 'codex',
spawnImpl(_command, _args, options) {
assert.equal(
options.env.CODEX_HOME,
path.resolve(environment.CODEX_HOME),
);
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.equal(
environment.CODEX_HOME,
'./fixture-codex',
'caller environment is unchanged',
);
child.emit('exit', 0);
assert.deepEqual(await login.completion, { exitCode: 0 });
});
test('Codex login hides spawn errors and can cancel only its owned child', async () => {
const child = new EventEmitter();
await assert.rejects(
startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() =>
child.emit('error', new Error('private-token-fixture')),
);
return child;
},
}),
(error) =>
error.code === 'CODEX_OAUTH_LOGIN_FAILED' &&
!error.message.includes('private-token-fixture'),
);
const running = new EventEmitter();
let cancelled = 0;
running.kill = (signal) => {
assert.equal(signal, 'SIGTERM');
cancelled += 1;
running.emit('exit', null);
};
const login = await startCodexChatGptLogin({
executable: 'codex',
spawnImpl() {
queueMicrotask(() => running.emit('spawn'));
return running;
},
});
login.cancel();
assert.equal(cancelled, 1);
assert.deepEqual(await login.completion, { exitCode: null });
});
+31 -3
View File
@@ -75,7 +75,13 @@ export async function waitForChatGptOAuth({
signal,
});
const payload = await response.json().catch(() => ({}));
if (signal?.aborted) return false;
if (response.ok && payload.available) return true;
if (!response.ok || payload.loginFailed) {
throw new Error(
payload.error || 'Could not check ChatGPT sign-in. Try again.',
);
}
if (now() >= deadline) break;
await sleep(Math.max(0, pollMs), signal);
}
@@ -288,6 +294,7 @@ export async function initKeySetup({
const statusLine = root.querySelector('[data-key-setup-status]');
const defaultStatusText = statusLine?.textContent || '';
let busy = false;
let oauthBusy = false;
let open = false;
const render = (nextStatus) => {
@@ -303,6 +310,10 @@ export async function initKeySetup({
rowsHost.textContent = '';
for (const key of status.keys || [])
rowsHost.append(buildRow(documentRef, key));
for (const button of rowsHost.querySelectorAll(
'[data-cloud-voice-auth-toggle]',
))
button.disabled = oauthBusy;
};
const visible = () =>
@@ -435,6 +446,7 @@ export async function initKeySetup({
'[data-cloud-voice-auth-toggle]',
);
if (authButton && !disposed) {
if (oauthBusy) return;
void (async () => {
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
@@ -444,6 +456,7 @@ export async function initKeySetup({
return;
}
authButton.disabled = true;
oauthBusy = true;
say('Checking local ChatGPT OAuth sign-in…');
try {
const response = await doFetch('/api/realtime/oauth-status', {
@@ -451,6 +464,11 @@ export async function initKeySetup({
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!response.ok)
throw new Error(
payload.error || 'Could not check ChatGPT sign-in.',
);
if (response.ok && payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
render(status);
@@ -460,13 +478,18 @@ export async function initKeySetup({
return;
}
say('Opening ChatGPT sign-in in your browser…');
say(
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
? 'ChatGPT sign-in expired. Opening sign-in again…'
: 'Opening ChatGPT sign-in in your browser…',
);
const loginResponse = await doFetch('/api/realtime/oauth-login', {
method: 'POST',
cache: 'no-store',
signal: lifetime.signal,
});
const loginPayload = await loginResponse.json().catch(() => ({}));
if (lifetime.signal.aborted) return;
if (!loginResponse.ok && loginResponse.status !== 202) {
say(
loginPayload.error ||
@@ -491,9 +514,10 @@ export async function initKeySetup({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (lifetime.signal.aborted) return;
if (!available) {
say(
'ChatGPT sign-in is still pending. Finish it in the browser, then click USE CHATGPT OAUTH again.',
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
);
return;
}
@@ -506,7 +530,11 @@ export async function initKeySetup({
if (lifetime.signal.aborted) return;
say(`OAuth check failed: ${error?.message || error}`);
} finally {
authButton.disabled = false;
oauthBusy = false;
for (const button of rowsHost.querySelectorAll(
'[data-cloud-voice-auth-toggle]',
))
button.disabled = false;
}
})();
return;
+36
View File
@@ -84,6 +84,42 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
assert.equal(checks, 3);
});
test('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
for (const result of [
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
{ ok: false, payload: { error: 'Sign-in is local only.' } },
]) {
let checks = 0;
await assert.rejects(waitForChatGptOAuth({
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
sleep: () => assert.fail('failed login must stop polling'),
}), { message: result.payload.error });
assert.equal(checks, 1);
}
});
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
let clock = 0;
let checks = 0;
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
assert.equal(checks, 2);
const controller = new AbortController();
controller.abort();
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
assert.equal(checks, 2, 'aborted polling does not fetch');
});
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
const controller = new AbortController();
const available = await waitForChatGptOAuth({
signal: controller.signal,
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
sleep: () => assert.fail('aborted polling must not continue'),
});
assert.equal(available, false);
});
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
assert.ok(stripped !== null);
+222 -12
View File
@@ -299,25 +299,72 @@ test('OAuth status reports local sign-in availability without returning the toke
assert.equal(response.body.includes('fixture-oauth-secret'), false);
});
test('OAuth login endpoint launches local Codex sign-in once and never exposes credentials', async () => {
let starts = 0;
let clock = 1_000;
test('OAuth status and token minting report expired sign-in as re-auth required', async () => {
const expired = () => {
const error = new Error('ChatGPT sign-in has expired.');
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
throw error;
};
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
throw new Error('not signed in');
},
startOAuthLogin: async () => {
starts += 1;
},
now: () => clock,
resolveOAuthAccessToken: expired,
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
},
}),
);
const status = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(status.status, 200);
assert.deepEqual(status.json(), {
available: false,
error: 'ChatGPT sign-in has expired.',
code: 'CODEX_OAUTH_REAUTH_REQUIRED',
reauthRequired: true,
});
const token = await request(routes.get('/api/realtime/token'), {
url: '/?auth=oauth',
});
assert.equal(token.status, 503);
assert.equal(token.json().code, 'CODEX_OAUTH_REAUTH_REQUIRED');
});
test('OAuth login endpoint shares pending attempts and never exposes credentials', async (t) => {
t.mock.timers.enable({ apis: ['setTimeout'] });
let starts = 0;
let spawnReady;
let exit;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
throw new Error('not signed in');
},
startOAuthLogin: async () => {
starts += 1;
await new Promise((resolve) => {
spawnReady = resolve;
});
return {
completion: new Promise((resolve) => {
exit = resolve;
}),
cancel() {},
};
},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const handler = routes.get('/api/realtime/oauth-login');
const started = await request(handler, { method: 'POST' });
const firstRequest = request(handler, { method: 'POST' });
const concurrent = await request(handler, { method: 'POST' });
assert.equal(concurrent.status, 202);
assert.equal(concurrent.json().started, false);
assert.equal(starts, 1, 'reserve the attempt before awaiting spawn');
spawnReady();
const started = await firstRequest;
assert.equal(started.status, 202);
assert.deepEqual(started.json(), {
available: false,
@@ -326,7 +373,7 @@ test('OAuth login endpoint launches local Codex sign-in once and never exposes c
});
assert.equal(starts, 1);
clock += 1_000;
t.mock.timers.tick(31_000);
const duplicate = await request(handler, { method: 'POST' });
assert.equal(duplicate.status, 202);
assert.equal(duplicate.json().pending, true);
@@ -345,6 +392,169 @@ test('OAuth login endpoint launches local Codex sign-in once and never exposes c
});
assert.equal(crossOrigin.status, 403);
assert.equal(starts, 1);
exit({ exitCode: 1 });
await Promise.resolve();
const failed = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(failed.json().loginFailed, true);
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
});
test('OAuth status follows process completion, permits retry, and checks credential storage', async (t) => {
let token = null;
let exit;
let starts = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => token,
startOAuthLogin: async () => {
starts += 1;
return {
completion: new Promise((resolve) => {
exit = resolve;
}),
cancel() {},
};
},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
const status = () => request(routes.get('/api/realtime/oauth-status'));
await launch();
exit({ exitCode: 0 });
await Promise.resolve();
assert.equal(
(await status()).json().code,
'CODEX_OAUTH_STORAGE_UNAVAILABLE',
'exit success alone is not usable sign-in',
);
await launch();
assert.equal(starts, 2, 'failed attempts can be retried immediately');
token = 'fixture-private-token';
exit({ exitCode: 0 });
await Promise.resolve();
const ready = await status();
assert.deepEqual(ready.json(), { available: true });
assert.equal(ready.body.includes(token), false);
});
test('OAuth login timeout and server shutdown cancel the owned process', async () => {
let timeout;
let cancelled = 0;
let cleared = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => null,
startOAuthLogin: async () => ({
completion: new Promise(() => {}),
cancel() {
cancelled += 1;
},
}),
setTimer(callback, duration) {
timeout = callback;
assert.equal(duration, 120_000);
return 1;
},
clearTimer() {
cleared += 1;
},
},
});
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
await launch();
timeout();
assert.equal(cancelled, 1);
const status = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(status.json().code, 'CODEX_OAUTH_LOGIN_TIMEOUT');
await launch();
plugin.closeBundle();
plugin.closeBundle();
assert.equal(cancelled, 2, 'shutdown is idempotent');
assert.equal(cleared, 2);
});
test('OAuth launch errors are safe and cross-origin status requests cannot read credentials', async () => {
let reads = 0;
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken() {
reads += 1;
return null;
},
startOAuthLogin: async () => {
throw new Error('private-token-fixture');
},
},
}),
);
const remote = await request(routes.get('/api/realtime/oauth-status'), {
origin: 'https://example.com',
});
assert.equal(remote.status, 403);
assert.equal(reads, 0);
const failed = await request(routes.get('/api/realtime/oauth-login'), {
method: 'POST',
});
assert.equal(failed.status, 503);
assert.equal(failed.json().code, 'CODEX_OAUTH_LOGIN_FAILED');
assert.equal(failed.body.includes('private-token-fixture'), false);
});
test('a login that spawns after timeout is cancelled and cannot replace a retry', async (t) => {
let timeout;
let releaseSpawn;
let cancelled = 0;
let launches = 0;
const plugin = openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => null,
startOAuthLogin: async () => {
launches += 1;
if (launches === 1)
await new Promise((resolve) => {
releaseSpawn = resolve;
});
return {
completion: new Promise(() => {}),
cancel() {
cancelled += 1;
},
};
},
setTimer(callback) {
timeout = callback;
return 1;
},
clearTimer() {},
},
});
t.after(plugin.closeBundle);
const routes = install(plugin);
const launch = () =>
request(routes.get('/api/realtime/oauth-login'), { method: 'POST' });
const first = launch();
timeout();
await launch();
releaseSpawn();
await first;
assert.equal(launches, 2);
assert.equal(cancelled, 1, 'late process must not become an orphan');
const status = (
await request(routes.get('/api/realtime/oauth-status'))
).json();
assert.equal(status.pending, true);
assert.equal(
status.loginFailed,
undefined,
'the older attempt cannot fail the retry',
);
});
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {