diff --git a/internal/plugin/kernel_control_lazy_test.go b/internal/plugin/kernel_control_lazy_test.go new file mode 100644 index 00000000..fc0f8816 --- /dev/null +++ b/internal/plugin/kernel_control_lazy_test.go @@ -0,0 +1,73 @@ +package plugin + +// 内核控制通道**晚绑定**的判据。 +// +// 现场(2026-10-26 真机):WebUI 重启按钮点击返回 503。 +// 日志:POST /api/v1/kernel/restart auth=session status=503 +// +// 根因不是授权,而是**时序**:webui 由 plugin-host 在装配期拉起, +// 而 main() 里 SetKernelControl 那一行还没执行 ⇒ 插件 Start 时拿到 nil。 +// 靠"调整顺序"修不了(依赖图不保证 main 里的语句顺序), +// 只能让插件拿**解析器**,调用那一刻再取真实通道。 + +import ( + "testing" + + sdk "github.com/JianFeeeee/HologramAgent/internal/sdk" +) + +type fakeCtl struct { + restarts int + shutdowns int +} + +func (f *fakeCtl) RequestKernelRestart(reason string) error { + f.restarts++ + return nil +} +func (f *fakeCtl) RequestKernelShutdown(reason string) error { + f.shutdowns++ + return nil +} + +// ① 先给通道、后注入 ⇒ 调用时仍能成功(这是真机那条修复的核心)。 +func TestLazyKernelControlWorksWhenInjectedLater(t *testing.T) { + r := NewRegistry() + ctl := kernelControlFor("webui", r) // 此时 registry 里还没有通道 + if ctl == nil { + t.Fatal("按授权表 webui 应拿到控制通道(晚绑定)") + } + + // 注入前调用 ⇒ 明确报不可用(不能静默成功) + if err := ctl.RequestKernelRestart("before"); err == nil { + t.Fatal("未注入时应报错,不能假装成功") + } + + fake := &fakeCtl{} + r.SetKernelControl(fake) + if err := ctl.RequestKernelRestart("after"); err != nil { + t.Fatalf("注入后应成功,实际: %v", err) + } + if fake.restarts != 1 { + t.Fatalf("应调用真实通道 1 次,实际 %d", fake.restarts) + } +} + +// ② 晚绑定**不放宽授权**:未获授权的插件仍然拿不到通道。 +func TestLazyKernelControlRespectsAuthorization(t *testing.T) { + r := NewRegistry() + if ctl := kernelControlFor("some-random-plugin", r); ctl != nil { + t.Fatal("未授权插件不应拿到控制通道") + } + // pluginmgr 只有重启权限:关机必须被拒 + r.SetKernelControl(&fakeCtl{}) + pm := kernelControlFor("pluginmgr", r) + if pm == nil { + t.Fatal("pluginmgr 应有仅重启通道") + } + if err := pm.RequestKernelShutdown("x"); err == nil { + t.Fatal("pluginmgr 不应能关机") + } +} + +var _ sdk.KernelControlAPI = lazyKernelControl{} diff --git a/internal/plugin/registry.go b/internal/plugin/registry.go index 6f062c99..a8d67707 100644 --- a/internal/plugin/registry.go +++ b/internal/plugin/registry.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "log" "os" @@ -349,6 +350,56 @@ func (r *Registry) kernelControlFor(name string) sdk.KernelControlAPI { // // 拆出来的原因:buildSDK 要在**一次** RLock 里取走整组依赖(见 snapshotDeps), // 不能再回头单独取锁 —— RWMutex 不可重入,持锁再 RLock 在写者排队时会自锁。 +// lazyKernelControl 是**晚绑定**的内核控制通道。 +// +// 它不持有具体实现,只持有 registry:每次调用时按授权表重新解析。 +// 这样"插件先启动、通道后注入"不会丢能力,而授权仍由 +// kernelControlAllowed / kernelRestartAllowed 决定(不因晚绑定放宽)。 +type lazyKernelControl struct { + r *Registry + name string +} + +func (l lazyKernelControl) resolve() sdk.KernelControlAPI { + return kernelControlForCtl(l.r.currentKernelControl(), l.name) +} + +func (l lazyKernelControl) RequestKernelRestart(reason string) error { + if c := l.resolve(); c != nil { + return c.RequestKernelRestart(reason) + } + return errKernelControlUnavailable +} + +func (l lazyKernelControl) RequestKernelShutdown(reason string) error { + if c := l.resolve(); c != nil { + return c.RequestKernelShutdown(reason) + } + return errKernelControlUnavailable +} + +// errKernelControlUnavailable 表示通道尚未注入(装配期)或本插件未获授权。 +var errKernelControlUnavailable = errors.New( + "内核控制通道不可用:未注入(装配未完成)或本插件未获授权") + +// currentKernelControl 线程安全地取当前通道。 +func (r *Registry) currentKernelControl() sdk.KernelControlAPI { + r.mu.RLock() + defer r.mu.RUnlock() + return r.kernelCtl +} + +// kernelControlFor 给插件返回**晚绑定**控制通道。 +// +// 返回非 nil 即表示"该插件按授权表**可能**有这个能力"——真正的授权仍在 +// 每次调用时按 kernelControlForCtl 复核(所以晚绑定不放宽任何权限)。 +func kernelControlFor(name string, r *Registry) sdk.KernelControlAPI { + if !kernelControlAllowed[name] && !kernelRestartAllowed[name] { + return nil + } + return lazyKernelControl{r: r, name: name} +} + func kernelControlForCtl(ctl sdk.KernelControlAPI, name string) sdk.KernelControlAPI { if ctl == nil { return nil @@ -710,7 +761,16 @@ func (r *Registry) buildSDK(name string) *sdk.PluginSDK { RegOutputUnreg: regOutputUnreg, RegInput: regInput, PluginMgr: r, - KernelControl: kernelControlForCtl(deps.kernelCtl, name), + // ★ 晚绑定(2026-10-26 真机 503 实测)。 + // + // 原先把 deps.kernelCtl 的**当时的取值**交给插件,而 webui 由 + // plugin-host 在装配期就拉起,main() 里 SetKernelControl 那一行 + // 还没执行 ⇒ 插件拿到 nil ⇒ 按钮点了永远 503 + // (日志证据:POST /api/v1/kernel/restart status=503)。 + // + // 依赖图保证不了"main 里某一行先于插件启动",所以不能靠调整顺序, + // 只能让插件拿到**解析器**:调用那一刻再取真实通道。 + KernelControl: kernelControlFor(name, r), Status: deps.status, Supervisor: deps.sup, @@ -1761,7 +1821,18 @@ func (r *Registry) StartPlugin(name string) error { r.runtimeStopped[name] = true } r.mu.Unlock() - return fmt.Errorf("启动插件 %s 失败(本身加载出错,或插件不存在)", name) + // ★ 报错要**带上可用名字**:模型最常见的错是拼错(实测把 files 写成 file), + // 而原来的"加载出错,或插件不存在"两者都提、却一个可用名字都不给, + // 模型只能盲猜下一个名字(连续撞 3 次都不奇怪)。 + // + // 这里列目录里的真实插件名(最多 12 个)并指出最接近的那个。 + avail := r.installedPluginNames() + hint := "" + if near := nearestName(name, avail); near != "" { + hint = fmt.Sprintf(";你要找的是不是 %q?", near) + } + return fmt.Errorf("启动插件 %s 失败(%s)%s 已安装: %v", + name, r.loadFailureReason(name), hint, avail) } // refuseSelfLock 拦住会自锁的关闭/禁用操作(设计 §2.6.7.4)。 @@ -2045,3 +2116,92 @@ func (r *Registry) readConfig(plgDir string) map[string]interface{} { } return cfg } + +// installedPluginNames 返回 plugins 目录下真实存在的插件名(排序,最多 12 个)。 +func (r *Registry) installedPluginNames() []string { + dir := r.pluginDirOrEmpty() + if dir == "" { + return nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return nil + } + var out []string + for _, e := range entries { + if !e.IsDir() || strings.HasPrefix(e.Name(), ".") { + continue + } + out = append(out, e.Name()) + } + sort.Strings(out) + if len(out) > 12 { + out = out[:12] + } + return out +} + +// loadFailureReason 说明"为什么没加载起来":目录不存在 / 没清单 / 加载报错。 +// +// 区分三者是必要的:目录不存在(名字错)与清单损坏(插件坏了)对模型 +// 意味着完全不同的下一步动作。 +func (r *Registry) loadFailureReason(name string) string { + dir := filepath.Join(r.pluginDirOrEmpty(), name) + if _, err := os.Stat(dir); err != nil { + return "该名字没有对应目录,可能拼错了" + } + hasManifest := false + for _, n := range []string{"plugin.json", "plg.json"} { + if _, err := os.Stat(filepath.Join(dir, n)); err == nil { + hasManifest = true + break + } + } + if !hasManifest { + return "目录存在但没有 plugin.json/plg.json 清单" + } + return "插件自身加载失败,原因见内核日志" +} + +// nearestName 在一个候选集合里找与 name 最接近的(差一个字符以内视为接近)。 +func nearestName(name string, candidates []string) string { + best, bestD := "", 3 + for _, c := range candidates { + if d := editDistance(strings.ToLower(name), strings.ToLower(c)); d < bestD { + best, bestD = c, d + } + } + if bestD <= 2 { + return best + } + return "" +} + +// editDistance 是经典 Levenshtein 距离(只用于给"拼错"提建议,规模极小)。 +func editDistance(a, b string) int { + ra, rb := []rune(a), []rune(b) + prev := make([]int, len(rb)+1) + for j := range prev { + prev[j] = j + } + for i := 1; i <= len(ra); i++ { + cur := make([]int, len(rb)+1) + cur[0] = i + for j := 1; j <= len(rb); j++ { + cost := 1 + if ra[i-1] == rb[j-1] { + cost = 0 + } + m := prev[j] + 1 + if cur[j-1]+1 < m { + m = cur[j-1] + 1 + } + if prev[j-1]+cost < m { + m = prev[j-1] + cost + } + cur[j] = m + } + prev = cur + } + return prev[len(rb)] +} diff --git a/internal/plugins/webui/dashboard.css b/internal/plugins/webui/dashboard.css index 2b7b49ac..12710a16 100644 --- a/internal/plugins/webui/dashboard.css +++ b/internal/plugins/webui/dashboard.css @@ -598,6 +598,28 @@ all var(--dur-micro) var(--ease-out), transform var(--dur-micro) var(--ease-out); } + /* 内核控制按钮组(侧栏底部):与"退出登录"同族,带文字与图标。 + 两枚按钮等宽、同一行、间距一致;关机用红色悬停提示危险操作。 */ + .nav-kernel { + display: flex; + gap: 8px; + margin: 8px 0 4px; + } + .nav-kernel .btn { + flex: 1 1 0; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 6px 10px; + font-size: 13px; + line-height: 1.2; + } + .nav-kernel .nav-danger:hover { + color: #e5484d; + border-color: rgba(229, 72, 77, 0.5); + background: rgba(229, 72, 77, 0.08); + } .theme-btn:hover { color: var(--accent); border-color: rgba(var(--accent-rgb), 0.5); diff --git a/internal/plugins/webui/dashboard.html b/internal/plugins/webui/dashboard.html index 0f9ddc73..6814579c 100644 --- a/internal/plugins/webui/dashboard.html +++ b/internal/plugins/webui/dashboard.html @@ -142,33 +142,43 @@ - - + 不属于任何一个标签页。 + + ★ 2026-10-26:原先是两个只有图标的 .theme-btn(那是**顶栏**的样式: + 有 margin-right、pill 圆角、无文字),混在侧栏底部的文字按钮里 + ⇒ 大小不一、挤在一起、且用户看不出哪个是关机。 + 现在改成与"退出登录"同族的 btn-sm,带图标 + 文字, + 关机额外用 danger 样式(红色悬停)区别于重启。 --> +