@goodandready/dsh-clinebot 0.6.0 → 0.6.2
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/README.md +6 -14
- package/README.ru.md +2 -13
- package/README.zh.md +2 -13
- package/lib/client.js +99 -157
- package/lib/cline-client.js +0 -102
- package/lib/config.js +0 -8
- package/lib/index.js +7 -201
- package/lib/provider-sync.js +2 -141
- package/lib/routes/accounts.js +1 -37
- package/lib/routes/settings.js +19 -1
- package/lib/stats-storage.js +16 -1
- package/lib/transport/cline-transport.js +1 -1
- package/package.json +1 -1
- package/lib/proxy-server.js +0 -138
- package/lib/proxy-token.js +0 -187
- package/lib/routes/proxy.js +0 -578
package/lib/proxy-server.js
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dedicated Loopback Proxy Server (Issues GH #12, #195)
|
|
3
|
-
*
|
|
4
|
-
* Runs an isolated loopback HTTP server bound exclusively to 127.0.0.1 (ephemeral port).
|
|
5
|
-
* Completely bypasses DSH Desktop 2.x DesktopWebServer route fencing (`permits(req)`
|
|
6
|
-
* requiring `x-dsh-desktop-renderer` header), allowing Node.js host LLM requests to
|
|
7
|
-
* connect cleanly to loopback proxy endpoints without encountering HTTP 403 "forbidden".
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import http from 'node:http'
|
|
11
|
-
import { createChatCompletionsHandler, createModelsHandler } from './routes/proxy.js'
|
|
12
|
-
|
|
13
|
-
let activeProxyServer = null
|
|
14
|
-
let activeProxyPort = null
|
|
15
|
-
let startingPromise = null
|
|
16
|
-
|
|
17
|
-
export function getProxyPort() {
|
|
18
|
-
return activeProxyPort
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
export function resetProxyServerState() {
|
|
22
|
-
if (activeProxyServer) {
|
|
23
|
-
try {
|
|
24
|
-
activeProxyServer.close()
|
|
25
|
-
} catch {
|
|
26
|
-
/* ignore */
|
|
27
|
-
}
|
|
28
|
-
}
|
|
29
|
-
activeProxyServer = null
|
|
30
|
-
activeProxyPort = null
|
|
31
|
-
startingPromise = null
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
export async function detectHostWebServerFence(port) {
|
|
35
|
-
if (!port || typeof port !== 'number' || port <= 0) return false
|
|
36
|
-
try {
|
|
37
|
-
const res = await fetch(`http://127.0.0.1:${port}/dsh-clinebot/v1/models`, {
|
|
38
|
-
method: 'GET',
|
|
39
|
-
signal: AbortSignal.timeout(1000),
|
|
40
|
-
})
|
|
41
|
-
if (res.status === 403) {
|
|
42
|
-
const text = await res.text().catch(() => '')
|
|
43
|
-
if (text.trim().toLowerCase() === 'forbidden') {
|
|
44
|
-
return true
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
} catch {
|
|
48
|
-
return false
|
|
49
|
-
}
|
|
50
|
-
return false
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
export async function startLoopbackProxyServer(ctx, routeEnv) {
|
|
54
|
-
if (activeProxyServer && activeProxyPort) {
|
|
55
|
-
return activeProxyPort
|
|
56
|
-
}
|
|
57
|
-
if (startingPromise) {
|
|
58
|
-
return startingPromise
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
startingPromise = (async () => {
|
|
62
|
-
try {
|
|
63
|
-
const chatHandler = createChatCompletionsHandler(ctx, routeEnv)
|
|
64
|
-
const modelsHandler = createModelsHandler(ctx, routeEnv)
|
|
65
|
-
|
|
66
|
-
const server = http.createServer(async (req, res) => {
|
|
67
|
-
try {
|
|
68
|
-
const rawUrl = req.url || '/'
|
|
69
|
-
const path = rawUrl.split('?')[0].replace(/\/+$/, '')
|
|
70
|
-
if (path === '/dsh-clinebot/v1/chat/completions') {
|
|
71
|
-
await chatHandler(req, res)
|
|
72
|
-
return
|
|
73
|
-
}
|
|
74
|
-
if (path === '/dsh-clinebot/v1/models') {
|
|
75
|
-
await modelsHandler(req, res)
|
|
76
|
-
return
|
|
77
|
-
}
|
|
78
|
-
res.statusCode = 404
|
|
79
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
80
|
-
res.end(JSON.stringify({ ok: false, error: 'Not found' }))
|
|
81
|
-
} catch (err) {
|
|
82
|
-
if (!res.headersSent) {
|
|
83
|
-
res.statusCode = 500
|
|
84
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8')
|
|
85
|
-
res.end(JSON.stringify({ ok: false, error: String(err?.message || err) }))
|
|
86
|
-
}
|
|
87
|
-
}
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
const port = await new Promise((resolve, reject) => {
|
|
91
|
-
server.on('error', (err) => {
|
|
92
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Server error: ${err?.message || err}`)
|
|
93
|
-
reject(err)
|
|
94
|
-
})
|
|
95
|
-
server.listen(0, '127.0.0.1', () => {
|
|
96
|
-
const addr = server.address()
|
|
97
|
-
const assignedPort = typeof addr === 'object' && addr?.port ? addr.port : null
|
|
98
|
-
if (!assignedPort) {
|
|
99
|
-
reject(new Error('Failed to obtain assigned ephemeral port'))
|
|
100
|
-
return
|
|
101
|
-
}
|
|
102
|
-
if (typeof server.unref === 'function') {
|
|
103
|
-
server.unref()
|
|
104
|
-
}
|
|
105
|
-
resolve(assignedPort)
|
|
106
|
-
})
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
activeProxyServer = server
|
|
110
|
-
activeProxyPort = port
|
|
111
|
-
ctx?.logger?.info?.(`[dsh-clinebot:proxy-server] Dedicated loopback proxy listening on 127.0.0.1:${port}`)
|
|
112
|
-
return port
|
|
113
|
-
} catch (err) {
|
|
114
|
-
ctx?.logger?.warn?.(`[dsh-clinebot:proxy-server] Failed to bind dedicated loopback proxy server: ${err?.message || err}`)
|
|
115
|
-
activeProxyServer = null
|
|
116
|
-
activeProxyPort = null
|
|
117
|
-
return null
|
|
118
|
-
} finally {
|
|
119
|
-
startingPromise = null
|
|
120
|
-
}
|
|
121
|
-
})()
|
|
122
|
-
|
|
123
|
-
return startingPromise
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export async function stopLoopbackProxyServer() {
|
|
127
|
-
if (!activeProxyServer) return
|
|
128
|
-
const server = activeProxyServer
|
|
129
|
-
activeProxyServer = null
|
|
130
|
-
activeProxyPort = null
|
|
131
|
-
startingPromise = null
|
|
132
|
-
return new Promise((resolve) => {
|
|
133
|
-
server.close(() => {
|
|
134
|
-
resolve()
|
|
135
|
-
})
|
|
136
|
-
})
|
|
137
|
-
}
|
|
138
|
-
|
package/lib/proxy-token.js
DELETED
|
@@ -1,187 +0,0 @@
|
|
|
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.0'
|
|
26
|
-
try {
|
|
27
|
-
packageVersion = require('../package.json').version || '0.6.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 }
|
|
187
|
-
}
|