From af8b93bd7cb02bc779e21eb440c5d7962bdcb7de Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 11:31:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=86=85=E6=A0=B8+=E6=8F=92=E4=BB=B6):=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=86=99=E7=94=9F=E4=BA=A7=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=B1=A0=20=E2=80=94=E2=80=94=2013=20=E4=B8=AA=E8=B4=A6?= =?UTF-8?q?=E5=8F=B7=E8=A2=AB=E8=A6=86=E7=9B=96=E6=88=90=200=20=E4=B8=AA?= =?UTF-8?q?=EF=BC=88=E7=9C=9F=E5=AE=9E=E6=95=B0=E6=8D=AE=E4=BA=8B=E6=95=85?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户报"完全不可用"。逐平台查 account/list,发现 13 个账号里只有 1 个有凭据, 其余是占位壳子 —— 再查发现**账号池被清空了**,而保险库里凭据还在 (症状正是"账号凭空消失")。本提交修的是这个数据事故的根因。 ## 根因(全程实测,非推断) channels 插件用 `storeDir()` 决定自己的存储位置,它有多级兜底: env → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → 硬编码。 测试台没设 `LLMSPROXY_PLUGIN_DIR` 时,后两级直接命中**生产**: 测试插件目录 = /tmp/TestXXX/002/plugins storeDir 结果 = /etc/llmsproxy/plugins ← 实测输出 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。 一次全量 `go test ./internal/lua` 就把 `/etc/llmsproxy/plugins/.channels-store.json` 从 1730 覆盖成 1986 字节、 账号池清空(inode 都换了)。 同类前科:曾把 26 个测试账号写进生产 store。两次同一类错误。 ## 修法(两层,第二层才是根治) 1. **内核把插件的真实目录交给插件**(ground truth,不再靠猜): 新增 `__llmsproxy_plugin_dir` 全局,在 bootPluginState 里注册。 `storeDir()` 优先用它 —— 这样**即使没有环境变量**也不会落到生产路径。 实测:撤掉 env 后 storeDir 仍落在临时目录。 2. 测试台补隔离:`runtimePlugins` 设 env + `DisableStatePersistence()` (插件声明了 schedule 时内核会自动起调度器,**后台 goroutine** 比 `t.Setenv` 生命周期长,测试结束后仍可能落盘)。 ## 判据 + 变异验证 `internal/lua/store_dir_isolation_test.go`: - storeDir 绝不能是 /etc/llmsproxy 前缀 - 必须落在临时目录 - env 必须真的被设上(机制而非巧合) **变异验证**:把隔离设成生产路径 → 判据变红并打印 「storeDir 指向了**生产**目录」;恢复 → 变绿。 全量回归:9 个包全绿,且 `go test ./...` 后生产存储**未被改动**(此前必被改)。 --- internal/lua/plugin_bridges.go | 21 ++++++++ internal/lua/plugin_runtime_test.go | 16 +++++++ internal/lua/plugins.go | 2 + internal/lua/plugins/channels.lua | 18 ++++++- internal/lua/store_dir_isolation_test.go | 61 ++++++++++++++++++++++++ 5 files changed, 117 insertions(+), 1 deletion(-) create mode 100644 internal/lua/store_dir_isolation_test.go diff --git a/internal/lua/plugin_bridges.go b/internal/lua/plugin_bridges.go index 56387de..3817fcc 100644 --- a/internal/lua/plugin_bridges.go +++ b/internal/lua/plugin_bridges.go @@ -24,6 +24,27 @@ import ( // secretGlobal 是凭据保险库的 Lua 名字。 const secretGlobal = "__llmsproxy_secrets" +// pluginDirGlobal carries the plugin's OWN directory as ground truth. +// +// Why it must come from the kernel (live defect): the channels plugin decides +// where to keep its state by reading /etc/llmsproxy/config.yaml when +// LLMSPROXY_PLUGIN_DIR is unset. Tests load the real plugin but never set that +// variable, so they read AND WROTE the production store — one full +// `go test ./internal/lua` replaced the production account pool with the test's +// empty one (13 accounts → 0; the credentials stayed in the vault, so the +// symptom was "accounts vanished"). The kernel already knows the true directory +// (ps.dir), so handing it over removes the guess entirely: a plugin no longer +// has to infer its own location from a config file it might not own. +const pluginDirGlobal = "__llmsproxy_plugin_dir" + +// registerPluginDirBridge installs the plugin's own directory as a plain string +// global. Deliberately a string, not a table: there is nothing to call, and a +// bare value cannot be shadowed by a later SetField on a shared table. +func registerPluginDirBridge(L *golua.State, dir string) { + L.PushString(dir) + L.SetGlobal(pluginDirGlobal) +} + // kernelGlobal 是内核动作面的 Lua 名字。 const kernelGlobal = "__llmsproxy_kernel" diff --git a/internal/lua/plugin_runtime_test.go b/internal/lua/plugin_runtime_test.go index b2ec628..1923dca 100644 --- a/internal/lua/plugin_runtime_test.go +++ b/internal/lua/plugin_runtime_test.go @@ -2,6 +2,7 @@ package lua import ( "fmt" + "os" "testing" "time" ) @@ -30,8 +31,23 @@ func runtimePlugins(t *testing.T) (*VM, *Plugins, string) { } t.Cleanup(vm.Stop) pdir := fmt.Sprintf("%s/plugins", t.TempDir()) + // ⚠️ **必须**设 LLMSPROXY_PLUGIN_DIR。channels 插件用 storeDir() 决定自己的 + // 存储位置,而 storeDir 在环境变量缺失时会去读 /etc/llmsproxy/config.yaml —— + // 那是**生产路径**。曾经因此把生产账号池覆盖成测试的空 pool + // (真实事故:一次全量 internal/lua 后 /etc/llmsproxy/plugins/ + // .channels-store.json 里 13 个账号变成 0 个,凭据却还留在保险库)。 + if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { + t.Setenv("LLMSPROXY_PLUGIN_DIR", pdir) + } ps := NewPlugins(vm, pdir) ps.SetOutboundHosts([]string{"127.0.0.1"}) + // ⚠️ 关掉状态持久化。插件声明了 schedule 时内核会自动起调度器 + // (plugins.go: p.Runtime = ps.startRuntime(...)),而真 channels 的 + // refresh_models 会在 5s 后(first_due_now)跑一轮并 logEvent → markDirty。 + // 那是**后台 goroutine**,比 t.Setenv 的生命周期长:测试结束后它仍可能落盘, + // 此时环境变量已还原,storeDir() 就兜底到生产路径。 + // 实测:只跑 internal/lua 全会话,生产 store 的 log 会多出调度器条目。 + ps.DisableStatePersistence() return vm, ps, pdir } diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 9ca3099..55cc278 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -704,6 +704,8 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor // the kernel. registerSecretBridge(L, ps, name) registerKernelBridge(L, ps, name) + // The plugin's own directory, as ground truth (see pluginDirGlobal). + registerPluginDirBridge(L, ps.dir) } if err := L.DoString(code); err != nil { L.Close() diff --git a/internal/lua/plugins/channels.lua b/internal/lua/plugins/channels.lua index 718c914..c2049c0 100644 --- a/internal/lua/plugins/channels.lua +++ b/internal/lua/plugins/channels.lua @@ -52,7 +52,7 @@ plugin.kernel = { "add_source", "list_sources", "probe_all", "reset_health", "st -- WebUI 可调用的导出函数(POST /api/plugins/channels/call/{fn})。 -- set_endpoints 让运维把渠道端点指向自建代理/镜像;测试用它打假上游。 -plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "credits_capability", "claims_capable" } +plugin.exports = { "set_endpoints", "account_add", "account_list", "account_update", "account_delete", "account_test", "account_refresh", "account_reorder", "account_reset", "account_retest", "model_list", "model_refresh", "model_set_disabled", "model_set_disabled_many", "model_set_all_disabled", "model_clear_dead", "provider_status", "provider_set_enabled", "provider_get_order", "provider_set_order", "credits_claim_all", "credits_status", "credits_balances", "credits_permanent_lock", "backup_export", "backup_import", "backup_status", "auto_checkin", "gateway_get_enabled", "gateway_set_enabled", "usage_token_ledger", "usage_badge", "usage_badge_preference", "onboarding_status", "onboarding_claim", "__trae_derive_probe", "__codearts_sign_probe", "__qoder_machine_probe", "__qoder_refresh_probe", "__codearts_dpop_probe", "__codearts_cred_probe", "__codearts_adapter_sign_probe", "__sync_source_probe", "__trae_solo_probe", "__adapter_install_probe", "__store_dir_probe", "credits_capability", "claims_capable" } @@ -2319,8 +2319,17 @@ local function storeDir() -- 显式覆盖优先(测试与多实例部署都靠它隔离,避免读到别处的生产配置) local env = os.getenv and os.getenv("LLMSPROXY_PLUGIN_DIR") if type(env) == "string" and env ~= "" then return env end + -- 内核交给本插件的目录(ground truth,不需要猜)。 + local kern = __llmsproxy_plugin_dir + if type(kern) == "string" and kern ~= "" then return kern end local s = plugin.state.settings and plugin.state.settings.store_dir if type(s) == "string" and s ~= "" then return s end + -- ⚠️ 优先用内核交给我们的**本插件目录**(`__llmsproxy_plugin_dir`): + -- 那是 ground truth,不需要猜。此前这里直接落到"读生产 config.yaml"的兜底, + -- 在**未设 LLMSPROXY_PLUGIN_DIR 的测试里**会命中生产路径 —— 测试读生产存储、 + -- 也写生产存储。一次全量 internal/lua 就把生产账号池覆盖成测试的空 pool + -- (实测:13 个账号 → 0,凭据还在保险库,症状是"账号凭空消失")。 + -- 判据:internal/lua/store_dir_isolation_test.go。 for _, path in ipairs({ "/etc/llmsproxy/config.yaml", "/usr/local/etc/llmsproxy/config.yaml" }) do local f = io.open(path, "r") if f then @@ -7974,6 +7983,13 @@ plugin.ui = { }, } +-- plugin.__store_dir_probe:只给测试用 —— 报告本插件实际使用的存储目录。 +-- 测试污染的排查完全依赖它:storeDir() 有多级兜底(env → settings → 读生产 +-- config.yaml → 硬编码 /etc/llmsproxy/plugins),写错一级就会让测试**写进生产**。 +function plugin.__store_dir_probe() + return { json = { store_dir = storeDir() } } +end + -- plugin.credits_capability:只给测试用 —— 暴露能力表条目,让判据能断言 -- "表与参考真值一致"(表错了只会静默少领,没有任何报错)。 function plugin.credits_capability(args) diff --git a/internal/lua/store_dir_isolation_test.go b/internal/lua/store_dir_isolation_test.go new file mode 100644 index 0000000..69b4d21 --- /dev/null +++ b/internal/lua/store_dir_isolation_test.go @@ -0,0 +1,61 @@ +package lua + +import ( + "os" + "strings" + "testing" +) + +// **测试绝不允许写生产存储** —— 这条是真实事故的回归判据。 +// +// 事故:channels 插件用 storeDir() 决定自己的存储位置,而 storeDir 有多级兜底: +// 环境变量 → settings → **读 /etc/llmsproxy/config.yaml 的 plugin_dir** → +// 硬编码 /etc/llmsproxy/plugins。测试台没设环境变量时,后两级直接命中**生产**: +// +// 测试插件目录 = /tmp/TestXXX/002/plugins +// storeDir 结果 = /etc/llmsproxy/plugins ← 实测 +// +// 于是测试加载真 channels.lua 时**读的是生产存储、写的也是生产存储**。一次全量 +// `go test ./internal/lua` 就把生产账号池覆盖成测试的空 pool: +// /etc/llmsproxy/plugins/.channels-store.json 从 1730 变 1986 字节, +// 13 个账号变成 0 个(凭据还留在保险库,所以症状是"账号凭空消失")。 +// +// 同类前科(本文件存在的另一半理由):曾经有 26 个测试账号被写进生产 +// .channels-store.json。两次都是"测试没隔离存储目录",所以这条判据必须存在。 +func TestStoreDirIsolatedInTests(t *testing.T) { + _, ps, pdir := runtimePlugins(t) + src, err := bundledAdapters.ReadFile("plugins/channels.lua") + if err != nil { + t.Fatal(err) + } + if err := ps.LoadSource("channels", string(src)); err != nil { + t.Fatal(err) + } + out, err := ps.Call("channels", "__store_dir_probe", nil) + if err != nil { + t.Fatalf("探针: %v", err) + } + w, _ := out.(map[string]interface{}) + j, _ := w["json"].(map[string]interface{}) + if j == nil { + t.Fatalf("探针无 json: %#v", out) + } + got, _ := j["store_dir"].(string) + t.Logf("测试插件目录 = %s", pdir) + t.Logf("storeDir 结果 = %s", got) + + // ① 绝不能是生产目录 + if got == "/etc/llmsproxy/plugins" || strings.HasPrefix(got, "/etc/llmsproxy") { + t.Fatalf("storeDir 指向了**生产**目录 %q —— 测试会覆盖生产账号池\n"+ + "(真实事故:13 个账号被覆盖成 0 个)。修法:runtimePlugins 设\n"+ + "LLMSPROXY_PLUGIN_DIR 到 t.TempDir()", got) + } + // ② 必须落在测试的临时目录里 + if !strings.Contains(got, pdir) && !strings.HasPrefix(got, os.TempDir()) { + t.Errorf("storeDir 应落在临时目录(%s 或 %s),实得 %q", pdir, os.TempDir(), got) + } + // ③ 环境变量必须真的被设上(机制而非巧合) + if os.Getenv("LLMSPROXY_PLUGIN_DIR") == "" { + t.Error("runtimePlugins 必须设 LLMSPROXY_PLUGIN_DIR(否则 storeDir 会读生产 config.yaml)") + } +}