@goodandready/dsh-clinebot 0.6.1 → 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.
@@ -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
-
@@ -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.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
- }