@goodandready/dsh-subscriptions 0.6.82 → 0.6.84
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 +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/lib/accounts.js +1 -0
- package/lib/blob.js +4 -0
- package/lib/client.js +59 -26
- package/lib/config-schema.js +8 -0
- package/lib/diagnostic-wizard.js +20 -21
- package/lib/headless-auth.js +14 -1
- package/lib/http.js +45 -0
- package/lib/import-auth.js +3 -6
- package/lib/index.js +1 -0
- package/lib/routes/accounts.js +11 -7
- package/lib/routes/oauth.js +8 -1
- package/lib/routes/status.js +4 -4
- package/lib/subscriptions.js +24 -1
- package/lib/vault-sync.js +5 -3
- package/lib/vault.js +24 -1
- package/lib/vendors/antigravity.js +2 -2
- package/lib/vendors/copilot-cli.js +45 -7
- package/lib/vendors/jetbrains.js +5 -2
- package/lib/vendors/perplexity.js +5 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -309,6 +309,8 @@ dsh-subscriptions:
|
|
|
309
309
|
|
|
310
310
|
- `customVendors.authUrl`, `tokenUrl` and `baseUrl` must use `https://`. Plain `http://` is accepted only for loopback hosts (`localhost`, `127.0.0.1`, `::1`).
|
|
311
311
|
- Grok `baseUrl` overrides must point to `api.x.ai`, `grok.com` / `*.grok.com`, or a loopback host; other hosts are rejected with `GROK_UNTRUSTED_HOST` so bearer tokens never leave trusted xAI infrastructure.
|
|
312
|
+
- Antigravity CLI imports must configure `antigravityClientSecret`; the embedded default was removed.
|
|
313
|
+
- `copilotCliPath` must point to a `copilot`, `github-copilot-cli` or `copilot-cli` binary; rename custom wrappers.
|
|
312
314
|
- The error buffer and diagnostics reports redact bearer tokens, API keys, JWTs and `key=value` credentials before storing or returning them.
|
|
313
315
|
|
|
314
316
|
|
package/README.ru.md
CHANGED
|
@@ -282,6 +282,8 @@ dsh-subscriptions:
|
|
|
282
282
|
|
|
283
283
|
- `customVendors.authUrl`, `tokenUrl` и `baseUrl` обязаны использовать `https://`. Открытый `http://` допускается только для loopback-хостов (`localhost`, `127.0.0.1`, `::1`).
|
|
284
284
|
- Переопределение Grok `baseUrl` должно указывать на `api.x.ai`, `grok.com` / `*.grok.com` или loopback-хост; остальные хосты отклоняются с `GROK_UNTRUSTED_HOST`, чтобы bearer-токен не покидал доверенную инфраструктуру xAI.
|
|
285
|
+
- Импорт Antigravity CLI требует явного `antigravityClientSecret`; встроенный default удалён.
|
|
286
|
+
- `copilotCliPath` должен указывать на бинарь `copilot`, `github-copilot-cli` или `copilot-cli`; кастомные обёртки переименуйте.
|
|
285
287
|
- Error buffer и диагностические отчёты редактируют bearer-токены, API-ключи, JWT и `key=value`-credentials до сохранения и выдачи.
|
|
286
288
|
|
|
287
289
|
|
package/README.zh.md
CHANGED
|
@@ -188,6 +188,8 @@ dsh plugin --profile web add @goodandready/dsh-subscriptions
|
|
|
188
188
|
|
|
189
189
|
- `customVendors.authUrl`、`tokenUrl` 和 `baseUrl` 必须使用 `https://`。明文 `http://` 仅允许用于回环主机(`localhost`、`127.0.0.1`、`::1`)。
|
|
190
190
|
- Grok `baseUrl` 覆盖只能指向 `api.x.ai`、`grok.com` / `*.grok.com` 或回环主机;其他主机会以 `GROK_UNTRUSTED_HOST` 拒绝,避免 bearer 令牌离开可信的 xAI 基础设施。
|
|
191
|
+
- Antigravity CLI 导入必须配置 `antigravityClientSecret`;内置默认值已移除。
|
|
192
|
+
- `copilotCliPath` 必须指向 `copilot`、`github-copilot-cli` 或 `copilot-cli` 可执行文件;自定义包装器请重命名。
|
|
191
193
|
- 错误缓冲区和诊断报告会在存储或返回前脱敏 bearer 令牌、API 密钥、JWT 和 `key=value` 凭据。
|
|
192
194
|
|
|
193
195
|
|
package/lib/accounts.js
CHANGED
|
@@ -379,6 +379,7 @@ export function createAccountStore({ credentials, getConfig, fetchImpl, fetchFor
|
|
|
379
379
|
label: slot.label || info.label,
|
|
380
380
|
pacePerHour: info.pacePerHour || 0,
|
|
381
381
|
maxConcurrency: slot.maxConcurrency !== undefined ? slot.maxConcurrency : (curConfig.accountMaxConcurrency || 1),
|
|
382
|
+
maxQueueSize: slot.maxQueueSize > 0 ? Number(slot.maxQueueSize) : (curConfig.maxQueueSize || 20),
|
|
382
383
|
})
|
|
383
384
|
}
|
|
384
385
|
return out
|
package/lib/blob.js
CHANGED
package/lib/client.js
CHANGED
|
@@ -56,9 +56,19 @@ window.__ModuleLoader__.load({
|
|
|
56
56
|
const PKG = '@goodandready/dsh-subscriptions'
|
|
57
57
|
const ROW_ID = 'dsh-subscriptions'
|
|
58
58
|
const ROW_CONFIG_KEY = PKG + '#' + ROW_ID
|
|
59
|
-
|
|
60
|
-
let INSTRUCTIONS = {};
|
|
61
59
|
|
|
60
|
+
function safeExternalUrl(raw, allowDataImage) {
|
|
61
|
+
const text = String(raw == null ? '' : raw).trim()
|
|
62
|
+
if (!text) return ''
|
|
63
|
+
if (allowDataImage && /^data:image\/(?:png|jpe?g|gif|webp|svg\+xml)[;,]/i.test(text)) return text
|
|
64
|
+
try {
|
|
65
|
+
const base = (typeof window !== 'undefined' && window.location && window.location.origin) || 'http://127.0.0.1'
|
|
66
|
+
const url = new URL(text, base)
|
|
67
|
+
if (url.protocol === 'http:' || url.protocol === 'https:' || url.protocol === 'dsh-app:') return url.href
|
|
68
|
+
} catch { /* invalid url */ }
|
|
69
|
+
return ''
|
|
70
|
+
}
|
|
71
|
+
|
|
62
72
|
const en = {
|
|
63
73
|
"syncModels": "Sync Models",
|
|
64
74
|
"syncModelsTitle": "Discover and synchronize available models from vendor API",
|
|
@@ -157,6 +167,7 @@ window.__ModuleLoader__.load({
|
|
|
157
167
|
"atRestEncrypted": "Encrypted at Rest (AES-256-GCM)",
|
|
158
168
|
"atRestEncryptedTitle": "Credentials are protected at rest with master key encryption.",
|
|
159
169
|
"maxConcurrencySlotTitle": "Max parallel streams for this account slot (default: 1)",
|
|
170
|
+
"monthlyCostSlotTitle": "Monthly subscription cost in USD for this account (default: 20)",
|
|
160
171
|
"concurrencyQueueHint": "Active and queued parallel requests for this account",
|
|
161
172
|
"activeStreams": "active",
|
|
162
173
|
"queuedStreams": "queued",
|
|
@@ -489,6 +500,7 @@ window.__ModuleLoader__.load({
|
|
|
489
500
|
"atRestEncrypted": "静态存储加密 (AES-256-GCM)",
|
|
490
501
|
"atRestEncryptedTitle": "凭据已通过主密钥进行静态 AES-256-GCM 加密保护。",
|
|
491
502
|
"maxConcurrencySlotTitle": "当前账户槽位最大并发请求数(默认:1)",
|
|
503
|
+
"monthlyCostSlotTitle": "该账户每月订阅费用(美元,默认:20)",
|
|
492
504
|
"concurrencyQueueHint": "当前账户槽位进行中与排队中的并发请求",
|
|
493
505
|
"activeStreams": "活跃",
|
|
494
506
|
"queuedStreams": "排队中",
|
|
@@ -1253,10 +1265,10 @@ window.__ModuleLoader__.load({
|
|
|
1253
1265
|
const pendingKeys = Object.entries(device).filter(([, d]) => d && d.status === 'pending')
|
|
1254
1266
|
if (!pendingKeys.length) return
|
|
1255
1267
|
const timers = pendingKeys.map(([key, d]) => setInterval(() => {
|
|
1256
|
-
devicePollOnce(key.split(':')[0], Number(key.split(':')[1]))
|
|
1268
|
+
devicePollOnce(key.split(':')[0], Number(key.split(':')[1])).catch(() => {})
|
|
1257
1269
|
}, d.intervalMs || 5000))
|
|
1258
1270
|
return () => timers.forEach(clearInterval)
|
|
1259
|
-
})
|
|
1271
|
+
}, [device])
|
|
1260
1272
|
|
|
1261
1273
|
// #81: settings snapshot status - never render phantom inputs before the
|
|
1262
1274
|
// config snapshot arrives, and offer a retry when the store is unavailable.
|
|
@@ -1274,7 +1286,13 @@ window.__ModuleLoader__.load({
|
|
|
1274
1286
|
}
|
|
1275
1287
|
|
|
1276
1288
|
const slots = Array.isArray(draft.slots) ? draft.slots : []
|
|
1277
|
-
const setSlots = (next) => setDraft((d) =>
|
|
1289
|
+
const setSlots = (next) => setDraft((d) => {
|
|
1290
|
+
const base = Array.isArray(d && d.slots) ? d.slots : []
|
|
1291
|
+
return Object.assign({}, d, { slots: typeof next === 'function' ? next(base) : next })
|
|
1292
|
+
})
|
|
1293
|
+
const updateSlot = (provider, index, patch) => setSlots((prev) => prev.map((s) => (
|
|
1294
|
+
s && s.provider === provider && Number(s.index) === Number(index) ? Object.assign({}, s, patch) : s
|
|
1295
|
+
)))
|
|
1278
1296
|
const accountOf = (provider, index) => accounts.find((a) => a.provider === provider && a.index === index) || {}
|
|
1279
1297
|
|
|
1280
1298
|
const save = async () => {
|
|
@@ -1311,7 +1329,8 @@ window.__ModuleLoader__.load({
|
|
|
1311
1329
|
if (!res.ok) throw new Error((data.error && data.error.message) || ('HTTP ' + res.status))
|
|
1312
1330
|
const key = provider + ':' + index
|
|
1313
1331
|
if (data.headless) setHeadlessInfo((m) => Object.assign({}, m, { [key]: data.headless }))
|
|
1314
|
-
|
|
1332
|
+
const authTarget = safeExternalUrl(data.url)
|
|
1333
|
+
if (authTarget) window.open(authTarget, '_blank', 'noopener')
|
|
1315
1334
|
}
|
|
1316
1335
|
|
|
1317
1336
|
// #90: device-code login (headless). Server mints user_code; we show it,
|
|
@@ -1509,7 +1528,7 @@ window.__ModuleLoader__.load({
|
|
|
1509
1528
|
const used = slots.filter((s) => s.provider === provider).map((s) => s.index)
|
|
1510
1529
|
let index = 1
|
|
1511
1530
|
while (used.indexOf(index) >= 0) index++
|
|
1512
|
-
setSlots(
|
|
1531
|
+
setSlots((prev) => prev.concat([{ provider: provider, index: index, label: '' }]))
|
|
1513
1532
|
}
|
|
1514
1533
|
|
|
1515
1534
|
const names = providers.length ? providers : [
|
|
@@ -2338,8 +2357,9 @@ window.__ModuleLoader__.load({
|
|
|
2338
2357
|
const account = accountOf(row.slot.provider, row.slot.index)
|
|
2339
2358
|
const key = row.slot.provider + ':' + row.slot.index
|
|
2340
2359
|
const on = !!account.configured
|
|
2341
|
-
const
|
|
2342
|
-
|
|
2360
|
+
const avatarSrc = safeExternalUrl(account.avatarUrl, true)
|
|
2361
|
+
const avatarEl = (avatarSrc ? h('img', {
|
|
2362
|
+
src: avatarSrc,
|
|
2343
2363
|
alt: account.email || account.label || 'Avatar',
|
|
2344
2364
|
className: 'dsub-avatar',
|
|
2345
2365
|
onError: (e) => { e.target.style.display = 'none' },
|
|
@@ -2355,9 +2375,7 @@ window.__ModuleLoader__.load({
|
|
|
2355
2375
|
value: row.slot.label || '',
|
|
2356
2376
|
placeholder: account.email || account.label || (t('accountLabel') + ' ' + row.slot.index),
|
|
2357
2377
|
onChange: (e) => {
|
|
2358
|
-
|
|
2359
|
-
next[row.i] = Object.assign({}, next[row.i], { label: e.target.value })
|
|
2360
|
-
setSlots(next)
|
|
2378
|
+
updateSlot(row.slot.provider, row.slot.index, { label: e.target.value })
|
|
2361
2379
|
},
|
|
2362
2380
|
}),
|
|
2363
2381
|
h('span', { className: 'dsub-badge' + (account.isRevoked ? ' dsub-badge-bad' : (account.validationUrl ? ' dsub-badge-warn' : (on ? ' dsub-badge-on' : ''))) }, badgeFor(account, t)),
|
|
@@ -2431,7 +2449,7 @@ window.__ModuleLoader__.load({
|
|
|
2431
2449
|
onClick: () => {
|
|
2432
2450
|
const slot = row.slot
|
|
2433
2451
|
Promise.resolve(on ? logout(slot.provider, slot.index) : null)
|
|
2434
|
-
.then(() => setSlots(
|
|
2452
|
+
.then(() => setSlots((prev) => prev.filter((s) => !(s && s.provider === slot.provider && Number(s.index) === Number(slot.index)))))
|
|
2435
2453
|
.catch((e) => setErr(cleanErrorMessage(e.message || e)))
|
|
2436
2454
|
},
|
|
2437
2455
|
}, '\u00d7'),
|
|
@@ -2478,10 +2496,10 @@ window.__ModuleLoader__.load({
|
|
|
2478
2496
|
h('div', { style: { fontFamily: 'monospace', fontSize: '11px', margin: '4px 0', wordBreak: 'break-all' } },
|
|
2479
2497
|
headlessInfo[key].sshTunnelCommand
|
|
2480
2498
|
),
|
|
2481
|
-
headlessInfo[key].svgDataUrl ? h('div', { style: { marginTop: 6, textAlign: 'center' } },
|
|
2499
|
+
safeExternalUrl(headlessInfo[key].svgDataUrl, true) ? h('div', { style: { marginTop: 6, textAlign: 'center' } },
|
|
2482
2500
|
h('div', { className: 'dsub-sub', style: { marginBottom: 4 } }, t('headlessScanQr')),
|
|
2483
2501
|
h('img', {
|
|
2484
|
-
src: headlessInfo[key].svgDataUrl,
|
|
2502
|
+
src: safeExternalUrl(headlessInfo[key].svgDataUrl, true),
|
|
2485
2503
|
alt: 'QR Code',
|
|
2486
2504
|
style: { width: '130px', height: '130px', borderRadius: '4px', background: '#fff', padding: '4px' }
|
|
2487
2505
|
})
|
|
@@ -2537,7 +2555,7 @@ window.__ModuleLoader__.load({
|
|
|
2537
2555
|
})() : null),
|
|
2538
2556
|
(openHelp[key] ? (function renderInlineHelp(){
|
|
2539
2557
|
const prov = row.slot.provider;
|
|
2540
|
-
const info =
|
|
2558
|
+
const info = {
|
|
2541
2559
|
title: prov,
|
|
2542
2560
|
stepsZh: ['请在上方输入框中粘贴令牌或 API Key 并点击“保存令牌”。'],
|
|
2543
2561
|
stepsEn: ['Paste your token or API key into the field above and click "Save token".'],
|
|
@@ -2575,9 +2593,7 @@ window.__ModuleLoader__.load({
|
|
|
2575
2593
|
value: row.slot.proxyUrl || '',
|
|
2576
2594
|
placeholder: t('proxyPlaceholder'),
|
|
2577
2595
|
onChange: (e) => {
|
|
2578
|
-
|
|
2579
|
-
next[row.i] = Object.assign({}, next[row.i], { proxyUrl: e.target.value })
|
|
2580
|
-
setSlots(next)
|
|
2596
|
+
updateSlot(row.slot.provider, row.slot.index, { proxyUrl: e.target.value })
|
|
2581
2597
|
},
|
|
2582
2598
|
}),
|
|
2583
2599
|
h('button', {
|
|
@@ -2598,10 +2614,26 @@ window.__ModuleLoader__.load({
|
|
|
2598
2614
|
value: row.slot.maxConcurrency !== undefined ? row.slot.maxConcurrency : 1,
|
|
2599
2615
|
title: t('maxConcurrencySlotTitle'),
|
|
2600
2616
|
onChange: (e) => {
|
|
2601
|
-
const next = slots.slice()
|
|
2602
2617
|
const val = parseInt(e.target.value, 10)
|
|
2603
|
-
|
|
2604
|
-
|
|
2618
|
+
updateSlot(row.slot.provider, row.slot.index, { maxConcurrency: isNaN(val) ? 1 : Math.max(1, val) })
|
|
2619
|
+
},
|
|
2620
|
+
})
|
|
2621
|
+
),
|
|
2622
|
+
h('span', {
|
|
2623
|
+
style: { fontSize: '11px', color: 'var(--dsw-alias-label-secondary)', display: 'inline-flex', alignItems: 'center', gap: '3px' },
|
|
2624
|
+
title: t('monthlyCostSlotTitle')
|
|
2625
|
+
},
|
|
2626
|
+
'💰',
|
|
2627
|
+
h('input', {
|
|
2628
|
+
type: 'number',
|
|
2629
|
+
min: 0,
|
|
2630
|
+
step: 0.01,
|
|
2631
|
+
style: { width: '56px', padding: '1px 4px', fontSize: '11px', textAlign: 'center' },
|
|
2632
|
+
value: row.slot.monthlyCost !== undefined ? row.slot.monthlyCost : 20,
|
|
2633
|
+
title: t('monthlyCostSlotTitle'),
|
|
2634
|
+
onChange: (e) => {
|
|
2635
|
+
const val = parseFloat(e.target.value)
|
|
2636
|
+
updateSlot(row.slot.provider, row.slot.index, { monthlyCost: isNaN(val) ? 20 : Math.max(0, val) })
|
|
2605
2637
|
},
|
|
2606
2638
|
})
|
|
2607
2639
|
),
|
|
@@ -2627,7 +2659,7 @@ window.__ModuleLoader__.load({
|
|
|
2627
2659
|
type: 'button', className: 'dsub-mini',
|
|
2628
2660
|
onClick: () => { try { navigator.clipboard.writeText(d.userCode) } catch { /* clipboard write error */ } },
|
|
2629
2661
|
}, t('deviceCopy')),
|
|
2630
|
-
h('a', { href: d.authUrl, target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('verifyLink')),
|
|
2662
|
+
h('a', { href: safeExternalUrl(d.authUrl) || undefined, target: '_blank', rel: 'noopener noreferrer', className: 'dsub-mini' }, t('verifyLink')),
|
|
2631
2663
|
),
|
|
2632
2664
|
h('div', { className: 'dsub-sub' }, t('deviceHint')),
|
|
2633
2665
|
) : null,
|
|
@@ -2721,9 +2753,9 @@ window.__ModuleLoader__.load({
|
|
|
2721
2753
|
}, '🔄 ' + t('reconnectNow'))
|
|
2722
2754
|
) : null,
|
|
2723
2755
|
(function(){ var r=checkRes[key]; if(!r) return null; var txt=r.ok ? ('ok ' + (r.email||'')) : ('fail ' + (r.error && r.error.message || '')); var cls=r.ok ? 'dsub-ok' : 'dsub-bad'; var q=r.quota; if(q && q.remaining!=null) txt += ' quota:'+q.remaining+(q.limit!=null?'/'+q.limit:''); return h('div', {className: cls}, txt) })(),
|
|
2724
|
-
account.validationUrl ? h('div', { className: 'dsub-verify' },
|
|
2756
|
+
safeExternalUrl(account.validationUrl) ? h('div', { className: 'dsub-verify' },
|
|
2725
2757
|
t('verifyPrefix'),
|
|
2726
|
-
h('a', { href: account.validationUrl, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
|
|
2758
|
+
h('a', { href: safeExternalUrl(account.validationUrl) || undefined, target: '_blank', rel: 'noopener noreferrer' }, t('verifyLink')),
|
|
2727
2759
|
t('verifySuffix'),
|
|
2728
2760
|
) : null,
|
|
2729
2761
|
(function usageLimitsBlock(){
|
|
@@ -3626,7 +3658,8 @@ window.__ModuleLoader__.load({
|
|
|
3626
3658
|
if (!prov || !providerMatch.test(prov)) return
|
|
3627
3659
|
fetch('/dsh-subscriptions/oauth/start?provider=' + encodeURIComponent(prov) + '&index=1', { cache: 'no-store' })
|
|
3628
3660
|
.then((r) => r.json()).then((data) => {
|
|
3629
|
-
|
|
3661
|
+
const target = data && safeExternalUrl(data.url)
|
|
3662
|
+
if (target) window.open(target, '_blank', 'noopener')
|
|
3630
3663
|
}).catch(() => {})
|
|
3631
3664
|
}
|
|
3632
3665
|
const line = (line) => line.trim()
|
package/lib/config-schema.js
CHANGED
|
@@ -22,6 +22,10 @@ export const Slot = z.object({
|
|
|
22
22
|
.description('#88 Per-account proxy URL (http://, https://, socks5://). All requests for this account route through it. Empty = direct connection.'),
|
|
23
23
|
maxConcurrency: z.number().default(1)
|
|
24
24
|
.description('#555 Per-slot max concurrent requests before queuing. Defaults to 1 for subscription safety.'),
|
|
25
|
+
monthlyCost: z.number().default(20)
|
|
26
|
+
.description('#590 Optional monthly subscription cost in USD for analytics. Defaults to 20.'),
|
|
27
|
+
maxQueueSize: z.number().default(0)
|
|
28
|
+
.description('#592 Per-account queue depth before overflow. 0 = use the global maxQueueSize.'),
|
|
25
29
|
})
|
|
26
30
|
|
|
27
31
|
export const defaultSlots = PROVIDERS.map((provider) => ({ provider, index: 1, label: '' }))
|
|
@@ -285,3 +289,7 @@ export function restoreMaskedSecrets(incoming, existing) {
|
|
|
285
289
|
|
|
286
290
|
return out
|
|
287
291
|
}
|
|
292
|
+
|
|
293
|
+
export function revisionOf(settingsApi) {
|
|
294
|
+
return settingsApi && typeof settingsApi.revision === 'function' ? settingsApi.revision() : undefined
|
|
295
|
+
}
|
package/lib/diagnostic-wizard.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
|
|
4
4
|
import { maskEmail, maskSecrets, cleanErrorMessage } from './mask.js'
|
|
5
5
|
import { getVendor } from './vendors/index.js'
|
|
6
|
+
import { vendorFetch } from './http.js'
|
|
7
|
+
import { proxyFetch } from './proxy.js'
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Mask sensitive credentials inside diagnostic reports.
|
|
@@ -50,6 +52,8 @@ export async function runDiagnosticWizard(options = {}) {
|
|
|
50
52
|
const includeNetwork = Boolean(options.includeNetwork)
|
|
51
53
|
const fetchImpl = options.fetchImpl || globalThis.fetch || fetch
|
|
52
54
|
const timeoutMs = options.timeoutMs || 8000
|
|
55
|
+
const vendorCallFetch = vendorFetch(fetchImpl, timeoutMs)
|
|
56
|
+
const proxyImplFactory = typeof options.proxyFetchImpl === 'function' ? options.proxyFetchImpl : proxyFetch
|
|
53
57
|
const now = Date.now()
|
|
54
58
|
|
|
55
59
|
let raw = options.rawBlob || null
|
|
@@ -140,7 +144,7 @@ export async function runDiagnosticWizard(options = {}) {
|
|
|
140
144
|
// -------------------------------------------------------------
|
|
141
145
|
// STEP 2: Network & Proxy Verification
|
|
142
146
|
// -------------------------------------------------------------
|
|
143
|
-
const proxyUrl = account.proxyUrl ||
|
|
147
|
+
const proxyUrl = account.proxyUrl || null
|
|
144
148
|
let proxyStatus = 'pass'
|
|
145
149
|
let proxyCode = 'DIRECT_CONNECTION'
|
|
146
150
|
let proxyMsg = 'Direct network connection configured'
|
|
@@ -171,16 +175,11 @@ export async function runDiagnosticWizard(options = {}) {
|
|
|
171
175
|
try {
|
|
172
176
|
const controller = new AbortController()
|
|
173
177
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
174
|
-
|
|
175
|
-
|
|
178
|
+
const impl = proxyImplFactory(proxyUrl)
|
|
179
|
+
if (!impl) throw new Error('invalid proxy URL')
|
|
180
|
+
await impl('https://api.openai.com/v1/models', {
|
|
176
181
|
method: 'HEAD',
|
|
177
182
|
signal: controller.signal,
|
|
178
|
-
}).catch((e) => {
|
|
179
|
-
// If proxy accepts HTTP or rejects HEAD with 405/404, TCP connection succeeded
|
|
180
|
-
if (e && e.name !== 'AbortError' && e.code !== 'ECONNREFUSED' && e.code !== 'ENOTFOUND') {
|
|
181
|
-
return { ok: true }
|
|
182
|
-
}
|
|
183
|
-
throw e
|
|
184
183
|
})
|
|
185
184
|
clearTimeout(timer)
|
|
186
185
|
proxyLatencyMs = Date.now() - t0
|
|
@@ -238,18 +237,18 @@ export async function runDiagnosticWizard(options = {}) {
|
|
|
238
237
|
// Attempt safe singleflight token refresh if vendor supports it
|
|
239
238
|
let vendor = null
|
|
240
239
|
try { vendor = getVendor(provider) } catch { /* unknown provider */ }
|
|
241
|
-
if (vendor && typeof vendor.refresh === 'function' && blob.refresh_token) {
|
|
240
|
+
if (vendor && typeof vendor.refresh === 'function' && (blob.refreshToken || blob.refresh_token)) {
|
|
242
241
|
try {
|
|
243
242
|
const controller = new AbortController()
|
|
244
243
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
245
|
-
const refreshed = await vendor.refresh(
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
244
|
+
const refreshed = await vendor.refresh(
|
|
245
|
+
options.live ? options.live() : {},
|
|
246
|
+
blob,
|
|
247
|
+
(url, init) => vendorCallFetch(url, { ...(init || {}), signal: controller.signal }),
|
|
248
|
+
)
|
|
250
249
|
clearTimeout(timer)
|
|
251
250
|
tokenLatencyMs = Date.now() - t0
|
|
252
|
-
if (refreshed && (refreshed.access_token || refreshed.token)) {
|
|
251
|
+
if (refreshed && (refreshed.accessToken || refreshed.access_token || refreshed.token)) {
|
|
253
252
|
tokenStatus = 'pass'
|
|
254
253
|
tokenCode = 'TOKEN_REFRESH_OK'
|
|
255
254
|
tokenMsg = `Token refreshed successfully (${tokenLatencyMs}ms)`
|
|
@@ -313,11 +312,11 @@ export async function runDiagnosticWizard(options = {}) {
|
|
|
313
312
|
try {
|
|
314
313
|
const controller = new AbortController()
|
|
315
314
|
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
316
|
-
const models = await vendor.listModels(
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
315
|
+
const models = await vendor.listModels(
|
|
316
|
+
blob,
|
|
317
|
+
options.live ? options.live() : {},
|
|
318
|
+
(url, init) => vendorCallFetch(url, { ...(init || {}), signal: controller.signal }),
|
|
319
|
+
)
|
|
321
320
|
clearTimeout(timer)
|
|
322
321
|
catalogLatencyMs = Date.now() - t0
|
|
323
322
|
modelCount = Array.isArray(models) ? models.length : 0
|
package/lib/headless-auth.js
CHANGED
|
@@ -326,6 +326,15 @@ export function getHeadlessAuthInfo({ provider = '', redirectUri = '', authUrl =
|
|
|
326
326
|
}
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
export function safePostMessageOrigin(originHeader) {
|
|
330
|
+
try {
|
|
331
|
+
const u = new URL(String(originHeader || ''))
|
|
332
|
+
if (u.protocol === 'http:' || u.protocol === 'https:') return u.origin
|
|
333
|
+
if (u.protocol === 'dsh-app:') return 'dsh-app://' + u.host
|
|
334
|
+
} catch { /* invalid origin */ }
|
|
335
|
+
return ''
|
|
336
|
+
}
|
|
337
|
+
|
|
329
338
|
export async function handleHeadlessRedirect(req, res, { pending, completeOAuth, accountsView }) {
|
|
330
339
|
const rawUrl = new URL(req.url || '/', 'http://127.0.0.1')
|
|
331
340
|
const code = rawUrl.searchParams.get('code') || ''
|
|
@@ -353,11 +362,15 @@ export async function handleHeadlessRedirect(req, res, { pending, completeOAuth,
|
|
|
353
362
|
try {
|
|
354
363
|
await completeOAuth({ provider: row.provider, index: row.index, code, state })
|
|
355
364
|
if (typeof accountsView === 'function') await accountsView()
|
|
365
|
+
const targetOrigin = safePostMessageOrigin(req && req.headers && req.headers.origin)
|
|
366
|
+
const relay = targetOrigin
|
|
367
|
+
? `<script>if (window.opener) { window.opener.postMessage({ type: 'dsh-oauth-success', state: ${JSON.stringify(String(state))} }, ${JSON.stringify(targetOrigin)}); }</script>`
|
|
368
|
+
: ''
|
|
356
369
|
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
357
370
|
res.end(`<!doctype html><meta charset="utf-8"><title>Subscriptions</title><body style="font-family:sans-serif;padding:2rem;">` +
|
|
358
371
|
`<h2 style="color:#2e7d32;">Signed In Successfully</h2>` +
|
|
359
372
|
`<p>Your account has been authorized. You can close this window and return to Settings.</p>` +
|
|
360
|
-
|
|
373
|
+
relay +
|
|
361
374
|
`</body>`)
|
|
362
375
|
return
|
|
363
376
|
} catch (e) {
|
package/lib/http.js
CHANGED
|
@@ -79,7 +79,34 @@ export function isLoopback(value) {
|
|
|
79
79
|
|
|
80
80
|
|
|
81
81
|
|
|
82
|
+
function hostNameOf(hostHeader) {
|
|
83
|
+
const raw = String(Array.isArray(hostHeader) ? (hostHeader[0] || '') : (hostHeader || '')).trim().toLowerCase()
|
|
84
|
+
if (!raw) return ''
|
|
85
|
+
if (raw === '::1') return '::1'
|
|
86
|
+
if (raw.startsWith('[')) {
|
|
87
|
+
const end = raw.indexOf(']')
|
|
88
|
+
return end > 0 ? raw.slice(0, end + 1) : raw
|
|
89
|
+
}
|
|
90
|
+
return raw.split(':')[0]
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function configuredTrustedHosts(targetCtx) {
|
|
94
|
+
const out = new Set()
|
|
95
|
+
for (const part of String(process.env.DSH_TRUSTED_HOSTS || '').split(',')) {
|
|
96
|
+
const host = hostNameOf(part)
|
|
97
|
+
if (host) out.add(host)
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const url = targetCtx && targetCtx.webServer && targetCtx.webServer.url
|
|
101
|
+
if (url) out.add(hostNameOf(new URL(url).host))
|
|
102
|
+
} catch { /* ignore malformed web server url */ }
|
|
103
|
+
return out
|
|
104
|
+
}
|
|
105
|
+
|
|
82
106
|
/**
|
|
107
|
+
* @param {import('node:http').IncomingMessage} request
|
|
108
|
+
* @param {any} [targetCtx]
|
|
109
|
+
* @returns {boolean}/**
|
|
83
110
|
* @param {import('node:http').IncomingMessage} request
|
|
84
111
|
* @param {any} [targetCtx]
|
|
85
112
|
* @returns {boolean}
|
|
@@ -148,6 +175,20 @@ export function isTrustedSettingsRequest(request, targetCtx) {
|
|
|
148
175
|
}
|
|
149
176
|
}
|
|
150
177
|
|
|
178
|
+
// 4a. Loopback peers must present a loopback or configured Host.
|
|
179
|
+
// A loopback socket with a foreign Host is a DNS-rebinding attempt.
|
|
180
|
+
const peerAddress = request?.socket?.remoteAddress || request?.connection?.remoteAddress
|
|
181
|
+
if (peerAddress && isLoopback(peerAddress)) {
|
|
182
|
+
const host = hostNameOf(headers.host)
|
|
183
|
+
const loopbackHost = host === ''
|
|
184
|
+
|| host === 'localhost'
|
|
185
|
+
|| host === 'localhost.'
|
|
186
|
+
|| host === '127.0.0.1'
|
|
187
|
+
|| host === '::1'
|
|
188
|
+
|| host === '[::1]'
|
|
189
|
+
if (!loopbackHost && !configuredTrustedHosts(targetCtx).has(host)) return false
|
|
190
|
+
}
|
|
191
|
+
|
|
151
192
|
// 4. Delegate to DSH connection service if available (#374)
|
|
152
193
|
let conn = null
|
|
153
194
|
try {
|
|
@@ -400,3 +441,7 @@ export function escapeHtml(text) {
|
|
|
400
441
|
.replace(/</g, '<')
|
|
401
442
|
.replace(/>/g, '>')
|
|
402
443
|
}
|
|
444
|
+
|
|
445
|
+
export function vendorFetch(fetchImpl, timeoutMs = 15000) {
|
|
446
|
+
return (url, init, options) => fetchWithTimeout(fetchImpl || fetch, url, init, { timeoutMs, ...(options || {}) })
|
|
447
|
+
}
|
package/lib/import-auth.js
CHANGED
|
@@ -6,7 +6,7 @@ import { readClaudeCredentials } from './vendors/claude-cli.js'
|
|
|
6
6
|
import { readCopilotCredentials } from './vendors/copilot-cli.js'
|
|
7
7
|
import { normalizeExpiresAt } from './blob.js'
|
|
8
8
|
import { isGoogleClientId, extractGoogleClientIdFromJwt, emailFromToken } from './jwt.js'
|
|
9
|
-
|
|
9
|
+
|
|
10
10
|
|
|
11
11
|
// Test seam: a fixture root overrides the real home for the duration of a
|
|
12
12
|
// public call. Undefined (the default) keeps production behaviour.
|
|
@@ -146,7 +146,8 @@ async function discoverLocalCliSessionsInner() {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
|
|
149
|
+
const canReadWindowsCredentialManager = Boolean(activeWindowsCredentialReader) || (!activeHome && process.platform === 'win32')
|
|
150
|
+
if (!detected.antigravity && canReadWindowsCredentialManager) {
|
|
150
151
|
const winCred = readWindowsAntigravityCredential()
|
|
151
152
|
const tok = winCred && winCred.token ? winCred.token : winCred
|
|
152
153
|
if (tok && (tok.access_token || tok.accessToken)) {
|
|
@@ -453,10 +454,6 @@ export async function loadLocalCliBlob(provider, options = {}) {
|
|
|
453
454
|
raw.installed?.client_secret || raw.web?.client_secret || ''
|
|
454
455
|
let clientSecret = typeof directSecret === 'string' ? directSecret.trim() : ''
|
|
455
456
|
|
|
456
|
-
if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
|
|
457
|
-
clientSecret = DEFAULT_CLI_CLIENT_SECRET
|
|
458
|
-
}
|
|
459
|
-
|
|
460
457
|
const rawExpiry = tok.expiry ?? tok.expires_at ?? tok.expiresAt ?? tok.expiry_date ?? raw.expiry_date ?? raw.expiry ?? (tok.expires_in ? Date.now() + Number(tok.expires_in) * 1000 : 0)
|
|
461
458
|
const expiresAt = normalizeExpiresAt(rawExpiry)
|
|
462
459
|
|
package/lib/index.js
CHANGED
|
@@ -135,6 +135,7 @@ export function apply(ctx, rawConfig) {
|
|
|
135
135
|
|
|
136
136
|
const settingsApi = {
|
|
137
137
|
get: () => live(),
|
|
138
|
+
revision: () => describeRow()?.revision,
|
|
138
139
|
replace: async (next, expectedRevision) => {
|
|
139
140
|
const parsed = plainConfig(next)
|
|
140
141
|
const prevConfig = cachedConfig
|
package/lib/routes/accounts.js
CHANGED
|
@@ -4,10 +4,11 @@ import { syncVaultPush, syncVaultPull, getVaultSyncStatus } from '../vault-sync.
|
|
|
4
4
|
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
5
5
|
import { getVendor } from '../vendors/index.js'
|
|
6
6
|
import { notifyQuarantineEntered, notifyQuarantineReleased } from '../alerts.js'
|
|
7
|
-
import { Config, plainConfig, publicConfig } from '../config-schema.js'
|
|
7
|
+
import { Config, plainConfig, publicConfig, revisionOf } from '../config-schema.js'
|
|
8
8
|
import { decryptWithPassphrase, encryptWithPassphrase } from '../crypto.js'
|
|
9
9
|
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson } from '../http.js'
|
|
10
10
|
import { discoverLocalCliSessions, loadLocalCliBlob } from '../import-auth.js'
|
|
11
|
+
import { validateCredentialBlob } from '../blob.js'
|
|
11
12
|
import { isProvider, oauthRef, parseOauthRef } from '../refs.js'
|
|
12
13
|
|
|
13
14
|
export function registerAccountsRoutes(ctx, state) {
|
|
@@ -276,18 +277,20 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
276
277
|
const curSlots = Array.isArray(live().slots) ? live().slots.slice() : []
|
|
277
278
|
const exists = curSlots.some((s) => s && s.provider === prov && Number(s.index) === idx)
|
|
278
279
|
if (!exists && getSettingsApi()) {
|
|
280
|
+
const settings = getSettingsApi()
|
|
281
|
+
const revision = revisionOf(settings)
|
|
279
282
|
const nextSlots = curSlots.concat([{
|
|
280
283
|
provider: prov,
|
|
281
284
|
index: idx,
|
|
282
285
|
label: blob.email || '',
|
|
283
286
|
}])
|
|
284
287
|
const parsed = plainConfig(Config({ ...live(), slots: stripLegacySlots(nextSlots) }))
|
|
285
|
-
await
|
|
288
|
+
await settings.replace(parsed, revision)
|
|
286
289
|
syncCustomVendors()
|
|
287
290
|
}
|
|
288
291
|
const slot = normalizeSlots(live().slots).find((s) => s.provider === prov && s.index === idx)
|
|
289
292
|
const ref = slot ? slot.ref : oauthRef(prov, idx)
|
|
290
|
-
await store.saveBlob(ref, blob)
|
|
293
|
+
await store.saveBlob(ref, validateCredentialBlob(blob))
|
|
291
294
|
await syncAdapter()
|
|
292
295
|
refreshModels().catch(() => {})
|
|
293
296
|
writeJson(res, 200, {
|
|
@@ -389,7 +392,7 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
389
392
|
for (const row of bundle.accounts) {
|
|
390
393
|
try {
|
|
391
394
|
if (!row || typeof row.ref !== 'string' || !parseOauthRef(row.ref) || !row.blob) continue
|
|
392
|
-
await store.saveBlob(row.ref, row.blob)
|
|
395
|
+
await store.saveBlob(row.ref, validateCredentialBlob(row.blob))
|
|
393
396
|
imported++
|
|
394
397
|
} catch { /* skip broken */ }
|
|
395
398
|
}
|
|
@@ -437,7 +440,7 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
437
440
|
const ref = oauthRef(provider, index)
|
|
438
441
|
const blob = { accessToken: apiKey || refreshToken, refreshToken: refreshToken || apiKey }
|
|
439
442
|
if (apiKey) { blob.apiKey = apiKey; blob.apiKeyOnly = true }
|
|
440
|
-
await store.saveBlob(ref, blob)
|
|
443
|
+
await store.saveBlob(ref, validateCredentialBlob(blob))
|
|
441
444
|
await syncAdapter()
|
|
442
445
|
refreshModels().catch(() => {})
|
|
443
446
|
writeJson(res, 200, { ok: true, ref, accounts: await accountsView() })
|
|
@@ -674,6 +677,7 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
674
677
|
try {
|
|
675
678
|
const result = importVault(vault, passphrase)
|
|
676
679
|
const settings = getSettingsApi()
|
|
680
|
+
const revision = revisionOf(settings)
|
|
677
681
|
let parsed
|
|
678
682
|
if (result.slots.length) {
|
|
679
683
|
if (!settings) throw new Error('settings are unavailable for vault import')
|
|
@@ -690,11 +694,11 @@ export function registerAccountsRoutes(ctx, state) {
|
|
|
690
694
|
}
|
|
691
695
|
let blobCount = 0
|
|
692
696
|
for (const [ref, blob] of Object.entries(result.blobs)) {
|
|
693
|
-
await store.saveBlob(ref, blob)
|
|
697
|
+
await store.saveBlob(ref, validateCredentialBlob(blob))
|
|
694
698
|
blobCount++
|
|
695
699
|
}
|
|
696
700
|
if (parsed) {
|
|
697
|
-
await settings.replace(parsed)
|
|
701
|
+
await settings.replace(parsed, revision)
|
|
698
702
|
syncCustomVendors()
|
|
699
703
|
}
|
|
700
704
|
await syncAdapter()
|
package/lib/routes/oauth.js
CHANGED
|
@@ -301,6 +301,13 @@ export function registerOauthRoutes(ctx, state) {
|
|
|
301
301
|
ctx.effect(() => ctx.webServer.register({
|
|
302
302
|
kind: 'exact',
|
|
303
303
|
path: '/dsh-subscriptions/oauth/headless-redirect',
|
|
304
|
-
handler: (req, res) =>
|
|
304
|
+
handler: (req, res) => {
|
|
305
|
+
if (!isTrustedSettingsRequest(req, ctx)) {
|
|
306
|
+
res.writeHead(403, { 'Content-Type': 'text/html; charset=utf-8' })
|
|
307
|
+
res.end('<!doctype html><meta charset="utf-8"><p>Forbidden: same-origin or loopback requests only.</p>')
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
return handleHeadlessRedirect(req, res, { pending, completeOAuth, accountsView })
|
|
311
|
+
},
|
|
305
312
|
}), 'dsh-subscriptions: /oauth/headless-redirect')
|
|
306
313
|
}
|
package/lib/routes/status.js
CHANGED
|
@@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs'
|
|
|
17
17
|
import { getRecentAlerts, clearAlerts } from '../alerts.js'
|
|
18
18
|
import { normalizeSlots, vendorConfig } from '../accounts.js'
|
|
19
19
|
import { Config, plainConfig, publicConfig, restoreMaskedSecrets } from '../config-schema.js'
|
|
20
|
-
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson,
|
|
20
|
+
import { isTrustedSettingsRequest, queryOf, readBody, safeJsonHandler, writeJson, isTimeoutError, vendorFetch } from '../http.js'
|
|
21
21
|
import { quotaSnapshot } from '../ratelimit.js'
|
|
22
22
|
import { PROVIDERS, displayName, droppedCredentialRefs, isProvider, oauthRef } from '../refs.js'
|
|
23
23
|
import { getVendor } from '../vendors/index.js'
|
|
@@ -384,8 +384,8 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
384
384
|
const vendor = getVendor(provider)
|
|
385
385
|
let capturedQuota = null
|
|
386
386
|
const probeFetch = async (url, init = {}) => {
|
|
387
|
-
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
388
|
-
const res2 = await
|
|
387
|
+
const doFetch = vendorFetch((typeof fetchForRef === "function" && fetchForRef(ref)) || fetch, 15_000)
|
|
388
|
+
const res2 = await vendorFetch(doFetch, 15_000)(url, init)
|
|
389
389
|
try {
|
|
390
390
|
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
391
391
|
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
|
@@ -522,7 +522,7 @@ export function registerStatusRoutes(ctx, state) {
|
|
|
522
522
|
let capturedQuota = null
|
|
523
523
|
const probeFetch = async (url, init = {}) => {
|
|
524
524
|
const doFetch = (typeof fetchForRef === "function" && fetchForRef(ref)) || fetch
|
|
525
|
-
const res2 = await
|
|
525
|
+
const res2 = await vendorFetch(doFetch, 15_000)(url, init)
|
|
526
526
|
try {
|
|
527
527
|
const snap = quotaSnapshot(provider, res2.headers, null, Date.now())
|
|
528
528
|
if (snap) { capturedQuota = snap; store.rememberQuota(ref, snap) }
|
package/lib/subscriptions.js
CHANGED
|
@@ -5,16 +5,39 @@ import { pickFetch } from "./proxy.js"
|
|
|
5
5
|
import { fetchWithTimeout } from "./http.js"
|
|
6
6
|
|
|
7
7
|
// ponytail: allowlist per provider — add path to extend without touching request logic
|
|
8
|
+
const OPENAI_COMPAT = [
|
|
9
|
+
"/models",
|
|
10
|
+
"/v1/models",
|
|
11
|
+
"/chat/completions",
|
|
12
|
+
"/v1/chat/completions",
|
|
13
|
+
"/responses",
|
|
14
|
+
"/v1/responses",
|
|
15
|
+
"/images/generations",
|
|
16
|
+
"/v1/images/generations",
|
|
17
|
+
]
|
|
18
|
+
|
|
8
19
|
export const ALLOWLIST = {
|
|
9
20
|
codex: ["/responses", "/models", "/images/generations", "/backend-api/codex/responses", "/backend-api/codex/models", "/backend-api/codex/images/generations"],
|
|
10
21
|
claude: ["/v1/messages", "/api/oauth/profile", "/api/oauth/usage", "/v1/models"],
|
|
11
22
|
grok: ["/v1/models", "/responses", "/v1/responses", "/v1/billing", "/v1/chat/completions", "/images/generations", "/v1/images/generations"],
|
|
12
23
|
antigravity: ["/v1/models", "/v1/generateContent", "/v1/streamGenerateContent", "/v1/loadCodeAssist", "/v1/fetchAvailableModels", "/v1/countTokens", "/v1/internal:loadCodeAssist"],
|
|
24
|
+
kimi: OPENAI_COMPAT,
|
|
25
|
+
glm: [...OPENAI_COMPAT, "/api/paas/v4/chat/completions", "/api/paas/v4/models"],
|
|
26
|
+
cursor: OPENAI_COMPAT,
|
|
27
|
+
kiro: OPENAI_COMPAT,
|
|
28
|
+
copilot: OPENAI_COMPAT,
|
|
29
|
+
qwen: [...OPENAI_COMPAT, "/compatible-mode/v1/chat/completions", "/compatible-mode/v1/models"],
|
|
30
|
+
ernie: [...OPENAI_COMPAT, "/rpc/2.0/ai_custom/v1/wenxinworkshop/chat", "/oauth/2.0/token"],
|
|
31
|
+
spark: OPENAI_COMPAT,
|
|
32
|
+
jetbrains: OPENAI_COMPAT,
|
|
33
|
+
perplexity: [...OPENAI_COMPAT, "/api/v1/user/profile", "/api/v1/user/usage"],
|
|
34
|
+
replit: [...OPENAI_COMPAT, "/api/v1/ai"],
|
|
35
|
+
cody: OPENAI_COMPAT,
|
|
13
36
|
}
|
|
14
37
|
|
|
15
38
|
export function isAllowed(provider, path) {
|
|
16
39
|
if (!isProvider(provider)) return false
|
|
17
|
-
const list = ALLOWLIST[provider] ||
|
|
40
|
+
const list = ALLOWLIST[provider] || OPENAI_COMPAT
|
|
18
41
|
let p = String(path || "")
|
|
19
42
|
// strip query and hash
|
|
20
43
|
p = p.split("?")[0].split("#")[0]
|
package/lib/vault-sync.js
CHANGED
|
@@ -4,7 +4,8 @@
|
|
|
4
4
|
import { createHmac, createHash } from 'node:crypto'
|
|
5
5
|
import { exportVault, importVault } from './vault.js'
|
|
6
6
|
import { normalizeSlots } from './accounts.js'
|
|
7
|
-
import { Config, plainConfig } from './config-schema.js'
|
|
7
|
+
import { Config, plainConfig, revisionOf } from './config-schema.js'
|
|
8
|
+
import { validateCredentialBlob } from './blob.js'
|
|
8
9
|
|
|
9
10
|
let syncStatus = {
|
|
10
11
|
enabled: false,
|
|
@@ -368,6 +369,7 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
|
|
|
368
369
|
|
|
369
370
|
// Merge slots into live configuration
|
|
370
371
|
if (result.slots.length && settingsApi) {
|
|
372
|
+
const revision = revisionOf(settingsApi)
|
|
371
373
|
const curSlots = Array.isArray(cfg.slots) ? cfg.slots.slice() : []
|
|
372
374
|
for (const s of result.slots) {
|
|
373
375
|
const idx = curSlots.findIndex((c) => c.provider === s.provider && Number(c.index) === Number(s.index))
|
|
@@ -378,13 +380,13 @@ export async function syncVaultPull({ live, store, settingsApi, syncAdapter, ref
|
|
|
378
380
|
}
|
|
379
381
|
}
|
|
380
382
|
const parsed = plainConfig(Config({ ...cfg, slots: curSlots }))
|
|
381
|
-
await settingsApi.replace(parsed)
|
|
383
|
+
await settingsApi.replace(parsed, revision)
|
|
382
384
|
}
|
|
383
385
|
|
|
384
386
|
// Save blobs into store
|
|
385
387
|
let blobCount = 0
|
|
386
388
|
for (const [ref, blob] of Object.entries(result.blobs)) {
|
|
387
|
-
await store.saveBlob(ref, blob)
|
|
389
|
+
await store.saveBlob(ref, validateCredentialBlob(blob))
|
|
388
390
|
blobCount++
|
|
389
391
|
}
|
|
390
392
|
|
package/lib/vault.js
CHANGED
|
@@ -1,11 +1,32 @@
|
|
|
1
1
|
// #353 & #360: Encrypted Vault Export/Import for Subscriptions
|
|
2
2
|
// Secure migration and backup of slots and credentials using AES-256-GCM.
|
|
3
3
|
|
|
4
|
+
import { readFileSync } from 'node:fs'
|
|
4
5
|
import { encryptWithPassphrase, decryptWithPassphrase } from './crypto.js'
|
|
5
6
|
import { isProvider, oauthRef } from './refs.js'
|
|
6
7
|
|
|
7
8
|
export const VAULT_FORMAT = 'dsh-subscriptions-vault-v1'
|
|
8
9
|
|
|
10
|
+
let cachedVersion = ''
|
|
11
|
+
function pluginVersion() {
|
|
12
|
+
if (!cachedVersion) {
|
|
13
|
+
try {
|
|
14
|
+
cachedVersion = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version || '0.0.0'
|
|
15
|
+
} catch {
|
|
16
|
+
cachedVersion = '0.0.0'
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
return cachedVersion
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function assertSupportedVaultVersion(version) {
|
|
23
|
+
const text = String(version == null ? '' : version).trim()
|
|
24
|
+
if (!text) return
|
|
25
|
+
const match = /^(\d+)\.(\d+)\.(\d+)/.exec(text)
|
|
26
|
+
if (!match) throw new Error('invalid vault version')
|
|
27
|
+
if (Number(match[1]) > 0) throw new Error('vault version ' + text + ' is newer than this plugin supports')
|
|
28
|
+
}
|
|
29
|
+
|
|
9
30
|
/**
|
|
10
31
|
* Encrypts slots configuration and credential blobs into a portable vault string.
|
|
11
32
|
*/
|
|
@@ -17,7 +38,7 @@ export function exportVault({ slots = [], blobs = {}, exportedBy = 'dsh-subscrip
|
|
|
17
38
|
|
|
18
39
|
const payload = {
|
|
19
40
|
format: VAULT_FORMAT,
|
|
20
|
-
version:
|
|
41
|
+
version: pluginVersion(),
|
|
21
42
|
exportedAt: new Date().toISOString(),
|
|
22
43
|
exportedBy,
|
|
23
44
|
slots: Array.isArray(slots) ? slots : [],
|
|
@@ -66,6 +87,8 @@ export function importVault(encryptedPayload, passphrase) {
|
|
|
66
87
|
throw new Error('invalid vault schema: unsupported structure')
|
|
67
88
|
}
|
|
68
89
|
|
|
90
|
+
assertSupportedVaultVersion(data.version)
|
|
91
|
+
|
|
69
92
|
const refs = new Set()
|
|
70
93
|
for (const slot of data.slots) {
|
|
71
94
|
if (!slot || !isProvider(slot.provider) || !Number.isSafeInteger(slot.index) || slot.index < 1) {
|
|
@@ -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'
|
|
@@ -143,7 +143,7 @@ export async function refresh(cfg, blob, fetchImpl) {
|
|
|
143
143
|
let clientSecret = (cfg?.clientSecret && String(cfg.clientSecret).trim()) || (blob?.clientSecret && String(blob.clientSecret).trim()) || ''
|
|
144
144
|
|
|
145
145
|
if (!clientSecret && clientId === DEFAULT_CLI_CLIENT_ID) {
|
|
146
|
-
|
|
146
|
+
throw new Error('Google OAuth refresh requires a client secret for the default Gemini CLI client. Configure antigravityClientSecret in plugin settings.')
|
|
147
147
|
}
|
|
148
148
|
|
|
149
149
|
if (!clientId) {
|
|
@@ -8,6 +8,19 @@ import { join, delimiter } from 'node:path'
|
|
|
8
8
|
import { spawn } from 'node:child_process'
|
|
9
9
|
import { LlmError } from '@deepseek-ai/dsh-llm'
|
|
10
10
|
|
|
11
|
+
const MAX_STDERR_CHARS = 2048
|
|
12
|
+
const GITHUB_TOKEN_PATTERN = /\b(?:gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b/g
|
|
13
|
+
|
|
14
|
+
const COPILOT_CLI_BASENAME = /^(?:copilot|github-copilot-cli|copilot-cli)(?:\.(?:cmd|bat|exe))?$/i
|
|
15
|
+
|
|
16
|
+
export function isAllowedCopilotBinary(binaryPath) {
|
|
17
|
+
return COPILOT_CLI_BASENAME.test(String(binaryPath || '').split(/[\\/]/).pop())
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function redactCopilotStderr(text) {
|
|
21
|
+
return String(text || '').replace(GITHUB_TOKEN_PATTERN, '[REDACTED]')
|
|
22
|
+
}
|
|
23
|
+
|
|
11
24
|
/**
|
|
12
25
|
* Standard candidate binary names for GitHub Copilot CLI.
|
|
13
26
|
*/
|
|
@@ -127,9 +140,14 @@ export async function readCopilotCredentials(home, env = process.env) {
|
|
|
127
140
|
export function findCopilotCli(customPath, env = process.env, platform = process.platform) {
|
|
128
141
|
if (customPath && typeof customPath === 'string' && customPath.trim()) {
|
|
129
142
|
const p = customPath.trim()
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
143
|
+
let exists = false
|
|
144
|
+
try { exists = existsSync(p) } catch { /* best-effort */ }
|
|
145
|
+
if (exists) {
|
|
146
|
+
if (!isAllowedCopilotBinary(p)) {
|
|
147
|
+
throw new Error('copilotCliPath must point to a copilot binary (copilot, github-copilot-cli or copilot-cli)')
|
|
148
|
+
}
|
|
149
|
+
return p
|
|
150
|
+
}
|
|
133
151
|
}
|
|
134
152
|
|
|
135
153
|
const pathVar = env.PATH || ''
|
|
@@ -144,8 +162,11 @@ export function findCopilotCli(customPath, env = process.env, platform = process
|
|
|
144
162
|
)
|
|
145
163
|
}
|
|
146
164
|
|
|
165
|
+
const candidates = platform === 'win32'
|
|
166
|
+
? COPILOT_CLI_BINARIES.filter((bin) => /\.(cmd|exe|bat)$/i.test(bin))
|
|
167
|
+
: COPILOT_CLI_BINARIES.filter((bin) => !/\.(cmd|exe|bat)$/i.test(bin))
|
|
147
168
|
for (const dir of pathDirs) {
|
|
148
|
-
for (const bin of
|
|
169
|
+
for (const bin of candidates) {
|
|
149
170
|
const fullPath = join(dir, bin)
|
|
150
171
|
try {
|
|
151
172
|
if (existsSync(fullPath)) {
|
|
@@ -232,6 +253,16 @@ export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'c
|
|
|
232
253
|
return { args, env, inputPayload }
|
|
233
254
|
}
|
|
234
255
|
|
|
256
|
+
export function resolveSpawnCommand(binaryPath, args, platform = process.platform) {
|
|
257
|
+
if (platform === 'win32' && /\.(cmd|bat)$/i.test(String(binaryPath))) {
|
|
258
|
+
return {
|
|
259
|
+
command: process.env.ComSpec || 'cmd.exe',
|
|
260
|
+
args: ['/d', '/s', '/c', binaryPath, ...args],
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return { command: binaryPath, args }
|
|
264
|
+
}
|
|
265
|
+
|
|
235
266
|
/**
|
|
236
267
|
* Stream execution from local Copilot CLI process.
|
|
237
268
|
* Yields canonical DSH events: block-start, text-delta, tool-call-delta, finish, usage.
|
|
@@ -245,16 +276,20 @@ export function prepareCopilotCliInvocation(options, blob = {}, _binaryPath = 'c
|
|
|
245
276
|
* @returns {AsyncGenerator<any, void, unknown>}
|
|
246
277
|
*/
|
|
247
278
|
export async function* streamCopilotCli({ binaryPath, options, blob, config: _config, signal, spawnImpl }) {
|
|
279
|
+
if (!isAllowedCopilotBinary(binaryPath)) {
|
|
280
|
+
throw new Error('copilotCliPath must point to a copilot binary (copilot, github-copilot-cli or copilot-cli)')
|
|
281
|
+
}
|
|
248
282
|
if (signal && signal.aborted) {
|
|
249
283
|
throw new LlmError('Request aborted by client', 'ABORTED')
|
|
250
284
|
}
|
|
251
285
|
|
|
252
286
|
const { args, env, inputPayload } = prepareCopilotCliInvocation(options, blob, binaryPath)
|
|
253
287
|
const spawner = spawnImpl || spawn
|
|
288
|
+
const invocation = resolveSpawnCommand(binaryPath, args)
|
|
254
289
|
|
|
255
290
|
let child
|
|
256
291
|
try {
|
|
257
|
-
child = spawner(
|
|
292
|
+
child = spawner(invocation.command, invocation.args, {
|
|
258
293
|
env,
|
|
259
294
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
260
295
|
})
|
|
@@ -285,7 +320,10 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
|
|
|
285
320
|
let stderrBuffer = ''
|
|
286
321
|
|
|
287
322
|
if (child.stderr) {
|
|
288
|
-
child.stderr.on('data', (d) => {
|
|
323
|
+
child.stderr.on('data', (d) => {
|
|
324
|
+
if (stderrBuffer.length >= MAX_STDERR_CHARS) return
|
|
325
|
+
stderrBuffer += String(d).slice(0, MAX_STDERR_CHARS - stderrBuffer.length)
|
|
326
|
+
})
|
|
289
327
|
}
|
|
290
328
|
|
|
291
329
|
const chunkQueue = []
|
|
@@ -326,7 +364,7 @@ export async function* streamCopilotCli({ binaryPath, options, blob, config: _co
|
|
|
326
364
|
stdoutBuffer = ''
|
|
327
365
|
}
|
|
328
366
|
if (code !== 0 && !chunkQueue.length) {
|
|
329
|
-
processError = new LlmError(`Copilot CLI exited with code ${code}: ${stderrBuffer.trim() || 'unknown error'}`, 'VENDOR')
|
|
367
|
+
processError = new LlmError(`Copilot CLI exited with code ${code}: ${redactCopilotStderr(stderrBuffer.trim()) || 'unknown error'}`, 'VENDOR')
|
|
330
368
|
}
|
|
331
369
|
if (resolveNext) {
|
|
332
370
|
const r = resolveNext
|
package/lib/vendors/jetbrains.js
CHANGED
|
@@ -83,12 +83,15 @@ export function defaults() {
|
|
|
83
83
|
export function authorizeUrl(cfg = {}, pkce = {}) {
|
|
84
84
|
const c = cfg || {}
|
|
85
85
|
const p = pkce || {}
|
|
86
|
+
if (!p.challenge || !p.state) {
|
|
87
|
+
throw new Error('JetBrains OAuth requires PKCE challenge and state')
|
|
88
|
+
}
|
|
86
89
|
return buildAuthorizeUrl({
|
|
87
90
|
authUrl: AUTH,
|
|
88
91
|
clientId: c.clientId || defaults().clientId,
|
|
89
92
|
redirectUri: c.redirectUri || defaults().redirectUri,
|
|
90
|
-
challenge: p.challenge
|
|
91
|
-
state: p.state
|
|
93
|
+
challenge: p.challenge,
|
|
94
|
+
state: p.state,
|
|
92
95
|
scope: SCOPE,
|
|
93
96
|
})
|
|
94
97
|
}
|
|
@@ -77,12 +77,15 @@ export function defaults() {
|
|
|
77
77
|
export function authorizeUrl(cfg = {}, pkce = {}) {
|
|
78
78
|
const c = cfg || {}
|
|
79
79
|
const p = pkce || {}
|
|
80
|
+
if (!p.challenge || !p.state) {
|
|
81
|
+
throw new Error('Perplexity OAuth requires PKCE challenge and state')
|
|
82
|
+
}
|
|
80
83
|
return buildAuthorizeUrl({
|
|
81
84
|
authUrl: AUTH,
|
|
82
85
|
clientId: c.clientId || defaults().clientId,
|
|
83
86
|
redirectUri: c.redirectUri || defaults().redirectUri,
|
|
84
|
-
challenge: p.challenge
|
|
85
|
-
state: p.state
|
|
87
|
+
challenge: p.challenge,
|
|
88
|
+
state: p.state,
|
|
86
89
|
scope: SCOPE,
|
|
87
90
|
})
|
|
88
91
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-subscriptions",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.84",
|
|
4
4
|
"description": "Use ChatGPT Codex, Claude, Grok, Antigravity, Kimi, GLM, Cursor, Kiro, Copilot, Qwen, ERNIE, Spark, JetBrains, Perplexity, Replit, and Cody subscriptions as DeepSeek Harness LLM providers via OAuth.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|