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,238 @@
|
||||
package gateway
|
||||
|
||||
// Admin plugin install/uninstall and the installed-plugin listing. A runtime
|
||||
// "plugin" in v1 is an external MCP server: install connects it, hot-registers
|
||||
// its tools into the capability registry (so they are injected into the next
|
||||
// model request without a restart), persists its config to the gitignored
|
||||
// plugins.json, and holds the live server for a later refresh/uninstall
|
||||
// (design/09 插件生态 §1). Installing a compiled Go plugin is impossible in a
|
||||
// single static binary, so it is not offered. Writes are owner-only and audited;
|
||||
// an API key is persisted but never returned or logged verbatim.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
// pluginInstallTimeout bounds the connect+discover handshake the install request
|
||||
// waits for. The plugin manager applies its own per-server timeout inside it.
|
||||
const pluginInstallTimeout = 20 * time.Second
|
||||
|
||||
// pluginActionRequest is the POST /api/v1/admin/plugins/uninstall body.
|
||||
type pluginActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// pluginInstallRequest is the POST /api/v1/admin/plugins/install body. Config
|
||||
// carries the MCP server connection fields; the top-level name/kind are kept
|
||||
// separate so the request reads like the other admin writes.
|
||||
type pluginInstallRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Config pluginInstallConfig `json:"config"`
|
||||
}
|
||||
|
||||
// pluginInstallConfig is the MCP server connection block. Endpoint is the URL
|
||||
// for http/sse; url is accepted as an alias. APIKey/Env are persisted but never
|
||||
// echoed back.
|
||||
type pluginInstallConfig struct {
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
URL string `json:"url"`
|
||||
Command string `json:"command"`
|
||||
Args []string `json:"args"`
|
||||
APIKey string `json:"apiKey"`
|
||||
Env map[string]string `json:"env"`
|
||||
}
|
||||
|
||||
// AdminInstalledPlugin is the wire shape of one installed plugin in the admin
|
||||
// list. The API key and any sensitive env values are masked.
|
||||
type AdminInstalledPlugin struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Transport string `json:"transport"`
|
||||
Endpoint string `json:"endpoint,omitempty"`
|
||||
Command string `json:"command,omitempty"`
|
||||
Args []string `json:"args,omitempty"`
|
||||
APIKey string `json:"apiKey,omitempty"`
|
||||
Env map[string]string `json:"env,omitempty"`
|
||||
CreatedAt string `json:"createdAt,omitempty"`
|
||||
// Live reports whether the MCP server is connected and its tools registered.
|
||||
Live bool `json:"live"`
|
||||
// ToolCount is the number of tools the live server registered.
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
|
||||
// adminPluginInstall installs (or refreshes) an MCP server plugin: it validates
|
||||
// the request, connects and discovers the server's tools, then persists the
|
||||
// config. A connect/discover/register failure returns 502 and persists nothing,
|
||||
// so a plugin is never half-installed.
|
||||
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginInstallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
if kind == "" {
|
||||
kind = runtimeconfig.PluginKindMCP
|
||||
}
|
||||
if kind != runtimeconfig.PluginKindMCP {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s", kind, runtimeconfig.PluginKindMCP))
|
||||
return
|
||||
}
|
||||
if s.plugins == nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件服务未注入(未注入 plugin manager)", HTTPCode: http.StatusNotImplemented})
|
||||
return
|
||||
}
|
||||
cfg := runtimeconfig.PluginConfig{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Transport: strings.TrimSpace(req.Config.Transport),
|
||||
Endpoint: firstNonEmpty(req.Config.Endpoint, req.Config.URL),
|
||||
Command: strings.TrimSpace(req.Config.Command),
|
||||
Args: req.Config.Args,
|
||||
APIKey: req.Config.APIKey,
|
||||
Env: req.Config.Env,
|
||||
}.Normalize()
|
||||
if err := cfg.Validate(); err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
|
||||
return
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(r.Context(), pluginInstallTimeout)
|
||||
defer cancel()
|
||||
res, err := s.plugins.Install(ctx, cfg)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
s.log.Error("plugin install failed", "name", name, "transport", cfg.Transport, "error", err)
|
||||
writeError(w, &apperr.AppError{
|
||||
Code: "plugin_install_failed",
|
||||
Message: fmt.Sprintf("连接/发现 MCP server %q 失败,未落盘", name),
|
||||
Cause: err,
|
||||
HTTPCode: http.StatusBadGateway,
|
||||
})
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "plugin.install", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installed": true,
|
||||
"name": res.Config.Name,
|
||||
"kind": res.Config.Kind,
|
||||
"transport": res.Config.Transport,
|
||||
"toolCount": res.ToolCount,
|
||||
})
|
||||
}
|
||||
|
||||
// adminPluginUninstall uninstalls a plugin. An installed MCP server is closed and
|
||||
// its tools removed through the plugin manager, then its config is deleted from
|
||||
// plugins.json; any other name falls back to removing the capability from the
|
||||
// registry, preserving the pre-existing behavior.
|
||||
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
if s.plugins != nil {
|
||||
removed, err := s.plugins.Uninstall(r.Context(), name)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
s.log.Error("plugin uninstall failed", "name", name, "error", err)
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||||
return
|
||||
}
|
||||
if removed {
|
||||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true, "kind": runtimeconfig.PluginKindMCP})
|
||||
return
|
||||
}
|
||||
}
|
||||
reg, ok := s.caps.(*capreg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||||
return
|
||||
}
|
||||
if err := reg.Remove(name); err != nil {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
if errors.Is(err, capreg.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", name))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": name, "uninstalled": true})
|
||||
}
|
||||
|
||||
// adminListInstalledPlugins lists the runtime-installed plugins with their
|
||||
// masked config and per-server tool count. It is a read endpoint (owner/admin)
|
||||
// and never returns an API key or a sensitive env value verbatim.
|
||||
func (s *Server) adminListInstalledPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
out := make([]AdminInstalledPlugin, 0)
|
||||
if s.plugins != nil {
|
||||
for _, it := range s.plugins.List() {
|
||||
out = append(out, adminInstalledPluginOf(it))
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminInstalledPluginOf maps an installed plugin to its masked wire shape.
|
||||
func adminInstalledPluginOf(it pluginmgr.Installed) AdminInstalledPlugin {
|
||||
cfg := runtimeconfig.MaskedPlugin(it.Config)
|
||||
return AdminInstalledPlugin{
|
||||
ID: cfg.ID,
|
||||
Name: cfg.Name,
|
||||
Kind: cfg.Kind,
|
||||
Transport: cfg.Transport,
|
||||
Endpoint: cfg.Endpoint,
|
||||
Command: cfg.Command,
|
||||
Args: cfg.Args,
|
||||
APIKey: cfg.APIKey,
|
||||
Env: cfg.Env,
|
||||
CreatedAt: cfg.CreatedAt,
|
||||
Live: it.Live,
|
||||
ToolCount: it.ToolCount,
|
||||
}
|
||||
}
|
||||
|
||||
// firstNonEmpty returns the first non-blank value, so an install body may send
|
||||
// either endpoint or url for the server address.
|
||||
func firstNonEmpty(values ...string) string {
|
||||
for _, v := range values {
|
||||
if strings.TrimSpace(v) != "" {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,301 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config/runtimeconfig"
|
||||
)
|
||||
|
||||
// fakeMCPHandler is a minimal Streamable HTTP MCP server for the plugin tests.
|
||||
// wantAuth, when set, requires the bearer header; tools is mutated atomically so
|
||||
// a refresh test can change the tool list.
|
||||
type fakeMCPHandler struct {
|
||||
wantAuth string
|
||||
tools []string
|
||||
}
|
||||
|
||||
func (f *fakeMCPHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodGet {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if fl, ok := w.(http.Flusher); ok {
|
||||
fl.Flush()
|
||||
}
|
||||
<-r.Context().Done()
|
||||
return
|
||||
}
|
||||
if f.wantAuth != "" && r.Header.Get("Authorization") != "Bearer "+f.wantAuth {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var req struct {
|
||||
ID json.RawMessage `json:"id"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
_ = json.Unmarshal(body, &req)
|
||||
if len(req.ID) == 0 || string(req.ID) == "null" {
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
return
|
||||
}
|
||||
var result any
|
||||
switch req.Method {
|
||||
case "initialize":
|
||||
result = map[string]any{"protocolVersion": "2025-11-25", "capabilities": map[string]any{}, "serverInfo": map[string]any{"name": "fake", "version": "1.0"}}
|
||||
case "tools/list":
|
||||
list := make([]map[string]any, 0, len(f.tools))
|
||||
for _, name := range f.tools {
|
||||
list = append(list, map[string]any{"name": name, "description": name, "inputSchema": map[string]any{"type": "object"}})
|
||||
}
|
||||
result = map[string]any{"tools": list}
|
||||
default:
|
||||
result = map[string]any{}
|
||||
}
|
||||
resp, _ := json.Marshal(map[string]any{"jsonrpc": "2.0", "id": json.RawMessage(req.ID), "result": result})
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write(resp)
|
||||
}
|
||||
|
||||
// pluginTestEnv is a gateway wired with a real plugin manager over a temp
|
||||
// plugins.json.
|
||||
type pluginTestEnv struct {
|
||||
ts *httptest.Server
|
||||
caps *capreg.Registry
|
||||
path string
|
||||
token string
|
||||
mcpURL string
|
||||
}
|
||||
|
||||
func newPluginTestEnv(t *testing.T, mcp *fakeMCPHandler) *pluginTestEnv {
|
||||
t.Helper()
|
||||
mcpURL := ""
|
||||
if mcp != nil {
|
||||
ts := httptest.NewServer(mcp)
|
||||
t.Cleanup(ts.Close)
|
||||
mcpURL = ts.URL
|
||||
}
|
||||
jwt := auth.New(config.AuthConfig{JWTSecret: testSecret})
|
||||
caps := capreg.New()
|
||||
path := filepath.Join(t.TempDir(), "plugins.json")
|
||||
mgr := pluginmgr.NewManager(caps, path, nil)
|
||||
cfg := &config.Config{Auth: config.AuthConfig{AdminUser: "admin", AdminPass: "secret", JWTSecret: testSecret}}
|
||||
srv, err := New(cfg, Deps{Auth: jwt, Bus: bus.New(), Registry: caps, Plugins: mgr})
|
||||
if err != nil {
|
||||
t.Fatalf("new server: %v", err)
|
||||
}
|
||||
ts := httptest.NewServer(srv.Handler())
|
||||
t.Cleanup(func() {
|
||||
ts.Close()
|
||||
mgr.Close(context.Background())
|
||||
})
|
||||
tok, err := jwt.Sign("acct", auth.RoleOwner, time.Hour)
|
||||
if err != nil {
|
||||
t.Fatalf("sign: %v", err)
|
||||
}
|
||||
return &pluginTestEnv{ts: ts, caps: caps, path: path, token: tok, mcpURL: mcpURL}
|
||||
}
|
||||
|
||||
// newFakeMCPHandler returns a fake server plus a setter for its tool list.
|
||||
func newFakeMCPHandler(wantAuth string, tools ...string) *fakeMCPHandler {
|
||||
return &fakeMCPHandler{wantAuth: wantAuth, tools: tools}
|
||||
}
|
||||
|
||||
func installedTools(reg *capreg.Registry) map[string]bool {
|
||||
out := make(map[string]bool)
|
||||
for _, c := range reg.List(capreg.KindTool) {
|
||||
out[c.Name] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestAdminPluginInstall_RegistersToolsPersistsAndMasks(t *testing.T) {
|
||||
t.Parallel()
|
||||
mcp := newFakeMCPHandler("sk-1234", "add", "mul")
|
||||
env := newPluginTestEnv(t, mcp)
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
||||
"kind": "mcp",
|
||||
"name": "calc",
|
||||
"config": map[string]any{
|
||||
"transport": "http",
|
||||
"endpoint": env.mcpURL,
|
||||
"apiKey": "sk-1234",
|
||||
},
|
||||
}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Installed bool `json:"installed"`
|
||||
Name string `json:"name"`
|
||||
Transport string `json:"transport"`
|
||||
ToolCount int `json:"toolCount"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if !envelope.Data.Installed || envelope.Data.ToolCount != 2 || envelope.Data.Transport != "http" {
|
||||
t.Fatalf("install data = %+v", envelope.Data)
|
||||
}
|
||||
names := installedTools(env.caps)
|
||||
if !names["add"] || !names["mul"] {
|
||||
t.Fatalf("registered tools = %v", names)
|
||||
}
|
||||
if _, err := env.caps.Get("mcp:calc"); err != nil {
|
||||
t.Fatalf("mcp:calc capability: %v", err)
|
||||
}
|
||||
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
||||
if err != nil || len(persisted) != 1 || persisted[0].APIKey != "sk-1234" {
|
||||
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
||||
}
|
||||
|
||||
resp, raw = doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins/installed", nil, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("list status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if strings.Contains(string(raw), "sk-1234") {
|
||||
t.Fatalf("installed list leaks the API key: %s", raw)
|
||||
}
|
||||
var listEnv struct {
|
||||
Data []map[string]any `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &listEnv); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, raw)
|
||||
}
|
||||
if len(listEnv.Data) != 1 {
|
||||
t.Fatalf("list = %+v, want one entry", listEnv.Data)
|
||||
}
|
||||
if got := listEnv.Data[0]["apiKey"]; got != "****1234" {
|
||||
t.Fatalf("masked apiKey = %v, want ****1234", got)
|
||||
}
|
||||
if got := listEnv.Data[0]["live"]; got != true {
|
||||
t.Fatalf("live = %v, want true", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginUninstall_RemovesToolsAndConfig(t *testing.T) {
|
||||
t.Parallel()
|
||||
mcp := newFakeMCPHandler("", "add")
|
||||
env := newPluginTestEnv(t, mcp)
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
||||
"name": "calc",
|
||||
"config": map[string]any{"endpoint": env.mcpURL},
|
||||
}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("install status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
|
||||
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "calc"}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
Uninstalled bool `json:"uninstalled"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if !envelope.Data.Uninstalled || envelope.Data.Kind != runtimeconfig.PluginKindMCP {
|
||||
t.Fatalf("uninstall data = %+v", envelope.Data)
|
||||
}
|
||||
if names := installedTools(env.caps); len(names) != 0 {
|
||||
t.Fatalf("tools after uninstall = %v", names)
|
||||
}
|
||||
if _, err := env.caps.Get("mcp:calc"); err == nil {
|
||||
t.Fatal("mcp:calc capability still registered")
|
||||
}
|
||||
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
||||
if err != nil || len(persisted) != 0 {
|
||||
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginInstall_ConnectFailureReturns502NoPersist(t *testing.T) {
|
||||
t.Parallel()
|
||||
dead := httptest.NewServer(http.NotFoundHandler())
|
||||
url := dead.URL
|
||||
dead.Close()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", map[string]any{
|
||||
"name": "down",
|
||||
"config": map[string]any{"endpoint": url},
|
||||
}, env.token)
|
||||
if resp.StatusCode != http.StatusBadGateway {
|
||||
t.Fatalf("status = %d, want 502, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if names := installedTools(env.caps); len(names) != 0 {
|
||||
t.Fatalf("tools = %v, want none", names)
|
||||
}
|
||||
persisted, err := runtimeconfig.LoadPlugins(env.path)
|
||||
if err != nil || len(persisted) != 0 {
|
||||
t.Fatalf("persisted = %+v, err = %v", persisted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginUninstall_FallsBackToCapabilityRemoval(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
if err := env.caps.RegisterTool(stubTool{}); err != nil {
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "echo"}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if _, err := env.caps.Get("echo"); err == nil {
|
||||
t.Fatal("echo capability still registered")
|
||||
}
|
||||
resp, _ = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "nope"}, env.token)
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Fatalf("unknown status = %d, want 404", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminPluginInstall_InvalidRequestsReturn400(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, newFakeMCPHandler(""))
|
||||
cases := []struct {
|
||||
name string
|
||||
body any
|
||||
raw string
|
||||
}{
|
||||
{name: "no name", body: map[string]any{"config": map[string]any{"command": "x"}}},
|
||||
{name: "bad kind", body: map[string]any{"kind": "tool", "name": "x", "config": map[string]any{"command": "x"}}},
|
||||
{name: "no transport source", body: map[string]any{"name": "x", "config": map[string]any{}}},
|
||||
{name: "stdio no command", body: map[string]any{"name": "x", "config": map[string]any{"transport": "stdio"}}},
|
||||
{name: "bad transport", body: map[string]any{"name": "x", "config": map[string]any{"transport": "pigeon", "endpoint": "http://x"}}},
|
||||
{name: "malformed json", raw: "{"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
var resp *http.Response
|
||||
var raw []byte
|
||||
if tc.raw != "" {
|
||||
resp, raw = doRaw(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", tc.raw, env.token)
|
||||
} else {
|
||||
resp, raw = doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", tc.body, env.token)
|
||||
}
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -3,66 +3,17 @@ package gateway
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
||||
)
|
||||
|
||||
// Plugin uninstall, memory retrieval and session export admin writes
|
||||
// (owner-only, audited). Uninstall removes a capability from the registry; memory
|
||||
// search runs a real retrieval; session export returns the session metadata.
|
||||
|
||||
// pluginActionRequest is the POST /api/v1/admin/plugins/install|uninstall body.
|
||||
type pluginActionRequest struct {
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// adminPluginInstall answers honestly: installing a plugin needs a real plugin
|
||||
// source (compiled binary or skill YAML), which the admin API cannot supply. It
|
||||
// returns a clear 501 rather than fabricating a registered plugin.
|
||||
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, &apperr.AppError{Code: "not_implemented", Message: "插件安装需插件源码清单(编译二进制/YAML)为 M10 提供;卸载可直接调用 registry", HTTPCode: http.StatusNotImplemented})
|
||||
}
|
||||
|
||||
// adminPluginUninstall removes a registered capability from the registry — a real
|
||||
// effect that stops the plugin being enumerated or dispatched.
|
||||
func (s *Server) adminPluginUninstall(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginActionRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Name) == "" {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
reg, ok := s.caps.(*capreg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||||
return
|
||||
}
|
||||
if err := reg.Remove(req.Name); err != nil {
|
||||
s.auditClaims(r, "plugin.uninstall", "error")
|
||||
if errors.Is(err, capreg.ErrNotFound) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrNotFound, "not_found", "plugin %q not found", req.Name))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_remove_failed", "failed to uninstall plugin"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "plugin.uninstall", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"name": req.Name, "uninstalled": true})
|
||||
}
|
||||
// Memory retrieval and session export admin writes (owner-only, audited). Memory
|
||||
// search runs a real retrieval; session export returns the session metadata. The
|
||||
// plugin install/uninstall writes live in admin_plugins.go.
|
||||
|
||||
// memorySearchRequest is the POST /api/v1/admin/memories/search body.
|
||||
type memorySearchRequest struct {
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
pluginmgr "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/plugin"
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/session"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/config"
|
||||
@@ -293,6 +294,11 @@ type Deps struct {
|
||||
// Optional: when nil the admin /channels/config endpoints return a clear
|
||||
// not-configured error, matching how the LLM stack behaves when unset.
|
||||
Channels ChannelConfigStore
|
||||
// Plugins is the runtime-installable external tool provider manager
|
||||
// (plugins.json + live MCP servers). Optional: when nil install answers a
|
||||
// clear not-implemented error and uninstall falls back to a plain capability
|
||||
// removal, so pre-existing gateway tests keep working.
|
||||
Plugins *pluginmgr.Manager
|
||||
// OwnerRegistrar stamps a scan-login's bound owner id into the owner
|
||||
// whitelist (configs/users/owner.yaml) and hot-updates the live profile.
|
||||
// Optional: when nil the weixin confirm flow still persists channels.json but
|
||||
@@ -364,6 +370,9 @@ type Server struct {
|
||||
// that do not wire it keep working.
|
||||
llm *model.RuntimeLLM
|
||||
channels ChannelConfigStore
|
||||
// plugins is the runtime-installable external tool provider manager. See
|
||||
// Deps.Plugins.
|
||||
plugins *pluginmgr.Manager
|
||||
// ownerRegistrar stamps a scan-login bound owner id into the owner whitelist
|
||||
// (best-effort). See Deps.OwnerRegistrar.
|
||||
ownerRegistrar OwnerRegistrar
|
||||
@@ -405,6 +414,7 @@ func New(cfg *config.Config, deps Deps) (*Server, error) {
|
||||
frontend: deps.Frontend,
|
||||
llm: deps.LLM,
|
||||
channels: deps.Channels,
|
||||
plugins: deps.Plugins,
|
||||
ownerRegistrar: deps.OwnerRegistrar,
|
||||
webhooks: deps.Webhooks,
|
||||
dbPath: deps.DatabasePath,
|
||||
@@ -472,6 +482,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("GET /api/v1/admin/sessions", adminAuth(http.HandlerFunc(s.adminListSessions)))
|
||||
mux.Handle("GET /api/v1/admin/memories", adminAuth(http.HandlerFunc(s.adminListMemories)))
|
||||
mux.Handle("GET /api/v1/admin/plugins", adminAuth(http.HandlerFunc(s.adminListPlugins)))
|
||||
mux.Handle("GET /api/v1/admin/plugins/installed", adminAuth(http.HandlerFunc(s.adminListInstalledPlugins)))
|
||||
mux.Handle("GET /api/v1/admin/channels", adminAuth(http.HandlerFunc(s.adminListChannels)))
|
||||
mux.Handle("GET /api/v1/admin/channels/messages", adminAuth(http.HandlerFunc(s.handleChannelMessages)))
|
||||
mux.Handle("GET /api/v1/admin/channels/{platform}/messages", adminAuth(http.HandlerFunc(s.handleChannelPlatformLog)))
|
||||
|
||||
Reference in new issue
Block a user