Files
ModelRouter/docs/design-plugin-kernel-api.md
JianFeeeee a59295e826 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 侧桥接可用。
2026-10-11 13:52:29 +08:00

118 lines
5.4 KiB
Markdown
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.

# 插件可调用内核动作(R4-c)设计
> 用户决策(2026-10-11):**全开**(13 个内核动作全部可调)+ **设置页可配权限** +
> **Go 侧 deadline 兜底**。
## 1. 要解决的问题
用户原话:"现在这个接口不全,可以多给一些接口让 lua 调用内核。"
现状(`__llmsproxy_adapters`)是我手写的**三个闭包**(install/update/remove),
只能管适配器。内核还有 13 个公开动作(Reload / ResetHealth / AddSource /
RemoveSource / SaveAutoRules / key 增删改 / 列表读取…),插件一个都调不到。
而"自动孵化 / 自愈 / 联动"这类插件能力(例如:探测到上游全挂 → 触发重探 +
清冷却;发现额度变化 → 重排 AUTO 链)恰恰需要它们。
## 2. 关键认识:Go 调用可以超时,Lua 死循环不能
这决定了整个设计,也是用户提"阻塞 + Go 兜底"的原因:
| 形态 | 能否中断 | 处置方式 |
|---|---|---|
| Lua 里 `while true do end` | **不能**(LuaJIT 限制,已实测三次) | 只能停用插件 |
| Lua 里 `__llmsproxy_kernel.call("reload")` | **能** | Go 侧 `select` + deadline,超时把错误返回给 Lua |
因为 Lua 只是在等 Go 的返回值,**不占用 Lua 栈的执行**。所以给每次内核调用加
Go 侧 deadline 是可行的、干净的 —— 不需要"Lua 投递请求、Go 队列执行":那样
还要定义"超时后队列里积压的命令怎么办",而 deadline 直接返回 `(nil, err)`,
语义闭合。
## 3. 依赖方向
`internal/lua` **不能** import `internal/core`(方向是 core → lua)。
按既有先例(`SetOutboundHosts`)注入一个**接口**,由 core 实现:
```go
// internal/lua/kernel_api.go
type KernelAction struct {
Name string // "reload" / "reset_health" / ...
Title string // 设置页展示用
Description string
Danger string // ""=只读, "write"=改配置, "danger"=破坏性
Params []string // 参数名(设置页/文档用)
}
type KernelAPI interface {
// Actions 列出可调用动作(设置页据此渲染权限开关)
Actions() []KernelAction
// Invoke 执行一个动作,args 为已解码参数。**必须**尊重 ctx 的 deadline。
Invoke(ctx context.Context, name string, args map[string]any) (any, error)
}
```
core 侧实现 `Invoke` 时用**现有的 Core 方法**,本身不持 `p.mu`,因此不会与
插件锁成环(已核实:所有 `ps.mu` → `p.mu` 都是先释放 `ps.mu`)。
## 4. 权限模型
复用 `visibilityOverrides` 的**同一套持久化机制**(`.access.json`),
但用独立的键空间:`kernel:<plugin>` → 逗号分隔的动作名或 `*`。
- **默认拒绝**:插件不显式获得授权时,`call` 返回 `(nil, "not permitted")`。
理由:全开是"能力存在",不是"默认授予"。一个插件能删密钥这件事必须由
运维在设置页点过确认。
- **`danger` 类必须显式逐个授权**,不接受 `*`。这一条是防止运维图省事写
通配符,把"删密钥"和"读状态"等同起来。
- 插件可以在自己源码里**声明**它想要的动作(`plugin.kernel = {...}`),
设置页把它显示为"申请",运维批准后才生效。声明不等于授权。
## 5. 动作清单(13 个 + 适配器 3 个)
| 动作 | 危险级 | 对应 Core 方法 | 超时 |
|---|---|---|---|
| `status` | 只读 | 读 AutoSlotStates/Registry | 2s |
| `list_sources` | 只读 | Sources() | 2s |
| `list_models` | 只读 | Registry().ModelList() | 2s |
| `list_adapters` | 只读 | ListAdapters() | 2s |
| `adapter_owner` | 只读 | AdapterOwnerOf | 2s |
| `reload` | write | Reload() | 30s |
| `reset_health` | write | ResetHealth() | 5s |
| `probe_all` | write | Registry().ProbeAll(ctx) | 60s |
| `save_auto_rules` | write | SaveAutoRules() | 10s |
| `save_key_auto` | write | SaveKeyAuto() | 10s |
| `add_source` | write | AddSource() | 30s |
| `remove_source` | danger | RemoveSource() | 30s |
| `create_key` | write | CreateKey() | 10s |
| `update_key` | write | UpdateKey() | 10s |
| `delete_key` | danger | DeleteKey() | 10s |
| `install_adapter` | write | UploadAdapter() | 10s |
| `remove_adapter` | danger | RemoveAdapter() | 30s |
| `set_plugin_enabled` | danger | SetEnabled() | 5s |
**绝不开放**:`ListKeys()` / `FindKey()` —— 返回**明文密钥**。给插件读密钥等于
把网关的凭据面交给第三方 Lua。要用 key 相关动作就只用带掩码的形态。
## 6. Lua 调用面
```lua
-- 探测:拿到可用动作与自己的授权情况
local acts = __llmsproxy_kernel.actions()
-- 调用:统一入口,返回 (result, err)
local ok, err = __llmsproxy_kernel.call("reset_health", {})
local st, err = __llmsproxy_kernel.call("status", {})
```
统一入口(而不是每个动作一个全局函数)的理由:动作集合将来会变,而
`__llmsproxy_kernel` 的签名不变 —— 否则每加一个动作就要动
`bootPluginState`,而那里正是历史上出过 cgo 崩溃的地方。
## 7. 实施顺序
1. `internal/lua/kernel_api.go`:接口 + 动作元数据 + 权限检查 + deadline 包装
2. `internal/lua/plugin_kernel_bridge.go`:Lua 绑定(`actions` / `call`)
3. `internal/core/kernel_actions.go`:实现 `KernelAPI`(core 侧)
4. 权限持久化:复用 `.access.json`,新键空间
5. 设置页:插件行加"内核动作"列 + 授权编辑
6. 测试:Go 侧全动作、Lua 侧调用、权限拒绝、deadline 生效、危险动作需显式授权