Files
Cyrene_v2/internal/services/gateway/admin_plugins_skill.go
T
AskaEth decf59c1b4
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
- 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 已存在、无路由)
2026-10-04 14:21:41 +08:00

201 lines
8.1 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
// 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,M12 交付)。
// 仅 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
}