@goodandready/dsh-clinebot 0.5.1 → 0.5.3

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,21 @@ 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.3] - 2026-09-28
9
+
10
+ ### Fixed
11
+ - **Proxy Token Routing via DSH llm-pi-ai & Dual Bearer Auth (Issue #150, GitHub #11)**: In `@deepseek-ai/dsh-llm-pi-ai@0.1.7-rc.2`, the streaming runner (`streamWithSnapshot`) resolves request authorization strictly from `profile.apiKeyEnv` and completely ignores the literal `apiKey` field in the provider descriptor. When `proxyMode` is active, `upsertPiAiProvider` now correctly registers `apiKeyEnv: LOCAL_PROXY_KEY_ENV` (`CLINEBOT_LOCAL_PROXY_TOKEN`), while `lib/proxy-token.js` populates the credential store with the generated token value. In addition, `isAuthorizedProxyRequest` now accepts requests authenticated with either `CLINEBOT_LOCAL_PROXY_TOKEN` or any valid account key configured in the active account pool, ensuring uninterrupted communication across streaming, non-streaming, and diagnostic invocations without HTTP 401 Unauthorized rejections.
12
+
13
+ ## [0.5.2] - 2026-09-28
14
+
15
+ ### Security
16
+ - **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.
17
+
18
+ ### Added & Improved
19
+ - **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.
20
+ - **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.
21
+ - **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.
22
+
8
23
  ## [0.5.1] - 2026-09-28
9
24
 
10
25
  ### Fixed
@@ -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
  }
@@ -21,6 +21,8 @@ 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
28
  let guard = 0
@@ -37,7 +39,8 @@ export function resolvePathWithHome(p) {
37
39
  }
38
40
  if (typeof p !== 'string') return ''
39
41
  if (p.startsWith('~/') || p === '~') {
40
- return path.join(os.homedir(), p.slice(1))
42
+ const home = process.env.HOME || os.homedir()
43
+ return path.join(home, p.slice(1))
41
44
  }
42
45
  return p
43
46
  }
@@ -196,13 +199,24 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
196
199
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
197
200
 
198
201
  const port = ctx?.webServer?.port || process.env.PORT || 3080
199
- const effectiveBaseUrl = pub.proxyMode !== false
202
+ const isProxy = pub.proxyMode !== false
203
+ const effectiveBaseUrl = isProxy
200
204
  ? `http://127.0.0.1:${port}/dsh-clinebot/v1`
201
205
  : pub.baseUrl
202
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
+
203
216
  const providerObj = buildPiAiProvider({
204
217
  baseUrl: effectiveBaseUrl,
205
- apiKeyEnv: activeAcc?.apiKeyEnv || pub.apiKeyEnv,
218
+ apiKeyEnv: isProxy ? LOCAL_PROXY_KEY_ENV : (activeAcc?.apiKeyEnv || pub.apiKeyEnv),
219
+ apiKey: localToken,
206
220
  models: modelsToRegister.length ? modelsToRegister : allModels,
207
221
  customModels: [...(pub.dynamicModels || []), ...(pub.customModels || [])],
208
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,66 @@
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.3'
28
+ try {
29
+ packageVersion = require('../../package.json').version || '0.5.3'
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, accountPool = []) {
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
+ if (!token) return false
53
+ const localToken = getLocalProxyToken()
54
+ if (localToken && token === localToken) {
55
+ return true
56
+ }
57
+ if (Array.isArray(accountPool) && accountPool.length > 0) {
58
+ if (accountPool.some((acc) => acc.value && acc.value === token)) {
59
+ return true
60
+ }
61
+ }
62
+ return false
63
+ }
21
64
 
22
65
  export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
66
  // 1. POST /dsh-clinebot/v1/chat/completions
@@ -33,6 +76,15 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
33
76
  return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
34
77
  }
35
78
 
79
+ if (hasDisallowedBrowserOrigin(req)) {
80
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
81
+ }
82
+
83
+ const pool = await resolveAccountPool(ctx, live())
84
+ if (!isAuthorizedProxyRequest(req, pool)) {
85
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
86
+ }
87
+
36
88
  let bodyBuf
37
89
  try {
38
90
  bodyBuf = await readBody(req, 64 * 1024 * 1024)
@@ -72,19 +124,19 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
72
124
  body.reasoning_effort = pub.modelReasoningDefaults[body.model]
73
125
  }
74
126
 
75
- const pool = await resolveAccountPool(ctx, cfg)
76
- const isStreaming = Boolean(body.stream) || String(req.headers.accept || '').includes('text/event-stream')
127
+ const isStreaming = Boolean(body.stream)
128
+ const configuredAccounts = pool.filter(a => a.present && a.value)
77
129
 
78
- // Transparent retry loop (up to max configured accounts, min 1, max 3)
79
- const maxRetries = Math.min(Math.max(1, pool.length), 3)
130
+ const maxRetries = Math.max(1, configuredAccounts.length)
80
131
  let attempt = 0
81
132
  let lastError = null
82
- let targetAccount = null
133
+ let hadTimeout = false
83
134
 
84
135
  while (attempt < maxRetries) {
85
- attempt++
136
+ attempt += 1
137
+
86
138
  const routeResult = resolveSessionAccount(sessionId, pool, pub.activeAccount)
87
- targetAccount = routeResult.account
139
+ const targetAccount = routeResult.account
88
140
 
89
141
  if (!targetAccount || !targetAccount.value) {
90
142
  return writeJson(res, 503, {
@@ -101,8 +153,19 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
101
153
  'Content-Type': 'application/json',
102
154
  'Authorization': `Bearer ${targetAccount.value}`,
103
155
  'Accept': isStreaming ? 'text/event-stream' : 'application/json',
104
- 'User-Agent': '@goodandready/dsh-clinebot/0.5.0'
156
+ 'User-Agent': USER_AGENT
157
+ }
158
+
159
+ const upstreamTimeoutMs = Math.max(1000, Number(pub.timeoutMs) || 60000)
160
+ const timeoutSignal = AbortSignal.timeout(upstreamTimeoutMs)
161
+ const clientAbortCtrl = new AbortController()
162
+ const onReqClose = () => {
163
+ clientAbortCtrl.abort()
164
+ }
165
+ if (typeof req.on === 'function') {
166
+ req.on('close', onReqClose)
105
167
  }
168
+ const fetchSignal = AbortSignal.any([clientAbortCtrl.signal, timeoutSignal])
106
169
 
107
170
  let upstreamRes
108
171
  try {
@@ -110,15 +173,26 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
110
173
  method: 'POST',
111
174
  headers: requestHeaders,
112
175
  body: JSON.stringify(body),
176
+ signal: fetchSignal,
113
177
  keepalive: true
114
178
  })
115
179
  } catch (fetchErr) {
180
+ if (clientAbortCtrl.signal.aborted) {
181
+ // Client closed connection, stop processing
182
+ return
183
+ }
116
184
  lastError = fetchErr
117
- // Network errors: retry with next account if available
185
+ const isTimeout = timeoutSignal.aborted || fetchErr.name === 'TimeoutError' || String(fetchErr).includes('timeout')
186
+ if (isTimeout) {
187
+ hadTimeout = true
188
+ const accountId = targetAccount.id || targetAccount.label
189
+ markAccountCooldown(accountId, 60000, null, 'upstream_timeout')
190
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Account "${accountId}" upstream timed out after ${upstreamTimeoutMs}ms, attempting failover (attempt ${attempt}/${maxRetries})`)
191
+ }
118
192
  continue
119
193
  }
120
194
 
121
- // If upstream rejected with 429 or 403 quota exhaustion before streaming, failover!
195
+ // If upstream rejected with 429 or 402 quota exhaustion before streaming, failover!
122
196
  if (upstreamRes.status === 429 || upstreamRes.status === 402) {
123
197
  const accountId = targetAccount.id || targetAccount.label
124
198
  markAccountCooldown(accountId, 60000, null, 'upstream_429')
@@ -152,7 +226,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
152
226
  const decoder = new TextDecoder('utf8')
153
227
 
154
228
  let streamAborted = false
155
- const onClientAbort = () => {
229
+ const onStreamAbort = () => {
156
230
  streamAborted = true
157
231
  try {
158
232
  reader.cancel().catch(() => {})
@@ -161,7 +235,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
161
235
  }
162
236
  }
163
237
  if (typeof req.on === 'function') {
164
- req.on('close', onClientAbort)
238
+ req.on('close', onStreamAbort)
165
239
  }
166
240
 
167
241
  try {
@@ -175,47 +249,48 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
175
249
  // Parse SSE lines for usage metrics
176
250
  buffer += chunkStr
177
251
  const lines = buffer.split('\n')
178
- buffer = lines.pop() // keep incomplete last line
252
+ buffer = lines.pop() || ''
179
253
 
180
254
  for (const line of lines) {
181
255
  const trimmed = line.trim()
182
- if (trimmed.startsWith('data: ') && trimmed !== 'data: [DONE]') {
256
+ if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
183
257
  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
258
+ const parsed = JSON.parse(trimmed.slice(5).trim())
259
+ if (parsed.usage) {
260
+ promptTokens = parsed.usage.prompt_tokens || promptTokens
261
+ completionTokens = parsed.usage.completion_tokens || completionTokens
262
+ totalTokens = parsed.usage.total_tokens || totalTokens
189
263
  }
190
264
  } catch {
191
- /* ignore partial or non-json SSE lines */
265
+ /* ignore JSON chunk parse error */
192
266
  }
193
267
  }
194
268
  }
195
269
  }
196
270
  } catch (streamErr) {
197
- if (!streamAborted) {
198
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
199
- }
271
+ ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
200
272
  } finally {
201
- if (typeof req.off === 'function') {
202
- req.off('close', onClientAbort)
273
+ try {
274
+ reader.releaseLock()
275
+ } catch (err) {
276
+ /* ignore lock release error if reader already closed */
203
277
  }
204
278
  res.end()
279
+
205
280
  recordUsage({
206
281
  model: body.model,
207
282
  accountId: targetAccount.id || targetAccount.label,
208
283
  promptTokens,
209
284
  completionTokens,
210
- totalTokens,
285
+ totalTokens: totalTokens || (promptTokens + completionTokens),
211
286
  statsPath: pub.statsPath
212
287
  })
213
288
  }
214
289
  return
215
290
  }
216
291
 
217
- // Non-streaming JSON response or error status from upstream
218
- const resText = await upstreamRes.text()
292
+ // Non-streaming JSON response
293
+ const resText = await upstreamRes.text().catch(() => '{}')
219
294
  let resJson = null
220
295
  try {
221
296
  resJson = JSON.parse(resText)
@@ -242,6 +317,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
242
317
  }
243
318
 
244
319
  // Exhausted all retries
320
+ if (hadTimeout) {
321
+ return writeJson(res, 504, {
322
+ error: {
323
+ message: 'Gateway Timeout: upstream provider timed out across all available accounts',
324
+ type: 'gateway_timeout',
325
+ code: 'upstream_timeout'
326
+ }
327
+ })
328
+ }
329
+
245
330
  writeJson(res, 429, {
246
331
  error: {
247
332
  message: 'All configured accounts in pool exceeded rate limits or quota',
@@ -265,6 +350,16 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
265
350
  if (!isLoopbackAddress(req.socket?.remoteAddress)) {
266
351
  return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
267
352
  }
353
+
354
+ if (hasDisallowedBrowserOrigin(req)) {
355
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
356
+ }
357
+
358
+ const pool = await resolveAccountPool(ctx, live())
359
+ if (!isAuthorizedProxyRequest(req, pool)) {
360
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
361
+ }
362
+
268
363
  const pub = publicConfig(live())
269
364
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
270
365
  const custom = Array.isArray(pub.customModels) ? pub.customModels : []
@@ -40,6 +40,17 @@ 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) {
44
55
  let unwrapGuard = 0
45
56
  while (unwrapGuard < 5 && targetPath) {
@@ -57,10 +68,11 @@ export function resolvePath(targetPath) {
57
68
  targetPath = ''
58
69
  }
59
70
  if (!targetPath) {
60
- return path.join(homedir(), '.dsh', 'clinebot-stats.json')
71
+ return path.join(getDshHome(), 'clinebot-stats.json')
61
72
  }
62
73
  if (targetPath.startsWith('~/') || targetPath === '~') {
63
- return path.join(homedir(), targetPath.slice(1))
74
+ const home = process.env.HOME || homedir()
75
+ return path.join(home, targetPath.slice(1))
64
76
  }
65
77
  return path.resolve(targetPath)
66
78
  }
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.1",
3
+ "version": "0.5.3",
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"