@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 +17 -0
- package/lib/client.js +7 -5
- package/lib/key-discovery.js +65 -3
- package/lib/proxy-token.js +59 -12
- package/lib/routes/accounts.js +123 -75
- package/lib/routes/proxy.js +8 -27
- package/lib/updater.js +5 -1
- package/package.json +1 -1
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
|
-
|
|
1022
|
+
id: item.id,
|
|
1023
1023
|
})
|
|
1024
|
-
setDiscoveredKeys((prev) => prev.filter((k) => k.
|
|
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
|
|
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(
|
|
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}`)
|
package/lib/key-discovery.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/proxy-token.js
CHANGED
|
@@ -1,22 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Local Proxy Authentication Token (
|
|
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.
|
|
25
|
+
let packageVersion = '0.5.33'
|
|
18
26
|
try {
|
|
19
|
-
packageVersion = require('../package.json').version || '0.5.
|
|
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
|
-
|
|
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
|
+
}
|
package/lib/routes/accounts.js
CHANGED
|
@@ -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
|
-
|
|
15
|
+
export function registerAccountsRoutes(ctx, { live, syncProviderState, getSettingsApi }) {
|
|
16
|
+
const pendingSecretCleanupAccounts = new Set()
|
|
16
17
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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/lib/routes/proxy.js
CHANGED
|
@@ -15,19 +15,19 @@
|
|
|
15
15
|
* 7. Streaming SSE token usage interception & persistent telemetry recording.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { writeJson, readBody
|
|
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
|
-
|
|
39
|
-
|
|
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
|
-
|
|
547
|
-
|
|
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:
|
|
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.
|
|
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",
|