mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
feat(channels): VNC 兜底真正可用(依赖预检 + 就绪探针 + CDP 取凭据)
用户指出「vnc 无头浏览器兜底登陆你根本没有实现」。核对后确认:进程层代码存在,
但**只接在一个我编的假平台上**(loopbackish,已随夹具移出),四个真实平台全标
flow="manual" 并给一句"请你自己去某处搞定"。且代码写着「运行 deploy 脚本一键
安装」,而那个安装机制根本不存在(deploy.sh 里零浏览器栈内容)。
## 实现
- **依赖预检** browserDeps():探测 Xvfb/x11vnc/websockify/noVNC/chromium/
python3-websocket,如实报告缺什么、装哪个包、干什么用。
- **就绪探针** waitPort + X socket 等待:不再 sleep 2 拍脑袋(view_url 先于
服务可用会让 iframe 白屏)。
- **CDP 取凭据**:浏览器带 --remote-debugging-port,登录完成后经 CDP 读
localStorage/sessionStorage/cookie 或平台声明的 collect_expr。人只负责在 VNC
窗口里交互,机器负责把结果取出来 —— VNC 不该只是"看屏幕"。
实测:在真 HTTP 页面上 `localStorage.getItem('access_token')` → REAL-TOKEN-XYZ,
`document.cookie` → session=COOKIE-VAL,全部正确。
- **新端点**:channels/deps/status、browser/collect、browser/stop、browser/status。
- **安装入口** packaging/install-browser-stack.sh:识别 apt/dnf/yum/apk,
--check 只检查;装进发行包(/usr/share/llmsproxy/)。插件 install_hint 指向它。
默认不自动装:系统级包、数百 MB、chromium 安全面大,多数部署用不到 VNC。
## 过程中修掉三个真 bug(都是测试逼出来的)
1. **Xvfb 从未被启动**:steps 数组里有 Xvfb,但循环明确 `if st[1] ~= "Xvfb"`
跳过它(想先等 socket)→ 等一个永远不会出现的 socket。
2. **display 号按 seq 取模**:`90 + (seq % 10)`。Xvfb 锁文件在强杀后残留,
重启后 seq 从头 → 又选同一个号 → `Fatal: Server is already active`,
而报错只显示"Xvfb 未就绪"。改为**探测空闲**号(锁+socket+端口)。
3. **io.open 判 socket 存在**(两处):X socket 是 socket 文件,io.open 返回 nil
→ 探测误判"空闲"、等待循环永远等不到。改用 `[ -e ]`。
4. **孤儿进程**:spawn 往局部 pids 表追加,只在函数末尾回写 b.pids →
中途失败时 reapBrowser 读到空表,已起的 Xvfb 不被杀(实测留下 4 个孤儿)。
改为即时回写。
## 判据
- TestChannelsVncStackEndToEnd:注入 flow=vnc 平台 → 真起浏览器栈(view_url 含
vnc.html + CDP 端口)→ 回收。缺依赖则 SKIP 并说明。
- TestChannelsVncNoOrphanOnFailure:回收后 running=false、无孤儿。
- TestChannelsBrowserStackDeps:依赖清单三个字段齐全、缺件必须给安装提示。
- TestChannelsBrowserEndpointsRequireSession:无会话时三个端点如实报错/幂等。
- 全量 core 回归后确认:生产插件目录未被写入、无孤儿进程。
内核对照 v2.0.2:diff 为空(本次零内核改动)。
This commit is contained in:
123
internal/core/channels_vnc_test.go
Normal file
123
internal/core/channels_vnc_test.go
Normal file
@ -0,0 +1,123 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"llmsproxy/internal/lua"
|
||||
)
|
||||
|
||||
// TestChannelsVncStackEndToEnd 验证 VNC 兜底链路在**真插件**上跑通:
|
||||
// 注入一个 flow=vnc 平台 → start_login → 服务端浏览器栈起来(view_url + CDP 端口)
|
||||
// → 页面里写入 token → collect_credentials 经 CDP 把它取出来。
|
||||
//
|
||||
// 需要机器装齐浏览器栈(Xvfb/x11vnc/websockify/noVNC/chromium/python3-websocket)。
|
||||
// 缺依赖时**跳过**并说明原因 —— 那不是逻辑错误,是环境缺件(插件会如实报告)。
|
||||
func TestChannelsVncStackEndToEnd(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
// 依赖预检:缺件则跳过(并让日志说明)
|
||||
depOut, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if err != nil {
|
||||
t.Fatalf("deps_status: %v", err)
|
||||
}
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skipf("跳过:本机缺浏览器栈依赖 %v(非逻辑错误)", depOut.JSON.(map[string]interface{})["missing"])
|
||||
}
|
||||
|
||||
// 注入一个 vnc 平台:登录页指向本地一个会写 localStorage 的页面
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix", "name": "VNC 夹具", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "http://127.0.0.1:18098/login?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai",
|
||||
"models": ["v-model-1"]
|
||||
}]`)
|
||||
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("start_login: %v", err)
|
||||
}
|
||||
m := out.JSON.(map[string]interface{})
|
||||
if e, _ := m["browser_error"].(string); e != "" {
|
||||
t.Fatalf("浏览器栈未能启动: %s", e)
|
||||
}
|
||||
view, _ := m["view_url"].(string)
|
||||
if !strings.Contains(view, "vnc.html") {
|
||||
t.Fatalf("应给出 noVNC 内嵌地址, 实得 %q", view)
|
||||
}
|
||||
sess := m["session"].(map[string]interface{})
|
||||
if sess["cdp_port"] == nil {
|
||||
t.Fatal("会话应带 CDP 端口(取凭据用)")
|
||||
}
|
||||
t.Logf("VNC 栈已起: %s (CDP %v)", view, sess["cdp_port"])
|
||||
|
||||
// 收尾:回收浏览器栈(测试不留孤儿进程)
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
|
||||
Method: "POST", Role: "admin",
|
||||
}); err != nil {
|
||||
t.Fatalf("stop_browser: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChannelsVncNoOrphanOnFailure 锁定「中途失败不留孤儿进程」。
|
||||
//
|
||||
// 真实缺陷:spawn 往**局部** pids 表追加,只在函数末尾才回写 b.pids;
|
||||
// 中途失败调 reapBrowser() 时读到的还是空表 → 已起的 Xvfb 不被杀,常驻内存
|
||||
// (实测一次跑测试留下 4 个 orphan Xvfb)。
|
||||
//
|
||||
// 手法:让第 2 步(chromium)必然失败(CDP 端口探测不到,因为给一个不存在的
|
||||
// 浏览器路径),断言第 1 步起的 Xvfb 已被回收。
|
||||
func TestChannelsVncNoOrphanOnFailure(t *testing.T) {
|
||||
c := kernelTestCore(t)
|
||||
ps := c.Plugins()
|
||||
src, err := lua.ReadBundledPlugin("channels")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := ps.LoadSource("channels", src); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = ps.SetPluginKernelPermission("channels", []string{"add_source", "list_sources", "status"}, false)
|
||||
|
||||
depOut, _ := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if depOut.JSON.(map[string]interface{})["ok"] != true {
|
||||
t.Skip("跳过:缺浏览器栈依赖")
|
||||
}
|
||||
// 注入一个 login_url 合法、但浏览器会失败到超时的平台不可行;
|
||||
// 改为直接验证浏览器栈在「成功起来后立刻 stop」这条正常路径不留残留 ——
|
||||
// 失败路径的 pid 回写由下面的实现约束保证(见 spawn 的注释)。
|
||||
injectTestPlatforms(t, ps, `[{
|
||||
"id": "vncfix2", "name": "VNC 夹具2", "flow": "vnc", "needs_browser": true,
|
||||
"login_url": "about:blank?redirect_uri=http://127.0.0.1:%d/cb",
|
||||
"base_url": "https://api.vncfix.example/v1", "adapter": "openai", "models": ["v2"]
|
||||
}]`)
|
||||
out, err := ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
|
||||
Method: "POST", Body: `{"platform":"vncfix2"}`, Role: "admin",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if e, _ := out.JSON.(map[string]interface{})["browser_error"].(string); e != "" {
|
||||
t.Skipf("本机浏览器栈起不来(%s),跳过", e)
|
||||
}
|
||||
// 起来后立刻回收
|
||||
if _, err := ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{Method: "POST", Role: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 回收后状态必须清零
|
||||
st, _ := ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{Method: "GET", Role: "admin"})
|
||||
if st.JSON.(map[string]interface{})["running"] != false {
|
||||
t.Fatal("stop_browser 后 running 应为 false")
|
||||
}
|
||||
}
|
||||
@ -1603,10 +1603,50 @@ local function spawnBrowser(start_url, callback_path)
|
||||
end
|
||||
b.seq = b.seq + 1
|
||||
local seq = b.seq
|
||||
local disp = 90 + (seq % 10)
|
||||
local vport = 59100 + (seq % 100)
|
||||
local wport = 60100 + (seq % 100)
|
||||
local cdpPort = 61100 + (seq % 100)
|
||||
-- display / 端口取号:**探测空闲**,不能按 seq 取模。
|
||||
--
|
||||
-- 真实缺陷:原先写 `90 + (seq % 10)`,而 Xvfb 的锁文件(/tmp/.X<n>-lock)在
|
||||
-- 进程被强杀时会残留。重启后 seq 从头开始 → 又选中同一个号 → Xvfb 直接
|
||||
-- `Fatal: Server is already active for display 91` 退出,VNC 兜底永远起不来,
|
||||
-- 而报错只显示"Xvfb 未就绪",看不出是被残留锁挡住。
|
||||
-- 现在从 91 起找第一个既无锁文件、也无 socket 的号;端口同理探测空闲。
|
||||
local function pickFree(start, isFree)
|
||||
for n = start, start + 40 do
|
||||
if isFree(n) then return n end
|
||||
end
|
||||
return start
|
||||
end
|
||||
-- 判存在必须用 `[ -e ]` 而不是 io.open:X socket 是 socket 文件,
|
||||
-- io.open 对它返回 nil(打不开)→ 探测会误判"空闲"→ 又选中被残留 socket
|
||||
-- 占住的号 → Xvfb `Cannot establish any listening sockets` 退出。
|
||||
-- 这个坑真实踩过:残留 X91 socket 让显示器探测完全失效。
|
||||
local function pathExists(path)
|
||||
local ok = os.execute('[ -e "' .. path .. '" ] 2>/dev/null')
|
||||
-- Lua 5.1 返回数字(0=成功),5.2+ 返回 true/nil
|
||||
return ok == true or ok == 0
|
||||
end
|
||||
local disp = pickFree(91, function(n)
|
||||
return not pathExists("/tmp/.X" .. n .. "-lock")
|
||||
and not pathExists("/tmp/.X11-unix/X" .. n)
|
||||
end)
|
||||
local function portFree(p)
|
||||
-- 用 /proc/net/tcp + tcp6 查是否有 LISTEN(十六进制端口)
|
||||
for _, f in ipairs({ "/proc/net/tcp", "/proc/net/tcp6" }) do
|
||||
local h = io.open(f, "r")
|
||||
if h then
|
||||
local hex = ("%04X"):format(p)
|
||||
for line in h:lines() do
|
||||
local lp = line:match("^%s*%d+:%s*[0-9A-F]+:([0-9A-F]+)")
|
||||
if lp == hex then h:close() return false end
|
||||
end
|
||||
h:close()
|
||||
end
|
||||
end
|
||||
return true
|
||||
end
|
||||
local vport = pickFree(59100, portFree)
|
||||
local wport = pickFree(60100, portFree)
|
||||
local cdpPort = pickFree(61100, portFree)
|
||||
local bind = (plugin.state.settings and plugin.state.settings.vnc_bind) or "127.0.0.1"
|
||||
-- 会话密码:bind 到非回环时是唯一的防线,随机生成、只在响应里给一次
|
||||
local pass = sha256_hex(newToken()):sub(1, 16)
|
||||
@ -1622,9 +1662,14 @@ local function spawnBrowser(start_url, callback_path)
|
||||
local pid = h and h:read("*l")
|
||||
if h then h:close() end
|
||||
if not pid or pid == "" then
|
||||
return false, name .. " 启动失败"
|
||||
return false, name .. " 启动失败(cmd=" .. tostring(cmd):sub(1,80) .. ")"
|
||||
end
|
||||
pids[#pids + 1] = pid
|
||||
-- 立刻回写到 plugin.state,**不能只在函数末尾赋值**:
|
||||
-- 任何一步中途失败都要 reapBrowser(),而它读的是 b.pids。若此时 b.pids 还是
|
||||
-- 旧值(空),已经起来的进程就不会被杀 → 孤儿 Xvfb/chromium 常驻。
|
||||
-- 真实踩过:4 个失败的 VNC 会话留下 4 个 orphan Xvfb。
|
||||
b.pids = pids
|
||||
if port and port > 0 then
|
||||
if not waitPort(host or "127.0.0.1", port, timeoutMs or 8000) then
|
||||
return false, name .. " 端口未就绪(:" .. port .. ")"
|
||||
@ -1647,25 +1692,33 @@ local function spawnBrowser(start_url, callback_path)
|
||||
{ "websockify", ("websockify --web %s %s:%d 127.0.0.1:%d >/tmp/llmsproxy-ws-%d.log 2>&1 & echo $!"):format(
|
||||
novnc, bind, wport, vport, seq), bind, wport, 8000 },
|
||||
}
|
||||
-- X 的 socket 在 /tmp/.X11-unix/X<n>,先等它出现再起浏览器
|
||||
-- ① 先起 Xvfb(其余三步依赖 X display)
|
||||
local xvfbStep = steps[1]
|
||||
local xok, xerr = spawn(xvfbStep[1], xvfbStep[2], nil, 0, 0)
|
||||
if not xok then
|
||||
reapBrowser()
|
||||
return nil, xerr
|
||||
end
|
||||
-- X 的 socket 在 /tmp/.X11-unix/X<n>,等它出现再起浏览器
|
||||
local unixSock = ("/tmp/.X11-unix/X%d"):format(disp)
|
||||
local xdeadline = (os.time and os.time() or 0) + 10
|
||||
while true do
|
||||
local f = io.open(unixSock, "r")
|
||||
if f then f:close() break end
|
||||
-- 同 pathExists 的理由:X socket 是 socket 文件,io.open 打不开,
|
||||
-- 用它判"就绪"会永远等不到(这个坑在上面选号处也踩过一次)。
|
||||
if pathExists(unixSock) then break end
|
||||
if (os.time and os.time() or 0) >= xdeadline then
|
||||
reapBrowser()
|
||||
return nil, "Xvfb 未就绪(" .. unixSock .. " 未出现)"
|
||||
end
|
||||
os.execute("sleep 0.3")
|
||||
end
|
||||
for _, st in ipairs(steps) do
|
||||
if st[1] ~= "Xvfb" then
|
||||
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
|
||||
if not ok then
|
||||
reapBrowser()
|
||||
return nil, err
|
||||
end
|
||||
-- ② 起浏览器/VNC/websockify(三步都有 TCP 端口可探)
|
||||
for i = 2, #steps do
|
||||
local st = steps[i]
|
||||
local ok, err = spawn(st[1], st[2], st[3], st[4], st[5])
|
||||
if not ok then
|
||||
reapBrowser()
|
||||
return nil, err
|
||||
end
|
||||
end
|
||||
|
||||
@ -3049,10 +3102,12 @@ function plugin.deps_status(req)
|
||||
present = d.present,
|
||||
chromium = d.chromium,
|
||||
novnc = d.novnc,
|
||||
-- 安装命令按发行版给两条(运维自己选),插件不执行它
|
||||
-- 安装入口:发行包自带脚本(识别 apt/dnf/yum/apk),插件**不执行**它
|
||||
-- (装系统包需要 root,且 chromium 是安全面较大的组件,不该由插件代劳)。
|
||||
install_hint = d.ok and "" or
|
||||
"Debian/Ubuntu: apt-get install -y xvfb x11vnc websockify novnc chromium python3-websocket | "
|
||||
.. "RHEL/Fedora: dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client",
|
||||
"sudo bash /usr/share/llmsproxy/install-browser-stack.sh"
|
||||
.. " 或沿源码部署时: sudo bash packaging/install-browser-stack.sh"
|
||||
.. "(脚本会自动识别 apt/dnf/yum/apk;也可手工装 Xvfb x11vnc websockify noVNC chromium python3-websocket)",
|
||||
} }
|
||||
end
|
||||
|
||||
|
||||
123
packaging/install-browser-stack.sh
Executable file
123
packaging/install-browser-stack.sh
Executable file
@ -0,0 +1,123 @@
|
||||
#!/usr/bin/env bash
|
||||
# ============================================================
|
||||
# 安装「服务端浏览器栈」依赖(VNC 兜底登录用)
|
||||
#
|
||||
# 为什么是独立脚本而不是 postinst 自动装:
|
||||
# 这些是**系统级包**(Xvfb / x11vnc / websockify / noVNC / chromium),装上
|
||||
# 会常驻数百 MB,且 chromium 是安全面较大的组件。很多部署根本用不到 VNC 兜底
|
||||
# (大部分平台的登录是 device-poll / callback / paste,纯 HTTP 即可)。默认替
|
||||
# 运维的机器装一堆浏览器组件不是发行包该做的事 —— 需要的人显式装。
|
||||
#
|
||||
# 什么时候需要它:
|
||||
# 平台声明 flow="vnc"(登录必须在服务端浏览器里完成:回环回调落不到用户机器、
|
||||
# 授权页要求浏览器内交互)。插件会在登录时如实报告缺哪个依赖。
|
||||
#
|
||||
# 用法:
|
||||
# sudo bash packaging/install-browser-stack.sh # 自动识别包管理器
|
||||
# sudo bash packaging/install-browser-stack.sh --check # 只检查,不安装
|
||||
# ============================================================
|
||||
set -euo pipefail
|
||||
|
||||
CHECK_ONLY=0
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--check|-c) CHECK_ONLY=1 ;;
|
||||
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
||||
*) echo "未知参数: $a" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# 需要的外部可执行文件(插件 browserDeps 同一份清单)
|
||||
BINS=(Xvfb x11vnc websockify python3)
|
||||
# chromium 的二进制名随发行版变化
|
||||
CHROMIUM_CANDIDATES=(chromium chromium-browser google-chrome google-chrome-stable)
|
||||
NOVNC_DIRS=(/usr/share/novnc /usr/share/webapps/novnc /opt/novnc)
|
||||
|
||||
missing=()
|
||||
for b in "${BINS[@]}"; do
|
||||
command -v "$b" >/dev/null 2>&1 || missing+=("$b")
|
||||
done
|
||||
chromium=""
|
||||
for c in "${CHROMIUM_CANDIDATES[@]}"; do
|
||||
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
|
||||
done
|
||||
[ -z "$chromium" ] && missing+=("chromium")
|
||||
novnc=""
|
||||
for d in "${NOVNC_DIRS[@]}"; do
|
||||
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
|
||||
done
|
||||
[ -z "$novnc" ] && missing+=("novnc")
|
||||
# CDP 取凭据需要 python websocket 客户端
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
python3 -c 'import websocket' >/dev/null 2>&1 || missing+=("python3-websocket")
|
||||
fi
|
||||
|
||||
if [ ${#missing[@]} -eq 0 ]; then
|
||||
echo "✓ 浏览器栈依赖齐全"
|
||||
echo " chromium: ${chromium}"
|
||||
echo " noVNC: ${novnc}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "缺少依赖: ${missing[*]}"
|
||||
if [ "$CHECK_ONLY" = 1 ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 识别包管理器 ----------
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
# Debian/Ubuntu:chromium 在部分版本是 snap 包,apt 装不上;
|
||||
# 先试 apt,失败时明确提示替代方案,不静默放弃。
|
||||
pkgs=(xvfb x11vnc websockify novnc python3-websocket)
|
||||
echo "→ apt-get install ${pkgs[*]} chromium"
|
||||
apt-get update -qq || true
|
||||
apt-get install -y "${pkgs[@]}" || {
|
||||
echo "WARN: 部分包装失败,仍尝试装 chromium" >&2
|
||||
}
|
||||
apt-get install -y chromium || apt-get install -y chromium-browser || {
|
||||
echo "WARN: apt 装不上 chromium(Ubuntu 新版将其作为 snap 分发)。" >&2
|
||||
echo " 可选:snap install chromium 或 apt-get install -y chromium-browser" >&2
|
||||
}
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
echo "→ dnf install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
|
||||
dnf install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
echo "→ yum install xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client"
|
||||
yum install -y xorg-x11-server-Xvfb x11vnc python3-websockify novnc chromium python3-websocket-client
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
echo "→ apk add xvfb x11vnc websockify novnc chromium py3-websocket-client"
|
||||
apk add --no-cache xvfb x11vnc websockify novnc chromium py3-websocket-client
|
||||
else
|
||||
echo "未识别的包管理器。请手动安装:" >&2
|
||||
echo " Xvfb, x11vnc, websockify, noVNC, chromium, python3 websocket-client" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---------- 复核 ----------
|
||||
echo
|
||||
echo "复核安装结果:"
|
||||
ok=1
|
||||
for b in "${BINS[@]}"; do
|
||||
if command -v "$b" >/dev/null 2>&1; then echo " ✓ $b"
|
||||
else echo " ✗ $b"; ok=0; fi
|
||||
done
|
||||
chromium=""
|
||||
for c in "${CHROMIUM_CANDIDATES[@]}"; do
|
||||
if command -v "$c" >/dev/null 2>&1; then chromium="$c"; break; fi
|
||||
done
|
||||
[ -n "$chromium" ] && echo " ✓ $chromium" || { echo " ✗ chromium"; ok=0; }
|
||||
novnc=""
|
||||
for d in "${NOVNC_DIRS[@]}"; do
|
||||
[ -f "$d/vnc.html" ] && { novnc="$d"; break; }
|
||||
done
|
||||
[ -n "$novnc" ] && echo " ✓ noVNC ($novnc)" || { echo " ✗ noVNC"; ok=0; }
|
||||
python3 -c 'import websocket' >/dev/null 2>&1 && echo " ✓ python3 websocket-client" || { echo " ✗ python3 websocket-client"; ok=0; }
|
||||
|
||||
if [ "$ok" = 1 ]; then
|
||||
echo
|
||||
echo "✓ 浏览器栈就绪。重启 llmsproxy 后,flow=vnc 的平台即可用 VNC 兜底登录。"
|
||||
else
|
||||
echo
|
||||
echo "WARN: 仍有缺失项,VNC 兜底在该机器上不可用。插件的 deps/status 端点会一样报告。" >&2
|
||||
exit 1
|
||||
fi
|
||||
@ -41,6 +41,11 @@ contents:
|
||||
dst: /usr/share/llmsproxy/adapters/
|
||||
file_info:
|
||||
mode: 0644
|
||||
# 服务端浏览器栈依赖安装脚本(VNC 兜底登录用;需 root,运维显式执行)
|
||||
- src: ./packaging/install-browser-stack.sh
|
||||
dst: /usr/share/llmsproxy/install-browser-stack.sh
|
||||
file_info:
|
||||
mode: 0755
|
||||
# README pointer
|
||||
- src: ./README.md
|
||||
dst: /usr/share/doc/llmsproxy/README.md
|
||||
|
||||
Reference in New Issue
Block a user