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。
250 lines
7.6 KiB
Go
250 lines
7.6 KiB
Go
package runtimeconfig
|
||
|
||
import (
|
||
"encoding/json"
|
||
"errors"
|
||
"os"
|
||
"path/filepath"
|
||
"runtime"
|
||
"strings"
|
||
"testing"
|
||
)
|
||
|
||
func TestLoadPlugins_MissingFileIsEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
got, err := LoadPlugins(filepath.Join(t.TempDir(), "plugins.json"))
|
||
if err != nil {
|
||
t.Fatalf("LoadPlugins: %v", err)
|
||
}
|
||
if len(got) != 0 {
|
||
t.Fatalf("len = %d, want 0", len(got))
|
||
}
|
||
}
|
||
|
||
func TestSaveLoadPlugins_RoundTrip(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "nested", "plugins.json")
|
||
in := []PluginConfig{{
|
||
ID: "p1",
|
||
Name: "calc",
|
||
Kind: PluginKindMCP,
|
||
Transport: TransportHTTP,
|
||
Endpoint: "http://127.0.0.1:3001/mcp",
|
||
APIKey: "sk-secret-1234",
|
||
CreatedAt: "2026-09-08T00:00:00Z",
|
||
}}
|
||
if err := SavePlugins(path, in); err != nil {
|
||
t.Fatalf("SavePlugins: %v", err)
|
||
}
|
||
info, err := os.Stat(path)
|
||
if err != nil {
|
||
t.Fatalf("stat: %v", err)
|
||
}
|
||
if runtime.GOOS != "windows" {
|
||
// Windows has no POSIX permission bits for regular files: Chmod only
|
||
// toggles the read-only flag and Stat reports 0666 regardless of the
|
||
// requested 0600, so this assertion is only meaningful on Unix-like
|
||
// platforms. SavePlugins still requests 0600 (see plugins.go).
|
||
if perm := info.Mode().Perm(); perm != 0o600 {
|
||
t.Fatalf("mode = %o, want 600", perm)
|
||
}
|
||
}
|
||
got, err := LoadPlugins(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadPlugins: %v", err)
|
||
}
|
||
if len(got) != 1 || got[0].Name != "calc" || got[0].APIKey != "sk-secret-1234" {
|
||
t.Fatalf("got = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestLoadPlugins_EmptyArrayIsValid(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||
if err := os.WriteFile(path, []byte("[]"), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
got, err := LoadPlugins(path)
|
||
if err != nil {
|
||
t.Fatalf("LoadPlugins: %v", err)
|
||
}
|
||
if len(got) != 0 {
|
||
t.Fatalf("len = %d, want 0", len(got))
|
||
}
|
||
}
|
||
|
||
func TestLoadPlugins_InvalidReturnsError(t *testing.T) {
|
||
t.Parallel()
|
||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||
if err := os.WriteFile(path, []byte(`[{"name":""}]`), 0o600); err != nil {
|
||
t.Fatalf("write: %v", err)
|
||
}
|
||
if _, err := LoadPlugins(path); !errors.Is(err, ErrInvalidPlugin) {
|
||
t.Fatalf("err = %v, want ErrInvalidPlugin", err)
|
||
}
|
||
}
|
||
|
||
func TestValidatePlugin(t *testing.T) {
|
||
t.Parallel()
|
||
cases := []struct {
|
||
name string
|
||
cfg PluginConfig
|
||
wantErr bool
|
||
}{
|
||
{name: "stdio ok", cfg: PluginConfig{Name: "a", Command: "mcp-a"}},
|
||
{name: "http ok", cfg: PluginConfig{Name: "b", Endpoint: "http://x/mcp"}},
|
||
{name: "sse ok", cfg: PluginConfig{Name: "c", Transport: TransportSSE, Endpoint: "http://x/sse"}},
|
||
{name: "blank name", cfg: PluginConfig{Command: "x"}, wantErr: true},
|
||
{name: "bad kind", cfg: PluginConfig{Name: "d", Kind: "tool", Command: "x"}, wantErr: true},
|
||
{name: "stdio no command", cfg: PluginConfig{Name: "e", Transport: TransportStdio}, wantErr: true},
|
||
{name: "http no endpoint", cfg: PluginConfig{Name: "f", Transport: TransportHTTP}, wantErr: true},
|
||
{name: "bad transport", cfg: PluginConfig{Name: "g", Transport: "carrier-pigeon", Endpoint: "http://x"}, wantErr: true},
|
||
{name: "no transport no fields", cfg: PluginConfig{Name: "h"}, wantErr: true},
|
||
}
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
t.Parallel()
|
||
err := tc.cfg.Validate()
|
||
if tc.wantErr && err == nil {
|
||
t.Fatal("want error, got nil")
|
||
}
|
||
if !tc.wantErr && err != nil {
|
||
t.Fatalf("unexpected error: %v", err)
|
||
}
|
||
})
|
||
}
|
||
}
|
||
|
||
func TestUpsertPlugin_ByNameReplacesAndPreservesOrder(t *testing.T) {
|
||
t.Parallel()
|
||
base := []PluginConfig{
|
||
{ID: "1", Name: "a", Command: "a"},
|
||
{ID: "2", Name: "b", Command: "b"},
|
||
}
|
||
got := UpsertPlugin(base, PluginConfig{Name: "b", Command: "b2"})
|
||
if len(got) != 2 {
|
||
t.Fatalf("len = %d, want 2", len(got))
|
||
}
|
||
if got[1].Name != "b" || got[1].Command != "b2" {
|
||
t.Fatalf("got[1] = %+v", got[1])
|
||
}
|
||
got = UpsertPlugin(got, PluginConfig{Name: "c", Command: "c"})
|
||
if len(got) != 3 || got[2].Name != "c" || got[2].ID == "" {
|
||
t.Fatalf("append = %+v", got)
|
||
}
|
||
}
|
||
|
||
func TestDeletePlugin(t *testing.T) {
|
||
t.Parallel()
|
||
base := []PluginConfig{{ID: "1", Name: "a", Command: "a"}, {ID: "2", Name: "b", Command: "b"}}
|
||
got, removed := DeletePlugin(base, "a")
|
||
if !removed || len(got) != 1 || got[0].Name != "b" {
|
||
t.Fatalf("removed=%v got=%+v", removed, got)
|
||
}
|
||
got, removed = DeletePlugin(base, "2")
|
||
if !removed || len(got) != 1 || got[0].Name != "a" {
|
||
t.Fatalf("by id: removed=%v got=%+v", removed, got)
|
||
}
|
||
if _, removed := DeletePlugin(base, "nope"); removed {
|
||
t.Fatal("removed = true for unknown name")
|
||
}
|
||
}
|
||
|
||
func TestMaskedPlugin_EmptySecretsStayEmpty(t *testing.T) {
|
||
t.Parallel()
|
||
got := MaskedPlugin(PluginConfig{Name: "calc", Env: map[string]string{"API_KEY": ""}})
|
||
if got.APIKey != "" {
|
||
t.Fatalf("apiKey = %q, want empty", got.APIKey)
|
||
}
|
||
if got.Env["API_KEY"] != "" {
|
||
t.Fatalf("empty env secret = %q, want empty", got.Env["API_KEY"])
|
||
}
|
||
}
|
||
|
||
func TestMaskedPlugin_RedactsKeyAndEnv(t *testing.T) {
|
||
t.Parallel()
|
||
p := PluginConfig{
|
||
Name: "calc",
|
||
APIKey: "sk-secret-1234",
|
||
Env: map[string]string{"PATH": "/usr/bin", "GITHUB_TOKEN": "ghp-abcdefgh"},
|
||
}
|
||
got := MaskedPlugin(p)
|
||
if got.APIKey != "****1234" {
|
||
t.Fatalf("apiKey = %q, want ****1234", got.APIKey)
|
||
}
|
||
if got.Env["PATH"] != "/usr/bin" {
|
||
t.Fatalf("PATH = %q, want unchanged", got.Env["PATH"])
|
||
}
|
||
if got.Env["GITHUB_TOKEN"] != "****efgh" {
|
||
t.Fatalf("GITHUB_TOKEN = %q, want masked", got.Env["GITHUB_TOKEN"])
|
||
}
|
||
if p.APIKey != "sk-secret-1234" {
|
||
t.Fatal("original mutated")
|
||
}
|
||
raw, _ := json.Marshal(got)
|
||
if strings.Contains(string(raw), "sk-secret-1234") || strings.Contains(string(raw), "ghp-abcdefgh") {
|
||
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)
|
||
}
|
||
}
|
||
}
|