feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- P0-19:POST /admin/plugins/install 支持 `kind:"skill"`(此前一律 400)——yaml 或 path 二选一、
  name 一致性校验、显式错误码(bad_request/name_mismatch/skill_parse_failed/name_conflict)、
  装后以 kind:"skill" 出现在 GET /admin/plugins;**文件来源解析错误绝不回显文件内容**(变异验证实测确认
  该泄露向量真实存在:yaml 解码器会带出出错值前几字符 ⇒ 只回显路径 + 通用原因,细节进日志)
- P0-20:AdminBackthinkItem 补 route/detail/bypassed(DB 早已存,零迁移),面板 Backthink 展示
- P0-22:新增 POST /admin/automations/reminders(owner-only + 审计),at/message 必填、RFC3339 校验、
  409 already_exists;过去时间不作 400(下次轮询立即触发)
- 顺手修真缺陷:新建 id 原用 time.Now().UnixNano(),Windows 时钟粒度粗到毫秒 ⇒ 并行创建撞 id 得假 409;
  改用 crypto/rand 128 位 hex(仓库既有 skills/store.go 范式)
- 复核:gateway 包 build/vet/测试绿 + 前端 tsc 0 错;新增 12 用例(含 8 条非法输入显式码);
  **4 条变异反证**均已回滚
- 未做(登记):WS 帧侧的 route/detail 未在前端展示(`lib/wsParse.ts` 3 行,需放行);skill 未落
  skills.Store(重启后消失,注入点在 cmd/cyrene);提醒无删除入口(CancelReminder 已存在、无路由)
This commit is contained in:
AskaEth committed 2026-10-04 14:21:41 +08:00
1 parent 3638ed7708
commit decf59c1b4
12 files changed
+956 -24

No files matched your search

+41 -22
View File
@@ -1,13 +1,17 @@
package gateway
// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时
// 「插件」是一个外部 MCP server:安装即连接它、把它的
// 工具热注册进能力注册表(从而无需重启就注入下一次
// 模型请求),把其配置持久化到被 gitignore 的
// plugins.json,并持有活着的 server 以便之后刷新/卸载
// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 Go 插件
// 不可能,因此不提供该方式。写操作仅 owner 且带审计;
// API key 会被持久化,但绝不返回,也绝不原样记日志。
// 管理侧插件安装/卸载与已安装插件列表。运行时「插件」按 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"
@@ -36,11 +40,16 @@ type pluginActionRequest struct {
// 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
@@ -75,10 +84,9 @@ type AdminInstalledPlugin struct {
ToolCount int `json:"toolCount"`
}
// adminPluginInstall 安装(或刷新)一个 MCP server 插件:它校验
// 请求、连接并发现该 server 的工具,然后持久化配置。
// connect/discover/register 失败会返回 502 且不持久化任何东西,
// 因此插件绝不会处于半安装状态。
// 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 {
@@ -86,19 +94,30 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
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 {
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", kind, runtimeconfig.PluginKindMCP))
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 {
@@ -108,7 +127,7 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
}
cfg := runtimeconfig.PluginConfig{
Name: name,
Kind: kind,
Kind: runtimeconfig.PluginKindMCP,
Transport: strings.TrimSpace(req.Config.Transport),
Endpoint: firstNonEmpty(req.Config.Endpoint, req.Config.URL),
Command: strings.TrimSpace(req.Config.Command),