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.
@@ -0,0 +1,161 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import { fileURLToPath } from 'url';
5
+ import { execSync } from 'child_process';
6
+
7
+ const BINARIES = [
8
+ { cmd: 'claude', id: 'claude-code', name: 'Claude Code', icon: 'C', protocol: 'cli' },
9
+ { cmd: 'opencode', id: 'opencode', name: 'OpenCode', icon: 'O', protocol: 'acp', npxPackage: 'opencode-ai' },
10
+ { cmd: 'gemini', id: 'gemini', name: 'Gemini CLI', icon: 'G', protocol: 'acp', npxPackage: '@google/gemini-cli' },
11
+ { cmd: 'kilo', id: 'kilo', name: 'Kilo Code', icon: 'K', protocol: 'acp', npxPackage: '@kilocode/cli' },
12
+ { cmd: 'goose', id: 'goose', name: 'Goose', icon: 'g', protocol: 'acp' },
13
+ { cmd: 'openhands', id: 'openhands', name: 'OpenHands', icon: 'H', protocol: 'acp' },
14
+ { cmd: 'augment', id: 'augment', name: 'Augment Code', icon: 'A', protocol: 'acp' },
15
+ { cmd: 'cline', id: 'cline', name: 'Cline', icon: 'c', protocol: 'acp' },
16
+ { cmd: 'kimi', id: 'kimi', name: 'Kimi CLI', icon: 'K', protocol: 'acp' },
17
+ { cmd: 'qwen-code', id: 'qwen', name: 'Qwen Code', icon: 'Q', protocol: 'acp' },
18
+ { cmd: 'codex', id: 'codex', name: 'Codex CLI', icon: 'X', protocol: 'acp', npxPackage: '@openai/codex' },
19
+ { cmd: 'mistral-vibe', id: 'mistral', name: 'Mistral Vibe', icon: 'M', protocol: 'acp' },
20
+ { cmd: 'kiro', id: 'kiro', name: 'Kiro CLI', icon: 'k', protocol: 'acp' },
21
+ { cmd: 'fast-agent', id: 'fast-agent', name: 'fast-agent', icon: 'F', protocol: 'acp' },
22
+ ];
23
+
24
+ const CLI_WRAPPERS = [
25
+ { id: 'cli-opencode', name: 'OpenCode', icon: 'O', protocol: 'cli-wrapper', acpId: 'opencode' },
26
+ { id: 'cli-gemini', name: 'Gemini', icon: 'G', protocol: 'cli-wrapper', acpId: 'gemini' },
27
+ { id: 'cli-kilo', name: 'Kilo', icon: 'K', protocol: 'cli-wrapper', acpId: 'kilo' },
28
+ { id: 'cli-codex', name: 'Codex', icon: 'X', protocol: 'cli-wrapper', acpId: 'codex' },
29
+ ];
30
+
31
+ export function findCommand(cmd, rootDir) {
32
+ const isWindows = os.platform() === 'win32';
33
+ const localBin = path.join(rootDir, 'node_modules', '.bin', isWindows ? cmd + '.cmd' : cmd);
34
+ if (fs.existsSync(localBin)) {
35
+ console.log(`[agent-discovery] Found ${cmd} in local node_modules`);
36
+ return localBin;
37
+ }
38
+ try {
39
+ const timeoutMs = 10000;
40
+ if (isWindows) {
41
+ const result = execSync(`where ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
42
+ if (result) {
43
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
44
+ return result.split('\n')[0].trim();
45
+ }
46
+ } else {
47
+ const result = execSync(`which ${cmd}`, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'ignore'], timeout: timeoutMs }).trim();
48
+ if (result) {
49
+ console.log(`[agent-discovery] Found ${cmd} in PATH`);
50
+ return result;
51
+ }
52
+ }
53
+ } catch (err) {
54
+ console.log(`[agent-discovery] ${cmd} not found or timed out`);
55
+ return null;
56
+ }
57
+ return null;
58
+ }
59
+
60
+ export async function queryACPServerAgents(baseUrl) {
61
+ const endpoint = baseUrl.endsWith('/') ? baseUrl + 'agents/search' : baseUrl + '/agents/search';
62
+ try {
63
+ const response = await fetch(endpoint, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
66
+ body: JSON.stringify({}),
67
+ signal: AbortSignal.timeout(5000)
68
+ });
69
+ if (!response.ok) {
70
+ console.error(`Failed to query ACP agents from ${baseUrl}: ${response.status}`);
71
+ return [];
72
+ }
73
+ const data = await response.json();
74
+ if (!data?.agents || !Array.isArray(data.agents)) {
75
+ console.error(`Invalid agents response from ${baseUrl}`);
76
+ return [];
77
+ }
78
+ return data.agents.map(agent => ({
79
+ id: agent.agent_id || agent.id,
80
+ name: agent.metadata?.ref?.name || agent.name || 'Unknown Agent',
81
+ metadata: {
82
+ ref: {
83
+ name: agent.metadata?.ref?.name,
84
+ version: agent.metadata?.ref?.version,
85
+ url: agent.metadata?.ref?.url,
86
+ tags: agent.metadata?.ref?.tags
87
+ },
88
+ description: agent.metadata?.description,
89
+ author: agent.metadata?.author,
90
+ license: agent.metadata?.license
91
+ },
92
+ specs: agent.specs ? {
93
+ capabilities: agent.specs.capabilities,
94
+ input_schema: agent.specs.input_schema || agent.specs.input,
95
+ output_schema: agent.specs.output_schema || agent.specs.output,
96
+ thread_state_schema: agent.specs.thread_state_schema || agent.specs.thread_state,
97
+ config_schema: agent.specs.config_schema || agent.specs.config,
98
+ custom_streaming_update_schema: agent.specs.custom_streaming_update_schema || agent.specs.custom_streaming_update
99
+ } : null,
100
+ custom_data: agent.custom_data,
101
+ icon: agent.metadata?.ref?.name?.charAt(0) || 'A',
102
+ protocol: 'acp',
103
+ path: baseUrl
104
+ }));
105
+ } catch (error) {
106
+ console.error(`ACP agents query failed for ${baseUrl}: ${error.message}`);
107
+ return [];
108
+ }
109
+ }
110
+
111
+ export function discoverAgents(rootDir) {
112
+ const agents = [];
113
+ for (const bin of BINARIES) {
114
+ const result = findCommand(bin.cmd, rootDir);
115
+ if (result) {
116
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: result, protocol: bin.protocol });
117
+ } else if (bin.npxPackage) {
118
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: bin.npxPackage, npxLaunchable: true });
119
+ } else if (bin.id === 'claude-code') {
120
+ agents.push({ id: bin.id, name: bin.name, icon: bin.icon, path: null, protocol: bin.protocol, npxPackage: '@anthropic-ai/claude-code', npxLaunchable: true });
121
+ }
122
+ }
123
+
124
+ console.log('[discoverAgents] Found agents:', agents.map(a => a.id).join(', '));
125
+ for (const wrapper of CLI_WRAPPERS) {
126
+ if (agents.some(a => a.id === wrapper.acpId)) {
127
+ console.log(`[discoverAgents] Adding CLI wrapper for ${wrapper.id}`);
128
+ agents.push(wrapper);
129
+ } else {
130
+ console.log(`[discoverAgents] Skipping CLI wrapper ${wrapper.id} (ACP agent ${wrapper.acpId} not found)`);
131
+ }
132
+ }
133
+ const wrappedAcpIds = new Set(CLI_WRAPPERS.filter(w => agents.some(a => a.id === w.acpId)).map(w => w.acpId));
134
+ const filtered = agents.filter(a => !wrappedAcpIds.has(a.id));
135
+ console.log('[discoverAgents] Final agent count:', filtered.length, 'Agent IDs:', filtered.map(a => a.id).join(', '));
136
+ return filtered;
137
+ }
138
+
139
+ export async function discoverExternalACPServers(discoveredAgents) {
140
+ const externalAgents = [];
141
+ for (const agent of discoveredAgents.filter(a => a.protocol === 'acp' && a.acpPort)) {
142
+ try {
143
+ const agents = await queryACPServerAgents(`http://localhost:${agent.acpPort}`);
144
+ externalAgents.push(...agents);
145
+ } catch (_) {}
146
+ }
147
+ return externalAgents;
148
+ }
149
+
150
+ export async function initializeAgentDiscovery(discoveredAgents, rootDir, logError) {
151
+ try {
152
+ const agents = discoverAgents(rootDir);
153
+ discoveredAgents.length = 0;
154
+ discoveredAgents.push(...agents);
155
+ console.log('[AGENTS] Discovered:', discoveredAgents.map(a => ({ id: a.id, found: !!a.path, protocol: a.protocol })));
156
+ console.log('[AGENTS] Total count:', discoveredAgents.length);
157
+ } catch (err) {
158
+ console.error('[AGENTS] Discovery error:', err.message);
159
+ if (logError) logError('initializeAgentDiscovery', err);
160
+ }
161
+ }
@@ -0,0 +1,162 @@
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
+ setTimeout(() => {
79
+ if (codexOAuthState.status === 'pending') {
80
+ codexOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
81
+ codexOAuthPending = null;
82
+ }
83
+ }, 5 * 60 * 1000);
84
+ return { authUrl, mode };
85
+ }
86
+
87
+ export async function exchangeCodexOAuthCode(code, stateParam) {
88
+ if (!codexOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
89
+ const { pkce, redirectUri, state: expectedCsrf } = codexOAuthPending;
90
+ const { csrfToken } = decodeOAuthState(stateParam);
91
+ if (csrfToken !== expectedCsrf) {
92
+ codexOAuthState = { status: 'error', error: 'State mismatch', email: null };
93
+ codexOAuthPending = null;
94
+ throw new Error('State mismatch - possible CSRF attack.');
95
+ }
96
+ if (!code) {
97
+ codexOAuthState = { status: 'error', error: 'No authorization code received', email: null };
98
+ codexOAuthPending = null;
99
+ throw new Error('No authorization code received.');
100
+ }
101
+ const body = new URLSearchParams({
102
+ grant_type: 'authorization_code',
103
+ code,
104
+ redirect_uri: redirectUri,
105
+ client_id: CODEX_CLIENT_ID,
106
+ code_verifier: pkce.codeVerifier,
107
+ });
108
+ const resp = await fetch(`${CODEX_OAUTH_ISSUER}/oauth/token`, {
109
+ method: 'POST',
110
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
111
+ body: body.toString(),
112
+ });
113
+ if (!resp.ok) {
114
+ const text = await resp.text();
115
+ codexOAuthState = { status: 'error', error: `Token exchange failed: ${resp.status}`, email: null };
116
+ codexOAuthPending = null;
117
+ throw new Error(`Token exchange failed (${resp.status}): ${text}`);
118
+ }
119
+ const tokens = await resp.json();
120
+ const email = parseJwtEmail(tokens.id_token || '');
121
+ saveCodexCredentials(tokens);
122
+ codexOAuthState = { status: 'success', error: null, email };
123
+ codexOAuthPending = null;
124
+ return email;
125
+ }
126
+
127
+ export async function handleCodexOAuthCallback(req, res, PORT) {
128
+ const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
129
+ const code = reqUrl.searchParams.get('code');
130
+ const state = reqUrl.searchParams.get('state');
131
+ const error = reqUrl.searchParams.get('error');
132
+ const errorDesc = reqUrl.searchParams.get('error_description');
133
+ if (error) {
134
+ const desc = errorDesc || error;
135
+ codexOAuthState = { status: 'error', error: desc, email: null };
136
+ codexOAuthPending = null;
137
+ }
138
+ const stateData = decodeOAuthState(state || '');
139
+ if (stateData.relayUrl) {
140
+ res.writeHead(200, { 'Content-Type': 'text/html' });
141
+ res.end(oauthRelayPage(code, state, errorDesc || error));
142
+ return;
143
+ }
144
+ if (!codexOAuthPending) {
145
+ res.writeHead(200, { 'Content-Type': 'text/html' });
146
+ res.end(oauthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
147
+ return;
148
+ }
149
+ try {
150
+ if (error) throw new Error(errorDesc || error);
151
+ const email = await exchangeCodexOAuthCode(code, state);
152
+ res.writeHead(200, { 'Content-Type': 'text/html' });
153
+ res.end(oauthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Codex CLI credentials saved.', true));
154
+ } catch (e) {
155
+ res.writeHead(200, { 'Content-Type': 'text/html' });
156
+ res.end(oauthResultPage('Authentication Failed', e.message, false));
157
+ }
158
+ }
159
+
160
+ export function getCodexOAuthState() { return codexOAuthState; }
161
+ export function getCodexOAuthPending() { return codexOAuthPending; }
162
+ export { CODEX_HOME, CODEX_AUTH_FILE };
@@ -0,0 +1,92 @@
1
+ import crypto from 'crypto';
2
+
3
+ export function buildBaseUrl(req, PORT) {
4
+ const override = process.env.AGENTGUI_BASE_URL;
5
+ if (override) return override.replace(/\/+$/, '');
6
+ const fwdProto = req.headers['x-forwarded-proto'];
7
+ const fwdHost = req.headers['x-forwarded-host'] || req.headers['host'];
8
+ if (fwdHost) {
9
+ const proto = fwdProto || (req.socket.encrypted ? 'https' : 'http');
10
+ const cleanHost = fwdHost.replace(/:443$/, '').replace(/:80$/, '');
11
+ return `${proto}://${cleanHost}`;
12
+ }
13
+ return `http://127.0.0.1:${PORT}`;
14
+ }
15
+
16
+ export function isRemoteRequest(req) {
17
+ return !!(req && (req.headers['x-forwarded-for'] || req.headers['x-forwarded-host'] || req.headers['x-forwarded-proto']));
18
+ }
19
+
20
+ export function encodeOAuthState(csrfToken, relayUrl) {
21
+ const payload = JSON.stringify({ t: csrfToken, r: relayUrl });
22
+ return Buffer.from(payload).toString('base64url');
23
+ }
24
+
25
+ export function decodeOAuthState(stateStr) {
26
+ try {
27
+ const payload = JSON.parse(Buffer.from(stateStr, 'base64url').toString());
28
+ return { csrfToken: payload.t, relayUrl: payload.r };
29
+ } catch (_) {
30
+ return { csrfToken: stateStr, relayUrl: null };
31
+ }
32
+ }
33
+
34
+ export function oauthResultPage(title, message, success) {
35
+ const color = success ? '#10b981' : '#ef4444';
36
+ const icon = success ? '&#10003;' : '&#10007;';
37
+ return `<!DOCTYPE html><html><head><title>${title}</title></head>
38
+ <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;">
39
+ <div style="text-align:center;max-width:400px;padding:2rem;">
40
+ <div style="font-size:4rem;color:${color};margin-bottom:1rem;">${icon}</div>
41
+ <h1 style="font-size:1.5rem;margin-bottom:0.5rem;">${title}</h1>
42
+ <p style="color:#9ca3af;">${message}</p>
43
+ <p style="color:#6b7280;margin-top:1rem;font-size:0.875rem;">You can close this tab.</p>
44
+ </div></body></html>`;
45
+ }
46
+
47
+ export function oauthRelayPage(code, state, error) {
48
+ const stateData = decodeOAuthState(state || '');
49
+ const relayUrl = stateData.relayUrl || '';
50
+ const escapedCode = (code || '').replace(/['"\\]/g, '');
51
+ const escapedState = (state || '').replace(/['"\\]/g, '');
52
+ const escapedError = (error || '').replace(/['"\\]/g, '');
53
+ const escapedRelay = relayUrl.replace(/['"\\]/g, '');
54
+ return `<!DOCTYPE html><html><head><title>Completing sign-in...</title></head>
55
+ <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;">
56
+ <div id="status" style="text-align:center;max-width:400px;padding:2rem;">
57
+ <div id="spinner" style="font-size:2rem;margin-bottom:1rem;">&#8987;</div>
58
+ <h1 id="title" style="font-size:1.5rem;margin-bottom:0.5rem;">Completing sign-in...</h1>
59
+ <p id="msg" style="color:#9ca3af;">Relaying authentication to server...</p>
60
+ </div>
61
+ <script>
62
+ (function() {
63
+ var code = '${escapedCode}';
64
+ var state = '${escapedState}';
65
+ var error = '${escapedError}';
66
+ var relayUrl = '${escapedRelay}';
67
+ function show(icon, title, msg, color) {
68
+ document.getElementById('spinner').textContent = icon;
69
+ document.getElementById('spinner').style.color = color;
70
+ document.getElementById('title').textContent = title;
71
+ document.getElementById('msg').textContent = msg;
72
+ }
73
+ if (error) { show('\\u2717', 'Authentication Failed', error, '#ef4444'); return; }
74
+ if (!code) { show('\\u2717', 'Authentication Failed', 'No authorization code received.', '#ef4444'); return; }
75
+ if (!relayUrl) { show('\\u2713', 'Authentication Successful', 'Credentials saved. You can close this tab.', '#10b981'); return; }
76
+ fetch(relayUrl, {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ body: JSON.stringify({ code: code, state: state })
80
+ }).then(function(r) { return r.json(); }).then(function(data) {
81
+ if (data.success) {
82
+ show('\\u2713', 'Authentication Successful', data.email ? 'Signed in as ' + data.email + '. You can close this tab.' : 'Credentials saved. You can close this tab.', '#10b981');
83
+ } else {
84
+ show('\\u2717', 'Authentication Failed', data.error || 'Unknown error', '#ef4444');
85
+ }
86
+ }).catch(function(e) {
87
+ show('\\u2717', 'Relay Failed', 'Could not reach server: ' + e.message + '. You may need to paste the URL manually.', '#ef4444');
88
+ });
89
+ })();
90
+ </script>
91
+ </body></html>`;
92
+ }
@@ -0,0 +1,200 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+ import os from 'os';
4
+ import crypto from 'crypto';
5
+ import { execSync } from 'child_process';
6
+ import { OAuth2Client } from 'google-auth-library';
7
+ import { findCommand } from './agent-discovery.js';
8
+ import { buildBaseUrl, isRemoteRequest, encodeOAuthState, decodeOAuthState, oauthResultPage, oauthRelayPage } from './oauth-common.js';
9
+
10
+ const GEMINI_SCOPES = [
11
+ 'https://www.googleapis.com/auth/cloud-platform',
12
+ 'https://www.googleapis.com/auth/userinfo.email',
13
+ 'https://www.googleapis.com/auth/userinfo.profile',
14
+ ];
15
+ const GEMINI_DIR = path.join(os.homedir(), '.gemini');
16
+ const GEMINI_OAUTH_FILE = path.join(GEMINI_DIR, 'oauth_creds.json');
17
+ const GEMINI_ACCOUNTS_FILE = path.join(GEMINI_DIR, 'google_accounts.json');
18
+
19
+ let geminiOAuthState = { status: 'idle', error: null, email: null };
20
+ let geminiOAuthPending = null;
21
+
22
+ function extractOAuthFromFile(oauth2Path) {
23
+ try {
24
+ const src = fs.readFileSync(oauth2Path, 'utf8');
25
+ const idMatch = src.match(/OAUTH_CLIENT_ID\s*=\s*['"]([^'"]+)['"]/);
26
+ const secretMatch = src.match(/OAUTH_CLIENT_SECRET\s*=\s*['"]([^'"]+)['"]/);
27
+ if (idMatch && secretMatch) return { clientId: idMatch[1], clientSecret: secretMatch[1] };
28
+ } catch {}
29
+ return null;
30
+ }
31
+
32
+ export function getGeminiOAuthCreds(rootDir) {
33
+ if (process.env.GOOGLE_OAUTH_CLIENT_ID && process.env.GOOGLE_OAUTH_CLIENT_SECRET) {
34
+ return { clientId: process.env.GOOGLE_OAUTH_CLIENT_ID, clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET, custom: true };
35
+ }
36
+ const oauthRelPath = path.join('node_modules', '@google', 'gemini-cli-core', 'dist', 'src', 'code_assist', 'oauth2.js');
37
+ try {
38
+ const geminiPath = findCommand('gemini', rootDir);
39
+ if (geminiPath) {
40
+ const realPath = fs.realpathSync(geminiPath);
41
+ const pkgRoot = path.resolve(path.dirname(realPath), '..');
42
+ const result = extractOAuthFromFile(path.join(pkgRoot, oauthRelPath));
43
+ if (result) return result;
44
+ }
45
+ } catch (e) {
46
+ console.error('[gemini-oauth] gemini lookup failed:', e.message);
47
+ }
48
+ try {
49
+ const npmCacheDirs = new Set();
50
+ const addDir = (d) => { if (d) npmCacheDirs.add(path.join(d, '_npx')); };
51
+ addDir(path.join(os.homedir(), '.npm'));
52
+ addDir(path.join(os.homedir(), '.cache', '.npm'));
53
+ if (process.env.NPM_CACHE) addDir(process.env.NPM_CACHE);
54
+ if (process.env.npm_config_cache) addDir(process.env.npm_config_cache);
55
+ try { addDir(execSync('npm config get cache', { encoding: 'utf8', timeout: 5000 }).trim()); } catch {}
56
+ for (const cacheDir of npmCacheDirs) {
57
+ if (!fs.existsSync(cacheDir)) continue;
58
+ for (const d of fs.readdirSync(cacheDir).filter(d => !d.startsWith('.'))) {
59
+ const result = extractOAuthFromFile(path.join(cacheDir, d, oauthRelPath));
60
+ if (result) return result;
61
+ }
62
+ }
63
+ } catch (e) {
64
+ console.error('[gemini-oauth] npm cache scan failed:', e.message);
65
+ }
66
+ console.error('[gemini-oauth] Could not find Gemini CLI OAuth credentials in any known location');
67
+ return null;
68
+ }
69
+
70
+ function saveGeminiCredentials(tokens, email) {
71
+ if (!fs.existsSync(GEMINI_DIR)) fs.mkdirSync(GEMINI_DIR, { recursive: true });
72
+ fs.writeFileSync(GEMINI_OAUTH_FILE, JSON.stringify(tokens, null, 2), { mode: 0o600 });
73
+ try { fs.chmodSync(GEMINI_OAUTH_FILE, 0o600); } catch (_) {}
74
+ let accounts = { active: null, old: [] };
75
+ try {
76
+ if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
77
+ accounts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
78
+ }
79
+ } catch (_) {}
80
+ if (email) {
81
+ if (accounts.active && accounts.active !== email && !accounts.old.includes(accounts.active)) {
82
+ accounts.old.push(accounts.active);
83
+ }
84
+ accounts.active = email;
85
+ }
86
+ fs.writeFileSync(GEMINI_ACCOUNTS_FILE, JSON.stringify(accounts, null, 2), { mode: 0o600 });
87
+ }
88
+
89
+ export async function startGeminiOAuth(req, { PORT, BASE_URL, rootDir }) {
90
+ const creds = getGeminiOAuthCreds(rootDir);
91
+ if (!creds) throw new Error('Could not find Gemini CLI OAuth credentials. Install gemini CLI first.');
92
+ const useCustomClient = !!creds.custom;
93
+ const remote = isRemoteRequest(req);
94
+ let redirectUri;
95
+ if (useCustomClient && req) {
96
+ redirectUri = `${buildBaseUrl(req, PORT)}${BASE_URL}/oauth2callback`;
97
+ } else {
98
+ redirectUri = `http://localhost:${PORT}${BASE_URL}/oauth2callback`;
99
+ }
100
+ const csrfToken = crypto.randomBytes(32).toString('hex');
101
+ const relayUrl = req ? `${buildBaseUrl(req, PORT)}${BASE_URL}/api/gemini-oauth/relay` : null;
102
+ const state = encodeOAuthState(csrfToken, relayUrl);
103
+ const client = new OAuth2Client({ clientId: creds.clientId, clientSecret: creds.clientSecret });
104
+ const authUrl = client.generateAuthUrl({ redirect_uri: redirectUri, access_type: 'offline', scope: GEMINI_SCOPES, state });
105
+ const mode = useCustomClient ? 'custom' : (remote ? 'cli-remote' : 'cli-local');
106
+ geminiOAuthPending = { client, redirectUri, state: csrfToken };
107
+ geminiOAuthState = { status: 'pending', error: null, email: null };
108
+ setTimeout(() => {
109
+ if (geminiOAuthState.status === 'pending') {
110
+ geminiOAuthState = { status: 'error', error: 'Authentication timed out', email: null };
111
+ geminiOAuthPending = null;
112
+ }
113
+ }, 5 * 60 * 1000);
114
+ return { authUrl, mode };
115
+ }
116
+
117
+ export async function exchangeGeminiOAuthCode(code, stateParam) {
118
+ if (!geminiOAuthPending) throw new Error('No pending OAuth flow. Please start authentication again.');
119
+ const { client, redirectUri, state: expectedCsrf } = geminiOAuthPending;
120
+ const { csrfToken } = decodeOAuthState(stateParam);
121
+ if (csrfToken !== expectedCsrf) {
122
+ geminiOAuthState = { status: 'error', error: 'State mismatch', email: null };
123
+ geminiOAuthPending = null;
124
+ throw new Error('State mismatch - possible CSRF attack.');
125
+ }
126
+ if (!code) {
127
+ geminiOAuthState = { status: 'error', error: 'No authorization code received', email: null };
128
+ geminiOAuthPending = null;
129
+ throw new Error('No authorization code received.');
130
+ }
131
+ const { tokens } = await client.getToken({ code, redirect_uri: redirectUri });
132
+ client.setCredentials(tokens);
133
+ let email = '';
134
+ try {
135
+ const { token } = await client.getAccessToken();
136
+ if (token) {
137
+ const resp = await fetch('https://www.googleapis.com/oauth2/v2/userinfo', { headers: { Authorization: `Bearer ${token}` } });
138
+ if (resp.ok) { const info = await resp.json(); email = info.email || ''; }
139
+ }
140
+ } catch (_) {}
141
+ saveGeminiCredentials(tokens, email);
142
+ geminiOAuthState = { status: 'success', error: null, email };
143
+ geminiOAuthPending = null;
144
+ return email;
145
+ }
146
+
147
+ export async function handleGeminiOAuthCallback(req, res, PORT) {
148
+ const reqUrl = new URL(req.url, `http://localhost:${PORT}`);
149
+ const code = reqUrl.searchParams.get('code');
150
+ const state = reqUrl.searchParams.get('state');
151
+ const error = reqUrl.searchParams.get('error');
152
+ const errorDesc = reqUrl.searchParams.get('error_description');
153
+ if (error) {
154
+ const desc = errorDesc || error;
155
+ geminiOAuthState = { status: 'error', error: desc, email: null };
156
+ geminiOAuthPending = null;
157
+ }
158
+ const stateData = decodeOAuthState(state || '');
159
+ if (stateData.relayUrl) {
160
+ res.writeHead(200, { 'Content-Type': 'text/html' });
161
+ res.end(oauthRelayPage(code, state, errorDesc || error));
162
+ return;
163
+ }
164
+ if (!geminiOAuthPending) {
165
+ res.writeHead(200, { 'Content-Type': 'text/html' });
166
+ res.end(oauthResultPage('Authentication Failed', 'No pending OAuth flow.', false));
167
+ return;
168
+ }
169
+ try {
170
+ if (error) throw new Error(errorDesc || error);
171
+ const email = await exchangeGeminiOAuthCode(code, state);
172
+ res.writeHead(200, { 'Content-Type': 'text/html' });
173
+ res.end(oauthResultPage('Authentication Successful', email ? `Signed in as ${email}` : 'Gemini CLI credentials saved.', true));
174
+ } catch (e) {
175
+ res.writeHead(200, { 'Content-Type': 'text/html' });
176
+ res.end(oauthResultPage('Authentication Failed', e.message, false));
177
+ }
178
+ }
179
+
180
+ export function getGeminiOAuthStatus() {
181
+ try {
182
+ if (fs.existsSync(GEMINI_OAUTH_FILE)) {
183
+ const creds = JSON.parse(fs.readFileSync(GEMINI_OAUTH_FILE, 'utf8'));
184
+ if (creds.refresh_token || creds.access_token) {
185
+ let email = '';
186
+ try {
187
+ if (fs.existsSync(GEMINI_ACCOUNTS_FILE)) {
188
+ const accts = JSON.parse(fs.readFileSync(GEMINI_ACCOUNTS_FILE, 'utf8'));
189
+ email = accts.active || '';
190
+ }
191
+ } catch (_) {}
192
+ return { hasKey: true, apiKey: email || '****oauth', defaultModel: '', path: GEMINI_OAUTH_FILE, authMethod: 'oauth' };
193
+ }
194
+ }
195
+ } catch (_) {}
196
+ return null;
197
+ }
198
+
199
+ export function getGeminiOAuthState() { return geminiOAuthState; }
200
+ export function getGeminiOAuthPending() { return geminiOAuthPending; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.751",
3
+ "version": "1.0.753",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "electron/main.js",