agentgui 1.0.701 → 1.0.702

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,7 +82,6 @@ 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`)
86
85
 
87
86
  ## ACP Tool Lifecycle
88
87
 
@@ -124,11 +123,6 @@ All routes are prefixed with `BASE_URL` (default `/gm`).
124
123
  - `GET /api/tools/:id/history` - Get tool install/update history (query: limit, offset)
125
124
  - `POST /api/tools/update` - Batch update all tools with available updates
126
125
  - `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)
132
126
 
133
127
  ## Tool Update System
134
128
 
@@ -292,27 +286,6 @@ Speech models (~470MB total) are downloaded automatically on server startup. No
292
286
  - **Client init:** `loadAgents()`, `loadConversations()`, `checkSpeechStatus()` run in parallel via `Promise.all()`.
293
287
  - **`perMessageDeflate: false`** on WebSocket server — msgpack binary doesn't compress well, and zlib was blocking the event loop on every streaming_progress send.
294
288
 
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
-
316
289
  ## ACP SDK Integration
317
290
 
318
291
  - **@agentclientprotocol/sdk** (`^0.4.1`) added to dependencies
@@ -9,7 +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
+ startCodexDeviceAuth, codexDeviceAuthState,
13
13
  STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents } = deps;
14
14
 
15
15
  router.handle('home', () => ({ home: os.homedir(), cwd: STARTUP_CWD }));
@@ -158,46 +158,31 @@ export function register(router, deps) {
158
158
  } catch (e) { err(400, e.message); }
159
159
  });
160
160
 
161
- router.handle('gemini.complete', async (p) => {
162
- const pastedUrl = (p.url || '').trim();
163
- if (!pastedUrl) err(400, 'No URL provided');
164
- let parsed;
165
- try { parsed = new URL(pastedUrl); } catch { err(400, 'Invalid URL. Paste the full URL from the browser address bar.'); }
166
- const urlError = parsed.searchParams.get('error');
167
- if (urlError) {
168
- const desc = parsed.searchParams.get('error_description') || urlError;
169
- return { error: desc };
170
- }
171
- const code = parsed.searchParams.get('code');
172
- const state = parsed.searchParams.get('state');
173
- try {
174
- const email = await exchangeGeminiOAuthCode(code, state);
175
- return { success: true, email };
176
- } catch (e) { err(400, e.message); }
177
- });
178
-
179
161
  router.handle('codex.start', async () => {
180
162
  try {
181
- const result = await startCodexOAuth();
182
- return { authUrl: result.authUrl, mode: result.mode };
163
+ const result = startCodexDeviceAuth();
164
+ const st = typeof codexDeviceAuthState === 'function' ? codexDeviceAuthState() : codexDeviceAuthState;
165
+ if (result.alreadyAuthenticated) return { status: 'success', authenticated: true };
166
+ const waitForCode = () => new Promise((resolve) => {
167
+ let tries = 12;
168
+ const poll = () => {
169
+ const state = typeof codexDeviceAuthState === 'function' ? codexDeviceAuthState() : codexDeviceAuthState;
170
+ if (state.authUrl || tries-- <= 0) resolve(state);
171
+ else setTimeout(poll, 400);
172
+ };
173
+ poll();
174
+ });
175
+ const state = await waitForCode();
176
+ return { status: state.status, authUrl: state.authUrl, userCode: state.userCode };
183
177
  } catch (e) { err(500, e.message); }
184
178
  });
185
179
 
186
180
  router.handle('codex.status', () => {
187
- const st = typeof codexOAuthState === 'function' ? codexOAuthState() : codexOAuthState;
181
+ const st = typeof codexDeviceAuthState === 'function' ? codexDeviceAuthState() : codexDeviceAuthState;
188
182
  return st;
189
183
  });
190
184
 
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) => {
185
+ router.handle('gemini.complete', async (p) => {
201
186
  const pastedUrl = (p.url || '').trim();
202
187
  if (!pastedUrl) err(400, 'No URL provided');
203
188
  let parsed;
@@ -210,7 +195,7 @@ export function register(router, deps) {
210
195
  const code = parsed.searchParams.get('code');
211
196
  const state = parsed.searchParams.get('state');
212
197
  try {
213
- const email = await exchangeCodexOAuthCode(code, state);
198
+ const email = await exchangeGeminiOAuthCode(code, state);
214
199
  return { success: true, email };
215
200
  } catch (e) { err(400, e.message); }
216
201
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.701",
3
+ "version": "1.0.702",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -696,6 +696,96 @@ const GEMINI_ACCOUNTS_FILE = path.join(GEMINI_DIR, 'google_accounts.json');
696
696
  let geminiOAuthState = { status: 'idle', error: null, email: null };
697
697
  let geminiOAuthPending = null;
698
698
 
699
+ const CODEX_AUTH_FILE = path.join(os.homedir(), '.codex', 'auth.json');
700
+ let codexDeviceAuthState = { status: 'idle', error: null, userCode: null, authUrl: null };
701
+ let codexDeviceAuthProcess = null;
702
+
703
+ function getCodexAuthStatus() {
704
+ try {
705
+ if (fs.existsSync(CODEX_AUTH_FILE)) {
706
+ const creds = JSON.parse(fs.readFileSync(CODEX_AUTH_FILE, 'utf-8'));
707
+ if (creds.auth_mode === 'oauth' || creds.access_token || creds.refresh_token) {
708
+ return { authenticated: true, detail: creds.email || 'oauth' };
709
+ }
710
+ if (creds.auth_mode === 'apikey' && creds.OPENAI_API_KEY) {
711
+ return { authenticated: true, detail: 'api-key' };
712
+ }
713
+ }
714
+ } catch (_) {}
715
+ return { authenticated: false, detail: 'no credentials' };
716
+ }
717
+
718
+ function startCodexDeviceAuth() {
719
+ if (codexDeviceAuthProcess) {
720
+ return { alreadyRunning: true };
721
+ }
722
+ const codexStatus = getCodexAuthStatus();
723
+ if (codexStatus.authenticated) {
724
+ codexDeviceAuthState = { status: 'success', error: null, userCode: null, authUrl: null };
725
+ return { alreadyAuthenticated: true };
726
+ }
727
+ codexDeviceAuthState = { status: 'pending', error: null, userCode: null, authUrl: null };
728
+ const child = spawn('npx', ['@openai/codex', 'login', '--device-auth'], {
729
+ stdio: ['ignore', 'pipe', 'pipe'],
730
+ env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
731
+ shell: os.platform() === 'win32',
732
+ });
733
+ codexDeviceAuthProcess = child;
734
+
735
+ const onData = (chunk) => {
736
+ const text = chunk.toString().replace(/\x1b\[[0-9;]*m/g, '');
737
+ const urlMatch = text.match(/https:\/\/auth\.openai\.com\/codex\/device[^\s]*/);
738
+ const codeMatch = text.match(/\b([0-9A-Z]{4}-[0-9A-Z]{5})\b/);
739
+ if (urlMatch && !codexDeviceAuthState.authUrl) codexDeviceAuthState.authUrl = urlMatch[0];
740
+ if (codeMatch && !codexDeviceAuthState.userCode) codexDeviceAuthState.userCode = codeMatch[1];
741
+ };
742
+ child.stdout.on('data', onData);
743
+ child.stderr.on('data', onData);
744
+
745
+ const authFileWatcher = fs.watchFile ? null : null;
746
+ const pollInterval = setInterval(() => {
747
+ const st = getCodexAuthStatus();
748
+ if (st.authenticated) {
749
+ clearInterval(pollInterval);
750
+ clearTimeout(timeoutId);
751
+ codexDeviceAuthState = { status: 'success', error: null, userCode: codexDeviceAuthState.userCode, authUrl: codexDeviceAuthState.authUrl };
752
+ if (codexDeviceAuthProcess) { try { codexDeviceAuthProcess.kill('SIGTERM'); } catch (_) {} codexDeviceAuthProcess = null; }
753
+ }
754
+ }, 1500);
755
+
756
+ const timeoutId = setTimeout(() => {
757
+ clearInterval(pollInterval);
758
+ if (codexDeviceAuthState.status === 'pending') {
759
+ codexDeviceAuthState = { status: 'error', error: 'Authentication timed out', userCode: null, authUrl: null };
760
+ }
761
+ if (codexDeviceAuthProcess) { try { codexDeviceAuthProcess.kill('SIGTERM'); } catch (_) {} codexDeviceAuthProcess = null; }
762
+ }, 5 * 60 * 1000);
763
+
764
+ child.on('error', (e) => {
765
+ clearInterval(pollInterval);
766
+ clearTimeout(timeoutId);
767
+ codexDeviceAuthState = { status: 'error', error: e.message, userCode: null, authUrl: null };
768
+ codexDeviceAuthProcess = null;
769
+ });
770
+ child.on('close', (code) => {
771
+ clearInterval(pollInterval);
772
+ clearTimeout(timeoutId);
773
+ codexDeviceAuthProcess = null;
774
+ if (codexDeviceAuthState.status === 'pending') {
775
+ if (code === 0) {
776
+ const st = getCodexAuthStatus();
777
+ codexDeviceAuthState = st.authenticated
778
+ ? { status: 'success', error: null, userCode: codexDeviceAuthState.userCode, authUrl: codexDeviceAuthState.authUrl }
779
+ : { status: 'error', error: 'Process exited without saving credentials', userCode: null, authUrl: null };
780
+ } else {
781
+ codexDeviceAuthState = { status: 'error', error: 'Authentication cancelled', userCode: null, authUrl: null };
782
+ }
783
+ }
784
+ });
785
+
786
+ return { started: true };
787
+ }
788
+
699
789
  function buildBaseUrl(req) {
700
790
  const override = process.env.AGENTGUI_BASE_URL;
701
791
  if (override) return override.replace(/\/+$/, '');
@@ -949,238 +1039,6 @@ function getGeminiOAuthStatus() {
949
1039
  return null;
950
1040
  }
951
1041
 
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
-
1184
1042
  const PROVIDER_CONFIGS = {
1185
1043
  'anthropic': {
1186
1044
  name: 'Anthropic', configPaths: [
@@ -1269,13 +1127,6 @@ function getProviderConfigs() {
1269
1127
  continue;
1270
1128
  }
1271
1129
  }
1272
- if (providerId === 'codex') {
1273
- const oauthStatus = getCodexOAuthStatus();
1274
- if (oauthStatus) {
1275
- configs[providerId] = { name: config.name, ...oauthStatus };
1276
- continue;
1277
- }
1278
- }
1279
1130
  for (const configPath of config.configPaths) {
1280
1131
  try {
1281
1132
  if (fs.existsSync(configPath)) {
@@ -1406,11 +1257,6 @@ const server = http.createServer(async (req, res) => {
1406
1257
  return;
1407
1258
  }
1408
1259
 
1409
- if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') {
1410
- await handleCodexOAuthCallback(req, res);
1411
- return;
1412
- }
1413
-
1414
1260
  if (pathOnly === '/api/conversations' && req.method === 'GET') {
1415
1261
  const conversations = queries.getConversationsList();
1416
1262
  // Filter out stale streaming state using a single bulk query instead of N+1 per-conversation queries
@@ -2421,6 +2267,10 @@ const server = http.createServer(async (req, res) => {
2421
2267
  } else {
2422
2268
  status.detail = 'no credentials';
2423
2269
  }
2270
+ } else if (agent.id === 'codex') {
2271
+ const codexSt = getCodexAuthStatus();
2272
+ status.authenticated = codexSt.authenticated;
2273
+ status.detail = codexSt.detail;
2424
2274
  } else {
2425
2275
  status.detail = 'unknown';
2426
2276
  }
@@ -2866,110 +2716,12 @@ const server = http.createServer(async (req, res) => {
2866
2716
  return;
2867
2717
  }
2868
2718
 
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
-
2936
2719
  const agentAuthMatch = pathOnly.match(/^\/api\/agents\/([^/]+)\/auth$/);
2937
2720
  if (agentAuthMatch && req.method === 'POST') {
2938
2721
  const agentId = agentAuthMatch[1];
2939
2722
  const agent = discoveredAgents.find(a => a.id === agentId);
2940
2723
  if (!agent) { sendJSON(req, res, 404, { error: 'Agent not found' }); return; }
2941
2724
 
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
-
2973
2725
  if (agentId === 'gemini') {
2974
2726
  try {
2975
2727
  const result = await startGeminiOAuth(req);
@@ -3001,6 +2753,55 @@ const server = http.createServer(async (req, res) => {
3001
2753
  }
3002
2754
  }
3003
2755
 
2756
+ if (agentId === 'codex') {
2757
+ const conversationId = '__agent_auth__';
2758
+ const result = startCodexDeviceAuth();
2759
+ if (result.alreadyRunning) {
2760
+ sendJSON(req, res, 200, { ok: true, agentId, authUrl: codexDeviceAuthState.authUrl, userCode: codexDeviceAuthState.userCode, status: 'pending' });
2761
+ return;
2762
+ }
2763
+ if (result.alreadyAuthenticated) {
2764
+ sendJSON(req, res, 200, { ok: true, agentId, status: 'success' });
2765
+ return;
2766
+ }
2767
+ broadcastSync({ type: 'script_started', conversationId, script: 'auth-codex', agentId: 'codex', timestamp: Date.now() });
2768
+ broadcastSync({ type: 'script_output', conversationId, data: '\x1b[36mStarting Codex device authorization...\x1b[0m\r\n', stream: 'stdout', timestamp: Date.now() });
2769
+
2770
+ const waitForCode = (tries) => {
2771
+ if (tries <= 0) return;
2772
+ if (codexDeviceAuthState.authUrl && codexDeviceAuthState.userCode) {
2773
+ const msg = `\r\nVisit: ${codexDeviceAuthState.authUrl}\r\nEnter code: ${codexDeviceAuthState.userCode}\r\n`;
2774
+ broadcastSync({ type: 'script_output', conversationId, data: msg, stream: 'stdout', timestamp: Date.now() });
2775
+ return;
2776
+ }
2777
+ setTimeout(() => waitForCode(tries - 1), 500);
2778
+ };
2779
+ waitForCode(10);
2780
+
2781
+ const pollId = setInterval(() => {
2782
+ if (codexDeviceAuthState.status === 'success') {
2783
+ clearInterval(pollId);
2784
+ broadcastSync({ type: 'script_output', conversationId, data: '\r\n\x1b[32mCodex authentication successful\x1b[0m\r\n', stream: 'stdout', timestamp: Date.now() });
2785
+ broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
2786
+ } else if (codexDeviceAuthState.status === 'error') {
2787
+ clearInterval(pollId);
2788
+ broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${codexDeviceAuthState.error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
2789
+ broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: codexDeviceAuthState.error, timestamp: Date.now() });
2790
+ }
2791
+ }, 1500);
2792
+ setTimeout(() => clearInterval(pollId), 5 * 60 * 1000 + 5000);
2793
+
2794
+ const waitForInfo = (tries) => {
2795
+ if (codexDeviceAuthState.authUrl || tries <= 0) {
2796
+ sendJSON(req, res, 200, { ok: true, agentId, authUrl: codexDeviceAuthState.authUrl, userCode: codexDeviceAuthState.userCode, status: codexDeviceAuthState.status });
2797
+ } else {
2798
+ setTimeout(() => waitForInfo(tries - 1), 400);
2799
+ }
2800
+ };
2801
+ waitForInfo(12);
2802
+ return;
2803
+ }
2804
+
3004
2805
  const authCommands = {
3005
2806
  'claude-code': { cmd: 'claude', args: ['setup-token'] },
3006
2807
  'opencode': { cmd: 'opencode', args: ['auth', 'login'] },
@@ -4532,8 +4333,7 @@ registerUtilHandlers(wsRouter, {
4532
4333
  broadcastSync, getSpeech, getProviderConfigs, saveProviderConfig,
4533
4334
  startGeminiOAuth, exchangeGeminiOAuthCode,
4534
4335
  geminiOAuthState: () => geminiOAuthState,
4535
- startCodexOAuth, exchangeCodexOAuthCode,
4536
- codexOAuthState: () => codexOAuthState,
4336
+ startCodexDeviceAuth, codexDeviceAuthState: () => codexDeviceAuthState,
4537
4337
  STARTUP_CWD, activeScripts, voiceCacheManager, toolManager, discoveredAgents
4538
4338
  });
4539
4339
 
@@ -128,6 +128,7 @@
128
128
  function closeDropdown() { dropdown.classList.remove('open'); editingProvider = null; }
129
129
 
130
130
  var oauthPollInterval = null, oauthPollTimeout = null, oauthFallbackTimer = null;
131
+ var codexPollInterval = null, codexPollTimeout = null;
131
132
 
132
133
  function cleanupOAuthPolling() {
133
134
  if (oauthPollInterval) { clearInterval(oauthPollInterval); oauthPollInterval = null; }
@@ -135,6 +136,96 @@
135
136
  if (oauthFallbackTimer) { clearTimeout(oauthFallbackTimer); oauthFallbackTimer = null; }
136
137
  }
137
138
 
139
+ function cleanupCodexPolling() {
140
+ if (codexPollInterval) { clearInterval(codexPollInterval); codexPollInterval = null; }
141
+ if (codexPollTimeout) { clearTimeout(codexPollTimeout); codexPollTimeout = null; }
142
+ }
143
+
144
+ function removeCodexModal() {
145
+ var el = document.getElementById('codexDeviceModal');
146
+ if (el) el.remove();
147
+ }
148
+
149
+ function showCodexDeviceModal(authUrl, userCode) {
150
+ removeCodexModal();
151
+ var overlay = document.createElement('div');
152
+ overlay.id = 'codexDeviceModal';
153
+ overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.7);display:flex;align-items:center;justify-content:center;z-index:9999;';
154
+ var displayUrl = authUrl || 'https://auth.openai.com/codex/device';
155
+ var displayCode = userCode || '';
156
+ overlay.innerHTML = '<div style="background:var(--color-bg-secondary,#1f2937);border-radius:1rem;padding:2rem;max-width:30rem;width:calc(100% - 2rem);box-shadow:0 25px 50px rgba(0,0,0,0.5);color:var(--color-text-primary,white);font-family:system-ui,sans-serif;" onclick="event.stopPropagation()">' +
157
+ '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1.25rem;">' +
158
+ '<h2 style="font-size:1.125rem;font-weight:700;margin:0;">Codex Sign-In</h2>' +
159
+ '<button id="codexModalClose" style="background:none;border:none;color:var(--color-text-secondary,#9ca3af);font-size:1.5rem;cursor:pointer;padding:0;line-height:1;">\u00d7</button></div>' +
160
+ '<div id="codexModalContent" style="text-align:center;">' +
161
+ '<div style="font-size:2rem;margin-bottom:1rem;animation:pulse 2s infinite;">&#9203;</div>' +
162
+ '<p style="font-size:0.85rem;color:var(--color-text-secondary,#d1d5db);margin:0 0 1rem;">Follow these steps to sign in with your OpenAI account:</p>' +
163
+ '<div style="margin-bottom:1rem;">' +
164
+ '<p style="font-size:0.8rem;color:var(--color-text-secondary,#9ca3af);margin:0 0 0.5rem;text-align:left;">1. Open this link in your browser:</p>' +
165
+ '<div style="display:flex;gap:0.5rem;align-items:center;">' +
166
+ '<a href="' + esc(displayUrl) + '" target="_blank" style="flex:1;padding:0.5rem 0.75rem;background:var(--color-primary,#3b82f6);color:white;text-decoration:none;border-radius:0.375rem;font-size:0.8rem;font-weight:600;text-align:center;">Open Sign-In Page</a>' +
167
+ '</div></div>' +
168
+ '<div style="margin-bottom:1.25rem;">' +
169
+ '<p style="font-size:0.8rem;color:var(--color-text-secondary,#9ca3af);margin:0 0 0.5rem;text-align:left;">2. Enter this one-time code:</p>' +
170
+ '<div style="display:flex;gap:0.5rem;align-items:center;">' +
171
+ '<div id="codexUserCode" style="flex:1;padding:0.625rem;background:var(--color-bg-primary,#111827);border:2px solid var(--color-primary,#3b82f6);border-radius:0.5rem;font-family:monospace;font-size:1.25rem;font-weight:700;letter-spacing:0.15em;text-align:center;">' + esc(displayCode) + '</div>' +
172
+ '<button id="codexCopyBtn" style="padding:0.5rem 0.75rem;background:var(--color-bg-primary,#374151);border:1px solid var(--color-border,#4b5563);border-radius:0.375rem;color:var(--color-text-primary,white);font-size:0.75rem;cursor:pointer;flex-shrink:0;">Copy</button>' +
173
+ '</div></div>' +
174
+ '<p style="font-size:0.75rem;color:var(--color-text-secondary,#6b7280);margin:0;">This dialog will close automatically when sign-in completes.</p>' +
175
+ '</div>' +
176
+ '<div id="codexModalSuccess" style="display:none;text-align:center;padding:1rem 0;">' +
177
+ '<div style="font-size:3rem;color:#10b981;margin-bottom:0.75rem;">&#10003;</div>' +
178
+ '<p style="font-weight:600;margin:0 0 0.25rem;">Authentication Successful</p>' +
179
+ '<p style="font-size:0.8rem;color:var(--color-text-secondary,#9ca3af);margin:0;">Codex CLI is now authenticated.</p>' +
180
+ '</div>' +
181
+ '<div style="margin-top:1.25rem;">' +
182
+ '<button id="codexModalCancel" style="width:100%;padding:0.625rem;border-radius:0.5rem;border:1px solid var(--color-border,#4b5563);background:transparent;color:var(--color-text-primary,white);font-size:0.8rem;cursor:pointer;font-weight:600;">Cancel</button></div>' +
183
+ '<style>@keyframes pulse{0%,100%{opacity:1}50%{opacity:0.5}}</style></div>';
184
+ document.body.appendChild(overlay);
185
+
186
+ var dismiss = function() { cleanupCodexPolling(); authRunning = false; removeCodexModal(); };
187
+ document.getElementById('codexModalClose').addEventListener('click', dismiss);
188
+ document.getElementById('codexModalCancel').addEventListener('click', dismiss);
189
+
190
+ var copyBtn = document.getElementById('codexCopyBtn');
191
+ if (copyBtn && displayCode) {
192
+ copyBtn.addEventListener('click', function(e) {
193
+ e.stopPropagation();
194
+ navigator.clipboard.writeText(displayCode).then(function() {
195
+ copyBtn.textContent = 'Copied!';
196
+ setTimeout(function() { copyBtn.textContent = 'Copy'; }, 2000);
197
+ }).catch(function() {});
198
+ });
199
+ }
200
+
201
+ cleanupCodexPolling();
202
+ codexPollInterval = setInterval(function() {
203
+ window.wsClient.rpc('codex.status')
204
+ .then(function(st) {
205
+ if (st.status === 'success') {
206
+ cleanupCodexPolling();
207
+ authRunning = false;
208
+ var content = document.getElementById('codexModalContent');
209
+ var success = document.getElementById('codexModalSuccess');
210
+ var cancel = document.getElementById('codexModalCancel');
211
+ if (content) content.style.display = 'none';
212
+ if (success) success.style.display = 'block';
213
+ if (cancel) cancel.textContent = 'Close';
214
+ setTimeout(function() { removeCodexModal(); refresh(); }, 2500);
215
+ } else if (st.status === 'error') {
216
+ cleanupCodexPolling();
217
+ authRunning = false;
218
+ removeCodexModal();
219
+ refresh();
220
+ }
221
+ }).catch(function() {});
222
+ }, 1500);
223
+ codexPollTimeout = setTimeout(function() {
224
+ cleanupCodexPolling();
225
+ if (authRunning) { authRunning = false; removeCodexModal(); }
226
+ }, 5 * 60 * 1000);
227
+ }
228
+
138
229
  function showOAuthWaitingModal() {
139
230
  removeOAuthModal();
140
231
  var overlay = document.createElement('div');
@@ -217,6 +308,19 @@
217
308
 
218
309
  function triggerAuth(agentId) {
219
310
  if (authRunning) return;
311
+ if (agentId === 'codex') {
312
+ authRunning = true;
313
+ window.wsClient.rpc('codex.start')
314
+ .then(function(data) {
315
+ if (data.status === 'success') {
316
+ authRunning = false;
317
+ refresh();
318
+ return;
319
+ }
320
+ showCodexDeviceModal(data.authUrl, data.userCode);
321
+ }).catch(function() { authRunning = false; });
322
+ return;
323
+ }
220
324
  window.wsClient.rpc('agent.auth', { id: agentId })
221
325
  .then(function(data) {
222
326
  if (data.ok) {
@@ -271,6 +375,8 @@
271
375
  authRunning = false;
272
376
  removeOAuthModal();
273
377
  cleanupOAuthPolling();
378
+ cleanupCodexPolling();
379
+ removeCodexModal();
274
380
  var term = getTerminal();
275
381
  var msg = data.error ? data.error : ('exited with code ' + (data.code || 0));
276
382
  if (term) term.writeln('\r\n\x1b[90m[auth ' + msg + ']\x1b[0m');