Files
ModelRouter/internal/lua/plugin_bridges.go
JianFeeeee af8b93bd7c 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 ./...` 后生产存储**未被改动**(此前必被改)。
2026-10-14 11:31:41 +08:00

286 lines
8.5 KiB
Go
Raw Permalink 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 (
"fmt"
"sort"
"strings"
golua "github.com/aarzilli/golua/lua"
)
// Package lua — Lua 绑定:凭据保险库 + 内核动作面
//
// 这两个全局把"插件自己主导链接认证"所需的两块补齐:
//
// __llmsproxy_secrets —— 安全存放 OAuth token / session / 设备指纹
// __llmsproxy_kernel —— 触发内核动作(注册源、探测、清冷却、改链路…)
//
// 注册点与 __llmsproxy_http 相同:只在 PLUGIN 的 bootPluginState 里装,
// 因为适配器共用 setupGlobals 而不该拿到这些能力。
//
// 两张表都用 buildJSONTable 的既有写法(NewTable + PushGoClosure + SetField),
// 该写法在 json 表上长期稳定,也是内核自己的用法。
// 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"
// registerSecretBridge installs the credential vault onto a plugin's state.
//
// The plugin name is bound HERE, at boot, rather than read from the Lua state at
// call time. That is what makes "a plugin can only read its OWN credentials" a
// mechanism instead of a convention: a shard or any other holder of the compiled
// script cannot pass a different name.
func registerSecretBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
L.PushGoClosure(luaSecretSet(ps, name))
L.SetField(-2, "set")
L.PushGoClosure(luaSecretGet(ps, name))
L.SetField(-2, "get")
L.PushGoClosure(luaSecretDelete(ps, name))
L.SetField(-2, "delete")
L.PushGoClosure(luaSecretKeys(ps, name))
L.SetField(-2, "keys")
L.SetGlobal(secretGlobal)
}
// luaSecretSet: set(key, value) -> (true, nil) | (nil, err)
func luaSecretSet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
val := L.ToString(2)
if err := ps.SetSecret(name, key, val); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// luaSecretGet: get(key) -> (value, nil) | (nil, err). A missing key yields
// (nil, nil) so a plugin can test `if tok == nil then login() end` — the "no
// credential stored yet" case is the common one for a fresh install, and making
// it an error would force every caller to string-match on a message.
func luaSecretGet(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
v, ok, err := ps.GetSecret(name, key)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
if !ok {
L.PushNil()
L.PushNil()
return 2
}
L.PushString(v)
L.PushNil()
return 2
}
}
// luaSecretDelete: delete(key) -> (true, nil) | (nil, err)
func luaSecretDelete(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
key := L.ToString(1)
if err := ps.DeleteSecret(name, key); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
// luaSecretKeys: keys() -> (array of key names, nil). Names only — a plugin can
// render "which accounts are configured" without ever echoing token bytes.
func luaSecretKeys(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
keys, err := ps.SecretKeys(name)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.NewTable()
for i, k := range keys {
L.PushString(k)
L.RawSeti(-2, i+1)
}
L.PushNil()
return 2
}
}
// registerKernelBridge installs the kernel action surface onto a plugin's state.
func registerKernelBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
L.PushGoClosure(luaKernelActions(ps, name))
L.SetField(-2, "actions")
L.PushGoClosure(luaKernelCall(ps, name))
L.SetField(-2, "call")
L.SetGlobal(kernelGlobal)
}
// luaKernelActions: actions() -> (array of {name,title,danger,description,params,
// granted}, nil)
//
// It reports GRANTED alongside each action so a plugin can degrade gracefully
// (skip the part that needs a permission it does not have) instead of
// discovering it by hitting an error mid-operation.
func luaKernelActions(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
perm := ps.PluginKernelPermission(name)
L.NewTable()
for i, a := range KernelActions() {
L.NewTable()
L.PushString(a.Name)
L.SetField(-2, "name")
L.PushString(a.Title)
L.SetField(-2, "title")
L.PushString(a.Danger)
L.SetField(-2, "danger")
L.PushString(a.Description)
L.SetField(-2, "description")
L.PushBoolean(perm.allows(a))
L.SetField(-2, "granted")
L.NewTable()
for j, p := range a.Params {
L.PushString(p)
L.RawSeti(-2, j+1)
}
L.SetField(-2, "params")
L.RawSeti(-2, i+1)
}
L.PushNil()
return 2
}
}
// luaKernelCall: call(name, args) -> (result, nil) | (nil, err)
//
// One entry point rather than a function per action: the action set will change,
// and a table field per action would mean editing bootPluginState — the exact
// place that produced a cgo crash during earlier work.
func luaKernelCall(ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
action := L.ToString(1)
args := map[string]interface{}{}
if L.Type(2) == golua.LUA_TTABLE {
var raw interface{}
if err := luaToJSON(L, 2, &raw); err == nil {
if m, ok := raw.(map[string]interface{}); ok {
args = m
}
}
}
v, err := ps.callKernel(name, action, args)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
if v == nil {
L.PushNil()
} else {
pushGoValue(L, v)
}
L.PushNil()
return 2
}
}
// readPluginKernelDeclared reads `plugin.kernel`, the actions a plugin asks for.
//
// The shape is a list of names:
//
// plugin.kernel = { "status", "reset_health" }
//
// Unknown names are kept rather than dropped so the settings page can show a
// typo as "requested but not a real action" — silently discarding it would make
// a plugin's intent invisible at exactly the moment the operator is deciding
// what to grant.
func readPluginKernelDeclared(L *golua.State) []string {
raw, ok := pluginFieldValue(L, "kernel")
if !ok {
return nil
}
items, isList := raw.([]interface{})
if !isList {
if one, isTable := raw.(map[string]interface{}); isTable {
// A single string field or a set-style table; accept both spellings.
if v, ok := one["actions"].([]interface{}); ok {
items = v
} else {
return nil
}
} else {
return nil
}
}
out := make([]string, 0, len(items))
seen := map[string]bool{}
for _, it := range items {
name, ok := it.(string)
if !ok {
continue
}
name = strings.TrimSpace(name)
if name == "" || seen[name] {
continue
}
seen[name] = true
out = append(out, name)
}
sort.Strings(out)
return out
}
// luaTableArg decodes a Lua table argument into a Go map, reporting a clear error
// when the caller passed something else (a common mistake that would otherwise
// surface as a silent zero value).
func luaTableArg(L *golua.State, idx int) (map[string]interface{}, error) {
if L.Type(idx) != golua.LUA_TTABLE {
return nil, fmt.Errorf("expected a table argument, got %s", normalizeString(L, idx))
}
var raw interface{}
if err := luaToJSON(L, idx, &raw); err != nil {
return nil, err
}
m, ok := raw.(map[string]interface{})
if !ok {
return nil, fmt.Errorf("argument must be a keyed table")
}
return m, nil
}