From 9654ce886106d770cce1f27ba90cdba494d8ed9a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 19:31:31 +0800 Subject: [PATCH 1/3] =?UTF-8?q?feat(plugin):=20MCP=20=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=99=A8=E7=AE=A1=E7=90=86=E9=A1=B5=20+=20=E6=8C=89=20server?= =?UTF-8?q?=20=E7=8B=AC=E7=AB=8B=E5=90=AF=E5=81=9C=E4=B8=8E=E7=8A=B6?= =?UTF-8?q?=E6=80=81=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit design/09 §5.2.1 / §5.6.1:补上「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」的洞。 - runtimeconfig:PluginConfig 新增 `enabled`(*bool,缺省=启用 ⇒ 既有 plugins.json 向后兼容);新增 RedactPluginSecrets(整把凭据 + 任意 ≥6 字符片段一并抹除)。 - mcp:新增 status.go 的只读状态读面(ToolSummaries:工具名 + 截断描述,取自能力 注册表本身,因此与模型可见的工具集合同源)。 - plugin 管理器: · Install/Restore 尊重 enabled(停用=落盘不连接,重启后仍是停用,且一次都不连); · SetEnabled 落盘 enabled(停用先落盘再下线;启用失败时 enabled 保持 false); · 新增 Reconnect(对已停用者拒绝,不替用户启用); · 新增 Statuses(三态:connected 缺席=未知,工具数/明细/时间只在知道时给出, lastError 在记录那一刻即脱敏截断); · 内置「MCP 服务器」页声明 + 7 个已声明动作(list/install/reconnect/enable/ disable/uninstall/transports),走既有通用动作端点(owner-admin + 审计); · 护栏:热装插件的生命周期不改动内置提供者(mcp)的目录条目。 - gateway:已安装列表与 install 响应加 enabled,install 支持 config.enabled。 --- internal/capability/mcp/status.go | 56 +++ internal/capability/plugin/enabled.go | 186 ++++++++++ internal/capability/plugin/enabled_test.go | 223 ++++++++++++ internal/capability/plugin/manager.go | 145 ++------ internal/capability/plugin/manager_test.go | 10 + internal/capability/plugin/mcppanel.go | 191 ++++++++++ .../capability/plugin/mcppanel_actions.go | 331 ++++++++++++++++++ internal/capability/plugin/mcppanel_test.go | 274 +++++++++++++++ internal/capability/plugin/panels.go | 61 +++- internal/capability/plugin/status.go | 273 +++++++++++++++ internal/capability/plugin/status_test.go | 185 ++++++++++ internal/config/runtimeconfig/plugins.go | 79 +++++ internal/config/runtimeconfig/plugins_test.go | 61 ++++ internal/services/gateway/admin_plugins.go | 9 + 14 files changed, 1969 insertions(+), 115 deletions(-) create mode 100644 internal/capability/mcp/status.go create mode 100644 internal/capability/plugin/enabled.go create mode 100644 internal/capability/plugin/enabled_test.go create mode 100644 internal/capability/plugin/mcppanel.go create mode 100644 internal/capability/plugin/mcppanel_actions.go create mode 100644 internal/capability/plugin/mcppanel_test.go create mode 100644 internal/capability/plugin/status.go create mode 100644 internal/capability/plugin/status_test.go diff --git a/internal/capability/mcp/status.go b/internal/capability/mcp/status.go new file mode 100644 index 0000000..c506d78 --- /dev/null +++ b/internal/capability/mcp/status.go @@ -0,0 +1,56 @@ +package mcp + +// 本文件是 MCP 客户端 registry 的**状态读面**(切片 M20:MCP 服务器的管理页)。 +// +// 为什么放在 mcp 包而不是插件管理器:工具名与描述的唯一真源是**已注册进能力注册表 +// 的那些条目**(`Register`/`refresh` 写进去、`Unregister` 摘掉的正是它们)。管理器只 +// 知道「这个 server 活没活」,它没有工具描述;让它自己去猜工具清单就会出现「面板上的 +// 工具列表」与「模型真正能调的工具」两份事实。这里只读,不改任何注册/刷新逻辑。 + +// ToolSummary 是一把工具在状态快照里的形状:名字 + 截断后的描述。 +// +// 描述必然要截断:MCP 服务器的描述可以是一整段 Markdown(实测有几百字的),而状态页是 +// 给人扫一眼的排障面,把原文全塞进表格只会把有用的列挤没。 +type ToolSummary struct { + // Name 是工具名(模型调用时用的标识,不截断:它是排障时要对的那个字符串)。 + Name string `json:"name"` + // Description 是截断后的描述(原始描述见 mcp 客户端的 tools/list)。 + Description string `json:"description,omitempty"` +} + +// ToolSummaries 返回本 registry 当前注册的工具摘要(按注册顺序)。 +// +// 「当前注册」= `ToolNames` 里还活着的名字;被 `refresh` 摘掉的工具不会留下影子。 +// descriptionLimit ≤ 0 表示不截断。描述取自能力注册表的条目:**注册失败的工具不在 +// 其中**,因此清单与模型可见的工具集合同源。 +func (r *Registry) ToolSummaries(descriptionLimit int) []ToolSummary { + if r == nil { + return nil + } + names := r.ToolNames() + out := make([]ToolSummary, 0, len(names)) + for _, name := range names { + desc := "" + if r.reg != nil { + if cap, err := r.reg.Get(name); err == nil { + desc = cap.Description + } + } + out = append(out, ToolSummary{Name: name, Description: TruncateRunes(desc, descriptionLimit)}) + } + return out +} + +// TruncateRunes 把 s 截到最多 limit 个字符(按 rune 计,不切坏多字节字符);被截断时 +// 末尾补一个省略号,让「这里还有」在界面上可见(静默截断会被读成「原文就这么长」)。 +// limit ≤ 0 表示不截断。 +func TruncateRunes(s string, limit int) string { + if limit <= 0 { + return s + } + runes := []rune(s) + if len(runes) <= limit { + return s + } + return string(runes[:limit]) + "…" +} diff --git a/internal/capability/plugin/enabled.go b/internal/capability/plugin/enabled.go new file mode 100644 index 0000000..6a67bb7 --- /dev/null +++ b/internal/capability/plugin/enabled.go @@ -0,0 +1,186 @@ +package plugin + +// 本文件是「按 server 独立启停」的落点(切片 M20):启用态**持久化**在 plugins.json 的 +// `enabled` 字段上,因此停用在重启后仍然有效;热切换的语义是「停用 = 摘掉工具,启用 = +// 连上并重新发现」,两条路都经插件管理器(design/09 §5.2.1「插件管理器 = 唯一中枢」)。 +// +// 为什么这些函数从 manager.go 搬出来:manager.go 已接近「文件 ≤500 行」的上限,而启停 +// 生命周期本身就有四件事——落盘、内存态、面板入口、运行期观察——挤在一起会既超行数又 +// 说不清顺序。顺序在这里写死:**先落盘、再动活 server、最后改面板入口**;落盘失败即 +// 原样返回错误,绝不出现「面板说停用了、重启又连上」。 +// +// 刻意不做的事: +// - 停用**不动** plugins.json 的条目(停用 ≠ 卸载,卸载才删配置); +// - 启用的失败**不改**已落盘的 enabled(失败仍禁用,如实报错,不假装开着)。 + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。 +var ErrPluginNotFound = errors.New("plugin: not found") + +// notFound 构造一条带名字的「找不到」。 +func notFound(name string) error { + if name == "" { + return fmt.Errorf("%w: empty name", ErrPluginNotFound) + } + return fmt.Errorf("%w: %s", ErrPluginNotFound, name) +} + +// SetEnabled 启用或停用一台已安装的 server。 +// +// 语义(design/09 §5.2.1 生命周期一栏): +// - 停用:落盘 `enabled:false` → 关掉活的 server(其工具随之下线,模型看不见)→ 隐藏 +// 它的管理面板入口(`when:{enabled:true}` 的入口不再出现)。配置保留,重启后仍是停用。 +// - 启用:按 plugins.json 的配置重新连接 + 发现 + 注册 → 入口回来 → 落盘 `enabled:true`。 +// **连接失败时返回值即错误,且落盘的 enabled 保持 false**(不假装开着)。 +// +// 两条路都会推一次 `panels_changed`(停用推 disable、启用推 enable)。 +// 找不到该 server 时返回 ErrPluginNotFound。 +func (m *Manager) SetEnabled(ctx context.Context, name string, on bool) error { + name = strings.TrimSpace(name) + if name == "" { + return notFound("") + } + if !on { + return m.disable(ctx, name) + } + return m.enable(ctx, name) +} + +// Reconnect 断开并重新连接一台已启用的 server(重新发现工具,重新注册)。 +// +// 它与「先停用再启用」的差别只在面板帧的原因标签与「已停用时拒绝」:重连不改变启用态, +// 所以它既不写 enabled,也不该替用户启用一个被停用的 server(那会让「为什么它忽然活了」 +// 变成一个查不出的谜)。 +func (m *Manager) Reconnect(ctx context.Context, name string) (Installed, error) { + name = strings.TrimSpace(name) + cfg, ok := m.persistedConfig(name) + if !ok { + if srv := m.liveServer(name); srv != nil { + cfg, ok = srv.cfg, true + } + } + if !ok { + return Installed{}, notFound(name) + } + if !cfg.IsEnabled() { + return Installed{}, fmt.Errorf("%w: %s", ErrPluginDisabled, name) + } + return m.install(ctx, cfg, false, PanelChangeInstalled) +} + +// disable 停用一台 server:落盘 → 下线 → 隐藏入口。 +// +// 落盘在**前面**:先写文件的好处是「写不进去时 server 还在跑」,而不是先摘掉工具再 +// 报一个落盘错误(那样内存与文件分叉,用户看到的与重启后得到的不是一回事)。 +func (m *Manager) disable(ctx context.Context, name string) error { + srv := m.liveServer(name) + cfg, persisted := m.persistedConfig(name) + if srv == nil && !persisted { + return notFound(name) + } + if !persisted { + // 文件里没有这条,但 server 活着(手工塞进来的):用活 server 的配置补一条, + // 否则「停用」重启后会变成「不存在」。 + cfg = srv.cfg + } + if err := m.persistEnabled(cfg, false); err != nil { + return fmt.Errorf("plugin %q: %w", name, err) + } + if srv != nil { + m.take(srv.cfg.Name) + m.stop(ctx, srv) + } + m.noteDisabled(cfg) + m.setPanelsEnabled(cfg.Name, false) + m.log.Info("plugin disabled", "name", cfg.Name) + return nil +} + +// enable 重新启用一台已安装的 server:按 plugins.json 的配置重新连接。 +// 已经活着的 server 是幂等空操作(只把文件里的 enabled 纠正为 true,不推多余帧)。 +func (m *Manager) enable(ctx context.Context, name string) error { + cfg, ok := m.persistedConfig(name) + if !ok { + if srv := m.liveServer(name); srv != nil { + return m.persistEnabled(srv.cfg, true) + } + return notFound(name) + } + if srv := m.liveServer(name); srv != nil { + if cfg.IsEnabled() { + return nil + } + return m.persistEnabled(cfg, true) + } + // 只有连上才落 enabled=true:连接/发现失败时文件保持停用,返回值就是那条失败。 + res, err := m.install(ctx, cfg.WithEnabled(true), false, PanelChangeEnabled) + if err != nil { + return err + } + return m.persistEnabled(res.Config, true) +} + +// persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。 +// +// 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。 +func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error { + if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil { + return fmt.Errorf("插件 %q 的启用态未落盘: %w", cfg.Name, 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 +} diff --git a/internal/capability/plugin/enabled_test.go b/internal/capability/plugin/enabled_test.go new file mode 100644 index 0000000..c4f8acd --- /dev/null +++ b/internal/capability/plugin/enabled_test.go @@ -0,0 +1,223 @@ +package plugin + +// 本文件锁定「按 server 独立启停」的三条硬要求(切片 M20): +// ① 热切换:停用后工具**真的**从注册表消失(模型看不见它们),启用后回来; +// ② 持久化:`enabled:false` 落进 plugins.json,重启(Restore)后仍是停用 —— 而且 +// **一次都不去连**(不是「连了一下再判定停用」); +// ③ 失败如实:启用失败时不假装开着,落盘的 enabled 保持 false,快照给 lastError。 + +import ( + "context" + "errors" + "path/filepath" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// newEnabledManager 构造一台连到假 MCP server 的已安装插件,返回管理器与它的注册表。 +func newEnabledManager(t *testing.T, path string, endpoint string, tools ...string) (*Manager, *registry.Registry) { + t.Helper() + reg := registry.New() + mgr := NewManager(reg, path, nil) + t.Cleanup(func() { mgr.Close(context.Background()) }) + if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ + Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: endpoint, + }); err != nil { + t.Fatalf("Install: %v", err) + } + return mgr, reg +} + +// TestDisableUnregistersToolsAndPersists 锁定 ①+② 的一半:停用即摘工具、写 enabled:false。 +func TestDisableUnregistersToolsAndPersists(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add", "mul") + mgr, reg := newEnabledManager(t, path, srv.URL) + + if names := toolNames(reg); !names["add"] || !names["mul"] { + t.Fatalf("安装后工具 = %v,期望 add+mul", names) + } + if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + // 工具必须真的不在注册表里:模型的面就是这样来的。 + if names := toolNames(reg); len(names) != 0 { + t.Fatalf("停用后工具仍可被模型看到 = %v", names) + } + if _, err := reg.Get("mcp:calc"); err == nil { + t.Fatal("停用后 mcp:calc 能力仍在") + } + cfgs, err := runtimeconfig.LoadPlugins(path) + if err != nil { + t.Fatalf("LoadPlugins: %v", err) + } + if len(cfgs) != 1 || cfgs[0].IsEnabled() { + t.Fatalf("落盘配置 = %+v,期望仅一条且已停用", cfgs) + } + if cfgs[0].Enabled == nil { + t.Fatal("停用必须写显式的 enabled=false(缺席=启用,无法表达停用)") + } + st := statusOfOne(t, mgr, "calc") + if st.State != ServerStateDisabled || st.Connected == nil || *st.Connected { + t.Fatalf("停用后状态 = %+v,期望 disabled/connected=false", st) + } + if st.ToolCount != nil { + t.Fatalf("停用的 server 不该报工具数(不知道它有什么),得到 %d", *st.ToolCount) + } +} + +// TestEnableBringsToolsBack 锁定 ①的另一半:启用重新连接并重新注册。 +func TestEnableBringsToolsBack(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add", "mul") + mgr, reg := newEnabledManager(t, path, srv.URL) + ctx := context.Background() + + if err := mgr.SetEnabled(ctx, "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + if err := mgr.SetEnabled(ctx, "calc", true); err != nil { + t.Fatalf("SetEnabled(true): %v", err) + } + if names := toolNames(reg); !names["add"] || !names["mul"] { + t.Fatalf("启用后工具 = %v,期望 add+mul 回来", names) + } + cfgs, err := runtimeconfig.LoadPlugins(path) + if err != nil { + t.Fatalf("LoadPlugins: %v", err) + } + if len(cfgs) != 1 || !cfgs[0].IsEnabled() { + t.Fatalf("启用后落盘 = %+v,期望 enabled=true", cfgs) + } + st := statusOfOne(t, mgr, "calc") + if st.State != ServerStateConnected || st.ToolCount == nil || *st.ToolCount != 2 { + t.Fatalf("启用后状态 = %+v,期望 connected 且 2 把工具", st) + } + if len(st.Tools) != 2 { + t.Fatalf("工具明细 = %+v,期望 2 条", st.Tools) + } +} + +// TestRestoreHonoursDisabledFlag 锁定 ②:重启后停用仍然是停用,而且**一次都不连**。 +func TestRestoreHonoursDisabledFlag(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add") + mgr, _ := newEnabledManager(t, path, srv.URL) + if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + mgr.Close(context.Background()) + before := srv.Config.Handler.(*fakeMCP).hitCount() + + reg2 := registry.New() + mgr2 := NewManager(reg2, path, nil) + defer mgr2.Close(context.Background()) + mgr2.Restore(context.Background()) + + if names := toolNames(reg2); len(names) != 0 { + t.Fatalf("重启后停用的 server 仍注册了工具 = %v", names) + } + if after := srv.Config.Handler.(*fakeMCP).hitCount(); after != before { + t.Fatalf("重启期对停用的 server 发起了连接:hits %d -> %d", before, after) + } + st := statusOfOne(t, mgr2, "calc") + if st.State != ServerStateDisabled || st.Enabled { + t.Fatalf("重启后状态 = %+v,期望仍是 disabled", st) + } +} + +// TestInstallDisabledDoesNotConnect 锁定 Install 也尊重 enabled:装上但停用 = 落盘且不连。 +func TestInstallDisabledDoesNotConnect(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add") + reg := registry.New() + mgr := NewManager(reg, path, nil) + defer mgr.Close(context.Background()) + + got, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ + Name: "calc", Endpoint: srv.URL, Enabled: enabledFlag(false), + }) + if err != nil { + t.Fatalf("Install(disabled): %v", err) + } + if got.Live { + t.Fatalf("停用安装不该有活 server:%+v", got) + } + if hits := srv.Config.Handler.(*fakeMCP).hitCount(); hits != 0 { + t.Fatalf("停用安装发起了 %d 次连接", hits) + } + if names := toolNames(reg); len(names) != 0 { + t.Fatalf("停用安装注册了工具 = %v", names) + } + cfgs, err := runtimeconfig.LoadPlugins(path) + if err != nil || len(cfgs) != 1 || cfgs[0].IsEnabled() { + t.Fatalf("落盘 = %+v(err=%v),期望一条停用配置", cfgs, err) + } +} + +// TestEnableFailureKeepsDisabled 锁定 ③:启用失败 ⇒ 返回错误 + 落盘仍是停用 + 快照给 lastError。 +func TestEnableFailureKeepsDisabled(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add") + mgr, _ := newEnabledManager(t, path, srv.URL) + ctx := context.Background() + if err := mgr.SetEnabled(ctx, "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + // 让 server 连不上:关掉进程内的假 server。 + srv.Close() + + if err := mgr.SetEnabled(ctx, "calc", true); err == nil { + t.Fatal("启用一台连不上的 server 应当报错") + } + cfgs, err := runtimeconfig.LoadPlugins(path) + if err != nil || len(cfgs) != 1 { + t.Fatalf("落盘 = %+v(err=%v)", cfgs, err) + } + if cfgs[0].IsEnabled() { + t.Fatalf("启用失败却把 enabled 写成了 true:%+v", cfgs[0]) + } + // 状态仍是 disabled(启用没成功,它就还是停用态),而 lastError 说清了这次失败 —— + // 「有意停用」与「想启用但失败」由这两列分别表达,不互相冒充。 + st := statusOfOne(t, mgr, "calc") + if st.State != ServerStateDisabled || st.Enabled { + t.Fatalf("启用失败后状态 = %q/enabled=%v,期望 disabled/false", st.State, st.Enabled) + } + if st.Connected == nil || *st.Connected { + t.Fatalf("启用失败后 connected = %v,期望 false(确认没连)", st.Connected) + } + if st.LastError == "" { + t.Fatal("启用失败后 lastError 为空:面板说不出为什么挂了") + } + if st.LastErrorAt == "" { + t.Fatal("启用失败后 lastErrorAt 为空") + } +} + +// TestReconnectRejectsDisabled 锁定:重连不替用户启用(那会让「它为什么忽然活了」变成谜)。 +func TestReconnectRejectsDisabled(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add") + mgr, _ := newEnabledManager(t, path, srv.URL) + ctx := context.Background() + if err := mgr.SetEnabled(ctx, "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + if _, err := mgr.Reconnect(ctx, "calc"); !errors.Is(err, ErrPluginDisabled) { + t.Fatalf("对停用的 server 重连 = %v,期望 ErrPluginDisabled", err) + } + if _, err := mgr.Reconnect(ctx, "ghost"); !errors.Is(err, ErrPluginNotFound) { + t.Fatalf("重连不存在的 server = %v,期望 ErrPluginNotFound", err) + } +} + +// enabledFlag 是 `&false` 的简写(PluginConfig.Enabled 是三态指针)。 +func enabledFlag(v bool) *bool { return &v } diff --git a/internal/capability/plugin/manager.go b/internal/capability/plugin/manager.go index 1beef3d..72130a7 100644 --- a/internal/capability/plugin/manager.go +++ b/internal/capability/plugin/manager.go @@ -61,6 +61,10 @@ type Manager struct { mu sync.Mutex servers map[string]*server + // observed 是每台 server 最近一次被观察到的运行期事实(成功连上的时刻、最近一次失败 + // 的文本与时刻)。它与 servers(活没活)是两个不同的事实:一台连不上的 server 不在 + // servers 里,但它的失败记录必须留着 —— 否则「为什么挂了」在快照里就是一个空。 + observed map[string]observed // panelKeys 记住每个已安装插件**当时用过的**面板目录键(插件名 → 目录键)。 // 它是 memo 而不是第二份真源:目录键由 PanelPluginID 从 cfg 推出,这里只把 // 结果留下,好让 Uninstall 在活 server 已不在时仍能精确清掉该插件的面板。 @@ -80,6 +84,7 @@ func NewManager(reg *registry.Registry, path string, log *slog.Logger) *Manager path: path, log: log, servers: make(map[string]*server), + observed: make(map[string]observed), panelKeys: make(map[string]string), } } @@ -116,6 +121,11 @@ func (m *Manager) Install(ctx context.Context, cfg runtimeconfig.PluginConfig) ( // install is Install with the persist step optional (Restore reuses it without // rewriting the file it just read). changeReason labels the panel change pushed // when the plugin becomes live (install vs enable). +// +// It respects cfg.Enabled: a config that says enabled=false is **persisted but not +// connected** (no live server, no tools), which is how "installed but switched off" +// survives a restart. Restore goes through this same path, so startup honours the +// flag too. 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 { @@ -129,8 +139,13 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p if prev != nil { m.stop(ctx, prev) } + if !cfg.IsEnabled() { + return m.installDisabled(cfg, persist, prev) + } srv, err := m.start(ctx, cfg) if err != nil { + // 失败也要留痕:状态快照要能说出「为什么挂了」,而不是把失败悄悄丢进日志。 + m.noteFailure(cfg, err) m.restore(prev) return Installed{}, err } @@ -142,11 +157,30 @@ func (m *Manager) install(ctx context.Context, cfg runtimeconfig.PluginConfig, p } } m.put(srv) + m.noteSeen(cfg) 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 } +// installDisabled 走「已安装但停用」这条路:只落配置,不连接、不发现、不注册任何工具, +// 并把面板入口隐藏(`when:{enabled:true}` 的入口不出现)。 +// +// 调用前 prev(同名活 server)已经被停掉:配置现在说的是「停用」,让旧 server 继续挂着 +// 就成了两份互相矛盾的事实。落盘失败时把 prev 恢复回来(回滚到「启用且活着」那个状态)。 +func (m *Manager) installDisabled(cfg runtimeconfig.PluginConfig, persist bool, prev *server) (Installed, error) { + if persist { + if err := m.persistUpsert(cfg); err != nil { + m.restore(prev) + return Installed{}, err + } + } + m.noteDisabled(cfg) + m.markDisabled(cfg) + m.log.Info("plugin installed disabled", "name", cfg.Name, "kind", cfg.Kind, "transport", cfg.Transport) + return Installed{Config: cfg}, nil +} + // Uninstall removes the plugin matching name: it unregisters the live server's // 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. @@ -166,6 +200,7 @@ func (m *Manager) Uninstall(ctx context.Context, name string) (bool, error) { panelName = srv.cfg.Name } m.forgetPanels(panelName) + m.forgetObserved(panelName) m.log.Info("plugin uninstalled", "name", name) } return removed, nil @@ -231,114 +266,6 @@ 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) { @@ -389,6 +316,8 @@ func (m *Manager) restore(prev *server) { return } m.put(srv) + // 回滚成功也是一次成功连接:快照的 lastSeenAt 不该停留在这之前的时刻。 + m.noteSeen(prev.cfg) } // take removes and returns the live server named name, or nil. diff --git a/internal/capability/plugin/manager_test.go b/internal/capability/plugin/manager_test.go index 5e0059a..0bdf548 100644 --- a/internal/capability/plugin/manager_test.go +++ b/internal/capability/plugin/manager_test.go @@ -62,6 +62,15 @@ type fakeMCP struct { mu sync.Mutex auth string + hits int +} + +// hitCount 返回该 server 收到的 POST 次数(「有没有真的连过」的证据:停用的 server +// 在重启后不该被连一次)。 +func (f *fakeMCP) hitCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.hits } func (f *fakeMCP) setTools(tools ...string) { @@ -88,6 +97,7 @@ func (f *fakeMCP) ServeHTTP(w http.ResponseWriter, r *http.Request) { } f.mu.Lock() f.auth = r.Header.Get("Authorization") + f.hits++ tools := append([]string(nil), f.tools...) f.mu.Unlock() if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth { diff --git a/internal/capability/plugin/mcppanel.go b/internal/capability/plugin/mcppanel.go new file mode 100644 index 0000000..b5a3c4e --- /dev/null +++ b/internal/capability/plugin/mcppanel.go @@ -0,0 +1,191 @@ +package plugin + +// 本文件是「MCP 服务器」管理页的**声明面**(design/09 §5.2 的 adminPanels,形状即 +// `pkg/plugins/sdk` 的 Panel):清单表(状态列 + 行内动作)+ 安装表单。 +// +// 为什么这页由插件管理器自己声明(而不是某个外部插件):它是**管理器自己的页面** —— +// 数据面就是 `*Manager` 的 `Statuses()`,动作面就是 `Install/Reconnect/SetEnabled/ +// Uninstall`。把声明放到别处就要再导出一套「代管理器说话」的中转,两处必然分叉。 +// +// 声明只说三件事:要什么字段、怎么渲染、动作叫什么。它不注入 JS/CSS/HTML、不给 URL +// (只有动作名)、不开任何路由 —— 分发走既有的通用动作端点 +// `POST /api/v1/admin/plugins/{id}/actions/{action}`(写动作要求 owner/admin + 审计)。 +// +// 与前端通用渲染器的核对结论(本片任务书要求先验,`frontend/web/src/admin/plugin/`): +// - 表格列只有 `key`/`label`(没有列类型/徽标)⇒ 状态列显示**闭集码**(与 web-search +// 页的 `state` 列同一口径),工具栏退化成一列截断文本; +// - 行内动作没有二次确认,且一律按**写**动作鉴权 ⇒ 卸载只能一键即发; +// - `args`(字符串列表)用 `multiSelect`、`env`(键值对)用 `kv`:这两项**能直接渲染** +// (无候选的 multiSelect 降级成逗号/换行分隔文本框,提交值仍是 `string[]`; +// kv 是 JSON 对象输入),因此本页不需要新增字段类型、也不需要改前端。 + +import ( + "fmt" + + "git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk" +) + +// MCPPanelPluginID 是「MCP 服务器」页在面板目录里的命名空间。 +// +// 它是**宿主内置提供者**的 id,不是某个热装 MCP server 的名字。两者可能撞名(主人可以 +// 把 server 也叫 mcp),因此热装插件的生命周期一律不许改动这个名字的目录条目 +// (见 isReservedPanelKey)。 +const MCPPanelPluginID = "mcp" + +// MCPPanelID 是面板 id。前端路由为 `/admin/plugin/mcp/servers`。 +const MCPPanelID = "servers" + +// 本页声明的动作名。它们只是名字(不是 URL),分发端点是既有的 +// `POST /api/v1/admin/plugins/mcp/actions/`。 +const ( + // ActionListMCPServers 列出全部 server 的状态快照(表格数据源,读面)。 + ActionListMCPServers = "plugin.mcpServers" + // ActionInstallMCPServer 安装(或刷新)一台 server(表单提交,写面)。 + ActionInstallMCPServer = "plugin.installMcpServer" + // ActionReconnectMCPServer 断开重连并重新发现工具(行内动作,写面)。 + ActionReconnectMCPServer = "plugin.reconnectMcpServer" + // ActionEnableMCPServer 启用一台被停用的 server(行内动作,写面)。 + ActionEnableMCPServer = "plugin.enableMcpServer" + // ActionDisableMCPServer 停用一台 server(行内动作,写面)。 + ActionDisableMCPServer = "plugin.disableMcpServer" + // ActionUninstallMCPServer 卸载一台 server(行内动作,写面)。 + ActionUninstallMCPServer = "plugin.uninstallMcpServer" + // ActionMCPTransports 是传输形态的候选来源(读面):`optionsFrom` 为 + // OptionsSourceTransports 时前端按此动作名取候选。 + ActionMCPTransports = "plugin.mcpTransports" +) + +// OptionsSourceTransports 是表单 `transport` 字段的候选来源(声明面写法 `plugin:` 前缀)。 +const OptionsSourceTransports = "plugin:mcpTransports" + +// 「MCP 服务器」表单与表格的字段 key。它们是**面板契约**的一部分(前端提交的键名), +// 因此集中在这里,处理器引用常量而不另写字面量。 +const ( + // FieldServerName 是 server 名(唯一标签,也是能力命名空间 `mcp:`)。 + FieldServerName = "name" + // FieldTransport 是传输形态(stdio / http / sse)。 + FieldTransport = "transport" + // FieldCommand 是 stdio 的启动命令。 + FieldCommand = "command" + // FieldArgs 是 stdio 的命令参数(字符串列表)。 + FieldArgs = "args" + // FieldURL 是 http/sse 的服务地址(与运行时字段 endpoint 同义,面板用 url 更直白)。 + FieldURL = "url" + // FieldAPIKey 是下发给 server 的 Bearer 凭据(secret:读侧永不回显)。 + FieldAPIKey = "apiKey" + // FieldEnv 是 stdio 子进程的额外环境变量(键值对)。 + FieldEnv = "env" +) + +// MCPAdminPanels 返回「MCP 服务器」页的声明。 +func MCPAdminPanels() []sdk.Panel { + return []sdk.Panel{{ + ID: MCPPanelID, + Title: "MCP 服务器", + Description: "接入外部 MCP server 并管理它们的运行状态:每台可独立启停、重连、" + + "卸载,清单里直接看到连没连上、暴露了哪些工具、最近一次错在哪。" + + "配置落在 $DATA_DIR/plugins.json,装/卸/启停**立即生效**(下一轮对话即可用," + + "无需重启)。", + Group: sdk.GroupPlugins, + Order: 10, + Icon: "server", + // 内置提供者恒为启用;写条件是为了让「启用事实」由面板中枢给出(单一事实来源), + // 而不是由声明自己假设。 + When: &sdk.When{Enabled: flagBool(true)}, + Sections: []sdk.Section{ + { + Type: sdk.SectionTable, + ID: "list", + Title: "已接入的服务器", + Source: &sdk.Source{Action: ActionListMCPServers}, + // 列 key 与 ServerRow 的字段同源;`target` / `toolsText` 是给表格用的 + // 显示派生量(地址或命令二选一、工具名拼一行),机器字段仍是 + // `endpoint`/`command`/`tools[]`。 + Columns: []sdk.Column{ + {Key: "name", Label: "名称"}, + {Key: "transport", Label: "传输"}, + {Key: "target", Label: "地址 / 命令"}, + {Key: "state", Label: "状态"}, + {Key: "toolsText", Label: "工具"}, + {Key: "lastError", Label: "最近一次错误"}, + {Key: "lastSeenAt", Label: "最近连接"}, + }, + RowActions: []sdk.RowAction{ + {ID: "reconnect", Label: "重连", Action: ActionReconnectMCPServer}, + {ID: "enable", Label: "启用", Action: ActionEnableMCPServer}, + {ID: "disable", Label: "停用", Action: ActionDisableMCPServer}, + {ID: "uninstall", Label: "卸载", Action: ActionUninstallMCPServer}, + }, + }, + { + Type: sdk.SectionForm, + ID: "install", + Title: "安装 / 刷新服务器", + Fields: []sdk.Field{ + {Key: FieldServerName, Label: "名称", Type: sdk.FieldText, Required: true, + Placeholder: "如 fs / github;同名即刷新,能力命名空间为 mcp:<名称>"}, + {Key: FieldTransport, Label: "传输", Type: sdk.FieldSelect, + OptionsFrom: OptionsSourceTransports, + Placeholder: "留空 = 按 command / url 推断(有命令走 stdio,有地址走 http)"}, + {Key: FieldCommand, Label: "启动命令(stdio)", Type: sdk.FieldText, + Placeholder: "npx / uvx / 可执行文件路径"}, + {Key: FieldArgs, Label: "命令参数(stdio)", Type: sdk.FieldMultiSelect, + Placeholder: "逗号或换行分隔,如 -y,@modelcontextprotocol/server-filesystem,/tmp"}, + {Key: FieldURL, Label: "服务地址(http / sse)", Type: sdk.FieldText, + Placeholder: "https://..."}, + {Key: FieldAPIKey, Label: "API Key", Type: sdk.FieldSecret, + Placeholder: "留空 = 不带鉴权(非空则作为 Bearer 下发)"}, + {Key: FieldEnv, Label: "环境变量(stdio)", Type: sdk.FieldKV, + Placeholder: `{"TOKEN":"..."}`}, + }, + Submit: &sdk.Submit{Action: ActionInstallMCPServer}, + }, + }, + }} +} + +// DeclareMCPPanel 把「MCP 服务器」页登记到面板中枢(幂等:重复调用等于重新声明)。 +// +// 它是组合根在**声明阶段**要调的那一步;动作处理器另由 `(*Manager).RegisterMCPPanelActions` +// 登记(声明与登记是两个事实,见 actions.go 的口径)。 +func DeclareMCPPanel(hub *Panels) ([]string, error) { + if hub == nil { + return nil, fmt.Errorf("plugin: nil panel hub") + } + return hub.Declare(MCPPanelPluginID, MCPAdminPanels()) +} + +// AttachMCPPanel 把「MCP 服务器」页完整接到本管理器的已接线中枢上:先声明、再登记动作 +// 处理器。组合根在 `wirePanelHub`(AttachPanels 之后)调它一次即可。 +// +// 两步都在这里做完,是因为它们必须成对出现:只声明不登记 ⇒ 每个动作都诚实回 501; +// 只登记不声明 ⇒ 动作不可达(Dispatch 先查声明)。中枢未接线时报错而不是静默成功。 +func (m *Manager) AttachMCPPanel() error { + hub := m.Panels() + if hub == nil { + err := fmt.Errorf("%w: %s(面板中枢未接线,MCP 管理页无处挂)", + ErrInvalidActionRegistration, MCPPanelPluginID) + m.log.Error("mcp panel not declared; its actions will answer 501", + "pluginId", MCPPanelPluginID, "error", err) + return err + } + if _, err := DeclareMCPPanel(hub); err != nil { + m.log.Error("mcp panel declaration rejected; its actions will answer 501", + "pluginId", MCPPanelPluginID, "error", err) + return fmt.Errorf("plugin: declare mcp panel: %w", err) + } + if err := m.RegisterMCPPanelActions(); err != nil { + m.log.Error("mcp panel actions not registered; they will answer 501", + "pluginId", MCPPanelPluginID, "error", err) + return err + } + m.log.Info("mcp panel attached", "pluginId", MCPPanelPluginID, "panelId", MCPPanelID) + return nil +} + +// isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。 +// +// 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很 +// 实在 —— 内置提供者的 id 是给人看的短名字(mcp),而 server 的名字由主人随手起;一台 +// 恰好叫 mcp 的 server 在卸载时若照常 MarkUninstalled("mcp"),整页「MCP 服务器」就消失了。 +func isReservedPanelKey(key string) bool { return key == MCPPanelPluginID } diff --git a/internal/capability/plugin/mcppanel_actions.go b/internal/capability/plugin/mcppanel_actions.go new file mode 100644 index 0000000..599e92f --- /dev/null +++ b/internal/capability/plugin/mcppanel_actions.go @@ -0,0 +1,331 @@ +package plugin + +// 本文件是「MCP 服务器」页的**执行面**(切片 M20):动作处理器 + 载荷解析 + 失败分类。 +// +// 三条分工(与 design/09 §5.2.1 的动作分发一栏同向): +// - 鉴权与审计在宿主端点(网关 `adminPluginAction`:写动作要求 owner/admin,每次落审计); +// - **能不能调**由声明决定(Dispatch 先查声明),处理器只负责「这个名字要做什么」; +// - 失败要说清病因:ActionError 带稳定错误码 + 一句人话 + 目标状态码,绝不把上游报文 +// 原样吐给前端(§5.4.6),也绝不把凭据写进返回值或日志。 +// +// 载荷形状由前端通用渲染器决定(`frontend/web/src/admin/plugin/tableState.ts` / +// `formState.ts`):表格数据源发空体;行内动作发 `{"row":{…行数据…}}`;表单提交发 +// `{"values":{…}}`(multiSelect → `[]string`、kv → JSON 对象、switch → bool、其余 → string)。 + +import ( + "context" + "errors" + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// 本页动作的失败码(机器可读、稳定;前端按码本地化,不解析文案)。 +const ( + // mcpCodeInvalidConfig 报告提交的配置本身不合法(缺 name / 缺 command / 缺 endpoint)。 + mcpCodeInvalidConfig = "invalid_config" + // mcpCodeInstallFailed 报告连接或发现失败(配置**不会**落盘)。 + mcpCodeInstallFailed = "install_failed" + // mcpCodeReconnectFailed 报告重连失败(原连接已被摘下)。 + mcpCodeReconnectFailed = "reconnect_failed" + // mcpCodeEnableFailed 报告启用失败(server 保持停用,不假装开着)。 + mcpCodeEnableFailed = "enable_failed" + // mcpCodeDisableFailed 报告停用失败(落盘失败;连接可能仍在)。 + mcpCodeDisableFailed = "disable_failed" + // mcpCodeUninstallFailed 报告卸载失败(落盘失败;工具是否已下线取决于错误发生的时机)。 + mcpCodeUninstallFailed = "uninstall_failed" + // mcpCodeServerNotFound 报告点名的 server 既不在 plugins.json 里也不活着。 + mcpCodeServerNotFound = "server_not_found" + // mcpCodeServerDisabled 报告对一台已停用的 server 发起了「重连」。 + mcpCodeServerDisabled = "server_disabled" +) + +// RegisterMCPPanelActions 给「MCP 服务器」页登记动作处理器。 +// +// 它必须在**声明之后**调用(`AttachMCPPanel` 就按这个顺序做):登记期会核对「登记的 +// 处理器有没有对应声明」,顺序反过来只会得到一串「声明还没到」的 WARN。 +func (m *Manager) RegisterMCPPanelActions() error { + return m.RegisterActions(MCPPanelPluginID, map[string]ActionHandler{ + ActionListMCPServers: m.actListServers, + ActionInstallMCPServer: m.actInstallServer, + ActionReconnectMCPServer: m.actReconnectServer, + ActionEnableMCPServer: m.actEnableServer, + ActionDisableMCPServer: m.actDisableServer, + ActionUninstallMCPServer: m.actUninstallServer, + ActionMCPTransports: m.actTransports, + }) +} + +// actListServers 返回全部 server 的状态快照(表格数据源)。 +// +// 它是**读面**(viewer 也能调):快照里没有任何凭据(见 status.go 的口径②)。 +func (m *Manager) actListServers(_ context.Context, _ ActionRequest) (any, error) { + statuses := m.Statuses() + rows := make([]ServerRow, 0, len(statuses)) + for _, st := range statuses { + rows = append(rows, serverRowOf(st)) + } + return map[string]any{"rows": rows}, nil +} + +// actTransports 返回传输形态候选(`transport` 字段的 `optionsFrom`)。 +// +// 候选的 value 与运行时接受的取值逐字一致(`runtimeconfig.Transport*`),label 是给人看 +// 的一句说明:写 `http` 而以为是「老式 SSE」是最常见的一次配错。 +func (m *Manager) actTransports(_ context.Context, _ ActionRequest) (any, error) { + return map[string]any{"options": []map[string]string{ + {"value": runtimeconfig.TransportStdio, "label": "stdio(启动本地子进程)"}, + {"value": runtimeconfig.TransportHTTP, "label": "http(Streamable HTTP)"}, + {"value": runtimeconfig.TransportSSE, "label": "sse(老式 SSE 长连接)"}, + }}, nil +} + +// actInstallServer 安装(或刷新)一台 server:连接 + 发现 + 注册后才落盘。 +func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, error) { + values, err := actionValues(req.Payload) + if err != nil { + return nil, err + } + cfg := runtimeconfig.PluginConfig{ + Name: actionText(values, FieldServerName), + Kind: runtimeconfig.PluginKindMCP, + Transport: actionText(values, FieldTransport), + Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")), + Command: actionText(values, FieldCommand), + Args: actionStringList(values[FieldArgs]), + APIKey: actionText(values, FieldAPIKey), + Env: actionStringMap(values[FieldEnv]), + }.Normalize() + if err := cfg.Validate(); err != nil { + return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err) + } + res, err := m.Install(ctx, cfg) + if err != nil { + m.log.Warn("mcp install failed", "name", cfg.Name, "transport", cfg.Transport, "error", err) + return nil, NewActionError(mcpCodeInstallFailed, + fmt.Sprintf("连接/发现 MCP server %q 失败,配置未落盘", cfg.Name), 502, err) + } + return map[string]any{ + "installed": true, + "name": res.Config.Name, + "transport": res.Config.Transport, + "toolCount": res.ToolCount, + }, nil +} + +// actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。 +func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) { + name, err := actionRowName(req.Payload) + if err != nil { + return nil, err + } + res, err := m.Reconnect(ctx, name) + switch { + case errors.Is(err, ErrPluginNotFound): + return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err) + case errors.Is(err, ErrPluginDisabled): + return nil, NewActionError(mcpCodeServerDisabled, + fmt.Sprintf("server %q 已停用:先「启用」再重连", name), 409, err) + case err != nil: + m.log.Warn("mcp reconnect failed", "name", name, "error", err) + return nil, NewActionError(mcpCodeReconnectFailed, + fmt.Sprintf("重连 %q 失败,详细原因见后端日志", name), 502, err) + } + return map[string]any{"name": res.Config.Name, "reconnected": true, "toolCount": res.ToolCount}, nil +} + +// actEnableServer 启用一台 server(连接成功后才把 enabled=true 落盘)。 +func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, error) { + name, err := actionRowName(req.Payload) + if err != nil { + return nil, err + } + if err := m.SetEnabled(ctx, name, true); err != nil { + if errors.Is(err, ErrPluginNotFound) { + return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err) + } + m.log.Warn("mcp enable failed", "name", name, "error", err) + return nil, NewActionError(mcpCodeEnableFailed, + fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err) + } + return map[string]any{"name": name, "enabled": true, "connected": true}, nil +} + +// actDisableServer 停用一台 server(工具随之下线,配置保留)。 +func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, error) { + name, err := actionRowName(req.Payload) + if err != nil { + return nil, err + } + if err := m.SetEnabled(ctx, name, false); err != nil { + if errors.Is(err, ErrPluginNotFound) { + return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err) + } + m.log.Warn("mcp disable failed", "name", name, "error", err) + return nil, NewActionError(mcpCodeDisableFailed, + fmt.Sprintf("停用 %q 失败:启用态未能落盘,详细原因见后端日志", name), 500, err) + } + return map[string]any{"name": name, "enabled": false, "connected": false}, nil +} + +// actUninstallServer 卸载一台 server(摘工具 + 删配置)。 +func (m *Manager) actUninstallServer(ctx context.Context, req ActionRequest) (any, error) { + name, err := actionRowName(req.Payload) + if err != nil { + return nil, err + } + removed, err := m.Uninstall(ctx, name) + if err != nil { + m.log.Warn("mcp uninstall failed", "name", name, "error", err) + return nil, NewActionError(mcpCodeUninstallFailed, + fmt.Sprintf("卸载 %q 失败,详细原因见后端日志", name), 500, err) + } + if !removed { + return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, nil) + } + return map[string]any{"name": name, "uninstalled": true}, nil +} + +// ServerRow 是「MCP 服务器」表格的一行:状态快照 + 两列显示派生量。 +// +// 快照字段(Status)逐字嵌入,因此一行既是表格数据、也是机器可读的状态契约;两个派生列 +// 只为表格排版而存在(一列只能显示一个标量)。 +type ServerRow struct { + Status + // Target 是「地址 / 命令」列的显示值(http/sse ⇒ 地址;stdio ⇒ 命令)。 + Target string `json:"target,omitempty"` + // ToolsText 是「工具」列的显示值(工具名以「、」拼接并截断)。 + ToolsText string `json:"toolsText,omitempty"` +} + +// serverRowOf 把一条快照翻成表格行。 +func serverRowOf(st Status) ServerRow { + target := st.Endpoint + if target == "" { + target = st.Command + } + return ServerRow{Status: st, Target: target, ToolsText: toolsText(st.Tools)} +} + +// --- 载荷解析 ----------------------------------------------------------------- + +// actionValues 取出表单提交的 `values` 对象(缺省 / 形状不对都如实报错)。 +func actionValues(payload any) (map[string]any, error) { + obj, ok := payload.(map[string]any) + if !ok { + return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil) + } + raw, ok := obj["values"] + if !ok { + return nil, NewActionError(ActionCodeInvalidPayload, "请求体缺少 values 对象", 400, nil) + } + values, ok := raw.(map[string]any) + if !ok { + return nil, NewActionError(ActionCodeInvalidPayload, "values 必须是对象", 400, nil) + } + return values, nil +} + +// actionRowName 取出行内动作点名的 server 名(`{"row":{"name":…}}`,也接受 `{"name":…}`)。 +func actionRowName(payload any) (string, error) { + obj, ok := payload.(map[string]any) + if !ok { + return "", NewActionError(ActionCodeInvalidPayload, "行内动作缺少 row 对象", 400, nil) + } + row, ok := obj["row"].(map[string]any) + if !ok { + // 容错:某些客户端直接发 `{name, …}`。名字仍然只有这一个真源,不猜别的键。 + row = obj + } + name := actionText(row, FieldServerName) + if name == "" { + return "", NewActionError(ActionCodeInvalidPayload, "row 里缺少 name", 400, nil) + } + return name, nil +} + +// actionText 取一个字符串值(非字符串 / 缺省都给空串 —— 空串在配置里就是「没填」)。 +func actionText(values map[string]any, key string) string { + raw, ok := values[key] + if !ok { + return "" + } + s, ok := raw.(string) + if !ok { + return "" + } + return strings.TrimSpace(s) +} + +// actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是 +// `[]any`;元素非字符串即忽略,空串不保留)。空列表返回 nil(配置里就不写这个键)。 +func actionStringList(raw any) []string { + items, ok := raw.([]any) + if !ok { + if strs, okStr := raw.([]string); okStr { + return compactStrings(strs) + } + return nil + } + out := make([]string, 0, len(items)) + for _, item := range items { + s, ok := item.(string) + if !ok { + continue + } + out = append(out, s) + } + return compactStrings(out) +} + +// actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。 +// +// 值只接受字符串:`{"PORT":8080}`(数字)这类会被**拒之门外而不是静默转成 "8080"** —— +// 静默转换正是「看着配好了、其实没生效」的来源。 +func actionStringMap(raw any) map[string]string { + obj, ok := raw.(map[string]any) + if !ok { + return nil + } + out := make(map[string]string, len(obj)) + for k, v := range obj { + s, ok := v.(string) + if !ok { + continue + } + key := strings.TrimSpace(k) + if key == "" { + continue + } + out[key] = s + } + if len(out) == 0 { + return nil + } + return out +} + +// compactStrings 去掉两端空白与空项;结果为空时返回 nil。 +func compactStrings(items []string) []string { + out := make([]string, 0, len(items)) + for _, item := range items { + if s := strings.TrimSpace(item); s != "" { + out = append(out, s) + } + } + if len(out) == 0 { + return nil + } + return out +} + +// firstNonEmpty 返回首个非空值(安装表单里 url 与 endpoint 是同义键)。 +func firstNonEmpty(values ...string) string { + for _, v := range values { + if strings.TrimSpace(v) != "" { + return strings.TrimSpace(v) + } + } + return "" +} diff --git a/internal/capability/plugin/mcppanel_test.go b/internal/capability/plugin/mcppanel_test.go new file mode 100644 index 0000000..7393dfd --- /dev/null +++ b/internal/capability/plugin/mcppanel_test.go @@ -0,0 +1,274 @@ +package plugin + +// 本文件锁定「MCP 服务器」页(切片 M20)的三件事: +// ① 声明本身合法(形状按 paneldecl 校验通过 —— 前端按声明泛化渲染,形状错就是半渲染); +// ② 声明与登记**成对**(每个声明的动作都有处理器:只有声明 ⇒ 每个动作都 501); +// ③ 动作面端到端可用:装 → 看 → 重连 → 停 → 启用 → 卸,每一步都推 panels_changed, +// 失败按结构化错误码如实分类(install_failed / server_not_found / server_disabled)。 + +import ( + "context" + "net/http" + "net/http/httptest" + "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" +) + +// TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。 +func TestMCPPanelDeclarationIsValid(t *testing.T) { + t.Parallel() + if err := paneldecl.Validate(MCPPanelPluginID, MCPAdminPanels()); err != nil { + t.Fatalf("「MCP 服务器」页声明非法:%v", err) + } +} + +// TestMCPPanelActionsWired 锁定 ②:声明的动作与登记的处理器必须一一对应。 +// +// 这条断言的价值是把「声明里写了、没人实现」从「用户点出一个 501」提前到测试里:少一个 +// 处理器,本测试直接红。 +func TestMCPPanelActionsWired(t *testing.T) { + t.Parallel() + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + declared := hub.DeclaredActions(MCPPanelPluginID) + handled := hub.ActionHandlerNames(MCPPanelPluginID) + if len(declared) == 0 || len(handled) == 0 { + t.Fatalf("声明 %v / 处理器 %v;两者都不该为空", declared, handled) + } + set := make(map[string]bool, len(handled)) + for _, name := range handled { + set[name] = true + } + for _, name := range declared { + if !set[name] { + t.Fatalf("动作 %q 已声明但没有处理器(点了会 501)", name) + } + } + // 候选来源(`optionsFrom: plugin:mcpTransports`)必须能被解析成已登记的动作名, + // 否则前端的传输下拉会静默降级成文本框。 + if name := OptionsActionName(OptionsSourceTransports); !set[name] { + t.Fatalf("候选来源 %q 映射到 %q,但没有处理器", OptionsSourceTransports, name) + } +} + +// TestMCPPanelInstallAndList 锁定 ③ 的前半:安装(表单载荷)→ 看(表格行)。 +func TestMCPPanelInstallAndList(t *testing.T) { + t.Parallel() + srv := newFakeMCP(t, "", "add", "mul") + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + ctx := context.Background() + + installViaAction(t, hub, ctx, "calc", srv.URL) + assertInstalledConfig(t, mgr, "calc") + row := mustRows(t, hub, ctx)[0] + if row.Name != "calc" || row.State != ServerStateConnected || row.ToolCount == nil || *row.ToolCount != 2 { + t.Fatalf("行 = %+v,期望 calc/connected/2 工具", row) + } + if row.ToolsText != "add、mul" { + t.Fatalf("toolsText = %q,期望 add、mul", row.ToolsText) + } + if row.Target != srv.URL { + t.Fatalf("target = %q,期望地址 %q", row.Target, srv.URL) + } +} + +// TestMCPPanelToggleUninstallAndFrames 锁定 ③ 的后半:重连 / 停用 / 启用 / 卸载, +// 以及每一步都推 `panels_changed`(前端据此热更新,不必刷新页面)。 +func TestMCPPanelToggleUninstallAndFrames(t *testing.T) { + t.Parallel() + srv := newFakeMCP(t, "", "add", "mul") + mgr, hub, rec := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + ctx := context.Background() + installViaAction(t, hub, ctx, "calc", srv.URL) + + if _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc"); err != nil { + t.Fatalf("reconnect 动作: %v", err) + } + if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil { + t.Fatalf("disable 动作: %v", err) + } + if names := toolNames(mgr.reg); len(names) != 0 { + t.Fatalf("停用后工具仍在注册表 = %v", names) + } + if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateDisabled || row.Enabled { + t.Fatalf("停用后行 = %+v", row) + } + _, err := dispatchRow(t, hub, ActionReconnectMCPServer, "calc") + if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerDisabled || ae.Status != http.StatusConflict { + t.Fatalf("对停用的 server 重连 = %v,期望 %s/409", err, mcpCodeServerDisabled) + } + + if _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc"); err != nil { + t.Fatalf("enable 动作: %v", err) + } + if names := toolNames(mgr.reg); !names["add"] || !names["mul"] { + t.Fatalf("启用后工具 = %v,期望 add+mul", names) + } + if _, err := dispatchRow(t, hub, ActionUninstallMCPServer, "calc"); err != nil { + t.Fatalf("uninstall 动作: %v", err) + } + if rows := mustRows(t, hub, ctx); len(rows) != 0 { + t.Fatalf("卸载后仍有行:%+v", rows) + } + + reasons := changeReasons(rec.all()) + for _, want := range []string{ + PanelChangeInstalled, PanelChangeDisabled, PanelChangeEnabled, PanelChangeUninstalled, + } { + if !containsString(reasons, want) { + t.Fatalf("变更原因 = %v,缺 %q", reasons, want) + } + } +} + +// TestMCPPanelInstallFailureIsClassified 锁定失败分类:装不上 ≠ 找不到 ≠ 已停用。 +func TestMCPPanelInstallFailureIsClassified(t *testing.T) { + t.Parallel() + dead := httptest.NewServer(http.NotFoundHandler()) + deadURL := dead.URL + dead.Close() + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + ctx := context.Background() + + _, err := hub.Dispatch(ctx, ActionRequest{ + PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, + Payload: map[string]any{"values": map[string]any{"name": "down", "url": deadURL}}, + }) + if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInstallFailed || ae.Status != http.StatusBadGateway { + t.Fatalf("连接失败 = %v,期望 %s/502", err, mcpCodeInstallFailed) + } + // 配置不落盘,但失败要留在快照里(面板要说得出为什么挂了)。 + if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 { + t.Fatalf("失败后落盘 = %+v(err=%v),期望空", cfgs, err) + } + if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateError || row.LastError == "" { + t.Fatalf("失败行 = %+v,期望 error 且带 lastError", row) + } + + // 缺字段 ⇒ invalid_config / 400(不是 502:请求本身写错了)。 + _, err = hub.Dispatch(ctx, ActionRequest{ + PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, + Payload: map[string]any{"values": map[string]any{"transport": "stdio"}}, + }) + if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest { + t.Fatalf("缺 name/command = %v,期望 %s/400", err, mcpCodeInvalidConfig) + } + + // 卸载一个不存在的名字 ⇒ server_not_found / 404。 + _, err = dispatchRow(t, hub, ActionUninstallMCPServer, "ghost") + if ae, ok := AsActionError(err); !ok || ae.Code != mcpCodeServerNotFound || ae.Status != http.StatusNotFound { + t.Fatalf("卸载不存在的 server = %v,期望 %s/404", err, mcpCodeServerNotFound) + } +} + +// TestMCPPanelReservedNamespaceIsGuarded 锁定护栏:一台恰好叫 mcp 的 server 的卸载/停用 +// 不许把内置「MCP 服务器」页整页摘掉。 +func TestMCPPanelReservedNamespaceIsGuarded(t *testing.T) { + t.Parallel() + srv := newFakeMCP(t, "", "add") + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + installFake(t, mgr, MCPPanelPluginID, srv.URL) + if _, err := mgr.Uninstall(context.Background(), MCPPanelPluginID); err != nil { + t.Fatalf("Uninstall: %v", err) + } + entries, err := hub.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(entries) != 1 || entries[0].PluginID != MCPPanelPluginID { + t.Fatalf("内置 MCP 页被热装插件的生命周期改动了:%+v", entries) + } +} + +// mustRows 取表格行(`{rows}` 载荷)并断言载荷形状。 +func mustRows(t *testing.T, hub *Panels, ctx context.Context) []ServerRow { + t.Helper() + res, err := hub.Dispatch(ctx, ActionRequest{PluginID: MCPPanelPluginID, Action: ActionListMCPServers}) + if err != nil { + t.Fatalf("list 动作: %v", err) + } + body, ok := res.(map[string]any) + if !ok { + t.Fatalf("list 结果 = %T,期望对象", res) + } + rows, ok := body["rows"].([]ServerRow) + if !ok { + t.Fatalf("list 结果的 rows 类型 = %T", body["rows"]) + } + return rows +} + +// dispatchRow 以行内动作的形状(`{row:{name}}`)分发一次动作。 +func dispatchRow(t *testing.T, hub *Panels, action, name string) (any, error) { + t.Helper() + return hub.Dispatch(context.Background(), ActionRequest{ + PluginID: MCPPanelPluginID, Action: action, + Payload: map[string]any{"row": map[string]any{FieldServerName: name}}, + }) +} + +// installViaAction 以表单载荷(`{values}`)经动作面安装一台 server。 +func installViaAction(t *testing.T, hub *Panels, ctx context.Context, name, url string) { + t.Helper() + if _, err := hub.Dispatch(ctx, ActionRequest{ + PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, + Payload: map[string]any{"values": map[string]any{ + "name": name, "transport": "http", "url": url, + "args": []any{"--flag", "value"}, + "env": map[string]any{"TOKEN": "t0ken"}, + }}, + }); err != nil { + t.Fatalf("install 动作: %v", err) + } +} + +// assertInstalledConfig 断言表单字段确实映射进了 plugins.json(args/env 这类列表/键值 +// 最容易在「前端提交了、后端读错键」这一步静默丢失)。 +func assertInstalledConfig(t *testing.T, mgr *Manager, name string) { + t.Helper() + cfgs, err := runtimeconfig.LoadPlugins(mgr.path) + if err != nil || len(cfgs) != 1 { + t.Fatalf("落盘 = %+v(err=%v),期望一条", cfgs, err) + } + if cfgs[0].Name != name { + t.Fatalf("落盘名字 = %q", cfgs[0].Name) + } + if len(cfgs[0].Args) != 2 || cfgs[0].Args[1] != "value" { + t.Fatalf("args 没映射进配置:%+v", cfgs[0].Args) + } + if cfgs[0].Env["TOKEN"] != "t0ken" { + t.Fatalf("env 没映射进配置:%+v", cfgs[0].Env) + } +} + +// containsString 报告 items 里是否有 want。 +func containsString(items []string, want string) bool { + for _, item := range items { + if item == want { + return true + } + } + return false +} diff --git a/internal/capability/plugin/panels.go b/internal/capability/plugin/panels.go index 36d0723..a8f4ffd 100644 --- a/internal/capability/plugin/panels.go +++ b/internal/capability/plugin/panels.go @@ -287,15 +287,11 @@ func PanelIDValid(id string) bool { // 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) + key := m.rememberPanelKey(cfg) + if key == "" || isReservedPanelKey(key) { return } - m.mu.Lock() - m.panelKeys[cfg.Name] = key - hub := m.panels - m.mu.Unlock() + hub := m.panelsHub() if hub == nil { return } @@ -308,6 +304,51 @@ func (m *Manager) markInstalled(cfg runtimeconfig.PluginConfig, changeReason str hub.MarkInstalled(key) } +// markDisabled 记录停用时用过的面板目录键,并让中枢隐藏该插件 `when:{enabled:true}` 的 +// 入口(声明保留,重新启用即原样回来)。 +// +// 「停用」是**有意**没连,因此入口必须消失(§5.6.1「未启用的插件 ⇒ 入口不出现」); +// 而「连不上」不该摘入口 —— 那时用户正需要点「重连」。 +func (m *Manager) markDisabled(cfg runtimeconfig.PluginConfig) { + key := m.rememberPanelKey(cfg) + if key == "" || isReservedPanelKey(key) { + return + } + if hub := m.panelsHub(); hub != nil { + hub.SetEnabled(key, false) + } +} + +// rememberPanelKey 记下该插件的面板目录键(memo)并回它;键非法时记日志并回空串。 +// +// memo 是**缓存而不是第二份真源**:键由 PanelPluginID 从 cfg 推出,这里只把结果留下, +// 好让「活 server 已不在了」的卸载/停用仍能精确找到该清哪一条。 +func (m *Manager) rememberPanelKey(cfg runtimeconfig.PluginConfig) string { + key := PanelPluginID(cfg) + if key == "" { + m.log.Warn("plugin has no panel catalog key", "name", cfg.Name) + return "" + } + if isReservedPanelKey(key) { + // 内置提供者的命名空间不归热装插件管:一台恰好叫 mcp 的 server 不许改动 + // 「MCP 服务器」页本身(见 isReservedPanelKey)。 + m.log.Info("plugin name collides with a built-in panel namespace; panel entry untouched", + "name", cfg.Name, "panelId", key) + return "" + } + m.mu.Lock() + m.panelKeys[cfg.Name] = key + m.mu.Unlock() + return key +} + +// panelsHub 返回已接线中枢(未接线时 nil)。 +func (m *Manager) panelsHub() *Panels { + m.mu.Lock() + defer m.mu.Unlock() + return m.panels +} + // forgetPanels 在卸载后清掉该插件的面板。键优先取安装时记下的 memo;没有记录 // (例如配置文件是手工放进 plugins.json 的)时回落到「名字恰好合法就用名字」。 func (m *Manager) forgetPanels(name string) { @@ -325,6 +366,9 @@ func (m *Manager) forgetPanels(name string) { return } } + if isReservedPanelKey(key) { + return + } hub.MarkUninstalled(key) } @@ -343,5 +387,8 @@ func (m *Manager) setPanelsEnabled(name string, on bool) { return } } + if isReservedPanelKey(key) { + return + } hub.SetEnabled(key, on) } diff --git a/internal/capability/plugin/status.go b/internal/capability/plugin/status.go new file mode 100644 index 0000000..8501257 --- /dev/null +++ b/internal/capability/plugin/status.go @@ -0,0 +1,273 @@ +package plugin + +// 本文件是 MCP 服务器的**状态读面**(切片 M20):每台 server 的连接态、工具清单、最近一次 +// 错误与时间。它是「看不见连没连上 / 暴露了哪些工具 / 为什么挂了」这个洞的补丁。 +// +// 三条口径: +// +// 1. **三态,缺席 = 未知**(项目既有纪律:零值不得冒充「没有/未连接」,见 +// 「零值 = 不知道」类裁决)。因此 `connected` 是 `*bool`:present true = 连着; +// present false = 确认没连(停用 / 上次连接失败);**absent = 宿主不知道**。同理 +// `toolCount` / `tools` / `lastSeenAt` 只在「知道」时才出现 —— 一台停用的 server +// 不是「暴露 0 把工具」,而是「没连,所以不知道它有什么」。 +// 2. **凭据绝不出现在快照里**:`apiKey` 不进任何字段(Env 与 Args 也一律不下发 —— +// stdio 的 args 常带 `--token xxx`),自由文本(`lastError`、`endpoint`、`command`) +// 在**记录/构造那一刻**就用 `runtimeconfig.RedactPluginSecrets` 抹过(含凭据的任意 +// 片段)。抹在这里而不是在网关:读面不止一条路(面板动作、日志、未来的端点), +// 把安全押在「每个调用方都记得抹」上迟早漏一次。 +// 3. **状态是派生的、不是另存一份**:`state` 由「活没活 / 启没启用 / 有没有失败记录」 +// 推出来(见 statusOf),只有运行期观察(lastError / lastSeenAt)是记下来的事实。 +// 两处各存一份状态必然分叉 —— 那正是「面板说连上了、模型调不到工具」的形状。 + +import ( + "sort" + "strings" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/mcp" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// serverState 的取值闭集(`state` 列 / `Status.State`)。它们是**机器读**的码,不是文案: +// 前端按码分支,中文名由前端映射(与 web-search 页 `state` 列同一口径)。 +const ( + // ServerStateConnected 表示 server 已连接、工具已注册。 + ServerStateConnected = "connected" + // ServerStateError 表示已启用但最近一次连接/发现失败(`lastError` 有值)。 + ServerStateError = "error" + // ServerStateDisabled 表示被显式停用(`enabled=false`):没连,是**有意**没连。 + ServerStateDisabled = "disabled" + // ServerStateUnknown 表示宿主说不清:已启用、没连着、也没有失败记录 + // (例如 plugins.json 被手工改过、启动期还没轮到它)。 + ServerStateUnknown = "unknown" +) + +// 快照里的截断上限(都是「表格能扫一眼」的排版倒推出来的,不是随手取的数)。 +const ( + // toolDescriptionLimit 是单把工具描述的字符上限。 + toolDescriptionLimit = 200 + // lastErrorLimit 是最近一次错误的字符上限。错误链可能很长,排障要的是病因那一句。 + lastErrorLimit = 400 + // toolsTextLimit 是表格「工具」列(工具名拼成的文本)的字符上限。 + toolsTextLimit = 240 +) + +// Status 是一台 MCP server 的状态快照(可序列化,供面板行与未来端点复用)。 +type Status struct { + // Name 是 server 名(plugins.json 的唯一标签,也是能力的命名空间)。 + Name string `json:"name"` + // ID 是 server 的稳定标识(缺省省略)。 + ID string `json:"id,omitempty"` + // Kind 是插件种类(本期恒为 mcp)。 + Kind string `json:"kind,omitempty"` + // Transport 是连接形态(stdio / http / sse)。 + Transport string `json:"transport,omitempty"` + // Endpoint 是 http/sse 的地址(已抹凭据)。 + Endpoint string `json:"endpoint,omitempty"` + // Command 是 stdio 的可执行文件(参数**不下发**:args 常带凭据)。 + Command string `json:"command,omitempty"` + // Enabled 是启用态(来自 plugins.json;缺省视为 true)。 + Enabled bool `json:"enabled"` + // State 是状态闭集码(见 ServerState*)。 + State string `json:"state"` + // Connected 是三态连接态:true/false 是事实,**缺席是未知**(见文件头口径①)。 + Connected *bool `json:"connected,omitempty"` + // ToolCount 是已注册的工具数。只在**连着**时给出:不连的 server 不是「0 把工具」, + // 而是「不知道它有哪些工具」。 + ToolCount *int `json:"toolCount,omitempty"` + // Tools 是工具摘要(名字 + 截断描述)。同样只在连着时给出;连着但零工具时 + // `toolCount` 是 0 而本字段缺席(空数组同样意味着「没有工具」,两种表达不必并存)。 + Tools []mcp.ToolSummary `json:"tools,omitempty"` + // LastError 是最近一次连接/发现失败的文本(已抹凭据、已截断)。 + LastError string `json:"lastError,omitempty"` + // LastErrorAt 是那次失败的时刻(RFC3339)。 + LastErrorAt string `json:"lastErrorAt,omitempty"` + // LastSeenAt 是最近一次**成功**连上的时刻(RFC3339)。缺席 = 还没成功连过。 + LastSeenAt string `json:"lastSeenAt,omitempty"` +} + +// observed 是一台 server 最近一次被观察到的运行期事实(内存态,不落盘)。 +// +// 它只记「事件」:成功连上过(lastSeenAt)、失败过(lastError/lastErrorAt)。启用态与 +// 连接态都不在这里 —— 前者的真源是 plugins.json,后者的真源是 servers 表。 +type observed struct { + cfg runtimeconfig.PluginConfig + lastError string + lastErrorAt time.Time + lastSeenAt time.Time +} + +// Statuses 返回全部 MCP server 的状态快照:plugins.json 的顺序在前,其后是「只活在内存里」 +// 的(装了但还没落盘、或文件丢了但 server 活着)与「只留下失败记录」的,各自按名字排序 +// (map 迭代顺序不稳定,快照顺序必须稳定 —— 否则前端每次刷新都在跳行)。 +func (m *Manager) Statuses() []Status { + cfgs, err := runtimeconfig.LoadPlugins(m.path) + if err != nil { + m.log.Warn("plugins.json load skipped", "path", m.path, "error", err) + cfgs = nil + } + live, obs := m.snapshotState() + + out := make([]Status, 0, len(cfgs)+len(obs)) + seen := make(map[string]bool, len(cfgs)+len(obs)) + for _, cfg := range cfgs { + out = append(out, statusOf(cfg, live[cfg.Name], obs[cfg.Name])) + seen[cfg.Name] = true + } + for _, name := range sortedKeys(obs) { + if seen[name] { + continue + } + out = append(out, statusOf(obs[name].cfg, live[name], obs[name])) + seen[name] = true + } + for _, name := range sortedKeys(live) { + if seen[name] { + continue + } + out = append(out, statusOf(live[name].cfg, live[name], obs[name])) + seen[name] = true + } + return out +} + +// snapshotState 复制一份活 server 表与观察表(锁内复制、锁外计算:statusOf 会问能力注册表, +// 那是外部代码,持本管理器的锁去碰它等于把两把锁的顺序交给外部)。 +func (m *Manager) snapshotState() (map[string]*server, map[string]observed) { + m.mu.Lock() + defer m.mu.Unlock() + live := make(map[string]*server, len(m.servers)) + for name, srv := range m.servers { + live[name] = srv + } + obs := make(map[string]observed, len(m.observed)) + for name, rec := range m.observed { + obs[name] = rec + } + return live, obs +} + +// statusOf 把「配置 + 活 server + 观察记录」推成一条快照。 +func statusOf(cfg runtimeconfig.PluginConfig, srv *server, rec observed) Status { + st := Status{ + Name: cfg.Name, + ID: cfg.ID, + Kind: cfg.Kind, + Transport: cfg.Transport, + Endpoint: runtimeconfig.RedactPluginSecrets(cfg, cfg.Endpoint), + Command: runtimeconfig.RedactPluginSecrets(cfg, cfg.Command), + Enabled: cfg.IsEnabled(), + LastError: rec.lastError, + LastErrorAt: formatTime(rec.lastErrorAt), + } + switch { + case srv != nil: + st.State = ServerStateConnected + st.Connected = flagBool(true) + count := srv.toolCount + st.ToolCount = &count + st.Tools = srv.reg.ToolSummaries(toolDescriptionLimit) + st.LastSeenAt = formatTime(rec.lastSeenAt) + case !cfg.IsEnabled(): + // 停用是**有意**没连:连接态是确认的 false(不是未知),工具则是未知。 + // + // 「想启用但失败了」也落在这里(落盘的 enabled 仍是 false,见 enabled.go):此时 + // `state` 说「它现在是停用的」,`lastError` 说「刚才那次启用为什么没成」—— 两个 + // 事实各归各的列,谁也不冒充谁。若让 error 抢在 disabled 前面,一台**故意**停用 + // 过又失败过一次的 server 会永远显示「错误」,用户就没法把它当成「关着的」。 + st.State = ServerStateDisabled + st.Connected = flagBool(false) + st.LastSeenAt = formatTime(rec.lastSeenAt) + case rec.lastError != "": + st.State = ServerStateError + st.Connected = flagBool(false) + st.LastSeenAt = formatTime(rec.lastSeenAt) + default: + // 已启用、没连着、也没有失败记录 ⇒ 宿主不知道它到底行不行(不猜)。 + st.State = ServerStateUnknown + } + return st +} + +// noteSeen 记录一次成功连接(tools 已注册)。 +func (m *Manager) noteSeen(cfg runtimeconfig.PluginConfig) { + m.observe(cfg, func(rec *observed) { rec.lastSeenAt = time.Now().UTC() }) +} + +// noteFailure 记录一次连接/发现失败。错误文本在**这一刻**就抹掉凭据并截断:内存里也不留 +// 明文,之后的任何出口(面板、日志、未来端点)都不必再各自记得抹。 +func (m *Manager) noteFailure(cfg runtimeconfig.PluginConfig, err error) { + if err == nil { + return + } + text := mcp.TruncateRunes(runtimeconfig.RedactPluginSecrets(cfg, err.Error()), lastErrorLimit) + m.observe(cfg, func(rec *observed) { + rec.lastError = text + rec.lastErrorAt = time.Now().UTC() + }) +} + +// noteDisabled 记录「这台 server 现在是停用的」(保留历史观察:排障时要看它停用前发生过什么)。 +func (m *Manager) noteDisabled(cfg runtimeconfig.PluginConfig) { + m.observe(cfg, func(*observed) {}) +} + +// observe 在锁内更新一台 server 的观察记录(不存在则新建)。 +func (m *Manager) observe(cfg runtimeconfig.PluginConfig, apply func(*observed)) { + name := cfg.Name + if name == "" { + return + } + m.mu.Lock() + defer m.mu.Unlock() + if m.observed == nil { + m.observed = make(map[string]observed, 1) + } + rec := m.observed[name] + rec.cfg = cfg + apply(&rec) + m.observed[name] = rec +} + +// forgetObserved 丢掉一台 server 的观察记录(卸载即不再有这台 server 的任何事实)。 +func (m *Manager) forgetObserved(name string) { + m.mu.Lock() + defer m.mu.Unlock() + delete(m.observed, name) +} + +// formatTime 把时刻格式化成 RFC3339;零值返回空串(缺席 = 还没发生,不写 1970)。 +func formatTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.UTC().Format(time.RFC3339) +} + +// flagBool 是 `&true` / `&false` 的简写(Connected 是三态指针)。 +func flagBool(v bool) *bool { return &v } + +// sortedKeys 返回 map 键的有序副本(快照顺序必须稳定)。 +func sortedKeys[V any](m map[string]V) []string { + out := make([]string, 0, len(m)) + for k := range m { + out = append(out, k) + } + sort.Strings(out) + return out +} + +// toolsText 把工具摘要拼成表格里「工具」列的一行文本(名字在前、描述省略)。 +// +// 表格单元格只认标量(前端 `cellText` 会把数组用「、」拼起来,但长度不可控),因此这里 +// 自己拼并截断:排障要看的是**有哪些工具**,不是每把工具的完整描述(描述在 `tools[]` 里)。 +func toolsText(tools []mcp.ToolSummary) string { + if len(tools) == 0 { + return "" + } + names := make([]string, 0, len(tools)) + for _, t := range tools { + names = append(names, t.Name) + } + return mcp.TruncateRunes(strings.Join(names, "、"), toolsTextLimit) +} diff --git a/internal/capability/plugin/status_test.go b/internal/capability/plugin/status_test.go new file mode 100644 index 0000000..08b9052 --- /dev/null +++ b/internal/capability/plugin/status_test.go @@ -0,0 +1,185 @@ +package plugin + +// 本文件锁定 MCP 服务器状态快照的三条硬要求(切片 M20): +// ① 三态:缺席 = 未知,不用零值冒充「未连接」; +// ② 凭据绝不出现(apiKey 及其任意片段),带变异自证; +// ③ 连接失败如实给 lastError + connected=false,且失败的那台也出现在快照里 +// (否则「为什么挂了」在面板上根本无处可看)。 + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" + "git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig" +) + +// statusOfOne 返回快照里某台 server 的那一条(没有就 fail)。 +func statusOfOne(t *testing.T, mgr *Manager, name string) Status { + t.Helper() + for _, st := range mgr.Statuses() { + if st.Name == name { + return st + } + } + t.Fatalf("快照里没有 %q:%+v", name, mgr.Statuses()) + return Status{} +} + +// TestStatusesTriState 锁定 ①:三种「知道」程度各自长什么样。 +func TestStatusesTriState(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, "", "add") + mgr, _ := newEnabledManager(t, path, srv.URL) + + // 连着:connected=true、工具数/明细/最近连接都在。 + live := statusOfOne(t, mgr, "calc") + if live.State != ServerStateConnected { + t.Fatalf("state = %q,期望 connected", live.State) + } + if live.Connected == nil || !*live.Connected || live.ToolCount == nil || *live.ToolCount != 1 { + t.Fatalf("连接态快照不对 = %+v", live) + } + if live.LastSeenAt == "" || live.LastError != "" { + t.Fatalf("活着时应当有 lastSeenAt 且没有 lastError:%+v", live) + } + if live.Endpoint != srv.URL { + t.Fatalf("endpoint = %q,期望 %q", live.Endpoint, srv.URL) + } + + // 停用:connected 是**确认的 false**(有意没连),工具则是未知。 + if err := mgr.SetEnabled(context.Background(), "calc", false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + off := statusOfOne(t, mgr, "calc") + if off.State != ServerStateDisabled || off.Connected == nil || *off.Connected { + t.Fatalf("停用快照 = %+v", off) + } + if off.ToolCount != nil || off.Tools != nil { + t.Fatalf("停用时工具应当是**未知**(缺席),得到 %+v / %+v", off.ToolCount, off.Tools) + } + + // 未知:文件里有这一条,但本次进程从没连过也没失败过(手工改文件就是这样)。 + handEdited := runtimeconfig.PluginConfig{Name: "manual", Kind: runtimeconfig.PluginKindMCP, + Transport: runtimeconfig.TransportHTTP, Endpoint: "https://example.invalid/mcp"} + if err := runtimeconfig.SavePlugins(path, []runtimeconfig.PluginConfig{handEdited}); err != nil { + t.Fatalf("SavePlugins: %v", err) + } + unknown := statusOfOne(t, mgr, "manual") + if unknown.State != ServerStateUnknown { + t.Fatalf("state = %q,期望 unknown", unknown.State) + } + if unknown.Connected != nil { + t.Fatalf("未知的 connected 必须**缺席**(不得用零值冒充未连接),得到 %v", *unknown.Connected) + } + if unknown.ToolCount != nil || unknown.LastSeenAt != "" || unknown.LastError != "" { + t.Fatalf("未知时不该有工具/时间/错误:%+v", unknown) + } +} + +// TestStatusesFailureIsVisible 锁定 ③:连不上的**新装**attempt 也要出现在快照里。 +func TestStatusesFailureIsVisible(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + dead := httptest.NewServer(http.NotFoundHandler()) + deadURL := dead.URL + dead.Close() + + mgr := NewManager(registry.New(), path, nil) + defer mgr.Close(context.Background()) + if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ + Name: "down", Transport: runtimeconfig.TransportHTTP, Endpoint: deadURL, + }); err == nil { + t.Fatal("对关掉的 server 安装应当失败") + } + st := statusOfOne(t, mgr, "down") + if st.State != ServerStateError || st.Connected == nil || *st.Connected { + t.Fatalf("失败快照 = %+v,期望 error 且 connected=false", st) + } + if st.LastError == "" { + t.Fatal("失败快照没有 lastError:面板说不出病因") + } + if st.Enabled != true { + t.Fatalf("失败的那台是「想启用但没连上」,enabled 应为 true:%+v", st) + } +} + +// TestStatusesNeverLeakAPIKey 锁定 ②:快照 JSON 里不得出现 apiKey 或它的任意片段。 +// +// 覆盖两条最容易漏的路:① 正常快照里的配置字段;② 失败文本(MCP 客户端/上游可能把 +// Bearer 令牌写进错误里)。片段检查是「变异自证」:把密钥切成若干 8 字符片段再各查一次。 +func TestStatusesNeverLeakAPIKey(t *testing.T) { + t.Parallel() + const key = "sk-live-SECRET-9f3c2b1d" + path := filepath.Join(t.TempDir(), "plugins.json") + srv := newFakeMCP(t, key, "add") + mgr := NewManager(registry.New(), path, nil) + defer mgr.Close(context.Background()) + if _, err := mgr.Install(context.Background(), runtimeconfig.PluginConfig{ + Name: "calc", Transport: runtimeconfig.TransportHTTP, Endpoint: srv.URL, APIKey: key, + }); err != nil { + t.Fatalf("Install: %v", err) + } + cfgs, err := runtimeconfig.LoadPlugins(path) + if err != nil || len(cfgs) != 1 { + t.Fatalf("LoadPlugins = %+v, %v", cfgs, err) + } + cfgWithKey := cfgs[0] + // 失败文本里塞进整把钥匙和它的中段(这正是「上游把凭据回显进 401」的形状)。 + mgr.noteFailure(cfgWithKey, errors.New("401 Unauthorized: bearer "+key+" rejected; echo=SECRET-9f3")) + mgr.noteFailure(cfgWithKey, errors.New("dial "+srv.URL+"?api_key="+key+" failed")) + + raw, err := json.Marshal(mgr.Statuses()) + if err != nil { + t.Fatalf("marshal statuses: %v", err) + } + body := string(raw) + assertNoSecret(t, body, key) + if !strings.Contains(body, "failed") { + t.Fatalf("抹凭据不该把病因整条抹掉:%s", body) + } +} + +// TestRedactPluginSecrets 直接钉住抹除函数:整把、前缀、中段、后缀都不许留下。 +func TestRedactPluginSecrets(t *testing.T) { + t.Parallel() + const key = "tok_ABCDEF123456" + cfg := runtimeconfig.PluginConfig{APIKey: key, + Env: map[string]string{"GITHUB_TOKEN": "ghp_ZZZZZZ9999", "PLAIN": "keep-me"}} + cases := []string{ + "authorization: Bearer " + key, + "prefix " + key[:8] + " suffix", + "middle ABCDEF123456 and more", + "env dump GITHUB_TOKEN=ghp_ZZZZZZ9999", + } + for _, in := range cases { + out := runtimeconfig.RedactPluginSecrets(cfg, in) + assertNoSecret(t, out, key) + assertNoSecret(t, out, "ghp_ZZZZZZ9999") + } + // 非敏感值不该被连带抹掉(否则排障信息会被无谓地毁掉)。 + if out := runtimeconfig.RedactPluginSecrets(cfg, "plain keep-me"); !strings.Contains(out, "keep-me") { + t.Fatalf("非敏感值被误抹:%q", out) + } +} + +// assertNoSecret 断言 text 不含 secret 本身,也不含它的任意 8 字符片段。 +func assertNoSecret(t *testing.T, text, secret string) { + t.Helper() + if strings.Contains(text, secret) { + t.Fatalf("文本里出现了完整凭据 %q:%s", secret, text) + } + const frag = 8 + for i := 0; i+frag <= len(secret); i++ { + if part := secret[i : i+frag]; strings.Contains(text, part) { + t.Fatalf("文本里出现了凭据片段 %q:%s", part, text) + } + } +} diff --git a/internal/config/runtimeconfig/plugins.go b/internal/config/runtimeconfig/plugins.go index 76b731a..5df3965 100644 --- a/internal/config/runtimeconfig/plugins.go +++ b/internal/config/runtimeconfig/plugins.go @@ -14,6 +14,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strings" ) @@ -61,10 +62,30 @@ type PluginConfig struct { // Env is extra environment for a stdio subprocess. Values under a sensitive // key (token/secret/password/key...) are redacted by MaskedPlugin. Env map[string]string `json:"env,omitempty"` + // Enabled is the per-server on/off switch. It is a pointer so that "the file + // has no enabled key" (every plugins.json written before this field existed) + // stays distinguishable from an explicit false: absent means enabled + // (IsEnabled), and only an explicit disable is persisted. + // + // Disabled is NOT uninstalled: the entry stays in plugins.json (so it survives + // a restart as disabled) and only its live connection + tools go away. + Enabled *bool `json:"enabled,omitempty"` // CreatedAt is the RFC3339 install time. CreatedAt string `json:"createdAt,omitempty"` } +// IsEnabled reports whether the server should be connected. A missing enabled key +// (older plugins.json / an install that never mentioned it) counts as enabled. +func (p PluginConfig) IsEnabled() bool { + return p.Enabled == nil || *p.Enabled +} + +// WithEnabled returns a copy of p with an explicit enabled flag. +func (p PluginConfig) WithEnabled(on bool) PluginConfig { + p.Enabled = &on + return p +} + // DefaultPluginsPath returns the plugins.json path under dataDir. func DefaultPluginsPath(dataDir string) string { return filepath.Join(dataDir, pluginsFileName) @@ -245,6 +266,64 @@ func maskEnv(env map[string]string) map[string]string { return out } +// secretMask is what a redacted secret (or a fragment of one) collapses to inside +// free text. It is deliberately not maskToken: free text needs a marker that +// cannot be mistaken for a real tail of the credential. +const secretMask = "***" + +// minRedactFragment is the shortest run of a secret that is still scrubbed from +// free text. Anything shorter is not evidence of that credential (a 2-3 char run +// of a hex key shows up in ordinary prose by chance), and keeping the floor low +// enough that a leaked *middle* of a key (an error printing a substring) is still +// caught is the point of scanning fragments at all. +const minRedactFragment = 6 + +// RedactPluginSecrets scrubs every configured credential out of text, returning a +// string that is safe to show in a status snapshot or a log line. +// +// Why this exists (and why MaskedPlugin is not enough): a connect failure's error +// text is written by the MCP client, the HTTP stack or the server itself, so it +// can carry the bearer token (a 401 echoing the Authorization header, a URL with +// `?token=...`, a stdio subprocess dumping its env). The snapshot exposes that +// text as `lastError`, therefore the text — not just the config — has to be +// redacted. Replacement is by whole value first, then by every fragment of at +// least minRedactFragment characters, so a partially printed key is caught too. +func RedactPluginSecrets(p PluginConfig, text string) string { + if text == "" { + return "" + } + out := redactSecret(text, p.APIKey) + keys := make([]string, 0, len(p.Env)) + for k := range p.Env { + if isSensitiveKey(k) { + keys = append(keys, k) + } + } + // 排序只为结果稳定:Env 是 map,不排序会让同一份输入在不同运行里产出不同文本。 + sort.Strings(keys) + for _, k := range keys { + out = redactSecret(out, p.Env[k]) + } + return out +} + +// redactSecret replaces secret and every fragment of it (longest first, down to +// minRedactFragment runes-worth of bytes) with secretMask. +func redactSecret(text, secret string) string { + secret = strings.TrimSpace(secret) + if secret == "" || text == "" { + return text + } + out := strings.ReplaceAll(text, secret, secretMask) + // 长片段优先:先抹掉最长的,剩下的短片段才不会被抹成一片 *** 后漏掉边界。 + for n := len(secret) - 1; n >= minRedactFragment; n-- { + for i := 0; i+n <= len(secret); i++ { + out = strings.ReplaceAll(out, secret[i:i+n], secretMask) + } + } + return out +} + // samePlugin reports whether cur is the entry p replaces: same name when p has // one, otherwise same id. func samePlugin(cur, p PluginConfig) bool { diff --git a/internal/config/runtimeconfig/plugins_test.go b/internal/config/runtimeconfig/plugins_test.go index 330f985..280f1db 100644 --- a/internal/config/runtimeconfig/plugins_test.go +++ b/internal/config/runtimeconfig/plugins_test.go @@ -186,3 +186,64 @@ func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) { t.Fatalf("masked JSON leaks a secret: %s", raw) } } + +// TestPluginEnabled_BackwardCompatible 锁定 enabled 的向后兼容:既有 plugins.json +// (没有 enabled 键)必须被当成**启用**,而显式 false 必须原样存活(含重启)。 +func TestPluginEnabled_BackwardCompatible(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "plugins.json") + legacy := `[{"name":"calc","kind":"mcp","transport":"http","endpoint":"https://x"}]` + if err := os.WriteFile(path, []byte(legacy), 0o600); err != nil { + t.Fatalf("write legacy file: %v", err) + } + got, err := LoadPlugins(path) + if err != nil { + t.Fatalf("LoadPlugins: %v", err) + } + if len(got) != 1 || !got[0].IsEnabled() { + t.Fatalf("老文件(无 enabled 键)应视为启用:%+v", got) + } + if got[0].Enabled != nil { + t.Fatalf("缺失的 enabled 必须保持 nil(= 缺席),得到 %v", *got[0].Enabled) + } + + off := got[0].WithEnabled(false) + if err := SavePlugins(path, []PluginConfig{off}); err != nil { + t.Fatalf("SavePlugins: %v", err) + } + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read back: %v", err) + } + if !strings.Contains(string(raw), `"enabled": false`) { + t.Fatalf("显式停用必须落盘:%s", raw) + } + back, err := LoadPlugins(path) + if err != nil { + t.Fatalf("LoadPlugins: %v", err) + } + if back[0].IsEnabled() { + t.Fatalf("重启后停用态丢失:%+v", back[0]) + } +} + +// TestRedactPluginSecrets_ScrubsFragments 锁定「凭据任意片段也不许泄漏」。 +func TestRedactPluginSecrets_ScrubsFragments(t *testing.T) { + t.Parallel() + const key = "sk-live-0123456789abcdef" + p := PluginConfig{APIKey: key, Env: map[string]string{"GH_TOKEN": "ghp_secret_value"}} + for _, text := range []string{ + "bearer " + key, + "echo " + key[8:20], + "tail " + key[len(key)-6:], + "env GH_TOKEN=ghp_secret_value", + } { + out := RedactPluginSecrets(p, text) + if strings.Contains(out, key) || strings.Contains(out, "ghp_secret_value") { + t.Fatalf("整把凭据仍在:%q", out) + } + if strings.Contains(out, key[8:20]) || strings.Contains(out, key[len(key)-6:]) { + t.Fatalf("凭据片段仍在:%q", out) + } + } +} diff --git a/internal/services/gateway/admin_plugins.go b/internal/services/gateway/admin_plugins.go index 3d5d9e9..e182423 100644 --- a/internal/services/gateway/admin_plugins.go +++ b/internal/services/gateway/admin_plugins.go @@ -63,6 +63,9 @@ type pluginInstallConfig struct { Args []string `json:"args"` APIKey string `json:"apiKey"` Env map[string]string `json:"env"` + // Enabled 是安装时的初始启用态。缺省(nil)=启用;显式 false =装上但先不连 + // (配置落盘、工具不注册,之后可由面板「启用」)。 + Enabled *bool `json:"enabled"` } // AdminInstalledPlugin 是管理列表中一个已安装插件的线上 @@ -78,6 +81,9 @@ type AdminInstalledPlugin struct { APIKey string `json:"apiKey,omitempty"` Env map[string]string `json:"env,omitempty"` CreatedAt string `json:"createdAt,omitempty"` + // Enabled 是这一台的启用态(plugins.json 的 `enabled`,缺省视为 true)。 + // 停用 ≠ 卸载:条目仍在列表里,只是没连、没注册工具。 + Enabled bool `json:"enabled"` // Live 报告 MCP server 是否已连接、其工具是否已注册。 Live bool `json:"live"` // ToolCount 是活着的 server 已注册的工具数。 @@ -134,6 +140,7 @@ func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, r Args: req.Config.Args, APIKey: req.Config.APIKey, Env: req.Config.Env, + Enabled: req.Config.Enabled, }.Normalize() if err := cfg.Validate(); err != nil { s.auditClaims(r, "plugin.install", "error") @@ -160,6 +167,7 @@ func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, r "name": res.Config.Name, "kind": res.Config.Kind, "transport": res.Config.Transport, + "enabled": res.Config.IsEnabled(), "toolCount": res.ToolCount, }) } @@ -240,6 +248,7 @@ func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin { APIKey: cfg.APIKey, Env: cfg.Env, CreatedAt: cfg.CreatedAt, + Enabled: cfg.IsEnabled(), Live: it.Live, ToolCount: it.ToolCount, } From 2421a8dac9e5c254bf425ce31f2a9f1dd62ec7da Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 19:38:38 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(plugin):=20=E7=BB=84=E5=90=88=E6=A0=B9?= =?UTF-8?q?=E8=A3=85=E9=85=8D=E3=80=8CMCP=20=E6=9C=8D=E5=8A=A1=E5=99=A8?= =?UTF-8?q?=E3=80=8D=E9=A1=B5=EF=BC=88=E5=A3=B0=E6=98=8E=20+=20=E5=8A=A8?= =?UTF-8?q?=E4=BD=9C=E5=A4=84=E7=90=86=E5=99=A8=E6=88=90=E5=AF=B9=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wirePanelHub 在 AttachPanels / declareBuiltinPanels 之后调 AttachMCPPanel: 一处完成「声明页面」+「登记 7 个动作处理器」,缺任一步就是面板在、动作全 501。 - wire_panels_test 的两条「内置面板恰好 1 条」断言改成按 pluginID 定位 (web-search/providers),并加钉区块形状(form+table;viewer 只剩 table)。 接线落地后目录里确实是两条(mcp/10 + web-search/20),旧断言是事实过期而非回归。 --- cmd/cyrene/wire_frontend.go | 7 +++++++ cmd/cyrene/wire_panels_test.go | 35 ++++++++++++++++++++++++++++------ 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/cmd/cyrene/wire_frontend.go b/cmd/cyrene/wire_frontend.go index d2cf104..c3538bf 100644 --- a/cmd/cyrene/wire_frontend.go +++ b/cmd/cyrene/wire_frontend.go @@ -203,6 +203,13 @@ func wirePanelHub(a *app) { }) a.plugins.AttachPanels(hub) declareBuiltinPanels(hub, log) + // 「MCP 服务器」页(切片 M20):声明 + 动作处理器成对装配,且必须在中枢接线之后 + // (AttachMCPPanel 经插件管理器取中枢,未接线时会响亮报错而不是静默不挂)。 + // 它归属插件管理器自己的领域数据(Statuses/Install/SetEnabled/Uninstall),因此 + // 与 web-search 的纯声明不同:这里一处调用同时完成声明与动作登记。 + if err := a.plugins.AttachMCPPanel(); err != nil { + log.Warn("mcp server panel unavailable; its actions will answer 501", "error", err) + } // browser 插件(design/28 §8 B4)的面板声明 + 六个动作:它自己就把「先声明、后登记 // 动作」这一步走完(wire_browser_panel.go 一处),因此与 search 的纯声明并列。 // diff --git a/cmd/cyrene/wire_panels_test.go b/cmd/cyrene/wire_panels_test.go index 2aa5481..233a856 100644 --- a/cmd/cyrene/wire_panels_test.go +++ b/cmd/cyrene/wire_panels_test.go @@ -8,6 +8,7 @@ import ( "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/paneldecl" 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" @@ -99,23 +100,45 @@ func TestWirePanelHubDeclaresBuiltinSearchPanel(t *testing.T) { 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) + // 按 pluginID 定位,而不是数「恰好一条」:内置提供者会越来越多(本片新增 + // mcp/servers 管理页),数总数的断言每加一页就假红一次,而它真正想守的是 + // 「web-search/providers 在目录里,且形状仍是声明的那一版」。 + entry, ok := findPanelEntry(entries, search.PanelPluginID, search.PanelIDProviders) + if !ok { + t.Fatalf("内置面板里没有 web-search/providers:%+v", entries) + } + // 形状也钉住:一个 form(写面)+ 一个 table(读面)。 + if len(entry.Panel.Sections) != 2 || entry.Panel.Sections[0].Type != sdk.SectionForm || + entry.Panel.Sections[1].Type != sdk.SectionTable { + t.Fatalf("web-search/providers 的区块形状 = %+v,期望 form+table", entry.Panel.Sections) } for _, name := range search.ActionNames() { if _, ok := hub.Action(search.PanelPluginID, name); !ok { t.Fatalf("内置插件声明的动作 %q 未进白名单", name) } } - // viewer 拿到的必须是裁掉 form 的只读投影。 + // 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) + viewerEntry, ok := findPanelEntry(viewer, search.PanelPluginID, search.PanelIDProviders) + if !ok { + t.Fatalf("viewer 快照里没有 web-search/providers:%+v", viewer) } + if len(viewerEntry.Panel.Sections) != 1 || viewerEntry.Panel.Sections[0].Type != sdk.SectionTable { + t.Fatalf("viewer 的只读投影 = %+v,期望只剩表格区块", viewerEntry.Panel.Sections) + } +} + +// findPanelEntry 在目录快照里按 (pluginId, panelId) 定位那一条。 +func findPanelEntry(entries []paneldecl.Entry, pluginID, panelID string) (paneldecl.Entry, bool) { + for _, e := range entries { + if e.PluginID == pluginID && e.Panel.ID == panelID { + return e, true + } + } + return paneldecl.Entry{}, false } // TestWirePanelHubNilPluginsIsNoop 断言未构造插件管理器时接线是空操作(不 panic)。 From 2142de92f25ed9c1556e403010ed64bb96165118 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Sat, 24 Oct 2026 19:53:38 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(plugin):=20=E6=8B=92=E6=94=B6=20env/arg?= =?UTF-8?q?s=20=E7=9A=84=E9=9D=9E=E5=AD=97=E7=AC=A6=E4=B8=B2=E5=80=BC=20+?= =?UTF-8?q?=20=E4=BF=9D=E7=95=99=E9=94=AE=E6=94=B6=E6=88=90=E5=86=85?= =?UTF-8?q?=E7=BD=AE=20id=20=E9=9B=86=E5=90=88=20+=20=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E8=90=BD=E7=9B=98=E5=A4=B1=E8=B4=A5=E6=96=87=E6=A1=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit r1 复审 P2-1 + 两条顺手件。 P2-1(必修):`actionStringMap` / `actionStringList` 之前对非字符串值 `continue`,于是 前端 kv 控件里最自然的写法 `{"PORT":8080}` 会被**静默丢掉**、安装仍报 `installed:true`。 现在非字符串值、空键、非数组一律 400 invalid_config,文案只点名**键与类型**(不回显值); 盘上零变更、注册表零工具、快照零行(测试同时断言)。 顺手件 1:`isReservedPanelKey` 从「只挡 mcp」改成内置提供者闭集(mcp / web-search / browser,写成 switch,新增内置页时漏补一眼可见)。之前一台名为 web-search 的 server 被 卸载时,`forgetPanels → MarkUninstalled` 会把内置「搜索提供商」页在重启前整页摘掉。 顺手件 2:启用「已连上但启用态未落盘」的文案改成事实(原来那句「它仍处于停用状态」与 事实相反)。为此新增 `ErrPluginStatePersist` 哨兵,把「磁盘没记住」与「连不上」分开; `state=connected` 的派生口径不动。停用失败文案同时补上「server 仍在运行」。 测试:三条新用例,均已验「修前红 → 修后绿」(见 review-request 的取证)。 --- internal/capability/plugin/enabled.go | 11 +- internal/capability/plugin/mcppanel.go | 18 +- .../capability/plugin/mcppanel_actions.go | 158 +++++++++++++----- internal/capability/plugin/mcppanel_test.go | 156 +++++++++++++++++ 4 files changed, 297 insertions(+), 46 deletions(-) diff --git a/internal/capability/plugin/enabled.go b/internal/capability/plugin/enabled.go index 6a67bb7..b7f0b2d 100644 --- a/internal/capability/plugin/enabled.go +++ b/internal/capability/plugin/enabled.go @@ -25,6 +25,13 @@ import ( // ErrPluginNotFound 报告指定的插件既没活着也不在 plugins.json 里。 var ErrPluginNotFound = errors.New("plugin: not found") +// ErrPluginStatePersist 报告「启用态没写成」:内存/连接态可能已经变了,但 plugins.json +// 还是旧值 —— 因此**重启后会退回旧状态**。 +// +// 它与「连不上」是两件相反的事,调用方必须能分开(文案说反就是骗人):连不上 ⇒ server +// 现在不可用;落盘失败 ⇒ server 可能正连着、工具可用,只是这个事实活不过一次重启。 +var ErrPluginStatePersist = errors.New("plugin: enabled state not persisted") + // notFound 构造一条带名字的「找不到」。 func notFound(name string) error { if name == "" { @@ -131,9 +138,11 @@ func (m *Manager) enable(ctx context.Context, name string) error { // persistEnabled 把启用态写回 plugins.json(upsert by name,其余字段原样保留)。 // // 它**不**碰内存里的活 server:调用方按自己的顺序决定何时连线/断线。 +// 失败用 ErrPluginStatePersist 包起来:调用方要能把它与「连不上」区分开 —— 这两个失败 +// 的事实相反(一个没连上、一个可能已经连上而磁盘没记住)。 func (m *Manager) persistEnabled(cfg runtimeconfig.PluginConfig, on bool) error { if err := m.persistUpsert(cfg.WithEnabled(on)); err != nil { - return fmt.Errorf("插件 %q 的启用态未落盘: %w", cfg.Name, err) + return fmt.Errorf("%w: 插件 %q 的启用态未落盘: %w", ErrPluginStatePersist, cfg.Name, err) } return nil } diff --git a/internal/capability/plugin/mcppanel.go b/internal/capability/plugin/mcppanel.go index b5a3c4e..854d3e6 100644 --- a/internal/capability/plugin/mcppanel.go +++ b/internal/capability/plugin/mcppanel.go @@ -186,6 +186,18 @@ func (m *Manager) AttachMCPPanel() error { // isReservedPanelKey 报告该目录键属于**宿主内置面板提供者**(不是热装插件的命名空间)。 // // 护栏的方向是单向的:热装插件的安装/停用/卸载绝不许改动内置提供者的目录条目。理由很 -// 实在 —— 内置提供者的 id 是给人看的短名字(mcp),而 server 的名字由主人随手起;一台 -// 恰好叫 mcp 的 server 在卸载时若照常 MarkUninstalled("mcp"),整页「MCP 服务器」就消失了。 -func isReservedPanelKey(key string) bool { return key == MCPPanelPluginID } +// 实在 —— 内置提供者的 id 是给人看的短名字(mcp / web-search / browser),而 server 的 +// 名字由主人随手起;一台恰好同名的 server 在卸载时若照常 MarkUninstalled,那整页内置页 +// 就在**重启前**消失了(`forgetPanels → MarkUninstalled` 连声明与动作索引一起清)。 +// +// 名单写死成 switch 而不是包级 map:新增内置页时没补这一行,review 时一眼可见(与 +// paneldecl.shapeOf 同一取舍)。当前三个内置提供者:本包的 mcp 页(MCP 服务器)、 +// `plugins/tools/io/search` 的 web-search 页、`plugins/tools/browser` 的 browser 页。 +func isReservedPanelKey(key string) bool { + switch key { + case MCPPanelPluginID, "web-search", "browser": + return true + default: + return false + } +} diff --git a/internal/capability/plugin/mcppanel_actions.go b/internal/capability/plugin/mcppanel_actions.go index 599e92f..d73973e 100644 --- a/internal/capability/plugin/mcppanel_actions.go +++ b/internal/capability/plugin/mcppanel_actions.go @@ -87,16 +87,10 @@ func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, if err != nil { return nil, err } - cfg := runtimeconfig.PluginConfig{ - Name: actionText(values, FieldServerName), - Kind: runtimeconfig.PluginKindMCP, - Transport: actionText(values, FieldTransport), - Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")), - Command: actionText(values, FieldCommand), - Args: actionStringList(values[FieldArgs]), - APIKey: actionText(values, FieldAPIKey), - Env: actionStringMap(values[FieldEnv]), - }.Normalize() + cfg, err := installConfigOf(values) + if err != nil { + return nil, err + } if err := cfg.Validate(); err != nil { return nil, NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置不完整:%v", err), 400, err) } @@ -114,6 +108,40 @@ func (m *Manager) actInstallServer(ctx context.Context, req ActionRequest) (any, }, nil } +// installConfigOf 把表单值翻译成插件配置。 +// +// `args` / `env` 的形状错**必须在这里变成 400**:前端的 `kv` 控件是自由 JSON textarea, +// 主人写 `{"PORT":8080}` 是最自然的写法,若后端把非字符串值静默丢掉,结果就是「装完提示 +// 成功、server 也连上了、env 却没生效」—— 正是任务书点名要防的「看着配好了、其实没生效」。 +// 同理 `args` 里的非字符串元素也不许悄悄消失。 +func installConfigOf(values map[string]any) (runtimeconfig.PluginConfig, error) { + args, err := actionStringList(values[FieldArgs]) + if err != nil { + return runtimeconfig.PluginConfig{}, invalidConfigError(err) + } + env, err := actionStringMap(values[FieldEnv]) + if err != nil { + return runtimeconfig.PluginConfig{}, invalidConfigError(err) + } + return runtimeconfig.PluginConfig{ + Name: actionText(values, FieldServerName), + Kind: runtimeconfig.PluginKindMCP, + Transport: actionText(values, FieldTransport), + Endpoint: firstNonEmpty(actionText(values, FieldURL), actionText(values, "endpoint")), + Command: actionText(values, FieldCommand), + Args: args, + APIKey: actionText(values, FieldAPIKey), + Env: env, + }.Normalize(), nil +} + +// invalidConfigError 把一条「配置没写对」包成结构化动作失败(400)。 +// +// 文案里只出现**键名与类型**,绝不回显值:env 的值可能就是凭据。 +func invalidConfigError(err error) error { + return NewActionError(mcpCodeInvalidConfig, fmt.Sprintf("配置没写对:%v", err), 400, err) +} + // actReconnectServer 断开并重新连接一台已启用的 server(重新发现工具)。 func (m *Manager) actReconnectServer(ctx context.Context, req ActionRequest) (any, error) { name, err := actionRowName(req.Payload) @@ -146,6 +174,12 @@ func (m *Manager) actEnableServer(ctx context.Context, req ActionRequest) (any, return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err) } m.log.Warn("mcp enable failed", "name", name, "error", err) + // 两种失败的事实不同,文案也必须不同:连不上 ⇒ 它仍是停用的;**已经连上但启用态 + // 没落盘** ⇒ 现在能用、重启后会回到停用(说成「仍处于停用状态」就是与事实相反)。 + if errors.Is(err, ErrPluginStatePersist) { + return nil, NewActionError(mcpCodeEnableFailed, + fmt.Sprintf("%q 已连上,但启用态未落盘:重启后会回到停用,详细原因见后端日志", name), 502, err) + } return nil, NewActionError(mcpCodeEnableFailed, fmt.Sprintf("启用 %q 失败:它仍处于停用状态,详细原因见后端日志", name), 502, err) } @@ -163,8 +197,9 @@ func (m *Manager) actDisableServer(ctx context.Context, req ActionRequest) (any, return nil, NewActionError(mcpCodeServerNotFound, fmt.Sprintf("没有这台 server:%s", name), 404, err) } m.log.Warn("mcp disable failed", "name", name, "error", err) + // 停用的落盘在**前面**(见 enabled.go):写不进去时 server 还在跑、工具还在。 return nil, NewActionError(mcpCodeDisableFailed, - fmt.Sprintf("停用 %q 失败:启用态未能落盘,详细原因见后端日志", name), 500, err) + fmt.Sprintf("停用 %q 失败:启用态未能落盘,server 仍在运行(可再试一次),详细原因见后端日志", name), 500, err) } return map[string]any{"name": name, "enabled": false, "connected": false}, nil } @@ -259,51 +294,90 @@ func actionText(values map[string]any, key string) string { } // actionStringList 取一个字符串列表(`multiSelect` 提交的是 `[]string`,JSON 解码后是 -// `[]any`;元素非字符串即忽略,空串不保留)。空列表返回 nil(配置里就不写这个键)。 -func actionStringList(raw any) []string { - items, ok := raw.([]any) - if !ok { - if strs, okStr := raw.([]string); okStr { - return compactStrings(strs) +// `[]any`)。 +// +// 形状错一律报错:非数组、元素非字符串都不许**静默丢弃**(少一个参数、或多出一个空参数, +// 都会让 server 起来后行为不对,而面板还报「装好了」)。空项按前端 `splitListValue` 的同一 +// 口径去掉(那只来自多余的分隔符,不是用户意图)。 +func actionStringList(raw any) ([]string, error) { + switch items := raw.(type) { + case nil: + return nil, nil + case []string: + return compactStrings(items), nil + case []any: + out := make([]string, 0, len(items)) + for i, item := range items { + s, ok := item.(string) + if !ok { + return nil, fmt.Errorf("args 的第 %d 项不是字符串(收到 %s);每个参数都必须是字符串", i+1, jsonKindOf(item)) + } + out = append(out, s) } - return nil + return compactStrings(out), nil + default: + return nil, fmt.Errorf("args 必须是字符串数组(收到 %s)", jsonKindOf(raw)) } - out := make([]string, 0, len(items)) - for _, item := range items { - s, ok := item.(string) - if !ok { - continue - } - out = append(out, s) - } - return compactStrings(out) } // actionStringMap 取一个键值对(`kv` 提交的是 JSON 对象)。 // -// 值只接受字符串:`{"PORT":8080}`(数字)这类会被**拒之门外而不是静默转成 "8080"** —— -// 静默转换正是「看着配好了、其实没生效」的来源。 -func actionStringMap(raw any) map[string]string { - obj, ok := raw.(map[string]any) - if !ok { - return nil - } - out := make(map[string]string, len(obj)) - for k, v := range obj { - s, ok := v.(string) - if !ok { - continue +// 值**只接受字符串**,其它类型一律报错而不是丢掉那个键:`{"PORT":8080}` 是最自然的写法, +// 静默丢掉它等于「装完提示成功、env 却没生效」(前端 `KvControl` 只校验「是个对象」, +// 所以这条路 UI 天然可达)。空键同理 —— 丢一个键与拒一个键必须选后者。 +func actionStringMap(raw any) (map[string]string, error) { + var pairs map[string]any + switch obj := raw.(type) { + case nil: + return nil, nil + case map[string]any: + pairs = obj + case map[string]string: + pairs = make(map[string]any, len(obj)) + for k, v := range obj { + pairs[k] = v } + default: + return nil, fmt.Errorf("env 必须是键值对对象(收到 %s)", jsonKindOf(raw)) + } + out := make(map[string]string, len(pairs)) + for k, v := range pairs { key := strings.TrimSpace(k) if key == "" { - continue + return nil, fmt.Errorf("env 里有空键;键就是环境变量名,不能为空") + } + s, ok := v.(string) + if !ok { + return nil, fmt.Errorf("env.%s 的值不是字符串(收到 %s);要写数字/布尔请加引号,如 \"8080\"", key, jsonKindOf(v)) } out[key] = s } if len(out) == 0 { - return nil + return nil, nil + } + return out, nil +} + +// jsonKindOf 给出一个 JSON 值的中文类型名(只为错误文案:说清「收到的是什么」)。 +// +// 它刻意不打印值本身:env 的值可能就是凭据。 +func jsonKindOf(v any) string { + switch v.(type) { + case nil: + return "null" + case string: + return "字符串" + case bool: + return "布尔" + case float64, float32, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64: + return "数字" + case []any: + return "数组" + case map[string]any: + return "对象" + default: + return fmt.Sprintf("%T", v) } - return out } // compactStrings 去掉两端空白与空项;结果为空时返回 nil。 diff --git a/internal/capability/plugin/mcppanel_test.go b/internal/capability/plugin/mcppanel_test.go index 7393dfd..5bd045d 100644 --- a/internal/capability/plugin/mcppanel_test.go +++ b/internal/capability/plugin/mcppanel_test.go @@ -10,11 +10,15 @@ import ( "context" "net/http" "net/http/httptest" + "os" + "runtime" + "strings" "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" ) // TestMCPPanelDeclarationIsValid 锁定 ①:整份声明过 paneldecl 的形状校验。 @@ -272,3 +276,155 @@ func containsString(items []string, want string) bool { } return false } + +// TestMCPPanelInstallRejectsNonStringEnv 锁定 P2-1:`env` / `args` 里的非字符串值必须 +// 变成 400,**不许静默丢键**(`{"PORT":8080}` 是前端 kv 控件最自然的写法:装完提示成功、 +// server 连上了、env 却没生效 —— 比直接失败更坏)。 +func TestMCPPanelInstallRejectsNonStringEnv(t *testing.T) { + t.Parallel() + srv := newFakeMCP(t, "", "add") + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + ctx := context.Background() + + cases := []struct { + name string + mutate func(values map[string]any) + wantKey string + }{ + {"env 值是数字", func(v map[string]any) { v["env"] = map[string]any{"PORT": 9182.0} }, "env.PORT"}, + {"env 值是布尔", func(v map[string]any) { v["env"] = map[string]any{"DEBUG": true} }, "env.DEBUG"}, + {"args 元素是数字", func(v map[string]any) { v["args"] = []any{"-y", 42.0} }, "args"}, + } + for _, tc := range cases { + values := map[string]any{ + "name": "calc", "transport": "http", "url": srv.URL, + "env": map[string]any{"TOKEN": "abc"}, + "args": []any{"--flag"}, + } + tc.mutate(values) + _, err := hub.Dispatch(ctx, ActionRequest{ + PluginID: MCPPanelPluginID, Action: ActionInstallMCPServer, + Payload: map[string]any{"values": values}, + }) + ae, ok := AsActionError(err) + if !ok || ae.Code != mcpCodeInvalidConfig || ae.Status != http.StatusBadRequest { + t.Fatalf("%s:err = %v,期望 %s/400", tc.name, err, mcpCodeInvalidConfig) + } + if !strings.Contains(ae.Message, tc.wantKey) { + t.Fatalf("%s:文案没点名出错的键(%q 不含 %q)", tc.name, ae.Message, tc.wantKey) + } + // 文案里不许回显**收到的值**(env 的值可能就是凭据):9182 是本用例特意挑的 + // 独特数字,文案里的 "8080" 只是「要加引号」的示例,不是回显。 + if strings.Contains(ae.Message, "9182") { + t.Fatalf("%s:文案回显了值:%q", tc.name, ae.Message) + } + } + + // 三次都失败 ⇒ 盘上零变更、注册表零工具、列表零行。 + if cfgs, err := runtimeconfig.LoadPlugins(mgr.path); err != nil || len(cfgs) != 0 { + t.Fatalf("盘上 = %+v(err=%v),期望空", cfgs, err) + } + if names := toolNames(mgr.reg); len(names) != 0 { + t.Fatalf("注册表里有工具 = %v", names) + } + if rows := mustRows(t, hub, ctx); len(rows) != 0 { + t.Fatalf("快照里有行 = %+v", rows) + } +} + +// TestReservedPanelKeysProtectBuiltinProviders 锁定顺手件 1:三台内置提供者的命名空间 +// 都不许被一台同名 server 的安装/停用/启用/卸载改写(否则内置页在重启前就消失)。 +func TestReservedPanelKeysProtectBuiltinProviders(t *testing.T) { + t.Parallel() + for _, id := range []string{MCPPanelPluginID, "web-search", "browser"} { + id := id + t.Run(id, func(t *testing.T) { + t.Parallel() + srv := newFakeMCP(t, "", "add") + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + ctx := context.Background() + // 模拟该内置提供者在装配期声明的那一页。 + if _, err := hub.Declare(id, []sdk.Panel{samplePanel()}); err != nil { + t.Fatalf("Declare(%s): %v", id, err) + } + installFake(t, mgr, id, srv.URL) + assertBuiltinPageAlive(t, hub, id, "安装") + if err := mgr.SetEnabled(ctx, id, false); err != nil { + t.Fatalf("SetEnabled(false): %v", err) + } + assertBuiltinPageAlive(t, hub, id, "停用") + if err := mgr.SetEnabled(ctx, id, true); err != nil { + t.Fatalf("SetEnabled(true): %v", err) + } + if _, err := mgr.Uninstall(ctx, id); err != nil { + t.Fatalf("Uninstall: %v", err) + } + assertBuiltinPageAlive(t, hub, id, "卸载") + }) + } +} + +// assertBuiltinPageAlive 断言该内置提供者的页仍在目录里。 +func assertBuiltinPageAlive(t *testing.T, hub *Panels, id, step string) { + t.Helper() + entries, err := hub.List(auth.RoleOwner) + if err != nil { + t.Fatalf("List: %v", err) + } + for _, e := range entries { + if e.PluginID == id { + return + } + } + t.Fatalf("%s一台同名 server 之后,内置页 %q 不见了:%+v", step, id, entries) +} + +// TestMCPPanelEnablePersistFailureReportsAccurately 锁定顺手件 2:server **已连上**但 +// 启用态没落盘时,文案必须说的是这件事(「仍处于停用状态」是相反的陈述)。 +// +// 只在本平台跑:靠「把 plugins.json 置成只读」让 rename 失败(Unix 下 rename 由目录权限 +// 决定,同法不成立)。 +func TestMCPPanelEnablePersistFailureReportsAccurately(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("只读位只在 windows 的 rename 上生效") + } + t.Parallel() + srv := newFakeMCP(t, "", "add") + mgr, hub, _ := newPanelManager(t) + defer mgr.Close(context.Background()) + if err := mgr.AttachMCPPanel(); err != nil { + t.Fatalf("AttachMCPPanel: %v", err) + } + ctx := context.Background() + installViaAction(t, hub, ctx, "calc", srv.URL) + if _, err := dispatchRow(t, hub, ActionDisableMCPServer, "calc"); err != nil { + t.Fatalf("disable: %v", err) + } + if err := os.Chmod(mgr.path, 0o444); err != nil { + t.Fatalf("chmod: %v", err) + } + t.Cleanup(func() { _ = os.Chmod(mgr.path, 0o600) }) + + _, err := dispatchRow(t, hub, ActionEnableMCPServer, "calc") + ae, ok := AsActionError(err) + if !ok || ae.Code != mcpCodeEnableFailed || ae.Status != http.StatusBadGateway { + t.Fatalf("启用(落盘失败)= %v,期望 %s/502", err, mcpCodeEnableFailed) + } + // 断言的是**文案与事实一致**(下面两条),不是某个内部哨兵:这句话是给主人看的。 + + if !strings.Contains(ae.Message, "未落盘") || strings.Contains(ae.Message, "仍处于停用状态") { + t.Fatalf("文案与事实不符:%q", ae.Message) + } + // 事实是「已经连上、工具可用」,快照也这么说(state 派生口径不变)。 + if names := toolNames(mgr.reg); !names["add"] { + t.Fatalf("启用后工具 = %v,期望已连上并注册", names) + } + if row := mustRows(t, hub, ctx)[0]; row.State != ServerStateConnected || row.Enabled { + t.Fatalf("快照 = %+v,期望 state=connected 且 enabled=false(盘上仍是停用)", row) + } +}