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

+7 -2
View File
@@ -7,6 +7,7 @@ import {
createRealtimeTokenHandler,
} from './openai/realtime.js';
import { sameSiteGated } from './common/same-site.js';
import { createCodexOAuthLogin } from './openai/codex-auth.js';
/**
* Vite plugin: OpenAI Realtime ephemeral client secret.
@@ -19,6 +20,7 @@ function openAiRealtimeProxy({
annotationGuidance,
realtime = {},
} = {}) {
const oauthLogin = createCodexOAuthLogin(realtime);
function install(middlewares) {
// Cost-bearing and log endpoints refuse cross-site browser requests
// (see server/providers/common/same-site.js and SECURITY.md).
@@ -38,12 +40,12 @@ function openAiRealtimeProxy({
middlewares.use(
'/api/realtime/oauth-status',
createRealtimeOAuthStatusHandler(realtime),
sameSiteGated(createRealtimeOAuthStatusHandler({ oauthLogin })),
);
middlewares.use(
'/api/realtime/oauth-login',
createRealtimeOAuthLoginHandler(realtime),
sameSiteGated(createRealtimeOAuthLoginHandler({ oauthLogin })),
);
}
@@ -51,10 +53,13 @@ function openAiRealtimeProxy({
name: 'openai-realtime-proxy',
configureServer(server) {
install(server.middlewares);
server.httpServer?.once('close', oauthLogin.dispose);
},
configurePreviewServer(server) {
install(server.middlewares);
server.httpServer?.once('close', oauthLogin.dispose);
},
closeBundle: oauthLogin.dispose,
};
}
+201 -27
View File
@@ -34,6 +34,12 @@ export function preferCodexOAuth(environment = process.env) {
);
}
function codexOAuthError(message, code = 'CODEX_OAUTH_UNAVAILABLE') {
const error = new Error(message);
error.code = code;
return error;
}
export function readCodexOAuthAccessToken({
authPath = codexAuthPath(),
readFile = readFileSync,
@@ -46,10 +52,10 @@ export function readCodexOAuthAccessToken({
try {
parsed = JSON.parse(readFile(authPath, 'utf8'));
} catch {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
const mode = String(parsed.auth_mode || '')
.trim()
@@ -58,15 +64,8 @@ export function readCodexOAuthAccessToken({
? mode === 'chatgpt' || mode === 'chatgptauthtokens'
: typeof parsed.OPENAI_API_KEY !== 'string';
const token = parsed.tokens?.access_token;
const refresh = parsed.tokens?.refresh_token;
if (
!chatGptMode ||
typeof token !== 'string' ||
!token.trim() ||
typeof refresh !== 'string' ||
!refresh.trim()
) {
throw new Error(unavailable);
if (!chatGptMode || typeof token !== 'string' || !token.trim()) {
throw codexOAuthError(unavailable);
}
// This only checks token shape and expiry. OpenAI verifies the signature when
@@ -76,33 +75,34 @@ export function readCodexOAuthAccessToken({
parts.length !== 3 ||
parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))
) {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
let payload;
try {
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
} catch {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
let expiresAtMs;
if (Object.hasOwn(payload, 'exp')) {
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
expiresAtMs = payload.exp * 1000;
} else {
try {
expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000;
} catch {
throw new Error(unavailable);
throw codexOAuthError(unavailable);
}
}
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) {
throw new Error(
'ChatGPT sign-in has expired. Run codex login and try again.',
throw codexOAuthError(
'ChatGPT sign-in has expired. Re-authenticate in Provider Settings or run codex login and try again.',
'CODEX_OAUTH_REAUTH_REQUIRED',
);
}
return token;
@@ -144,28 +144,202 @@ export function startCodexChatGptLogin({
home = os.homedir(),
executable = resolveCodexExecutable({ environment, home }),
} = {}) {
// Codex owns its credential storage. An explicit read path must agree with
// the CLI's write path; never silently sign in to another credential store.
const configuredHome = String(environment.CODEX_HOME || '').trim();
const loginHome = configuredHome
? path.resolve(configuredHome)
: path.join(home, '.codex');
if (
codexAuthPath({ environment, home }) !== path.join(loginHome, 'auth.json')
) {
return Promise.reject(
codexOAuthError(
'CODEX_AUTH_JSON does not match the Codex login storage path. Set CODEX_HOME to the matching directory or sign in to Codex separately.',
'CODEX_OAUTH_STORAGE_MISMATCH',
),
);
}
return new Promise((resolve, reject) => {
let child;
let spawned = false;
let finish;
const completion = new Promise((done) => {
finish = done;
});
const fail = () => {
reject(
codexOAuthError(
'Could not start ChatGPT sign-in. Check that Codex is installed, or set CODEX_BIN to its executable.',
'CODEX_OAUTH_LOGIN_FAILED',
),
);
finish({ exitCode: null });
};
try {
child = spawnImpl(executable, ['login'], {
cwd: home,
detached: true,
stdio: 'ignore',
env: environment,
env: { ...environment, CODEX_HOME: loginHome },
});
} catch (error) {
reject(error);
} catch {
fail();
return;
}
child.once('error', reject);
child.once('error', () => {
if (!spawned) fail();
else finish({ exitCode: null });
});
child.once('exit', (exitCode) => finish({ exitCode }));
child.once('spawn', () => {
spawned = true;
child.unref?.();
resolve({ executable });
resolve({
completion,
cancel() {
try {
child.kill?.('SIGTERM');
} catch {
/* Already stopped. */
}
},
});
});
}).catch((error) => {
throw new Error(
`Could not start ChatGPT sign-in with Codex: ${error?.message || error}`,
);
});
}
/** One local login owner shared by the launch and status endpoints. */
export function createCodexOAuthLogin({
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
startOAuthLogin = () => startCodexChatGptLogin(),
loginTimeoutMs = 120_000,
setTimer = setTimeout,
clearTimer = clearTimeout,
} = {}) {
let active = null;
let failure = null;
let disposed = false;
function credentials() {
try {
if (resolveOAuthAccessToken()) return { available: true };
} catch (error) {
const code = error?.code || 'CODEX_OAUTH_UNAVAILABLE';
return {
available: false,
error: error?.message || 'ChatGPT sign-in is unavailable',
code,
reauthRequired: code === 'CODEX_OAUTH_REAUTH_REQUIRED',
};
}
return {
available: false,
error: 'ChatGPT sign-in is unavailable',
code: 'CODEX_OAUTH_UNAVAILABLE',
reauthRequired: false,
};
}
function status() {
const current = credentials();
if (current.available) return current;
if (failure) return { available: false, ...failure, loginFailed: true };
return active ? { ...current, pending: true } : current;
}
function finish(attempt, error) {
if (active !== attempt) return;
clearTimer(attempt.timer);
active = null;
failure = error;
}
async function start() {
if (disposed)
throw codexOAuthError(
'ChatGPT sign-in is unavailable because the server has stopped.',
'CODEX_OAUTH_LOGIN_FAILED',
);
if (credentials().available)
return { available: true, started: false, pending: false };
if (active) return { available: false, started: false, pending: true };
// Reserve the attempt before awaiting spawn so concurrent POSTs share it.
const attempt = { process: null, timer: null };
active = attempt;
failure = null;
attempt.timer = setTimer(() => {
finish(attempt, {
code: 'CODEX_OAUTH_LOGIN_TIMEOUT',
error:
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
});
attempt.process?.cancel();
}, loginTimeoutMs);
attempt.timer?.unref?.();
try {
const login = await startOAuthLogin();
if (!login?.completion || typeof login.cancel !== 'function')
throw new Error('Invalid login process');
attempt.process = login;
if (active !== attempt) {
login.cancel();
return { ...status(), started: false, pending: false };
}
Promise.resolve(login.completion).then(
(result) => {
if (active !== attempt) return;
if (result?.exitCode !== 0) {
finish(attempt, {
code: 'CODEX_OAUTH_LOGIN_FAILED',
error:
'ChatGPT sign-in did not complete. Try again in Provider Settings, or run codex login separately.',
});
} else if (!credentials().available) {
finish(attempt, {
code: 'CODEX_OAUTH_STORAGE_UNAVAILABLE',
error:
'Codex sign-in finished, but its configured auth.json has no usable ChatGPT token. Check CODEX_HOME and Codex credential storage. GEV can read file storage only.',
});
} else finish(attempt, null);
},
() =>
finish(attempt, {
code: 'CODEX_OAUTH_LOGIN_FAILED',
error:
'ChatGPT sign-in did not complete. Try again in Provider Settings.',
}),
);
return { available: false, started: true, pending: true };
} catch (error) {
const code =
error?.code === 'CODEX_OAUTH_STORAGE_MISMATCH'
? error.code
: 'CODEX_OAUTH_LOGIN_FAILED';
finish(attempt, {
code,
error:
code === 'CODEX_OAUTH_STORAGE_MISMATCH'
? error.message
: 'Could not start ChatGPT sign-in. Check that Codex is installed, or set CODEX_BIN to its executable.',
});
return { ...status(), started: false, pending: false };
}
}
return {
status,
start,
dispose() {
disposed = true;
if (!active) return;
const attempt = active;
finish(attempt, null);
attempt.process?.cancel();
},
};
}
+10 -53
View File
@@ -16,7 +16,7 @@ import { GEV_REALTIME_TOOLS } from './tools.js';
import {
preferCodexOAuth,
readCodexOAuthAccessToken,
startCodexChatGptLogin,
createCodexOAuthLogin,
} from './codex-auth.js';
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
@@ -53,7 +53,7 @@ function resolveRealtimeAuthMode(query) {
}
function createRealtimeOAuthStatusHandler({
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
oauthLogin = createCodexOAuthLogin(),
} = {}) {
return (req, res) => {
res.setHeader('Cache-Control', 'no-store');
@@ -73,30 +73,14 @@ function createRealtimeOAuthStatusHandler({
);
return;
}
try {
const token = resolveOAuthAccessToken();
if (!token) throw new Error('ChatGPT sign-in is unavailable');
res.statusCode = 200;
res.end(JSON.stringify({ available: true }));
} catch (error) {
res.statusCode = 200;
res.end(
JSON.stringify({
available: false,
error: error?.message || 'ChatGPT sign-in is unavailable',
}),
);
}
res.statusCode = 200;
res.end(JSON.stringify(oauthLogin.status()));
};
}
function createRealtimeOAuthLoginHandler({
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
startOAuthLogin = () => startCodexChatGptLogin(),
now = () => Date.now(),
restartCooldownMs = 30_000,
oauthLogin = createCodexOAuthLogin(),
} = {}) {
let lastStartedAt = 0;
return async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Content-Type', 'application/json');
@@ -127,42 +111,15 @@ function createRealtimeOAuthLoginHandler({
}
try {
if (resolveOAuthAccessToken()) {
res.statusCode = 200;
res.end(
JSON.stringify({ available: true, started: false, pending: false }),
);
return;
}
const result = await oauthLogin.start();
res.statusCode = result.available ? 200 : result.loginFailed ? 503 : 202;
res.end(JSON.stringify(result));
} catch {
// No usable local token yet; launch the supported Codex sign-in flow.
}
const currentTime = now();
if (
lastStartedAt > 0 &&
currentTime - lastStartedAt < Math.max(0, restartCooldownMs)
) {
res.statusCode = 202;
res.end(
JSON.stringify({ available: false, started: false, pending: true }),
);
return;
}
try {
await startOAuthLogin();
lastStartedAt = currentTime;
res.statusCode = 202;
res.end(
JSON.stringify({ available: false, started: true, pending: true }),
);
} catch (error) {
res.statusCode = 503;
res.end(
JSON.stringify({
available: false,
error: error?.message || 'Could not start ChatGPT sign-in',
error: 'Could not start ChatGPT sign-in',
}),
);
}
@@ -242,7 +199,7 @@ function createRealtimeTokenHandler({
res.end(
JSON.stringify({
error: error?.message || 'ChatGPT sign-in is unavailable',
code: 'CODEX_OAUTH_UNAVAILABLE',
code: error?.code || 'CODEX_OAUTH_UNAVAILABLE',
}),
);
return;