opencode-studio-server 1.2.1 → 1.2.2
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/.env +2 -0
- package/.env.example +2 -0
- package/AGENTS.md +37 -0
- package/cli.js +7 -6
- package/index.js +698 -86
- package/package.json +1 -1
package/.env
ADDED
package/.env.example
ADDED
package/AGENTS.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# SERVER LAYER
|
|
2
|
+
|
|
3
|
+
Express API backend (port 3001). Single-file architecture.
|
|
4
|
+
|
|
5
|
+
## STRUCTURE
|
|
6
|
+
|
|
7
|
+
| File | Purpose |
|
|
8
|
+
|------|---------|
|
|
9
|
+
| `index.js` | All routes, config IO, auth, skills, plugins, usage stats |
|
|
10
|
+
| `cli.js` | npm bin entry, protocol URL parser, pending action queue |
|
|
11
|
+
| `register-protocol.js` | OS-specific `opencodestudio://` handler registration |
|
|
12
|
+
|
|
13
|
+
## WHERE TO LOOK
|
|
14
|
+
|
|
15
|
+
| Task | Location |
|
|
16
|
+
|------|----------|
|
|
17
|
+
| Add API endpoint | `index.js` - add `app.get/post/delete` |
|
|
18
|
+
| Config path detection | `index.js:getPaths()` - `CANDIDATE_PATHS` logic |
|
|
19
|
+
| Auth profile management | `index.js:440-620` - profiles CRUD |
|
|
20
|
+
| Google plugin switching | `index.js:803-860` - gemini/antigravity toggle |
|
|
21
|
+
| Usage stats aggregation | `index.js:687-800` - reads message storage |
|
|
22
|
+
| Protocol actions | `cli.js:34-84` - switch on action type |
|
|
23
|
+
| Windows registry | `register-protocol.js:8-55` |
|
|
24
|
+
|
|
25
|
+
## CONVENTIONS
|
|
26
|
+
|
|
27
|
+
- All routes in single file (no route modules)
|
|
28
|
+
- Studio prefs in `~/.config/opencode-studio/studio.json`, separate from opencode config
|
|
29
|
+
- Auth profiles namespaced: `google.gemini`, `google.antigravity`
|
|
30
|
+
- 30min idle timeout auto-shutdown
|
|
31
|
+
|
|
32
|
+
## ANTI-PATTERNS
|
|
33
|
+
|
|
34
|
+
- Hardcoding `~/.config/opencode` - use `getPaths().current`
|
|
35
|
+
- Importing from `client-next/` - separate processes
|
|
36
|
+
- Adding Next.js API routes - all API here
|
|
37
|
+
- Modifying opencode.json structure without updating `client-next/src/types/`
|
package/cli.js
CHANGED
|
@@ -40,16 +40,17 @@ if (protocolArg) {
|
|
|
40
40
|
break;
|
|
41
41
|
|
|
42
42
|
case 'install-mcp':
|
|
43
|
-
//
|
|
44
|
-
|
|
43
|
+
// Security: Do NOT accept 'cmd' or 'env' from deep links to prevent RCE.
|
|
44
|
+
// Only allow the name to be passed, user must configure the rest manually.
|
|
45
|
+
if (params.name) {
|
|
45
46
|
pendingAction = {
|
|
46
47
|
type: 'install-mcp',
|
|
47
|
-
name: params.name
|
|
48
|
-
command: params.cmd ? decodeURIComponent(params.cmd) : null,
|
|
49
|
-
env: params.env ? JSON.parse(decodeURIComponent(params.env)) : null,
|
|
48
|
+
name: params.name,
|
|
49
|
+
// command: params.cmd ? decodeURIComponent(params.cmd) : null, // DISABLED FOR SECURITY
|
|
50
|
+
// env: params.env ? JSON.parse(decodeURIComponent(params.env)) : null, // DISABLED FOR SECURITY
|
|
50
51
|
timestamp: Date.now(),
|
|
51
52
|
};
|
|
52
|
-
console.log(`Queued MCP install: ${pendingAction.name}`);
|
|
53
|
+
console.log(`Queued MCP install (name only): ${pendingAction.name}`);
|
|
53
54
|
}
|
|
54
55
|
break;
|
|
55
56
|
|
package/index.js
CHANGED
|
@@ -4,8 +4,24 @@ const bodyParser = require('body-parser');
|
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
6
|
const os = require('os');
|
|
7
|
+
const crypto = require('crypto');
|
|
7
8
|
const { spawn, exec } = require('child_process');
|
|
8
9
|
|
|
10
|
+
// Atomic file write: write to temp file then rename to prevent corruption
|
|
11
|
+
const atomicWriteFileSync = (filePath, data, options = 'utf8') => {
|
|
12
|
+
const dir = path.dirname(filePath);
|
|
13
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
const tempPath = path.join(dir, `.${path.basename(filePath)}.${crypto.randomBytes(6).toString('hex')}.tmp`);
|
|
15
|
+
try {
|
|
16
|
+
fs.writeFileSync(tempPath, data, options);
|
|
17
|
+
fs.renameSync(tempPath, filePath);
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// Clean up temp file if rename fails
|
|
20
|
+
try { fs.unlinkSync(tempPath); } catch {}
|
|
21
|
+
throw err;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
9
25
|
const app = express();
|
|
10
26
|
const PORT = 3001;
|
|
11
27
|
const IDLE_TIMEOUT_MS = 30 * 60 * 1000;
|
|
@@ -204,9 +220,7 @@ function loadStudioConfig() {
|
|
|
204
220
|
|
|
205
221
|
function saveStudioConfig(config) {
|
|
206
222
|
try {
|
|
207
|
-
|
|
208
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
209
|
-
fs.writeFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8');
|
|
223
|
+
atomicWriteFileSync(STUDIO_CONFIG_PATH, JSON.stringify(config, null, 2));
|
|
210
224
|
return true;
|
|
211
225
|
} catch (err) {
|
|
212
226
|
console.error('Failed to save studio config:', err);
|
|
@@ -265,9 +279,7 @@ const loadConfig = () => {
|
|
|
265
279
|
const saveConfig = (config) => {
|
|
266
280
|
const configPath = getConfigPath();
|
|
267
281
|
if (!configPath) throw new Error('No config path found');
|
|
268
|
-
|
|
269
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
270
|
-
fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf8');
|
|
282
|
+
atomicWriteFileSync(configPath, JSON.stringify(config, null, 2));
|
|
271
283
|
};
|
|
272
284
|
|
|
273
285
|
app.get('/api/health', (req, res) => res.json({ status: 'ok' }));
|
|
@@ -317,6 +329,9 @@ app.get('/api/skills', (req, res) => {
|
|
|
317
329
|
});
|
|
318
330
|
|
|
319
331
|
app.get('/api/skills/:name', (req, res) => {
|
|
332
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
333
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
334
|
+
}
|
|
320
335
|
const sd = getSkillDir();
|
|
321
336
|
const p = sd ? path.join(sd, req.params.name, 'SKILL.md') : null;
|
|
322
337
|
if (!p || !fs.existsSync(p)) return res.status(404).json({ error: 'Not found' });
|
|
@@ -324,6 +339,9 @@ app.get('/api/skills/:name', (req, res) => {
|
|
|
324
339
|
});
|
|
325
340
|
|
|
326
341
|
app.post('/api/skills/:name', (req, res) => {
|
|
342
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
343
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
344
|
+
}
|
|
327
345
|
const sd = getSkillDir();
|
|
328
346
|
if (!sd) return res.status(404).json({ error: 'No config' });
|
|
329
347
|
const dp = path.join(sd, req.params.name);
|
|
@@ -333,6 +351,9 @@ app.post('/api/skills/:name', (req, res) => {
|
|
|
333
351
|
});
|
|
334
352
|
|
|
335
353
|
app.delete('/api/skills/:name', (req, res) => {
|
|
354
|
+
if (!/^[a-zA-Z0-9_\-\s]+$/.test(req.params.name)) {
|
|
355
|
+
return res.status(400).json({ error: 'Invalid skill name' });
|
|
356
|
+
}
|
|
336
357
|
const sd = getSkillDir();
|
|
337
358
|
const dp = sd ? path.join(sd, req.params.name) : null;
|
|
338
359
|
if (dp && fs.existsSync(dp)) fs.rmSync(dp, { recursive: true, force: true });
|
|
@@ -572,7 +593,7 @@ app.post('/api/auth/profiles/:provider', (req, res) => {
|
|
|
572
593
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
573
594
|
|
|
574
595
|
const profilePath = path.join(dir, `${name || Date.now()}.json`);
|
|
575
|
-
|
|
596
|
+
atomicWriteFileSync(profilePath, JSON.stringify(auth[provider], null, 2));
|
|
576
597
|
res.json({ success: true, name: path.basename(profilePath, '.json') });
|
|
577
598
|
});
|
|
578
599
|
|
|
@@ -603,7 +624,7 @@ app.post('/api/auth/profiles/:provider/:name/activate', (req, res) => {
|
|
|
603
624
|
|
|
604
625
|
const cp = getConfigPath();
|
|
605
626
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
606
|
-
|
|
627
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
607
628
|
res.json({ success: true });
|
|
608
629
|
});
|
|
609
630
|
|
|
@@ -643,30 +664,79 @@ app.put('/api/auth/profiles/:provider/:name', (req, res) => {
|
|
|
643
664
|
|
|
644
665
|
app.post('/api/auth/login', (req, res) => {
|
|
645
666
|
let { provider } = req.body;
|
|
667
|
+
|
|
668
|
+
// Security: Validate provider against allowlist to prevent command injection
|
|
669
|
+
const ALLOWED_PROVIDERS = [
|
|
670
|
+
"", "google", "anthropic", "openai", "xai",
|
|
671
|
+
"openrouter", "github-copilot", "gemini",
|
|
672
|
+
"together", "mistral", "deepseek", "amazon-bedrock", "azure"
|
|
673
|
+
];
|
|
674
|
+
|
|
675
|
+
if (provider && !ALLOWED_PROVIDERS.includes(provider)) {
|
|
676
|
+
return res.status(400).json({ error: 'Invalid provider' });
|
|
677
|
+
}
|
|
678
|
+
|
|
646
679
|
if (typeof provider !== 'string') provider = "";
|
|
647
680
|
|
|
648
681
|
let cmd = 'opencode auth login';
|
|
649
682
|
if (provider) cmd += ` ${provider}`;
|
|
650
683
|
|
|
651
684
|
const platform = process.platform;
|
|
652
|
-
|
|
685
|
+
|
|
653
686
|
if (platform === 'win32') {
|
|
654
|
-
terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
687
|
+
const terminalCmd = `start "" cmd /c "call ${cmd} || pause"`;
|
|
688
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
689
|
+
exec(terminalCmd, (err) => {
|
|
690
|
+
if (err) {
|
|
691
|
+
console.error('Failed to open terminal:', err);
|
|
692
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
693
|
+
}
|
|
694
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
695
|
+
});
|
|
655
696
|
} else if (platform === 'darwin') {
|
|
656
|
-
terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
697
|
+
const terminalCmd = `osascript -e 'tell application "Terminal" to do script "${cmd}"'`;
|
|
698
|
+
console.log('Executing terminal command:', terminalCmd);
|
|
699
|
+
exec(terminalCmd, (err) => {
|
|
700
|
+
if (err) {
|
|
701
|
+
console.error('Failed to open terminal:', err);
|
|
702
|
+
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
703
|
+
}
|
|
704
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
705
|
+
});
|
|
657
706
|
} else {
|
|
658
|
-
|
|
707
|
+
const linuxTerminals = [
|
|
708
|
+
{ name: 'x-terminal-emulator', cmd: `x-terminal-emulator -e "${cmd}"` },
|
|
709
|
+
{ name: 'gnome-terminal', cmd: `gnome-terminal -- bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
710
|
+
{ name: 'konsole', cmd: `konsole -e bash -c "${cmd}; read -p 'Press Enter to close...'"` },
|
|
711
|
+
{ name: 'xfce4-terminal', cmd: `xfce4-terminal -e "bash -c \\"${cmd}; read -p 'Press Enter to close...'\\"" ` },
|
|
712
|
+
{ name: 'xterm', cmd: `xterm -e "bash -c '${cmd}; read -p Press_Enter_to_close...'"` }
|
|
713
|
+
];
|
|
714
|
+
|
|
715
|
+
const tryTerminal = (index) => {
|
|
716
|
+
if (index >= linuxTerminals.length) {
|
|
717
|
+
const fallbackCmd = cmd;
|
|
718
|
+
return res.json({
|
|
719
|
+
success: false,
|
|
720
|
+
message: 'No terminal emulator found',
|
|
721
|
+
note: 'Run this command manually in your terminal',
|
|
722
|
+
command: fallbackCmd
|
|
723
|
+
});
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
const terminal = linuxTerminals[index];
|
|
727
|
+
console.log(`Trying terminal: ${terminal.name}`);
|
|
728
|
+
exec(terminal.cmd, (err) => {
|
|
729
|
+
if (err) {
|
|
730
|
+
console.log(`${terminal.name} failed, trying next...`);
|
|
731
|
+
tryTerminal(index + 1);
|
|
732
|
+
} else {
|
|
733
|
+
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
tryTerminal(0);
|
|
659
739
|
}
|
|
660
|
-
|
|
661
|
-
console.log('Executing terminal command:', terminalCmd);
|
|
662
|
-
|
|
663
|
-
exec(terminalCmd, (err) => {
|
|
664
|
-
if (err) {
|
|
665
|
-
console.error('Failed to open terminal:', err);
|
|
666
|
-
return res.status(500).json({ error: 'Failed to open terminal', details: err.message });
|
|
667
|
-
}
|
|
668
|
-
res.json({ success: true, message: 'Terminal opened', note: 'Complete login in the terminal window' });
|
|
669
|
-
});
|
|
670
740
|
});
|
|
671
741
|
|
|
672
742
|
app.delete('/api/auth/:provider', (req, res) => {
|
|
@@ -675,7 +745,7 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
675
745
|
delete authCfg[provider];
|
|
676
746
|
const cp = getConfigPath();
|
|
677
747
|
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
678
|
-
|
|
748
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
679
749
|
|
|
680
750
|
const studio = loadStudioConfig();
|
|
681
751
|
if (studio.activeProfiles) delete studio.activeProfiles[provider];
|
|
@@ -684,6 +754,332 @@ app.delete('/api/auth/:provider', (req, res) => {
|
|
|
684
754
|
res.json({ success: true });
|
|
685
755
|
});
|
|
686
756
|
|
|
757
|
+
// ============================================
|
|
758
|
+
// ACCOUNT POOL MANAGEMENT (Antigravity-style)
|
|
759
|
+
// ============================================
|
|
760
|
+
|
|
761
|
+
const POOL_METADATA_FILE = path.join(HOME_DIR, '.config', 'opencode-studio', 'pool-metadata.json');
|
|
762
|
+
|
|
763
|
+
function loadPoolMetadata() {
|
|
764
|
+
if (!fs.existsSync(POOL_METADATA_FILE)) return {};
|
|
765
|
+
try {
|
|
766
|
+
return JSON.parse(fs.readFileSync(POOL_METADATA_FILE, 'utf8'));
|
|
767
|
+
} catch {
|
|
768
|
+
return {};
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function savePoolMetadata(metadata) {
|
|
773
|
+
const dir = path.dirname(POOL_METADATA_FILE);
|
|
774
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
775
|
+
atomicWriteFileSync(POOL_METADATA_FILE, JSON.stringify(metadata, null, 2));
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function getAccountStatus(meta, now) {
|
|
779
|
+
if (!meta) return 'ready';
|
|
780
|
+
if (meta.cooldownUntil && meta.cooldownUntil > now) return 'cooldown';
|
|
781
|
+
if (meta.expired) return 'expired';
|
|
782
|
+
return 'ready';
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function buildAccountPool(provider) {
|
|
786
|
+
const activePlugin = getActiveGooglePlugin();
|
|
787
|
+
const namespace = provider === 'google'
|
|
788
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
789
|
+
: provider;
|
|
790
|
+
|
|
791
|
+
const profileDir = path.join(AUTH_PROFILES_DIR, namespace);
|
|
792
|
+
const profiles = [];
|
|
793
|
+
const now = Date.now();
|
|
794
|
+
const metadata = loadPoolMetadata();
|
|
795
|
+
const providerMeta = metadata[namespace] || {};
|
|
796
|
+
|
|
797
|
+
// Get current active profile from studio config
|
|
798
|
+
const studio = loadStudioConfig();
|
|
799
|
+
const activeProfile = studio.activeProfiles?.[provider] || null;
|
|
800
|
+
|
|
801
|
+
if (fs.existsSync(profileDir)) {
|
|
802
|
+
const files = fs.readdirSync(profileDir).filter(f => f.endsWith('.json'));
|
|
803
|
+
files.forEach(file => {
|
|
804
|
+
const name = file.replace('.json', '');
|
|
805
|
+
const meta = providerMeta[name] || {};
|
|
806
|
+
const status = name === activeProfile ? 'active' : getAccountStatus(meta, now);
|
|
807
|
+
|
|
808
|
+
profiles.push({
|
|
809
|
+
name,
|
|
810
|
+
email: meta.email || null,
|
|
811
|
+
status,
|
|
812
|
+
lastUsed: meta.lastUsed || 0,
|
|
813
|
+
usageCount: meta.usageCount || 0,
|
|
814
|
+
cooldownUntil: meta.cooldownUntil || null,
|
|
815
|
+
createdAt: meta.createdAt || 0
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
// Sort: active first, then by lastUsed (LRU)
|
|
821
|
+
profiles.sort((a, b) => {
|
|
822
|
+
if (a.status === 'active') return -1;
|
|
823
|
+
if (b.status === 'active') return 1;
|
|
824
|
+
return a.lastUsed - b.lastUsed;
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
const available = profiles.filter(p => p.status === 'active' || p.status === 'ready').length;
|
|
828
|
+
const cooldown = profiles.filter(p => p.status === 'cooldown').length;
|
|
829
|
+
|
|
830
|
+
return {
|
|
831
|
+
provider,
|
|
832
|
+
namespace,
|
|
833
|
+
accounts: profiles,
|
|
834
|
+
activeAccount: activeProfile,
|
|
835
|
+
totalAccounts: profiles.length,
|
|
836
|
+
availableAccounts: available,
|
|
837
|
+
cooldownAccounts: cooldown
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// GET /api/auth/pool - Get account pool for Google (or specified provider)
|
|
842
|
+
app.get('/api/auth/pool', (req, res) => {
|
|
843
|
+
const provider = req.query.provider || 'google';
|
|
844
|
+
const pool = buildAccountPool(provider);
|
|
845
|
+
|
|
846
|
+
// Also include quota estimate (local tracking)
|
|
847
|
+
const metadata = loadPoolMetadata();
|
|
848
|
+
const activePlugin = getActiveGooglePlugin();
|
|
849
|
+
const namespace = provider === 'google'
|
|
850
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
851
|
+
: provider;
|
|
852
|
+
|
|
853
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
854
|
+
const today = new Date().toISOString().split('T')[0];
|
|
855
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
856
|
+
|
|
857
|
+
// Estimate: 1000 requests/day limit (configurable)
|
|
858
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
859
|
+
const remaining = Math.max(0, dailyLimit - todayUsage);
|
|
860
|
+
const percentage = Math.round((remaining / dailyLimit) * 100);
|
|
861
|
+
|
|
862
|
+
const quota = {
|
|
863
|
+
dailyLimit,
|
|
864
|
+
remaining,
|
|
865
|
+
used: todayUsage,
|
|
866
|
+
percentage,
|
|
867
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
868
|
+
byAccount: pool.accounts.map(acc => ({
|
|
869
|
+
name: acc.name,
|
|
870
|
+
email: acc.email,
|
|
871
|
+
used: acc.usageCount,
|
|
872
|
+
limit: Math.floor(dailyLimit / Math.max(1, pool.totalAccounts))
|
|
873
|
+
}))
|
|
874
|
+
};
|
|
875
|
+
|
|
876
|
+
res.json({ pool, quota });
|
|
877
|
+
});
|
|
878
|
+
|
|
879
|
+
// POST /api/auth/pool/rotate - Rotate to next available account
|
|
880
|
+
app.post('/api/auth/pool/rotate', (req, res) => {
|
|
881
|
+
const provider = req.body.provider || 'google';
|
|
882
|
+
const pool = buildAccountPool(provider);
|
|
883
|
+
|
|
884
|
+
if (pool.accounts.length === 0) {
|
|
885
|
+
return res.status(400).json({ error: 'No accounts in pool' });
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
const now = Date.now();
|
|
889
|
+
const available = pool.accounts.filter(acc =>
|
|
890
|
+
acc.status === 'ready' || (acc.status === 'cooldown' && acc.cooldownUntil && acc.cooldownUntil < now)
|
|
891
|
+
);
|
|
892
|
+
|
|
893
|
+
if (available.length === 0) {
|
|
894
|
+
return res.status(400).json({ error: 'No available accounts (all in cooldown or expired)' });
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// Pick least recently used
|
|
898
|
+
const next = available.sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
899
|
+
const previousActive = pool.activeAccount;
|
|
900
|
+
|
|
901
|
+
// Activate the new account
|
|
902
|
+
const activePlugin = getActiveGooglePlugin();
|
|
903
|
+
const namespace = provider === 'google'
|
|
904
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
905
|
+
: provider;
|
|
906
|
+
|
|
907
|
+
const profilePath = path.join(AUTH_PROFILES_DIR, namespace, `${next.name}.json`);
|
|
908
|
+
if (!fs.existsSync(profilePath)) {
|
|
909
|
+
return res.status(404).json({ error: 'Profile file not found' });
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
const profileData = JSON.parse(fs.readFileSync(profilePath, 'utf8'));
|
|
913
|
+
|
|
914
|
+
// Update auth.json
|
|
915
|
+
const authCfg = loadAuthConfig() || {};
|
|
916
|
+
authCfg[provider] = profileData;
|
|
917
|
+
if (provider === 'google') {
|
|
918
|
+
authCfg[namespace] = profileData;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const cp = getConfigPath();
|
|
922
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
923
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
924
|
+
|
|
925
|
+
// Update studio config
|
|
926
|
+
const studio = loadStudioConfig();
|
|
927
|
+
if (!studio.activeProfiles) studio.activeProfiles = {};
|
|
928
|
+
studio.activeProfiles[provider] = next.name;
|
|
929
|
+
saveStudioConfig(studio);
|
|
930
|
+
|
|
931
|
+
// Update metadata
|
|
932
|
+
const metadata = loadPoolMetadata();
|
|
933
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
934
|
+
metadata[namespace][next.name] = {
|
|
935
|
+
...metadata[namespace][next.name],
|
|
936
|
+
lastUsed: now
|
|
937
|
+
};
|
|
938
|
+
savePoolMetadata(metadata);
|
|
939
|
+
|
|
940
|
+
res.json({
|
|
941
|
+
success: true,
|
|
942
|
+
previousAccount: previousActive,
|
|
943
|
+
newAccount: next.name,
|
|
944
|
+
reason: 'manual_rotation'
|
|
945
|
+
});
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// PUT /api/auth/pool/:name/cooldown - Mark account as in cooldown
|
|
949
|
+
app.put('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
950
|
+
const { name } = req.params;
|
|
951
|
+
const { duration = 3600000, provider = 'google' } = req.body; // default 1 hour
|
|
952
|
+
|
|
953
|
+
const activePlugin = getActiveGooglePlugin();
|
|
954
|
+
const namespace = provider === 'google'
|
|
955
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
956
|
+
: provider;
|
|
957
|
+
|
|
958
|
+
const metadata = loadPoolMetadata();
|
|
959
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
960
|
+
|
|
961
|
+
metadata[namespace][name] = {
|
|
962
|
+
...metadata[namespace][name],
|
|
963
|
+
cooldownUntil: Date.now() + duration,
|
|
964
|
+
lastCooldownReason: req.body.reason || 'rate_limit'
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
savePoolMetadata(metadata);
|
|
968
|
+
res.json({ success: true, cooldownUntil: metadata[namespace][name].cooldownUntil });
|
|
969
|
+
});
|
|
970
|
+
|
|
971
|
+
// DELETE /api/auth/pool/:name/cooldown - Clear cooldown for account
|
|
972
|
+
app.delete('/api/auth/pool/:name/cooldown', (req, res) => {
|
|
973
|
+
const { name } = req.params;
|
|
974
|
+
const provider = req.query.provider || 'google';
|
|
975
|
+
|
|
976
|
+
const activePlugin = getActiveGooglePlugin();
|
|
977
|
+
const namespace = provider === 'google'
|
|
978
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
979
|
+
: provider;
|
|
980
|
+
|
|
981
|
+
const metadata = loadPoolMetadata();
|
|
982
|
+
if (metadata[namespace]?.[name]) {
|
|
983
|
+
delete metadata[namespace][name].cooldownUntil;
|
|
984
|
+
savePoolMetadata(metadata);
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
res.json({ success: true });
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// POST /api/auth/pool/:name/usage - Increment usage counter (for tracking)
|
|
991
|
+
app.post('/api/auth/pool/:name/usage', (req, res) => {
|
|
992
|
+
const { name } = req.params;
|
|
993
|
+
const { provider = 'google' } = req.body;
|
|
994
|
+
|
|
995
|
+
const activePlugin = getActiveGooglePlugin();
|
|
996
|
+
const namespace = provider === 'google'
|
|
997
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
998
|
+
: provider;
|
|
999
|
+
|
|
1000
|
+
const metadata = loadPoolMetadata();
|
|
1001
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1002
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = { usageCount: 0 };
|
|
1003
|
+
|
|
1004
|
+
metadata[namespace][name].usageCount = (metadata[namespace][name].usageCount || 0) + 1;
|
|
1005
|
+
metadata[namespace][name].lastUsed = Date.now();
|
|
1006
|
+
|
|
1007
|
+
// Track daily quota
|
|
1008
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1009
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1010
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1011
|
+
metadata._quota[namespace][today] = (metadata._quota[namespace][today] || 0) + 1;
|
|
1012
|
+
|
|
1013
|
+
savePoolMetadata(metadata);
|
|
1014
|
+
res.json({ success: true, usageCount: metadata[namespace][name].usageCount });
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
// PUT /api/auth/pool/:name/metadata - Update account metadata (email, etc.)
|
|
1018
|
+
app.put('/api/auth/pool/:name/metadata', (req, res) => {
|
|
1019
|
+
const { name } = req.params;
|
|
1020
|
+
const { provider = 'google', email, createdAt } = req.body;
|
|
1021
|
+
|
|
1022
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1023
|
+
const namespace = provider === 'google'
|
|
1024
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1025
|
+
: provider;
|
|
1026
|
+
|
|
1027
|
+
const metadata = loadPoolMetadata();
|
|
1028
|
+
if (!metadata[namespace]) metadata[namespace] = {};
|
|
1029
|
+
if (!metadata[namespace][name]) metadata[namespace][name] = {};
|
|
1030
|
+
|
|
1031
|
+
if (email !== undefined) metadata[namespace][name].email = email;
|
|
1032
|
+
if (createdAt !== undefined) metadata[namespace][name].createdAt = createdAt;
|
|
1033
|
+
|
|
1034
|
+
savePoolMetadata(metadata);
|
|
1035
|
+
res.json({ success: true });
|
|
1036
|
+
});
|
|
1037
|
+
|
|
1038
|
+
// GET /api/auth/pool/quota - Get quota info
|
|
1039
|
+
app.get('/api/auth/pool/quota', (req, res) => {
|
|
1040
|
+
const provider = req.query.provider || 'google';
|
|
1041
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1042
|
+
const namespace = provider === 'google'
|
|
1043
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1044
|
+
: provider;
|
|
1045
|
+
|
|
1046
|
+
const metadata = loadPoolMetadata();
|
|
1047
|
+
const quotaMeta = metadata._quota?.[namespace] || {};
|
|
1048
|
+
const today = new Date().toISOString().split('T')[0];
|
|
1049
|
+
const todayUsage = quotaMeta[today] || 0;
|
|
1050
|
+
const dailyLimit = quotaMeta.dailyLimit || 1000;
|
|
1051
|
+
|
|
1052
|
+
res.json({
|
|
1053
|
+
dailyLimit,
|
|
1054
|
+
remaining: Math.max(0, dailyLimit - todayUsage),
|
|
1055
|
+
used: todayUsage,
|
|
1056
|
+
percentage: Math.round(((dailyLimit - todayUsage) / dailyLimit) * 100),
|
|
1057
|
+
resetAt: new Date(new Date().setHours(24, 0, 0, 0)).toISOString(),
|
|
1058
|
+
byAccount: []
|
|
1059
|
+
});
|
|
1060
|
+
});
|
|
1061
|
+
|
|
1062
|
+
// POST /api/auth/pool/quota/limit - Set daily quota limit
|
|
1063
|
+
app.post('/api/auth/pool/quota/limit', (req, res) => {
|
|
1064
|
+
const { provider = 'google', limit } = req.body;
|
|
1065
|
+
const activePlugin = getActiveGooglePlugin();
|
|
1066
|
+
const namespace = provider === 'google'
|
|
1067
|
+
? (activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini')
|
|
1068
|
+
: provider;
|
|
1069
|
+
|
|
1070
|
+
const metadata = loadPoolMetadata();
|
|
1071
|
+
if (!metadata._quota) metadata._quota = {};
|
|
1072
|
+
if (!metadata._quota[namespace]) metadata._quota[namespace] = {};
|
|
1073
|
+
metadata._quota[namespace].dailyLimit = limit;
|
|
1074
|
+
|
|
1075
|
+
savePoolMetadata(metadata);
|
|
1076
|
+
res.json({ success: true, dailyLimit: limit });
|
|
1077
|
+
});
|
|
1078
|
+
|
|
1079
|
+
// ============================================
|
|
1080
|
+
// END ACCOUNT POOL MANAGEMENT
|
|
1081
|
+
// ============================================
|
|
1082
|
+
|
|
687
1083
|
app.get('/api/usage', async (req, res) => {
|
|
688
1084
|
try {
|
|
689
1085
|
const {projectId: fid, granularity = 'daily', range = '30d'} = req.query;
|
|
@@ -714,22 +1110,29 @@ app.get('/api/usage', async (req, res) => {
|
|
|
714
1110
|
|
|
715
1111
|
if (!md) return res.json({ totalCost: 0, totalTokens: 0, byModel: [], byDay: [], byProject: [] });
|
|
716
1112
|
|
|
717
|
-
|
|
718
1113
|
const pmap = new Map();
|
|
719
1114
|
if (fs.existsSync(sd)) {
|
|
720
|
-
fs.
|
|
1115
|
+
const sessionDirs = await fs.promises.readdir(sd);
|
|
1116
|
+
await Promise.all(sessionDirs.map(async d => {
|
|
721
1117
|
const fp = path.join(sd, d);
|
|
722
|
-
|
|
723
|
-
fs.
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
1118
|
+
try {
|
|
1119
|
+
const stats = await fs.promises.stat(fp);
|
|
1120
|
+
if (stats.isDirectory()) {
|
|
1121
|
+
const files = await fs.promises.readdir(fp);
|
|
1122
|
+
await Promise.all(files.map(async f => {
|
|
1123
|
+
if (f.startsWith('ses_') && f.endsWith('.json')) {
|
|
1124
|
+
try {
|
|
1125
|
+
const m = JSON.parse(await fs.promises.readFile(path.join(fp, f), 'utf8'));
|
|
1126
|
+
pmap.set(f.replace('.json', ''), {
|
|
1127
|
+
name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'),
|
|
1128
|
+
id: m.projectID || d
|
|
1129
|
+
});
|
|
1130
|
+
} catch {}
|
|
1131
|
+
}
|
|
1132
|
+
}));
|
|
1133
|
+
}
|
|
1134
|
+
} catch {}
|
|
1135
|
+
}));
|
|
733
1136
|
}
|
|
734
1137
|
|
|
735
1138
|
const stats = { totalCost: 0, totalTokens: 0, byModel: {}, byTime: {}, byProject: {} };
|
|
@@ -741,52 +1144,61 @@ app.get('/api/usage', async (req, res) => {
|
|
|
741
1144
|
else if (range === '30d') min = now - 2592000000;
|
|
742
1145
|
else if (range === '1y') min = now - 31536000000;
|
|
743
1146
|
|
|
744
|
-
fs.
|
|
1147
|
+
const sessionDirs = await fs.promises.readdir(md);
|
|
1148
|
+
await Promise.all(sessionDirs.map(async s => {
|
|
745
1149
|
if (!s.startsWith('ses_')) return;
|
|
746
1150
|
const sp = path.join(md, s);
|
|
747
|
-
|
|
748
|
-
fs.
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
if (
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
const
|
|
759
|
-
|
|
760
|
-
if (
|
|
761
|
-
|
|
762
|
-
const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
763
|
-
tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
|
|
764
|
-
} else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
|
|
765
|
-
else tk = d.toISOString().split('T')[0];
|
|
766
|
-
|
|
767
|
-
const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
768
|
-
stats.totalCost += c; stats.totalTokens += t;
|
|
769
|
-
[stats.byModel, stats.byProject].forEach((obj, i) => {
|
|
770
|
-
const key = i === 0 ? mid : pid;
|
|
771
|
-
if (!obj[key]) obj[key] = { name: key, id: key, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
772
|
-
if (i === 1) obj[key].name = pmap.get(s)?.name || 'Unassigned';
|
|
773
|
-
obj[key].cost += c; obj[key].tokens += t; obj[key].inputTokens += it; obj[key].outputTokens += ot;
|
|
774
|
-
});
|
|
775
|
-
|
|
776
|
-
if (!stats.byTime[tk]) stats.byTime[tk] = { date: tk, name: tk, id: tk, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
777
|
-
const te = stats.byTime[tk];
|
|
778
|
-
te.cost += c; te.tokens += t; te.inputTokens += it; te.outputTokens += ot;
|
|
779
|
-
if (!te[mid]) te[mid] = 0;
|
|
780
|
-
te[mid] += c;
|
|
1151
|
+
try {
|
|
1152
|
+
const spStats = await fs.promises.stat(sp);
|
|
1153
|
+
if (spStats.isDirectory()) {
|
|
1154
|
+
const files = await fs.promises.readdir(sp);
|
|
1155
|
+
for (const f of files) {
|
|
1156
|
+
if (!f.endsWith('.json')) continue;
|
|
1157
|
+
const fullPath = path.join(sp, f);
|
|
1158
|
+
if (seen.has(fullPath)) continue;
|
|
1159
|
+
seen.add(fullPath);
|
|
1160
|
+
|
|
1161
|
+
try {
|
|
1162
|
+
const msg = JSON.parse(await fs.promises.readFile(fullPath, 'utf8'));
|
|
1163
|
+
const pid = pmap.get(s)?.id || 'unknown';
|
|
1164
|
+
if (fid && fid !== 'all' && pid !== fid) continue;
|
|
1165
|
+
if (min > 0 && msg.time.created < min) continue;
|
|
781
1166
|
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1167
|
+
if (msg.role === 'assistant' && msg.tokens) {
|
|
1168
|
+
const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
|
|
1169
|
+
const d = new Date(msg.time.created);
|
|
1170
|
+
let tk;
|
|
1171
|
+
if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
|
|
1172
|
+
else if (granularity === 'weekly') {
|
|
1173
|
+
const day = d.getDay(), diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
|
1174
|
+
tk = new Date(d.setDate(diff)).toISOString().split('T')[0];
|
|
1175
|
+
} else if (granularity === 'monthly') tk = d.toISOString().substring(0, 7) + '-01';
|
|
1176
|
+
else tk = d.toISOString().split('T')[0];
|
|
1177
|
+
|
|
1178
|
+
const mid = msg.modelID || (msg.model && (msg.model.modelID || msg.model.id)) || 'unknown';
|
|
1179
|
+
stats.totalCost += c; stats.totalTokens += t;
|
|
1180
|
+
|
|
1181
|
+
if (!stats.byModel[mid]) stats.byModel[mid] = { name: mid, id: mid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1182
|
+
stats.byModel[mid].cost += c; stats.byModel[mid].tokens += t; stats.byModel[mid].inputTokens += it; stats.byModel[mid].outputTokens += ot;
|
|
1183
|
+
|
|
1184
|
+
if (!stats.byProject[pid]) stats.byProject[pid] = { name: pmap.get(s)?.name || 'Unassigned', id: pid, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1185
|
+
stats.byProject[pid].cost += c; stats.byProject[pid].tokens += t; stats.byProject[pid].inputTokens += it; stats.byProject[pid].outputTokens += ot;
|
|
1186
|
+
|
|
1187
|
+
if (!stats.byTime[tk]) stats.byTime[tk] = { date: tk, name: tk, id: tk, cost: 0, tokens: 0, inputTokens: 0, outputTokens: 0 };
|
|
1188
|
+
const te = stats.byTime[tk];
|
|
1189
|
+
te.cost += c; te.tokens += t; te.inputTokens += it; te.outputTokens += ot;
|
|
1190
|
+
if (!te[mid]) te[mid] = 0;
|
|
1191
|
+
te[mid] += c;
|
|
1192
|
+
|
|
1193
|
+
const kIn = `${mid}_input`, kOut = `${mid}_output`;
|
|
1194
|
+
te[kIn] = (te[kIn] || 0) + it;
|
|
1195
|
+
te[kOut] = (te[kOut] || 0) + ot;
|
|
1196
|
+
}
|
|
1197
|
+
} catch {}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
} catch {}
|
|
1201
|
+
}));
|
|
790
1202
|
|
|
791
1203
|
res.json({
|
|
792
1204
|
totalCost: stats.totalCost,
|
|
@@ -796,7 +1208,8 @@ app.get('/api/usage', async (req, res) => {
|
|
|
796
1208
|
byProject: Object.values(stats.byProject).sort((a, b) => b.cost - a.cost)
|
|
797
1209
|
});
|
|
798
1210
|
} catch (error) {
|
|
799
|
-
|
|
1211
|
+
console.error('Usage API error:', error);
|
|
1212
|
+
res.status(500).json({ error: 'Failed to fetch usage statistics' });
|
|
800
1213
|
}
|
|
801
1214
|
});
|
|
802
1215
|
|
|
@@ -809,11 +1222,14 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
809
1222
|
try {
|
|
810
1223
|
const opencode = loadConfig();
|
|
811
1224
|
if (opencode) {
|
|
812
|
-
if (opencode.provider
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
1225
|
+
if (!opencode.provider) opencode.provider = {};
|
|
1226
|
+
if (!opencode.provider.google) {
|
|
1227
|
+
opencode.provider.google = { models: {} };
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
const models = studio.pluginModels[plugin];
|
|
1231
|
+
if (models) {
|
|
1232
|
+
opencode.provider.google.models = models;
|
|
817
1233
|
}
|
|
818
1234
|
|
|
819
1235
|
if (!opencode.plugin) opencode.plugin = [];
|
|
@@ -844,7 +1260,7 @@ app.post('/api/auth/google/plugin', (req, res) => {
|
|
|
844
1260
|
} else if (plugin === 'gemini' && authCfg['google.gemini']) {
|
|
845
1261
|
authCfg.google = { ...authCfg['google.gemini'] };
|
|
846
1262
|
}
|
|
847
|
-
|
|
1263
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
848
1264
|
}
|
|
849
1265
|
}
|
|
850
1266
|
} catch (err) {
|
|
@@ -859,6 +1275,202 @@ app.get('/api/auth/google/plugin', (req, res) => {
|
|
|
859
1275
|
res.json({ activePlugin: studio.activeGooglePlugin || null });
|
|
860
1276
|
});
|
|
861
1277
|
|
|
1278
|
+
const GEMINI_CLIENT_ID = process.env.GEMINI_CLIENT_ID || "";
|
|
1279
|
+
const GEMINI_CLIENT_SECRET = process.env.GEMINI_CLIENT_SECRET || "";
|
|
1280
|
+
const GEMINI_SCOPES = [
|
|
1281
|
+
"https://www.googleapis.com/auth/cloud-platform",
|
|
1282
|
+
"https://www.googleapis.com/auth/userinfo.email",
|
|
1283
|
+
"https://www.googleapis.com/auth/userinfo.profile"
|
|
1284
|
+
];
|
|
1285
|
+
const OAUTH_CALLBACK_PORT = 8085;
|
|
1286
|
+
const GEMINI_REDIRECT_URI = `http://localhost:${OAUTH_CALLBACK_PORT}/oauth2callback`;
|
|
1287
|
+
|
|
1288
|
+
let pendingOAuthState = null;
|
|
1289
|
+
let oauthCallbackServer = null;
|
|
1290
|
+
|
|
1291
|
+
function generatePKCE() {
|
|
1292
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
1293
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
1294
|
+
return { verifier, challenge };
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
function encodeOAuthState(payload) {
|
|
1298
|
+
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
app.post('/api/auth/google/start', async (req, res) => {
|
|
1302
|
+
if (oauthCallbackServer) {
|
|
1303
|
+
return res.status(400).json({ error: 'OAuth flow already in progress' });
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
const { verifier, challenge } = generatePKCE();
|
|
1307
|
+
const state = encodeOAuthState({ verifier });
|
|
1308
|
+
|
|
1309
|
+
pendingOAuthState = { verifier, status: 'pending', startedAt: Date.now() };
|
|
1310
|
+
|
|
1311
|
+
const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
|
|
1312
|
+
authUrl.searchParams.set('client_id', GEMINI_CLIENT_ID);
|
|
1313
|
+
authUrl.searchParams.set('response_type', 'code');
|
|
1314
|
+
authUrl.searchParams.set('redirect_uri', GEMINI_REDIRECT_URI);
|
|
1315
|
+
authUrl.searchParams.set('scope', GEMINI_SCOPES.join(' '));
|
|
1316
|
+
authUrl.searchParams.set('code_challenge', challenge);
|
|
1317
|
+
authUrl.searchParams.set('code_challenge_method', 'S256');
|
|
1318
|
+
authUrl.searchParams.set('state', state);
|
|
1319
|
+
authUrl.searchParams.set('access_type', 'offline');
|
|
1320
|
+
authUrl.searchParams.set('prompt', 'consent');
|
|
1321
|
+
|
|
1322
|
+
const callbackApp = express();
|
|
1323
|
+
|
|
1324
|
+
callbackApp.get('/oauth2callback', async (callbackReq, callbackRes) => {
|
|
1325
|
+
const { code, state: returnedState, error } = callbackReq.query;
|
|
1326
|
+
|
|
1327
|
+
if (error) {
|
|
1328
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error };
|
|
1329
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>Error: ' + error + '</p><script>window.close()</script></body></html>');
|
|
1330
|
+
shutdownCallbackServer();
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
if (!code) {
|
|
1335
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'No authorization code received' };
|
|
1336
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>No code received</p><script>window.close()</script></body></html>');
|
|
1337
|
+
shutdownCallbackServer();
|
|
1338
|
+
return;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
try {
|
|
1342
|
+
const tokenResponse = await fetch('https://oauth2.googleapis.com/token', {
|
|
1343
|
+
method: 'POST',
|
|
1344
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1345
|
+
body: new URLSearchParams({
|
|
1346
|
+
client_id: GEMINI_CLIENT_ID,
|
|
1347
|
+
client_secret: GEMINI_CLIENT_SECRET,
|
|
1348
|
+
code,
|
|
1349
|
+
grant_type: 'authorization_code',
|
|
1350
|
+
redirect_uri: GEMINI_REDIRECT_URI,
|
|
1351
|
+
code_verifier: pendingOAuthState.verifier
|
|
1352
|
+
})
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
if (!tokenResponse.ok) {
|
|
1356
|
+
const errText = await tokenResponse.text();
|
|
1357
|
+
throw new Error(errText);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
const tokens = await tokenResponse.json();
|
|
1361
|
+
|
|
1362
|
+
let email = null;
|
|
1363
|
+
try {
|
|
1364
|
+
const userInfoRes = await fetch('https://www.googleapis.com/oauth2/v1/userinfo?alt=json', {
|
|
1365
|
+
headers: { 'Authorization': `Bearer ${tokens.access_token}` }
|
|
1366
|
+
});
|
|
1367
|
+
if (userInfoRes.ok) {
|
|
1368
|
+
const userInfo = await userInfoRes.json();
|
|
1369
|
+
email = userInfo.email;
|
|
1370
|
+
}
|
|
1371
|
+
} catch {}
|
|
1372
|
+
|
|
1373
|
+
const cp = getConfigPath();
|
|
1374
|
+
const ap = path.join(path.dirname(cp), 'auth.json');
|
|
1375
|
+
const authCfg = fs.existsSync(ap) ? JSON.parse(fs.readFileSync(ap, 'utf8')) : {};
|
|
1376
|
+
|
|
1377
|
+
const studio = loadStudioConfig();
|
|
1378
|
+
const activePlugin = studio.activeGooglePlugin || 'gemini';
|
|
1379
|
+
const namespace = activePlugin === 'antigravity' ? 'google.antigravity' : 'google.gemini';
|
|
1380
|
+
|
|
1381
|
+
const credentials = {
|
|
1382
|
+
refresh_token: tokens.refresh_token,
|
|
1383
|
+
access_token: tokens.access_token,
|
|
1384
|
+
expiry: Date.now() + (tokens.expires_in * 1000),
|
|
1385
|
+
email
|
|
1386
|
+
};
|
|
1387
|
+
|
|
1388
|
+
authCfg.google = credentials;
|
|
1389
|
+
authCfg[namespace] = credentials;
|
|
1390
|
+
|
|
1391
|
+
atomicWriteFileSync(ap, JSON.stringify(authCfg, null, 2));
|
|
1392
|
+
|
|
1393
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'success', email };
|
|
1394
|
+
|
|
1395
|
+
callbackRes.send(`
|
|
1396
|
+
<html>
|
|
1397
|
+
<head><style>body{font-family:system-ui;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#f0fdf4}
|
|
1398
|
+
.card{background:white;padding:2rem;border-radius:12px;box-shadow:0 4px 12px rgba(0,0,0,0.1);text-align:center}
|
|
1399
|
+
h2{color:#16a34a;margin:0 0 0.5rem}</style></head>
|
|
1400
|
+
<body><div class="card"><h2>✓ Login Successful!</h2><p>Logged in as ${email || 'Google User'}</p><p style="color:#666;font-size:0.875rem">You can close this window.</p></div>
|
|
1401
|
+
<script>setTimeout(()=>window.close(),2000)</script></body></html>
|
|
1402
|
+
`);
|
|
1403
|
+
} catch (err) {
|
|
1404
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: err.message };
|
|
1405
|
+
callbackRes.send('<html><body><h2>Login Failed</h2><p>' + err.message + '</p><script>window.close()</script></body></html>');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
shutdownCallbackServer();
|
|
1409
|
+
});
|
|
1410
|
+
|
|
1411
|
+
function shutdownCallbackServer() {
|
|
1412
|
+
if (oauthCallbackServer) {
|
|
1413
|
+
oauthCallbackServer.close();
|
|
1414
|
+
oauthCallbackServer = null;
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
try {
|
|
1419
|
+
oauthCallbackServer = callbackApp.listen(OAUTH_CALLBACK_PORT, () => {
|
|
1420
|
+
console.log(`OAuth callback server listening on port ${OAUTH_CALLBACK_PORT}`);
|
|
1421
|
+
});
|
|
1422
|
+
|
|
1423
|
+
oauthCallbackServer.on('error', (err) => {
|
|
1424
|
+
console.error('Failed to start OAuth callback server:', err);
|
|
1425
|
+
pendingOAuthState = { status: 'error', error: `Port ${OAUTH_CALLBACK_PORT} in use` };
|
|
1426
|
+
oauthCallbackServer = null;
|
|
1427
|
+
});
|
|
1428
|
+
|
|
1429
|
+
setTimeout(() => {
|
|
1430
|
+
if (oauthCallbackServer && pendingOAuthState?.status === 'pending') {
|
|
1431
|
+
pendingOAuthState = { ...pendingOAuthState, status: 'error', error: 'OAuth timeout (2 minutes)' };
|
|
1432
|
+
shutdownCallbackServer();
|
|
1433
|
+
}
|
|
1434
|
+
}, 120000);
|
|
1435
|
+
|
|
1436
|
+
const platform = process.platform;
|
|
1437
|
+
let openCmd;
|
|
1438
|
+
if (platform === 'win32') {
|
|
1439
|
+
openCmd = `start "" "${authUrl.toString()}"`;
|
|
1440
|
+
} else if (platform === 'darwin') {
|
|
1441
|
+
openCmd = `open "${authUrl.toString()}"`;
|
|
1442
|
+
} else {
|
|
1443
|
+
openCmd = `xdg-open "${authUrl.toString()}"`;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
exec(openCmd, (err) => {
|
|
1447
|
+
if (err) console.error('Failed to open browser:', err);
|
|
1448
|
+
});
|
|
1449
|
+
|
|
1450
|
+
res.json({ success: true, authUrl: authUrl.toString(), message: 'Browser opened for Google login' });
|
|
1451
|
+
|
|
1452
|
+
} catch (err) {
|
|
1453
|
+
pendingOAuthState = null;
|
|
1454
|
+
res.status(500).json({ error: err.message });
|
|
1455
|
+
}
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
app.get('/api/auth/google/status', (req, res) => {
|
|
1459
|
+
if (!pendingOAuthState) {
|
|
1460
|
+
return res.json({ status: 'idle' });
|
|
1461
|
+
}
|
|
1462
|
+
res.json(pendingOAuthState);
|
|
1463
|
+
});
|
|
1464
|
+
|
|
1465
|
+
app.post('/api/auth/google/cancel', (req, res) => {
|
|
1466
|
+
if (oauthCallbackServer) {
|
|
1467
|
+
oauthCallbackServer.close();
|
|
1468
|
+
oauthCallbackServer = null;
|
|
1469
|
+
}
|
|
1470
|
+
pendingOAuthState = null;
|
|
1471
|
+
res.json({ success: true });
|
|
1472
|
+
});
|
|
1473
|
+
|
|
862
1474
|
app.get('/api/pending-action', (req, res) => {
|
|
863
1475
|
if (pendingActionMemory) return res.json({ action: pendingActionMemory });
|
|
864
1476
|
if (fs.existsSync(PENDING_ACTION_PATH)) {
|