@goodandready/dsh-clinebot 0.5.31 → 0.5.33

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.33] - 2026-10-19
9
+
10
+ ### Security
11
+ - **Proxy Token Isolation from Environment & Child Processes (Issue #247)**:
12
+ - Local proxy bearer authentication token (`CLINEBOT_LOCAL_PROXY_TOKEN`) is now retained strictly in module-private memory and never exported to `process.env`.
13
+ - Added startup and mutation scrubbing to ensure `process.env[CLINEBOT_LOCAL_PROXY_TOKEN]` is deleted immediately.
14
+ - Sanitized child process spawn environment in `lib/updater.js` to ensure the token cannot be inherited by sub-processes.
15
+ - Enforced unified DSH connection authorization gate (`connection.requestRejection`) across `/dsh-clinebot/v1/chat/completions` and `/dsh-clinebot/v1/models` endpoints.
16
+
17
+ ## [0.5.32] - 2026-10-19
18
+
19
+ ### Security
20
+ - **API Key Sanitization in Key Auto-Discovery & Import Flow (Issue #246)**:
21
+ - Eliminated raw secret key exposure from `GET /dsh-clinebot/keys/discovered`. Discovered keys are now cached exclusively on the server with a 15-minute TTL under an opaque UUID `id`.
22
+ - Added dedicated server-side import route `POST /dsh-clinebot/keys/discovered/import` that consumes the discovered secret by `id` directly on the server and persists it into DSH Credentials (`saveCredentialKey`) without ever passing secret tokens through client memory, devtools, or network logs.
23
+ - Updated client settings UI to import discovered keys strictly by opaque identifier.
24
+
8
25
  ## [0.5.31] - 2026-10-18
9
26
 
10
27
  ### Fixed
package/lib/client.js CHANGED
@@ -1019,9 +1019,9 @@ function AccountsSection({
1019
1019
  await handleAddAccount({
1020
1020
  label: item.source || targetEnv,
1021
1021
  apiKeyEnv: targetEnv,
1022
- apiKey: item.key,
1022
+ id: item.id,
1023
1023
  })
1024
- setDiscoveredKeys((prev) => prev.filter((k) => k.key !== item.key))
1024
+ setDiscoveredKeys((prev) => prev.filter((k) => k.id !== item.id))
1025
1025
  }
1026
1026
  }
1027
1027
 
@@ -3510,11 +3510,13 @@ function SettingsPage(props) {
3510
3510
  })
3511
3511
  }
3512
3512
 
3513
- async function handleAddAccount({ label, apiKeyEnv, apiKey }) {
3513
+ async function handleAddAccount({ label, apiKeyEnv, apiKey, id }) {
3514
3514
  await performAction('add-account', async () => {
3515
- const res = await fetchWithTimeout(`${ROUTE_PREFIX}/accounts`, {
3515
+ const endpoint = id ? `${ROUTE_PREFIX}/keys/discovered/import` : `${ROUTE_PREFIX}/accounts`
3516
+ const payload = id ? { id, label, apiKeyEnv } : { label, apiKeyEnv, apiKey }
3517
+ const res = await fetchWithTimeout(endpoint, {
3516
3518
  method: 'POST', headers: { 'Content-Type': 'application/json' },
3517
- body: JSON.stringify({ label, apiKeyEnv, apiKey }),
3519
+ body: JSON.stringify(payload),
3518
3520
  })
3519
3521
  const data = await res.json().catch(() => ({}))
3520
3522
  if (!res.ok || !data.ok) throw new Error(data.error || `HTTP ${res.status}`)
@@ -1,10 +1,44 @@
1
1
  import fs from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import os from 'node:os'
4
+ import crypto from 'node:crypto'
4
5
 
5
6
  const CLINE_KEY_REGEX = /cline_[A-Za-z0-9_\-]{16,}/g
7
+ const DISCOVERY_CACHE_TTL_MS = 15 * 60 * 1000 // 15 minutes TTL
6
8
 
7
- function maskApiKey(key) {
9
+ // Server-side cache for discovered keys: id -> { id, key, source, maskedKey, path, expiresAt }
10
+ const discoveredKeysCache = new Map()
11
+
12
+ function cleanupExpired() {
13
+ const now = Date.now()
14
+ for (const [id, item] of discoveredKeysCache.entries()) {
15
+ if (item.expiresAt && item.expiresAt <= now) {
16
+ discoveredKeysCache.delete(id)
17
+ }
18
+ }
19
+ }
20
+
21
+ export function getDiscoveredKey(id) {
22
+ if (!id || typeof id !== 'string') return null
23
+ cleanupExpired()
24
+ const item = discoveredKeysCache.get(id)
25
+ return item?.key || null
26
+ }
27
+
28
+ export function consumeDiscoveredKey(id) {
29
+ if (!id || typeof id !== 'string') return null
30
+ cleanupExpired()
31
+ const item = discoveredKeysCache.get(id)
32
+ if (!item) return null
33
+ discoveredKeysCache.delete(id)
34
+ return item.key
35
+ }
36
+
37
+ export function clearDiscoveredKeysCache() {
38
+ discoveredKeysCache.clear()
39
+ }
40
+
41
+ export function maskApiKey(key) {
8
42
  if (!key || typeof key !== 'string') return ''
9
43
  if (key.length <= 12) return '••••••••'
10
44
  return key.slice(0, 8) + '••••' + key.slice(-4)
@@ -35,13 +69,41 @@ function scanFileForKeys(filePath, sourceName) {
35
69
  }
36
70
 
37
71
  export function discoverLocalClineKeys() {
72
+ cleanupExpired()
38
73
  const discovered = []
39
74
  const seenKeys = new Set()
40
75
 
41
76
  const addKey = (item) => {
42
77
  if (!item?.key || seenKeys.has(item.key)) return
43
78
  seenKeys.add(item.key)
44
- discovered.push(item)
79
+
80
+ // Reuse existing ID if already cached
81
+ let existingId = null
82
+ for (const [id, cached] of discoveredKeysCache.entries()) {
83
+ if (cached.key === item.key) {
84
+ existingId = id
85
+ cached.expiresAt = Date.now() + DISCOVERY_CACHE_TTL_MS
86
+ break
87
+ }
88
+ }
89
+
90
+ const id = existingId || crypto.randomUUID()
91
+ discoveredKeysCache.set(id, {
92
+ id,
93
+ key: item.key,
94
+ source: item.source,
95
+ maskedKey: item.maskedKey,
96
+ path: item.path,
97
+ expiresAt: Date.now() + DISCOVERY_CACHE_TTL_MS,
98
+ })
99
+
100
+ // Public view: NEVER expose the raw secret key (#246)
101
+ discovered.push({
102
+ id,
103
+ source: item.source,
104
+ maskedKey: item.maskedKey,
105
+ path: item.path,
106
+ })
45
107
  }
46
108
 
47
109
  // 1. Check environment variables
@@ -106,4 +168,4 @@ export function discoverLocalClineKeys() {
106
168
  } catch {}
107
169
 
108
170
  return discovered
109
- }
171
+ }
@@ -1,22 +1,30 @@
1
1
  /**
2
- * Local Proxy Authentication Token (Issue #145)
2
+ * Local Proxy Authentication Token (Issues #145, #247)
3
3
  *
4
4
  * Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
5
5
  * LAN clients or cross-site browser requests (CSRF).
6
+ * Token is stored strictly in-memory and isolated from process.env and child processes (#247).
6
7
  */
7
8
 
8
9
  import crypto from 'node:crypto'
9
10
  import { createRequire } from 'node:module'
10
- import { writeJson } from './http.js'
11
+ import { writeJson, isLoopbackAddress, getConnection } from './http.js'
12
+ import { resolveAccountPool } from './account-pool.js'
11
13
 
12
14
  export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
13
15
 
14
16
  let cachedToken = null
15
17
 
18
+ // At module startup, scrub any token from process.env if present (#247)
19
+ if (process.env[LOCAL_PROXY_KEY_ENV]) {
20
+ cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
21
+ delete process.env[LOCAL_PROXY_KEY_ENV]
22
+ }
23
+
16
24
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.31'
25
+ let packageVersion = '0.5.33'
18
26
  try {
19
- packageVersion = require('../package.json').version || '0.5.31'
27
+ packageVersion = require('../package.json').version || '0.5.33'
20
28
  } catch (err) {
21
29
  /* fallback to default version if manifest is unreadable */
22
30
  }
@@ -25,6 +33,7 @@ export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
25
33
 
26
34
  /**
27
35
  * Get or generate the local proxy bearer token.
36
+ * Token is stored strictly in module-private memory and never exported to process.env (#247).
28
37
  *
29
38
  * @returns {string}
30
39
  */
@@ -32,25 +41,22 @@ export function getLocalProxyToken() {
32
41
  if (cachedToken) return cachedToken
33
42
  if (process.env[LOCAL_PROXY_KEY_ENV]) {
34
43
  cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
44
+ delete process.env[LOCAL_PROXY_KEY_ENV]
35
45
  return cachedToken
36
46
  }
37
47
  cachedToken = crypto.randomBytes(24).toString('hex')
38
- process.env[LOCAL_PROXY_KEY_ENV] = cachedToken
39
48
  return cachedToken
40
49
  }
41
50
 
42
51
  /**
43
52
  * Manually set local proxy token (for testing).
53
+ * Never leaks token to process.env (#247).
44
54
  *
45
55
  * @param {string|null} token
46
56
  */
47
57
  export function setLocalProxyToken(token) {
48
58
  cachedToken = token
49
- if (token) {
50
- process.env[LOCAL_PROXY_KEY_ENV] = token
51
- } else {
52
- delete process.env[LOCAL_PROXY_KEY_ENV]
53
- }
59
+ delete process.env[LOCAL_PROXY_KEY_ENV]
54
60
  }
55
61
 
56
62
  /**
@@ -75,6 +81,48 @@ export function isAuthorizedProxyRequest(req, accountPool = []) {
75
81
  return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
76
82
  }
77
83
 
84
+ /**
85
+ * Unified gate for proxy endpoints (/v1/chat/completions, /v1/models) (#247).
86
+ * Verifies loopback address, browser origin, DSH connection rejection, and bearer token.
87
+ */
88
+ export async function assertAuthorizedProxyRequest(ctx, req, res, live) {
89
+ if (!isLoopbackAddress(req.socket?.remoteAddress)) {
90
+ writeJson(res, 403, { ok: false, error: 'Loopback access only' })
91
+ return { ok: false }
92
+ }
93
+
94
+ if (hasDisallowedBrowserOrigin(req)) {
95
+ writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
96
+ return { ok: false }
97
+ }
98
+
99
+ const conn = getConnection(ctx) || req?.connectionService
100
+ if (conn && typeof conn.requestRejection === 'function') {
101
+ let rejection
102
+ try {
103
+ rejection = conn.requestRejection(req)
104
+ } catch {
105
+ rejection = 403
106
+ }
107
+ if (rejection !== undefined) {
108
+ const status = typeof rejection === 'number' ? rejection : 403
109
+ writeJson(res, status, {
110
+ ok: false,
111
+ error: status === 401 ? 'Authentication required' : 'Forbidden',
112
+ })
113
+ return { ok: false }
114
+ }
115
+ }
116
+
117
+ const pool = await resolveAccountPool(ctx, typeof live === 'function' ? live() : live)
118
+ if (!isAuthorizedProxyRequest(req, pool)) {
119
+ writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
120
+ return { ok: false }
121
+ }
122
+
123
+ return { ok: true, pool }
124
+ }
125
+
78
126
  /**
79
127
  * Write appropriate terminal JSON error response when retries are exhausted.
80
128
  */
@@ -119,7 +167,6 @@ export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastEr
119
167
  })
120
168
  }
121
169
 
122
-
123
170
  /**
124
171
  * Safely extract standard and reasoning token counts from usage object.
125
172
  */
@@ -137,4 +184,4 @@ export function extractUsageMetrics(usage) {
137
184
  )
138
185
  const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
139
186
  return { promptTokens, completionTokens, reasoningTokens, totalTokens }
140
- }
187
+ }
@@ -1,5 +1,5 @@
1
1
  import { writeJson, readBody } from '../http.js'
2
- import { discoverLocalClineKeys } from '../key-discovery.js'
2
+ import { discoverLocalClineKeys, consumeDiscoveredKey } from '../key-discovery.js'
3
3
  import { assertTrustedSettingsRequest } from '../access.js'
4
4
  import { publicConfig, plainConfig, Config } from '../config.js'
5
5
  import { upsertPiAiProvider, removePiAiProvider } from '../provider-sync.js'
@@ -12,10 +12,81 @@ import {
12
12
  DEFAULT_API_KEY_ENV,
13
13
  } from '../cline-client.js'
14
14
 
15
- const pendingSecretCleanupAccounts = new Set()
15
+ export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
16
+ const pendingSecretCleanupAccounts = new Set()
16
17
 
17
- export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProviderState }) {
18
- // POST /dsh-clinebot/accounts — add or update account in pool
18
+ async function addAccountInternal({ apiKey, label, apiKeyEnv }) {
19
+ const curr = plainConfig(live())
20
+ const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
21
+
22
+ let targetEnv = String(apiKeyEnv || '').trim()
23
+ if (!targetEnv) {
24
+ let maxN = 1
25
+ for (const acc of existingAccounts) {
26
+ const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
27
+ if (m) {
28
+ const num = parseInt(m[1], 10)
29
+ if (num >= maxN) maxN = num + 1
30
+ }
31
+ }
32
+ if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
33
+ const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
34
+ if (m) {
35
+ const num = parseInt(m[1], 10)
36
+ if (num >= maxN) maxN = num + 1
37
+ }
38
+ }
39
+ targetEnv = `CLINEBOT_API_KEY_${maxN}`
40
+ }
41
+
42
+ if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
43
+ const err = new Error(`Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`)
44
+ err.status = 400
45
+ throw err
46
+ }
47
+
48
+ const accountLabel = String(label || '').trim() || targetEnv
49
+
50
+ await saveCredentialKey(ctx, targetEnv, apiKey)
51
+
52
+ const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
53
+ const accountEntry = { label: accountLabel, apiKeyEnv: targetEnv }
54
+ if (idx >= 0) {
55
+ existingAccounts[idx] = accountEntry
56
+ } else {
57
+ existingAccounts.push(accountEntry)
58
+ }
59
+
60
+ const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
61
+ const settingsApi = getSettingsApi()
62
+ await settingsApi.replace(next)
63
+ await syncProviderState(next)
64
+
65
+ let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
66
+ try {
67
+ const parsed = new URL(curr.baseUrl)
68
+ if (parsed.protocol === 'https:') {
69
+ validation = await smokeChat(curr.baseUrl, apiKey, {
70
+ model: curr.defaultModel,
71
+ timeoutMs: 15000,
72
+ })
73
+ }
74
+ } catch {
75
+ validation = { ok: false, error: 'Invalid baseUrl' }
76
+ }
77
+
78
+ clearUsageCache()
79
+ clearProbeCache()
80
+
81
+ return {
82
+ account: accountEntry,
83
+ validated: validation.ok,
84
+ latencyMs: validation.latencyMs,
85
+ validationError: validation.ok ? null : validation.error,
86
+ }
87
+ }
88
+
89
+ // POST /dsh-clinebot/accounts — add account endpoint (supports DELETE for backward compat)
19
90
  ctx.effect(() => ctx.webServer.register({
20
91
  kind: 'exact',
21
92
  path: '/dsh-clinebot/accounts',
@@ -39,77 +110,15 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
39
110
  return writeJson(res, 400, { ok: false, error: 'API key is required' })
40
111
  }
41
112
 
42
- const curr = plainConfig(live())
43
- const existingAccounts = Array.isArray(curr.accounts) ? [...curr.accounts] : []
44
-
45
- let targetEnv = String(body.apiKeyEnv || '').trim()
46
- if (!targetEnv) {
47
- let maxN = 1
48
- for (const acc of existingAccounts) {
49
- const m = String(acc?.apiKeyEnv || '').match(/^CLINEBOT_API_KEY_(\d+)$/)
50
- if (m) {
51
- const num = parseInt(m[1], 10)
52
- if (num >= maxN) maxN = num + 1
53
- }
54
- }
55
- if (curr.apiKeyEnv && curr.apiKeyEnv.startsWith('CLINEBOT_API_KEY_')) {
56
- const m = curr.apiKeyEnv.match(/^CLINEBOT_API_KEY_(\d+)$/)
57
- if (m) {
58
- const num = parseInt(m[1], 10)
59
- if (num >= maxN) maxN = num + 1
60
- }
61
- }
62
- targetEnv = `CLINEBOT_API_KEY_${maxN}`
63
- }
64
-
65
- if (!/^CLINEBOT_API_KEY(_[A-Z0-9]+)?$/.test(targetEnv)) {
66
- return writeJson(res, 400, {
67
- ok: false,
68
- error: `Disallowed apiKeyEnv: "${targetEnv}". Must match ^CLINEBOT_API_KEY(_[A-Z0-9]+)?$`,
69
- })
70
- }
71
-
72
- const label = String(body.label || '').trim() || targetEnv
73
-
74
- await saveCredentialKey(ctx, targetEnv, apiKey)
75
-
76
- const idx = existingAccounts.findIndex((a) => a && a.apiKeyEnv === targetEnv)
77
- const accountEntry = { label, apiKeyEnv: targetEnv }
78
- if (idx >= 0) {
79
- existingAccounts[idx] = accountEntry
80
- } else {
81
- existingAccounts.push(accountEntry)
82
- }
83
-
84
- const next = plainConfig(Config({ ...curr, accounts: existingAccounts }))
85
- await settingsApi.replace(next)
86
- await syncProviderState(next)
87
-
88
- let validation = { ok: false, error: 'Skipped validation: baseUrl is not https' }
89
- try {
90
- const parsed = new URL(curr.baseUrl)
91
- if (parsed.protocol === 'https:') {
92
- validation = await smokeChat(curr.baseUrl, apiKey, {
93
- model: curr.defaultModel,
94
- timeoutMs: 15000,
95
- })
96
- }
97
- } catch {
98
- validation = { ok: false, error: 'Invalid baseUrl' }
99
- }
100
-
101
- clearUsageCache()
102
- clearProbeCache()
103
-
104
- writeJson(res, 200, {
105
- ok: true,
106
- account: accountEntry,
107
- validated: validation.ok,
108
- latencyMs: validation.latencyMs,
109
- validationError: validation.ok ? null : validation.error,
113
+ const result = await addAccountInternal({
114
+ apiKey,
115
+ label: body.label,
116
+ apiKeyEnv: body.apiKeyEnv,
110
117
  })
118
+ writeJson(res, 200, { ok: true, ...result })
111
119
  } catch (err) {
112
- writeJson(res, 500, { ok: false, error: String(err?.message || err) })
120
+ const status = typeof err?.status === 'number' ? err.status : 500
121
+ writeJson(res, status, { ok: false, error: String(err?.message || err) })
113
122
  }
114
123
  },
115
124
  }), 'dsh-clinebot: /accounts')
@@ -338,7 +347,7 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
338
347
  },
339
348
  }), 'dsh-clinebot: /unregister')
340
349
 
341
- // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232)
350
+ // GET /dsh-clinebot/keys/discovered — scan local Cline CLI and VS Code extension keys (#232, #246)
342
351
  ctx.effect(() => ctx.webServer.register({
343
352
  kind: 'exact',
344
353
  path: '/dsh-clinebot/keys/discovered',
@@ -353,4 +362,43 @@ export function registerAccountsRoutes(ctx, { live, getSettingsApi, syncProvider
353
362
  }
354
363
  },
355
364
  }), 'dsh-clinebot: /keys/discovered')
356
- }
365
+
366
+ // POST /dsh-clinebot/keys/discovered/import — safely import discovered key without exposing secret (#246)
367
+ ctx.effect(() => ctx.webServer.register({
368
+ kind: 'exact',
369
+ path: '/dsh-clinebot/keys/discovered/import',
370
+ handler: async (req, res) => {
371
+ if (req.method !== 'POST') return writeJson(res, 405, { ok: false, error: 'POST only' })
372
+ if (!assertTrustedSettingsRequest(req, res, ctx)) return
373
+ const settingsApi = getSettingsApi()
374
+ if (!settingsApi || typeof settingsApi.replace !== 'function') {
375
+ return writeJson(res, 503, { ok: false, error: 'Settings service unavailable or read-only' })
376
+ }
377
+ try {
378
+ const bodyBuf = await readBody(req)
379
+ let body = {}
380
+ try { body = JSON.parse(bodyBuf.toString('utf8')) } catch { body = {} }
381
+
382
+ const id = String(body.id || '').trim()
383
+ if (!id) {
384
+ return writeJson(res, 400, { ok: false, error: 'Discovered key id is required' })
385
+ }
386
+
387
+ const apiKey = consumeDiscoveredKey(id)
388
+ if (!apiKey) {
389
+ return writeJson(res, 404, { ok: false, error: 'Discovered key not found or expired' })
390
+ }
391
+
392
+ const result = await addAccountInternal({
393
+ apiKey,
394
+ label: body.label,
395
+ apiKeyEnv: body.apiKeyEnv,
396
+ })
397
+ writeJson(res, 200, { ok: true, ...result })
398
+ } catch (err) {
399
+ const status = typeof err?.status === 'number' ? err.status : 500
400
+ writeJson(res, status, { ok: false, error: String(err?.message || err) })
401
+ }
402
+ },
403
+ }), 'dsh-clinebot: /keys/discovered/import')
404
+ }
@@ -15,19 +15,19 @@
15
15
  * 7. Streaming SSE token usage interception & persistent telemetry recording.
16
16
  */
17
17
 
18
- import { writeJson, readBody, isLoopbackAddress } from '../http.js'
18
+ import { writeJson, readBody } from '../http.js'
19
19
  import { publicConfig, plainConfig } from '../config.js'
20
- import { resolveAccountPool } from '../account-pool.js'
21
20
  import { resolveSessionAccount, markAccountCooldown } from '../session-router.js'
22
21
  import { recordUsage } from '../stats-storage.js'
23
22
  import { getAllModels, DEFAULT_MODEL_ID } from '../models.js'
24
23
  import { optimizePromptCacheAndBudget } from '../multimodal-budget.js'
25
24
  import {
26
25
  getLocalProxyToken, hasDisallowedBrowserOrigin, isAuthorizedProxyRequest,
26
+ assertAuthorizedProxyRequest,
27
27
  USER_AGENT, sendProxyTerminalError, extractUsageMetrics
28
28
  } from '../proxy-token.js'
29
29
 
30
- export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, USER_AGENT }
30
+ export { hasDisallowedBrowserOrigin, isAuthorizedProxyRequest, assertAuthorizedProxyRequest, USER_AGENT }
31
31
 
32
32
  export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncProviderState }) {
33
33
  return async (req, res) => {
@@ -35,18 +35,9 @@ export function createChatCompletionsHandler(ctx, { live, getSettingsApi, syncPr
35
35
  return writeJson(res, 405, { ok: false, error: 'POST only' })
36
36
  }
37
37
 
38
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
39
- return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
40
- }
41
-
42
- if (hasDisallowedBrowserOrigin(req)) {
43
- return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
44
- }
45
-
46
- const pool = await resolveAccountPool(ctx, live())
47
- if (!isAuthorizedProxyRequest(req, pool)) {
48
- return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
49
- }
38
+ const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
39
+ if (!auth.ok) return
40
+ const pool = auth.pool
50
41
 
51
42
  let bodyBuf
52
43
  try {
@@ -543,18 +534,8 @@ export function createModelsHandler(ctx, { live }) {
543
534
  return writeJson(res, 405, { ok: false, error: 'GET only' })
544
535
  }
545
536
 
546
- if (!isLoopbackAddress(req.socket?.remoteAddress)) {
547
- return writeJson(res, 403, { ok: false, error: 'Loopback access only' })
548
- }
549
-
550
- if (hasDisallowedBrowserOrigin(req)) {
551
- return writeJson(res, 403, { ok: false, error: 'Cross-site or browser requests to proxy are forbidden' })
552
- }
553
-
554
- const pool = await resolveAccountPool(ctx, live())
555
- if (!isAuthorizedProxyRequest(req, pool)) {
556
- return writeJson(res, 401, { ok: false, error: 'Unauthorized: missing or invalid bearer token' })
557
- }
537
+ const auth = await assertAuthorizedProxyRequest(ctx, req, res, live)
538
+ if (!auth.ok) return
558
539
 
559
540
  const pub = publicConfig(live())
560
541
  const dynamic = Array.isArray(pub.dynamicModels) ? pub.dynamicModels : []
package/lib/updater.js CHANGED
@@ -263,7 +263,11 @@ export async function installExact(target, packageSpec, options = {}) {
263
263
  cwd: target.profileDir,
264
264
  windowsHide: true,
265
265
  stdio: ['ignore', 'pipe', 'pipe'],
266
- env: { ...process.env, NO_COLOR: '1' },
266
+ env: (() => {
267
+ const childEnv = { ...process.env, NO_COLOR: '1' }
268
+ delete childEnv.CLINEBOT_LOCAL_PROXY_TOKEN
269
+ return childEnv
270
+ })(),
267
271
  detached: options.detached !== undefined ? options.detached : process.platform !== 'win32',
268
272
  })
269
273
  let detail = ''
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.31",
3
+ "version": "0.5.33",
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",