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 +27 -0
- package/lib/ws-handlers-util.js +82 -42
- package/package.json +1 -1
- package/server.js +344 -0
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
|
package/lib/ws-handlers-util.js
CHANGED
|
@@ -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
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 ? '✓' : '✗';
|
|
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;">⌛</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
|
|