@goodandready/dsh-clinebot 0.5.4 → 0.5.6

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
@@ -1,15 +1,24 @@
1
1
  # Changelog
2
2
 
3
- ## 0.5.4
4
-
5
- ### Fixed
6
- - **Peer gate on DSH 0.2.0-rc.1** (#58): DSH skips a profile bundle whose `peerDependencies` exclude the running version, so this plugin was absent from the profile with no error in the UI. Every `@deepseek-ai/dsh-*` peer now names both the 0.1.7-rc.2 and 0.2.0-rc.1 lines, because semver does not admit a prerelease of the next minor into a range that does not name it.
7
-
8
3
  All notable changes to `@goodandready/dsh-clinebot` will be documented in this file.
9
4
 
10
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
11
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
12
7
 
8
+ ## [0.5.6] - 2026-09-30
9
+
10
+ ### Security
11
+ - **Credential Deletion Scope Enforcement (Issue #155)**: Restricted credential deletion on `DELETE /dsh-clinebot/accounts` and `POST /dsh-clinebot/accounts/delete` strictly to configured secondary accounts matching `^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`. Prohibited deletion of the primary account. Unconfigured or invalid keys receive HTTP 400/404 without calling credentials store. Credential deletion errors return HTTP 500.
12
+
13
+ ### Fixed
14
+ - **Atomic Settings Persistence & Volatile Lifecycle (Issue #157)**: Re-architected `settingsApi.replace` and `settingsApi.update` to persist changes via DSH `SettingsForms` *before* mutating live state. If settings service is unavailable or non-writable, explicit errors are thrown. Mutating in-memory configuration now preserves dynamic Volatile references and getters across updates without severance.
15
+ - **Cold Start Persistent Stats Loading & Flush on Dispose (Issue #158)**: Connected `loadStats(statsPath)` to plugin startup lifecycle in `apply()`, preventing cold starts from overwriting historical token metrics with empty counters. Added `flushStats()` effect hook on plugin disposal and automatic corrupted file preservation (`*.corrupt.<timestamp>`) for diagnostics.
16
+
17
+ ## [0.5.5] - 2026-09-30
18
+
19
+ ### Fixed
20
+ - **Volatile Boxes Unwrapping in Config Validation (Issue #153)**: Corrected deep unwrapping of Schemastery Volatile boxes and getters when passing dynamic configuration objects through `Config()` validation, preventing schema validation failures.
21
+
13
22
  ## [0.5.3] - 2026-09-28
14
23
 
15
24
  ### Fixed
package/lib/config.js CHANGED
@@ -87,7 +87,7 @@ export const Config = z.object({
87
87
  .description('Manually pinned active account envName or empty for auto/default.').volatile(),
88
88
  })
89
89
 
90
- function isVolatileRef(value) {
90
+ export function isVolatileRef(value) {
91
91
  return !!value && typeof value === 'object' && !Array.isArray(value) && typeof value.get === 'function'
92
92
  }
93
93
 
package/lib/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { Config, publicConfig, plainConfig, volatileConfig, NS, LLM_PI_AI_NS } from './config.js'
1
+ import { Config, publicConfig, plainConfig, volatileConfig, isVolatileRef, NS, LLM_PI_AI_NS } from './config.js'
2
2
  import { registerPluginUpdater } from './updater.js'
3
3
  import {
4
4
  sessionStats,
@@ -22,7 +22,7 @@ import { registerModelsRoutes } from './routes/models.js'
22
22
  import { registerAuthRoutes } from './routes/auth.js'
23
23
  import { registerProxyRoutes } from './routes/proxy.js'
24
24
  import { registerSlashCommand } from './slash-command.js'
25
- import { recordUsage } from './stats-storage.js'
25
+ import { recordUsage, loadStats, flushStats } from './stats-storage.js'
26
26
 
27
27
  export const name = '@goodandready/dsh-clinebot'
28
28
  export const inject = ['webServer', 'credentials']
@@ -67,35 +67,62 @@ export function apply(ctx, config) {
67
67
  }
68
68
  }
69
69
 
70
+ const applyConfigMutations = (target, source) => {
71
+ const targetConfig = typeof target === 'function' ? target() : target
72
+ if (targetConfig && typeof targetConfig === 'object' && !Object.isFrozen(targetConfig)) {
73
+ for (const [key, value] of Object.entries(source)) {
74
+ try {
75
+ if (isVolatileRef(targetConfig[key]) && typeof targetConfig[key].set === 'function') {
76
+ targetConfig[key].set(value)
77
+ } else if (Object.getOwnPropertyDescriptor(targetConfig, key)?.set) {
78
+ targetConfig[key] = value
79
+ } else if (!Object.getOwnPropertyDescriptor(targetConfig, key)?.get) {
80
+ targetConfig[key] = value
81
+ }
82
+ } catch {
83
+ /* ignore read-only */
84
+ }
85
+ }
86
+ } else {
87
+ currentConfig = source
88
+ }
89
+ }
90
+
70
91
  settingsApi = {
71
92
  get: () => live(),
72
93
  replace: async (next) => {
73
- const parsed = Config(structuredClone(plainConfig(next)))
74
- currentConfig = parsed
75
94
  const svc = ctx?.get?.('settings')
76
- if (svc) {
77
- const payload = volatileConfig(parsed)
78
- if (typeof svc.replace === 'function') {
79
- await svc.replace(NS, payload, getRevision(svc))
80
- } else if (typeof svc.update === 'function') {
81
- await svc.update(NS, payload, getRevision(svc))
82
- }
95
+ if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
96
+ throw new Error('DSH settings service is unavailable or non-writable')
97
+ }
98
+ const parsed = Config(structuredClone(plainConfig(next)))
99
+ const payload = volatileConfig(parsed)
100
+ const rev = getRevision(svc)
101
+ if (typeof svc.replace === 'function') {
102
+ await svc.replace(NS, payload, rev)
103
+ } else {
104
+ await svc.update(NS, payload, rev)
83
105
  }
106
+ applyConfigMutations(currentConfig, parsed)
107
+ loadStats(publicConfig(live()).statsPath)
84
108
  await syncProviderState(live())
85
109
  return parsed
86
110
  },
87
111
  update: async (patch) => {
88
- const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
89
- currentConfig = merged
90
112
  const svc = ctx?.get?.('settings')
91
- if (svc) {
92
- const payload = volatileConfig(merged)
93
- if (typeof svc.update === 'function') {
94
- await svc.update(NS, payload, getRevision(svc))
95
- } else if (typeof svc.replace === 'function') {
96
- await svc.replace(NS, payload, getRevision(svc))
97
- }
113
+ if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
114
+ throw new Error('DSH settings service is unavailable or non-writable')
98
115
  }
116
+ const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
117
+ const payload = volatileConfig(merged)
118
+ const rev = getRevision(svc)
119
+ if (typeof svc.update === 'function') {
120
+ await svc.update(NS, payload, rev)
121
+ } else {
122
+ await svc.replace(NS, payload, rev)
123
+ }
124
+ applyConfigMutations(currentConfig, merged)
125
+ loadStats(publicConfig(live()).statsPath)
99
126
  await syncProviderState(live())
100
127
  return merged
101
128
  },
@@ -106,6 +133,15 @@ export function apply(ctx, config) {
106
133
  },
107
134
  }
108
135
 
136
+ loadStats(publicConfig(live()).statsPath)
137
+ if (typeof ctx.effect === 'function') {
138
+ ctx.effect(() => {
139
+ return () => {
140
+ flushStats(publicConfig(live()).statsPath)
141
+ }
142
+ }, 'dsh-clinebot: stats-storage')
143
+ }
144
+
109
145
  syncProviderState(live())
110
146
  if (typeof ctx.effect === 'function') {
111
147
  ctx.effect(() => {
@@ -274,10 +274,10 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
274
274
  const hasObsolete = existingDynamic.some((m) => Boolean(OBSOLETE_MODEL_ID_MAP[m?.id]))
275
275
  if (hasObsolete && settingsApi?.replace) {
276
276
  const migrated = existingDynamic.map(migrateModelEntry)
277
- const next = Config({
277
+ const next = plainConfig(Config({
278
278
  ...plainConfig(live()),
279
279
  dynamicModels: migrated,
280
- })
280
+ }))
281
281
  await settingsApi.replace(next)
282
282
  await syncProviderState(next)
283
283
  }
@@ -286,11 +286,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
286
286
  const fromDisk = await loadModelsDiskCache(cacheFile)
287
287
  if (Array.isArray(fromDisk) && fromDisk.length && settingsApi?.replace) {
288
288
  const migratedDisk = fromDisk.map(migrateModelEntry)
289
- const next = Config({
289
+ const next = plainConfig(Config({
290
290
  ...plainConfig(live()),
291
291
  dynamicModels: migratedDisk,
292
292
  planSyncedAt: fromDisk.planSyncedAt || Date.now(),
293
- })
293
+ }))
294
294
  await settingsApi.replace(next)
295
295
  await syncProviderState(next)
296
296
  }
@@ -311,11 +311,11 @@ export async function autoDiscoverPlanModels(ctx, { live, getSettingsApi, syncPr
311
311
 
312
312
  if (hasNew && settingsApi?.replace) {
313
313
  const now = Date.now()
314
- const next = Config({
314
+ const next = plainConfig(Config({
315
315
  ...plainConfig(live()),
316
316
  dynamicModels: usageData.dynamicModels,
317
317
  planSyncedAt: now,
318
- })
318
+ }))
319
319
  await settingsApi.replace(next)
320
320
  await syncProviderState(next)
321
321
  if (cacheFile) {
@@ -78,7 +78,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
78
78
  existingAccounts.push(accountEntry)
79
79
  }
80
80
 
81
- const next = Config({ ...curr, accounts: existingAccounts })
81
+ const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
82
82
  await settingsApi.replace(next)
83
83
  await syncProviderState(next)
84
84
 
@@ -127,31 +127,60 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
127
127
  body = {}
128
128
  }
129
129
  }
130
- const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || '').trim()
130
+ let queryApiKeyEnv = ''
131
+ try {
132
+ const u = new URL(req.url, 'http://127.0.0.1')
133
+ queryApiKeyEnv = u.searchParams.get('apiKeyEnv') || ''
134
+ } catch {
135
+ queryApiKeyEnv = ''
136
+ }
137
+ const targetEnv = String(targetEnvFromUrl || body.apiKeyEnv || queryApiKeyEnv || '').trim()
131
138
  if (!targetEnv) {
132
139
  return writeJson(res, 400, { ok: false, error: 'apiKeyEnv is required' })
133
140
  }
134
141
 
142
+ const isDefaultPattern = /^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)
143
+ if (!isDefaultPattern) {
144
+ return writeJson(res, 400, {
145
+ ok: false,
146
+ error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$.`,
147
+ })
148
+ }
149
+
135
150
  const curr = plainConfig(live())
136
151
  if (targetEnv === curr.apiKeyEnv || targetEnv === DEFAULT_API_KEY_ENV) {
137
152
  return writeJson(res, 400, { ok: false, error: 'Cannot delete the primary account' })
138
153
  }
139
154
 
140
155
  const existingAccounts = Array.isArray(curr.accounts) ? curr.accounts : []
156
+ const accountToDelete = existingAccounts.find((a) => a && a.apiKeyEnv === targetEnv)
157
+ if (!accountToDelete) {
158
+ return writeJson(res, 404, {
159
+ ok: false,
160
+ error: `Account "${targetEnv}" not found in accounts pool`,
161
+ })
162
+ }
163
+
164
+ if (body.deleteSecret) {
165
+ const delRes = await deleteCredentialKey(ctx, targetEnv)
166
+ if (!delRes || !delRes.ok) {
167
+ return writeJson(res, 500, {
168
+ ok: false,
169
+ error: `Failed to delete secret for account "${targetEnv}" from credentials store`,
170
+ })
171
+ }
172
+ }
173
+
141
174
  const nextAccounts = existingAccounts.filter((a) => a && a.apiKeyEnv !== targetEnv)
142
175
  let activeAccount = curr.activeAccount
143
176
  if (activeAccount === targetEnv) {
144
177
  activeAccount = ''
145
178
  }
146
179
 
147
- const next = Config({ ...curr, accounts: nextAccounts, activeAccount })
180
+ const next = plainConfig(Config({ ...curr, accounts: nextAccounts, activeAccount }))
148
181
  await settingsApi.replace(next)
149
182
  await syncProviderState(next)
150
183
 
151
- if (body.deleteSecret) {
152
- await deleteCredentialKey(ctx, targetEnv)
153
- }
154
-
155
184
  clearUsageCache()
156
185
  clearProbeCache()
157
186
 
@@ -193,7 +222,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
193
222
  return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
194
223
  }
195
224
 
196
- const next = Config({ ...plainConfig(live()), activeAccount: account })
225
+ const next = plainConfig(Config({ ...plainConfig(live()), activeAccount: account }))
197
226
  await settingsApi.replace(next)
198
227
  await syncProviderState(next)
199
228
  writeJson(res, 200, { ok: true, activeAccount: account })
@@ -40,11 +40,11 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
40
40
  }
41
41
 
42
42
  const now = Date.now()
43
- const next = Config({
43
+ const next = plainConfig(Config({
44
44
  ...plainConfig(live()),
45
45
  dynamicModels,
46
46
  planSyncedAt: now,
47
- })
47
+ }))
48
48
  await settingsApi.replace(next)
49
49
  await syncProviderState(next)
50
50
  const cacheFile = resolvePathWithHome(pub.modelsCachePath)
@@ -92,7 +92,7 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
92
92
  patch.disabledModels = allModels.map((m) => m.id).filter((id) => !enabledSet.has(id))
93
93
  }
94
94
  if (body.defaultModel) patch.defaultModel = body.defaultModel
95
- const next = Config({ ...plainConfig(live()), ...patch })
95
+ const next = plainConfig(Config({ ...plainConfig(live()), ...patch }))
96
96
  await settingsApi.replace(next)
97
97
  await syncProviderState(next)
98
98
  writeJson(res, 200, { ok: true, disabledModels: next.disabledModels, enabledModels: publicConfig(next).enabledModels })
@@ -164,10 +164,10 @@ export function registerModelsRoutes(ctx, { live, getSettingsApi, syncProviderSt
164
164
  return writeJson(res, 400, { ok: false, error: 'Missing modelId, mode, or overrides array' })
165
165
  }
166
166
 
167
- const next = Config({
167
+ const next = plainConfig(Config({
168
168
  ...plainConfig(live()),
169
169
  modelContextOverrides: currentOverrides,
170
- })
170
+ }))
171
171
  await settingsApi.replace(next)
172
172
  await syncProviderState(next)
173
173
 
@@ -26,7 +26,7 @@ import { getLocalProxyToken } from '../proxy-token.js'
26
26
  const require = createRequire(import.meta.url)
27
27
  let packageVersion = '0.5.3'
28
28
  try {
29
- packageVersion = require('../../package.json').version || '0.5.3'
29
+ packageVersion = require('../../package.json').version || '0.5.6'
30
30
  } catch (err) {
31
31
  /* fallback to default version if manifest is unreadable */
32
32
  }
@@ -93,7 +93,7 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
93
93
  const base = plainConfig(live()) || {}
94
94
  delete base.enabledModels
95
95
  const merged = { ...base, ...rawPayload }
96
- const parsed = Config(merged)
96
+ const parsed = plainConfig(Config(merged))
97
97
  await settingsApi.replace(parsed)
98
98
  await syncProviderState(parsed)
99
99
  writeJson(res, 200, { ok: true, config: publicConfig(live()) })
@@ -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 } from 'node:fs'
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
@@ -84,9 +84,9 @@ export function resolvePath(targetPath) {
84
84
  * @returns {Object} Loaded stats
85
85
  */
86
86
  export function loadStats(customPath) {
87
+ const filePath = resolvePath(customPath)
88
+ activeStatsPath = filePath
87
89
  try {
88
- const filePath = resolvePath(customPath)
89
- activeStatsPath = filePath
90
90
  if (existsSync(filePath)) {
91
91
  const raw = readFileSync(filePath, 'utf8')
92
92
  const parsed = JSON.parse(raw)
@@ -103,14 +103,35 @@ export function loadStats(customPath) {
103
103
  return inMemoryStats
104
104
  }
105
105
  }
106
- } catch {
107
- // If reading fails or file is corrupted, fallback to clean stats
106
+ } catch (err) {
107
+ // If reading fails or file is corrupted, preserve corrupted file for diagnostics
108
+ try {
109
+ if (existsSync(filePath)) {
110
+ const corruptBackup = `${filePath}.corrupt.${Date.now()}`
111
+ copyFileSync(filePath, corruptBackup)
112
+ }
113
+ } catch {
114
+ /* ignore backup failure */
115
+ }
108
116
  }
109
117
 
110
118
  inMemoryStats = createEmptyStats()
111
119
  return inMemoryStats
112
120
  }
113
121
 
122
+ /**
123
+ * Flush any debounced pending writes immediately to disk.
124
+ *
125
+ * @param {string} [customPath]
126
+ */
127
+ export function flushStats(customPath) {
128
+ if (saveTimer) {
129
+ clearTimeout(saveTimer)
130
+ saveTimer = null
131
+ }
132
+ saveStatsSync(customPath)
133
+ }
134
+
114
135
  /**
115
136
  * Synchronously write stats to disk using atomic rename.
116
137
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.4",
3
+ "version": "0.5.6",
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",