agentgui 1.0.917 → 1.0.919

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.
Files changed (53) hide show
  1. package/database-schema.js +0 -58
  2. package/lib/db-queries-cleanup.js +0 -12
  3. package/lib/db-queries-del.js +0 -1
  4. package/lib/db-queries.js +0 -4
  5. package/lib/http-handler.js +1 -10
  6. package/lib/plugins/acp-plugin.js +1 -2
  7. package/lib/plugins/database-plugin.js +1 -1
  8. package/lib/process-message.js +2 -2
  9. package/lib/provider-config.js +0 -16
  10. package/lib/recovery.js +2 -12
  11. package/lib/routes-agent-actions.js +2 -58
  12. package/lib/routes-debug.js +1 -7
  13. package/lib/routes-registry.js +5 -17
  14. package/lib/server-startup.js +1 -59
  15. package/lib/stream-event-handler.js +1 -3
  16. package/lib/ws-handlers-session2.js +2 -23
  17. package/lib/ws-handlers-util.js +106 -175
  18. package/lib/ws-legacy-handlers.js +1 -104
  19. package/lib/ws-setup.js +1 -3
  20. package/package.json +1 -15
  21. package/server.js +9 -26
  22. package/test.js +1 -21
  23. package/ecosystem.config.cjs +0 -22
  24. package/lib/checkpoint-manager.js +0 -182
  25. package/lib/db-queries-tools.js +0 -131
  26. package/lib/db-queries-voice.js +0 -85
  27. package/lib/oauth-codex.js +0 -164
  28. package/lib/oauth-common.js +0 -92
  29. package/lib/oauth-gemini.js +0 -199
  30. package/lib/plugins/auth-plugin.js +0 -132
  31. package/lib/plugins/speech-plugin.js +0 -72
  32. package/lib/plugins/tools-plugin.js +0 -120
  33. package/lib/pm2-manager.js +0 -170
  34. package/lib/routes-oauth.js +0 -105
  35. package/lib/routes-speech.js +0 -173
  36. package/lib/routes-tools.js +0 -184
  37. package/lib/speech-manager.js +0 -200
  38. package/lib/speech.js +0 -50
  39. package/lib/tool-install-machine.js +0 -157
  40. package/lib/tool-manager.js +0 -98
  41. package/lib/tool-provisioner.js +0 -98
  42. package/lib/tool-spawner.js +0 -163
  43. package/lib/tool-version-check.js +0 -196
  44. package/lib/tool-version-fetch.js +0 -68
  45. package/lib/ws-handlers-oauth.js +0 -76
  46. package/static/js/agent-auth-oauth.js +0 -159
  47. package/static/js/pm2-monitor.js +0 -151
  48. package/static/js/stt-handler.js +0 -147
  49. package/static/js/tool-install-machine.js +0 -155
  50. package/static/js/tools-manager-ui.js +0 -124
  51. package/static/js/tools-manager.js +0 -172
  52. package/static/js/voice-machine.js +0 -145
  53. package/static/js/voice.js +0 -134
package/test.js CHANGED
@@ -7,12 +7,9 @@ import { createQueries } from './lib/db-queries.js';
7
7
  import { encode, decode } from './lib/codec.js';
8
8
  import { WsRouter } from './lib/ws-protocol.js';
9
9
  import { WSOptimizer } from './lib/ws-optimizer.js';
10
- import * as tim from './lib/tool-install-machine.js';
11
10
  import * as exm from './lib/execution-machine.js';
12
11
  import * as asm from './lib/acp-server-machine.js';
13
12
  import { maskKey, buildSystemPrompt } from './lib/provider-config.js';
14
- import { buildBaseUrl, isRemoteRequest, encodeOAuthState, decodeOAuthState } from './lib/oauth-common.js';
15
- import { compareVersions } from './lib/tool-version-check.js';
16
13
  import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
17
14
  import { createACPProtocolHandler } from './lib/acp-protocol.js';
18
15
  import { sendJSON, compressAndSend, acceptsEncoding } from './lib/http-utils.js';
@@ -87,8 +84,7 @@ await ok('WsRouter: dispatch + 404 + error + legacy', async () => {
87
84
  assert.deepEqual(replies[0], { r: 1, d: { pong: 7 } });
88
85
  assert.equal(replies[1].e.c, 404); assert.equal(replies[2].e.c, 422); assert.equal(legacy.type, 'subscribe');
89
86
  });
90
- await ok('machines: tool-install + execution + acp-server lifecycle', () => {
91
- assert.ok(tim.getMachineActors() instanceof Map);
87
+ await ok('machines: execution + acp-server lifecycle', () => {
92
88
  assert.equal(exm.snapshot('nonexistent-conv-id'), null);
93
89
  assert.equal(asm.getOrCreate('test-tool').getSnapshot().value, 'stopped');
94
90
  asm.send('test-tool', { type: 'START', pid: 123 });
@@ -122,22 +118,6 @@ await ok('provider-config: maskKey + buildSystemPrompt', () => {
122
118
  assert.equal(buildSystemPrompt('opencode', 'sonnet'), 'Use opencode subagent for all tasks. Model: sonnet.');
123
119
  assert.equal(buildSystemPrompt('foo-·-bar', null, 'sub'), 'Use foo subagent for all tasks. Subagent: sub.');
124
120
  });
125
- await ok('oauth-common: state codec + url helpers', () => {
126
- const dec = decodeOAuthState(encodeOAuthState('csrf-tok', 'https://relay.test/cb'));
127
- assert.equal(dec.csrfToken, 'csrf-tok'); assert.equal(dec.relayUrl, 'https://relay.test/cb');
128
- const fb = decodeOAuthState('not-base64-json');
129
- assert.equal(fb.csrfToken, 'not-base64-json'); assert.equal(fb.relayUrl, null);
130
- const reqRemote = { headers: { 'x-forwarded-host': 'a.com', 'x-forwarded-proto': 'https' }, socket: {} };
131
- assert.equal(buildBaseUrl(reqRemote, 3000), 'https://a.com'); assert.equal(isRemoteRequest(reqRemote), true);
132
- assert.equal(buildBaseUrl({ headers: {}, socket: {} }, 3000), 'http://127.0.0.1:3000');
133
- assert.equal(isRemoteRequest({ headers: {} }), false);
134
- });
135
- await ok('tool-version-check: compareVersions', () => {
136
- assert.equal(compareVersions('1.0.0', '1.0.1'), true); assert.equal(compareVersions('1.0.1', '1.0.0'), false);
137
- assert.equal(compareVersions('1.0.0', '1.0.0'), false); assert.equal(compareVersions('1.2', '1.2.1'), true);
138
- assert.equal(compareVersions('2.0.0', '1.99.99'), false);
139
- assert.equal(compareVersions(null, '1.0.0'), false); assert.equal(compareVersions('1.0.0', null), false);
140
- });
141
121
  await ok('agent-descriptors: initialize + cache', () => {
142
122
  assert.equal(initializeDescriptors([{ id: 'claude-code', name: 'Claude Code', path: '/x' }]), 1);
143
123
  const d = getAgentDescriptor('claude-code');
@@ -1,22 +0,0 @@
1
- module.exports = {
2
- apps: [{
3
- name: 'agentgui',
4
- script: 'server.js',
5
- interpreter: 'node',
6
- interpreter_args: '--experimental-vm-modules',
7
- watch: false,
8
- env: {
9
- NODE_ENV: 'development',
10
- PORT: '3000',
11
- HOT_RELOAD: 'false'
12
- },
13
- max_memory_restart: '512M',
14
- restart_delay: 2000,
15
- max_restarts: 10,
16
- exp_backoff_restart_delay: 100,
17
- error_file: '~/.gmgui/logs/err.log',
18
- out_file: '~/.gmgui/logs/out.log',
19
- merge_logs: true,
20
- time: true
21
- }]
22
- };
@@ -1,182 +0,0 @@
1
- /**
2
- * Checkpoint Manager
3
- * Handles session recovery by loading checkpoints and injecting into resume flow
4
- * Ensures idempotency and prevents duplicate event replay
5
- */
6
-
7
- class CheckpointManager {
8
- constructor(queries) {
9
- this.queries = queries;
10
- this._injectedSessions = new Set(); // Track which sessions already had checkpoints injected
11
- this._pendingCheckpoints = new Map(); // Store checkpoints to inject on subscribe: { conversationId -> checkpoint }
12
- }
13
-
14
- /**
15
- * Load checkpoint for a session (all events + chunks from previous session)
16
- * Used when resuming after interruption
17
- */
18
- loadCheckpoint(previousSessionId) {
19
- if (!previousSessionId) return null;
20
-
21
- try {
22
- const session = this.queries.getSession(previousSessionId);
23
- if (!session) return null;
24
-
25
- const events = this.queries.getSessionEvents(previousSessionId);
26
- const chunks = this.queries.getChunksSinceSeq(previousSessionId, -1);
27
-
28
- return {
29
- sessionId: previousSessionId,
30
- conversationId: session.conversationId,
31
- events: events || [],
32
- chunks: chunks || [],
33
- lastSequence: chunks.length > 0 ? Math.max(...chunks.map(c => c.sequence)) : -1
34
- };
35
- } catch (e) {
36
- console.error(`[checkpoint] Failed to load checkpoint for session ${previousSessionId}:`, e.message);
37
- return null;
38
- }
39
- }
40
-
41
- /**
42
- * Inject checkpoint events into the new session
43
- * Marks them with resumeOrigin to prevent replay
44
- * Returns the next sequence number to use
45
- */
46
- injectCheckpointEvents(newSessionId, checkpoint, broadcastFn) {
47
- if (!checkpoint || !checkpoint.events || checkpoint.events.length === 0) {
48
- return -1;
49
- }
50
-
51
- // Prevent double-injection for same session
52
- const injectionKey = `${newSessionId}:checkpoint`;
53
- if (this._injectedSessions.has(injectionKey)) {
54
- console.log(`[checkpoint] Session ${newSessionId} already had checkpoint injected, skipping`);
55
- return checkpoint.lastSequence;
56
- }
57
-
58
- let sequenceStart = checkpoint.lastSequence + 1;
59
-
60
- try {
61
- // Broadcast each checkpoint event as if it's arriving now
62
- for (const evt of checkpoint.events) {
63
- // Skip internal session management events
64
- if (evt.type === 'session.created') continue;
65
-
66
- // Re-broadcast with resume markers
67
- broadcastFn({
68
- ...evt,
69
- resumeOrigin: 'checkpoint',
70
- originalSessionId: checkpoint.sessionId,
71
- newSessionId: newSessionId,
72
- timestamp: Date.now()
73
- });
74
- }
75
-
76
- // Mark this session as having been injected
77
- this._injectedSessions.add(injectionKey);
78
-
79
- console.log(
80
- `[checkpoint] Injected ${checkpoint.events.length} events from session ` +
81
- `${checkpoint.sessionId} into new session ${newSessionId}`
82
- );
83
-
84
- return sequenceStart;
85
- } catch (e) {
86
- console.error(`[checkpoint] Failed to inject checkpoint events:`, e.message);
87
- return checkpoint.lastSequence;
88
- }
89
- }
90
-
91
- /**
92
- * Copy checkpoint chunks to new session with modified sequence
93
- * Ensures chunks are marked as injected to distinguish from new streaming
94
- */
95
- copyCheckpointChunks(oldSessionId, newSessionId, startSequence = 0) {
96
- try {
97
- const chunks = this.queries.getChunksSinceSeq(oldSessionId, -1);
98
- if (!chunks || chunks.length === 0) return 0;
99
-
100
- let copiedCount = 0;
101
-
102
- for (let i = 0; i < chunks.length; i++) {
103
- const chunk = chunks[i];
104
- const newSequence = startSequence + i;
105
-
106
- try {
107
- this.queries.createChunk(
108
- newSessionId,
109
- chunk.conversationId,
110
- newSequence,
111
- chunk.type,
112
- { ...chunk.data, resumeOrigin: 'checkpoint' }
113
- );
114
- copiedCount++;
115
- } catch (e) {
116
- console.error(`[checkpoint] Failed to copy chunk ${i}:`, e.message);
117
- }
118
- }
119
-
120
- console.log(`[checkpoint] Copied ${copiedCount} chunks from ${oldSessionId} to ${newSessionId}`);
121
- return startSequence + copiedCount;
122
- } catch (e) {
123
- console.error(`[checkpoint] Failed to copy checkpoint chunks:`, e.message);
124
- return startSequence;
125
- }
126
- }
127
-
128
- /**
129
- * Clean up: mark previous session as properly resumed
130
- * Prevents re-resuming the same interrupted session multiple times
131
- */
132
- markSessionResumed(previousSessionId) {
133
- try {
134
- this.queries.updateSession(previousSessionId, {
135
- status: 'resumed',
136
- completed_at: Date.now()
137
- });
138
- console.log(`[checkpoint] Marked session ${previousSessionId} as resumed`);
139
- } catch (e) {
140
- console.error(`[checkpoint] Failed to mark session as resumed:`, e.message);
141
- }
142
- }
143
-
144
- /**
145
- * Clear injected sessions cache (call on server restart)
146
- */
147
- reset() {
148
- this._injectedSessions.clear();
149
- }
150
-
151
- /**
152
- * Store a checkpoint to be injected when client subscribes
153
- * (Used during server startup when no clients are connected yet)
154
- */
155
- storeCheckpointForDelay(conversationId, checkpoint) {
156
- if (checkpoint && checkpoint.events && checkpoint.events.length > 0) {
157
- this._pendingCheckpoints.set(conversationId, checkpoint);
158
- console.log(`[checkpoint] Stored checkpoint for ${conversationId} to inject on subscribe (${checkpoint.events.length} events, ${checkpoint.chunks.length} chunks)`);
159
- }
160
- }
161
-
162
- /**
163
- * Get and remove a pending checkpoint (called when client subscribes)
164
- */
165
- getPendingCheckpoint(conversationId) {
166
- const checkpoint = this._pendingCheckpoints.get(conversationId);
167
- if (checkpoint) {
168
- this._pendingCheckpoints.delete(conversationId);
169
- console.log(`[checkpoint] Retrieved pending checkpoint for ${conversationId}`);
170
- }
171
- return checkpoint;
172
- }
173
-
174
- /**
175
- * Check if there's a pending checkpoint for a conversation
176
- */
177
- hasPendingCheckpoint(conversationId) {
178
- return this._pendingCheckpoints.has(conversationId);
179
- }
180
- }
181
-
182
- export default CheckpointManager;
@@ -1,131 +0,0 @@
1
- export function addToolQueries(q, db, prep, generateId) {
2
- q.initializeToolInstallations = function(tools) {
3
- const now = Date.now();
4
- for (const tool of tools) {
5
- const stmt = prep(`
6
- INSERT OR IGNORE INTO tool_installations
7
- (id, tool_id, status, created_at, updated_at)
8
- VALUES (?, ?, ?, ?, ?)
9
- `);
10
- stmt.run(generateId('tinst'), tool.id, 'not_installed', now, now);
11
- }
12
- };
13
-
14
- q.getToolStatus = function(toolId) {
15
- const stmt = prep(`
16
- SELECT id, tool_id, version, installed_at, status, last_check_at,
17
- error_message, update_available, latest_version, created_at, updated_at
18
- FROM tool_installations
19
- WHERE tool_id = ?
20
- `);
21
- return stmt.get(toolId) || null;
22
- };
23
-
24
- q.getAllToolStatuses = function() {
25
- const stmt = prep(`
26
- SELECT id, tool_id, version, installed_at, status, last_check_at,
27
- error_message, update_available, latest_version, created_at, updated_at
28
- FROM tool_installations
29
- ORDER BY tool_id
30
- `);
31
- return stmt.all();
32
- };
33
-
34
- q.insertToolInstallation = function(toolId, data) {
35
- const now = Date.now();
36
- const stmt = prep(`
37
- INSERT OR IGNORE INTO tool_installations
38
- (id, tool_id, version, installed_at, status, last_check_at, error_message, update_available, latest_version, created_at, updated_at)
39
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
40
- `);
41
- stmt.run(
42
- generateId('ti'),
43
- toolId,
44
- data.version || null,
45
- data.installed_at || null,
46
- data.status || 'not_installed',
47
- now,
48
- data.error_message || null,
49
- 0,
50
- null,
51
- now,
52
- now
53
- );
54
- };
55
-
56
- q.updateToolStatus = function(toolId, data) {
57
- const now = Date.now();
58
- const stmt = prep(`
59
- UPDATE tool_installations
60
- SET version = ?, installed_at = ?, status = ?, last_check_at = ?,
61
- error_message = ?, update_available = ?, latest_version = ?, updated_at = ?
62
- WHERE tool_id = ?
63
- `);
64
- stmt.run(
65
- data.version || null,
66
- data.installed_at || null,
67
- data.status || 'not_installed',
68
- data.last_check_at || now,
69
- data.error_message || null,
70
- data.update_available ? 1 : 0,
71
- data.latest_version || null,
72
- now,
73
- toolId
74
- );
75
- };
76
-
77
- q.addToolInstallHistory = function(toolId, action, status, error) {
78
- const now = Date.now();
79
- // Ensure the parent tool_installations row exists before inserting history
80
- // (handles tools added after the DB was first initialized)
81
- prep(`INSERT OR IGNORE INTO tool_installations (id, tool_id, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`)
82
- .run(generateId('tinst'), toolId, 'not_installed', now, now);
83
- const stmt = prep(`
84
- INSERT INTO tool_install_history
85
- (id, tool_id, action, started_at, completed_at, status, error_message, created_at)
86
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)
87
- `);
88
- stmt.run(generateId('thist'), toolId, action, now, now, status, error || null, now);
89
- };
90
-
91
- q.getToolInstallHistory = function(toolId, limit = 20, offset = 0) {
92
- const stmt = prep(`
93
- SELECT id, tool_id, action, started_at, completed_at, status, error_message, created_at
94
- FROM tool_install_history
95
- WHERE tool_id = ?
96
- ORDER BY created_at DESC
97
- LIMIT ? OFFSET ?
98
- `);
99
- return stmt.all(toolId, limit, offset);
100
- };
101
-
102
- q.pruneToolInstallHistory = function(toolId, keepCount = 100) {
103
- const stmt = prep(`
104
- DELETE FROM tool_install_history
105
- WHERE tool_id = ? AND id NOT IN (
106
- SELECT id FROM tool_install_history
107
- WHERE tool_id = ?
108
- ORDER BY created_at DESC
109
- LIMIT ?
110
- )
111
- `);
112
- return stmt.run(toolId, toolId, keepCount).changes;
113
- };
114
-
115
- q.searchMessages = function(query, limit = 50) {
116
- try {
117
- const sanitized = '"' + query.replace(/"/g, '""') + '"';
118
- const stmt = prep(`
119
- SELECT m.id, m.conversationId, m.role, m.content, m.created_at,
120
- c.title as conversationTitle, c.agentType
121
- FROM messages_fts fts
122
- JOIN messages m ON m.rowid = fts.rowid
123
- JOIN conversations c ON c.id = m.conversationId
124
- WHERE messages_fts MATCH ?
125
- ORDER BY m.created_at DESC LIMIT ?
126
- `);
127
- return stmt.all(sanitized, limit);
128
- } catch (_) { return []; }
129
- };
130
-
131
- }
@@ -1,85 +0,0 @@
1
- export function addVoiceQueries(q, db, prep, generateId) {
2
- q.getDownloadsByStatus = function(status) {
3
- const stmt = prep('SELECT * FROM WHERE status = ? ORDER BY started_at DESC');
4
- return stmt.all(status);
5
- };
6
-
7
- q.updateDownloadResume = function(downloadId, currentSize, attempts, lastAttempt, status) {
8
- const stmt = prep(`
9
- UPDATE
10
- SET downloaded_bytes = ?, attempts = ?, lastAttempt = ?, status = ?
11
- WHERE id = ?
12
- `);
13
- stmt.run(currentSize, attempts, lastAttempt, status, downloadId);
14
- };
15
-
16
- q.updateDownloadHash = function(downloadId, hash) {
17
- const stmt = prep('UPDATE SET hash = ? WHERE id = ?');
18
- stmt.run(hash, downloadId);
19
- };
20
-
21
- q.markDownloadResuming = function(downloadId) {
22
- const stmt = prep('UPDATE SET status = ?, lastAttempt = ? WHERE id = ?');
23
- stmt.run('resuming', Date.now(), downloadId);
24
- };
25
-
26
- q.markDownloadPaused = function(downloadId, errorMessage) {
27
- const stmt = prep('UPDATE SET status = ?, error_message = ?, lastAttempt = ? WHERE id = ?');
28
- stmt.run('paused', errorMessage, Date.now(), downloadId);
29
- };
30
-
31
- q.saveVoiceCache = function(conversationId, text, audioBlob, ttlMs = 3600000) {
32
- const id = generateId('vcache');
33
- const now = Date.now();
34
- const expiresAt = now + ttlMs;
35
- const byteSize = audioBlob ? Buffer.byteLength(audioBlob) : 0;
36
- const stmt = prep(`
37
- INSERT INTO voice_cache (id, conversationId, text, audioBlob, byteSize, created_at, expires_at)
38
- VALUES (?, ?, ?, ?, ?, ?, ?)
39
- `);
40
- stmt.run(id, conversationId, text, audioBlob || null, byteSize, now, expiresAt);
41
- return { id, conversationId, text, byteSize, created_at: now, expires_at: expiresAt };
42
- };
43
-
44
- q.getVoiceCache = function(conversationId, text) {
45
- const now = Date.now();
46
- const stmt = prep(`
47
- SELECT id, conversationId, text, audioBlob, byteSize, created_at, expires_at
48
- FROM voice_cache
49
- WHERE conversationId = ? AND text = ? AND expires_at > ?
50
- LIMIT 1
51
- `);
52
- return stmt.get(conversationId, text, now) || null;
53
- };
54
-
55
- q.cleanExpiredVoiceCache = function() {
56
- const now = Date.now();
57
- const stmt = prep('DELETE FROM voice_cache WHERE expires_at <= ?');
58
- return stmt.run(now).changes;
59
- };
60
-
61
- q.getVoiceCacheSize = function(conversationId) {
62
- const now = Date.now();
63
- const stmt = prep(`
64
- SELECT COALESCE(SUM(byteSize), 0) as totalSize
65
- FROM voice_cache
66
- WHERE conversationId = ? AND expires_at > ?
67
- `);
68
- return stmt.get(conversationId, now).totalSize || 0;
69
- };
70
-
71
- q.deleteOldestVoiceCache = function(conversationId, neededBytes) {
72
- const stmt = prep(`
73
- SELECT id FROM voice_cache
74
- WHERE conversationId = ?
75
- ORDER BY created_at ASC
76
- LIMIT (SELECT COUNT(*) FROM voice_cache WHERE conversationId = ? AND byteSize > ?)
77
- `);
78
- const oldest = stmt.all(conversationId, conversationId, neededBytes);
79
- const deleteStmt = prep('DELETE FROM voice_cache WHERE id = ?');
80
- for (const row of oldest) {
81
- deleteStmt.run(row.id);
82
- }
83
- return oldest.length;
84
- };
85
- }
@@ -1,164 +0,0 @@
1
- import fs from 'fs';
2
- import path from 'path';
3
- import os from 'os';
4
- import crypto from 'crypto';
5
- import { buildBaseUrl, isRemoteRequest, encodeOAuthState, decodeOAuthState, oauthResultPage, oauthRelayPage } from './oauth-common.js';
6
-
7
- const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
8
- const CODEX_AUTH_FILE = path.join(CODEX_HOME, 'auth.json');
9
- const CODEX_OAUTH_ISSUER = 'https://auth.openai.com';
10
- const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
11
- const CODEX_SCOPES = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
12
- const CODEX_OAUTH_PORT = 1455;
13
-
14
- let codexOAuthState = { status: 'idle', error: null, email: null };
15
- let codexOAuthPending = null;
16
-
17
- function generatePkce() {
18
- const verifierBytes = crypto.randomBytes(64);
19
- const codeVerifier = verifierBytes.toString('base64url');
20
- const challengeBytes = crypto.createHash('sha256').update(codeVerifier).digest();
21
- const codeChallenge = challengeBytes.toString('base64url');
22
- return { codeVerifier, codeChallenge };
23
- }
24
-
25
- function parseJwtEmail(jwt) {
26
- try {
27
- const parts = jwt.split('.');
28
- if (parts.length < 2) return '';
29
- const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
30
- return payload.email || payload['https://api.openai.com/profile']?.email || '';
31
- } catch (_) { return ''; }
32
- }
33
-
34
- function saveCodexCredentials(tokens) {
35
- if (!fs.existsSync(CODEX_HOME)) fs.mkdirSync(CODEX_HOME, { recursive: true });
36
- const auth = { auth_mode: 'chatgpt', tokens, last_refresh: new Date().toISOString() };
37
- fs.writeFileSync(CODEX_AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
38
- try { fs.chmodSync(CODEX_AUTH_FILE, 0o600); } catch (_) {}
39
- }
40
-
41
- export function getCodexOAuthStatus() {
42
- try {
43
- if (fs.existsSync(CODEX_AUTH_FILE)) {
44
- const auth = JSON.parse(fs.readFileSync(CODEX_AUTH_FILE, 'utf8'));
45
- if (auth.tokens?.access_token || auth.tokens?.refresh_token) {
46
- const email = parseJwtEmail(auth.tokens?.id_token || '') || '';
47
- return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: CODEX_AUTH_FILE, authMethod: 'oauth' };
48
- }
49
- }
50
- } catch (_) {}
51
- return null;
52
- }
53
-
54
- export async function startCodexOAuth(req, { PORT, BASE_URL }) {
55
- const remote = isRemoteRequest(req);
56
- const redirectUri = remote
57
- ? `${buildBaseUrl(req, PORT)}${BASE_URL}/codex-oauth2callback`
58
- : `http://localhost:${CODEX_OAUTH_PORT}/auth/callback`;
59
- const pkce = generatePkce();
60
- const csrfToken = crypto.randomBytes(32).toString('hex');
61
- const relayUrl = remote ? `${buildBaseUrl(req, PORT)}${BASE_URL}/api/codex-oauth/relay` : null;
62
- const state = encodeOAuthState(csrfToken, relayUrl);
63
- const params = new URLSearchParams({
64
- response_type: 'code',
65
- client_id: CODEX_CLIENT_ID,
66
- redirect_uri: redirectUri,
67
- scope: CODEX_SCOPES,
68
- code_challenge: pkce.codeChallenge,
69
- code_challenge_method: 'S256',
70
- id_token_add_organizations: 'true',
71
- codex_cli_simplified_flow: 'true',
72
- state,
73
- });
74
- const authUrl = `${CODEX_OAUTH_ISSUER}/oauth/authorize?${params.toString()}`;
75
- const mode = remote ? 'remote' : 'local';
76
- codexOAuthPending = { pkce, redirectUri, state: csrfToken };
77
- codexOAuthState = { status: 'pending', error: null, email: null };
78
- if (codexOAuthPending._timeout) clearTimeout(codexOAuthPending._timeout);
79
- codexOAuthPending._timeout = setTimeout(() => {
80
- if (codexOAuthState.status === 'pending') {
81
- codexOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
82
- codexOAuthPending = null;
83
- }
84
- }, 5 * 60 * 1000);
85
- return { authUrl, mode };
86
- }
87
-
88
- export async function exchangeCodexOAuthCode(code, stateParam) {
89
- if (!codexOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
90
- if (codexOAuthPending._timeout) { clearTimeout(codexOAuthPending._timeout); codexOAuthPending._timeout = null; }
91
- const { pkce, redirectUri, state: expectedCsrf } = codexOAuthPending;
92
- const { csrfToken } = decodeOAuthState(stateParam);
93
- if (csrfToken !== expectedCsrf) {
94
- codexOAuthState = { status: 'error', error: 'State mismatch', email: null };
95
- codexOAuthPending = null;
96
- throw new Error('State mismatch - possible CSRF attack.');
97
- }
98
- if (!code) {
99
- codexOAuthState = { status: 'error', error: 'No authorization code received', email: null };
100
- codexOAuthPending = null;
101
- throw new Error('No authorization code received.');
102
- }
103
- const body = new URLSearchParams({
104
- grant_type: 'authorization_code',
105
- code,
106
- redirect_uri: redirectUri,
107
- client_id: CODEX_CLIENT_ID,
108
- code_verifier: pkce.codeVerifier,
109
- });
110
- const resp = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
111
- method: 'POST',
112
- headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
113
- body: body.toString(),
114
- });
115
- if (!resp.ok) {
116
- const text = await resp.text();
117
- codexOAuthState = { status: 'error', error: `Token exchange failed: ${resp.status}`, email: null };
118
- codexOAuthPending = null;
119
- throw new Error(`Token exchange failed (${resp.status}): ${text}`);
120
- }
121
- const tokens = await resp.json();
122
- const email = parseJwtEmail(tokens.id_token || '');
123
- saveCodexCredentials(tokens);
124
- codexOAuthState = { status: 'success', error: null, email };
125
- codexOAuthPending = null;
126
- return email;
127
- }
128
-
129
- export async function handleCodexOAuthCallback(req, res, PORT) {
130
- const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
131
- const code = reqUrl.searchParams.get('code');
132
- const state = reqUrl.searchParams.get('state');
133
- const error = reqUrl.searchParams.get('error');
134
- const errorDesc = reqUrl.searchParams.get('error_description');
135
- if (error) {
136
- const desc = errorDesc || error;
137
- codexOAuthState = { status: 'error', error: desc, email: null };
138
- codexOAuthPending = null;
139
- }
140
- const stateData = decodeOAuthState(state || '');
141
- if (stateData.relayUrl) {
142
- res.writeHead(200, { 'Content-Type': 'text/html' });
143
- res.end(oauthRelayPage(code, state, errorDesc || error));
144
- return;
145
- }
146
- if (!codexOAuthPending) {
147
- res.writeHead(200, { 'Content-Type': 'text/html' });
148
- res.end(oauthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
149
- return;
150
- }
151
- try {
152
- if (error) throw new Error(errorDesc || error);
153
- const email = await exchangeCodexOAuthCode(code, state);
154
- res.writeHead(200, { 'Content-Type': 'text/html' });
155
- res.end(oauthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Codex CLI credentials saved.', true));
156
- } catch (e) {
157
- res.writeHead(200, { 'Content-Type': 'text/html' });
158
- res.end(oauthResultPage('Authentication Failed', e.message, false));
159
- }
160
- }
161
-
162
- export function getCodexOAuthState() { return codexOAuthState; }
163
- export function getCodexOAuthPending() { return codexOAuthPending; }
164
- export { CODEX_HOME, CODEX_AUTH_FILE };