diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go
index e217e21..690eff6 100644
--- a/internal/core/channels_plugin_test.go
+++ b/internal/core/channels_plugin_test.go
@@ -27,6 +27,9 @@ func TestChannelsPluginEndToEnd(t *testing.T) {
if err := ps.LoadSource("channels", src); err != nil {
t.Fatalf("加载 channels: %v", err)
}
+ // 测试夹具经运行时注入:生产目录只含真实厂商,夹具不再内联进源码
+ // (否则会出现在生产 UI 的平台列表里)。
+ injectTestPlatforms(t, ps, examplePlatformFixture)
// 1. 平台清单可用(未授权任何东西也应能列出)
out, err := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{
@@ -239,3 +242,197 @@ func stateHasSecret(ps *lua.Plugins, plugin, secret string) bool {
}
return contains(string(b), secret)
}
+
+// examplePlatformFixture 是端到端测试用的占位平台(对应此前内联在生产源码里
+// 的 "example" 平台)。它经 inject_platforms 注入,且**只在测试进程内存在** ——
+// 生产插件的 PLATFORMS 只含真实厂商。
+const examplePlatformFixture = `[
+ {
+ "id": "example",
+ "name": "Example 平台(测试夹具)",
+ "flow": "link-poll",
+ "login_url": "https://auth.example.com/device",
+ "base_url": "https://api.example.com/v1",
+ "adapter": "openai",
+ "models": ["example-chat", "example-reason"],
+ "checkin": {"url": "https://api.example.com/v1/api/daily/claim", "method": "POST", "body": "{}"}
+ }
+]`
+
+// injectTestPlatforms 把测试夹具注入插件(运行时,不落盘)。
+func injectTestPlatforms(t *testing.T, ps *lua.Plugins, fixtureJSON string) {
+ t.Helper()
+ out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platforms":` + fixtureJSON + `}`, Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("inject_platforms: %v", err)
+ }
+ m, _ := out.JSON.(map[string]interface{})
+ if m["ok"] != true {
+ t.Fatalf("注入测试平台失败: %v", m)
+ }
+}
+
+// TestInjectPlatformsRejectsBadInput 锁定注入机制的三条边界:
+// 1. 不得覆盖内建平台(注入不能劫持真实厂商)
+// 2. 重复 id 拒绝
+// 3. 空数组 = 清空注入(夹具不跨测试残留)
+//
+// 这个机制存在的理由:夹具内联进生产源码会让它们出现在生产 UI 的平台列表里
+// (真实踩过 —— 16 个平台里有 4 个是夹具)。
+func TestInjectPlatformsRejectsBadInput(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)
+ }
+
+ call := func(body string) map[string]interface{} {
+ out, err := ps.InvokeHandler("channels", "inject_platforms", &lua.HandlerRequest{
+ Method: "POST", Body: body, Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("inject_platforms(%s): %v", body, err)
+ }
+ return out.JSON.(map[string]interface{})
+ }
+ platforms := func() []interface{} {
+ out, _ := ps.InvokeHandler("channels", "list_platforms", &lua.HandlerRequest{Method: "GET", Role: "admin"})
+ return out.JSON.(map[string]interface{})["platforms"].([]interface{})
+ }
+
+ base := len(platforms())
+ if base == 0 {
+ t.Fatal("生产目录应有真实厂商平台")
+ }
+
+ // 注入一个合法夹具 → 列表 +1
+ if m := call(`{"platforms":[{"id":"fixture-a","name":"A","flow":"paste","base_url":"https://a.example/v1"}]}`); m["ok"] != true {
+ t.Fatalf("合法注入应成功: %v", m)
+ }
+ if got := len(platforms()); got != base+1 {
+ t.Fatalf("注入后平台数 = %d, 期望 %d", got, base+1)
+ }
+
+ // 1. 覆盖内建平台 → 拒绝
+ if m := call(`{"platforms":[{"id":"buddy","name":"劫持"}]}`); m["error"] == nil {
+ t.Fatal("覆盖内建平台必须被拒绝")
+ }
+ // 2. 重复 id → 拒绝
+ if m := call(`{"platforms":[{"id":"fixture-b"},{"id":"fixture-b"}]}`); m["error"] == nil {
+ t.Fatal("重复 id 必须被拒绝")
+ }
+ // 无 id → 拒绝
+ if m := call(`{"platforms":[{"name":"no-id"}]}`); m["error"] == nil {
+ t.Fatal("缺 id 必须被拒绝")
+ }
+
+ // 3. 空数组 = 清空注入,回到基线
+ if m := call(`{"platforms":[]}`); m["ok"] != true {
+ t.Fatalf("清空应成功: %v", m)
+ }
+ if got := len(platforms()); got != base {
+ t.Fatalf("清空后平台数 = %d, 期望回到基线 %d", got, base)
+ }
+}
+
+// TestChannelsBrowserStackDeps 锁定浏览器栈的依赖预检能力。
+//
+// 这个能力此前**不存在**:代码里写着「运行 deploy 脚本一键安装」,但安装机制
+// 根本没有(deploy.sh 里零浏览器栈内容),于是 VNC 兜底在缺依赖的机器上只会
+// 报一句含糊的"进程启动失败"。现在如实报告缺什么、装哪个包。
+func TestChannelsBrowserStackDeps(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)
+ }
+ out, err := ps.InvokeHandler("channels", "deps_status", &lua.HandlerRequest{
+ Method: "GET", Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("deps_status: %v", err)
+ }
+ m, ok := out.JSON.(map[string]interface{})
+ if !ok {
+ t.Fatalf("deps_status 应返回对象: %#v", out.JSON)
+ }
+ if _, has := m["ok"]; !has {
+ t.Fatal("deps_status 必须报告 ok")
+ }
+ if _, has := m["missing"]; !has {
+ t.Fatal("deps_status 必须报告 missing 清单")
+ }
+ if m["ok"] == false {
+ hint, _ := m["install_hint"].(string)
+ if hint == "" {
+ t.Fatal("缺依赖时必须给出安装命令(否则运维无从下手)")
+ }
+ // 每个缺失项都要说清是谁、装什么包、干什么用
+ for _, raw := range asArr(m["missing"]) {
+ e := raw.(map[string]interface{})
+ for _, k := range []string{"bin", "pkg", "why"} {
+ if s, _ := e[k].(string); s == "" {
+ t.Fatalf("缺少字段 %s: %v", k, e)
+ }
+ }
+ }
+ t.Logf("本机缺依赖(预期:CI 容器通常没有浏览器栈): %v", m["missing"])
+ }
+}
+
+// TestChannelsBrowserEndpointsRequireSession 浏览器栈的三个端点在没有会话时
+// 必须**如实报错**,而不是假装成功或 panic。
+func TestChannelsBrowserEndpointsRequireSession(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)
+ }
+
+ // 无会话时 collect 必须报错
+ out, err := ps.InvokeHandler("channels", "collect_credentials", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("collect_credentials: %v", err)
+ }
+ if out.JSON.(map[string]interface{})["error"] == nil {
+ t.Fatal("无浏览器会话时 collect_credentials 必须报错")
+ }
+
+ // status 如实说 running=false
+ out, err = ps.InvokeHandler("channels", "browser_status", &lua.HandlerRequest{
+ Method: "GET", Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("browser_status: %v", err)
+ }
+ if out.JSON.(map[string]interface{})["running"] != false {
+ t.Fatal("无会话时 running 应为 false")
+ }
+
+ // stop 幂等:无会话时也返回 ok
+ out, err = ps.InvokeHandler("channels", "stop_browser", &lua.HandlerRequest{
+ Method: "POST", Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("stop_browser: %v", err)
+ }
+ if out.JSON.(map[string]interface{})["ok"] != true {
+ t.Fatal("stop_browser 应幂等成功")
+ }
+}
diff --git a/internal/core/channels_store_test.go b/internal/core/channels_store_test.go
new file mode 100644
index 0000000..c428065
--- /dev/null
+++ b/internal/core/channels_store_test.go
@@ -0,0 +1,222 @@
+package core
+
+import (
+ "encoding/json"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "llmsproxy/internal/lua"
+)
+
+// TestChannelsStoreIsPluginOwned 是「插件自持持久化」的回归。
+//
+// 背景:本插件声明 hooks = {},全部状态变更走自己的 HTTP 路由。此前 v2.0.4
+// 曾让内核的 InvokeHandler 补 markDirty 来解决「重启丢账号」——那是**方向错误**
+// 的修法(把插件的数据安全外包给宿主某条分发路径的实现细节)。参考实现
+// (channel-pack-store.ts)踩过同一课:宿主 settings 能力一变,账号列表就退化
+// 为纯内存,正解是插件自持文档。
+//
+// 本测试断言:插件把状态写进**自己的**存储文件,且新实例能读回。
+func TestChannelsStoreIsPluginOwned(t *testing.T) {
+ // 固定插件目录:两次「重启」必须共用同一目录,否则测不出持久化。
+ // 这也是防止测试写进生产插件目录的那道隔离(kernelTestCore 会 respect 它)。
+ dir := t.TempDir()
+ t.Setenv("LLMSPROXY_PLUGIN_DIR", dir)
+ load := func() *lua.Plugins {
+ 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", "remove_source", "status"}, false)
+ return ps
+ }
+
+ ps := load()
+ // 加账号 → 状态变更 → 应立即落盘
+ add, err := ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"buddy","nickname":"持久化测试"}`, Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("account_add: %v", err)
+ }
+ aid := add.JSON.(map[string]interface{})["account_id"].(string)
+
+ // ① 插件自有存储文件必须存在
+ storePath := filepath.Join(dir, ".channels-store.json")
+ b, err := os.ReadFile(storePath)
+ if err != nil {
+ t.Fatalf("★ 插件自有存储未写出(%s): %v\n"+
+ " —— 状态落盘必须由插件自己负责,不能依赖内核分发路径", storePath, err)
+ }
+ var saved struct {
+ State map[string]interface{} `json:"state"`
+ }
+ if err := json.Unmarshal(b, &saved); err != nil {
+ t.Fatalf("存储文件不是合法 JSON: %v", err)
+ }
+ pool, _ := saved.State["pool"].(map[string]interface{})
+ buddy, _ := pool["buddy"].(map[string]interface{})
+ if _, ok := buddy[aid]; !ok {
+ t.Fatalf("存储里应含账号 %s,实得 pool=%v", aid, pool)
+ }
+
+ // ② 凭据绝不进这个文件(它在 .secrets/ 保险库,0600)
+ if s, _ := saved.State["credentials"].(map[string]interface{}); len(s) > 0 {
+ t.Fatal("自有存储不得包含凭据(凭据只进 .secrets 保险库)")
+ }
+ raw := string(b)
+ for _, bad := range []string{"access_token", "refresh_token", "api_key"} {
+ if strings.Contains(raw, bad) {
+ t.Fatalf("自有存储疑似含凭据字段 %q —— 凭据与索引必须分离", bad)
+ }
+ }
+
+ // ③ 新实例(模拟重启)必须读回账号
+ ps2 := load()
+ lst, err := ps2.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
+ })
+ if err != nil {
+ t.Fatalf("account_list: %v", err)
+ }
+ found := false
+ for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
+ if raw.(map[string]interface{})["id"] == aid {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("★ 重启后账号 %s 未恢复 —— 持久化未生效", aid)
+ }
+}
+
+// TestChannelsMigratesLegacyKernelState 回归:升级时把内核遗留文件
+// (.channels.state.json,v2.0.4 内核写的)迁移到自有存储并删除遗留文件。
+//
+// 迁移必须真的搬数据(生产上那份文件含真实账号池),且**写成功才删**遗留 ——
+// 写失败还删它,等于用一次迁移把数据弄丢。
+func TestChannelsMigratesLegacyKernelState(t *testing.T) {
+ // 同 TestChannelsStoreIsPluginOwned:目录由 kernelTestCore 提供(已隔离)
+ dir := ""
+
+ // 造一个内核遗留文件(v2.0.4 的形状)
+ legacy := map[string]interface{}{
+ "version": 1,
+ "state": map[string]interface{}{
+ "accounts": map[string]interface{}{},
+ "log": []interface{}{},
+ "pool": map[string]interface{}{
+ "buddy": map[string]interface{}{
+ "legacy-acct": map[string]interface{}{
+ "id": "legacy-acct", "provider": "buddy", "nickname": "遗留账号",
+ "enabled": true, "credentialRef": "channels:buddy:legacy-acct",
+ "createdAt": 1, "last_claim": 0, "last_claim_status": 0, "last_claim_summary": "",
+ },
+ },
+ },
+ },
+ }
+ b, _ := json.Marshal(legacy)
+
+ c := kernelTestCore(t)
+ dir = c.Config().PluginDir
+ legacyPath := filepath.Join(dir, ".channels.state.json")
+ if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ 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)
+ }
+
+ // ① 遗留账号必须被迁移进内存(可见)
+ lst, err := ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"buddy"}`, Role: "admin",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ found := false
+ for _, raw := range asArr(lst.JSON.(map[string]interface{})["accounts"]) {
+ if raw.(map[string]interface{})["id"] == "legacy-acct" {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatal("★ 遗留账号未迁移 —— 升级即丢账号")
+ }
+
+ // ② 自有存储必须已写出
+ if _, err := os.Stat(filepath.Join(dir, ".channels-store.json")); err != nil {
+ t.Fatalf("迁移后自有存储应存在: %v", err)
+ }
+ // ③ 遗留文件必须被删除(否则内核 restore() 会用它覆盖自有存储)
+ if _, err := os.Stat(legacyPath); !os.IsNotExist(err) {
+ t.Fatal("★ 遗留内核文件应被删除,否则下次启动会被它覆盖")
+ }
+}
+
+// TestChannelsMigrationKeepsLegacyOnWriteFailure 锁定迁移的**数据安全**契约:
+// 写自有存储失败时**绝不能**删除遗留文件 —— 写失败还删它,等于用一次迁移把
+// 数据弄丢(与本次要修的「重启丢账号」是同类事故)。
+//
+// 这条契约必须单独测:迁移成功后的删除有两条路径(迁移内删 + 自有存储已存在
+// 时的 drop),互相掩盖,只测「最终文件没了」区分不出「写失败也删了」。
+func TestChannelsMigrationKeepsLegacyOnWriteFailure(t *testing.T) {
+ // 强制写失败的手段:把 persistState 要写的临时文件路径
+ // (
/.channels-store.json.tmp)**占成一个目录**,io.open(tmp,"w") 必失败。
+ // 不用 chmod:测试以 root 跑,root 绕过权限位,chmod 拦不住。
+ sub := t.TempDir()
+ t.Setenv("LLMSPROXY_PLUGIN_DIR", sub)
+
+ legacy := map[string]interface{}{
+ "version": 1,
+ "state": map[string]interface{}{
+ "pool": map[string]interface{}{
+ "buddy": map[string]interface{}{
+ "keepme": map[string]interface{}{
+ "id": "keepme", "provider": "buddy",
+ "credentialRef": "channels:buddy:keepme", "createdAt": 1,
+ },
+ },
+ },
+ },
+ }
+ b, _ := json.Marshal(legacy)
+ legacyPath := filepath.Join(sub, ".channels.state.json")
+ if err := os.WriteFile(legacyPath, b, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.MkdirAll(filepath.Join(sub, ".channels-store.json.tmp"), 0o700); err != nil {
+ t.Fatal(err)
+ }
+
+ c := kernelTestCore(t)
+ ps := c.Plugins()
+ src, err := lua.ReadBundledPlugin("channels")
+ if err != nil {
+ t.Fatal(err)
+ }
+ _ = ps.LoadSource("channels", src)
+
+ // 写失败的态下,遗留文件必须原样保留
+ if _, err := os.Stat(legacyPath); err != nil {
+ t.Fatalf("★ 写自有存储失败时遗留文件被删了 —— 数据被一次迁移弄丢: %v", err)
+ }
+ // 且自有存储不应存在(写失败了)
+ if _, err := os.Stat(filepath.Join(sub, ".channels-store.json")); err == nil {
+ t.Fatal("tmp 路径被占住时不应写出自有存储")
+ }
+}
diff --git a/internal/core/channels_stress_test.go b/internal/core/channels_stress_test.go
index 040a6d2..8f32ab6 100644
--- a/internal/core/channels_stress_test.go
+++ b/internal/core/channels_stress_test.go
@@ -38,6 +38,18 @@ func channelsCore(t *testing.T, src string) (*Core, *lua.Plugins) {
return c, ps
}
+// cookieishPlatformFixture 是压力测试用的占位平台(对应此前内联在生产源码里的
+// "cookieish")。夹具经运行时注入,生产目录只含真实厂商。
+const cookieishPlatformFixture = `[{
+ "id": "cookieish",
+ "name": "Cookie 平台(测试夹具)",
+ "flow": "paste",
+ "base_url": "https://api.cookieish.example/v1",
+ "adapter": "openai",
+ "models": ["c-model-1"],
+ "checkin": {"url": "https://api.cookieish.example/v1/api/credit/claim", "method": "POST", "body": "{}"}
+}]`
+
// TestChannelsConcurrentLogins 压力:并发走 device-poll 登录。
//
// 插件是单 VM 单锁 —— 本测试验证的是**正确性下的并发**(会话互不串台、
@@ -134,6 +146,7 @@ func TestChannelsClaimStress(t *testing.T) {
defer ts.Close()
_, ps := channelsCore(t, mustChannelsSrc(t))
+ injectTestPlatforms(t, ps, cookieishPlatformFixture)
if _, err := ps.Call("channels", "set_endpoints", map[string]interface{}{
"platform": "cookieish",
"checkin": map[string]interface{}{"url": ts.URL + "/v1/checkin"},
diff --git a/internal/core/channels_trae_test.go b/internal/core/channels_trae_test.go
index 9666391..c27f687 100644
--- a/internal/core/channels_trae_test.go
+++ b/internal/core/channels_trae_test.go
@@ -171,3 +171,67 @@ func TestTraeCallbackBehavior(t *testing.T) {
}
})
}
+
+// TestTraeCallbackPoolPath 回归:callback 流的**池路径**(UI 真实流程)。
+//
+// 与 buddy poll_login 的池路径同一缺陷:complete_login 不带 account_id 时,
+// 凭据落到与占位账号无关的 `channels:` 键,池账号永远 has_credential=false。
+// 此前只有 device-poll / state-poll 分支修了,callback 分支是漏网。
+func TestTraeCallbackPoolPath(t *testing.T) {
+ h := newTraeHarness(t)
+ atomic.StoreInt32(&h.exchOK, 1)
+
+ // UI 流程:先建占位账号,再带 account_id 发起登录
+ add, err := h.ps.InvokeHandler("channels", "account_add", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"trae"}`,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ aid := add.JSON.(map[string]interface{})["account_id"].(string)
+
+ out, err := h.ps.InvokeHandler("channels", "start_login", &lua.HandlerRequest{
+ Method: "POST", Body: fmt.Sprintf(`{"platform":"trae","account_id":%q}`, aid),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ nonce := out.JSON.(map[string]interface{})["session"].(map[string]interface{})["nonce"].(string)
+
+ st, body := h.invokeCallback(map[string]string{
+ "state": nonce, "refreshToken": "RT-FROM-CALLBACK", "userInfo": `{"UserID":"u9","ScreenName":"池用户"}`,
+ })
+ if st != 200 {
+ t.Fatalf("回调应 200: %d %s", st, body)
+ }
+
+ // ① 凭据必须落在池账号的 ref
+ v, ok, _ := h.ps.GetSecret("channels", "channels:trae:"+aid)
+ if !ok || v == "" {
+ t.Fatalf("★ callback 池路径凭据未落到 channels:trae:%s(用户报障的同一缺陷)", aid)
+ }
+ // ② account_list 必须报 has_credential=true
+ lst, err := h.ps.InvokeHandler("channels", "account_list", &lua.HandlerRequest{
+ Method: "POST", Body: `{"platform":"trae"}`,
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, raw := range lst.JSON.(map[string]interface{})["accounts"].([]interface{}) {
+ a := raw.(map[string]interface{})
+ if a["id"] == aid && a["has_credential"] != true {
+ t.Fatalf("has_credential 应为 true, 实得 %v", a["has_credential"])
+ }
+ }
+}
+
+// invokeCallback 直打 oauth_callback(Query 已含 state)。
+func (h *traeHarness) invokeCallback(q map[string]string) (int, string) {
+ res, err := h.ps.InvokeHandler("channels", "oauth_callback", &lua.HandlerRequest{
+ Method: "GET", Query: q,
+ })
+ if err != nil {
+ h.t.Fatal(err)
+ }
+ return res.Status, fmt.Sprintf("%v|%v", res.Body, res.JSON)
+}
diff --git a/internal/core/channels_vnc_test.go b/internal/core/channels_vnc_test.go
new file mode 100644
index 0000000..3f9e20e
--- /dev/null
+++ b/internal/core/channels_vnc_test.go
@@ -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")
+ }
+}
diff --git a/internal/core/kernel_actions_test.go b/internal/core/kernel_actions_test.go
index e86dc68..cf3a6f7 100644
--- a/internal/core/kernel_actions_test.go
+++ b/internal/core/kernel_actions_test.go
@@ -20,10 +20,19 @@ import (
func kernelTestCore(t *testing.T) *Core {
t.Helper()
dir := t.TempDir()
+ pluginDir := filepath.Join(dir, "plugins")
+ // 插件的自有存储位置由插件自己决定(读 config.yaml 的 plugin_dir,或
+ // LLMSPROXY_PLUGIN_DIR 覆盖)。测试**必须**指向临时目录 —— 否则插件会读到
+ // /etc/llmsproxy/config.yaml 并写进**生产插件目录**(真实事故:一次全量测试
+ // 把 26 个测试账号写进了生产 .channels-store.json)。
+ // 调用方已显式设定时尊重它:多次「重启」要共用同一目录才能测持久化。
+ if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" {
+ t.Setenv("LLMSPROXY_PLUGIN_DIR", pluginDir)
+ }
cfg := &config.Config{
Path: filepath.Join(dir, "config.yaml"),
AdapterDir: filepath.Join(dir, "adapters"),
- PluginDir: filepath.Join(dir, "plugins"),
+ PluginDir: pluginDir,
RuntimeFile: filepath.Join(dir, "runtime.json"),
DefaultModel: "AUTO",
GatewayKeys: []string{"sk-kernel-0001"},
diff --git a/internal/lua/persist_test.go b/internal/lua/persist_test.go
index 86c21a8..4a08e17 100644
--- a/internal/lua/persist_test.go
+++ b/internal/lua/persist_test.go
@@ -338,67 +338,3 @@ return plugin
t.Errorf("persisted n = %v after three exported calls, want 3", saved.State["n"])
}
}
-
-// TestInvokeHandlerIsPersisted 是真实事故的回归:channels 插件声明 hooks=[],
-// 一切状态变更(账号入池、凭据引用、模型黑名单、事件日志)都经过自己的 HTTP
-// handler。而 InvokeHandler 是唯一一条**从不 markDirty** 的分发路径 —— hook 路径
-// 有、Call 路径有,只有它漏了。后果:
-//
-// .channels.state.json 从未被写出 → 重启后账号池静默清空。
-// 运维在升级前的重启里丢掉了刚登录的账号,界面显示"已登录"但列表为空。
-//
-// billing/quota 侥幸没踩到,只因为它们有 hooks(billing)或用 Call(quota)。
-// 本测试用**无 hooks、只走 handler** 的插件复现该形态。
-func TestInvokeHandlerIsPersisted(t *testing.T) {
- ps, _ := newPersistVM(t)
- if err := ps.LoadSource("handlersonly", `
-local plugin = { name = "handlersonly", version = "1.0" }
-plugin.state = { accounts = {} }
--- 不声明任何 hooks:状态只能经 HTTP handler 变更(channels 的形态)
-plugin.hooks = {}
-plugin.http = {
- { path = "add", method = "POST", handler = "add" },
-}
-function plugin.add(req)
- local body = req.body or ""
- local id = body:match('"id":"([^"]+)"') or "unknown"
- plugin.state.accounts[id] = { id = id, enabled = true }
- return { status = 200, json = { ok = true } }
-end
-return plugin
-`); err != nil {
- t.Fatal(err)
- }
-
- for _, id := range []string{"acct-1", "acct-2"} {
- resp, err := ps.InvokeHandler("handlersonly", "add", &handlerRequest{
- Method: "POST", Body: `{"id":"` + id + `"}`,
- })
- if err != nil {
- t.Fatalf("handler: %v", err)
- }
- if resp == nil {
- t.Fatal("handler returned nil response")
- }
- }
- // Close() waits for the final flush, so no timing is involved: anything the
- // handlers did NOT mark dirty cannot appear here.
- ps.Close()
-
- b, err := os.ReadFile(ps.stateFile("handlersonly"))
- if err != nil {
- t.Fatalf("★ 无 hooks 的插件经 HTTP handler 改状态后未落盘: %v\n"+
- " —— InvokeHandler 必须像 Call / hook 路径一样 markDirty,\n"+
- " 否则 channels 的账号池会在每次重启后静默清空", err)
- }
- var saved struct {
- State map[string]interface{} `json:"state"`
- }
- if err := json.Unmarshal(b, &saved); err != nil {
- t.Fatal(err)
- }
- accts, _ := saved.State["accounts"].(map[string]interface{})
- if len(accts) != 2 {
- t.Fatalf("落盘的 accounts = %v,应为 2 个账号", saved.State["accounts"])
- }
-}
diff --git a/internal/lua/plugin_http_handler.go b/internal/lua/plugin_http_handler.go
index f10bf95..859a115 100644
--- a/internal/lua/plugin_http_handler.go
+++ b/internal/lua/plugin_http_handler.go
@@ -264,10 +264,6 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
L.Pop(1)
}
if errMsg != "" {
- // A handler that raised partway may already have mutated plugin.state
- // (an account added before a later step failed), so mark it dirty before
- // bailing: the mutation it managed to make is still real.
- ps.markDirtyLocked(p)
return nil, fmt.Errorf("plugin %s: %s", plugin, errMsg)
}
if L.GetTop() < 1 || L.IsNil(-1) {
@@ -277,18 +273,6 @@ func (ps *Plugins) InvokeHandler(plugin, fnName string, req *handlerRequest) (*h
if err := luaToJSON(L, -1, &raw); err != nil {
return nil, err
}
- // A handler mutates plugin.state just as a hook or an exported Call does, and
- // this was the one dispatch path that never marked the plugin dirty.
- //
- // Consequence (real defect): a plugin with NO hooks — channels is exactly
- // that, it declares hooks=[] and drives everything through its own HTTP
- // routes — accumulated accounts, credential refs, model blacklists and its
- // event log entirely in memory. Nothing ever marked it dirty, so
- // .channels.state.json was never written and a restart silently wiped the
- // account pool: the operator saw "logged in", restarted for an upgrade, and
- // the account was gone. billing/quota escape this only because they declare
- // hooks and/or use Call, both of which already mark dirty.
- ps.markDirtyLocked(p)
return parseHandlerResponse(raw)
}
diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua
index 5446c94..027cdb8 100644
--- a/internal/lua/plugins/channels.lua
+++ b/internal/lua/plugins/channels.lua
@@ -77,28 +77,12 @@ plugin.exports = { "set_endpoints", "account_add", "account_list", "account_upda
-- WASM 签名(Qoder)/captcha(ZCode)/扫码(Loomy,Raccoon) 这类无法在当前 Lua
-- 环境完成的流程标记 flow="manual",登录时给出明确说明而非假装成功。
local PLATFORMS = {
- -- ===== 测试/示范用占位平台(example.com,不打真实厂商)=====
- {
- id = "example",
- name = "Example 平台",
- flow = "link-poll",
- login_url = "https://auth.example.com/device",
- base_url = "https://api.example.com/v1",
- adapter = "openai",
- models = { "example-chat", "example-reason" },
- checkin = { url = "https://api.example.com/v1/api/daily/claim", method = "POST", body = "{}" },
- },
- {
- id = "googleish",
- name = "Google 系(示范 callback)",
- flow = "callback",
- login_url = "https://accounts.google.com/o/oauth2/auth",
- base_url = "https://api.googleish.example/v1",
- adapter = "openai",
- models = { "g-model-1" },
- },
-
-- ===== 真实厂商渠道(逆向自参考仓库,端点逐字照抄)=====
+ --
+ -- ⚠️ 这里**只放真实厂商**。测试夹具(example.com 的假平台、示范各种 flow 的
+ -- 占位平台)绝不能内联到这里:本目录会被 UI / status / list_platforms 直接
+ -- 展示给运维,夹具写进来就是「生产 UI 里凭空多出 4 个假平台」。
+ -- 夹具由测试经 channels/inject_platforms 端点运行时注入。
-- TRAE(字节跳动)—— callback 登录 + ExchangeToken 续期 + 每日签到。
-- 这是最标准的一条,作为"同类厂商"的实现模板。
@@ -386,27 +370,6 @@ local PLATFORMS = {
},
},
- -- 服务端浏览器兜底示范(回环重定向类)。真实厂商若需要可改 flow="vnc"。
- {
- id = "loopbackish",
- name = "回环登录平台(示范 VNC 兜底)",
- flow = "vnc",
- needs_browser = true,
- login_url = "https://auth.loopbackish.example/authorize?redirect_uri=http://127.0.0.1:%d/callback",
- base_url = "https://api.loopbackish.example/v1",
- adapter = "openai",
- models = { "lb-model-1" },
- },
- {
- id = "cookieish",
- name = "Cookie 平台(示范 paste)",
- flow = "paste",
- base_url = "https://api.cookieish.example/v1",
- adapter = "openai",
- models = { "c-model-1" },
- checkin = { url = "https://api.cookieish.example/v1/api/credit/claim", method = "POST", body = "{}" },
- },
-
-- Gemini(Google Cloud Code)—— 标准 OAuth 授权码流 + token 交换。
-- 照参考 gemini-oauth.ts:授权页 client_id/redirect_uri/scope 齐全,回环回调
-- 拿到 code 后 POST oauth2.googleapis.com/token 换 token(带 code_verifier)。
@@ -804,25 +767,228 @@ local function deviceRegister(plat, payload)
return access, acct, nil
end
--- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)
+-- platformById 查平台定义;未知 id 返回 nil(调用方必须处理,而不是拿到空表)。
+--
+-- 先查内建目录,再查运行时注入表(plugin.state.injected_platforms)。注入表的存在
+-- 是因为**测试夹具绝不能写成生产源码**:此前 example/googleish/loopbackish/
+-- cookieish 四个夹具直接内联在 PLATFORMS 里,于是它们真的出现在生产 UI(16 个
+-- 平台里有 4 个是夹具)供运维选择。夹具属于测试,不属于发行物 —— 正确形态是
+-- 测试用自己的那几行注入(见 channels/test_platforms 端点),生产目录只留真实厂商。
local function platformById(id)
for _, p in ipairs(PLATFORMS) do
if p.id == id then return p end
end
+ local injected = plugin.state and plugin.state.injected_platforms
+ if type(injected) == "table" then
+ for _, p in ipairs(injected) do
+ if type(p) == "table" and p.id == id then return p end
+ end
+ end
return nil
end
+-- allPlatforms 返回内建目录 + 注入表(列表/遍历用)。
+local function allPlatforms()
+ local out = {}
+ for _, p in ipairs(PLATFORMS) do out[#out + 1] = p end
+ local injected = plugin.state and plugin.state.injected_platforms
+ if type(injected) == "table" then
+ for _, p in ipairs(injected) do
+ if type(p) == "table" and p.id then out[#out + 1] = p end
+ end
+ end
+ return out
+end
+
-- ============================================================================
-- 状态
-- ============================================================================
+-- ============================================================================
+-- 插件自有持久化(账号池 / 模型黑名单 / 设置 / 日志)
+-- ============================================================================
+--
+-- 为什么插件自己落盘,而不是靠宿主框架的 plugin.state:
+-- 本插件声明 hooks = {},一切状态变更都走自己的 HTTP 路由。宿主的状态自动
+-- flush 由 hook / Call / handler 三条分发路径触发,插件不应把「我的状态会不会
+-- 被写下去」押在宿主某条路径的实现细节上 —— 那是把插件的数据安全外包给宿主。
+-- 参考实现踩过同一课(channel-pack-store.ts):宿主 settings 能力一变,账号
+-- 列表就退化为纯内存,必须改由插件自持文档。
+--
+-- 位置与凭据分离:凭据在 .secrets/(0600,跨插件不可读),这里只放**索引与
+-- 配置**(账号条目含 credentialRef 而非凭据本身、模型黑名单、设置、事件日志)。
+-- 即便这个文件泄露,也不含任何 token。
+--
+-- 写入:tmp + rename 原子替换。读:缺失/损坏 → 用内置默认值(不阻塞启动)。
+local STORE_NAME = ".channels-store.json"
+
+-- storeDir 求插件目录:优先 config.yaml 的 plugin_dir,否则用本文件所在目录。
+-- 插件与内核同机、有完整 OS 权限,读自己的配置是能力范围内的事。
+local function storeDir()
+ -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置)
+ local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR")
+ if type(env) == "string" and env ~= "" then return env end
+ local s = plugin.state.settings and plugin.state.settings.store_dir
+ if type(s) == "string" and s ~= "" then return s end
+ for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do
+ local f = io.open(path, "r")
+ if f then
+ local txt = f:read("*a")
+ f:close()
+ if txt then
+ local d = txt:match("\nplugin_dir:%s*([^\n]%S*)") or txt:match("^plugin_dir:%s*([^\n]%S*)")
+ if d and d ~= "" then return d end
+ end
+ end
+ end
+ return "/etc/llmsproxy/plugins"
+end
+
+-- persistKeys 是要落盘的字段。故意不含 sessions(登录会话是短命态,重启即失效,
+-- 写下去只会留下过期垃圾)与 browser(进程 pid 跨重启无意义)。
+local PERSIST_KEYS = {
+ "accounts", "pool", "modelDisabled", "settings",
+ "providerOrder", "log", "injected_platforms",
+}
+
+local function persistState()
+ local dir = storeDir()
+ local path = dir .. "/" .. STORE_NAME
+ local snap = {}
+ for _, k in ipairs(PERSIST_KEYS) do
+ if plugin.state[k] ~= nil then snap[k] = plugin.state[k] end
+ end
+ local body = json.encode({ version = 1, state = snap })
+ if not body then return false, "json.encode 失败" end
+ local tmp = path .. ".tmp"
+ local w = io.open(tmp, "w")
+ if not w then return false, "无法写入 " .. tmp end
+ w:write(body)
+ w:close()
+ os.execute(("chmod 0600 %s 2>/dev/null"):format(tmp))
+ local ok = os.rename(tmp, path)
+ if not ok then
+ os.remove(tmp)
+ return false, "rename 失败"
+ end
+ return true
+end
+
+local function applySnapshot(snap)
+ for _, k in ipairs(PERSIST_KEYS) do
+ if snap[k] ~= nil then plugin.state[k] = snap[k] end
+ end
+end
+
+-- dropLegacyKernelState 删除内核遗留文件(若存在)。
+--
+-- 必须删:内核的 restore() 在插件源码执行**之后**运行,会把这个文件的内容整体
+-- 覆盖到 plugin.state 上。自有存储已经是权威,留下它等于给"下次启动被旧数据
+-- 覆盖"留一个后门。删它不需要先读它(迁移路径自己会读)。
+local function dropLegacyKernelState()
+ local legacy = storeDir() .. "/.channels.state.json"
+ local f = io.open(legacy, "r")
+ if not f then return end
+ f:close()
+ os.remove(legacy)
+ io.stderr:write("[channels] 已删除内核遗留状态文件(自有存储为权威)\n")
+end
+
+-- loadState 读插件自有文档;缺失时**从内核遗留文件迁移一次**。
+--
+-- 历史背景:v2.0.4 曾让内核的 InvokeHandler 补 markDirty,于是状态被内核写到了
+-- `.channels.state.json`(内核的 .state.json 命名)。现在内核改动已回退,
+-- 那个文件不会再被内核读写;但生产上已经存在一份(含真实账号池),必须迁移过来,
+-- 否则升级即丢账号 —— 与本次要修的缺陷同类,不能自己再造一次。
+-- 迁移后删除遗留文件:留着它只会让「哪个文件权威」变成永久的疑团。
+local function loadOwnSnapshot()
+ local dir = storeDir()
+ local f = io.open(dir .. "/" .. STORE_NAME, "r")
+ if f then
+ local txt = f:read("*a")
+ f:close()
+ if txt and txt ~= "" then
+ local ok, rec = pcall(json.decode, txt)
+ if ok and type(rec) == "table" and type(rec.state) == "table" then
+ applySnapshot(rec.state)
+ return true
+ end
+ -- 损坏文件不阻塞:用内置默认值(下次写入覆盖它)
+ io.stderr:write("[channels] 自有存储损坏,改用默认值\n")
+ end
+ end
+ return false
+end
+
+-- migrateLegacyKernelState 把内核遗留的 .channels.state.json 搬进自有文档。
+local function migrateLegacyKernelState()
+ local dir = storeDir()
+ local legacy = dir .. "/.channels.state.json"
+ local f = io.open(legacy, "r")
+ if not f then return false end
+ local txt = f:read("*a")
+ f:close()
+ if not txt or txt == "" then return false end
+ local ok, rec = pcall(json.decode, txt)
+ if not ok or type(rec) ~= "table" or type(rec.state) ~= "table" then
+ return false
+ end
+ applySnapshot(rec.state)
+ -- 立即写成自有文档;**只有写成功才删遗留文件** —— 写失败还删它,
+ -- 等于用一次迁移把数据弄丢,与本次要修的缺陷同类。
+ local written, werr = persistState()
+ if written then
+ os.remove(legacy)
+ io.stderr:write("[channels] 已迁移遗留状态到 " .. STORE_NAME .. "\n")
+ else
+ io.stderr:write("[channels] 遗留状态迁移写入失败(保留原文件): "
+ .. tostring(werr) .. "\n")
+ end
+ return true
+end
+
+local function loadState()
+ if loadOwnSnapshot() then
+ -- 自有存储已权威:遗留文件必须删掉,否则内核 restore() 会用它覆盖
+ dropLegacyKernelState()
+ return true
+ end
+ return migrateLegacyKernelState()
+end
+
+-- markDirty = 立即同步落盘(tmp + rename 原子替换)。
+--
+-- 为什么不用「置标志 + 定时 flush」:本插件没有 hooks,45 个端点各自
+-- 直接变更状态,还有大量内部调用(syncAccountSource / claim_daily /
+-- auto_checkin 互调)。把 flush 挂在某个边界上就必须保证**每一条**路径都经过
+-- 它,漏一条就是「操作成功但重启丢失」——正是本次要修的缺陷形态。同步写在
+-- 变更点,路径覆盖是结构性的,不依赖我数全 45 个入口。
+--
+-- 代价可接受:状态很小(实测 ~1.7KB),channels 没有 chat 钩子,写入只发生在
+-- 运维动作(登录/领取/开关)与登录轮询(未完成时才有)上,不在请求热路径。
+-- 参考实现同样是每次变更 writeFileSync + renameSync。
+--
+-- 失败处理:写失败**不从状态里回滚**内存变更(内存是对的),只留痕到 stderr
+-- ——不递归调 logEvent,那会再次触发 markDirty。
+local function markDirty()
+ local ok, err = persistState()
+ if not ok then
+ io.stderr:write("[channels] 状态落盘失败: " .. tostring(err) .. "\n")
+ end
+end
+
plugin.state = plugin.state or {}
plugin.state.accounts = plugin.state.accounts or {}
plugin.state.log = plugin.state.log or {}
+-- 启动时从插件自有文档恢复(必须在 plugin.state 建表之后:loadState 往表里填
+-- 字段,先填再被 or {} 覆盖就白填了)。
+loadState()
+
local function logEvent(msg)
table.insert(plugin.state.log, 1, { at = os.time and os.time() or 0, msg = msg })
while #plugin.state.log > 50 do table.remove(plugin.state.log) end
+ markDirty()
end
-- accountOf 取某平台的账号记录(凭据本身在保险库,不在 state 里)
@@ -866,10 +1032,12 @@ end
local function poolAdd(provider, entry)
poolAccounts(provider)[entry.id] = entry
+ markDirty()
end
local function poolRemove(provider, accountId)
poolAccounts(provider)[accountId] = nil
+ markDirty()
end
-- 凭据取值:保险库里存的可能是**裸 token**(历史/device-poll/paste 路径),
@@ -909,12 +1077,41 @@ end
-- CodeBuddy 的 login/account 响应**不含昵称**,昵称只在 access_token 的声明里;
-- token 端又只返回 expiresIn(相对秒数)而不含 expiresAt。两处都得从 JWT 兜底。
-- 解析失败返回 nil(调用方自行处理)。
+-- b64url_decode 纯 Lua 的 base64url 解码(读 JWT payload 用)。
+--
+-- 为什么不用内核的 base64 能力:内核只提供 encode(适配器签名要用),没有 decode。
+-- 为读一个 JWT 声明而给内核加一个通用解码函数,是把插件的事推给内核 ——
+-- 插件能做就该自己做。纯 Lua 实现约 20 行,无依赖、无内核改动。
+-- 接受标准/URL-safe 两种字母表,容忍缺失填充。
+local B64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+local B64_LOOKUP = {}
+for i = 1, #B64_ALPHABET do B64_LOOKUP[B64_ALPHABET:sub(i, i)] = i - 1 end
+local function b64url_decode(input)
+ if type(input) ~= "string" or input == "" then return nil end
+ -- 归一化:URL-safe → 标准,去空白,去掉填充(下面按 4 位一组补零处理)
+ local s = input:gsub("%s", ""):gsub("-", "+"):gsub("_", "/"):gsub("=", "")
+ local out, acc, bits = {}, 0, 0
+ for i = 1, #s do
+ local v = B64_LOOKUP[s:sub(i, i)]
+ if v == nil then return nil end -- 非法字符:如实失败,不猜
+ acc = acc * 64 + v
+ bits = bits + 6
+ if bits >= 8 then
+ bits = bits - 8
+ local byte = math.floor(acc / (2 ^ bits)) % 256
+ out[#out + 1] = string.char(byte)
+ acc = acc % (2 ^ bits)
+ end
+ end
+ return table.concat(out)
+end
+
local function jwtClaim(token, name)
if type(token) ~= "string" or token == "" then return nil end
local payload = token:match("^[^%.]+%.([^%.]+)")
if not payload then return nil end
- local bin, derr = base64_decode(payload) -- 内核新增:base64url 兼容
- if not bin or derr then return nil end
+ local bin = b64url_decode(payload)
+ if not bin then return nil end
local ok, rec = pcall(json.decode, bin)
if not ok or type(rec) ~= "table" then return nil end
return rec[name]
@@ -1045,6 +1242,15 @@ local function disabledModelsOf(provider)
return m
end
+-- setModelDisabled 是**唯一**的模型黑名单写入口:写完即落盘。
+-- 之所以收成一个函数:黑名单的写点曾有 5+ 处(单个开关/批量/清死模型/供应商
+-- 整体开关),散着写必然漏标脏。收口后只有这里需要保证。
+local function setModelDisabled(provider, modelId, disabled)
+ local m = disabledModelsOf(provider)
+ if disabled then m[modelId] = true else m[modelId] = nil end
+ markDirty()
+end
+
-- 渠道积分能力表(对齐参考 CHANNEL_CREDITS):声明各渠道有没有领取/余额。
local CHANNEL_CREDITS = {
codearts = { claim = true },
@@ -1148,12 +1354,39 @@ end
-- plugin.state.browser.pids,插件卸载或会话结束时 kill。
-- 绑定地址默认 127.0.0.1(同机操作);远程运维可把 settings.vnc_bind 设成
-- "0.0.0.0" 并依赖 x11vnc 随机会话密码(不设密码的裸 VNC 永远不会出现)。
+--
+-- 为什么需要它:有些平台的登录**必须**在服务端完成 —— 回环重定向落不到用户
+-- 机器上、或授权页要求浏览器内交互(扫码后确认、短信验证码)。这时只有服务端
+-- 浏览器能走完,VNC 是把那个浏览器呈现给运维的唯一途径。
+--
+-- 登录后的取凭据不走「截图 + 人眼看」:浏览器以 --remote-debugging-port 起,
+-- 完成后经 CDP 读 document.cookie / localStorage / 显式取回的 token,
+-- 落到保险库。人只负责在 VNC 窗口里完成交互,机器负责把结果取出来。
local function browserStack()
if not plugin.state.browser then plugin.state.browser = { pids = {}, seq = 0 } end
return plugin.state.browser
end
+-- browserDeps 探测浏览器栈的外部依赖。返回 { ok=bool, missing={...}, present={...} }。
+-- 依赖是**外部系统包**(发行版装的),插件不擅自 apt/dnf 安装 —— 那需要 root 且
+-- 会改动运维的机器;如实报告缺什么,由运维或部署脚本装。deploy.sh 提供安装路径。
+local BROWSER_DEPS = {
+ { bin = "Xvfb", pkg = "xvfb", why = "虚拟显示" },
+ { bin = "x11vnc", pkg = "x11vnc", why = "VNC 服务端" },
+ { bin = "websockify", pkg = "websockify", why = "VNC→WebSocket 网关" },
+ { bin = "python3", pkg = "python3", why = "CDP 取凭据" },
+}
+local function which(bin)
+ local h = io.popen("command -v " .. bin .. " 2>/dev/null")
+ if not h then return false end
+ local p = h:read("*l")
+ h:close()
+ return p ~= nil and p ~= ""
+end
+
+-- reapBrowser 回收本插件拉起的整组进程。进程是插件起的,插件负责杀 ——
+-- 留一个孤儿 chromium 会一直占着显示与内存,且下次 spawn 会撞端口。
local function reapBrowser()
local b = browserStack()
for _, pid in ipairs(b.pids or {}) do
@@ -1163,48 +1396,341 @@ local function reapBrowser()
b.session = nil
end
--- spawnBrowser 拉起一组服务端浏览器。返回 view_url(给 iframe 内嵌)或 nil, err。
--- start_url 是浏览器要打开的登录页;redirect_host 是拼 redirect_uri 用的主机名
--- (浏览器在服务端,127.0.0.1 就是服务端自己的回环 —— 回调直接命中网关)。
+local function browserDeps()
+ local missing, present = {}, {}
+ for _, d in ipairs(BROWSER_DEPS) do
+ if which(d.bin) then present[#present + 1] = d.bin
+ else missing[#missing + 1] = { bin = d.bin, pkg = d.pkg, why = d.why } end
+ end
+ -- chromium 的二进制名随发行版变化(chromium / chromium-browser / google-chrome)
+ local chromium = nil
+ for _, c in ipairs({ "chromium", "chromium-browser", "google-chrome", "google-chrome-stable" }) do
+ if which(c) then chromium = c break end
+ end
+ if chromium then present[#present + 1] = chromium
+ else missing[#missing + 1] = { bin = "chromium", pkg = "chromium", why = "浏览器" } end
+ -- noVNC 静态资源(websockify --web 指向它)
+ local novnc = nil
+ for _, p in ipairs({ "/usr/share/novnc", "/usr/share/webapps/novnc", "/opt/novnc" }) do
+ local f = io.open(p .. "/vnc.html", "r")
+ if f then f:close() novnc = p break end
+ end
+ if not novnc then
+ missing[#missing + 1] = { bin = "novnc", pkg = "novnc", why = "浏览器内嵌页面" }
+ end
+ -- CDP 需要 websocket 客户端
+ local pyws = false
+ if which("python3") then
+ local h = io.popen("python3 -c 'import websocket' 2>/dev/null && echo yes")
+ if h then local r = h:read("*l") h:close() pyws = (r == "yes") end
+ end
+ if which("python3") and not pyws then
+ missing[#missing + 1] = { bin = "python3-websocket", pkg = "python3-websocket", why = "CDP 取凭据(websocket-client)" } end
+ return {
+ ok = #missing == 0,
+ missing = missing,
+ present = present,
+ chromium = chromium,
+ novnc = novnc,
+ }
+end
+
+-- gatewayPort 问内核要本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
+-- 端口不能硬编码(非默认部署会全错)。内核 status 里带 listen(如 "0.0.0.0:8081")。
+-- gatewayPort 求本网关的监听端口。VNC 里的浏览器要回调到本插件的挂载点,
+-- 端口不能硬编码(非默认部署会全错)。
+--
+-- 端口由插件**自己**取得,不问内核:插件在信任模型里就是可信、有完整 OS 能力的
+-- (能拉浏览器栈、能在磁盘上读写),读一下自己的 config.yaml 是它能力范围内的事。
+-- 为了一个端口给内核加只读动作,既不必要,也污染了内核的「零厂商/零部署知识」边界。
+-- 优先级:settings 显式覆盖 > config.yaml 的 listen > 环境变量 > 默认值。
+local function gatewayPort()
+ -- ① settings:运维可显式指定(前置代理 / 反代场景,对外端口与 listen 不同)
+ local s = plugin.state.settings and plugin.state.settings.gateway_port
+ if tonumber(s) then return tonumber(s) end
+ -- ② 配置文件:插件与内核同机,直接读自己的配置
+ for _, path in ipairs({
+ "/etc/llmsproxy/config.yaml",
+ "/usr/local/etc/llmsproxy/config.yaml",
+ }) do
+ local f = io.open(path, "r")
+ if f then
+ local listen = f:read("*a")
+ f:close()
+ if listen then
+ local addr = listen:match("\nlisten:%s*([^\n]%S*)") or listen:match("^listen:%s*([^\n]%S*)")
+ if addr then
+ local p = addr:match(":(%d+)")
+ if p then return tonumber(p) end
+ end
+ end
+ end
+ end
+ -- ③ 实听端口:从内核自己的监听 socket 反查(配置文件缺失/被换时兜底)
+ do
+ local h = io.popen("ss -ltnp 2>/dev/null | grep 'pid=' | grep -o ':[0-9]*' | head -1")
+ if h then
+ local line = h:read("*l")
+ h:close()
+ local p = line and line:match(":(%d+)")
+ if p then return tonumber(p) end
+ end
+ end
+ return 8081
+end
+
+-- waitPort 等端口就绪(就绪探针),避免「进程起来了但还不能连」。
+-- 返回 true/false,最多等 timeout_ms。
+local function waitPort(host, port, timeoutMs)
+ local deadline = (os.time and os.time() or 0) * 1000 + timeoutMs
+ while true do
+ local h = io.popen(("bash -c 'exec 3<>/dev/tcp/%s/%d' 2>/dev/null && echo ok"):format(host, port))
+ local r = h and h:read("*l")
+ if h then h:close() end
+ if r == "ok" then return true end
+ local now = (os.time and os.time() or 0) * 1000
+ if now >= deadline then return false end
+ os.execute("sleep 0.3")
+ end
+end
+
+-- CDP 取凭据的 Python 桥。argv: