三处遗留(M37/M38/M39)同落 internal/services/gateway:
① adminAddMemory 显式传 RetentionTier=long(主人 2026-10-26 裁定「人工动作
默认 long」)。此前不传档位,面板典型输入(短寒暄/一次性祈使/时效查询/句首
时点/进行中)会被 resolveRetention 判 temp 并写 7 天有效期 —— 主人在面板上
亲手加的记忆会静默消失。只钉这一条入口,Store.Add 默认仍由规则判定
(自动抽取路径不放宽)。
② adminInstallSkillPlugin 解析失败不再打 err.Error():新建
admin_plugins_skill_error.go,把失败折叠成「类别(yaml_syntax /
yaml_type_mismatch / skill_invalid / skill_source)+ 行列号」,日志与 400
响应用同一份摘要。解码器会把出错值前 7 个字符嵌进错误串(M38 #17),
原样日志等于把上传内容写进 viewer 可读的日志面。
③ 读侧脱敏走既有 helper,不另写一套:
- adminLogOf 的 msg 过 trace.RedactText(与写侧同一函数);
- adminSpanOf 先过 trace.SanitizeRecord(REST 尾部与 observability_trace
推送帧在同一处收口)。⚠️ 有意改变 admin 响应内容:props 变白名单投影 +
omitted 摘要;字段名与帧形状不变。行数/级别/时间戳/来源、span 的
status/耗时/时间/名字一律保住。
测试(每条都验过「删掉实现 ⇒ FAIL」):
- 面板加记忆 ⇒ 落库 (retention_tier='long', expires_at='');对照组证明同一句
内容走 Store.Add 仍判 temp;
- 非法 YAML 含哨兵 ⇒ 响应与日志均无哨兵、都有类别与 line 2(内联/文件/语法/
来源四类);
- 日志页 msg 含 sk- 形状凭据 ⇒ [redacted],level/source/time 与非敏感正文保留;
- trace 响应 props 正文 ⇒ omitted Digest、args_summary 打码,排障字段原样。
验收:go build ./... / go vet ./... 全绿;go test -count=1 -race
./internal/services/gateway/... 全绿。未 push(按任务口径)。
212 lines
8.8 KiB
Go
212 lines
8.8 KiB
Go
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 只影响「失败时给面板的那句话怎么写」(文件来源额外
|
||
// 报出路径)——**两个来源都绝不回显解码器原文**(它会带出被解析内容的前几个
|
||
// 字符,见 admin_plugins_skill_error.go)。
|
||
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")
|
||
// 🔴 日志只留「类别 + 行列」(见 admin_plugins_skill_error.go):YAML 解码器
|
||
// 会把出错值的前几个字符嵌进 err.Error(),原样打日志 = 把上传内容写进
|
||
// 日志面(M38 #17)。位置足够定位,类别足够分流。
|
||
failure := skillParseFailureOf(err)
|
||
s.log.Error("skill install parse failed",
|
||
append([]any{"source", src.describe()}, failure.logArgs()...)...)
|
||
if src.fromFile {
|
||
// 内容由服务端从磁盘读出,因此连「类别 + 行号」都只给后端日志,
|
||
// 面板只拿到路径 + 同一份安全摘要。路径用 %s 而非 %q:Windows 路径经
|
||
// %q 会变成双反斜杠,回显给面板很难读。
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed",
|
||
"skill 文件「%s」解析失败(%s),详细原因见后端日志", src.path, failure))
|
||
return
|
||
}
|
||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed",
|
||
"skill YAML 解析失败(%s)", failure))
|
||
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 文件路径)")
|
||
}
|
||
}
|
||
|
||
// errSkillSource 标记「来源本身不可用」(打不开 / 不是普通文件 / 超上限)——
|
||
// 与「YAML 内容解析失败」分开归类:前者与内容无关,后者一律只留类别+行列。
|
||
var errSkillSource = errors.New("gateway: skill source unusable")
|
||
|
||
// 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 不是普通文件: %w", src.path, errSkillSource)
|
||
}
|
||
if info.Size() > maxSkillYAMLBytes {
|
||
return nil, fmt.Errorf("%q 超过 %d 字节上限: %w", src.path, maxSkillYAMLBytes, errSkillSource)
|
||
}
|
||
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
|
||
}
|