Files
Cyrene_v2/internal/services/gateway/admin_plugins_skill_error.go
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

148 lines
5.4 KiB
Go

package gateway
// 本文件是「Skill 安装解析失败」的**安全投影**:失败只以「类别 + 行列」出现,
// 解码器回显的原文既不进日志、也不回显给面板。
//
// 为什么需要它(M38 真泄漏 #17 的形态):yaml.v3 的类型错误会把**出错值的前
// 7 个字符**嵌进错误串 ——
//
// cannot unmarshal !!str `LEAKMAR...` into []skills.yamlStep
//
// 也就是说「把 err.Error() 打进日志」等价于「把面板上传的那段内容写进日志面」。
// 而日志面正是 owner 之外的角色(viewer)也能读的只读监控面,所以这条路的
// 方向必须是**白名单式**:只允许「错误类别」与「行列号」这两类不含内容的字节
// 离开本文件。
//
// 与「Skill 安装」共用的还有一种情况:来源本身不可读(打不开 / 不是普通文件 /
// 超上限)。那类错误里只有路径,但路径已经在日志的 source 字段里,因此同样走
// 本投影、不留原文。
import (
"errors"
"io/fs"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// skillParseKindSyntax 是 YAML 语法层失败(缩进/引号/流式集未闭合…)。
skillParseKindSyntax = "yaml_syntax"
// skillParseKindType 是结构不匹配(把标量塞进 []step 之类)—— 正是解码器
// 会回显内容片段的那一类。
skillParseKindType = "yaml_type_mismatch"
// skillParseKindInvalid 是「YAML 解得动,但定义违反 Skill 结构约束」
// (步骤依赖缺失/成环、护栏 id 重复、枚举字段非法…)。这类错误里带的是
// 面板上传的步骤名/字段值,因此同样只留类别。
skillParseKindInvalid = "skill_invalid"
// skillParseKindSource 是来源不可用(打不开 / 不是普通文件 / 超大小上限)。
skillParseKindSource = "skill_source"
)
// yamlPositionPattern 从解码器错误串里**只抠两个数字**:行号,以及语法错误有时
// 会给出的列号(yaml.v3 的诊断形如 `line 2: cannot unmarshal ...` /
// `line 3, column 5: ...`)。匹配成功与否只影响这两个整数 —— 原文永远不离开
// 本文件。
var yamlPositionPattern = regexp.MustCompile(`line (\d+)(?:, column (\d+))?`)
// skillParseFailure 是一次解析失败的安全摘要:类别 + 位置,**没有原文**。
// 日志与 400 响应用同一份摘要,因此两面不可能给出不同口径。
type skillParseFailure struct {
// Kind 是上面四个类别之一。
Kind string
// Line / Column 是解码器给出的位置(0 = 该类别不带位置信息)。
Line int
Column int
}
// String 渲染成人读的一行,例如 `yaml_type_mismatch at line 2`。
func (f skillParseFailure) String() string {
var b strings.Builder
b.WriteString(f.Kind)
if f.Line > 0 {
b.WriteString(" at line ")
b.WriteString(strconv.Itoa(f.Line))
if f.Column > 0 {
b.WriteString(", column ")
b.WriteString(strconv.Itoa(f.Column))
}
}
return b.String()
}
// logArgs 返回 slog 键值对:类别必出,位置只在已知时出 —— 未知就整键省略,
// 免得日志里出现 `yaml_line=0` 这种「看起来像位置」的假值。
func (f skillParseFailure) logArgs() []any {
args := []any{"yaml_kind", f.Kind}
if f.Line > 0 {
args = append(args, "yaml_line", f.Line)
}
if f.Column > 0 {
args = append(args, "yaml_column", f.Column)
}
return args
}
// skillParseFailureOf 把 parseSkill 的错误折叠成安全摘要。nil 调用方不传,
// 但仍给出一个合法类别而不是空摘要。
func skillParseFailureOf(err error) skillParseFailure {
if err == nil {
return skillParseFailure{Kind: skillParseKindSyntax}
}
f := skillParseFailure{Kind: skillParseKindOf(err)}
if m := yamlPositionPattern.FindStringSubmatch(err.Error()); m != nil {
f.Line = digitsOr0(m[1])
f.Column = digitsOr0(m[2])
}
return f
}
// skillParseKindOf 给失败分类。判据是**类型与哨兵**,不是错误文字:文字只用来
// 抠行列号,绝不参与「这一条能不能打」的决定。
func skillParseKindOf(err error) string {
var typeErr *yaml.TypeError
if errors.As(err, &typeErr) {
return skillParseKindType
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) || errors.Is(err, fs.ErrNotExist) || errors.Is(err, errSkillSource) {
return skillParseKindSource
}
for _, sentinel := range skillDefinitionErrors() {
if errors.Is(err, sentinel) {
return skillParseKindInvalid
}
}
// 剩下的只可能是解码器自己的语法错误(loader 把 decode 包成
// `loader: decode: yaml: ...`);兜底归到语法类而不是编一个新类别。
return skillParseKindSyntax
}
// skillDefinitionErrors 是「定义结构不合法」的哨兵集合(skills 包与 sdk 的
// 字段校验)。逐个 errors.Is,而不是比字符串,因此不会随措辞漂移。
func skillDefinitionErrors() []error {
return []error{
skills.ErrInvalidSkill,
skills.ErrStepDuplicate,
skills.ErrStepDependsMissing,
skills.ErrStepCycle,
skills.ErrToolConflict,
skills.ErrGuardrailDuplicate,
sdk.ErrInvalidSkillField,
}
}
// digitsOr0 把纯数字串折成整数(正则已保证只含数字;空串/溢出按「未知」处理)。
func digitsOr0(raw string) int {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}