Files
ModelRouter/internal/lua/plugin_adapter_bridge.go
JianFeeeee 5948ad19ca feat(plugins): 服务路径/适配器名冲突进裁决而非崩溃或硬拒绝(用户要求 A+B)
此前两类冲突各有问题:
A) 两个插件声明同一条 /api/plugins/http/ 服务路径 → 分发器按加载顺序静默
   选一个(装个新插件就可能改变既有路径归属)。
B) 插件装与内置/他插件同名的适配器 → 硬错误 ErrAdapterNameReserved / owned by。

用户要求:都改成「报冲突、列出候选、运维选一个保留、选择持久化」,绝不崩溃
或拒绝启动。

## A 类:插件 HTTP 服务路径冲突
- dispatch(plugin_http_routes.go)同路径多认领时不再 load-order 选边:未裁决
  返回 409 并点名候选;已裁决按赢家分发。
- route_conflicts.go:RouteConflicts/RouteWinner/ResolveRouteConflict,只统计
  loaded 且未禁用的插件;赢家插件若不再声明该路径,裁决作废回未裁决态。

## B 类:适配器名冲突
- InstallAdapter 撞名(内置或他插件所有)不再硬拒,改挂起:现任继续服务,候选
  入 AdapterConflicts,返回 typed ErrAdapterConflictPending(区分「代码错」)。
- ResolveAdapterConflict:incumbent 保留现任 / candidate 真正装上插件版(绕过
  reserved 门禁——这是运维显式授权覆盖内置的唯一入口)并转移归属。
- 现任内置永不被静默覆盖,守住「第三方插件不得篡改内核发行物」信任边界。

## 持久化 + WebAPI + WebUI
- 两张裁决表落 .route-conflicts.json(routes + adapters),LoadDir 时读回;
  重启后插件再装同名适配器自动套用上次裁决。
- WebAPI:GET /api/plugins/conflicts 列冲突;POST {kind,path|name,winner} 裁决。
- WebUI:插件页顶部「冲突裁决」卡片,逐条列路径/适配器冲突 + 候选 + 裁决按钮。

## 测试
- 单测 conflict_arbitration_test.go 6 项全过:路径冲突检测/裁决/持久化/重启、
  赢家消失回退、适配器撞名挂起/incumbent/candidate/归属转移、非法 winner 拒绝、
  自身更新不受影响(token 轮换不走冲突路径)。
- e2e conflict_arbitration_e2e_test.go 2 项全过(真二进制):两插件抢路径→409
  点名→裁决→200 赢家应答→/v1/models 无回归;插件装 shadow openai→挂起→现任
  保留→裁决 candidate→插件版生效→冲突清空。
- 全量 build + vet 通过,既有 lua/gateway/core 回归无破坏。

修正过程中发现并修了一个真 bug:LoadAdapterSource 对 key≠declared 的适配器
重装会建第二个池、留旧池继续服务(v2 装了 v1 还在跑)——改成统一按注册键
归档,裁决 candidate 后新版本才真正生效。
2026-10-12 12:32:10 +08:00

294 lines
10 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 — 插件适配器管理桥接(R4-a / Phase 3)
//
// 用户需求:插件可安装、运行时修改适配器,以支持复杂认证与 token 轮换;
// 适配器本身不需要持续运行(它是无状态请求级弹性池)。
//
// 插件(长期运行)──定期/事件驱动──> install/update/remove_adapter
// │
// ▼
// 内核重建该适配器的池(LoadAdapterSource)
// │
// ▼
// 后续请求自然使用新逻辑
//
// 三条安全规则(与 R6 同源):
//
// 1. 路径安全:名字必须通过 ValidateAdapterName(无穿越/绝对路径/空白)
// 2. 不得影子内置:插件不能注册与内置适配器同名的适配器
// (用 VM.InstallPluginAdapter,运维仍可自行覆盖)
// 3. 归属可查:记录每个适配器的 owner("plugin:名字" 或 "operator"),
// 于是「插件只能改自己装的」这条规则是可判定的,而不是靠约定
//
// 第 3 条是必要的:没有归属信息时,"插件 A 改插件 B 装的适配器" 无法区分
// 于合法的自身更新,会导致一个插件能劫持另一个插件的认证逻辑。
package lua
import (
"fmt"
"sort"
"strings"
"sync"
golua "github.com/aarzilli/golua/lua"
)
// AdapterOwner 标识一个适配器由谁安装。
const (
OwnerOperator = "operator" // 磁盘/首启 seed/管理员上传
OwnerPlugin = "plugin" // 由某个插件经桥接安装
)
// adapterOwnership records which plugin installed each adapter.
//
// This is the state that makes rule 3 enforceable: InstallPluginAdapter marks
// the adapter as owned by that plugin, and a later update from a DIFFERENT
// plugin is refused. An adapter with no owner is the operator's, and no plugin
// may touch it.
type adapterOwnership struct {
mu sync.RWMutex
// owner maps adapter name -> installing plugin name (only for plugin-owned).
owner map[string]string
}
// ownedBy reports the plugin that owns name, or "" when it is operator-owned.
func (o *adapterOwnership) ownedBy(name string) string {
o.mu.RLock()
defer o.mu.RUnlock()
return o.owner[name]
}
func (o *adapterOwnership) set(name, plugin string) {
o.mu.Lock()
defer o.mu.Unlock()
if o.owner == nil {
o.owner = map[string]string{}
}
o.owner[name] = plugin
}
func (o *adapterOwnership) clear(name string) {
o.mu.Lock()
defer o.mu.Unlock()
delete(o.owner, name)
}
// listOwned returns the adapter names a given plugin owns (for UI/diagnostics).
func (o *adapterOwnership) listOwned(plugin string) []string {
o.mu.RLock()
defer o.mu.RUnlock()
var out []string
for name, p := range o.owner {
if p == plugin {
out = append(out, name)
}
}
sort.Strings(out)
return out
}
// Plugins carries an adapter-ownership table.
func (ps *Plugins) adapterOwner() *adapterOwnership {
ps.adapterOwnOnce.Do(func() { ps.adapterOwn = &adapterOwnership{} })
return ps.adapterOwn
}
// InstallAdapter registers an adapter on behalf of a plugin.
//
// Returns the name that was actually registered — an adapter script may declare
// a different `name` than the file/registration key, and the declared name is
// what sources must reference. Callers get it back so they can update config.
func (ps *Plugins) InstallAdapter(plugin, name, code string) (string, error) {
if plugin == "" {
return "", fmt.Errorf("install_adapter: caller plugin is unknown")
}
if ps.vm == nil {
return "", fmt.Errorf("install_adapter: no adapter VM available")
}
// A name conflict used to be a hard rejection (owned by another plugin, or
// shadows a built-in). The user now wants it SUSPENDED for operator
// arbitration instead of refused outright: the incumbent keeps serving, the
// candidate waits in the conflicts list, and the operator picks a winner in
// the WebUI. The install still returns a (typed) error so the caller knows it
// did not take effect — but it is ErrAdapterConflictPending, not a dead end.
incumbent := ""
if owner := ps.adapterOwner().ownedBy(name); owner != "" && owner != plugin {
incumbent = "plugin:" + owner
} else if containsNameFold(ps.vm.bundledNames(), name) {
incumbent = "operator (built-in)"
}
if incumbent != "" {
if ps.recordAdapterConflict(name, incumbent, plugin, code) {
// The operator already chose THIS candidate in a prior session. Install
// for real via LoadAdapterSource, which bypasses the reserved-name gate
// — that explicit operator override is exactly what the arbitration is
// FOR, and re-suspending it on every restart would be a loop.
if err := ps.vm.LoadAdapterSource(name, code); err != nil {
return "", err
}
return ps.finishInstall(plugin, name), nil
}
return "", &ErrAdapterConflictPending{Name: name, Incumbent: incumbent}
}
// No conflict: normal install path. Rules 1+2 (path safety + no shadowing
// built-ins) live in InstallPluginAdapter so there is exactly one
// enforcement point. (The shadow check there is now unreachable for a
// genuine built-in — it is caught above and suspended — but kept as the
// single authority for path safety and any future reserved set.)
if err := ps.vm.InstallPluginAdapter(name, code); err != nil {
return "", err
}
return ps.finishInstall(plugin, name), nil
}
// finishInstall records adapter ownership under BOTH the registration key and
// the declared name (a script may declare a name different from the key, and it
// is the declared name sources reference — recording only the key would leave
// the adapter un-owned and therefore mutable by anyone). Both entries are
// cleared together on remove.
func (ps *Plugins) finishInstall(plugin, name string) string {
effective := ps.vm.EffectiveAdapterName(name)
ps.adapterOwner().set(effective, plugin)
if effective != name {
ps.adapterOwner().set(name, plugin)
}
return effective
}
// containsNameFold reports whether name is in names (case-insensitive, matching
// ValidateAdapterName's reserved-name comparison).
func containsNameFold(names []string, name string) bool {
for _, n := range names {
if strings.EqualFold(n, name) {
return true
}
}
return false
}
// UpdateAdapter replaces an adapter a plugin previously installed — the token
// rotation path. The plugin gets a NEW pool; the old one is shut down, so an
// in-flight request either uses the old or the new code, never a mixture.
//
// Only the owning plugin may update. Updating an operator-installed adapter is
// refused: the operator's file on disk would then disagree with what the running
// gateway executes, and the next restart would silently revert the "update".
func (ps *Plugins) UpdateAdapter(plugin, name, code string) error {
if plugin == "" {
return fmt.Errorf("update_adapter: caller plugin is unknown")
}
owner := ps.adapterOwner().ownedBy(name)
if owner == "" {
return fmt.Errorf("update_adapter: adapter %q is operator-installed; "+
"only adapters a plugin installed can be updated at runtime", name)
}
if owner != plugin {
return fmt.Errorf("update_adapter: adapter %q is owned by plugin %q, not %q",
name, owner, plugin)
}
if err := ps.vm.InstallPluginAdapter(name, code); err != nil {
return err
}
// Ownership is unchanged: same plugin, new version.
return nil
}
// RemoveAdapter removes an adapter a plugin installed. Operator-installed
// adapters are refused (same reasoning as UpdateAdapter).
func (ps *Plugins) RemoveAdapter(plugin, name string) error {
owner := ps.adapterOwner().ownedBy(name)
if owner == "" {
return fmt.Errorf("remove_adapter: adapter %q is operator-installed; "+
"use DELETE /api/adapters/%s", name, name)
}
if owner != plugin {
return fmt.Errorf("remove_adapter: adapter %q is owned by plugin %q, not %q",
name, owner, plugin)
}
ps.vm.RemoveAdapter(name)
ps.adapterOwner().clear(name)
return nil
}
// PluginAdapters lists the adapters a plugin owns (its token-rotation surface).
func (ps *Plugins) PluginAdapters(plugin string) []string {
return ps.adapterOwner().listOwned(plugin)
}
// AdapterOwnerOf reports the owning plugin of an adapter, or "" if operator-owned.
func (ps *Plugins) AdapterOwnerOf(name string) string {
return ps.adapterOwner().ownedBy(name)
}
// ---- Lua 绑定(R4:插件在运行时改自己的适配器)----
// adapterBridgeGlobal 暴露给插件 Lua 的适配器管理表。
//
// 用一张表而不是三个顶层全局,是为了少占插件可见的全局命名空间。表用
// buildJSONTable 的同一套写法(NewTable + PushGoClosure + SetField),
// 该写法在 json 表上已被长期验证。
const adapterBridgeGlobal = "__llmsproxy_adapters"
// registerAdapterBridge 把适配器管理表装进插件的 Lua 状态。
//
// name 是**该插件自己的启动名**,随每次调用一起交给 Go 侧桥接 —— 归属校验
// 因此来自启动时的固定身份,而不是从 Lua 状态里现读(那样一个分片或任何持有
// 已编译脚本的调用者就能冒充别的插件)。
func registerAdapterBridge(L *golua.State, ps *Plugins, name string) {
L.NewTable()
install := luaInstallAdapter(L, ps, name)
L.PushGoClosure(install)
L.SetField(-2, "install")
update := luaUpdateAdapter(L, ps, name)
L.PushGoClosure(update)
L.SetField(-2, "update")
remove := luaRemoveAdapter(L, ps, name)
L.PushGoClosure(remove)
L.SetField(-2, "remove")
L.SetGlobal(adapterBridgeGlobal)
}
func luaInstallAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
adapterName := L.ToString(1)
code := L.ToString(2)
effective, err := ps.InstallAdapter(name, adapterName, code)
if err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushString(effective)
L.PushNil()
return 2
}
}
func luaUpdateAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
adapterName := L.ToString(1)
code := L.ToString(2)
if err := ps.UpdateAdapter(name, adapterName, code); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}
func luaRemoveAdapter(L *golua.State, ps *Plugins, name string) golua.LuaGoFunction {
return func(L *golua.State) int {
adapterName := L.ToString(1)
if err := ps.RemoveAdapter(name, adapterName); err != nil {
L.PushNil()
L.PushString(err.Error())
return 2
}
L.PushBoolean(true)
L.PushNil()
return 2
}
}