@goodandready/dsh-clinebot 0.5.30 → 0.5.32

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,22 @@ 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.32] - 2026-10-19
9
+
10
+ ### Security
11
+ - **API Key Sanitization in Key Auto-Discovery & Import Flow (Issue #246)**:
12
+ - 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`.
13
+ - 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.
14
+ - Updated client settings UI to import discovered keys strictly by opaque identifier.
15
+
16
+ ## [0.5.31] - 2026-10-18
17
+
18
+ ### Fixed
19
+ - **CI / Host Native Adapter Test (#241)**: Mocked `process.env.CLINEBOT_API_KEY` in `test/settings-service.test.js:638` with guaranteed cleanup, ensuring compatibility with newer `@deepseek-ai/dsh-llm-pi-ai` adapter authentication requirements.
20
+ - **DSH Design Token Compliance (#242)**: Replaced hardcoded `#888` fallback with `var(--dsw-alias-label-secondary)` and `rgba(0,0,0,0.15)` with `var(--dsw-elevation-prominent)` in composer cost dock and accounts section.
21
+ - **Design Contract Synchronization (#243)**: Updated `docs/design/DESIGN.md` with specifications for `DashboardDialog` modal, `settings.section`, native web search `ClineBotSearchProvider`, and multimodal KV-cache prompt budgeting.
22
+ - **Repository Hygiene (#244)**: Pruned obsolete `.tgz` release archives from repository root.
23
+
8
24
  ## [0.5.30] - 2026-10-18
9
25
 
10
26
  ### Added
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
 
@@ -1119,7 +1119,7 @@ function AccountsSection({
1119
1119
  gap: '8px',
1120
1120
  margin: '8px 0 12px 0',
1121
1121
  fontSize: '12px',
1122
- color: 'var(--dsw-alias-label-secondary, #888)',
1122
+ color: 'var(--dsw-alias-label-secondary)',
1123
1123
  },
1124
1124
  },
1125
1125
  React.createElement('span', { style: { fontWeight: 500 } }, t('accounts.mode_label') + ':'),
@@ -3208,7 +3208,7 @@ function ComposerCostDock(props) {
3208
3208
  background: 'var(--dsw-alias-bg-layer-3)',
3209
3209
  border: '1px solid var(--dsw-alias-border-l2)',
3210
3210
  borderRadius: '8px',
3211
- boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
3211
+ boxShadow: 'var(--dsw-elevation-prominent)',
3212
3212
  zIndex: 100,
3213
3213
  fontSize: '12px',
3214
3214
  color: 'var(--dsw-alias-label-primary)',
@@ -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
+ }
@@ -14,9 +14,9 @@ export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
14
14
  let cachedToken = null
15
15
 
16
16
  const require = createRequire(import.meta.url)
17
- let packageVersion = '0.5.30'
17
+ let packageVersion = '0.5.32'
18
18
  try {
19
- packageVersion = require('../package.json').version || '0.5.30'
19
+ packageVersion = require('../package.json').version || '0.5.32'
20
20
  } catch (err) {
21
21
  /* fallback to default version if manifest is unreadable */
22
22
  }
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-clinebot",
3
- "version": "0.5.30",
3
+ "version": "0.5.32",
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",