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

+154 -6
View File
@@ -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) {
+487
View File
@@ -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)
}
@@ -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
}
+326
View File
@@ -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)
}