mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
Merge remote-tracking branch 'upstream/main' into fix/tomtom-cross-site-gate
This commit is contained in:
commit
e32d2eb9ed
33 files changed
+2492
-39
No files matched your search
@@ -0,0 +1,273 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
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({
|
||||
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 reader accepts a current ChatGPT token and rejects unusable credentials', () => {
|
||||
const current = jwt({ exp: nowMs / 1000 + 3600 });
|
||||
assert.equal(
|
||||
readCodexOAuthAccessToken({
|
||||
authPath: '/unused',
|
||||
nowMs,
|
||||
readFile: () => auth(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 [
|
||||
() => '{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' },
|
||||
}),
|
||||
() => auth('opaque-token'),
|
||||
() => auth('header.not-json.signature'),
|
||||
() => auth(jwt({ exp: 'tomorrow' })),
|
||||
() => {
|
||||
throw new Error('missing');
|
||||
},
|
||||
]) {
|
||||
assert.throws(
|
||||
() => 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 })),
|
||||
});
|
||||
},
|
||||
(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 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(
|
||||
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',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
OPENAI_API_KEY: 'fixture-api-key',
|
||||
GOOGLE_MAPS_SERVER_API_KEY: 'fixture-google-key',
|
||||
OPENSKY_CLIENT_SECRET: 'fixture-opensky-secret',
|
||||
},
|
||||
spawnImpl(command, args, options) {
|
||||
calls.push({ command, args, options });
|
||||
queueMicrotask(() => child.emit('spawn'));
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const login = await pending;
|
||||
assert.equal(unref, 1);
|
||||
assert.equal(calls[0].command, '/fixture/codex');
|
||||
assert.deepEqual(calls[0].args, ['login']);
|
||||
assert.equal(calls[0].options.detached, true);
|
||||
assert.equal(calls[0].options.stdio, 'ignore');
|
||||
assert.deepEqual(
|
||||
calls[0].options.env,
|
||||
{
|
||||
PATH: '/fixture',
|
||||
https_proxy: 'http://proxy.fixture:8080',
|
||||
CODEX_HOME: path.join('/home/fixture', '.codex'),
|
||||
},
|
||||
'provider secrets stay out of the login process',
|
||||
);
|
||||
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 });
|
||||
});
|
||||
+179
@@ -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,52 @@ 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 (signal?.aborted) return false;
|
||||
if (response.ok && payload.available) return true;
|
||||
if (!response.ok || payload.loginFailed) {
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in. Try again.',
|
||||
);
|
||||
}
|
||||
if (now() >= deadline) break;
|
||||
await sleep(Math.max(0, pollMs), signal);
|
||||
}
|
||||
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
|
||||
@@ -61,6 +111,21 @@ export function stripKeylessBasemapFromHash(hash) {
|
||||
|
||||
const TIER_DOTS = Object.freeze({ metered: '🔴', free: '🟡' });
|
||||
|
||||
function updateCloudVoiceAuthControl(auth, busy = false) {
|
||||
const mode = readStoredCloudVoiceAuthMode();
|
||||
const state = auth.querySelector('.key-setup-cloud-auth-state');
|
||||
const toggle = auth.querySelector('[data-cloud-voice-auth-toggle]');
|
||||
state.textContent =
|
||||
mode === 'oauth' ? 'VOICE AUTH · CHATGPT OAUTH' : 'VOICE AUTH · API KEY';
|
||||
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';
|
||||
// Keep the control focusable while the delegated handler blocks repeats.
|
||||
toggle.setAttribute('aria-disabled', String(busy));
|
||||
}
|
||||
|
||||
/** Build one key row. All content is our own registry text, set via textContent. */
|
||||
function buildRow(documentRef, key) {
|
||||
const row = documentRef.createElement('section');
|
||||
@@ -144,6 +209,19 @@ function buildRow(documentRef, key) {
|
||||
}
|
||||
row.append(fields);
|
||||
}
|
||||
if (key.id === 'openai') {
|
||||
const auth = documentRef.createElement('div');
|
||||
auth.className = 'key-setup-cloud-auth';
|
||||
const state = documentRef.createElement('span');
|
||||
state.className = 'key-setup-cloud-auth-state';
|
||||
const toggle = documentRef.createElement('button');
|
||||
toggle.type = 'button';
|
||||
toggle.className = 'key-setup-cloud-auth-toggle';
|
||||
toggle.dataset.cloudVoiceAuthToggle = 'true';
|
||||
auth.append(state, toggle);
|
||||
updateCloudVoiceAuthControl(auth);
|
||||
row.append(auth);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
@@ -224,8 +302,14 @@ export async function initKeySetup({
|
||||
const statusLine = root.querySelector('[data-key-setup-status]');
|
||||
const defaultStatusText = statusLine?.textContent || '';
|
||||
let busy = false;
|
||||
let oauthBusy = false;
|
||||
let open = false;
|
||||
|
||||
const syncCloudVoiceAuth = () => {
|
||||
for (const auth of rowsHost.querySelectorAll('.key-setup-cloud-auth'))
|
||||
updateCloudVoiceAuthControl(auth, oauthBusy);
|
||||
};
|
||||
|
||||
const render = (nextStatus) => {
|
||||
if (disposed) return;
|
||||
status = nextStatus;
|
||||
@@ -239,6 +323,7 @@ export async function initKeySetup({
|
||||
rowsHost.textContent = '';
|
||||
for (const key of status.keys || [])
|
||||
rowsHost.append(buildRow(documentRef, key));
|
||||
syncCloudVoiceAuth();
|
||||
};
|
||||
|
||||
const visible = () =>
|
||||
@@ -367,6 +452,100 @@ 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) {
|
||||
if (oauthBusy) return;
|
||||
void (async () => {
|
||||
const current = readStoredCloudVoiceAuthMode();
|
||||
if (current === 'oauth') {
|
||||
writeStoredCloudVoiceAuthMode('api-key');
|
||||
syncCloudVoiceAuth();
|
||||
say('Cloud voice will use OPENAI_API_KEY on the next session.');
|
||||
return;
|
||||
}
|
||||
oauthBusy = true;
|
||||
syncCloudVoiceAuth();
|
||||
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 (lifetime.signal.aborted) return;
|
||||
if (!response.ok)
|
||||
throw new Error(
|
||||
payload.error || 'Could not check ChatGPT sign-in.',
|
||||
);
|
||||
if (payload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
say(
|
||||
'ChatGPT OAuth selected for cloud voice. Your API key stays saved and available.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
say(
|
||||
payload.code === 'CODEX_OAUTH_REAUTH_REQUIRED'
|
||||
? 'ChatGPT sign-in expired. Opening sign-in again…'
|
||||
: 'Opening ChatGPT sign-in in your browser…',
|
||||
);
|
||||
const loginResponse = await doFetch('/api/realtime/oauth-login', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
signal: lifetime.signal,
|
||||
});
|
||||
const loginPayload = await loginResponse.json().catch(() => ({}));
|
||||
if (lifetime.signal.aborted) return;
|
||||
if (!loginResponse.ok) {
|
||||
say(
|
||||
loginPayload.error ||
|
||||
payload.error ||
|
||||
'Could not start ChatGPT sign-in on this machine.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (loginPayload.available) {
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
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 (lifetime.signal.aborted) return;
|
||||
if (!available) {
|
||||
say(
|
||||
'ChatGPT sign-in timed out. Click USE CHATGPT OAUTH to try again.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
writeStoredCloudVoiceAuthMode('oauth');
|
||||
syncCloudVoiceAuth();
|
||||
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 {
|
||||
oauthBusy = false;
|
||||
if (!disposed) syncCloudVoiceAuth();
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
const button = event.target?.closest?.('[data-key-setup-remove]');
|
||||
if (disposed || !button || busy) return;
|
||||
let envVars = [];
|
||||
|
||||
@@ -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,64 @@ 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('OAuth login polling stops on login or HTTP failure instead of waiting for timeout', async () => {
|
||||
for (const result of [
|
||||
{ ok: true, payload: { available: false, loginFailed: true, error: 'Sign-in failed.' } },
|
||||
{ ok: false, payload: { error: 'Sign-in is local only.' } },
|
||||
]) {
|
||||
let checks = 0;
|
||||
await assert.rejects(waitForChatGptOAuth({
|
||||
fetchImpl: async () => { checks += 1; return { ok: result.ok, json: async () => result.payload }; },
|
||||
sleep: () => assert.fail('failed login must stop polling'),
|
||||
}), { message: result.payload.error });
|
||||
assert.equal(checks, 1);
|
||||
}
|
||||
});
|
||||
|
||||
test('OAuth login polling respects timeout and an aborted setup surface', async () => {
|
||||
let clock = 0;
|
||||
let checks = 0;
|
||||
const poll = { fetchImpl: async () => { checks += 1; return { ok: true, json: async () => ({ available: false, pending: true }) }; } };
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, timeoutMs: 100, pollMs: 100, now: () => clock, sleep: async (ms) => { clock += ms; } }), false);
|
||||
assert.equal(checks, 2);
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
assert.equal(await waitForChatGptOAuth({ ...poll, signal: controller.signal }), false);
|
||||
assert.equal(checks, 2, 'aborted polling does not fetch');
|
||||
});
|
||||
|
||||
test('OAuth polling ignores a ready response received after the surface was aborted', async () => {
|
||||
const controller = new AbortController();
|
||||
const available = await waitForChatGptOAuth({
|
||||
signal: controller.signal,
|
||||
fetchImpl: async () => ({ ok: true, json: async () => { controller.abort(); return { available: true }; } }),
|
||||
sleep: () => assert.fail('aborted polling must not continue'),
|
||||
});
|
||||
assert.equal(available, false);
|
||||
});
|
||||
|
||||
test('the first Google key strips ONLY the keyless OSM basemap from the share hash', () => {
|
||||
const stripped = stripKeylessBasemapFromHash('lat=30.2&lon=-97.7&map=osm&style=normal');
|
||||
assert.ok(stripped !== null);
|
||||
|
||||
@@ -55,7 +55,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',
|
||||
|
||||
@@ -274,6 +274,13 @@ test('doctor describes the credential ladder without exposing values', () => {
|
||||
);
|
||||
assert.match(capabilities.map, /Bing and world-terrain stacks/);
|
||||
assert.equal(capabilities.voice, 'available');
|
||||
assert.equal(
|
||||
buildCapabilitySummary({
|
||||
...credentials,
|
||||
OPENAI_API_KEY: { configured: false },
|
||||
}).voice,
|
||||
'off until an OpenAI key is added, or a Codex ChatGPT sign-in is selected in Provider Settings (experimental)',
|
||||
);
|
||||
assert.match(capabilities.missions, /token allowance/);
|
||||
assert.equal(
|
||||
capabilities.flights,
|
||||
|
||||
@@ -214,11 +214,18 @@ function decodeLayers(bytes, z, x, y, layerNames) {
|
||||
return { roads, military };
|
||||
}
|
||||
|
||||
/** Construct an immutable-version road/military tile source without starting I/O. */
|
||||
/**
|
||||
* Construct an immutable-version road/military tile source without starting I/O.
|
||||
*
|
||||
* `allowedOrigin` is deliberately not set here: `createVectorTileSource`
|
||||
* derives it from whichever `tileJsonUrl` is in effect, so a caller that
|
||||
* repoints only `tileJsonUrl` gets tiles from that host or a named error —
|
||||
* never a silent fall back to the default host (#933). Passing
|
||||
* `allowedOrigin` explicitly still overrides both.
|
||||
*/
|
||||
export function createOpenFreeMapSource(options = {}) {
|
||||
return createVectorTileSource({
|
||||
tileJsonUrl: 'https://tiles.openfreemap.org/planet',
|
||||
allowedOrigin: 'https://tiles.openfreemap.org',
|
||||
decode: decodeOpenFreeMapTile,
|
||||
maxEntries: 192,
|
||||
maxCacheBytes: 64 * 1024 * 1024,
|
||||
|
||||
@@ -320,3 +320,41 @@ test('Capitol, greenbelt and commercial property fixtures exclude non-public dri
|
||||
assert.ok(records.some((r) => r.area === area && !r.drivable));
|
||||
}
|
||||
});
|
||||
|
||||
test('repointing tileJsonUrl at a mirror never falls back to the default tile host', async () => {
|
||||
const urls = [];
|
||||
const mirror = 'http://127.0.0.1:12348';
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: async (url) => {
|
||||
urls.push(url);
|
||||
return url.endsWith('/planet')
|
||||
? Response.json({ tiles: [`${mirror}/planet/v/{z}/{x}/{y}.pbf`] })
|
||||
: new Response(fixture('ofm-austin-14-3743-6745.pbf'));
|
||||
},
|
||||
tileJsonUrl: `${mirror}/planet`,
|
||||
});
|
||||
await source.fetchBounds(
|
||||
{ south: 30.264, north: 30.271, west: -97.75, east: -97.74 },
|
||||
{ zoom: 14 },
|
||||
);
|
||||
assert.ok(urls.length > 1, 'tiles were fetched');
|
||||
assert.deepEqual(
|
||||
urls.filter((url) => !url.startsWith(mirror)),
|
||||
[],
|
||||
'a repointed source must not fetch from tiles.openfreemap.org',
|
||||
);
|
||||
});
|
||||
|
||||
test('a mirror that serves the default host TileJSON verbatim fails loudly', async () => {
|
||||
// A transparent caching proxy returns upstream's TileJSON unchanged, whose
|
||||
// tiles[0] is an absolute upstream URL. That must be an error, not 100% of
|
||||
// tile traffic quietly leaving the configured mirror.
|
||||
const source = createOpenFreeMapSource({
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.openfreemap.org/planet/v/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
});
|
||||
await assert.rejects(source.getMetadata(), { retryable: false });
|
||||
});
|
||||
@@ -16,11 +16,27 @@ export function validTileBounds(box) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The origin a configured TileJSON is served from, or undefined when there is
|
||||
* no absolute URL to read one off. Deriving `allowedOrigin` from it means
|
||||
* repointing `tileJsonUrl` at a mirror cannot silently keep loading tiles from
|
||||
* the old host: the origin check below then names the mismatch instead of
|
||||
* passing it (#933).
|
||||
*/
|
||||
function tileJsonOrigin(tileJsonUrl) {
|
||||
if (typeof tileJsonUrl !== 'string') return undefined;
|
||||
try {
|
||||
return new URL(tileJsonUrl).origin;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** A decoded XYZ cache with bounded workers, reads, retained bytes and request lifetimes. */
|
||||
export function createVectorTileSource({
|
||||
tileJsonUrl,
|
||||
template,
|
||||
allowedOrigin,
|
||||
allowedOrigin = tileJsonOrigin(tileJsonUrl),
|
||||
decode,
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
maxTiles = 16,
|
||||
|
||||
@@ -203,3 +203,45 @@ test('polygon-selected tiles keep nearest-first order and cached revisits issue
|
||||
'over-budget selection is rejected before fetching',
|
||||
);
|
||||
});
|
||||
|
||||
test('a configured tileJsonUrl sets the tile origin, and a foreign tiles[0] is still refused', async () => {
|
||||
// Without an explicit allowedOrigin the mirror's own TileJSON is honoured…
|
||||
const mirrored = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({ tiles: ['http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf'] }),
|
||||
});
|
||||
assert.equal(
|
||||
(await mirrored.getMetadata()).template,
|
||||
'http://127.0.0.1:12348/p/{z}/{x}/{y}.pbf',
|
||||
);
|
||||
|
||||
// …while a TileJSON that names a different host is refused rather than
|
||||
// silently fetched, which is what the origin check is for.
|
||||
const foreign = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
});
|
||||
await assert.rejects(foreign.getMetadata(), { retryable: false });
|
||||
});
|
||||
|
||||
test('an explicit allowedOrigin still wins over the one derived from tileJsonUrl', async () => {
|
||||
const source = createVectorTileSource({
|
||||
tileJsonUrl: 'http://127.0.0.1:12348/planet',
|
||||
allowedOrigin: 'https://tiles.example',
|
||||
decode: () => [],
|
||||
fetchImpl: async () =>
|
||||
Response.json({
|
||||
tiles: ['https://tiles.example/planet/{z}/{x}/{y}.pbf'],
|
||||
}),
|
||||
});
|
||||
assert.equal(
|
||||
(await source.getMetadata()).template,
|
||||
'https://tiles.example/planet/{z}/{x}/{y}.pbf',
|
||||
);
|
||||
});
|
||||
@@ -41,6 +41,7 @@ function request(
|
||||
url = '/',
|
||||
body = '',
|
||||
origin = 'http://localhost:4173',
|
||||
remoteAddress = '127.0.0.1',
|
||||
} = {},
|
||||
) {
|
||||
return new Promise((resolve, reject) => {
|
||||
@@ -53,7 +54,7 @@ function request(
|
||||
origin,
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
socket: { remoteAddress: '127.0.0.1' },
|
||||
socket: { remoteAddress },
|
||||
});
|
||||
const headers = {};
|
||||
const res = {
|
||||
@@ -199,6 +200,386 @@ 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');
|
||||
|
||||
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');
|
||||
|
||||
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(
|
||||
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 status and token minting report expired sign-in as re-auth required', async () => {
|
||||
const expired = () => {
|
||||
const error = new Error('ChatGPT sign-in has expired.');
|
||||
error.code = 'CODEX_OAUTH_REAUTH_REQUIRED';
|
||||
throw error;
|
||||
};
|
||||
const routes = install(
|
||||
openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: expired,
|
||||
fetchImpl: () => assert.fail('expired OAuth must not reach OpenAI'),
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const status = await request(routes.get('/api/realtime/oauth-status'));
|
||||
assert.equal(status.status, 200);
|
||||
assert.deepEqual(status.json(), {
|
||||
available: false,
|
||||
error: 'ChatGPT sign-in has expired.',
|
||||
code: 'CODEX_OAUTH_REAUTH_REQUIRED',
|
||||
reauthRequired: true,
|
||||
});
|
||||
|
||||
const token = await request(routes.get('/api/realtime/token'), {
|
||||
url: '/?auth=oauth',
|
||||
});
|
||||
assert.equal(token.status, 503);
|
||||
assert.equal(token.json().code, 'CODEX_OAUTH_REAUTH_REQUIRED');
|
||||
});
|
||||
|
||||
test('OAuth login endpoint shares pending attempts and never exposes credentials', async (t) => {
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
let starts = 0;
|
||||
let spawnReady;
|
||||
let exit;
|
||||
const plugin = openAiRealtimeProxy({
|
||||
realtime: {
|
||||
resolveOAuthAccessToken: () => {
|
||||
throw new Error('not signed in');
|
||||
},
|
||||
startOAuthLogin: async () => {
|
||||
starts += 1;
|
||||
await new Promise((resolve) => {
|
||||
spawnReady = resolve;
|
||||
});
|
||||
return {
|
||||
completion: new Promise((resolve) => {
|
||||
exit = resolve;
|
||||
}),
|
||||
cancel() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
});
|
||||
t.after(plugin.closeBundle);
|
||||
const routes = install(plugin);
|
||||
const handler = routes.get('/api/realtime/oauth-login');
|
||||
|
||||
const firstRequest = request(handler, { method: 'POST' });
|
||||
const concurrent = await request(handler, { method: 'POST' });
|
||||
assert.equal(concurrent.status, 202);
|
||||
assert.equal(concurrent.json().started, false);
|
||||
assert.equal(starts, 1, 'reserve the attempt before awaiting spawn');
|
||||
spawnReady();
|
||||
const started = await firstRequest;
|
||||
assert.equal(started.status, 202);
|
||||
assert.deepEqual(started.json(), {
|
||||
available: false,
|
||||
started: true,
|
||||
pending: true,
|
||||
});
|
||||
assert.equal(starts, 1);
|
||||
|
||||
t.mock.timers.tick(31_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);
|
||||
|
||||
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 () => {
|
||||
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('caption transcription is on by default, reported to the client meter, and can be turned off', async (t) => {
|
||||
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
|
||||
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
|
||||
|
||||
@@ -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[aria-disabled='true'] {
|
||||
cursor: wait;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.key-setup-footer {
|
||||
margin-top: 0.85rem;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -3195,6 +3195,54 @@ 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 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, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.deepEqual(controller.recordUsage(usdUsage(100)), {
|
||||
responses: 1,
|
||||
input: 0,
|
||||
output: 1562500,
|
||||
captions: 0,
|
||||
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('OAuth cloud sessions count caption transcriptions without pricing them into the API cap', () => {
|
||||
const { controller, ui } = costControllerHarness();
|
||||
controller.status = 'listening';
|
||||
controller._cost.cloudVoiceAuth = 'oauth';
|
||||
controller._cost.sessionCloudVoiceAuth = 'oauth';
|
||||
controller._cost.bindTranscriptionModel('gpt-4o-mini-transcribe');
|
||||
const before = controller.costTracker.state();
|
||||
// Priced, this much caption audio would cross the $5 cap.
|
||||
const result = controller.recordTranscriptionUsage({
|
||||
type: 'duration',
|
||||
seconds: 3_600_000,
|
||||
});
|
||||
assert.equal(result.captions, 1);
|
||||
assert.equal(result.costKnown, false);
|
||||
const after = controller.costTracker.state();
|
||||
assert.equal(after.totalUsd, before.totalUsd);
|
||||
assert.equal(after.capReached, false);
|
||||
assert.equal(controller.costCapStopped, false);
|
||||
assert.equal(ui.costValue.textContent, 'COST UNKNOWN');
|
||||
assert.match(ui.costValue.title, /1 caption transcription\(s\)/);
|
||||
});
|
||||
|
||||
test('F1: when idle, toggling does re-price the preview meter', () => {
|
||||
const { controller } = costControllerHarness();
|
||||
controller.status = 'idle';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -374,7 +374,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._metrics.flush('stop');
|
||||
this._connection.closeTransport();
|
||||
this.stopVoiceVisualizer();
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
writeStoredVoiceTier,
|
||||
writeStoredVoiceLimits,
|
||||
} from './realtimePreferences.js';
|
||||
import { readStoredCloudVoiceAuthMode } from './cloudVoiceAuth.js';
|
||||
import {
|
||||
createVoiceCostTracker,
|
||||
resolveVoiceModel,
|
||||
@@ -17,6 +18,9 @@ export class RealtimeCost {
|
||||
Object.assign(this, { readUi, readStatus }, operations);
|
||||
this.voiceTier = readStoredVoiceTier();
|
||||
this.voiceLimits = readStoredVoiceLimits();
|
||||
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
|
||||
this.sessionCloudVoiceAuth = null;
|
||||
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 0 };
|
||||
this.transcribeModel = null;
|
||||
this.costTracker = createVoiceCostTracker({
|
||||
tier: this.voiceTier,
|
||||
@@ -58,6 +62,21 @@ export class RealtimeCost {
|
||||
}; applies next session`;
|
||||
}
|
||||
if (this.ui?.costValue) {
|
||||
const liveAuth = this.sessionCloudVoiceAuth || this.cloudVoiceAuth;
|
||||
if (liveAuth === 'oauth') {
|
||||
const { responses, input, output, captions } = 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, ${captions} caption transcription(s) 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 =
|
||||
@@ -138,6 +157,15 @@ export class RealtimeCost {
|
||||
*/
|
||||
recordUsage(usage) {
|
||||
if (!usage) 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 };
|
||||
}
|
||||
return this.applyCost(this.costTracker.record(usage));
|
||||
}
|
||||
|
||||
@@ -148,6 +176,13 @@ export class RealtimeCost {
|
||||
*/
|
||||
recordTranscriptionUsage(usage) {
|
||||
if (!usage) return null;
|
||||
// An OAuth session has no API-dollar meter, so captions are counted
|
||||
// rather than priced into the API-key warning and cap.
|
||||
if (this.sessionCloudVoiceAuth === 'oauth') {
|
||||
this.oauthUsage.captions += 1;
|
||||
this.syncCostUi();
|
||||
return { ...this.oauthUsage, costKnown: false };
|
||||
}
|
||||
return this.applyCost(
|
||||
this.costTracker.recordUsd(
|
||||
estimateTranscriptionCostUsd(usage, this.transcribeModel),
|
||||
@@ -213,6 +248,9 @@ export class RealtimeCost {
|
||||
prepareSession() {
|
||||
this.voiceTier = readStoredVoiceTier();
|
||||
this.voiceLimits = readStoredVoiceLimits();
|
||||
this.cloudVoiceAuth = readStoredCloudVoiceAuthMode();
|
||||
this.sessionCloudVoiceAuth = this.cloudVoiceAuth;
|
||||
this.oauthUsage = { responses: 0, input: 0, output: 0, captions: 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
|
||||
|
||||
Reference in new issue
Block a user