@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 +15 -0
- package/lib/cline-client.js +2 -0
- package/lib/provider-sync.js +17 -3
- package/lib/proxy-token.js +42 -0
- package/lib/routes/proxy.js +128 -33
- package/lib/stats-storage.js +14 -2
- package/lib/updater.js +71 -3
- package/package.json +2 -2
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
|
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/provider-sync.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|
package/lib/routes/proxy.js
CHANGED
|
@@ -1,23 +1,66 @@
|
|
|
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.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
|
|
76
|
-
const
|
|
127
|
+
const isStreaming = Boolean(body.stream)
|
|
128
|
+
const configuredAccounts = pool.filter(a => a.present && a.value)
|
|
77
129
|
|
|
78
|
-
|
|
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
|
|
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':
|
|
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
|
-
|
|
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
|
|
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
|
|
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',
|
|
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()
|
|
252
|
+
buffer = lines.pop() || ''
|
|
179
253
|
|
|
180
254
|
for (const line of lines) {
|
|
181
255
|
const trimmed = line.trim()
|
|
182
|
-
if (trimmed.startsWith('data:
|
|
256
|
+
if (trimmed.startsWith('data:') && trimmed !== 'data: [DONE]') {
|
|
183
257
|
try {
|
|
184
|
-
const
|
|
185
|
-
if (
|
|
186
|
-
promptTokens =
|
|
187
|
-
completionTokens =
|
|
188
|
-
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
|
|
265
|
+
/* ignore JSON chunk parse error */
|
|
192
266
|
}
|
|
193
267
|
}
|
|
194
268
|
}
|
|
195
269
|
}
|
|
196
270
|
} catch (streamErr) {
|
|
197
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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
|
|
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 : []
|
package/lib/stats-storage.js
CHANGED
|
@@ -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(
|
|
71
|
+
return path.join(getDshHome(), 'clinebot-stats.json')
|
|
61
72
|
}
|
|
62
73
|
if (targetPath.startsWith('~/') || targetPath === '~') {
|
|
63
|
-
|
|
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
|
-
|
|
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.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"
|