@simonyea/holysheep-cli 2.1.40 → 2.1.42
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/dist/configure-worker.js +4510 -0
- package/dist/index.js +9610 -0
- package/dist/process-proxy-inject.js +117 -0
- package/package.json +19 -6
- package/.gitea/workflows/sanity.yml +0 -125
- package/scripts/check-tarball-size.js +0 -44
- package/src/commands/balance.js +0 -57
- package/src/commands/claude-proxy.js +0 -248
- package/src/commands/claude.js +0 -135
- package/src/commands/doctor.js +0 -282
- package/src/commands/login.js +0 -211
- package/src/commands/openclaw.js +0 -258
- package/src/commands/reset.js +0 -53
- package/src/commands/setup.js +0 -493
- package/src/commands/upgrade.js +0 -168
- package/src/commands/webui.js +0 -622
- package/src/index.js +0 -226
- package/src/tools/aider.js +0 -78
- package/src/tools/antigravity.js +0 -42
- package/src/tools/claude-code.js +0 -228
- package/src/tools/claude-process-proxy.js +0 -1030
- package/src/tools/codex.js +0 -254
- package/src/tools/continue.js +0 -146
- package/src/tools/cursor.js +0 -71
- package/src/tools/droid.js +0 -281
- package/src/tools/env-config.js +0 -185
- package/src/tools/gemini-cli.js +0 -82
- package/src/tools/hermes.js +0 -354
- package/src/tools/index.js +0 -13
- package/src/tools/openclaw-bridge.js +0 -987
- package/src/tools/openclaw.js +0 -925
- package/src/tools/opencode.js +0 -227
- package/src/tools/process-proxy-inject.js +0 -142
- package/src/utils/config.js +0 -54
- package/src/utils/shell.js +0 -342
- package/src/utils/which.js +0 -176
- package/src/webui/aionui-runtime-fetcher.js +0 -429
- package/src/webui/aionui-runtime.js +0 -139
- package/src/webui/aionui-wrapper.js +0 -734
- package/src/webui/configure-worker.js +0 -67
- package/src/webui/server.js +0 -1572
- package/src/webui/workspace-runtime.js +0 -288
- package/src/webui/workspace-store.js +0 -325
- /package/{src/webui → dist}/index.html +0 -0
- /package/{src/tools → dist}/pty-hermes-wrapper.py +0 -0
|
@@ -1,734 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* AionUi wrapper — zero-dep Node http proxy in front of AionUi.
|
|
3
|
-
*
|
|
4
|
-
* Architecture:
|
|
5
|
-
* user browser :9876 (this wrapper)
|
|
6
|
-
* ├─ POST /api/auth/holysheep-login → validate HS key, mint bootstrap token
|
|
7
|
-
* ├─ GET /api/auth/holysheep-bootstrap → loopback-only, one-shot, copy AionUi cookie, 302 /
|
|
8
|
-
* ├─ GET /api/holysheep/status|tools|config
|
|
9
|
-
* ├─ POST /api/holysheep/login (alias for holysheep-login, convenience)
|
|
10
|
-
* ├─ POST /api/holysheep/setup/:tool, install, launch, configure, reset
|
|
11
|
-
* └─ * → proxied to AionUi 127.0.0.1:<internalPort>
|
|
12
|
-
* + WebSocket upgrade support
|
|
13
|
-
*
|
|
14
|
-
* Security invariants:
|
|
15
|
-
* - AionUi server binds 127.0.0.1 only (ALLOW_REMOTE never set)
|
|
16
|
-
* - /api/auth/holysheep-bootstrap refuses non-loopback remoteAddress
|
|
17
|
-
* - /api/auth/holysheep-bootstrap refuses X-Forwarded-For / X-Real-IP
|
|
18
|
-
* - Bootstrap tokens: single-use, ≤ 30s TTL, 24-byte CSRNG random
|
|
19
|
-
* - Bridge admin credential file perms enforced 0600, startup refuses otherwise
|
|
20
|
-
* - Bridge credential never logged — only the masked form
|
|
21
|
-
*
|
|
22
|
-
* Vendor independence:
|
|
23
|
-
* AionUi's dist-server/server.mjs is NEVER modified. We speak HTTP to it.
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
'use strict'
|
|
27
|
-
|
|
28
|
-
const http = require('http')
|
|
29
|
-
const net = require('net')
|
|
30
|
-
const fs = require('fs')
|
|
31
|
-
const path = require('path')
|
|
32
|
-
const os = require('os')
|
|
33
|
-
const crypto = require('crypto')
|
|
34
|
-
const { execSync, spawn } = require('child_process')
|
|
35
|
-
|
|
36
|
-
const {
|
|
37
|
-
getApiKey, loadConfig, saveConfig,
|
|
38
|
-
BASE_URL_OPENAI,
|
|
39
|
-
} = require('../utils/config')
|
|
40
|
-
|
|
41
|
-
// ── Constants ────────────────────────────────────────────────────────────────
|
|
42
|
-
|
|
43
|
-
const BRIDGE_DIR = path.join(os.homedir(), '.holysheep')
|
|
44
|
-
const BRIDGE_CRED_FILE = path.join(BRIDGE_DIR, 'aionui-bridge.json')
|
|
45
|
-
const TOKEN_TTL_MS = 30_000
|
|
46
|
-
const INTERNAL_PORT_START = 9877
|
|
47
|
-
const INTERNAL_PORT_TRIES = 10
|
|
48
|
-
// First launch on Windows spends 30-40s in Defender + bun JIT + sqlite init,
|
|
49
|
-
// so 25s was flaking on cold boxes. Align with the old standalone spawn path.
|
|
50
|
-
const UPSTREAM_STARTUP_TIMEOUT_MS = Number(process.env.HS_WEB_STARTUP_TIMEOUT_MS) || 60_000
|
|
51
|
-
const UPSTREAM_CONNECT_TIMEOUT_MS = 30_000
|
|
52
|
-
const AIONUI_LOG_TAIL_BYTES = 4096
|
|
53
|
-
|
|
54
|
-
// Bootstrap token store — Map<token, { createdAt, used }>
|
|
55
|
-
const bootstrapTokens = new Map()
|
|
56
|
-
|
|
57
|
-
// Periodic GC so idle wrappers don't grow memory unboundedly. .unref() so the
|
|
58
|
-
// timer doesn't block process exit on SIGINT. No-op if TTL-cleanup already
|
|
59
|
-
// happened via the lazy path in pruneExpiredTokens().
|
|
60
|
-
let tokenCleanupInterval = null
|
|
61
|
-
function startTokenCleanup() {
|
|
62
|
-
if (tokenCleanupInterval) return
|
|
63
|
-
tokenCleanupInterval = setInterval(() => pruneExpiredTokens(), 60_000)
|
|
64
|
-
if (typeof tokenCleanupInterval.unref === 'function') tokenCleanupInterval.unref()
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// Cached AionUi session cookie obtained by internal /login. Refreshed lazily.
|
|
68
|
-
let cachedAionUiCookie = null
|
|
69
|
-
let cachedAionUiCookieAt = 0
|
|
70
|
-
const AIONUI_COOKIE_TTL_MS = 10 * 60 * 1000
|
|
71
|
-
|
|
72
|
-
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
73
|
-
|
|
74
|
-
function log(msg) {
|
|
75
|
-
// eslint-disable-next-line no-console
|
|
76
|
-
console.log(`[holysheep-web] ${msg}`)
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
function randomToken(bytes = 24) {
|
|
80
|
-
return crypto.randomBytes(bytes).toString('hex')
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
function randomPassword() {
|
|
84
|
-
// 24-char URL-safe password. Long enough for bcrypt, avoids shell-meta headaches.
|
|
85
|
-
return crypto.randomBytes(18).toString('base64').replace(/[+/=]/g, '').slice(0, 24)
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function nowMs() { return Date.now() }
|
|
89
|
-
|
|
90
|
-
function isLoopbackRequest(req) {
|
|
91
|
-
if (req.headers['x-forwarded-for']) return false
|
|
92
|
-
if (req.headers['x-real-ip']) return false
|
|
93
|
-
const addr = req.socket.remoteAddress || ''
|
|
94
|
-
return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function sendJson(res, statusCode, body) {
|
|
98
|
-
const payload = JSON.stringify(body)
|
|
99
|
-
res.writeHead(statusCode, {
|
|
100
|
-
'Content-Type': 'application/json; charset=utf-8',
|
|
101
|
-
'Content-Length': Buffer.byteLength(payload),
|
|
102
|
-
})
|
|
103
|
-
res.end(payload)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
function readBody(req, maxBytes = 1024 * 512) {
|
|
107
|
-
return new Promise((resolve, reject) => {
|
|
108
|
-
let size = 0
|
|
109
|
-
const chunks = []
|
|
110
|
-
req.on('data', (c) => {
|
|
111
|
-
size += c.length
|
|
112
|
-
if (size > maxBytes) {
|
|
113
|
-
reject(new Error('payload too large'))
|
|
114
|
-
try { req.destroy() } catch {}
|
|
115
|
-
return
|
|
116
|
-
}
|
|
117
|
-
chunks.push(c)
|
|
118
|
-
})
|
|
119
|
-
req.on('end', () => {
|
|
120
|
-
try {
|
|
121
|
-
const raw = Buffer.concat(chunks).toString('utf8')
|
|
122
|
-
if (!raw) return resolve({})
|
|
123
|
-
if (req.headers['content-type']?.includes('application/json')) {
|
|
124
|
-
return resolve(JSON.parse(raw))
|
|
125
|
-
}
|
|
126
|
-
resolve({ _raw: raw })
|
|
127
|
-
} catch (e) { reject(e) }
|
|
128
|
-
})
|
|
129
|
-
req.on('error', reject)
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
function pruneExpiredTokens() {
|
|
134
|
-
const now = nowMs()
|
|
135
|
-
for (const [t, meta] of bootstrapTokens) {
|
|
136
|
-
if (meta.used || now - meta.createdAt > TOKEN_TTL_MS) {
|
|
137
|
-
bootstrapTokens.delete(t)
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
// ── Bridge credential: persistent admin user for AionUi internal /login ──────
|
|
143
|
-
|
|
144
|
-
function ensureBridgeDir() {
|
|
145
|
-
if (!fs.existsSync(BRIDGE_DIR)) fs.mkdirSync(BRIDGE_DIR, { recursive: true, mode: 0o700 })
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function loadBridgeCredentials() {
|
|
149
|
-
if (!fs.existsSync(BRIDGE_CRED_FILE)) return null
|
|
150
|
-
try {
|
|
151
|
-
// Enforce 0600 perms — world-readable bridge creds defeat the whole loopback story
|
|
152
|
-
const stat = fs.statSync(BRIDGE_CRED_FILE)
|
|
153
|
-
if (process.platform !== 'win32' && (stat.mode & 0o077)) {
|
|
154
|
-
throw new Error(
|
|
155
|
-
`Refusing to read ${BRIDGE_CRED_FILE} with perms ${(stat.mode & 0o777).toString(8)} — ` +
|
|
156
|
-
`must be 0600. Run: chmod 600 ${BRIDGE_CRED_FILE}`
|
|
157
|
-
)
|
|
158
|
-
}
|
|
159
|
-
return JSON.parse(fs.readFileSync(BRIDGE_CRED_FILE, 'utf8'))
|
|
160
|
-
} catch (e) {
|
|
161
|
-
if (e.message.startsWith('Refusing')) throw e
|
|
162
|
-
return null
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function saveBridgeCredentials(creds) {
|
|
167
|
-
ensureBridgeDir()
|
|
168
|
-
fs.writeFileSync(BRIDGE_CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 })
|
|
169
|
-
if (process.platform !== 'win32') {
|
|
170
|
-
try { fs.chmodSync(BRIDGE_CRED_FILE, 0o600) } catch {}
|
|
171
|
-
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/**
|
|
175
|
-
* The vendored AionUi v1.9.17 build has already been customized upstream so
|
|
176
|
-
* that `POST /login` accepts `{ apiKey }` (NOT username/password) and validates
|
|
177
|
-
* against HolySheep. That means the wrapper does NOT need to provision or
|
|
178
|
-
* maintain a bridge admin password at all — we simply forward the HS API key
|
|
179
|
-
* to AionUi's own /login endpoint.
|
|
180
|
-
*
|
|
181
|
-
* The `loadBridgeCredentials()` / `saveBridgeCredentials()` helpers remain
|
|
182
|
-
* above as a fallback codepath for any AionUi build that still uses legacy
|
|
183
|
-
* username/password auth. When the vendored build is HolySheep-aware
|
|
184
|
-
* (detected below), we prefer the direct apiKey-to-/login path.
|
|
185
|
-
*/
|
|
186
|
-
function detectHolySheepAionUi(runtimeDir) {
|
|
187
|
-
try {
|
|
188
|
-
const serverPath = path.join(runtimeDir, 'dist-server', 'server.mjs')
|
|
189
|
-
// Scan for the HolySheep validation marker — fast regex, file is large
|
|
190
|
-
// but we stop after finding the first match.
|
|
191
|
-
const buf = fs.readFileSync(serverPath, 'utf8')
|
|
192
|
-
return buf.includes('validateHolySheepApiKey') ||
|
|
193
|
-
buf.includes('HolySheep API key is required') ||
|
|
194
|
-
buf.includes('HOLYSHEEP_PROVIDER_NAME')
|
|
195
|
-
} catch { return false }
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── Validate HolySheep API key ───────────────────────────────────────────────
|
|
199
|
-
|
|
200
|
-
async function validateHolySheepKey(apiKey) {
|
|
201
|
-
// Reuse the same validation contract as `hs login`: GET /v1/models with Bearer.
|
|
202
|
-
const fetch = require('node-fetch')
|
|
203
|
-
try {
|
|
204
|
-
const res = await fetch(`${BASE_URL_OPENAI}/models`, {
|
|
205
|
-
method: 'GET',
|
|
206
|
-
headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
|
|
207
|
-
timeout: 15_000,
|
|
208
|
-
})
|
|
209
|
-
return res.status === 200
|
|
210
|
-
} catch {
|
|
211
|
-
return false
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
// ── AionUi internal login: mint a cookie we can hand to the browser ──────────
|
|
216
|
-
|
|
217
|
-
/**
|
|
218
|
-
* POST the HolySheep API key to the internal AionUi /login endpoint.
|
|
219
|
-
* AionUi's customized build (detectHolySheepAionUi above) accepts
|
|
220
|
-
* `{ apiKey: 'cr_...' }` and returns a JWT cookie via Set-Cookie.
|
|
221
|
-
* Works for vendored v1.9.17 and any future build that preserves this contract.
|
|
222
|
-
*/
|
|
223
|
-
function aionuiInternalLoginWithApiKey({ internalPort, apiKey }) {
|
|
224
|
-
return new Promise((resolve, reject) => {
|
|
225
|
-
const body = JSON.stringify({ apiKey })
|
|
226
|
-
const req = http.request({
|
|
227
|
-
host: '127.0.0.1', port: internalPort, path: '/login', method: 'POST',
|
|
228
|
-
headers: {
|
|
229
|
-
'Content-Type': 'application/json',
|
|
230
|
-
'Content-Length': Buffer.byteLength(body),
|
|
231
|
-
},
|
|
232
|
-
timeout: 15_000,
|
|
233
|
-
}, (res) => {
|
|
234
|
-
let buf = ''
|
|
235
|
-
res.on('data', (c) => { buf += c.toString() })
|
|
236
|
-
res.on('end', () => {
|
|
237
|
-
if (res.statusCode !== 200) {
|
|
238
|
-
return reject(new Error(`HolySheep runtime /login returned ${res.statusCode}: ${buf.slice(0, 200)}`))
|
|
239
|
-
}
|
|
240
|
-
const setCookie = res.headers['set-cookie']
|
|
241
|
-
if (!setCookie || setCookie.length === 0) {
|
|
242
|
-
return reject(new Error('HolySheep runtime /login succeeded but no Set-Cookie header returned'))
|
|
243
|
-
}
|
|
244
|
-
resolve(setCookie)
|
|
245
|
-
})
|
|
246
|
-
})
|
|
247
|
-
req.on('error', reject)
|
|
248
|
-
req.setTimeout(15_000, () => { req.destroy(new Error('HolySheep runtime /login timed out')) })
|
|
249
|
-
req.write(body)
|
|
250
|
-
req.end()
|
|
251
|
-
})
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
async function getAionUiCookieFresh({ internalPort }) {
|
|
255
|
-
if (cachedAionUiCookie && nowMs() - cachedAionUiCookieAt < AIONUI_COOKIE_TTL_MS) {
|
|
256
|
-
return cachedAionUiCookie
|
|
257
|
-
}
|
|
258
|
-
const apiKey = getApiKey()
|
|
259
|
-
if (!apiKey) throw new Error('no HolySheep API key — call /api/auth/holysheep-login first')
|
|
260
|
-
const cookies = await aionuiInternalLoginWithApiKey({ internalPort, apiKey })
|
|
261
|
-
cachedAionUiCookie = cookies
|
|
262
|
-
cachedAionUiCookieAt = nowMs()
|
|
263
|
-
return cookies
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
// ── Wrapper endpoint handlers ────────────────────────────────────────────────
|
|
267
|
-
|
|
268
|
-
async function handleHolySheepLogin(req, res) {
|
|
269
|
-
try {
|
|
270
|
-
const body = await readBody(req)
|
|
271
|
-
const apiKey = (body.apiKey || '').trim()
|
|
272
|
-
if (!apiKey || !apiKey.startsWith('cr_')) {
|
|
273
|
-
return sendJson(res, 400, { success: false, message: 'API Key must start with cr_' })
|
|
274
|
-
}
|
|
275
|
-
const valid = await validateHolySheepKey(apiKey)
|
|
276
|
-
if (!valid) return sendJson(res, 401, { success: false, message: 'HolySheep API key invalid' })
|
|
277
|
-
saveConfig({ apiKey, savedAt: new Date().toISOString() })
|
|
278
|
-
|
|
279
|
-
// Issue bootstrap token. Browser will hit /api/auth/holysheep-bootstrap next.
|
|
280
|
-
pruneExpiredTokens()
|
|
281
|
-
const token = randomToken()
|
|
282
|
-
bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
|
|
283
|
-
sendJson(res, 200, { success: true, bootstrapUrl: `/api/auth/holysheep-bootstrap?token=${token}` })
|
|
284
|
-
} catch (e) {
|
|
285
|
-
sendJson(res, 500, { success: false, message: e.message })
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
async function handleBootstrap(req, res, ctx) {
|
|
290
|
-
if (!isLoopbackRequest(req)) {
|
|
291
|
-
return sendJson(res, 403, { success: false, message: 'bootstrap endpoint is loopback-only' })
|
|
292
|
-
}
|
|
293
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
294
|
-
const token = url.searchParams.get('token')
|
|
295
|
-
pruneExpiredTokens()
|
|
296
|
-
const entry = token ? bootstrapTokens.get(token) : null
|
|
297
|
-
if (!entry || entry.used || nowMs() - entry.createdAt > TOKEN_TTL_MS) {
|
|
298
|
-
return sendJson(res, 401, { success: false, message: 'bootstrap token invalid or expired' })
|
|
299
|
-
}
|
|
300
|
-
entry.used = true
|
|
301
|
-
|
|
302
|
-
try {
|
|
303
|
-
const cookies = await getAionUiCookieFresh({ internalPort: ctx.internalPort })
|
|
304
|
-
res.writeHead(302, {
|
|
305
|
-
'Set-Cookie': cookies,
|
|
306
|
-
'Location': '/',
|
|
307
|
-
'Cache-Control': 'no-store',
|
|
308
|
-
})
|
|
309
|
-
res.end()
|
|
310
|
-
} catch (e) {
|
|
311
|
-
sendJson(res, 502, { success: false, message: `bridge login failed: ${e.message}` })
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
async function handleHolySheepStatus(req, res) {
|
|
316
|
-
const apiKey = getApiKey()
|
|
317
|
-
sendJson(res, 200, {
|
|
318
|
-
loggedIn: !!apiKey,
|
|
319
|
-
apiKeyMasked: apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : null,
|
|
320
|
-
mode: 'holysheep-webui',
|
|
321
|
-
version: require('../../package.json').version,
|
|
322
|
-
})
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
// Reuse legacy handlers in-process — no cross-port hops.
|
|
326
|
-
let legacyModule = null
|
|
327
|
-
function legacy() {
|
|
328
|
-
if (!legacyModule) legacyModule = require('./server')
|
|
329
|
-
return legacyModule
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
// ── HTTP proxy to AionUi internal server ─────────────────────────────────────
|
|
333
|
-
|
|
334
|
-
const BODYLESS_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
|
|
335
|
-
|
|
336
|
-
function proxyHttp(req, res, internalPort) {
|
|
337
|
-
const headers = { ...req.headers }
|
|
338
|
-
// Host header must match internal target for Express routing to behave consistently
|
|
339
|
-
headers.host = `127.0.0.1:${internalPort}`
|
|
340
|
-
// Strip hop-by-hop per RFC 7230 §6.1
|
|
341
|
-
delete headers['connection']
|
|
342
|
-
delete headers['keep-alive']
|
|
343
|
-
delete headers['proxy-connection']
|
|
344
|
-
delete headers['te']
|
|
345
|
-
delete headers['trailer']
|
|
346
|
-
delete headers['transfer-encoding']
|
|
347
|
-
delete headers['upgrade']
|
|
348
|
-
|
|
349
|
-
const upstream = http.request({
|
|
350
|
-
host: '127.0.0.1',
|
|
351
|
-
port: internalPort,
|
|
352
|
-
method: req.method,
|
|
353
|
-
path: req.url,
|
|
354
|
-
headers,
|
|
355
|
-
timeout: UPSTREAM_CONNECT_TIMEOUT_MS,
|
|
356
|
-
}, (upRes) => {
|
|
357
|
-
// Clone upstream headers; drop hop-by-hop coming back
|
|
358
|
-
const outHeaders = { ...upRes.headers }
|
|
359
|
-
delete outHeaders['connection']
|
|
360
|
-
delete outHeaders['keep-alive']
|
|
361
|
-
delete outHeaders['proxy-connection']
|
|
362
|
-
res.writeHead(upRes.statusCode, upRes.statusMessage, outHeaders)
|
|
363
|
-
upRes.pipe(res)
|
|
364
|
-
})
|
|
365
|
-
upstream.on('error', (e) => {
|
|
366
|
-
try {
|
|
367
|
-
if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' })
|
|
368
|
-
res.end(`upstream error: ${e.code || e.message}`)
|
|
369
|
-
} catch {}
|
|
370
|
-
})
|
|
371
|
-
upstream.on('timeout', () => {
|
|
372
|
-
try { upstream.destroy(new Error('upstream timeout')) } catch {}
|
|
373
|
-
})
|
|
374
|
-
|
|
375
|
-
// For body-less methods, end immediately — otherwise Node waits for `req` to
|
|
376
|
-
// emit 'end', which may have already fired for header-only IncomingMessages.
|
|
377
|
-
if (BODYLESS_METHODS.has((req.method || 'GET').toUpperCase())) {
|
|
378
|
-
upstream.end()
|
|
379
|
-
} else {
|
|
380
|
-
req.pipe(upstream)
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
// Client disconnect → kill upstream.
|
|
384
|
-
//
|
|
385
|
-
// [HolySheep fork v2.1.32 / hs23] Root-cause fix for "Network error" on
|
|
386
|
-
// /login: Node's IncomingMessage (`req`) emits 'close' as soon as its
|
|
387
|
-
// Readable side is fully consumed — NOT when the TCP socket disconnects.
|
|
388
|
-
// For POST requests, req.pipe(upstream) drains the body and the very next
|
|
389
|
-
// tick `req` fires 'close', which here destroyed the upstream *before*
|
|
390
|
-
// Express on 9877 had a chance to respond → ECONNRESET → wrapper 502.
|
|
391
|
-
//
|
|
392
|
-
// The correct signal for "client disconnected" is `res.on('close')` +
|
|
393
|
-
// `res.writableFinished === false`. That only fires if the downstream
|
|
394
|
-
// client aborts before we've finished writing the response.
|
|
395
|
-
res.on('close', () => {
|
|
396
|
-
if (res.writableFinished) return
|
|
397
|
-
if (!upstream.destroyed) upstream.destroy()
|
|
398
|
-
})
|
|
399
|
-
upstream.on('close', () => {
|
|
400
|
-
// If upstream closes before we finished writing to `res` (e.g. upstream
|
|
401
|
-
// crash), make sure we don't leave the client hanging.
|
|
402
|
-
if (!res.writableFinished && !res.headersSent) {
|
|
403
|
-
try { res.writeHead(502, { 'Content-Type': 'text/plain' }); res.end('upstream closed') } catch {}
|
|
404
|
-
}
|
|
405
|
-
})
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
// ── WebSocket proxy (upgrade event) ──────────────────────────────────────────
|
|
409
|
-
|
|
410
|
-
function proxyWebSocket(req, clientSocket, head, internalPort) {
|
|
411
|
-
const upstream = net.connect({ host: '127.0.0.1', port: internalPort }, () => {
|
|
412
|
-
// Replay upgrade request verbatim
|
|
413
|
-
const lines = [
|
|
414
|
-
`${req.method} ${req.url} HTTP/1.1`,
|
|
415
|
-
`Host: 127.0.0.1:${internalPort}`,
|
|
416
|
-
]
|
|
417
|
-
for (const [k, v] of Object.entries(req.headers)) {
|
|
418
|
-
if (k.toLowerCase() === 'host') continue
|
|
419
|
-
if (Array.isArray(v)) {
|
|
420
|
-
for (const vv of v) lines.push(`${k}: ${vv}`)
|
|
421
|
-
} else {
|
|
422
|
-
lines.push(`${k}: ${v}`)
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
upstream.write(lines.join('\r\n') + '\r\n\r\n')
|
|
426
|
-
if (head && head.length) upstream.write(head)
|
|
427
|
-
|
|
428
|
-
// Bidirectional pipe. `end: false` prevents premature close on one side dying.
|
|
429
|
-
upstream.pipe(clientSocket, { end: false })
|
|
430
|
-
clientSocket.pipe(upstream, { end: false })
|
|
431
|
-
})
|
|
432
|
-
|
|
433
|
-
const killBoth = (why) => {
|
|
434
|
-
try { upstream.destroy() } catch {}
|
|
435
|
-
try { clientSocket.destroy() } catch {}
|
|
436
|
-
}
|
|
437
|
-
upstream.on('error', killBoth)
|
|
438
|
-
upstream.on('close', () => killBoth('upstream-close'))
|
|
439
|
-
clientSocket.on('error', killBoth)
|
|
440
|
-
clientSocket.on('close', () => killBoth('client-close'))
|
|
441
|
-
|
|
442
|
-
// Prevent zombie connections on slow upstream
|
|
443
|
-
upstream.setTimeout(UPSTREAM_CONNECT_TIMEOUT_MS, () => killBoth('upstream-timeout'))
|
|
444
|
-
}
|
|
445
|
-
|
|
446
|
-
// ── Wait for internal AionUi server to become ready ──────────────────────────
|
|
447
|
-
|
|
448
|
-
function waitForUpstreamReady(internalPort, timeoutMs = UPSTREAM_STARTUP_TIMEOUT_MS) {
|
|
449
|
-
const startedAt = nowMs()
|
|
450
|
-
return new Promise((resolve, reject) => {
|
|
451
|
-
const tick = () => {
|
|
452
|
-
const req = http.get({
|
|
453
|
-
host: '127.0.0.1', port: internalPort, path: '/', family: 4, timeout: 1500,
|
|
454
|
-
}, (res) => {
|
|
455
|
-
res.resume()
|
|
456
|
-
if (res.statusCode && res.statusCode < 500) return resolve(true)
|
|
457
|
-
retry()
|
|
458
|
-
})
|
|
459
|
-
req.on('error', retry)
|
|
460
|
-
req.on('timeout', () => { req.destroy(); retry() })
|
|
461
|
-
}
|
|
462
|
-
const retry = () => {
|
|
463
|
-
if (nowMs() - startedAt >= timeoutMs) return reject(new Error('upstream not ready in time'))
|
|
464
|
-
setTimeout(tick, 500)
|
|
465
|
-
}
|
|
466
|
-
tick()
|
|
467
|
-
})
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
// ── Find a free internal port ────────────────────────────────────────────────
|
|
471
|
-
|
|
472
|
-
function findFreeInternalPort(start = INTERNAL_PORT_START, tries = INTERNAL_PORT_TRIES) {
|
|
473
|
-
for (let i = 0; i < tries; i++) {
|
|
474
|
-
const p = start + i
|
|
475
|
-
try {
|
|
476
|
-
const server = net.createServer()
|
|
477
|
-
// Sync-ish port probe using Node's listen on 127.0.0.1
|
|
478
|
-
const ok = new Promise((resolve) => {
|
|
479
|
-
server.once('error', () => resolve(false))
|
|
480
|
-
server.once('listening', () => { server.close(() => resolve(true)) })
|
|
481
|
-
server.listen(p, '127.0.0.1')
|
|
482
|
-
})
|
|
483
|
-
// eslint-disable-next-line no-unused-expressions
|
|
484
|
-
ok // we use the returned probe below
|
|
485
|
-
return { port: p, probe: ok }
|
|
486
|
-
} catch {}
|
|
487
|
-
}
|
|
488
|
-
return null
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
async function pickInternalPort() {
|
|
492
|
-
for (let i = 0; i < INTERNAL_PORT_TRIES; i++) {
|
|
493
|
-
const p = INTERNAL_PORT_START + i
|
|
494
|
-
const server = net.createServer()
|
|
495
|
-
const ok = await new Promise((resolve) => {
|
|
496
|
-
server.once('error', () => resolve(false))
|
|
497
|
-
server.once('listening', () => { server.close(() => resolve(true)) })
|
|
498
|
-
server.listen(p, '127.0.0.1')
|
|
499
|
-
})
|
|
500
|
-
if (ok) return p
|
|
501
|
-
}
|
|
502
|
-
throw new Error(`no free internal port in ${INTERNAL_PORT_START}..${INTERNAL_PORT_START + INTERNAL_PORT_TRIES - 1}`)
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// ── Router ───────────────────────────────────────────────────────────────────
|
|
506
|
-
|
|
507
|
-
function buildRouter(ctx) {
|
|
508
|
-
return async function onRequest(req, res) {
|
|
509
|
-
try {
|
|
510
|
-
const url = new URL(req.url, `http://${req.headers.host}`)
|
|
511
|
-
const route = url.pathname
|
|
512
|
-
|
|
513
|
-
if (req.method === 'OPTIONS') {
|
|
514
|
-
res.writeHead(204, {
|
|
515
|
-
'Access-Control-Allow-Origin': '*',
|
|
516
|
-
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
|
517
|
-
'Access-Control-Allow-Headers': 'Content-Type',
|
|
518
|
-
})
|
|
519
|
-
return res.end()
|
|
520
|
-
}
|
|
521
|
-
|
|
522
|
-
// 1. HolySheep authentication endpoints
|
|
523
|
-
if (route === '/api/auth/holysheep-login' && req.method === 'POST') {
|
|
524
|
-
return await handleHolySheepLogin(req, res)
|
|
525
|
-
}
|
|
526
|
-
if (route === '/api/auth/holysheep-bootstrap' && req.method === 'GET') {
|
|
527
|
-
return await handleBootstrap(req, res, ctx)
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
// 2. HolySheep multi-tool config & status (reuse legacy handlers in-process)
|
|
531
|
-
if (route === '/api/holysheep/status' && req.method === 'GET') {
|
|
532
|
-
return await handleHolySheepStatus(req, res)
|
|
533
|
-
}
|
|
534
|
-
if (route === '/api/holysheep/tools' && req.method === 'GET') {
|
|
535
|
-
return await legacy().handleTools(req, res)
|
|
536
|
-
}
|
|
537
|
-
if (route === '/api/holysheep/models' && req.method === 'GET') {
|
|
538
|
-
return await legacy().handleModels(req, res)
|
|
539
|
-
}
|
|
540
|
-
if (route === '/api/holysheep/balance' && req.method === 'GET') {
|
|
541
|
-
return await legacy().handleBalance(req, res)
|
|
542
|
-
}
|
|
543
|
-
if (route === '/api/holysheep/doctor' && req.method === 'GET') {
|
|
544
|
-
return await legacy().handleDoctor(req, res)
|
|
545
|
-
}
|
|
546
|
-
if (route === '/api/holysheep/env' && req.method === 'GET') {
|
|
547
|
-
return legacy().handleEnv(req, res)
|
|
548
|
-
}
|
|
549
|
-
if (route === '/api/holysheep/whoami' && req.method === 'GET') {
|
|
550
|
-
return await legacy().handleWhoami(req, res)
|
|
551
|
-
}
|
|
552
|
-
// One-click-all (SSE) — configure every installed tool in a single stream.
|
|
553
|
-
// Must sit next to `/api/holysheep/tool/*` so the Dashboard's
|
|
554
|
-
// "一键配置所有 CLI" button hits a real route instead of falling
|
|
555
|
-
// through to the default AionUi proxy (which returns 404/502 and
|
|
556
|
-
// surfaces as "Failed to load resource" in the console).
|
|
557
|
-
if (route === '/api/holysheep/setup' && req.method === 'POST') {
|
|
558
|
-
return await legacy().handleSetup(req, res)
|
|
559
|
-
}
|
|
560
|
-
// POST handlers: install, configure, reset, launch for a named tool
|
|
561
|
-
if (route === '/api/holysheep/tool/install' && req.method === 'POST') {
|
|
562
|
-
return await legacy().handleToolInstall(req, res)
|
|
563
|
-
}
|
|
564
|
-
if (route === '/api/holysheep/tool/configure' && req.method === 'POST') {
|
|
565
|
-
return await legacy().handleToolConfigure(req, res)
|
|
566
|
-
}
|
|
567
|
-
if (route === '/api/holysheep/tool/reset' && req.method === 'POST') {
|
|
568
|
-
return await legacy().handleToolReset(req, res)
|
|
569
|
-
}
|
|
570
|
-
if (route === '/api/holysheep/tool/launch' && req.method === 'POST') {
|
|
571
|
-
return await legacy().handleToolLaunch(req, res)
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
// 3. Health probe (wrapper itself)
|
|
575
|
-
if (route === '/api/holysheep/__wrapper/healthz') {
|
|
576
|
-
return sendJson(res, 200, {
|
|
577
|
-
ok: true,
|
|
578
|
-
wrapper: require('../../package.json').version,
|
|
579
|
-
aionuiRuntime: ctx.runtimeVersion,
|
|
580
|
-
aionuiSource: ctx.runtimeSource,
|
|
581
|
-
})
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// 4. Default: proxy to AionUi
|
|
585
|
-
return proxyHttp(req, res, ctx.internalPort)
|
|
586
|
-
} catch (e) {
|
|
587
|
-
try {
|
|
588
|
-
if (!res.headersSent) sendJson(res, 500, { success: false, message: e.message })
|
|
589
|
-
} catch {}
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
}
|
|
593
|
-
|
|
594
|
-
// ── Public entry point ───────────────────────────────────────────────────────
|
|
595
|
-
|
|
596
|
-
/**
|
|
597
|
-
* Start the wrapper.
|
|
598
|
-
* @param {object} opts
|
|
599
|
-
* @param {number} opts.port public-facing port (e.g. 9876)
|
|
600
|
-
* @param {string} opts.runtimeDir resolved AionUi runtime directory
|
|
601
|
-
* @param {string} opts.runtimeVersion version string from package.json or 'unknown'
|
|
602
|
-
* @param {string} opts.runtimeSource 'user-cache' | 'vendor' | 'env-download'
|
|
603
|
-
* @param {string} opts.bunPath path to bun binary
|
|
604
|
-
* @returns {Promise<{ server, aionui, internalPort, mintBootstrapToken }>}
|
|
605
|
-
*/
|
|
606
|
-
async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, bunPath }) {
|
|
607
|
-
// Detect if the vendored AionUi build natively speaks HolySheep auth.
|
|
608
|
-
// Vendored v1.9.17 does; upstream AionUi releases do not (use username/password).
|
|
609
|
-
const hsNative = detectHolySheepAionUi(runtimeDir)
|
|
610
|
-
log(`/login mode: ${hsNative ? 'holysheep-native (apiKey)' : 'legacy (username/password bridge)'}`)
|
|
611
|
-
|
|
612
|
-
// If the build is legacy username/password, eager pre-flight the bridge cred
|
|
613
|
-
// perms so a misconfigured file fails at boot rather than during a request.
|
|
614
|
-
if (!hsNative) {
|
|
615
|
-
loadBridgeCredentials() // throws if perms are wrong (0600 enforced on posix)
|
|
616
|
-
}
|
|
617
|
-
|
|
618
|
-
const internalPort = await pickInternalPort()
|
|
619
|
-
log(`internal runtime port: ${internalPort}`)
|
|
620
|
-
|
|
621
|
-
// Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set).
|
|
622
|
-
// stdio is piped (not inherited) so that:
|
|
623
|
-
// 1. When startup fails we can surface the last ~4KB of bun/AionUi
|
|
624
|
-
// output via the rejected `startWrapper` promise (previously silent).
|
|
625
|
-
// 2. In HS_WEB_DEBUG=1 mode we stream live logs prefixed with [aionui].
|
|
626
|
-
const debug = process.env.HS_WEB_DEBUG === '1'
|
|
627
|
-
// [HolySheep fork v2.1.19] Pin CLI root so AionUi's prepareClaude() can
|
|
628
|
-
// require('claude-process-proxy.js') regardless of npm global install layout.
|
|
629
|
-
// `__dirname` here is .../holysheep-cli/src/webui, so up two levels is the
|
|
630
|
-
// CLI package root containing package.json + src/.
|
|
631
|
-
const cliRoot = path.resolve(__dirname, '..', '..')
|
|
632
|
-
const wrapperVersion = require('../../package.json').version
|
|
633
|
-
// [HolySheep fork v2.1.23] Do NOT put HOLYSHEEP_API_KEY in env — any other
|
|
634
|
-
// process owned by the same user can read it via `ps ewww`. Instead, the
|
|
635
|
-
// fork side reads ~/.holysheep/config.json (already 0644 today; will be
|
|
636
|
-
// tightened to 0600 in the same release). Pass only the path hint.
|
|
637
|
-
const credPath = path.join(os.homedir(), '.holysheep', 'config.json')
|
|
638
|
-
try {
|
|
639
|
-
// Belt-and-suspenders: ensure the creds file is not world-readable.
|
|
640
|
-
if (fs.existsSync(credPath)) {
|
|
641
|
-
fs.chmodSync(credPath, 0o600)
|
|
642
|
-
}
|
|
643
|
-
} catch { /* best effort */ }
|
|
644
|
-
const sanitizedParentEnv = { ...process.env }
|
|
645
|
-
// If the parent process already had HOLYSHEEP_API_KEY in its env (e.g. the
|
|
646
|
-
// user exported it in their shell), DON'T propagate it — the fork reads
|
|
647
|
-
// the file instead. This closes the ps-leak vector for npm-global users.
|
|
648
|
-
delete sanitizedParentEnv.HOLYSHEEP_API_KEY
|
|
649
|
-
const aionui = spawn(bunPath, ['dist-server/server.mjs'], {
|
|
650
|
-
cwd: runtimeDir,
|
|
651
|
-
env: {
|
|
652
|
-
...sanitizedParentEnv,
|
|
653
|
-
PORT: String(internalPort),
|
|
654
|
-
HOST: '127.0.0.1',
|
|
655
|
-
ALLOW_REMOTE: '',
|
|
656
|
-
NODE_ENV: 'production',
|
|
657
|
-
HOLYSHEEP_CLI_ROOT: cliRoot,
|
|
658
|
-
HOLYSHEEP_CLI_VERSION: wrapperVersion,
|
|
659
|
-
HOLYSHEEP_CONFIG_PATH: credPath,
|
|
660
|
-
},
|
|
661
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
662
|
-
})
|
|
663
|
-
|
|
664
|
-
let logTail = ''
|
|
665
|
-
const appendLog = (stream, chunk) => {
|
|
666
|
-
const s = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk)
|
|
667
|
-
logTail += s
|
|
668
|
-
if (logTail.length > AIONUI_LOG_TAIL_BYTES) {
|
|
669
|
-
logTail = logTail.slice(logTail.length - AIONUI_LOG_TAIL_BYTES)
|
|
670
|
-
}
|
|
671
|
-
if (debug) {
|
|
672
|
-
const target = stream === 'err' ? process.stderr : process.stdout
|
|
673
|
-
target.write(`[aionui] ${s}`)
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
aionui.stdout.on('data', (d) => appendLog('out', d))
|
|
677
|
-
aionui.stderr.on('data', (d) => appendLog('err', d))
|
|
678
|
-
aionui.on('exit', (code) => {
|
|
679
|
-
log(`runtime upstream exited (code=${code})`)
|
|
680
|
-
process.exit(code || 1)
|
|
681
|
-
})
|
|
682
|
-
|
|
683
|
-
try {
|
|
684
|
-
await waitForUpstreamReady(internalPort)
|
|
685
|
-
} catch (e) {
|
|
686
|
-
const tail = logTail.trim()
|
|
687
|
-
const msg = tail
|
|
688
|
-
? `${e.message}\n\n --- last AionUi output (tail) ---\n${tail
|
|
689
|
-
.split(/\r?\n/)
|
|
690
|
-
.map((ln) => ` ${ln}`)
|
|
691
|
-
.join('\n')}\n ------------------------------------`
|
|
692
|
-
: e.message
|
|
693
|
-
throw new Error(msg)
|
|
694
|
-
}
|
|
695
|
-
log(`runtime ready (version=${runtimeVersion}, source=${runtimeSource})`)
|
|
696
|
-
|
|
697
|
-
const ctx = { internalPort, runtimeDir, runtimeVersion, runtimeSource, bunPath }
|
|
698
|
-
const server = http.createServer(buildRouter(ctx))
|
|
699
|
-
server.on('upgrade', (req, socket, head) => {
|
|
700
|
-
try {
|
|
701
|
-
proxyWebSocket(req, socket, head, internalPort)
|
|
702
|
-
} catch (e) {
|
|
703
|
-
try { socket.destroy() } catch {}
|
|
704
|
-
}
|
|
705
|
-
})
|
|
706
|
-
await new Promise((resolve, reject) => {
|
|
707
|
-
server.once('error', reject)
|
|
708
|
-
server.listen(port, '127.0.0.1', resolve)
|
|
709
|
-
})
|
|
710
|
-
log(`wrapper listening on http://127.0.0.1:${port}`)
|
|
711
|
-
startTokenCleanup()
|
|
712
|
-
|
|
713
|
-
return {
|
|
714
|
-
server,
|
|
715
|
-
aionui,
|
|
716
|
-
internalPort,
|
|
717
|
-
mintBootstrapToken() {
|
|
718
|
-
pruneExpiredTokens()
|
|
719
|
-
const token = randomToken()
|
|
720
|
-
bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
|
|
721
|
-
return token
|
|
722
|
-
},
|
|
723
|
-
}
|
|
724
|
-
}
|
|
725
|
-
|
|
726
|
-
module.exports = {
|
|
727
|
-
startWrapper,
|
|
728
|
-
// Exported for tests / inspection
|
|
729
|
-
isLoopbackRequest,
|
|
730
|
-
pruneExpiredTokens,
|
|
731
|
-
_tokens: bootstrapTokens,
|
|
732
|
-
TOKEN_TTL_MS,
|
|
733
|
-
BRIDGE_CRED_FILE,
|
|
734
|
-
}
|