@simonyea/holysheep-cli 2.0.3 → 2.0.5

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/README.md CHANGED
@@ -107,6 +107,49 @@ If you forget the port, check `~/.openclaw/openclaw.json` (`gateway.port`) or ru
107
107
  | Anthropic SDK / Claude Code | `https://api.holysheep.ai` (no `/v1`) |
108
108
  | OpenAI-compatible / Codex / Aider | `https://api.holysheep.ai/v1` (with `/v1`) |
109
109
 
110
+ ### AionUi mode (experimental, opt-in)
111
+
112
+ `hs web` ships a lightweight legacy WebUI by default — Windows / macOS / Linux, zero extra dependencies.
113
+
114
+ For a richer UI, `hs web --aionui` boots the **AionUi runtime** behind a zero-dep Node proxy that turns your HolySheep API key into a one-click auto-login. No username, no password — your HS key **is** your credential.
115
+
116
+ ```bash
117
+ # Regular install (legacy WebUI, always works, ~90 kB)
118
+ npm install -g @simonyea/holysheep-cli
119
+
120
+ # Optional — enable AionUi mode. Requires the AionUi runtime locally.
121
+ hs web --aionui
122
+ ```
123
+
124
+ **How it works**
125
+
126
+ 1. Wrapper on port `9876` (public) proxies to an AionUi server on `127.0.0.1:9877` (loopback-only).
127
+ 2. Your saved HolySheep API key is POSTed to AionUi's `/login` route, which returns a JWT cookie.
128
+ 3. A single-use bootstrap token (≤30 s TTL, loopback-only) copies the cookie into your browser → you land on `/guid` authenticated.
129
+ 4. `/api/holysheep/tools`, `/api/holysheep/balance`, `/api/holysheep/whoami` etc. are served directly by the wrapper (reuses the legacy handlers in-process, no cross-port coupling).
130
+
131
+ **Runtime resolution order** (first match wins):
132
+
133
+ 1. `~/.holysheep/aionui-runtime/` (installed / cached)
134
+ 2. `<cli>/src/webui/vendor/aionui/` (developer checkout — not shipped to npm)
135
+ 3. `HOLYSHEEP_AIONUI_RUNTIME_URL` env var (advanced escape hatch — downloads + SHA256-verifies into `~/.holysheep/aionui-runtime/`)
136
+
137
+ **Install the runtime yourself** (npm-only users — we do NOT ship a 154 MB bundle):
138
+
139
+ ```bash
140
+ # Build from AionUi source
141
+ git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi
142
+ cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server
143
+ mkdir -p ~/.holysheep/aionui-runtime
144
+ cp -R ~/AionUi/{dist-server,out,package.json} ~/.holysheep/aionui-runtime/
145
+ hs web --aionui
146
+ ```
147
+
148
+ **Recovery / advanced**
149
+
150
+ - `/login` is still reachable at `http://127.0.0.1:9876/login` for direct apiKey entry.
151
+ - To rotate credentials: `rm -rf ~/.holysheep/aionui-runtime && hs web --aionui` (or edit `~/.holysheep/config.json`).
152
+
110
153
  ---
111
154
 
112
155
  <a name="chinese"></a>
@@ -198,6 +241,52 @@ npx openclaw gateway --port <显示的端口>
198
241
  | Anthropic SDK / Claude Code | `https://api.holysheep.ai`(不带 /v1) |
199
242
  | OpenAI 兼容 / Codex / Aider | `https://api.holysheep.ai/v1`(带 /v1) |
200
243
 
244
+ ### AionUi 模式(实验性,opt-in)
245
+
246
+ `hs web` 默认启动轻量版 WebUI,所有平台(Windows / macOS / Linux)开箱即用,无额外依赖。
247
+
248
+ 要体验完整的 AionUi 界面,用 `hs web --aionui`。它会通过一个零依赖的 Node 代理把 AionUi 套在外面,**用 HolySheep API Key 代替账号密码**,一键自动登录。
249
+
250
+ ```bash
251
+ # 普通安装(始终可用的轻量 WebUI)
252
+ npm install -g @simonyea/holysheep-cli
253
+
254
+ # 可选:启用 AionUi 模式(需本地安装 AionUi runtime)
255
+ hs web --aionui
256
+ ```
257
+
258
+ **原理**
259
+
260
+ 1. 代理监听公开端口 `9876`,将请求转发到 `127.0.0.1:9877` 上的 AionUi 服务(仅回环)。
261
+ 2. 自动将保存在 `~/.holysheep/config.json` 的 HS API Key POST 到 AionUi 的 `/login`,拿到 JWT cookie。
262
+ 3. 一次性 bootstrap token(≤30 s TTL,仅回环,拒绝 `X-Forwarded-For` / `X-Real-IP`)把 cookie 交给浏览器 → 用户直接落到 `/guid`,无需再输入任何凭据。
263
+ 4. `/api/holysheep/tools`、`/api/holysheep/balance`、`/api/holysheep/whoami` 等由代理自己就地处理(复用 legacy WebUI 的 handler,不跨端口),所以 HolySheep 多终端配置能力在 AionUi 界面里可用。
264
+
265
+ **runtime 查找顺序**(命中即用):
266
+
267
+ 1. `~/.holysheep/aionui-runtime/`(已安装 / 已缓存)
268
+ 2. `<cli>/src/webui/vendor/aionui/`(开发者本地仓库 —— npm 包不携带)
269
+ 3. `HOLYSHEEP_AIONUI_RUNTIME_URL` 环境变量(高级逃生口 —— 下载 + SHA256 校验后写入 `~/.holysheep/aionui-runtime/`)
270
+
271
+ **本地安装 runtime**(npm 用户 —— 我们**不**把 154 MB 塞进包里):
272
+
273
+ ```bash
274
+ # 从 AionUi 源码构建
275
+ git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi
276
+ cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server
277
+ mkdir -p ~/.holysheep/aionui-runtime
278
+ cp -R ~/AionUi/{dist-server,out,package.json} ~/.holysheep/aionui-runtime/
279
+ hs web --aionui
280
+ ```
281
+
282
+ **恢复 / 高级**
283
+
284
+ - `/login` 仍可直接访问:`http://127.0.0.1:9876/login`,里面可以手动粘 HS API Key 再登录。
285
+ - 要重置:`rm -rf ~/.holysheep/aionui-runtime && hs web --aionui`,或直接编辑 `~/.holysheep/config.json`。
286
+ - runtime 缺失时:`hs web --aionui` 会打印清晰的安装指引并自动回退到 legacy WebUI,不会静默失败。
287
+
288
+ 不设置该环境变量时 `hs web` 走 legacy WebUI,**不会再打印任何黄色警告**。
289
+
201
290
  ### 常见问题
202
291
 
203
292
  **Q: API Key 在哪里获取?**
@@ -257,3 +346,10 @@ A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试
257
346
  ## License
258
347
 
259
348
  MIT
349
+ ��持,自动写入配置并启动 Gateway
350
+
351
+ ---
352
+
353
+ ## License
354
+
355
+ MIT
package/package.json CHANGED
@@ -1,9 +1,10 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
4
4
  "description": "Claude Code/Cursor/Cline API relay for China — ¥1=$1, WeChat/Alipay payment, no credit card, no VPN. One command setup for all AI coding tools.",
5
5
  "scripts": {
6
- "test": "node tests/droid.test.js && node tests/workspace-store.test.js"
6
+ "test": "node tests/droid.test.js && node tests/workspace-store.test.js",
7
+ "prepublishOnly": "node scripts/check-tarball-size.js"
7
8
  },
8
9
  "keywords": [
9
10
  "openai-china",
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * prepublish guard: refuse to publish if the tarball grows beyond the
4
+ * expected ~100KB slim size. This protects against accidentally packing
5
+ * `src/webui/vendor/` (the 154MB AionUi runtime) again, which broke
6
+ * 2.0.2 (Taobao mirror skipped it, Windows got ETARGET).
7
+ *
8
+ * If this script fails, inspect `.npmignore` — vendor/ must stay excluded.
9
+ */
10
+ 'use strict'
11
+
12
+ const { execSync } = require('child_process')
13
+
14
+ const MAX_BYTES = 5 * 1024 * 1024 // 5MB hard ceiling
15
+ const SOFT_FILES = 100 // flag if > 100 files
16
+
17
+ try {
18
+ const raw = execSync('npm pack --dry-run --json', { stdio: ['ignore', 'pipe', 'pipe'] }).toString()
19
+ const info = JSON.parse(raw)[0]
20
+ const { size, unpackedSize, entryCount, filename } = info
21
+
22
+ const sizeKB = (size / 1024).toFixed(1)
23
+ const unpackedMB = (unpackedSize / 1024 / 1024).toFixed(2)
24
+
25
+ if (size > MAX_BYTES) {
26
+ console.error(`\n❌ prepublish guard FAILED`)
27
+ console.error(` ${filename}`)
28
+ console.error(` packed: ${sizeKB} kB (limit 5 MB)`)
29
+ console.error(` unpacked: ${unpackedMB} MB`)
30
+ console.error(` files: ${entryCount}`)
31
+ console.error(`\n vendor/ 可能又被打进包了。检查 .npmignore 是否排除 src/webui/vendor/`)
32
+ process.exit(1)
33
+ }
34
+
35
+ if (entryCount > SOFT_FILES) {
36
+ console.warn(`\n⚠️ prepublish guard: ${entryCount} files is unusually high (soft limit ${SOFT_FILES})`)
37
+ console.warn(` 仍允许发布,但请确认没有误打包大目录`)
38
+ }
39
+
40
+ console.log(`✅ prepublish guard OK: ${sizeKB} kB, ${entryCount} files, ${unpackedMB} MB unpacked`)
41
+ } catch (err) {
42
+ console.error(`prepublish guard could not run: ${err.message}`)
43
+ process.exit(1)
44
+ }
@@ -1,83 +1,155 @@
1
1
  /**
2
- * hs webui — 启动 WebUI 本地管理面板
2
+ * hs web — 启动 WebUI 本地管理面板
3
+ *
4
+ * Modes (resolution order):
5
+ * --aionui → AionUi proxy wrapper (HolySheep API key login)
6
+ * HOLYSHEEP_WEBUI_AIONUI=1 → same as --aionui (back-compat)
7
+ * HOLYSHEEP_WEBUI_LEGACY=1 → no-op (legacy is already the default)
8
+ * <none> → legacy WebUI (default, always ships in npm)
3
9
  */
4
10
  'use strict'
5
11
 
6
12
  const chalk = require('chalk')
7
13
  const { execSync } = require('child_process')
14
+ const fs = require('fs')
15
+ const path = require('path')
8
16
 
9
- // AionUi runtime is opt-in. The bundled vendor/ directory is stripped from
10
- // the npm package (see .npmignore) because it only works on darwin-arm64 and
11
- // bloated 2.0.2 to 160MB. Developers who keep vendor/ around locally can
12
- // still run AionUi via HOLYSHEEP_WEBUI_AIONUI=1. The legacy WebUI is the
13
- // default for everyone else.
14
- function shouldTryAionUi() {
17
+ function wantsAionUi(opts) {
18
+ if (opts && opts.aionui) return true
15
19
  if (process.env.HOLYSHEEP_WEBUI_AIONUI === '1') return true
16
- // Back-compat: HOLYSHEEP_WEBUI_LEGACY used to be the only way to force
17
- // legacy. It now has no effect (legacy is already the default) but we
18
- // intentionally don't crash if someone still sets it.
19
20
  return false
20
21
  }
21
22
 
22
- async function webui(opts) {
23
+ function resolveBunPath() {
24
+ // Dev bundle: holysheep-cli/src/webui/vendor/bun-darwin-arm64
25
+ const bundledBun = path.join(__dirname, '..', 'webui', 'vendor', 'bun-darwin-arm64')
26
+ if (process.platform === 'darwin' && process.arch === 'arm64' && fs.existsSync(bundledBun)) {
27
+ return bundledBun
28
+ }
29
+ if (process.env.BUN && fs.existsSync(process.env.BUN)) return process.env.BUN
30
+ try {
31
+ const resolved = execSync('which bun', {
32
+ stdio: ['ignore', 'pipe', 'ignore'], encoding: 'utf8', timeout: 2000,
33
+ }).trim()
34
+ return resolved || null
35
+ } catch { return null }
36
+ }
37
+
38
+ async function startAionUiMode(opts) {
23
39
  const port = Number(opts.port) || 9876
40
+ const { resolveRuntime, describeInstallGuidance } = require('../webui/aionui-runtime-fetcher')
41
+ const { startWrapper } = require('../webui/aionui-wrapper')
42
+ const { getApiKey } = require('../utils/config')
24
43
 
25
- console.log()
26
- console.log(chalk.bold('🌐 HolySheep WebUI'))
27
- console.log(chalk.gray('━'.repeat(50)))
28
- console.log()
44
+ const allowDownload = opts.setupRuntime || process.env.HOLYSHEEP_AIONUI_RUNTIME_URL
45
+ const runtime = await resolveRuntime({
46
+ allowDownload: !!allowDownload,
47
+ logger: (m) => console.log(chalk.gray(` ${m}`)),
48
+ })
49
+
50
+ if (!runtime) {
51
+ console.log(chalk.red('✗ AionUi runtime not installed'))
52
+ console.log()
53
+ console.log(chalk.gray(describeInstallGuidance()))
54
+ console.log()
55
+ console.log(chalk.yellow('Falling back to legacy WebUI. Run `hs web --aionui --setup-runtime` once you provide HOLYSHEEP_AIONUI_RUNTIME_URL, or install manually.'))
56
+ console.log()
57
+ return startLegacyMode(opts)
58
+ }
59
+
60
+ const bunPath = resolveBunPath()
61
+ if (!bunPath) {
62
+ console.log(chalk.red('✗ bun is required to start the AionUi runtime (install: https://bun.sh)'))
63
+ console.log(chalk.yellow('Falling back to legacy WebUI.'))
64
+ return startLegacyMode(opts)
65
+ }
66
+
67
+ console.log(chalk.cyan(`▶ AionUi mode (runtime: ${runtime.version}, source: ${runtime.source})`))
29
68
 
69
+ let handle
30
70
  try {
31
- let child = null
32
- let mode = 'legacy'
33
-
34
- if (shouldTryAionUi()) {
35
- try {
36
- const { startAionUiRuntime } = require('../webui/aionui-runtime')
37
- const result = await startAionUiRuntime(port)
38
- child = result.child
39
- mode = 'aionui'
40
- console.log(chalk.green(' AionUi runtime 已接管 hs web'))
41
- } catch (error) {
42
- console.log(chalk.yellow(`! AionUi runtime 启动失败,回退旧版 WebUI: ${error.message}`))
43
- }
44
- }
71
+ handle = await startWrapper({
72
+ port,
73
+ runtimeDir: runtime.dir,
74
+ runtimeVersion: runtime.version,
75
+ runtimeSource: runtime.source,
76
+ bunPath,
77
+ })
78
+ } catch (e) {
79
+ console.log(chalk.red(`✗ AionUi wrapper failed to start: ${e.message}`))
80
+ console.log(chalk.yellow('Falling back to legacy WebUI.'))
81
+ return startLegacyMode(opts)
82
+ }
45
83
 
46
- if (!child) {
47
- const { startServer } = require('../webui/server')
48
- await startServer(port)
49
- }
84
+ const baseUrl = `http://127.0.0.1:${port}`
85
+ const apiKey = getApiKey()
50
86
 
51
- const url = `http://127.0.0.1:${port}`
52
- console.log(chalk.green(`✓ WebUI 已启动: ${chalk.cyan.bold(url)}`))
53
- if (mode === 'aionui') {
54
- console.log(chalk.gray(' 当前模式: AionUi runtime'))
55
- }
56
- console.log(chalk.gray(' 按 Ctrl+C 停止'))
57
- console.log()
87
+ // Auto-bootstrap: if we already have an HS key, mint a single-use token and
88
+ // open the browser at /api/auth/holysheep-bootstrap?token=… so the user
89
+ // lands authenticated without touching AionUi's /login form.
90
+ let launchUrl = baseUrl
91
+ if (apiKey) {
92
+ const token = handle.mintBootstrapToken()
93
+ launchUrl = `${baseUrl}/api/auth/holysheep-bootstrap?token=${token}`
94
+ console.log(chalk.green(`✓ HolySheep API key detected — auto-login via bootstrap token`))
95
+ } else {
96
+ console.log(chalk.yellow(`! No HolySheep API key saved. POST it to ${baseUrl}/api/auth/holysheep-login or run 'hs login' first.`))
97
+ }
58
98
 
59
- if (opts.open !== false) {
60
- try {
61
- const platform = process.platform
62
- if (platform === 'darwin') execSync(`open "${url}"`)
63
- else if (platform === 'win32') execSync(`start "" "${url}"`)
64
- else execSync(`xdg-open "${url}"`)
65
- } catch {}
66
- }
99
+ console.log(chalk.green(`✓ WebUI 已启动: ${chalk.cyan.bold(launchUrl)}`))
100
+ console.log(chalk.gray(' Mode: AionUi runtime + HolySheep API key bridge'))
101
+ console.log(chalk.gray(' Press Ctrl+C to stop'))
102
+ console.log()
67
103
 
68
- if (child) {
69
- const stopChild = () => {
70
- if (!child.killed) child.kill('SIGTERM')
71
- }
72
- process.on('SIGINT', stopChild)
73
- process.on('SIGTERM', stopChild)
74
- child.on('exit', (code) => {
75
- process.exit(code || 0)
76
- })
77
- }
104
+ if (opts.open !== false) {
105
+ try {
106
+ const platform = process.platform
107
+ if (platform === 'darwin') execSync(`open "${launchUrl}"`)
108
+ else if (platform === 'win32') execSync(`start "" "${launchUrl}"`)
109
+ else execSync(`xdg-open "${launchUrl}"`)
110
+ } catch {}
111
+ }
112
+
113
+ const stopChildren = () => {
114
+ try { handle.aionui.kill('SIGTERM') } catch {}
115
+ try { handle.server.close() } catch {}
116
+ }
117
+ process.on('SIGINT', stopChildren)
118
+ process.on('SIGTERM', stopChildren)
119
+
120
+ await new Promise(() => {})
121
+ }
122
+
123
+ async function startLegacyMode(opts) {
124
+ const port = Number(opts.port) || 9876
125
+ const { startServer } = require('../webui/server')
126
+ await startServer(port)
127
+ const url = `http://127.0.0.1:${port}`
128
+ console.log(chalk.green(`✓ WebUI 已启动: ${chalk.cyan.bold(url)}`))
129
+ console.log(chalk.gray(' 按 Ctrl+C 停止'))
130
+ console.log()
131
+ if (opts.open !== false) {
132
+ try {
133
+ const platform = process.platform
134
+ if (platform === 'darwin') execSync(`open "${url}"`)
135
+ else if (platform === 'win32') execSync(`start "" "${url}"`)
136
+ else execSync(`xdg-open "${url}"`)
137
+ } catch {}
138
+ }
139
+ await new Promise(() => {})
140
+ }
78
141
 
79
- // Keep alive
80
- await new Promise(() => {})
142
+ async function webui(opts) {
143
+ console.log()
144
+ console.log(chalk.bold('🌐 HolySheep WebUI'))
145
+ console.log(chalk.gray('━'.repeat(50)))
146
+ console.log()
147
+
148
+ try {
149
+ if (wantsAionUi(opts)) {
150
+ return await startAionUiMode(opts)
151
+ }
152
+ return await startLegacyMode(opts)
81
153
  } catch (err) {
82
154
  console.log(chalk.red(`✗ 启动失败: ${err.message}`))
83
155
  process.exit(1)
package/src/index.js CHANGED
@@ -174,9 +174,11 @@ program
174
174
  program
175
175
  .command('web')
176
176
  .alias('webui')
177
- .description('启动 WebUI 本地管理面板')
177
+ .description('启动 WebUI 本地管理面板 (--aionui 启用 AionUi 界面 + HolySheep API Key 登录)')
178
178
  .option('-p, --port <port>', '指定端口', '9876')
179
179
  .option('--no-open', '不自动打开浏览器')
180
+ .option('--aionui', '使用 AionUi 运行时 (需本地已安装 runtime;npm 包不打包,保持轻量)')
181
+ .option('--setup-runtime', '允许首次运行时从 HOLYSHEEP_AIONUI_RUNTIME_URL 下载 AionUi runtime')
180
182
  .action(async (opts) => {
181
183
  printBanner()
182
184
  await require('./commands/webui')(opts)
@@ -0,0 +1,177 @@
1
+ /**
2
+ * AionUi runtime resolver
3
+ *
4
+ * Resolution order:
5
+ * 1. ~/.holysheep/aionui-runtime/ (installed / cached)
6
+ * 2. <cli>/src/webui/vendor/aionui/ (dev checkout — not shipped to npm)
7
+ * 3. HOLYSHEEP_AIONUI_RUNTIME_URL env var (advanced user escape hatch,
8
+ * downloads + SHA256-verifies when the constant below is also populated)
9
+ * 4. null → caller must show a clear "runtime not installed" error
10
+ *
11
+ * We DO NOT ship a hard-coded GitHub Release URL in this version because
12
+ * the release asset has not been uploaded yet. Shipping a 404 URL is a
13
+ * "code that lies" anti-pattern — explicit null + clear error message is
14
+ * honest. A follow-up PR will add the URL + SHA256 constant once the
15
+ * prebuilt tarball is published.
16
+ */
17
+
18
+ 'use strict'
19
+
20
+ const fs = require('fs')
21
+ const path = require('path')
22
+ const os = require('os')
23
+ const crypto = require('crypto')
24
+ const https = require('https')
25
+ const http = require('http')
26
+
27
+ const USER_CACHE_DIR = path.join(os.homedir(), '.holysheep', 'aionui-runtime')
28
+ const VENDOR_DIR = path.join(__dirname, 'vendor', 'aionui')
29
+
30
+ function isValidRuntimeDir(dir) {
31
+ if (!dir) return false
32
+ try {
33
+ return (
34
+ fs.existsSync(path.join(dir, 'dist-server', 'server.mjs')) &&
35
+ fs.existsSync(path.join(dir, 'out', 'renderer', 'index.html'))
36
+ )
37
+ } catch {
38
+ return false
39
+ }
40
+ }
41
+
42
+ function readVersion(dir) {
43
+ try {
44
+ const pkgPath = path.join(dir, 'package.json')
45
+ if (fs.existsSync(pkgPath)) {
46
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'))
47
+ return pkg.version || 'unknown'
48
+ }
49
+ } catch {}
50
+ return 'unknown'
51
+ }
52
+
53
+ /**
54
+ * Resolve an AionUi runtime directory.
55
+ * @returns {{ dir: string, version: string, source: 'user-cache'|'vendor'|'env-download' } | null}
56
+ */
57
+ async function resolveRuntime({ allowDownload = false, logger = () => {} } = {}) {
58
+ // 1. User cache
59
+ if (isValidRuntimeDir(USER_CACHE_DIR)) {
60
+ return { dir: USER_CACHE_DIR, version: readVersion(USER_CACHE_DIR), source: 'user-cache' }
61
+ }
62
+
63
+ // 2. Dev vendor checkout
64
+ if (isValidRuntimeDir(VENDOR_DIR)) {
65
+ return { dir: VENDOR_DIR, version: readVersion(VENDOR_DIR), source: 'vendor' }
66
+ }
67
+
68
+ // 3. Environment-provided download URL (advanced escape hatch)
69
+ const url = process.env.HOLYSHEEP_AIONUI_RUNTIME_URL
70
+ const expectedSha = process.env.HOLYSHEEP_AIONUI_RUNTIME_SHA256
71
+ if (url && allowDownload) {
72
+ try {
73
+ await downloadAndExtract(url, USER_CACHE_DIR, expectedSha, logger)
74
+ if (isValidRuntimeDir(USER_CACHE_DIR)) {
75
+ return { dir: USER_CACHE_DIR, version: readVersion(USER_CACHE_DIR), source: 'env-download' }
76
+ }
77
+ logger('AionUi runtime downloaded but directory structure invalid')
78
+ } catch (e) {
79
+ logger(`AionUi runtime download failed: ${e.message}`)
80
+ }
81
+ }
82
+
83
+ return null
84
+ }
85
+
86
+ /**
87
+ * Download a tar.gz from url, verify optional SHA256, extract into destDir.
88
+ * Uses only Node built-ins (https + tar via exec) — zero new deps.
89
+ */
90
+ function downloadAndExtract(url, destDir, expectedSha, logger) {
91
+ return new Promise((resolve, reject) => {
92
+ const tmpFile = path.join(os.tmpdir(), `aionui-runtime-${Date.now()}.tar.gz`)
93
+ logger(`Downloading AionUi runtime from ${url}`)
94
+
95
+ const client = url.startsWith('https:') ? https : http
96
+ const file = fs.createWriteStream(tmpFile)
97
+ const hasher = crypto.createHash('sha256')
98
+ let totalBytes = 0
99
+
100
+ const req = client.get(url, (res) => {
101
+ // Follow redirects (GitHub Releases → S3 CDN)
102
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
103
+ file.close()
104
+ fs.unlinkSync(tmpFile)
105
+ return resolve(downloadAndExtract(res.headers.location, destDir, expectedSha, logger))
106
+ }
107
+ if (res.statusCode !== 200) {
108
+ file.close()
109
+ try { fs.unlinkSync(tmpFile) } catch {}
110
+ return reject(new Error(`HTTP ${res.statusCode} from ${url}`))
111
+ }
112
+ res.on('data', (chunk) => {
113
+ hasher.update(chunk)
114
+ totalBytes += chunk.length
115
+ })
116
+ res.pipe(file)
117
+ file.on('finish', () => {
118
+ file.close(() => {
119
+ const actualSha = hasher.digest('hex')
120
+ if (expectedSha && expectedSha.toLowerCase() !== actualSha.toLowerCase()) {
121
+ try { fs.unlinkSync(tmpFile) } catch {}
122
+ return reject(new Error(`SHA256 mismatch: expected ${expectedSha}, got ${actualSha}`))
123
+ }
124
+ logger(`Downloaded ${(totalBytes / 1024 / 1024).toFixed(1)} MB, sha256=${actualSha.slice(0, 12)}…`)
125
+ try {
126
+ fs.mkdirSync(destDir, { recursive: true })
127
+ const { execSync } = require('child_process')
128
+ execSync(`tar -xzf "${tmpFile}" -C "${destDir}" --strip-components=0`, { stdio: 'ignore' })
129
+ fs.unlinkSync(tmpFile)
130
+ resolve()
131
+ } catch (e) {
132
+ try { fs.unlinkSync(tmpFile) } catch {}
133
+ reject(new Error(`extract failed: ${e.message}`))
134
+ }
135
+ })
136
+ })
137
+ })
138
+
139
+ req.on('error', (e) => {
140
+ file.close()
141
+ try { fs.unlinkSync(tmpFile) } catch {}
142
+ reject(e)
143
+ })
144
+ req.setTimeout(120_000, () => {
145
+ req.destroy(new Error('download timeout (120s)'))
146
+ })
147
+ })
148
+ }
149
+
150
+ function describeInstallGuidance() {
151
+ return [
152
+ 'AionUi runtime not installed. To enable `hs web --aionui`, install it via ONE of:',
153
+ '',
154
+ ' Option 1 — Build from source (requires bun):',
155
+ ' git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi',
156
+ ' cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server',
157
+ ` mkdir -p ${USER_CACHE_DIR}`,
158
+ ` cp -R ~/AionUi/{dist-server,out,package.json} ${USER_CACHE_DIR}/`,
159
+ '',
160
+ ' Option 2 — Supply a prebuilt tarball URL:',
161
+ ' export HOLYSHEEP_AIONUI_RUNTIME_URL=https://your.host/aionui-runtime.tar.gz',
162
+ ' export HOLYSHEEP_AIONUI_RUNTIME_SHA256=<sha256>',
163
+ ' hs web --aionui',
164
+ '',
165
+ ' Option 3 — Fall back to legacy WebUI:',
166
+ ' hs web',
167
+ ].join('\n')
168
+ }
169
+
170
+ module.exports = {
171
+ USER_CACHE_DIR,
172
+ VENDOR_DIR,
173
+ isValidRuntimeDir,
174
+ readVersion,
175
+ resolveRuntime,
176
+ describeInstallGuidance,
177
+ }
@@ -0,0 +1,635 @@
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
+ const UPSTREAM_STARTUP_TIMEOUT_MS = 25_000
49
+ const UPSTREAM_CONNECT_TIMEOUT_MS = 30_000
50
+
51
+ // Bootstrap token store — Map<token, { createdAt, used }>
52
+ const bootstrapTokens = new Map()
53
+
54
+ // Cached AionUi session cookie obtained by internal /login. Refreshed lazily.
55
+ let cachedAionUiCookie = null
56
+ let cachedAionUiCookieAt = 0
57
+ const AIONUI_COOKIE_TTL_MS = 10 * 60 * 1000
58
+
59
+ // ── Helpers ──────────────────────────────────────────────────────────────────
60
+
61
+ function log(msg) {
62
+ // eslint-disable-next-line no-console
63
+ console.log(`[aionui-wrapper] ${msg}`)
64
+ }
65
+
66
+ function randomToken(bytes = 24) {
67
+ return crypto.randomBytes(bytes).toString('hex')
68
+ }
69
+
70
+ function randomPassword() {
71
+ // 24-char URL-safe password. Long enough for bcrypt, avoids shell-meta headaches.
72
+ return crypto.randomBytes(18).toString('base64').replace(/[+/=]/g, '').slice(0, 24)
73
+ }
74
+
75
+ function nowMs() { return Date.now() }
76
+
77
+ function isLoopbackRequest(req) {
78
+ if (req.headers['x-forwarded-for']) return false
79
+ if (req.headers['x-real-ip']) return false
80
+ const addr = req.socket.remoteAddress || ''
81
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
82
+ }
83
+
84
+ function sendJson(res, statusCode, body) {
85
+ const payload = JSON.stringify(body)
86
+ res.writeHead(statusCode, {
87
+ 'Content-Type': 'application/json; charset=utf-8',
88
+ 'Content-Length': Buffer.byteLength(payload),
89
+ })
90
+ res.end(payload)
91
+ }
92
+
93
+ function readBody(req, maxBytes = 1024 * 512) {
94
+ return new Promise((resolve, reject) => {
95
+ let size = 0
96
+ const chunks = []
97
+ req.on('data', (c) => {
98
+ size += c.length
99
+ if (size > maxBytes) {
100
+ reject(new Error('payload too large'))
101
+ try { req.destroy() } catch {}
102
+ return
103
+ }
104
+ chunks.push(c)
105
+ })
106
+ req.on('end', () => {
107
+ try {
108
+ const raw = Buffer.concat(chunks).toString('utf8')
109
+ if (!raw) return resolve({})
110
+ if (req.headers['content-type']?.includes('application/json')) {
111
+ return resolve(JSON.parse(raw))
112
+ }
113
+ resolve({ _raw: raw })
114
+ } catch (e) { reject(e) }
115
+ })
116
+ req.on('error', reject)
117
+ })
118
+ }
119
+
120
+ function pruneExpiredTokens() {
121
+ const now = nowMs()
122
+ for (const [t, meta] of bootstrapTokens) {
123
+ if (meta.used || now - meta.createdAt > TOKEN_TTL_MS) {
124
+ bootstrapTokens.delete(t)
125
+ }
126
+ }
127
+ }
128
+
129
+ // ── Bridge credential: persistent admin user for AionUi internal /login ──────
130
+
131
+ function ensureBridgeDir() {
132
+ if (!fs.existsSync(BRIDGE_DIR)) fs.mkdirSync(BRIDGE_DIR, { recursive: true, mode: 0o700 })
133
+ }
134
+
135
+ function loadBridgeCredentials() {
136
+ if (!fs.existsSync(BRIDGE_CRED_FILE)) return null
137
+ try {
138
+ // Enforce 0600 perms — world-readable bridge creds defeat the whole loopback story
139
+ const stat = fs.statSync(BRIDGE_CRED_FILE)
140
+ if (process.platform !== 'win32' && (stat.mode & 0o077)) {
141
+ throw new Error(
142
+ `Refusing to read ${BRIDGE_CRED_FILE} with perms ${(stat.mode & 0o777).toString(8)} — ` +
143
+ `must be 0600. Run: chmod 600 ${BRIDGE_CRED_FILE}`
144
+ )
145
+ }
146
+ return JSON.parse(fs.readFileSync(BRIDGE_CRED_FILE, 'utf8'))
147
+ } catch (e) {
148
+ if (e.message.startsWith('Refusing')) throw e
149
+ return null
150
+ }
151
+ }
152
+
153
+ function saveBridgeCredentials(creds) {
154
+ ensureBridgeDir()
155
+ fs.writeFileSync(BRIDGE_CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 })
156
+ if (process.platform !== 'win32') {
157
+ try { fs.chmodSync(BRIDGE_CRED_FILE, 0o600) } catch {}
158
+ }
159
+ }
160
+
161
+ /**
162
+ * The vendored AionUi v1.9.17 build has already been customized upstream so
163
+ * that `POST /login` accepts `{ apiKey }` (NOT username/password) and validates
164
+ * against HolySheep. That means the wrapper does NOT need to provision or
165
+ * maintain a bridge admin password at all — we simply forward the HS API key
166
+ * to AionUi's own /login endpoint.
167
+ *
168
+ * The `loadBridgeCredentials()` / `saveBridgeCredentials()` helpers remain
169
+ * above as a fallback codepath for any AionUi build that still uses legacy
170
+ * username/password auth. When the vendored build is HolySheep-aware
171
+ * (detected below), we prefer the direct apiKey-to-/login path.
172
+ */
173
+ function detectHolySheepAionUi(runtimeDir) {
174
+ try {
175
+ const serverPath = path.join(runtimeDir, 'dist-server', 'server.mjs')
176
+ // Scan for the HolySheep validation marker — fast regex, file is large
177
+ // but we stop after finding the first match.
178
+ const buf = fs.readFileSync(serverPath, 'utf8')
179
+ return buf.includes('validateHolySheepApiKey') ||
180
+ buf.includes('HolySheep API key is required') ||
181
+ buf.includes('HOLYSHEEP_PROVIDER_NAME')
182
+ } catch { return false }
183
+ }
184
+
185
+ // ── Validate HolySheep API key ───────────────────────────────────────────────
186
+
187
+ async function validateHolySheepKey(apiKey) {
188
+ // Reuse the same validation contract as `hs login`: GET /v1/models with Bearer.
189
+ const fetch = require('node-fetch')
190
+ try {
191
+ const res = await fetch(`${BASE_URL_OPENAI}/models`, {
192
+ method: 'GET',
193
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
194
+ timeout: 15_000,
195
+ })
196
+ return res.status === 200
197
+ } catch {
198
+ return false
199
+ }
200
+ }
201
+
202
+ // ── AionUi internal login: mint a cookie we can hand to the browser ──────────
203
+
204
+ /**
205
+ * POST the HolySheep API key to the internal AionUi /login endpoint.
206
+ * AionUi's customized build (detectHolySheepAionUi above) accepts
207
+ * `{ apiKey: 'cr_...' }` and returns a JWT cookie via Set-Cookie.
208
+ * Works for vendored v1.9.17 and any future build that preserves this contract.
209
+ */
210
+ function aionuiInternalLoginWithApiKey({ internalPort, apiKey }) {
211
+ return new Promise((resolve, reject) => {
212
+ const body = JSON.stringify({ apiKey })
213
+ const req = http.request({
214
+ host: '127.0.0.1', port: internalPort, path: '/login', method: 'POST',
215
+ headers: {
216
+ 'Content-Type': 'application/json',
217
+ 'Content-Length': Buffer.byteLength(body),
218
+ },
219
+ timeout: 15_000,
220
+ }, (res) => {
221
+ let buf = ''
222
+ res.on('data', (c) => { buf += c.toString() })
223
+ res.on('end', () => {
224
+ if (res.statusCode !== 200) {
225
+ return reject(new Error(`AionUi /login returned ${res.statusCode}: ${buf.slice(0, 200)}`))
226
+ }
227
+ const setCookie = res.headers['set-cookie']
228
+ if (!setCookie || setCookie.length === 0) {
229
+ return reject(new Error('AionUi /login succeeded but no Set-Cookie header returned'))
230
+ }
231
+ resolve(setCookie)
232
+ })
233
+ })
234
+ req.on('error', reject)
235
+ req.setTimeout(15_000, () => { req.destroy(new Error('AionUi /login timed out')) })
236
+ req.write(body)
237
+ req.end()
238
+ })
239
+ }
240
+
241
+ async function getAionUiCookieFresh({ internalPort }) {
242
+ if (cachedAionUiCookie && nowMs() - cachedAionUiCookieAt < AIONUI_COOKIE_TTL_MS) {
243
+ return cachedAionUiCookie
244
+ }
245
+ const apiKey = getApiKey()
246
+ if (!apiKey) throw new Error('no HolySheep API key — call /api/auth/holysheep-login first')
247
+ const cookies = await aionuiInternalLoginWithApiKey({ internalPort, apiKey })
248
+ cachedAionUiCookie = cookies
249
+ cachedAionUiCookieAt = nowMs()
250
+ return cookies
251
+ }
252
+
253
+ // ── Wrapper endpoint handlers ────────────────────────────────────────────────
254
+
255
+ async function handleHolySheepLogin(req, res) {
256
+ try {
257
+ const body = await readBody(req)
258
+ const apiKey = (body.apiKey || '').trim()
259
+ if (!apiKey || !apiKey.startsWith('cr_')) {
260
+ return sendJson(res, 400, { success: false, message: 'API Key must start with cr_' })
261
+ }
262
+ const valid = await validateHolySheepKey(apiKey)
263
+ if (!valid) return sendJson(res, 401, { success: false, message: 'HolySheep API key invalid' })
264
+ saveConfig({ apiKey, savedAt: new Date().toISOString() })
265
+
266
+ // Issue bootstrap token. Browser will hit /api/auth/holysheep-bootstrap next.
267
+ pruneExpiredTokens()
268
+ const token = randomToken()
269
+ bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
270
+ sendJson(res, 200, { success: true, bootstrapUrl: `/api/auth/holysheep-bootstrap?token=${token}` })
271
+ } catch (e) {
272
+ sendJson(res, 500, { success: false, message: e.message })
273
+ }
274
+ }
275
+
276
+ async function handleBootstrap(req, res, ctx) {
277
+ if (!isLoopbackRequest(req)) {
278
+ return sendJson(res, 403, { success: false, message: 'bootstrap endpoint is loopback-only' })
279
+ }
280
+ const url = new URL(req.url, `http://${req.headers.host}`)
281
+ const token = url.searchParams.get('token')
282
+ pruneExpiredTokens()
283
+ const entry = token ? bootstrapTokens.get(token) : null
284
+ if (!entry || entry.used || nowMs() - entry.createdAt > TOKEN_TTL_MS) {
285
+ return sendJson(res, 401, { success: false, message: 'bootstrap token invalid or expired' })
286
+ }
287
+ entry.used = true
288
+
289
+ try {
290
+ const cookies = await getAionUiCookieFresh({ internalPort: ctx.internalPort })
291
+ res.writeHead(302, {
292
+ 'Set-Cookie': cookies,
293
+ 'Location': '/',
294
+ 'Cache-Control': 'no-store',
295
+ })
296
+ res.end()
297
+ } catch (e) {
298
+ sendJson(res, 502, { success: false, message: `AionUi bridge login failed: ${e.message}` })
299
+ }
300
+ }
301
+
302
+ async function handleHolySheepStatus(req, res) {
303
+ const apiKey = getApiKey()
304
+ sendJson(res, 200, {
305
+ loggedIn: !!apiKey,
306
+ apiKeyMasked: apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : null,
307
+ mode: 'aionui-wrapper',
308
+ version: require('../../package.json').version,
309
+ })
310
+ }
311
+
312
+ // Reuse legacy handlers in-process — no cross-port hops.
313
+ let legacyModule = null
314
+ function legacy() {
315
+ if (!legacyModule) legacyModule = require('./server')
316
+ return legacyModule
317
+ }
318
+
319
+ // ── HTTP proxy to AionUi internal server ─────────────────────────────────────
320
+
321
+ const BODYLESS_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
322
+
323
+ function proxyHttp(req, res, internalPort) {
324
+ const headers = { ...req.headers }
325
+ // Host header must match internal target for Express routing to behave consistently
326
+ headers.host = `127.0.0.1:${internalPort}`
327
+ // Strip hop-by-hop per RFC 7230 §6.1
328
+ delete headers['connection']
329
+ delete headers['keep-alive']
330
+ delete headers['proxy-connection']
331
+ delete headers['te']
332
+ delete headers['trailer']
333
+ delete headers['transfer-encoding']
334
+ delete headers['upgrade']
335
+
336
+ const upstream = http.request({
337
+ host: '127.0.0.1',
338
+ port: internalPort,
339
+ method: req.method,
340
+ path: req.url,
341
+ headers,
342
+ timeout: UPSTREAM_CONNECT_TIMEOUT_MS,
343
+ }, (upRes) => {
344
+ // Clone upstream headers; drop hop-by-hop coming back
345
+ const outHeaders = { ...upRes.headers }
346
+ delete outHeaders['connection']
347
+ delete outHeaders['keep-alive']
348
+ delete outHeaders['proxy-connection']
349
+ res.writeHead(upRes.statusCode, upRes.statusMessage, outHeaders)
350
+ upRes.pipe(res)
351
+ })
352
+ upstream.on('error', (e) => {
353
+ try {
354
+ if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' })
355
+ res.end(`upstream error: ${e.code || e.message}`)
356
+ } catch {}
357
+ })
358
+ upstream.on('timeout', () => {
359
+ try { upstream.destroy(new Error('upstream timeout')) } catch {}
360
+ })
361
+
362
+ // For body-less methods, end immediately — otherwise Node waits for `req` to
363
+ // emit 'end', which may have already fired for header-only IncomingMessages.
364
+ if (BODYLESS_METHODS.has((req.method || 'GET').toUpperCase())) {
365
+ upstream.end()
366
+ } else {
367
+ req.pipe(upstream)
368
+ }
369
+
370
+ // Client disconnect → kill upstream
371
+ req.on('close', () => { if (!upstream.destroyed) upstream.destroy() })
372
+ }
373
+
374
+ // ── WebSocket proxy (upgrade event) ──────────────────────────────────────────
375
+
376
+ function proxyWebSocket(req, clientSocket, head, internalPort) {
377
+ const upstream = net.connect({ host: '127.0.0.1', port: internalPort }, () => {
378
+ // Replay upgrade request verbatim
379
+ const lines = [
380
+ `${req.method} ${req.url} HTTP/1.1`,
381
+ `Host: 127.0.0.1:${internalPort}`,
382
+ ]
383
+ for (const [k, v] of Object.entries(req.headers)) {
384
+ if (k.toLowerCase() === 'host') continue
385
+ if (Array.isArray(v)) {
386
+ for (const vv of v) lines.push(`${k}: ${vv}`)
387
+ } else {
388
+ lines.push(`${k}: ${v}`)
389
+ }
390
+ }
391
+ upstream.write(lines.join('\r\n') + '\r\n\r\n')
392
+ if (head && head.length) upstream.write(head)
393
+
394
+ // Bidirectional pipe. `end: false` prevents premature close on one side dying.
395
+ upstream.pipe(clientSocket, { end: false })
396
+ clientSocket.pipe(upstream, { end: false })
397
+ })
398
+
399
+ const killBoth = (why) => {
400
+ try { upstream.destroy() } catch {}
401
+ try { clientSocket.destroy() } catch {}
402
+ }
403
+ upstream.on('error', killBoth)
404
+ upstream.on('close', () => killBoth('upstream-close'))
405
+ clientSocket.on('error', killBoth)
406
+ clientSocket.on('close', () => killBoth('client-close'))
407
+
408
+ // Prevent zombie connections on slow upstream
409
+ upstream.setTimeout(UPSTREAM_CONNECT_TIMEOUT_MS, () => killBoth('upstream-timeout'))
410
+ }
411
+
412
+ // ── Wait for internal AionUi server to become ready ──────────────────────────
413
+
414
+ function waitForUpstreamReady(internalPort, timeoutMs = UPSTREAM_STARTUP_TIMEOUT_MS) {
415
+ const startedAt = nowMs()
416
+ return new Promise((resolve, reject) => {
417
+ const tick = () => {
418
+ const req = http.get({
419
+ host: '127.0.0.1', port: internalPort, path: '/', family: 4, timeout: 1500,
420
+ }, (res) => {
421
+ res.resume()
422
+ if (res.statusCode && res.statusCode < 500) return resolve(true)
423
+ retry()
424
+ })
425
+ req.on('error', retry)
426
+ req.on('timeout', () => { req.destroy(); retry() })
427
+ }
428
+ const retry = () => {
429
+ if (nowMs() - startedAt >= timeoutMs) return reject(new Error('upstream not ready in time'))
430
+ setTimeout(tick, 500)
431
+ }
432
+ tick()
433
+ })
434
+ }
435
+
436
+ // ── Find a free internal port ────────────────────────────────────────────────
437
+
438
+ function findFreeInternalPort(start = INTERNAL_PORT_START, tries = INTERNAL_PORT_TRIES) {
439
+ for (let i = 0; i < tries; i++) {
440
+ const p = start + i
441
+ try {
442
+ const server = net.createServer()
443
+ // Sync-ish port probe using Node's listen on 127.0.0.1
444
+ const ok = new Promise((resolve) => {
445
+ server.once('error', () => resolve(false))
446
+ server.once('listening', () => { server.close(() => resolve(true)) })
447
+ server.listen(p, '127.0.0.1')
448
+ })
449
+ // eslint-disable-next-line no-unused-expressions
450
+ ok // we use the returned probe below
451
+ return { port: p, probe: ok }
452
+ } catch {}
453
+ }
454
+ return null
455
+ }
456
+
457
+ async function pickInternalPort() {
458
+ for (let i = 0; i < INTERNAL_PORT_TRIES; i++) {
459
+ const p = INTERNAL_PORT_START + i
460
+ const server = net.createServer()
461
+ const ok = await new Promise((resolve) => {
462
+ server.once('error', () => resolve(false))
463
+ server.once('listening', () => { server.close(() => resolve(true)) })
464
+ server.listen(p, '127.0.0.1')
465
+ })
466
+ if (ok) return p
467
+ }
468
+ throw new Error(`no free internal port in ${INTERNAL_PORT_START}..${INTERNAL_PORT_START + INTERNAL_PORT_TRIES - 1}`)
469
+ }
470
+
471
+ // ── Router ───────────────────────────────────────────────────────────────────
472
+
473
+ function buildRouter(ctx) {
474
+ return async function onRequest(req, res) {
475
+ try {
476
+ const url = new URL(req.url, `http://${req.headers.host}`)
477
+ const route = url.pathname
478
+
479
+ if (req.method === 'OPTIONS') {
480
+ res.writeHead(204, {
481
+ 'Access-Control-Allow-Origin': '*',
482
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
483
+ 'Access-Control-Allow-Headers': 'Content-Type',
484
+ })
485
+ return res.end()
486
+ }
487
+
488
+ // 1. HolySheep authentication endpoints
489
+ if (route === '/api/auth/holysheep-login' && req.method === 'POST') {
490
+ return await handleHolySheepLogin(req, res)
491
+ }
492
+ if (route === '/api/auth/holysheep-bootstrap' && req.method === 'GET') {
493
+ return await handleBootstrap(req, res, ctx)
494
+ }
495
+
496
+ // 2. HolySheep multi-tool config & status (reuse legacy handlers in-process)
497
+ if (route === '/api/holysheep/status' && req.method === 'GET') {
498
+ return await handleHolySheepStatus(req, res)
499
+ }
500
+ if (route === '/api/holysheep/tools' && req.method === 'GET') {
501
+ return await legacy().handleTools(req, res)
502
+ }
503
+ if (route === '/api/holysheep/models' && req.method === 'GET') {
504
+ return await legacy().handleModels(req, res)
505
+ }
506
+ if (route === '/api/holysheep/balance' && req.method === 'GET') {
507
+ return await legacy().handleBalance(req, res)
508
+ }
509
+ if (route === '/api/holysheep/doctor' && req.method === 'GET') {
510
+ return await legacy().handleDoctor(req, res)
511
+ }
512
+ if (route === '/api/holysheep/env' && req.method === 'GET') {
513
+ return legacy().handleEnv(req, res)
514
+ }
515
+ if (route === '/api/holysheep/whoami' && req.method === 'GET') {
516
+ return await legacy().handleWhoami(req, res)
517
+ }
518
+ // POST handlers: install, configure, reset, launch for a named tool
519
+ if (route === '/api/holysheep/tool/install' && req.method === 'POST') {
520
+ return await legacy().handleToolInstall(req, res)
521
+ }
522
+ if (route === '/api/holysheep/tool/configure' && req.method === 'POST') {
523
+ return await legacy().handleToolConfigure(req, res)
524
+ }
525
+ if (route === '/api/holysheep/tool/reset' && req.method === 'POST') {
526
+ return await legacy().handleToolReset(req, res)
527
+ }
528
+ if (route === '/api/holysheep/tool/launch' && req.method === 'POST') {
529
+ return await legacy().handleToolLaunch(req, res)
530
+ }
531
+
532
+ // 3. Health probe (wrapper itself)
533
+ if (route === '/api/holysheep/__wrapper/healthz') {
534
+ return sendJson(res, 200, {
535
+ ok: true,
536
+ wrapper: require('../../package.json').version,
537
+ aionuiRuntime: ctx.runtimeVersion,
538
+ aionuiSource: ctx.runtimeSource,
539
+ })
540
+ }
541
+
542
+ // 4. Default: proxy to AionUi
543
+ return proxyHttp(req, res, ctx.internalPort)
544
+ } catch (e) {
545
+ try {
546
+ if (!res.headersSent) sendJson(res, 500, { success: false, message: e.message })
547
+ } catch {}
548
+ }
549
+ }
550
+ }
551
+
552
+ // ── Public entry point ───────────────────────────────────────────────────────
553
+
554
+ /**
555
+ * Start the wrapper.
556
+ * @param {object} opts
557
+ * @param {number} opts.port public-facing port (e.g. 9876)
558
+ * @param {string} opts.runtimeDir resolved AionUi runtime directory
559
+ * @param {string} opts.runtimeVersion version string from package.json or 'unknown'
560
+ * @param {string} opts.runtimeSource 'user-cache' | 'vendor' | 'env-download'
561
+ * @param {string} opts.bunPath path to bun binary
562
+ * @returns {Promise<{ server, aionui, internalPort, mintBootstrapToken }>}
563
+ */
564
+ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, bunPath }) {
565
+ // Detect if the vendored AionUi build natively speaks HolySheep auth.
566
+ // Vendored v1.9.17 does; upstream AionUi releases do not (use username/password).
567
+ const hsNative = detectHolySheepAionUi(runtimeDir)
568
+ log(`AionUi /login mode: ${hsNative ? 'holysheep-native (apiKey)' : 'legacy (username/password bridge)'}`)
569
+
570
+ // If the build is legacy username/password, eager pre-flight the bridge cred
571
+ // perms so a misconfigured file fails at boot rather than during a request.
572
+ if (!hsNative) {
573
+ loadBridgeCredentials() // throws if perms are wrong (0600 enforced on posix)
574
+ }
575
+
576
+ const internalPort = await pickInternalPort()
577
+ log(`internal AionUi port: ${internalPort}`)
578
+
579
+ // Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set)
580
+ const aionui = spawn(bunPath, ['dist-server/server.mjs'], {
581
+ cwd: runtimeDir,
582
+ env: {
583
+ ...process.env,
584
+ PORT: String(internalPort),
585
+ HOST: '127.0.0.1',
586
+ ALLOW_REMOTE: '',
587
+ NODE_ENV: 'production',
588
+ },
589
+ stdio: ['ignore', 'inherit', 'inherit'],
590
+ })
591
+ aionui.on('exit', (code) => {
592
+ log(`AionUi upstream exited (code=${code})`)
593
+ process.exit(code || 1)
594
+ })
595
+
596
+ await waitForUpstreamReady(internalPort)
597
+ log(`AionUi upstream ready (version=${runtimeVersion}, source=${runtimeSource})`)
598
+
599
+ const ctx = { internalPort, runtimeDir, runtimeVersion, runtimeSource, bunPath }
600
+ const server = http.createServer(buildRouter(ctx))
601
+ server.on('upgrade', (req, socket, head) => {
602
+ try {
603
+ proxyWebSocket(req, socket, head, internalPort)
604
+ } catch (e) {
605
+ try { socket.destroy() } catch {}
606
+ }
607
+ })
608
+ await new Promise((resolve, reject) => {
609
+ server.once('error', reject)
610
+ server.listen(port, '127.0.0.1', resolve)
611
+ })
612
+ log(`wrapper listening on http://127.0.0.1:${port}`)
613
+
614
+ return {
615
+ server,
616
+ aionui,
617
+ internalPort,
618
+ mintBootstrapToken() {
619
+ pruneExpiredTokens()
620
+ const token = randomToken()
621
+ bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
622
+ return token
623
+ },
624
+ }
625
+ }
626
+
627
+ module.exports = {
628
+ startWrapper,
629
+ // Exported for tests / inspection
630
+ isLoopbackRequest,
631
+ pruneExpiredTokens,
632
+ _tokens: bootstrapTokens,
633
+ TOKEN_TTL_MS,
634
+ BRIDGE_CRED_FILE,
635
+ }
@@ -1421,4 +1421,22 @@ async function bootstrapBackgroundServices() {
1421
1421
  } catch {}
1422
1422
  }
1423
1423
 
1424
- module.exports = { startServer, bootstrapBackgroundServices }
1424
+ // Exported for in-process reuse by the AionUi wrapper.
1425
+ // Each handler is a pure `(req, res) => Promise<void>` and writes the HTTP response itself.
1426
+ module.exports = {
1427
+ startServer,
1428
+ bootstrapBackgroundServices,
1429
+ // Handlers reused by src/webui/aionui-wrapper.js
1430
+ handleTools,
1431
+ handleSetup,
1432
+ handleToolInstall,
1433
+ handleToolConfigure,
1434
+ handleToolReset,
1435
+ handleToolLaunch,
1436
+ handleBalance,
1437
+ handleDoctor,
1438
+ handleWhoami,
1439
+ handleStatus,
1440
+ handleModels,
1441
+ handleEnv,
1442
+ }