@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 +17 -0
- package/lib/cline-client.js +2 -0
- package/lib/index.js +4 -4
- package/lib/provider-sync.js +30 -3
- package/lib/proxy-token.js +42 -0
- package/lib/routes/proxy.js +117 -33
- package/lib/stats-storage.js +39 -9
- package/lib/updater.js +71 -3
- package/package.json +2 -2
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
|
package/lib/cline-client.js
CHANGED
|
@@ -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()
|
package/lib/provider-sync.js
CHANGED
|
@@ -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
|
-
|
|
27
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/routes/proxy.js
CHANGED
|
@@ -1,23 +1,56 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Transparent Loopback Proxy & Zero-Downtime Failover (
|
|
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.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4.
|
|
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
|
|
118
|
+
const configuredAccounts = pool.filter(a => a.present && a.value)
|
|
77
119
|
|
|
78
|
-
|
|
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
|
|
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':
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
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()
|
|
242
|
+
buffer = lines.pop() || ''
|
|
179
243
|
|
|
180
244
|
for (const line of lines) {
|
|
181
245
|
const trimmed = line.trim()
|
|
182
|
-
if (trimmed.startsWith('data:
|
|
246
|
+
if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
|
|
183
247
|
try {
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
promptTokens =
|
|
187
|
-
completionTokens =
|
|
188
|
-
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
|
|
255
|
+
/* ignore JSON chunk parse error */
|
|
192
256
|
}
|
|
193
257
|
}
|
|
194
258
|
}
|
|
195
259
|
}
|
|
196
260
|
} catch (streamErr) {
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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 : []
|
package/lib/stats-storage.js
CHANGED
|
@@ -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(
|
|
71
|
+
return path.join(getDshHome(), 'clinebot-stats.json')
|
|
46
72
|
}
|
|
47
73
|
if (targetPath.startsWith('~/') || targetPath === '~') {
|
|
48
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|