@goodandready/dsh-clinebot 0.6.20 → 0.6.22

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/CHANGELOG.md CHANGED
@@ -5,6 +5,23 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.22] - 2026-10-28
9
+
10
+ ### Fixed
11
+ - **Updater env scrub** (#267): child install processes no longer inherit API keys, tokens, secrets, passwords, DSH/CommandCode credentials or npm auth tokens.
12
+ - **Committed-stream failover** (#273): the native adapter no longer retries on another account after a client-visible chunk, so two responses cannot be mixed.
13
+ - **Atomic account mutations** (#274, #272): additions are serialized, credentials are rolled back when settings persistence fails, and discovered-key imports keep the opaque id until success for retry.
14
+ - **SSRF and path hardening** (#268, #269): `priceCatalogUrl` is https models.dev-only with manual redirects; data paths must stay inside `$DSH_HOME`.
15
+ - **CSV export** (#270): RFC 4180 escaping and formula neutralization for model/session keys.
16
+ - **Multimodal payload** (#275, #276): the serialized request (images, text and tools) is measured against `maxPayloadMb`; failed image reads become text blocks instead of fake `image_url` values.
17
+ - **Transport timeouts and limits** (#277, #278): non-SSE bodies have an idle timeout and cancellation; oversized SSE events and response bodies cancel the reader.
18
+ - **FREE catalog diff** (#271): `isCatalogDifferent()` now detects free-status changes.
19
+
20
+ ## [0.6.21] - 2026-10-28
21
+
22
+ ### Fixed
23
+ - **Startup catalog backfill** now also refreshes an existing but stale catalog (older than 5 minutes), not only a completely unsynced profile. This makes the 15-second retry work on WEB profiles that already had a `planSyncedAt` value.
24
+
8
25
  ## [0.6.20] - 2026-10-28
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Native ClineBot / ClinePass Provider Companion for DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.20</b> backfills the model/free/dated catalog automatically (15-second startup retry plus a 6-hour refresh) and keeps FREE annotations, DSH <code>0.2.1-rc.4</code>, model-scoped credit cooldowns and 402/413 errors. Chat runs on the native <code>ctx.llm</code> adapter; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
7
+ <p><b>0.6.22</b> hardens updater credentials, provider failover, concurrent account changes, price-catalog/path access, CSV export and stream limits. It keeps automatic model/free/dated catalog backfill, DSH <code>0.2.1-rc.4</code>, model-scoped credit cooldowns and 402/413 errors. Chat runs on the native <code>ctx.llm</code> adapter; the legacy <code>llm-pi-ai</code>, loopback proxy and <code>llm/stream</code> path are removed.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
package/README.ru.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>Нативное подключение провайдера ClineBot / ClinePass для DeepSeek Harness</h3>
6
6
 
7
- <p><b>0.6.20</b> автоматически добирает каталог моделей/FREE/dated (повтор через 15 секунд после старта и refresh раз в 6 часов) и сохраняет FREE-аннотации, DSH <code>0.2.1-rc.4</code>, cooldown недостатка кредитов только для конкретной модели и понятные ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
7
+ <p><b>0.6.22</b> ужесточает работу с credentials в updater, failover провайдера, конкурентные изменения аккаунтов, доступ к URL/путям, CSV-экспорт и лимиты потоков. Сохраняются автодобор каталога моделей/FREE/dated, DSH <code>0.2.1-rc.4</code>, cooldown недостатка кредитов для конкретной модели и ошибки 402/413. Чат работает через нативный адаптер <code>ctx.llm</code>; старые <code>llm-pi-ai</code>, loopback-прокси и <code>llm/stream</code> удалены.</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  <h3>适用于 DeepSeek Harness 的 ClineBot / ClinePass 原生模型提供商伴侣插件</h3>
6
6
 
7
- <p><b>0.6.20</b> 自动补齐模型/免费/日期变体目录(启动后 15 秒重试并每 6 小时刷新),同时保留免费模型标记、DSH <code>0.2.1-rc.4</code>、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
7
+ <p><b>0.6.22</b> 加强更新器凭据隔离、提供方故障转移、并发账户变更、目录 URL/路径访问、CSV 导出和流限制。保留模型/免费/日期变体自动补齐、DSH <code>0.2.1-rc.4</code>、按模型隔离的余额不足冷却和明确的 402/413 错误。聊天使用原生 <code>ctx.llm</code> 适配器;旧的 <code>llm-pi-ai</code>、回环代理和 <code>llm/stream</code> 已删除。</p>
8
8
 
9
9
  <p align="center">
10
10
  <a href="https://www.npmjs.com/package/@goodandready/dsh-clinebot"><img src="https://img.shields.io/npm/v/@goodandready/dsh-clinebot.svg?style=for-the-badge&color=6366f1&labelColor=1e1b4b" alt="npm version"></a>
@@ -0,0 +1,26 @@
1
+ export function makeRequestBody({
2
+ model,
3
+ payload,
4
+ maxTokens,
5
+ temperature,
6
+ stop,
7
+ reasoningEffort,
8
+ sessionId,
9
+ }) {
10
+ return {
11
+ model,
12
+ messages: payload.messages,
13
+ stream: true,
14
+ stream_options: { include_usage: true },
15
+ ...(payload.tools.length > 0 ? { tools: payload.tools, tool_choice: 'auto' } : {}),
16
+ ...(temperature !== undefined ? { temperature } : {}),
17
+ ...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
18
+ ...(Array.isArray(stop) && stop.length > 0 ? { stop } : {}),
19
+ ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
20
+ ...(sessionId ? {
21
+ session_id: sessionId,
22
+ conversation_id: sessionId,
23
+ prompt_cache_key: sessionId,
24
+ } : {}),
25
+ }
26
+ }
@@ -103,8 +103,13 @@ async function userContent(blocks, attachments, signal) {
103
103
  if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') {
104
104
  content.push({ type: 'text', text: block.text })
105
105
  } else if (block.type === 'image') {
106
- hasImage = true
107
- content.push({ type: 'image_url', image_url: { url: await imageToUrl(block, attachments, signal) } })
106
+ const url = await imageToUrl(block, attachments, signal)
107
+ if (url.startsWith('data:image/')) {
108
+ hasImage = true
109
+ content.push({ type: 'image_url', image_url: { url } })
110
+ } else {
111
+ content.push({ type: 'text', text: url })
112
+ }
108
113
  } else if (block.type === 'file') {
109
114
  content.push({ type: 'text', text: fileText(block) })
110
115
  } else if (block.type === 'tool-result') {
package/lib/adapter.js CHANGED
@@ -16,6 +16,7 @@ import { getBudgetSpend } from './stats-storage.js'
16
16
  import { budgetStatus, budgetErrorMessage } from './budget.js'
17
17
  import { buildOpenAiPayload } from './adapter-messages.js'
18
18
  import { optimizePromptCacheAndBudget } from './multimodal-budget.js'
19
+ import { makeRequestBody } from './adapter-body.js'
19
20
  import { streamJsonCompletion, streamOpenAiChunks } from './adapter-stream.js'
20
21
  import {
21
22
  abortError,
@@ -265,12 +266,13 @@ export class ClineBotAdapter extends LlmAdapter {
265
266
  const attachments = this.resolveAttachments
266
267
  ? this.resolveAttachments()
267
268
  : attachmentStore(this.ctx)
268
- const optimized = optimizePromptCacheAndBudget(options.messages, {
269
+ const maxPayloadBytes = Number(pub.maxPayloadMb || 40) * 1024 * 1024
270
+ let optimized = optimizePromptCacheAndBudget(options.messages, {
269
271
  offloadSeenImages: pub.offloadSeenImages,
270
- maxPayloadBytes: Number(pub.maxPayloadMb || 40) * 1024 * 1024,
272
+ maxPayloadBytes,
271
273
  })
272
274
  const requestOptions = { ...options, offloadedImages: optimized.offloadedCount }
273
- const payload = await buildOpenAiPayload({
275
+ let payload = await buildOpenAiPayload({
274
276
  ...options,
275
277
  messages: optimized.messages,
276
278
  model,
@@ -298,21 +300,26 @@ export class ClineBotAdapter extends LlmAdapter {
298
300
  ? String(options.reasoningEffort)
299
301
  : (pub.modelReasoningDefaults?.[model] || undefined)
300
302
 
301
- const body = {
302
- model,
303
- messages: payload.messages,
304
- stream: true,
305
- stream_options: { include_usage: true },
306
- ...(payload.tools.length > 0 ? { tools: payload.tools, tool_choice: 'auto' } : {}),
307
- ...(options.temperature !== undefined ? { temperature: options.temperature } : {}),
308
- ...(maxTokens !== undefined ? { max_tokens: maxTokens } : {}),
309
- ...(Array.isArray(options.stop) && options.stop.length > 0 ? { stop: options.stop } : {}),
310
- ...(reasoningEffort ? { reasoning_effort: reasoningEffort } : {}),
311
- ...(sessionId ? {
312
- session_id: sessionId,
313
- conversation_id: sessionId,
314
- prompt_cache_key: sessionId,
315
- } : {}),
303
+ let body = makeRequestBody({ model, payload, maxTokens, temperature: options.temperature, stop: options.stop, reasoningEffort, sessionId })
304
+ if (Buffer.byteLength(JSON.stringify(body), 'utf8') > maxPayloadBytes) {
305
+ const allImagesOffloaded = optimizePromptCacheAndBudget(optimized.messages, {
306
+ offloadSeenImages: false,
307
+ maxPayloadBytes: 0,
308
+ })
309
+ if (allImagesOffloaded.offloadedCount > 0) {
310
+ optimized = allImagesOffloaded
311
+ requestOptions.offloadedImages += allImagesOffloaded.offloadedCount
312
+ payload = await buildOpenAiPayload({
313
+ ...options,
314
+ messages: optimized.messages,
315
+ model,
316
+ signal,
317
+ }, { attachments })
318
+ body = makeRequestBody({ model, payload, maxTokens, temperature: options.temperature, stop: options.stop, reasoningEffort, sessionId })
319
+ }
320
+ }
321
+ if (Buffer.byteLength(JSON.stringify(body), 'utf8') > maxPayloadBytes) {
322
+ throw new LlmError('ClinePass request is too large after image offload. Start a new session or reduce messages and tools.', 'REQUEST_TOO_LARGE')
316
323
  }
317
324
 
318
325
  const extraHeaders = sessionId
@@ -363,7 +370,9 @@ export class ClineBotAdapter extends LlmAdapter {
363
370
  }
364
371
 
365
372
  if (!response.ok) {
366
- const text = await readResponseText(response).catch(() => '')
373
+ const text = await readResponseText(response, {
374
+ idleTimeoutMs: Math.min(pub.timeoutMs || 5000, 5000),
375
+ }).catch(() => '')
367
376
  const classified = classifyUpstreamStatus(response.status, text)
368
377
  const retryAfter = retryAfterMs(response.headers)
369
378
  lastError = new LlmError(classified.message, classified.code, {
@@ -391,6 +400,7 @@ export class ClineBotAdapter extends LlmAdapter {
391
400
  if (ttftMs === null) ttftMs = Date.now() - startedAt
392
401
  }
393
402
 
403
+ let clientCommitted = false
394
404
  try {
395
405
  if (isEventStream(response)) {
396
406
  const events = readSseEvents(response, {
@@ -443,8 +453,14 @@ export class ClineBotAdapter extends LlmAdapter {
443
453
  )
444
454
  }
445
455
 
446
- for (const chunk of buffered) yield chunk
447
- for await (const chunk of iterator) yield chunk
456
+ for (const chunk of buffered) {
457
+ clientCommitted = true
458
+ yield chunk
459
+ }
460
+ for await (const chunk of iterator) {
461
+ clientCommitted = true
462
+ yield chunk
463
+ }
448
464
 
449
465
  this.recordAttempt({
450
466
  pub,
@@ -460,7 +476,10 @@ export class ClineBotAdapter extends LlmAdapter {
460
476
  }
461
477
 
462
478
  // Fallback for deployments that ignore `stream: true`.
463
- const text = await readResponseText(response)
479
+ const text = await readResponseText(response, {
480
+ idleTimeoutMs: pub.streamIdleTimeoutMs,
481
+ signal,
482
+ })
464
483
  let json
465
484
  try {
466
485
  json = JSON.parse(text)
@@ -470,6 +489,7 @@ export class ClineBotAdapter extends LlmAdapter {
470
489
  let committed = false
471
490
  for await (const chunk of streamJsonCompletion(json, { onUsage, onFirstToken })) {
472
491
  if (isContentChunk(chunk)) committed = true
492
+ clientCommitted = true
473
493
  yield chunk
474
494
  }
475
495
  if (!committed) {
@@ -511,7 +531,7 @@ export class ClineBotAdapter extends LlmAdapter {
511
531
  || normalized.code === 'INSUFFICIENT_CREDITS'
512
532
  || normalized.code === 'QUOTA_EXCEEDED'
513
533
  lastError = normalized
514
- if (retryable && attempt + 1 < maxAttempts) {
534
+ if (retryable && !clientCommitted && attempt + 1 < maxAttempts) {
515
535
  const isCredit = normalized.code === 'INSUFFICIENT_CREDITS'
516
536
  markAccountCooldown(
517
537
  accountId,
package/lib/csv.js ADDED
@@ -0,0 +1,5 @@
1
+ export function csvCell(value) {
2
+ let text = String(value ?? '')
3
+ if (/^[=+\-@]/.test(text)) text = `'${text}`
4
+ return `"${text.replace(/"/g, '""')}"`
5
+ }
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ export const name = '@goodandready/dsh-clinebot'
32
32
  export const inject = []
33
33
 
34
34
  const CATALOG_REFRESH_MS = 6 * 60 * 60 * 1000
35
+ const CATALOG_STALE_MS = 5 * 60 * 1000
35
36
 
36
37
  export function configReader(config) {
37
38
  return () => config
@@ -340,7 +341,8 @@ export function apply(ctx, config) {
340
341
  const timer = setTimeout(triggerAutoDiscover, 500)
341
342
  const retryTimer = setTimeout(triggerAutoDiscover, 3000)
342
343
  const lateRetry = setTimeout(() => {
343
- if (!Number(publicConfig(live()).planSyncedAt || 0)) triggerAutoDiscover()
344
+ const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
345
+ if (Date.now() - syncedAt >= CATALOG_STALE_MS) triggerAutoDiscover()
344
346
  }, 15000)
345
347
  const refreshTimer = setInterval(() => {
346
348
  const syncedAt = Number(publicConfig(live()).planSyncedAt || 0)
@@ -18,13 +18,17 @@ function cleanupExpired() {
18
18
  }
19
19
  }
20
20
 
21
- export function consumeDiscoveredKey(id) {
21
+ export function peekDiscoveredKey(id) {
22
22
  if (!id || typeof id !== 'string') return null
23
23
  cleanupExpired()
24
- const item = discoveredKeysCache.get(id)
25
- if (!item) return null
26
- discoveredKeysCache.delete(id)
27
- return item.key
24
+ return discoveredKeysCache.get(id)?.key || null
25
+ }
26
+
27
+ export function consumeDiscoveredKey(id) {
28
+ if (!id || typeof id !== 'string') return null
29
+ const key = peekDiscoveredKey(id)
30
+ if (key) discoveredKeysCache.delete(id)
31
+ return key
28
32
  }
29
33
 
30
34
  export function maskApiKey(key) {
package/lib/models.js CHANGED
@@ -572,10 +572,7 @@ export async function loadModelsDiskCache(cachePath, options = {}) {
572
572
  }
573
573
  }
574
574
 
575
- /**
576
- * Detects whether the authoritative dynamic models catalog differs from the current catalog
577
- * in model count, IDs, or metadata (e.g. contextLength, maxTokens, category, etc.).
578
- */
575
+ /* Compare authoritative model count, IDs and metadata. */
579
576
  export function isCatalogDifferent(currentList = [], authoritativeList = []) {
580
577
  if (!Array.isArray(currentList) || !Array.isArray(authoritativeList)) return true
581
578
  if (currentList.length !== authoritativeList.length) return true
@@ -589,6 +586,7 @@ export function isCatalogDifferent(currentList = [], authoritativeList = []) {
589
586
  cur.maxTokens !== auth.maxTokens ||
590
587
  cur.category !== auth.category ||
591
588
  cur.description !== auth.description ||
589
+ Boolean(cur.free) !== Boolean(auth.free) ||
592
590
  JSON.stringify(cur.input || []) !== JSON.stringify(auth.input || []) ||
593
591
  JSON.stringify(cur.reasoningEfforts || []) !== JSON.stringify(auth.reasoningEfforts || [])
594
592
  ) {
@@ -17,6 +17,10 @@ function estimateImageBlockBytes(block) {
17
17
  if (block.type === 'image' && typeof block.source?.data === 'string') {
18
18
  return block.source.data.length
19
19
  }
20
+ if (block.type === 'image' && block.attachment && typeof block.attachment === 'object') {
21
+ const bytes = Number(block.attachment.bytes || block.attachment.size || 0)
22
+ if (bytes > 0) return Math.ceil(bytes * 4 / 3) + 64
23
+ }
20
24
  return 0
21
25
  }
22
26
 
@@ -0,0 +1,17 @@
1
+ import os from 'node:os'
2
+ import path from 'node:path'
3
+
4
+ export function dshHome() {
5
+ return path.resolve(process.env.DSH_HOME || path.join(process.env.HOME || os.homedir(), '.dsh'))
6
+ }
7
+
8
+ export function isSafeDataPath(value) {
9
+ const raw = String(value ?? '').trim()
10
+ if (!raw) return true
11
+ if (raw.includes('\0')) return false
12
+ const resolved = raw === '~' || raw.startsWith('~/')
13
+ ? path.resolve(process.env.HOME || os.homedir(), raw.slice(1))
14
+ : path.resolve(raw)
15
+ const home = dshHome()
16
+ return resolved === home || resolved.startsWith(`${home}${path.sep}`)
17
+ }
package/lib/price-sync.js CHANGED
@@ -4,6 +4,35 @@ import path from 'node:path'
4
4
  import { normalizeModelKey, getPublicPriceCatalog, setPublicPriceCatalog } from './model-prices.js'
5
5
 
6
6
  const DEFAULT_URL = 'https://models.dev/api.json'
7
+
8
+ function isPrivateHost(host) {
9
+ const value = String(host || '').toLowerCase().replace(/^\[|\]$/g, '')
10
+ if (!value || value === 'localhost' || value.endsWith('.localhost')) return true
11
+ if (value === '::1' || value.startsWith('fc') || value.startsWith('fd') || value.startsWith('fe80:')) return true
12
+ if (value.startsWith('::ffff:')) return isPrivateHost(value.slice(7))
13
+ const parts = value.split('.')
14
+ if (parts.length !== 4 || parts.some((part) => !/^\d+$/.test(part))) return false
15
+ const octets = parts.map(Number)
16
+ if (octets.some((part) => part > 255)) return false
17
+ return octets[0] === 0 || octets[0] === 10 || octets[0] === 127
18
+ || (octets[0] === 169 && octets[1] === 254)
19
+ || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31)
20
+ || (octets[0] === 192 && octets[1] === 168)
21
+ || (octets[0] === 100 && octets[1] >= 64 && octets[1] <= 127)
22
+ }
23
+
24
+ export function isAllowedPriceCatalogUrl(value) {
25
+ try {
26
+ const url = new URL(String(value || ''))
27
+ if (url.protocol !== 'https:' || url.username || url.password) return false
28
+ const host = url.hostname.toLowerCase()
29
+ if (isPrivateHost(host)) return false
30
+ return host === 'models.dev' || host.endsWith('.models.dev')
31
+ } catch {
32
+ return false
33
+ }
34
+ }
35
+
7
36
  const PROVIDER_PRIORITY = {
8
37
  opencode: 0,
9
38
  cline: 0,
@@ -131,6 +160,7 @@ export async function syncPublicPrices({
131
160
  fetchImpl = fetch,
132
161
  timeoutMs = 15000,
133
162
  } = {}) {
163
+ if (!isAllowedPriceCatalogUrl(url)) throw new Error('Price catalog URL is not allowed')
134
164
  const controller = new AbortController()
135
165
  const timer = setTimeout(() => controller.abort(), Math.max(1000, Number(timeoutMs) || 15000))
136
166
  let data
@@ -139,7 +169,9 @@ export async function syncPublicPrices({
139
169
  signal: controller.signal,
140
170
  headers: { accept: 'application/json' },
141
171
  cache: 'no-store',
172
+ redirect: 'manual',
142
173
  })
174
+ if (response?.status >= 300 && response?.status < 400) throw new Error('Price catalog redirect is not allowed')
143
175
  if (!response?.ok) throw new Error(`HTTP ${response?.status || 0}`)
144
176
  data = await response.json()
145
177
  } finally {
@@ -1,5 +1,6 @@
1
1
  import { writeJson, readBody, publicUsage } from '../http.js'
2
- import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
2
+ import { discoverLocalClineKeys, consumeDiscoveredKey, peekDiscoveredKey } from '../key-discovery.js'
3
+ import { resolveKeyValue } from '../credential-refs.js'
3
4
  import { assertTrustedSettingsRequest } from '../access.js'
4
5
  import { publicConfig, plainConfig, Config } from '../config.js'
5
6
  import { resolveActiveAccountKey } from '../provider-sync.js'
@@ -17,8 +18,19 @@ import {
17
18
 
18
19
  export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
19
20
  const pendingSecretCleanupAccounts = new Set()
21
+ let accountMutationTail = Promise.resolve()
20
22
 
21
- async function addAccountInternal({ apiKey, label, apiKeyEnv }) {
23
+ function runAccountMutation(task) {
24
+ const run = accountMutationTail.then(task)
25
+ accountMutationTail = run.catch(() => {})
26
+ return run
27
+ }
28
+
29
+ async function addAccountInternal(input) {
30
+ return runAccountMutation(() => addAccountInternalUnlocked(input))
31
+ }
32
+
33
+ async function addAccountInternalUnlocked({ apiKey, label, apiKeyEnv }) {
22
34
  const curr = plainConfig(live())
23
35
  const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
24
36
 
@@ -49,6 +61,18 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
49
61
  }
50
62
 
51
63
  const accountLabel = String(label || '').trim() || targetEnv
64
+ const previous = await resolveKeyValue(ctx, targetEnv)
65
+ const restoreCredential = async () => {
66
+ try {
67
+ if (previous.source === 'credentials' && previous.value) {
68
+ await saveCredentialKey(ctx, targetEnv, previous.value)
69
+ } else {
70
+ await deleteCredentialKey(ctx, targetEnv)
71
+ }
72
+ } catch {
73
+ /* best-effort rollback */
74
+ }
75
+ }
52
76
 
53
77
  await saveCredentialKey(ctx, targetEnv, apiKey)
54
78
 
@@ -62,7 +86,12 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
62
86
 
63
87
  const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
64
88
  const settingsApi = getSettingsApi()
65
- await settingsApi.replace(next)
89
+ try {
90
+ await settingsApi.replace(next)
91
+ } catch (err) {
92
+ await restoreCredential()
93
+ throw err
94
+ }
66
95
  await syncProviderState(next)
67
96
 
68
97
  let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
@@ -351,7 +380,7 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
351
380
  return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
352
381
  }
353
382
 
354
- const apiKey = consumeDiscoveredKey(id)
383
+ const apiKey = peekDiscoveredKey(id)
355
384
  if (!apiKey) {
356
385
  return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
357
386
  }
@@ -361,6 +390,7 @@ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettin
361
390
  label: body.label,
362
391
  apiKeyEnv: body.apiKeyEnv,
363
392
  })
393
+ consumeDiscoveredKey(id)
364
394
  writeJson(res, 200, { ok: true, ...result })
365
395
  } catch (err) {
366
396
  const status = typeof err?.status === 'number' ? err.status : 500
@@ -6,7 +6,8 @@ import { saveCredentialKey, smokeChat, DEFAULT_API_KEY_ENV } from '../cline-clie
6
6
  import { getStatsSummary, getSessionStats, resetStats, exportStatsAsCsv, getAnalytics } from '../stats-storage.js'
7
7
  import { exportStatsAsJson } from '../stats-export.js'
8
8
  import { priceTable, getPublicPriceCatalog, setPublicPriceCatalog } from '../model-prices.js'
9
- import { syncPublicPrices } from '../price-sync.js'
9
+ import { syncPublicPrices, isAllowedPriceCatalogUrl } from '../price-sync.js'
10
+ import { isSafeDataPath } from '../path-safety.js'
10
11
 
11
12
  export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProviderState, triggerAutoDiscover }) {
12
13
  // 1. GET /dsh-clinebot/status
@@ -92,6 +93,14 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
92
93
  return writeJson(res, 400, { ok: false, error: 'Invalid baseUrl' })
93
94
  }
94
95
  }
96
+ if (rawPayload.priceCatalogUrl !== undefined && !isAllowedPriceCatalogUrl(rawPayload.priceCatalogUrl)) {
97
+ return writeJson(res, 400, { ok: false, error: 'priceCatalogUrl must be an https models.dev URL' })
98
+ }
99
+ for (const key of ['statsPath', 'modelsCachePath', 'priceCatalogPath', 'eventLogPath']) {
100
+ if (rawPayload[key] !== undefined && !isSafeDataPath(rawPayload[key])) {
101
+ return writeJson(res, 400, { ok: false, error: `${key} must stay inside the DSH home directory` })
102
+ }
103
+ }
95
104
  try {
96
105
  const base = plainConfig(live()) || {}
97
106
  delete base.enabledModels
@@ -14,6 +14,7 @@ import {
14
14
  percentileFromHistogram,
15
15
  pruneRetentionOnStats,
16
16
  } from './stats-metrics.js'
17
+ import { csvCell } from './csv.js'
17
18
 
18
19
  const MAX_SESSIONS = 500
19
20
  const SESSION_TTL_MS = 90 * 24 * 60 * 60 * 1000
@@ -561,11 +562,7 @@ export function getAnalytics(query = {}) {
561
562
  }
562
563
  }
563
564
 
564
- /**
565
- * Export CSV. The compact form keeps the historical schema; `detailed`
566
- * appends cache, cost, TTFT and latency columns.
567
- * @param {boolean} [detailed]
568
- */
565
+ /* Export the compact or detailed CSV schema. */
569
566
  export function exportStatsAsCsv(detailed = false) {
570
567
  const header = detailed
571
568
  ? 'Dimension,Key,Requests,PromptTokens,CompletionTokens,ReasoningTokens,CacheReadTokens,CacheWriteTokens,TotalTokens,CostUsd,TtftMs,LatencyMs'
@@ -574,9 +571,9 @@ export function exportStatsAsCsv(detailed = false) {
574
571
  const addRows = (dimension, obj) => {
575
572
  for (const [key, val] of Object.entries(obj || {})) {
576
573
  if (detailed) {
577
- lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
574
+ lines.push(`${dimension},${csvCell(key)},${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.cacheReadTokens || 0},${val.cacheWriteTokens || 0},${val.totalTokens || 0},${Number(val.costUsd || 0).toFixed(6)},${Math.round(val.ttftMsSum || 0)},${Math.round(val.latencyMsSum || 0)}`)
578
575
  } else {
579
- lines.push(`${dimension},"${key}",${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
576
+ lines.push(`${dimension},${csvCell(key)},${val.requests || 0},${val.promptTokens || 0},${val.completionTokens || 0},${val.reasoningTokens || 0},${val.totalTokens || 0}`)
580
577
  }
581
578
  }
582
579
  }
@@ -9,7 +9,7 @@
9
9
  import { createRequire } from 'node:module'
10
10
 
11
11
  const require = createRequire(import.meta.url)
12
- let packageVersion = '0.6.20'
12
+ let packageVersion = '0.6.22'
13
13
  try {
14
14
  packageVersion = require('../../package.json').version || packageVersion
15
15
  } catch {
@@ -20,6 +20,7 @@ const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
20
20
  const DEFAULT_CONNECT_TIMEOUT_MS = 15000
21
21
  const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 30000
22
22
  const DEFAULT_MAX_BODY_BYTES = 10 * 1024 * 1024
23
+ const DEFAULT_MAX_SSE_EVENT_BYTES = 1024 * 1024
23
24
 
24
25
  /**
25
26
  * Build the abort-shaped error every transport caller understands.
@@ -131,7 +132,30 @@ export function classifyUpstreamStatus(status, bodyText = '') {
131
132
  * @param {{ maxBytes?: number }} [options]
132
133
  * @returns {Promise<string>}
133
134
  */
134
- export async function readResponseText(response, { maxBytes = DEFAULT_MAX_BODY_BYTES } = {}) {
135
+ async function readChunkWithIdle(reader, idleTimeoutMs) {
136
+ let timer = null
137
+ try {
138
+ return await Promise.race([
139
+ reader.read(),
140
+ new Promise((_, reject) => {
141
+ timer = setTimeout(() => {
142
+ const error = new Error(`Upstream body idle timeout after ${idleTimeoutMs}ms`)
143
+ error.code = 'STREAM_IDLE_TIMEOUT'
144
+ reject(error)
145
+ }, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
146
+ if (typeof timer.unref === 'function') timer.unref()
147
+ }),
148
+ ])
149
+ } finally {
150
+ if (timer) clearTimeout(timer)
151
+ }
152
+ }
153
+
154
+ export async function readResponseText(response, {
155
+ maxBytes = DEFAULT_MAX_BODY_BYTES,
156
+ idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
157
+ signal,
158
+ } = {}) {
135
159
  if (!response?.body) return ''
136
160
  const reader = response.body.getReader()
137
161
  const decoder = new TextDecoder('utf8')
@@ -139,16 +163,21 @@ export async function readResponseText(response, { maxBytes = DEFAULT_MAX_BODY_B
139
163
  let out = ''
140
164
  try {
141
165
  while (true) {
142
- const { done, value } = await reader.read()
166
+ if (signal?.aborted) throw abortError()
167
+ const { done, value } = await readChunkWithIdle(reader, idleTimeoutMs)
143
168
  if (done) break
144
169
  size += value.byteLength
145
170
  if (size > maxBytes) {
171
+ await reader.cancel(`body exceeded ${maxBytes} bytes`).catch(() => {})
146
172
  throw new Error(`Upstream response exceeded ${maxBytes} bytes`)
147
173
  }
148
174
  out += decoder.decode(value, { stream: true })
149
175
  }
150
176
  out += decoder.decode()
151
177
  return out
178
+ } catch (error) {
179
+ await reader.cancel(error?.message || 'body read failed').catch(() => {})
180
+ throw error
152
181
  } finally {
153
182
  try { reader.releaseLock() } catch { /* explicit best-effort fallback: non-fatal by design */ }
154
183
  }
@@ -263,30 +292,14 @@ function parseSseBlock(raw) {
263
292
  export async function* readSseEvents(response, {
264
293
  signal,
265
294
  idleTimeoutMs = DEFAULT_STREAM_IDLE_TIMEOUT_MS,
295
+ maxEventBytes = DEFAULT_MAX_SSE_EVENT_BYTES,
266
296
  } = {}) {
267
297
  if (!response?.body) return
268
298
  const reader = response.body.getReader()
269
299
  const decoder = new TextDecoder('utf8')
270
300
  let buffer = ''
271
301
 
272
- async function readWithIdle() {
273
- let timer = null
274
- try {
275
- return await Promise.race([
276
- reader.read(),
277
- new Promise((_, reject) => {
278
- timer = setTimeout(() => {
279
- const error = new Error(`Stream idle timeout after ${idleTimeoutMs}ms`)
280
- error.code = 'STREAM_IDLE_TIMEOUT'
281
- reject(error)
282
- }, Math.max(1, Number(idleTimeoutMs) || DEFAULT_STREAM_IDLE_TIMEOUT_MS))
283
- if (typeof timer.unref === 'function') timer.unref()
284
- }),
285
- ])
286
- } finally {
287
- if (timer) clearTimeout(timer)
288
- }
289
- }
302
+ const readWithIdle = () => readChunkWithIdle(reader, idleTimeoutMs)
290
303
 
291
304
  try {
292
305
  while (true) {
@@ -301,11 +314,17 @@ export async function* readSseEvents(response, {
301
314
  let boundary = buffer.indexOf('\n\n')
302
315
  while (boundary >= 0) {
303
316
  const block = buffer.slice(0, boundary)
317
+ if (block.length > maxEventBytes) {
318
+ throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
319
+ }
304
320
  buffer = buffer.slice(boundary + 2)
305
321
  const parsed = parseSseBlock(block)
306
322
  if (parsed !== undefined) yield parsed
307
323
  boundary = buffer.indexOf('\n\n')
308
324
  }
325
+ if (buffer.length > maxEventBytes) {
326
+ throw new Error(`SSE event exceeded ${maxEventBytes} bytes`)
327
+ }
309
328
  }
310
329
  buffer += decoder.decode()
311
330
  const tail = parseSseBlock(buffer)
package/lib/updater.js CHANGED
@@ -14,8 +14,19 @@ import { fileURLToPath } from 'node:url'
14
14
  const UPDATE_HEADER = 'x-dsh-plugin-update'
15
15
  const UPDATE_TIMEOUT_MS = 10 * 60_000
16
16
  const VERSION_CACHE_MS = 5 * 60_000
17
+ const SECRET_ENV_RE = /(?:^|_)(?:API[_-]?KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)(?:_|$)/i
17
18
  let latestCache = undefined
18
19
 
20
+ export function sanitizeChildEnv(env = process.env) {
21
+ const out = { ...env, NO_COLOR: '1' }
22
+ for (const key of Object.keys(out)) {
23
+ if (/^CLINEBOT_/i.test(key) || /^COMMANDCODE_/i.test(key) || SECRET_ENV_RE.test(key) || /AUTH[_-]?TOKEN/i.test(key)) {
24
+ delete out[key]
25
+ }
26
+ }
27
+ return out
28
+ }
29
+
19
30
  function header(request, name) {
20
31
  const value = request?.headers?.[name]
21
32
  return Array.isArray(value) ? value[0] : value
@@ -263,11 +274,7 @@ export async function installExact(target, packageSpec, options = {}) {
263
274
  cwd: target.profileDir,
264
275
  windowsHide: true,
265
276
  stdio: ['ignore', 'pipe', 'pipe'],
266
- env: (() => {
267
- const childEnv = { ...process.env, NO_COLOR: '1' }
268
- delete childEnv.CLINEBOT_LOCAL_PROXY_TOKEN
269
- return childEnv
270
- })(),
277
+ env: sanitizeChildEnv(),
271
278
  detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
272
279
  })
273
280
  let detail = ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.6.20",
3
+ "version": "0.6.22",
4
4
  "description": "DeepSeek Harness companion for ClineBot / ClinePass: dynamic subscription models sync, quota exhaustion warnings, session metrics, dedicated settings page, live usage limits, and /cline slash-command.",
5
5
  "license": "MIT",
6
6
  "type": "module",