From a398a259341537dc45497acbad2f1f17ce82eac4 Mon Sep 17 00:00:00 2001 From: devv-shayan Date: Sun, 27 Sep 2026 21:09:26 +0500 Subject: [PATCH] fix(voice): validate Codex OAuth and report unknown usage --- .env.example | 3 + server/providers/openai/codex-auth.js | 85 +++++++++++++++++++++++--- server/providers/openai/realtime.js | 28 +++++++++ src/codexOauthRealtime.test.mjs | 86 +++++++++++++++++++++++++-- src/tooling/localServices.test.mjs | 33 +++++++++- src/voice/gevRealtime.test.mjs | 17 +++++- src/voice/realtimeController.js | 5 +- src/voice/realtimeCost.js | 27 ++++++++- 8 files changed, 265 insertions(+), 19 deletions(-) diff --git a/.env.example b/.env.example index 4c7edb372..552962ba1 100644 --- a/.env.example +++ b/.env.example @@ -57,6 +57,9 @@ CESIUM_ION_TOKEN= # ChatGPT/Codex OAuth session; the browser still receives only a short-lived # Realtime client secret. Do not prefix with VITE_. OPENAI_API_KEY= +# Optional: forbid API-key voice sessions. Select ChatGPT OAuth in Provider +# Settings; missing or expired Codex sign-in then fails closed. +# GEV_PREFER_CODEX_OAUTH=true OPENAI_REALTIME_MODEL=gpt-realtime-2 # Cheaper voice tier, used when the GEV MIC panel's MINI toggle is on. # Model ids and prices are external facts that drift — if OpenAI moves this id, diff --git a/server/providers/openai/codex-auth.js b/server/providers/openai/codex-auth.js index 6e882accc..68c20e929 100644 --- a/server/providers/openai/codex-auth.js +++ b/server/providers/openai/codex-auth.js @@ -1,5 +1,10 @@ import { spawn } from 'node:child_process'; -import { accessSync, constants as fsConstants, readFileSync } from 'node:fs'; +import { + accessSync, + constants as fsConstants, + readFileSync, + statSync, +} from 'node:fs'; import os from 'node:os'; import path from 'node:path'; @@ -16,22 +21,88 @@ export function codexAuthPath({ ); } +/** An optional operator lock that refuses the metered API-key voice lane. */ +export function preferCodexOAuth(environment = process.env) { + if (!Object.hasOwn(environment, 'GEV_PREFER_CODEX_OAUTH')) return false; + const value = String(environment.GEV_PREFER_CODEX_OAUTH || '') + .trim() + .toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(value)) return true; + if (['0', 'false', 'no', 'off'].includes(value)) return false; + throw new Error( + 'GEV_PREFER_CODEX_OAUTH must be true or false. Refusing to choose a voice auth mode.', + ); +} + export function readCodexOAuthAccessToken({ authPath = codexAuthPath(), readFile = readFileSync, + statFile = statSync, + nowMs = Date.now(), } = {}) { + const unavailable = + 'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.'; let parsed; try { parsed = JSON.parse(readFile(authPath, 'utf8')); } catch { - throw new Error( - 'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.', - ); + throw new Error(unavailable); } - const token = String(parsed?.tokens?.access_token || '').trim(); - if (!token) { + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(unavailable); + } + const mode = String(parsed.auth_mode || '') + .trim() + .toLowerCase(); + const chatGptMode = mode + ? 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); + } + + // This only checks token shape and expiry. OpenAI verifies the signature when + // minting the short-lived Realtime credential; we never modify auth.json. + const parts = token.split('.'); + if ( + parts.length !== 3 || + parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part)) + ) { + throw new Error(unavailable); + } + let payload; + try { + payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); + } catch { + throw new Error(unavailable); + } + if (!payload || typeof payload !== 'object' || Array.isArray(payload)) { + throw new Error(unavailable); + } + let expiresAtMs; + if (Object.hasOwn(payload, 'exp')) { + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) { + throw new Error(unavailable); + } + expiresAtMs = payload.exp * 1000; + } else { + try { + expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000; + } catch { + throw new Error(unavailable); + } + } + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) { throw new Error( - 'ChatGPT sign-in is unavailable. Sign in to Codex or the ChatGPT desktop app and try again.', + 'ChatGPT sign-in has expired. Run codex login and try again.', ); } return token; diff --git a/server/providers/openai/realtime.js b/server/providers/openai/realtime.js index 98fea5318..1869ebd24 100644 --- a/server/providers/openai/realtime.js +++ b/server/providers/openai/realtime.js @@ -14,6 +14,7 @@ import { import { realtimeInstructions } from './instructions.js'; import { GEV_REALTIME_TOOLS } from './tools.js'; import { + preferCodexOAuth, readCodexOAuthAccessToken, startCodexChatGptLogin, } from './codex-auth.js'; @@ -174,6 +175,7 @@ function createRealtimeTokenHandler({ fetchImpl = (...args) => fetch(...args), resolveApiKey = () => process.env.OPENAI_API_KEY, resolveOAuthAccessToken = () => readCodexOAuthAccessToken(), + environment = process.env, models = {}, } = {}) { return async (req, res) => { @@ -194,6 +196,28 @@ function createRealtimeTokenHandler({ return; } + let oauthPinned; + try { + oauthPinned = preferCodexOAuth(environment); + } catch (error) { + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ error: error.message })); + return; + } + if (oauthPinned && authMode !== 'oauth') { + res.statusCode = 503; + res.setHeader('Content-Type', 'application/json'); + res.end( + JSON.stringify({ + error: + 'ChatGPT OAuth is required by GEV_PREFER_CODEX_OAUTH. Select USE CHATGPT OAUTH in Provider Settings.', + code: 'CODEX_OAUTH_REQUIRED', + }), + ); + return; + } + // Opt-in per-IP throttle (GEV_RATELIMIT_OPENAI_PER_MIN). No-op when unset. if (!enforceOptInRateLimit(openAiRateLimiter(), req, res)) return; @@ -332,6 +356,10 @@ function createRealtimeTokenHandler({ // case where a bogus ?tier= was silently downgraded to standard. res.setHeader('X-GEV-Voice-Tier', tier); res.setHeader('X-GEV-Voice-Model', model); + res.setHeader( + 'X-GEV-Voice-Auth', + authMode === 'oauth' ? 'codex-oauth' : 'env', + ); if (requestedTier && !isKnownVoiceTier(requestedTier)) { res.setHeader('X-GEV-Voice-Tier-Fallback', '1'); } diff --git a/src/codexOauthRealtime.test.mjs b/src/codexOauthRealtime.test.mjs index 4afadb0e6..ffd2b169f 100644 --- a/src/codexOauthRealtime.test.mjs +++ b/src/codexOauthRealtime.test.mjs @@ -4,11 +4,32 @@ import test from 'node:test'; import path from 'node:path'; import { codexAuthPath, + preferCodexOAuth, readCodexOAuthAccessToken, resolveCodexExecutable, startCodexChatGptLogin, } from '../server/providers/openai/codex-auth.js'; +const nowMs = Date.UTC(2026, 8, 27); +const jwt = (payload) => + `${Buffer.from('{}').toString('base64url')}.${Buffer.from(JSON.stringify(payload)).toString('base64url')}.signature`; +const auth = (token, extras = {}) => + JSON.stringify({ + auth_mode: 'chatgpt', + tokens: { access_token: token, refresh_token: 'refresh-fixture' }, + ...extras, + }); + +test('optional Codex OAuth pin fails closed on ambiguous values', () => { + assert.equal(preferCodexOAuth({}), false); + assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'true' }), true); + assert.equal(preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'off' }), false); + assert.throws( + () => preferCodexOAuth({ GEV_PREFER_CODEX_OAUTH: 'maybe' }), + /must be true or false/, + ); +}); + test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => { assert.equal( codexAuthPath({ @@ -30,28 +51,85 @@ test('Codex auth path follows explicit auth file, CODEX_HOME, then home director ); }); -test('Codex OAuth token reader returns only the access token and fails closed', () => { +test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable credentials', () => { + const current = jwt({ exp: nowMs / 1000 + 3600 }); assert.equal( readCodexOAuthAccessToken({ authPath: '/unused', - readFile: () => JSON.stringify({ tokens: { access_token: ' oauth-fixture ' } }), + nowMs, + readFile: () => auth(current), }), - 'oauth-fixture', + current, ); for (const readFile of [ () => '{bad json', () => JSON.stringify({ tokens: {} }), + () => auth(current, { auth_mode: 'apikey' }), + () => + JSON.stringify({ + 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' })), () => { throw new Error('missing'); }, ]) { assert.throws( - () => readCodexOAuthAccessToken({ authPath: '/unused', readFile }), + () => readCodexOAuthAccessToken({ authPath: '/unused', readFile, nowMs }), /ChatGPT sign-in is unavailable/, ); } }); +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/, + ); + } + const noExp = jwt({ sub: 'fixture' }); + const base = { authPath: '/unused', nowMs, readFile: () => auth(noExp) }; + assert.equal( + readCodexOAuthAccessToken({ + ...base, + statFile: () => ({ mtimeMs: nowMs }), + }), + noExp, + ); + assert.throws( + () => + readCodexOAuthAccessToken({ + ...base, + statFile: () => ({ mtimeMs: nowMs - 3600_000 }), + }), + /sign-in has expired/, + ); + assert.throws( + () => + readCodexOAuthAccessToken({ + ...base, + statFile: () => { + throw new Error('missing'); + }, + }), + /sign-in is unavailable/, + ); +}); + test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => { const checked = []; assert.equal( diff --git a/src/tooling/localServices.test.mjs b/src/tooling/localServices.test.mjs index 7014659b7..408dbbbc3 100644 --- a/src/tooling/localServices.test.mjs +++ b/src/tooling/localServices.test.mjs @@ -217,15 +217,44 @@ test('Realtime handler keeps API key as default and uses OAuth only when request ); const token = routes.get('/api/realtime/token'); - assert.equal((await request(token)).status, 200); + const apiResponse = await request(token); + assert.equal(apiResponse.status, 200); + assert.equal(apiResponse.headers['x-gev-voice-auth'], 'env'); assert.equal(oauthReads, 0, 'default cloud voice does not read OAuth'); assert.equal(authorizations.at(-1), 'Bearer fixture-api-key'); - assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200); + const oauthResponse = await request(token, { url: '/?auth=oauth' }); + assert.equal(oauthResponse.status, 200); + assert.equal(oauthResponse.headers['x-gev-voice-auth'], 'codex-oauth'); assert.equal(oauthReads, 1); assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token'); }); +test('a Codex OAuth pin refuses API-key minting without silently switching modes', async (t) => { + env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined); + const authorizations = []; + const routes = install( + openAiRealtimeProxy({ + realtime: { + environment: { GEV_PREFER_CODEX_OAUTH: 'true' }, + resolveApiKey: () => assert.fail('pinned OAuth cannot read an API key'), + resolveOAuthAccessToken: () => 'fixture-oauth-token', + fetchImpl: async (_url, options) => { + authorizations.push(options.headers.Authorization); + return Response.json({ value: 'fixture-ephemeral' }); + }, + }, + }), + ); + const token = routes.get('/api/realtime/token'); + const refused = await request(token); + assert.equal(refused.status, 503); + assert.equal(refused.json().code, 'CODEX_OAUTH_REQUIRED'); + assert.equal(authorizations.length, 0); + assert.equal((await request(token, { url: '/?auth=oauth' })).status, 200); + assert.deepEqual(authorizations, ['Bearer fixture-oauth-token']); +}); + test('OAuth status and token minting never expose local OAuth to remote clients', async () => { let oauthReads = 0; const routes = install( diff --git a/src/voice/gevRealtime.test.mjs b/src/voice/gevRealtime.test.mjs index 66e1b1367..f16e02a91 100644 --- a/src/voice/gevRealtime.test.mjs +++ b/src/voice/gevRealtime.test.mjs @@ -3194,18 +3194,29 @@ test('F1: the toggle still records the next-session preference while live', () = assert.match(ui.tierButton.title, /this session stays on/i); }); -test('OAuth cloud sessions hide API spend and do not trip the API spend guard', () => { +test('OAuth cloud sessions report unknown cost and usage without tripping the API spend guard', () => { const { controller, ui } = costControllerHarness(); controller.status = 'listening'; controller._cost.cloudVoiceAuth = 'oauth'; controller._cost.sessionCloudVoiceAuth = 'oauth'; controller.syncCostUi(); const before = controller.costTracker.state(); - assert.equal(ui.costValue.hidden, true); - assert.equal(controller.recordUsage(usdUsage(100)), null); + assert.equal(ui.costValue.hidden, false); + assert.equal(ui.costValue.textContent, 'COST UNKNOWN'); + assert.deepEqual(controller.recordUsage(usdUsage(100)), { + responses: 1, + input: 0, + output: 1562500, + costKnown: false, + }); const after = controller.costTracker.state(); assert.equal(after.totalUsd, before.totalUsd); assert.equal(controller.costCapStopped, false); + assert.match(ui.costValue.title, /1 response\(s\).*1562500 output tokens/); + controller.dc = {}; + controller.responseActive = true; + controller.stop(); + assert.match(ui.costValue.title, /Usage is incomplete/); }); test('F1: when idle, toggling does re-price the preview meter', () => { diff --git a/src/voice/realtimeController.js b/src/voice/realtimeController.js index d0c9f1bae..999297c27 100644 --- a/src/voice/realtimeController.js +++ b/src/voice/realtimeController.js @@ -239,7 +239,10 @@ export class GevRealtimeController extends RealtimeFacade { status: this.status, connection: this.connectionDiagnostics(), }); - if (this.dc && this.responseActive) this.costTracker.markIncomplete(); + if (this.dc && this.responseActive) { + this.costTracker.markIncomplete(); + this.syncCostUi(); + } this._connection.closeTransport(); this.stopVoiceVisualizer(); this._connection.releaseMedia(); diff --git a/src/voice/realtimeCost.js b/src/voice/realtimeCost.js index 83d19785e..0de89065c 100644 --- a/src/voice/realtimeCost.js +++ b/src/voice/realtimeCost.js @@ -19,6 +19,7 @@ export class RealtimeCost { this.voiceLimits = readStoredVoiceLimits(); this.cloudVoiceAuth = readStoredCloudVoiceAuthMode(); this.sessionCloudVoiceAuth = null; + this.oauthUsage = { responses: 0, input: 0, output: 0 }; this.costTracker = createVoiceCostTracker({ tier: this.voiceTier, limits: this.voiceLimits, @@ -60,7 +61,20 @@ export class RealtimeCost { } if (this.ui?.costValue) { const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth; - this.ui.costValue.hidden = liveAuth === 'oauth'; + if (liveAuth === 'oauth') { + const { responses, input, output } = this.oauthUsage; + this.ui.costValue.hidden = false; + this.ui.costValue.textContent = 'COST UNKNOWN'; + this.ui.costValue.dataset.level = 'unknown'; + this.ui.costValue.title = + `ChatGPT OAuth session: ${responses} response(s), ${input} input and ${output} output tokens reported. ` + + 'USD cost and the API spend cap are unavailable for this auth mode.' + + (state.incomplete + ? ' Usage is incomplete because a response was still in flight when the session ended.' + : ''); + return; + } + this.ui.costValue.hidden = false; this.ui.costValue.textContent = state.display; this.ui.costValue.dataset.level = state.level; this.ui.costValue.title = @@ -141,7 +155,15 @@ export class RealtimeCost { */ recordUsage(usage) { if (!usage) return null; - if (this.sessionCloudVoiceAuth === 'oauth') return null; + if (this.sessionCloudVoiceAuth === 'oauth') { + const count = (value) => + Number.isFinite(value) && value > 0 ? value : 0; + this.oauthUsage.responses += 1; + this.oauthUsage.input += count(usage.input_tokens); + this.oauthUsage.output += count(usage.output_tokens); + this.syncCostUi(); + return { ...this.oauthUsage, costKnown: false }; + } const state = this.costTracker.record(usage); this.syncCostUi(); if (state.warnCrossed) { @@ -198,6 +220,7 @@ export class RealtimeCost { this.voiceLimits = readStoredVoiceLimits(); this.cloudVoiceAuth = readStoredCloudVoiceAuthMode(); this.sessionCloudVoiceAuth = this.cloudVoiceAuth; + this.oauthUsage = { responses: 0, input: 0, output: 0 }; this.costCapStopped = false; // Provisional meter (tier-priced) so the readout shows $0.00 while // connecting. It is REPLACED below with one bound to the model the server