fix(内核+插件): 测试写生产账号池 —— 13 个账号被覆盖成 0 个(真实数据事故)

用户报"完全不可用"。逐平台查 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 ./...` 后生产存储**未被改动**(此前必被改)。
This commit is contained in:
JianFeeeee
2026-10-14 11:31:41 +08:00
parent 3819b00ded
commit af8b93bd7c
5 changed files with 117 additions and 1 deletions

View File

@ -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"

View File

@ -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
}

View File

@ -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()

View File

@ -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)

View File

@ -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)")
}
}