@goodandready/dsh-clinebot 0.5.13 → 0.5.16

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.16] - 2026-10-04
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **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`.
13
+ - **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.
14
+
15
+ ## [0.5.15] - 2026-10-02
16
+
17
+ ### Fixed
18
+ - **Atomic Catalog Snapshot Reset on Settings Persistence (Issue #192)**: In `settingsApi.update` and `settingsApi.replace`, defer invalidating the verified model catalog snapshot (`resetCatalogSnapshot()`) and updating `lastCatalogIdentity` until after persistence to the DSH settings service completes successfully. If settings persistence fails (e.g. revision conflict or rejection), local in-memory dynamic models and plan synchronization timestamp are strictly preserved, preventing catalog loss when endpoint and credentials remain unchanged.
19
+
20
+ ## [0.5.14] - 2026-10-02
21
+
22
+ ### Fixed
23
+ - **Catalog Identity Lifecycle on Endpoint & Primary Credential Switch (Issue #168)**: Unified catalog invalidation under an identity contract: the verified catalog and plan synchronization timestamp strictly belong to the identity tuple (`baseUrl::activeAccount::apiKeyEnv`). Changing primary credential reference (`apiKeyEnv`) or provider endpoint (`baseUrl`) — via native Cosmokit volatile updates or `PUT /dsh-clinebot/config` — immediately resets `dynamicModels = []`, `planSyncedAt = 0`, and purges both usage and probe caches (`clearUsageCache()`, `clearProbeCache()`), preventing foreign or unverified model snapshots from persisting across endpoint or credential changes.
24
+
8
25
  ## [0.5.13] - 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
 
@@ -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)
@@ -106,25 +112,35 @@ export function apply(ctx, config) {
106
112
  }
107
113
  }
108
114
 
109
- let lastActiveAccount = publicConfig(live()).activeAccount
115
+ const normalizeEndpoint = (url) => String(url || '').trim().replace(/\/+$/, '')
116
+ const getCatalogIdentity = (cfg) => {
117
+ const pub = publicConfig(cfg)
118
+ return `${normalizeEndpoint(pub.baseUrl)}::${String(pub.activeAccount || '').trim()}::${String(pub.apiKeyEnv || '').trim()}`
119
+ }
120
+
121
+ let lastCatalogIdentity = getCatalogIdentity(live())
122
+ const resetCatalogSnapshot = () => {
123
+ try {
124
+ if (currentConfig && typeof currentConfig === 'object') {
125
+ currentConfig.dynamicModels = []
126
+ currentConfig.planSyncedAt = 0
127
+ }
128
+ } catch {
129
+ /* ignore read-only or frozen */
130
+ }
131
+ overrides.dynamicModels = []
132
+ overrides.planSyncedAt = 0
133
+ clearUsageCache()
134
+ clearProbeCache()
135
+ }
136
+
110
137
  if (typeof ctx?.on === 'function') {
111
138
  ctx.on('loader/volatile-update', () => {
112
139
  const liveCfg = live()
113
- const currentActive = publicConfig(liveCfg).activeAccount
114
- if (currentActive !== lastActiveAccount) {
115
- lastActiveAccount = currentActive
116
- try {
117
- if (currentConfig && typeof currentConfig === 'object') {
118
- currentConfig.dynamicModels = []
119
- currentConfig.planSyncedAt = 0
120
- }
121
- } catch {
122
- /* ignore read-only or frozen */
123
- }
124
- overrides.dynamicModels = []
125
- overrides.planSyncedAt = 0
126
- clearUsageCache()
127
- clearProbeCache()
140
+ const currentIdentity = getCatalogIdentity(liveCfg)
141
+ if (currentIdentity !== lastCatalogIdentity) {
142
+ lastCatalogIdentity = currentIdentity
143
+ resetCatalogSnapshot()
128
144
  }
129
145
  syncStatsStorage(liveCfg)
130
146
  })
@@ -181,7 +197,14 @@ export function apply(ctx, config) {
181
197
  if (!svc || (typeof svc.replace !== 'function' && typeof svc.update !== 'function')) {
182
198
  throw new Error('DSH settings service is unavailable or non-writable')
183
199
  }
200
+ const prevIdentity = getCatalogIdentity(live())
184
201
  const parsed = Config(structuredClone(plainConfig(next)))
202
+ const nextIdentity = getCatalogIdentity(parsed)
203
+ const identityChanged = nextIdentity !== prevIdentity
204
+ if (identityChanged) {
205
+ if (!('dynamicModels' in next)) parsed.dynamicModels = []
206
+ if (!('planSyncedAt' in next)) parsed.planSyncedAt = 0
207
+ }
185
208
  const payload = volatileConfig(parsed)
186
209
  const rev = getRevision(svc)
187
210
  if (typeof svc.replace === 'function') {
@@ -189,6 +212,10 @@ export function apply(ctx, config) {
189
212
  } else {
190
213
  await svc.update(NS, payload, rev)
191
214
  }
215
+ if (identityChanged) {
216
+ lastCatalogIdentity = nextIdentity
217
+ resetCatalogSnapshot()
218
+ }
192
219
  applyConfigMutations(currentConfig, parsed)
193
220
  syncStatsStorage(live())
194
221
  await syncProviderState(live())
@@ -199,7 +226,14 @@ export function apply(ctx, config) {
199
226
  if (!svc || (typeof svc.update !== 'function' && typeof svc.replace !== 'function')) {
200
227
  throw new Error('DSH settings service is unavailable or non-writable')
201
228
  }
229
+ const prevIdentity = getCatalogIdentity(live())
202
230
  const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
231
+ const nextIdentity = getCatalogIdentity(merged)
232
+ const identityChanged = nextIdentity !== prevIdentity
233
+ if (identityChanged) {
234
+ if (!('dynamicModels' in patch)) merged.dynamicModels = []
235
+ if (!('planSyncedAt' in patch)) merged.planSyncedAt = 0
236
+ }
203
237
  const payload = volatileConfig(merged)
204
238
  const rev = getRevision(svc)
205
239
  if (typeof svc.update === 'function') {
@@ -207,6 +241,10 @@ export function apply(ctx, config) {
207
241
  } else {
208
242
  await svc.replace(NS, payload, rev)
209
243
  }
244
+ if (identityChanged) {
245
+ lastCatalogIdentity = nextIdentity
246
+ resetCatalogSnapshot()
247
+ }
210
248
  applyConfigMutations(currentConfig, merged)
211
249
  syncStatsStorage(live())
212
250
  await syncProviderState(live())
@@ -227,6 +265,21 @@ export function apply(ctx, config) {
227
265
  }, 'dsh-clinebot: stats-storage')
228
266
  }
229
267
 
268
+ const routeEnv = {
269
+ live,
270
+ getSettingsApi: () => settingsApi,
271
+ syncProviderState,
272
+ triggerAutoDiscover,
273
+ }
274
+
275
+ if (typeof ctx.effect === 'function') {
276
+ ctx.effect(() => {
277
+ return () => {
278
+ stopLoopbackProxyServer()
279
+ }
280
+ }, 'dsh-clinebot: loopback-proxy-server')
281
+ }
282
+
230
283
  syncProviderState(live())
231
284
  if (typeof ctx.effect === 'function') {
232
285
  ctx.effect(() => {
@@ -249,13 +302,6 @@ export function apply(ctx, config) {
249
302
  ctx.effect(() => () => unregisterUpdater?.(), 'dsh-clinebot: updater')
250
303
  }
251
304
 
252
- const routeEnv = {
253
- live,
254
- getSettingsApi: () => settingsApi,
255
- syncProviderState,
256
- triggerAutoDiscover,
257
- }
258
-
259
305
  registerSettingsRoutes(ctx, routeEnv)
260
306
  registerAccountsRoutes(ctx, routeEnv)
261
307
  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
@@ -208,11 +209,29 @@ export async function upsertPiAiProvider(ctx, cfg, activeModelIds) {
208
209
 
209
210
  const activeAcc = await resolveActiveAccountKey(ctx, cfg)
210
211
 
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
212
+ let isProxy = pub.proxyMode !== false
213
+ let effectiveBaseUrl = pub.baseUrl
214
+ let proxyPort = getProxyPort()
215
+
216
+ if (isProxy) {
217
+ if (!proxyPort) {
218
+ proxyPort = ctx?.webServer?.port || process.env.PORT || 3080
219
+ const isFenced = await detectHostWebServerFence(proxyPort)
220
+ if (isFenced) {
221
+ ctx?.logger?.warn?.(
222
+ `[dsh-clinebot] DesktopWebServer security fence detected on port ${proxyPort} and dedicated loopback proxy is not active. Falling back to direct upstream mode.`
223
+ )
224
+ isProxy = false
225
+ }
226
+ }
227
+ }
228
+
229
+ if (isProxy && proxyPort) {
230
+ effectiveBaseUrl = `http://127.0.0.1:${proxyPort}/dsh-clinebot/v1`
231
+ } else {
232
+ isProxy = false
233
+ effectiveBaseUrl = pub.baseUrl
234
+ }
216
235
 
217
236
  const localToken = isProxy ? getLocalProxyToken() : undefined
218
237
  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
+ }
@@ -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
 
@@ -532,51 +499,67 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
532
499
  details: lastError?.message || null
533
500
  }
534
501
  })
535
- },
502
+ }
503
+ }
504
+
505
+ export function createModelsHandler(ctx, { live }) {
506
+ return async (req, res) => {
507
+ if (req.method !== 'GET') {
508
+ return writeJson(res, 405, { ok: false, error: 'GET only' })
509
+ }
510
+
511
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
512
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
513
+ }
514
+
515
+ if (hasDisallowedBrowserOrigin(req)) {
516
+ return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
517
+ }
518
+
519
+ const pool = await resolveAccountPool(ctx, live())
520
+ if (!isAuthorizedProxyRequest(req, pool)) {
521
+ return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
522
+ }
523
+
524
+ const pub = publicConfig(live())
525
+ const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
526
+ const custom = Array.isArray(pub.customModels) ? pub.customModels : []
527
+ const overrides = Array.isArray(pub.modelContextOverrides) ? pub.modelContextOverrides : []
528
+ const allModels = getAllModels(dynamic, overrides, custom)
529
+
530
+ const modelsData = allModels.map(m => ({
531
+ id: m.id,
532
+ object: 'model',
533
+ created: 1700000000,
534
+ owned_by: 'clinebot',
535
+ permission: [],
536
+ root: m.id,
537
+ parent: null
538
+ }))
539
+
540
+ writeJson(res, 200, {
541
+ object: 'list',
542
+ data: modelsData
543
+ })
544
+ }
545
+ }
546
+
547
+ export function registerProxyRoutes(ctx, routeEnv) {
548
+ const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
549
+ const modelsHandler = createModelsHandler(ctx, routeEnv)
550
+
551
+ // 1. POST /dsh-clinebot/v1/chat/completions
552
+ ctx.effect(() => ctx.webServer.register({
553
+ kind: 'exact',
554
+ path: '/dsh-clinebot/v1/chat/completions',
555
+ handler: chatHandler,
536
556
  }), 'dsh-clinebot: /v1/chat/completions')
537
557
 
538
558
  // 2. GET /dsh-clinebot/v1/models (OpenAI standard)
539
559
  ctx.effect(() => ctx.webServer.register({
540
560
  kind: 'exact',
541
561
  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
- },
562
+ handler: modelsHandler,
581
563
  }), 'dsh-clinebot: /v1/models')
582
564
  }
565
+
@@ -93,7 +93,10 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, syncProvider
93
93
  const base = plainConfig(live()) || {}
94
94
  delete base.enabledModels
95
95
  const merged = { ...base, ...rawPayload }
96
- if (rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()) {
96
+ const activeChanged = rawPayload.activeAccount !== undefined && String(rawPayload.activeAccount).trim() !== String(base.activeAccount || "").trim()
97
+ const apiKeyEnvChanged = rawPayload.apiKeyEnv !== undefined && String(rawPayload.apiKeyEnv).trim() !== String(base.apiKeyEnv || "").trim()
98
+ const baseUrlChanged = rawPayload.baseUrl !== undefined && String(rawPayload.baseUrl).trim().replace(/\/+$/, '') !== String(base.baseUrl || "").trim().replace(/\/+$/, '')
99
+ if (activeChanged || apiKeyEnvChanged || baseUrlChanged) {
97
100
  if (!("dynamicModels" in rawPayload)) merged.dynamicModels = []
98
101
  if (!("planSyncedAt" in rawPayload)) merged.planSyncedAt = 0
99
102
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.13",
3
+ "version": "0.5.16",
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",