@goodandready/dsh-clinebot 0.4.8 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,21 @@ All notable changes to `@goodandready/dsh-clinebot` will be documented in this f
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.5.0] - 2026-09-27
9
+
10
+ ### Security
11
+ - **Loopback Proxy Access Protection (#133)**: Strictly restricted OpenAI-compatible proxy endpoints (`POST /dsh-clinebot/v1/chat/completions` and `GET /dsh-clinebot/v1/models`) to loopback callers (`127.0.0.1`, `::1`, `localhost`). External network requests are rejected immediately with HTTP 403 Forbidden (`Loopback access only`).
12
+
13
+ ### Added & Improved
14
+ - **Session Router Bounded Memory & LRU Eviction (#134)**: Introduced hard session limit `MAX_SESSIONS = 1000` and session expiration TTL `SESSION_TTL_MS = 24h` in `lib/session-router.js`. Prevents unbounded Map growth under high concurrency by pruning expired sessions and evicting least-recently-used sessions.
15
+
16
+ ### Fixed & Cleaned
17
+ - **Dead & Test-Only Exports Elimination (#135)**: Pruned dead and internal-only exports across server modules (`USAGE_CACHE_TTL_MS` in `lib/cline-client.js`, `queueSaveStats` in `lib/stats-storage.js`, and `migrateModelId`, `getOriginalModelProvider`, `findModel`, `getDefaultModelIds` in `lib/models.js`), aligning module boundaries with public contract.
18
+ - **Design Token Purity in Progress Bar (#136)**: Replaced hardcoded fallback hex `#f87171` in `src/client/progress-bar.js` with semantic design system token `var(--dsw-alias-status-danger)`, eliminating color hardcodes across all client components.
19
+ - **Proxy Client Abort Stream Cancellation (#138)**: Connected `req.on('close')` in streaming SSE completions (`POST /dsh-clinebot/v1/chat/completions`) to immediately cancel upstream response reader via `reader.cancel()`, preventing orphaned background inference and quota drain when clients disconnect.
20
+ - **Circular Module Dependency Removal (#139)**: Eradicated artificial circular import cycle between `lib/cline-client.js` and `lib/account-pool.js` by removing legacy re-exports from `cline-client.js` and routing consumers directly to `lib/account-pool.js`.
21
+ - **Proxy Model Fallback (#140)**: Implemented seamless fallback to `pub.defaultModel` in `/dsh-clinebot/v1/chat/completions` when `model` parameter is omitted, preventing upstream HTTP 400 Bad Request.
22
+
8
23
  ## [0.4.8] - 2026-09-27
9
24
 
10
25
  ### Added
package/lib/client.js CHANGED
@@ -478,7 +478,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
478
478
  gap: '8px',
479
479
  padding: '12px 14px',
480
480
  borderRadius: '8px',
481
- border: isBad ? '1px solid var(--dsw-alias-error, #f87171)' : '1px solid var(--dsw-alias-border-l2)',
481
+ border: isBad ? '1px solid var(--dsw-alias-status-danger)' : '1px solid var(--dsw-alias-border-l2)',
482
482
  background: 'var(--dsw-alias-bg-layer-2)',
483
483
  },
484
484
  },
@@ -498,7 +498,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
498
498
  'span',
499
499
  { className: 'cb-progress-label', style: { fontWeight: 500, whiteSpace: 'nowrap' } },
500
500
  label,
501
- isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-error, #f87171)' } }, '⚠️ <10%') : null
501
+ isBad ? React.createElement('span', { style: { marginLeft: '6px', fontSize: '11px', color: 'var(--dsw-alias-status-danger)' } }, '⚠️ <10%') : null
502
502
  ),
503
503
  React.createElement(
504
504
  'span',
@@ -506,7 +506,7 @@ function ProgressBar({ label, percentUsed, remainingPercent, resetsAt, t }) {
506
506
  className: 'cb-progress-meta cb-bar-meta',
507
507
  style: {
508
508
  fontSize: '12px',
509
- color: isBad ? 'var(--dsw-alias-error, #f87171)' : 'var(--dsw-alias-label-secondary)',
509
+ color: isBad ? 'var(--dsw-alias-status-danger)' : 'var(--dsw-alias-label-secondary)',
510
510
  whiteSpace: 'nowrap',
511
511
  marginLeft: 'auto',
512
512
  },
@@ -26,7 +26,7 @@ export const DEFAULT_BASE_URL = 'https://api.cline.bot/api/v1'
26
26
  export const DEFAULT_API_KEY_ENV = 'CLINEBOT_API_KEY'
27
27
  export const DEFAULT_TIMEOUT_MS = 15000
28
28
  export const DEFAULT_SMOKE_TIMEOUT_MS = 25000
29
- export const USAGE_CACHE_TTL_MS = 60000
29
+ const USAGE_CACHE_TTL_MS = 60000
30
30
 
31
31
  export { PROVIDER_ID, PROVIDER_DISPLAY_NAME, DEFAULT_MODEL_ID }
32
32
 
@@ -508,4 +508,3 @@ export function buildPiAiProvider({
508
508
 
509
509
 
510
510
 
511
- export { resolveAccountPool, isAccountQuotaExhausted, rotateToNextAccount, getLastRotation, setLastRotation, isQuotaExceededError } from './account-pool.js'
package/lib/http.js CHANGED
@@ -46,7 +46,7 @@ function header(request, name) {
46
46
  return Array.isArray(value) ? value[0] : value
47
47
  }
48
48
 
49
- function isLoopbackAddress(value) {
49
+ export function isLoopbackAddress(value) {
50
50
  const address = String(value || '').toLowerCase().replace(/^\[|\]$/g, '')
51
51
  return address === 'localhost' || address === 'localhost.' || address === '::1'
52
52
  || address.startsWith('127.')
package/lib/index.js CHANGED
@@ -4,9 +4,11 @@ import {
4
4
  sessionStats,
5
5
  recordSessionRequest,
6
6
  resetSessionStats,
7
+ } from './cline-client.js'
8
+ import {
7
9
  rotateToNextAccount,
8
10
  isQuotaExceededError,
9
- } from './cline-client.js'
11
+ } from './account-pool.js'
10
12
  import { PROVIDER_ID } from './models.js'
11
13
  import {
12
14
  upsertPiAiProvider,
package/lib/models.js CHANGED
@@ -20,7 +20,7 @@ export const OBSOLETE_MODEL_ID_MAP = Object.freeze({
20
20
  'cline-pass/muse-spark-13-contributor': 'cline-pass/muse-spark-1.3-contributor',
21
21
  })
22
22
 
23
- export function migrateModelId(id) {
23
+ function migrateModelId(id) {
24
24
  if (typeof id !== 'string') return id
25
25
  return OBSOLETE_MODEL_ID_MAP[id] || id
26
26
  }
@@ -57,7 +57,7 @@ export function getOriginalModelContext(nameOrId) {
57
57
  return 200000
58
58
  }
59
59
 
60
- export function getOriginalModelProvider(nameOrId) {
60
+ function getOriginalModelProvider(nameOrId) {
61
61
  const norm = String(nameOrId || '').toLowerCase()
62
62
  if (norm.includes('kimi')) return 'Moonshot AI'
63
63
  if (norm.includes('qwen')) return 'Alibaba Cloud'
@@ -437,7 +437,7 @@ export function getAllModels(dynamicModels = [], modelContextOverrides = [], cus
437
437
  return baseModels
438
438
  }
439
439
 
440
- export function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
440
+ function findModel(id, dynamicModels = [], modelContextOverrides = [], customModels = []) {
441
441
  const all = getAllModels(dynamicModels, modelContextOverrides)
442
442
  const found = all.find((m) => m.id === id)
443
443
  if (found) return found
@@ -448,7 +448,7 @@ export function isSupportedModel(id, dynamicModels = [], modelContextOverrides =
448
448
  return findModel(id, dynamicModels, modelContextOverrides) !== null
449
449
  }
450
450
 
451
- export function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
451
+ function getDefaultModelIds(dynamicModels = [], modelContextOverrides = [], customModels = []) {
452
452
  return getAllModels(dynamicModels, modelContextOverrides).map((m) => m.id)
453
453
  }
454
454
 
@@ -5,10 +5,10 @@ import { resolveActiveAccountKey } from '../provider-sync.js'
5
5
  import {
6
6
  smokeChat,
7
7
  recordSmokeTest,
8
- rotateToNextAccount,
9
8
  DEFAULT_MODEL_ID,
10
9
  normalizeBaseUrl,
11
10
  } from '../cline-client.js'
11
+ import { rotateToNextAccount } from '../account-pool.js'
12
12
 
13
13
  export function registerAuthRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
14
14
  // POST /dsh-clinebot/key/verify — on-the-fly verification of Cline API key
@@ -12,12 +12,12 @@
12
12
  * 4. Model reasoning_effort default injection from config.
13
13
  */
14
14
 
15
- import { writeJson, readBody } from '../http.js'
15
+ import { writeJson, readBody, isLoopbackAddress } from '../http.js'
16
16
  import { publicConfig, plainConfig } from '../config.js'
17
17
  import { resolveAccountPool } from '../account-pool.js'
18
18
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
19
19
  import { recordUsage } from '../stats-storage.js'
20
- import { getAllModels } from '../models.js'
20
+ import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
21
21
 
22
22
  export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
23
23
  // 1. POST /dsh-clinebot/v1/chat/completions
@@ -29,6 +29,10 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
29
29
  return writeJson(res, 405, { ok: false, error: 'POST only' })
30
30
  }
31
31
 
32
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
33
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
34
+ }
35
+
32
36
  let bodyBuf
33
37
  try {
34
38
  bodyBuf = await readBody(req)
@@ -56,10 +60,12 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
56
60
  ''
57
61
  ).trim()
58
62
 
63
+ // Ensure model is set, fallback to defaultModel (#140)
64
+ body.model = String(body.model || pub.defaultModel || DEFAULT_MODEL_ID).trim()
65
+
59
66
  // Inject model reasoning effort if model has default configured and payload omitted it
60
67
  if (
61
68
  pub.modelReasoningDefaults &&
62
- body.model &&
63
69
  pub.modelReasoningDefaults[body.model] &&
64
70
  !body.reasoning_effort
65
71
  ) {
@@ -95,7 +101,7 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
95
101
  'Content-Type': 'application/json',
96
102
  'Authorization': `Bearer ${targetAccount.value}`,
97
103
  'Accept': isStreaming ? 'text/event-stream' : 'application/json',
98
- 'User-Agent': '@goodandready/dsh-clinebot/0.4.8'
104
+ 'User-Agent': '@goodandready/dsh-clinebot/0.5.0'
99
105
  }
100
106
 
101
107
  let upstreamRes
@@ -145,10 +151,23 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
145
151
  const reader = upstreamRes.body.getReader()
146
152
  const decoder = new TextDecoder('utf8')
147
153
 
154
+ let streamAborted = false
155
+ const onClientAbort = () => {
156
+ streamAborted = true
157
+ try {
158
+ reader.cancel().catch(() => {})
159
+ } catch {
160
+ /* ignore */
161
+ }
162
+ }
163
+ if (typeof req.on === 'function') {
164
+ req.on('close', onClientAbort)
165
+ }
166
+
148
167
  try {
149
168
  while (true) {
150
169
  const { done, value } = await reader.read()
151
- if (done) break
170
+ if (done || streamAborted) break
152
171
 
153
172
  const chunkStr = decoder.decode(value, { stream: true })
154
173
  res.write(value)
@@ -175,8 +194,13 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
175
194
  }
176
195
  }
177
196
  } catch (streamErr) {
178
- ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
197
+ if (!streamAborted) {
198
+ ctx?.logger?.warn?.(`[dsh-clinebot:proxy] Error streaming SSE chunk: ${streamErr?.message || streamErr}`)
199
+ }
179
200
  } finally {
201
+ if (typeof req.off === 'function') {
202
+ req.off('close', onClientAbort)
203
+ }
180
204
  res.end()
181
205
  recordUsage({
182
206
  model: body.model,
@@ -237,6 +261,10 @@ export function registerProxyRoutes(ctx, { live, getSettingsApi, syncProviderSta
237
261
  if (req.method !== 'GET') {
238
262
  return writeJson(res, 405, { ok: false, error: 'GET only' })
239
263
  }
264
+
265
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
266
+ return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
267
+ }
240
268
  const pub = publicConfig(live())
241
269
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
242
270
  const custom = Array.isArray(pub.customModels) ? pub.customModels : []
@@ -14,6 +14,40 @@ const sessions = new Map()
14
14
  // In-memory cooldowns: accountId -> { until: timestamp, reason: string }
15
15
  const cooldowns = new Map()
16
16
 
17
+ export const MAX_SESSIONS = 1000
18
+ export const SESSION_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
19
+
20
+ /**
21
+ * Evict expired sessions and trim capacity to maxLimit via LRU.
22
+ *
23
+ * @param {number} [now=Date.now()] Current timestamp
24
+ * @param {number} [maxLimit=MAX_SESSIONS] Capacity threshold
25
+ * @param {number} [ttlMs=SESSION_TTL_MS] Expiration TTL
26
+ */
27
+ export function pruneSessions(now = Date.now(), maxLimit = MAX_SESSIONS, ttlMs = SESSION_TTL_MS) {
28
+ for (const [id, s] of sessions.entries()) {
29
+ if (now - s.lastUsedAt > ttlMs) {
30
+ sessions.delete(id)
31
+ }
32
+ }
33
+
34
+ while (sessions.size > maxLimit) {
35
+ let oldestId = null
36
+ let oldestTime = Infinity
37
+ for (const [id, s] of sessions.entries()) {
38
+ if (s.lastUsedAt < oldestTime) {
39
+ oldestTime = s.lastUsedAt
40
+ oldestId = id
41
+ }
42
+ }
43
+ if (oldestId) {
44
+ sessions.delete(oldestId)
45
+ } else {
46
+ break
47
+ }
48
+ }
49
+ }
50
+
17
51
  /**
18
52
  * Check if an account is currently in cooldown or rate-limited.
19
53
  * Performs automatic recovery if the cooldown duration has passed.
@@ -120,15 +154,27 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
120
154
  return { account: best, isReassigned: false, reason: 'no_session_id' }
121
155
  }
122
156
 
123
- const existing = sessions.get(sessionId)
157
+ let existing = sessions.get(sessionId)
158
+ const now = Date.now()
124
159
  if (existing) {
125
- const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
126
- // If assigned account is still valid and NOT in cooldown, keep sticky pin
127
- if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
128
- existing.lastUsedAt = Date.now()
129
- return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
160
+ // TTL eviction check
161
+ if (now - existing.lastUsedAt > SESSION_TTL_MS) {
162
+ sessions.delete(sessionId)
163
+ existing = null
164
+ } else {
165
+ const assigned = safeAccounts.find(a => (a.id || a.label) === existing.accountId)
166
+ // If assigned account is still valid and NOT in cooldown, keep sticky pin
167
+ if (assigned && !isAccountInCooldown(assigned.id || assigned.label)) {
168
+ existing.lastUsedAt = now
169
+ return { account: assigned, isReassigned: false, reason: 'sticky_pinned' }
170
+ }
171
+ // Failover: account was deleted or entered cooldown mid-session
130
172
  }
131
- // Failover: account was deleted or entered cooldown mid-session
173
+ }
174
+
175
+ // Prune before adding a brand new session if capacity reached
176
+ if (!existing && sessions.size >= MAX_SESSIONS) {
177
+ pruneSessions(now, MAX_SESSIONS - 1, SESSION_TTL_MS)
132
178
  }
133
179
 
134
180
  // New session or mid-session failover: pick least-used account
@@ -137,8 +183,8 @@ export function resolveSessionAccount(sessionId, accounts = [], defaultActiveId
137
183
  const chosenId = chosen.id || chosen.label
138
184
  sessions.set(sessionId, {
139
185
  accountId: chosenId,
140
- assignedAt: existing ? existing.assignedAt : Date.now(),
141
- lastUsedAt: Date.now()
186
+ assignedAt: existing ? existing.assignedAt : now,
187
+ lastUsedAt: now
142
188
  })
143
189
  }
144
190
 
@@ -174,6 +220,8 @@ export function resetSessionRouter() {
174
220
  export function getSessionRouterStatus() {
175
221
  return {
176
222
  activeSessionsCount: sessions.size,
223
+ maxSessions: MAX_SESSIONS,
224
+ sessionTtlMs: SESSION_TTL_MS,
177
225
  cooldownsCount: cooldowns.size,
178
226
  cooldowns: Object.fromEntries(
179
227
  Array.from(cooldowns.entries()).map(([k, v]) => [
@@ -1,5 +1,6 @@
1
1
  import { publicConfig, plainConfig, Config } from './config.js'
2
2
  import { resolveActiveAccountKey, formatProgressBar } from './provider-sync.js'
3
+ import { resolveAccountPool, rotateToNextAccount, getLastRotation } from './account-pool.js'
3
4
  import {
4
5
  getAllModels,
5
6
  isVisionModel,
@@ -8,8 +9,6 @@ import {
8
9
  DEFAULT_MODEL_ID,
9
10
  } from './models.js'
10
11
  import {
11
- resolveAccountPool,
12
- rotateToNextAccount,
13
12
  probeHealth,
14
13
  smokeChat,
15
14
  fetchUsageLimits,
@@ -18,7 +17,6 @@ import {
18
17
  clearProbeCache,
19
18
  usageCache,
20
19
  sessionStats,
21
- getLastRotation,
22
20
  } from './cline-client.js'
23
21
 
24
22
  export function registerSlashCommand(ctx, { live, getSettingsApi, syncProviderState }) {
@@ -117,7 +117,7 @@ export function saveStatsSync(customPath) {
117
117
  * @param {string} [customPath]
118
118
  * @param {number} [debounceMs=500]
119
119
  */
120
- export function queueSaveStats(customPath, debounceMs = 500) {
120
+ function queueSaveStats(customPath, debounceMs = 500) {
121
121
  if (saveTimer) {
122
122
  clearTimeout(saveTimer)
123
123
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.4.8",
3
+ "version": "0.5.0",
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",