@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 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
@@ -90,3 +90,7 @@ export function parseBlob(text) {
90
90
  ...(obj.usageAt ? { usageAt: Number(obj.usageAt) } : {}),
91
91
  }
92
92
  }
93
+
94
+ export function validateCredentialBlob(blob) {
95
+ return parseBlob(serializeBlob(blob))
96
+ }
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) => Object.assign({}, d, { slots: next }))
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
- if (data.url) window.open(data.url, '_blank', 'noopener')
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(slots.concat([{ provider: provider, index: index, label: '' }]))
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 avatarEl = (account.avatarUrl ? h('img', {
2342
- src: account.avatarUrl,
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
- const next = slots.slice()
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(slots.filter((_, k) => k !== row.i)))
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 = (typeof INSTRUCTIONS !== 'undefined' && INSTRUCTIONS[prov]) || {
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
- const next = slots.slice()
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
- next[row.i] = Object.assign({}, next[row.i], { maxConcurrency: isNaN(val) ? 1 : Math.max(1, val) })
2604
- setSlots(next)
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
- if (data && data.url) window.open(data.url, '_blank', 'noopener')
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()
@@ -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
+ }
@@ -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 || (options.live && options.live() && options.live().proxyUrl) || null
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
- // Light probe: connect to target or HEAD request
175
- await fetchImpl(proxyUrl, {
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(blob, {
246
- fetchImpl,
247
- signal: controller.signal,
248
- config: options.live ? options.live() : {},
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(account, {
317
- fetchImpl,
318
- signal: controller.signal,
319
- config: options.live ? options.live() : {},
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
@@ -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
- `<script>if (window.opener) { window.opener.postMessage({ type: 'dsh-oauth-success', state: '${escapeHtml(state)}' }, '*'); }</script>` +
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, '&lt;')
401
442
  .replace(/>/g, '&gt;')
402
443
  }
444
+
445
+ export function vendorFetch(fetchImpl, timeoutMs = 15000) {
446
+ return (url, init, options) => fetchWithTimeout(fetchImpl || fetch, url, init, { timeoutMs, ...(options || {}) })
447
+ }
@@ -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
- import { DEFAULT_CLI_CLIENT_ID, DEFAULT_CLI_CLIENT_SECRET } from './vendors/antigravity.js'
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
- if (!detected.antigravity && (process.platform === 'win32' || activeWindowsCredentialReader)) {
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
@@ -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 getSettingsApi().replace(parsed)
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()
@@ -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) => handleHeadlessRedirect(req, res, { pending, completeOAuth, accountsView }),
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
  }
@@ -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, fetchWithTimeout, isTimeoutError } from '../http.js'
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 fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
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 fetchWithTimeout(doFetch, url, init, { timeoutMs: 15_000 })
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) }
@@ -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: '0.6.77',
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
- 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'
@@ -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
- 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.')
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
- try {
131
- if (existsSync(p)) return p
132
- } catch { /* best-effort */ }
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 COPILOT_CLI_BINARIES) {
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(binaryPath, args, {
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) => { stderrBuffer += d.toString() })
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
@@ -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 || 'challenge',
91
- state: p.state || '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 || 'challenge',
85
- state: p.state || '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.82",
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",