Files
AskaEth b040f588b2 fix(browser): M53 追加 Chrome 输出预览打码
preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
2026-10-26 22:19:31 +08:00

113 lines
4.4 KiB
Go
Raw Permalink 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 browser
import (
"strconv"
"strings"
"sync"
"unicode/utf8"
)
// 接住子进程输出的两个上限(AGENTS §10:不出现裸魔法数字)。
const (
// chromeOutputMaxBytes 是缓存的**前缀**字节数。为什么留前 N 而不是后 N:Chrome 的
// 抱怨/崩溃信息总在开头,而尾部的常规日志量最大 —— 留头既省内存又留下原因。
chromeOutputMaxBytes = 4096
// chromeOutputMaxLines 是失败上报时最多打出的行数(前 N 行)。
chromeOutputMaxLines = 12
)
// 本文件只做一件事:**接住浏览器自己说的话**。design/28 §6 坑 5「失败要如实报」
// 的落地前提是「看得见失败」—— 启动失败时浏览器把原因写在自己的 stdout/stderr 上
// (参数被拒、profile 锁、崩溃),不接住它,日志里就只剩我们这一侧的猜测。
//
// 三条口径:
//
// - **有界**:只留前 N 字节 / 上报前 N 行。正常启动时 Chrome 也会往 stderr 写常规
// 信息,全打进日志就是刷屏;缓存也无上限地吃内存。
// - **打码**:这是浏览器可控的自由文本,出镜前必过 chromeOutputPreview(凭据形状
// 打码 + rune 上限,见 errtext.go)。它进的不只是日志,还有用户侧的工具结果。
// - **不主动打**:本类型只缓存,**不输出**。成功时只报字节数(Debug),失败时由
// `localChrome.Dial` 一次性把前 N 行与本次 argv 一起报出来(见 launch.go)。
type chromeOutput struct {
mu sync.Mutex
head []byte
total int
truncated bool
}
// Write 实现 io.Writer。stdout 与 stderr 是两条独立的管道,`os/exec` 为它们各起一个
// 复制协程 ⇒ 必须加锁。它**永不返回错误**:缓存失败不能反过来弄坏子进程的写入。
func (o *chromeOutput) Write(p []byte) (int, error) {
o.mu.Lock()
defer o.mu.Unlock()
o.total += len(p)
room := chromeOutputMaxBytes - len(o.head)
if room <= 0 {
if len(p) > 0 {
o.truncated = true
}
return len(p), nil
}
if len(p) > room {
o.head = append(o.head, p[:room]...)
o.truncated = true
return len(p), nil
}
o.head = append(o.head, p...)
return len(p), nil
}
// totalBytes 报告子进程一共写了多少字节(截断时也照实报,不拿留下的那部分当全部)。
func (o *chromeOutput) totalBytes() int {
o.mu.Lock()
defer o.mu.Unlock()
return o.total
}
// 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)
truncated := o.truncated
total := o.total
o.mu.Unlock()
kept := make([]string, 0, chromeOutputMaxLines)
for _, line := range strings.Split(raw, "\n") {
line = strings.Join(strings.Fields(line), " ")
if line == "" {
continue
}
kept = append(kept, line)
if len(kept) == chromeOutputMaxLines {
truncated = true
break
}
}
// 先拼再清洗(而不是逐行清洗):跨行的凭据形状(`access_token=` 与值被换行拆开)
// 只有拼起来之后才看得见。
text := chromeOutputPreview(strings.Join(kept, " | "))
if truncated && text != "" {
text += " | ...(已截断,共 " + strconv.Itoa(total) + " 字节)"
}
return text
}
// truncateRune 去掉尾部可能被切断的半个 UTF-8 字符(按字节截断的代价):最多回退
// utf8.UTFMax-1 个字节,不因个别非法字节丢掉整段可读输出。
func truncateRune(raw []byte) string {
for i := 0; i < utf8.UTFMax-1 && len(raw) > 0 && !utf8.Valid(raw); i++ {
raw = raw[:len(raw)-1]
}
return string(raw)
}