feat(plugin): 插件热安装(MCP server) — POST /admin/plugins/install 连接+发现+注册工具进registry+落盘plugins.json
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- 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:
AskaEth committed 2026-09-08 23:20:12 +08:00
1 parent 53002f1e11
commit 70c9fba529
20 files changed
+1885 -57

No files matched your search

+238
View File
@@ -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 {
+11
View File
@@ -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)))