From 6d0df6a82503e76cfb19893f4578b982aae7bb10 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 13:20:45 +0800 Subject: [PATCH] =?UTF-8?q?fix(trace):=20=E7=99=BD=E5=90=8D=E5=8D=95?= =?UTF-8?q?=E6=94=BE=E8=A1=8C=20session=20=E2=80=94=E2=80=94=20=E5=BD=92?= =?UTF-8?q?=E5=B1=9E=E6=A0=87=E8=AF=86=E5=8F=AF=E8=AF=BB=EF=BC=8C=E6=AD=A3?= =?UTF-8?q?=E6=96=87=E8=BE=B9=E7=95=8C=E4=B8=8D=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人 2026-10-26 裁定:session 是**业务标识**,不是正文。它是排障时把一条 stderr 日志对回某个会话的唯一线索;而 /admin/flow/graph 本来就按会话返回、连 sessionId 都给了,图里多一个 session 不构成新的暴露。因此把 session 加进共享白名单 (safePropKeys,走 redactValue 的普通清洗),不再被写侧折成 Digest。 「不放宽白名单」仍是原则:本次只放行这一个键,正文的边界一个字没动 —— - trace 侧新增 TestJSONRecorder_KeepsSessionReadableDropsBody,**一条用例同时钉 两件相反的事**:JSONL 里 `"session":"web#lane-2"` 原文可读;同一 span 里的 result / text 哨兵正文仍然搜不到,且只在 omitted 里留下体积与指纹。 - loop 侧真循环用例补一句断言:落盘里 `"session":"sess"` 可读,而工具结果哨兵 同时仍不可见。 - 变异验证:把 session 从白名单摘掉,上述两处都会变红(session 落到 omitted)。 回归:go build ./... + go vet ./... + go test -count=1 ./... 全绿; trace/loop/orchestration 另跑 -race 全绿。 --- .../runtime/loop/span_write_redaction_test.go | 6 +++ internal/runtime/trace/span_props.go | 10 ++++- .../trace/write_side_redaction_test.go | 38 +++++++++++++++++++ 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/internal/runtime/loop/span_write_redaction_test.go b/internal/runtime/loop/span_write_redaction_test.go index e5704f6..7dfd429 100644 --- a/internal/runtime/loop/span_write_redaction_test.go +++ b/internal/runtime/loop/span_write_redaction_test.go @@ -130,6 +130,12 @@ func TestRun_ToolResultNeverEntersSpan(t *testing.T) { if strings.Contains(raw, strings.Repeat("拿到的报表行:", 16)) { t.Fatalf("JSONL 里出现了超出一份摘要的结果原文:%s", raw) } + // 归属标识反而必须原文可读(主人 2026-10-26 裁定:session 是业务标识,不是 + // 正文;排障要把一条 stderr 日志对回某个会话)。它可读与正文不可见并不矛盾, + // 这一条与上一条同时成立才是这一片的验收口径。 + if !strings.Contains(raw, `"session":"sess"`) { + t.Fatalf("JSONL 里 session 不可读:%s", raw) + } } // TestRun_ToolSpanStillTellsOKFromError 是「脱敏不能把 span 变得没用」的验收钉: diff --git a/internal/runtime/trace/span_props.go b/internal/runtime/trace/span_props.go index 80d290b..03db52a 100644 --- a/internal/runtime/trace/span_props.go +++ b/internal/runtime/trace/span_props.go @@ -24,11 +24,17 @@ import "strings" // - memory(裁决 11.4.2):hits / count / failed(命中、写入与失败条数)、 // bytes(注入块字节数)、scope / action(记忆作用域与操作名)—— 只记元数据, // 不含任何记忆正文; -// - orchestration(裁决 11.4.2):subagent(被派发的子代理 id)。 +// - orchestration(裁决 11.4.2):subagent(被派发的子代理 id); +// - 归属:session —— span 所属的 flow 图/会话标识(`{会话}[#{路}]`)。 // // 表外的键**一律不出图、也不进 JSONL**。确实需要保留诊断线索的,用 Digest // (digest.go)作为 prop 值 —— 它由本包构造,构造上不含正文,两条投影都会放行。 // +// session 是**业务标识,不是正文**(主人 2026-10-26 裁定放行):它是排障时把 +// 一条 stderr 日志对回某个会话的唯一线索,而 `/admin/flow/graph` 本来就按会话 +// 返回、连 sessionId 都给了,因此放行它不构成新的暴露。它走 redactValue 的普通 +// 清洗(自身没有凭据形状),不做摘要 —— 变成 Digest 就失去了「对回会话」的用途。 +// // persona 是子代理分支根节点上的「目标角色 id」:orchestration/scheduler.go 只在 // subagent_spawn 显式指定角色时才设置它,且该 id 必须先通过角色注册表校验 // (ErrUnknownPersona 会拒绝未注册的 id,orchestration/persona.go),因此它是注册表里 @@ -41,7 +47,7 @@ var safePropKeys = []string{ "args_keys", "args_summary", "result_summary", "allow", "action", "elapsed_ms", "hits", "count", "failed", "bytes", "scope", - "subagent", + "subagent", "session", } // safePropKeySet 是 safePropKeys 的查表形态。它在包初始化时由同一张表构造一次, diff --git a/internal/runtime/trace/write_side_redaction_test.go b/internal/runtime/trace/write_side_redaction_test.go index c3843a9..c8f6277 100644 --- a/internal/runtime/trace/write_side_redaction_test.go +++ b/internal/runtime/trace/write_side_redaction_test.go @@ -35,6 +35,44 @@ func writeRecords(t *testing.T, recs ...SpanRecord) ([]SpanRecord, string) { return decodeLines(t, buf.Bytes()), buf.String() } +// TestJSONRecorder_KeepsSessionReadableDropsBody 是「日志里 session 可读、正文仍 +// 不可见」**同一条断言**(主人 2026-10-26 裁定放行 session): +// +// - session 是业务标识、不是正文,进白名单后必须**原文可读**(排障要把一条 +// stderr 日志对回某个会话;变成 Digest 就失去用途); +// - 同一 span 里的正文(result / text)仍必须落不到盘上 —— 这一条防的是 +// 「顺手放宽了别的东西」:白名单加一个键,正文的边界不能跟着动。 +func TestJSONRecorder_KeepsSessionReadableDropsBody(t *testing.T) { + t.Parallel() + const session = "web#lane-2" + rec := spanRec("sess1", "", "tool.fs.read", SpanKindTool, SpanStatusOK, map[string]any{ + "session": session, + "result": sentinelToolBody, + "text": sentinelToolBody, + }) + + recs, raw := writeRecords(t, rec) + if !strings.Contains(raw, `"session":"`+session+`"`) { + t.Fatalf("JSONL 里 session 不可读(它是排障对回会话的唯一线索):%s", raw) + } + if strings.Contains(raw, sentinelToolBody) { + t.Fatalf("放行 session 的同时正文也落盘了:%s", raw) + } + + got := byID(recs, "sess1") + if got.Props["session"] != session { + t.Fatalf("session prop = %v, want 原文 %q", got.Props["session"], session) + } + for _, key := range []string{"result", "text"} { + if _, ok := got.Props[key]; ok { + t.Fatalf("表外 prop %q 落盘了:%+v", key, got.Props) + } + if d := omittedEntry(t, got, key); d.SHA256 == "" || d.Bytes == 0 { + t.Fatalf("omitted 里没有 %q 的体积/指纹:%+v", key, d) + } + } +} + // TestJSONRecorder_DropsRawContentAndKeepsDigest 是写侧投影的主钉: // - 表外的 `result` / `text`(正文形态)绝不落盘; // - 白名单键仍落盘(图上与日志里要能看见 tool / error 之类的元数据);