@goodandready/dsh-clinebot 0.5.33 → 0.6.1
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 +32 -0
- package/README.md +63 -36
- package/README.ru.md +3 -1
- package/README.zh.md +3 -1
- package/lib/adapter-messages.js +237 -0
- package/lib/adapter-stream.js +292 -0
- package/lib/adapter.js +616 -0
- package/lib/client.js +2634 -922
- package/lib/cline-client.js +15 -6
- package/lib/config.js +266 -233
- package/lib/credential-refs.js +104 -84
- package/lib/index.js +569 -502
- package/lib/model-prices.js +117 -0
- package/lib/provider-sync.js +404 -387
- package/lib/proxy-token.js +186 -186
- package/lib/routes/accounts.js +523 -403
- package/lib/routes/models.js +2 -1
- package/lib/routes/settings.js +81 -2
- package/lib/services.js +55 -0
- package/lib/stats-storage.js +378 -165
- package/lib/transport/cline-transport.js +314 -0
- package/lib/tui-settings.js +158 -0
- package/package.json +113 -77
package/lib/proxy-token.js
CHANGED
|
@@ -1,187 +1,187 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Local Proxy Authentication Token (Issues #145, #247)
|
|
3
|
-
*
|
|
4
|
-
* Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
|
|
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).
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import crypto from 'node:crypto'
|
|
10
|
-
import { createRequire } from 'node:module'
|
|
11
|
-
import { writeJson, isLoopbackAddress, getConnection } from './http.js'
|
|
12
|
-
import { resolveAccountPool } from './account-pool.js'
|
|
13
|
-
|
|
14
|
-
export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
|
|
15
|
-
|
|
16
|
-
let cachedToken = null
|
|
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
|
-
|
|
24
|
-
const require = createRequire(import.meta.url)
|
|
25
|
-
let packageVersion = '0.
|
|
26
|
-
try {
|
|
27
|
-
packageVersion = require('../package.json').version || '0.
|
|
28
|
-
} catch (err) {
|
|
29
|
-
/* fallback to default version if manifest is unreadable */
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
33
|
-
|
|
34
|
-
/**
|
|
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).
|
|
37
|
-
*
|
|
38
|
-
* @returns {string}
|
|
39
|
-
*/
|
|
40
|
-
export function getLocalProxyToken() {
|
|
41
|
-
if (cachedToken) return cachedToken
|
|
42
|
-
if (process.env[LOCAL_PROXY_KEY_ENV]) {
|
|
43
|
-
cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
|
|
44
|
-
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
45
|
-
return cachedToken
|
|
46
|
-
}
|
|
47
|
-
cachedToken = crypto.randomBytes(24).toString('hex')
|
|
48
|
-
return cachedToken
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Manually set local proxy token (for testing).
|
|
53
|
-
* Never leaks token to process.env (#247).
|
|
54
|
-
*
|
|
55
|
-
* @param {string|null} token
|
|
56
|
-
*/
|
|
57
|
-
export function setLocalProxyToken(token) {
|
|
58
|
-
cachedToken = token
|
|
59
|
-
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/**
|
|
63
|
-
* Reject cross-site and browser requests to proxy endpoints.
|
|
64
|
-
*/
|
|
65
|
-
export function hasDisallowedBrowserOrigin(req) {
|
|
66
|
-
const secFetchSite = req.headers?.['sec-fetch-site']
|
|
67
|
-
return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Verify whether incoming HTTP request is authorized to access proxy endpoints.
|
|
72
|
-
*/
|
|
73
|
-
export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
74
|
-
const auth = req.headers?.['authorization'] || ''
|
|
75
|
-
const match = auth.match(/^Bearer\s+(.+)$/i)
|
|
76
|
-
if (!match) return false
|
|
77
|
-
const token = match[1].trim()
|
|
78
|
-
if (!token) return false
|
|
79
|
-
const localToken = getLocalProxyToken()
|
|
80
|
-
if (localToken && token === localToken) return true
|
|
81
|
-
return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
|
|
82
|
-
}
|
|
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
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Write appropriate terminal JSON error response when retries are exhausted.
|
|
128
|
-
*/
|
|
129
|
-
export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter }) {
|
|
130
|
-
if (hadTimeout) {
|
|
131
|
-
const code = lastError?.code || (String(lastError?.message).toLowerCase().includes('body read') ? 'body_read_timeout' : 'upstream_timeout')
|
|
132
|
-
return writeJson(res, 504, {
|
|
133
|
-
error: {
|
|
134
|
-
message: 'Gateway Timeout: upstream provider timed out across all available accounts',
|
|
135
|
-
type: 'gateway_timeout',
|
|
136
|
-
code,
|
|
137
|
-
details: lastError?.message || null,
|
|
138
|
-
},
|
|
139
|
-
})
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
if (is429Exhausted) {
|
|
143
|
-
if (lastRetryAfter) {
|
|
144
|
-
res.setHeader('Retry-After', lastRetryAfter)
|
|
145
|
-
}
|
|
146
|
-
return writeJson(res, 429, {
|
|
147
|
-
error: {
|
|
148
|
-
message: 'All configured accounts in pool exceeded rate limits or quota',
|
|
149
|
-
type: 'rate_limit_exceeded',
|
|
150
|
-
code: 'rate_limit_exceeded',
|
|
151
|
-
details: lastError?.message || null,
|
|
152
|
-
},
|
|
153
|
-
})
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
|
|
157
|
-
? lastError.status
|
|
158
|
-
: 502
|
|
159
|
-
const code = lastError?.code || 'upstream_unavailable'
|
|
160
|
-
return writeJson(res, status, {
|
|
161
|
-
error: {
|
|
162
|
-
message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
|
|
163
|
-
type: 'bad_gateway',
|
|
164
|
-
code,
|
|
165
|
-
details: lastError?.message || null,
|
|
166
|
-
},
|
|
167
|
-
})
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/**
|
|
171
|
-
* Safely extract standard and reasoning token counts from usage object.
|
|
172
|
-
*/
|
|
173
|
-
export function extractUsageMetrics(usage) {
|
|
174
|
-
if (!usage || typeof usage !== 'object') {
|
|
175
|
-
return { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, totalTokens: 0 }
|
|
176
|
-
}
|
|
177
|
-
const promptTokens = Number(usage.prompt_tokens) || 0
|
|
178
|
-
const completionTokens = Number(usage.completion_tokens) || 0
|
|
179
|
-
const reasoningTokens = Number(
|
|
180
|
-
usage.completion_tokens_details?.reasoning_tokens ||
|
|
181
|
-
usage.reasoning_tokens ||
|
|
182
|
-
usage.prompt_tokens_details?.reasoning_tokens ||
|
|
183
|
-
0
|
|
184
|
-
)
|
|
185
|
-
const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
|
|
186
|
-
return { promptTokens, completionTokens, reasoningTokens, totalTokens }
|
|
1
|
+
/**
|
|
2
|
+
* Local Proxy Authentication Token (Issues #145, #247)
|
|
3
|
+
*
|
|
4
|
+
* Secures transparent proxy endpoints (/dsh-clinebot/v1/*) from unauthorized
|
|
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).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import crypto from 'node:crypto'
|
|
10
|
+
import { createRequire } from 'node:module'
|
|
11
|
+
import { writeJson, isLoopbackAddress, getConnection } from './http.js'
|
|
12
|
+
import { resolveAccountPool } from './account-pool.js'
|
|
13
|
+
|
|
14
|
+
export const LOCAL_PROXY_KEY_ENV = 'CLINEBOT_LOCAL_PROXY_TOKEN'
|
|
15
|
+
|
|
16
|
+
let cachedToken = null
|
|
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
|
+
|
|
24
|
+
const require = createRequire(import.meta.url)
|
|
25
|
+
let packageVersion = '0.6.1'
|
|
26
|
+
try {
|
|
27
|
+
packageVersion = require('../package.json').version || '0.6.1'
|
|
28
|
+
} catch (err) {
|
|
29
|
+
/* fallback to default version if manifest is unreadable */
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const USER_AGENT = `@goodandready/dsh-clinebot/${packageVersion}`
|
|
33
|
+
|
|
34
|
+
/**
|
|
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).
|
|
37
|
+
*
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function getLocalProxyToken() {
|
|
41
|
+
if (cachedToken) return cachedToken
|
|
42
|
+
if (process.env[LOCAL_PROXY_KEY_ENV]) {
|
|
43
|
+
cachedToken = process.env[LOCAL_PROXY_KEY_ENV]
|
|
44
|
+
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
45
|
+
return cachedToken
|
|
46
|
+
}
|
|
47
|
+
cachedToken = crypto.randomBytes(24).toString('hex')
|
|
48
|
+
return cachedToken
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Manually set local proxy token (for testing).
|
|
53
|
+
* Never leaks token to process.env (#247).
|
|
54
|
+
*
|
|
55
|
+
* @param {string|null} token
|
|
56
|
+
*/
|
|
57
|
+
export function setLocalProxyToken(token) {
|
|
58
|
+
cachedToken = token
|
|
59
|
+
delete process.env[LOCAL_PROXY_KEY_ENV]
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Reject cross-site and browser requests to proxy endpoints.
|
|
64
|
+
*/
|
|
65
|
+
export function hasDisallowedBrowserOrigin(req) {
|
|
66
|
+
const secFetchSite = req.headers?.['sec-fetch-site']
|
|
67
|
+
return secFetchSite === 'cross-site' || secFetchSite === 'same-site' || Boolean(req.headers?.['origin'])
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verify whether incoming HTTP request is authorized to access proxy endpoints.
|
|
72
|
+
*/
|
|
73
|
+
export function isAuthorizedProxyRequest(req, accountPool = []) {
|
|
74
|
+
const auth = req.headers?.['authorization'] || ''
|
|
75
|
+
const match = auth.match(/^Bearer\s+(.+)$/i)
|
|
76
|
+
if (!match) return false
|
|
77
|
+
const token = match[1].trim()
|
|
78
|
+
if (!token) return false
|
|
79
|
+
const localToken = getLocalProxyToken()
|
|
80
|
+
if (localToken && token === localToken) return true
|
|
81
|
+
return Array.isArray(accountPool) && accountPool.some((acc) => acc.value && acc.value === token)
|
|
82
|
+
}
|
|
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
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Write appropriate terminal JSON error response when retries are exhausted.
|
|
128
|
+
*/
|
|
129
|
+
export function sendProxyTerminalError(res, { hadTimeout, is429Exhausted, lastError, lastRetryAfter }) {
|
|
130
|
+
if (hadTimeout) {
|
|
131
|
+
const code = lastError?.code || (String(lastError?.message).toLowerCase().includes('body read') ? 'body_read_timeout' : 'upstream_timeout')
|
|
132
|
+
return writeJson(res, 504, {
|
|
133
|
+
error: {
|
|
134
|
+
message: 'Gateway Timeout: upstream provider timed out across all available accounts',
|
|
135
|
+
type: 'gateway_timeout',
|
|
136
|
+
code,
|
|
137
|
+
details: lastError?.message || null,
|
|
138
|
+
},
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (is429Exhausted) {
|
|
143
|
+
if (lastRetryAfter) {
|
|
144
|
+
res.setHeader('Retry-After', lastRetryAfter)
|
|
145
|
+
}
|
|
146
|
+
return writeJson(res, 429, {
|
|
147
|
+
error: {
|
|
148
|
+
message: 'All configured accounts in pool exceeded rate limits or quota',
|
|
149
|
+
type: 'rate_limit_exceeded',
|
|
150
|
+
code: 'rate_limit_exceeded',
|
|
151
|
+
details: lastError?.message || null,
|
|
152
|
+
},
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const status = typeof lastError?.status === 'number' && lastError.status >= 500 && lastError.status <= 599
|
|
157
|
+
? lastError.status
|
|
158
|
+
: 502
|
|
159
|
+
const code = lastError?.code || 'upstream_unavailable'
|
|
160
|
+
return writeJson(res, status, {
|
|
161
|
+
error: {
|
|
162
|
+
message: `Bad Gateway: upstream network error or unavailable: ${lastError?.message || 'unknown error'}`,
|
|
163
|
+
type: 'bad_gateway',
|
|
164
|
+
code,
|
|
165
|
+
details: lastError?.message || null,
|
|
166
|
+
},
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Safely extract standard and reasoning token counts from usage object.
|
|
172
|
+
*/
|
|
173
|
+
export function extractUsageMetrics(usage) {
|
|
174
|
+
if (!usage || typeof usage !== 'object') {
|
|
175
|
+
return { promptTokens: 0, completionTokens: 0, reasoningTokens: 0, totalTokens: 0 }
|
|
176
|
+
}
|
|
177
|
+
const promptTokens = Number(usage.prompt_tokens) || 0
|
|
178
|
+
const completionTokens = Number(usage.completion_tokens) || 0
|
|
179
|
+
const reasoningTokens = Number(
|
|
180
|
+
usage.completion_tokens_details?.reasoning_tokens ||
|
|
181
|
+
usage.reasoning_tokens ||
|
|
182
|
+
usage.prompt_tokens_details?.reasoning_tokens ||
|
|
183
|
+
0
|
|
184
|
+
)
|
|
185
|
+
const totalTokens = Number(usage.total_tokens) || (promptTokens + completionTokens)
|
|
186
|
+
return { promptTokens, completionTokens, reasoningTokens, totalTokens }
|
|
187
187
|
}
|