Files
ModelRouter/internal/lua/plugin_unload_test.go
JianFeeeee 74ec1e7874 refactor(lua): 内核回归六项职责 —— 登录/浏览器全部移入插件
用户钉死了内核的职责边界,本提交把越界部分全部拆除:

  内核只做:加载适配器 · auto 调度 · 加载插件 · 调度插件 · 维护源与 key · 签发密钥

## 架构纠正(用户指出的根本错误)

我此前把信任模型弄反了。实测:插件 VM 的 os.execute/io.popen/package 全部可达
(true,true,true) —— 插件从来不是 OS 级沙箱。正确的边界是:

  插件   可信(运维安装),非沙箱,有完整 OS 能力 → 平台知识/登录/浏览器归它
  适配器 不可信(可被动态安装),请求级 → 沙箱归它(12 个内置适配器实测均不碰
         os/io/package,后续沙箱化零风险,属独立一步)

## 删除(内核越界部分)

- plugin_browser.go        内核浏览器管理器(Xvfb/chromium/x11vnc/websockify
                           进程栈、回环监听器)—— 388 行全删,浏览器由插件用
                           os.execute 自己拉起(实测 spawn+reap 闭环可用)
- plugin_login.go          登录会话状态机 + LoginFlow 枚举 + VNC 全局门禁
                           —— 457 行全删。"登录"从内核概念中消失
- gateway/browser_proxy.go viewer 反代
- handlePluginLogin API    登录会话列表端点
- vnc_enabled 全局开关     改为 per-plugin 授权(见下)

## 新增(内核职责内的通用机制,无任何登录/浏览器语义)

- plugin_outbound.go       通用额外出站主机授权:插件声明 plugin.hosts(请求)
                           → 运维在设置页逐个批准(授权)。与 kernel actions
                           同一"声明≠授权"模型。内核只知道白名单加减,不知道
                           host 被用来干什么
- on_unload 生命周期钩子   卸载是"调度插件"的一部分:内核在关闭插件状态前调用
                           插件导出的 on_unload,让它自清理外部资源。内核不知道
                           资源是什么(进程/句柄/远端会话都行)。带进程回收回归
                           测试 TestOnUnloadReapsPluginProcesses

## channels.lua 全量接管

- 登录会话自持:plugin.state.sessions(nonce 只在 begin 响应出现一次,
  惰性过期清理)
- 服务端浏览器栈自持:spawnBrowser 用 io.popen 拉起 Xvfb/chromium/x11vnc/
  websockify 并记 pid,reapBrowser 回收;on_unload 兜底
- 优先级阶梯(用户指定):① 本机浏览器(给 URL)→ ② 自动化(轮询/nonce 回调)
  → ③ VNC 无头浏览器兜底(无浏览器系统/自动化无法完成的场景,如回环重定向
  必须落在服务端)
- plugin.auth_hosts → plugin.hosts(语义泛化:出站主机授权与登录无关)

## 验收

- 全仓旧符号零残留(grep 权威确认,代码+注释+lua+测试)
- 内核非测试代码零厂商名
- build + vet 全绿;lua/core/gateway/types/scheduler/provider 全量测试通过
- channels 端到端(登录→凭据→add_source→模型可见)继续通过
2026-10-11 16:24:55 +08:00

53 lines
1.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"os/exec"
"testing"
"time"
)
// TestOnUnloadReapsPluginProcesses 验收通用卸载钩子。
//
// 内核职责是"调度插件"——卸载是调度的一部分,而插件可能持有外部资源(进程、
// 文件句柄、远端会话)。内核不知道也不需要知道这些资源是什么;它只要在关闭
// 状态前给插件一次自清理的机会(on_unload),资源归属就始终在插件侧。
// 本测试用"插件起一个进程、卸载时杀掉"来证明这条链路真的闭合。
func TestOnUnloadReapsPluginProcesses(t *testing.T) {
_, ps, _ := runtimePlugins(t)
code := `
local plugin = { name = "reaper", version = "1.0" }
plugin.exports = { "touch" }
local pid = nil
function plugin.touch()
local h = io.popen("sh -c 'sleep 300 & echo $!'")
pid = h:read("*l"); h:close()
return { status = 200, json = { pid = pid } }
end
function plugin.on_unload()
if pid then os.execute("kill -9 " .. pid .. " 2>/dev/null") end
end
return plugin`
if err := ps.LoadSource("reaper", code); err != nil {
t.Fatal(err)
}
out, err := ps.Call("reaper", "touch", map[string]interface{}{})
if err != nil {
t.Fatal(err)
}
m := out.(map[string]interface{})
js := m["json"].(map[string]interface{})
pid := js["pid"].(string)
time.Sleep(300 * time.Millisecond)
if err := exec.Command("kill", "-0", pid).Run(); err != nil {
t.Fatalf("进程应存活: %v", err)
}
if err := ps.Unload("reaper"); err != nil {
t.Fatalf("unload: %v", err)
}
time.Sleep(500 * time.Millisecond)
if err := exec.Command("kill", "-0", pid).Run(); err == nil {
exec.Command("kill", "-9", pid).Run()
t.Fatal("★ unload 后进程仍存活 —— on_unload 未被调用")
}
}