feat(lua)!: 插件只通过 modelrouter SDK 与内核交互,且不再随内核安装

- 新增唯一公开接口表 `modelrouter`(sdk_version=1.0):http / secrets /
  kernel / adapters / plugin_dir。成员与内部桥是**同一对象**而非副本 ——
  复制会让凭据保险库分叉(插件写 A、内核读 B)。
- `llmsproxy` 与 `__llmsproxy_*` 降为同对象兼容别名,只为已装插件继续可用。
  仓库内 4 个插件(channels / billing / quota / usage)全部改用
  `modelrouter.*`:此前它们直接依赖内核私有全局,内核无法在不炸插件的前提下
  改实现(重命名、加守卫、换实现都算破坏性变更)。
- 插件不再由网关安装:`SeedBundled()` 变彻底 no-op,`core.New` 不再调用;
  `ReadBundledPlugin` 改为按源文件定位(脱离 cwd),只服务测试与开发工具。
- 桌面端删除 `BUILTIN_PLUGINS` 与 `syncBundledPluginsOnUpgrade`:升级不再
  删除、改名或覆盖用户插件文件。插件是运维资产,不是内核资源。
- `Builtin` 不再靠"源码与仓库示例逐字节相同"推断,插件一律视为外部文件。
- 判据:SDK 存在/版本/形状、别名与内部桥同一张表、适配器 VM 拿不到 SDK、
  经 SDK 真实写读凭据并驱动内核动作、GUI 升级不动插件文件、release 包不含插件。

说明:channels.lua / quota.lua 同时带有此前的渠道与额度改动(含 OpenCode 免费池
监控),与 SDK 改名同处一个文件,无法文件级拆分,故一并纳入。
This commit is contained in:
JianFeeeee
2026-10-26 22:27:12 +08:00
parent 79bc55cfbe
commit de7553eaa2
19 changed files with 653 additions and 465 deletions

View File

@ -0,0 +1,133 @@
package lua
import (
"testing"
golua "github.com/aarzilli/golua/lua"
)
// 本文件钉住"插件与内核之间只有一条公开接口"这条契约。
//
// 背景:插件曾经直接调用 `__llmsproxy_*` 这类内核私有全局。它们是实现细节,
// 却被当成 API 使用,于是内核无法在不破坏所有已安装插件的前提下改实现
// (重命名、加守卫、把闭包换成表都会直接炸插件)。现在插件只有一个入口
// `llmsproxy`,旧全局降级为兼容别名。
//
// 这些断言咬的是"SDK 是唯一入口"本身,而不是某个能力恰好能用:
// - 表存在、带版本号;
// - 每个成员与内部桥是**同一个对象**(不是复制品,复制会让凭据保险库分叉);
// - 通过 SDK 能真正完成一次内核交互;
// - 未授予的能力是"缺失"而不是"存在但一调用就报错"。
const sdkProbe = `
local plugin = { name = "sdkprobe", version = "1.0" }
plugin.exports = { "surface", "roundtrip" }
function plugin.surface()
local sdk = modelrouter
if sdk == nil then return { has_sdk = false } end
return {
has_sdk = true,
sdk_version = sdk.sdk_version,
http_fn = type(sdk.http),
secrets_t = type(sdk.secrets),
kernel_t = type(sdk.kernel),
adapters_t = type(sdk.adapters),
plugin_dir_t = type(sdk.plugin_dir),
-- 与旧别名是同一张表?是则 tostring 相同。
secrets_aliased = tostring(sdk.secrets) == tostring(__llmsproxy_secrets),
kernel_aliased = tostring(sdk.kernel) == tostring(__llmsproxy_kernel),
-- 产品名是 modelrouter;llmsproxy 只作为历史别名,且必须指向同一张表。
legacy_is_same = tostring(llmsproxy) == tostring(modelrouter),
-- 插件目录必须是内核给出的真实路径,而不是让插件自己猜。
plugin_dir = sdk.plugin_dir,
}
end
-- 只通过 SDK 完成一次写读,证明它不是一个空壳表。
function plugin.roundtrip()
local ok, err = modelrouter.secrets.set("probe-key", "probe-value")
if not ok then return { ok = false, err = err } end
local got = modelrouter.secrets.get("probe-key")
local actions = modelrouter.kernel.actions()
local n = 0
for _ in ipairs(actions) do n = n + 1 end
return { ok = got == "probe-value", got = got, actions = n }
end
return plugin
`
func TestPluginSDKIsTheSingleEntryPoint(t *testing.T) {
_, ps, _ := runtimePlugins(t)
if err := ps.LoadSource("sdkprobe", sdkProbe); err != nil {
t.Fatal(err)
}
out, err := ps.Call("sdkprobe", "surface", nil)
if err != nil {
t.Fatalf("SDK 探测失败: %v", err)
}
m, ok := out.(map[string]interface{})
if !ok {
t.Fatalf("返回值不是 table: %#v", out)
}
if m["has_sdk"] != true {
t.Fatal("插件 VM 里没有 modelrouter —— 插件将只能依赖内核私有全局")
}
if m["legacy_is_same"] != true {
t.Error("llmsproxy 别名与 modelrouter 不是同一张表 —— 凭据/内核动作面会分叉")
}
if m["sdk_version"] != SDKVersion {
t.Errorf("sdk_version = %v, 期望 %q(版本号是插件判断契约的依据)",
m["sdk_version"], SDKVersion)
}
// http 是函数,其余是表:这个形状就是文档里的形状,写错会让插件
// 在第一次网络调用时才炸。
wantTypes := map[string]string{
"http_fn": "function", "secrets_t": "table", "kernel_t": "table",
"adapters_t": "table", "plugin_dir_t": "string",
}
for k, want := range wantTypes {
if got := m[k]; got != want {
t.Errorf("%s = %v, 期望 %s", k, got, want)
}
}
// 复制品会让 secrets 变成两张表:插件写进 A、内核从 B 读。
if m["secrets_aliased"] != true {
t.Error("llmsproxy.secrets 与内部桥不是同一张表 —— 凭据会分叉")
}
if m["kernel_aliased"] != true {
t.Error("llmsproxy.kernel 与内部桥不是同一张表")
}
if m["plugin_dir"] == "" {
t.Error("llmsproxy.plugin_dir 为空 —— 插件会退回去猜路径(曾覆盖生产数据)")
}
// 端到端:SDK 不是声明,而是能真正驱动内核。
rout, err := ps.Call("sdkprobe", "roundtrip", nil)
if err != nil {
t.Fatalf("SDK 往返失败: %v", err)
}
rm, ok := rout.(map[string]interface{})
if !ok {
t.Fatalf("返回值不是 table: %#v", rout)
}
if rm["ok"] != true {
t.Errorf("经 SDK 写读凭据失败: %#v", rm)
}
if n, _ := rm["actions"].(float64); n == 0 {
t.Error("llmsproxy.kernel.actions() 为空 —— SDK 没有暴露内核动作面")
}
}
// TestAdapterVMHasNoPluginSDK 是安全边界:适配器与插件共用 setupGlobals,
// 但 SDK 只装在插件 VM 上。适配器是运维提供的配置,不该拿到凭据库与内核动作面。
func TestAdapterVMHasNoPluginSDK(t *testing.T) {
L := golua.NewState()
defer L.Close()
L.OpenLibs()
setupGlobals(L)
L.GetGlobal(sdkGlobal)
if L.Type(-1) != golua.LUA_TNIL {
t.Error("setupGlobals 安装了 llmsproxy —— 适配器 VM 也会拿到凭据库与内核动作面")
}
}