agentgui 1.0.700 → 1.0.701

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -82,6 +82,7 @@ XState v5 machines provide formal state tracking alongside (not replacing) the e
82
82
  - `BASE_URL` - URL prefix (default: /gm)
83
83
  - `STARTUP_CWD` - Working directory passed to agents
84
84
  - `HOT_RELOAD` - Set to "false" to disable watch mode
85
+ - `CODEX_HOME` - Override Codex CLI home directory (default: `~/.codex`)
85
86
 
86
87
  ## ACP Tool Lifecycle
87
88
 
@@ -123,6 +124,11 @@ All routes are prefixed with `BASE_URL` (default `/gm`).
123
124
  - `GET /api/tools/:id/history` - Get tool install/update history (query: limit, offset)
124
125
  - `POST /api/tools/update` - Batch update all tools with available updates
125
126
  - `POST /api/tools/refresh-all` - Refresh all tool statuses from package manager
127
+ - `POST /api/codex-oauth/start` - Start Codex CLI OAuth flow (returns `{ authUrl, mode }`)
128
+ - `GET /api/codex-oauth/status` - Get current Codex OAuth state `{ status, email, error }`
129
+ - `POST /api/codex-oauth/relay` - Relay OAuth code+state from remote browser (body: `{ code, state }`)
130
+ - `POST /api/codex-oauth/complete` - Complete OAuth by pasting redirect URL (body: `{ url }`)
131
+ - `GET /codex-oauth2callback` - OAuth callback endpoint (redirect_uri for local flows)
126
132
 
127
133
  ## Tool Update System
128
134
 
@@ -286,6 +292,27 @@ Speech models (~470MB total) are downloaded automatically on server startup. No
286
292
  - **Client init:** `loadAgents()`, `loadConversations()`, `checkSpeechStatus()` run in parallel via `Promise.all()`.
287
293
  - **`perMessageDeflate: false`** on WebSocket server — msgpack binary doesn't compress well, and zlib was blocking the event loop on every streaming_progress send.
288
294
 
295
+ ## Codex CLI OAuth
296
+
297
+ OpenAI Codex CLI uses PKCE authorization code flow against `https://auth.openai.com`.
298
+
299
+ **Flow:**
300
+ 1. `POST /api/codex-oauth/start` generates PKCE (SHA-256 S256 challenge), CSRF state, returns `authUrl`
301
+ 2. User opens `authUrl` in browser and authenticates via OpenAI/ChatGPT
302
+ 3. **Local**: Browser redirects to `http://localhost:1455/auth/callback` — but since agentgui's server is on a different port, the redirect goes to `GET /codex-oauth2callback` (agentgui intercepts via matching route). Token exchange happens server-side.
303
+ 4. **Remote**: Redirect goes to `/codex-oauth2callback` which serves a relay page. Relay POSTs `{ code, state }` to `/api/codex-oauth/relay`. Token exchange happens on the server.
304
+ 5. Tokens saved to `$CODEX_HOME/auth.json` (default: `~/.codex/auth.json`) as `{ auth_mode: "chatgpt", tokens: { id_token, access_token, refresh_token }, last_refresh }`
305
+
306
+ **Constants (in server.js):**
307
+ - Issuer: `https://auth.openai.com`
308
+ - Client ID: `app_EMoamEEZ73f0CkXaXp7hrann`
309
+ - Scopes: `openid profile email offline_access api.connectors.read api.connectors.invoke`
310
+ - Redirect URI (local): `http://localhost:1455/auth/callback` (actual callback goes to agentgui's `/codex-oauth2callback`)
311
+
312
+ **WebSocket handlers** (in `lib/ws-handlers-util.js`): `codex.start`, `codex.status`, `codex.relay`, `codex.complete`
313
+
314
+ **Agent auth**: `POST /api/agents/codex/auth` starts OAuth flow same as Gemini — broadcasts `script_started`/`script_output`/`script_stopped` events as OAuth progresses.
315
+
289
316
  ## ACP SDK Integration
290
317
 
291
318
  - **@agentclientprotocol/sdk** (`^0.4.1`) added to dependencies
@@ -9,6 +9,7 @@ export function register(router, deps) {
9
9
  const { queries, wsOptimizer, modelDownloadState, ensureModelsDownloaded,
10
10
  broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
11
11
  startGeminiOAuth, exchangeGeminiOAuthCode, geminiOAuthState,
12
+ startCodexOAuth, exchangeCodexOAuthCode, codexOAuthState,
12
13
  STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents } = deps;
13
14
 
14
15
  router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
@@ -175,6 +176,45 @@ export function register(router, deps) {
175
176
  } catch (e) { err(400, e.message); }
176
177
  });
177
178
 
179
+ router.handle('codex.start', async () => {
180
+ try {
181
+ const result = await startCodexOAuth();
182
+ return { authUrl: result.authUrl, mode: result.mode };
183
+ } catch (e) { err(500, e.message); }
184
+ });
185
+
186
+ router.handle('codex.status', () => {
187
+ const st = typeof codexOAuthState === 'function' ? codexOAuthState() : codexOAuthState;
188
+ return st;
189
+ });
190
+
191
+ router.handle('codex.relay', async (p) => {
192
+ const { code, state } = p;
193
+ if (!code || !state) err(400, 'Missing code or state');
194
+ try {
195
+ const email = await exchangeCodexOAuthCode(code, state);
196
+ return { success: true, email };
197
+ } catch (e) { err(400, e.message); }
198
+ });
199
+
200
+ router.handle('codex.complete', async (p) => {
201
+ const pastedUrl = (p.url || '').trim();
202
+ if (!pastedUrl) err(400, 'No URL provided');
203
+ let parsed;
204
+ try { parsed = new URL(pastedUrl); } catch { err(400, 'Invalid URL. Paste the full URL from the browser address bar.'); }
205
+ const urlError = parsed.searchParams.get('error');
206
+ if (urlError) {
207
+ const desc = parsed.searchParams.get('error_description') || urlError;
208
+ return { error: desc };
209
+ }
210
+ const code = parsed.searchParams.get('code');
211
+ const state = parsed.searchParams.get('state');
212
+ try {
213
+ const email = await exchangeCodexOAuthCode(code, state);
214
+ return { success: true, email };
215
+ } catch (e) { err(400, e.message); }
216
+ });
217
+
178
218
  router.handle('ws.stats', () => wsOptimizer.getStats());
179
219
 
180
220
  router.handle('conv.scripts', (p) => {
@@ -268,47 +308,47 @@ export function register(router, deps) {
268
308
  }
269
309
  });
270
310
 
271
- router.handle('agent.subagents', async (p) => {
272
- const { id } = p;
273
- if (!id) err(400, 'Missing agent id');
274
-
275
- // Claude Code: run 'claude agents list' and parse output
276
- if (id === 'claude-code' || id === 'cli-claude') {
277
- const spawnEnv = { ...process.env };
278
- delete spawnEnv.CLAUDECODE;
279
- const result = spawnSync('claude', ['agents', 'list'], {
280
- encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
281
- env: spawnEnv
282
- });
283
- if (result.status !== 0 || !result.stdout) return { subAgents: [] };
284
- const output = result.stdout.trim();
285
- // Output format: ' agentId · model' lines under section headers
286
- const agents = [];
287
- for (const line of output.split('\n').filter(l => l.trim())) {
288
- const match = line.match(/^ (\S+)\s+·/);
289
- if (match) {
290
- const id = match[1];
291
- agents.push({ id, name: id });
292
- }
293
- }
294
- console.log('[agent.subagents] claude agents list found:', agents.map(a => a.id).join(', '));
295
- return { subAgents: agents };
296
- }
297
-
298
- // ACP agents: hardcoded map filtered by installed tools
299
- const subAgentMap = {
300
- 'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
301
- 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
302
- 'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
303
- 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
304
- 'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
305
- 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
306
- 'codex': [],
307
- 'cli-codex': []
308
- };
309
- const subAgents = subAgentMap[id] || [];
310
- const tools = await toolManager.getAllToolsAsync();
311
- const installed = new Set(tools.filter(t => t.category === 'plugin' && t.installed).map(t => t.id));
312
- return { subAgents: subAgents.filter(sa => installed.has(sa.id)) };
311
+ router.handle('agent.subagents', async (p) => {
312
+ const { id } = p;
313
+ if (!id) err(400, 'Missing agent id');
314
+
315
+ // Claude Code: run 'claude agents list' and parse output
316
+ if (id === 'claude-code' || id === 'cli-claude') {
317
+ const spawnEnv = { ...process.env };
318
+ delete spawnEnv.CLAUDECODE;
319
+ const result = spawnSync('claude', ['agents', 'list'], {
320
+ encoding: 'utf-8', timeout: 10000, stdio: ['pipe', 'pipe', 'pipe'],
321
+ env: spawnEnv
322
+ });
323
+ if (result.status !== 0 || !result.stdout) return { subAgents: [] };
324
+ const output = result.stdout.trim();
325
+ // Output format: ' agentId · model' lines under section headers
326
+ const agents = [];
327
+ for (const line of output.split('\n').filter(l => l.trim())) {
328
+ const match = line.match(/^ (\S+)\s+·/);
329
+ if (match) {
330
+ const id = match[1];
331
+ agents.push({ id, name: id });
332
+ }
333
+ }
334
+ console.log('[agent.subagents] claude agents list found:', agents.map(a => a.id).join(', '));
335
+ return { subAgents: agents };
336
+ }
337
+
338
+ // ACP agents: hardcoded map filtered by installed tools
339
+ const subAgentMap = {
340
+ 'opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
341
+ 'cli-opencode': [{ id: 'gm-oc', name: 'GM OpenCode' }],
342
+ 'gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
343
+ 'cli-gemini': [{ id: 'gm-gc', name: 'GM Gemini' }],
344
+ 'kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
345
+ 'cli-kilo': [{ id: 'gm-kilo', name: 'GM Kilo' }],
346
+ 'codex': [],
347
+ 'cli-codex': []
348
+ };
349
+ const subAgents = subAgentMap[id] || [];
350
+ const tools = await toolManager.getAllToolsAsync();
351
+ const installed = new Set(tools.filter(t => t.category === 'plugin' && t.installed).map(t => t.id));
352
+ return { subAgents: subAgents.filter(sa => installed.has(sa.id)) };
313
353
  });
314
354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.700",
3
+ "version": "1.0.701",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -949,6 +949,238 @@ function getGeminiOAuthStatus() {
949
949
  return null;
950
950
  }
951
951
 
952
+ const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
953
+ const CODEX_AUTH_FILE = path.join(CODEX_HOME, 'auth.json');
954
+ const CODEX_OAUTH_ISSUER = 'https://auth.openai.com';
955
+ const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
956
+ const CODEX_SCOPES = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
957
+ const CODEX_OAUTH_PORT = 1455;
958
+
959
+ let codexOAuthState = { status: 'idle', error: null, email: null };
960
+ let codexOAuthPending = null;
961
+
962
+ function generatePkce() {
963
+ const verifierBytes = crypto.randomBytes(64);
964
+ const codeVerifier = verifierBytes.toString('base64url');
965
+ const challengeBytes = crypto.createHash('sha256').update(codeVerifier).digest();
966
+ const codeChallenge = challengeBytes.toString('base64url');
967
+ return { codeVerifier, codeChallenge };
968
+ }
969
+
970
+ function parseJwtEmail(jwt) {
971
+ try {
972
+ const parts = jwt.split('.');
973
+ if (parts.length < 2) return '';
974
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
975
+ return payload.email || payload['https://api.openai.com/profile']?.email || '';
976
+ } catch (_) { return ''; }
977
+ }
978
+
979
+ function saveCodexCredentials(tokens) {
980
+ if (!fs.existsSync(CODEX_HOME)) fs.mkdirSync(CODEX_HOME, { recursive: true });
981
+ const auth = { auth_mode: 'chatgpt', tokens, last_refresh: new Date().toISOString() };
982
+ fs.writeFileSync(CODEX_AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
983
+ try { fs.chmodSync(CODEX_AUTH_FILE, 0o600); } catch (_) {}
984
+ }
985
+
986
+ function getCodexOAuthStatus() {
987
+ try {
988
+ if (fs.existsSync(CODEX_AUTH_FILE)) {
989
+ const auth = JSON.parse(fs.readFileSync(CODEX_AUTH_FILE, 'utf8'));
990
+ if (auth.tokens?.access_token || auth.tokens?.refresh_token) {
991
+ const email = parseJwtEmail(auth.tokens?.id_token || '') || '';
992
+ return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: CODEX_AUTH_FILE, authMethod: 'oauth' };
993
+ }
994
+ }
995
+ } catch (_) {}
996
+ return null;
997
+ }
998
+
999
+ async function startCodexOAuth(req) {
1000
+ const remote = isRemoteRequest(req);
1001
+ const redirectUri = remote
1002
+ ? `${buildBaseUrl(req)}${BASE_URL}/codex-oauth2callback`
1003
+ : `http://localhost:${CODEX_OAUTH_PORT}/auth/callback`;
1004
+
1005
+ const pkce = generatePkce();
1006
+ const csrfToken = crypto.randomBytes(32).toString('hex');
1007
+ const relayUrl = remote ? `${buildBaseUrl(req)}${BASE_URL}/api/codex-oauth/relay` : null;
1008
+ const state = encodeOAuthState(csrfToken, relayUrl);
1009
+
1010
+ const params = new URLSearchParams({
1011
+ response_type: 'code',
1012
+ client_id: CODEX_CLIENT_ID,
1013
+ redirect_uri: redirectUri,
1014
+ scope: CODEX_SCOPES,
1015
+ code_challenge: pkce.codeChallenge,
1016
+ code_challenge_method: 'S256',
1017
+ id_token_add_organizations: 'true',
1018
+ codex_cli_simplified_flow: 'true',
1019
+ state,
1020
+ });
1021
+
1022
+ const authUrl = `${CODEX_OAUTH_ISSUER}/oauth/authorize?${params.toString()}`;
1023
+ const mode = remote ? 'remote' : 'local';
1024
+
1025
+ codexOAuthPending = { pkce, redirectUri, state: csrfToken };
1026
+ codexOAuthState = { status: 'pending', error: null, email: null };
1027
+
1028
+ setTimeout(() => {
1029
+ if (codexOAuthState.status === 'pending') {
1030
+ codexOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
1031
+ codexOAuthPending = null;
1032
+ }
1033
+ }, 5 * 60 * 1000);
1034
+
1035
+ return { authUrl, mode };
1036
+ }
1037
+
1038
+ async function exchangeCodexOAuthCode(code, stateParam) {
1039
+ if (!codexOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
1040
+
1041
+ const { pkce, redirectUri, state: expectedCsrf } = codexOAuthPending;
1042
+ const { csrfToken } = decodeOAuthState(stateParam);
1043
+
1044
+ if (csrfToken !== expectedCsrf) {
1045
+ codexOAuthState = { status: 'error', error: 'State mismatch', email: null };
1046
+ codexOAuthPending = null;
1047
+ throw new Error('State mismatch - possible CSRF attack.');
1048
+ }
1049
+
1050
+ if (!code) {
1051
+ codexOAuthState = { status: 'error', error: 'No authorization code received', email: null };
1052
+ codexOAuthPending = null;
1053
+ throw new Error('No authorization code received.');
1054
+ }
1055
+
1056
+ const body = new URLSearchParams({
1057
+ grant_type: 'authorization_code',
1058
+ code,
1059
+ redirect_uri: redirectUri,
1060
+ client_id: CODEX_CLIENT_ID,
1061
+ code_verifier: pkce.codeVerifier,
1062
+ });
1063
+
1064
+ const resp = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
1065
+ method: 'POST',
1066
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1067
+ body: body.toString(),
1068
+ });
1069
+
1070
+ if (!resp.ok) {
1071
+ const text = await resp.text();
1072
+ codexOAuthState = { status: 'error', error: `Token exchange failed: ${resp.status}`, email: null };
1073
+ codexOAuthPending = null;
1074
+ throw new Error(`Token exchange failed (${resp.status}): ${text}`);
1075
+ }
1076
+
1077
+ const tokens = await resp.json();
1078
+ const email = parseJwtEmail(tokens.id_token || '');
1079
+
1080
+ saveCodexCredentials(tokens);
1081
+ codexOAuthState = { status: 'success', error: null, email };
1082
+ codexOAuthPending = null;
1083
+
1084
+ return email;
1085
+ }
1086
+
1087
+ function codexOAuthResultPage(title, message, success) {
1088
+ const color = success ? '#10b981' : '#ef4444';
1089
+ const icon = success ? '&#10003;' : '&#10007;';
1090
+ return `<!DOCTYPE html><html><head><title>${title}</title></head>
1091
+ <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111827;font-family:system-ui,sans-serif;color:white;">
1092
+ <div style="text-align:center;max-width:400px;padding:2rem;">
1093
+ <div style="font-size:4rem;color:${color};margin-bottom:1rem;">${icon}</div>
1094
+ <h1 style="font-size:1.5rem;margin-bottom:0.5rem;">${title}</h1>
1095
+ <p style="color:#9ca3af;">${message}</p>
1096
+ <p style="color:#6b7280;margin-top:1rem;font-size:0.875rem;">You can close this tab.</p>
1097
+ </div></body></html>`;
1098
+ }
1099
+
1100
+ function codexOAuthRelayPage(code, state, error) {
1101
+ const stateData = decodeOAuthState(state || '');
1102
+ const relayUrl = stateData.relayUrl || '';
1103
+ const escapedCode = (code || '').replace(/['"\\]/g, '');
1104
+ const escapedState = (state || '').replace(/['"\\]/g, '');
1105
+ const escapedError = (error || '').replace(/['"\\]/g, '');
1106
+ const escapedRelay = relayUrl.replace(/['"\\]/g, '');
1107
+ return `<!DOCTYPE html><html><head><title>Completing sign-in...</title></head>
1108
+ <body style="margin:0;display:flex;align-items:center;justify-content:center;min-height:100vh;background:#111827;font-family:system-ui,sans-serif;color:white;">
1109
+ <div id="status" style="text-align:center;max-width:400px;padding:2rem;">
1110
+ <div id="spinner" style="font-size:2rem;margin-bottom:1rem;">&#8987;</div>
1111
+ <h1 id="title" style="font-size:1.5rem;margin-bottom:0.5rem;">Completing sign-in...</h1>
1112
+ <p id="msg" style="color:#9ca3af;">Relaying authentication to server...</p>
1113
+ </div>
1114
+ <script>
1115
+ (function() {
1116
+ var code = '${escapedCode}';
1117
+ var state = '${escapedState}';
1118
+ var error = '${escapedError}';
1119
+ var relayUrl = '${escapedRelay}';
1120
+ function show(icon, title, msg, color) {
1121
+ document.getElementById('spinner').textContent = icon;
1122
+ document.getElementById('spinner').style.color = color;
1123
+ document.getElementById('title').textContent = title;
1124
+ document.getElementById('msg').textContent = msg;
1125
+ }
1126
+ if (error) { show('\\u2717', 'Authentication Failed', error, '#ef4444'); return; }
1127
+ if (!code) { show('\\u2717', 'Authentication Failed', 'No authorization code received.', '#ef4444'); return; }
1128
+ if (!relayUrl) { show('\\u2713', 'Authentication Successful', 'Credentials saved. You can close this tab.', '#10b981'); return; }
1129
+ fetch(relayUrl, {
1130
+ method: 'POST',
1131
+ headers: { 'Content-Type': 'application/json' },
1132
+ body: JSON.stringify({ code: code, state: state })
1133
+ }).then(function(r) { return r.json(); }).then(function(data) {
1134
+ if (data.success) {
1135
+ show('\\u2713', 'Authentication Successful', data.email ? 'Signed in as ' + data.email + '. You can close this tab.' : 'Credentials saved. You can close this tab.', '#10b981');
1136
+ } else {
1137
+ show('\\u2717', 'Authentication Failed', data.error || 'Unknown error', '#ef4444');
1138
+ }
1139
+ }).catch(function(e) {
1140
+ show('\\u2717', 'Relay Failed', 'Could not reach server: ' + e.message + '. You may need to paste the URL manually.', '#ef4444');
1141
+ });
1142
+ })();
1143
+ </script>
1144
+ </body></html>`;
1145
+ }
1146
+
1147
+ async function handleCodexOAuthCallback(req, res) {
1148
+ const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
1149
+ const code = reqUrl.searchParams.get('code');
1150
+ const state = reqUrl.searchParams.get('state');
1151
+ const error = reqUrl.searchParams.get('error');
1152
+ const errorDesc = reqUrl.searchParams.get('error_description');
1153
+
1154
+ if (error) {
1155
+ const desc = errorDesc || error;
1156
+ codexOAuthState = { status: 'error', error: desc, email: null };
1157
+ codexOAuthPending = null;
1158
+ }
1159
+
1160
+ const stateData = decodeOAuthState(state || '');
1161
+ if (stateData.relayUrl) {
1162
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1163
+ res.end(codexOAuthRelayPage(code, state, errorDesc || error));
1164
+ return;
1165
+ }
1166
+
1167
+ if (!codexOAuthPending) {
1168
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1169
+ res.end(codexOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
1170
+ return;
1171
+ }
1172
+
1173
+ try {
1174
+ if (error) throw new Error(errorDesc || error);
1175
+ const email = await exchangeCodexOAuthCode(code, state);
1176
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1177
+ res.end(codexOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Codex CLI credentials saved.', true));
1178
+ } catch (e) {
1179
+ res.writeHead(200, { 'Content-Type': 'text/html' });
1180
+ res.end(codexOAuthResultPage('Authentication Failed', e.message, false));
1181
+ }
1182
+ }
1183
+
952
1184
  const PROVIDER_CONFIGS = {
953
1185
  'anthropic': {
954
1186
  name: 'Anthropic', configPaths: [
@@ -1037,6 +1269,13 @@ function getProviderConfigs() {
1037
1269
  continue;
1038
1270
  }
1039
1271
  }
1272
+ if (providerId === 'codex') {
1273
+ const oauthStatus = getCodexOAuthStatus();
1274
+ if (oauthStatus) {
1275
+ configs[providerId] = { name: config.name, ...oauthStatus };
1276
+ continue;
1277
+ }
1278
+ }
1040
1279
  for (const configPath of config.configPaths) {
1041
1280
  try {
1042
1281
  if (fs.existsSync(configPath)) {
@@ -1167,6 +1406,11 @@ const server = http.createServer(async (req, res) => {
1167
1406
  return;
1168
1407
  }
1169
1408
 
1409
+ if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') {
1410
+ await handleCodexOAuthCallback(req, res);
1411
+ return;
1412
+ }
1413
+
1170
1414
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
1171
1415
  const conversations = queries.getConversationsList();
1172
1416
  // Filter out stale streaming state using a single bulk query instead of N+1 per-conversation queries
@@ -2622,12 +2866,110 @@ const server = http.createServer(async (req, res) => {
2622
2866
  return;
2623
2867
  }
2624
2868
 
2869
+ if (pathOnly === '/api/codex-oauth/start' && req.method === 'POST') {
2870
+ try {
2871
+ const result = await startCodexOAuth(req);
2872
+ sendJSON(req, res, 200, { authUrl: result.authUrl, mode: result.mode });
2873
+ } catch (e) {
2874
+ console.error('[codex-oauth] /api/codex-oauth/start failed:', e);
2875
+ sendJSON(req, res, 500, { error: e.message });
2876
+ }
2877
+ return;
2878
+ }
2879
+
2880
+ if (pathOnly === '/api/codex-oauth/status' && req.method === 'GET') {
2881
+ sendJSON(req, res, 200, codexOAuthState);
2882
+ return;
2883
+ }
2884
+
2885
+ if (pathOnly === '/api/codex-oauth/relay' && req.method === 'POST') {
2886
+ try {
2887
+ const body = await parseBody(req);
2888
+ const { code, state: stateParam } = body;
2889
+ if (!code || !stateParam) {
2890
+ sendJSON(req, res, 400, { error: 'Missing code or state' });
2891
+ return;
2892
+ }
2893
+ const email = await exchangeCodexOAuthCode(code, stateParam);
2894
+ sendJSON(req, res, 200, { success: true, email });
2895
+ } catch (e) {
2896
+ codexOAuthState = { status: 'error', error: e.message, email: null };
2897
+ codexOAuthPending = null;
2898
+ sendJSON(req, res, 400, { error: e.message });
2899
+ }
2900
+ return;
2901
+ }
2902
+
2903
+ if (pathOnly === '/api/codex-oauth/complete' && req.method === 'POST') {
2904
+ try {
2905
+ const body = await parseBody(req);
2906
+ const pastedUrl = (body.url || '').trim();
2907
+ if (!pastedUrl) {
2908
+ sendJSON(req, res, 400, { error: 'No URL provided' });
2909
+ return;
2910
+ }
2911
+ let parsed;
2912
+ try { parsed = new URL(pastedUrl); } catch (_) {
2913
+ sendJSON(req, res, 400, { error: 'Invalid URL. Paste the full URL from the browser address bar.' });
2914
+ return;
2915
+ }
2916
+ const error = parsed.searchParams.get('error');
2917
+ if (error) {
2918
+ const desc = parsed.searchParams.get('error_description') || error;
2919
+ codexOAuthState = { status: 'error', error: desc, email: null };
2920
+ codexOAuthPending = null;
2921
+ sendJSON(req, res, 200, { error: desc });
2922
+ return;
2923
+ }
2924
+ const code = parsed.searchParams.get('code');
2925
+ const state = parsed.searchParams.get('state');
2926
+ const email = await exchangeCodexOAuthCode(code, state);
2927
+ sendJSON(req, res, 200, { success: true, email });
2928
+ } catch (e) {
2929
+ codexOAuthState = { status: 'error', error: e.message, email: null };
2930
+ codexOAuthPending = null;
2931
+ sendJSON(req, res, 400, { error: e.message });
2932
+ }
2933
+ return;
2934
+ }
2935
+
2625
2936
  const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
2626
2937
  if (agentAuthMatch && req.method === 'POST') {
2627
2938
  const agentId = agentAuthMatch[1];
2628
2939
  const agent = discoveredAgents.find(a => a.id === agentId);
2629
2940
  if (!agent) { sendJSON(req, res, 404, { error: 'Agent not found' }); return; }
2630
2941
 
2942
+ if (agentId === 'codex' || agentId === 'cli-codex') {
2943
+ try {
2944
+ const result = await startCodexOAuth(req);
2945
+ const conversationId = '__agent_auth__';
2946
+ broadcastSync({ type: 'script_started', conversationId, script: 'auth-codex', agentId: 'codex', timestamp: Date.now() });
2947
+ broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening OpenAI OAuth in your browser...\x1b[0m\r\n\r\nIf it doesn't open automatically, visit:\r\n${result.authUrl}\r\n`, stream: 'stdout', timestamp: Date.now() });
2948
+
2949
+ const pollId = setInterval(() => {
2950
+ if (codexOAuthState.status === 'success') {
2951
+ clearInterval(pollId);
2952
+ const email = codexOAuthState.email || '';
2953
+ broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
2954
+ broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
2955
+ } else if (codexOAuthState.status === 'error') {
2956
+ clearInterval(pollId);
2957
+ broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${codexOAuthState.error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
2958
+ broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: codexOAuthState.error, timestamp: Date.now() });
2959
+ }
2960
+ }, 1000);
2961
+
2962
+ setTimeout(() => clearInterval(pollId), 5 * 60 * 1000);
2963
+
2964
+ sendJSON(req, res, 200, { ok: true, agentId, authUrl: result.authUrl, mode: result.mode });
2965
+ return;
2966
+ } catch (e) {
2967
+ console.error('[codex-oauth] /api/agents/codex/auth failed:', e);
2968
+ sendJSON(req, res, 500, { error: e.message });
2969
+ return;
2970
+ }
2971
+ }
2972
+
2631
2973
  if (agentId === 'gemini') {
2632
2974
  try {
2633
2975
  const result = await startGeminiOAuth(req);
@@ -4190,6 +4532,8 @@ registerUtilHandlers(wsRouter, {
4190
4532
  broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
4191
4533
  startGeminiOAuth, exchangeGeminiOAuthCode,
4192
4534
  geminiOAuthState: () => geminiOAuthState,
4535
+ startCodexOAuth, exchangeCodexOAuthCode,
4536
+ codexOAuthState: () => codexOAuthState,
4193
4537
  STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents
4194
4538
  });
4195
4539