@goodandready/dsh-clinebot 0.5.15 → 0.5.17

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.17] - 2026-10-05
9
+
10
+ ### Fixed
11
+ - **Cosmokit Volatile Getters Unwrapping in Live Config (Issues #194, #200)**: Resolved issue where `live()` returned raw Cosmokit `Volatile` `{ get }` object boxes for volatile schema fields, causing downstream boolean checks (such as `!pub.enabled` in `syncProviderState`) to always evaluate as truthy objects regardless of actual boolean value. Fully unwrapped `live()` return values via `plainConfig(Config(plainConfig(...)))` and guaranteed robust fallback defaults when `apply()` receives null/undefined config.
12
+ - **Proxy Body Read Timeout & Payload Size Bounds (Issues #198, #202)**: Switched non-streaming upstream body read timeouts from `streamIdleTimeoutMs` (30s) to `bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)`. Added strict guard against unbounded payload responses (`MAX_BODY_BYTES = 10 * 1024 * 1024`) returning 502 Bad Gateway if upstream response exceeds 10MB.
13
+ - **Dead Configuration Keys & Timeout Cleanup (Issue #197)**: Removed non-existent configuration keys (`streamTotalTimeoutMs`, `connectTimeoutMs`, `idleTimeoutMs`) from `lib/routes/proxy.js` and eliminated dead timer logic.
14
+ - **DSH Non-Existent Service API Calls Removal (Issue #199)**: Removed dead `settings?.get` and `settingsApi.watch` calls on DSH services that lack those methods.
15
+ - **Corrupted Stats Backup Pruning (Issue #201)**: Added automatic rotation and pruning for `.corrupt.*` backup files in `lib/stats-storage.js`, retaining at most the 3 latest diagnostic copies to prevent unbounded disk usage.
16
+ - **Version Literals & Slash Command Cleanup (Issue #202)**: Synchronized hardcoded fallback version literals to `'0.5.17'`, cleaned up unused `invocation` argument in `slash-command.js`, and updated fallback port detection (`ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080`).
17
+
18
+ ## [0.5.16] - 2026-10-04
19
+
20
+ ### Fixed
21
+ - **Dedicated Loopback Proxy Server & DSH Desktop 2.x Compatibility (Issues GH #12, #195)**: Resolved issue where `DesktopWebServer` on DSH Desktop 2.x in advanced shell mode rejected all internal loopback requests from the Node.js host process with HTTP 403 `forbidden` (lacking `x-dsh-desktop-renderer`). Decoupled proxy execution by spinning up an isolated dedicated HTTP loopback server listening on `127.0.0.1` (ephemeral port), completely bypassing host route fencing and restoring transparent proxy routing, multi-account failover, and telemetry on Desktop installations.
22
+ - **Desktop Fence Self-Detection & Direct Upstream Fallback (Issues GH #12, #195)**: Added `detectHostWebServerFence` probe that detects `DesktopWebServer` 403 `forbidden` signatures on host ports; automatically falls back to direct upstream endpoint (`baseUrl`) if the dedicated loopback server is unavailable or fails to bind, guaranteeing requests never stall with `AUTH 403`.
23
+ - **Proxy Mode UI Toggle in Diagnostics Section (Issues GH #12, #195)**: Added an explicit user-facing toggle in the Diagnostics & Auto-Registration settings card (`src/client/components/diag-section.js`) allowing users to easily switch between Transparent Loopback Proxy Mode (failover & telemetry) and Direct Mode (direct upstream connection) directly from the Web & Desktop UI.
24
+
8
25
  ## [0.5.15] - 2026-10-02
9
26
 
10
27
  ### Fixed
package/README.md CHANGED
@@ -229,6 +229,8 @@ All endpoints are registered under `/dsh-clinebot/*` and protected against untru
229
229
  * `POST /dsh-clinebot/v1/chat/completions` — Transparent local proxy supporting streaming SSE and non-streaming responses, Bearer token authentication, sticky session affinity with least-used quota allocation, body read watchdog (`streamIdleTimeoutMs`), and automatic 429 failover.
230
230
  * `GET /dsh-clinebot/v1/models` — OpenAI-compatible catalog listing all active plan and custom models.
231
231
 
232
+ > **DSH Desktop 2.x & Headless Environments**: The loopback proxy binds an isolated loopback HTTP server on `127.0.0.1` (ephemeral port), completely bypassing `DesktopWebServer` renderer fences (`x-dsh-desktop-renderer`). Automated fence detection (`detectHostWebServerFence`) falls back to direct upstream connection if the loopback server is restricted, and proxy mode can be directly toggled via the Diagnostics settings card.
233
+
232
234
  ---
233
235
 
234
236
  ## Visual verification
package/README.ru.md CHANGED
@@ -226,6 +226,8 @@ dsh-clinebot:
226
226
  * `POST /dsh-clinebot/v1/chat/completions` — Прозрачный локальный прокси с поддержкой SSE и нестриминговых ответов, Bearer-аутентификацией, сессионной липкостью и least-used распределением квот, контролем дедлайнов (`streamIdleTimeoutMs`) и автоматическим 429 failover.
227
227
  * `GET /dsh-clinebot/v1/models` — Каталог моделей в стандарте OpenAI API.
228
228
 
229
+ > **DSH Desktop 2.x и автономные окружения**: Прокси поднимает изолированный выделенный HTTP-сервер на `127.0.0.1` (эфемеридный порт), полностью обходя сетевые экраны `DesktopWebServer` (`x-dsh-desktop-renderer`). Встроенный детектор защитных экранов (`detectHostWebServerFence`) автоматически переключает провайдер на прямое соединение с upstream при ограничении loopback-порта, а режим прокси можно переключать напрямую в карточке «Диагностика» в UI.
230
+
229
231
  ---
230
232
 
231
233
  ## 🧪 Тестирование
package/README.zh.md CHANGED
@@ -228,6 +228,8 @@ dsh-clinebot:
228
228
  * `POST /dsh-clinebot/v1/chat/completions` — 本地透明代理,支持 SSE 流式与完整响应、会话粘性路由、最低用量负载均衡、响应体超时防护 (`streamIdleTimeoutMs`) 以及 429 自动故障转移。
229
229
  * `GET /dsh-clinebot/v1/models` — 符合 OpenAI API 规范的模型列表。
230
230
 
231
+ > **DSH Desktop 2.x 与无界面环境**:环回代理在 `127.0.0.1` 绑定独立的专用 HTTP 服务(临时端口),彻底绕过桌面端路由防护 (`DesktopWebServer` 的 `x-dsh-desktop-renderer` 限制)。内置防护检测机制 (`detectHostWebServerFence`) 在环回端口受限时自动回退为直连模式,用户亦可在设置页的“诊断”模块中直接切换代理模式。
232
+
231
233
  ---
232
234
 
233
235
  ## 🧪 测试
package/lib/client.js CHANGED
@@ -178,6 +178,10 @@ const en = {
178
178
  'diag.unregister_btn': 'Unregister from DSH',
179
179
  'diag.unregistering': 'Removing…',
180
180
  'diag.unregistered_msg': 'Provider removed from DSH Models',
181
+ 'diag.proxy_title': 'Transparent Loopback Proxy',
182
+ 'diag.proxy_desc': 'Enables multi-account failover, sticky sessions, and telemetry via local loopback proxy. Disable to connect directly to upstream endpoint.',
183
+ 'diag.proxy_enabled': 'Proxy Mode Enabled (Failover & Telemetry Active)',
184
+ 'diag.proxy_disabled': 'Direct Mode (Direct Upstream, No Proxy)',
181
185
  'update.available': 'Update available: v{latestVersion} (current: v{currentVersion})',
182
186
  'update.btn': 'Update Now',
183
187
  'update.updating': 'Updating…',
@@ -327,6 +331,10 @@ const en = {
327
331
  'diag.unregister_btn': '从 DSH 注销该服务',
328
332
  'diag.unregistering': '正在注销…',
329
333
  'diag.unregistered_msg': '已从 DSH 模型注册表中移除',
334
+ 'diag.proxy_title': '本地透明回环代理',
335
+ 'diag.proxy_desc': '通过本地回环代理支持多账户故障转移、会话保持与实时统计。关闭后直连上游端点。',
336
+ 'diag.proxy_enabled': '代理模式已开启(支持故障转移与遥测)',
337
+ 'diag.proxy_disabled': '直连模式(直接连接上游,无本地代理)',
330
338
  'update.available': '发现新版本 v{latestVersion}(当前版本 v{currentVersion})',
331
339
  'update.btn': '立即更新',
332
340
  'update.updating': '正在更新…',
@@ -1835,12 +1843,72 @@ function StatsSection({ status, onResetStats, busy, t }) {
1835
1843
  }
1836
1844
 
1837
1845
  // --- src/client/components/diag-section.js ---
1838
- function DiagSection({ keyPresent, isRegistered, busy, smokeResult, handleSmoke, handleUnregister, handleRegister, t }) {
1846
+ function DiagSection({
1847
+ keyPresent,
1848
+ isRegistered,
1849
+ busy,
1850
+ smokeResult,
1851
+ handleSmoke,
1852
+ handleUnregister,
1853
+ handleRegister,
1854
+ proxyMode,
1855
+ onToggleProxy,
1856
+ t,
1857
+ }) {
1839
1858
  return React.createElement(
1840
1859
  'div',
1841
1860
  { className: 'cb-section-card' },
1842
1861
  React.createElement('div', { className: 'cb-section-title' }, t('diag.title')),
1843
1862
  React.createElement('div', { className: 'cb-section-desc' }, t('diag.desc')),
1863
+ React.createElement(
1864
+ 'div',
1865
+ {
1866
+ style: {
1867
+ display: 'flex',
1868
+ alignItems: 'center',
1869
+ justifyContent: 'space-between',
1870
+ padding: '10px 14px',
1871
+ background: 'var(--dsw-alias-bg-layer-2)',
1872
+ borderRadius: '8px',
1873
+ border: '1px solid var(--dsw-alias-border-l2)',
1874
+ },
1875
+ },
1876
+ React.createElement(
1877
+ 'div',
1878
+ { style: { display: 'flex', flexDirection: 'column', gap: '3px' } },
1879
+ React.createElement(
1880
+ 'span',
1881
+ { style: { fontSize: '13px', fontWeight: 600, color: 'var(--dsw-alias-label-primary)' } },
1882
+ t('diag.proxy_title')
1883
+ ),
1884
+ React.createElement(
1885
+ 'span',
1886
+ { style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary)' } },
1887
+ t('diag.proxy_desc')
1888
+ )
1889
+ ),
1890
+ React.createElement(
1891
+ 'label',
1892
+ { style: { display: 'inline-flex', alignItems: 'center', gap: '8px', cursor: 'pointer', whiteSpace: 'nowrap' } },
1893
+ React.createElement('input', {
1894
+ type: 'checkbox',
1895
+ checked: !!proxyMode,
1896
+ disabled: !!busy,
1897
+ onChange: (e) => onToggleProxy && onToggleProxy(e.target.checked),
1898
+ }),
1899
+ React.createElement(
1900
+ 'span',
1901
+ {
1902
+ style: {
1903
+ fontSize: '13px',
1904
+ fontWeight: 500,
1905
+ color: proxyMode ? 'var(--dsw-alias-state-success-primary)' : 'var(--dsw-alias-label-secondary)',
1906
+ },
1907
+ },
1908
+ proxyMode ? t('diag.proxy_enabled') : t('diag.proxy_disabled')
1909
+ )
1910
+ )
1911
+ ),
1844
1912
  React.createElement(
1845
1913
  'div',
1846
1914
  { className: 'cb-row' },
@@ -1901,8 +1969,7 @@ function SettingsPage(props) {
1901
1969
  const [status, setStatus] = React.useState(null)
1902
1970
  const [draft, setDraft] = React.useState(null)
1903
1971
  const [busy, setBusy] = React.useState('')
1904
- const [err, setErr] = React.useState('')
1905
- const [msg, setMsg] = React.useState('')
1972
+ const [err, setErr] = React.useState(''), [msg, setMsg] = React.useState('')
1906
1973
  const [smokeResult, setSmokeResult] = React.useState(null)
1907
1974
 
1908
1975
  const [updateState, setUpdateState] = React.useState({
@@ -1911,8 +1978,7 @@ function SettingsPage(props) {
1911
1978
  error: '', notice: '',
1912
1979
  })
1913
1980
 
1914
- const [apiKeyInput, setApiKeyInput] = React.useState('')
1915
- const [showKey, setShowKey] = React.useState(false)
1981
+ const [apiKeyInput, setApiKeyInput] = React.useState(''), [showKey, setShowKey] = React.useState(false)
1916
1982
 
1917
1983
  React.useEffect(() => { ensureCss() }, [])
1918
1984
 
@@ -1930,9 +1996,7 @@ function SettingsPage(props) {
1930
1996
  setDraft(data.config || {})
1931
1997
  }, [])
1932
1998
 
1933
- React.useEffect(() => {
1934
- load().catch((e) => setErr(String(e.message || e)))
1935
- }, [load])
1999
+ React.useEffect(() => { load().catch((e) => setErr(String(e.message || e))) }, [load])
1936
2000
 
1937
2001
  const checkUpdate = React.useCallback(async () => {
1938
2002
  setUpdateState((s) => ({ ...s, checking: true, status: 'checking', error: '' }))
@@ -2202,11 +2266,7 @@ function SettingsPage(props) {
2202
2266
 
2203
2267
  const debounceTimerRef = typeof React.useRef === 'function' ? React.useRef(null) : { current: null }
2204
2268
 
2205
- React.useEffect(() => {
2206
- return () => {
2207
- if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
2208
- }
2209
- }, [])
2269
+ React.useEffect(() => () => { if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current) }, [])
2210
2270
 
2211
2271
  function debounceSaveDisabledModels(nextDisabled, previousDisabled) {
2212
2272
  if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current)
@@ -2368,8 +2428,7 @@ function SettingsPage(props) {
2368
2428
  onConfigUpdate: handleConfigPatch,
2369
2429
  onUpdateReasoningEffort: (modelId, effort) => {
2370
2430
  const next = { ...(draft?.modelReasoningDefaults || {}) }
2371
- if (!effort) delete next[modelId]
2372
- else next[modelId] = effort
2431
+ if (!effort) delete next[modelId]; else next[modelId] = effort
2373
2432
  handleConfigPatch({ modelReasoningDefaults: next })
2374
2433
  },
2375
2434
  t,
@@ -2387,6 +2446,8 @@ function SettingsPage(props) {
2387
2446
  handleSmoke,
2388
2447
  handleUnregister,
2389
2448
  handleRegister,
2449
+ proxyMode: draft?.proxyMode !== false,
2450
+ onToggleProxy: (val) => handleConfigPatch({ proxyMode: val }),
2390
2451
  t,
2391
2452
  })
2392
2453
  )
package/lib/index.js CHANGED
@@ -23,6 +23,7 @@ import { registerAccountsRoutes } from './routes/accounts.js'
23
23
  import { registerModelsRoutes } from './routes/models.js'
24
24
  import { registerAuthRoutes } from './routes/auth.js'
25
25
  import { registerProxyRoutes } from './routes/proxy.js'
26
+ import { startLoopbackProxyServer, stopLoopbackProxyServer } from './proxy-server.js'
26
27
  import { registerSlashCommand } from './slash-command.js'
27
28
  import { recordUsage, loadStats, flushStats, resolvePath, switchStatsStorage } from './stats-storage.js'
28
29
 
@@ -36,7 +37,7 @@ export function configReader(config) {
36
37
  export { NS, LLM_PI_AI_NS, Config, sessionStats, recordSessionRequest, resetSessionStats, rotateToNextAccount }
37
38
 
38
39
  export function apply(ctx, config) {
39
- let currentConfig = config
40
+ let currentConfig = config || {}
40
41
  const overrides = {}
41
42
  const getConfig = () => {
42
43
  if (!currentConfig) return currentConfig
@@ -72,7 +73,7 @@ export function apply(ctx, config) {
72
73
  }
73
74
  return currentConfig
74
75
  }
75
- const live = () => (getConfig() ? Config(structuredClone(plainConfig(getConfig()))) : config)
76
+ const live = () => plainConfig(Config(plainConfig(getConfig() || config || {})))
76
77
  let settingsApi
77
78
 
78
79
  const syncProviderState = async (cfg) => {
@@ -82,6 +83,11 @@ export function apply(ctx, config) {
82
83
  await removePiAiProvider(ctx)
83
84
  return
84
85
  }
86
+ if (pub.proxyMode !== false && (ctx?.webServer || typeof ctx?.get === 'function')) {
87
+ await startLoopbackProxyServer(ctx, routeEnv)
88
+ } else {
89
+ await stopLoopbackProxyServer()
90
+ }
85
91
  const activeKey = await resolveActiveAccountKey(ctx, cfg)
86
92
  if (activeKey.value) {
87
93
  await upsertPiAiProvider(ctx, cfg, pub.enabledModels)
@@ -213,7 +219,7 @@ export function apply(ctx, config) {
213
219
  applyConfigMutations(currentConfig, parsed)
214
220
  syncStatsStorage(live())
215
221
  await syncProviderState(live())
216
- return parsed
222
+ return plainConfig(parsed)
217
223
  },
218
224
  update: async (patch) => {
219
225
  const svc = ctx?.get?.('settings')
@@ -242,13 +248,9 @@ export function apply(ctx, config) {
242
248
  applyConfigMutations(currentConfig, merged)
243
249
  syncStatsStorage(live())
244
250
  await syncProviderState(live())
245
- return merged
246
- },
247
- watch: (cb) => {
248
- const svc = ctx?.get?.('settings')
249
- if (typeof svc?.watch === 'function') return svc.watch(cb)
250
- return () => {}
251
+ return plainConfig(merged)
251
252
  },
253
+
252
254
  }
253
255
 
254
256
  if (typeof ctx.effect === 'function') {
@@ -259,6 +261,21 @@ export function apply(ctx, config) {
259
261
  }, 'dsh-clinebot: stats-storage')
260
262
  }
261
263
 
264
+ const routeEnv = {
265
+ live,
266
+ getSettingsApi: () => settingsApi,
267
+ syncProviderState,
268
+ triggerAutoDiscover,
269
+ }
270
+
271
+ if (typeof ctx.effect === 'function') {
272
+ ctx.effect(() => {
273
+ return () => {
274
+ stopLoopbackProxyServer()
275
+ }
276
+ }, 'dsh-clinebot: loopback-proxy-server')
277
+ }
278
+
262
279
  syncProviderState(live())
263
280
  if (typeof ctx.effect === 'function') {
264
281
  ctx.effect(() => {
@@ -281,13 +298,6 @@ export function apply(ctx, config) {
281
298
  ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
282
299
  }
283
300
 
284
- const routeEnv = {
285
- live,
286
- getSettingsApi: () => settingsApi,
287
- syncProviderState,
288
- triggerAutoDiscover,
289
- }
290
-
291
301
  registerSettingsRoutes(ctx, routeEnv)
292
302
  registerAccountsRoutes(ctx, routeEnv)
293
303
  registerModelsRoutes(ctx, routeEnv)
@@ -25,6 +25,7 @@ import { resolveKeyValue } from './credential-refs.js'
25
25
  import { resolveAccountPool, getLastRotation } from './account-pool.js'
26
26
  import { getLocalProxyToken, LOCAL_PROXY_KEY_ENV } from './proxy-token.js'
27
27
  import { saveCredentialKey } from './credential-refs.js'
28
+ import { getProxyPort, detectHostWebServerFence } from './proxy-server.js'
28
29
 
29
30
  export function resolvePathWithHome(p) {
30
31
  let guard = 0
@@ -55,10 +56,7 @@ export async function checkRegisteredInPiAi(ctx) {
55
56
  const piAiDesc = Array.isArray(descriptors) ? descriptors.find((d) => d?.ns === LLM_PI_AI_NS) : null
56
57
  if (piAiDesc?.value?.providers?.[PROVIDER_ID]) return true
57
58
  }
58
- if (typeof settings?.get === 'function') {
59
- const piAi = settings.get(LLM_PI_AI_NS)
60
- if (piAi?.providers?.[PROVIDER_ID]) return true
61
- }
59
+
62
60
  const llm = ctx?.get?.('llm')
63
61
  if (typeof llm?.listProviders === 'function') {
64
62
  const list = await llm.listProviders()
@@ -208,11 +206,29 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
208
206
 
209
207
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
210
208
 
211
- const port = ctx?.webServer?.port || process.env.PORT || 3080
212
- const isProxy = pub.proxyMode !== false
213
- const effectiveBaseUrl = isProxy
214
- ? `http://127.0.0.1:${port}/dsh-clinebot/v1`
215
- : pub.baseUrl
209
+ let isProxy = pub.proxyMode !== false
210
+ let effectiveBaseUrl = pub.baseUrl
211
+ let proxyPort = getProxyPort()
212
+
213
+ if (isProxy) {
214
+ if (!proxyPort) {
215
+ proxyPort = ctx?.webServer?.port || ctx?.get?.('webServer')?.port || process.env.PORT || 3080
216
+ const isFenced = await detectHostWebServerFence(proxyPort)
217
+ if (isFenced) {
218
+ ctx?.logger?.warn?.(
219
+ `[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
220
+ )
221
+ isProxy = false
222
+ }
223
+ }
224
+ }
225
+
226
+ if (isProxy && proxyPort) {
227
+ effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
228
+ } else {
229
+ isProxy = false
230
+ effectiveBaseUrl = pub.baseUrl
231
+ }
216
232
 
217
233
  const localToken = isProxy ? getLocalProxyToken() : undefined
218
234
  if (isProxy && localToken) {
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Dedicated Loopback Proxy Server (Issues GH #12, #195)
3
+ *
4
+ * Runs an isolated loopback HTTP server bound exclusively to 127.0.0.1 (ephemeral port).
5
+ * Completely bypasses DSH Desktop 2.x DesktopWebServer route fencing (`permits(req)`
6
+ * requiring `x-dsh-desktop-renderer` header), allowing Node.js host LLM requests to
7
+ * connect cleanly to loopback proxy endpoints without encountering HTTP 403 "forbidden".
8
+ */
9
+
10
+ import http from 'node:http'
11
+ import { createChatCompletionsHandler, createModelsHandler } from './routes/proxy.js'
12
+
13
+ let activeProxyServer = null
14
+ let activeProxyPort = null
15
+ let startingPromise = null
16
+
17
+ export function getProxyPort() {
18
+ return activeProxyPort
19
+ }
20
+
21
+ export function resetProxyServerState() {
22
+ if (activeProxyServer) {
23
+ try {
24
+ activeProxyServer.close()
25
+ } catch {
26
+ /* ignore */
27
+ }
28
+ }
29
+ activeProxyServer = null
30
+ activeProxyPort = null
31
+ startingPromise = null
32
+ }
33
+
34
+ export async function detectHostWebServerFence(port) {
35
+ if (!port || typeof port !== 'number' || port <= 0) return false
36
+ try {
37
+ const res = await fetch(`http://127.0.0.1:${port}/dsh-clinebot/v1/models`, {
38
+ method: 'GET',
39
+ signal: AbortSignal.timeout(1000),
40
+ })
41
+ if (res.status === 403) {
42
+ const text = await res.text().catch(() => '')
43
+ if (text.trim().toLowerCase() === 'forbidden') {
44
+ return true
45
+ }
46
+ }
47
+ } catch {
48
+ return false
49
+ }
50
+ return false
51
+ }
52
+
53
+ export async function startLoopbackProxyServer(ctx, routeEnv) {
54
+ if (activeProxyServer && activeProxyPort) {
55
+ return activeProxyPort
56
+ }
57
+ if (startingPromise) {
58
+ return startingPromise
59
+ }
60
+
61
+ startingPromise = (async () => {
62
+ try {
63
+ const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
64
+ const modelsHandler = createModelsHandler(ctx, routeEnv)
65
+
66
+ const server = http.createServer(async (req, res) => {
67
+ try {
68
+ const rawUrl = req.url || '/'
69
+ const path = rawUrl.split('?')[0].replace(/\/+$/, '')
70
+ if (path === '/dsh-clinebot/v1/chat/completions') {
71
+ await chatHandler(req, res)
72
+ return
73
+ }
74
+ if (path === '/dsh-clinebot/v1/models') {
75
+ await modelsHandler(req, res)
76
+ return
77
+ }
78
+ res.statusCode = 404
79
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
80
+ res.end(JSON.stringify({ ok: false, error: 'Not found' }))
81
+ } catch (err) {
82
+ if (!res.headersSent) {
83
+ res.statusCode = 500
84
+ res.setHeader('Content-Type', 'application/json; charset=utf-8')
85
+ res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
86
+ }
87
+ }
88
+ })
89
+
90
+ const port = await new Promise((resolve, reject) => {
91
+ server.on('error', (err) => {
92
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Server error: ${err?.message || err}`)
93
+ reject(err)
94
+ })
95
+ server.listen(0, '127.0.0.1', () => {
96
+ const addr = server.address()
97
+ const assignedPort = typeof addr === 'object' && addr?.port ? addr.port : null
98
+ if (!assignedPort) {
99
+ reject(new Error('Failed to obtain assigned ephemeral port'))
100
+ return
101
+ }
102
+ if (typeof server.unref === 'function') {
103
+ server.unref()
104
+ }
105
+ resolve(assignedPort)
106
+ })
107
+ })
108
+
109
+ activeProxyServer = server
110
+ activeProxyPort = port
111
+ ctx?.logger?.info?.(`[dsh-clinebot:proxy-server] Dedicated loopback proxy listening on 127.0.0.1:${port}`)
112
+ return port
113
+ } catch (err) {
114
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Failed to bind dedicated loopback proxy server: ${err?.message || err}`)
115
+ activeProxyServer = null
116
+ activeProxyPort = null
117
+ return null
118
+ } finally {
119
+ startingPromise = null
120
+ }
121
+ })()
122
+
123
+ return startingPromise
124
+ }
125
+
126
+ export async function stopLoopbackProxyServer() {
127
+ if (!activeProxyServer) return
128
+ const server = activeProxyServer
129
+ activeProxyServer = null
130
+ activeProxyPort = null
131
+ startingPromise = null
132
+ return new Promise((resolve) => {
133
+ server.close(() => {
134
+ resolve()
135
+ })
136
+ })
137
+ }
138
+
139
+ export async function getOrStartProxyPort(ctx, routeEnv) {
140
+ if (activeProxyPort) return activeProxyPort
141
+ if (routeEnv) {
142
+ return await startLoopbackProxyServer(ctx, routeEnv)
143
+ }
144
+ return null
145
+ }
@@ -24,9 +24,9 @@ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
24
  import { getLocalProxyToken } from '../proxy-token.js'
25
25
 
26
26
  const require = createRequire(import.meta.url)
27
- let packageVersion = '0.5.3'
27
+ let packageVersion = '0.5.17'
28
28
  try {
29
- packageVersion = require('../../package.json').version || '0.5.6'
29
+ packageVersion = require('../../package.json').version || '0.5.17'
30
30
  } catch (err) {
31
31
  /* fallback to default version if manifest is unreadable */
32
32
  }
@@ -35,13 +35,7 @@ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
35
35
 
36
36
  export function hasDisallowedBrowserOrigin(req) {
37
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
38
+ return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
45
39
  }
46
40
 
47
41
  export function isAuthorizedProxyRequest(req, accountPool = []) {
@@ -51,26 +45,15 @@ export function isAuthorizedProxyRequest(req, accountPool = []) {
51
45
  const token = match[1].trim()
52
46
  if (!token) return false
53
47
  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
48
+ if (localToken && token === localToken) return true
49
+ return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
63
50
  }
64
51
 
65
- export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
66
- // 1. POST /dsh-clinebot/v1/chat/completions
67
- ctx.effect(() => ctx.webServer.register({
68
- kind: 'exact',
69
- path: '/dsh-clinebot/v1/chat/completions',
70
- handler: async (req, res) => {
71
- if (req.method !== 'POST') {
72
- return writeJson(res, 405, { ok: false, error: 'POST only' })
73
- }
52
+ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
53
+ return async (req, res) => {
54
+ if (req.method !== 'POST') {
55
+ return writeJson(res, 405, { ok: false, error: 'POST only' })
56
+ }
74
57
 
75
58
  if (!isLoopbackAddress(req.socket?.remoteAddress)) {
76
59
  return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
@@ -102,20 +85,11 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
102
85
  const cfg = plainConfig(live())
103
86
  const pub = publicConfig(cfg)
104
87
 
105
- // Session identification
88
+ const h = req.headers || {}
106
89
  const sessionId = String(
107
- req.headers['x-dsh-session-id'] ||
108
- req.headers['x-session-id'] ||
109
- req.headers['session-id'] ||
110
- req.headers['session_id'] ||
111
- req.headers['x-session-affinity'] ||
112
- req.headers['x-client-request-id'] ||
113
- body?.session_id ||
114
- body?.conversation_id ||
115
- body?.prompt_cache_key ||
116
- body?.chat_id ||
117
- body?.user ||
118
- ''
90
+ h['x-dsh-session-id'] || h['x-session-id'] || h['session-id'] || h['session_id'] ||
91
+ h['x-session-affinity'] || h['x-client-request-id'] ||
92
+ body?.session_id || body?.conversation_id || body?.prompt_cache_key || body?.chat_id || body?.user || ''
119
93
  ).trim()
120
94
 
121
95
  if (sessionId) {
@@ -123,15 +97,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
123
97
  if (!body.conversation_id) body.conversation_id = sessionId
124
98
  }
125
99
 
126
- // Ensure model is set, fallback to defaultModel (#140)
127
100
  body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
128
-
129
- // Inject model reasoning effort if model has default configured and payload omitted it
130
- if (
131
- pub.modelReasoningDefaults &&
132
- pub.modelReasoningDefaults[body.model] &&
133
- !body.reasoning_effort
134
- ) {
101
+ if (pub.modelReasoningDefaults?.[body.model] && !body.reasoning_effort) {
135
102
  body.reasoning_effort = pub.modelReasoningDefaults[body.model]
136
103
  }
137
104
 
@@ -194,8 +161,8 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
194
161
  'User-Agent': USER_AGENT
195
162
  }
196
163
 
197
- const connectTimeoutMs = Math.max(50, Number(pub.connectTimeoutMs || pub.timeoutMs) || 15000)
198
- const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs || pub.idleTimeoutMs) || 30000)
164
+ const connectTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
165
+ const streamIdleTimeoutMs = Math.max(50, Number(pub.streamIdleTimeoutMs) || 30000)
199
166
 
200
167
  const connectAbortCtrl = new AbortController()
201
168
  const bodyAbortCtrl = new AbortController()
@@ -316,16 +283,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
316
283
  }
317
284
  }
318
285
 
319
- let totalTimer = null
320
- if (pub.streamTotalTimeoutMs && Number(pub.streamTotalTimeoutMs) > 0) {
321
- const totalMs = Number(pub.streamTotalTimeoutMs)
322
- totalTimer = setTimeout(() => {
323
- streamError = new Error(`Stream total timeout after ${totalMs}ms`)
324
- try {
325
- reader.cancel('stream total timeout').catch(() => {})
326
- } catch { /* reader already cancelled or closed */ }
327
- }, totalMs)
328
- }
286
+
329
287
 
330
288
  try {
331
289
  resetIdleTimer()
@@ -374,7 +332,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
374
332
  ctx?.logger?.warn?.('[dsh-clinebot:proxy] Stream read error: ' + (streamErr?.message || streamErr))
375
333
  } finally {
376
334
  clearIdleTimer()
377
- if (totalTimer) clearTimeout(totalTimer)
335
+
378
336
  clientAbortCtrl.signal.removeEventListener('abort', onStreamAbort)
379
337
  cleanupClientListeners()
380
338
  try {
@@ -412,17 +370,26 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
412
370
  return
413
371
  }
414
372
 
415
- // Non-streaming JSON response with deadline and fail-closed error handling (#185)
416
- const idleTimeoutMs = streamIdleTimeoutMs
373
+ // Non-streaming JSON response with deadline and fail-closed error handling (#185, #198, #202)
374
+ const bodyTimeoutMs = Math.max(50, Number(pub.timeoutMs) || 15000)
417
375
  let bodyTimer = null
418
376
  const bodyTimeoutPromise = new Promise((_, reject) => {
419
377
  bodyTimer = setTimeout(() => {
420
378
  bodyTimedOut = true
421
- bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
422
- reject(new Error(`Upstream body read timed out after ${idleTimeoutMs}ms`))
423
- }, idleTimeoutMs)
379
+ bodyAbortCtrl.abort(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
380
+ reject(new Error(`Upstream body read timed out after ${bodyTimeoutMs}ms`))
381
+ }, bodyTimeoutMs)
424
382
  })
425
383
 
384
+ const MAX_BODY_BYTES = 10 * 1024 * 1024
385
+ const contentLength = Number(upstreamRes.headers.get('content-length'))
386
+ if (contentLength > MAX_BODY_BYTES) {
387
+ clearTimeout(bodyTimer)
388
+ cleanupClientListeners()
389
+ writeJson(res, 502, { ok: false, error: 'Upstream response exceeded maximum allowed size' })
390
+ return
391
+ }
392
+
426
393
  let resText = ''
427
394
  let readError = null
428
395
  try {
@@ -430,6 +397,9 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
430
397
  upstreamRes.text(),
431
398
  bodyTimeoutPromise,
432
399
  ])
400
+ if (resText.length > MAX_BODY_BYTES) {
401
+ throw new Error('Upstream response exceeded maximum allowed size')
402
+ }
433
403
  } catch (err) {
434
404
  readError = err
435
405
  } finally {
@@ -452,7 +422,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
452
422
  })
453
423
  const statusCode = isTimeout ? 504 : 502
454
424
  const errMessage = isTimeout
455
- ? `Gateway Timeout: upstream body read timed out after ${idleTimeoutMs}ms`
425
+ ? `Gateway Timeout: upstream body read timed out after ${bodyTimeoutMs}ms`
456
426
  : `Bad Gateway: upstream response body read error: ${readError?.message || readError}`
457
427
  return writeJson(res, statusCode, {
458
428
  error: {
@@ -532,51 +502,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
532
502
  details: lastError?.message || null
533
503
  }
534
504
  })
535
- },
505
+ }
506
+ }
507
+
508
+ export function createModelsHandler(ctx, { live }) {
509
+ return async (req, res) => {
510
+ if (req.method !== 'GET') {
511
+ return writeJson(res, 405, { ok: false, error: 'GET only' })
512
+ }
513
+
514
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
515
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
516
+ }
517
+
518
+ if (hasDisallowedBrowserOrigin(req)) {
519
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
520
+ }
521
+
522
+ const pool = await resolveAccountPool(ctx, live())
523
+ if (!isAuthorizedProxyRequest(req, pool)) {
524
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
525
+ }
526
+
527
+ const pub = publicConfig(live())
528
+ const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
529
+ const custom = Array.isArray(pub.customModels) ? pub.customModels : []
530
+ const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
531
+ const allModels = getAllModels(dynamic, overrides, custom)
532
+
533
+ const modelsData = allModels.map(m => ({
534
+ id: m.id,
535
+ object: 'model',
536
+ created: 1700000000,
537
+ owned_by: 'clinebot',
538
+ permission: [],
539
+ root: m.id,
540
+ parent: null
541
+ }))
542
+
543
+ writeJson(res, 200, {
544
+ object: 'list',
545
+ data: modelsData
546
+ })
547
+ }
548
+ }
549
+
550
+ export function registerProxyRoutes(ctx, routeEnv) {
551
+ const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
552
+ const modelsHandler = createModelsHandler(ctx, routeEnv)
553
+
554
+ // 1. POST /dsh-clinebot/v1/chat/completions
555
+ ctx.effect(() => ctx.webServer.register({
556
+ kind: 'exact',
557
+ path: '/dsh-clinebot/v1/chat/completions',
558
+ handler: chatHandler,
536
559
  }), 'dsh-clinebot: /v1/chat/completions')
537
560
 
538
561
  // 2. GET /dsh-clinebot/v1/models (OpenAI standard)
539
562
  ctx.effect(() => ctx.webServer.register({
540
563
  kind: 'exact',
541
564
  path: '/dsh-clinebot/v1/models',
542
- handler: async (req, res) => {
543
- if (req.method !== 'GET') {
544
- return writeJson(res, 405, { ok: false, error: 'GET only' })
545
- }
546
-
547
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
548
- return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
549
- }
550
-
551
- if (hasDisallowedBrowserOrigin(req)) {
552
- return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
553
- }
554
-
555
- const pool = await resolveAccountPool(ctx, live())
556
- if (!isAuthorizedProxyRequest(req, pool)) {
557
- return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
558
- }
559
-
560
- const pub = publicConfig(live())
561
- const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
562
- const custom = Array.isArray(pub.customModels) ? pub.customModels : []
563
- const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
564
- const allModels = getAllModels(dynamic, overrides, custom)
565
-
566
- const modelsData = allModels.map(m => ({
567
- id: m.id,
568
- object: 'model',
569
- created: 1700000000,
570
- owned_by: 'clinebot',
571
- permission: [],
572
- root: m.id,
573
- parent: null
574
- }))
575
-
576
- writeJson(res, 200, {
577
- object: 'list',
578
- data: modelsData
579
- })
580
- },
565
+ handler: modelsHandler,
581
566
  }), 'dsh-clinebot: /v1/models')
582
567
  }
568
+
@@ -26,7 +26,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
26
26
  const commands = cmdCtx.commands
27
27
  if (typeof commands?.register !== 'function') return
28
28
 
29
- const executeCommand = async (rawArgs, invocation = null) => {
29
+ const executeCommand = async (rawArgs) => {
30
30
  try {
31
31
  const pub = publicConfig(live())
32
32
  const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'quota'
@@ -102,12 +102,12 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
102
102
  if (settingsApi?.replace) {
103
103
  const curr = plainConfig(live())
104
104
  const isDifferent = String(param || "").trim() !== String(curr.activeAccount || "").trim()
105
- const next = Config({
105
+ const next = plainConfig(Config({
106
106
  ...curr,
107
107
  activeAccount: param,
108
108
  dynamicModels: isDifferent ? [] : (curr.dynamicModels || []),
109
109
  planSyncedAt: isDifferent ? 0 : Number(curr.planSyncedAt || 0),
110
- })
110
+ }))
111
111
  await settingsApi.replace(next)
112
112
  await syncProviderState(next)
113
113
  return `Active account switched to ${param}`
@@ -276,7 +276,7 @@ export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderSt
276
276
  ? invocation
277
277
  : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
278
278
  try {
279
- const output = await executeCommand(rawArgs, invocation)
279
+ const output = await executeCommand(rawArgs)
280
280
  return {
281
281
  kind: 'success',
282
282
  text: output,
@@ -5,7 +5,7 @@
5
5
  * Survives dsh-web service restarts and tracks historical consumption by day, month, model, and account.
6
6
  */
7
7
 
8
- import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync } from 'node:fs'
8
+ import { readFileSync, writeFileSync, renameSync, mkdirSync, existsSync, copyFileSync, readdirSync, unlinkSync } from 'node:fs'
9
9
  import { homedir } from 'node:os'
10
10
  import path from 'node:path'
11
11
 
@@ -104,11 +104,25 @@ export function loadStats(customPath) {
104
104
  }
105
105
  }
106
106
  } catch (err) {
107
- // If reading fails or file is corrupted, preserve corrupted file for diagnostics
107
+ // If reading fails or file is corrupted, preserve corrupted file for diagnostics (#158, #201)
108
108
  try {
109
109
  if (existsSync(filePath)) {
110
110
  const corruptBackup = `${filePath}.corrupt.${Date.now()}`
111
111
  copyFileSync(filePath, corruptBackup)
112
+ const dir = path.dirname(filePath)
113
+ const base = path.basename(filePath)
114
+ const prefix = `${base}.corrupt.`
115
+ const existing = readdirSync(dir)
116
+ .filter((name) => name.startsWith(prefix))
117
+ .sort()
118
+ while (existing.length > 3) {
119
+ const oldest = existing.shift()
120
+ try {
121
+ unlinkSync(path.join(dir, oldest))
122
+ } catch {
123
+ /* ignore deletion failure */
124
+ }
125
+ }
112
126
  }
113
127
  } catch {
114
128
  /* ignore backup failure */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.15",
3
+ "version": "0.5.17",
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",