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 ADDED
@@ -0,0 +1,2 @@
1
+ GEMINI_CLIENT_ID=681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com
2
+ GEMINI_CLIENT_SECRET=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
package/.env.example ADDED
@@ -0,0 +1,2 @@
1
+ GEMINI_CLIENT_ID=your_client_id_here
2
+ GEMINI_CLIENT_SECRET=your_client_secret_here
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
- // Queue MCP server installation
44
- if (params.cmd || params.name) {
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 || 'MCP Server',
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
- const dir = path.dirname(STUDIO_CONFIG_PATH);
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
- const dir = path.dirname(configPath);
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
- fs.writeFileSync(profilePath, JSON.stringify(auth[provider], null, 2), 'utf8');
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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
- let terminalCmd;
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
- terminalCmd = `x-terminal-emulator -e "${cmd}"`;
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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.readdirSync(sd).forEach(d => {
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
- if (fs.statSync(fp).isDirectory()) {
723
- fs.readdirSync(fp).forEach(f => {
724
- if (f.startsWith('ses_') && f.endsWith('.json')) {
725
- try {
726
- const m = JSON.parse(fs.readFileSync(path.join(fp, f), 'utf8'));
727
- pmap.set(f.replace('.json', ''), { name: m.directory ? path.basename(m.directory) : (m.projectID ? m.projectID.substring(0, 8) : 'Unknown'), id: m.projectID || d });
728
- } catch {}
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.readdirSync(md).forEach(s => {
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
- if (fs.statSync(sp).isDirectory()) {
748
- fs.readdirSync(sp).forEach(f => {
749
- if (!f.endsWith('.json') || seen.has(path.join(sp, f))) return;
750
- seen.add(path.join(sp, f));
751
- try {
752
- const msg = JSON.parse(fs.readFileSync(path.join(sp, f), 'utf8'));
753
- const pid = pmap.get(s)?.id || 'unknown';
754
- if (fid && fid !== 'all' && pid !== fid) return;
755
- if (min > 0 && msg.time.created < min) return;
756
- if (msg.role === 'assistant' && msg.tokens) {
757
- const c = msg.cost || 0, it = msg.tokens.input || 0, ot = msg.tokens.output || 0, t = it + ot;
758
- const d = new Date(msg.time.created);
759
- let tk;
760
- if (granularity === 'hourly') tk = d.toISOString().substring(0, 13) + ':00:00Z';
761
- else if (granularity === 'weekly') {
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
- const kIn = `${mid}_input`, kOut = `${mid}_output`;
783
- te[kIn] = (te[kIn] || 0) + it;
784
- te[kOut] = (te[kOut] || 0) + ot;
785
- }
786
- } catch {}
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
- res.status(500).json({ error: 'Failed' });
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?.google) {
813
- const models = studio.pluginModels[plugin];
814
- if (models) {
815
- opencode.provider.google.models = models;
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
- fs.writeFileSync(ap, JSON.stringify(authCfg, null, 2), 'utf8');
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)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencode-studio-server",
3
- "version": "1.2.1",
3
+ "version": "1.2.2",
4
4
  "description": "Backend server for OpenCode Studio - manages opencode configurations",
5
5
  "main": "index.js",
6
6
  "bin": {