feat(voice): add ChatGPT OAuth auth option

This commit is contained in:
devv-shayan committed 2026-09-27 20:58:08 +05:00
1 parent b210ab0fe4
commit 881e24485c
23 files changed
+918 -34

No files matched your search

+93
View File
@@ -0,0 +1,93 @@
import assert from 'node:assert/strict';
import { EventEmitter } from 'node:events';
import test from 'node:test';
import path from 'node:path';
import {
codexAuthPath,
readCodexOAuthAccessToken,
resolveCodexExecutable,
startCodexChatGptLogin,
} from '../server/providers/openai/codex-auth.js';
test('Codex auth path follows explicit auth file, CODEX_HOME, then home directory', () => {
assert.equal(
codexAuthPath({
environment: { CODEX_AUTH_JSON: '/tmp/custom-auth.json' },
home: '/home/fixture',
}),
path.resolve('/tmp/custom-auth.json'),
);
assert.equal(
codexAuthPath({
environment: { CODEX_HOME: '/tmp/codex-home' },
home: '/home/fixture',
}),
path.join(path.resolve('/tmp/codex-home'), 'auth.json'),
);
assert.equal(
codexAuthPath({ environment: {}, home: '/home/fixture' }),
path.join('/home/fixture', '.codex', 'auth.json'),
);
});
test('Codex OAuth token reader returns only the access token and fails closed', () => {
assert.equal(
readCodexOAuthAccessToken({
authPath: '/unused',
readFile: () => JSON.stringify({ tokens: { access_token: ' oauth-fixture ' } }),
}),
'oauth-fixture',
);
for (const readFile of [
() => '{bad json',
() => JSON.stringify({ tokens: {} }),
() => {
throw new Error('missing');
},
]) {
assert.throws(
() => readCodexOAuthAccessToken({ authPath: '/unused', readFile }),
/ChatGPT sign-in is unavailable/,
);
}
});
test('Codex login resolves an installed executable before PATH and starts browser auth detached', async () => {
const checked = [];
assert.equal(
resolveCodexExecutable({
environment: {},
home: '/home/fixture',
access(candidate) {
checked.push(candidate);
if (candidate.endsWith('/.local/bin/codex')) return;
throw new Error('missing');
},
}),
path.join('/home/fixture', '.local', 'bin', 'codex'),
);
assert.equal(checked.length, 1);
const child = new EventEmitter();
let unref = 0;
child.unref = () => {
unref += 1;
};
const calls = [];
const pending = startCodexChatGptLogin({
executable: '/fixture/codex',
home: '/home/fixture',
environment: { PATH: '/fixture' },
spawnImpl(command, args, options) {
calls.push({ command, args, options });
queueMicrotask(() => child.emit('spawn'));
return child;
},
});
assert.deepEqual(await pending, { executable: '/fixture/codex' });
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');
});
+144
View File
@@ -1,4 +1,8 @@
import { createSurfaceKeyboard } from './ui/surfaceKeyboard.js';
import {
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './voice/cloudVoiceAuth.js';
/**
* The POWER UP surface — paste a key, get a power.
@@ -38,6 +42,46 @@ export function collectKeyUpdates(fields) {
return updates;
}
function sleepWithSignal(ms, signal) {
return new Promise((resolve, reject) => {
if (signal?.aborted) {
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
return;
}
const onAbort = () => {
clearTimeout(timer);
reject(signal.reason || new DOMException('Aborted', 'AbortError'));
};
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
signal?.addEventListener('abort', onAbort, { once: true });
});
}
export async function waitForChatGptOAuth({
fetchImpl,
signal,
timeoutMs = 120_000,
pollMs = 1_000,
now = () => Date.now(),
sleep = sleepWithSignal,
} = {}) {
const deadline = now() + Math.max(0, timeoutMs);
while (!signal?.aborted && now() <= deadline) {
const response = await fetchImpl('/api/realtime/oauth-status', {
cache: 'no-store',
signal,
});
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.available) return true;
if (now() >= deadline) break;
await sleep(Math.max(0, pollMs), signal);
}
return false;
}
/**
* After the FIRST Google key lands, the restart's reload should boot the
* photoreal default — not faithfully restore the auto-selected keyless OSM
@@ -144,6 +188,26 @@ function buildRow(documentRef, key) {
}
row.append(fields);
}
if (key.id === 'openai') {
const auth = documentRef.createElement('div');
auth.className = 'key-setup-cloud-auth';
const mode = readStoredCloudVoiceAuthMode();
const state = documentRef.createElement('span');
state.className = 'key-setup-cloud-auth-state';
state.textContent =
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
const toggle = documentRef.createElement('button');
toggle.type = 'button';
toggle.className = 'key-setup-cloud-auth-toggle';
toggle.dataset.cloudVoiceAuthToggle = 'true';
toggle.textContent = mode === 'oauth' ? 'USE API KEY' : 'USE CHATGPT OAUTH';
toggle.title =
mode === 'oauth'
? 'Use OPENAI_API_KEY for the next cloud voice session'
: 'Use the signed-in local ChatGPT/Codex OAuth session for the next cloud voice session';
auth.append(state, toggle);
row.append(auth);
}
return row;
}
@@ -363,6 +427,86 @@ export async function initKeySetup({
applyButton?.addEventListener('click', onApply);
// Remove buttons are rendered per row; delegate so re-renders stay wired.
rowsHost?.addEventListener('click', (event) => {
const authButton = event.target?.closest?.(
'[data-cloud-voice-auth-toggle]',
);
if (authButton && !disposed) {
void (async () => {
const current = readStoredCloudVoiceAuthMode();
if (current === 'oauth') {
writeStoredCloudVoiceAuthMode('api-key');
render(status);
say('Cloud voice will use OPENAI_API_KEY on the next session.');
return;
}
authButton.disabled = true;
say('Checking local ChatGPT OAuth sign-in…');
try {
const response = await doFetch('/api/realtime/oauth-status', {
cache: 'no-store',
signal: lifetime.signal,
});
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.available) {
writeStoredCloudVoiceAuthMode('oauth');
render(status);
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say('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 (!loginResponse.ok && loginResponse.status !== 202) {
say(
loginPayload.error ||
payload.error ||
'Could not start ChatGPT sign-in on this machine.',
);
return;
}
if (loginPayload.available) {
writeStoredCloudVoiceAuthMode('oauth');
render(status);
say(
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
);
return;
}
say(
'Finish ChatGPT sign-in in the browser. Waiting for it to complete…',
);
const available = await waitForChatGptOAuth({
fetchImpl: doFetch,
signal: lifetime.signal,
});
if (!available) {
say(
'ChatGPT sign-in is still pending. Finish it in the browser, then click USE CHATGPT OAUTH again.',
);
return;
}
writeStoredCloudVoiceAuthMode('oauth');
render(status);
say(
'ChatGPT sign-in complete. OAuth will be used for the next cloud voice session.',
);
} catch (error) {
if (lifetime.signal.aborted) return;
say(`OAuth check failed: ${error?.message || error}`);
} finally {
authButton.disabled = false;
}
})();
return;
}
const button = event.target?.closest?.('[data-key-setup-remove]');
if (disposed || !button || busy) return;
let envVars = [];
+23
View File
@@ -5,6 +5,7 @@ import {
keySetupChipLabel,
stripKeylessBasemapFromHash,
bindKeySetupPlacement,
waitForChatGptOAuth,
} from './keySetup.js';
test('setup placement moves the same button only in Cyber and disconnects on teardown', () => {
@@ -61,6 +62,28 @@ test('collectKeyUpdates keeps only non-empty trimmed values', () => {
assert.deepEqual(collectKeyUpdates(null), {});
});
test('OAuth login polling stops as soon as local ChatGPT auth becomes available', async () => {
let checks = 0;
let clock = 0;
const available = await waitForChatGptOAuth({
fetchImpl: async () => {
checks += 1;
return {
ok: true,
json: async () => ({ available: checks >= 3 }),
};
},
timeoutMs: 10_000,
pollMs: 100,
now: () => clock,
sleep: async (ms) => {
clock += ms;
},
});
assert.equal(available, true);
assert.equal(checks, 3);
});
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);
+1 -1
View File
@@ -52,7 +52,7 @@ export const KEY_SETUP_KEYS = Object.freeze([
Object.freeze({
id: 'openai',
title: 'OPENAI',
unlocks: 'Voice control — talk to the planet',
unlocks: 'Voice control — API key or ChatGPT OAuth',
getUrl: 'https://platform.openai.com/api-keys',
envVars: Object.freeze(['OPENAI_API_KEY']),
tier: 'metered',
+7
View File
@@ -224,6 +224,13 @@ test('doctor describes the credential ladder without exposing values', () => {
assert.match(capabilities.map, /Google Photorealistic 3D Tiles through Cesium ion/);
assert.match(capabilities.map, /Bing and world-terrain stacks/);
assert.equal(capabilities.voice, 'available');
assert.equal(
buildCapabilitySummary({
...credentials,
OPENAI_API_KEY: { configured: false },
}).voice,
'ChatGPT/Codex OAuth can be selected at runtime',
);
assert.match(capabilities.missions, /token allowance/);
assert.equal(capabilities.flights, 'OpenSky keyless anonymous access (rate-limited)');
+143 -1
View File
@@ -37,6 +37,7 @@ function request(
url = '/',
body = '',
origin = 'http://localhost:4173',
remoteAddress = '127.0.0.1',
} = {},
) {
return new Promise((resolve, reject) => {
@@ -49,7 +50,7 @@ function request(
origin,
'content-type': 'application/json',
},
socket: { remoteAddress: '127.0.0.1' },
socket: { remoteAddress },
});
const headers = {};
const res = {
@@ -195,6 +196,147 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
});
test('Realtime handler keeps API key as default and uses OAuth only when requested', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
let oauthReads = 0;
const authorizations = [];
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveApiKey: () => 'fixture-api-key',
resolveOAuthAccessToken: () => {
oauthReads += 1;
return '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');
assert.equal((await request(token)).status, 200);
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);
assert.equal(oauthReads, 1);
assert.equal(authorizations.at(-1), 'Bearer fixture-oauth-token');
});
test('OAuth status and token minting never expose local OAuth to remote clients', async () => {
let oauthReads = 0;
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
oauthReads += 1;
return 'must-not-be-read-remotely';
},
fetchImpl: () =>
assert.fail('remote OAuth request must not reach OpenAI'),
},
}),
);
const remote = { remoteAddress: '192.168.1.20' };
assert.equal(
(await request(routes.get('/api/realtime/oauth-status'), remote)).status,
403,
);
assert.equal(
(
await request(routes.get('/api/realtime/token'), {
...remote,
url: '/?auth=oauth',
})
).status,
403,
);
assert.equal(oauthReads, 0);
});
test('OAuth status reports local sign-in availability without returning the token', async () => {
const routes = install(
openAiRealtimeProxy({
realtime: { resolveOAuthAccessToken: () => 'fixture-oauth-secret' },
}),
);
const response = await request(routes.get('/api/realtime/oauth-status'));
assert.equal(response.status, 200);
assert.deepEqual(response.json(), { available: true });
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;
const routes = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => {
throw new Error('not signed in');
},
startOAuthLogin: async () => {
starts += 1;
},
now: () => clock,
},
}),
);
const handler = routes.get('/api/realtime/oauth-login');
const started = await request(handler, { method: 'POST' });
assert.equal(started.status, 202);
assert.deepEqual(started.json(), {
available: false,
started: true,
pending: true,
});
assert.equal(starts, 1);
clock += 1_000;
const duplicate = await request(handler, { method: 'POST' });
assert.equal(duplicate.status, 202);
assert.equal(duplicate.json().pending, true);
assert.equal(starts, 1, 'rapid retries must not spawn another login');
const remote = await request(handler, {
method: 'POST',
remoteAddress: '192.168.1.20',
});
assert.equal(remote.status, 403);
assert.equal(starts, 1);
const crossOrigin = await request(handler, {
method: 'POST',
origin: 'https://example.com',
});
assert.equal(crossOrigin.status, 403);
assert.equal(starts, 1);
});
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {
const handler = install(
openAiRealtimeProxy({
realtime: {
resolveOAuthAccessToken: () => 'fixture-existing-secret',
startOAuthLogin: () =>
assert.fail('existing auth must not launch login'),
},
}),
).get('/api/realtime/oauth-login');
const response = await request(handler, { method: 'POST' });
assert.equal(response.status, 200);
assert.deepEqual(response.json(), {
available: true,
started: false,
pending: false,
});
assert.equal(response.body.includes('fixture-existing-secret'), false);
});
test('debug logging resolves each supplied application directory independently', async (t) => {
const first = root(t),
second = root(t);
+37
View File
@@ -300,6 +300,43 @@
color: rgba(148, 178, 188, 0.42);
}
.key-setup-cloud-auth {
display: flex;
align-items: center;
gap: 0.5rem;
}
.key-setup-cloud-auth-state {
color: rgba(190, 214, 222, 0.7);
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.05rem;
}
.key-setup-cloud-auth-toggle {
margin-left: auto;
padding: 0.28rem 0.55rem;
color: rgba(54, 220, 255, 0.9);
font-family: var(--font-mono);
font-size: 0.55rem;
letter-spacing: 0.06rem;
background: rgba(54, 220, 255, 0.08);
border: 1px solid rgba(54, 220, 255, 0.28);
border-radius: 0.32rem;
cursor: pointer;
}
.key-setup-cloud-auth-toggle:hover,
.key-setup-cloud-auth-toggle:focus-visible {
border-color: rgba(54, 220, 255, 0.65);
outline: none;
}
.key-setup-cloud-auth-toggle:disabled {
cursor: wait;
opacity: 0.5;
}
.key-setup-footer {
margin-top: 0.85rem;
}
+41
View File
@@ -0,0 +1,41 @@
export const CLOUD_VOICE_AUTH_MODES = Object.freeze(['api-key', 'oauth']);
export const DEFAULT_CLOUD_VOICE_AUTH_MODE = 'api-key';
export const CLOUD_VOICE_AUTH_STORAGE_KEY = 'godsEyeView.voice.cloudAuth';
function storageRef(storage) {
if (storage) return storage;
try {
return typeof localStorage === 'undefined' ? null : localStorage;
} catch {
return null;
}
}
export function normalizeCloudVoiceAuthMode(mode) {
const normalized = String(mode || '')
.trim()
.toLowerCase();
return CLOUD_VOICE_AUTH_MODES.includes(normalized)
? normalized
: DEFAULT_CLOUD_VOICE_AUTH_MODE;
}
export function readStoredCloudVoiceAuthMode(storage) {
try {
return normalizeCloudVoiceAuthMode(
storageRef(storage)?.getItem(CLOUD_VOICE_AUTH_STORAGE_KEY),
);
} catch {
return DEFAULT_CLOUD_VOICE_AUTH_MODE;
}
}
export function writeStoredCloudVoiceAuthMode(mode, storage) {
const resolved = normalizeCloudVoiceAuthMode(mode);
try {
storageRef(storage)?.setItem(CLOUD_VOICE_AUTH_STORAGE_KEY, resolved);
} catch {
/* best effort */
}
return resolved;
}
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
CLOUD_VOICE_AUTH_STORAGE_KEY,
readStoredCloudVoiceAuthMode,
writeStoredCloudVoiceAuthMode,
} from './cloudVoiceAuth.js';
function memoryStorage(seed = {}) {
const values = new Map(Object.entries(seed));
return {
getItem: (key) => values.get(key) ?? null,
setItem: (key, value) => values.set(key, value),
};
}
test('cloud voice auth defaults to API key and persists OAuth independently', () => {
const storage = memoryStorage();
assert.equal(readStoredCloudVoiceAuthMode(storage), 'api-key');
assert.equal(writeStoredCloudVoiceAuthMode('oauth', storage), 'oauth');
assert.equal(readStoredCloudVoiceAuthMode(storage), 'oauth');
assert.equal(writeStoredCloudVoiceAuthMode('unknown', storage), 'api-key');
assert.equal(
readStoredCloudVoiceAuthMode(
memoryStorage({ [CLOUD_VOICE_AUTH_STORAGE_KEY]: 'oauth' }),
),
'oauth',
);
});
+14
View File
@@ -3194,6 +3194,20 @@ 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', () => {
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);
const after = controller.costTracker.state();
assert.equal(after.totalUsd, before.totalUsd);
assert.equal(controller.costCapStopped, false);
});
test('F1: when idle, toggling does re-price the preview meter', () => {
const { controller } = costControllerHarness();
controller.status = 'idle';
+10 -2
View File
@@ -15,11 +15,19 @@ export function createRealtimeBackend({
);
return Object.freeze({
protocol: 'openai-realtime',
async requestToken({ tier = DEFAULT_VOICE_TIER, signal } = {}) {
async requestToken({
tier = DEFAULT_VOICE_TIER,
authMode = 'api-key',
signal,
} = {}) {
signal = scoped(signal);
signal.throwIfAborted();
const separator = tokenEndpoint.includes('?') ? '&' : '?';
const url = `${tokenEndpoint}${separator}tier=${encodeURIComponent(resolveVoiceModel(tier).tier)}`;
const query = new URLSearchParams({
tier: resolveVoiceModel(tier).tier,
});
if (authMode === 'oauth') query.set('auth', 'oauth');
const url = `${tokenEndpoint}${separator}${query}`;
const response = await tokenTransport(url, {
signal,
cache: 'no-store',
+16
View File
@@ -41,6 +41,22 @@ test('separate configured token and SDP transports preserve model metadata and c
]);
});
test('OAuth is an additive token query while API key stays the default', async () => {
const calls = [];
const backend = createRealtimeBackend({
tokenTransport: async (input) => {
calls.push(input);
return tokenReply();
},
});
await backend.requestToken();
await backend.requestToken({ authMode: 'oauth' });
assert.deepEqual(calls, [
'/api/realtime/token?tier=standard',
'/api/realtime/token?tier=standard&auth=oauth',
]);
});
test('denied tokens never negotiate or retry; reconnect requests a fresh token', async () => {
let requests = 0;
const backend = createRealtimeBackend({
+2
View File
@@ -82,6 +82,7 @@ export class RealtimeConnection {
this.debugLog('session.starting', {
epoch,
tier: this.cost.voiceTier,
authMode: this.cost.sessionCloudVoiceAuth,
connection: this.connectionDiagnostics(),
});
let localStream = null;
@@ -89,6 +90,7 @@ export class RealtimeConnection {
try {
const minted = await this.backend.requestToken({
tier: this.cost.voiceTier,
authMode: this.cost.sessionCloudVoiceAuth,
signal,
});
const token = minted.token;
+8
View File
@@ -4,6 +4,7 @@ import {
writeStoredVoiceTier,
writeStoredVoiceLimits,
} from './realtimePreferences.js';
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
import {
createVoiceCostTracker,
resolveVoiceModel,
@@ -16,6 +17,8 @@ export class RealtimeCost {
Object.assign(this, { readUi, readStatus }, operations);
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = null;
this.costTracker = createVoiceCostTracker({
tier: this.voiceTier,
limits: this.voiceLimits,
@@ -56,6 +59,8 @@ export class RealtimeCost {
}; applies next session`;
}
if (this.ui?.costValue) {
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
this.ui.costValue.hidden = liveAuth === 'oauth';
this.ui.costValue.textContent = state.display;
this.ui.costValue.dataset.level = state.level;
this.ui.costValue.title =
@@ -136,6 +141,7 @@ export class RealtimeCost {
*/
recordUsage(usage) {
if (!usage) return null;
if (this.sessionCloudVoiceAuth === 'oauth') return null;
const state = this.costTracker.record(usage);
this.syncCostUi();
if (state.warnCrossed) {
@@ -190,6 +196,8 @@ export class RealtimeCost {
prepareSession() {
this.voiceTier = readStoredVoiceTier();
this.voiceLimits = readStoredVoiceLimits();
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
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