feat(plugins): 插件管理器接线 —— 面板接入点快照 + 动作分发(PLG-2)

- GET /api/v1/admin/panels:按调用者角色过滤后的接入点快照(PLG-1 的 catalog 挂进插件管理器)
- POST /api/v1/admin/plugins/{id}/actions/{action}:**只接受该插件声明过的动作名**
  (未声明 ⇒ 404);写动作 writeAuth + 落审计;插件只给动作名不给 URL ⇒ 不给插件开任意请求面
- 生命周期:install/uninstall/enable/disable 变化后**重算面板集合**并经进程内 bus 推 panels_changed
  (前端可热更新;不新开端口)

契约 v0.78 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。
This commit is contained in:
AskaEth committed 2026-10-06 09:03:57 +08:00
1 parent c907a34510
commit 2d21aa553e
9 files changed
+1990 -6

No files matched your search

@@ -0,0 +1,141 @@
package plugin
import (
"context"
"path/filepath"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
// 本文件锁定 PLG-2 的**接线**:面板目录跟着插件管理器的安装/卸载/启停一起重算,
// 并且每次重算推一条带原因的变更通知(§5.2.1 生命周期一栏)。
// newPanelManager 构造一个已接线面板中枢的管理器(plugins.json 落在临时目录)。
func newPanelManager(t *testing.T) (*Manager, *Panels, *changeRecorder) {
t.Helper()
mgr := NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), nil)
hub, rec := newTestHub(t)
mgr.AttachPanels(hub)
if mgr.Panels() != hub {
t.Fatalf("Panels() 未回接线的中枢")
}
return mgr, hub, rec
}
// installFake 安装一个指向假 MCP server 的插件。
func installFake(t *testing.T, mgr *Manager, name, endpoint string) {
t.Helper()
_, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{
Name: name, Kind: runtimeconfig.PluginKindMCP,
Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint,
})
if err != nil {
t.Fatalf("Install(%s): %v", name, err)
}
}
// TestManagerPanelLifecycleInstallUninstall 锁定:安装 ⇒ 声明可见 + 推 install;
// 卸载 ⇒ 声明与动作一起消失 + 推 uninstall。
func TestManagerPanelLifecycleInstallUninstall(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr, hub, rec := newPanelManager(t)
defer mgr.Close(context.Background())
// 声明在安装之前就登记(模拟未来的 manifest 读取:先有声明,装成之后生效)。
if _, err := hub.Declare("calc", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
installFake(t, mgr, "calc", mcp.URL)
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("安装后应当看到面板,实际 %v", got)
}
if _, ok := hub.Action("calc", "plugin.saveProvider"); !ok {
t.Fatalf("安装后动作应当可分发")
}
if removed, err := mgr.Uninstall(context.Background(), "calc"); err != nil || !removed {
t.Fatalf("Uninstall = (%v,%v),期望 (true,nil)", removed, err)
}
if got := mustIDs(t, hub); len(got) != 0 {
t.Fatalf("卸载后不该再有面板,实际 %v", got)
}
if _, ok := hub.Action("calc", "plugin.saveProvider"); ok {
t.Fatalf("卸载后动作不得仍被声明")
}
reasons := changeReasons(rec.all())
if len(reasons) != 3 || reasons[0] != PanelChangeDeclared ||
reasons[1] != PanelChangeInstalled || reasons[2] != PanelChangeUninstalled {
t.Fatalf("变更原因 = %v,期望 [declare install uninstall]", reasons)
}
}
// TestManagerPanelLifecycleDisableEnable 锁定 §5.2.1 的启停一栏:停用摘掉入口
// (且动作不再分发),启用让它回来 —— 两条路各推一条变更。
func TestManagerPanelLifecycleDisableEnable(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr, hub, rec := newPanelManager(t)
caps := mgr.reg
defer mgr.Close(context.Background())
if _, err := hub.Declare("calc", []sdk.Panel{samplePanel()}); err != nil {
t.Fatalf("Declare: %v", err)
}
installFake(t, mgr, "calc", mcp.URL)
before := len(rec.all())
if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil {
t.Fatalf("SetEnabled(false): %v", err)
}
if got := mustIDs(t, hub); len(got) != 0 {
t.Fatalf("停用后入口应当不出现,实际 %v", got)
}
if _, err := caps.Get("mcp:calc"); err == nil {
t.Fatalf("停用后该插件的 MCP 能力应当已下线")
}
if err := mgr.SetEnabled(context.Background(), "calc", true); err != nil {
t.Fatalf("SetEnabled(true): %v", err)
}
if got := mustIDs(t, hub); len(got) != 1 {
t.Fatalf("重新启用后入口应当回来,实际 %v", got)
}
reasons := changeReasons(rec.all())[before:]
if len(reasons) != 2 || reasons[0] != PanelChangeDisabled || reasons[1] != PanelChangeEnabled {
t.Fatalf("启停变更原因 = %v,期望 [disable enable]", reasons)
}
// 未安装的名字必须显式报「找不到」,绝不静默成功。
if err := mgr.SetEnabled(context.Background(), "ghost", false); err == nil {
t.Fatalf("停用一个不存在的插件应当报错")
}
}
// TestManagerPanelsUnwiredIsNil 锁定未接线时的诚实语义:Panels() 回 nil,
// 管理器对面板的一切调用都是安全空操作(既有行为逐字不变)。
func TestManagerPanelsUnwiredIsNil(t *testing.T) {
t.Parallel()
mcp := newFakeMCP(t, "", "add")
mgr := NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), nil)
defer mgr.Close(context.Background())
if mgr.Panels() != nil {
t.Fatalf("未接线时 Panels() 应当回 nil")
}
installFake(t, mgr, "calc", mcp.URL)
if _, err := mgr.Uninstall(context.Background(), "calc"); err != nil {
t.Fatalf("未接线时卸载不应受影响: %v", err)
}
}
// changeReasons 抽取变更原因序列(顺序敏感)。
func changeReasons(changes []PanelChange) []string {
out := make([]string, 0, len(changes))
for _, c := range changes {
out = append(out, c.Reason)
}
return out
}