Files
Cyrene_v2/internal/services/gateway/admin_plugins.go
T
AskaEth 074ffc4ebf
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(gateway): gateway 剩余文件注释改中文(逐行 1:1,代码零字节改动)
主人 2026-10-03 拍板「注释一律中文」;gateway 至此整包注释中文化完成(本会话共 3 批:
已推库 16 个文件 + 本批剩余文件)。
校验:机械校验(非注释改动行)= 空;剥注释后代码文本逐行比对全等(不一致文件数 0);
全树 go test ./... 58 包 ok / 0 FAIL;交叉编译绿。
2026-10-03 22:28:25 +08:00

239 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package gateway
// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时
// 「插件」是一个外部 MCP server:安装即连接它、把它的
// 工具热注册进能力注册表(从而无需重启就注入下一次
// 模型请求),把其配置持久化到被 gitignore 的
// plugins.json,并持有活着的 server 以便之后刷新/卸载
// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 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 被单独
// 保留,使该请求读起来与其他管理写一致。
type pluginInstallRequest struct {
Kind string `json:"kind"`
Name string `json:"name"`
Config pluginInstallConfig `json:"config"`
}
// 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 安装(或刷新)一个 MCP server 插件:它校验
// 请求、连接并发现该 server 的工具,然后持久化配置。
// connect/discover/register 失败会返回 502 且不持久化任何东西,
// 因此插件绝不会处于半安装状态。
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 卸载一个插件。已安装的 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 ""
}