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。
224 lines
8.4 KiB
Go
224 lines
8.4 KiB
Go
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 }
|