preview() 出镜前过 trace.RedactTextLimited(600 rune),覆盖失败日志的 chrome_output 与 withChromeOutput 追加给调用方的错误串;补凭据形状/原话可见/有界三条测试,删实现可打红。
113 lines
4.4 KiB
Go
113 lines
4.4 KiB
Go
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)
|
||
}
|