From b040f588b28cc0e4b8b6d3cc78b1ca69306cd6f5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 22:19:31 +0800 Subject: [PATCH] =?UTF-8?q?fix(browser):=20M53=20=E8=BF=BD=E5=8A=A0=20Chro?= =?UTF-8?q?me=20=E8=BE=93=E5=87=BA=E9=A2=84=E8=A7=88=E6=89=93=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。 --- internal/capability/browser/adopt.go | 12 ++ .../browser/chromeoutput_redact_test.go | 154 ++++++++++++++++++ internal/capability/browser/errtext.go | 34 +++- internal/capability/browser/launchoutput.go | 15 +- 4 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 internal/capability/browser/chromeoutput_redact_test.go diff --git a/internal/capability/browser/adopt.go b/internal/capability/browser/adopt.go index 48f60fd..3beb171 100644 --- a/internal/capability/browser/adopt.go +++ b/internal/capability/browser/adopt.go @@ -119,6 +119,18 @@ func portListening(ctx context.Context, port int) bool { // 侧看到的工具结果(`browserError`)来自这条错误 —— 不上报原话,用户就只看到我们这一侧的猜测 // (真机踩过:窗口上跳出浏览器的抱怨,日志里像浏览器坏了,工具结果里什么都没有)。 // 没有输出时也如实点明,那本身是线索(参数被拒之类通常会带话)。 +// +// 拼接后整条都是**调用方可见**的,因此两半各自的出镜口径必须交代清楚: +// +// - `preview` 是浏览器可控的自由文本 ⇒ 已在 preview() 里过了 chromeOutputPreview +// (凭据形状打码 + 600 rune 上限)。这里**不再**加一道带上限的守卫:同一份上限套在 +// 整条上,会把下面这条错误自己的**可操作**建议截掉(`profileBusyHint` 里带 profileDir +// 路径,adopt_test 钉着它必须可见); +// - `err` 那一半是我们自己拼的(ErrLaunch 分类标记、endpointURL 回环地址、两条 hint 里 +// 的文件系统路径),**不含对端文本** —— 对端说的话一律经 preview 走,没有第二条入口。 +// +// 换言之:整条被覆盖,靠的是「对端文本只有一个入口(preview)」这个结构约束,而不是在 +// 拼接处再洗一遍。 func withChromeOutput(err error, preview string) error { if preview == "" { return fmt.Errorf("%w;浏览器没有输出任何内容", err) diff --git a/internal/capability/browser/chromeoutput_redact_test.go b/internal/capability/browser/chromeoutput_redact_test.go new file mode 100644 index 0000000..fe72d15 --- /dev/null +++ b/internal/capability/browser/chromeoutput_redact_test.go @@ -0,0 +1,154 @@ +package browser + +// 本文件钉住「浏览器自己说的话」这条自由文本出口的两条约束 —— 既**有界**(600 rune + +// 12 行)又**打码**(凭据形状不外泄),而且两者都**不许**伤到排障信息:普通原话必须仍然 +// 看得见(这是 launch_test / adopt_test 既有的「失败要如实报」口径)。 +// +// 「删实现能打红」的落点:把 preview() 里的 chromeOutputPreview 调用退回原样直吐,本文件 +// 的凭据断言(以及日志读面那条)会全红。 + +import ( + "context" + "errors" + "log/slog" + "strings" + "testing" + "time" + "unicode/utf8" +) + +// TestChromeOutputPreviewRedactsCredentials 是这条出口的**核心断言**:浏览器把带凭据的 +// 地址/请求头打进 stderr 时,出镜的预览里只有 [redacted],没有凭据本体;同时排障用的 +// 普通原话一个字都不许少。 +func TestChromeOutputPreviewRedactsCredentials(t *testing.T) { + t.Parallel() + const ( + complaint = "ERROR:process_singleton.cc(343)] 参数被拒: --remote-allow-origins=*" + credURL = "ERROR:navigation] 载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345" + bearer = "WARNING:net] 请求头 Authorization: Bearer abcdefghij1234567890" + ) + var out chromeOutput + for _, line := range []string{complaint, credURL, bearer} { + if _, err := out.Write([]byte(line + "\n")); err != nil { + t.Fatalf("chromeOutput.Write 永不返回错误: %v", err) + } + } + + got := out.preview() + t.Logf("实测预览=%q", got) + + for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} { + if strings.Contains(got, secret) { + t.Errorf("凭据形状 %q 原样出镜了: %q", secret, got) + } + } + if !strings.Contains(got, "[redacted]") { + t.Errorf("凭据应被打成 [redacted](看得见的打码,不是静默删除): %q", got) + } + // 反面:打码不能顺手把「浏览器为什么拒绝」一起吃掉 —— 那正是这条出口存在的理由。 + for _, want := range []string{complaint, "载入失败", "请求头 Authorization"} { + if !strings.Contains(got, want) { + t.Errorf("排障用的普通原话 %q 不该被洗掉: %q", want, got) + } + } +} + +// TestChromeOutputPreviewIsBounded 钉住上限的两半:正文被截到 chromeOutputPreviewRunes +// 并留 `…`;而「其实说了多少字节」这条**我们自己的**说明必须活着(上限只压正文,不吃 +// 量级)。没有它,读者会把半截输出误当成完整输出。 +func TestChromeOutputPreviewIsBounded(t *testing.T) { + t.Parallel() + + t.Run("单行超长只留上限并标截断", func(t *testing.T) { + t.Parallel() + var out chromeOutput + if _, err := out.Write([]byte(strings.Repeat("A", 4000) + "\n")); err != nil { + t.Fatalf("Write: %v", err) + } + got := out.preview() + if !strings.HasSuffix(got, "…") { + t.Errorf("超上限必须留可见的截断标记: 长度=%d 尾部=%q", utf8.RuneCountInString(got), tailRunes(got, 8)) + } + if n := utf8.RuneCountInString(got); n != chromeOutputPreviewRunes+1 { + t.Errorf("正文应正好截到 %d rune + 一个 `…`,实际 %d rune", chromeOutputPreviewRunes, n) + } + }) + + t.Run("字节截断时仍报总量", func(t *testing.T) { + t.Parallel() + var out chromeOutput + // 5000 字节 > chromeOutputMaxBytes(4096)⇒ 缓存本身就被截,preview 必须点明总量。 + if _, err := out.Write([]byte(strings.Repeat("B", 5000))); err != nil { + t.Fatalf("Write: %v", err) + } + got := out.preview() + if !strings.Contains(got, "已截断,共 5000 字节") { + t.Errorf("字节被截时必须报出真实总量(不能拿留下的那部分当全部): %q", tailRunes(got, 40)) + } + }) +} + +// TestDial_ChromeOutputOnFailureIsRedacted 走真实失败路径(假浏览器 exit 0 ⇒ +// profileBusyHint + withChromeOutput),断言**拼接后的整条**(hint 那半 + 浏览器原话那半) +// 在用户侧可见的错误串与失败日志里都没有凭据形状,且两半的信息都还在。 +func TestDial_ChromeOutputOnFailureIsRedacted(t *testing.T) { + t.Parallel() + const ( + complaint = "不支持的标记: --remote-allow-origins=*" + credLine = "载入失败 https://example.com/cb?access_token=sk-live-ABCDEFGH12345" + bearerLine = "请求头 Authorization: Bearer abcdefghij1234567890" + ) + log, buf := newCapturedLog(slog.LevelDebug) + l := newFakeChrome(t, fakeBrowserScript{ + Stderr: []string{complaint, credLine, bearerLine}, ExitCode: 0, + }, 5*time.Second, log) + + _, stop, err := l.Dial(context.Background()) + if stop != nil { + t.Error("失败路径不该交出停止函数") + } + if err == nil { + t.Fatal("进程 exit 0 却不写端口记录 ⇒ 必须报错") + } + if !errors.Is(err, ErrLaunch) { + t.Fatalf("仍须包住 ErrLaunch: %v", err) + } + msg := err.Error() + logs := buf.String() + t.Logf("实测 error=%v\n实测日志=%s", err, logs) + + for _, secret := range []string{"sk-live-ABCDEFGH12345", "abcdefghij1234567890", "access_token="} { + if strings.Contains(msg, secret) { + t.Errorf("返回给调用方的错误里出现了凭据形状 %q(用户侧工具结果直接来自这条串): %v", secret, err) + } + if strings.Contains(logs, secret) { + t.Errorf("失败日志里出现了凭据形状 %q(密钥不落日志):\n%s", secret, logs) + } + } + if !strings.Contains(msg, "[redacted]") { + t.Errorf("打码应当看得见([redacted]): %v", err) + } + + // 两半都得活着:hint 那半(可操作建议 + profileDir 路径)不能被上限截掉, + // 浏览器原话那半是这条出口存在的理由。 + for _, want := range []string{ + "绝不杀别人的浏览器", l.profileDir, + complaint, "载入失败", + } { + if !strings.Contains(msg, want) { + t.Errorf("整条错误里缺少 %q(过度打码/截断): %v", want, err) + } + } + if !strings.Contains(logs, complaint) { + t.Errorf("失败日志里丢了浏览器原话 %q(排障信息):\n%s", complaint, logs) + } +} + +// tailRunes 返回 s 的末 n 个 rune,供失败断言打印尾部而不是把 4000 字符灌进报告。 +func tailRunes(s string, n int) string { + r := []rune(s) + if len(r) <= n { + return s + } + return string(r[len(r)-n:]) +} diff --git a/internal/capability/browser/errtext.go b/internal/capability/browser/errtext.go index e321f52..2e2638b 100644 --- a/internal/capability/browser/errtext.go +++ b/internal/capability/browser/errtext.go @@ -1,9 +1,9 @@ package browser -// 本文件是**页面侧自由文本出口**的收敛点:CDP 有两个字段会把页面/对端说的话原样 -// 交给我们,它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。 +// 本文件是**对端自由文本出口**的收敛点:有三处会把页面/浏览器自己说的话原样交给我们, +// 它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。 // -// 两个字段**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go +// 三处**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go // 的「按字段类别判定」): // // - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码 @@ -11,7 +11,9 @@ package browser // 像枚举就保留,散文一律丢弃; // - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以 // throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带 -// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断。 +// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断; +// - 浏览器自己的 stdout/stderr 预览(launchoutput.go 的 `preview()`)也是自由文本: +// 参数被拒时 Chromium 会把**本次 argv** 与地址原话打出来(`chromeOutputPreview`)。 // // 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段 // 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住 @@ -96,3 +98,27 @@ func evaluateCause(raw string) string { // 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。 return "页面异常(对端未给文本)" } + +// chromeOutputPreviewRunes 是浏览器 stdout/stderr 预览出镜时的 rune 上限:**600** +// (= 12 行 × 50 rune)。 +// +// 为什么是这个量级:预览的**行**预算已经是 12 行(chromeOutputMaxLines),50 rune 大致 +// 是一个短分句 —— Chromium 的抱怨句式是「原因在前」(`ERROR:...] 参数被拒:...`), +// 所以 600 保得住原因那一句,又把「一整块转储」挡在模型可见的错误串之外。 +// +// 为什么不沿用另外两档:80(`trace.RedactText` 摘要档)连**一行**浏览器日志都放不下; +// 4096(`chromeOutputMaxBytes` 缓存档、网关日志页 maxLogMsgRunes)是「运维读全文」的 +// 量级,对浏览器可控的字段等于允许把 4KB 正文塞进工具结果。 +// +// 为什么 600 不会把「到底说了多少」藏起来:本上限只压在**预览正文**上,调用方 +// (launchoutput.go)随后仍会追加「已截断,共 N 字节」——读者看到的永远是「这里有 600 +// rune 的原话 + 它其实说了 N 字节」,截断与量级都看得见。 +const chromeOutputPreviewRunes = 600 + +// chromeOutputPreview 是浏览器 stdout/stderr 预览出镜前的唯一处理:凭据形状打码 + +// 截断(清洗实现只有一份:trace.RedactTextLimited)。它服务**两个**读面 —— 失败日志的 +// `chrome_output` 字段与返回给调用方的错误串(launch.go 的 launchOnce),因此收敛在 +// preview() 里,两个读面不会判得不一致。 +func chromeOutputPreview(text string) string { + return trace.RedactTextLimited(text, chromeOutputPreviewRunes) +} diff --git a/internal/capability/browser/launchoutput.go b/internal/capability/browser/launchoutput.go index 4d1bfd9..5bed489 100644 --- a/internal/capability/browser/launchoutput.go +++ b/internal/capability/browser/launchoutput.go @@ -20,10 +20,12 @@ const ( // 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上 // (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。 // -// 两条口径: +// 三条口径: // // - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规 // 信息,全打进日志就是刷屏;缓存也无上限地吃内存。 +// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状 +// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。 // - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由 // `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。 type chromeOutput struct { @@ -65,6 +67,13 @@ func (o *chromeOutput) totalBytes() int { // preview 返回失败上报用的「前 N 行」:每行内的空白压成单空格、空行去掉;被截断时 // 在末尾点明**截断与总字节数**(绝不把半截输出说得像完整输出)。没有输出时返回空串, // 调用方据此判断「浏览器一个字都没说」。 +// +// 🔴 正文出镜前必过 chromeOutputPreview(凭据形状打码 + 600 rune 上限,见 errtext.go): +// 这个字符串**是浏览器可控的自由文本**,而它会同时进失败日志与**返回给调用方的错误串** +// (launchOnce ⇒ 用户侧工具结果)。真机上 Chromium 会把本次 argv、被拒的参数、地址原话 +// 打出来 —— 一旦里面出现 `?access_token=` 或 `Bearer ...`,原样透出就是凭据落进日志与 +// 模型上下文。上限只压正文:下面的「已截断,共 N 字节」后缀是我们自己的定长说明,不进 +// 这份预算,因此截断与量级都照实可见。 func (o *chromeOutput) preview() string { o.mu.Lock() raw := truncateRune(o.head) @@ -84,7 +93,9 @@ func (o *chromeOutput) preview() string { break } } - text := strings.Join(kept, " | ") + // 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开) + // 只有拼起来之后才看得见。 + text := chromeOutputPreview(strings.Join(kept, " | ")) if truncated && text != "" { text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)" }