fix(lua): 持久化的出站主机授权在重启后不生效(LoadDir 缺折叠)

生产实测暴露的内核缺陷:**重启后所有已批准出站主机全被拒**。

根因链:
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} 全绿。
This commit is contained in:
JianFeeeee
2026-10-14 10:55:50 +08:00
parent 10ad4421f9
commit 3819b00ded
2 changed files with 92 additions and 0 deletions

View File

@ -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 不该被放行")
}
}

View File

@ -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
}