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 }