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(按任务口径)。
This commit is contained in:
1 parent
a697fa0a1e
commit
fa5d3d23bf
8 files changed
+686
-12
No files matched your search
@@ -30,8 +30,9 @@ const (
|
||||
)
|
||||
|
||||
// skillSource 是 Skill 定义的两种来源之一:内联 YAML 文本,或磁盘上的
|
||||
// YAML 文件路径。fromFile 决定解析失败时能否回显 YAML 解码器的原始
|
||||
// 错误(它可能带出被解析内容的前几个字符,见 adminInstallSkillPlugin)。
|
||||
// YAML 文件路径。fromFile 只影响「失败时给面板的那句话怎么写」(文件来源额外
|
||||
// 报出路径)——**两个来源都绝不回显解码器原文**(它会带出被解析内容的前几个
|
||||
// 字符,见 admin_plugins_skill_error.go)。
|
||||
type skillSource struct {
|
||||
text string
|
||||
path string
|
||||
@@ -60,16 +61,22 @@ func (s *Server) adminInstallSkillPlugin(w http.ResponseWriter, r *http.Request,
|
||||
skill, err := s.parseSkill(src)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
s.log.Error("skill install parse failed", "source", src.describe(), "error", err)
|
||||
// 🔴 日志只留「类别 + 行列」(见 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 {
|
||||
// 内容由服务端从磁盘读出:YAML 解码错误会带出内容片段(实测
|
||||
// `cannot unmarshal !!str \`LEAKMAR...\`` 会泄露前 7 个字符),
|
||||
// 因此绝不回显给客户端(完整原因在后端日志里)。路径用 %s 而非
|
||||
// %q:Windows 路径经 %q 会变成双反斜杠,回显给面板很难读。
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill 文件「%s」解析失败,详细原因见后端日志", src.path))
|
||||
// 内容由服务端从磁盘读出,因此连「类别 + 行号」都只给后端日志,
|
||||
// 面板只拿到路径 + 同一份安全摘要。路径用 %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 解析失败:%v", err))
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed",
|
||||
"skill YAML 解析失败(%s)", failure))
|
||||
return
|
||||
}
|
||||
if name := strings.TrimSpace(req.Name); name != "" && name != skill.Name() {
|
||||
@@ -166,6 +173,10 @@ func skillSourceOf(req pluginInstallRequest) (skillSource, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// errSkillSource 标记「来源本身不可用」(打不开 / 不是普通文件 / 超上限)——
|
||||
// 与「YAML 内容解析失败」分开归类:前者与内容无关,后者一律只留类别+行列。
|
||||
var errSkillSource = errors.New("gateway: skill source unusable")
|
||||
|
||||
// parseSkill 用 skills.Loader 把来源解析为一个 Skill。文件来源有大小上限,
|
||||
// 且只接受普通文件(目录/设备文件不是 Skill 定义)。
|
||||
func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
|
||||
@@ -187,10 +198,10 @@ func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
|
||||
return nil, fmt.Errorf("stat %q: %w", src.path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%q 不是普通文件", src.path)
|
||||
return nil, fmt.Errorf("%q 不是普通文件: %w", src.path, errSkillSource)
|
||||
}
|
||||
if info.Size() > maxSkillYAMLBytes {
|
||||
return nil, fmt.Errorf("%q 超过 %d 字节上限", src.path, maxSkillYAMLBytes)
|
||||
return nil, fmt.Errorf("%q 超过 %d 字节上限: %w", src.path, maxSkillYAMLBytes, errSkillSource)
|
||||
}
|
||||
skill, err := loader.Load(io.LimitReader(f, maxSkillYAMLBytes))
|
||||
if err != nil {
|
||||
|
||||
Reference in new issue
Block a user