package gateway // 管理侧插件安装/卸载与已安装插件列表。运行时「插件」按 design/09 // §2.1 有两类,都经同一个 install 端点、由 body 的 `kind` 分派: // // - MCP server(本文件):安装即连接它、把它的工具热注册进能力注册表 // (从而无需重启就注入下一次模型请求),把其配置持久化到被 gitignore // 的 plugins.json,并持有活着的 server 以便之后刷新/卸载。 // - Skill(admin_plugins_skill.go):安装即解析一份工作流 YAML 并 // RegisterSkill 进注册表。 // // 在单静态二进制中安装编译好的 Go 插件不可能,因此不提供该方式。 // 写操作仅 owner 且带审计;API key 会被持久化,但绝不返回, // 也绝不原样记日志。 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 限定安装请求所等待的 connect+discover 握手 // 时长。插件管理器在其中施加自己的每 server 超时。 const pluginInstallTimeout = 20 * time.Second // pluginActionRequest 是 POST /api/v1/admin/plugins/uninstall 的请求体。 type pluginActionRequest struct { Name string `json:"name"` Kind string `json:"kind"` } // pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config // 承载 MCP server 连接字段;顶层的 name/kind 被单独 // 保留,使该请求读起来与其他管理写一致。YAML/Path 是 // kind="skill" 的两种来源(内联内容或磁盘路径,二选一)。 type pluginInstallRequest struct { Kind string `json:"kind"` Name string `json:"name"` Config pluginInstallConfig `json:"config"` // YAML 是 Skill 定义的 YAML 文本(kind="skill")。 YAML string `json:"yaml"` // Path 是 Skill 定义的 YAML 文件路径(kind="skill"),与 YAML 互斥。 Path string `json:"path"` } // pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse // 的 URL;url 作为别名被接受。APIKey/Env 会被持久化,但绝不 // 回显。 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 是管理列表中一个已安装插件的线上 // 结构。API key 与任何敏感 env 值都会脱敏。 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 报告 MCP server 是否已连接、其工具是否已注册。 Live bool `json:"live"` // ToolCount 是活着的 server 已注册的工具数。 ToolCount int `json:"toolCount"` } // adminPluginInstall 安装(或刷新)一个运行时插件。它先按 body 的 // kind 分派(缺省为 mcp),两类的失败语义一致:校验或接入失败即返回 // 400/502/409 且不留下任何半安装状态。 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 } kind := strings.TrimSpace(req.Kind) if kind == "" { kind = runtimeconfig.PluginKindMCP } switch kind { case runtimeconfig.PluginKindMCP: s.adminInstallMCPPlugin(w, r, req) case pluginKindSkill: s.adminInstallSkillPlugin(w, r, req) default: s.auditClaims(r, "plugin.install", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s and %s", kind, runtimeconfig.PluginKindMCP, pluginKindSkill)) } } // adminInstallMCPPlugin 安装(或刷新)一个 MCP server 插件:它校验 // 请求、连接并发现该 server 的工具,然后持久化配置。 // connect/discover/register 失败会返回 502 且不持久化任何东西, // 因此插件绝不会处于半安装状态。 func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) { 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 } 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: runtimeconfig.PluginKindMCP, 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 卸载一个插件。已安装的 MCP server 会被关闭、 // 其工具经插件管理器移除,随后其配置从 plugins.json 删除; // 其他任何名字回退为把该能力从注册表移除, // 保持既有行为。 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 列出运行时安装的插件,附其脱敏配置 // 与每 server 工具数。它是读端点(owner/admin), // 绝不原样返回 API key 或敏感 env 值。 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 把已安装插件映射为其脱敏后的线上结构。 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 返回首个非空值,因此安装请求体可用 endpoint // 或 url 任一字段给出 server 地址。 func firstNonEmpty(values ...string) string { for _, v := range values { if strings.TrimSpace(v) != "" { return strings.TrimSpace(v) } } return "" }