三处遗留(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(按任务口径)。
148 lines
5.4 KiB
Go
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
|
|
}
|