diff --git a/internal/runtime/trace/redact.go b/internal/runtime/trace/redact.go index 563adbe..ff1bf59 100644 --- a/internal/runtime/trace/redact.go +++ b/internal/runtime/trace/redact.go @@ -62,7 +62,27 @@ func IsSecretKey(key string) bool { // to maxSummaryRunes, so a published string can never carry a secret or an // unbounded blob. func RedactText(s string) string { - return truncate(redactSecrets(s), maxSummaryRunes) + return RedactTextLimited(s, maxSummaryRunes) +} + +// RedactTextLimited 是 RedactText 的**带上限**形态:同一套凭据形状清洗,截断长度 +// 由调用方给。它存在的理由是两个读面要的不是同一种东西 —— +// +// - **出图 / 摘要面**(span props、事件正文、工具结果摘要)要的是摘要 ⇒ +// `maxSummaryRunes`(80)足够,且保证一屏可读。这些调用方继续用 `RedactText`; +// - **日志面**(`GET /admin/observability/logs` 的一行)要的是**可排障的全文** ⇒ +// 80 会把长错误行拦腰截断,等于丢掉诊断信息。 +// +// 但它绝不接受「无上限散用」:一条日志可能是 `%v` 出来的 MiB 级对象,原样塞进 +// JSON 响应会撑爆面板的帧与响应体。因此上限**必须由调用方显式给出**并写清理由 +// (网关侧见 `maxLogMsgRunes`)。 +// +// limit <= 0 表示**不截断**(只打码),留给「体积已由自己界定」的调用方。 +// +// 🔴 清洗逻辑仍然只有一份(redactSecrets):上限是参数,不是第二条脱敏路径 —— +// 另写一套必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。 +func RedactTextLimited(s string, limit int) string { + return truncate(redactSecrets(s), limit) } // RedactEvent returns ev with every string value — including those nested in maps diff --git a/internal/services/gateway/admin_memory_manual_tier_test.go b/internal/services/gateway/admin_memory_manual_tier_test.go new file mode 100644 index 0000000..96e6047 --- /dev/null +++ b/internal/services/gateway/admin_memory_manual_tier_test.go @@ -0,0 +1,99 @@ +package gateway + +// 本文件钉住**面板「人工加一条记忆」的留存档位**(M37 遗留的第三条入口, +// 主人 2026-10-26 裁定:「人工动作默认应为 long(人显式加一条记忆 = 长期意图, +// 不该被 7 天后静默清掉)」)。 +// +// M37 已在 memory 包内钉过 `Store.RememberManual` 与 `ManualExtractor.Extract`; +// 面板这条路径(`adminAddMemory` → `Store.Add`)当时仍在外面,因此 panel 上最 +// 典型的输入(短寒暄 / 一次性祈使 / 时效查询 / 句首时点 / 进行中)会被 +// `resolveRetention` 的 N1~N7 规则判成 `temp` 并写 7 天有效期 —— 主人在面板上 +// 亲手加的一条记忆会静默消失。 +// +// 🔴 断言必须打在**落库值**上(`retention_tier` + `expires_at`),而不是只看响应 +// 体:响应体里没有档位字段,只有表里的两列能证明「这条不会被清掉」。 +// 判据内容刻意选 N2 情绪的典型句 —— 它是**规则真的会判 temp** 的输入,因此 +// 「把 adminAddMemory 的 RetentionTier 摘掉」这条变异必然打红(M37 的教训: +// 空断言会给出假绿)。 + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory" +) + +// adminAddMemoryContent 是一条**自动抽取路径会被判 temp** 的面板典型输入 +// (N2 情绪反例,memory 包的 extract_retention_test.go 钉着同一个判据)。 +// 用它做夹具,「人工动作钉 long」这条实现一旦缺失就立刻现形。 +const adminAddMemoryContent = "今天好累" + +// TestAdminAddMemory_PinsLongRetentionTier 走真实 HTTP 面板入口 + 真实 +// memory.Store + 真实 sqlite,断言落库两列同时满足长期层:`retention_tier='long'` +// 且 `expires_at` 为空串(长期层不过期 ⇒ 不会被到期清扫读走)。 +func TestAdminAddMemory_PinsLongRetentionTier(t *testing.T) { + t.Parallel() + d, store := newMemoryStoreAndDBForGateway(t) + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store}) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories", + map[string]any{"personaId": "cyrene", "content": adminAddMemoryContent, "scope": "persona"}, tok) + if resp.StatusCode != http.StatusCreated { + t.Fatalf("add status = %d, want 201 (body=%s)", resp.StatusCode, body) + } + var added struct { + ID string `json:"id"` + } + if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil { + t.Fatalf("decode add: %v (body=%s)", err, body) + } + if added.ID == "" { + t.Fatalf("add returned no id (body=%s)", body) + } + + var tier, expires string + if err := d.QueryRowContext(context.Background(), + `SELECT retention_tier, expires_at FROM memory_items WHERE id = ?`, + added.ID).Scan(&tier, &expires); err != nil { + t.Fatalf("read retention columns of %s: %v", added.ID, err) + } + if tier != string(memory.RetentionLong) { + t.Fatalf("面板人工加的记忆 retention_tier = %q, want %q —— 人工动作是长期意图,"+ + "判成 temp 会在 7 天后从所有读面静默消失(内容=%q)", + tier, memory.RetentionLong, adminAddMemoryContent) + } + if expires != "" { + t.Fatalf("长期层不该有有效期,expires_at = %q(want 空串):带有效期的行会被"+ + "到期清扫物理删除", expires) + } +} + +// TestAdminAddMemory_ControlContentStillJudgedTemp 是上一条的**对照**:同一套 +// 存储与判定链上,**自动抽取**那一侧(不传档位的 Store.Add)对同一句内容仍然判 +// temp。它证明「面板钉 long」是这条入口的**特例**,而不是把整条判定链改宽 +// —— M37 复审钉过「自动抽取路径不放宽」,这条用例就是那个口径的回归锚点。 +func TestAdminAddMemory_ControlContentStillJudgedTemp(t *testing.T) { + t.Parallel() + store := newMemoryStoreForGateway(t) + + item, err := store.Add(context.Background(), memory.MemoryItem{ + PersonaID: "cyrene", + Content: adminAddMemoryContent, + Scope: memory.ScopePersona, + }) + if err != nil { + t.Fatalf("Store.Add: %v", err) + } + if item.RetentionTier != memory.RetentionTemp { + t.Fatalf("未显式传档位的 Store.Add 对 %q 判成 %q, want %q —— 自动抽取的留存规则"+ + "必须照旧生效(判据被放宽了?)", + adminAddMemoryContent, item.RetentionTier, memory.RetentionTemp) + } + if item.ExpiresAt.IsZero() { + t.Fatal("temp 档条目必须带有效期(temp ⇔ 有到期时刻),却拿到零值") + } +} diff --git a/internal/services/gateway/admin_plugins_skill.go b/internal/services/gateway/admin_plugins_skill.go index dc65cfe..2d784ac 100644 --- a/internal/services/gateway/admin_plugins_skill.go +++ b/internal/services/gateway/admin_plugins_skill.go @@ -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 { diff --git a/internal/services/gateway/admin_plugins_skill_error.go b/internal/services/gateway/admin_plugins_skill_error.go new file mode 100644 index 0000000..2beb1d7 --- /dev/null +++ b/internal/services/gateway/admin_plugins_skill_error.go @@ -0,0 +1,147 @@ +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 +} diff --git a/internal/services/gateway/admin_plugins_skill_error_test.go b/internal/services/gateway/admin_plugins_skill_error_test.go new file mode 100644 index 0000000..c160cc9 --- /dev/null +++ b/internal/services/gateway/admin_plugins_skill_error_test.go @@ -0,0 +1,177 @@ +package gateway + +// 本文件钉住 Skill 安装解析失败的**回显面**(M38 真泄漏 #17): +// +// - 后端日志只留「类别 + 行列」,**不含**解码器回显的内容片段; +// - 400 响应同样不含内容片段 —— 内联来源也不例外(这条路上客户端的报文 +// 可能与文件来源一样敏感,判据只能是「不许回显」而不是「谁读的文件」)。 +// +// 判据为什么会回显内容:yaml.v3 的类型错误把出错值的前 7 个字符嵌进错误串 +// (`cannot unmarshal !!str \`SENTINE...\``)。夹具因此用一个**长于 10 字符**的 +// 哨兵值 —— 短于 10 的值解码器会整段回显,断言用前 7 个字符就足以抓住两种形态。 +// +// 🔴 断言打在**响应体与日志输出**上(不是函数的返回值):这两处才是泄漏面。 + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry" +) + +// skillLeakSentinel 是塞进非法 YAML 的哨兵;`skillLeakPrefix` 是解码器会回显的 +// 前 7 个字符 —— 断言「日志/响应里搜不到它」比断言整串更严格(整串只在短值时 +// 才被完整回显)。 +const ( + skillLeakSentinel = "SENTINEL7SECRET" + skillLeakPrefix = "SENTINE" +) + +// syncLogBuffer 是并发安全的日志汇聚点:handler goroutine 写、测试主 goroutine 读。 +type syncLogBuffer struct { + mu sync.Mutex + buf bytes.Buffer +} + +// Write 实现 io.Writer(slog 的 handler 用它落日志)。 +func (b *syncLogBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.Write(p) +} + +// String 返回已捕获的日志文本。 +func (b *syncLogBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.buf.String() +} + +// newSkillInstallLogEnv 构建一个 Skill 安装环境,并把网关 logger 接到可读的 +// 缓冲上(安装失败的那条 ERROR 就是断言对象)。返回注册表用于断言「失败不留 +// 半安装状态」。 +func newSkillInstallLogEnv(t *testing.T) (logs *syncLogBuffer, caps *capreg.Registry, base, tok string) { + t.Helper() + logs = &syncLogBuffer{} + caps = capreg.New() + _, ts, jwt := buildEnv(t, cfgDefault(), Deps{ + Log: slog.New(slog.NewTextHandler(logs, nil)), + Registry: caps, + }) + return logs, caps, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner) +} + +// assertNoLeakInSkillFailure 断言「响应体与日志都没有哨兵、但都留了类别与行号」。 +func assertNoLeakInSkillFailure(t *testing.T, raw []byte, logs, wantKind string, wantLine bool) { + t.Helper() + if strings.Contains(string(raw), skillLeakPrefix) { + t.Fatalf("400 响应回显了 YAML 内容片段(%q):%s", skillLeakPrefix, raw) + } + if strings.Contains(logs, skillLeakPrefix) { + t.Fatalf("后端日志回显了 YAML 内容片段(%q):%s", skillLeakPrefix, logs) + } + var envErr errorEnvelope + if err := json.Unmarshal(raw, &envErr); err != nil { + t.Fatalf("decode error envelope: %v (%s)", err, raw) + } + if envErr.Error.Code != "skill_parse_failed" { + t.Fatalf("error code = %q, want skill_parse_failed (%s)", envErr.Error.Code, raw) + } + if !strings.Contains(envErr.Error.Message, wantKind) { + t.Fatalf("响应 message 缺错误类别 %q:%q", wantKind, envErr.Error.Message) + } + if !strings.Contains(logs, "yaml_kind="+wantKind) { + t.Fatalf("日志缺 yaml_kind=%s:%s", wantKind, logs) + } + if !wantLine { + return + } + if !strings.Contains(envErr.Error.Message, "at line 2") { + t.Fatalf("响应 message 缺行号:%q", envErr.Error.Message) + } + if !strings.Contains(logs, "yaml_line=2") { + t.Fatalf("日志缺 yaml_line=2:%s", logs) + } +} + +// TestAdminInstallSkill_InlineParseFailureHidesContent 是 #17 的复现面:**内联** +// 来源的类型错误此前被原样打进日志、并原样回显给面板。现在两处都不含哨兵, +// 但都保留类别与行号(排障不能靠猜)。 +func TestAdminInstallSkill_InlineParseFailureHidesContent(t *testing.T) { + logs, caps, base, tok := newSkillInstallLogEnv(t) + // 第 2 行把标量塞进 steps(期望 []step)⇒ yaml_type_mismatch,位置 line 2。 + leakyYAML := "name: leaky\nsteps: \"" + skillLeakSentinel + "\"\n" + resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install", + map[string]any{"kind": "skill", "yaml": leakyYAML}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw) + } + assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindType, true) + if n := len(caps.List(capreg.KindSkill)); n != 0 { + t.Fatalf("失败的安装留下了 %d 个 skill(不许半安装)", n) + } +} + +// TestAdminInstallSkill_FileParseFailureHidesContent 是同一泄漏面的**文件**来源: +// 文件由服务端读取,客户端的报文里没有内容,所以这条更容易被忽视 —— +// 但日志面一样是 viewer 可读的监控面,口径必须与内联来源一致。 +func TestAdminInstallSkill_FileParseFailureHidesContent(t *testing.T) { + logs, _, base, tok := newSkillInstallLogEnv(t) + path := filepath.Join(t.TempDir(), "leaky.yaml") + leakyYAML := "name: leaky\nsteps: \"" + skillLeakSentinel + "\"\n" + if err := os.WriteFile(path, []byte(leakyYAML), 0o600); err != nil { + t.Fatalf("write leaky skill: %v", err) + } + resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install", + map[string]any{"kind": "skill", "path": path}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw) + } + assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindType, true) + // 路径断言走**解码后**的 message:JSON 会把 Windows 路径的反斜杠转义成 `\\`, + // 直接在原始字节里找会永远找不到。 + var envErr errorEnvelope + if err := json.Unmarshal(raw, &envErr); err != nil { + t.Fatalf("decode error envelope: %v (%s)", err, raw) + } + if !strings.Contains(envErr.Error.Message, path) { + t.Fatalf("文件来源的失败必须报出路径,message = %q", envErr.Error.Message) + } +} + +// TestAdminInstallSkill_SyntaxFailureIsCategorised 钉住另一个类别:YAML 语法层 +// 失败归 `yaml_syntax`(不是 type_mismatch),且同样不回显内容。 +func TestAdminInstallSkill_SyntaxFailureIsCategorised(t *testing.T) { + logs, _, base, tok := newSkillInstallLogEnv(t) + // 未闭合的流式序列 ⇒ 解码器报语法错(`did not find expected ',' or ']'`)。 + brokenYAML := "name: leaky\nsteps: [" + skillLeakSentinel + "\n" + resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install", + map[string]any{"kind": "skill", "yaml": brokenYAML}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw) + } + assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindSyntax, false) +} + +// TestAdminInstallSkill_SourceFailureIsCategorised 钉住第三个类别:来源不可用 +// (路径不存在)归 `skill_source`,与「YAML 内容解析失败」分开 —— 运维据此 +// 一眼分辨「文件没放对」还是「YAML 写错了」。 +func TestAdminInstallSkill_SourceFailureIsCategorised(t *testing.T) { + logs, _, base, tok := newSkillInstallLogEnv(t) + resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install", + map[string]any{"kind": "skill", "path": filepath.Join(t.TempDir(), "nope.yaml")}, tok) + if resp.StatusCode != http.StatusBadRequest { + t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw) + } + if !strings.Contains(logs.String(), "yaml_kind="+skillParseKindSource) { + t.Fatalf("日志缺 yaml_kind=%s:%s", skillParseKindSource, logs.String()) + } +} diff --git a/internal/services/gateway/admin_write_memory_rag.go b/internal/services/gateway/admin_write_memory_rag.go index 67b6002..ea705bc 100644 --- a/internal/services/gateway/admin_write_memory_rag.go +++ b/internal/services/gateway/admin_write_memory_rag.go @@ -27,6 +27,18 @@ type memoryAddRequest struct { // 「添加记忆」路径:条目默认作用于 persona,因此行为与 // loop 已在写的「记住 X」路径一致。Scope 可省略(默认 // persona),或为 user|persona|project 之一;非法非空 scope 会被拒绝。 +// +// 🔴 档位显式钉 `long`(主人 2026-10-26 裁定:「人工动作默认应为 `long`(人显式 +// 加一条记忆 = 长期意图,不该被 7 天后静默清掉)」)。不传档位时 `memory.Store.Add` +// 会走 `resolveRetention` 的留存规则(N1~N7),面板最典型的输入恰恰落在反例里 +// —— 短寒暄(「谢谢」)、一次性祈使、时效查询、句首时点、进行中状态都会被判 +// `temp` 并写 7 天有效期,于是主人在面板上亲手加的一条记忆会静默消失。 +// `resolveRetention` 对**显式档位**的处置是「以调用方为准」,因此这一位是真正的 +// 钉死而不是提示。M37 已在包内钉过 `Store.RememberManual` 与 +// `ManualExtractor.Extract` 两条入口;本处是同一裁定在**面板**这条入口上的落点。 +// +// 只钉这一条入口:`Store.Add` 的默认值仍是「未分型 ⇒ 由规则判定」,自动抽取路径 +// 绝不放宽(M37 复审口径)。 func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) { var req memoryAddRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { @@ -59,6 +71,8 @@ func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) { PersonaID: personaID, Content: content, Scope: memory.Scope(rawScope), + // 人工动作 = 长期意图,见上方注释:显式档位优先于留存规则。 + RetentionTier: memory.RetentionLong, }) if err != nil { s.auditClaims(r, "memory.add", "error") diff --git a/internal/services/gateway/observability_logs.go b/internal/services/gateway/observability_logs.go index a6a095d..61fc529 100644 --- a/internal/services/gateway/observability_logs.go +++ b/internal/services/gateway/observability_logs.go @@ -9,12 +9,24 @@ import ( apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" ) // 日志尾部端点的调参。编译期常量(项目规范 §10)。 const ( defaultLogLimit = 200 maxLogLimit = 1000 + // maxLogMsgRunes 是日志页**单行 msg** 的截断上限(M42 r2)。 + // + // 🔴 为什么不是 80:`trace.RedactText` 的 80 rune 是**出图摘要**那一档(span + // props / 工具结果摘要要的是「一眼看完的摘要」),而日志页是**排障面** —— + // 把一条长错误行拦腰截断就是丢诊断信息(实测一条 500 rune 的上游报错会被切到 + // 80 + 省略号,恰好切掉「谁拒了、为什么」)。 + // + // 🔴 为什么也不是无上限:单条日志可能是 `%v` 一个大对象(MiB 级),面板一次 + // 要 1000 条 ⇒ 原样透出会把 JSON 响应撑到 GB 量级。4096 rune 足够装下完整的 + // 技术栈 / 长错误行 / 一次请求的回显,而 4096 × 1000 条仍只是几 MB 量级。 + maxLogMsgRunes = 4096 ) // AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的 @@ -91,12 +103,26 @@ func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request) } // adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。 +// +// 🔴 读侧脱敏(M42 #3):`msg` 是日志正文,也是唯一可能夹带凭据/正文的字段 +// (本仓有过「把上游报文整段打进日志」的先例),因此出面板前过一遍 trace 包的 +// 共享清洗 —— 与写侧同一条路径,**不另写一套**:两套脱敏必然漂移,而漂移的方向 +// 总是「其中一面开始原样吐凭据」。 +// +// 🔴 上限与出图摘要**故意不同**(M42 r2,塔 2026-10-26 裁定):这里用 +// `trace.RedactTextLimited(msg, maxLogMsgRunes)`(4096),而不是 `RedactText` +// 的 80 —— 日志页是排障面,80 会把长错误行拦腰截断(实质性 UX 回归)。出图那一侧 +// (span props / 摘要)仍是 80,两边**不该被统一**:一个要摘要,一个要全文。 +// +// 排障字段一律保住:`time`(RFC3339)、`level`、`source`(模块来源标签,编译期 +// 常量构成,不含内容)原样透出,且 `msg` 的打码不改变条目条数、排序与级别过滤 +// —— 面板因此照旧可诊断「谁在什么时候按什么级别报了哪一类错」。 func adminLogOf(e logger.Entry) AdminLogEntry { return AdminLogEntry{ Time: e.Time.UTC().Format(time.RFC3339), Level: e.Level, Source: e.Source, - Msg: e.Msg, + Msg: trace.RedactTextLimited(e.Msg, maxLogMsgRunes), } } diff --git a/internal/services/gateway/observability_redact_test.go b/internal/services/gateway/observability_redact_test.go new file mode 100644 index 0000000..7da6179 --- /dev/null +++ b/internal/services/gateway/observability_redact_test.go @@ -0,0 +1,306 @@ +package gateway + +// 本文件钉住**可观测面板读侧的脱敏**(M42 #3 = M38 #1 的读侧放大面 / M39 遗留)。 +// +// 写侧(stderr 的 JSONL)在 M39 已经有了 `trace.SanitizeRecord` / `trace.RedactText`; +// 但面板这两条读路径当时仍原样搬运: +// +// - 日志页 `GET /api/v1/admin/observability/logs`:`buf.Snapshot` → 原样出图; +// - trace 页 `GET /api/v1/admin/observability/trace`:环形缓冲持有**原始** props, +// 原样出图。 +// +// 两条都在这里补上「读侧也走同一个 helper」——**不另写一套脱敏**:一套脱敏两处 +// 实现必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。 +// +// 🔴 同时保住**排障字段**:日志的行数/级别/时间戳/来源,trace 的 status/耗时/时间/ +// 名字都必须原样透出。脱敏只动「内容面」(msg 正文、props 值),不动结构 —— +// 否则面板就变成不可诊断的空壳(这条是塔的明确要求)。 +// +// 每条用例都能被一条变异打红:把读侧的 `trace.RedactText` / `trace.SanitizeRecord` +// 摘掉,哨兵与凭据就会原样出现在响应体里。 + +import ( + "encoding/json" + "io" + "net/http" + "strings" + "testing" + "time" + "unicode/utf8" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/auth" + "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// obsLeakKey 是 `sk-` 形状的假凭据:trace 包的形状规则(`\bsk-[A-Za-z0-9_\-]{8,}`) +// 认它,因此它出现在响应体里就一定意味着读侧没过脱敏。 +const obsLeakKey = "sk-live0000000000000000ABCD" + +// obsLeakPrefix 是断言用的前缀(比整串更严格:打码后不该留下任何一段)。 +const obsLeakPrefix = "sk-live0000" + +// obsLeakBody 是哨兵正文:它没有凭据形状,只能靠「白名单/正文键」判据拦下 —— +// 因此它抓的是「正文原样出图」,与 obsLeakKey 抓的「凭据原样出图」互不替代。 +const obsLeakBody = "SENTINEL_BODY_PAYLOAD_42" + +// obsLogServer 构建一个注入了真实 logger 环形缓冲的网关。 +func obsLogServer(t *testing.T) (base, tok string) { + t.Helper() + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + ring := logger.NewRingBuffer(16) + log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring}) + log.Info("boot ok") + // 最新在前:错误放最后,因此它是 Data[0]。 + logger.WithSource(log, "gateway/model").Error("upstream rejected " + obsLeakKey + " with 401") + srv.SetLogBuffer(ring) + return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner) +} + +// TestAdminObservabilityLogs_RedactsCredentialInMsg 钉住日志页读侧打码: +// 凭据形状被换成 `[redacted]`,而同一条 msg 的非敏感部分、以及 level/source/time +// 这些排障字段原样保留(一行的可诊断性不能被脱敏吃掉)。 +func TestAdminObservabilityLogs_RedactsCredentialInMsg(t *testing.T) { + t.Parallel() + base, tok := obsLogServer(t) + + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if strings.Contains(string(body), obsLeakPrefix) { + t.Fatalf("日志页回显了凭据(%q):%s", obsLeakPrefix, body) + } + var env logEnvelope + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, body) + } + if len(env.Data) != 2 { + t.Fatalf("日志条数 = %d, want 2(脱敏不许改变条目数)", len(env.Data)) + } + got := env.Data[0] + if !strings.Contains(got.Msg, "[redacted]") { + t.Fatalf("凭据位置应换成 [redacted],msg = %q", got.Msg) + } + // 排障字段与非敏感正文必须保住。 + if got.Level != "error" || got.Source != "gateway/model" { + t.Fatalf("level/source 被改动:%+v", got) + } + if _, err := time.Parse(time.RFC3339, got.Time); err != nil { + t.Fatalf("time 不是 RFC3339:%q (%v)", got.Time, err) + } + if !strings.Contains(got.Msg, "upstream rejected") || !strings.Contains(got.Msg, "with 401") { + t.Fatalf("msg 的非敏感部分被脱敏吃掉:%q", got.Msg) + } + if env.Data[1].Msg != "boot ok" || env.Data[1].Level != "info" { + t.Fatalf("无关条目被改动:%+v", env.Data[1]) + } +} + +// obsTraceServer 构建一个网关并把一条**含正文与凭据**的 span 送进 trace 环形 +// 缓冲(模拟生产:ObserveSpan 收的是 tracer 的原始记录)。 +func obsTraceServer(t *testing.T) (base, tok string) { + t.Helper() + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC) + srv.ObserveSpan(trace.SpanRecord{ + ID: "span-42", + Name: "tool.execution", + Kind: trace.SpanKindTool, + Status: trace.SpanStatusOK, + StartedAt: started, + EndedAt: started.Add(25 * time.Millisecond), + Duration: 25 * time.Millisecond, + Props: map[string]any{ + // 白名单键:保留,但值里的凭据必须打码。 + "tool": "web_search", + "args_summary": "query=天气 " + obsLeakKey, + // 表外正文键:折成 omitted 里的 Digest(留指纹、去正文)。 + "messages": obsLeakBody, + "result": obsLeakBody, + }, + Events: []trace.Event{{ + Time: started.Add(time.Millisecond), + Type: "tool.done", + Props: map[string]any{"text": obsLeakBody, "ok": true}, + }}, + }) + return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner) +} + +// TestAdminObservabilityTrace_RedactsPropsAndEvents 钉住 trace 页读侧投影: +// props 与事件里的正文/凭据都出不来,而 span 的 status/时间/耗时/名字逐字保留。 +// +// ⚠️ 这条用例同时是「有意改变 admin 响应内容」的证据(见 adminSpanOf 注释): +// props 从原始值变成白名单投影 + omitted 摘要,客户端不得依赖未声明的 props。 +func TestAdminObservabilityTrace_RedactsPropsAndEvents(t *testing.T) { + t.Parallel() + base, tok := obsTraceServer(t) + + resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/trace", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + if strings.Contains(string(body), obsLeakBody) { + t.Fatalf("trace 页回显了正文哨兵(%q):%s", obsLeakBody, body) + } + if strings.Contains(string(body), obsLeakPrefix) { + t.Fatalf("trace 页回显了凭据(%q):%s", obsLeakPrefix, body) + } + var env struct { + Data []AdminTraceSpan `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, body) + } + if len(env.Data) != 1 { + t.Fatalf("span 条数 = %d, want 1", len(env.Data)) + } + sp := env.Data[0] + // 排障字段:status / 时间 / 耗时 / 名字 / kind 一个都不许动。 + if sp.ID != "span-42" || sp.Name != "tool.execution" || sp.Kind != "tool_call" || + sp.Status != "ok" || sp.DurationNs != int64(25*time.Millisecond) { + t.Fatalf("span 的排障字段被改动:%+v", sp) + } + if sp.StartedAt != "2026-10-26T03:04:05Z" || sp.EndedAt != "2026-10-26T03:04:05Z" { + t.Fatalf("时间字段被改动:startedAt=%q endedAt=%q", sp.StartedAt, sp.EndedAt) + } + // 白名单键保留(面板靠它分组),但值里的凭据已打码。 + if tool, _ := sp.Props["tool"].(string); tool != "web_search" { + t.Fatalf("白名单 props.tool = %v, want web_search(脱敏不许把排障键也丢掉)", sp.Props["tool"]) + } + summary, _ := sp.Props["args_summary"].(string) + if !strings.Contains(summary, "[redacted]") || strings.Contains(summary, obsLeakPrefix) { + t.Fatalf("白名单键的值没打码:args_summary = %q", summary) + } + // 表外正文键折成 omitted 里的 Digest:留键名(可判断「这次带了什么」),不留正文。 + omitted, ok := sp.Props["omitted"].(map[string]any) + if !ok { + t.Fatalf("表外正文键应折成 props.omitted,实际 props = %+v", sp.Props) + } + for _, k := range []string{"messages", "result"} { + if _, present := omitted[k]; !present { + t.Fatalf("omitted 缺键 %q:%+v", k, omitted) + } + } + // 事件正文键折成 Digest,非正文键(ok)保留。 + if len(sp.Events) != 1 { + t.Fatalf("events = %+v, want 1 条", sp.Events) + } + if _, leaked := sp.Events[0].Props["text"].(string); leaked { + t.Fatalf("事件正文键仍是字符串(未折成 Digest):%+v", sp.Events[0].Props) + } + if ok, _ := sp.Events[0].Props["ok"].(bool); !ok { + t.Fatalf("事件非正文键 ok 应原样保留:%+v", sp.Events[0].Props) + } +} + +// obsLogTailMarker 是长日志行**末尾**的哨兵:按 80 rune 截断必然把它切掉,按 +// 4096(`maxLogMsgRunes`)必然留下它 —— 它就是「这一行有没有被拦腰截断」的判据。 +const obsLogTailMarker = "TAIL_OF_LONG_LOG_LINE" + +// longLogMsg 造一条 ~500 rune 的日志行:凭据放在**行首**(所以 80 rune 截断也能 +// 打码成功,红的是「截断」这一条而不是「没打码」),末尾带哨兵 —— 一旦被切到 80, +// 哨兵必然消失,判据因此指向问题本身。 +// 「排障上下文一行 」是 8 rune,×60 = 480 rune ⇒ 远超 80 的摘要上限。 +func longLogMsg() string { + return "upstream rejected " + obsLeakKey + " " + + strings.Repeat("排障上下文一行 ", 60) + obsLogTailMarker +} + +// TestAdminObservabilityLogs_LongLineKeepsFullText 钉住日志页的**上限与出图不同** +// (M42 r2,塔 2026-10-26 裁定):日志页是排障面,一条 500 rune 的错误行必须完整 +// 保留(凭据仍然打码),不能被 `maxSummaryRunes`(80)拦腰截断。 +// +// 变异:把 `adminLogOf` 改回 `trace.RedactText`(80)⇒ 本用例必红(末尾哨兵消失、 +// rune 数掉到 81)。 +func TestAdminObservabilityLogs_LongLineKeepsFullText(t *testing.T) { + t.Parallel() + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + ring := logger.NewRingBuffer(8) + log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring}) + msg := longLogMsg() + if utf8.RuneCountInString(msg) <= maxLogMsgRunes/8 { + t.Fatalf("夹具只有 %d rune,不足以证明「不被 80 截断」", utf8.RuneCountInString(msg)) + } + log.Error(msg) + srv.SetLogBuffer(ring) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var env logEnvelope + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, body) + } + if len(env.Data) != 1 { + t.Fatalf("日志条数 = %d, want 1", len(env.Data)) + } + got := env.Data[0].Msg + if strings.Contains(got, obsLeakPrefix) { + t.Fatalf("长行里的凭据没打码(%q):%q", obsLeakPrefix, got) + } + if !strings.Contains(got, "[redacted]") { + t.Fatalf("长行里的凭据位置应换成 [redacted]:%q", got) + } + if !strings.Contains(got, obsLogTailMarker) { + t.Fatalf("日志页把 %d rune 的行截断了(末尾哨兵 %q 消失),拿到 %d rune:…%s", + utf8.RuneCountInString(msg), obsLogTailMarker, utf8.RuneCountInString(got), + string([]rune(got)[max(0, utf8.RuneCountInString(got)-20):])) + } + // 长度只允许差「凭据 → [redacted]」那一处替换,不许多掉任何东西。 + wantLen := utf8.RuneCountInString(msg) - utf8.RuneCountInString(obsLeakKey) + + utf8.RuneCountInString("[redacted]") + if n := utf8.RuneCountInString(got); n != wantLen { + t.Fatalf("日志行 rune 数 = %d, want %d(只允许打码带来的差额,不许截断)", n, wantLen) + } +} + +// TestAdminObservabilityTrace_PropsStayAtSummaryBound 是上一条的**对偶**:日志页 +// 的上限调大到 4096 之后,出图这一侧(span props)不许被顺手统一 —— 那里要的是 +// 摘要(`maxSummaryRunes` = 80),不是一个更长的全文。 +// +// 变异:把 `maxSummaryRunes` 提到 4096(或让出图侧改用日志上限)⇒ 本用例必红。 +func TestAdminObservabilityTrace_PropsStayAtSummaryBound(t *testing.T) { + t.Parallel() + srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{}) + started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC) + long := strings.Repeat("摘", 200) + srv.ObserveSpan(trace.SpanRecord{ + ID: "span-bound", + Name: "tool.execution", + Kind: trace.SpanKindTool, + Status: trace.SpanStatusOK, + StartedAt: started, + EndedAt: started.Add(time.Millisecond), + Duration: time.Millisecond, + Props: map[string]any{"tool": "web_search", "args_summary": long}, + }) + tok := signAny(t, jwt, "acct", auth.RoleOwner) + + resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/trace", nil, tok) + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body) + } + var env struct { + Data []AdminTraceSpan `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + t.Fatalf("decode: %v (body=%s)", err, body) + } + if len(env.Data) != 1 { + t.Fatalf("span 条数 = %d, want 1", len(env.Data)) + } + summary, _ := env.Data[0].Props["args_summary"].(string) + // 80 是 trace 包的摘要上限(`maxSummaryRunes`,未导出 ⇒ 这里按契约写常量)。 + const summaryBound = 80 + if n := utf8.RuneCountInString(summary); n != summaryBound+1 { + t.Fatalf("args_summary rune 数 = %d, want %d(%d rune 摘要 + 省略号)—— "+ + "出图侧不许跟日志页一起放宽到 %d", n, summaryBound+1, summaryBound, maxLogMsgRunes) + } + if !strings.HasSuffix(summary, "…") { + t.Fatalf("截断结果应以省略号收尾:%q", summary) + } +} diff --git a/internal/services/gateway/observability_trace.go b/internal/services/gateway/observability_trace.go index 4395513..0c013dd 100644 --- a/internal/services/gateway/observability_trace.go +++ b/internal/services/gateway/observability_trace.go @@ -161,7 +161,25 @@ func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error { } // adminSpanOf 把 trace.SpanRecord 映射为驼峰式管理线上结构。 +// +// 🔴 读侧脱敏(M42 #3,M38 #1 的读侧放大面):入参先过 `trace.SanitizeRecord` +// —— 与写侧(stderr 的 JSONL,trace.JSONRecorder)**同一份投影**,不另写一套。 +// 环形缓冲(ObserveSpan → traceBuf)持的是**原始**记录,因此 REST 尾部 +// (GET /api/v1/admin/observability/trace)与 observability_trace 推送帧这两条 +// 出图路径都必须在这里收口,否则面板就是「凭据与正文的最后一面」。 +// +// ⚠️ 这是**有意的行为变更**(admin 面有意改变响应内容):`props` 从原始值变成 +// 脱敏投影 —— 白名单(trace 包 safePropKeys)内的键逐值打码+截断,表外键折成 +// `omitted` 里的 Digest(留键名、留体积指纹、去正文),事件里的正文键同样折成 +// Digest。输出**字段名与帧形状不变**(id/parentId/name/kind/status/startedAt/ +// endedAt/durationNs/props/events 一个不改),客户端因此**不得依赖未声明的 +// props**(即不得假定某个表外键一定带原文)—— 契约口径本就是「props 只含已 +// 声明键」。 +// +// 排障字段一律保住:status / 时间 / durationNs / name / kind 都不是内容面, +// 原样透出;`omitted` 的存在还让「这次调用有多大输出」仍可回答。 func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan { + rec = trace.SanitizeRecord(rec) sp := AdminTraceSpan{ ID: rec.ID, ParentID: rec.ParentID,