mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
fix(voice): track OAuth login lifecycle and storage readiness
This commit is contained in:
1 parent
6c88b78140
commit
8f5710e7cb
10 files changed
+906
-106
No files matched your search
@@ -237,6 +237,12 @@ _Why cockpit mode exists: you're riding a real aircraft over real terrain — an
|
|||||||
|
|
||||||
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
> Voice can use an **OpenAI API key** or, on the same machine, an opt-in signed-in **ChatGPT/Codex OAuth** session. The API-key path remains the default. Selecting ChatGPT OAuth in Provider Settings reuses an existing local sign-in or starts the Codex browser sign-in flow and selects OAuth when it completes. The OpenAI key still drives the **AI HUD summary**: a terse, five-word intelligence-style readout of the current view that regenerates as you move.
|
||||||
|
|
||||||
|
The OAuth option is experimental. OpenAI documents ChatGPT plan usage for the [Responses API](https://developers.openai.com/siwc/token-sharing-open-source/models-and-inference); a supported third-party Realtime contract for reusing Codex tokens has not been confirmed. A successful token mint does not establish billing or support terms.
|
||||||
|
|
||||||
|
GEV reads Codex's file credentials but does not write or refresh them. It checks token shape and expiry, and reports expired sign-in as requiring re-authentication. The login button runs `codex login`, permits one pending attempt, and reports exit failures or a two-minute timeout. `CODEX_HOME` must match the directory that Codex uses. If `CODEX_AUTH_JSON` points elsewhere, sign in separately or fix that mismatch before using the button. Codex credential storage must use `auth.json`; Keychain-only and in-memory storage are not readable by GEV. See [Codex credential storage](https://learn.chatgpt.com/docs/auth#credential-storage).
|
||||||
|
|
||||||
|
For a live voice check, run `node scripts/qa-voice-auth.mjs http://127.0.0.1:4189 oauth microphone.wav` against your local dev server. Use a WAV with three seconds of leading silence followed by a short spoken reply request. Replace `oauth` with `api-key` to check the API-key path. The check verifies audio input, a completed spoken reply, usage, resource cleanup, and reconnect. It saves screenshots and credential-free results under `.gev-logs/voice-auth/`.
|
||||||
|
|
||||||
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
Click **GEV MIC**, grant the microphone, and just talk. This is more than a voice-controlled remote:
|
||||||
|
|
||||||
- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask _"what city is this?"_ mid-flight and it knows.
|
- **🧠 It knows what it's looking at.** The agent pulls live scene context before answering — including coordinates, street names, active layers, and view scale. Ask _"what city is this?"_ mid-flight and it knows.
|
||||||
|
|||||||
@@ -3924,6 +3924,8 @@ and unreachable upstream (502/504) separately from road geometry.
|
|||||||
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
3. **Every count names its scope in words** — "42 in your window", "8 in view", "about 30 within 250 km of Austin" — never a bare number. `analyst_query` returns `scopeLabel` so this is mechanical. Two different numbers with named scopes are not a contradiction.
|
||||||
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
4. **The loaded-data caveat is stated once when relevant**: counts cover loaded data, and the flights layer loads where you look (appended to `coverage.note` for radius/view scopes over viewport-loaded layers).
|
||||||
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
|
- **Degradation**: API-key mode without `OPENAI_API_KEY` returns 503 as before. Selecting OAuth without a usable local ChatGPT/Codex sign-in starts the local `codex login` browser flow, polls for the resulting local token, and selects OAuth when sign-in completes. If the Codex login flow cannot start or does not complete, the rest of the app is unaffected.
|
||||||
|
- **Login lifecycle and storage**: one pending Codex login is shared by the login and status routes. Process failure, success without readable credentials, and a two-minute timeout each report a clear failure; timeout and server shutdown cancel the owned process. The launcher resolves `CODEX_HOME` before changing directories and refuses a mismatched `CODEX_AUTH_JSON`. GEV reads file credentials only, never writes or refreshes them, and does not require the vendor refresh token. Expired access tokens report `CODEX_OAUTH_REAUTH_REQUIRED`. OAuth status and login routes also use the same-site request gate.
|
||||||
|
- **Support boundary**: the local OAuth option is experimental. OpenAI's documented Sign in with ChatGPT plan-sharing inference endpoint is `/v1/responses`; support for third-party reuse of Codex credentials at `/v1/realtime/client_secrets` remains unconfirmed. Technical success does not establish a supported auth or billing contract.
|
||||||
|
|
||||||
### AI HUD Summary (June 2026)
|
### AI HUD Summary (June 2026)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Live Realtime auth acceptance with a prerecorded Chromium microphone.
|
||||||
|
* Run against the local dev server after configuring the selected credential:
|
||||||
|
* node scripts/qa-voice-auth.mjs http://127.0.0.1:4189 oauth microphone.wav
|
||||||
|
* Use a short spoken reply request with three seconds of leading silence.
|
||||||
|
* Saves only usage, transport metadata, and screenshots; never credentials.
|
||||||
|
*/
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import puppeteer from 'puppeteer';
|
||||||
|
|
||||||
|
const root = fileURLToPath(new URL('../', import.meta.url));
|
||||||
|
const url = new URL(process.argv[2] || 'http://127.0.0.1:4189');
|
||||||
|
const auth = process.argv[3] || 'oauth';
|
||||||
|
assert.ok(['oauth', 'api-key'].includes(auth), 'Choose oauth or api-key');
|
||||||
|
assert.ok(
|
||||||
|
['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname),
|
||||||
|
'Use a local server',
|
||||||
|
);
|
||||||
|
assert.ok(process.argv[4], 'Provide a prerecorded microphone WAV');
|
||||||
|
const wav = path.resolve(process.argv[4]);
|
||||||
|
assert.ok(fs.existsSync(wav), 'Microphone WAV must exist');
|
||||||
|
url.searchParams.set('welcome', '0');
|
||||||
|
url.searchParams.set('setup', '1');
|
||||||
|
const output = path.join(root, '.gev-logs', 'voice-auth');
|
||||||
|
fs.mkdirSync(output, { recursive: true });
|
||||||
|
const executablePath =
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH ||
|
||||||
|
(fs.existsSync('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome')
|
||||||
|
? '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'
|
||||||
|
: puppeteer.executablePath());
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: true,
|
||||||
|
executablePath,
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--use-fake-device-for-media-stream',
|
||||||
|
`--use-file-for-fake-audio-capture=${wav}%noloop`,
|
||||||
|
'--autoplay-policy=no-user-gesture-required',
|
||||||
|
'--disable-background-timer-throttling',
|
||||||
|
'--window-size=1440,900',
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const result = {
|
||||||
|
ok: false,
|
||||||
|
auth,
|
||||||
|
input: 'prerecorded microphone',
|
||||||
|
mints: [],
|
||||||
|
negotiations: [],
|
||||||
|
};
|
||||||
|
let page;
|
||||||
|
const readSession = () =>
|
||||||
|
page.evaluate(async () => {
|
||||||
|
const voice = window.__godsEyeView.voiceCommands;
|
||||||
|
const stats = voice.pc ? [...(await voice.pc.getStats()).values()] : [];
|
||||||
|
const input = stats.find(
|
||||||
|
(entry) => entry.type === 'outbound-rtp' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const source = stats.find(
|
||||||
|
(entry) => entry.type === 'media-source' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const audio = stats.find(
|
||||||
|
(entry) => entry.type === 'inbound-rtp' && entry.kind === 'audio',
|
||||||
|
);
|
||||||
|
const meter = document.getElementById('gev-voice-cost-value');
|
||||||
|
return {
|
||||||
|
...window.__voiceAuthEvidence,
|
||||||
|
connection: voice.pc?.connectionState,
|
||||||
|
microphoneEnabled: voice.stream
|
||||||
|
?.getAudioTracks()
|
||||||
|
.every((track) => track.enabled),
|
||||||
|
inputBytes: input?.bytesSent || 0,
|
||||||
|
inputAudioEnergy: source?.totalAudioEnergy ?? null,
|
||||||
|
outputBytes: audio?.bytesReceived || 0,
|
||||||
|
outputAudioEnergy: audio?.totalAudioEnergy || 0,
|
||||||
|
playbackTime: voice.audioEl?.currentTime || 0,
|
||||||
|
meter: {
|
||||||
|
text: meter?.textContent,
|
||||||
|
usage: meter?.title,
|
||||||
|
visible: Boolean(meter?.getClientRects().length),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await browser
|
||||||
|
.defaultBrowserContext()
|
||||||
|
.overridePermissions(url.origin, ['microphone']);
|
||||||
|
page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1440, height: 900 });
|
||||||
|
page.on('response', (response) => {
|
||||||
|
const route = new URL(response.url()).pathname;
|
||||||
|
if (route === '/api/realtime/token')
|
||||||
|
result.mints.push({
|
||||||
|
status: response.status(),
|
||||||
|
auth: response.headers()['x-gev-voice-auth'],
|
||||||
|
model: response.headers()['x-gev-voice-model'],
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
route === '/v1/realtime/calls' &&
|
||||||
|
response.request().method() === 'POST'
|
||||||
|
)
|
||||||
|
result.negotiations.push({ status: response.status() });
|
||||||
|
});
|
||||||
|
// Observe the application's real transport. Do not replace fetch or replies.
|
||||||
|
await page.evaluateOnNewDocument(() => {
|
||||||
|
window.__voiceAuthEvidence = {
|
||||||
|
speechStopped: 0,
|
||||||
|
transcripts: [],
|
||||||
|
replies: [],
|
||||||
|
responses: [],
|
||||||
|
errors: [],
|
||||||
|
eventCounts: {},
|
||||||
|
};
|
||||||
|
const create = RTCPeerConnection.prototype.createDataChannel;
|
||||||
|
RTCPeerConnection.prototype.createDataChannel = function (...args) {
|
||||||
|
const channel = create.apply(this, args);
|
||||||
|
channel.addEventListener('message', ({ data }) => {
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(data);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const evidence = window.__voiceAuthEvidence;
|
||||||
|
evidence.eventCounts[event.type] =
|
||||||
|
(evidence.eventCounts[event.type] || 0) + 1;
|
||||||
|
if (event.type === 'input_audio_buffer.speech_stopped')
|
||||||
|
evidence.speechStopped += 1;
|
||||||
|
if (
|
||||||
|
event.type === 'conversation.item.input_audio_transcription.completed'
|
||||||
|
)
|
||||||
|
evidence.transcripts.push(event.transcript);
|
||||||
|
if (event.type === 'response.output_audio_transcript.done')
|
||||||
|
evidence.replies.push(event.transcript);
|
||||||
|
if (event.type === 'response.done')
|
||||||
|
evidence.responses.push({
|
||||||
|
status: event.response?.status,
|
||||||
|
inputTokens: event.response?.usage?.input_tokens,
|
||||||
|
outputTokens: event.response?.usage?.output_tokens,
|
||||||
|
});
|
||||||
|
if (event.type === 'error')
|
||||||
|
evidence.errors.push({ code: event.error?.code });
|
||||||
|
});
|
||||||
|
return channel;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 30_000 });
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView?.voiceCommands &&
|
||||||
|
document.getElementById('loading-screen')?.classList.contains('hidden'),
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
);
|
||||||
|
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
|
||||||
|
);
|
||||||
|
const selected = await page.evaluate(
|
||||||
|
() => localStorage.getItem('godsEyeView.voice.cloudAuth') || 'api-key',
|
||||||
|
);
|
||||||
|
if (selected !== auth) {
|
||||||
|
await page.click('[data-cloud-voice-auth-toggle]');
|
||||||
|
await page.waitForFunction(
|
||||||
|
(mode) => localStorage.getItem('godsEyeView.voice.cloudAuth') === mode,
|
||||||
|
{ timeout: 125_000 },
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await page.click('[data-key-setup-close]');
|
||||||
|
await page.click('#gev-voice-button');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
|
||||||
|
window.__godsEyeView.voiceCommands.status === 'error',
|
||||||
|
{ timeout: 35_000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.voiceCommands.dc?.readyState,
|
||||||
|
),
|
||||||
|
'open',
|
||||||
|
'Realtime channel must open',
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__voiceAuthEvidence.speechStopped > 0 &&
|
||||||
|
window.__voiceAuthEvidence.responses.some(
|
||||||
|
(response) =>
|
||||||
|
response.status === 'completed' && response.outputTokens > 0,
|
||||||
|
),
|
||||||
|
{ timeout: 45_000 },
|
||||||
|
);
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__voiceAuthEvidence.eventCounts['output_audio_buffer.stopped'] > 0,
|
||||||
|
{ timeout: 30_000 },
|
||||||
|
);
|
||||||
|
result.session = await readSession();
|
||||||
|
assert.ok(
|
||||||
|
result.session.inputBytes > 0 && result.session.outputBytes > 0,
|
||||||
|
'Audio must flow in both directions',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.session.outputAudioEnergy > 0 && result.session.playbackTime > 0,
|
||||||
|
'Reply audio must play',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.session.meter.text === 'COST UNKNOWN',
|
||||||
|
auth === 'oauth',
|
||||||
|
'Use the correct accounting display',
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, `${auth}-usage.png`) });
|
||||||
|
await page.click('#key-setup-chip');
|
||||||
|
await page.waitForSelector('[data-cloud-voice-auth-toggle]', {
|
||||||
|
visible: true,
|
||||||
|
});
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
getComputedStyle(document.getElementById('key-setup')).opacity === '1',
|
||||||
|
);
|
||||||
|
await page.screenshot({ path: path.join(output, `${auth}-settings.png`) });
|
||||||
|
await page.click('[data-key-setup-close]');
|
||||||
|
result.stopped = await page.evaluate(() => {
|
||||||
|
const voice = window.__godsEyeView.voiceCommands;
|
||||||
|
const pc = voice.pc;
|
||||||
|
const tracks = voice.stream?.getTracks() || [];
|
||||||
|
voice.session.stop();
|
||||||
|
return (
|
||||||
|
pc.connectionState === 'closed' &&
|
||||||
|
tracks.every((track) => track.readyState === 'ended')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
assert.ok(result.stopped, 'Stop must close the peer and microphone tracks');
|
||||||
|
await page.click('#gev-voice-button');
|
||||||
|
await page.waitForFunction(
|
||||||
|
() =>
|
||||||
|
window.__godsEyeView.voiceCommands.dc?.readyState === 'open' ||
|
||||||
|
window.__godsEyeView.voiceCommands.status === 'error',
|
||||||
|
{ timeout: 35_000 },
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await page.evaluate(
|
||||||
|
() => window.__godsEyeView.voiceCommands.dc?.readyState,
|
||||||
|
),
|
||||||
|
'open',
|
||||||
|
'Reconnect must work',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
result.mints.length,
|
||||||
|
2,
|
||||||
|
'Reconnect must request a fresh client secret',
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
result.mints.every(
|
||||||
|
(mint) =>
|
||||||
|
mint.status === 200 &&
|
||||||
|
mint.auth === (auth === 'oauth' ? 'codex-oauth' : 'api-key'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert.ok(result.negotiations.every((entry) => entry.status === 201));
|
||||||
|
result.ok = true;
|
||||||
|
} catch (error) {
|
||||||
|
result.error = error.message;
|
||||||
|
if (page) {
|
||||||
|
result.session = await readSession().catch(() => null);
|
||||||
|
result.detail = await page
|
||||||
|
.$eval('#gev-voice-detail', (element) => element.textContent)
|
||||||
|
.catch(() => null);
|
||||||
|
await page
|
||||||
|
.screenshot({ path: path.join(output, `${auth}-failure.png`) })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await page
|
||||||
|
?.evaluate(() => window.__godsEyeView?.voiceCommands?.session?.stop())
|
||||||
|
.catch(() => {});
|
||||||
|
await browser.close();
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(output, `${auth}-result.json`),
|
||||||
|
`${JSON.stringify(result, null, 2)}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
console.log(JSON.stringify(result, null, 2));
|
||||||
|
if (!result.ok) process.exitCode = 1;
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
createRealtimeTokenHandler,
|
createRealtimeTokenHandler,
|
||||||
} from './openai/realtime.js';
|
} from './openai/realtime.js';
|
||||||
import { sameSiteGated } from './common/same-site.js';
|
import { sameSiteGated } from './common/same-site.js';
|
||||||
|
import { createCodexOAuthLogin } from './openai/codex-auth.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
* Vite plugin: OpenAI Realtime ephemeral client secret.
|
||||||
@@ -19,6 +20,7 @@ function openAiRealtimeProxy({
|
|||||||
annotationGuidance,
|
annotationGuidance,
|
||||||
realtime = {},
|
realtime = {},
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const oauthLogin = createCodexOAuthLogin(realtime);
|
||||||
function install(middlewares) {
|
function install(middlewares) {
|
||||||
// Cost-bearing and log endpoints refuse cross-site browser requests
|
// Cost-bearing and log endpoints refuse cross-site browser requests
|
||||||
// (see server/providers/common/same-site.js and SECURITY.md).
|
// (see server/providers/common/same-site.js and SECURITY.md).
|
||||||
@@ -38,12 +40,12 @@ function openAiRealtimeProxy({
|
|||||||
|
|
||||||
middlewares.use(
|
middlewares.use(
|
||||||
'/api/realtime/oauth-status',
|
'/api/realtime/oauth-status',
|
||||||
createRealtimeOAuthStatusHandler(realtime),
|
sameSiteGated(createRealtimeOAuthStatusHandler({ oauthLogin })),
|
||||||
);
|
);
|
||||||
|
|
||||||
middlewares.use(
|
middlewares.use(
|
||||||
'/api/realtime/oauth-login',
|
'/api/realtime/oauth-login',
|
||||||
createRealtimeOAuthLoginHandler(realtime),
|
sameSiteGated(createRealtimeOAuthLoginHandler({ oauthLogin })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,10 +53,13 @@ function openAiRealtimeProxy({
|
|||||||
name: 'openai-realtime-proxy',
|
name: 'openai-realtime-proxy',
|
||||||
configureServer(server) {
|
configureServer(server) {
|
||||||
install(server.middlewares);
|
install(server.middlewares);
|
||||||
|
server.httpServer?.once('close', oauthLogin.dispose);
|
||||||
},
|
},
|
||||||
configurePreviewServer(server) {
|
configurePreviewServer(server) {
|
||||||
install(server.middlewares);
|
install(server.middlewares);
|
||||||
|
server.httpServer?.once('close', oauthLogin.dispose);
|
||||||
},
|
},
|
||||||
|
closeBundle: oauthLogin.dispose,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
export function readCodexOAuthAccessToken({
|
||||||
authPath = codexAuthPath(),
|
authPath = codexAuthPath(),
|
||||||
readFile = readFileSync,
|
readFile = readFileSync,
|
||||||
@@ -46,10 +52,10 @@ export function readCodexOAuthAccessToken({
|
|||||||
try {
|
try {
|
||||||
parsed = JSON.parse(readFile(authPath, 'utf8'));
|
parsed = JSON.parse(readFile(authPath, 'utf8'));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
const mode = String(parsed.auth_mode || '')
|
const mode = String(parsed.auth_mode || '')
|
||||||
.trim()
|
.trim()
|
||||||
@@ -58,15 +64,8 @@ export function readCodexOAuthAccessToken({
|
|||||||
? mode === 'chatgpt' || mode === 'chatgptauthtokens'
|
? mode === 'chatgpt' || mode === 'chatgptauthtokens'
|
||||||
: typeof parsed.OPENAI_API_KEY !== 'string';
|
: typeof parsed.OPENAI_API_KEY !== 'string';
|
||||||
const token = parsed.tokens?.access_token;
|
const token = parsed.tokens?.access_token;
|
||||||
const refresh = parsed.tokens?.refresh_token;
|
if (!chatGptMode || typeof token !== 'string' || !token.trim()) {
|
||||||
if (
|
throw codexOAuthError(unavailable);
|
||||||
!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
|
// This only checks token shape and expiry. OpenAI verifies the signature when
|
||||||
@@ -76,33 +75,34 @@ export function readCodexOAuthAccessToken({
|
|||||||
parts.length !== 3 ||
|
parts.length !== 3 ||
|
||||||
parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))
|
parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))
|
||||||
) {
|
) {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
let payload;
|
let payload;
|
||||||
try {
|
try {
|
||||||
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
if (!payload || typeof payload !== 'object' || Array.isArray(payload)) {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
let expiresAtMs;
|
let expiresAtMs;
|
||||||
if (Object.hasOwn(payload, 'exp')) {
|
if (Object.hasOwn(payload, 'exp')) {
|
||||||
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
|
if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
expiresAtMs = payload.exp * 1000;
|
expiresAtMs = payload.exp * 1000;
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000;
|
expiresAtMs = statFile(authPath).mtimeMs + 60 * 60 * 1000;
|
||||||
} catch {
|
} catch {
|
||||||
throw new Error(unavailable);
|
throw codexOAuthError(unavailable);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) {
|
if (!Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs + 60_000) {
|
||||||
throw new Error(
|
throw codexOAuthError(
|
||||||
'ChatGPT sign-in has expired. Run codex login and try again.',
|
'ChatGPT sign-in has expired. Re-authenticate in Provider Settings or run codex login and try again.',
|
||||||
|
'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
@@ -144,28 +144,202 @@ export function startCodexChatGptLogin({
|
|||||||
home = os.homedir(),
|
home = os.homedir(),
|
||||||
executable = resolveCodexExecutable({ environment, home }),
|
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) => {
|
return new Promise((resolve, reject) => {
|
||||||
let child;
|
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 {
|
try {
|
||||||
child = spawnImpl(executable, ['login'], {
|
child = spawnImpl(executable, ['login'], {
|
||||||
cwd: home,
|
cwd: home,
|
||||||
detached: true,
|
detached: true,
|
||||||
stdio: 'ignore',
|
stdio: 'ignore',
|
||||||
env: environment,
|
env: { ...environment, CODEX_HOME: loginHome },
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch {
|
||||||
reject(error);
|
fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
child.once('error', reject);
|
child.once('error', () => {
|
||||||
|
if (!spawned) fail();
|
||||||
|
else finish({ exitCode: null });
|
||||||
|
});
|
||||||
|
child.once('exit', (exitCode) => finish({ exitCode }));
|
||||||
child.once('spawn', () => {
|
child.once('spawn', () => {
|
||||||
|
spawned = true;
|
||||||
child.unref?.();
|
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();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@ import { GEV_REALTIME_TOOLS } from './tools.js';
|
|||||||
import {
|
import {
|
||||||
preferCodexOAuth,
|
preferCodexOAuth,
|
||||||
readCodexOAuthAccessToken,
|
readCodexOAuthAccessToken,
|
||||||
startCodexChatGptLogin,
|
createCodexOAuthLogin,
|
||||||
} from './codex-auth.js';
|
} from './codex-auth.js';
|
||||||
|
|
||||||
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
const LOOPBACK_ADDRESSES = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
||||||
@@ -53,7 +53,7 @@ function resolveRealtimeAuthMode(query) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createRealtimeOAuthStatusHandler({
|
function createRealtimeOAuthStatusHandler({
|
||||||
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
|
oauthLogin = createCodexOAuthLogin(),
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return (req, res) => {
|
return (req, res) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
@@ -73,30 +73,14 @@ function createRealtimeOAuthStatusHandler({
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
res.statusCode = 200;
|
||||||
const token = resolveOAuthAccessToken();
|
res.end(JSON.stringify(oauthLogin.status()));
|
||||||
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',
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createRealtimeOAuthLoginHandler({
|
function createRealtimeOAuthLoginHandler({
|
||||||
resolveOAuthAccessToken = () => readCodexOAuthAccessToken(),
|
oauthLogin = createCodexOAuthLogin(),
|
||||||
startOAuthLogin = () => startCodexChatGptLogin(),
|
|
||||||
now = () => Date.now(),
|
|
||||||
restartCooldownMs = 30_000,
|
|
||||||
} = {}) {
|
} = {}) {
|
||||||
let lastStartedAt = 0;
|
|
||||||
return async (req, res) => {
|
return async (req, res) => {
|
||||||
res.setHeader('Cache-Control', 'no-store');
|
res.setHeader('Cache-Control', 'no-store');
|
||||||
res.setHeader('Content-Type', 'application/json');
|
res.setHeader('Content-Type', 'application/json');
|
||||||
@@ -127,42 +111,15 @@ function createRealtimeOAuthLoginHandler({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (resolveOAuthAccessToken()) {
|
const result = await oauthLogin.start();
|
||||||
res.statusCode = 200;
|
res.statusCode = result.available ? 200 : result.loginFailed ? 503 : 202;
|
||||||
res.end(
|
res.end(JSON.stringify(result));
|
||||||
JSON.stringify({ available: true, started: false, pending: false }),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} catch {
|
} 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.statusCode = 503;
|
||||||
res.end(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
available: false,
|
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(
|
res.end(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
error: error?.message || 'ChatGPT sign-in is unavailable',
|
error: error?.message || 'ChatGPT sign-in is unavailable',
|
||||||
code: 'CODEX_OAUTH_UNAVAILABLE',
|
code: error?.code || 'CODEX_OAUTH_UNAVAILABLE',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -61,6 +61,19 @@ test('Codex OAuth reader accepts a current ChatGPT token and rejects unusable cr
|
|||||||
}),
|
}),
|
||||||
current,
|
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 [
|
for (const readFile of [
|
||||||
() => '{bad json',
|
() => '{bad json',
|
||||||
() => JSON.stringify({ tokens: {} }),
|
() => 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',
|
OPENAI_API_KEY: 'sk-fixture',
|
||||||
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
tokens: { access_token: current, refresh_token: 'refresh-fixture' },
|
||||||
}),
|
}),
|
||||||
() =>
|
|
||||||
JSON.stringify({
|
|
||||||
auth_mode: 'chatgpt',
|
|
||||||
tokens: { access_token: current },
|
|
||||||
}),
|
|
||||||
() => auth('opaque-token'),
|
() => auth('opaque-token'),
|
||||||
() => auth('header.not-json.signature'),
|
() => auth('header.not-json.signature'),
|
||||||
() => auth(jwt({ exp: 'tomorrow' })),
|
() => 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', () => {
|
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]) {
|
for (const exp of [nowMs / 1000 - 1, nowMs / 1000 + 59]) {
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() => {
|
||||||
readCodexOAuthAccessToken({
|
readCodexOAuthAccessToken({
|
||||||
authPath: '/unused',
|
authPath: '/unused',
|
||||||
nowMs,
|
nowMs,
|
||||||
readFile: () => auth(jwt({ exp })),
|
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' });
|
const noExp = jwt({ sub: 'fixture' });
|
||||||
@@ -162,10 +175,88 @@ test('Codex login resolves an installed executable before PATH and starts browse
|
|||||||
return child;
|
return child;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.deepEqual(await pending, { executable: '/fixture/codex' });
|
const login = await pending;
|
||||||
assert.equal(unref, 1);
|
assert.equal(unref, 1);
|
||||||
assert.equal(calls[0].command, '/fixture/codex');
|
assert.equal(calls[0].command, '/fixture/codex');
|
||||||
assert.deepEqual(calls[0].args, ['login']);
|
assert.deepEqual(calls[0].args, ['login']);
|
||||||
assert.equal(calls[0].options.detached, true);
|
assert.equal(calls[0].options.detached, true);
|
||||||
assert.equal(calls[0].options.stdio, 'ignore');
|
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
@@ -75,7 +75,13 @@ export async function waitForChatGptOAuth({
|
|||||||
signal,
|
signal,
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
const payload = await response.json().catch(() => ({}));
|
||||||
|
if (signal?.aborted) return false;
|
||||||
if (response.ok && payload.available) return true;
|
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;
|
if (now() >= deadline) break;
|
||||||
await sleep(Math.max(0, pollMs), signal);
|
await sleep(Math.max(0, pollMs), signal);
|
||||||
}
|
}
|
||||||
@@ -288,6 +294,7 @@ export async function initKeySetup({
|
|||||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
const statusLine = root.querySelector('[data-key-setup-status]');
|
||||||
const defaultStatusText = statusLine?.textContent || '';
|
const defaultStatusText = statusLine?.textContent || '';
|
||||||
let busy = false;
|
let busy = false;
|
||||||
|
let oauthBusy = false;
|
||||||
let open = false;
|
let open = false;
|
||||||
|
|
||||||
const render = (nextStatus) => {
|
const render = (nextStatus) => {
|
||||||
@@ -303,6 +310,10 @@ export async function initKeySetup({
|
|||||||
rowsHost.textContent = '';
|
rowsHost.textContent = '';
|
||||||
for (const key of status.keys || [])
|
for (const key of status.keys || [])
|
||||||
rowsHost.append(buildRow(documentRef, key));
|
rowsHost.append(buildRow(documentRef, key));
|
||||||
|
for (const button of rowsHost.querySelectorAll(
|
||||||
|
'[data-cloud-voice-auth-toggle]',
|
||||||
|
))
|
||||||
|
button.disabled = oauthBusy;
|
||||||
};
|
};
|
||||||
|
|
||||||
const visible = () =>
|
const visible = () =>
|
||||||
@@ -435,6 +446,7 @@ export async function initKeySetup({
|
|||||||
'[data-cloud-voice-auth-toggle]',
|
'[data-cloud-voice-auth-toggle]',
|
||||||
);
|
);
|
||||||
if (authButton && !disposed) {
|
if (authButton && !disposed) {
|
||||||
|
if (oauthBusy) return;
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const current = readStoredCloudVoiceAuthMode();
|
const current = readStoredCloudVoiceAuthMode();
|
||||||
if (current === 'oauth') {
|
if (current === 'oauth') {
|
||||||
@@ -444,6 +456,7 @@ export async function initKeySetup({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
authButton.disabled = true;
|
authButton.disabled = true;
|
||||||
|
oauthBusy = true;
|
||||||
say('Checking local ChatGPT OAuth sign-in…');
|
say('Checking local ChatGPT OAuth sign-in…');
|
||||||
try {
|
try {
|
||||||
const response = await doFetch('/api/realtime/oauth-status', {
|
const response = await doFetch('/api/realtime/oauth-status', {
|
||||||
@@ -451,6 +464,11 @@ export async function initKeySetup({
|
|||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
const payload = await response.json().catch(() => ({}));
|
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) {
|
if (response.ok && payload.available) {
|
||||||
writeStoredCloudVoiceAuthMode('oauth');
|
writeStoredCloudVoiceAuthMode('oauth');
|
||||||
render(status);
|
render(status);
|
||||||
@@ -460,13 +478,18 @@ export async function initKeySetup({
|
|||||||
return;
|
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', {
|
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
cache: 'no-store',
|
cache: 'no-store',
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
const loginPayload = await loginResponse.json().catch(() => ({}));
|
const loginPayload = await loginResponse.json().catch(() => ({}));
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
if (!loginResponse.ok && loginResponse.status !== 202) {
|
if (!loginResponse.ok && loginResponse.status !== 202) {
|
||||||
say(
|
say(
|
||||||
loginPayload.error ||
|
loginPayload.error ||
|
||||||
@@ -491,9 +514,10 @@ export async function initKeySetup({
|
|||||||
fetchImpl: doFetch,
|
fetchImpl: doFetch,
|
||||||
signal: lifetime.signal,
|
signal: lifetime.signal,
|
||||||
});
|
});
|
||||||
|
if (lifetime.signal.aborted) return;
|
||||||
if (!available) {
|
if (!available) {
|
||||||
say(
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -506,7 +530,11 @@ export async function initKeySetup({
|
|||||||
if (lifetime.signal.aborted) return;
|
if (lifetime.signal.aborted) return;
|
||||||
say(`OAuth check failed: ${error?.message || error}`);
|
say(`OAuth check failed: ${error?.message || error}`);
|
||||||
} finally {
|
} finally {
|
||||||
authButton.disabled = false;
|
oauthBusy = false;
|
||||||
|
for (const button of rowsHost.querySelectorAll(
|
||||||
|
'[data-cloud-voice-auth-toggle]',
|
||||||
|
))
|
||||||
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -84,6 +84,42 @@ test('OAuth login polling stops as soon as local ChatGPT auth becomes available'
|
|||||||
assert.equal(checks, 3);
|
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', () => {
|
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');
|
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||||
assert.ok(stripped !== null);
|
assert.ok(stripped !== null);
|
||||||
|
|||||||
@@ -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);
|
assert.equal(response.body.includes('fixture-oauth-secret'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('OAuth login endpoint launches local Codex sign-in once and never exposes credentials', async () => {
|
test('OAuth status and token minting report expired sign-in as re-auth required', async () => {
|
||||||
let starts = 0;
|
const expired = () => {
|
||||||
let clock = 1_000;
|
const error = new Error('ChatGPT sign-in has expired.');
|
||||||
|
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
|
||||||
|
throw error;
|
||||||
|
};
|
||||||
const routes = install(
|
const routes = install(
|
||||||
openAiRealtimeProxy({
|
openAiRealtimeProxy({
|
||||||
realtime: {
|
realtime: {
|
||||||
resolveOAuthAccessToken: () => {
|
resolveOAuthAccessToken: expired,
|
||||||
throw new Error('not signed in');
|
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
|
||||||
},
|
|
||||||
startOAuthLogin: async () => {
|
|
||||||
starts += 1;
|
|
||||||
},
|
|
||||||
now: () => clock,
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 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.equal(started.status, 202);
|
||||||
assert.deepEqual(started.json(), {
|
assert.deepEqual(started.json(), {
|
||||||
available: false,
|
available: false,
|
||||||
@@ -326,7 +373,7 @@ test('OAuth login endpoint launches local Codex sign-in once and never exposes c
|
|||||||
});
|
});
|
||||||
assert.equal(starts, 1);
|
assert.equal(starts, 1);
|
||||||
|
|
||||||
clock += 1_000;
|
t.mock.timers.tick(31_000);
|
||||||
const duplicate = await request(handler, { method: 'POST' });
|
const duplicate = await request(handler, { method: 'POST' });
|
||||||
assert.equal(duplicate.status, 202);
|
assert.equal(duplicate.status, 202);
|
||||||
assert.equal(duplicate.json().pending, true);
|
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(crossOrigin.status, 403);
|
||||||
assert.equal(starts, 1);
|
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 () => {
|
test('OAuth login endpoint selects an existing local sign-in without launching Codex', async () => {
|
||||||
|
|||||||
Reference in new issue
Block a user