@simonyea/holysheep-cli 2.1.40 → 2.1.41

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.
Files changed (45) hide show
  1. package/dist/configure-worker.js +4491 -0
  2. package/dist/index.js +9591 -0
  3. package/dist/process-proxy-inject.js +117 -0
  4. package/package.json +20 -7
  5. package/.gitea/workflows/sanity.yml +0 -125
  6. package/scripts/check-tarball-size.js +0 -44
  7. package/src/commands/balance.js +0 -57
  8. package/src/commands/claude-proxy.js +0 -248
  9. package/src/commands/claude.js +0 -135
  10. package/src/commands/doctor.js +0 -282
  11. package/src/commands/login.js +0 -211
  12. package/src/commands/openclaw.js +0 -258
  13. package/src/commands/reset.js +0 -53
  14. package/src/commands/setup.js +0 -493
  15. package/src/commands/upgrade.js +0 -168
  16. package/src/commands/webui.js +0 -622
  17. package/src/index.js +0 -226
  18. package/src/tools/aider.js +0 -78
  19. package/src/tools/antigravity.js +0 -42
  20. package/src/tools/claude-code.js +0 -228
  21. package/src/tools/claude-process-proxy.js +0 -1030
  22. package/src/tools/codex.js +0 -254
  23. package/src/tools/continue.js +0 -146
  24. package/src/tools/cursor.js +0 -71
  25. package/src/tools/droid.js +0 -281
  26. package/src/tools/env-config.js +0 -185
  27. package/src/tools/gemini-cli.js +0 -82
  28. package/src/tools/hermes.js +0 -354
  29. package/src/tools/index.js +0 -13
  30. package/src/tools/openclaw-bridge.js +0 -987
  31. package/src/tools/openclaw.js +0 -925
  32. package/src/tools/opencode.js +0 -227
  33. package/src/tools/process-proxy-inject.js +0 -142
  34. package/src/utils/config.js +0 -54
  35. package/src/utils/shell.js +0 -342
  36. package/src/utils/which.js +0 -176
  37. package/src/webui/aionui-runtime-fetcher.js +0 -429
  38. package/src/webui/aionui-runtime.js +0 -139
  39. package/src/webui/aionui-wrapper.js +0 -734
  40. package/src/webui/configure-worker.js +0 -67
  41. package/src/webui/server.js +0 -1572
  42. package/src/webui/workspace-runtime.js +0 -288
  43. package/src/webui/workspace-store.js +0 -325
  44. /package/{src/webui → dist}/index.html +0 -0
  45. /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
- }