Files
Cyrene_v2/internal/runtime/loop/span_write_redaction_test.go
T
AskaEth 003d12a712 feat(trace): M40 span 写侧脱敏 + 共享上游错误收敛 helper
stderr 的 JSONL 是无主人在场的落盘面,却在此前原样落工具结果(result)、
子代理回复(text/summary)与上游报文,等于一次 tail 就能拷走整段对话。
本切片交付两件地基:

1) 写侧脱敏(trace.JSONRecorder)
   - 新增 trace/span_props.go:safePropKeys 白名单与 projectSpanProps 投影,
     读侧(safeSpanProps)与写侧共用同一张表、同一个投影函数;
   - WriteSpan 落盘前经 trace.SanitizeRecord 投影:白名单键清洗,表外键只留
     Digest(bytes/runes/sha256 前缀,收进 omitted),事件先 RedactEvent 再把
     正文键折成 Digest。落到磁盘的只有元数据,正文一个字节都不留;
   - 新增 trace/digest.go:Digest/DigestOf/DigestOfValue,给「脱敏后仍要能诊断」
     一个构造上不含正文的取值形态(体积 + 指纹),两条投影都按类型放行它。

2) 共享上游错误体收敛 helper(给 M41/M42,禁止三家各写一份)
   - trace.UpstreamError(upstream, status, contentType, body):只保留 status /
     contentType / bodyLen / body sha256 前缀 / 上游 JSON 的 error.code /
     error.type / 截断 128 rune 的 error.message,其余一律丢弃,响应体原文绝不
     进 error 或日志;*UpstreamErrorInfo 提供 Error()/Attrs()/Unwrap()(errors.Is
     可判 ErrUpstream 走降级链);
   - trace.HostOf / trace.MaskQuery:日志里的 URL 只回来源,凭据类 query 与
     fragment 值打码为 ***,user:pass@ 整段打码。

3) 剥掉三处正文上 span
   - loop:tool span 不再放 result,改放 result_summary(与 tool_execution.end
     帧同源、已脱敏截断)与 result_digest;
   - orchestration:subagent span 不再放 text,subagent.done 事件不再放 summary,
     改记 status/steps/chars/sha256;
   - automations:AddRuleByName 不再 %v 整个 payload,改记 subject/type/bytes/sha256。

测试:以哨兵正文在落盘字节上断言(JSONL / slog 输出),并分别对「编排层收到的
原始 span」与「写侧投影」两层各钉一遍;UpstreamError 用回显 input 的 422 报文
与 HTML 错误页各钉一条;解析出/未解析出 JSON 都不回原文。以上均做过变异验证
(去掉保护即变红)。

验收:go build ./... + go vet ./... + go test -count=1 ./... 全绿;
trace/loop/orchestration 另跑 -race 全绿。
2026-10-26 13:17:42 +08:00

194 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package loop
import (
"bytes"
"context"
"encoding/json"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 本文件钉住 M40 的工具侧写盘边界,分两层各钉一遍:
//
// - **循环层**:真跑一次循环,按 writer 收到的**原始 SpanRecord**断言 —— tool
// span 里根本没有 `result` 原文(曾经是原样落盘的工具结果全文,M38 排查出的
// 最严重一条),只有 result_summary(已脱敏、已截断,供回看面读)与
// result_digest(字节/rune/sha256 前缀);
// - **写侧**:同一份记录的 stderr JSONL 字节里搜不到正文。
//
// 两层分开断言是刻意的:只测 JSONL 的话,写侧白名单会替循环层的漏做遮挡,把
// 「span 里曾经存着正文」这件事测不出来(反过来也一样)。
//
// 口径:工具结果正文留在会话库,span 只留摘要与指纹 —— 于是「成功还是失败、
// 输出多大、两次是不是同一份」仍可分辨,脱敏不该把 span 变得没用。
// captureWriter 收下每个 span 的**原始**记录(未经写侧投影),供测试按 span 层断言。
type captureWriter struct {
mu sync.Mutex
recs []trace.SpanRecord
}
// WriteSpan 记录一份 span。
func (c *captureWriter) WriteSpan(rec trace.SpanRecord) error {
c.mu.Lock()
defer c.mu.Unlock()
c.recs = append(c.recs, rec)
return nil
}
// snapshot 返回已收集记录的副本。
func (c *captureWriter) snapshot() []trace.SpanRecord {
c.mu.Lock()
defer c.mu.Unlock()
out := make([]trace.SpanRecord, len(c.recs))
copy(out, c.recs)
return out
}
// teeWriter 把同一份 span 同时交给「原始记录捕获器」与 JSONL 记录器,使两层
// 断言落在同一次运行上。
type teeWriter []trace.Writer
// WriteSpan 依次写入每个出口,返回第一个错误。
func (t teeWriter) WriteSpan(rec trace.SpanRecord) error {
var firstErr error
for _, w := range t {
if err := w.WriteSpan(rec); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// runToolTurnTraced 跑一个「一次工具调用 + 收尾」的回合,返回原始 span 记录与
// stderr JSONL 的原始字节。
func runToolTurnTraced(t *testing.T, toolResult any, toolErr error) ([]trace.SpanRecord, string) {
t.Helper()
call := `{"tool_calls":[{"name":"echo","args":{"q":"hi"}}]}`
ad := &mockAdapter{chat: []mgResp{
{resp: model.Response{Content: call}},
{resp: model.Response{Content: "done"}},
}}
td := dispatcherWith(t, &mockTool{name: "echo", result: toolResult, err: toolErr})
a := newTestAgent(t, ad, td)
var buf bytes.Buffer
capture := &captureWriter{}
writer := teeWriter{capture, trace.NewJSONRecorder(&buf)}
l := New(Options{Agent: a, Tracer: trace.New(writer), MaxRounds: 8})
if _, err := l.Run(context.Background(), "sess", []model.Message{model.TextMessage(model.RoleUser, "go")}); err != nil {
t.Fatalf("Run: %v", err)
}
return capture.snapshot(), buf.String()
}
// TestRun_ToolResultNeverEntersSpan 是循环层的泄漏钉:工具结果里有一段哨兵正文,
// span 的 props 与 stderr JSONL 里都搜不到它。
//
// 哨兵刻意放在**摘要窗口之外**:结果摘要(result_summary,回看面要读的那份)按
// 设计就是结果的有界前缀(80 rune),它是摘要、不是正文 —— 本测试钉的是「整段
// 结果不再落盘」,因此不能把断言的前提设成「连摘要都不许有」。
func TestRun_ToolResultNeverEntersSpan(t *testing.T) {
t.Parallel()
const sentinel = "SENTINEL-TOOL-BODY-3b7d-工具结果的后半段"
result := strings.Repeat("拿到的报表行:", 40) + sentinel
recs, raw := runToolTurnTraced(t, result, nil)
span := toolSpanOf(t, recs)
if _, ok := span.Props["result"]; ok {
t.Fatalf("tool span 仍带着 result 原文:%+v", span.Props)
}
if _, ok := span.Props["text"]; ok {
t.Fatalf("tool span 带着 text 原文:%+v", span.Props)
}
propsBlob := mustMarshal(t, span.Props)
if strings.Contains(propsBlob, sentinel) {
t.Fatalf("tool span 的 props 带着正文:%s", propsBlob)
}
digest, ok := span.Props["result_digest"].(trace.Digest)
if !ok {
t.Fatalf("tool span 没有 result_digest:%+v", span.Props)
}
if digest.Bytes != len(result) {
t.Fatalf("result_digest.Bytes = %d, want %d(输出大小必须仍可读)", digest.Bytes, len(result))
}
summary, _ := span.Props["result_summary"].(string)
if summary == "" {
t.Fatalf("result_summary 缺席:回看面会失去结果摘要:%+v", span.Props)
}
if n := len([]rune(summary)); n > 81 {
t.Fatalf("result_summary 长度 = %d rune,want 有界(摘要不是正文)", n)
}
// 写侧:同一份记录落盘后仍搜不到正文,也不含任何结果原文的长尾。
if strings.Contains(raw, sentinel) {
t.Fatalf("工具结果正文落进了 JSONL:%s", raw)
}
if strings.Contains(raw, strings.Repeat("拿到的报表行:", 16)) {
t.Fatalf("JSONL 里出现了超出一份摘要的结果原文:%s", raw)
}
}
// TestRun_ToolSpanStillTellsOKFromError 是「脱敏不能把 span 变得没用」的验收钉:
// 成功与失败在落盘上仍可分辨 —— 终态不同、失败的带已脱敏的 error、输出体积可读。
func TestRun_ToolSpanStillTellsOKFromError(t *testing.T) {
t.Parallel()
const secret = "abcdef1234567890"
small := "ok"
large := strings.Repeat("失", 500)
okRecs, okRaw := runToolTurnTraced(t, small, nil)
badRecs, badRaw := runToolTurnTraced(t, nil, &toolFailErr{msg: "boom: token=" + secret + " " + large})
okSpan, badSpan := toolSpanOf(t, okRecs), toolSpanOf(t, badRecs)
if okSpan.Status != trace.SpanStatusOK || badSpan.Status != trace.SpanStatusError {
t.Fatalf("status = %q / %q,成功与失败必须仍可分辨", okSpan.Status, badSpan.Status)
}
okDigest, _ := okSpan.Props["result_digest"].(trace.Digest)
if okDigest.Bytes != len(small) {
t.Fatalf("成功调用的 result_digest.Bytes = %d, want %d", okDigest.Bytes, len(small))
}
// 失败侧写盘面:错误原因仍可读("boom"),凭据已打码,长度有界。
for _, surface := range []string{okRaw, badRaw} {
if strings.Contains(surface, secret) {
t.Fatalf("JSONL 带着凭据原文:%s", surface)
}
}
if !strings.Contains(badRaw, "boom") {
t.Fatalf("失败落盘里没有可诊断的原因:%s", badRaw)
}
if strings.Contains(badRaw, large) {
t.Fatalf("失败落盘里带着整段错误文本:%s", badRaw)
}
}
// toolFailErr 是一个只提供 Error() 的工具失败(模拟工具自己拼的错误文本)。
type toolFailErr struct{ msg string }
func (e *toolFailErr) Error() string { return e.msg }
// toolSpanOf 在记录里取那条工具 span。
func toolSpanOf(t *testing.T, recs []trace.SpanRecord) trace.SpanRecord {
t.Helper()
for _, rec := range recs {
if rec.Name == "tool.echo" {
return rec
}
}
t.Fatalf("记录里没有 tool.echo span:%+v", recs)
return trace.SpanRecord{}
}
// mustMarshal 把一段东西序列化成 JSON 文本,用于「搜不到正文」的断言。
func mustMarshal(t *testing.T, v any) string {
t.Helper()
b, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
return string(b)
}