From 8058660537f1fba5b6190b7e9cc6b239beaed466 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 21:57:48 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(browser):=20M53=20=E4=B8=A4=E6=9D=A1?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E4=BE=A7=E8=87=AA=E7=94=B1=E6=96=87=E6=9C=AC?= =?UTF-8?q?=E5=87=BA=E5=8F=A3=E6=94=B6=E6=95=9B=E8=84=B1=E6=95=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Page.navigate 的 errorText 是**协议枚举**面(net error 码):先剥 `net::` 命名空间再交给 trace.ErrorToken,像枚举就原样保留、散文一律丢弃只留字节数; - Runtime.evaluate 的 exceptionDetails.text 是**页面自由文本**面:压平空白 (防日志行伪造)+ trace.RedactTextLimited 按 200 rune 截断(档位理由见 errtext.go:80 会截断真实异常消息,4096 等于允许页面塞 4KB 正文); - 两类守卫不互换(口径见 trace/errortoken.go 的按字段类别判定),清洗实现 仍只有 trace 那一份,本片只做「选哪一道 + 补形状前提」。 测试:出口 A/B 各一条经 RenderText 的真实路径断言(含 URL/凭据形状的假文本), 外加 net::ERR_* 正向保留、上限截断/换行压平、netErrorCode 形状表。三条泄露断言 已验证「退回原样直吐即红」。 --- internal/capability/browser/errtext.go | 98 ++++++++++ internal/capability/browser/errtext_test.go | 200 ++++++++++++++++++++ internal/capability/browser/session.go | 7 +- 3 files changed, 303 insertions(+), 2 deletions(-) create mode 100644 internal/capability/browser/errtext.go create mode 100644 internal/capability/browser/errtext_test.go diff --git a/internal/capability/browser/errtext.go b/internal/capability/browser/errtext.go new file mode 100644 index 0000000..e321f52 --- /dev/null +++ b/internal/capability/browser/errtext.go @@ -0,0 +1,98 @@ +package browser + +// 本文件是**页面侧自由文本出口**的收敛点:CDP 有两个字段会把页面/对端说的话原样 +// 交给我们,它们出镜(进 error ⇒ 日志 + 模型可见视图)之前必须在这里过一道。 +// +// 两个字段**不是同一类**,因此守卫也不同(口径见 internal/runtime/trace/errortoken.go +// 的「按字段类别判定」): +// +// - `Page.navigate` 的 `errorText` 是**协议枚举**:值域是 Chrome 的 net error 码 +// (`net::ERR_NAME_NOT_RESOLVED` 之类),短、标识符形状 ⇒ 用 trace.ErrorToken 守: +// 像枚举就保留,散文一律丢弃; +// - `Runtime.evaluate` 的 `exceptionDetails.text` 是**页面自由文本**:页面的 JS 可以 +// throw 任何东西 —— 包括它读到的 DOM 正文、地址栏里带 token 的 URL。⇒ 默认不带 +// 全文,按「带上限地带」处理:压平空白 + trace.RedactTextLimited 截断。 +// +// 🔴 两类守卫**不能互换**:拿 ErrorToken 去「清洗」自由文本是错的口径(它只会把整段 +// 话删掉,不是清洗);拿 RedactTextLimited 去守枚举字段也不对(它不判形状,挡不住 +// 散文)。清洗实现仍然只有一份,本文件只做「选哪一道 + 补形状前提」。 + +import ( + "fmt" + "strings" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace" +) + +// maxExceptionTextRunes 是 `exceptionDetails.text` 允许出镜的 rune 上限:**200**。 +// +// 为什么不复用既有的两个档位: +// - **80**(`trace.RedactText` 的摘要档)会把真实的异常消息拦腰截断 —— +// `TypeError: Cannot read properties of undefined (reading 'addEventListener')` +// 已经 73 rune,再带上半句调用点就过 80,而调用点是唯一的定位线索; +// - **4096**(网关日志页 `maxLogMsgRunes` 那一档)是给「运维要读全文」的日志行用的; +// 这个字段是**页面自己说的话**,4096 等于允许页面把 4KB 正文塞进一条错误串 —— +// 而这条错误串同时进日志与模型可见视图。 +// +// 200 容得下「异常消息 + 一段调用点」,又把页面可控文本压在「一句话」量级。超限时 +// trace.RedactTextLimited 会补 `…`,因此截断本身是看得见的,不会被误读成全文。 +const maxExceptionTextRunes = 200 + +// netErrorNamespace 是 Chrome net error 码的命名空间前缀(`net::ERR_...`)。 +// +// 为什么要先剥掉它再交给 ErrorToken:ErrorToken 的字符集是 `[A-Za-z0-9_.-]`,**不含 +// `:`**,整串直接送进去会把合法的网络错误码一起判掉 —— 而「合法网络错误码形态保留」 +// 是这条出口的硬要求(上层与工具都在断言它,见 render_test ③)。这个前缀是**协议 +// 常量**,不是从对端抄来的文本,因此剥它是安全的。 +const netErrorNamespace = "net::" + +// netErrorCodePrefix 是 net error 枚举的名字前缀(`ERR_`):所有真实取值都以它开头。 +// 于是这一条额外把「恰好是单个令牌的自由文本」(`hunter2` 之类)也挡掉 —— 形状判定 +// 仍由 trace.ErrorToken 做,本文件只补这一个前提,不复制第二份守卫实现。 +const netErrorCodePrefix = "ERR_" + +// netErrorCode 只放行「像 net error 枚举」的 errorText:`[net::]ERR_<标识符>`。 +// 其余(散文、被回显的 URL、带凭据的文本)返回空串,由调用方按「已隐去」处理。 +func netErrorCode(raw string) string { + text := strings.TrimSpace(raw) + namespace := "" + if rest, ok := strings.CutPrefix(text, netErrorNamespace); ok { + namespace, text = netErrorNamespace, rest + } + if !strings.HasPrefix(text, netErrorCodePrefix) { + return "" + } + token := trace.ErrorToken(text) + if token == "" { + return "" + } + return namespace + token +} + +// navigateCause 把 `Page.navigate` 的 errorText 变成可出镜的原因子句。 +// 调用前提:raw 非空(navigate 只在 errorText 非空时判定为失败)。 +// +// 不是枚举时一个字符的自由文本都不带,只留**字节数** —— 够判断「对端说了句话」, +// 不带那句话(与 cdpError.Error 同一口径)。 +func navigateCause(raw string) string { + if code := netErrorCode(raw); code != "" { + return code + } + return fmt.Sprintf("网络错误文本已隐去(%d 字节)", len(raw)) +} + +// pageExceptionText 是 `exceptionDetails.text` 出镜前的唯一处理:压平空白(一个换行 +// 就能在按行聚合的日志里伪造出「另一条记录」,与 sdklog / judgeReason 同一做法)+ +// 凭据形状打码 + 截断(清洗实现只有一份:trace.RedactTextLimited)。 +func pageExceptionText(raw string) string { + return trace.RedactTextLimited(strings.Join(strings.Fields(raw), " "), maxExceptionTextRunes) +} + +// evaluateCause 把 `Runtime.evaluate` 的 exceptionDetails.text 变成可出镜的原因子句。 +func evaluateCause(raw string) string { + if text := pageExceptionText(raw); text != "" { + return text + } + // 压平之后什么都不剩(对端只给了空白):如实说「没给文本」,不假装异常无因。 + return "页面异常(对端未给文本)" +} diff --git a/internal/capability/browser/errtext_test.go b/internal/capability/browser/errtext_test.go new file mode 100644 index 0000000..73cd76a --- /dev/null +++ b/internal/capability/browser/errtext_test.go @@ -0,0 +1,200 @@ +package browser + +import ( + "context" + "errors" + "strconv" + "strings" + "testing" + "unicode/utf8" +) + +// navigateErrorCDP 造一个「导航必失败、errorText 是给定文本」的假 CDP:只应答 Page.enable +// 与 Page.navigate,导航之后一律拒绝(失败之后不该再有求值)。 +func navigateErrorCDP(t *testing.T, errorText string) *fakeCDP { + t.Helper() + return newFakeCDP(t, func(method string, _ map[string]any) (any, *cdpError) { + switch method { + case "Page.enable": + return map[string]any{}, nil + case "Page.navigate": + return map[string]any{"errorText": errorText}, nil + } + return nil, &cdpError{Code: -32601, Message: "导航失败后不该再发 " + method} + }) +} + +// evaluateExceptionCDP 造一个「导航与就绪都正常、取正文那一次求值抛异常」的假 CDP: +// 异常文本由调用方给(模拟页面 JS 往 exceptionDetails.text 里塞任何东西)。 +func evaluateExceptionCDP(t *testing.T, exceptionText string) *fakeCDP { + t.Helper() + return newFakeCDP(t, func(method string, params map[string]any) (any, *cdpError) { + switch method { + case "Page.enable", "Page.navigate": + return map[string]any{}, nil + case "Page.getFrameTree": + return map[string]any{"frameTree": map[string]any{"frame": map[string]any{"id": "frame-1"}}}, nil + case "Page.createIsolatedWorld": + return map[string]any{"executionContextId": 1}, nil + case "Runtime.evaluate": + expr, _ := params["expression"].(string) + if strings.Contains(expr, "readyState") { + return map[string]any{"result": map[string]any{"value": map[string]any{ + "url": "https://example.com/", "title": "t", "readyState": "complete", + }}}, nil + } + return map[string]any{"exceptionDetails": map[string]any{"text": exceptionText}}, nil + } + return nil, &cdpError{Code: -32601, Message: "unexpected " + method} + }) +} + +// 出口 A(`Page.navigate` 的 errorText):散文形状的值一个字符都不许出镜 —— 代理与网关 +// 会把它回显成任意文本,包括它刚收到的那条带 token 的 URL。判据是「原文本里每个有辨识度 +// 的片段都不在错误串里」,且出镜的是**字节数**(够判断对端说了句话,不带那句话)。 +// +// 删掉 navigateCause 的调用(退回 `fmt.Errorf("%w: %s", ErrNavigate, text)`)时本用例必红。 +func TestManager_RenderTextNavigateErrorTextNeverEchoesFreeText(t *testing.T) { + t.Parallel() + const raw = "Failed loading https://example.com/?access_token=SUPERSECRET123 (via proxy)" + m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, raw))) + + _, err := m.RenderText(context.Background(), "https://example.com/") + if !errors.Is(err, ErrNavigate) { + t.Fatalf("err = %v, want ErrNavigate", err) + } + msg := err.Error() + for _, leak := range []string{"SUPERSECRET123", "access_token", "https://example.com/?", "Failed loading", "proxy"} { + if strings.Contains(msg, leak) { + t.Errorf("错误串泄露了 errorText 的自由文本片段 %q: %q", leak, msg) + } + } + if !strings.Contains(msg, "已隐去") { + t.Errorf("应点明文本已隐去(否则读起来像对端没说话): %q", msg) + } + if !strings.Contains(msg, strconv.Itoa(len(raw))) { + t.Errorf("应带出被隐去文本的字节数 %d(唯一的排查线索): %q", len(raw), msg) + } +} + +// 出口 A 的正向:合法的 net error 枚举**必须原样保留** —— 它是调用方与工具分类失败的 +// 唯一依据(render_test ③ 也在断言它)。这条同时钉住「net:: 前缀要先剥掉再交给 +// ErrorToken」这个实现前提:整串直接送进 ErrorToken 会因 `:` 被判空,正向就丢了。 +func TestManager_RenderTextNavigateErrorTextKeepsNetErrorCode(t *testing.T) { + t.Parallel() + for _, code := range []string{ + "net::ERR_NAME_NOT_RESOLVED", + "net::ERR_CONNECTION_REFUSED", + "net::ERR_TIMED_OUT", + "ERR_ABORTED", + } { + m := renderTestManager(t, cdpEndpointAvail(navigateErrorCDP(t, code))) + _, err := m.RenderText(context.Background(), "https://example.com/") + if !errors.Is(err, ErrNavigate) { + t.Fatalf("%s: err = %v, want ErrNavigate", code, err) + } + if !strings.Contains(err.Error(), code) { + t.Errorf("%s 是合法的网络错误码,必须原样保留,实得 %q", code, err.Error()) + } + } +} + +// 出口 B(`Runtime.evaluate` 的 exceptionDetails.text):页面 JS 可以 throw 任何东西, +// 包括它读到的凭据。判据是「凭据形状被打码、消息本体仍可排障」—— 不是整段丢弃(自由 +// 文本的处置口径是「带上限地带」,见 errtext.go)。 +// +// 删掉 evaluateCause 的调用(退回原样直吐)时本用例必红。 +func TestManager_RenderTextExceptionTextRedactsCredentials(t *testing.T) { + t.Parallel() + const raw = "TypeError: fetch failed https://api.example.com/v1?api_key=sk-live-ABCDEFGH12345" + m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw))) + + _, err := m.RenderText(context.Background(), "https://example.com/") + if !errors.Is(err, ErrEvaluate) { + t.Fatalf("err = %v, want ErrEvaluate", err) + } + msg := err.Error() + for _, leak := range []string{"ABCDEFGH12345", "sk-live"} { + if strings.Contains(msg, leak) { + t.Errorf("错误串泄露了凭据 %q: %q", leak, msg) + } + } + if !strings.Contains(msg, "[redacted]") { + t.Errorf("凭据形状应被打成 [redacted](看得见的打码,不是静默删除): %q", msg) + } + if !strings.Contains(msg, "TypeError") { + t.Errorf("异常消息本体是唯一的定位线索,不该被整段丢掉: %q", msg) + } +} + +// 出口 B 的上限:页面可以把 4KB 正文塞进异常文本,而这条错误串同时进日志与模型可见视图。 +// 判据:超限时截断看得见(`…`),且超出上限的尾部**确实没出镜**;换行被压平(一个换行 +// 就能在按行聚合的日志里伪造出另一条记录)。 +func TestManager_RenderTextExceptionTextIsBoundedAndFlattened(t *testing.T) { + t.Parallel() + const tail = "TAILMARKER" + raw := strings.Repeat("A", maxExceptionTextRunes) + "\n" + tail + m := renderTestManager(t, cdpEndpointAvail(evaluateExceptionCDP(t, raw))) + + _, err := m.RenderText(context.Background(), "https://example.com/") + if !errors.Is(err, ErrEvaluate) { + t.Fatalf("err = %v, want ErrEvaluate", err) + } + msg := err.Error() + if strings.Contains(msg, tail) { + t.Errorf("超出上限的尾部不该出镜: %q", msg) + } + if !strings.HasSuffix(msg, "…") { + t.Errorf("截断必须看得见(尾部补省略号),不许让半截文本读起来像全文: %q", msg) + } + if strings.ContainsAny(msg, "\r\n") { + t.Errorf("异常文本里的换行必须压平(否则可在按行聚合的日志里伪造记录): %q", msg) + } +} + +// pageExceptionText 的档位就是 maxExceptionTextRunes:超限出 maxExceptionTextRunes 个 +// rune 加一个省略号,一个不多。 +func TestPageExceptionTextLimitsRunes(t *testing.T) { + t.Parallel() + got := pageExceptionText(strings.Repeat("长", maxExceptionTextRunes+50)) + if n, want := utf8.RuneCountInString(got), maxExceptionTextRunes+1; n != want { + t.Errorf("rune 数 = %d, want %d(%d + 省略号)", n, want, maxExceptionTextRunes) + } + if !strings.HasSuffix(got, "…") { + t.Errorf("截断应补省略号: %q", got) + } + // 未超限时原样返回,不补省略号(截断标记只表示「确实被截了」)。 + short := pageExceptionText("TypeError: body is gone") + if short != "TypeError: body is gone" { + t.Errorf("未超限不该改动文本: %q", short) + } + // 只给空白 ⇒ 空串,由 evaluateCause 如实说「对端未给文本」。 + if got := evaluateCause(" \n\t "); got != "页面异常(对端未给文本)" { + t.Errorf("空白异常文本的处理 = %q", got) + } +} + +// netErrorCode 只放行「像 net error 枚举」的值:形状判定仍由 trace.ErrorToken 做,本 +// 函数只补「net:: 命名空间可剥 + 必须以 ERR_ 开头」这两个前提 —— 于是「恰好是单个令牌 +// 的自由文本」(`hunter2`)不会被误当成错误码保留。 +func TestNetErrorCodeOnlyAcceptsEnumerations(t *testing.T) { + t.Parallel() + cases := []struct { + in string + want string + }{ + {"net::ERR_NAME_NOT_RESOLVED", "net::ERR_NAME_NOT_RESOLVED"}, + {" net::ERR_ABORTED ", "net::ERR_ABORTED"}, + {"ERR_FAILED", "ERR_FAILED"}, + {"hunter2", ""}, + {"Failed loading https://example.com/", ""}, + {"net::ERR_NOT_A_CODE because spaces", ""}, + {"net::hunter2", ""}, + {"", ""}, + } + for _, c := range cases { + if got := netErrorCode(c.in); got != c.want { + t.Errorf("netErrorCode(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/capability/browser/session.go b/internal/capability/browser/session.go index 785a236..e634a78 100644 --- a/internal/capability/browser/session.go +++ b/internal/capability/browser/session.go @@ -100,7 +100,8 @@ func (s *session) navigate(ctx context.Context, url string) (pageState, error) { return pageState{}, err } if text := strings.TrimSpace(res.ErrorText); text != "" { - return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, text) + // errorText 是**协议枚举**面:像 net error 码就保留,散文一律丢弃(见 errtext.go)。 + return pageState{}, fmt.Errorf("%w: %s", ErrNavigate, navigateCause(text)) } if err := s.waitReady(ctx); err != nil { return pageState{}, err @@ -163,7 +164,9 @@ func (s *session) evaluate(ctx context.Context, expr string, out any) error { return err } if res.ExceptionDetails != nil { - return fmt.Errorf("%w: %s", ErrEvaluate, res.ExceptionDetails.Text) + // text 是**页面自由文本**面:页面 JS 可以 throw 任何东西(含 DOM 正文、带 token + // 的 URL)⇒ 压平空白 + 凭据打码 + 截断后再出镜(见 errtext.go)。 + return fmt.Errorf("%w: %s", ErrEvaluate, evaluateCause(res.ExceptionDetails.Text)) } if out == nil || len(res.Result.Value) == 0 { return nil From b040f588b28cc0e4b8b6d3cc78b1ca69306cd6f5 Mon Sep 17 00:00:00 2001 From: AskaEth Date: Mon, 26 Oct 2026 22:19:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?fix(browser):=20M53=20=E8=BF=BD=E5=8A=A0=20?= =?UTF-8?q?Chrome=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) + " 字节)" }