@goodandready/dsh-subscriptions 0.6.72 → 0.6.83

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 (49) hide show
  1. package/README.md +8 -0
  2. package/README.ru.md +8 -0
  3. package/README.zh.md +8 -0
  4. package/lib/accounts-view.js +97 -5
  5. package/lib/accounts.js +137 -11
  6. package/lib/adapter.js +3 -0
  7. package/lib/alerts.js +35 -0
  8. package/lib/analytics.js +31 -2
  9. package/lib/billing.js +99 -0
  10. package/lib/blob.js +22 -0
  11. package/lib/capabilities.js +208 -0
  12. package/lib/client.js +344 -17
  13. package/lib/config-schema.js +50 -4
  14. package/lib/diagnostic-wizard.js +2 -5
  15. package/lib/diagnostics.js +2 -2
  16. package/lib/error-buffer.js +2 -2
  17. package/lib/forecast.js +56 -0
  18. package/lib/headless-auth.js +392 -0
  19. package/lib/history.js +36 -0
  20. package/lib/http.js +67 -4
  21. package/lib/import-auth.js +3 -6
  22. package/lib/index.js +99 -11
  23. package/lib/jwt.js +84 -17
  24. package/lib/loopback.js +3 -2
  25. package/lib/mask.js +16 -0
  26. package/lib/oauth-pending.js +12 -0
  27. package/lib/pricing.js +89 -0
  28. package/lib/refs.js +2 -0
  29. package/lib/reset-clock.js +55 -0
  30. package/lib/rotate.js +18 -4
  31. package/lib/routes/accounts.js +142 -7
  32. package/lib/routes/oauth.js +51 -2
  33. package/lib/routes/proxy.js +14 -0
  34. package/lib/scope-inspector.js +51 -0
  35. package/lib/stream-rotate.js +31 -10
  36. package/lib/subscriptions.js +24 -1
  37. package/lib/updater.js +21 -0
  38. package/lib/vault-sync.js +414 -0
  39. package/lib/vault.js +1 -1
  40. package/lib/vendor-factory.js +6 -2
  41. package/lib/vendors/antigravity.js +13 -4
  42. package/lib/vendors/claude.js +56 -8
  43. package/lib/vendors/codex.js +34 -6
  44. package/lib/vendors/copilot-cli.js +36 -5
  45. package/lib/vendors/ernie.js +9 -2
  46. package/lib/vendors/grok.js +66 -6
  47. package/lib/vendors/jetbrains.js +136 -13
  48. package/lib/vendors/perplexity.js +151 -17
  49. package/package.json +1 -1
@@ -0,0 +1,414 @@
1
+ // #562: Automated Encrypted Vault Sync via WebDAV, S3, or Private Gist
2
+ // Supports periodic or on-change backup and synchronization of credentials.
3
+
4
+ import { createHmac, createHash } from 'node:crypto'
5
+ import { exportVault, importVault } from './vault.js'
6
+ import { normalizeSlots } from './accounts.js'
7
+ import { Config, plainConfig, revisionOf } from './config-schema.js'
8
+ import { validateCredentialBlob } from './blob.js'
9
+
10
+ let syncStatus = {
11
+ enabled: false,
12
+ backend: 'webdav',
13
+ lastPushAt: null,
14
+ lastPullAt: null,
15
+ lastPushStatus: 'idle',
16
+ lastPullStatus: 'idle',
17
+ lastExportedAt: null,
18
+ lastError: null,
19
+ }
20
+
21
+ export function getVaultSyncStatus() {
22
+ return { ...syncStatus }
23
+ }
24
+
25
+ export function resetVaultSyncStatus() {
26
+ syncStatus = {
27
+ enabled: false,
28
+ backend: 'webdav',
29
+ lastPushAt: null,
30
+ lastPullAt: null,
31
+ lastPushStatus: 'idle',
32
+ lastPullStatus: 'idle',
33
+ lastExportedAt: null,
34
+ lastError: null,
35
+ }
36
+ }
37
+
38
+ // --- Driver: WebDAV ---
39
+
40
+ export async function pushToWebdav({ url, username, password, encryptedData, fetchFn = fetch }) {
41
+ if (!url) throw new Error('WebDAV URL is required')
42
+ const headers = {
43
+ 'Content-Type': 'text/plain; charset=utf-8',
44
+ }
45
+ if (username || password) {
46
+ headers['Authorization'] = 'Basic ' + Buffer.from(`${username || ''}:${password || ''}`).toString('base64')
47
+ }
48
+ const res = await fetchFn(url, {
49
+ method: 'PUT',
50
+ headers,
51
+ body: encryptedData,
52
+ })
53
+ if (!res.ok && res.status !== 201 && res.status !== 204) {
54
+ throw new Error(`WebDAV push failed: HTTP ${res.status} ${res.statusText}`)
55
+ }
56
+ return { ok: true, status: res.status }
57
+ }
58
+
59
+ export async function pullFromWebdav({ url, username, password, fetchFn = fetch }) {
60
+ if (!url) throw new Error('WebDAV URL is required')
61
+ const headers = {}
62
+ if (username || password) {
63
+ headers['Authorization'] = 'Basic ' + Buffer.from(`${username || ''}:${password || ''}`).toString('base64')
64
+ }
65
+ const res = await fetchFn(url, {
66
+ method: 'GET',
67
+ headers,
68
+ })
69
+ if (!res.ok) {
70
+ throw new Error(`WebDAV pull failed: HTTP ${res.status} ${res.statusText}`)
71
+ }
72
+ return res.text()
73
+ }
74
+
75
+ // --- Driver: Gist (GitHub / Gitea) ---
76
+
77
+ export async function pushToGist({ baseUrl = 'https://api.github.com', gistId, token, encryptedData, fetchFn = fetch }) {
78
+ if (!token) throw new Error('Gist access token is required')
79
+ const cleanBase = baseUrl.replace(/\/+$/, '')
80
+ const headers = {
81
+ 'Authorization': `token ${token}`,
82
+ 'Accept': 'application/vnd.github.v3+json, application/json',
83
+ 'Content-Type': 'application/json',
84
+ 'User-Agent': 'dsh-subscriptions-vault-sync',
85
+ }
86
+
87
+ const files = {
88
+ 'dsh-subscriptions-vault.enc': { content: encryptedData },
89
+ }
90
+
91
+ if (gistId) {
92
+ const res = await fetchFn(`${cleanBase}/gists/${gistId}`, {
93
+ method: 'PATCH',
94
+ headers,
95
+ body: JSON.stringify({
96
+ description: 'dsh-subscriptions encrypted vault',
97
+ files,
98
+ }),
99
+ })
100
+ if (!res.ok) throw new Error(`Gist update failed: HTTP ${res.status} ${res.statusText}`)
101
+ const json = await res.json()
102
+ return { ok: true, gistId: json.id || gistId }
103
+ }
104
+
105
+ // Create new secret gist
106
+ const res = await fetchFn(`${cleanBase}/gists`, {
107
+ method: 'POST',
108
+ headers,
109
+ body: JSON.stringify({
110
+ description: 'dsh-subscriptions encrypted vault',
111
+ public: false,
112
+ files,
113
+ }),
114
+ })
115
+ if (!res.ok) throw new Error(`Gist creation failed: HTTP ${res.status} ${res.statusText}`)
116
+ const json = await res.json()
117
+ return { ok: true, gistId: json.id }
118
+ }
119
+
120
+ export async function pullFromGist({ baseUrl = 'https://api.github.com', gistId, token, fetchFn = fetch }) {
121
+ if (!gistId) throw new Error('Gist ID is required')
122
+ const cleanBase = baseUrl.replace(/\/+$/, '')
123
+ const headers = {
124
+ 'Accept': 'application/vnd.github.v3+json, application/json',
125
+ 'User-Agent': 'dsh-subscriptions-vault-sync',
126
+ }
127
+ if (token) headers['Authorization'] = `token ${token}`
128
+
129
+ const res = await fetchFn(`${cleanBase}/gists/${gistId}`, {
130
+ method: 'GET',
131
+ headers,
132
+ })
133
+ if (!res.ok) throw new Error(`Gist pull failed: HTTP ${res.status} ${res.statusText}`)
134
+ const json = await res.json()
135
+ const file = json.files && json.files['dsh-subscriptions-vault.enc']
136
+ if (!file || !file.content) {
137
+ throw new Error('dsh-subscriptions-vault.enc not found in Gist')
138
+ }
139
+ return file.content
140
+ }
141
+
142
+ // --- Driver: S3 (SigV4) ---
143
+
144
+ function hmac(key, str) {
145
+ return createHmac('sha256', key).update(str, 'utf8').digest()
146
+ }
147
+
148
+ function sha256Hex(data) {
149
+ return createHash('sha256').update(data, 'utf8').digest('hex')
150
+ }
151
+
152
+ export function buildS3SigV4Headers({ method, url, region = 'us-east-1', accessKey, secretKey, body = '', date = new Date() }) {
153
+ const parsed = new URL(url)
154
+ const host = parsed.host
155
+ const path = parsed.pathname || '/'
156
+
157
+ const amzDate = date.toISOString().replace(/[:-]|\.\d{3}/g, '')
158
+ const dateStamp = amzDate.slice(0, 8)
159
+
160
+ const payloadHash = sha256Hex(body)
161
+ const canonicalHeaders = `host:${host}\nx-amz-content-sha256:${payloadHash}\nx-amz-date:${amzDate}\n`
162
+ const signedHeaders = 'host;x-amz-content-sha256;x-amz-date'
163
+
164
+ const canonicalReq = [
165
+ method.toUpperCase(),
166
+ path,
167
+ parsed.search.slice(1),
168
+ canonicalHeaders,
169
+ signedHeaders,
170
+ payloadHash,
171
+ ].join('\n')
172
+
173
+ const credentialScope = `${dateStamp}/${region}/s3/aws4_request`
174
+ const stringToSign = [
175
+ 'AWS4-HMAC-SHA256',
176
+ amzDate,
177
+ credentialScope,
178
+ sha256Hex(canonicalReq),
179
+ ].join('\n')
180
+
181
+ const kDate = hmac('AWS4' + secretKey, dateStamp)
182
+ const kRegion = hmac(kDate, region)
183
+ const kService = hmac(kRegion, 's3')
184
+ const kSigning = hmac(kService, 'aws4_request')
185
+ const signature = createHmac('sha256', kSigning).update(stringToSign, 'utf8').digest('hex')
186
+
187
+ const authHeader = `AWS4-HMAC-SHA256 Credential=${accessKey}/${credentialScope}, SignedHeaders=${signedHeaders}, Signature=${signature}`
188
+
189
+ return {
190
+ 'host': host,
191
+ 'x-amz-date': amzDate,
192
+ 'x-amz-content-sha256': payloadHash,
193
+ 'Authorization': authHeader,
194
+ }
195
+ }
196
+
197
+ export async function pushToS3({ endpoint, bucket, key = 'dsh-subscriptions-vault.enc', region = 'us-east-1', accessKey, secretKey, encryptedData, fetchFn = fetch }) {
198
+ if (!endpoint || !accessKey || !secretKey) throw new Error('S3 endpoint, access key, and secret key are required')
199
+ let cleanEndpoint = endpoint.replace(/\/+$/, '')
200
+ let targetUrl = bucket ? `${cleanEndpoint}/${bucket}/${key.replace(/^\/+/, '')}` : `${cleanEndpoint}/${key.replace(/^\/+/, '')}`
201
+
202
+ const headers = buildS3SigV4Headers({
203
+ method: 'PUT',
204
+ url: targetUrl,
205
+ region,
206
+ accessKey,
207
+ secretKey,
208
+ body: encryptedData,
209
+ })
210
+ headers['Content-Type'] = 'text/plain; charset=utf-8'
211
+
212
+ const res = await fetchFn(targetUrl, {
213
+ method: 'PUT',
214
+ headers,
215
+ body: encryptedData,
216
+ })
217
+ if (!res.ok && res.status !== 200 && res.status !== 204) {
218
+ throw new Error(`S3 push failed: HTTP ${res.status} ${res.statusText}`)
219
+ }
220
+ return { ok: true, status: res.status }
221
+ }
222
+
223
+ export async function pullFromS3({ endpoint, bucket, key = 'dsh-subscriptions-vault.enc', region = 'us-east-1', accessKey, secretKey, fetchFn = fetch }) {
224
+ if (!endpoint || !accessKey || !secretKey) throw new Error('S3 endpoint, access key, and secret key are required')
225
+ let cleanEndpoint = endpoint.replace(/\/+$/, '')
226
+ let targetUrl = bucket ? `${cleanEndpoint}/${bucket}/${key.replace(/^\/+/, '')}` : `${cleanEndpoint}/${key.replace(/^\/+/, '')}`
227
+
228
+ const headers = buildS3SigV4Headers({
229
+ method: 'GET',
230
+ url: targetUrl,
231
+ region,
232
+ accessKey,
233
+ secretKey,
234
+ body: '',
235
+ })
236
+
237
+ const res = await fetchFn(targetUrl, {
238
+ method: 'GET',
239
+ headers,
240
+ })
241
+ if (!res.ok) {
242
+ throw new Error(`S3 pull failed: HTTP ${res.status} ${res.statusText}`)
243
+ }
244
+ return res.text()
245
+ }
246
+
247
+ // --- Sync Coordination: Push & Pull ---
248
+
249
+ export async function syncVaultPush({ live, store, fetchFn = fetch }) {
250
+ const cfg = live && typeof live === 'function' ? live() : (live || {})
251
+ const passphrase = String(cfg.vaultSyncPassphrase || '').trim()
252
+ if (!passphrase) {
253
+ throw new Error('vaultSyncPassphrase is required to sync vault')
254
+ }
255
+
256
+ syncStatus.enabled = Boolean(cfg.vaultSyncEnabled)
257
+ syncStatus.backend = cfg.vaultSyncBackend || 'webdav'
258
+ syncStatus.lastPushStatus = 'running'
259
+
260
+ try {
261
+ const slots = cfg.slots || []
262
+ const blobs = {}
263
+ for (const slot of normalizeSlots(slots)) {
264
+ try {
265
+ const blob = await store.loadBlob(slot.ref)
266
+ if (blob) blobs[slot.ref] = blob
267
+ } catch { /* ignore */ }
268
+ }
269
+
270
+ const { vault, slotCount, blobCount } = exportVault({ slots, blobs }, passphrase)
271
+ const backend = cfg.vaultSyncBackend || 'webdav'
272
+
273
+ if (backend === 'webdav') {
274
+ await pushToWebdav({
275
+ url: cfg.vaultSyncWebdavUrl,
276
+ username: cfg.vaultSyncWebdavUsername,
277
+ password: cfg.vaultSyncWebdavPassword,
278
+ encryptedData: vault,
279
+ fetchFn,
280
+ })
281
+ } else if (backend === 'gist') {
282
+ const res = await pushToGist({
283
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
284
+ gistId: cfg.vaultSyncGistId,
285
+ token: cfg.vaultSyncGistToken,
286
+ encryptedData: vault,
287
+ fetchFn,
288
+ })
289
+ if (res.gistId && !cfg.vaultSyncGistId) {
290
+ syncStatus.lastGistId = res.gistId
291
+ }
292
+ } else if (backend === 's3') {
293
+ await pushToS3({
294
+ endpoint: cfg.vaultSyncS3Endpoint,
295
+ bucket: cfg.vaultSyncS3Bucket,
296
+ key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
297
+ region: cfg.vaultSyncS3Region || 'us-east-1',
298
+ accessKey: cfg.vaultSyncS3AccessKey,
299
+ secretKey: cfg.vaultSyncS3SecretKey,
300
+ encryptedData: vault,
301
+ fetchFn,
302
+ })
303
+ } else {
304
+ throw new Error(`unsupported vault sync backend: ${backend}`)
305
+ }
306
+
307
+ syncStatus.lastPushAt = new Date().toISOString()
308
+ syncStatus.lastPushStatus = 'ok'
309
+ syncStatus.lastError = null
310
+
311
+ return {
312
+ ok: true,
313
+ backend,
314
+ slotCount,
315
+ blobCount,
316
+ timestamp: syncStatus.lastPushAt,
317
+ }
318
+ } catch (err) {
319
+ syncStatus.lastPushStatus = 'error'
320
+ syncStatus.lastError = String(err && err.message || err)
321
+ throw err
322
+ }
323
+ }
324
+
325
+ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, refreshModels, fetchFn = fetch }) {
326
+ const cfg = live && typeof live === 'function' ? live() : (live || {})
327
+ const passphrase = String(cfg.vaultSyncPassphrase || '').trim()
328
+ if (!passphrase) {
329
+ throw new Error('vaultSyncPassphrase is required to sync vault')
330
+ }
331
+
332
+ syncStatus.enabled = Boolean(cfg.vaultSyncEnabled)
333
+ syncStatus.backend = cfg.vaultSyncBackend || 'webdav'
334
+ syncStatus.lastPullStatus = 'running'
335
+
336
+ try {
337
+ const backend = cfg.vaultSyncBackend || 'webdav'
338
+ let encryptedData = ''
339
+
340
+ if (backend === 'webdav') {
341
+ encryptedData = await pullFromWebdav({
342
+ url: cfg.vaultSyncWebdavUrl,
343
+ username: cfg.vaultSyncWebdavUsername,
344
+ password: cfg.vaultSyncWebdavPassword,
345
+ fetchFn,
346
+ })
347
+ } else if (backend === 'gist') {
348
+ encryptedData = await pullFromGist({
349
+ baseUrl: cfg.vaultSyncGistBaseUrl || 'https://api.github.com',
350
+ gistId: cfg.vaultSyncGistId,
351
+ token: cfg.vaultSyncGistToken,
352
+ fetchFn,
353
+ })
354
+ } else if (backend === 's3') {
355
+ encryptedData = await pullFromS3({
356
+ endpoint: cfg.vaultSyncS3Endpoint,
357
+ bucket: cfg.vaultSyncS3Bucket,
358
+ key: cfg.vaultSyncS3Key || 'dsh-subscriptions-vault.enc',
359
+ region: cfg.vaultSyncS3Region || 'us-east-1',
360
+ accessKey: cfg.vaultSyncS3AccessKey,
361
+ secretKey: cfg.vaultSyncS3SecretKey,
362
+ fetchFn,
363
+ })
364
+ } else {
365
+ throw new Error(`unsupported vault sync backend: ${backend}`)
366
+ }
367
+
368
+ const result = importVault(encryptedData, passphrase)
369
+
370
+ // Merge slots into live configuration
371
+ if (result.slots.length && settingsApi) {
372
+ const revision = revisionOf(settingsApi)
373
+ const curSlots = Array.isArray(cfg.slots) ? cfg.slots.slice() : []
374
+ for (const s of result.slots) {
375
+ const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
376
+ if (idx >= 0) {
377
+ curSlots[idx] = { ...curSlots[idx], ...s }
378
+ } else {
379
+ curSlots.push(s)
380
+ }
381
+ }
382
+ const parsed = plainConfig(Config({ ...cfg, slots: curSlots }))
383
+ await settingsApi.replace(parsed, revision)
384
+ }
385
+
386
+ // Save blobs into store
387
+ let blobCount = 0
388
+ for (const [ref, blob] of Object.entries(result.blobs)) {
389
+ await store.saveBlob(ref, validateCredentialBlob(blob))
390
+ blobCount++
391
+ }
392
+
393
+ if (typeof syncAdapter === 'function') await syncAdapter()
394
+ if (typeof refreshModels === 'function') refreshModels().catch(() => {})
395
+
396
+ syncStatus.lastPullAt = new Date().toISOString()
397
+ syncStatus.lastExportedAt = result.exportedAt
398
+ syncStatus.lastPullStatus = 'ok'
399
+ syncStatus.lastError = null
400
+
401
+ return {
402
+ ok: true,
403
+ backend,
404
+ slotCount: result.slots.length,
405
+ blobCount,
406
+ exportedAt: result.exportedAt,
407
+ timestamp: syncStatus.lastPullAt,
408
+ }
409
+ } catch (err) {
410
+ syncStatus.lastPullStatus = 'error'
411
+ syncStatus.lastError = String(err && err.message || err)
412
+ throw err
413
+ }
414
+ }
package/lib/vault.js CHANGED
@@ -17,7 +17,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
17
17
 
18
18
  const payload = {
19
19
  format: VAULT_FORMAT,
20
- version: '0.6.18',
20
+ version: '0.6.77',
21
21
  exportedAt: new Date().toISOString(),
22
22
  exportedBy,
23
23
  slots: Array.isArray(slots) ? slots : [],
@@ -111,14 +111,14 @@ export function createVendorFromProfile(profile) {
111
111
  return null
112
112
  },
113
113
  check: undefined,
114
- async* streamOnce({ blob, options, fetchImpl, headers, config, signal }) {
114
+ async* streamOnce({ blob, options, fetchImpl, headers, signal }) {
115
115
  const body = codexResponsesBody(options, "")
116
116
  if (!body.instructions) delete body.instructions
117
117
  const res = await fetchWithTimeout(fetchImpl || fetch, `${base}/responses`, {
118
118
  method: "POST",
119
119
  headers: {
120
120
  ...headers,
121
- ...identityHeaders(blob, config),
121
+ ...identityHeaders(blob),
122
122
  "Content-Type": "application/json",
123
123
  Accept: "text/event-stream",
124
124
  },
@@ -139,12 +139,16 @@ export function validateProfile(raw) {
139
139
  }
140
140
  if (!/^[a-z][a-z0-9_]*$/.test(p.id)) throw new Error(`customVendors: id "${p.id}" must match [a-z][a-z0-9_]*`)
141
141
  if (p.tokenStyle && !["form", "json"].includes(p.tokenStyle)) throw new Error(`customVendors[${p.id}]: tokenStyle must be form|json`)
142
+ const localHostnames = new Set(["localhost", "127.0.0.1", "::1", "[::1]"])
142
143
  for (const key of ["authUrl", "tokenUrl", "baseUrl"]) {
143
144
  try {
144
145
  const u = new URL(p[key])
145
146
  if (u.protocol !== "https:" && u.protocol !== "http:") {
146
147
  throw new Error("protocol must be http: or https:")
147
148
  }
149
+ if (u.protocol === "http:" && !localHostnames.has(u.hostname)) {
150
+ throw new Error("http is allowed only for localhost")
151
+ }
148
152
  } catch (e) {
149
153
  throw new Error(`customVendors[${p.id}]: field ${key} must be a valid http(s) URL (${e.message || e})`)
150
154
  }
@@ -3,7 +3,7 @@ import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { googleContents } from '../messages.js'
4
4
  import { formTokenRequest, googleStream, throwHttpError, tokenBlobFromOAuth } from '../wire.js'
5
5
  import { fetchWithTimeout } from '../http.js'
6
- import { emailFromToken } from '../jwt.js'
6
+ import { emailFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
7
7
  import {
8
8
  CODE_ASSIST_STREAM,
9
9
  antigravityMetadata,
@@ -18,7 +18,7 @@ import {
18
18
 
19
19
  export const id = 'antigravity'
20
20
  export const DEFAULT_CLI_CLIENT_ID = ['681255809395', 'oo8ft2oprdrnp9e3aqf6av3hmdib135j'].join('-') + '.' + 'apps.googleusercontent.com'
21
- export const DEFAULT_CLI_CLIENT_SECRET = ['GOCSPX', '4uHgMPm', '1o7Sk', 'geV6Cu5clXFsxl'].join('-')
21
+
22
22
 
23
23
  const AUTH = 'https://accounts.google.com/o/oauth2/v2/auth'
24
24
  const TOKEN = 'https://oauth2.googleapis.com/token'
@@ -125,7 +125,16 @@ export async function exchangeCode(cfg, pkce, code, fetchImpl) {
125
125
  clientId: cfg.clientId,
126
126
  ...(cfg.clientSecret ? { clientSecret: cfg.clientSecret } : {}),
127
127
  })
128
- const labeled = { ...blob, email: blob.email || emailFromToken(blob.accessToken), label: blob.label || 'Antigravity' }
128
+ const email = blob.email || emailFromToken(blob.idToken || blob.accessToken)
129
+ const avatarUrl = blob.avatarUrl || avatarFromToken(blob.idToken || blob.accessToken)
130
+ const scopes = blob.scopes?.length ? blob.scopes : (json?.scope ? String(json.scope).split(/[\s,]+/).filter(Boolean) : scopesFromToken(blob.idToken || blob.accessToken))
131
+ const labeled = {
132
+ ...blob,
133
+ email,
134
+ avatarUrl,
135
+ scopes,
136
+ label: blob.label || email || "Antigravity",
137
+ }
129
138
  return withProject(labeled, fetchImpl)
130
139
  }
131
140
 
@@ -134,7 +143,7 @@ export async function refresh(cfg, blob, fetchImpl) {
134
143
  let clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
135
144
 
136
145
  if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
137
- clientSecret = DEFAULT_CLI_CLIENT_SECRET
146
+ throw new Error('Google OAuth refresh requires a client secret for the default Gemini CLI client. Configure antigravityClientSecret in plugin settings.')
138
147
  }
139
148
 
140
149
  if (!clientId) {
@@ -2,7 +2,7 @@ import { fetchWithTimeout } from '../http.js'
2
2
  import { buildAuthorizeUrl } from '../oauth.js'
3
3
  import { anthropicPayload, modelCatalog } from '../messages.js'
4
4
  import { jsonTokenRequest, anthropicStream, httpError, tokenBlobFromOAuth, readJson } from '../wire.js'
5
- import { emailFromToken } from '../jwt.js'
5
+ import { emailFromToken, planFromToken, avatarFromToken, scopesFromToken } from '../jwt.js'
6
6
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
7
7
 
8
8
  export const id = 'claude'
@@ -81,13 +81,19 @@ export function defaults() {
81
81
  }
82
82
  }
83
83
 
84
- function oauthHeaders(token) {
85
- return {
84
+ function oauthHeaders(token, blob) {
85
+ const h = {
86
86
  Authorization: `Bearer ${token}`,
87
87
  Accept: 'application/json',
88
88
  'anthropic-version': '2023-06-01',
89
89
  'anthropic-beta': BETA,
90
90
  }
91
+ const org = blob && (blob.selectedOrgId || blob.organizationId)
92
+ if (org) {
93
+ h['anthropic-organization-id'] = String(org)
94
+ h['x-anthropic-organization'] = String(org)
95
+ }
96
+ return h
91
97
  }
92
98
 
93
99
  export function authorizeUrl(cfg, pkce) {
@@ -104,14 +110,56 @@ export function authorizeUrl(cfg, pkce) {
104
110
 
105
111
  async function decorate(blob, fetchImpl) {
106
112
  let email = blob.email || emailFromToken(blob.accessToken)
107
- if (!email && fetchImpl && blob.accessToken) {
113
+ let plan = blob.plan || planFromToken(blob.accessToken)
114
+ let avatarUrl = blob.avatarUrl || avatarFromToken(blob.accessToken)
115
+ let scopes = blob.scopes || scopesFromToken(blob.accessToken)
116
+ let orgs = Array.isArray(blob.organizations) ? [...blob.organizations] : []
117
+ if (fetchImpl && blob.accessToken && (!email || !plan || !orgs.length)) {
108
118
  try {
109
- const res = await fetchImpl(PROFILE, { headers: oauthHeaders(blob.accessToken) })
119
+ const res = await fetchImpl(PROFILE, { headers: oauthHeaders(blob.accessToken, blob) })
110
120
  const json = await readJson(res)
111
- email = json.email || (json.account && json.account.email) || email
121
+ email = email || json.email || (json.account && json.account.email) || ""
122
+ plan = plan || json.plan || (json.account && (json.account.plan_type || (json.account.has_team ? "Team" : (json.account.has_pro ? "Pro" : "Free")))) || ""
123
+ avatarUrl = avatarUrl || json.picture || (json.account && json.account.picture) || ""
124
+ if (json.scope && !scopes.length) scopes = String(json.scope).split(/[\s,]+/).filter(Boolean)
125
+ if (Array.isArray(json.organizations)) {
126
+ for (const o of json.organizations) {
127
+ if (o && (o.id || o.uuid)) orgs.push({ id: String(o.id || o.uuid), name: String(o.name || o.title || o.id) })
128
+ }
129
+ } else if (Array.isArray(json.workspaces)) {
130
+ for (const w of json.workspaces) {
131
+ if (w && (w.id || w.uuid)) orgs.push({ id: String(w.id || w.uuid), name: String(w.name || w.title || w.id) })
132
+ }
133
+ } else if (json.account && Array.isArray(json.account.memberships)) {
134
+ for (const m of json.account.memberships) {
135
+ const o = m.organization || m
136
+ if (o && (o.id || o.uuid)) orgs.push({ id: String(o.id || o.uuid), name: String(o.name || o.title || o.id), role: m.role ? String(m.role) : undefined })
137
+ }
138
+ }
112
139
  } catch { /* profile is optional */ }
113
140
  }
114
- return { ...blob, email, label: blob.label || email || 'Claude' }
141
+ const dedupedOrgs = []
142
+ const seen = new Set()
143
+ for (const o of orgs) {
144
+ if (!seen.has(o.id)) {
145
+ seen.add(o.id)
146
+ dedupedOrgs.push(o)
147
+ }
148
+ }
149
+ const selectedOrgId = blob.selectedOrgId || blob.organizationId || (dedupedOrgs[0] ? dedupedOrgs[0].id : '')
150
+ const selectedOrgName = blob.selectedOrgName || (dedupedOrgs.find(o => o.id === selectedOrgId)?.name || selectedOrgId)
151
+ return {
152
+ ...blob,
153
+ email: email || "",
154
+ plan: plan || "Claude Pro",
155
+ avatarUrl: avatarUrl || "",
156
+ scopes,
157
+ selectedOrgId,
158
+ selectedOrgName,
159
+ organizationId: selectedOrgId,
160
+ organizations: dedupedOrgs,
161
+ label: blob.label || email || "Claude",
162
+ }
115
163
  }
116
164
 
117
165
  export async function exchangeCode(cfg, pkce, code, fetchImpl) {
@@ -302,7 +350,7 @@ export async function* streamOnce({ blob, options, fetchImpl, headers, config, s
302
350
  method: 'POST',
303
351
  headers: {
304
352
  ...headers,
305
- ...oauthHeaders(blob.accessToken),
353
+ ...oauthHeaders(blob.accessToken, blob),
306
354
  'Content-Type': 'application/json',
307
355
  Accept: 'text/event-stream',
308
356
  },
@@ -2,7 +2,7 @@ import { isTimeoutError, fetchWithTimeout } from "../http.js"
2
2
  import { randomUUID, createHash } from 'node:crypto'
3
3
  import { buildAuthorizeUrl } from '../oauth.js'
4
4
  import { codexResponsesBody, modelCatalog } from '../messages.js'
5
- import { chatgptAccountId, emailFromToken } from '../jwt.js'
5
+ import { chatgptAccountId, emailFromToken, planFromToken, avatarFromToken, scopesFromToken, organizationsFromToken } from '../jwt.js'
6
6
  import { formTokenRequest, codexResponsesStream, readJson, tokenBlobFromOAuth, httpError } from '../wire.js'
7
7
  import { asUsageSnapshot, usageWindows, deepestUsedPercent } from '../usage.js'
8
8
 
@@ -62,11 +62,13 @@ export function authorizeUrl(cfg, pkce) {
62
62
 
63
63
  function identityHeaders(blob, config, extra) {
64
64
  const originator = config.originator || 'codex_cli_rs'
65
+ const activeOrgId = blob.selectedOrgId || blob.accountId || ''
65
66
  return {
66
67
  Authorization: `Bearer ${blob.accessToken}`,
67
68
  originator,
68
- 'chatgpt-account-id': blob.accountId || '',
69
- 'ChatGPT-Account-ID': blob.accountId || '',
69
+ 'chatgpt-account-id': activeOrgId,
70
+ 'ChatGPT-Account-ID': activeOrgId,
71
+ ...(activeOrgId ? { 'OpenAI-Organization': activeOrgId } : {}),
70
72
  'User-Agent': `${originator}/0.0.1`,
71
73
  'OpenAI-Beta': 'responses=experimental',
72
74
  ...(extra || {}),
@@ -75,11 +77,37 @@ function identityHeaders(blob, config, extra) {
75
77
 
76
78
  function decorate(blob, json) {
77
79
  const idToken = (json && json.id_token) || blob.idToken || ''
80
+ const email = blob.email || emailFromToken(idToken) || emailFromToken(blob.accessToken)
81
+ const plan = blob.plan || planFromToken(idToken) || planFromToken(blob.accessToken)
82
+ const avatarUrl = blob.avatarUrl || avatarFromToken(idToken) || avatarFromToken(blob.accessToken)
83
+ const scopes = blob.scopes?.length ? blob.scopes : (json?.scope ? String(json.scope).split(/[\s,]+/).filter(Boolean) : scopesFromToken(idToken || blob.accessToken))
84
+ const parsedOrgs = [
85
+ ...organizationsFromToken(idToken),
86
+ ...organizationsFromToken(blob.accessToken),
87
+ ]
88
+ const dedupedOrgs = Array.isArray(blob.organizations) && blob.organizations.length ? [...blob.organizations] : []
89
+ const seen = new Set(dedupedOrgs.map(o => o.id))
90
+ for (const o of parsedOrgs) {
91
+ if (!seen.has(o.id)) {
92
+ seen.add(o.id)
93
+ dedupedOrgs.push(o)
94
+ }
95
+ }
96
+ const accountId = blob.accountId || chatgptAccountId(idToken) || chatgptAccountId(blob.accessToken)
97
+ const selectedOrgId = blob.selectedOrgId || accountId || (dedupedOrgs[0] ? dedupedOrgs[0].id : '')
98
+ const selectedOrgName = blob.selectedOrgName || (dedupedOrgs.find(o => o.id === selectedOrgId)?.name || selectedOrgId)
99
+
78
100
  return {
79
101
  ...blob,
80
- accountId: blob.accountId || chatgptAccountId(idToken) || chatgptAccountId(blob.accessToken),
81
- email: blob.email || emailFromToken(idToken) || emailFromToken(blob.accessToken),
82
- label: blob.label || emailFromToken(idToken) || emailFromToken(blob.accessToken) || 'ChatGPT',
102
+ accountId,
103
+ selectedOrgId,
104
+ selectedOrgName,
105
+ organizations: dedupedOrgs,
106
+ email,
107
+ plan: plan || 'ChatGPT',
108
+ avatarUrl,
109
+ scopes,
110
+ label: blob.label || email || 'ChatGPT',
83
111
  }
84
112
  }
85
113