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

@@ -0,0 +1,200 @@
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
}