package gateway // Skill 运行时安装(design/09 §2.1):POST /api/v1/admin/plugins/install 的 // kind="skill" 分支。单二进制红线不允许热装编译好的 Go 程序,因此「安装 // Skill」= 解析一份 Skill 工作流 YAML(内联内容或磁盘路径)并 // RegisterSkill 进能力注册表。效果与 MCP 安装同范式:注册即刻可见 // (GET /api/v1/admin/plugins 按 kind="skill" 列出、下一轮模型请求即可枚举), // 无需重启。执行侧**已实现**(`Runner.Run` 真执行;哨兵 `ErrRunPending` 已删除)。 // 仅 owner 且带审计;解析失败 400 显式作答,绝不产生半安装状态。 import ( "errors" "fmt" "io" "net/http" "os" "strings" capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills" apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" ) const ( // pluginKindSkill 是 install 端点接受的第二类插件(design/09 §0/§2.1)。 pluginKindSkill = "skill" // maxSkillYAMLBytes 限制内联 YAML 与磁盘文件的大小,避免把任意巨型 // 文件读进内存。Skill 定义是提示词 + 步骤表,1 MiB 已足够宽裕。 maxSkillYAMLBytes = 1 << 20 ) // skillSource 是 Skill 定义的两种来源之一:内联 YAML 文本,或磁盘上的 // YAML 文件路径。fromFile 决定解析失败时能否回显 YAML 解码器的原始 // 错误(它可能带出被解析内容的前几个字符,见 adminInstallSkillPlugin)。 type skillSource struct { text string path string fromFile bool } // describe 返回审计/日志用的来源标签,绝不包含 YAML 内容。 func (s skillSource) describe() string { if s.fromFile { return "file:" + s.path } return "inline" } // adminInstallSkillPlugin 安装(或刷新)一个 Skill:校验来源 → // 解析 YAML → 注册进能力注册表。同名已注册的 *Skill* 视为刷新 // (与 MCP 安装「重复安装同名 = 刷新」同语义);同名已被其他 kind // 的能力占用则 409 显式失败,绝不静默顶掉别人。 func (s *Server) adminInstallSkillPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) { src, err := skillSourceOf(req) if err != nil { s.auditClaims(r, "plugin.install", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err)) return } skill, err := s.parseSkill(src) if err != nil { s.auditClaims(r, "plugin.install", "error") s.log.Error("skill install parse failed", "source", src.describe(), "error", err) if src.fromFile { // 内容由服务端从磁盘读出:YAML 解码错误会带出内容片段(实测 // `cannot unmarshal !!str \`LEAKMAR...\`` 会泄露前 7 个字符), // 因此绝不回显给客户端(完整原因在后端日志里)。路径用 %s 而非 // %q:Windows 路径经 %q 会变成双反斜杠,回显给面板很难读。 writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill 文件「%s」解析失败,详细原因见后端日志", src.path)) return } writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill YAML 解析失败:%v", err)) return } if name := strings.TrimSpace(req.Name); name != "" && name != skill.Name() { s.auditClaims(r, "plugin.install", "error") writeError(w, apperr.Wrap(apperr.ErrBadRequest, "name_mismatch", "name %q 与 YAML 中的 skill 名 %q 不一致", name, skill.Name())) return } reg, ok := s.caps.(*capreg.Registry) if !ok { s.auditClaims(r, "plugin.install", "error") writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured")) return } refreshed, err := registerSkillForInstall(reg, skill) if err != nil { s.auditClaims(r, "plugin.install", "error") s.log.Error("skill install register failed", "skill", skill.Name(), "error", err) var conflict *skillNameConflict if errors.As(err, &conflict) { writeError(w, apperr.Wrap(apperr.ErrConflict, "name_conflict", "%v", conflict)) return } writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_install_failed", "skill %q 注册失败", skill.Name())) return } s.auditClaims(r, "plugin.install", "ok") s.log.Info("skill installed", "skill", skill.Name(), "source", src.describe(), "refreshed", refreshed, "steps", len(skill.Steps())) writeJSON(w, http.StatusOK, map[string]any{ "installed": true, "name": skill.Name(), "kind": pluginKindSkill, "refreshed": refreshed, "steps": len(skill.Steps()), "guardrails": len(skill.Guardrails()), }) } // skillNameConflict 表示要安装的 skill 名已被另一类能力占用(409 显式 // 作答,绝不静默替换)。它是错误值,使 handler 能把「名字冲突」与 // 「注册表故障」分开映射到 409 与 500。 type skillNameConflict struct { name string occupiedBy string occupiedK string } // Error 实现 error。 func (c *skillNameConflict) Error() string { return fmt.Sprintf("能力名 %q 已被 kind=%s 的能力(%s)占用,不能用作 skill 名;请换名或先卸载该能力", c.name, c.occupiedK, c.occupiedBy) } // registerSkillForInstall 把 skill 注册进 reg 并报告是否发生了刷新: // - 名字空闲 → 直接注册; // - 名字已被**同类**(KindSkill)占用 → 先移除再注册(MCP 安装的 // 「重复安装同名 = 刷新」同语义;注册表已落盘的策略标记会被重新套上); // - 名字被**其他 kind** 占用 → 返回 *skillNameConflict,绝不删除别人。 func registerSkillForInstall(reg *capreg.Registry, skill *skills.Skill) (bool, error) { existing, err := reg.Get(skill.Name()) switch { case err == nil && existing.Kind != capreg.KindSkill: return false, &skillNameConflict{name: skill.Name(), occupiedBy: existing.Name, occupiedK: string(existing.Kind)} case err == nil: if rerr := reg.Remove(existing.Name); rerr != nil { return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr) } if rerr := skills.Register(reg, skill); rerr != nil { return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr) } return true, nil case errors.Is(err, capreg.ErrNotFound): if rerr := skills.Register(reg, skill); rerr != nil { return false, fmt.Errorf("gateway: register skill %q: %w", skill.Name(), rerr) } return false, nil default: return false, fmt.Errorf("gateway: lookup skill %q: %w", skill.Name(), err) } } // skillSourceOf 从安装请求体取出 Skill 来源,强制 YAML 与 Path 二选一 // (都空或都给都是显式 400 —— 后者会让人无法判断到底装的是哪一份)。 func skillSourceOf(req pluginInstallRequest) (skillSource, error) { text := strings.TrimSpace(req.YAML) path := strings.TrimSpace(req.Path) switch { case text != "" && path != "": return skillSource{}, errors.New("yaml 与 path 互斥,只能给其中一个") case path != "": return skillSource{path: path, fromFile: true}, nil case text != "": return skillSource{text: text}, nil default: return skillSource{}, errors.New("kind=\"skill\" 需要 yaml(YAML 内容)或 path(YAML 文件路径)") } } // parseSkill 用 skills.Loader 把来源解析为一个 Skill。文件来源有大小上限, // 且只接受普通文件(目录/设备文件不是 Skill 定义)。 func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) { loader := skills.NewLoader(s.log) if !src.fromFile { skill, err := loader.Load(strings.NewReader(src.text)) if err != nil { return nil, fmt.Errorf("skill yaml: %w", err) } return skill, nil } f, err := os.Open(src.path) if err != nil { return nil, fmt.Errorf("open %q: %w", src.path, err) } defer f.Close() info, err := f.Stat() if err != nil { return nil, fmt.Errorf("stat %q: %w", src.path, err) } if !info.Mode().IsRegular() { return nil, fmt.Errorf("%q 不是普通文件", src.path) } if info.Size() > maxSkillYAMLBytes { return nil, fmt.Errorf("%q 超过 %d 字节上限", src.path, maxSkillYAMLBytes) } skill, err := loader.Load(io.LimitReader(f, maxSkillYAMLBytes)) if err != nil { return nil, fmt.Errorf("skill file %q: %w", src.path, err) } return skill, nil }