diff --git a/internal/core/channels_plugin_test.go b/internal/core/channels_plugin_test.go index 7c06181..690eff6 100644 --- a/internal/core/channels_plugin_test.go +++ b/internal/core/channels_plugin_test.go @@ -340,3 +340,99 @@ func TestInjectPlatformsRejectsBadInput(t *testing.T) { 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/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 32aa4f4..d57e413 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -804,13 +804,191 @@ 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 里) @@ -854,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 路径), @@ -897,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] @@ -1033,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 }, @@ -1136,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 @@ -1151,48 +1396,288 @@ 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: