feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
- capability/plugin/manager.go: Manager(Install/Uninstall/List/Restore/Close); Install=连接+发现+mcp:name注册工具,失败不落盘; holder持有*mcp.Registry; boot Restore重装
- config/runtimeconfig/plugins.go: PluginConfig(Normalize/Validate), LoadPlugins/SavePlugins(0600+tmp+rename)/Upsert/Delete/Masked(apiKey+敏感env掩码)
- mcp: MCPConfig加APIKey/Env, http/sse Bearer应用, stdio子进程env合并; registry.ToolNames()
- gateway: adminPluginInstall/Uninstall/ListInstalledPlugins; Deps.Plugins注入
- cmd/cyrene/plugins.go: wirePlugins(boot Restore+close)
- 契约: body{kind:mcp,name,config{transport, endpoint?/command?,args?,apiKey?,env?}} → {installed,name,kind,transport,toolCount}; 失败502不落盘/非法400/未注入501; 卸载连带unregister+删配置
- 测试(manager/plugins/admin/mcp_auth/组合根), build/vet/test绿+3平台交叉编译
- Docs/api-client v0.28 + api-reference install=501改真实; .gitignore加plugins.json
- 前端api.ts仍发旧body(无config)会400, 面板安装表单待后续
This commit is contained in:
1 parent
53002f1e11
commit
70c9fba529
20 files changed
+1885
-57
No files matched your search
@@ -0,0 +1,181 @@
|
||||
package runtimeconfig
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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 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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user