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

@ -244,6 +244,11 @@ type Plugin struct {
// was supposed to inherit from.
persisted bool
// KernelDeclared lists the kernel actions this plugin asks for in its own
// source (`plugin.kernel = {...}`). It is a REQUEST, shown to the operator
// beside what was actually granted; it grants nothing by itself.
KernelDeclared []string
// Schedule is the periodic work this plugin declared, and Runtime is the
// scheduler that runs it on THIS plugin's own VM (p.state) under p.mu — the
// same state and the same lock a request hook uses. That is what makes a
@ -293,6 +298,15 @@ type Plugins struct {
// nil means deny-all: an unconfigured gateway must not silently grant a
// plugin unrestricted network access.
allowHTTP *httpAllowlist
// kernelAPI is the injected kernel action surface (nil when unwired).
// See kernel_api.go: a plugin calling __llmsproxy_kernel reaches the core
// through this interface, never through a direct import (core depends on
// lua, not the other way round).
kernelAPI KernelAPI
// kernelPerms records what the OPERATOR granted each plugin. Separate from
// what a plugin DECLARES it wants (Plugin.KernelDeclared): a declaration is
// a request, a grant is a privilege. Persisted in .access.json.
kernelPerms kernelPermissionSet
// save coalesces state writes into one background goroutine.
//
// A hook must NOT write synchronously: the billing plugin mutates state on
@ -543,6 +557,12 @@ func (ps *Plugins) LoadDir() error {
if err := ps.LoadAccessOverrides(); err != nil {
log.Printf("[core] plugin access overrides not loaded: %v", err)
}
// Kernel grants load beside them and for the same reasons: an in-memory map
// would make "this plugin may reload config" look enforced right up to the
// next restart, at which point the grant silently vanished.
if err := ps.LoadKernelPermissions(); err != nil {
log.Printf("[core] plugin kernel permissions not loaded: %v", err)
}
if _, err := os.Stat(ps.dir); os.IsNotExist(err) {
return nil
}
@ -609,6 +629,12 @@ func bootPluginState(code, name string, allow *httpAllowlist, ps *Plugins) (*wor
// as __llmsproxy_http: adapters share setupGlobals and must not get it.
if ps != nil {
registerAdapterBridge(L, ps, name)
// R4-d: the credential vault and the kernel action surface. Both are
// plugin-only, for the same reason as the bridges above — an adapter
// shares setupGlobals and must not be able to read credentials or drive
// the kernel.
registerSecretBridge(L, ps, name)
registerKernelBridge(L, ps, name)
}
if err := L.DoString(code); err != nil {
L.Close()
@ -743,6 +769,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
p.UI = ui
}
// ---- declared kernel requests ----
// A declaration is a REQUEST, not a grant (see kernel_api.go). Reading it at
// load time lets the settings page show "this plugin wants add_source"
// beside what was actually granted.
p.KernelDeclared = readPluginKernelDeclared(w.L)
w.L.SetTop(0)
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)