@goodandready/dsh-clinebot 0.5.0 → 0.5.2

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.5.2] - 2026-09-28
9
+
10
+ ### Security
11
+ - **Proxy Endpoints Local Bearer Auth & CSRF Protection (Issue #145)**: Secured OpenAI-compatible proxy routes (`/dsh-clinebot/v1/chat/completions` and `/dsh-clinebot/v1/models`) by generating a unique local proxy token (`CLINEBOT_LOCAL_PROXY_TOKEN`) passed as `apiKey` to the DSH provider. Requests missing or with invalid Bearer tokens receive HTTP 401 Unauthorized. Incoming browser requests containing `Origin` or `Sec-Fetch-Site: cross-site|same-site` are rejected with HTTP 403 Forbidden to protect against LAN abuse and cross-site request forgery.
12
+
13
+ ### Added & Improved
14
+ - **Test Suite Isolation Sandbox (Issue #146)**: Introduced `test/_setup.mjs` configuring a dedicated temporary filesystem sandbox for `HOME` and `DSH_HOME` during automated test runs. Updated `lib/stats-storage.js` and `lib/provider-sync.js` to respect `DSH_HOME`. Added protective test verifying real `~/.dsh` files are untouched.
15
+ - **Dynamic Proxy User-Agent & Upstream Timeout Failover (Issue #147)**: User-Agent in proxy upstream requests now dynamically reflects the exact package version from `package.json`. Upstream completions use an abort timeout (`AbortSignal.any`); upon timing out, the request automatically rotates to the next available account in the pool, returning HTTP 504 Gateway Timeout if all accounts fail.
16
+ - **Self-Updater Concurrency Lock & Argument Cleanup (Issue #148)**: Removed deprecated `--config.minimumReleaseAge=0` flag from `dsh plugin add` invocations. Added `package.json.lock` verification inspecting PID liveness: active installations return HTTP 409 Conflict with PID, while stale locks from terminated processes are cleaned up automatically.
17
+
18
+ ## [0.5.1] - 2026-09-28
19
+
20
+ ### Fixed
21
+ - **Host Process Crash on Stats Save (Issue #141, GitHub #9)**: Safely unwrap volatile Schemastery `{ get }` references, getter functions, and non-string inputs in `resolvePath`. Wrapped `saveStatsSync` in `try/catch` inside `queueSaveStats` timer callback to guarantee disk persistence failures never become uncaught exceptions terminating the DSH host process. Updated `lib/index.js` to pass `publicConfig(live()).statsPath`.
22
+ - **Large Context / Multimodal Proxy Rejection (Issue #142, GitHub #10)**: Increased request body reading limit on `/dsh-clinebot/v1/chat/completions` from 256 KB to 64 MB, preventing HTTP 400 `"body too large"` errors on long conversational sessions and multimodal requests.
23
+ - **Model Cache Path Resolution (Issue #143)**: Added safe volatile reference and getter unwrapping to `resolvePathWithHome` in `lib/provider-sync.js`, preventing `TypeError [ERR_INVALID_ARG_TYPE]` on disk cache operations.
24
+
8
25
  ## [0.5.0] - 2026-09-27
9
26
 
10
27
  ### Security
@@ -448,6 +448,7 @@ export async function smokeChat(baseUrl, apiKey, {
448
448
  export function buildPiAiProvider({
449
449
  baseUrl = DEFAULT_BASE_URL,
450
450
  apiKeyEnv = DEFAULT_API_KEY_ENV,
451
+ apiKey,
451
452
  models = [],
452
453
  customModels = [],
453
454
  displayName = PROVIDER_DISPLAY_NAME,
@@ -500,6 +501,7 @@ export function buildPiAiProvider({
500
501
  api: 'openai-completions',
501
502
  baseURL: normalizeBaseUrl(baseUrl),
502
503
  apiKeyEnv: apiKeyEnv || DEFAULT_API_KEY_ENV,
504
+ ...(apiKey ? { apiKey } : {}),
503
505
  compat: { supportsReasoningEffort: true },
504
506
  models: modelList,
505
507
  }
package/lib/index.js CHANGED
@@ -210,7 +210,7 @@ export function apply(ctx, config) {
210
210
  totalTokens: promptTokens + completionTokens,
211
211
  isError: true,
212
212
  is429,
213
- statsPath: live().statsPath,
213
+ statsPath: publicConfig(live()).statsPath,
214
214
  })
215
215
  if (is429 || isExceeded) {
216
216
  handleStream429()
@@ -237,7 +237,7 @@ export function apply(ctx, config) {
237
237
  promptTokens,
238
238
  completionTokens,
239
239
  totalTokens: promptTokens + completionTokens,
240
- statsPath: live().statsPath,
240
+ statsPath: publicConfig(live()).statsPath,
241
241
  })
242
242
  }
243
243
  }
@@ -256,7 +256,7 @@ export function apply(ctx, config) {
256
256
  promptTokens,
257
257
  completionTokens,
258
258
  totalTokens: promptTokens + completionTokens,
259
- statsPath: live().statsPath,
259
+ statsPath: publicConfig(live()).statsPath,
260
260
  })
261
261
  }
262
262
  } catch (err) {
@@ -279,7 +279,7 @@ export function apply(ctx, config) {
279
279
  totalTokens: promptTokens + completionTokens,
280
280
  isError: true,
281
281
  is429: status === 429,
282
- statsPath: live().statsPath,
282
+ statsPath: publicConfig(live()).statsPath,
283
283
  })
284
284
  if (status === 429 || isQuotaExceededError(msg)) {
285
285
  handleStream429()
@@ -21,10 +21,26 @@ import { publicConfig, plainConfig, Config, LLM_PI_AI_NS } from './config.js'
21
21
  import { publicUsage } from './http.js'
22
22
  import { resolveKeyValue } from './credential-refs.js'
23
23
  import { resolveAccountPool, getLastRotation } from './account-pool.js'
24
+ import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
25
+ import { saveCredentialKey } from './credential-refs.js'
24
26
 
25
27
  export function resolvePathWithHome(p) {
26
- if (typeof p === 'string' && p.startsWith('~/')) {
27
- return path.join(os.homedir(), p.slice(2))
28
+ let guard = 0
29
+ while (guard < 5 && p) {
30
+ if (typeof p === 'function') {
31
+ try { p = p() } catch { p = ''; break }
32
+ guard += 1
33
+ } else if (typeof p === 'object' && typeof p.get === 'function') {
34
+ try { p = p.get() } catch { p = ''; break }
35
+ guard += 1
36
+ } else {
37
+ break
38
+ }
39
+ }
40
+ if (typeof p !== 'string') return ''
41
+ if (p.startsWith('~/') || p === '~') {
42
+ const home = process.env.HOME || os.homedir()
43
+ return path.join(home, p.slice(1))
28
44
  }
29
45
  return p
30
46
  }
@@ -183,13 +199,24 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
183
199
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
184
200
 
185
201
  const port = ctx?.webServer?.port || process.env.PORT || 3080
186
- const effectiveBaseUrl = pub.proxyMode !== false
202
+ const isProxy = pub.proxyMode !== false
203
+ const effectiveBaseUrl = isProxy
187
204
  ? `http://127.0.0.1:${port}/dsh-clinebot/v1`
188
205
  : pub.baseUrl
189
206
 
207
+ const localToken = isProxy ? getLocalProxyToken() : undefined
208
+ if (isProxy && localToken) {
209
+ try {
210
+ await saveCredentialKey(ctx, LOCAL_PROXY_KEY_ENV, localToken)
211
+ } catch {
212
+ /* credentials service might be absent/read-only in tests; process.env is set */
213
+ }
214
+ }
215
+
190
216
  const providerObj = buildPiAiProvider({
191
217
  baseUrl: effectiveBaseUrl,
192
218
  apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
219
+ apiKey: localToken,
193
220
  models: modelsToRegister.length ? modelsToRegister : allModels,
194
221
  customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
195
222
  displayName: PROVIDER_DISPLAY_NAME,
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Local Proxy Authentication Token (Issue #145)
3
+ *
4
+ * Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
5
+ * LAN clients or cross-site browser requests (CSRF).
6
+ */
7
+
8
+ import crypto from 'node:crypto'
9
+
10
+ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
11
+
12
+ let cachedToken = null
13
+
14
+ /**
15
+ * Get or generate the local proxy bearer token.
16
+ *
17
+ * @returns {string}
18
+ */
19
+ export function getLocalProxyToken() {
20
+ if (cachedToken) return cachedToken
21
+ if (process.env[LOCAL_PROXY_KEY_ENV]) {
22
+ cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
23
+ return cachedToken
24
+ }
25
+ cachedToken = crypto.randomBytes(24).toString('hex')
26
+ process.env[LOCAL_PROXY_KEY_ENV] = cachedToken
27
+ return cachedToken
28
+ }
29
+
30
+ /**
31
+ * Manually set local proxy token (for testing).
32
+ *
33
+ * @param {string|null} token
34
+ */
35
+ export function setLocalProxyToken(token) {
36
+ cachedToken = token
37
+ if (token) {
38
+ process.env[LOCAL_PROXY_KEY_ENV] = token
39
+ } else {
40
+ delete process.env[LOCAL_PROXY_KEY_ENV]
41
+ }
42
+ }
@@ -1,23 +1,56 @@
1
1
  /**
2
- * Transparent Loopback Proxy & Zero-Downtime Failover (Issue #127)
2
+ * Transparent Loopback Proxy & Zero-Downtime Failover (Issues #127, #145, #147)
3
3
  *
4
4
  * Exposes local OpenAI-compatible endpoints:
5
5
  * - POST /dsh-clinebot/v1/chat/completions
6
6
  * - GET /dsh-clinebot/v1/models
7
7
  *
8
8
  * Implements:
9
- * 1. Sticky session routing with least-used quota allocation.
10
- * 2. Transparent upstream retry on 429 / quota exhaustion before first chunk is streamed.
11
- * 3. Accurate streaming SSE token usage interception & persistent telemetry recording.
12
- * 4. Model reasoning_effort default injection from config.
9
+ * 1. Bearer local proxy token verification (Issue #145).
10
+ * 2. Origin & Sec-Fetch-Site rejection to prevent cross-site/browser CSRF attacks (Issue #145).
11
+ * 3. Dynamic User-Agent extraction from package.json (Issue #147).
12
+ * 4. Configurable upstream timeout with multi-account failover and 504 handling (Issue #147).
13
+ * 5. Sticky session routing with least-used quota allocation.
14
+ * 6. Streaming SSE token usage interception & persistent telemetry recording.
13
15
  */
14
16
 
17
+ import { createRequire } from 'node:module'
15
18
  import { writeJson, readBody, isLoopbackAddress } from '../http.js'
16
19
  import { publicConfig, plainConfig } from '../config.js'
17
20
  import { resolveAccountPool } from '../account-pool.js'
18
21
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
19
22
  import { recordUsage } from '../stats-storage.js'
20
23
  import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
+ import { getLocalProxyToken } from '../proxy-token.js'
25
+
26
+ const require = createRequire(import.meta.url)
27
+ let packageVersion = '0.5.2'
28
+ try {
29
+ packageVersion = require('../../package.json').version || '0.5.2'
30
+ } catch (err) {
31
+ /* fallback to default version if manifest is unreadable */
32
+ }
33
+
34
+ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
35
+
36
+ export function hasDisallowedBrowserOrigin(req) {
37
+ const secFetchSite = req.headers?.['sec-fetch-site']
38
+ if (secFetchSite === 'cross-site' || secFetchSite === 'same-site') {
39
+ return true
40
+ }
41
+ if (req.headers?.['origin']) {
42
+ return true
43
+ }
44
+ return false
45
+ }
46
+
47
+ export function isAuthorizedProxyRequest(req) {
48
+ const auth = req.headers?.['authorization'] || ''
49
+ const match = auth.match(/^Bearer\s+(.+)$/i)
50
+ if (!match) return false
51
+ const token = match[1].trim()
52
+ return Boolean(token && token === getLocalProxyToken())
53
+ }
21
54
 
22
55
  export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
56
  // 1. POST /dsh-clinebot/v1/chat/completions
@@ -33,9 +66,17 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
33
66
  return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
34
67
  }
35
68
 
69
+ if (hasDisallowedBrowserOrigin(req)) {
70
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
71
+ }
72
+
73
+ if (!isAuthorizedProxyRequest(req)) {
74
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
75
+ }
76
+
36
77
  let bodyBuf
37
78
  try {
38
- bodyBuf = await readBody(req)
79
+ bodyBuf = await readBody(req, 64 * 1024 * 1024)
39
80
  } catch (err) {
40
81
  return writeJson(res, 400, { ok: false, error: String(err?.message || err) })
41
82
  }
@@ -72,19 +113,20 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
72
113
  body.reasoning_effort = pub.modelReasoningDefaults[body.model]
73
114
  }
74
115
 
116
+ const isStreaming = Boolean(body.stream)
75
117
  const pool = await resolveAccountPool(ctx, cfg)
76
- const isStreaming = Boolean(body.stream) || String(req.headers.accept || '').includes('text/event-stream')
118
+ const configuredAccounts = pool.filter(a => a.present && a.value)
77
119
 
78
- // Transparent retry loop (up to max configured accounts, min 1, max 3)
79
- const maxRetries = Math.min(Math.max(1, pool.length), 3)
120
+ const maxRetries = Math.max(1, configuredAccounts.length)
80
121
  let attempt = 0
81
122
  let lastError = null
82
- let targetAccount = null
123
+ let hadTimeout = false
83
124
 
84
125
  while (attempt < maxRetries) {
85
- attempt++
126
+ attempt += 1
127
+
86
128
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
87
- targetAccount = routeResult.account
129
+ const targetAccount = routeResult.account
88
130
 
89
131
  if (!targetAccount || !targetAccount.value) {
90
132
  return writeJson(res, 503, {
@@ -101,8 +143,19 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
101
143
  'Content-Type': 'application/json',
102
144
  'Authorization': `Bearer ${targetAccount.value}`,
103
145
  'Accept': isStreaming ? 'text/event-stream' : 'application/json',
104
- 'User-Agent': '@goodandready/dsh-clinebot/0.5.0'
146
+ 'User-Agent': USER_AGENT
147
+ }
148
+
149
+ const upstreamTimeoutMs = Math.max(1000, Number(pub.timeoutMs) || 60000)
150
+ const timeoutSignal = AbortSignal.timeout(upstreamTimeoutMs)
151
+ const clientAbortCtrl = new AbortController()
152
+ const onReqClose = () => {
153
+ clientAbortCtrl.abort()
154
+ }
155
+ if (typeof req.on === 'function') {
156
+ req.on('close', onReqClose)
105
157
  }
158
+ const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, timeoutSignal])
106
159
 
107
160
  let upstreamRes
108
161
  try {
@@ -110,15 +163,26 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
110
163
  method: 'POST',
111
164
  headers: requestHeaders,
112
165
  body: JSON.stringify(body),
166
+ signal: fetchSignal,
113
167
  keepalive: true
114
168
  })
115
169
  } catch (fetchErr) {
170
+ if (clientAbortCtrl.signal.aborted) {
171
+ // Client closed connection, stop processing
172
+ return
173
+ }
116
174
  lastError = fetchErr
117
- // Network errors: retry with next account if available
175
+ const isTimeout = timeoutSignal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
176
+ if (isTimeout) {
177
+ hadTimeout = true
178
+ const accountId = targetAccount.id || targetAccount.label
179
+ markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
180
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream timed out after ${upstreamTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
181
+ }
118
182
  continue
119
183
  }
120
184
 
121
- // If upstream rejected with 429 or 403 quota exhaustion before streaming, failover!
185
+ // If upstream rejected with 429 or 402 quota exhaustion before streaming, failover!
122
186
  if (upstreamRes.status === 429 || upstreamRes.status === 402) {
123
187
  const accountId = targetAccount.id || targetAccount.label
124
188
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
@@ -152,7 +216,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
152
216
  const decoder = new TextDecoder('utf8')
153
217
 
154
218
  let streamAborted = false
155
- const onClientAbort = () => {
219
+ const onStreamAbort = () => {
156
220
  streamAborted = true
157
221
  try {
158
222
  reader.cancel().catch(() => {})
@@ -161,7 +225,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
161
225
  }
162
226
  }
163
227
  if (typeof req.on === 'function') {
164
- req.on('close', onClientAbort)
228
+ req.on('close', onStreamAbort)
165
229
  }
166
230
 
167
231
  try {
@@ -175,47 +239,48 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
175
239
  // Parse SSE lines for usage metrics
176
240
  buffer += chunkStr
177
241
  const lines = buffer.split('\n')
178
- buffer = lines.pop() // keep incomplete last line
242
+ buffer = lines.pop() || ''
179
243
 
180
244
  for (const line of lines) {
181
245
  const trimmed = line.trim()
182
- if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
246
+ if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
183
247
  try {
184
- const data = JSON.parse(trimmed.slice(6))
185
- if (data?.usage) {
186
- promptTokens = Number(data.usage.prompt_tokens) || promptTokens
187
- completionTokens = Number(data.usage.completion_tokens) || completionTokens
188
- totalTokens = Number(data.usage.total_tokens) || totalTokens
248
+ const parsed = JSON.parse(trimmed.slice(5).trim())
249
+ if (parsed.usage) {
250
+ promptTokens = parsed.usage.prompt_tokens || promptTokens
251
+ completionTokens = parsed.usage.completion_tokens || completionTokens
252
+ totalTokens = parsed.usage.total_tokens || totalTokens
189
253
  }
190
254
  } catch {
191
- /* ignore partial or non-json SSE lines */
255
+ /* ignore JSON chunk parse error */
192
256
  }
193
257
  }
194
258
  }
195
259
  }
196
260
  } catch (streamErr) {
197
- if (!streamAborted) {
198
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
199
- }
261
+ ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
200
262
  } finally {
201
- if (typeof req.off === 'function') {
202
- req.off('close', onClientAbort)
263
+ try {
264
+ reader.releaseLock()
265
+ } catch (err) {
266
+ /* ignore lock release error if reader already closed */
203
267
  }
204
268
  res.end()
269
+
205
270
  recordUsage({
206
271
  model: body.model,
207
272
  accountId: targetAccount.id || targetAccount.label,
208
273
  promptTokens,
209
274
  completionTokens,
210
- totalTokens,
275
+ totalTokens: totalTokens || (promptTokens + completionTokens),
211
276
  statsPath: pub.statsPath
212
277
  })
213
278
  }
214
279
  return
215
280
  }
216
281
 
217
- // Non-streaming JSON response or error status from upstream
218
- const resText = await upstreamRes.text()
282
+ // Non-streaming JSON response
283
+ const resText = await upstreamRes.text().catch(() => '{}')
219
284
  let resJson = null
220
285
  try {
221
286
  resJson = JSON.parse(resText)
@@ -242,6 +307,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
242
307
  }
243
308
 
244
309
  // Exhausted all retries
310
+ if (hadTimeout) {
311
+ return writeJson(res, 504, {
312
+ error: {
313
+ message: 'Gateway Timeout: upstream provider timed out across all available accounts',
314
+ type: 'gateway_timeout',
315
+ code: 'upstream_timeout'
316
+ }
317
+ })
318
+ }
319
+
245
320
  writeJson(res, 429, {
246
321
  error: {
247
322
  message: 'All configured accounts in pool exceeded rate limits or quota',
@@ -265,6 +340,15 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
265
340
  if (!isLoopbackAddress(req.socket?.remoteAddress)) {
266
341
  return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
267
342
  }
343
+
344
+ if (hasDisallowedBrowserOrigin(req)) {
345
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
346
+ }
347
+
348
+ if (!isAuthorizedProxyRequest(req)) {
349
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
350
+ }
351
+
268
352
  const pub = publicConfig(live())
269
353
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
270
354
  const custom = Array.isArray(pub.customModels) ? pub.customModels : []
@@ -40,12 +40,39 @@ export function createEmptyStats() {
40
40
  /**
41
41
  * Resolve '~' in paths to user's home directory.
42
42
  */
43
+ /**
44
+ * Get active .dsh directory, respecting DSH_HOME and HOME environment variables.
45
+ *
46
+ * @returns {string}
47
+ */
48
+ export function getDshHome() {
49
+ if (process.env.DSH_HOME) return process.env.DSH_HOME
50
+ const home = process.env.HOME || homedir()
51
+ return path.join(home, '.dsh')
52
+ }
53
+
43
54
  export function resolvePath(targetPath) {
55
+ let unwrapGuard = 0
56
+ while (unwrapGuard < 5 && targetPath) {
57
+ if (typeof targetPath === 'function') {
58
+ try { targetPath = targetPath() } catch { targetPath = ''; break }
59
+ unwrapGuard += 1
60
+ } else if (typeof targetPath === 'object' && typeof targetPath.get === 'function') {
61
+ try { targetPath = targetPath.get() } catch { targetPath = ''; break }
62
+ unwrapGuard += 1
63
+ } else {
64
+ break
65
+ }
66
+ }
67
+ if (typeof targetPath !== 'string') {
68
+ targetPath = ''
69
+ }
44
70
  if (!targetPath) {
45
- return path.join(homedir(), '.dsh', 'clinebot-stats.json')
71
+ return path.join(getDshHome(), 'clinebot-stats.json')
46
72
  }
47
73
  if (targetPath.startsWith('~/') || targetPath === '~') {
48
- return path.join(homedir(), targetPath.slice(1))
74
+ const home = process.env.HOME || homedir()
75
+ return path.join(home, targetPath.slice(1))
49
76
  }
50
77
  return path.resolve(targetPath)
51
78
  }
@@ -57,10 +84,9 @@ export function resolvePath(targetPath) {
57
84
  * @returns {Object} Loaded stats
58
85
  */
59
86
  export function loadStats(customPath) {
60
- const filePath = resolvePath(customPath)
61
- activeStatsPath = filePath
62
-
63
87
  try {
88
+ const filePath = resolvePath(customPath)
89
+ activeStatsPath = filePath
64
90
  if (existsSync(filePath)) {
65
91
  const raw = readFileSync(filePath, 'utf8')
66
92
  const parsed = JSON.parse(raw)
@@ -91,10 +117,10 @@ export function loadStats(customPath) {
91
117
  * @param {string} [customPath]
92
118
  */
93
119
  export function saveStatsSync(customPath) {
94
- const filePath = resolvePath(customPath || activeStatsPath)
95
- activeStatsPath = filePath
96
-
97
120
  try {
121
+ const filePath = resolvePath(customPath || activeStatsPath)
122
+ activeStatsPath = filePath
123
+
98
124
  const dir = path.dirname(filePath)
99
125
  if (!existsSync(dir)) {
100
126
  mkdirSync(dir, { recursive: true })
@@ -123,7 +149,11 @@ function queueSaveStats(customPath, debounceMs = 500) {
123
149
  }
124
150
  saveTimer = setTimeout(() => {
125
151
  saveTimer = null
126
- saveStatsSync(customPath)
152
+ try {
153
+ saveStatsSync(customPath)
154
+ } catch {
155
+ // Best-effort disk persistence, never crash host process
156
+ }
127
157
  }, debounceMs)
128
158
  }
129
159
 
package/lib/updater.js CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
 
6
6
  import { spawn } from 'node:child_process'
7
- import { existsSync, readFileSync } from 'node:fs'
7
+ import { existsSync, readFileSync, unlinkSync } from 'node:fs'
8
8
  import { readFile } from 'node:fs/promises'
9
9
  import { homedir } from 'node:os'
10
10
  import { basename, dirname, isAbsolute, resolve } from 'node:path'
@@ -170,6 +170,41 @@ async function readCurrentVersion(manifestUrl) {
170
170
  return value.version
171
171
  }
172
172
 
173
+ export function isPidAlive(pid) {
174
+ if (!pid || typeof pid !== 'number' || pid <= 0) return false
175
+ try {
176
+ process.kill(pid, 0)
177
+ return true
178
+ } catch (err) {
179
+ return err.code === 'EPERM'
180
+ }
181
+ }
182
+
183
+ export function checkProfileLock(profileDir) {
184
+ if (!profileDir) return { locked: false }
185
+ const lockFile = resolve(profileDir, 'package.json.lock')
186
+ if (!existsSync(lockFile)) return { locked: false }
187
+ try {
188
+ const content = readFileSync(lockFile, 'utf8')
189
+ const pid = parseInt(content.trim(), 10)
190
+ if (!Number.isNaN(pid) && pid > 0) {
191
+ if (isPidAlive(pid)) {
192
+ return { locked: true, pid, alive: true }
193
+ } else {
194
+ try {
195
+ unlinkSync(lockFile)
196
+ } catch (err) {
197
+ /* best-effort cleanup of stale lock */
198
+ }
199
+ return { locked: false, stalePid: pid }
200
+ }
201
+ }
202
+ } catch {
203
+ /* lock cannot be read */
204
+ }
205
+ return { locked: false }
206
+ }
207
+
173
208
  export async function checkUpdateStatus(options, target = detectRuntime()) {
174
209
  const current = await readCurrentVersion(options.manifestUrl)
175
210
  const latest = await fetchLatestVersion(options.packageName, options.registry || 'https://registry.npmjs.org')
@@ -189,7 +224,7 @@ async function installExact(target, packageSpec, options) {
189
224
  await new Promise((resolvePromise, reject) => {
190
225
  const child = spawn(process.execPath, [
191
226
  target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
192
- '--config.minimumReleaseAge=0', packageSpec,
227
+ packageSpec,
193
228
  `--registry=${options.registry || 'https://registry.npmjs.org/'}`,
194
229
  ], {
195
230
  cwd: target.profileDir,
@@ -201,7 +236,31 @@ async function installExact(target, packageSpec, options) {
201
236
  child.stdout?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
202
237
  child.stderr?.on('data', (chunk) => { detail = (detail + String(chunk)).slice(-4000) })
203
238
  const timer = setTimeout(() => {
204
- child.kill()
239
+ child.kill('SIGTERM')
240
+ setTimeout(() => {
241
+ if (!child.killed) {
242
+ try {
243
+ child.kill('SIGKILL')
244
+ } catch (err) {
245
+ /* ignore if process already terminated */
246
+ }
247
+ }
248
+ setTimeout(() => {
249
+ if (target.profileDir) {
250
+ const lockFile = resolve(target.profileDir, 'package.json.lock')
251
+ if (existsSync(lockFile)) {
252
+ try {
253
+ const pid = parseInt(readFileSync(lockFile, 'utf8').trim(), 10)
254
+ if (!isPidAlive(pid)) {
255
+ unlinkSync(lockFile)
256
+ }
257
+ } catch (err) {
258
+ /* best-effort stale lock unlink */
259
+ }
260
+ }
261
+ }
262
+ }, 100)
263
+ }, 500)
205
264
  reject(new Error('Update timed out.'))
206
265
  }, UPDATE_TIMEOUT_MS)
207
266
  child.once('error', (error) => { clearTimeout(timer); reject(error) })
@@ -248,6 +307,15 @@ export function registerPluginUpdater(ctx, options) {
248
307
  writeResponseJson(response, 409, { ok: false, error: 'This plugin is already updating.' })
249
308
  return
250
309
  }
310
+ const lockInfo = checkProfileLock(target.profileDir)
311
+ if (lockInfo.locked && lockInfo.alive) {
312
+ writeResponseJson(response, 409, {
313
+ ok: false,
314
+ error: `Another package installation is currently in progress (PID: ${lockInfo.pid}).`,
315
+ pid: lockInfo.pid
316
+ })
317
+ return
318
+ }
251
319
  installing = true
252
320
  try {
253
321
  const before = await checkUpdateStatus(options, target)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
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",
@@ -39,7 +39,7 @@
39
39
  },
40
40
  "scripts": {
41
41
  "build:client": "node scripts/build-client.js",
42
- "test": "node scripts/build-client.js && node --test test/*.test.js"
42
+ "test": "node scripts/build-client.js && node --import ./test/_setup.mjs --test test/*.test.js"
43
43
  },
44
44
  "engines": {
45
45
  "node": ">=20"