From 3819b00dedac853f45ebbee76a6c7cbfafff601d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Wed, 14 Oct 2026 10:55:50 +0800 Subject: [PATCH] =?UTF-8?q?fix(lua):=20=E6=8C=81=E4=B9=85=E5=8C=96?= =?UTF-8?q?=E7=9A=84=E5=87=BA=E7=AB=99=E4=B8=BB=E6=9C=BA=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E5=9C=A8=E9=87=8D=E5=90=AF=E5=90=8E=E4=B8=8D=E7=94=9F=E6=95=88?= =?UTF-8?q?=EF=BC=88LoadDir=20=E7=BC=BA=E6=8A=98=E5=8F=A0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产实测暴露的内核缺陷:**重启后所有已批准出站主机全被拒**。 根因链: 1. `LoadKernelPermissions` 把 `.kernel-perms.json` 里的 `jp.Hosts` 装进 `ps.extraHosts`,但它明确注释「折叠由调用方负责」——**启动路径上没人负责**。 2. `SetOutboundHosts` 早已跑过(core.New 里,在 LoadDir **之前**), 于是 allowlist 只含 source 主机,插件声明的整套 host 全被拒。 3. 症状是经典的「重启丢掉运维的决定」:运维一打开插件页重新保存就恢复。 **它还静默打坏了定时任务**:`refresh_models` 在启动后 5s 补跑 (first_due_now),正好落在这个窗口里,于是每个平台的刷新都报 「host "zcode.z.ai" is not an allowed outbound destination」——看起来像 "白名单缺 host",实际是授权根本没被折叠。我按这个假象查了一圈(去核对 .kernel-perms.json 里 zcode.z.ai 明明在),才定位到折叠这一步缺失。 修法:LoadDir 末尾补 `rebuildOutboundAllowlistLocked()`。 判据:`TestPersistedHostGrantsSurviveRestart` —— 造一份持久化授权、模拟 启动顺序(SetOutboundHosts → LoadDir),断言已批准的 host 可用、 source 主机未丢、未批准的仍被拒。 **变异验证**:撤掉修复 → 判据变红(两条"被拒");加回 → 变绿。 回归:internal/{lua,core,provider,gateway} 全绿。 --- internal/lua/outbound_grant_persist_test.go | 74 +++++++++++++++++++++ internal/lua/plugins.go | 18 +++++ 2 files changed, 92 insertions(+) create mode 100644 internal/lua/outbound_grant_persist_test.go diff --git a/internal/lua/outbound_grant_persist_test.go b/internal/lua/outbound_grant_persist_test.go new file mode 100644 index 0000000..26bd9d4 --- /dev/null +++ b/internal/lua/outbound_grant_persist_test.go @@ -0,0 +1,74 @@ +package lua + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +// 持久化的出站主机授权必须在**重启后自动生效**。 +// +// 实测缺陷(生产):LoadKernelPermissions 把已批准的 host 装进 ps.extraHosts, +// 但注释明说"折叠由调用方负责" —— 启动路径上没人负责。SetOutboundHosts 早已 +// 跑过(core.New 里、LoadDir 之前),于是 allowlist 只有 source 主机, +// 插件声明的整套 host(zcode.z.ai / copilot.tencent.com …)在重启后全被拒: +// +// host "zcode.z.ai" is not an allowed outbound destination +// +// 症状是经典的"重启丢掉运维的决定"——运维一打开插件页重新保存就恢复。 +// 它还打坏了定时任务:refresh_models 在启动后 5s 触发,正好落在那个窗口里, +// 于是每个平台的刷新都报"授权缺失"。 +func TestPersistedHostGrantsSurviveRestart(t *testing.T) { + dir := t.TempDir() + plugDir := filepath.Join(dir, "plugins") + if err := os.MkdirAll(plugDir, 0o755); err != nil { + t.Fatal(err) + } + + // 造一份持久化授权(模拟运维此前在 WebUI 批准过) + perms := map[string]interface{}{ + "channels": map[string]interface{}{ + "actions": []string{"add_source"}, + "hosts": []string{"zcode.z.ai", "copilot.tencent.com"}, + }, + } + b, _ := json.Marshal(perms) + if err := os.WriteFile(filepath.Join(plugDir, ".kernel-perms.json"), b, 0o600); err != nil { + t.Fatal(err) + } + + // 模拟启动:先 SetOutboundHosts(源主机),再 LoadDir(读授权) + vm := NewVM(filepath.Join(dir, "adapters")) + if err := vm.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(vm.Stop) + ps := NewPlugins(vm, plugDir) + ps.SetOutboundHosts([]string{"api.example.com"}) + + // LoadDir 会读授权文件;没有插件文件也没关系,关键是它必须折叠授权。 + if err := ps.LoadDir(); err != nil { + t.Fatalf("LoadDir: %v", err) + } + + al := ps.outboundAllowlist() + if al == nil { + t.Fatal("无 allowlist") + } + // ① 源主机仍可用 + if !al.allows("api.example.com", "443") { + t.Error("source 主机被丢了") + } + // ② 已批准的 extra host 必须生效(这就是本判据的核心) + for _, h := range []string{"zcode.z.ai", "copilot.tencent.com"} { + if !al.allows(h, "443") { + t.Errorf("重启后已批准的 host %q 被拒 —— "+ + "LoadKernelPermissions 装了 extraHosts 但没人折叠进 allowlist", h) + } + } + // ③ 没批准过的仍然不许 + if al.allows("evil.example.com", "443") { + t.Error("未批准的 host 不该被放行") + } +} diff --git a/internal/lua/plugins.go b/internal/lua/plugins.go index 2ce5e68..9ca3099 100644 --- a/internal/lua/plugins.go +++ b/internal/lua/plugins.go @@ -634,6 +634,24 @@ func (ps *Plugins) LoadDir() error { } } ps.rebuild() + // Persisted host grants must be folded into the allowlist HERE, not only in + // the WebUI grant path. + // + // Why (live defect): LoadKernelPermissions above loads `jp.Hosts` into + // ps.extraHosts but deliberately does NOT fold it — its comment says "the + // caller owns that fold". On the boot path nobody did. SetOutboundHosts had + // already run (core.New, before LoadDir), so the allowlist held the source + // hosts and nothing else, and every approved extra host (the plugin's whole + // declared set: zcode.z.ai, copilot.tencent.com, …) was refused after a + // restart with "host ... is not an allowed outbound destination". + // + // Symptom is the classic "restart loses the operator's decision": it clears + // the moment an operator opens the plugin page and re-saves the grants. It + // also broke the scheduled work — refresh_models fires 5s after boot, inside + // that window, and reported the grant as missing for every platform. + ps.mu.Lock() + ps.rebuildOutboundAllowlistLocked() + ps.mu.Unlock() return nil }