agentgui 1.0.752 → 1.0.754
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/oauth-codex.js +162 -0
- package/lib/oauth-common.js +92 -0
- package/lib/oauth-gemini.js +200 -0
- package/lib/speech-manager.js +203 -0
- package/package.json +1 -1
- package/server.js +30 -807
|
@@ -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 ? '✓' : '✗';
|
|
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;">⌛</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; }
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import os from 'os';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
|
|
6
|
+
let speechModule = null;
|
|
7
|
+
let _broadcastSync = null;
|
|
8
|
+
let _syncClients = null;
|
|
9
|
+
let _queries = null;
|
|
10
|
+
|
|
11
|
+
export function initSpeechManager({ broadcastSync, syncClients, queries }) {
|
|
12
|
+
_broadcastSync = broadcastSync;
|
|
13
|
+
_syncClients = syncClients;
|
|
14
|
+
_queries = queries;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function ensurePocketTtsSetup(onProgress) {
|
|
18
|
+
const r = createRequire(import.meta.url);
|
|
19
|
+
const serverTTS = r('webtalk/server-tts');
|
|
20
|
+
return serverTTS.ensureInstalled(onProgress);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function getSpeech() {
|
|
24
|
+
if (!speechModule) speechModule = await import('./speech.js');
|
|
25
|
+
return speechModule;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const ttsTextAccumulators = new Map();
|
|
29
|
+
|
|
30
|
+
export const voiceCacheManager = {
|
|
31
|
+
generating: new Map(),
|
|
32
|
+
maxCacheSize: 10 * 1024 * 1024,
|
|
33
|
+
async getOrGenerateCache(conversationId, text) {
|
|
34
|
+
const cacheKey = `${conversationId}:${text}`;
|
|
35
|
+
if (this.generating.has(cacheKey)) {
|
|
36
|
+
return new Promise((resolve) => {
|
|
37
|
+
const checkInterval = setInterval(() => {
|
|
38
|
+
const cached = _queries.getVoiceCache(conversationId, text);
|
|
39
|
+
if (cached) { clearInterval(checkInterval); resolve(cached); }
|
|
40
|
+
}, 50);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const cached = _queries.getVoiceCache(conversationId, text);
|
|
44
|
+
if (cached) return cached;
|
|
45
|
+
this.generating.set(cacheKey, true);
|
|
46
|
+
try {
|
|
47
|
+
const speech = await getSpeech();
|
|
48
|
+
const audioBlob = await speech.synthesize(text, 'default');
|
|
49
|
+
const saved = _queries.saveVoiceCache(conversationId, text, audioBlob);
|
|
50
|
+
const totalSize = _queries.getVoiceCacheSize(conversationId);
|
|
51
|
+
if (totalSize > this.maxCacheSize) {
|
|
52
|
+
const needed = totalSize - this.maxCacheSize;
|
|
53
|
+
_queries.deleteOldestVoiceCache(conversationId, needed);
|
|
54
|
+
}
|
|
55
|
+
return saved;
|
|
56
|
+
} finally {
|
|
57
|
+
this.generating.delete(cacheKey);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const modelDownloadState = {
|
|
63
|
+
downloading: false,
|
|
64
|
+
progress: null,
|
|
65
|
+
error: null,
|
|
66
|
+
complete: false,
|
|
67
|
+
startTime: null,
|
|
68
|
+
downloadMetrics: new Map()
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
export function broadcastModelProgress(progress) {
|
|
72
|
+
modelDownloadState.progress = progress;
|
|
73
|
+
const broadcastData = {
|
|
74
|
+
type: 'model_download_progress',
|
|
75
|
+
modelId: progress.type || 'unknown',
|
|
76
|
+
bytesDownloaded: progress.bytesDownloaded || 0,
|
|
77
|
+
bytesRemaining: progress.bytesRemaining || 0,
|
|
78
|
+
totalBytes: progress.totalBytes || 0,
|
|
79
|
+
downloadSpeed: progress.downloadSpeed || 0,
|
|
80
|
+
eta: progress.eta || 0,
|
|
81
|
+
retryCount: progress.retryCount || 0,
|
|
82
|
+
currentGateway: progress.currentGateway || '',
|
|
83
|
+
status: progress.status || (progress.done ? 'completed' : progress.downloading ? 'downloading' : 'paused'),
|
|
84
|
+
percentComplete: progress.percentComplete || 0,
|
|
85
|
+
completedFiles: progress.completedFiles || 0,
|
|
86
|
+
totalFiles: progress.totalFiles || 0,
|
|
87
|
+
timestamp: Date.now(),
|
|
88
|
+
...progress
|
|
89
|
+
};
|
|
90
|
+
_broadcastSync(broadcastData);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function validateAndCleanupModels(modelsDir) {
|
|
94
|
+
try {
|
|
95
|
+
const manifestPath = path.join(modelsDir, '.manifests.json');
|
|
96
|
+
if (fs.existsSync(manifestPath)) {
|
|
97
|
+
try {
|
|
98
|
+
const content = fs.readFileSync(manifestPath, 'utf8');
|
|
99
|
+
JSON.parse(content);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.error('[MODELS] Manifest corrupted, removing:', e.message);
|
|
102
|
+
fs.unlinkSync(manifestPath);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const files = fs.readdirSync(modelsDir);
|
|
106
|
+
for (const file of files) {
|
|
107
|
+
if (file.endsWith('.tmp')) {
|
|
108
|
+
try { fs.unlinkSync(path.join(modelsDir, file)); console.log('[MODELS] Cleaned up temp file:', file); }
|
|
109
|
+
catch (e) { console.warn('[MODELS] Failed to clean:', file); }
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
} catch (e) {
|
|
113
|
+
console.warn('[MODELS] Cleanup check failed:', e.message);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ensureModelsDownloaded() {
|
|
118
|
+
if (modelDownloadState.downloading) {
|
|
119
|
+
while (modelDownloadState.downloading) { await new Promise(r => setTimeout(r, 100)); }
|
|
120
|
+
return modelDownloadState.complete;
|
|
121
|
+
}
|
|
122
|
+
modelDownloadState.downloading = true;
|
|
123
|
+
modelDownloadState.error = null;
|
|
124
|
+
try {
|
|
125
|
+
const r = createRequire(import.meta.url);
|
|
126
|
+
const { createConfig } = r('webtalk/config');
|
|
127
|
+
const { ensureModel } = r('webtalk/whisper-models');
|
|
128
|
+
const { ensureTTSModels } = r('webtalk/tts-models');
|
|
129
|
+
const gmguiModels = path.join(os.homedir(), '.gmgui', 'models');
|
|
130
|
+
const modelsBase = process.env.PORTABLE_EXE_DIR
|
|
131
|
+
? (fs.existsSync(path.join(process.env.PORTABLE_EXE_DIR, 'models', 'onnx-community')) ? path.join(process.env.PORTABLE_EXE_DIR, 'models') : gmguiModels)
|
|
132
|
+
: gmguiModels;
|
|
133
|
+
await validateAndCleanupModels(modelsBase);
|
|
134
|
+
const config = createConfig({ modelsDir: modelsBase, ttsModelsDir: path.join(modelsBase, 'tts') });
|
|
135
|
+
const onProgress = (progress) => { broadcastModelProgress({ ...progress, started: true, done: false, downloading: true }); };
|
|
136
|
+
broadcastModelProgress({ started: true, done: false, downloading: true, type: 'whisper', status: 'starting' });
|
|
137
|
+
await ensureModel('onnx-community/whisper-base', config, onProgress);
|
|
138
|
+
broadcastModelProgress({ started: true, done: false, downloading: true, type: 'tts', status: 'starting' });
|
|
139
|
+
await ensureTTSModels(config, onProgress);
|
|
140
|
+
modelDownloadState.complete = true;
|
|
141
|
+
broadcastModelProgress({ started: true, done: true, complete: true, downloading: false });
|
|
142
|
+
return true;
|
|
143
|
+
} catch (err) {
|
|
144
|
+
console.error('[MODELS] Download error:', err.message);
|
|
145
|
+
modelDownloadState.error = err.message;
|
|
146
|
+
broadcastModelProgress({ done: true, error: err.message });
|
|
147
|
+
return false;
|
|
148
|
+
} finally {
|
|
149
|
+
modelDownloadState.downloading = false;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function eagerTTS(text, conversationId, sessionId) {
|
|
154
|
+
const key = `${conversationId}:${sessionId}`;
|
|
155
|
+
let acc = ttsTextAccumulators.get(key);
|
|
156
|
+
if (!acc) { acc = { text: '', timer: null }; ttsTextAccumulators.set(key, acc); }
|
|
157
|
+
acc.text += text;
|
|
158
|
+
if (acc.timer) clearTimeout(acc.timer);
|
|
159
|
+
acc.timer = setTimeout(() => flushTTSaccumulator(key, conversationId, sessionId), 600);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function flushTTSaccumulator(key, conversationId, sessionId) {
|
|
163
|
+
const acc = ttsTextAccumulators.get(key);
|
|
164
|
+
if (!acc || !acc.text) return;
|
|
165
|
+
const text = acc.text.trim();
|
|
166
|
+
acc.text = '';
|
|
167
|
+
ttsTextAccumulators.delete(key);
|
|
168
|
+
getSpeech().then(speech => {
|
|
169
|
+
const status = speech.getStatus();
|
|
170
|
+
if (!status.ttsReady || status.ttsError) return;
|
|
171
|
+
const voices = new Set();
|
|
172
|
+
for (const ws of _syncClients) {
|
|
173
|
+
const vid = ws.ttsVoiceId || 'default';
|
|
174
|
+
const convKey = `conv-${conversationId}`;
|
|
175
|
+
if (ws.subscriptions && (ws.subscriptions.has(sessionId) || ws.subscriptions.has(convKey))) {
|
|
176
|
+
voices.add(vid);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (voices.size === 0) return;
|
|
180
|
+
for (const vid of voices) {
|
|
181
|
+
const cacheKey = speech.ttsCacheKey(text, vid);
|
|
182
|
+
const cached = speech.ttsCacheGet(cacheKey);
|
|
183
|
+
if (cached) { pushTTSAudio(cacheKey, cached, conversationId, sessionId, vid); continue; }
|
|
184
|
+
speech.synthesize(text, vid).then(wav => {
|
|
185
|
+
if (speech.ttsCacheSet) speech.ttsCacheSet(cacheKey, wav);
|
|
186
|
+
pushTTSAudio(cacheKey, wav, conversationId, sessionId, vid);
|
|
187
|
+
}).catch(() => {});
|
|
188
|
+
}
|
|
189
|
+
}).catch(() => {});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function pushTTSAudio(cacheKey, wav, conversationId, sessionId, voiceId) {
|
|
193
|
+
const b64 = wav.toString('base64');
|
|
194
|
+
_broadcastSync({
|
|
195
|
+
type: 'tts_audio',
|
|
196
|
+
cacheKey,
|
|
197
|
+
audio: b64,
|
|
198
|
+
voiceId,
|
|
199
|
+
conversationId,
|
|
200
|
+
sessionId,
|
|
201
|
+
timestamp: Date.now()
|
|
202
|
+
});
|
|
203
|
+
}
|