@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 +101 -8
- package/package.json +1 -1
- package/src/commands/webui.js +132 -60
- package/src/index.js +3 -1
- package/src/webui/aionui-runtime-fetcher.js +177 -0
- package/src/webui/aionui-wrapper.js +646 -0
- package/src/webui/server.js +19 -1
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
|
|
255
|
+
### AionUi 模式(实验性,opt-in)
|
|
202
256
|
|
|
203
257
|
`hs web` 默认启动轻量版 WebUI,所有平台(Windows / macOS / Linux)开箱即用,无额外依赖。
|
|
204
258
|
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
214
|
-
|
|
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.
|
|
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",
|
package/src/commands/webui.js
CHANGED
|
@@ -1,83 +1,155 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* hs
|
|
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
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
await startServer(port)
|
|
49
|
-
}
|
|
84
|
+
const baseUrl = `http://127.0.0.1:${port}`
|
|
85
|
+
const apiKey = getApiKey()
|
|
50
86
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/src/webui/server.js
CHANGED
|
@@ -1421,4 +1421,22 @@ async function bootstrapBackgroundServices() {
|
|
|
1421
1421
|
} catch {}
|
|
1422
1422
|
}
|
|
1423
1423
|
|
|
1424
|
-
|
|
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
|
+
}
|