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) {