From 2d21aa553e67198dd65ab7ae0517639b8bb7e85f Mon Sep 17 00:00:00 2001 From: AskaEth Date: Tue, 6 Oct 2026 09:03:57 +0800 Subject: [PATCH] =?UTF-8?q?feat(plugins):=20=E6=8F=92=E4=BB=B6=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=99=A8=E6=8E=A5=E7=BA=BF=20=E2=80=94=E2=80=94=20?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=8E=A5=E5=85=A5=E7=82=B9=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=20+=20=E5=8A=A8=E4=BD=9C=E5=88=86=E5=8F=91=EF=BC=88PLG-2?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 登记由主会话随后统一落(本片报告给了可直接粘贴的契约段)。 --- cmd/cyrene/wire_frontend.go | 67 +++ cmd/cyrene/wire_panels_test.go | 139 +++++ internal/capability/plugin/manager.go | 160 +++++- internal/capability/plugin/panels.go | 487 ++++++++++++++++++ .../plugin/panels_lifecycle_test.go | 141 +++++ internal/capability/plugin/panels_test.go | 326 ++++++++++++ internal/services/gateway/admin_panels.go | 312 +++++++++++ .../services/gateway/admin_panels_test.go | 354 +++++++++++++ internal/services/gateway/routes.go | 10 + 9 files changed, 1990 insertions(+), 6 deletions(-) create mode 100644 cmd/cyrene/wire_panels_test.go create mode 100644 internal/capability/plugin/panels.go create mode 100644 internal/capability/plugin/panels_lifecycle_test.go create mode 100644 internal/capability/plugin/panels_test.go create mode 100644 internal/services/gateway/admin_panels.go create mode 100644 internal/services/gateway/admin_panels_test.go diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index b3304be..b9ef56f 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -3,15 +3,19 @@ package main import ( "context" "fmt" + "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" "git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/knowledge" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory/vector" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" "git.yeij.top/AskaEth/Cyrene_v2/internal/services/gateway" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" ) // capacityEmitter 是面向网关的 capacity_awaken 广播出口。 @@ -26,6 +30,10 @@ type capacityEmitter interface { func (a *app) wireFrontend() error { jwt := auth.New(a.cfg.Auth) apiKeys := a.wireAPIKeys() + // 插件管理面板声明中枢(design/09 §5.2.1;切片 PLG-2)必须挂在**同一个**插件管理器上 + // (两处各存一份生命周期事实必然分叉),且在 gateway.New 之前接好:网关的两条面板端点 + // 都经 s.plugins.Panels() 取它。见 wirePanelHub。 + wirePanelHub(a) ragStore := knowledge.New(knowledge.Deps{ Projects: a.projStore, Retriever: a.retriever, @@ -155,6 +163,65 @@ func (a *app) wireFrontend() error { return nil } +// wirePanelHub 给已构造的插件管理器接上面板声明中枢,并把它接到既有进程内 bus 的 +// `ws.panels_changed` 上(design/09 §5.2.1;切片 PLG-2)。 +// +// 三条约束都在这里落地: +// - **不新开端口**:热更新信号只走进程内 bus(hub 订阅 ws.> ⇒ 帧类型 panels_changed), +// 与既有实时帧同一根管子; +// - **帧不带内容**:载荷只有 reason / pluginId,前端按自己的角色重拉一次 +// GET /api/v1/admin/panels,避免在帧里再实现一遍角色过滤(也就不会漏过门); +// - **接线失败不阻断启动**:中枢构造失败只告警(网关随后对两条面板端点诚实回 501), +// 与 refresh store / key store 的降级口径一致。 +func wirePanelHub(a *app) { + if a.plugins == nil { + return + } + hub, err := pluginmgr.NewPanels(logger.WithSource(a.log, "capability/plugin/panels")) + if err != nil { + a.log.Warn("panel hub unavailable, plugin panel endpoints will answer 501", "error", err) + return + } + hub.SetPublisher(func(ch pluginmgr.PanelChange) { + if a.bus == nil { + return + } + if err := a.bus.Publish(context.Background(), "ws.panels_changed", map[string]any{ + "reason": ch.Reason, + "pluginId": ch.PluginID, + }); err != nil { + a.log.Warn("panels_changed publish failed", "reason", ch.Reason, "pluginId", ch.PluginID, "error", err) + } + }) + a.plugins.AttachPanels(hub) + declareBuiltinPanels(hub, logger.WithSource(a.log, "capability/plugin/panels")) +} + +// declareBuiltinPanels 把**编译进二进制的内置插件**的面板声明交给中枢 +// (design/09 §5.5 的 dogfood:「网络搜索 → 搜索提供商」页)。 +// +// 为什么在组合根做这一步:内置插件的声明与它自己的能力同源(`plugins/tools/io/search` +// 里就摆着 `AdminPanels()`),没有卸载/安装路径 ⇒ 只有「装配期登记一次」这一种生命周期。 +// 热装 MCP 插件的声明走另一条路(manifest 读取 + 生命周期重算,后续切片)。 +// +// 声明被拒只告警、不阻断启动:一个坏声明不该让整个后端起不来(§5.2.1 的口径是 +// 「该面板不注册 + 显式报错」,不是「宿主陪葬」)。 +func declareBuiltinPanels(hub *pluginmgr.Panels, log *slog.Logger) { + declarations := []struct { + pluginID string + panels []sdk.Panel + }{ + {search.PanelPluginID, search.AdminPanels()}, + } + for _, d := range declarations { + accepted, err := hub.Declare(d.pluginID, d.panels) + if err != nil { + log.Warn("builtin panel declaration rejected", + "pluginId", d.pluginID, "accepted", accepted, "error", err) + } + } +} + // wireAPIKeys 构造 API key 管理器,并把落盘载体接到它上面(K1:修「每次重启 key // 全失效」——此前 key 表只在内存里,主人刚签的 key 被一次重启全部打掉,签发端点 // 长凭据的语义与「重启即作废」自相矛盾)。 diff --git a/cmd/cyrene/wire_panels_test.go b/cmd/cyrene/wire_panels_test.go new file mode 100644 index 0000000..2aa5481 --- /dev/null +++ b/cmd/cyrene/wire_panels_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "context" + "path/filepath" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" + "git.yeij.top/AskaEth/Cyrene_v2/plugins/tools/io/search" +) + +// 本文件锁定组合根的面板接线(切片 PLG-2):wirePanelHub 必须把插件管理器的面板 +// 中枢接到**既有进程内 bus** 的 `ws.panels_changed` 上 —— 前端热更新靠这条帧, +// 且不新开任何端口(单端口红线)。 +// +// 为什么在组合根测:帧的主题名与载荷形状是跨片契约(前端片按它消费), +// 只有对 wirePanelHub 取证才能证明「组合根真的发的是这一条」。 + +// TestWirePanelHubPublishesPanelsChanged 断言生命周期变化真的经 bus 推出 +// `ws.panels_changed`,且载荷带 reason / pluginId(不含任何面板内容)。 +func TestWirePanelHubPublishesPanelsChanged(t *testing.T) { + b := bus.New() + defer b.Close() + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + + wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()}) + hub := mgr.Panels() + if hub == nil { + t.Fatal("wirePanelHub 未把面板中枢接到插件管理器上") + } + + events := make(chan bus.Event, 8) + if _, err := b.Subscribe(context.Background(), "ws.panels_changed", func(_ context.Context, e bus.Event) error { + events <- e + return nil + }); err != nil { + t.Fatalf("subscribe: %v", err) + } + + enabled := true + panels := []sdk.Panel{{ + ID: "providers", Title: "搜索提供商", Order: 1, + When: &sdk.When{Enabled: &enabled}, + Sections: []sdk.Section{{ + Type: sdk.SectionStatus, ID: "chain", Title: "降级链", + Fields: []sdk.Field{{Key: "active", Type: sdk.FieldText}}, + }}, + }} + if _, err := hub.Declare("web-search", panels); err != nil { + t.Fatalf("Declare: %v", err) + } + hub.SetEnabled("web-search", false) + + for _, want := range []string{"declare", "disable"} { + select { + case e := <-events: + if e.Subject != "ws.panels_changed" { + t.Fatalf("subject = %q", e.Subject) + } + payload, ok := e.Payload.(map[string]any) + if !ok { + t.Fatalf("载荷类型 = %T,期望 map[string]any", e.Payload) + } + if payload["reason"] != want { + t.Fatalf("reason = %v,期望 %q", payload["reason"], want) + } + if payload["pluginId"] != "web-search" { + t.Fatalf("pluginId = %v,期望 web-search", payload["pluginId"]) + } + // 帧刻意不带内容:前端按自己的角色重拉快照,避免在这里再实现一遍角色过滤。 + if _, leaked := payload["panels"]; leaked { + t.Fatalf("panels_changed 帧不得携带面板内容:%v", payload) + } + case <-time.After(2 * time.Second): + t.Fatalf("未收到 %q 的 panels_changed 帧", want) + } + } +} + +// TestWirePanelHubDeclaresBuiltinSearchPanel 断言内置插件(web-search)的面板声明 +// 真的进了目录 —— 没有这一步,契约面在运行期就是一份空快照(design/09 §5.5 的 dogfood +// 也就无从验起)。顺带锁定「声明通过校验」:search.AdminPanels() 若被改坏,这里会红。 +func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) { + b := bus.New() + defer b.Close() + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + + wirePanelHub(&app{plugins: mgr, bus: b, log: testLog()}) + hub := mgr.Panels() + + entries, err := hub.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 1 || entries[0].PluginID != search.PanelPluginID || + entries[0].Panel.ID != search.PanelIDProviders { + t.Fatalf("内置面板 = %+v,期望 web-search/providers 一条", entries) + } + for _, name := range search.ActionNames() { + if _, ok := hub.Action(search.PanelPluginID, name); !ok { + t.Fatalf("内置插件声明的动作 %q 未进白名单", name) + } + } + // viewer 拿到的必须是裁掉 form 的只读投影。 + viewer, err := hub.List(auth.RoleViewer) + if err != nil { + t.Fatalf("List(viewer): %v", err) + } + if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 { + t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewer) + } +} + +// TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 +func TestWirePanelHubNilPluginsIsNoop(t *testing.T) { + wirePanelHub(&app{bus: bus.New(), log: testLog()}) +} + +// TestWirePanelHubWithoutBusIsNoop 断言没有 bus 时也只告警、不 panic +// (发布器内部判空,与 refresh store / key store 的降级口径一致)。 +func TestWirePanelHubWithoutBusIsNoop(t *testing.T) { + mgr := pluginmgr.NewManager(registry.New(), filepath.Join(t.TempDir(), "plugins.json"), testLog()) + defer mgr.Close(context.Background()) + wirePanelHub(&app{plugins: mgr, log: testLog()}) + hub := mgr.Panels() + if hub == nil { + t.Fatal("没有 bus 时仍应接上面板中枢(只是不发帧)") + } + if _, err := hub.Declare("web-search", nil); err != nil { + t.Fatalf("Declare: %v", err) + } +} diff --git a/internal/capability/plugin/manager.go b/internal/capability/plugin/manager.go index b15c398..1beef3d 100644 --- a/internal/capability/plugin/manager.go +++ b/internal/capability/plugin/manager.go @@ -61,6 +61,13 @@ type Manager struct { mu sync.Mutex servers map[string]*server + // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 + // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 + // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 + panelKeys map[string]string + // panels 是可选的面板声明中枢(§5.2.1)。为 nil 时管理器完全不知道面板, + // 既有行为逐字不变(未接线的进程/测试就是这样)。 + panels *Panels } // NewManager returns a Manager backed by reg and the plugins.json at path. @@ -68,7 +75,33 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager if log == nil { log = slog.New(slog.DiscardHandler) } - return &Manager{reg: reg, path: path, log: log, servers: make(map[string]*server)} + return &Manager{ + reg: reg, + path: path, + log: log, + servers: make(map[string]*server), + panelKeys: make(map[string]string), + } +} + +// AttachPanels 把面板声明中枢接到管理器上(design/09 §5.2.1「插件管理器 = 唯一 +// 中枢」)。必须在插件被安装**之前**接好,否则启动期恢复的那些插件不会留下目录键。 +// 传 nil 等于不接线(合法:未接线的进程行为与接线前逐字一致)。 +func (m *Manager) AttachPanels(p *Panels) { + m.mu.Lock() + m.panels = p + m.mu.Unlock() +} + +// Panels 返回已接线的面板中枢;未接线时返回 nil(网关据此诚实作答 501, +// 而不是回一份空面板列表假装「没有插件的页面」)。 +func (m *Manager) Panels() *Panels { + if m == nil { + return nil + } + m.mu.Lock() + defer m.mu.Unlock() + return m.panels } // Install connects cfg's MCP server, registers its tools, persists the config @@ -77,12 +110,13 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager // connect the previous one is restored (the on-disk config is never changed on a // failure). func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) (Installed, error) { - return m.install(ctx, cfg, true) + return m.install(ctx, cfg, true, PanelChangeInstalled) } // install is Install with the persist step optional (Restore reuses it without -// rewriting the file it just read). -func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool) (Installed, error) { +// rewriting the file it just read). changeReason labels the panel change pushed +// when the plugin becomes live (install vs enable). +func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, persist bool, changeReason string) (Installed, error) { cfg = cfg.Normalize() if err := cfg.Validate(); err != nil { return Installed{}, err @@ -109,6 +143,7 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p } m.put(srv) m.log.Info("plugin installed", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport, "tools", srv.toolCount) + m.markInstalled(cfg, changeReason) return Installed{Config: cfg, Live: true, ToolCount: srv.toolCount}, nil } @@ -116,7 +151,7 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p // tools and closes it, then deletes the persisted config. It reports whether // anything matched, so the caller can fall back to a plain capability removal. func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) { - srv := m.take(name) + srv := m.takeAny(name) deleted, err := m.persistDelete(name) if srv != nil { m.stop(ctx, srv) @@ -126,6 +161,11 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) { } removed := srv != nil || deleted if removed { + panelName := name + if srv != nil { + panelName = srv.cfg.Name + } + m.forgetPanels(panelName) m.log.Info("plugin uninstalled", "name", name) } return removed, nil @@ -171,7 +211,7 @@ func (m *Manager) Restore(ctx context.Context) { return } for _, cfg := range cfgs { - if _, err := m.install(ctx, cfg, false); err != nil { + if _, err := m.install(ctx, cfg, false, PanelChangeInstalled); err != nil { m.log.Warn("plugin restore skipped", "name", cfg.Name, "error", err) } } @@ -191,6 +231,114 @@ func (m *Manager) Close(ctx context.Context) { } } +// SetEnabled 启用或停用一个已安装插件。 +// +// 语义(§5.2.1 生命周期一栏):停用会关闭活的 server(其工具随之下线)并隐藏它的 +// 管理面板入口(`when: {enabled:true}` 的入口不再出现),**配置保留在 plugins.json** +// —— 因此重启后它仍然「已安装」,只是要不要连着由这次调用决定;启用则重新连接并 +// 让入口回来。两条路都会推一次 `panels_changed`。 +// +// ⚠️ 已知缺口(本片如实登记):启用状态本身**目前不持久化**(PluginConfig 没有该 +// 字段,给 plugins.json 加字段属 runtimeconfig 面,不在本片文件范围内),因此 +// **重启后停用态丢失、按已安装恢复为启用**。需要跨重启保持就寝语义时,先给 +// plugins.json 加 `enabled` 字段(并让 Restore 读它)。 +// +// 找不到该插件(既不在活 server 里也不在 plugins.json 里)时返回 ErrPluginNotFound。 +func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error { + name = strings.TrimSpace(name) + if name == "" { + return fmt.Errorf("%w: empty name", ErrPluginNotFound) + } + if !on { + return m.disable(ctx, name) + } + return m.enable(ctx, name) +} + +// ErrPluginNotFound 报告 SetEnabled 指定的插件既没活着也不在 plugins.json 里。 +var ErrPluginNotFound = errors.New("plugin: not found") + +// disable 停用一个插件:摘掉活的 server,保留配置,并隐藏面板入口。 +// +// 它刻意**不**动 plugins.json:「停用」不是「卸载」,卸载才是删配置 +// (design/09 §5.2.1 把 install/uninstall 与 enable/disable 并列,说明两者不同)。 +func (m *Manager) disable(ctx context.Context, name string) error { + srv := m.takeAny(name) + _, persisted := m.persistedConfig(name) + if srv == nil && !persisted { + return fmt.Errorf("%w: %s", ErrPluginNotFound, name) + } + if srv != nil { + m.stop(ctx, srv) + } + m.setPanelsEnabled(name, false) + m.log.Info("plugin disabled", "name", name) + return nil +} + +// enable 重新启用一个已安装插件:按 plugins.json 里的配置重新连接。 +// 已经活着的插件是幂等空操作(面板集合没变 ⇒ 不推帧)。 +func (m *Manager) enable(ctx context.Context, name string) error { + if m.liveServer(name) != nil { + return nil + } + cfg, ok := m.persistedConfig(name) + if !ok { + return fmt.Errorf("%w: %s", ErrPluginNotFound, name) + } + if _, err := m.install(ctx, cfg, false, PanelChangeEnabled); err != nil { + return err + } + return nil +} + +// liveServer 返回活着的 server(按名字或 id 匹配,无则 nil)。 +func (m *Manager) liveServer(name string) *server { + m.mu.Lock() + defer m.mu.Unlock() + if srv := m.servers[name]; srv != nil { + return srv + } + for _, srv := range m.servers { + if srv.cfg.ID != "" && srv.cfg.ID == name { + return srv + } + } + return nil +} + +// takeAny 按名字或 id 摘掉活着的 server(take 只认精确的名字键)。 +func (m *Manager) takeAny(name string) *server { + if srv := m.take(name); srv != nil { + return srv + } + m.mu.Lock() + defer m.mu.Unlock() + for key, srv := range m.servers { + if srv.cfg.ID != "" && srv.cfg.ID == name { + delete(m.servers, key) + return srv + } + } + return nil +} + +// persistedConfig 按名(或 id)在 plugins.json 里找一条配置。文件读不出来时按 +// 「没有这条」处理并记日志:停用/启用不该因为一个坏文件而变成 500。 +func (m *Manager) persistedConfig(name string) (runtimeconfig.PluginConfig, bool) { + cfgs, err := runtimeconfig.LoadPlugins(m.path) + if err != nil { + m.log.Warn("plugins.json load failed", "path", m.path, "error", err) + return runtimeconfig.PluginConfig{}, false + } + for _, cfg := range cfgs { + if cfg.Name == name || (cfg.ID != "" && cfg.ID == name) { + return cfg, true + } + } + return runtimeconfig.PluginConfig{}, false +} + // start connects cfg, registers its tools and surfaces its KindMCP capability. // On a registration failure it cleans up and returns the error. func (m *Manager) start(ctx context.Context, cfg runtimeconfig.PluginConfig) (*server, error) { diff --git a/internal/capability/plugin/panels.go b/internal/capability/plugin/panels.go new file mode 100644 index 0000000..91230ad --- /dev/null +++ b/internal/capability/plugin/panels.go @@ -0,0 +1,487 @@ +package plugin + +// 本文件把 design/09-插件生态 §5.2.1「插件管理器 = 唯一中枢」接到 PLG-1 的 +// paneldecl 库上:声明登记 → 目录(catalog)→ 生命周期重算 → 变更通知, +// 外加「已声明动作」的读写面索引,供网关的动作分发端点使用。 +// +// 为什么中枢放在插件管理器而不是网关:§5.2.1 明写「插件既不直接操作前端注册表, +// 也不直接读写自己的配置文件,两条路都必须经过插件管理器」。安装/卸载/启停的真源 +// 就在 *Manager,面板集合是它的函数值 —— 两处各存一份状态必然分叉。 +// +// 刻意不做的事: +// - 不注册 HTTP 端点、不认识 gateway(契约面在 internal/services/gateway); +// - 不读 manifest / 不读 config / 不读 env:声明由调用方(未来的 manifest 读取 +// 切片、或宿主内置插件)交给 Declare,单一事实来源; +// - 不新开端口、不自己起 goroutine:变更只经注入的发布器(组合根把它接到既有 +// 进程内 bus 的 `ws.panels_changed` 上)。 + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sort" + "strings" + "sync" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 面板集合变更的原因标签,也是 `panels_changed` 帧 `data.reason` 的取值集合 +// (§5.2.1 生命周期一栏点名的四种变化 + 声明本身的变化)。 +const ( + // PanelChangeDeclared 报告某插件的面板声明被登记或替换。 + PanelChangeDeclared = "declare" + // PanelChangeInstalled 报告某插件安装(或启动期恢复)成功。 + PanelChangeInstalled = "install" + // PanelChangeUninstalled 报告某插件被卸载,其声明整条移除。 + PanelChangeUninstalled = "uninstall" + // PanelChangeEnabled 报告某插件被启用。 + PanelChangeEnabled = "enable" + // PanelChangeDisabled 报告某插件被停用(入口隐藏,声明保留)。 + PanelChangeDisabled = "disable" +) + +// 面板中枢的哨兵错误。调用方用 errors.Is 判类,不靠字符串匹配。 +var ( + // ErrActionNotDeclared 报告动作名不在该插件的声明集里 ⇒ 404(§5.2.1: + // 只接受该插件声明过的动作名)。 + ErrActionNotDeclared = errors.New("plugin: action not declared") + // ErrActionUnavailable 报告动作已声明,但宿主没有接线执行器 ⇒ 501(诚实作答, + // 绝不假装执行成功)。 + ErrActionUnavailable = errors.New("plugin: action executor is not wired") + // ErrPluginDisabled 报告插件已被显式停用,其动作不再分发 ⇒ 409。 + ErrPluginDisabled = errors.New("plugin: plugin is disabled") +) + +// ActionKind 分类一个已声明动作的读写面(§5.4.1:只读事实面对 viewer 可见, +// 写动作一律 owner/admin)。分类由**声明位置**决定,不由名字猜: +// form 的 submit 与 table 的 rowActions 是写面,table/log 的 source 是读面。 +type ActionKind string + +const ( + // ActionRead 是只读来源动作(section.table / section.log 的 source)。 + ActionRead ActionKind = "read" + // ActionWrite 是写动作(section.form 的 submit、section.table 的 rowActions)。 + ActionWrite ActionKind = "write" +) + +// ActionRequest 是一次已声明动作的分发请求。 +type ActionRequest struct { + // PluginID 是声明该动作的插件 id(目录命名空间)。 + PluginID string + // Action 是插件声明的动作名(如 plugin.saveProvider)。它只是名字,不是 URL。 + Action string + // Payload 是请求体解码后的任意值(缺省为 nil)。宿主原样交给执行器, + // 自身不解释插件的数据形状。 + Payload any +} + +// ActionExecutor 执行插件动作。宿主端点只把**已声明**的动作交给它,因此实现 +// 无需再做「这个名字合法吗」的判断;但**绝不允许**由它去发起插件提供的 URL —— +// §5.4.4 的动作面只有名字。 +type ActionExecutor func(ctx context.Context, req ActionRequest) (any, error) + +// PanelChange 是一次面板集合变更。它是发布给前端的热更新信号,**不含任何面板 +// 内容**:前端按自己的角色重新拉一次 `GET /api/v1/admin/panels` 即可,避免 +// 在帧里再实现一次角色过滤。 +type PanelChange struct { + // Reason 是变更原因(本文件的 PanelChange* 常量)。 + Reason string + // PluginID 是发生变化的插件 id。为空表示「无法归因到单个插件」, + // 调用方应把整份快照视为可能已变。 + PluginID string +} + +// Panels 是并发安全的面板声明中枢:目录 + 启用状态 + 动作索引 + 变更发布。 +// +// 它必须经 NewPanels 构造(目录需要非 nil 的 when 求值上下文来源)。 +type Panels struct { + mu sync.RWMutex + cat *paneldecl.Catalog + decls map[string][]sdk.Panel + enabled map[string]bool + exec ActionExecutor + publish func(PanelChange) + log *slog.Logger +} + +// NewPanels 构造面板中枢。log 为 nil 时使用丢弃型 logger。 +func NewPanels(log *slog.Logger) (*Panels, error) { + if log == nil { + log = slog.New(slog.DiscardHandler) + } + p := &Panels{ + decls: make(map[string][]sdk.Panel), + enabled: make(map[string]bool), + log: log, + } + cat, err := paneldecl.NewCatalog(p.resolve) + if err != nil { + return nil, fmt.Errorf("plugin: panel catalog: %w", err) + } + p.cat = cat + return p, nil +} + +// SetPublisher 注入变更发布器(组合根把它接到既有进程内 bus 的 `ws.panels_changed`)。 +// nil 是合法值 ⇒ 只重算、不通知(单测与未接线的进程用得上)。 +func (p *Panels) SetPublisher(pub func(PanelChange)) { + if p == nil { + return + } + p.mu.Lock() + p.publish = pub + p.mu.Unlock() +} + +// SetExecutor 注入动作执行器。未注入时已声明动作返回 ErrActionUnavailable +// (501),而不是静默成功。 +func (p *Panels) SetExecutor(exec ActionExecutor) { + if p == nil { + return + } + p.mu.Lock() + p.exec = exec + p.mu.Unlock() +} + +// Declare 用 panels 整体替换 pluginID 的声明集(§5.2.1 的「整体替换」口径), +// 返回真正生效的面板 id。非法插件 id 整插件拒绝(目录键不了它);某个面板非法 +// 只拒该面板(口径与理由见 paneldecl.Catalog.Register)。 +// +// 被拒面板的动作**不进**动作索引:§5.2.1 的「该面板不注册」意味着它的 +// submit/source/rowActions 也不存在,否则面板没渲染出来、动作却能调用。 +func (p *Panels) Declare(pluginID string, panels []sdk.Panel) ([]string, error) { + if p == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + accepted, err := p.cat.Register(pluginID, panels) + if errors.Is(err, paneldecl.ErrInvalidPluginID) { + return accepted, err + } + p.mu.Lock() + p.decls[pluginID] = keepAccepted(panels, accepted) + p.mu.Unlock() + p.emit(PanelChange{Reason: PanelChangeDeclared, PluginID: pluginID}) + return accepted, err +} + +// MarkInstalled 报告某插件已安装(或启动期恢复成功):确保它处于启用态, +// 并推一次面板变更(§5.2.1:install 也重算面板集合)。 +// +// pluginID 为空表示无法把该插件归因到目录键(见 PanelPluginID),此时只记日志 —— +// 没有合法命名空间就没有可挂的面板,这与 paneldecl「非法插件 id 整插件拒绝」同向。 +func (p *Panels) MarkInstalled(pluginID string) { + p.setEnabled(pluginID, true, PanelChangeInstalled, true) +} + +// MarkUninstalled 报告某插件已卸载:整条移除它的声明(面板与动作一起消失)。 +func (p *Panels) MarkUninstalled(pluginID string) { + if p == nil || pluginID == "" { + return + } + p.cat.Remove(pluginID) + p.mu.Lock() + delete(p.decls, pluginID) + delete(p.enabled, pluginID) + p.mu.Unlock() + p.emit(PanelChange{Reason: PanelChangeUninstalled, PluginID: pluginID}) +} + +// SetEnabled 显式启用或停用一个插件:停用后 `when: {enabled:true}` 的入口不出现 +// (§5.6.1),声明本身保留(重新启用即原样回来)。启用态**未变化时不发通知**, +// 避免「点两次启用」推两次无意义的帧。 +func (p *Panels) SetEnabled(pluginID string, on bool) { + reason := PanelChangeDisabled + if on { + reason = PanelChangeEnabled + } + p.setEnabled(pluginID, on, reason, false) +} + +// Enabled 报告插件当前是否启用。**缺省为启用**:声明只在安装期存在(卸载即整条 +// 移除),所以「没有显式停用记录」等于启用;只有 SetEnabled(false) 会写下 false。 +// 这样 `when:{enabled:true}` 的入口在正常安装后可见,`when:{enabled:false}` 的入口 +// 要等显式停用才出现 —— 两条语义都可表达,且不需要第二份「装没装」的事实。 +func (p *Panels) Enabled(pluginID string) bool { + if p == nil { + return false + } + p.mu.RLock() + defer p.mu.RUnlock() + return p.enabledLocked(pluginID) +} + +// List 返回按角色过滤后的面板快照(含 when 求值),排序与 paneldecl 一致。 +func (p *Panels) List(role auth.Role) ([]paneldecl.Entry, error) { + if p == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + return p.cat.List(role) +} + +// Groups 返回按侧边栏分组组织的同一份快照(§5.2.1 的 nav.group 一级)。 +func (p *Panels) Groups(role auth.Role) ([]paneldecl.Group, error) { + if p == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + return p.cat.Groups(role) +} + +// PluginIDs 返回当前登记过声明的插件 id(已排序)。**不等于已安装插件**: +// 它是 §5.2.1 红线③的支撑面 —— 前端可拿它与已安装列表对照,把「已装、未声明 +// 任何页面」的插件如实显示成「未声明管理页面」。 +func (p *Panels) PluginIDs() []string { + if p == nil { + return nil + } + return p.cat.PluginIDs() +} + +// Action 报告 pluginID 是否声明过 action,并给出它的读写面。 +func (p *Panels) Action(pluginID, action string) (ActionKind, bool) { + if p == nil || action == "" { + return "", false + } + p.mu.RLock() + defer p.mu.RUnlock() + kind, ok := p.actionIndexLocked(pluginID)[action] + return kind, ok +} + +// DeclaredActions 返回某插件已声明的动作名(已排序,读/写混在一起)。 +// 它面向诊断与测试;鉴权判定请用 Action(读写面要分开)。 +func (p *Panels) DeclaredActions(pluginID string) []string { + if p == nil { + return nil + } + p.mu.RLock() + defer p.mu.RUnlock() + idx := p.actionIndexLocked(pluginID) + out := make([]string, 0, len(idx)) + for name := range idx { + out = append(out, name) + } + sort.Strings(out) + return out +} + +// Dispatch 把一次动作交给执行器。它按「未声明 ⇒ 未声明」「已停用 ⇒ 停用」 +// 「未接线 ⇒ 未接线」三条分别报错,绝不用一个笼统失败掩盖病因。 +// +// 路由层已在进门前校验过动作名与角色;这里再校验一次是**纵深防御**: +// Dispatch 是唯一的执行入口,不能只靠调用方自觉。 +func (p *Panels) Dispatch(ctx context.Context, req ActionRequest) (any, error) { + if p == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + if _, ok := p.Action(req.PluginID, req.Action); !ok { + return nil, fmt.Errorf("%w: %s/%s", ErrActionNotDeclared, req.PluginID, req.Action) + } + if !p.Enabled(req.PluginID) { + return nil, fmt.Errorf("%w: %s", ErrPluginDisabled, req.PluginID) + } + p.mu.RLock() + exec := p.exec + p.mu.RUnlock() + if exec == nil { + return nil, fmt.Errorf("%w: %s/%s", ErrActionUnavailable, req.PluginID, req.Action) + } + return exec(ctx, req) +} + +// setEnabled 是启用态变更的唯一实现(force=true 表示无论是否变化都发通知, +// 供 install/restore 这类「生命周期事件本身就值得一帧」的场合使用)。 +func (p *Panels) setEnabled(pluginID string, on bool, reason string, force bool) { + if p == nil { + return + } + if pluginID == "" { + p.log.Warn("panel change without a catalog key", "reason", reason) + return + } + p.mu.Lock() + prev := p.enabledLocked(pluginID) + p.enabled[pluginID] = on + p.mu.Unlock() + if !force && prev == on { + return + } + p.emit(PanelChange{Reason: reason, PluginID: pluginID}) +} + +// emit 把一次变更交给发布器。它在状态锁**之外**调用:发布器会碰 bus(外部代码), +// 持锁调用等于把中枢的锁交给调用方(本项目踩过同类自锁死)。 +func (p *Panels) emit(ch PanelChange) { + if p == nil { + return + } + p.mu.RLock() + pub := p.publish + p.mu.RUnlock() + if pub == nil { + return + } + pub(ch) +} + +// resolve 是目录的 when 求值上下文来源。它只读启用状态,**绝不**回调目录方法 +// (paneldecl 明令:Resolver 回调 Register/List/Groups 会与目录锁自锁死)。 +func (p *Panels) resolve(pluginID string) paneldecl.Context { + p.mu.RLock() + defer p.mu.RUnlock() + return paneldecl.Context{Enabled: p.enabledLocked(pluginID)} +} + +// enabledLocked 读取启用态(缺省启用,见 Enabled)。调用方必须持有 p.mu。 +func (p *Panels) enabledLocked(pluginID string) bool { + on, ok := p.enabled[pluginID] + if !ok { + return true + } + return on +} + +// actionIndexLocked 返回某插件的「动作名 → 读写面」索引。调用方必须持有 p.mu。 +func (p *Panels) actionIndexLocked(pluginID string) map[string]ActionKind { + return actionIndex(p.decls[pluginID]) +} + +// actionIndex 从一个插件的声明集里抽出动作索引。 +// +// 同一动作名可能同时出现在读面与写面(例如 table 的 source 与某个 form 的 submit +// 同名);此时一律按**写**处理 —— 收紧方向永远是对的,放宽才需要证据(§5.4.1)。 +func actionIndex(panels []sdk.Panel) map[string]ActionKind { + out := make(map[string]ActionKind) + for _, panel := range panels { + for _, s := range panel.Sections { + if s.Submit != nil { + out[s.Submit.Action] = ActionWrite + } + for _, ra := range s.RowActions { + out[ra.Action] = ActionWrite + } + if s.Source != nil { + if _, exists := out[s.Source.Action]; !exists { + out[s.Source.Action] = ActionRead + } + } + } + } + return out +} + +// keepAccepted 只留下被目录接受的声明(按 id 比对)。 +// +// 用途是让**动作索引**与目录一致:被拒面板的动作不得可调用。normalizePanel 只改 +// Group、不改 ID,因此按 ID 过滤是与目录等价的判据。 +func keepAccepted(panels []sdk.Panel, accepted []string) []sdk.Panel { + if len(accepted) == 0 { + return nil + } + ok := make(map[string]bool, len(accepted)) + for _, id := range accepted { + ok[id] = true + } + out := make([]sdk.Panel, 0, len(accepted)) + for _, p := range panels { + if ok[p.ID] { + out = append(out, p) + } + } + return out +} + +// PanelPluginID 给出一个已安装插件在面板目录里的键(命名空间)。 +// +// 口径:**优先 Name**(它是 plugins.json 的唯一标签、跨重启稳定、也是用户/面板 +// 自然使用的地址),Name 不是合法目录键时回落 ID(runtimeconfig.NewID 生成的 +// 32 位十六进制,形状天然合法),两者都不合法时返回空串(该插件无法拥有面板)。 +// +// 为什么不优先 ID:首次安装时 install 拿到的 cfg.ID 还是空的(ID 由 UpsertPlugin +// 在落盘那一刻才生成),按 ID 建键会让同一个插件在「首次安装」与「重启后恢复」两次 +// 得到不同的键 —— 那正是「重启后面板消失」的事故形状。导出它是为了让未来读取 +// manifest 的切片复用**同一个**函数取键,不出现第二个口径。 +func PanelPluginID(cfg runtimeconfig.PluginConfig) string { + if PanelIDValid(strings.TrimSpace(cfg.Name)) { + return strings.TrimSpace(cfg.Name) + } + if id := strings.TrimSpace(cfg.ID); PanelIDValid(id) { + return id + } + return "" +} + +// PanelIDValid 报告 id 能否直接当面板目录键(路径段形状)。 +// 判据直接问 paneldecl,不复制它的正则 —— 口径只能有一份。 +func PanelIDValid(id string) bool { + return id != "" && paneldecl.Validate(id, nil) == nil +} + +// --- 管理器侧的面板衔接(*Manager 与 *Panels 的结合面) ----------------------- + +// markInstalled 记录安装时用过的面板目录键(memo,供卸载/停用复用),并把它报给 +// 面板中枢。未接线时只记 memo —— 计时与归因不依赖中枢在不在。 +func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason string) { + key := PanelPluginID(cfg) + if key == "" { + m.log.Warn("plugin has no panel catalog key", "name", cfg.Name) + return + } + m.mu.Lock() + m.panelKeys[cfg.Name] = key + hub := m.panels + m.mu.Unlock() + if hub == nil { + return + } + // 由「启用」触发的重连走 enable 语义(入口从隐藏变可见),其余(安装/启动期恢复) + // 走 install 语义。 + if changeReason == PanelChangeEnabled { + hub.SetEnabled(key, true) + return + } + hub.MarkInstalled(key) +} + +// forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 +// (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 +func (m *Manager) forgetPanels(name string) { + m.mu.Lock() + key := m.panelKeys[name] + delete(m.panelKeys, name) + hub := m.panels + m.mu.Unlock() + if hub == nil { + return + } + if key == "" { + key = strings.TrimSpace(name) + if !PanelIDValid(key) { + return + } + } + hub.MarkUninstalled(key) +} + +// setPanelsEnabled 把启用态变化交给面板中枢(未接线时空操作)。 +func (m *Manager) setPanelsEnabled(name string, on bool) { + m.mu.Lock() + key := m.panelKeys[name] + hub := m.panels + m.mu.Unlock() + if hub == nil { + return + } + if key == "" { + key = strings.TrimSpace(name) + if !PanelIDValid(key) { + return + } + } + hub.SetEnabled(key, on) +} diff --git a/internal/capability/plugin/panels_lifecycle_test.go b/internal/capability/plugin/panels_lifecycle_test.go new file mode 100644 index 0000000..baee118 --- /dev/null +++ b/internal/capability/plugin/panels_lifecycle_test.go @@ -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 +} diff --git a/internal/capability/plugin/panels_test.go b/internal/capability/plugin/panels_test.go new file mode 100644 index 0000000..3d71443 --- /dev/null +++ b/internal/capability/plugin/panels_test.go @@ -0,0 +1,326 @@ +package plugin + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定 PLG-2 的面板中枢口径:声明登记 → 目录 → 生命周期重算 → 变更通知, +// 以及「已声明动作」的读写面与分发三态。 + +// boolPtr 是 sdk.When.Enabled 三态指针的简写。 +func boolPtr(v bool) *bool { return &v } + +// samplePanel 是逐字对齐 design/09 §5.2 示例的声明(form 写面 + table 读/写面)。 +func samplePanel() sdk.Panel { + return sdk.Panel{ + ID: "providers", + Title: "搜索提供商", + Order: 20, + Icon: "search", + When: &sdk.When{Enabled: boolPtr(true)}, + Sections: []sdk.Section{ + { + Type: sdk.SectionForm, + ID: "add", + Title: "添加提供商", + Fields: []sdk.Field{ + {Key: "provider", Label: "提供商", Type: sdk.FieldSelect, + OptionsFrom: "plugin:providers", Required: true}, + {Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true}, + }, + Submit: &sdk.Submit{Action: "plugin.saveProvider"}, + }, + { + Type: sdk.SectionTable, + ID: "list", + Title: "已配置的提供商", + Source: &sdk.Source{Action: "plugin.listProviders"}, + Columns: []sdk.Column{ + {Key: "provider", Label: "提供商"}, + {Key: "state", Label: "状态"}, + }, + RowActions: []sdk.RowAction{ + {ID: "test", Label: "测试连通", Action: "plugin.testProvider"}, + }, + }, + }, + } +} + +// newTestHub 构造一个接好发布器(把变更记进切片)的中枢。 +func newTestHub(t *testing.T) (*Panels, *changeRecorder) { + t.Helper() + hub, err := NewPanels(nil) + if err != nil { + t.Fatalf("NewPanels: %v", err) + } + rec := &changeRecorder{} + hub.SetPublisher(rec.record) + return hub, rec +} + +// changeRecorder 以并发安全方式收集面板变更(中枢在任意 goroutine 上发布)。 +type changeRecorder struct { + mu sync.Mutex + changes []PanelChange +} + +func (r *changeRecorder) record(ch PanelChange) { + r.mu.Lock() + r.changes = append(r.changes, ch) + r.mu.Unlock() +} + +func (r *changeRecorder) all() []PanelChange { + r.mu.Lock() + defer r.mu.Unlock() + return append([]PanelChange(nil), r.changes...) +} + +// panelIDs 抽取快照里的面板 id(顺序敏感)。 +func panelIDs(entries []paneldecl.Entry) []string { + out := make([]string, 0, len(entries)) + for _, e := range entries { + out = append(out, e.Panel.ID) + } + return out +} + +// TestPanels_RoleFilterReachesWireShape 锁定「快照按角色裁剪」经中枢一路生效到 +// 返回值:owner 拿到 form(写面)与 table,viewer 只剩 table 且行内动作被摘掉。 +func TestPanels_RoleFilterReachesWireShape(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare: %v", err) + } + owner, err := hub.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List(owner): %v", err) + } + viewer, err := hub.List(auth.RoleViewer) + if err != nil { + t.Fatalf("List(viewer): %v", err) + } + if len(owner) != 1 || len(owner[0].Panel.Sections) != 2 { + t.Fatalf("owner 快照 = %+v,期望 1 个面板 / 2 个区块", owner) + } + if len(viewer) != 1 || len(viewer[0].Panel.Sections) != 1 { + t.Fatalf("viewer 快照 = %+v,期望 1 个面板 / 1 个区块(form 是写面,应被裁掉)", viewer) + } + if s := viewer[0].Panel.Sections[0]; s.Type != sdk.SectionTable || len(s.RowActions) != 0 { + t.Fatalf("viewer 的 table = %+v,期望行内动作被摘成空", s) + } + // 未知角色必须 fail-closed(不猜默认角色放行)。 + if _, err := hub.List(auth.Role("guest")); err == nil { + t.Fatalf("未知角色 List 应当报错,实际通过") + } +} + +// TestPanels_WhenEnabledHidesAndRestores 锁定 §5.6.1:显式停用 ⇒ 入口不出现, +// 重新启用 ⇒ 入口原样回来(声明没有被删掉)。 +func TestPanels_WhenEnabledHidesAndRestores(t *testing.T) { + t.Parallel() + hub, rec := newTestHub(t) + if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare: %v", err) + } + if got := mustIDs(t, hub); len(got) != 1 { + t.Fatalf("默认启用态下应当看到 1 个面板,实际 %v", got) + } + hub.SetEnabled("web-search", false) + if got := mustIDs(t, hub); len(got) != 0 { + t.Fatalf("停用后入口应当不出现,实际 %v", got) + } + hub.SetEnabled("web-search", true) + if got := mustIDs(t, hub); len(got) != 1 { + t.Fatalf("重新启用后入口应当回来,实际 %v", got) + } + // 变更通知:declare→disable→enable(「启用两次」的第二次是幂等空操作, + // 因为状态没变、面板集合也没变)。 + changes := rec.all() + if len(changes) != 3 { + t.Fatalf("变更通知 = %+v,期望 3 条", changes) + } + hub.SetEnabled("web-search", true) + if n := len(rec.all()); n != 3 { + t.Fatalf("幂等启用不应再推通知,实际 %d 条", n) + } +} + +// TestPanels_RejectedPanelKeepsItsNeighboursAndItsActionsStayUndeclared 锁定口径: +// 非法面板只拒该面板(同插件其余面板照常注册),且**被拒面板的动作不可调用**。 +func TestPanels_RejectedPanelKeepsItsNeighboursAndItsActionsStayUndeclared(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + bad := samplePanel() + bad.ID = "Bad_ID" // 大写与下划线不在 id 形状内 + bad.Sections[0].Submit = &sdk.Submit{Action: "plugin.nukeProvider"} + accepted, err := hub.Declare("web-search", []sdk.Panel{samplePanel(), bad}) + if err == nil { + t.Fatalf("含非法面板的声明应当返回聚合错误") + } + if len(accepted) != 1 || accepted[0] != "providers" { + t.Fatalf("accepted = %v,期望只接受 providers", accepted) + } + if got := mustIDs(t, hub); len(got) != 1 { + t.Fatalf("合法面板应当照常注册,实际 %v", got) + } + if _, ok := hub.Action("web-search", "plugin.saveProvider"); !ok { + t.Fatalf("合法面板的 submit 动作应当已声明") + } + // 被拒面板的动作不得可调用:面板没渲染出来、动作却能调,就是「半渲染 + 越权面」。 + if _, ok := hub.Action("web-search", "plugin.nukeProvider"); ok { + t.Fatalf("被拒面板的动作不得进入白名单") + } +} + +// TestPanels_InvalidPluginIDIsRejectedWholesale 锁定 paneldecl 的整插件拒绝口径: +// 没有合法命名空间就没有可挂的东西,一个面板都不进目录,也不留动作。 +func TestPanels_InvalidPluginIDIsRejectedWholesale(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + accepted, err := hub.Declare("Web Search", []sdk.Panel{samplePanel()}) + if !errors.Is(err, paneldecl.ErrInvalidPluginID) { + t.Fatalf("err = %v,期望 ErrInvalidPluginID 可判类", err) + } + if len(accepted) != 0 || len(mustIDs(t, hub)) != 0 { + t.Fatalf("非法插件 id 应当整插件拒绝,accepted=%v ids=%v", accepted, mustIDs(t, hub)) + } + if _, ok := hub.Action("Web Search", "plugin.saveProvider"); ok { + t.Fatalf("被整插件拒绝的声明不得留下任何动作") + } +} + +// TestPanels_ActionKindsFollowDeclarationSite 锁定读写面由**声明位置**决定: +// table/log 的 source 是读来源,form 的 submit 与 table 的 rowActions 是写面。 +func TestPanels_ActionKindsFollowDeclarationSite(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare: %v", err) + } + cases := []struct { + action string + want ActionKind + }{ + {"plugin.listProviders", ActionRead}, + {"plugin.saveProvider", ActionWrite}, + {"plugin.testProvider", ActionWrite}, + } + for _, c := range cases { + got, ok := hub.Action("web-search", c.action) + if !ok || got != c.want { + t.Fatalf("Action(%s) = (%q,%v),期望 (%q,true)", c.action, got, ok, c.want) + } + } + if _, ok := hub.Action("web-search", "plugin.deleteEverything"); ok { + t.Fatalf("未声明的动作不得被判为已声明") + } + if got := hub.DeclaredActions("web-search"); strings.Join(got, ",") != + "plugin.listProviders,plugin.saveProvider,plugin.testProvider" { + t.Fatalf("DeclaredActions = %v", got) + } +} + +// TestPanels_DispatchThreeWayRefusal 锁定分发失败的三种病因各自可判类, +// 且执行器只在「已声明 + 已启用 + 已接线」时才被调用。 +func TestPanels_DispatchThreeWayRefusal(t *testing.T) { + t.Parallel() + hub, _ := newTestHub(t) + if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare: %v", err) + } + req := ActionRequest{PluginID: "web-search", Action: "plugin.listProviders", Payload: map[string]any{"k": "v"}} + + if _, err := hub.Dispatch(context.Background(), ActionRequest{PluginID: "web-search", Action: "nope"}); !errors.Is(err, ErrActionNotDeclared) { + t.Fatalf("未声明动作 err = %v,期望 ErrActionNotDeclared", err) + } + if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrActionUnavailable) { + t.Fatalf("未接线执行器 err = %v,期望 ErrActionUnavailable", err) + } + + var seen ActionRequest + hub.SetExecutor(func(_ context.Context, r ActionRequest) (any, error) { + seen = r + return map[string]any{"rows": 1}, nil + }) + out, err := hub.Dispatch(context.Background(), req) + if err != nil { + t.Fatalf("Dispatch: %v", err) + } + if seen.PluginID != "web-search" || seen.Action != "plugin.listProviders" { + t.Fatalf("执行器收到 %+v", seen) + } + if m, ok := out.(map[string]any); !ok || m["rows"] != 1 { + t.Fatalf("Dispatch 返回 %#v,期望执行器结果原样透传", out) + } + + hub.SetEnabled("web-search", false) + if _, err := hub.Dispatch(context.Background(), req); !errors.Is(err, ErrPluginDisabled) { + t.Fatalf("停用后 err = %v,期望 ErrPluginDisabled", err) + } +} + +// TestPanels_UninstallRemovesDeclarationAndActions 锁定卸载口径:声明与动作一起消失 +// (「没有声明的面板就不存在」),且目录里不再列出该插件。 +func TestPanels_UninstallRemovesDeclarationAndActions(t *testing.T) { + t.Parallel() + hub, rec := newTestHub(t) + if _, err := hub.Declare("web-search", []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare: %v", err) + } + hub.MarkUninstalled("web-search") + if ids := mustIDs(t, hub); len(ids) != 0 { + t.Fatalf("卸载后不该再有面板,实际 %v", ids) + } + if _, ok := hub.Action("web-search", "plugin.saveProvider"); ok { + t.Fatalf("卸载后动作不得仍被声明") + } + if pids := hub.PluginIDs(); len(pids) != 0 { + t.Fatalf("卸载后 PluginIDs = %v,期望空", pids) + } + changes := rec.all() + if len(changes) != 2 || changes[1].Reason != PanelChangeUninstalled { + t.Fatalf("变更通知 = %+v,期望最后一条是 uninstall", changes) + } +} + +// TestPanelPluginIDPrefersName 锁定目录键口径:优先插件名(跨重启稳定的 +// plugins.json 标签),名字不是合法路径段时才回落 id。 +func TestPanelPluginIDPrefersName(t *testing.T) { + t.Parallel() + cases := []struct { + cfg runtimeconfig.PluginConfig + want string + }{ + {runtimeconfig.PluginConfig{Name: "web-search", ID: "deadbeef"}, "web-search"}, + {runtimeconfig.PluginConfig{Name: "Web Search", ID: "deadbeef"}, "deadbeef"}, + {runtimeconfig.PluginConfig{Name: "Web Search"}, ""}, + {runtimeconfig.PluginConfig{ID: "deadbeef"}, "deadbeef"}, + } + for _, c := range cases { + if got := PanelPluginID(c.cfg); got != c.want { + t.Fatalf("PanelPluginID(%+v) = %q,期望 %q", c.cfg, got, c.want) + } + } +} + +// mustIDs 返回某插件目录键下的面板 id 序列(owner 视角)。 +func mustIDs(t *testing.T, hub *Panels) []string { + t.Helper() + entries, err := hub.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + return panelIDs(entries) +} diff --git a/internal/services/gateway/admin_panels.go b/internal/services/gateway/admin_panels.go new file mode 100644 index 0000000..a846eee --- /dev/null +++ b/internal/services/gateway/admin_panels.go @@ -0,0 +1,312 @@ +package gateway + +// 本文件是 design/09-插件生态 §5.2.1 的「唯一契约面」在网关侧的落点(切片 PLG-2): +// +// - GET /api/v1/admin/panels —— 按调用者角色过滤后的接入点快照 +// - POST /api/v1/admin/plugins/{id}/actions/{action} —— 已声明动作的分发(鉴权 + 审计) +// +// 两条路都**只**经插件管理器的面板中枢(s.plugins.Panels())取事实,网关自己不读 +// plugins.json、不猜插件的存储形状(§5.2.1 面板侧红线②)。中枢未接时两条路都回 +// 501 panels_unavailable —— 绝不回一份空面板列表把「没接线」伪装成「没有页面」。 +// +// 动作面是**名字**面,不是 URL 面:插件声明的只有动作名,宿主不接受任何来自声明的 +// 请求地址(§5.4.4 不给插件开 SSRF 面)。 + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// panelActionBodyLimit 限制动作请求体大小。面板动作的载荷是表单值/行标识这类小对象, +// 1 MiB 已极宽裕;不设限会让一个坏客户端把整个请求体读进内存。 +const panelActionBodyLimit = 1 << 20 + +// errPanelActionBodyTooLarge 是本地哨兵,用来把「体太大」与「不是合法 JSON」分开 +// 映射到同一条 400 的不同文案。 +var errPanelActionBodyTooLarge = errors.New("plugin action body too large") + +// AdminPanelItem 是下发给面板的一条导航条目(§5.2.1 的 nav.item + page.root)。 +// +// Sections 直接复用 SDK 的声明类型:那份形状(JSON tag 与 §5.2 示例逐字同形)就是 +// 插件的公开声明面,网关不另立一份 DTO 去翻译它 —— 两套形状必然分叉。 +type AdminPanelItem struct { + // PluginID 是声明该面板的插件 id(目录命名空间)。 + PluginID string `json:"pluginId"` + // ID 是面板 id。 + ID string `json:"id"` + // Title 是页面标题(同时是侧边栏条目文案)。 + Title string `json:"title"` + // Description 是页面描述(page.root 消费;缺省省略)。 + Description string `json:"description,omitempty"` + // Icon 是宿主内置图标名(缺省省略 = 前端用默认图标)。 + Icon string `json:"icon,omitempty"` + // Order 是组内排序(小在前)。 + Order int `json:"order"` + // Route 是面板路由的**相对片段** `plugin//`,不含管理面板 + // 前缀(ADMIN_PATH 可变,前缀只有前端与入口注入页知道)—— 前端拼成 + // `/plugin//`,与 design/09 §5.7 C 同构。 + Route string `json:"route"` + // Sections 是该页面的区块声明(已按调用者角色裁剪:viewer 只留只读区块)。 + Sections []sdk.Section `json:"sections"` +} + +// AdminPanelGroup 是侧边栏折叠分组(§5.2.1 的 nav.group)。 +type AdminPanelGroup struct { + // ID 是分组 id(本期闭集只有 `plugins`)。 + ID string `json:"id"` + // Title 是分组的本地化显示名(宿主文案,见 panelGroupTitle)。 + Title string `json:"title"` + // Order 是分组排序(取组内最小 order)。 + Order int `json:"order"` + // Items 是分组内的条目。 + Items []AdminPanelItem `json:"items"` +} + +// AdminPanelsPayload 是 GET /api/v1/admin/panels 的 data 形状。 +type AdminPanelsPayload struct { + // Role 是本次快照按哪个角色过滤的(回显让前端能确认自己拿到的裁剪档)。 + Role string `json:"role"` + // Groups 是分组化后的面板集合。 + Groups []AdminPanelGroup `json:"groups"` + // PluginIDs 是**声明过面板**的插件 id(已排序)。它不等于已安装插件:前端拿它 + // 与 GET /admin/plugins/installed 对照,把「已装、未声明任何页面」的插件如实 + // 显示成「未声明管理页面」,而不是藏掉(§5.2.1 面板侧红线③)。 + PluginIDs []string `json:"pluginIds"` +} + +// adminListPanels 返回按调用者角色过滤后的面板接入点快照(§5.2.1 唯一契约面之一)。 +// +// 它是**读**端点,任何已认证角色都能调(路由门是 viewerAuth)—— 对 viewer 而言它 +// 回的就是被角色过滤裁掉写面后的只读投影(§5.4.1),因此不存在越权读。 +func (s *Server) adminListPanels(w http.ResponseWriter, r *http.Request) { + role, ok := callerRole(r) + if !ok { + writeError(w, apperr.Wrap(apperr.ErrUnauthorized, "unauthorized", "缺少调用者身份")) + return + } + hub := s.panelHub() + if hub == nil { + writeError(w, panelsUnavailable()) + return + } + groups, err := hub.Groups(role) + if err != nil { + writeError(w, apperr.Wrap(apperr.ErrForbidden, "unknown_role", "%v", err)) + return + } + out := AdminPanelsPayload{ + Role: string(role), + Groups: make([]AdminPanelGroup, 0, len(groups)), + PluginIDs: hub.PluginIDs(), + } + if out.PluginIDs == nil { + out.PluginIDs = []string{} + } + for _, g := range groups { + out.Groups = append(out.Groups, adminPanelGroupOf(g)) + } + writeJSON(w, http.StatusOK, out) +} + +// adminPanelGroupOf 把目录分组映射为其线形状。 +func adminPanelGroupOf(g paneldecl.Group) AdminPanelGroup { + items := make([]AdminPanelItem, 0, len(g.Panels)) + for _, e := range g.Panels { + items = append(items, AdminPanelItem{ + PluginID: e.PluginID, + ID: e.Panel.ID, + Title: e.Panel.Title, + Description: e.Panel.Description, + Icon: e.Panel.Icon, + Order: e.Panel.Order, + Route: panelRoute(e.PluginID, e.Panel.ID), + Sections: e.Panel.Sections, + }) + } + return AdminPanelGroup{ + ID: string(g.ID), + Title: panelGroupTitle(g.ID), + Order: g.Panels[0].Panel.Order, + Items: items, + } +} + +// adminPluginAction 分发一个插件动作(§5.2.1 动作分发 + 鉴权 + 审计)。 +// +// 顺序是刻意的**先证后行**: +// 1. 未声明 ⇒ 404(插件只给动作名 ⇒ 未声明的名字根本不存在,不是「没权限」); +// 2. 写动作 ⇒ 必须 owner/admin(读来源动作对 viewer 开放,§5.4.1); +// 3. 停止用插件 ⇒ 409; +// 4. 未接线执行器 ⇒ 501(诚实作答,绝不假装执行成功)。 +// +// 每一步都落审计:谁在什么时候想动哪个插件的哪个动作、结果如何。 +func (s *Server) adminPluginAction(w http.ResponseWriter, r *http.Request) { + pluginID := strings.TrimSpace(r.PathValue("id")) + action := strings.TrimSpace(r.PathValue("action")) + hub := s.panelHub() + if hub == nil { + s.auditPanelAction(r, pluginID, action, "unavailable") + writeError(w, panelsUnavailable()) + return + } + kind, declared := hub.Action(pluginID, action) + if !declared { + s.auditPanelAction(r, pluginID, action, "not_declared") + writeError(w, &apperr.AppError{ + Code: "action_not_declared", + Message: fmt.Sprintf("插件 %q 没有声明动作 %q(插件只给动作名,不给 URL)", pluginID, action), + HTTPCode: http.StatusNotFound, + }) + return + } + if kind == pluginmgr.ActionWrite && !callerHasWrite(r) { + s.auditPanelAction(r, pluginID, action, "denied") + writeError(w, apperr.Wrap(apperr.ErrForbidden, "forbidden", "写动作需要 owner/admin(§5.4.1)")) + return + } + payload, derr := decodeActionPayload(r) + if derr != nil { + s.auditPanelAction(r, pluginID, action, "bad_request") + writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", derr)) + return + } + res, err := hub.Dispatch(r.Context(), pluginmgr.ActionRequest{ + PluginID: pluginID, + Action: action, + Payload: payload, + }) + if err != nil { + status, code, msg := actionFailure(err) + s.auditPanelAction(r, pluginID, action, auditResultOf(err)) + writeError(w, &apperr.AppError{Code: code, Message: msg, Cause: err, HTTPCode: status}) + return + } + s.auditPanelAction(r, pluginID, action, "ok") + writeJSON(w, http.StatusOK, map[string]any{ + "pluginId": pluginID, + "action": action, + "kind": string(kind), + "result": res, + }) +} + +// actionFailure 把分发失败映射为 HTTP 状态 + 结构化错误码 + 文案 +// (「错误码与文案分离」,§5.4.6:前端据此本地化,不靠解析字符串)。 +func actionFailure(err error) (status int, code, message string) { + switch { + case errors.Is(err, pluginmgr.ErrActionNotDeclared): + return http.StatusNotFound, "action_not_declared", "该动作未在插件声明里出现过" + case errors.Is(err, pluginmgr.ErrPluginDisabled): + return http.StatusConflict, "plugin_disabled", "插件已停用,其动作不再分发" + case errors.Is(err, pluginmgr.ErrActionUnavailable): + return http.StatusNotImplemented, "action_not_implemented", "动作已声明,但宿主尚未接线执行器" + default: + return http.StatusBadGateway, "action_failed", "插件动作执行失败,详细原因见后端日志" + } +} + +// auditResultOf 把分发失败归成审计链里的一档结果。 +func auditResultOf(err error) string { + switch { + case errors.Is(err, pluginmgr.ErrActionNotDeclared): + return "not_declared" + case errors.Is(err, pluginmgr.ErrPluginDisabled): + return "disabled" + case errors.Is(err, pluginmgr.ErrActionUnavailable): + return "unimplemented" + default: + return "error" + } +} + +// auditPanelAction 把一次插件动作的结局写进审计链(who / what / when / result)。 +// 附加字段只有插件 id 与动作名 —— 请求体可能有凭据,绝不进审计。 +func (s *Server) auditPanelAction(r *http.Request, pluginID, action, result string) { + who := "anonymous" + if claims, ok := auth.ClaimsFromContext(r.Context()); ok { + who = claims.Sub + ":" + string(claims.Role) + } + s.audit(r.Context(), who, "plugin.action", result, "pluginId", pluginID, "action", action) +} + +// decodeActionPayload 解码可选的动作请求体:空体 ⇒ nil(多数读来源动作不需要参数), +// 非法 JSON 或超限 ⇒ 显式错误。绝不把坏体静默当空体继续跑。 +func decodeActionPayload(r *http.Request) (any, error) { + if r.Body == nil { + return nil, nil + } + raw, err := io.ReadAll(io.LimitReader(r.Body, panelActionBodyLimit+1)) + if err != nil { + return nil, fmt.Errorf("读取请求体失败: %w", err) + } + if len(raw) > panelActionBodyLimit { + return nil, errPanelActionBodyTooLarge + } + if strings.TrimSpace(string(raw)) == "" { + return nil, nil + } + var payload any + if err := json.Unmarshal(raw, &payload); err != nil { + return nil, fmt.Errorf("请求体不是合法 JSON: %w", err) + } + return payload, nil +} + +// panelHub 返回已接线的面板中枢(未接线时 nil)。网关只经插件管理器取它, +// 因此「面板中枢」与「插件生命周期」天然共享一份事实。 +func (s *Server) panelHub() *pluginmgr.Panels { + if s.plugins == nil { + return nil + } + return s.plugins.Panels() +} + +// callerRole 从请求上下文取已验证的调用者角色(路由门已保证存在;缺失即 fail-closed)。 +func callerRole(r *http.Request) (auth.Role, bool) { + claims, ok := auth.ClaimsFromContext(r.Context()) + if !ok || claims == nil || !claims.Role.Valid() { + return "", false + } + return claims.Role, true +} + +// callerHasWrite 报告调用者是否具备写权(owner/admin,沿用 design/11 的 writeAuth 口径)。 +func callerHasWrite(r *http.Request) bool { + role, ok := callerRole(r) + return ok && auth.HasRole(role, auth.RoleAdmin) +} + +// panelsUnavailable 是「面板中枢未接线」的诚实 501:不接线的部署拿到的是 +// 明确的错误码,而不是一份看起来正常但空的面板列表。 +func panelsUnavailable() error { + return &apperr.AppError{ + Code: "panels_unavailable", + Message: "插件面板中枢未接线(未注入 panel hub)", + HTTPCode: http.StatusNotImplemented, + } +} + +// panelRoute 给出前端路由的相对片段(不含 ADMIN_PATH 前缀,理由见 AdminPanelItem.Route)。 +func panelRoute(pluginID, panelID string) string { + return "plugin/" + pluginID + "/" + panelID +} + +// panelGroupTitle 给出分组的本地化显示名。§5.2.1 的 nav.group 消费 title/order, +// 但分组本身**没有声明**(`group` 只是分组 id)⇒ 分组名由宿主文案提供; +// PLG-1 报告 §2 不一致④已登记这个归属待裁决,改口径只改这一个函数。 +func panelGroupTitle(id sdk.GroupID) string { + if id == sdk.GroupPlugins { + return "插件" + } + return string(id) +} diff --git a/internal/services/gateway/admin_panels_test.go b/internal/services/gateway/admin_panels_test.go new file mode 100644 index 0000000..94ee36b --- /dev/null +++ b/internal/services/gateway/admin_panels_test.go @@ -0,0 +1,354 @@ +package gateway + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin" + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config" + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// 本文件锁定 design/09 §5.2.1 的两条「唯一契约面」(切片 PLG-2): +// GET /api/v1/admin/panels 与 POST /api/v1/admin/plugins/{id}/actions/{action}。 +// +// 断言重点不是「能返回 200」,而是三条红线: +// ① 快照按**调用者角色**过滤(viewer 拿不到写面); +// ② 动作**只接受已声明的名字**(未声明 ⇒ 404),写动作要 owner/admin; +// ③ 每个动作结算都落审计;未接线时诚实 501,绝不假装成功。 + +// panelTestEnv 是一个接线了「插件管理器 + 面板中枢」的网关。 +type panelTestEnv struct { + ts *httptest.Server + srv *Server + hub *pluginmgr.Panels + jwt *auth.JWTService +} + +func newPanelTestEnv(t *testing.T) *panelTestEnv { + t.Helper() + jwt := auth.New(config.AuthConfig{JWTSecret: testSecret}) + caps := capreg.New() + mgr := pluginmgr.NewManager(caps, filepath.Join(t.TempDir(), "plugins.json"), nil) + hub, err := pluginmgr.NewPanels(nil) + if err != nil { + t.Fatalf("NewPanels: %v", err) + } + mgr.AttachPanels(hub) + cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}} + srv, err := New(cfg, Deps{Auth: jwt, Bus: bus.New(), Registry: caps, Plugins: mgr}) + if err != nil { + t.Fatalf("new server: %v", err) + } + ts := httptest.NewServer(srv.Handler()) + t.Cleanup(func() { + ts.Close() + mgr.Close(context.Background()) + }) + env := &panelTestEnv{ts: ts, srv: srv, hub: hub, jwt: jwt} + env.declare(t, "web-search") + return env +} + +// declare 给 id 登记一份 form(写面)+ table(读面 + 行内写动作)的声明。 +func (e *panelTestEnv) declare(t *testing.T, pluginID string) { + t.Helper() + if _, err := e.hub.Declare(pluginID, []sdk.Panel{gatewayTestPanel()}); err != nil { + t.Fatalf("Declare(%s): %v", pluginID, err) + } +} + +// token 签发一个指定角色的访问令牌。 +func (e *panelTestEnv) token(t *testing.T, role auth.Role) string { + t.Helper() + tok, err := e.jwt.Sign("acct-"+string(role), role, time.Hour) + if err != nil { + t.Fatalf("sign %s: %v", role, err) + } + return tok +} + +// auditRecords 返回审计链里的记录(最新在前)。 +func (e *panelTestEnv) auditRecords(t *testing.T) []AuditRecord { + t.Helper() + recs, err := e.srv.obs.audit.List(context.Background(), 50) + if err != nil { + t.Fatalf("list audit: %v", err) + } + return recs +} + +// gatewayTestPanel 是 §5.2 示例形状的声明(form + table)。 +func gatewayTestPanel() sdk.Panel { + enabled := true + return sdk.Panel{ + ID: "providers", + Title: "搜索提供商", + Order: 20, + Icon: "search", + When: &sdk.When{Enabled: &enabled}, + Sections: []sdk.Section{ + { + Type: sdk.SectionForm, + ID: "add", + Title: "添加提供商", + Fields: []sdk.Field{ + {Key: "apiKey", Label: "API Key", Type: sdk.FieldSecret, Required: true}, + }, + Submit: &sdk.Submit{Action: "plugin.saveProvider"}, + }, + { + Type: sdk.SectionTable, + ID: "list", + Title: "已配置的提供商", + Source: &sdk.Source{Action: "plugin.listProviders"}, + Columns: []sdk.Column{ + {Key: "provider", Label: "提供商"}, + }, + RowActions: []sdk.RowAction{ + {ID: "test", Label: "测试连通", Action: "plugin.testProvider"}, + }, + }, + }, + } +} + +// TestAdminPanelsSnapshotIsRoleFiltered 锁定:同一份声明对 owner 与 viewer 下发的 +// 快照不同 —— owner 有 form(写面),viewer 只剩 table 且行内动作被摘掉。 +func TestAdminPanelsSnapshotIsRoleFiltered(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + + owner := panelsPayloadOf(t, env, auth.RoleOwner) + if owner.Role != "owner" || len(owner.Groups) != 1 { + t.Fatalf("owner 快照 = %+v", owner) + } + g := owner.Groups[0] + if g.ID != "plugins" || g.Title != "插件" || g.Order != 20 { + t.Fatalf("分组 = %+v,期望 plugins/插件/order=20", g) + } + if len(g.Items) != 1 { + t.Fatalf("条目 = %+v,期望 1 条", g.Items) + } + item := g.Items[0] + if item.PluginID != "web-search" || item.ID != "providers" || item.Route != "plugin/web-search/providers" { + t.Fatalf("条目 = %+v", item) + } + if len(item.Sections) != 2 { + t.Fatalf("owner 的区块 = %d 个,期望 form+table 两个", len(item.Sections)) + } + if len(owner.PluginIDs) != 1 || owner.PluginIDs[0] != "web-search" { + t.Fatalf("pluginIds = %v(红线③靠它与已安装列表对照)", owner.PluginIDs) + } + + viewer := panelsPayloadOf(t, env, auth.RoleViewer) + if viewer.Role != "viewer" || len(viewer.Groups) != 1 { + t.Fatalf("viewer 快照 = %+v", viewer) + } + vs := viewer.Groups[0].Items[0].Sections + if len(vs) != 1 || vs[0].Type != sdk.SectionTable { + t.Fatalf("viewer 的区块 = %+v,期望只剩 table(form 是写面)", vs) + } + if len(vs[0].RowActions) != 0 { + t.Fatalf("viewer 的行内动作应当被摘空,实际 %+v", vs[0].RowActions) + } +} + +// panelsPayloadOf 以给定角色拉一次面板快照并解码。 +func panelsPayloadOf(t *testing.T, env *panelTestEnv, role auth.Role) AdminPanelsPayload { + t.Helper() + resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/panels", nil, env.token(t, role)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("GET panels (%s) status = %d, body = %s", role, resp.StatusCode, raw) + } + var out struct { + Data AdminPanelsPayload `json:"data"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode panels: %v (%s)", err, raw) + } + return out.Data +} + +// TestAdminPanelsUnwiredAnswers501 锁定未接线口径:没有面板中枢时诚实回 501, +// 绝不回一份空列表把「没接线」伪装成「没有插件的页面」。 +func TestAdminPanelsUnwiredAnswers501(t *testing.T) { + t.Parallel() + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + _ = srv + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/panels", nil, tok) + if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") { + t.Fatalf("GET panels status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw) + } + resp, raw = doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/plugins/calc/actions/plugin.listProviders", nil, tok) + if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "panels_unavailable") { + t.Fatalf("POST action status = %d, body = %s,期望 501 panels_unavailable", resp.StatusCode, raw) + } +} + +// TestAdminPluginActionUndeclaredIs404 锁定 §5.2.1 的**动作白名单**:未在声明里 +// 出现过的动作名一律 404(插件只给名字,宿主没有可执行的东西)。 +func TestAdminPluginActionUndeclaredIs404(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + tok := env.token(t, auth.RoleOwner) + + resp, raw := doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.nukeEverything", nil, tok) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") { + t.Fatalf("status = %d, body = %s,期望 404 action_not_declared", resp.StatusCode, raw) + } + // 没有任何声明的插件同样 404(没有声明就没有名字空间,更谈不上动作)。 + resp, raw = doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/ghost/actions/plugin.listProviders", nil, tok) + if resp.StatusCode != http.StatusNotFound || !strings.Contains(string(raw), "action_not_declared") { + t.Fatalf("未声明插件的动作 status = %d, body = %s,期望 404", resp.StatusCode, raw) + } +} + +// TestAdminPluginActionWriteRequiresOwnerOrAdmin 锁定写动作门(§5.4.1): +// viewer 触发 form 的 submit ⇒ 403 且落审计;owner 且已接线执行器 ⇒ 200 且结果透传。 +func TestAdminPluginActionWriteRequiresOwnerOrAdmin(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + return map[string]any{"saved": req.Action, "payload": req.Payload}, nil + }) + url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider" + + resp, raw := doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleViewer)) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("viewer 写动作 status = %d, body = %s,期望 403", resp.StatusCode, raw) + } + if !hasAudit(env.auditRecords(t), "plugin.action", "denied") { + t.Fatalf("被拒的写动作必须落审计(denied)") + } + + resp, raw = doReq(t, http.MethodPost, url, map[string]any{"apiKey": "sk-x"}, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("owner 写动作 status = %d, body = %s,期望 200", resp.StatusCode, raw) + } + var out struct { + Data map[string]any `json:"data"` + } + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatalf("decode: %v (%s)", err, raw) + } + if out.Data["action"] != "plugin.saveProvider" || out.Data["kind"] != "write" { + t.Fatalf("动作响应 = %+v", out.Data) + } + if !hasAudit(env.auditRecords(t), "plugin.action", "ok") { + t.Fatalf("成功的写动作必须落审计(ok)") + } +} + +// TestAdminPluginActionReadSourceIsOpenToViewer 锁定读来源动作对 viewer 开放 +// (§5.4.1「只读事实面对 viewer 可见」):table 的 source 动作不被写门挡住。 +func TestAdminPluginActionReadSourceIsOpenToViewer(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + env.hub.SetExecutor(func(_ context.Context, req pluginmgr.ActionRequest) (any, error) { + return []map[string]any{{"provider": "searxng"}}, nil + }) + resp, raw := doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleViewer)) + if resp.StatusCode != http.StatusOK { + t.Fatalf("viewer 读来源动作 status = %d, body = %s,期望 200", resp.StatusCode, raw) + } + if !strings.Contains(string(raw), "searxng") { + t.Fatalf("读来源动作的结果应当透传:%s", raw) + } +} + +// TestAdminPluginActionHonest501WithoutExecutor 锁定「声明了但没接线」的诚实作答: +// 501 action_not_implemented,绝不回一份假的成功。 +func TestAdminPluginActionHonest501WithoutExecutor(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + resp, raw := doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.testProvider", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusNotImplemented || !strings.Contains(string(raw), "action_not_implemented") { + t.Fatalf("status = %d, body = %s,期望 501 action_not_implemented", resp.StatusCode, raw) + } + if !hasAudit(env.auditRecords(t), "plugin.action", "unimplemented") { + t.Fatalf("未接线的动作结算必须落审计(unimplemented)") + } +} + +// TestAdminPluginActionDisabledIs409 锁定停用插件不再分发动作(声明还在,入口没了)。 +func TestAdminPluginActionDisabledIs409(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + env.hub.SetEnabled("web-search", false) + resp, raw := doReq(t, http.MethodPost, + env.ts.URL+"/api/v1/admin/plugins/web-search/actions/plugin.listProviders", nil, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusConflict || !strings.Contains(string(raw), "plugin_disabled") { + t.Fatalf("status = %d, body = %s,期望 409 plugin_disabled", resp.StatusCode, raw) + } +} + +// TestAdminPluginActionRejectsMalformedBody 锁定请求体两条硬边界: +// 非法 JSON ⇒ 400(不当空体继续跑);超限 ⇒ 400(不把整个体读进内存)。 +func TestAdminPluginActionRejectsMalformedBody(t *testing.T) { + t.Parallel() + env := newPanelTestEnv(t) + env.hub.SetExecutor(func(_ context.Context, _ pluginmgr.ActionRequest) (any, error) { + return "should-not-run", nil + }) + url := env.ts.URL + "/api/v1/admin/plugins/web-search/actions/plugin.saveProvider" + + // 手工构造请求:doReq 会把入参 JSON 序列化,无法产生畸形 JSON 体。 + req, err := http.NewRequest(http.MethodPost, url, strings.NewReader("{not json")) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Authorization", "Bearer "+env.token(t, auth.RoleOwner)) + req.Header.Set("Content-Type", "application/json") + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + raw := readBody(t, resp) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("非法 JSON status = %d, body = %s,期望 400", resp.StatusCode, raw) + } + + big := strings.Repeat("a", panelActionBodyLimit+16) + resp, raw = doReq(t, http.MethodPost, url, map[string]any{"blob": big}, env.token(t, auth.RoleOwner)) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("超限请求体 status = %d, body = %s,期望 400", resp.StatusCode, raw) + } +} + +// readBody 读完并关闭响应体(供手工构造的请求复用)。 +func readBody(t *testing.T, resp *http.Response) []byte { + t.Helper() + defer resp.Body.Close() + b, err := io.ReadAll(resp.Body) + if err != nil { + t.Fatalf("read body: %v", err) + } + return b +} + +// hasAudit 报告审计链里是否存在指定的 (what, result) 记录。 +func hasAudit(recs []AuditRecord, what, result string) bool { + for _, r := range recs { + if r.What == what && r.Result == result { + return true + } + } + return false +} diff --git a/internal/services/gateway/routes.go b/internal/services/gateway/routes.go index 8f127b4..4b840b5 100644 --- a/internal/services/gateway/routes.go +++ b/internal/services/gateway/routes.go @@ -162,6 +162,16 @@ func (s *Server) registerAdminRoutes( // 任何新的持久化模型。 mux.Handle("GET /api/v1/admin/notifications", viewerAuth(http.HandlerFunc(s.adminListNotifications))) + // 插件管理面板的**唯一契约面**(design/09 §5.2.1;切片 PLG-2): + // - GET /admin/panels 是**按调用者角色过滤后**的接入点快照,走 viewerAuth —— + // viewer 拿到的是已被裁掉写面的只读投影(§5.4.1),因此这条读门不越权; + // - 动作端点走 viewerAuth,是因为 table/log 的 **source 读来源**动作对 viewer + // 可见;**写**动作(form 的 submit、table 的 rowActions)由 handler 内再按 + // owner|admin 收紧(callerHasWrite,见 admin_panels.go),与「写动作一律 + // writeAuth」逐字对齐 —— 路由中间件只能有一道门,写面的门因此落在 handler 里。 + mux.Handle("GET /api/v1/admin/panels", viewerAuth(http.HandlerFunc(s.adminListPanels))) + mux.Handle("POST /api/v1/admin/plugins/{id}/actions/{action}", viewerAuth(http.HandlerFunc(s.adminPluginAction))) + // 保留给 owner/admin 的管理读:私有内容、其他账号的 // 数据与审计链。不对 viewer 开放。 mux.Handle("GET /api/v1/admin/memories", writeAuth(http.HandlerFunc(s.adminListMemories)))