diff --git a/internal/core/channels_vnc_gpu_test.go b/internal/core/channels_vnc_gpu_test.go new file mode 100644 index 0000000..84e05af --- /dev/null +++ b/internal/core/channels_vnc_gpu_test.go @@ -0,0 +1,59 @@ +package core + +import ( + "strings" + "testing" +) + +// VNC 一直白屏的真因(2026-10-14 定位)。 +// +// 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium +// **独立 GPU 进程**启动所需的系统调用 ⇒ 它起不来 ⇒ chromium 放弃并退出: +// +// ERROR:gpu_process_host.cc] GPU process launch failed: error_code=1002 +// FATAL:gpu_data_manager_impl_private.cc] GPU process isn't usable. Goodbye. +// +// 整个 chromium 几秒内退出 ⇒ **VNC 上永远白屏**,而插件因为只等 CDP 端口就报 +// 「已就绪」,把白屏谎报成成功。 +// +// 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功。定位靠 +// `systemd-run` 逐 directive 二分 —— 只有 SystemCallFilter 与 ProtectSystem +// 能复现,其余加固项无影响。 +// +// 修法:`--in-process-gpu`(GPU 逻辑跑在浏览器进程内,不 fork)。 +func TestBrowserStackUsesInProcessGPU(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + // ① 必须带 --in-process-gpu,否则在加固环境下浏览器起不来 + if !strings.Contains(b, "--in-process-gpu") { + t.Fatal("chromium 命令行必须带 --in-process-gpu —— 否则在 " + + "SystemCallFilter=@system-service 的加固环境下 GPU 进程起不来、" + + "浏览器自杀、VNC 永远白屏(真实事故,2026-10-14)") + } + // ② 必须给可写的 HOME/XDG:ProtectHome=yes 下 /root 只读, + // chromium 建 dconf/ibus 目录会持续刷 permission denied 噪声 + for _, k := range []string{"XDG_CACHE_HOME=", "XDG_CONFIG_HOME="} { + if !strings.Contains(b, k) { + t.Errorf("chromium 命令行应设置 %s(加固环境下 /root 只读会产生噪声日志)", k) + } + } + // ③ **存活探针**必须存在:端口在听 ≠ 浏览器可用。 + // 没有它时,"浏览器已死"会被谎报成"已就绪",运维打开 VNC 是白屏, + // 还会以为是自己操作的问题。谎报比直接报错更坏。 + if !strings.Contains(b, "browserUtil.cdpAlive") { + t.Error("起栈后必须做 CDP 存活探针 —— 只等端口就报就绪会把白屏谎报成成功") + } + if !strings.Contains(b, "browserUtil.readTail") { + t.Error("启动失败必须把日志真因带给用户,而不是一句「启动失败」") + } + // ④ 失败必须回收整组进程(否则每次失败都留一套 Xvfb/x11vnc/websockify) + idx := strings.Index(b, "浏览器启动后即退出") + if idx < 0 { + t.Fatal("应有「浏览器启动后即退出」的失败分支") + } + if !strings.Contains(b[idx:idx+600], "reapBrowser()") { + t.Error("起栈失败必须先回收已起的进程(否则泄漏一套 Xvfb/x11vnc/websockify)") + } +} diff --git a/internal/core/channels_vnc_lifecycle_test.go b/internal/core/channels_vnc_lifecycle_test.go new file mode 100644 index 0000000..82dcd5c --- /dev/null +++ b/internal/core/channels_vnc_lifecycle_test.go @@ -0,0 +1,70 @@ +package core + +import ( + "strings" + "testing" +) + +// VNC 会话生命周期的**前向引用**判据(真实 bug 的守卫)。 +// +// `sweepSessions` 里调用了 reapLoopback / browserStack / reapBrowser,而这三个 +// 助手定义在**更后面**。Lua 的 `local` 只从定义处向后可见 ⇒ 未前向声明时它们是 +// **全局 nil**:加载期不报错,只有真正走到"会话过期回收"那条分支才炸: +// +// attempt to call global 'reapLoopback' (a nil value) +// +// 症状极具迷惑性:**第一次登录完全正常**(没有过期会话可回收),第二次才 500, +// 且已经起来的浏览器栈**不会被回收**(reapBrowser 同样调不到)⇒ 进程与端口泄漏。 +// 本文件此前已因同一个坑踩过 12 次,故这里钉住"三者都必须有前向声明"。 +func TestSweepSessionsHelpersAreForwardDeclared(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + // ① 必须存在对这三个助手的前向声明 + if !strings.Contains(b, "local reapLoopback, browserStack, reapBrowser") { + t.Fatal("sweepSessions 用到的三个助手必须**前向声明** —— 否则 " + + "`local` 只从定义处向后可见,运行到该分支时是 nil 调用(第一次登录" + + "正常、第二次 500,且浏览器栈不会被回收)") + } + // ② 定义处必须是**赋值**形态(绑定到那个 local),不能又是 `local function` + // —— 那会遮蔽前向声明,效果与没声明完全相同。 + for _, fn := range []string{"browserStack", "reapBrowser", "reapLoopback"} { + if strings.Contains(b, "local function "+fn+"(") { + t.Errorf("%s 的定必须是 `%s = function(...)` 形式(绑定前向声明);"+ + "再用 `local function` 会另起一个 local,前向声明形同虚设", fn, fn) + } + if !strings.Contains(b, "\n"+fn+" = function(") { + t.Errorf("找不到 %s 的赋值式定义", fn) + } + } + // ③ 声明必须**先于**使用(行号比较):这是前向引用的核心 + decl := strings.Index(b, "local reapLoopback, browserStack, reapBrowser") + use := strings.Index(b, "\n reapLoopback(id)\n") + if decl < 0 || use < 0 { + t.Fatalf("找不到声明(%d)或使用(%d)", decl, use) + } + if decl > use { + t.Error("前向声明必须在**使用之前** —— 否则 nil 调用") + } +} + +// 浏览器栈的**启动后存活**必须有真实判据(谎报"就绪"比直接报错更坏)。 +func TestBrowserStackVerifiesLivenessNotJustPort(t *testing.T) { + b, err := readBundledChannels(t) + if err != nil { + t.Fatal(err) + } + // CDP 真应答(端口在听 ≠ 服务可用) + if !strings.Contains(b, "browserUtil.cdpAlive") { + t.Error("必须用 CDP 真应答判活,不能只看端口") + } + // 进程真存活 + if !strings.Contains(b, "/proc/") { + t.Error("必须检查 chromium 进程仍存活(/proc/)") + } + // 失败要把日志真因带出来(而不是一句"启动失败") + if !strings.Contains(b, "browserUtil.readTail") { + t.Error("启动失败必须带出日志真因") + } +} diff --git a/internal/core/channels_xdisplay_test.go b/internal/core/channels_xdisplay_test.go index 4c3125a..6eac7ef 100644 --- a/internal/core/channels_xdisplay_test.go +++ b/internal/core/channels_xdisplay_test.go @@ -27,7 +27,19 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) { const ghost = "999999" // 必然不存在的 PID // 造 3 个陈旧锁,并要求插件**复用**其中一个。 // 只跳过不复用的话,号段会被逐个烧掉 —— 实测 41/41 占满后每次必失败。 - ghosts := []int{91, 92, 93} + // + // ⚠️ 号段必须**跟着测试台的 XDISPLAY_BASE 走**(不是硬编码 91/92/93)。 + // 原因:生产 unit 带 PrivateTmp,它起的 Xvfb :91 的锁在服务自己的 /tmp 里, + // 宿主测试看不到 ⇒ 测试会去抢 91 并失败。测试台因此把起点挪到 300 + // (见 kernelTestCore);硬编码 90 段会让本判据造出**根本不会被扫描到**的 + // 陈旧锁,于是"没复用"这个断言永远失败 —— 那是判据与被测对象脱节。 + base := 300 + if v := os.Getenv("LLMSPROXY_XDISPLAY_BASE"); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + base = n + } + } + ghosts := []int{base, base + 1, base + 2} for _, n := range ghosts { l := "/tmp/.X" + strconv.Itoa(n) + "-lock" if _, err := os.Stat(l); err == nil { @@ -38,8 +50,8 @@ func TestXDisplaySelectionReclaimsStaleLocks(t *testing.T) { } defer os.Remove(l) } - lock := "/tmp/.X91-lock" - sock := "/tmp/.X11-unix/X91" + lock := "/tmp/.X" + strconv.Itoa(base) + "-lock" + sock := "/tmp/.X11-unix/X" + strconv.Itoa(base) c := kernelTestCore(t) ps := c.Plugins() diff --git a/internal/core/kernel_actions_test.go b/internal/core/kernel_actions_test.go index 8224d3e..baff645 100644 --- a/internal/core/kernel_actions_test.go +++ b/internal/core/kernel_actions_test.go @@ -29,6 +29,16 @@ func kernelTestCore(t *testing.T) *Core { if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir) } + // ★ 显示器号与端口用**高段号**,避开生产。 + // + // 真实事故(2026-10-15):生产 unit 带 `PrivateTmp=yes`,它起的 Xvfb :91 的 + // 锁文件在**服务自己的 /tmp** 里 —— 宿主上的测试看不到它,于是判"91 空闲" + // 并去抢,结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`,而生产上正跑着 + // 一套浏览器栈。判据本身没错,错的是**两个 /tmp 互不可见**而号段重叠。 + // 故测试整体挪到 300+。 + if os.Getenv("LLMSPROXY_XDISPLAY_BASE") == "" { + t.Setenv("LLMSPROXY_XDISPLAY_BASE", "300") + } cfg := &config.Config{ Path: filepath.Join(dir, "config.yaml"), AdapterDir: filepath.Join(dir, "adapters"), diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 5703084..6114998 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -1219,14 +1219,21 @@ end -- code_challenge_method = **SHA-256**(不是 S256!portal 以此识别 OAuth, -- 错值会回退到旧 ticket 流程) -- 旧流程回退(ticket):CODEARTS_LOGIN_BASE + /snap-manager/v1/login/ticket -local CODEARTS_PORTAL_AUTHORIZE = "https://codearts.huaweicloud.com/portal/authorize" -local CODEARTS_STS_TOKEN = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens" -local CODEARTS_CLIENT_ID = "codearts-agent" -local CODEARTS_REDIRECT_PATH = "/oauth/callback" -local CODEARTS_LOGIN_PLUGIN_NAME = "snap_AIIDE" -local CODEARTS_LOGIN_PLUGIN_VERSION = "5.2.0" -local CODEARTS_OAUTH_THEME = "2" -local CODEARTS_OAUTH_LOCALE = "zh-cn" +-- ⚠️ 常量收进**表**(`plugin.consts`)而不是顶层 `local`。 +-- 原因很实际:本文件顶层 local 已**顶到 Lua 单函数 200 个上限**,多一个就让 +-- 整个插件**加载失败**(`too many local variables`);报错在编译期, +-- 运行期表现是"插件所有路由变成 404",毫无指向性。2026-10-14 实测踩到。 +-- 往本文件加顶层 local 前,务必先 `luac -p` 验证。 +local CODEARTS = { + portal_authorize = "https://codearts.huaweicloud.com/portal/authorize", + sts_token = "https://sts.cn-north-4.myhuaweicloud.com/v1/oauth2/tokens", + client_id = "codearts-agent", + redirect_path = "/oauth/callback", + login_plugin_name = "snap_AIIDE", + login_plugin_version = "5.2.0", + oauth_theme = "2", + oauth_locale = "zh-cn", +} -- ES256 JWS 签名(照参考 signDpopJws): -- header = {alg:"ES256", typ:"dpop+jwt", jwk:{kty,crv,x,y}} @@ -1370,14 +1377,14 @@ end -- `ExpiredRefreshToken`;**不认 InvalidDPoPHeader**(那是一次请求层面的拒绝, -- 时钟偏差/重放/抖动都会触发,当终态会把完好账号一步标死)。 local function codeartsRequestToken(body, key) - local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS_STS_TOKEN) + local dpop, derr = codeartsDpopSign(key, "POST", CODEARTS.sts_token) if not dpop then return nil, derr end local parts = {} for k, v in pairs(body) do parts[#parts + 1] = rfc3986Escape(k) .. "=" .. rfc3986Escape(v) end local resp, err = __llmsproxy_http({ - url = CODEARTS_STS_TOKEN, method = "POST", + url = CODEARTS.sts_token, method = "POST", headers = { ["DPoP"] = dpop, ["Content-Type"] = "application/x-www-form-urlencoded" }, body = table.concat(parts, "&"), }) @@ -1419,17 +1426,17 @@ end -- 多余的回调地址会被视为异常并回退旧流程。 -- 3. 端口必须 ≥10000。 local function buildCodeartsAuthorizeUrl(port, pkce, ticketId) - return CODEARTS_PORTAL_AUTHORIZE - .. "?theme=" .. CODEARTS_OAUTH_THEME - .. "&locale=" .. CODEARTS_OAUTH_LOCALE - .. "&uri_scheme=" .. CODEARTS_CLIENT_ID - .. "&client_id=" .. CODEARTS_CLIENT_ID + return CODEARTS.portal_authorize + .. "?theme=" .. CODEARTS.oauth_theme + .. "&locale=" .. CODEARTS.oauth_locale + .. "&uri_scheme=" .. CODEARTS.client_id + .. "&client_id=" .. CODEARTS.client_id .. "&port=" .. tostring(port) .. "&code_challenge=" .. pkce.challenge .. "&code_challenge_method=SHA-256" .. "&ticket_id=" .. rfc3986Escape(ticketId) - .. "&plugin-name=" .. CODEARTS_LOGIN_PLUGIN_NAME - .. "&plugin-version=" .. CODEARTS_LOGIN_PLUGIN_VERSION + .. "&plugin-name=" .. CODEARTS.login_plugin_name + .. "&plugin-version=" .. CODEARTS.login_plugin_version end -- codeartsTicketPoll(ticketId, secret):旧流程回退时轮询 ticket 换凭据。 @@ -3314,6 +3321,68 @@ local function sessions() return plugin.state.sessions end +-- browserUtil:浏览器栈的**小助手**。挂在**表**上而不是 `local function` —— +-- 本文件顶层 local 顶到 Lua 的 200 个上限,多一个就让整个插件加载失败 +-- (见 CODEARTS 常量处那段说明)。 +local browserUtil = {} + +-- browserUtil.pathExists(p):文件/目录是否存在。 +-- ⚠️ 不能用 io.open 判"存在":X socket 是 **socket 文件**,io.open 打不开它, +-- 拿它当判据会永远等不到(本文件在选显示器号处也踩过同一个坑)。 +function browserUtil.pathExists(p) + local f = io.open(p, "rb") + if f then f:close() return true end + -- io.open 失败时退到 stat(socket/设备文件也能判到) + local h = io.popen(("test -e %s && echo y"):format("'" .. tostring(p) .. "'")) + local r = h and h:read("*l") + if h then h:close() end + return r == "y" +end + +-- browserUtil.readTail(path, max):读日志尾部,把启动失败的**真因**带给用户 +-- (而不是一句"启动失败"让运维去猜)。 +function browserUtil.readTail(path, max) + local f = io.open(path, "r") + if not f then return "(无日志)" end + local txt = f:read("*a") or "" + f:close() + local keep = {} + for line in txt:gmatch("[^\n]+") do + if not line:find("dbus", 1, true) and not line:find("crashpad", 1, true) + and not line:find("NameHasOwner", 1, true) and not line:find("UPower", 1, true) then + keep[#keep + 1] = line + end + end + local joined = table.concat(keep, " | ") + local lim = max or 400 + if #joined > lim then joined = joined:sub(-lim) end + return joined +end + +-- browserUtil.cdpAlive(port):CDP HTTP 端点是否**真的应答**(端口在听 ≠ 服务可用)。 +function browserUtil.cdpAlive(port) + for _ = 1, 10 do + local h = io.popen(("curl -s --max-time 2 http://127.0.0.1:%d/json/version 2>/dev/null"):format(port)) + local out = h and h:read("*a") or "" + if h then h:close() end + if out and out:find("webSocketDebuggerUrl", 1, true) then return true end + os.execute("sleep 0.5") + end + return false +end + +-- ⚠️ **前向声明**(Lua 的 local 只从定义处向后可见)。 +-- +-- 下面这三个助手定义在**更后面**(本文件按"会话 → 浏览器栈 → loopback 接收器" +-- 的阅读顺序组织),但 sweepSessions 要用它们。不声明的话是: +-- `attempt to call global 'reapLoopback' (a nil value)` +-- **加载时不报错**,只有真正走到那条分支才炸 —— 所以第一次登录正常, +-- 第二次(有会话过期需回收时)才 500。这是本文件第 12 次踩前向引用。 +-- +-- 实测后果:重复发起登录直接 500,且**已经起来的浏览器栈不被回收** +-- (reapBrowser 也调不到),进程与端口持续泄漏。 +local reapLoopback, browserStack, reapBrowser + -- sweepSessions 惰性清理过期/终态会话(无后台定时器,读取时顺带做) local function sweepSessions() local now = os.time and os.time() or 0 @@ -3350,7 +3419,7 @@ end -- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token, -- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。 -local function browserStack() +browserStack = function() if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end return plugin.state.browser end @@ -3418,7 +3487,7 @@ local function killProcsMatching(needle) return #victims end -local function reapBrowser() +reapBrowser = function() local b = browserStack() local sess = b.session local disp = b.display or (sess and sess.display) @@ -3469,6 +3538,9 @@ local function reapBrowser() if b.profile then os.execute("rm -rf " .. b.profile .. " 2>/dev/null") end b.profile = nil + if b.home then + os.execute("rm -rf " .. b.home .. " 2>/dev/null") + end b.home = nil b.display = nil b.pids = {} b.session = nil @@ -3516,6 +3588,23 @@ end -- ② 扫不通时**如实报错**,不再 `return start` —— 返回一个已知被占的号只会 -- 让 Xvfb 报一句难以归因的 “already active for display N”。 local function pickFreeX(start, span) + -- 起始号可覆盖:`LLMSPROXY_XDISPLAY_BASE` env 或 settings.xdisplay_base。 + -- + -- ⚠️ 为什么需要它(真实事故 2026-10-15):生产 unit 带 `PrivateTmp=yes`, + -- 它起的 Xvfb :91 的锁文件(/tmp/.X91-lock)在**服务自己的 /tmp** 里 —— + -- 宿主上的测试**看不到**它,于是 xdisplayBusy(91) 判"空闲"、测试也去抢 91, + -- 结果 `Xvfb 未就绪(/tmp/.X11-unix/X91 未出现)`。测试与生产在同一个 + -- 显示器号上互相踩(而 pickFreeX 的判据本身是对的,它只是看不到另一个 /tmp)。 + -- + -- 故测试用高段号(如 300+)与生产错开;生产留空即用默认 91。 + local base = nil + local env = os.getenv and os.getenv("LLMSPROXY_XDISPLAY_BASE") + if type(env) == "string" and env ~= "" then base = tonumber(env) end + if not base then + local st = plugin.state and plugin.state.settings + if type(st) == "table" then base = tonumber(st.xdisplay_base) end + end + if base then start = base end for n = start, start + span do if not xdisplayBusy(n) then return n end end @@ -3880,7 +3969,7 @@ end -- reapLoopback 杀掉某会话的接收器进程并清文件(无需常驻,用完即收)。 -- 必须定义在 loopbackReceiver **之前**:Lua 的 local 作用域只覆盖其后的代码, -- 反过来写会让 loopbackReceiver 里的调用拿到 nil(真实踩过的坑)。 -local function reapLoopback(sessionId) +reapLoopback = function(sessionId) local l = plugin.state.loopbacks and plugin.state.loopbacks[sessionId] if not l then return end if l.pid and l.pid ~= "" then @@ -4036,6 +4125,11 @@ local function spawnBrowser(start_url) end return true end + -- 端口**不需要**覆盖点:端口是全局资源,`portFree` 直接查 /proc/net/tcp, + -- 所以它能看见生产占的 59100/60100/61100(这与显示器号不同 —— 那个靠 + -- /tmp/.X-lock,而生产 PrivateTmp 下的锁宿主看不到)。 + -- ⚠️ 我一度在这里加过 port_base 偏移,结果把端口推到 66xxx 反而起不来 —— + -- 端口不是"号段",偏移没有意义。 local vport = pickFree(59100, portFree) local wport = pickFree(60100, portFree) local cdpPort = pickFree(61100, portFree) @@ -4078,19 +4172,49 @@ local function spawnBrowser(start_url) -- 留下的 SingletonLock 会让它直接退出(见 tag 的注释)。这里不只是换新目录, -- 还把可能残留的单例锁与陈旧 profile 清掉,双保险。 local profileDir = "/tmp/llmsproxy-ch-" .. tag - os.execute("rm -rf " .. profileDir .. " 2>/dev/null") + -- ⚠️⚠️ 真实事故(2026-10-14,VNC 一直白屏的真因)。 + -- + -- 生产 unit 加固过,其中 `SystemCallFilter=@system-service` 会挡住 chromium + -- **独立 GPU 进程**启动所需的系统调用。它起不来 → chromium 直接放弃: + -- ERROR:content/browser/gpu/gpu_process_host.cc:1124] + -- GPU process launch failed: error_code=1002 + -- FATAL:content/browser/gpu/gpu_data_manager_impl_private.cc:417] + -- GPU process isn't usable. Goodbye. + -- 整个 chromium 在几秒内退出 ⇒ **VNC 上永远白屏**。 + -- + -- 为什么长期没发现:手工在**沙箱外**跑同一条命令每次都成功(我为此绕了很久, + -- 先后怀疑过 profile 锁、/tmp 满、PrivateTmp、可写 HOME —— 全是错的)。 + -- 定位靠的是**用 systemd-run 逐 directive 二分**:只有 + -- `SystemCallFilter=@system-service` 与 `ProtectSystem=strict` 能让它死, + -- 其余加固项均无影响。 + -- + -- 修法:`--in-process-gpu` 让 GPU 逻辑跑在浏览器进程内(不 fork 独立进程), + -- 于是不再触碰被 seccomp 挡掉的那条路径。**实测在完整生产沙箱下 + -- ALIVE=1 / 窗口=4 / 无 FATAL**。修在插件层,unit 一行不动 —— + -- 加固不该为了一个浏览器栈而放松。 + -- + -- 顺带保留可写的 HOME/XDG(下面几行):生产 `ProtectHome=yes` 下 /root 只读, + -- chromium 建 dconf/ibus 目录会刷一堆 permission denied。它**不是**这次白屏的 + -- 原因(我起初误判为真因),但不给可写目录会持续产生噪声日志。 + local homeDir = "/tmp/llmsproxy-bhome-" .. tag + os.execute("rm -rf " .. profileDir .. " " .. homeDir .. " 2>/dev/null") + os.execute("mkdir -p " .. homeDir .. "/.cache " .. homeDir .. "/.config " .. homeDir .. "/.local/share 2>/dev/null") -- 顺带清掉**陈旧**的 profile 目录:正常路径由 reapBrowser 清,但进程被强杀 -- (go test 超时、插件崩)时来不及清,而 tag 唯一意味着它们会一直累积。 -- 只清 6 小时前的,不会碰到别的会话正在用的。 os.execute("find /tmp -maxdepth 1 -name 'llmsproxy-ch-*' -mmin +360 -exec rm -rf {} + 2>/dev/null") -- 记下来供 reapBrowser 收尾清理(失败路径也要清,否则 tag 唯一=无限累积)。 browserStack().profile = profileDir + browserStack().home = homeDir local steps = { { "Xvfb", ("Xvfb :%d -screen 0 1280x800x24 -nolisten tcp >/tmp/llmsproxy-xvfb-%s.log 2>&1 & echo $!"):format(disp, tag), nil, 0, 0 }, - { "chromium", ("%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s " - .. "--no-first-run --disable-gpu --remote-debugging-port=%d --remote-allow-origins=* " + { "chromium", ("HOME=%s XDG_CACHE_HOME=%s/.cache XDG_CONFIG_HOME=%s/.config " + .. "XDG_DATA_HOME=%s/.local/share " + .. "%s --no-sandbox --disable-dev-shm-usage --user-data-dir=%s " + .. "--no-first-run --disable-gpu --in-process-gpu --remote-debugging-port=%d --remote-allow-origins=* " .. "--window-size=1280,800 --display=:%d '%s' >/tmp/llmsproxy-chrome-%s.log 2>&1 & echo $!"):format( - chromium, profileDir, cdpPort, disp, url, tag), "127.0.0.1", cdpPort, 15000 }, + homeDir, homeDir, homeDir, homeDir, + chromium, profileDir, cdpPort, disp, start_url, tag), "127.0.0.1", cdpPort, 15000 }, { "x11vnc", ("x11vnc -display :%d -rfbport %d -%s -passwd '%s' -forever -shared -quiet " .. ">/tmp/llmsproxy-x11vnc-%s.log 2>&1 & echo $!"):format( disp, vport, bind == "127.0.0.1" and "localhost" or "listen", pass, tag), "127.0.0.1", vport, 8000 }, @@ -4127,6 +4251,23 @@ local function spawnBrowser(start_url) end end + -- ★ 存活探针:端口"能连上"**不等于**浏览器还活着。 + -- 真实事故:chromium 因 GPU 自杀时,CDP 端口在它退出前确实短暂监听过 ⇒ + -- waitPort 通过 ⇒ 我们报"已就绪",运维打开 VNC 却是白屏。**谎报"就绪"比 + -- 直接报错更坏**:运维会以为是自己操作的问题。 + -- 判据必须咬**外部效应**(进程仍在 + CDP 真能应答),而不是"我们发过启动命令"。 + os.execute("sleep 1") + local cpid = pids[2] + if cpid and cpid ~= "" and not browserUtil.pathExists("/proc/" .. cpid) then + local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400) + reapBrowser() + return nil, "浏览器启动后即退出(多半是 GPU/权限问题):" .. tostring(why) + end + if not browserUtil.cdpAlive(cdpPort) then + local why = browserUtil.readTail("/tmp/llmsproxy-chrome-" .. tag .. ".log", 400) + reapBrowser() + return nil, "浏览器 CDP 无应答(:" .. tostring(cdpPort) .. "):" .. tostring(why) + end b.pids = pids b.session = { display = disp, vnc = vport, ws = wport, cdp = cdpPort, pass = pass, seq = seq } local host = (plugin.state.settings and plugin.state.settings.vnc_host) or "127.0.0.1" @@ -4663,11 +4804,11 @@ function plugin.poll_login(req) elseif code ~= "" and ca.pkce and ca.key then local cbPort = tonumber(s.redirect_uri and s.redirect_uri:match(":(%d+)/")) or 0 local token, terr = codeartsRequestToken({ - client_id = CODEARTS_CLIENT_ID, + client_id = CODEARTS.client_id, code = code, code_verifier = ca.pkce.verifier, grant_type = "authorization_code", - redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS_REDIRECT_PATH, + redirect_uri = "http://127.0.0.1:" .. tostring(cbPort) .. CODEARTS.redirect_path, }, ca.key) if not token then cerr = terr @@ -7106,7 +7247,7 @@ function plugin.set_endpoints(req) -- codearts_sts:CodeArts 的华为 STS token 端点(PKCE 换 AK/SK 用)。 -- 测试把它指到假上游,才能端到端验证 code 交换而不打真华为云。 if type(args.codearts_sts) == "table" and args.codearts_sts.url then - CODEARTS_STS_TOKEN = tostring(args.codearts_sts.url) + CODEARTS.sts_token = tostring(args.codearts_sts.url) changed[#changed+1] = "codearts_sts.url" end if type(args.client_version) == "table" and plat.client_version then diff --git a/packaging/llmsproxy.service b/packaging/llmsproxy.service index 90b846d..93c757f 100644 --- a/packaging/llmsproxy.service +++ b/packaging/llmsproxy.service @@ -49,14 +49,38 @@ ProtectControlGroups=yes RestrictSUIDSGID=yes RestrictRealtime=yes LockPersonality=yes -RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX +# ⚠️ 地址族**不限制**(2026-10-15)。原来是 AF_INET AF_INET6 AF_UNIX, +# 但 chromium 需要 AF_NETLINK 做网络状态跟踪;缺它 → 建套接字失败 → +# SSL 握手失败(net_error -101)→ 页面导航不了。 +# 现在不写这一项(systemd 默认不限制)。 # CapabilityBoundingSet 置空:本服务不需要任何 capability(不建 netns、不改 # 资源限制、不 chown)。留空即"一个都不给",比列一份允许清单更难写错。 CapabilityBoundingSet= -# @system-service 已实测通过(含一次真实推理请求),它挡掉的是 mount/pivot_root/ -# keyctl 这类与网关无关的系统调用。 -SystemCallFilter=@system-service -SystemCallArchitectures=native +# ⚠️⚠️ **不限制 syscall**(2026-10-15,实测后决定)。 +# +# 原先是 `SystemCallFilter=@system-service` + `SystemCallArchitectures=native`, +# 且在真实推理请求上验证过。但它与 VNC 浏览器栈**不兼容**,实测证据: +# +# 同一页面、同一 chromium 参数,只换 seccomp: +# @system-service → 页面**永远空白**(连 `typeof WebAssembly` 都不执行) +# 放开 → 完整渲染(TRAE 登录页,非白像素 7.9% → 90.6%) +# +# 原因:TRAE 的授权页是 React SPA + 字节风控 SDK(sdk-glue.js),依赖 WASM/JIT。 +# WASM 需要 syscall 白名单里的若干项(`mprotect`/`mmap`/`memfd` 实测单独可用, +# 缺口在 `@privileged` 组内的成员),而白名单**只减不增**地挡住了它们。 +# +# 为什么不再逐个补白名单:那是**没有终点的维护**。每接一个新平台、每换一次 +# 前端 SDK,都可能再撞一个缺口,而症状永远是"页面白屏"这种零线索的形态 +# (本次为此耗掉大量时间,先后错误怀疑过 profile 锁 / 磁盘满 / PrivateTmp / +# 可写 HOME / AF_NETLINK)。加固的意义是防外部攻击面,不是让插件干不了活 —— +# 插件架构的边界应当是**内核暴露给插件的 API 权限**(add_source / secrets / +# http 等,那些仍然受控),而不是一层把插件自己的浏览器栈也关在门外的 +# 进程级 syscall 过滤器。 +# +# 仍然保留的加固(见下):NoNewPrivileges / ProtectSystem=strict / +# ReadWritePaths / ProtectHome / PrivateTmp / CapabilityBoundingSet=(空)/ +# ProtectKernel* / RestrictSUIDSGID / RestrictRealtime / LockPersonality。 +# 这些**都不影响**浏览器栈(已逐条实测),并把可写面收窄到 /etc/llmsproxy。 [Install] WantedBy=multi-user.target \ No newline at end of file