agentgui 1.0.751 → 1.0.753
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/lib/agent-discovery.js +161 -0
- package/lib/oauth-codex.js +162 -0
- package/lib/oauth-common.js +92 -0
- package/lib/oauth-gemini.js +200 -0
- package/package.json +1 -1
- package/server.js +68 -755
- package/static/app.js +78 -0
- package/static/css/main.css +104 -0
- package/static/index.html +4 -1
package/server.js
CHANGED
|
@@ -10,13 +10,15 @@ import { execSync, spawn } from 'child_process';
|
|
|
10
10
|
import { LRUCache } from 'lru-cache';
|
|
11
11
|
import { createRequire } from 'module';
|
|
12
12
|
const PKG_VERSION = JSON.parse(fs.readFileSync(new URL('./package.json', import.meta.url), 'utf8')).version;
|
|
13
|
-
import { OAuth2Client } from 'google-auth-library';
|
|
14
13
|
import express from 'express';
|
|
15
14
|
import Busboy from 'busboy';
|
|
16
15
|
import fsbrowse from 'fsbrowse';
|
|
17
16
|
import { queries } from './database.js';
|
|
18
17
|
import { runClaudeWithStreaming } from './lib/claude-runner.js';
|
|
19
18
|
import { initializeDescriptors, getAgentDescriptor } from './lib/agent-descriptors.js';
|
|
19
|
+
import { findCommand, queryACPServerAgents, discoverAgents, discoverExternalACPServers, initializeAgentDiscovery } from './lib/agent-discovery.js';
|
|
20
|
+
import { getGeminiOAuthCreds, startGeminiOAuth, exchangeGeminiOAuthCode, handleGeminiOAuthCallback, getGeminiOAuthStatus, getGeminiOAuthState } from './lib/oauth-gemini.js';
|
|
21
|
+
import { startCodexOAuth, exchangeCodexOAuthCode, handleCodexOAuthCallback, getCodexOAuthStatus, getCodexOAuthState, CODEX_HOME, CODEX_AUTH_FILE } from './lib/oauth-codex.js';
|
|
20
22
|
import { WSOptimizer } from './lib/ws-optimizer.js';
|
|
21
23
|
import { WsRouter } from './lib/ws-protocol.js';
|
|
22
24
|
import { encode as wsEncode } from './lib/codec.js';
|
|
@@ -49,14 +51,20 @@ process.on('unhandledRejection', (reason, promise) => {
|
|
|
49
51
|
if (reason instanceof Error) console.error(reason.stack);
|
|
50
52
|
});
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
console.log(
|
|
54
|
+
function gracefulShutdown(signal) {
|
|
55
|
+
console.log(`[SIGNAL] ${signal} received - graceful shutdown`);
|
|
54
56
|
try { pm2Manager.disconnect(); } catch (_) {}
|
|
55
57
|
if (jsonlWatcher) try { jsonlWatcher.stop(); } catch (_) {}
|
|
58
|
+
for (const [convId, entry] of activeExecutions) {
|
|
59
|
+
try { if (entry.pid) process.kill(entry.pid, 'SIGTERM'); } catch (_) {}
|
|
60
|
+
}
|
|
56
61
|
stopACPTools().catch(() => {}).finally(() => {
|
|
57
62
|
try { wss.close(() => server.close(() => process.exit(0))); } catch (_) { process.exit(0); }
|
|
63
|
+
setTimeout(() => process.exit(1), 5000);
|
|
58
64
|
});
|
|
59
|
-
}
|
|
65
|
+
}
|
|
66
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
67
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
60
68
|
process.on('SIGHUP', () => { console.log('[SIGNAL] SIGHUP received (ignored - uncrashable)'); });
|
|
61
69
|
process.on('beforeExit', (code) => { console.log('[PROCESS] beforeExit with code:', code); });
|
|
62
70
|
process.on('exit', (code) => { console.log('[PROCESS] exit with code:', code); });
|
|
@@ -444,180 +452,12 @@ expressApp.use(BASE_URL + '/files/:conversationId', (req, res, next) => {
|
|
|
444
452
|
router(req, res, next);
|
|
445
453
|
});
|
|
446
454
|
|
|
447
|
-
function findCommand(cmd) {
|
|
448
|
-
const isWindows = os.platform() === 'win32';
|
|
449
|
-
const localBin = path.join(path.dirname(fileURLToPath(import.meta.url)), 'node_modules', '.bin', isWindows ? cmd + '.cmd' : cmd);
|
|
450
|
-
if (fs.existsSync(localBin)) {
|
|
451
|
-
console.log(`[agent-discovery] Found ${cmd} in local node_modules`);
|
|
452
|
-
return localBin;
|
|
453
|
-
}
|
|
454
|
-
try {
|
|
455
|
-
// Increase timeout to 10 seconds to handle slower systems and running agents
|
|
456
|
-
const timeoutMs = 10000;
|
|
457
|
-
if (isWindows) {
|
|
458
|
-
const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
|
|
459
|
-
if (result) {
|
|
460
|
-
console.log(`[agent-discovery] Found ${cmd} in PATH`);
|
|
461
|
-
return result.split('\n')[0].trim();
|
|
462
|
-
}
|
|
463
|
-
} else {
|
|
464
|
-
const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
|
|
465
|
-
if (result) {
|
|
466
|
-
console.log(`[agent-discovery] Found ${cmd} in PATH`);
|
|
467
|
-
return result;
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
} catch (err) {
|
|
471
|
-
console.log(`[agent-discovery] ${cmd} not found or timed out`);
|
|
472
|
-
return null;
|
|
473
|
-
}
|
|
474
|
-
return null;
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
async function queryACPServerAgents(baseUrl) {
|
|
478
|
-
const endpoint = baseUrl.endsWith('/') ? baseUrl + 'agents/search' : baseUrl + '/agents/search';
|
|
479
|
-
try {
|
|
480
|
-
const response = await fetch(endpoint, {
|
|
481
|
-
method: 'POST',
|
|
482
|
-
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
|
483
|
-
body: JSON.stringify({}),
|
|
484
|
-
signal: AbortSignal.timeout(5000)
|
|
485
|
-
});
|
|
486
|
-
if (!response.ok) {
|
|
487
|
-
console.error(`Failed to query ACP agents from ${baseUrl}: ${response.status}`);
|
|
488
|
-
return [];
|
|
489
|
-
}
|
|
490
|
-
const data = await response.json();
|
|
491
|
-
if (!data?.agents || !Array.isArray(data.agents)) {
|
|
492
|
-
console.error(`Invalid agents response from ${baseUrl}`);
|
|
493
|
-
return [];
|
|
494
|
-
}
|
|
495
|
-
return data.agents.map(agent => ({
|
|
496
|
-
id: agent.agent_id || agent.id,
|
|
497
|
-
name: agent.metadata?.ref?.name || agent.name || 'Unknown Agent',
|
|
498
|
-
metadata: {
|
|
499
|
-
ref: {
|
|
500
|
-
name: agent.metadata?.ref?.name,
|
|
501
|
-
version: agent.metadata?.ref?.version,
|
|
502
|
-
url: agent.metadata?.ref?.url,
|
|
503
|
-
tags: agent.metadata?.ref?.tags
|
|
504
|
-
},
|
|
505
|
-
description: agent.metadata?.description,
|
|
506
|
-
author: agent.metadata?.author,
|
|
507
|
-
license: agent.metadata?.license
|
|
508
|
-
},
|
|
509
|
-
specs: agent.specs ? {
|
|
510
|
-
capabilities: agent.specs.capabilities,
|
|
511
|
-
input_schema: agent.specs.input_schema || agent.specs.input,
|
|
512
|
-
output_schema: agent.specs.output_schema || agent.specs.output,
|
|
513
|
-
thread_state_schema: agent.specs.thread_state_schema || agent.specs.thread_state,
|
|
514
|
-
config_schema: agent.specs.config_schema || agent.specs.config,
|
|
515
|
-
custom_streaming_update_schema: agent.specs.custom_streaming_update_schema || agent.specs.custom_streaming_update
|
|
516
|
-
} : null,
|
|
517
|
-
custom_data: agent.custom_data,
|
|
518
|
-
icon: agent.metadata?.ref?.name?.charAt(0) || 'A',
|
|
519
|
-
protocol: 'acp',
|
|
520
|
-
path: baseUrl
|
|
521
|
-
}));
|
|
522
|
-
} catch (error) {
|
|
523
|
-
console.error(`ACP agents query failed for ${baseUrl}: ${error.message}`);
|
|
524
|
-
return [];
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
function discoverAgents() {
|
|
529
|
-
const agents = [];
|
|
530
|
-
const binaries = [
|
|
531
|
-
{ cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
|
|
532
|
-
{ cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
|
|
533
|
-
{ cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
|
|
534
|
-
{ cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
|
|
535
|
-
{ cmd: 'goose', id: 'goose', name: 'Goose', icon: 'g', protocol: 'acp' },
|
|
536
|
-
{ cmd: 'openhands', id: 'openhands', name: 'OpenHands', icon: 'H', protocol: 'acp' },
|
|
537
|
-
{ cmd: 'augment', id: 'augment', name: 'Augment Code', icon: 'A', protocol: 'acp' },
|
|
538
|
-
{ cmd: 'cline', id: 'cline', name: 'Cline', icon: 'c', protocol: 'acp' },
|
|
539
|
-
{ cmd: 'kimi', id: 'kimi', name: 'Kimi CLI', icon: 'K', protocol: 'acp' },
|
|
540
|
-
{ cmd: 'qwen-code', id: 'qwen', name: 'Qwen Code', icon: 'Q', protocol: 'acp' },
|
|
541
|
-
{ cmd: 'codex', id: 'codex', name: 'Codex CLI', icon: 'X', protocol: 'acp', npxPackage: '@openai/codex' },
|
|
542
|
-
{ cmd: 'mistral-vibe', id: 'mistral', name: 'Mistral Vibe', icon: 'M', protocol: 'acp' },
|
|
543
|
-
{ cmd: 'kiro', id: 'kiro', name: 'Kiro CLI', icon: 'k', protocol: 'acp' },
|
|
544
|
-
{ cmd: 'fast-agent', id: 'fast-agent', name: 'fast-agent', icon: 'F', protocol: 'acp' },
|
|
545
|
-
];
|
|
546
|
-
for (const bin of binaries) {
|
|
547
|
-
const result = findCommand(bin.cmd);
|
|
548
|
-
if (result) {
|
|
549
|
-
agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result, protocol: bin.protocol });
|
|
550
|
-
} else if (bin.npxPackage) {
|
|
551
|
-
// For npx-launchable packages (including claude-code as fallback)
|
|
552
|
-
agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: bin.npxPackage, npxLaunchable: true });
|
|
553
|
-
} else if (bin.id === 'claude-code') {
|
|
554
|
-
// Ensure Claude Code is always available as an npx-launchable agent
|
|
555
|
-
agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: '@anthropic-ai/claude-code', npxLaunchable: true });
|
|
556
|
-
}
|
|
557
|
-
}
|
|
558
|
-
|
|
559
|
-
// Add CLI tool wrappers for ACP agents (these map CLI commands to plugin sub-agents)
|
|
560
|
-
// These allow users to select "OpenCode", "Gemini", etc. and then pick a model/variant
|
|
561
|
-
const cliWrappers = [
|
|
562
|
-
{ id: 'cli-opencode', name: 'OpenCode', icon: 'O', protocol: 'cli-wrapper', acpId: 'opencode' },
|
|
563
|
-
{ id: 'cli-gemini', name: 'Gemini', icon: 'G', protocol: 'cli-wrapper', acpId: 'gemini' },
|
|
564
|
-
{ id: 'cli-kilo', name: 'Kilo', icon: 'K', protocol: 'cli-wrapper', acpId: 'kilo' },
|
|
565
|
-
{ id: 'cli-codex', name: 'Codex', icon: 'X', protocol: 'cli-wrapper', acpId: 'codex' },
|
|
566
|
-
];
|
|
567
|
-
|
|
568
|
-
// Only add CLI wrappers for agents that are already discovered
|
|
569
|
-
console.log('[discoverAgents] Found agents:', agents.map(a => a.id).join(', '));
|
|
570
|
-
for (const wrapper of cliWrappers) {
|
|
571
|
-
if (agents.some(a => a.id === wrapper.acpId)) {
|
|
572
|
-
console.log(`[discoverAgents] Adding CLI wrapper for ${wrapper.id}`);
|
|
573
|
-
agents.push(wrapper);
|
|
574
|
-
console.log(`[discoverAgents] After push, agents.length = ${agents.length}, includes ${wrapper.id}? ${agents.some(a => a.id === wrapper.id)}`);
|
|
575
|
-
} else {
|
|
576
|
-
console.log(`[discoverAgents] Skipping CLI wrapper ${wrapper.id} (ACP agent ${wrapper.acpId} not found)`);
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
// Remove raw ACP agents that have a cli-wrapper (avoid duplicates in UI)
|
|
580
|
-
const wrappedAcpIds = new Set(cliWrappers.filter(w => agents.some(a => a.id === w.acpId)).map(w => w.acpId));
|
|
581
|
-
const filtered = agents.filter(a => !wrappedAcpIds.has(a.id));
|
|
582
|
-
console.log('[discoverAgents] Final agent count:', filtered.length, 'Agent IDs:', filtered.map(a => a.id).join(', '));
|
|
583
|
-
|
|
584
|
-
return filtered;
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
// Function to discover agents from external ACP servers
|
|
588
|
-
async function discoverExternalACPServers() {
|
|
589
|
-
const externalAgents = [];
|
|
590
|
-
for (const agent of discoveredAgents.filter(a => a.protocol === 'acp' && a.acpPort)) {
|
|
591
|
-
try {
|
|
592
|
-
const agents = await queryACPServerAgents(`http://localhost:${agent.acpPort}`);
|
|
593
|
-
externalAgents.push(...agents);
|
|
594
|
-
} catch (_) {}
|
|
595
|
-
}
|
|
596
|
-
return externalAgents;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
455
|
let discoveredAgents = [];
|
|
600
456
|
initializeDescriptors(discoveredAgents);
|
|
601
457
|
|
|
602
|
-
// Agent discovery happens asynchronously in background to not block startup
|
|
603
|
-
async function initializeAgentDiscovery() {
|
|
604
|
-
try {
|
|
605
|
-
const agents = discoverAgents();
|
|
606
|
-
// Mutate the existing array instead of reassigning to preserve closure references in handlers
|
|
607
|
-
discoveredAgents.length = 0;
|
|
608
|
-
discoveredAgents.push(...agents);
|
|
609
|
-
initializeDescriptors(discoveredAgents);
|
|
610
|
-
console.log('[AGENTS] Discovered:', discoveredAgents.map(a => ({ id: a.id, found: !!a.path, protocol: a.protocol })));
|
|
611
|
-
console.log('[AGENTS] Total count:', discoveredAgents.length);
|
|
612
|
-
} catch (err) {
|
|
613
|
-
console.error('[AGENTS] Discovery error:', err.message);
|
|
614
|
-
logError('initializeAgentDiscovery', err);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
// Start immediately but don't wait for it
|
|
619
458
|
const startTime = Date.now();
|
|
620
|
-
initializeAgentDiscovery().then(() => {
|
|
459
|
+
initializeAgentDiscovery(discoveredAgents, rootDir, logError).then(() => {
|
|
460
|
+
initializeDescriptors(discoveredAgents);
|
|
621
461
|
console.log('[INIT] initializeAgentDiscovery completed in', Date.now() - startTime, 'ms');
|
|
622
462
|
}).catch(() => {});
|
|
623
463
|
|
|
@@ -644,551 +484,6 @@ async function getModelsForAgent(agentId) {
|
|
|
644
484
|
return models;
|
|
645
485
|
}
|
|
646
486
|
|
|
647
|
-
const GEMINI_SCOPES = [
|
|
648
|
-
'https://www.googleapis.com/auth/cloud-platform',
|
|
649
|
-
'https://www.googleapis.com/auth/userinfo.email',
|
|
650
|
-
'https://www.googleapis.com/auth/userinfo.profile',
|
|
651
|
-
];
|
|
652
|
-
|
|
653
|
-
function extractOAuthFromFile(oauth2Path) {
|
|
654
|
-
try {
|
|
655
|
-
const src = fs.readFileSync(oauth2Path, 'utf8');
|
|
656
|
-
const idMatch = src.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/);
|
|
657
|
-
const secretMatch = src.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/);
|
|
658
|
-
if (idMatch && secretMatch) return { clientId: idMatch[1], clientSecret: secretMatch[1] };
|
|
659
|
-
} catch {}
|
|
660
|
-
return null;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function getGeminiOAuthCreds() {
|
|
664
|
-
if (process.env.GOOGLE_OAUTH_CLIENT_ID && process.env.GOOGLE_OAUTH_CLIENT_SECRET) {
|
|
665
|
-
return { clientId: process.env.GOOGLE_OAUTH_CLIENT_ID, clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, custom: true };
|
|
666
|
-
}
|
|
667
|
-
const oauthRelPath = path.join('node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js');
|
|
668
|
-
try {
|
|
669
|
-
const geminiPath = findCommand('gemini');
|
|
670
|
-
if (geminiPath) {
|
|
671
|
-
const realPath = fs.realpathSync(geminiPath);
|
|
672
|
-
const pkgRoot = path.resolve(path.dirname(realPath), '..');
|
|
673
|
-
const result = extractOAuthFromFile(path.join(pkgRoot, oauthRelPath));
|
|
674
|
-
if (result) return result;
|
|
675
|
-
}
|
|
676
|
-
} catch (e) {
|
|
677
|
-
console.error('[gemini-oauth] gemini lookup failed:', e.message);
|
|
678
|
-
}
|
|
679
|
-
try {
|
|
680
|
-
const npmCacheDirs = new Set();
|
|
681
|
-
const addDir = (d) => { if (d) npmCacheDirs.add(path.join(d, '_npx')); };
|
|
682
|
-
addDir(path.join(os.homedir(), '.npm'));
|
|
683
|
-
addDir(path.join(os.homedir(), '.cache', '.npm'));
|
|
684
|
-
if (process.env.NPM_CACHE) addDir(process.env.NPM_CACHE);
|
|
685
|
-
if (process.env.npm_config_cache) addDir(process.env.npm_config_cache);
|
|
686
|
-
try { addDir(execSync('npm config get cache', { encoding: 'utf8', timeout: 5000 }).trim()); } catch {}
|
|
687
|
-
for (const cacheDir of npmCacheDirs) {
|
|
688
|
-
if (!fs.existsSync(cacheDir)) continue;
|
|
689
|
-
for (const d of fs.readdirSync(cacheDir).filter(d => !d.startsWith('.'))) {
|
|
690
|
-
const result = extractOAuthFromFile(path.join(cacheDir, d, oauthRelPath));
|
|
691
|
-
if (result) return result;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
} catch (e) {
|
|
695
|
-
console.error('[gemini-oauth] npm cache scan failed:', e.message);
|
|
696
|
-
}
|
|
697
|
-
console.error('[gemini-oauth] Could not find Gemini CLI OAuth credentials in any known location');
|
|
698
|
-
return null;
|
|
699
|
-
}
|
|
700
|
-
const GEMINI_DIR = path.join(os.homedir(), '.gemini');
|
|
701
|
-
const GEMINI_OAUTH_FILE = path.join(GEMINI_DIR, 'oauth_creds.json');
|
|
702
|
-
const GEMINI_ACCOUNTS_FILE = path.join(GEMINI_DIR, 'google_accounts.json');
|
|
703
|
-
|
|
704
|
-
let geminiOAuthState = { status: 'idle', error: null, email: null };
|
|
705
|
-
let geminiOAuthPending = null;
|
|
706
|
-
|
|
707
|
-
function buildBaseUrl(req) {
|
|
708
|
-
const override = process.env.AGENTGUI_BASE_URL;
|
|
709
|
-
if (override) return override.replace(/\/+$/, '');
|
|
710
|
-
const fwdProto = req.headers['x-forwarded-proto'];
|
|
711
|
-
const fwdHost = req.headers['x-forwarded-host'] || req.headers['host'];
|
|
712
|
-
if (fwdHost) {
|
|
713
|
-
const proto = fwdProto || (req.socket.encrypted ? 'https' : 'http');
|
|
714
|
-
const cleanHost = fwdHost.replace(/:443$/, '').replace(/:80$/, '');
|
|
715
|
-
return `${proto}://${cleanHost}`;
|
|
716
|
-
}
|
|
717
|
-
return `http://127.0.0.1:${PORT}`;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
function saveGeminiCredentials(tokens, email) {
|
|
721
|
-
if (!fs.existsSync(GEMINI_DIR)) fs.mkdirSync(GEMINI_DIR, { recursive: true });
|
|
722
|
-
fs.writeFileSync(GEMINI_OAUTH_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
|
|
723
|
-
try { fs.chmodSync(GEMINI_OAUTH_FILE, 0o600); } catch (_) {}
|
|
724
|
-
|
|
725
|
-
let accounts = { active: null, old: [] };
|
|
726
|
-
try {
|
|
727
|
-
if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
|
|
728
|
-
accounts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
|
|
729
|
-
}
|
|
730
|
-
} catch (_) {}
|
|
731
|
-
|
|
732
|
-
if (email) {
|
|
733
|
-
if (accounts.active && accounts.active !== email && !accounts.old.includes(accounts.active)) {
|
|
734
|
-
accounts.old.push(accounts.active);
|
|
735
|
-
}
|
|
736
|
-
accounts.active = email;
|
|
737
|
-
}
|
|
738
|
-
fs.writeFileSync(GEMINI_ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), { mode: 0o600 });
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function geminiOAuthResultPage(title, message, success) {
|
|
742
|
-
const color = success ? '#10b981' : '#ef4444';
|
|
743
|
-
const icon = success ? '✓' : '✗';
|
|
744
|
-
return `<!DOCTYPE html><html><head><title>${title}</title></head>
|
|
745
|
-
<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;">
|
|
746
|
-
<div style="text-align:center;max-width:400px;padding:2rem;">
|
|
747
|
-
<div style="font-size:4rem;color:${color};margin-bottom:1rem;">${icon}</div>
|
|
748
|
-
<h1 style="font-size:1.5rem;margin-bottom:0.5rem;">${title}</h1>
|
|
749
|
-
<p style="color:#9ca3af;">${message}</p>
|
|
750
|
-
<p style="color:#6b7280;margin-top:1rem;font-size:0.875rem;">You can close this tab.</p>
|
|
751
|
-
</div></body></html>`;
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
function encodeOAuthState(csrfToken, relayUrl) {
|
|
755
|
-
const payload = JSON.stringify({ t: csrfToken, r: relayUrl });
|
|
756
|
-
return Buffer.from(payload).toString('base64url');
|
|
757
|
-
}
|
|
758
|
-
|
|
759
|
-
function decodeOAuthState(stateStr) {
|
|
760
|
-
try {
|
|
761
|
-
const payload = JSON.parse(Buffer.from(stateStr, 'base64url').toString());
|
|
762
|
-
return { csrfToken: payload.t, relayUrl: payload.r };
|
|
763
|
-
} catch (_) {
|
|
764
|
-
return { csrfToken: stateStr, relayUrl: null };
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
function geminiOAuthRelayPage(code, state, error) {
|
|
769
|
-
const stateData = decodeOAuthState(state || '');
|
|
770
|
-
const relayUrl = stateData.relayUrl || '';
|
|
771
|
-
const escapedCode = (code || '').replace(/['"\\]/g, '');
|
|
772
|
-
const escapedState = (state || '').replace(/['"\\]/g, '');
|
|
773
|
-
const escapedError = (error || '').replace(/['"\\]/g, '');
|
|
774
|
-
const escapedRelay = relayUrl.replace(/['"\\]/g, '');
|
|
775
|
-
return `<!DOCTYPE html><html><head><title>Completing sign-in...</title></head>
|
|
776
|
-
<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;">
|
|
777
|
-
<div id="status" style="text-align:center;max-width:400px;padding:2rem;">
|
|
778
|
-
<div id="spinner" style="font-size:2rem;margin-bottom:1rem;">⌛</div>
|
|
779
|
-
<h1 id="title" style="font-size:1.5rem;margin-bottom:0.5rem;">Completing sign-in...</h1>
|
|
780
|
-
<p id="msg" style="color:#9ca3af;">Relaying authentication to server...</p>
|
|
781
|
-
</div>
|
|
782
|
-
<script>
|
|
783
|
-
(function() {
|
|
784
|
-
var code = '${escapedCode}';
|
|
785
|
-
var state = '${escapedState}';
|
|
786
|
-
var error = '${escapedError}';
|
|
787
|
-
var relayUrl = '${escapedRelay}';
|
|
788
|
-
function show(icon, title, msg, color) {
|
|
789
|
-
document.getElementById('spinner').textContent = icon;
|
|
790
|
-
document.getElementById('spinner').style.color = color;
|
|
791
|
-
document.getElementById('title').textContent = title;
|
|
792
|
-
document.getElementById('msg').textContent = msg;
|
|
793
|
-
}
|
|
794
|
-
if (error) { show('\\u2717', 'Authentication Failed', error, '#ef4444'); return; }
|
|
795
|
-
if (!code) { show('\\u2717', 'Authentication Failed', 'No authorization code received.', '#ef4444'); return; }
|
|
796
|
-
if (!relayUrl) { show('\\u2713', 'Authentication Successful', 'Credentials saved. You can close this tab.', '#10b981'); return; }
|
|
797
|
-
fetch(relayUrl, {
|
|
798
|
-
method: 'POST',
|
|
799
|
-
headers: { 'Content-Type': 'application/json' },
|
|
800
|
-
body: JSON.stringify({ code: code, state: state })
|
|
801
|
-
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
802
|
-
if (data.success) {
|
|
803
|
-
show('\\u2713', 'Authentication Successful', data.email ? 'Signed in as ' + data.email + '. You can close this tab.' : 'Credentials saved. You can close this tab.', '#10b981');
|
|
804
|
-
} else {
|
|
805
|
-
show('\\u2717', 'Authentication Failed', data.error || 'Unknown error', '#ef4444');
|
|
806
|
-
}
|
|
807
|
-
}).catch(function(e) {
|
|
808
|
-
show('\\u2717', 'Relay Failed', 'Could not reach server: ' + e.message + '. You may need to paste the URL manually.', '#ef4444');
|
|
809
|
-
});
|
|
810
|
-
})();
|
|
811
|
-
</script>
|
|
812
|
-
</body></html>`;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function isRemoteRequest(req) {
|
|
816
|
-
return !!(req && (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['x-forwarded-proto']));
|
|
817
|
-
}
|
|
818
|
-
|
|
819
|
-
async function startGeminiOAuth(req) {
|
|
820
|
-
const creds = getGeminiOAuthCreds();
|
|
821
|
-
if (!creds) throw new Error('Could not find Gemini CLI OAuth credentials. Install gemini CLI first.');
|
|
822
|
-
|
|
823
|
-
const useCustomClient = !!creds.custom;
|
|
824
|
-
const remote = isRemoteRequest(req);
|
|
825
|
-
let redirectUri;
|
|
826
|
-
if (useCustomClient && req) {
|
|
827
|
-
redirectUri = `${buildBaseUrl(req)}${BASE_URL}/oauth2callback`;
|
|
828
|
-
} else {
|
|
829
|
-
redirectUri = `http://localhost:${PORT}${BASE_URL}/oauth2callback`;
|
|
830
|
-
}
|
|
831
|
-
|
|
832
|
-
const csrfToken = crypto.randomBytes(32).toString('hex');
|
|
833
|
-
const relayUrl = req ? `${buildBaseUrl(req)}${BASE_URL}/api/gemini-oauth/relay` : null;
|
|
834
|
-
const state = encodeOAuthState(csrfToken, relayUrl);
|
|
835
|
-
|
|
836
|
-
const client = new OAuth2Client({
|
|
837
|
-
clientId: creds.clientId,
|
|
838
|
-
clientSecret: creds.clientSecret,
|
|
839
|
-
});
|
|
840
|
-
|
|
841
|
-
const authUrl = client.generateAuthUrl({
|
|
842
|
-
redirect_uri: redirectUri,
|
|
843
|
-
access_type: 'offline',
|
|
844
|
-
scope: GEMINI_SCOPES,
|
|
845
|
-
state,
|
|
846
|
-
});
|
|
847
|
-
|
|
848
|
-
const mode = useCustomClient ? 'custom' : (remote ? 'cli-remote' : 'cli-local');
|
|
849
|
-
geminiOAuthPending = { client, redirectUri, state: csrfToken };
|
|
850
|
-
geminiOAuthState = { status: 'pending', error: null, email: null };
|
|
851
|
-
|
|
852
|
-
setTimeout(() => {
|
|
853
|
-
if (geminiOAuthState.status === 'pending') {
|
|
854
|
-
geminiOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
|
|
855
|
-
geminiOAuthPending = null;
|
|
856
|
-
}
|
|
857
|
-
}, 5 * 60 * 1000);
|
|
858
|
-
|
|
859
|
-
return { authUrl, mode };
|
|
860
|
-
}
|
|
861
|
-
|
|
862
|
-
async function exchangeGeminiOAuthCode(code, stateParam) {
|
|
863
|
-
if (!geminiOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
|
|
864
|
-
|
|
865
|
-
const { client, redirectUri, state: expectedCsrf } = geminiOAuthPending;
|
|
866
|
-
const { csrfToken } = decodeOAuthState(stateParam);
|
|
867
|
-
|
|
868
|
-
if (csrfToken !== expectedCsrf) {
|
|
869
|
-
geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
|
|
870
|
-
geminiOAuthPending = null;
|
|
871
|
-
throw new Error('State mismatch - possible CSRF attack.');
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
if (!code) {
|
|
875
|
-
geminiOAuthState = { status: 'error', error: 'No authorization code received', email: null };
|
|
876
|
-
geminiOAuthPending = null;
|
|
877
|
-
throw new Error('No authorization code received.');
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
|
|
881
|
-
client.setCredentials(tokens);
|
|
882
|
-
|
|
883
|
-
let email = '';
|
|
884
|
-
try {
|
|
885
|
-
const { token } = await client.getAccessToken();
|
|
886
|
-
if (token) {
|
|
887
|
-
const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', {
|
|
888
|
-
headers: { Authorization: `Bearer ${token}` }
|
|
889
|
-
});
|
|
890
|
-
if (resp.ok) {
|
|
891
|
-
const info = await resp.json();
|
|
892
|
-
email = info.email || '';
|
|
893
|
-
}
|
|
894
|
-
}
|
|
895
|
-
} catch (_) {}
|
|
896
|
-
|
|
897
|
-
saveGeminiCredentials(tokens, email);
|
|
898
|
-
geminiOAuthState = { status: 'success', error: null, email };
|
|
899
|
-
geminiOAuthPending = null;
|
|
900
|
-
|
|
901
|
-
return email;
|
|
902
|
-
}
|
|
903
|
-
|
|
904
|
-
async function handleGeminiOAuthCallback(req, res) {
|
|
905
|
-
const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
|
|
906
|
-
const code = reqUrl.searchParams.get('code');
|
|
907
|
-
const state = reqUrl.searchParams.get('state');
|
|
908
|
-
const error = reqUrl.searchParams.get('error');
|
|
909
|
-
const errorDesc = reqUrl.searchParams.get('error_description');
|
|
910
|
-
|
|
911
|
-
if (error) {
|
|
912
|
-
const desc = errorDesc || error;
|
|
913
|
-
geminiOAuthState = { status: 'error', error: desc, email: null };
|
|
914
|
-
geminiOAuthPending = null;
|
|
915
|
-
}
|
|
916
|
-
|
|
917
|
-
const stateData = decodeOAuthState(state || '');
|
|
918
|
-
if (stateData.relayUrl) {
|
|
919
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
920
|
-
res.end(geminiOAuthRelayPage(code, state, errorDesc || error));
|
|
921
|
-
return;
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
if (!geminiOAuthPending) {
|
|
925
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
926
|
-
res.end(geminiOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
|
|
927
|
-
return;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
try {
|
|
931
|
-
if (error) throw new Error(errorDesc || error);
|
|
932
|
-
const email = await exchangeGeminiOAuthCode(code, state);
|
|
933
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
934
|
-
res.end(geminiOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
|
|
935
|
-
} catch (e) {
|
|
936
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
937
|
-
res.end(geminiOAuthResultPage('Authentication Failed', e.message, false));
|
|
938
|
-
}
|
|
939
|
-
}
|
|
940
|
-
|
|
941
|
-
function getGeminiOAuthStatus() {
|
|
942
|
-
try {
|
|
943
|
-
if (fs.existsSync(GEMINI_OAUTH_FILE)) {
|
|
944
|
-
const creds = JSON.parse(fs.readFileSync(GEMINI_OAUTH_FILE, 'utf8'));
|
|
945
|
-
if (creds.refresh_token || creds.access_token) {
|
|
946
|
-
let email = '';
|
|
947
|
-
try {
|
|
948
|
-
if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
|
|
949
|
-
const accts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
|
|
950
|
-
email = accts.active || '';
|
|
951
|
-
}
|
|
952
|
-
} catch (_) {}
|
|
953
|
-
return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: GEMINI_OAUTH_FILE, authMethod: 'oauth' };
|
|
954
|
-
}
|
|
955
|
-
}
|
|
956
|
-
} catch (_) {}
|
|
957
|
-
return null;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
const CODEX_HOME = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
961
|
-
const CODEX_AUTH_FILE = path.join(CODEX_HOME, 'auth.json');
|
|
962
|
-
const CODEX_OAUTH_ISSUER = 'https://auth.openai.com';
|
|
963
|
-
const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
|
|
964
|
-
const CODEX_SCOPES = 'openid profile email offline_access api.connectors.read api.connectors.invoke';
|
|
965
|
-
const CODEX_OAUTH_PORT = 1455;
|
|
966
|
-
|
|
967
|
-
let codexOAuthState = { status: 'idle', error: null, email: null };
|
|
968
|
-
let codexOAuthPending = null;
|
|
969
|
-
|
|
970
|
-
function generatePkce() {
|
|
971
|
-
const verifierBytes = crypto.randomBytes(64);
|
|
972
|
-
const codeVerifier = verifierBytes.toString('base64url');
|
|
973
|
-
const challengeBytes = crypto.createHash('sha256').update(codeVerifier).digest();
|
|
974
|
-
const codeChallenge = challengeBytes.toString('base64url');
|
|
975
|
-
return { codeVerifier, codeChallenge };
|
|
976
|
-
}
|
|
977
|
-
|
|
978
|
-
function parseJwtEmail(jwt) {
|
|
979
|
-
try {
|
|
980
|
-
const parts = jwt.split('.');
|
|
981
|
-
if (parts.length < 2) return '';
|
|
982
|
-
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
983
|
-
return payload.email || payload['https://api.openai.com/profile']?.email || '';
|
|
984
|
-
} catch (_) { return ''; }
|
|
985
|
-
}
|
|
986
|
-
|
|
987
|
-
function saveCodexCredentials(tokens) {
|
|
988
|
-
if (!fs.existsSync(CODEX_HOME)) fs.mkdirSync(CODEX_HOME, { recursive: true });
|
|
989
|
-
const auth = { auth_mode: 'chatgpt', tokens, last_refresh: new Date().toISOString() };
|
|
990
|
-
fs.writeFileSync(CODEX_AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
|
|
991
|
-
try { fs.chmodSync(CODEX_AUTH_FILE, 0o600); } catch (_) {}
|
|
992
|
-
}
|
|
993
|
-
|
|
994
|
-
function getCodexOAuthStatus() {
|
|
995
|
-
try {
|
|
996
|
-
if (fs.existsSync(CODEX_AUTH_FILE)) {
|
|
997
|
-
const auth = JSON.parse(fs.readFileSync(CODEX_AUTH_FILE, 'utf8'));
|
|
998
|
-
if (auth.tokens?.access_token || auth.tokens?.refresh_token) {
|
|
999
|
-
const email = parseJwtEmail(auth.tokens?.id_token || '') || '';
|
|
1000
|
-
return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: CODEX_AUTH_FILE, authMethod: 'oauth' };
|
|
1001
|
-
}
|
|
1002
|
-
}
|
|
1003
|
-
} catch (_) {}
|
|
1004
|
-
return null;
|
|
1005
|
-
}
|
|
1006
|
-
|
|
1007
|
-
async function startCodexOAuth(req) {
|
|
1008
|
-
const remote = isRemoteRequest(req);
|
|
1009
|
-
const redirectUri = remote
|
|
1010
|
-
? `${buildBaseUrl(req)}${BASE_URL}/codex-oauth2callback`
|
|
1011
|
-
: `http://localhost:${CODEX_OAUTH_PORT}/auth/callback`;
|
|
1012
|
-
|
|
1013
|
-
const pkce = generatePkce();
|
|
1014
|
-
const csrfToken = crypto.randomBytes(32).toString('hex');
|
|
1015
|
-
const relayUrl = remote ? `${buildBaseUrl(req)}${BASE_URL}/api/codex-oauth/relay` : null;
|
|
1016
|
-
const state = encodeOAuthState(csrfToken, relayUrl);
|
|
1017
|
-
|
|
1018
|
-
const params = new URLSearchParams({
|
|
1019
|
-
response_type: 'code',
|
|
1020
|
-
client_id: CODEX_CLIENT_ID,
|
|
1021
|
-
redirect_uri: redirectUri,
|
|
1022
|
-
scope: CODEX_SCOPES,
|
|
1023
|
-
code_challenge: pkce.codeChallenge,
|
|
1024
|
-
code_challenge_method: 'S256',
|
|
1025
|
-
id_token_add_organizations: 'true',
|
|
1026
|
-
codex_cli_simplified_flow: 'true',
|
|
1027
|
-
state,
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
const authUrl = `${CODEX_OAUTH_ISSUER}/oauth/authorize?${params.toString()}`;
|
|
1031
|
-
const mode = remote ? 'remote' : 'local';
|
|
1032
|
-
|
|
1033
|
-
codexOAuthPending = { pkce, redirectUri, state: csrfToken };
|
|
1034
|
-
codexOAuthState = { status: 'pending', error: null, email: null };
|
|
1035
|
-
|
|
1036
|
-
setTimeout(() => {
|
|
1037
|
-
if (codexOAuthState.status === 'pending') {
|
|
1038
|
-
codexOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
|
|
1039
|
-
codexOAuthPending = null;
|
|
1040
|
-
}
|
|
1041
|
-
}, 5 * 60 * 1000);
|
|
1042
|
-
|
|
1043
|
-
return { authUrl, mode };
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
async function exchangeCodexOAuthCode(code, stateParam) {
|
|
1047
|
-
if (!codexOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
|
|
1048
|
-
|
|
1049
|
-
const { pkce, redirectUri, state: expectedCsrf } = codexOAuthPending;
|
|
1050
|
-
const { csrfToken } = decodeOAuthState(stateParam);
|
|
1051
|
-
|
|
1052
|
-
if (csrfToken !== expectedCsrf) {
|
|
1053
|
-
codexOAuthState = { status: 'error', error: 'State mismatch', email: null };
|
|
1054
|
-
codexOAuthPending = null;
|
|
1055
|
-
throw new Error('State mismatch - possible CSRF attack.');
|
|
1056
|
-
}
|
|
1057
|
-
|
|
1058
|
-
if (!code) {
|
|
1059
|
-
codexOAuthState = { status: 'error', error: 'No authorization code received', email: null };
|
|
1060
|
-
codexOAuthPending = null;
|
|
1061
|
-
throw new Error('No authorization code received.');
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
const body = new URLSearchParams({
|
|
1065
|
-
grant_type: 'authorization_code',
|
|
1066
|
-
code,
|
|
1067
|
-
redirect_uri: redirectUri,
|
|
1068
|
-
client_id: CODEX_CLIENT_ID,
|
|
1069
|
-
code_verifier: pkce.codeVerifier,
|
|
1070
|
-
});
|
|
1071
|
-
|
|
1072
|
-
const resp = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
|
|
1073
|
-
method: 'POST',
|
|
1074
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1075
|
-
body: body.toString(),
|
|
1076
|
-
});
|
|
1077
|
-
|
|
1078
|
-
if (!resp.ok) {
|
|
1079
|
-
const text = await resp.text();
|
|
1080
|
-
codexOAuthState = { status: 'error', error: `Token exchange failed: ${resp.status}`, email: null };
|
|
1081
|
-
codexOAuthPending = null;
|
|
1082
|
-
throw new Error(`Token exchange failed (${resp.status}): ${text}`);
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
const tokens = await resp.json();
|
|
1086
|
-
const email = parseJwtEmail(tokens.id_token || '');
|
|
1087
|
-
|
|
1088
|
-
saveCodexCredentials(tokens);
|
|
1089
|
-
codexOAuthState = { status: 'success', error: null, email };
|
|
1090
|
-
codexOAuthPending = null;
|
|
1091
|
-
|
|
1092
|
-
return email;
|
|
1093
|
-
}
|
|
1094
|
-
|
|
1095
|
-
function codexOAuthResultPage(title, message, success) {
|
|
1096
|
-
const color = success ? '#10b981' : '#ef4444';
|
|
1097
|
-
const icon = success ? '✓' : '✗';
|
|
1098
|
-
return `<!DOCTYPE html><html><head><title>${title}</title></head>
|
|
1099
|
-
<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;">
|
|
1100
|
-
<div style="text-align:center;max-width:400px;padding:2rem;">
|
|
1101
|
-
<div style="font-size:4rem;color:${color};margin-bottom:1rem;">${icon}</div>
|
|
1102
|
-
<h1 style="font-size:1.5rem;margin-bottom:0.5rem;">${title}</h1>
|
|
1103
|
-
<p style="color:#9ca3af;">${message}</p>
|
|
1104
|
-
<p style="color:#6b7280;margin-top:1rem;font-size:0.875rem;">You can close this tab.</p>
|
|
1105
|
-
</div></body></html>`;
|
|
1106
|
-
}
|
|
1107
|
-
|
|
1108
|
-
function codexOAuthRelayPage(code, state, error) {
|
|
1109
|
-
const stateData = decodeOAuthState(state || '');
|
|
1110
|
-
const relayUrl = stateData.relayUrl || '';
|
|
1111
|
-
const escapedCode = (code || '').replace(/['"\\]/g, '');
|
|
1112
|
-
const escapedState = (state || '').replace(/['"\\]/g, '');
|
|
1113
|
-
const escapedError = (error || '').replace(/['"\\]/g, '');
|
|
1114
|
-
const escapedRelay = relayUrl.replace(/['"\\]/g, '');
|
|
1115
|
-
return `<!DOCTYPE html><html><head><title>Completing sign-in...</title></head>
|
|
1116
|
-
<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;">
|
|
1117
|
-
<div id="status" style="text-align:center;max-width:400px;padding:2rem;">
|
|
1118
|
-
<div id="spinner" style="font-size:2rem;margin-bottom:1rem;">⌛</div>
|
|
1119
|
-
<h1 id="title" style="font-size:1.5rem;margin-bottom:0.5rem;">Completing sign-in...</h1>
|
|
1120
|
-
<p id="msg" style="color:#9ca3af;">Relaying authentication to server...</p>
|
|
1121
|
-
</div>
|
|
1122
|
-
<script>
|
|
1123
|
-
(function() {
|
|
1124
|
-
var code = '${escapedCode}';
|
|
1125
|
-
var state = '${escapedState}';
|
|
1126
|
-
var error = '${escapedError}';
|
|
1127
|
-
var relayUrl = '${escapedRelay}';
|
|
1128
|
-
function show(icon, title, msg, color) {
|
|
1129
|
-
document.getElementById('spinner').textContent = icon;
|
|
1130
|
-
document.getElementById('spinner').style.color = color;
|
|
1131
|
-
document.getElementById('title').textContent = title;
|
|
1132
|
-
document.getElementById('msg').textContent = msg;
|
|
1133
|
-
}
|
|
1134
|
-
if (error) { show('\\u2717', 'Authentication Failed', error, '#ef4444'); return; }
|
|
1135
|
-
if (!code) { show('\\u2717', 'Authentication Failed', 'No authorization code received.', '#ef4444'); return; }
|
|
1136
|
-
if (!relayUrl) { show('\\u2713', 'Authentication Successful', 'Credentials saved. You can close this tab.', '#10b981'); return; }
|
|
1137
|
-
fetch(relayUrl, {
|
|
1138
|
-
method: 'POST',
|
|
1139
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1140
|
-
body: JSON.stringify({ code: code, state: state })
|
|
1141
|
-
}).then(function(r) { return r.json(); }).then(function(data) {
|
|
1142
|
-
if (data.success) {
|
|
1143
|
-
show('\\u2713', 'Authentication Successful', data.email ? 'Signed in as ' + data.email + '. You can close this tab.' : 'Credentials saved. You can close this tab.', '#10b981');
|
|
1144
|
-
} else {
|
|
1145
|
-
show('\\u2717', 'Authentication Failed', data.error || 'Unknown error', '#ef4444');
|
|
1146
|
-
}
|
|
1147
|
-
}).catch(function(e) {
|
|
1148
|
-
show('\\u2717', 'Relay Failed', 'Could not reach server: ' + e.message + '. You may need to paste the URL manually.', '#ef4444');
|
|
1149
|
-
});
|
|
1150
|
-
})();
|
|
1151
|
-
</script>
|
|
1152
|
-
</body></html>`;
|
|
1153
|
-
}
|
|
1154
|
-
|
|
1155
|
-
async function handleCodexOAuthCallback(req, res) {
|
|
1156
|
-
const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
|
|
1157
|
-
const code = reqUrl.searchParams.get('code');
|
|
1158
|
-
const state = reqUrl.searchParams.get('state');
|
|
1159
|
-
const error = reqUrl.searchParams.get('error');
|
|
1160
|
-
const errorDesc = reqUrl.searchParams.get('error_description');
|
|
1161
|
-
|
|
1162
|
-
if (error) {
|
|
1163
|
-
const desc = errorDesc || error;
|
|
1164
|
-
codexOAuthState = { status: 'error', error: desc, email: null };
|
|
1165
|
-
codexOAuthPending = null;
|
|
1166
|
-
}
|
|
1167
|
-
|
|
1168
|
-
const stateData = decodeOAuthState(state || '');
|
|
1169
|
-
if (stateData.relayUrl) {
|
|
1170
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1171
|
-
res.end(codexOAuthRelayPage(code, state, errorDesc || error));
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
|
|
1175
|
-
if (!codexOAuthPending) {
|
|
1176
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1177
|
-
res.end(codexOAuthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
|
|
1181
|
-
try {
|
|
1182
|
-
if (error) throw new Error(errorDesc || error);
|
|
1183
|
-
const email = await exchangeCodexOAuthCode(code, state);
|
|
1184
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1185
|
-
res.end(codexOAuthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Codex CLI credentials saved.', true));
|
|
1186
|
-
} catch (e) {
|
|
1187
|
-
res.writeHead(200, { 'Content-Type': 'text/html' });
|
|
1188
|
-
res.end(codexOAuthResultPage('Authentication Failed', e.message, false));
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
|
|
1192
487
|
const PROVIDER_CONFIGS = {
|
|
1193
488
|
'anthropic': {
|
|
1194
489
|
name: 'Anthropic', configPaths: [
|
|
@@ -1364,6 +659,9 @@ function sendJSON(req, res, statusCode, data) {
|
|
|
1364
659
|
compressAndSend(req, res, statusCode, 'application/json', JSON.stringify(data));
|
|
1365
660
|
}
|
|
1366
661
|
|
|
662
|
+
const _rateLimitMap = new LRUCache({ max: 1000, ttl: 60000 });
|
|
663
|
+
const RATE_LIMIT_MAX = parseInt(process.env.RATE_LIMIT_MAX || '300', 10);
|
|
664
|
+
|
|
1367
665
|
const server = http.createServer(async (req, res) => {
|
|
1368
666
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
1369
667
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
@@ -1371,6 +669,17 @@ const server = http.createServer(async (req, res) => {
|
|
|
1371
669
|
if (req.method === 'OPTIONS') { res.writeHead(200); res.end(); return; }
|
|
1372
670
|
if (req.headers.upgrade && req.headers.upgrade.toLowerCase() === 'websocket') return;
|
|
1373
671
|
|
|
672
|
+
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.socket.remoteAddress;
|
|
673
|
+
const hits = (_rateLimitMap.get(clientIp) || 0) + 1;
|
|
674
|
+
_rateLimitMap.set(clientIp, hits);
|
|
675
|
+
res.setHeader('X-RateLimit-Limit', RATE_LIMIT_MAX);
|
|
676
|
+
res.setHeader('X-RateLimit-Remaining', Math.max(0, RATE_LIMIT_MAX - hits));
|
|
677
|
+
if (hits > RATE_LIMIT_MAX) {
|
|
678
|
+
res.writeHead(429, { 'Retry-After': '60' });
|
|
679
|
+
res.end('Too Many Requests');
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
|
|
1374
683
|
const _pwd = process.env.PASSWORD;
|
|
1375
684
|
if (_pwd) {
|
|
1376
685
|
const _auth = req.headers['authorization'] || '';
|
|
@@ -1428,12 +737,26 @@ const server = http.createServer(async (req, res) => {
|
|
|
1428
737
|
const pathOnly = routePath.split('?')[0];
|
|
1429
738
|
|
|
1430
739
|
if (pathOnly === '/oauth2callback' && req.method === 'GET') {
|
|
1431
|
-
await handleGeminiOAuthCallback(req, res);
|
|
740
|
+
await handleGeminiOAuthCallback(req, res, PORT);
|
|
1432
741
|
return;
|
|
1433
742
|
}
|
|
1434
743
|
|
|
1435
744
|
if (pathOnly === '/codex-oauth2callback' && req.method === 'GET') {
|
|
1436
|
-
await handleCodexOAuthCallback(req, res);
|
|
745
|
+
await handleCodexOAuthCallback(req, res, PORT);
|
|
746
|
+
return;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
if (pathOnly === '/api/health' && req.method === 'GET') {
|
|
750
|
+
sendJSON(req, res, 200, {
|
|
751
|
+
status: 'ok',
|
|
752
|
+
version: PKG_VERSION,
|
|
753
|
+
uptime: process.uptime(),
|
|
754
|
+
agents: discoveredAgents.length,
|
|
755
|
+
activeExecutions: activeExecutions.size,
|
|
756
|
+
wsClients: wss.clients.size,
|
|
757
|
+
memory: process.memoryUsage(),
|
|
758
|
+
acp: getACPStatus()
|
|
759
|
+
});
|
|
1437
760
|
return;
|
|
1438
761
|
}
|
|
1439
762
|
|
|
@@ -2426,7 +1749,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2426
1749
|
const localResult = queries.searchAgents(discoveredAgents, body);
|
|
2427
1750
|
|
|
2428
1751
|
// Get external agents from ACP servers
|
|
2429
|
-
const externalAgents = await discoverExternalACPServers();
|
|
1752
|
+
const externalAgents = await discoverExternalACPServers(discoveredAgents);
|
|
2430
1753
|
const externalResult = queries.searchAgents(externalAgents, body);
|
|
2431
1754
|
|
|
2432
1755
|
// Combine results
|
|
@@ -2879,7 +2202,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2879
2202
|
|
|
2880
2203
|
if (pathOnly === '/api/gemini-oauth/start' && req.method === 'POST') {
|
|
2881
2204
|
try {
|
|
2882
|
-
const result = await startGeminiOAuth(req);
|
|
2205
|
+
const result = await startGeminiOAuth(req, { PORT, BASE_URL, rootDir });
|
|
2883
2206
|
sendJSON(req, res, 200, { authUrl: result.authUrl, mode: result.mode });
|
|
2884
2207
|
} catch (e) {
|
|
2885
2208
|
console.error('[gemini-oauth] /api/gemini-oauth/start failed:', e);
|
|
@@ -2889,7 +2212,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2889
2212
|
}
|
|
2890
2213
|
|
|
2891
2214
|
if (pathOnly === '/api/gemini-oauth/status' && req.method === 'GET') {
|
|
2892
|
-
sendJSON(req, res, 200,
|
|
2215
|
+
sendJSON(req, res, 200, getGeminiOAuthState());
|
|
2893
2216
|
return;
|
|
2894
2217
|
}
|
|
2895
2218
|
|
|
@@ -2904,8 +2227,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2904
2227
|
const email = await exchangeGeminiOAuthCode(code, stateParam);
|
|
2905
2228
|
sendJSON(req, res, 200, { success: true, email });
|
|
2906
2229
|
} catch (e) {
|
|
2907
|
-
geminiOAuthState = { status: 'error', error: e.message, email: null };
|
|
2908
|
-
geminiOAuthPending = null;
|
|
2909
2230
|
sendJSON(req, res, 400, { error: e.message });
|
|
2910
2231
|
}
|
|
2911
2232
|
return;
|
|
@@ -2929,8 +2250,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2929
2250
|
const error = parsed.searchParams.get('error');
|
|
2930
2251
|
if (error) {
|
|
2931
2252
|
const desc = parsed.searchParams.get('error_description') || error;
|
|
2932
|
-
geminiOAuthState = { status: 'error', error: desc, email: null };
|
|
2933
|
-
geminiOAuthPending = null;
|
|
2934
2253
|
sendJSON(req, res, 200, { error: desc });
|
|
2935
2254
|
return;
|
|
2936
2255
|
}
|
|
@@ -2940,8 +2259,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2940
2259
|
const email = await exchangeGeminiOAuthCode(code, state);
|
|
2941
2260
|
sendJSON(req, res, 200, { success: true, email });
|
|
2942
2261
|
} catch (e) {
|
|
2943
|
-
geminiOAuthState = { status: 'error', error: e.message, email: null };
|
|
2944
|
-
geminiOAuthPending = null;
|
|
2945
2262
|
sendJSON(req, res, 400, { error: e.message });
|
|
2946
2263
|
}
|
|
2947
2264
|
return;
|
|
@@ -2949,7 +2266,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2949
2266
|
|
|
2950
2267
|
if (pathOnly === '/api/codex-oauth/start' && req.method === 'POST') {
|
|
2951
2268
|
try {
|
|
2952
|
-
const result = await startCodexOAuth(req);
|
|
2269
|
+
const result = await startCodexOAuth(req, { PORT, BASE_URL });
|
|
2953
2270
|
sendJSON(req, res, 200, { authUrl: result.authUrl, mode: result.mode });
|
|
2954
2271
|
} catch (e) {
|
|
2955
2272
|
console.error('[codex-oauth] /api/codex-oauth/start failed:', e);
|
|
@@ -2959,7 +2276,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
2959
2276
|
}
|
|
2960
2277
|
|
|
2961
2278
|
if (pathOnly === '/api/codex-oauth/status' && req.method === 'GET') {
|
|
2962
|
-
sendJSON(req, res, 200,
|
|
2279
|
+
sendJSON(req, res, 200, getCodexOAuthState());
|
|
2963
2280
|
return;
|
|
2964
2281
|
}
|
|
2965
2282
|
|
|
@@ -2974,8 +2291,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2974
2291
|
const email = await exchangeCodexOAuthCode(code, stateParam);
|
|
2975
2292
|
sendJSON(req, res, 200, { success: true, email });
|
|
2976
2293
|
} catch (e) {
|
|
2977
|
-
codexOAuthState = { status: 'error', error: e.message, email: null };
|
|
2978
|
-
codexOAuthPending = null;
|
|
2979
2294
|
sendJSON(req, res, 400, { error: e.message });
|
|
2980
2295
|
}
|
|
2981
2296
|
return;
|
|
@@ -2997,8 +2312,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
2997
2312
|
const error = parsed.searchParams.get('error');
|
|
2998
2313
|
if (error) {
|
|
2999
2314
|
const desc = parsed.searchParams.get('error_description') || error;
|
|
3000
|
-
codexOAuthState = { status: 'error', error: desc, email: null };
|
|
3001
|
-
codexOAuthPending = null;
|
|
3002
2315
|
sendJSON(req, res, 200, { error: desc });
|
|
3003
2316
|
return;
|
|
3004
2317
|
}
|
|
@@ -3007,8 +2320,6 @@ const server = http.createServer(async (req, res) => {
|
|
|
3007
2320
|
const email = await exchangeCodexOAuthCode(code, state);
|
|
3008
2321
|
sendJSON(req, res, 200, { success: true, email });
|
|
3009
2322
|
} catch (e) {
|
|
3010
|
-
codexOAuthState = { status: 'error', error: e.message, email: null };
|
|
3011
|
-
codexOAuthPending = null;
|
|
3012
2323
|
sendJSON(req, res, 400, { error: e.message });
|
|
3013
2324
|
}
|
|
3014
2325
|
return;
|
|
@@ -3022,21 +2333,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
3022
2333
|
|
|
3023
2334
|
if (agentId === 'codex' || agentId === 'cli-codex') {
|
|
3024
2335
|
try {
|
|
3025
|
-
const result = await startCodexOAuth(req);
|
|
2336
|
+
const result = await startCodexOAuth(req, { PORT, BASE_URL });
|
|
3026
2337
|
const conversationId = '__agent_auth__';
|
|
3027
2338
|
broadcastSync({ type: 'script_started', conversationId, script: 'auth-codex', agentId: 'codex', timestamp: Date.now() });
|
|
3028
2339
|
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() });
|
|
3029
2340
|
|
|
3030
2341
|
const pollId = setInterval(() => {
|
|
3031
|
-
if (
|
|
2342
|
+
if (getCodexOAuthState().status === 'success') {
|
|
3032
2343
|
clearInterval(pollId);
|
|
3033
|
-
const email =
|
|
2344
|
+
const email = getCodexOAuthState().email || '';
|
|
3034
2345
|
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
|
|
3035
2346
|
broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
|
|
3036
|
-
} else if (
|
|
2347
|
+
} else if (getCodexOAuthState().status === 'error') {
|
|
3037
2348
|
clearInterval(pollId);
|
|
3038
|
-
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${
|
|
3039
|
-
broadcastSync({ type: 'script_stopped', conversationId, code: 1, error:
|
|
2349
|
+
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${getCodexOAuthState().error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
|
|
2350
|
+
broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: getCodexOAuthState().error, timestamp: Date.now() });
|
|
3040
2351
|
}
|
|
3041
2352
|
}, 1000);
|
|
3042
2353
|
|
|
@@ -3053,21 +2364,21 @@ const server = http.createServer(async (req, res) => {
|
|
|
3053
2364
|
|
|
3054
2365
|
if (agentId === 'gemini') {
|
|
3055
2366
|
try {
|
|
3056
|
-
const result = await startGeminiOAuth(req);
|
|
2367
|
+
const result = await startGeminiOAuth(req, { PORT, BASE_URL, rootDir });
|
|
3057
2368
|
const conversationId = '__agent_auth__';
|
|
3058
2369
|
broadcastSync({ type: 'script_started', conversationId, script: 'auth-gemini', agentId: 'gemini', timestamp: Date.now() });
|
|
3059
2370
|
broadcastSync({ type: 'script_output', conversationId, data: `\x1b[36mOpening Google 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() });
|
|
3060
2371
|
|
|
3061
2372
|
const pollId = setInterval(() => {
|
|
3062
|
-
if (
|
|
2373
|
+
if (getGeminiOAuthState().status === 'success') {
|
|
3063
2374
|
clearInterval(pollId);
|
|
3064
|
-
const email =
|
|
2375
|
+
const email = getGeminiOAuthState().email || '';
|
|
3065
2376
|
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[32mAuthentication successful${email ? ' (' + email + ')' : ''}\x1b[0m\r\n`, stream: 'stdout', timestamp: Date.now() });
|
|
3066
2377
|
broadcastSync({ type: 'script_stopped', conversationId, code: 0, timestamp: Date.now() });
|
|
3067
|
-
} else if (
|
|
2378
|
+
} else if (getGeminiOAuthState().status === 'error') {
|
|
3068
2379
|
clearInterval(pollId);
|
|
3069
|
-
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${
|
|
3070
|
-
broadcastSync({ type: 'script_stopped', conversationId, code: 1, error:
|
|
2380
|
+
broadcastSync({ type: 'script_output', conversationId, data: `\r\n\x1b[31mAuthentication failed: ${getGeminiOAuthState().error}\x1b[0m\r\n`, stream: 'stderr', timestamp: Date.now() });
|
|
2381
|
+
broadcastSync({ type: 'script_stopped', conversationId, code: 1, error: getGeminiOAuthState().error, timestamp: Date.now() });
|
|
3071
2382
|
}
|
|
3072
2383
|
}, 1000);
|
|
3073
2384
|
|
|
@@ -4649,7 +3960,7 @@ console.log('[INIT] About to call registerSessionHandlers, discoveredAgents.leng
|
|
|
4649
3960
|
registerSessionHandlers(wsRouter, {
|
|
4650
3961
|
db: queries, discoveredAgents, modelCache,
|
|
4651
3962
|
getAgentDescriptor, activeScripts, broadcastSync,
|
|
4652
|
-
startGeminiOAuth
|
|
3963
|
+
startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }), geminiOAuthState: getGeminiOAuthState
|
|
4653
3964
|
});
|
|
4654
3965
|
console.log('[INIT] registerSessionHandlers completed');
|
|
4655
3966
|
|
|
@@ -4669,10 +3980,12 @@ registerScriptHandlers(wsRouter, {
|
|
|
4669
3980
|
});
|
|
4670
3981
|
|
|
4671
3982
|
registerOAuthHandlers(wsRouter, {
|
|
4672
|
-
startGeminiOAuth,
|
|
4673
|
-
|
|
4674
|
-
|
|
4675
|
-
|
|
3983
|
+
startGeminiOAuth: (req) => startGeminiOAuth(req, { PORT, BASE_URL, rootDir }),
|
|
3984
|
+
exchangeGeminiOAuthCode,
|
|
3985
|
+
geminiOAuthState: getGeminiOAuthState,
|
|
3986
|
+
startCodexOAuth: (req) => startCodexOAuth(req, { PORT, BASE_URL }),
|
|
3987
|
+
exchangeCodexOAuthCode,
|
|
3988
|
+
codexOAuthState: getCodexOAuthState,
|
|
4676
3989
|
});
|
|
4677
3990
|
|
|
4678
3991
|
wsRouter.onLegacy((data, ws) => {
|