refactor(plugins): 声明式解码器 —— 未识别的字段不再静默丢弃

## 问题

三个读取函数(readPluginUI / readPluginExports / readPluginHTTP)各自手写一遍
「取字段、转换、追加」,字段名在 .lua 和 Go 里各存一份。加一个字段要改两处,
**漏了 Go 那一半不会报错**,字段静默读成未设置:

    ui = { visibilty = "admin" }   -- 一个字母之差
    ⇒ 什么都没限制,且与「插件主动不声明 audience」完全无法区分

今天一天里因此丢了三个字段(exports、http 端点、visibility/state_scope),
每次都是靠「量服务端返回的长度对比磁盘文件」反推出来的。

## 改动

新增 internal/lua/plugin_decode.go:schema 是数据,不是反射也不是 struct tag。
字段清单在一个地方,插件作者读一张表就知道网关认什么;不认识的字段**上报而非
丢弃**,并给出「你是不是想写 visibility」这种可执行的提示。

readPluginUI 2270 → 860 字符,readPluginExports 732 → 392。顺手去掉了
readPluginUI 被调用两次、其中第二次对已编译的 depth map 再编译一遍。

## 顺带修掉一个真 bug:handler 必须是名字而非函数值

`asString` 对任何非字符串都 `fmt.Sprintf("%v")`,所以写成
`handler = function(req) ... end` 会变成字符串 "function: 0x7f...",被当成
handler 名接受,端点注册成功,然后在**派发时**失败 —— 离声明处十万八千里。
现在在解码时就拒绝,并同时报出字段名和 path。

## 更正一个我写了但从未核实的说法

之前那批修复的注释里(三个文件 + 提交信息)都写着「插件注册发生在 setupGlobals
安装 json 全局之前,所以 luaToJSON 返回空」。**这个说法是错的**,实测:

    setupGlobals 在 vm.go:574,读取函数在 787,顺序相反
    json global present when readPlugin* runs: true
    luaToJSON 在同一个栈形态上解码正确,返回了完整的 visibility 和 pages

真正的差异是**索引语义**:luaTableToGoValue 会把负索引立即折算成绝对索引
(GetTop()+idx+1),luaToJSON 则保留相对索引并在 Call 前 push。栈一旦被
SetTop(0) 清掉,两者行为分道扬镳。

一个错误解释重复三次比没有解释更糟 —— 它会把下一个读代码的人送去寻找一个
不存在的时序 bug。三处注释都已改成实测结论,并说明这个错误是怎么被发现的。

## 判据

- 拼错的字段被上报、给出 did-you-mean,且该 audience 确实没被应用
- 正确声明零诊断(否则会被静音,然后什么也抓不到)
- 内联函数形式的 handler 被拒并说明原因;`handler = "name"` 形式不受影响
- decode 边界单独一条:走真实注册路径加载插件,断言声明的值都读回来了

变异自证:strictKeys 短路 → 变红。

已知局限(写在判据文件里):把 luaTableToGoValue 换回 luaToJSON 的变异目前
**抓不住** —— 已实测那两条路径在当前栈形态下等价。要让它可检出,需要一条
在 json 全局缺席时走注册路径的判据;现在这条是靠 review 和注释保证的,
不假装它是变异可证的。

Co-Authored-By: ModelRouter <noreply@modelrouter.dev>
This commit is contained in:
JianFeeeee
2026-10-04 20:10:07 +08:00
parent 700f4179fa
commit 2fd536e99b
3 changed files with 620 additions and 137 deletions

View File

@ -667,20 +667,13 @@ func (ps *Plugins) LoadSource(name, code string) error {
}
// ---- UI ----
if ui, ok := readPluginUI(w.L); ok {
if ui, ok := readPluginUI(w.L, p.Info.Name); ok {
p.UI = ui
}
// ---- http endpoints ----
p.http = readPluginHTTP(w.L)
if ui, ok := readPluginUI(w.L); ok {
p.UI = ui
if ui.StateScope != nil {
ui.StateScope.Depth = compileDepthMap(ui.StateScope.Depth)
}
}
// ---- exports ----
// Read here, while the state is locked and before restore() runs: the list is
// static for the life of the plugin, so Call never has to re-enter Lua just
@ -778,111 +771,112 @@ func pluginHookName(L *golua.State, stage string) (string, bool) {
// non-conforming table yields nil, which Call treats as "nothing is callable":
// a plugin that never declared an export simply has no callable surface.
func readPluginExports(L *golua.State) []string {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "exports")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON: this runs while the plugin is registering,
// before the json global is installed, and luaToJSON needs it. Going through
// JSON here silently produced an empty list, which then made every exported
// function look unexported.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
arr, ok := raw.([]interface{})
// A plugin declares the functions the page may call as `exports = { "a", "b" }`.
// Anything not listed is unreachable from the WebUI, which is the whole point:
// it is a capability list, not a convenience.
raw, ok := pluginFieldValue(L, "exports")
if !ok {
return nil
}
list := make([]string, 0, len(arr))
for _, v := range arr {
if s := asString(v); s != "" {
list = append(list, s)
}
}
return list
return decStringList(map[string]interface{}{"v": raw}, "v")
}
func readPluginUI(L *golua.State) (*UIExtension, bool) {
func readPluginUI(L *golua.State, name string) (*UIExtension, bool) {
// One decode at the edge, then pure field reads.
//
// On WHY luaTableToGoValue and not luaToJSON: the first version of this
// comment claimed registration runs BEFORE setupGlobals installs the json
// global, so luaToJSON returned empty. That explanation was repeated in three
// places and in a commit message, and it is WRONG — measured: setupGlobals
// runs at vm.go:574, these readers at 787, and json IS present on the stack
// when they run.
//
// What actually differs is INDEX SEMANTICS. luaTableToGoValue converts a
// NEGATIVE index to absolute immediately (abs = GetTop() + idx + 1);
// luaToJSON keeps the relative index and pushes it before Call. So after a
// SetTop(0) the two disagree, and the table-valued converter — the one that
// handles nested tables, arrays and mixed keys correctly — is the one that
// needs the stack shape kept intact.
//
// Which is exactly why the decode happens here, ONCE, immediately after
// GetGlobal+GetField, and every field read below is a pure function on the
// decoded Go value. The old readers repeated the stack dance per field.
m, ok := pluginFieldTable(L, "ui")
if !ok {
return nil, false
}
ctx := &decodeCtx{plugin: name}
ui := decodeUIExtension(ctx, m)
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
}
if len(ctx.problems) > 0 {
log.Printf("[plugins] %s: %s", ctx.plugin, strings.Join(ctx.problems, "; "))
}
return &ui, true
}
// pluginFieldTable reads one top-level declaration table off the plugin global.
func pluginFieldTable(L *golua.State, field string) (map[string]interface{}, bool) {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil, false
}
L.GetField(-1, "ui")
L.GetField(-1, field)
if L.Type(-1) == golua.LUA_TNIL {
L.SetTop(0)
return nil, false
}
// luaTableToGoValue, NOT luaToJSON. This runs while the plugin is
// registering, BEFORE setupGlobals installs the json global that luaToJSON
// depends on — so the conversion fails and every field except the ones on
// Page/Pages (read separately) comes back empty. That is why visibility and
// state_scope silently read as unset even though the .lua declares them:
// the third field in this file to be lost to the same cause, after
// readPluginExports and readPluginHTTP.
var ui UIExtension
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
if m, ok := raw.(map[string]interface{}); ok {
ui.Visibility = asString(m["visibility"])
if scope, ok := m["state_scope"].(map[string]interface{}); ok {
sc := &StateScope{}
if gs, ok := scope["global"].([]interface{}); ok {
for _, g := range gs {
if s := asString(g); s != "" {
sc.Global = append(sc.Global, s)
}
}
}
if ds, ok := scope["depth"].(map[string]interface{}); ok {
sc.Depth = map[string]int{}
for k, v := range ds {
if n, ok := asFloat(v); ok {
sc.Depth[k] = int(n)
}
}
sc.Depth = compileDepthMap(sc.Depth)
}
ui.StateScope = sc
}
if pg, ok := m["page"].(map[string]interface{}); ok {
ui.Page = uipageFromValue(pg)
}
if pgs, ok := m["pages"].([]interface{}); ok {
for _, it := range pgs {
if pm, ok := it.(map[string]interface{}); ok {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
}
}
if els, ok := m["elements"].([]interface{}); ok {
for _, it := range els {
em, ok := it.(map[string]interface{})
if !ok {
continue
}
el := UIElement{
Target: asString(em["target"]),
Anchor: asString(em["anchor"]),
Mount: asString(em["mount"]),
Audience: asString(em["audience"]),
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
}
ui.Elements = append(ui.Elements, el)
}
m, ok := raw.(map[string]interface{})
return m, ok
}
// decodeUIExtension reads a plugin's `ui` declaration.
//
// The field list below is the single source of truth for what the gateway
// understands. A declaration using anything else is REPORTED rather than
// silently dropped, because the old hand-written reader ignored unknown keys with
// no trace: `visibilty = "admin"` (one character off) restricted nothing and
// looked exactly like a plugin that simply chose not to declare an audience.
func decodeUIExtension(ctx *decodeCtx, m map[string]interface{}) UIExtension {
ctx.strictKeys(m, "ui", "visibility", "state_scope", "page", "pages", "elements")
var ui UIExtension
ui.Visibility = decString(m, "visibility")
if scope := decTable(m, "state_scope"); scope != nil {
ctx.strictKeys(scope, "ui.state_scope", "global", "depth")
sc := &StateScope{Global: decStringList(scope, "global")}
if d := decIntMap(scope, "depth"); len(d) > 0 {
sc.Depth = compileDepthMap(d)
}
ui.StateScope = sc
}
if ui.Page == nil && len(ui.Pages) == 0 && len(ui.Elements) == 0 {
return nil, false
if pg := decTable(m, "page"); pg != nil {
ui.Page = uipageFromValue(pg)
}
return &ui, true
for _, pm := range decTableList(m, "pages") {
ui.Pages = append(ui.Pages, uipageFromValue(pm))
}
for _, em := range decTableList(m, "elements") {
ctx.strictKeys(em, "ui.elements[]", "target", "anchor", "mount", "audience", "order")
el := UIElement{
Target: decString(em, "target"),
Anchor: decString(em, "anchor"),
Mount: decString(em, "mount"),
Audience: decString(em, "audience"),
}
if n, ok := asFloat(em["order"]); ok {
el.Order = int(n)
}
ui.Elements = append(ui.Elements, el)
}
return ui
}
func (ps *Plugins) append(p *Plugin) {
@ -2176,64 +2170,82 @@ func containsPage(pages []*UIPage, id string) bool {
// not exist. Failing the whole plugin would take a working billing widget down
// over a typo in an experimental endpoint.
func readPluginHTTP(L *golua.State) []HTTPHandler {
L.GetGlobal(pluginGlobal)
if L.IsNil(-1) {
L.SetTop(0)
return nil
}
L.GetField(-1, "http")
if L.Type(-1) != golua.LUA_TTABLE {
L.SetTop(0)
return nil
}
// luaTableToGoValue, NOT luaToJSON and NOT a hand-rolled GetField loop.
// `http` is a LIST of declarations, each naming the handler function by
// STRING:
//
// luaToJSON needs the json global, which is not installed yet while a plugin
// is registering. And golua's GetField does NOT convert a numeric key: with
// a string index "1" it returns nil, so an index loop silently produced an
// empty list and every plugin appeared to declare no endpoints at all.
raw := luaTableToGoValue(L, -1)
L.SetTop(0)
items, ok := raw.([]interface{})
// http = {
// { path = "health", method = "GET", roles = { "admin" }, handler = "health" },
// }
//
// handler is a name, not a value. An inline function silently produced an
// empty handler field and the endpoint was dropped without a word.
raw, ok := pluginFieldValue(L, "http")
if !ok {
return nil
}
// A single declaration may be given as a bare table instead of a one-element
// list; accepting both is friendlier than demanding one spelling.
items, isList := raw.([]interface{})
if !isList {
if one, isTable := raw.(map[string]interface{}); isTable {
items = []interface{}{one}
} else {
return nil
}
}
ctx := &decodeCtx{plugin: "http"}
var list []HTTPHandler
for _, it := range items {
m, ok := it.(map[string]interface{})
h, ok := it.(map[string]interface{})
if !ok {
continue
}
h := HTTPHandler{
Path: asString(m["path"]),
Method: asString(m["method"]),
Handler: asString(m["handler"]),
Description: asString(m["description"]),
Public: m["public"] == true,
ctx.strictKeys(h, "http[]", "path", "method", "handler", "description",
"roles", "keys", "public")
dh := HTTPHandler{
Path: decString(h, "path"),
Method: decString(h, "method"),
Description: decString(h, "description"),
Roles: decStringList(h, "roles"),
Keys: decStringList(h, "keys"),
}
if roles, ok := m["roles"].([]interface{}); ok {
for _, r := range roles {
if s := asString(r); s != "" {
h.Roles = append(h.Roles, s)
}
// handler must be a NAME, not a value. asString would happily turn a
// function into its address ("function: 0x..."), which then failed much
// later at dispatch with no connection to the declaration that caused it.
// Rejecting it here names the field and the path.
if hv, present := h["handler"]; present {
name, isStr := hv.(string)
if !isStr {
ctx.warnf("http[%s]: handler must be the NAME of a function on the "+
"plugin table, not the function itself", decString(h, "path"))
continue
}
dh.Handler = name
}
if keys, ok := m["keys"].([]interface{}); ok {
for _, k := range keys {
if s := asString(k); s != "" {
h.Keys = append(h.Keys, s)
}
}
if p, ok := h["public"].(bool); ok {
dh.Public = p
}
// A malformed entry is dropped rather than fatal: one bad route must not
// cost a plugin its hooks and pages. That is also why the mounted count
// can be lower than the declared count — /api/plugins lists what mounted.
if method, mErr := normalizeHandlerMethod(h.Method); mErr == nil {
h.Method = method
if validateHandlerPath(h.Path) == nil && h.Handler != "" {
list = append(list, h)
}
// cost a plugin its hooks and pages. The reason is named so a plugin whose
// endpoint silently vanished can find out why.
method, mErr := normalizeHandlerMethod(dh.Method)
switch {
case mErr != nil:
ctx.warnf("http[%s]: %v", dh.Path, mErr)
continue
case validateHandlerPath(dh.Path) != nil:
ctx.warnf("http[%s]: invalid path", dh.Path)
continue
case dh.Handler == "":
ctx.warnf("http[%s]: handler must name a function on the plugin table "+
"(an inline function is not supported)", dh.Path)
continue
}
dh.Method = method
list = append(list, dh)
}
if len(ctx.problems) > 0 {
log.Printf("[plugins] %s", strings.Join(ctx.problems, "; "))
}
return list
}