@simonyea/holysheep-cli 2.0.4 → 2.0.6

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,60 @@ 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 — one-command** (recommended — 21 MB prebuilt, HolySheep-auth-ready):
138
+
139
+ ```bash
140
+ export HOLYSHEEP_AIONUI_RUNTIME_URL=https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.17-holysheep.tar.gz
141
+ export HOLYSHEEP_AIONUI_RUNTIME_SHA256=d75adcea3c57c85f64b5db96d18c593f20ad150888f47283dc2da0a440fb652c
142
+ hs web --aionui --setup-runtime
143
+ ```
144
+
145
+ The fetcher downloads to `~/.holysheep/aionui-runtime/`, verifies SHA256, extracts `dist-server/` + `out/` + `package.json`, boots AionUi, and opens your browser at the bootstrap redirect (auto-logged-in with your HS API key). Runs macOS / Linux. Windows support depends on `bun` availability.
146
+
147
+ **Alternative — build from AionUi source** (advanced):
148
+
149
+ ```bash
150
+ git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi
151
+ cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server
152
+ mkdir -p ~/.holysheep/aionui-runtime
153
+ cp -R ~/AionUi/{dist-server,out,package.json} ~/.holysheep/aionui-runtime/
154
+ hs web --aionui
155
+ ```
156
+ Note: upstream AionUi's `/login` still expects `{ username, password }`; the legacy wrapper path handles that via an auto-provisioned bridge admin in `~/.holysheep/aionui-bridge.json` (0600).
157
+
158
+ **Recovery / advanced**
159
+
160
+ - `/login` is still reachable at `http://127.0.0.1:9876/login` for direct apiKey entry.
161
+ - To rotate credentials: `rm -rf ~/.holysheep/aionui-runtime && hs web --aionui --setup-runtime` (or edit `~/.holysheep/config.json`).
162
+ - Startup logs showing `[AionUi] Could not find builtin src/process/resources/...` are harmless — the prebuilt tarball ships only `dist-server/` + `out/` (not the source tree), so template assistants/skills load from built-in fallbacks instead. The UI works fully; only the "source-tree" assistant templates are unavailable.
163
+
110
164
  ---
111
165
 
112
166
  <a name="chinese"></a>
@@ -198,21 +252,60 @@ npx openclaw gateway --port <显示的端口>
198
252
  | Anthropic SDK / Claude Code | `https://api.holysheep.ai`(不带 /v1) |
199
253
  | OpenAI 兼容 / Codex / Aider | `https://api.holysheep.ai/v1`(带 /v1) |
200
254
 
201
- ### AionUi runtime(实验性,opt-in)
255
+ ### AionUi 模式(实验性,opt-in)
202
256
 
203
257
  `hs web` 默认启动轻量版 WebUI,所有平台(Windows / macOS / Linux)开箱即用,无额外依赖。
204
258
 
205
- 还有一个实验性的 AionUi runtime 提供更丰富的界面,但它只随本地 `src/webui/vendor/` 目录存在(npm 包里不包含,保持 ~90 kB 体积),目前仅支持 **macOS arm64 + [Bun](https://bun.sh)**。
259
+ 要体验完整的 AionUi 界面,用 `hs web --aionui`。它会通过一个零依赖的 Node 代理把 AionUi 套在外面,**用 HolySheep API Key 代替账号密码**,一键自动登录。
260
+
261
+ ```bash
262
+ # 普通安装(始终可用的轻量 WebUI)
263
+ npm install -g @simonyea/holysheep-cli
264
+
265
+ # 可选:启用 AionUi 模式(需本地安装 AionUi runtime)
266
+ hs web --aionui
267
+ ```
268
+
269
+ **原理**
206
270
 
207
- 在有 vendor/ 的机器上启用:
271
+ 1. 代理监听公开端口 `9876`,将请求转发到 `127.0.0.1:9877` 上的 AionUi 服务(仅回环)。
272
+ 2. 自动将保存在 `~/.holysheep/config.json` 的 HS API Key POST 到 AionUi 的 `/login`,拿到 JWT cookie。
273
+ 3. 一次性 bootstrap token(≤30 s TTL,仅回环,拒绝 `X-Forwarded-For` / `X-Real-IP`)把 cookie 交给浏览器 → 用户直接落到 `/guid`,无需再输入任何凭据。
274
+ 4. `/api/holysheep/tools`、`/api/holysheep/balance`、`/api/holysheep/whoami` 等由代理自己就地处理(复用 legacy WebUI 的 handler,不跨端口),所以 HolySheep 多终端配置能力在 AionUi 界面里可用。
275
+
276
+ **runtime 查找顺序**(命中即用):
277
+
278
+ 1. `~/.holysheep/aionui-runtime/`(已安装 / 已缓存)
279
+ 2. `<cli>/src/webui/vendor/aionui/`(开发者本地仓库 —— npm 包不携带)
280
+ 3. `HOLYSHEEP_AIONUI_RUNTIME_URL` 环境变量(高级逃生口 —— 下载 + SHA256 校验后写入 `~/.holysheep/aionui-runtime/`)
281
+
282
+ **安装 runtime —— 一条命令**(推荐,21 MB 预编译包,内置 HolySheep Key 登录):
208
283
 
209
284
  ```bash
210
- # 单次
211
- HOLYSHEEP_WEBUI_AIONUI=1 hs web
285
+ export HOLYSHEEP_AIONUI_RUNTIME_URL=https://mail.holysheep.ai/app/cli/aionui-runtime-v1.9.17-holysheep.tar.gz
286
+ export HOLYSHEEP_AIONUI_RUNTIME_SHA256=d75adcea3c57c85f64b5db96d18c593f20ad150888f47283dc2da0a440fb652c
287
+ hs web --aionui --setup-runtime
288
+ ```
289
+
290
+ fetcher 会下载到 `~/.holysheep/aionui-runtime/`,校验 SHA256,解压 `dist-server/ + out/ + package.json`,启动 AionUi,然后直接把浏览器带到 bootstrap 重定向(用你的 HS API Key 自动登录)。macOS / Linux 都行,Windows 视 `bun` 支持情况而定。
212
291
 
213
- # 或写到 ~/.zshrc / ~/.bashrc
214
- export HOLYSHEEP_WEBUI_AIONUI=1
292
+ **备用方案 —— AionUi 源码构建**(高级用户):
293
+
294
+ ```bash
295
+ git clone https://github.com/iOfficeAI/AionUi.git ~/AionUi
296
+ cd ~/AionUi && bun install && bun run build:renderer:web && bun run build:server
297
+ mkdir -p ~/.holysheep/aionui-runtime
298
+ cp -R ~/AionUi/{dist-server,out,package.json} ~/.holysheep/aionui-runtime/
299
+ hs web --aionui
215
300
  ```
301
+ 注意:上游 AionUi 的 `/login` 仍然是账号密码模式;wrapper 会自动走 legacy 路径,在 `~/.holysheep/aionui-bridge.json` (0600) 建一个桥接管理员账号,不影响使用。
302
+
303
+ **恢复 / 高级**
304
+
305
+ - `/login` 仍可直接访问:`http://127.0.0.1:9876/login`,里面可以手动粘 HS API Key 再登录。
306
+ - 要重置:`rm -rf ~/.holysheep/aionui-runtime && hs web --aionui --setup-runtime`,或直接编辑 `~/.holysheep/config.json`。
307
+ - runtime 缺失时:`hs web --aionui` 会打印清晰的安装指引并自动回退到 legacy WebUI,不会静默失败。
308
+ - 启动日志里 `[AionUi] Could not find builtin src/process/resources/...` 是已知噪音 —— 预编译 tarball 只带 `dist-server/` 和 `out/`,不含源码目录,所以模板 assistants/skills 会走内置 fallback。界面完全可用,只是少几个"源码级"模板。
216
309
 
217
310
  不设置该环境变量时 `hs web` 走 legacy WebUI,**不会再打印任何黄色警告**。
218
311
 
@@ -275,7 +368,7 @@ A: OpenClaw 需要 Node.js 20+,运行 `node --version` 确认版本后重试
275
368
  ## License
276
369
 
277
370
  MIT
278
- ��持,自动写入配置并启动 Gateway
371
+ ��持,自动写入配置并启动 Gateway
279
372
 
280
373
  ---
281
374
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simonyea/holysheep-cli",
3
- "version": "2.0.4",
3
+ "version": "2.0.6",
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
6
  "test": "node tests/droid.test.js && node tests/workspace-store.test.js",
@@ -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,646 @@
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
+ // Periodic GC so idle wrappers don't grow memory unboundedly. .unref() so the
55
+ // timer doesn't block process exit on SIGINT. No-op if TTL-cleanup already
56
+ // happened via the lazy path in pruneExpiredTokens().
57
+ let tokenCleanupInterval = null
58
+ function startTokenCleanup() {
59
+ if (tokenCleanupInterval) return
60
+ tokenCleanupInterval = setInterval(() => pruneExpiredTokens(), 60_000)
61
+ if (typeof tokenCleanupInterval.unref === 'function') tokenCleanupInterval.unref()
62
+ }
63
+
64
+ // Cached AionUi session cookie obtained by internal /login. Refreshed lazily.
65
+ let cachedAionUiCookie = null
66
+ let cachedAionUiCookieAt = 0
67
+ const AIONUI_COOKIE_TTL_MS = 10 * 60 * 1000
68
+
69
+ // ── Helpers ──────────────────────────────────────────────────────────────────
70
+
71
+ function log(msg) {
72
+ // eslint-disable-next-line no-console
73
+ console.log(`[aionui-wrapper] ${msg}`)
74
+ }
75
+
76
+ function randomToken(bytes = 24) {
77
+ return crypto.randomBytes(bytes).toString('hex')
78
+ }
79
+
80
+ function randomPassword() {
81
+ // 24-char URL-safe password. Long enough for bcrypt, avoids shell-meta headaches.
82
+ return crypto.randomBytes(18).toString('base64').replace(/[+/=]/g, '').slice(0, 24)
83
+ }
84
+
85
+ function nowMs() { return Date.now() }
86
+
87
+ function isLoopbackRequest(req) {
88
+ if (req.headers['x-forwarded-for']) return false
89
+ if (req.headers['x-real-ip']) return false
90
+ const addr = req.socket.remoteAddress || ''
91
+ return addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1'
92
+ }
93
+
94
+ function sendJson(res, statusCode, body) {
95
+ const payload = JSON.stringify(body)
96
+ res.writeHead(statusCode, {
97
+ 'Content-Type': 'application/json; charset=utf-8',
98
+ 'Content-Length': Buffer.byteLength(payload),
99
+ })
100
+ res.end(payload)
101
+ }
102
+
103
+ function readBody(req, maxBytes = 1024 * 512) {
104
+ return new Promise((resolve, reject) => {
105
+ let size = 0
106
+ const chunks = []
107
+ req.on('data', (c) => {
108
+ size += c.length
109
+ if (size > maxBytes) {
110
+ reject(new Error('payload too large'))
111
+ try { req.destroy() } catch {}
112
+ return
113
+ }
114
+ chunks.push(c)
115
+ })
116
+ req.on('end', () => {
117
+ try {
118
+ const raw = Buffer.concat(chunks).toString('utf8')
119
+ if (!raw) return resolve({})
120
+ if (req.headers['content-type']?.includes('application/json')) {
121
+ return resolve(JSON.parse(raw))
122
+ }
123
+ resolve({ _raw: raw })
124
+ } catch (e) { reject(e) }
125
+ })
126
+ req.on('error', reject)
127
+ })
128
+ }
129
+
130
+ function pruneExpiredTokens() {
131
+ const now = nowMs()
132
+ for (const [t, meta] of bootstrapTokens) {
133
+ if (meta.used || now - meta.createdAt > TOKEN_TTL_MS) {
134
+ bootstrapTokens.delete(t)
135
+ }
136
+ }
137
+ }
138
+
139
+ // ── Bridge credential: persistent admin user for AionUi internal /login ──────
140
+
141
+ function ensureBridgeDir() {
142
+ if (!fs.existsSync(BRIDGE_DIR)) fs.mkdirSync(BRIDGE_DIR, { recursive: true, mode: 0o700 })
143
+ }
144
+
145
+ function loadBridgeCredentials() {
146
+ if (!fs.existsSync(BRIDGE_CRED_FILE)) return null
147
+ try {
148
+ // Enforce 0600 perms — world-readable bridge creds defeat the whole loopback story
149
+ const stat = fs.statSync(BRIDGE_CRED_FILE)
150
+ if (process.platform !== 'win32' && (stat.mode & 0o077)) {
151
+ throw new Error(
152
+ `Refusing to read ${BRIDGE_CRED_FILE} with perms ${(stat.mode & 0o777).toString(8)} — ` +
153
+ `must be 0600. Run: chmod 600 ${BRIDGE_CRED_FILE}`
154
+ )
155
+ }
156
+ return JSON.parse(fs.readFileSync(BRIDGE_CRED_FILE, 'utf8'))
157
+ } catch (e) {
158
+ if (e.message.startsWith('Refusing')) throw e
159
+ return null
160
+ }
161
+ }
162
+
163
+ function saveBridgeCredentials(creds) {
164
+ ensureBridgeDir()
165
+ fs.writeFileSync(BRIDGE_CRED_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 })
166
+ if (process.platform !== 'win32') {
167
+ try { fs.chmodSync(BRIDGE_CRED_FILE, 0o600) } catch {}
168
+ }
169
+ }
170
+
171
+ /**
172
+ * The vendored AionUi v1.9.17 build has already been customized upstream so
173
+ * that `POST /login` accepts `{ apiKey }` (NOT username/password) and validates
174
+ * against HolySheep. That means the wrapper does NOT need to provision or
175
+ * maintain a bridge admin password at all — we simply forward the HS API key
176
+ * to AionUi's own /login endpoint.
177
+ *
178
+ * The `loadBridgeCredentials()` / `saveBridgeCredentials()` helpers remain
179
+ * above as a fallback codepath for any AionUi build that still uses legacy
180
+ * username/password auth. When the vendored build is HolySheep-aware
181
+ * (detected below), we prefer the direct apiKey-to-/login path.
182
+ */
183
+ function detectHolySheepAionUi(runtimeDir) {
184
+ try {
185
+ const serverPath = path.join(runtimeDir, 'dist-server', 'server.mjs')
186
+ // Scan for the HolySheep validation marker — fast regex, file is large
187
+ // but we stop after finding the first match.
188
+ const buf = fs.readFileSync(serverPath, 'utf8')
189
+ return buf.includes('validateHolySheepApiKey') ||
190
+ buf.includes('HolySheep API key is required') ||
191
+ buf.includes('HOLYSHEEP_PROVIDER_NAME')
192
+ } catch { return false }
193
+ }
194
+
195
+ // ── Validate HolySheep API key ───────────────────────────────────────────────
196
+
197
+ async function validateHolySheepKey(apiKey) {
198
+ // Reuse the same validation contract as `hs login`: GET /v1/models with Bearer.
199
+ const fetch = require('node-fetch')
200
+ try {
201
+ const res = await fetch(`${BASE_URL_OPENAI}/models`, {
202
+ method: 'GET',
203
+ headers: { Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' },
204
+ timeout: 15_000,
205
+ })
206
+ return res.status === 200
207
+ } catch {
208
+ return false
209
+ }
210
+ }
211
+
212
+ // ── AionUi internal login: mint a cookie we can hand to the browser ──────────
213
+
214
+ /**
215
+ * POST the HolySheep API key to the internal AionUi /login endpoint.
216
+ * AionUi's customized build (detectHolySheepAionUi above) accepts
217
+ * `{ apiKey: 'cr_...' }` and returns a JWT cookie via Set-Cookie.
218
+ * Works for vendored v1.9.17 and any future build that preserves this contract.
219
+ */
220
+ function aionuiInternalLoginWithApiKey({ internalPort, apiKey }) {
221
+ return new Promise((resolve, reject) => {
222
+ const body = JSON.stringify({ apiKey })
223
+ const req = http.request({
224
+ host: '127.0.0.1', port: internalPort, path: '/login', method: 'POST',
225
+ headers: {
226
+ 'Content-Type': 'application/json',
227
+ 'Content-Length': Buffer.byteLength(body),
228
+ },
229
+ timeout: 15_000,
230
+ }, (res) => {
231
+ let buf = ''
232
+ res.on('data', (c) => { buf += c.toString() })
233
+ res.on('end', () => {
234
+ if (res.statusCode !== 200) {
235
+ return reject(new Error(`AionUi /login returned ${res.statusCode}: ${buf.slice(0, 200)}`))
236
+ }
237
+ const setCookie = res.headers['set-cookie']
238
+ if (!setCookie || setCookie.length === 0) {
239
+ return reject(new Error('AionUi /login succeeded but no Set-Cookie header returned'))
240
+ }
241
+ resolve(setCookie)
242
+ })
243
+ })
244
+ req.on('error', reject)
245
+ req.setTimeout(15_000, () => { req.destroy(new Error('AionUi /login timed out')) })
246
+ req.write(body)
247
+ req.end()
248
+ })
249
+ }
250
+
251
+ async function getAionUiCookieFresh({ internalPort }) {
252
+ if (cachedAionUiCookie && nowMs() - cachedAionUiCookieAt < AIONUI_COOKIE_TTL_MS) {
253
+ return cachedAionUiCookie
254
+ }
255
+ const apiKey = getApiKey()
256
+ if (!apiKey) throw new Error('no HolySheep API key — call /api/auth/holysheep-login first')
257
+ const cookies = await aionuiInternalLoginWithApiKey({ internalPort, apiKey })
258
+ cachedAionUiCookie = cookies
259
+ cachedAionUiCookieAt = nowMs()
260
+ return cookies
261
+ }
262
+
263
+ // ── Wrapper endpoint handlers ────────────────────────────────────────────────
264
+
265
+ async function handleHolySheepLogin(req, res) {
266
+ try {
267
+ const body = await readBody(req)
268
+ const apiKey = (body.apiKey || '').trim()
269
+ if (!apiKey || !apiKey.startsWith('cr_')) {
270
+ return sendJson(res, 400, { success: false, message: 'API Key must start with cr_' })
271
+ }
272
+ const valid = await validateHolySheepKey(apiKey)
273
+ if (!valid) return sendJson(res, 401, { success: false, message: 'HolySheep API key invalid' })
274
+ saveConfig({ apiKey, savedAt: new Date().toISOString() })
275
+
276
+ // Issue bootstrap token. Browser will hit /api/auth/holysheep-bootstrap next.
277
+ pruneExpiredTokens()
278
+ const token = randomToken()
279
+ bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
280
+ sendJson(res, 200, { success: true, bootstrapUrl: `/api/auth/holysheep-bootstrap?token=${token}` })
281
+ } catch (e) {
282
+ sendJson(res, 500, { success: false, message: e.message })
283
+ }
284
+ }
285
+
286
+ async function handleBootstrap(req, res, ctx) {
287
+ if (!isLoopbackRequest(req)) {
288
+ return sendJson(res, 403, { success: false, message: 'bootstrap endpoint is loopback-only' })
289
+ }
290
+ const url = new URL(req.url, `http://${req.headers.host}`)
291
+ const token = url.searchParams.get('token')
292
+ pruneExpiredTokens()
293
+ const entry = token ? bootstrapTokens.get(token) : null
294
+ if (!entry || entry.used || nowMs() - entry.createdAt > TOKEN_TTL_MS) {
295
+ return sendJson(res, 401, { success: false, message: 'bootstrap token invalid or expired' })
296
+ }
297
+ entry.used = true
298
+
299
+ try {
300
+ const cookies = await getAionUiCookieFresh({ internalPort: ctx.internalPort })
301
+ res.writeHead(302, {
302
+ 'Set-Cookie': cookies,
303
+ 'Location': '/',
304
+ 'Cache-Control': 'no-store',
305
+ })
306
+ res.end()
307
+ } catch (e) {
308
+ sendJson(res, 502, { success: false, message: `AionUi bridge login failed: ${e.message}` })
309
+ }
310
+ }
311
+
312
+ async function handleHolySheepStatus(req, res) {
313
+ const apiKey = getApiKey()
314
+ sendJson(res, 200, {
315
+ loggedIn: !!apiKey,
316
+ apiKeyMasked: apiKey ? `${apiKey.slice(0, 6)}...${apiKey.slice(-4)}` : null,
317
+ mode: 'aionui-wrapper',
318
+ version: require('../../package.json').version,
319
+ })
320
+ }
321
+
322
+ // Reuse legacy handlers in-process — no cross-port hops.
323
+ let legacyModule = null
324
+ function legacy() {
325
+ if (!legacyModule) legacyModule = require('./server')
326
+ return legacyModule
327
+ }
328
+
329
+ // ── HTTP proxy to AionUi internal server ─────────────────────────────────────
330
+
331
+ const BODYLESS_METHODS = new Set(['GET', 'HEAD', 'OPTIONS'])
332
+
333
+ function proxyHttp(req, res, internalPort) {
334
+ const headers = { ...req.headers }
335
+ // Host header must match internal target for Express routing to behave consistently
336
+ headers.host = `127.0.0.1:${internalPort}`
337
+ // Strip hop-by-hop per RFC 7230 §6.1
338
+ delete headers['connection']
339
+ delete headers['keep-alive']
340
+ delete headers['proxy-connection']
341
+ delete headers['te']
342
+ delete headers['trailer']
343
+ delete headers['transfer-encoding']
344
+ delete headers['upgrade']
345
+
346
+ const upstream = http.request({
347
+ host: '127.0.0.1',
348
+ port: internalPort,
349
+ method: req.method,
350
+ path: req.url,
351
+ headers,
352
+ timeout: UPSTREAM_CONNECT_TIMEOUT_MS,
353
+ }, (upRes) => {
354
+ // Clone upstream headers; drop hop-by-hop coming back
355
+ const outHeaders = { ...upRes.headers }
356
+ delete outHeaders['connection']
357
+ delete outHeaders['keep-alive']
358
+ delete outHeaders['proxy-connection']
359
+ res.writeHead(upRes.statusCode, upRes.statusMessage, outHeaders)
360
+ upRes.pipe(res)
361
+ })
362
+ upstream.on('error', (e) => {
363
+ try {
364
+ if (!res.headersSent) res.writeHead(502, { 'Content-Type': 'text/plain' })
365
+ res.end(`upstream error: ${e.code || e.message}`)
366
+ } catch {}
367
+ })
368
+ upstream.on('timeout', () => {
369
+ try { upstream.destroy(new Error('upstream timeout')) } catch {}
370
+ })
371
+
372
+ // For body-less methods, end immediately — otherwise Node waits for `req` to
373
+ // emit 'end', which may have already fired for header-only IncomingMessages.
374
+ if (BODYLESS_METHODS.has((req.method || 'GET').toUpperCase())) {
375
+ upstream.end()
376
+ } else {
377
+ req.pipe(upstream)
378
+ }
379
+
380
+ // Client disconnect → kill upstream
381
+ req.on('close', () => { if (!upstream.destroyed) upstream.destroy() })
382
+ }
383
+
384
+ // ── WebSocket proxy (upgrade event) ──────────────────────────────────────────
385
+
386
+ function proxyWebSocket(req, clientSocket, head, internalPort) {
387
+ const upstream = net.connect({ host: '127.0.0.1', port: internalPort }, () => {
388
+ // Replay upgrade request verbatim
389
+ const lines = [
390
+ `${req.method} ${req.url} HTTP/1.1`,
391
+ `Host: 127.0.0.1:${internalPort}`,
392
+ ]
393
+ for (const [k, v] of Object.entries(req.headers)) {
394
+ if (k.toLowerCase() === 'host') continue
395
+ if (Array.isArray(v)) {
396
+ for (const vv of v) lines.push(`${k}: ${vv}`)
397
+ } else {
398
+ lines.push(`${k}: ${v}`)
399
+ }
400
+ }
401
+ upstream.write(lines.join('\r\n') + '\r\n\r\n')
402
+ if (head && head.length) upstream.write(head)
403
+
404
+ // Bidirectional pipe. `end: false` prevents premature close on one side dying.
405
+ upstream.pipe(clientSocket, { end: false })
406
+ clientSocket.pipe(upstream, { end: false })
407
+ })
408
+
409
+ const killBoth = (why) => {
410
+ try { upstream.destroy() } catch {}
411
+ try { clientSocket.destroy() } catch {}
412
+ }
413
+ upstream.on('error', killBoth)
414
+ upstream.on('close', () => killBoth('upstream-close'))
415
+ clientSocket.on('error', killBoth)
416
+ clientSocket.on('close', () => killBoth('client-close'))
417
+
418
+ // Prevent zombie connections on slow upstream
419
+ upstream.setTimeout(UPSTREAM_CONNECT_TIMEOUT_MS, () => killBoth('upstream-timeout'))
420
+ }
421
+
422
+ // ── Wait for internal AionUi server to become ready ──────────────────────────
423
+
424
+ function waitForUpstreamReady(internalPort, timeoutMs = UPSTREAM_STARTUP_TIMEOUT_MS) {
425
+ const startedAt = nowMs()
426
+ return new Promise((resolve, reject) => {
427
+ const tick = () => {
428
+ const req = http.get({
429
+ host: '127.0.0.1', port: internalPort, path: '/', family: 4, timeout: 1500,
430
+ }, (res) => {
431
+ res.resume()
432
+ if (res.statusCode && res.statusCode < 500) return resolve(true)
433
+ retry()
434
+ })
435
+ req.on('error', retry)
436
+ req.on('timeout', () => { req.destroy(); retry() })
437
+ }
438
+ const retry = () => {
439
+ if (nowMs() - startedAt >= timeoutMs) return reject(new Error('upstream not ready in time'))
440
+ setTimeout(tick, 500)
441
+ }
442
+ tick()
443
+ })
444
+ }
445
+
446
+ // ── Find a free internal port ────────────────────────────────────────────────
447
+
448
+ function findFreeInternalPort(start = INTERNAL_PORT_START, tries = INTERNAL_PORT_TRIES) {
449
+ for (let i = 0; i < tries; i++) {
450
+ const p = start + i
451
+ try {
452
+ const server = net.createServer()
453
+ // Sync-ish port probe using Node's listen on 127.0.0.1
454
+ const ok = new Promise((resolve) => {
455
+ server.once('error', () => resolve(false))
456
+ server.once('listening', () => { server.close(() => resolve(true)) })
457
+ server.listen(p, '127.0.0.1')
458
+ })
459
+ // eslint-disable-next-line no-unused-expressions
460
+ ok // we use the returned probe below
461
+ return { port: p, probe: ok }
462
+ } catch {}
463
+ }
464
+ return null
465
+ }
466
+
467
+ async function pickInternalPort() {
468
+ for (let i = 0; i < INTERNAL_PORT_TRIES; i++) {
469
+ const p = INTERNAL_PORT_START + i
470
+ const server = net.createServer()
471
+ const ok = await new Promise((resolve) => {
472
+ server.once('error', () => resolve(false))
473
+ server.once('listening', () => { server.close(() => resolve(true)) })
474
+ server.listen(p, '127.0.0.1')
475
+ })
476
+ if (ok) return p
477
+ }
478
+ throw new Error(`no free internal port in ${INTERNAL_PORT_START}..${INTERNAL_PORT_START + INTERNAL_PORT_TRIES - 1}`)
479
+ }
480
+
481
+ // ── Router ───────────────────────────────────────────────────────────────────
482
+
483
+ function buildRouter(ctx) {
484
+ return async function onRequest(req, res) {
485
+ try {
486
+ const url = new URL(req.url, `http://${req.headers.host}`)
487
+ const route = url.pathname
488
+
489
+ if (req.method === 'OPTIONS') {
490
+ res.writeHead(204, {
491
+ 'Access-Control-Allow-Origin': '*',
492
+ 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
493
+ 'Access-Control-Allow-Headers': 'Content-Type',
494
+ })
495
+ return res.end()
496
+ }
497
+
498
+ // 1. HolySheep authentication endpoints
499
+ if (route === '/api/auth/holysheep-login' && req.method === 'POST') {
500
+ return await handleHolySheepLogin(req, res)
501
+ }
502
+ if (route === '/api/auth/holysheep-bootstrap' && req.method === 'GET') {
503
+ return await handleBootstrap(req, res, ctx)
504
+ }
505
+
506
+ // 2. HolySheep multi-tool config & status (reuse legacy handlers in-process)
507
+ if (route === '/api/holysheep/status' && req.method === 'GET') {
508
+ return await handleHolySheepStatus(req, res)
509
+ }
510
+ if (route === '/api/holysheep/tools' && req.method === 'GET') {
511
+ return await legacy().handleTools(req, res)
512
+ }
513
+ if (route === '/api/holysheep/models' && req.method === 'GET') {
514
+ return await legacy().handleModels(req, res)
515
+ }
516
+ if (route === '/api/holysheep/balance' && req.method === 'GET') {
517
+ return await legacy().handleBalance(req, res)
518
+ }
519
+ if (route === '/api/holysheep/doctor' && req.method === 'GET') {
520
+ return await legacy().handleDoctor(req, res)
521
+ }
522
+ if (route === '/api/holysheep/env' && req.method === 'GET') {
523
+ return legacy().handleEnv(req, res)
524
+ }
525
+ if (route === '/api/holysheep/whoami' && req.method === 'GET') {
526
+ return await legacy().handleWhoami(req, res)
527
+ }
528
+ // POST handlers: install, configure, reset, launch for a named tool
529
+ if (route === '/api/holysheep/tool/install' && req.method === 'POST') {
530
+ return await legacy().handleToolInstall(req, res)
531
+ }
532
+ if (route === '/api/holysheep/tool/configure' && req.method === 'POST') {
533
+ return await legacy().handleToolConfigure(req, res)
534
+ }
535
+ if (route === '/api/holysheep/tool/reset' && req.method === 'POST') {
536
+ return await legacy().handleToolReset(req, res)
537
+ }
538
+ if (route === '/api/holysheep/tool/launch' && req.method === 'POST') {
539
+ return await legacy().handleToolLaunch(req, res)
540
+ }
541
+
542
+ // 3. Health probe (wrapper itself)
543
+ if (route === '/api/holysheep/__wrapper/healthz') {
544
+ return sendJson(res, 200, {
545
+ ok: true,
546
+ wrapper: require('../../package.json').version,
547
+ aionuiRuntime: ctx.runtimeVersion,
548
+ aionuiSource: ctx.runtimeSource,
549
+ })
550
+ }
551
+
552
+ // 4. Default: proxy to AionUi
553
+ return proxyHttp(req, res, ctx.internalPort)
554
+ } catch (e) {
555
+ try {
556
+ if (!res.headersSent) sendJson(res, 500, { success: false, message: e.message })
557
+ } catch {}
558
+ }
559
+ }
560
+ }
561
+
562
+ // ── Public entry point ───────────────────────────────────────────────────────
563
+
564
+ /**
565
+ * Start the wrapper.
566
+ * @param {object} opts
567
+ * @param {number} opts.port public-facing port (e.g. 9876)
568
+ * @param {string} opts.runtimeDir resolved AionUi runtime directory
569
+ * @param {string} opts.runtimeVersion version string from package.json or 'unknown'
570
+ * @param {string} opts.runtimeSource 'user-cache' | 'vendor' | 'env-download'
571
+ * @param {string} opts.bunPath path to bun binary
572
+ * @returns {Promise<{ server, aionui, internalPort, mintBootstrapToken }>}
573
+ */
574
+ async function startWrapper({ port, runtimeDir, runtimeVersion, runtimeSource, bunPath }) {
575
+ // Detect if the vendored AionUi build natively speaks HolySheep auth.
576
+ // Vendored v1.9.17 does; upstream AionUi releases do not (use username/password).
577
+ const hsNative = detectHolySheepAionUi(runtimeDir)
578
+ log(`AionUi /login mode: ${hsNative ? 'holysheep-native (apiKey)' : 'legacy (username/password bridge)'}`)
579
+
580
+ // If the build is legacy username/password, eager pre-flight the bridge cred
581
+ // perms so a misconfigured file fails at boot rather than during a request.
582
+ if (!hsNative) {
583
+ loadBridgeCredentials() // throws if perms are wrong (0600 enforced on posix)
584
+ }
585
+
586
+ const internalPort = await pickInternalPort()
587
+ log(`internal AionUi port: ${internalPort}`)
588
+
589
+ // Spawn AionUi, bound 127.0.0.1 only (ALLOW_REMOTE never set)
590
+ const aionui = spawn(bunPath, ['dist-server/server.mjs'], {
591
+ cwd: runtimeDir,
592
+ env: {
593
+ ...process.env,
594
+ PORT: String(internalPort),
595
+ HOST: '127.0.0.1',
596
+ ALLOW_REMOTE: '',
597
+ NODE_ENV: 'production',
598
+ },
599
+ stdio: ['ignore', 'inherit', 'inherit'],
600
+ })
601
+ aionui.on('exit', (code) => {
602
+ log(`AionUi upstream exited (code=${code})`)
603
+ process.exit(code || 1)
604
+ })
605
+
606
+ await waitForUpstreamReady(internalPort)
607
+ log(`AionUi upstream ready (version=${runtimeVersion}, source=${runtimeSource})`)
608
+
609
+ const ctx = { internalPort, runtimeDir, runtimeVersion, runtimeSource, bunPath }
610
+ const server = http.createServer(buildRouter(ctx))
611
+ server.on('upgrade', (req, socket, head) => {
612
+ try {
613
+ proxyWebSocket(req, socket, head, internalPort)
614
+ } catch (e) {
615
+ try { socket.destroy() } catch {}
616
+ }
617
+ })
618
+ await new Promise((resolve, reject) => {
619
+ server.once('error', reject)
620
+ server.listen(port, '127.0.0.1', resolve)
621
+ })
622
+ log(`wrapper listening on http://127.0.0.1:${port}`)
623
+ startTokenCleanup()
624
+
625
+ return {
626
+ server,
627
+ aionui,
628
+ internalPort,
629
+ mintBootstrapToken() {
630
+ pruneExpiredTokens()
631
+ const token = randomToken()
632
+ bootstrapTokens.set(token, { createdAt: nowMs(), used: false })
633
+ return token
634
+ },
635
+ }
636
+ }
637
+
638
+ module.exports = {
639
+ startWrapper,
640
+ // Exported for tests / inspection
641
+ isLoopbackRequest,
642
+ pruneExpiredTokens,
643
+ _tokens: bootstrapTokens,
644
+ TOKEN_TTL_MS,
645
+ BRIDGE_CRED_FILE,
646
+ }
@@ -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
+ }