@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.
- package/README.md +8 -0
- package/README.ru.md +8 -0
- package/README.zh.md +8 -0
- package/lib/accounts-view.js +97 -5
- package/lib/accounts.js +137 -11
- package/lib/adapter.js +3 -0
- package/lib/alerts.js +35 -0
- package/lib/analytics.js +31 -2
- package/lib/billing.js +99 -0
- package/lib/blob.js +22 -0
- package/lib/capabilities.js +208 -0
- package/lib/client.js +344 -17
- package/lib/config-schema.js +50 -4
- package/lib/diagnostic-wizard.js +2 -5
- package/lib/diagnostics.js +2 -2
- package/lib/error-buffer.js +2 -2
- package/lib/forecast.js +56 -0
- package/lib/headless-auth.js +392 -0
- package/lib/history.js +36 -0
- package/lib/http.js +67 -4
- package/lib/import-auth.js +3 -6
- package/lib/index.js +99 -11
- package/lib/jwt.js +84 -17
- package/lib/loopback.js +3 -2
- package/lib/mask.js +16 -0
- package/lib/oauth-pending.js +12 -0
- package/lib/pricing.js +89 -0
- package/lib/refs.js +2 -0
- package/lib/reset-clock.js +55 -0
- package/lib/rotate.js +18 -4
- package/lib/routes/accounts.js +142 -7
- package/lib/routes/oauth.js +51 -2
- package/lib/routes/proxy.js +14 -0
- package/lib/scope-inspector.js +51 -0
- package/lib/stream-rotate.js +31 -10
- package/lib/subscriptions.js +24 -1
- package/lib/updater.js +21 -0
- package/lib/vault-sync.js +414 -0
- package/lib/vault.js +1 -1
- package/lib/vendor-factory.js +6 -2
- package/lib/vendors/antigravity.js +13 -4
- package/lib/vendors/claude.js +56 -8
- package/lib/vendors/codex.js +34 -6
- package/lib/vendors/copilot-cli.js +36 -5
- package/lib/vendors/ernie.js +9 -2
- package/lib/vendors/grok.js +66 -6
- package/lib/vendors/jetbrains.js +136 -13
- package/lib/vendors/perplexity.js +151 -17
- 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.
|
|
20
|
+
version: '0.6.77',
|
|
21
21
|
exportedAt: new Date().toISOString(),
|
|
22
22
|
exportedBy,
|
|
23
23
|
slots: Array.isArray(slots) ? slots : [],
|
package/lib/vendor-factory.js
CHANGED
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
package/lib/vendors/claude.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) ||
|
|
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
|
-
|
|
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
|
},
|
package/lib/vendors/codex.js
CHANGED
|
@@ -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':
|
|
69
|
-
'ChatGPT-Account-ID':
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|