coding-tool-x 3.3.7 → 3.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.
Files changed (89) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +253 -326
  3. package/dist/web/assets/{Analytics-IW6eAy9u.js → Analytics-D6LzK9hk.js} +1 -1
  4. package/dist/web/assets/{ConfigTemplates-BPtkTMSc.js → ConfigTemplates-BUDYuxRi.js} +1 -1
  5. package/dist/web/assets/Home-BQxQ1LhR.css +1 -0
  6. package/dist/web/assets/Home-D7KX7iF8.js +1 -0
  7. package/dist/web/assets/{PluginManager-BGx9MSDV.js → PluginManager-DTgQ--vB.js} +1 -1
  8. package/dist/web/assets/{ProjectList-BCn-mrCx.js → ProjectList-DMCiGmCT.js} +1 -1
  9. package/dist/web/assets/{SessionList-CzLfebJQ.js → SessionList-CRBsdVRe.js} +1 -1
  10. package/dist/web/assets/{SkillManager-CXz2vBQx.js → SkillManager-DMwx2Q4k.js} +1 -1
  11. package/dist/web/assets/{WorkspaceManager-CHtgMfKc.js → WorkspaceManager-DapB4ljL.js} +1 -1
  12. package/dist/web/assets/{icons-B29onFfZ.js → icons-B5Pl4lrD.js} +1 -1
  13. package/dist/web/assets/index-CL-qpoJ_.js +2 -0
  14. package/dist/web/assets/index-D_5dRFOL.css +1 -0
  15. package/dist/web/assets/{markdown-C9MYpaSi.js → markdown-DyTJGI4N.js} +1 -1
  16. package/dist/web/assets/{naive-ui-CxpuzdjU.js → naive-ui-Bdxp09n2.js} +1 -1
  17. package/dist/web/assets/{vendors-DMjSfzlv.js → vendors-CKPV1OAU.js} +2 -2
  18. package/dist/web/assets/{vue-vendor-DET08QYg.js → vue-vendor-3bf-fPGP.js} +1 -1
  19. package/dist/web/index.html +7 -7
  20. package/docs/home.png +0 -0
  21. package/package.json +14 -5
  22. package/src/commands/daemon.js +3 -2
  23. package/src/commands/security.js +1 -2
  24. package/src/commands/toggle-proxy.js +100 -5
  25. package/src/config/paths.js +718 -90
  26. package/src/server/api/agents.js +1 -1
  27. package/src/server/api/channels.js +9 -0
  28. package/src/server/api/claude-hooks.js +13 -8
  29. package/src/server/api/codex-channels.js +9 -0
  30. package/src/server/api/codex-proxy.js +27 -15
  31. package/src/server/api/gemini-proxy.js +22 -11
  32. package/src/server/api/hooks.js +45 -0
  33. package/src/server/api/oauth-credentials.js +163 -0
  34. package/src/server/api/opencode-proxy.js +22 -10
  35. package/src/server/api/plugins.js +2 -1
  36. package/src/server/api/proxy.js +39 -44
  37. package/src/server/api/skills.js +91 -13
  38. package/src/server/api/ui-config.js +5 -0
  39. package/src/server/codex-proxy-server.js +90 -70
  40. package/src/server/gemini-proxy-server.js +107 -88
  41. package/src/server/index.js +2 -0
  42. package/src/server/opencode-proxy-server.js +381 -225
  43. package/src/server/proxy-server.js +86 -60
  44. package/src/server/services/alias.js +3 -3
  45. package/src/server/services/channels.js +21 -24
  46. package/src/server/services/codex-channels.js +158 -255
  47. package/src/server/services/codex-config.js +2 -5
  48. package/src/server/services/codex-env-manager.js +423 -0
  49. package/src/server/services/codex-settings-manager.js +21 -357
  50. package/src/server/services/codex-statistics-service.js +3 -27
  51. package/src/server/services/config-export-service.js +43 -9
  52. package/src/server/services/config-registry-service.js +3 -2
  53. package/src/server/services/config-sync-manager.js +1 -1
  54. package/src/server/services/favorites.js +4 -3
  55. package/src/server/services/gemini-channels.js +14 -12
  56. package/src/server/services/gemini-statistics-service.js +3 -25
  57. package/src/server/services/mcp-service.js +35 -19
  58. package/src/server/services/model-detector.js +4 -3
  59. package/src/server/services/native-keychain.js +243 -0
  60. package/src/server/services/native-oauth-adapters.js +891 -0
  61. package/src/server/services/network-access.js +39 -1
  62. package/src/server/services/notification-hooks.js +951 -0
  63. package/src/server/services/oauth-credentials-service.js +786 -0
  64. package/src/server/services/oauth-utils.js +49 -0
  65. package/src/server/services/opencode-channels.js +19 -15
  66. package/src/server/services/opencode-sessions.js +2 -2
  67. package/src/server/services/opencode-settings-manager.js +169 -16
  68. package/src/server/services/opencode-statistics-service.js +3 -27
  69. package/src/server/services/plugins-service.js +115 -15
  70. package/src/server/services/prompts-service.js +2 -3
  71. package/src/server/services/proxy-log-helper.js +242 -0
  72. package/src/server/services/proxy-runtime.js +6 -4
  73. package/src/server/services/repo-scanner-base.js +12 -4
  74. package/src/server/services/request-logger.js +7 -7
  75. package/src/server/services/security-config.js +4 -4
  76. package/src/server/services/session-cache.js +2 -2
  77. package/src/server/services/sessions.js +2 -2
  78. package/src/server/services/settings-manager.js +13 -0
  79. package/src/server/services/skill-service.js +867 -368
  80. package/src/server/services/statistics-service.js +5 -5
  81. package/src/server/services/ui-config.js +4 -3
  82. package/src/server/services/workspace-service.js +1 -1
  83. package/src/server/websocket-server.js +5 -4
  84. package/dist/web/assets/Home-BsSioaaB.css +0 -1
  85. package/dist/web/assets/Home-obifg_9E.js +0 -1
  86. package/dist/web/assets/index-C7LPdVsN.js +0 -2
  87. package/dist/web/assets/index-eEmjZKWP.css +0 -1
  88. package/docs/bannel.png +0 -0
  89. package/docs/model-redirection.md +0 -251
@@ -0,0 +1,891 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const crypto = require('crypto');
5
+ const toml = require('toml');
6
+ const tomlStringify = require('@iarna/toml').stringify;
7
+ const { NATIVE_PATHS, PATHS } = require('../../config/paths');
8
+ const claudeSettingsManager = require('./settings-manager');
9
+ const codexSettingsManager = require('./codex-settings-manager');
10
+ const geminiSettingsManager = require('./gemini-settings-manager');
11
+ const opencodeSettingsManager = require('./opencode-settings-manager');
12
+ const { syncCodexUserEnvironment } = require('./codex-env-manager');
13
+ const nativeKeychain = require('./native-keychain');
14
+ const { maskToken, decodeJwtPayload, removeFileIfExists, sha256 } = require('./oauth-utils');
15
+
16
+ const SUPPORTED_TOOLS = ['claude', 'codex', 'gemini', 'opencode'];
17
+ const GEMINI_MAIN_ACCOUNT_KEY = 'main-account';
18
+ const GEMINI_KEYCHAIN_SERVICE = 'gemini-cli-oauth';
19
+ const CODEX_KEYCHAIN_SERVICE = 'Codex Auth';
20
+
21
+ function ensureDir(dirPath) {
22
+ if (!dirPath) return;
23
+ fs.mkdirSync(dirPath, { recursive: true });
24
+ }
25
+
26
+ function ensureFileMode(filePath, mode = 0o600) {
27
+ if (process.platform === 'win32') {
28
+ return;
29
+ }
30
+ try {
31
+ fs.chmodSync(filePath, mode);
32
+ } catch {
33
+ // ignore chmod failures
34
+ }
35
+ }
36
+
37
+ function writeJsonFile(filePath, value) {
38
+ ensureDir(path.dirname(filePath));
39
+ fs.writeFileSync(filePath, JSON.stringify(value, null, 2), 'utf8');
40
+ ensureFileMode(filePath);
41
+ }
42
+
43
+ function readJsonFile(filePath, fallback = null) {
44
+ try {
45
+ if (!fs.existsSync(filePath)) {
46
+ return fallback;
47
+ }
48
+ return JSON.parse(fs.readFileSync(filePath, 'utf8'));
49
+ } catch {
50
+ return fallback;
51
+ }
52
+ }
53
+
54
+ function fingerprintFor(tool, value) {
55
+ return sha256(`${tool}:${value || ''}`);
56
+ }
57
+
58
+ function loadChannelsFile(tool) {
59
+ const filePath = PATHS.channels?.[tool];
60
+ const payload = readJsonFile(filePath, { channels: [] });
61
+ return Array.isArray(payload?.channels) ? payload.channels : [];
62
+ }
63
+
64
+ function buildNativeSummary(data = {}) {
65
+ return {
66
+ providerId: data.providerId || '',
67
+ accountId: data.accountId || '',
68
+ accountEmail: data.accountEmail || '',
69
+ expiresAt: data.expiresAt || null,
70
+ storage: data.storage || '',
71
+ tokenPreview: maskToken(data.primaryToken || data.accessToken || ''),
72
+ lastRefresh: data.lastRefresh || null
73
+ };
74
+ }
75
+
76
+ function parseClaudeOAuthPayload(raw) {
77
+ const parsed = raw?.claudeAiOauth && typeof raw.claudeAiOauth === 'object'
78
+ ? raw.claudeAiOauth
79
+ : raw;
80
+
81
+ if (!parsed || typeof parsed !== 'object') {
82
+ return null;
83
+ }
84
+
85
+ const accessToken = String(
86
+ parsed.accessToken
87
+ || parsed.access_token
88
+ || parsed.authToken
89
+ || parsed.token
90
+ || ''
91
+ ).trim();
92
+
93
+ if (!accessToken) {
94
+ return null;
95
+ }
96
+
97
+ return {
98
+ accessToken,
99
+ refreshToken: String(parsed.refreshToken || parsed.refresh_token || '').trim() || '',
100
+ expiresAt: Number(parsed.expiresAt || parsed.expiryDate || parsed.expiry_date || 0) || null,
101
+ primaryToken: accessToken
102
+ };
103
+ }
104
+
105
+ function getClaudeKeychainServiceName() {
106
+ const oauthSuffix = process.env.CLAUDE_CODE_CUSTOM_OAUTH_URL ? '-custom-oauth' : '';
107
+ const customConfigDir = process.env.CLAUDE_CONFIG_DIR ? NATIVE_PATHS.claude.dir : '';
108
+ const suffix = customConfigDir ? `-${sha256(customConfigDir).slice(0, 8)}` : '';
109
+ return `Claude Code${oauthSuffix}-credentials${suffix}`;
110
+ }
111
+
112
+ function getClaudeKeychainAccountName() {
113
+ return process.env.USER || os.userInfo().username;
114
+ }
115
+
116
+ function readClaudeNativeOAuth() {
117
+ const keychainRaw = nativeKeychain.isSupported()
118
+ ? nativeKeychain.getPassword(getClaudeKeychainServiceName(), getClaudeKeychainAccountName())
119
+ : null;
120
+ if (keychainRaw) {
121
+ const parsed = parseClaudeOAuthPayload(readJsonFileFromString(keychainRaw));
122
+ if (parsed) {
123
+ return { ...parsed, storage: 'keychain' };
124
+ }
125
+ }
126
+
127
+ const filePayload = readJsonFile(NATIVE_PATHS.claude.credentials, null);
128
+ const parsedFile = parseClaudeOAuthPayload(filePayload);
129
+ if (parsedFile) {
130
+ return { ...parsedFile, storage: 'file' };
131
+ }
132
+
133
+ return null;
134
+ }
135
+
136
+ function clearClaudeOAuth() {
137
+ if (nativeKeychain.isSupported()) {
138
+ nativeKeychain.deletePassword(getClaudeKeychainServiceName(), getClaudeKeychainAccountName());
139
+ }
140
+ removeFileIfExists(NATIVE_PATHS.claude.credentials);
141
+
142
+ let settings = {};
143
+ try {
144
+ settings = claudeSettingsManager.settingsExists()
145
+ ? claudeSettingsManager.readSettings()
146
+ : {};
147
+ } catch {
148
+ settings = {};
149
+ }
150
+
151
+ settings.env = settings.env || {};
152
+ delete settings.env.ANTHROPIC_AUTH_TOKEN;
153
+ delete settings.env.CLAUDE_CODE_OAUTH_TOKEN;
154
+ claudeSettingsManager.writeSettings(settings);
155
+ }
156
+
157
+ function applyClaudeOAuth(credential) {
158
+ clearClaudeOAuth();
159
+
160
+ let settings = {};
161
+ try {
162
+ settings = claudeSettingsManager.settingsExists()
163
+ ? claudeSettingsManager.readSettings()
164
+ : {};
165
+ } catch {
166
+ settings = {};
167
+ }
168
+
169
+ settings.env = settings.env || {};
170
+ delete settings.env.ANTHROPIC_API_KEY;
171
+ delete settings.env.ANTHROPIC_BASE_URL;
172
+ delete settings.env.ANTHROPIC_MODEL;
173
+ delete settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
174
+ delete settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
175
+ delete settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
176
+ // 不删除 HTTP_PROXY / HTTPS_PROXY / NO_PROXY,这些是与 OAuth 无关的通用网络配置
177
+ delete settings.apiKeyHelper;
178
+
179
+ const wrappedPayload = {
180
+ claudeAiOauth: {
181
+ accessToken: credential.accessToken,
182
+ refreshToken: credential.refreshToken || undefined,
183
+ expiresAt: credential.expiresAt || undefined
184
+ }
185
+ };
186
+
187
+ const rawPayload = JSON.stringify(wrappedPayload);
188
+ const wroteKeychain = nativeKeychain.isSupported()
189
+ ? nativeKeychain.setPassword(getClaudeKeychainServiceName(), getClaudeKeychainAccountName(), rawPayload)
190
+ : false;
191
+
192
+ if (!wroteKeychain) {
193
+ writeJsonFile(NATIVE_PATHS.claude.credentials, wrappedPayload);
194
+ }
195
+
196
+ claudeSettingsManager.writeSettings(settings);
197
+ return { storage: wroteKeychain ? 'keychain' : 'file' };
198
+ }
199
+
200
+ function inspectClaudeState() {
201
+ const { getProxyStatus } = require('../proxy-server');
202
+ const proxyStatus = getProxyStatus();
203
+ const nativeOAuth = readClaudeNativeOAuth();
204
+
205
+ let channelConfigured = false;
206
+ try {
207
+ const settings = claudeSettingsManager.settingsExists()
208
+ ? claudeSettingsManager.readSettings()
209
+ : {};
210
+ const env = settings?.env || {};
211
+ channelConfigured = Boolean(
212
+ String(env.ANTHROPIC_API_KEY || '').trim()
213
+ || String(env.ANTHROPIC_BASE_URL || '').trim()
214
+ || String(settings.apiKeyHelper || '').trim()
215
+ );
216
+ } catch {
217
+ channelConfigured = false;
218
+ }
219
+
220
+ return {
221
+ tool: 'claude',
222
+ mode: proxyStatus.running ? 'proxy' : (nativeOAuth ? 'oauth' : (channelConfigured ? 'channel' : 'idle')),
223
+ proxyRunning: proxyStatus.running,
224
+ oauthPresent: Boolean(nativeOAuth),
225
+ channelConfigured,
226
+ nativeCredential: nativeOAuth ? buildNativeSummary(nativeOAuth) : null
227
+ };
228
+ }
229
+
230
+ function readJsonFileFromString(raw) {
231
+ try {
232
+ return JSON.parse(raw);
233
+ } catch {
234
+ return null;
235
+ }
236
+ }
237
+
238
+ function getCodexKeychainAccount() {
239
+ const codexHome = NATIVE_PATHS.codex.dir;
240
+ const resolvedPath = fs.existsSync(codexHome)
241
+ ? fs.realpathSync.native(codexHome)
242
+ : path.resolve(codexHome);
243
+ return `cli|${sha256(resolvedPath).slice(0, 16)}`;
244
+ }
245
+
246
+ function parseCodexAuthPayload(raw) {
247
+ if (!raw || typeof raw !== 'object') {
248
+ return null;
249
+ }
250
+
251
+ const auth = raw.tokens ? raw : {
252
+ auth_mode: 'chatgpt',
253
+ tokens: raw
254
+ };
255
+
256
+ const tokens = auth.tokens && typeof auth.tokens === 'object' ? auth.tokens : null;
257
+ if (!tokens || !tokens.access_token) {
258
+ return null;
259
+ }
260
+
261
+ const idTokenPayload = decodeJwtPayload(tokens.id_token);
262
+ const accessTokenPayload = decodeJwtPayload(tokens.access_token);
263
+ const exp = Number(idTokenPayload?.exp || accessTokenPayload?.exp || 0) || null;
264
+ return {
265
+ authMode: String(auth.auth_mode || 'chatgpt').trim() || 'chatgpt',
266
+ accessToken: String(tokens.access_token || '').trim(),
267
+ refreshToken: String(tokens.refresh_token || '').trim() || '',
268
+ idToken: String(tokens.id_token || '').trim() || '',
269
+ accountId: String(tokens.account_id || '').trim() || '',
270
+ accountEmail: String(idTokenPayload?.email || '').trim() || '',
271
+ expiresAt: exp ? exp * 1000 : null,
272
+ lastRefresh: auth.last_refresh || null,
273
+ primaryToken: String(tokens.access_token || '').trim()
274
+ };
275
+ }
276
+
277
+ function readCodexKeychainAuth() {
278
+ const raw = nativeKeychain.isSupported()
279
+ ? nativeKeychain.getPassword(CODEX_KEYCHAIN_SERVICE, getCodexKeychainAccount())
280
+ : null;
281
+ if (!raw) {
282
+ return null;
283
+ }
284
+ const parsed = parseCodexAuthPayload(readJsonFileFromString(raw));
285
+ return parsed ? { ...parsed, storage: 'keychain' } : null;
286
+ }
287
+
288
+ function readCodexFileAuth() {
289
+ const payload = readJsonFile(NATIVE_PATHS.codex.auth, null);
290
+ const parsed = parseCodexAuthPayload(payload);
291
+ return parsed ? { ...parsed, storage: 'auth-file' } : null;
292
+ }
293
+
294
+ function readCodexNativeOAuth() {
295
+ return readCodexKeychainAuth() || readCodexFileAuth();
296
+ }
297
+
298
+ function clearCodexOAuth() {
299
+ if (nativeKeychain.isSupported()) {
300
+ nativeKeychain.deletePassword(CODEX_KEYCHAIN_SERVICE, getCodexKeychainAccount());
301
+ }
302
+
303
+ let auth = {};
304
+ try {
305
+ auth = codexSettingsManager.readAuth();
306
+ } catch {
307
+ auth = {};
308
+ }
309
+ delete auth.tokens;
310
+ delete auth.auth_mode;
311
+ delete auth.last_refresh;
312
+ codexSettingsManager.writeAuth(auth);
313
+ }
314
+
315
+ function removeCodexChannelEnvVars() {
316
+ syncCodexUserEnvironment({}, { replace: true });
317
+ }
318
+
319
+ function clearCodexChannelConfig() {
320
+ removeCodexChannelEnvVars();
321
+
322
+ const configPath = NATIVE_PATHS.codex.config;
323
+ let config = {};
324
+ if (fs.existsSync(configPath)) {
325
+ try {
326
+ config = toml.parse(fs.readFileSync(configPath, 'utf8'));
327
+ } catch {
328
+ config = {};
329
+ }
330
+ }
331
+
332
+ const managedProviderKeys = new Set(['cc-proxy']);
333
+ loadChannelsFile('codex').forEach((channel) => {
334
+ if (channel?.providerKey) {
335
+ managedProviderKeys.add(channel.providerKey);
336
+ }
337
+ });
338
+
339
+ if (config.model_provider && managedProviderKeys.has(config.model_provider)) {
340
+ delete config.model_provider;
341
+ }
342
+
343
+ if (config.model_providers && typeof config.model_providers === 'object') {
344
+ Object.keys(config.model_providers).forEach((providerKey) => {
345
+ if (managedProviderKeys.has(providerKey)) {
346
+ delete config.model_providers[providerKey];
347
+ }
348
+ });
349
+ if (Object.keys(config.model_providers).length === 0) {
350
+ delete config.model_providers;
351
+ }
352
+ }
353
+
354
+ ensureDir(path.dirname(configPath));
355
+ fs.writeFileSync(configPath, tomlStringify(config), 'utf8');
356
+ }
357
+
358
+ function applyCodexOAuth(credential) {
359
+ clearCodexOAuth();
360
+ clearCodexChannelConfig();
361
+
362
+ const authPayload = {
363
+ auth_mode: credential.authMode || 'chatgpt',
364
+ tokens: {
365
+ access_token: credential.accessToken,
366
+ refresh_token: credential.refreshToken || '',
367
+ id_token: credential.idToken || '',
368
+ account_id: credential.accountId || ''
369
+ },
370
+ last_refresh: credential.lastRefresh || new Date().toISOString()
371
+ };
372
+
373
+ writeJsonFile(NATIVE_PATHS.codex.auth, authPayload);
374
+ const wroteKeychain = nativeKeychain.isSupported()
375
+ ? nativeKeychain.setPassword(CODEX_KEYCHAIN_SERVICE, getCodexKeychainAccount(), JSON.stringify(authPayload))
376
+ : false;
377
+ return { storage: wroteKeychain ? 'auth-file+keychain' : 'auth-file' };
378
+ }
379
+
380
+ function inspectCodexState() {
381
+ const { getCodexProxyStatus } = require('../codex-proxy-server');
382
+ const proxyStatus = getCodexProxyStatus();
383
+ const nativeOAuth = readCodexNativeOAuth();
384
+
385
+ let channelConfigured = false;
386
+ try {
387
+ const config = codexSettingsManager.readConfig();
388
+ channelConfigured = Boolean(
389
+ (config?.model_provider && config.model_provider !== 'cc-proxy')
390
+ || (config?.model_providers && Object.keys(config.model_providers).length > 0)
391
+ );
392
+ } catch {
393
+ channelConfigured = false;
394
+ }
395
+
396
+ return {
397
+ tool: 'codex',
398
+ mode: proxyStatus.running ? 'proxy' : (nativeOAuth ? 'oauth' : (channelConfigured ? 'channel' : 'idle')),
399
+ proxyRunning: proxyStatus.running,
400
+ oauthPresent: Boolean(nativeOAuth),
401
+ channelConfigured,
402
+ nativeCredential: nativeOAuth ? buildNativeSummary(nativeOAuth) : null
403
+ };
404
+ }
405
+
406
+ function deriveGeminiEncryptionKey() {
407
+ const salt = `${os.hostname()}-${os.userInfo().username}-gemini-cli`;
408
+ return crypto.scryptSync(GEMINI_KEYCHAIN_SERVICE, salt, 32);
409
+ }
410
+
411
+ function encryptGeminiPayload(value) {
412
+ const key = deriveGeminiEncryptionKey();
413
+ const iv = crypto.randomBytes(16);
414
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
415
+ let encrypted = cipher.update(value, 'utf8', 'hex');
416
+ encrypted += cipher.final('hex');
417
+ const authTag = cipher.getAuthTag();
418
+ return `${iv.toString('hex')}:${authTag.toString('hex')}:${encrypted}`;
419
+ }
420
+
421
+ function decryptGeminiPayload(value) {
422
+ const parts = String(value || '').split(':');
423
+ if (parts.length !== 3) {
424
+ throw new Error('Invalid encrypted data format');
425
+ }
426
+
427
+ const key = deriveGeminiEncryptionKey();
428
+ const iv = Buffer.from(parts[0], 'hex');
429
+ const authTag = Buffer.from(parts[1], 'hex');
430
+ const encrypted = parts[2];
431
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
432
+ decipher.setAuthTag(authTag);
433
+ let decrypted = decipher.update(encrypted, 'hex', 'utf8');
434
+ decrypted += decipher.final('utf8');
435
+ return decrypted;
436
+ }
437
+
438
+ function parseGeminiCredential(raw, googleAccounts = null) {
439
+ if (!raw || typeof raw !== 'object') {
440
+ return null;
441
+ }
442
+
443
+ const credential = raw.token && typeof raw.token === 'object'
444
+ ? raw
445
+ : raw.access_token
446
+ ? {
447
+ serverName: GEMINI_MAIN_ACCOUNT_KEY,
448
+ token: {
449
+ accessToken: raw.access_token,
450
+ refreshToken: raw.refresh_token || '',
451
+ tokenType: raw.token_type || 'Bearer',
452
+ scope: raw.scope || '',
453
+ expiresAt: raw.expiry_date || null
454
+ },
455
+ updatedAt: Date.now()
456
+ }
457
+ : null;
458
+
459
+ if (!credential?.token?.accessToken) {
460
+ return null;
461
+ }
462
+
463
+ return {
464
+ accessToken: String(credential.token.accessToken || '').trim(),
465
+ refreshToken: String(credential.token.refreshToken || '').trim() || '',
466
+ tokenType: String(credential.token.tokenType || 'Bearer').trim() || 'Bearer',
467
+ scope: String(credential.token.scope || '').trim() || '',
468
+ expiresAt: Number(credential.token.expiresAt || 0) || null,
469
+ accountEmail: String(googleAccounts?.active || '').trim() || '',
470
+ primaryToken: String(credential.token.accessToken || '').trim()
471
+ };
472
+ }
473
+
474
+ function readGeminiEncryptedCredential() {
475
+ try {
476
+ if (!fs.existsSync(NATIVE_PATHS.gemini.oauthCredentialsEncrypted)) {
477
+ return null;
478
+ }
479
+ const encrypted = fs.readFileSync(NATIVE_PATHS.gemini.oauthCredentialsEncrypted, 'utf8');
480
+ const decrypted = decryptGeminiPayload(encrypted);
481
+ const payload = JSON.parse(decrypted);
482
+ const googleAccounts = readJsonFile(NATIVE_PATHS.gemini.googleAccounts, null);
483
+ return parseGeminiCredential(payload[GEMINI_MAIN_ACCOUNT_KEY], googleAccounts)
484
+ || parseGeminiCredential(payload, googleAccounts);
485
+ } catch {
486
+ return null;
487
+ }
488
+ }
489
+
490
+ function readGeminiKeychainCredential() {
491
+ const raw = nativeKeychain.isSupported()
492
+ ? nativeKeychain.getPassword(GEMINI_KEYCHAIN_SERVICE, GEMINI_MAIN_ACCOUNT_KEY)
493
+ : null;
494
+ if (!raw) {
495
+ return null;
496
+ }
497
+
498
+ const googleAccounts = readJsonFile(NATIVE_PATHS.gemini.googleAccounts, null);
499
+ const parsed = parseGeminiCredential(readJsonFileFromString(raw), googleAccounts);
500
+ return parsed ? { ...parsed, storage: 'keychain' } : null;
501
+ }
502
+
503
+ function readGeminiLegacyCredential() {
504
+ const payload = readJsonFile(NATIVE_PATHS.gemini.oauthCredentialsLegacy, null);
505
+ const googleAccounts = readJsonFile(NATIVE_PATHS.gemini.googleAccounts, null);
506
+ const parsed = parseGeminiCredential(payload, googleAccounts);
507
+ return parsed ? { ...parsed, storage: 'legacy-file' } : null;
508
+ }
509
+
510
+ function readGeminiNativeOAuth() {
511
+ const keychain = readGeminiKeychainCredential();
512
+ if (keychain) {
513
+ return keychain;
514
+ }
515
+
516
+ const encrypted = readGeminiEncryptedCredential();
517
+ if (encrypted) {
518
+ return { ...encrypted, storage: 'encrypted-file' };
519
+ }
520
+ return readGeminiLegacyCredential();
521
+ }
522
+
523
+ function clearGeminiOAuth() {
524
+ if (nativeKeychain.isSupported()) {
525
+ nativeKeychain.deletePassword(GEMINI_KEYCHAIN_SERVICE, GEMINI_MAIN_ACCOUNT_KEY);
526
+ }
527
+ removeFileIfExists(NATIVE_PATHS.gemini.oauthCredentialsEncrypted);
528
+ removeFileIfExists(NATIVE_PATHS.gemini.oauthCredentialsLegacy);
529
+
530
+ const googleAccounts = readJsonFile(NATIVE_PATHS.gemini.googleAccounts, null);
531
+ if (googleAccounts && typeof googleAccounts === 'object') {
532
+ googleAccounts.active = null;
533
+ writeJsonFile(NATIVE_PATHS.gemini.googleAccounts, googleAccounts);
534
+ }
535
+ }
536
+
537
+ function clearGeminiChannelConfig() {
538
+ const env = geminiSettingsManager.configExists()
539
+ ? geminiSettingsManager.readEnv()
540
+ : {};
541
+ delete env.GOOGLE_GEMINI_BASE_URL;
542
+ delete env.GEMINI_API_KEY;
543
+ delete env.GEMINI_MODEL;
544
+ geminiSettingsManager.writeEnv(env);
545
+ }
546
+
547
+ function applyGeminiOAuth(credential) {
548
+ clearGeminiOAuth();
549
+ clearGeminiChannelConfig();
550
+
551
+ const payload = {
552
+ [GEMINI_MAIN_ACCOUNT_KEY]: {
553
+ serverName: GEMINI_MAIN_ACCOUNT_KEY,
554
+ token: {
555
+ accessToken: credential.accessToken,
556
+ refreshToken: credential.refreshToken || undefined,
557
+ tokenType: credential.tokenType || 'Bearer',
558
+ scope: credential.scope || undefined,
559
+ expiresAt: credential.expiresAt || undefined
560
+ },
561
+ updatedAt: Date.now()
562
+ }
563
+ };
564
+
565
+ ensureDir(path.dirname(NATIVE_PATHS.gemini.oauthCredentialsEncrypted));
566
+ fs.writeFileSync(
567
+ NATIVE_PATHS.gemini.oauthCredentialsEncrypted,
568
+ encryptGeminiPayload(JSON.stringify(payload, null, 2)),
569
+ 'utf8'
570
+ );
571
+ ensureFileMode(NATIVE_PATHS.gemini.oauthCredentialsEncrypted);
572
+ const wroteKeychain = nativeKeychain.isSupported()
573
+ ? nativeKeychain.setPassword(
574
+ GEMINI_KEYCHAIN_SERVICE,
575
+ GEMINI_MAIN_ACCOUNT_KEY,
576
+ JSON.stringify(payload[GEMINI_MAIN_ACCOUNT_KEY])
577
+ )
578
+ : false;
579
+
580
+ const settings = geminiSettingsManager.settingsExists()
581
+ ? geminiSettingsManager.readSettings()
582
+ : {};
583
+ settings.security = settings.security || {};
584
+ settings.security.auth = settings.security.auth || {};
585
+ settings.security.auth.selectedType = 'oauth-personal';
586
+ geminiSettingsManager.writeSettings(settings);
587
+
588
+ if (credential.accountEmail) {
589
+ writeJsonFile(NATIVE_PATHS.gemini.googleAccounts, {
590
+ active: credential.accountEmail,
591
+ old: []
592
+ });
593
+ }
594
+
595
+ return { storage: wroteKeychain ? 'encrypted-file+keychain' : 'encrypted-file' };
596
+ }
597
+
598
+ function inspectGeminiState() {
599
+ const { getGeminiProxyStatus } = require('../gemini-proxy-server');
600
+ const proxyStatus = getGeminiProxyStatus();
601
+ const nativeOAuth = readGeminiNativeOAuth();
602
+
603
+ let channelConfigured = false;
604
+ try {
605
+ const env = geminiSettingsManager.configExists()
606
+ ? geminiSettingsManager.readEnv()
607
+ : {};
608
+ const settings = geminiSettingsManager.settingsExists()
609
+ ? geminiSettingsManager.readSettings()
610
+ : {};
611
+ channelConfigured = Boolean(
612
+ String(env.GOOGLE_GEMINI_BASE_URL || '').trim()
613
+ || String(env.GEMINI_API_KEY || '').trim()
614
+ || settings?.security?.auth?.selectedType === 'gemini-api-key'
615
+ );
616
+ } catch {
617
+ channelConfigured = false;
618
+ }
619
+
620
+ return {
621
+ tool: 'gemini',
622
+ mode: proxyStatus.running ? 'proxy' : (nativeOAuth ? 'oauth' : (channelConfigured ? 'channel' : 'idle')),
623
+ proxyRunning: proxyStatus.running,
624
+ oauthPresent: Boolean(nativeOAuth),
625
+ channelConfigured,
626
+ nativeCredential: nativeOAuth ? buildNativeSummary(nativeOAuth) : null
627
+ };
628
+ }
629
+
630
+ function parseOpenCodeOAuthPayload(raw) {
631
+ if (!raw || typeof raw !== 'object') {
632
+ return null;
633
+ }
634
+
635
+ if (raw.type === 'oauth' || raw.access) {
636
+ return parseOpenCodeOAuthEntry(raw.providerId, raw);
637
+ }
638
+
639
+ if (raw.openai && typeof raw.openai === 'object') {
640
+ return parseOpenCodeOAuthEntry('openai', raw.openai);
641
+ }
642
+
643
+ for (const [providerId, value] of Object.entries(raw)) {
644
+ const parsed = parseOpenCodeOAuthEntry(providerId, value);
645
+ if (parsed) {
646
+ return parsed;
647
+ }
648
+ }
649
+
650
+ return null;
651
+ }
652
+
653
+ function parseOpenCodeOAuthEntry(providerId, target) {
654
+ const normalizedProviderId = String(providerId || 'openai').trim() || 'openai';
655
+
656
+ if (!target || target.type !== 'oauth' || !target.access) {
657
+ return null;
658
+ }
659
+
660
+ return {
661
+ providerId: normalizedProviderId,
662
+ accessToken: String(target.access || '').trim(),
663
+ refreshToken: String(target.refresh || '').trim() || '',
664
+ expiresAt: Number(target.expires || 0) || null,
665
+ accountId: String(target.accountId || '').trim() || '',
666
+ enterpriseUrl: String(target.enterpriseUrl || '').trim() || '',
667
+ primaryToken: String(target.access || '').trim()
668
+ };
669
+ }
670
+
671
+ function getActiveOpenCodeProviderId() {
672
+ try {
673
+ const configPath = opencodeSettingsManager.selectConfigPath();
674
+ if (!configPath || !fs.existsSync(configPath)) {
675
+ return '';
676
+ }
677
+
678
+ const config = opencodeSettingsManager.readConfig(configPath);
679
+ const modelRef = String(config?.model || '').trim();
680
+ if (modelRef.includes('/')) {
681
+ return modelRef.split('/')[0].trim();
682
+ }
683
+
684
+ const providerIds = config?.provider && typeof config.provider === 'object'
685
+ ? Object.keys(config.provider).filter(Boolean)
686
+ : [];
687
+ return providerIds.length === 1 ? providerIds[0] : '';
688
+ } catch {
689
+ return '';
690
+ }
691
+ }
692
+
693
+ function readAllOpenCodeNativeOAuth() {
694
+ const payload = readJsonFile(NATIVE_PATHS.opencode.auth, null);
695
+ if (!payload || typeof payload !== 'object') {
696
+ return [];
697
+ }
698
+
699
+ const activeProviderId = getActiveOpenCodeProviderId();
700
+ const credentials = Object.entries(payload)
701
+ .map(([providerId, value]) => parseOpenCodeOAuthEntry(providerId, value))
702
+ .filter(Boolean)
703
+ .map((entry) => ({ ...entry, storage: 'auth-file' }));
704
+
705
+ if (!activeProviderId || credentials.length <= 1) {
706
+ return credentials;
707
+ }
708
+
709
+ return credentials.sort((left, right) => {
710
+ if (left.providerId === activeProviderId) return -1;
711
+ if (right.providerId === activeProviderId) return 1;
712
+ return left.providerId.localeCompare(right.providerId);
713
+ });
714
+ }
715
+
716
+ function readOpenCodeNativeOAuth() {
717
+ return readAllOpenCodeNativeOAuth()[0] || null;
718
+ }
719
+
720
+ function clearOpenCodeOAuth() {
721
+ const payload = readJsonFile(NATIVE_PATHS.opencode.auth, {});
722
+ if (!payload || typeof payload !== 'object') {
723
+ return;
724
+ }
725
+
726
+ Object.keys(payload).forEach((providerId) => {
727
+ if (payload[providerId]?.type === 'oauth') {
728
+ delete payload[providerId];
729
+ }
730
+ });
731
+
732
+ if (Object.keys(payload).length === 0) {
733
+ removeFileIfExists(NATIVE_PATHS.opencode.auth);
734
+ return;
735
+ }
736
+
737
+ writeJsonFile(NATIVE_PATHS.opencode.auth, payload);
738
+ }
739
+
740
+ function applyOpenCodeOAuth(credential) {
741
+ clearOpenCodeOAuth();
742
+ opencodeSettingsManager.clearManagedChannelConfig();
743
+
744
+ const providerId = String(credential.providerId || 'openai').trim() || 'openai';
745
+ const payload = readJsonFile(NATIVE_PATHS.opencode.auth, {});
746
+ payload[providerId] = {
747
+ type: 'oauth',
748
+ access: credential.accessToken,
749
+ refresh: credential.refreshToken || '',
750
+ expires: credential.expiresAt || null,
751
+ accountId: credential.accountId || undefined,
752
+ enterpriseUrl: credential.enterpriseUrl || undefined
753
+ };
754
+ writeJsonFile(NATIVE_PATHS.opencode.auth, payload);
755
+
756
+ const configPath = opencodeSettingsManager.selectConfigPath();
757
+ const config = fs.existsSync(configPath)
758
+ ? opencodeSettingsManager.readConfig(configPath)
759
+ : {};
760
+ config.provider = config.provider && typeof config.provider === 'object' ? config.provider : {};
761
+ if (!config.provider[providerId]) {
762
+ config.provider[providerId] = {};
763
+ }
764
+ opencodeSettingsManager.writeConfig(configPath, config);
765
+
766
+ return { storage: 'auth-file' };
767
+ }
768
+
769
+ function inspectOpenCodeState() {
770
+ const { getOpenCodeProxyStatus } = require('../opencode-proxy-server');
771
+ const proxyStatus = getOpenCodeProxyStatus();
772
+ const nativeOAuth = readOpenCodeNativeOAuth();
773
+
774
+ let channelConfigured = false;
775
+ try {
776
+ const configPath = opencodeSettingsManager.selectConfigPath();
777
+ const config = fs.existsSync(configPath)
778
+ ? opencodeSettingsManager.readConfig(configPath)
779
+ : {};
780
+ const providers = config?.provider && typeof config.provider === 'object'
781
+ ? Object.values(config.provider)
782
+ : [];
783
+ channelConfigured = providers.some(provider => provider?.__ctx_managed__ === true);
784
+ } catch {
785
+ channelConfigured = false;
786
+ }
787
+
788
+ return {
789
+ tool: 'opencode',
790
+ mode: proxyStatus.running ? 'proxy' : (nativeOAuth ? 'oauth' : (channelConfigured ? 'channel' : 'idle')),
791
+ proxyRunning: proxyStatus.running,
792
+ oauthPresent: Boolean(nativeOAuth),
793
+ channelConfigured,
794
+ nativeCredential: nativeOAuth ? buildNativeSummary(nativeOAuth) : null
795
+ };
796
+ }
797
+
798
+ function inspectTool(tool) {
799
+ switch (tool) {
800
+ case 'claude':
801
+ return inspectClaudeState();
802
+ case 'codex':
803
+ return inspectCodexState();
804
+ case 'gemini':
805
+ return inspectGeminiState();
806
+ case 'opencode':
807
+ return inspectOpenCodeState();
808
+ default:
809
+ throw new Error(`Unsupported OAuth tool: ${tool}`);
810
+ }
811
+ }
812
+
813
+ function readNativeOAuth(tool) {
814
+ switch (tool) {
815
+ case 'claude':
816
+ return readClaudeNativeOAuth();
817
+ case 'codex':
818
+ return readCodexNativeOAuth();
819
+ case 'gemini':
820
+ return readGeminiNativeOAuth();
821
+ case 'opencode':
822
+ return readOpenCodeNativeOAuth();
823
+ default:
824
+ throw new Error(`Unsupported OAuth tool: ${tool}`);
825
+ }
826
+ }
827
+
828
+ function readAllNativeOAuth(tool) {
829
+ switch (tool) {
830
+ case 'claude': {
831
+ const credential = readClaudeNativeOAuth();
832
+ return credential ? [credential] : [];
833
+ }
834
+ case 'codex': {
835
+ const credential = readCodexNativeOAuth();
836
+ return credential ? [credential] : [];
837
+ }
838
+ case 'gemini': {
839
+ const credential = readGeminiNativeOAuth();
840
+ return credential ? [credential] : [];
841
+ }
842
+ case 'opencode':
843
+ return readAllOpenCodeNativeOAuth();
844
+ default:
845
+ throw new Error(`Unsupported OAuth tool: ${tool}`);
846
+ }
847
+ }
848
+
849
+ function clearNativeOAuth(tool) {
850
+ switch (tool) {
851
+ case 'claude':
852
+ clearClaudeOAuth();
853
+ return;
854
+ case 'codex':
855
+ clearCodexOAuth();
856
+ return;
857
+ case 'gemini':
858
+ clearGeminiOAuth();
859
+ return;
860
+ case 'opencode':
861
+ clearOpenCodeOAuth();
862
+ return;
863
+ default:
864
+ throw new Error(`Unsupported OAuth tool: ${tool}`);
865
+ }
866
+ }
867
+
868
+ function applyOAuthCredential(tool, credential) {
869
+ switch (tool) {
870
+ case 'claude':
871
+ return applyClaudeOAuth(credential);
872
+ case 'codex':
873
+ return applyCodexOAuth(credential);
874
+ case 'gemini':
875
+ return applyGeminiOAuth(credential);
876
+ case 'opencode':
877
+ return applyOpenCodeOAuth(credential);
878
+ default:
879
+ throw new Error(`Unsupported OAuth tool: ${tool}`);
880
+ }
881
+ }
882
+
883
+ module.exports = {
884
+ SUPPORTED_TOOLS,
885
+ fingerprintFor,
886
+ inspectTool,
887
+ readNativeOAuth,
888
+ readAllNativeOAuth,
889
+ clearNativeOAuth,
890
+ applyOAuthCredential
891
+ };