mirror of
https://gitcode.com/JianFeeeee/ModelRouter.git
synced 2026-11-07 09:47:23 +00:00
chore(main): 回流 —— VNC 兜底登录真正可用(放开 browser-stack 不需要的加固 + 修 3 个插件缺陷)
按纪律先在 release/v2.0.x 提交,再内容对齐回 main。 校验:git diff release/v2.0.x = 0 行。
This commit is contained in:
@ -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<n>-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
|
||||
|
||||
Reference in New Issue
Block a user