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 }