feat(lua): 插件可调内核动作面(R4-c)+ 凭据保险库(R4-d)

用户指出「接口不全」——此前插件只有 __llmsproxy_adapters 三个手写闭包,
够不到内核。对照参考插件(dsh-our-free-model,13 个账号渠道)的真实能力,
缺的正是这两块:

  插件自己驱动链接认证 → 存凭据 → 定时领额度 → 把源接进内核

## R4-c 内核动作面:__llmsproxy_kernel

18 个动作,全部映射到既有 Core 方法(不是新造能力):

  只读 6:status / list_sources / list_models / list_adapters /
          list_auto_rules / adapter_owner
  写   8:reload / reset_health / probe_all / save_auto_rules /
          save_key_auto / add_source / create_key / update_key
  破坏性 4:remove_source / delete_key / remove_adapter / set_plugin_enabled

**add_source 是「插件注册自己的上游」的落点**——插件拿到凭据后可以自己把
端点接进网关,无需运维改 config.yaml。已端到端验证:插件调它 → 内核源列表
出现 → 模型进入可路由列表。

依赖方向:lua 不能 import core,所以接口定义在 lua、实现放 core
(kernel_actions.go),走 SetKernelAPI 注入——与 SetOutboundHosts 同一先例。

**凭据边界**:动作面**有意排除** list_keys/find_key。Core.ListKeys() 返回
明文网关密钥,交给第三方 Lua 等于交出整个部署的凭据面。有回归测试锁死
这条(TestKernelActionSurfaceExcludesPlaintextKeys)。list_sources 返回的
api_key 也强制掩码(只给 ***+后4位)。

**权限**(用户选「全开+设置页可配」):18 个动作全部可达,但**默认拒绝**,
授权落盘 .kernel-perms.json(独立文件,不并进 .access.json——后者是
map[string]string,塞进去会破坏旧二进制能读的格式)。danger 级不接受通配符。
插件可在源码 plugin.kernel={...} **申请**,设置页显示申请 vs 已授予。

**Go 侧 deadline**(用户选定):每个动作有超时预算,超时把 (nil,err) 还给
Lua。这可行的原因:Lua 只是等返回值,不占 Lua 栈执行权——与「Lua 死循环
不可中断」是两件事。

## R4-d 凭据保险库:__llmsproxy_secrets

此前插件存 token 只能写 plugin.state:0755 目录、明文、与计费累计量同文件、
会被 PUT /state 整体替换、会进 WebUI。现在独立文件:

  <plugin_dir>/.secrets/<plugin>.json   文件 0600,目录 0700

临时文件**先 chmod 再 rename**,全程不存在宽权限窗口。读取身份在插件 boot
时绑定(不从 Lua 状态现读)⇒ 跨插件读不可行是机制而非约定。插件删除时清空
(DeleteAllSecrets + 启动 PurgeOrphanVaults)。

## 开发中被测试抓出的两个真实缺陷(我自己引入的)

写了 TestNoKernelActionDeadlocksFromLua 对**每个**动作断言「在插件 Lua 调用
(此时持有 p.mu)内不得死锁」,抓出:

1. **自死锁**:set_plugin_enabled → SetEnabled → p.mu.Lock(),而死锁发生在
   调用者已持有的同一把锁上。
2. **更隐蔽的迟到副作用**:超时把 error 返回给插件后,动作仍在幕后执行完成——
   插件以为失败,实际已生效。

修法:标注 Deferred 的动作在本次调用返回后异步执行,如实返回 accepted:true
而不是谎报成功。18 个动作全部复验无死锁,另有单独测试确认副作用真的落地
(只测「不死锁」的话,一个全空转的 dispatch 也能通过)。

测试 11 个(core 8 + lua 3):动作可用/密钥掩码/默认拒绝/授权计数/
插件注册源并变为可路由/授权落盘重启保留/未知动作名被拒/凭据面排除明文/
全动作无死锁/破坏性动作生效/Lua 侧桥接可用。
This commit is contained in:
JianFeeeee
2026-10-11 13:52:29 +08:00
parent dafc3b3fc3
commit a59295e826
11 changed files with 2062 additions and 0 deletions

View File

@ -0,0 +1,245 @@
package lua
import (
"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"
// 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
}