Files
Cyrene_v2/internal/services/gateway/admin_plugins_skill.go
T
AskaEth fa5d3d23bf feat(gateway): 面板人工加记忆钉 long + skill 解析去回显 + 可观测读侧脱敏
三处遗留(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(按任务口径)。
2026-10-26 13:53:59 +08:00

212 lines
8.8 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` 已删除)。
// 仅 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
}