@goodandready/dsh-clinebot 0.5.16 → 0.5.17

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,16 @@ 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.5.17] - 2026-10-05
9
+
10
+ ### Fixed
11
+ - **Cosmokit Volatile Getters Unwrapping in Live Config (Issues #194, #200)**: Resolved issue where `live()` returned raw Cosmokit `Volatile` `{ get }` object boxes for volatile schema fields, causing downstream boolean checks (such as `!pub.enabled` in `syncProviderState`) to always evaluate as truthy objects regardless of actual boolean value. Fully unwrapped `live()` return values via `plainConfig(Config(plainConfig(...)))` and guaranteed robust fallback defaults when `apply()` receives null/undefined config.
12
+ - **Proxy Body Read Timeout & Payload Size Bounds (Issues #198, #202)**: Switched non-streaming upstream body read timeouts from `streamIdleTimeoutMs` (30s) to `bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)`. Added strict guard against unbounded payload responses (`MAX_BODY_BYTES = 10 * 1024 * 1024`) returning 502 Bad Gateway if upstream response exceeds 10MB.
13
+ - **Dead Configuration Keys & Timeout Cleanup (Issue #197)**: Removed non-existent configuration keys (`streamTotalTimeoutMs`, `connectTimeoutMs`, `idleTimeoutMs`) from `lib/routes/proxy.js` and eliminated dead timer logic.
14
+ - **DSH Non-Existent Service API Calls Removal (Issue #199)**: Removed dead `settings?.get` and `settingsApi.watch` calls on DSH services that lack those methods.
15
+ - **Corrupted Stats Backup Pruning (Issue #201)**: Added automatic rotation and pruning for `.corrupt.*` backup files in `lib/stats-storage.js`, retaining at most the 3 latest diagnostic copies to prevent unbounded disk usage.
16
+ - **Version Literals & Slash Command Cleanup (Issue #202)**: Synchronized hardcoded fallback version literals to `'0.5.17'`, cleaned up unused `invocation` argument in `slash-command.js`, and updated fallback port detection (`ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080`).
17
+
8
18
  ## [0.5.16] - 2026-10-04
9
19
 
10
20
  ### Fixed
package/lib/index.js CHANGED
@@ -37,7 +37,7 @@ export function configReader(config) {
37
37
  export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
38
38
 
39
39
  export function apply(ctx, config) {
40
- let currentConfig = config
40
+ let currentConfig = config || {}
41
41
  const overrides = {}
42
42
  const getConfig = () => {
43
43
  if (!currentConfig) return currentConfig
@@ -73,7 +73,7 @@ export function apply(ctx, config) {
73
73
  }
74
74
  return currentConfig
75
75
  }
76
- const live = () => (getConfig() ? Config(structuredClone(plainConfig(getConfig()))) : config)
76
+ const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
77
77
  let settingsApi
78
78
 
79
79
  const syncProviderState = async (cfg) => {
@@ -219,7 +219,7 @@ export function apply(ctx, config) {
219
219
  applyConfigMutations(currentConfig, parsed)
220
220
  syncStatsStorage(live())
221
221
  await syncProviderState(live())
222
- return parsed
222
+ return plainConfig(parsed)
223
223
  },
224
224
  update: async (patch) => {
225
225
  const svc = ctx?.get?.('settings')
@@ -248,13 +248,9 @@ export function apply(ctx, config) {
248
248
  applyConfigMutations(currentConfig, merged)
249
249
  syncStatsStorage(live())
250
250
  await syncProviderState(live())
251
- return merged
252
- },
253
- watch: (cb) => {
254
- const svc = ctx?.get?.('settings')
255
- if (typeof svc?.watch === 'function') return svc.watch(cb)
256
- return () => {}
251
+ return plainConfig(merged)
257
252
  },
253
+
258
254
  }
259
255
 
260
256
  if (typeof ctx.effect === 'function') {
@@ -56,10 +56,7 @@ export async function checkRegisteredInPiAi(ctx) {
56
56
  const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
57
57
  if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
58
58
  }
59
- if (typeof settings?.get === 'function') {
60
- const piAi = settings.get(LLM_PI_AI_NS)
61
- if (piAi?.providers?.[PROVIDER_ID]) return true
62
- }
59
+
63
60
  const llm = ctx?.get?.('llm')
64
61
  if (typeof llm?.listProviders === 'function') {
65
62
  const list = await llm.listProviders()
@@ -215,7 +212,7 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
215
212
 
216
213
  if (isProxy) {
217
214
  if (!proxyPort) {
218
- proxyPort = ctx?.webServer?.port || process.env.PORT || 3080
215
+ proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
219
216
  const isFenced = await detectHostWebServerFence(proxyPort)
220
217
  if (isFenced) {
221
218
  ctx?.logger?.warn?.(
@@ -24,9 +24,9 @@ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
24
  import { getLocalProxyToken } from '../proxy-token.js'
25
25
 
26
26
  const require = createRequire(import.meta.url)
27
- let packageVersion = '0.5.3'
27
+ let packageVersion = '0.5.17'
28
28
  try {
29
- packageVersion = require('../../package.json').version || '0.5.6'
29
+ packageVersion = require('../../package.json').version || '0.5.17'
30
30
  } catch (err) {
31
31
  /* fallback to default version if manifest is unreadable */
32
32
  }
@@ -161,8 +161,8 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
161
161
  'User-Agent': USER_AGENT
162
162
  }
163
163
 
164
- const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
165
- const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
164
+ const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
165
+ const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
166
166
 
167
167
  const connectAbortCtrl = new AbortController()
168
168
  const bodyAbortCtrl = new AbortController()
@@ -283,16 +283,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
283
283
  }
284
284
  }
285
285
 
286
- let totalTimer = null
287
- if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
288
- const totalMs = Number(pub.streamTotalTimeoutMs)
289
- totalTimer = setTimeout(() => {
290
- streamError = new Error(`Stream total timeout after ${totalMs}ms`)
291
- try {
292
- reader.cancel('stream total timeout').catch(() => {})
293
- } catch { /* reader already cancelled or closed */ }
294
- }, totalMs)
295
- }
286
+
296
287
 
297
288
  try {
298
289
  resetIdleTimer()
@@ -341,7 +332,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
341
332
  ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
342
333
  } finally {
343
334
  clearIdleTimer()
344
- if (totalTimer) clearTimeout(totalTimer)
335
+
345
336
  clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
346
337
  cleanupClientListeners()
347
338
  try {
@@ -379,17 +370,26 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
379
370
  return
380
371
  }
381
372
 
382
- // Non-streaming JSON response with deadline and fail-closed error handling (#185)
383
- const idleTimeoutMs = streamIdleTimeoutMs
373
+ // Non-streaming JSON response with deadline and fail-closed error handling (#185, #198, #202)
374
+ const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
384
375
  let bodyTimer = null
385
376
  const bodyTimeoutPromise = new Promise((_, reject) => {
386
377
  bodyTimer = setTimeout(() => {
387
378
  bodyTimedOut = true
388
- bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
389
- reject(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
390
- }, idleTimeoutMs)
379
+ bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
380
+ reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
381
+ }, bodyTimeoutMs)
391
382
  })
392
383
 
384
+ const MAX_BODY_BYTES = 10 * 1024 * 1024
385
+ const contentLength = Number(upstreamRes.headers.get('content-length'))
386
+ if (contentLength > MAX_BODY_BYTES) {
387
+ clearTimeout(bodyTimer)
388
+ cleanupClientListeners()
389
+ writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
390
+ return
391
+ }
392
+
393
393
  let resText = ''
394
394
  let readError = null
395
395
  try {
@@ -397,6 +397,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
397
397
  upstreamRes.text(),
398
398
  bodyTimeoutPromise,
399
399
  ])
400
+ if (resText.length > MAX_BODY_BYTES) {
401
+ throw new Error('Upstream response exceeded maximum allowed size')
402
+ }
400
403
  } catch (err) {
401
404
  readError = err
402
405
  } finally {
@@ -419,7 +422,7 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
419
422
  })
420
423
  const statusCode = isTimeout ? 504 : 502
421
424
  const errMessage = isTimeout
422
- ? `Gateway Timeout: upstream body read timed out after ${idleTimeoutMs}ms`
425
+ ? `Gateway Timeout: upstream body read timed out after ${bodyTimeoutMs}ms`
423
426
  : `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
424
427
  return writeJson(res, statusCode, {
425
428
  error: {
@@ -26,7 +26,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
26
26
  const commands = cmdCtx.commands
27
27
  if (typeof commands?.register !== 'function') return
28
28
 
29
- const executeCommand = async (rawArgs, invocation = null) => {
29
+ const executeCommand = async (rawArgs) => {
30
30
  try {
31
31
  const pub = publicConfig(live())
32
32
  const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
@@ -102,12 +102,12 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
102
102
  if (settingsApi?.replace) {
103
103
  const curr = plainConfig(live())
104
104
  const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
105
- const next = Config({
105
+ const next = plainConfig(Config({
106
106
  ...curr,
107
107
  activeAccount: param,
108
108
  dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
109
109
  planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
110
- })
110
+ }))
111
111
  await settingsApi.replace(next)
112
112
  await syncProviderState(next)
113
113
  return `Active account switched to ${param}`
@@ -276,7 +276,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
276
276
  ? invocation
277
277
  : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
278
278
  try {
279
- const output = await executeCommand(rawArgs, invocation)
279
+ const output = await executeCommand(rawArgs)
280
280
  return {
281
281
  kind: 'success',
282
282
  text: output,
@@ -5,7 +5,7 @@
5
5
  * Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
@@ -104,11 +104,25 @@ export function loadStats(customPath) {
104
104
  }
105
105
  }
106
106
  } catch (err) {
107
- // If reading fails or file is corrupted, preserve corrupted file for diagnostics
107
+ // If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
108
108
  try {
109
109
  if (existsSync(filePath)) {
110
110
  const corruptBackup = `${filePath}.corrupt.${Date.now()}`
111
111
  copyFileSync(filePath, corruptBackup)
112
+ const dir = path.dirname(filePath)
113
+ const base = path.basename(filePath)
114
+ const prefix = `${base}.corrupt.`
115
+ const existing = readdirSync(dir)
116
+ .filter((name) => name.startsWith(prefix))
117
+ .sort()
118
+ while (existing.length > 3) {
119
+ const oldest = existing.shift()
120
+ try {
121
+ unlinkSync(path.join(dir, oldest))
122
+ } catch {
123
+ /* ignore deletion failure */
124
+ }
125
+ }
112
126
  }
113
127
  } catch {
114
128
  /* ignore backup failure */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.16",
3
+ "version": "0.5.17",
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",