opencode-studio-server 1.3.7 → 1.3.9
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/index.js +349 -11
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -69,6 +69,159 @@ app.use(bodyParser.urlencoded({ limit: '50mb', extended: true }));
|
|
|
69
69
|
const HOME_DIR = os.homedir();
|
|
70
70
|
const STUDIO_CONFIG_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'studio.json');
|
|
71
71
|
const PENDING_ACTION_PATH = path.join(HOME_DIR, '.config', 'opencode-studio', 'pending-action.json');
|
|
72
|
+
const ANTIGRAVITY_ACCOUNTS_PATH = path.join(HOME_DIR, '.config', 'opencode', 'antigravity-accounts.json');
|
|
73
|
+
const LOG_DIR = path.join(HOME_DIR, '.local', 'share', 'opencode', 'log');
|
|
74
|
+
|
|
75
|
+
let logWatcher = null;
|
|
76
|
+
let currentLogFile = null;
|
|
77
|
+
let logReadStream = null;
|
|
78
|
+
|
|
79
|
+
function setupLogWatcher() {
|
|
80
|
+
if (!fs.existsSync(LOG_DIR)) return;
|
|
81
|
+
|
|
82
|
+
// Find latest log file
|
|
83
|
+
const getLatestLog = () => {
|
|
84
|
+
try {
|
|
85
|
+
const files = fs.readdirSync(LOG_DIR)
|
|
86
|
+
.filter(f => f.endsWith('.log'))
|
|
87
|
+
.map(f => ({ name: f, time: fs.statSync(path.join(LOG_DIR, f)).mtime.getTime() }))
|
|
88
|
+
.sort((a, b) => b.time - a.time);
|
|
89
|
+
return files[0] ? path.join(LOG_DIR, files[0].name) : null;
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const startTailing = (filePath) => {
|
|
96
|
+
if (currentLogFile === filePath) return;
|
|
97
|
+
if (logReadStream) logReadStream.destroy();
|
|
98
|
+
|
|
99
|
+
currentLogFile = filePath;
|
|
100
|
+
console.log(`Watching log file: ${filePath}`);
|
|
101
|
+
|
|
102
|
+
let fileSize = 0;
|
|
103
|
+
try {
|
|
104
|
+
fileSize = fs.statSync(filePath).size;
|
|
105
|
+
} catch {}
|
|
106
|
+
|
|
107
|
+
// Tail from end initially
|
|
108
|
+
let start = Math.max(0, fileSize - 10000);
|
|
109
|
+
|
|
110
|
+
const checkFile = () => {
|
|
111
|
+
try {
|
|
112
|
+
const stat = fs.statSync(filePath);
|
|
113
|
+
if (stat.size > fileSize) {
|
|
114
|
+
const stream = fs.createReadStream(filePath, {
|
|
115
|
+
start: fileSize,
|
|
116
|
+
end: stat.size,
|
|
117
|
+
encoding: 'utf8'
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
stream.on('data', (chunk) => {
|
|
121
|
+
const lines = chunk.split('\n');
|
|
122
|
+
lines.forEach(processLogLine);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
fileSize = stat.size;
|
|
126
|
+
}
|
|
127
|
+
} catch (e) {
|
|
128
|
+
// File might be rotated/deleted
|
|
129
|
+
if (e.code === 'ENOENT') {
|
|
130
|
+
clearInterval(tailInterval);
|
|
131
|
+
const newLog = getLatestLog();
|
|
132
|
+
if (newLog && newLog !== currentLogFile) startTailing(newLog);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const tailInterval = setInterval(checkFile, 2000);
|
|
138
|
+
logWatcher = tailInterval;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
const initialLog = getLatestLog();
|
|
142
|
+
if (initialLog) startTailing(initialLog);
|
|
143
|
+
|
|
144
|
+
// Watch dir for new logs
|
|
145
|
+
fs.watch(LOG_DIR, (eventType, filename) => {
|
|
146
|
+
if (filename && filename.endsWith('.log')) {
|
|
147
|
+
const latest = getLatestLog();
|
|
148
|
+
if (latest && latest !== currentLogFile) {
|
|
149
|
+
if (logWatcher) clearInterval(logWatcher);
|
|
150
|
+
startTailing(latest);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function processLogLine(line) {
|
|
157
|
+
// Detect LLM usage: service=llm providerID=... modelID=...
|
|
158
|
+
// Example: service=llm providerID=openai modelID=gpt-5.2-codex sessionID=...
|
|
159
|
+
const isUsage = line.includes('service=llm') && line.includes('stream');
|
|
160
|
+
const isError = line.includes('service=llm') && (line.includes('error=') || line.includes('status=429'));
|
|
161
|
+
|
|
162
|
+
if (!isUsage && !isError) return;
|
|
163
|
+
|
|
164
|
+
const providerMatch = line.match(/providerID=([^\s]+)/);
|
|
165
|
+
const modelMatch = line.match(/modelID=([^\s]+)/);
|
|
166
|
+
|
|
167
|
+
if (providerMatch) {
|
|
168
|
+
const provider = providerMatch[1];
|
|
169
|
+
const model = modelMatch ? modelMatch[1] : 'unknown';
|
|
170
|
+
|
|
171
|
+
// Map provider to pool namespace
|
|
172
|
+
let namespace = provider;
|
|
173
|
+
if (provider === 'google') {
|
|
174
|
+
const activePlugin = getActiveGooglePlugin();
|
|
175
|
+
namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const metadata = loadPoolMetadata();
|
|
179
|
+
if (!metadata._quota) metadata._quota = {};
|
|
180
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
181
|
+
|
|
182
|
+
const today = new Date().toISOString().split('T')[0];
|
|
183
|
+
|
|
184
|
+
if (isUsage) {
|
|
185
|
+
// Increment usage
|
|
186
|
+
metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
|
|
187
|
+
|
|
188
|
+
// Also update active account usage if possible
|
|
189
|
+
const studio = loadStudioConfig();
|
|
190
|
+
const activeProfile = studio.activeProfiles?.[provider];
|
|
191
|
+
if (activeProfile && metadata[namespace]?.[activeProfile]) {
|
|
192
|
+
metadata[namespace][activeProfile].usageCount = (metadata[namespace][activeProfile].usageCount || 0) + 1;
|
|
193
|
+
metadata[namespace][activeProfile].lastUsed = Date.now();
|
|
194
|
+
}
|
|
195
|
+
} else if (isError) {
|
|
196
|
+
// Check for quota exhaustion patterns
|
|
197
|
+
const errorMsg = line.match(/error=(.+)/)?.[1] || '';
|
|
198
|
+
const isQuotaError = line.includes('status=429') ||
|
|
199
|
+
errorMsg.toLowerCase().includes('quota') ||
|
|
200
|
+
errorMsg.toLowerCase().includes('rate limit');
|
|
201
|
+
|
|
202
|
+
if (isQuotaError) {
|
|
203
|
+
console.log(`[LogWatcher] Detected quota exhaustion for ${namespace}`);
|
|
204
|
+
// Mark exhausted for today
|
|
205
|
+
metadata._quota[namespace].exhausted = true;
|
|
206
|
+
metadata._quota[namespace].exhaustedDate = today;
|
|
207
|
+
|
|
208
|
+
// Adaptive limit learning: if we hit a limit, maybe that's the ceiling?
|
|
209
|
+
// Only update if we have meaningful usage (>5) to avoid false positives on glitches
|
|
210
|
+
const currentUsage = metadata._quota[namespace][today] || 0;
|
|
211
|
+
if (currentUsage > 5) {
|
|
212
|
+
// Update daily limit to current usage (maybe round up)
|
|
213
|
+
metadata._quota[namespace].dailyLimit = currentUsage;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
savePoolMetadata(metadata);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Start watcher on server start
|
|
223
|
+
setupLogWatcher();
|
|
224
|
+
importExistingAuth();
|
|
72
225
|
|
|
73
226
|
let pendingActionMemory = null;
|
|
74
227
|
|
|
@@ -576,6 +729,7 @@ app.get('/api/auth/providers', (req, res) => {
|
|
|
576
729
|
});
|
|
577
730
|
|
|
578
731
|
app.get('/api/auth', (req, res) => {
|
|
732
|
+
syncAntigravityPool();
|
|
579
733
|
const authCfg = loadAuthConfig() || {};
|
|
580
734
|
const studio = loadStudioConfig();
|
|
581
735
|
const ac = studio.activeProfiles || {};
|
|
@@ -646,6 +800,7 @@ app.get('/api/auth', (req, res) => {
|
|
|
646
800
|
});
|
|
647
801
|
|
|
648
802
|
app.get('/api/auth/profiles', (req, res) => {
|
|
803
|
+
syncAntigravityPool();
|
|
649
804
|
const authCfg = loadAuthConfig() || {};
|
|
650
805
|
const studio = loadStudioConfig();
|
|
651
806
|
const ac = studio.activeProfiles || {};
|
|
@@ -798,6 +953,13 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
798
953
|
const newPath = path.join(AUTH_PROFILES_DIR, namespace, `${newName}.json`);
|
|
799
954
|
if (fs.existsSync(oldPath)) fs.renameSync(oldPath, newPath);
|
|
800
955
|
|
|
956
|
+
const metadata = loadPoolMetadata();
|
|
957
|
+
if (metadata[namespace]?.[name]) {
|
|
958
|
+
metadata[namespace][newName] = metadata[namespace][name];
|
|
959
|
+
delete metadata[namespace][name];
|
|
960
|
+
savePoolMetadata(metadata);
|
|
961
|
+
}
|
|
962
|
+
|
|
801
963
|
// Update active profile name if it was the one renamed
|
|
802
964
|
const studio = loadStudioConfig();
|
|
803
965
|
if (studio.activeProfiles && studio.activeProfiles[provider] === name) {
|
|
@@ -913,6 +1075,10 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
913
1075
|
delete metadata['google.gemini'];
|
|
914
1076
|
delete metadata['google.antigravity'];
|
|
915
1077
|
savePoolMetadata(metadata);
|
|
1078
|
+
|
|
1079
|
+
if (activePlugin === 'antigravity' && fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) {
|
|
1080
|
+
fs.rmSync(ANTIGRAVITY_ACCOUNTS_PATH, { force: true });
|
|
1081
|
+
}
|
|
916
1082
|
} else {
|
|
917
1083
|
delete authCfg[provider];
|
|
918
1084
|
if (studio.activeProfiles) delete studio.activeProfiles[provider];
|
|
@@ -959,6 +1125,138 @@ function savePoolMetadata(metadata) {
|
|
|
959
1125
|
atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
960
1126
|
}
|
|
961
1127
|
|
|
1128
|
+
function loadAntigravityAccounts() {
|
|
1129
|
+
if (!fs.existsSync(ANTIGRAVITY_ACCOUNTS_PATH)) return null;
|
|
1130
|
+
try {
|
|
1131
|
+
return JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, 'utf8'));
|
|
1132
|
+
} catch {
|
|
1133
|
+
return null;
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
function listAntigravityAccounts() {
|
|
1138
|
+
const data = loadAntigravityAccounts();
|
|
1139
|
+
if (!data?.accounts || !Array.isArray(data.accounts)) return [];
|
|
1140
|
+
return data.accounts.map(a => ({
|
|
1141
|
+
email: a.email || null,
|
|
1142
|
+
refreshToken: a.refreshToken || null
|
|
1143
|
+
}));
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
function syncAntigravityPool() {
|
|
1147
|
+
const accounts = listAntigravityAccounts();
|
|
1148
|
+
const namespace = 'google.antigravity';
|
|
1149
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
1150
|
+
|
|
1151
|
+
if (!accounts.length) {
|
|
1152
|
+
if (fs.existsSync(profileDir)) fs.rmSync(profileDir, { recursive: true, force: true });
|
|
1153
|
+
const metadata = loadPoolMetadata();
|
|
1154
|
+
if (metadata[namespace]) {
|
|
1155
|
+
delete metadata[namespace];
|
|
1156
|
+
savePoolMetadata(metadata);
|
|
1157
|
+
}
|
|
1158
|
+
return;
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
1162
|
+
|
|
1163
|
+
const metadata = loadPoolMetadata();
|
|
1164
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1165
|
+
|
|
1166
|
+
const seen = new Set();
|
|
1167
|
+
accounts.forEach((account, idx) => {
|
|
1168
|
+
const name = account.email || `account-${idx + 1}`;
|
|
1169
|
+
seen.add(name);
|
|
1170
|
+
const profilePath = path.join(profileDir, `${name}.json`);
|
|
1171
|
+
if (!fs.existsSync(profilePath)) {
|
|
1172
|
+
atomicWriteFileSync(profilePath, JSON.stringify({ email: account.email }, null, 2));
|
|
1173
|
+
}
|
|
1174
|
+
if (!metadata[namespace][name]) {
|
|
1175
|
+
metadata[namespace][name] = {
|
|
1176
|
+
email: account.email || null,
|
|
1177
|
+
createdAt: Date.now(),
|
|
1178
|
+
lastUsed: 0,
|
|
1179
|
+
usageCount: 0
|
|
1180
|
+
};
|
|
1181
|
+
}
|
|
1182
|
+
});
|
|
1183
|
+
|
|
1184
|
+
const profileFiles = fs.existsSync(profileDir) ? fs.readdirSync(profileDir).filter(f => f.endsWith('.json')) : [];
|
|
1185
|
+
profileFiles.forEach(file => {
|
|
1186
|
+
const name = file.replace('.json', '');
|
|
1187
|
+
if (!seen.has(name)) {
|
|
1188
|
+
fs.unlinkSync(path.join(profileDir, file));
|
|
1189
|
+
if (metadata[namespace]?.[name]) delete metadata[namespace][name];
|
|
1190
|
+
}
|
|
1191
|
+
});
|
|
1192
|
+
|
|
1193
|
+
savePoolMetadata(metadata);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
function importExistingAuth() {
|
|
1197
|
+
const authCfg = loadAuthConfig();
|
|
1198
|
+
if (!authCfg) return;
|
|
1199
|
+
|
|
1200
|
+
const studio = loadStudioConfig();
|
|
1201
|
+
const activeProfiles = studio.activeProfiles || {};
|
|
1202
|
+
let changed = false;
|
|
1203
|
+
|
|
1204
|
+
// Standard providers to check
|
|
1205
|
+
const providers = ['google', 'openai', 'anthropic', 'xai', 'openrouter', 'github-copilot', 'mistral', 'deepseek', 'amazon-bedrock', 'azure'];
|
|
1206
|
+
|
|
1207
|
+
providers.forEach(provider => {
|
|
1208
|
+
if (!authCfg[provider]) return; // No creds for this provider
|
|
1209
|
+
|
|
1210
|
+
// Determine namespace
|
|
1211
|
+
// For auto-import, we target the standard 'google' namespace unless antigravity plugin is active?
|
|
1212
|
+
// Actually, auth.json 'google' key usually means Gemini/Vertex standard auth.
|
|
1213
|
+
const namespace = provider === 'google' && studio.activeGooglePlugin === 'antigravity'
|
|
1214
|
+
? 'google.antigravity'
|
|
1215
|
+
: (provider === 'google' ? 'google.gemini' : provider);
|
|
1216
|
+
|
|
1217
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
1218
|
+
|
|
1219
|
+
// If we already have an active profile for this provider, skip import
|
|
1220
|
+
if (activeProfiles[provider]) return;
|
|
1221
|
+
|
|
1222
|
+
// If directory exists and has files, check if empty
|
|
1223
|
+
if (fs.existsSync(profileDir) && fs.readdirSync(profileDir).filter(f => f.endsWith('.json')).length > 0) {
|
|
1224
|
+
return;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Import!
|
|
1228
|
+
if (!fs.existsSync(profileDir)) fs.mkdirSync(profileDir, { recursive: true });
|
|
1229
|
+
|
|
1230
|
+
const email = authCfg[provider].email || null;
|
|
1231
|
+
const name = email || `imported-${Date.now()}`;
|
|
1232
|
+
const profilePath = path.join(profileDir, `${name}.json`);
|
|
1233
|
+
|
|
1234
|
+
console.log(`[AutoImport] Importing existing ${provider} credentials as ${name}`);
|
|
1235
|
+
atomicWriteFileSync(profilePath, JSON.stringify(authCfg[provider], null, 2));
|
|
1236
|
+
|
|
1237
|
+
// Set as active
|
|
1238
|
+
if (!studio.activeProfiles) studio.activeProfiles = {};
|
|
1239
|
+
studio.activeProfiles[provider] = name;
|
|
1240
|
+
changed = true;
|
|
1241
|
+
|
|
1242
|
+
// Update metadata
|
|
1243
|
+
const metadata = loadPoolMetadata();
|
|
1244
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1245
|
+
metadata[namespace][name] = {
|
|
1246
|
+
email: email,
|
|
1247
|
+
createdAt: Date.now(),
|
|
1248
|
+
lastUsed: Date.now(),
|
|
1249
|
+
usageCount: 0,
|
|
1250
|
+
imported: true
|
|
1251
|
+
};
|
|
1252
|
+
savePoolMetadata(metadata);
|
|
1253
|
+
});
|
|
1254
|
+
|
|
1255
|
+
if (changed) {
|
|
1256
|
+
saveStudioConfig(studio);
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
|
|
962
1260
|
function getAccountStatus(meta, now) {
|
|
963
1261
|
if (!meta) return 'ready';
|
|
964
1262
|
if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
|
|
@@ -1029,12 +1327,7 @@ function buildAccountPool(provider) {
|
|
|
1029
1327
|
};
|
|
1030
1328
|
}
|
|
1031
1329
|
|
|
1032
|
-
|
|
1033
|
-
app.get('/api/auth/pool', (req, res) => {
|
|
1034
|
-
const provider = req.query.provider || 'google';
|
|
1035
|
-
const pool = buildAccountPool(provider);
|
|
1036
|
-
|
|
1037
|
-
// Also include quota estimate (local tracking)
|
|
1330
|
+
function getPoolQuota(provider, pool) {
|
|
1038
1331
|
const metadata = loadPoolMetadata();
|
|
1039
1332
|
const activePlugin = getActiveGooglePlugin();
|
|
1040
1333
|
const namespace = provider === 'google'
|
|
@@ -1050,7 +1343,7 @@ app.get('/api/auth/pool', (req, res) => {
|
|
|
1050
1343
|
const remaining = Math.max(0, dailyLimit - todayUsage);
|
|
1051
1344
|
const percentage = Math.round((remaining / dailyLimit) * 100);
|
|
1052
1345
|
|
|
1053
|
-
|
|
1346
|
+
return {
|
|
1054
1347
|
dailyLimit,
|
|
1055
1348
|
remaining,
|
|
1056
1349
|
used: todayUsage,
|
|
@@ -1063,7 +1356,14 @@ app.get('/api/auth/pool', (req, res) => {
|
|
|
1063
1356
|
limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
|
|
1064
1357
|
}))
|
|
1065
1358
|
};
|
|
1066
|
-
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// GET /api/auth/pool - Get account pool for Google (or specified provider)
|
|
1362
|
+
app.get('/api/auth/pool', (req, res) => {
|
|
1363
|
+
const provider = req.query.provider || 'google';
|
|
1364
|
+
syncAntigravityPool();
|
|
1365
|
+
const pool = buildAccountPool(provider);
|
|
1366
|
+
const quota = getPoolQuota(provider, pool);
|
|
1067
1367
|
res.json({ pool, quota });
|
|
1068
1368
|
});
|
|
1069
1369
|
|
|
@@ -1143,6 +1443,31 @@ app.post('/api/auth/pool/rotate', (req, res) => {
|
|
|
1143
1443
|
});
|
|
1144
1444
|
});
|
|
1145
1445
|
|
|
1446
|
+
// POST /api/auth/pool/limit - Set daily quota limit
|
|
1447
|
+
app.post('/api/auth/pool/limit', (req, res) => {
|
|
1448
|
+
const { provider, limit } = req.body;
|
|
1449
|
+
|
|
1450
|
+
if (!provider || typeof limit !== 'number' || limit < 0) {
|
|
1451
|
+
return res.status(400).json({ error: 'Invalid provider or limit' });
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1455
|
+
const namespace = provider === 'google'
|
|
1456
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1457
|
+
: provider;
|
|
1458
|
+
|
|
1459
|
+
const metadata = loadPoolMetadata();
|
|
1460
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1461
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1462
|
+
|
|
1463
|
+
metadata._quota[namespace].dailyLimit = limit;
|
|
1464
|
+
// Reset exhaustion if limit increased significantly? Maybe user wants to retry.
|
|
1465
|
+
// For now, simple update.
|
|
1466
|
+
|
|
1467
|
+
savePoolMetadata(metadata);
|
|
1468
|
+
res.json({ success: true, limit });
|
|
1469
|
+
});
|
|
1470
|
+
|
|
1146
1471
|
// PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
|
|
1147
1472
|
app.put('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
1148
1473
|
const { name } = req.params;
|
|
@@ -1483,6 +1808,14 @@ app.get('/api/auth/google/plugin', (req, res) => {
|
|
|
1483
1808
|
|
|
1484
1809
|
const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
|
|
1485
1810
|
const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
|
|
1811
|
+
|
|
1812
|
+
function getGeminiClientId() {
|
|
1813
|
+
if (GEMINI_CLIENT_ID) return GEMINI_CLIENT_ID;
|
|
1814
|
+
const opencodeCfg = loadConfig();
|
|
1815
|
+
const oauth = opencodeCfg?.mcp?.google?.oauth;
|
|
1816
|
+
return oauth?.clientId || "";
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1486
1819
|
const GEMINI_SCOPES = [
|
|
1487
1820
|
"https://www.googleapis.com/auth/cloud-platform",
|
|
1488
1821
|
"https://www.googleapis.com/auth/userinfo.email",
|
|
@@ -1508,14 +1841,19 @@ app.post('/api/auth/google/start', async (req, res) => {
|
|
|
1508
1841
|
if (oauthCallbackServer) {
|
|
1509
1842
|
return res.status(400).json({ error: 'OAuth flow already in progress' });
|
|
1510
1843
|
}
|
|
1511
|
-
|
|
1844
|
+
|
|
1845
|
+
const clientId = getGeminiClientId();
|
|
1846
|
+
if (!clientId) {
|
|
1847
|
+
return res.status(400).json({ error: 'Missing Gemini OAuth client_id. Set GEMINI_CLIENT_ID or mcp.google.oauth.clientId.' });
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1512
1850
|
const { verifier, challenge } = generatePKCE();
|
|
1513
1851
|
const state = encodeOAuthState({ verifier });
|
|
1514
1852
|
|
|
1515
1853
|
pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
|
|
1516
1854
|
|
|
1517
1855
|
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
1518
|
-
authUrl.searchParams.set('client_id',
|
|
1856
|
+
authUrl.searchParams.set('client_id', clientId);
|
|
1519
1857
|
authUrl.searchParams.set('response_type', 'code');
|
|
1520
1858
|
authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
|
|
1521
1859
|
authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
|
|
@@ -1549,7 +1887,7 @@ app.post('/api/auth/google/start', async (req, res) => {
|
|
|
1549
1887
|
method: 'POST',
|
|
1550
1888
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1551
1889
|
body: new URLSearchParams({
|
|
1552
|
-
client_id:
|
|
1890
|
+
client_id: clientId,
|
|
1553
1891
|
client_secret: GEMINI_CLIENT_SECRET,
|
|
1554
1892
|
code,
|
|
1555
1893
|
grant_type: 'authorization_code',
|