feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

依据 `DevDocs/reference/chromium-cdp/`(9 份实测+官方源码):旧姿势传 `--remote-debugging-port=0`
会被 Chromium 当成 ChromeDriver 式自动化启动 ⇒ 主动打开 `AutomationControlled`
⇒ `navigator.webdriver=true`;那个 bad-flag 是承重覆盖,但代价是**每次有头弹横幅**。
改用显式端口后 `webdriver` **本来就是 false** ⇒ flag 变纯冗余 ⇒ 去掉它,**两者兼得**。

- `launch.go`:`--remote-debugging-port=<我们选的端口>` 替掉 `=0`;**删掉**
  `--disable-blink-features=AutomationControlled`(注释写明理由)。
- 新增 `portrecord.go`:端口来源与落点的**唯一出处** —— 取空闲端口(`net.Listen`
  取号后立刻关闭,**本进程不常驻监听**)、写/读/清**我们自己的记录**
  `<profile>/cyrene-debug-port`(选它与 Chrome 的 `DevToolsActivePort` 同目录:
  天然按 profile 隔离、profile 换掉记录自动消失、内容只有端口号不含凭据);
  **兼容老记录**(换二进制时上一版起的浏览器可能还在跑 ⇒ 认不出会把"自己的浏览器"
  误报成"profile 被别人占了");就绪判据换成「我们选的那个端口答得上 `/json/version`」;
  TOCTOU 处理 = 端口被占**换号重试一次**,重试仍失败则如实报错且不留记录。
- 三条认领路径(复用/清陈记录/如实报错保留现场)与三条失败分类(进程立刻退出 /
  等端口超时 / 预算耗尽)**全部保留**;`ErrLaunch` + `errors.Is` 口径未变;
  **绝不杀进程**红线未变。

真机自证(子代理跑,默认 Skip 的 `realchrome_test.go`,`CYRENE_BROWSER_CDP_E2E=1` 开闸):
`webdriver=false` · 命令行**无** flag/`--enable-automation`/`--headless` · UA 无
`HeadlessChrome`、`plugins=5`、`languages=zh-CN,zh`、`window.chrome` 在 ·
**UI Automation 控件树 17 个命名节点里没有「信息栏容器/信息栏」⇒ 无横幅** ·
第二次 Dial **复用同端口、浏览器主进程仍是同一个 PID** · 收尾按 profile 路径筛残留 = 0。

验收(主会话复跑 + 亲自变异):整树 `go build ./...` + 两包 vet 绿;browser 包测试绿;
我用 -overlay 把该 flag 加回参数表 ⇒ `TestLaunchArgs_HeadfulCarriesNoAutomationTraces`
变红(逐字报"不许传的 flag")。

⚠️ 未验(如实):无头档的横幅(无窗口判不了)、`chrome-headless-shell`、风控侧是否把
"端口写法"本身当信号、生产 profile 整链路(未碰)。
This commit is contained in:
AskaEth committed 2026-10-07 11:18:25 +08:00
1 parent 74e171bc04
commit 6fe68ecfac
11 files changed
+1316 -320

No files matched your search

+101 -31
View File
@@ -7,19 +7,20 @@ package browser
// ③ 端口在听却握不上手 ⇒ 如实报错(带处置建议),不起进程;
// ④ 被占用且拿不到端点 ⇒ 情况②之外的「真被占用」:如实报 + 带上 Chrome 自己的原话。
//
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**诱饵** ——
// 这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
// (fakebrowser_test.go 的重跑自己范式)。真走了启动路径,喊声会进日志、Dial 也会失败;
// 用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒ 判据①立刻变红。)
// 判据①必须有**能证伪**的观察手段:怎么证明「没起第二个进程」?答案是**两重观察** ——
// ① **诱饵**:这些用例给 localChrome 配的 exe 是「一被拉起就大声喊、然后退出」的假浏览器
// (fakebrowser_test.go 的重跑自己范式),真走了启动路径,喊声会进日志、Dial 也会失败;
// ② **启动计数**:假浏览器每次被拉起都会往 profile 目录追一行(`fakeLaunches`),可以直接
// 数出「起了几次」。用例因此是绿的 ⇒ 启动路径没被走到。(变异自证:把复用那一支去掉 ⇒
// 判据①立刻变红。)
//
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的
// DevToolsActivePort,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把
// 命令委托给了已在跑的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
// 现场依据(主人实测复现):同一个 profile 上再起一次 Chrome 时,新进程 exit 0、不写新的端口
// 记录,stderr 逐字只有一句「正在现有的浏览器会话中打开。」—— Chromium 把命令委托给了已在跑
// 的实例。不做复用,这一档就一直报启动失败,直到那个进程退出。
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
@@ -62,19 +63,29 @@ func portOfEndpoint(t *testing.T, endpoint string) int {
return port
}
// writeDevToolsPortFile 写一条端口记录(真浏览器的格式:首行端口、次行 browser_guid)。
func writeDevToolsPortFile(t *testing.T, dir string, port int) {
// portFileExists 报告**我们自己的**端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
func portFileExists(dir string) bool {
_, err := os.Stat(portRecordPath(dir))
return err == nil
}
// writePortRecordFile 写一条我们自己的端口记录(实现见 portrecord.go;格式:一行端口号)。
func writePortRecordFile(t *testing.T, dir string, port int) {
t.Helper()
path := filepath.Join(dir, devToolsPortFile)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
if err := writePortRecord(dir, port); err != nil {
t.Fatalf("写端口记录: %v", err)
}
}
// portFileExists 报告端口记录还在不在(不清陈记录 / 保留现场两类断言都用它)。
func portFileExists(dir string) bool {
_, err := os.Stat(filepath.Join(dir, devToolsPortFile))
return err == nil
// writeLegacyDevToolsPortFile 写一条**老格式**的端口记录(Chromium 在 `port==0` 时写的
// `DevToolsActivePort`:首行端口、次行 browser_guid)—— 用它钉住「换二进制后上一版起的浏览器
// 仍能被复用」这条兼容路径。
func writeLegacyDevToolsPortFile(t *testing.T, dir string, port int) {
t.Helper()
path := filepath.Join(dir, legacyDevToolsPortFile)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\nGUID\n"), 0o600); err != nil {
t.Fatalf("写老格式端口记录: %v", err)
}
}
// TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess 是判据①:profile 里记的调试端点
@@ -87,7 +98,7 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
@@ -108,31 +119,90 @@ func TestDial_ReusesLiveEndpointAndDoesNotSpawnSecondProcess(t *testing.T) {
if !strings.Contains(logs, "source="+logSource) {
t.Errorf("这条 INFO 必须带来源(AGENTS §10 禁无来源裸日志):\n%s", logs)
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次)", n)
}
stop() // 复用来的实例不归我们杀:停止函数必须是空操作(能调用即通过)
}
// TestDial_ReusesLegacyDevToolsActivePort 钉住一条**兼容**路径:老版二进制(那时我们传的是
// `--remote-debugging-port=0`)在 profile 里留下的 `DevToolsActivePort` 若还活着,也必须被认领。
//
// 为什么值得单独钉:换二进制时上一版起的浏览器可能还在跑(本项目的后端常驻、浏览器按需回收)。
// 若不认那个文件,新二进制会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」,
// 于是功能一直不可用,直到那个浏览器自己空闲回收。
func TestDial_ReusesLegacyDevToolsActivePort(t *testing.T) {
t.Parallel()
cdp := newFakeCDP(t, func(string, map[string]any) (any, *cdpError) { return map[string]any{}, nil })
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 5*time.Second, log)
writeLegacyDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, cdp.base))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("老格式记录指向的活端点也该被复用,实得: %v", err)
}
defer stop()
if endpoint != cdp.base {
t.Errorf("endpoint = %q, want %q", endpoint, cdp.base)
}
if n := fakeLaunches(l.profileDir); n != 0 {
t.Errorf("复用这条路上不该起任何进程(假浏览器被拉起 %d 次):\n%s", n, buf.String())
}
}
// TestDial_ClearsStaleLegacyRecord 补上「陈记录清理」的另一半:**老格式**记录指向的端口已经
// 没人听时,它也必须被清掉(否则下一次启动还会去探一个死端点),且不挡住正常启动。
func TestDial_ClearsStaleLegacyRecord(t *testing.T) {
t.Parallel()
log, _ := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
writeLegacyDevToolsPortFile(t, l.profileDir, deadPort(t))
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("陈的老格式记录不该挡住正常启动: %v", err)
}
defer stop()
port, ok := readPortRecord(l.profileDir)
if !ok || endpoint != endpointURL(port) {
t.Fatalf("成功之后应当记下本次用的端口:endpoint=%q port=%d ok=%v", endpoint, port, ok)
}
if _, err := os.Stat(filepath.Join(l.profileDir, legacyDevToolsPortFile)); err == nil {
t.Error("陈的老格式记录应当已被清掉(留着它下次还会去探那个死端口)")
}
}
// TestDial_ClearsStalePortFileThenLaunches 是判据②:端口记录在、但端口已经没人听 ⇒ 那是
// **陈记录**,清掉后照常启动。两个子用例各钉一半:
//
// (1) 清掉之后,本次就绪判定认**新进程写的**那个端口;
// (2) 清掉是**真清**:新进程不写端口时,Dial 必须报「等端口超时」—— 而不是把陈端口当成
// (1) 清掉之后,本次就绪判定认**新进程实际服务的**那个端口;
// (2) 清掉是**真清**:新进程从不就绪时,Dial 必须报「等端口就绪超时」—— 而不是把陈端口当成
// 本次结果稀里糊涂地成功(那正是「不清陈记录」会造成的伪装)。
func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
t.Run("以新进程写的端口为准", func(t *testing.T) {
t.Run("以新进程实际服务的端口为准", func(t *testing.T) {
t.Parallel()
log, _ := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{WritePort: 55543, SleepMS: 3000}, 5*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, deadPort(t))
l := newFakeChrome(t, fakeBrowserScript{Serve: serveCDP}, 5*time.Second, log)
stale := deadPort(t)
writePortRecordFile(t, l.profileDir, stale)
endpoint, stop, err := l.Dial(context.Background())
if err != nil {
t.Fatalf("陈记录不该挡住正常启动: %v", err)
}
defer stop()
if want := "http://127.0.0.1:55543"; endpoint != want {
t.Errorf("endpoint = %q, want %q(陈端口被当成本次结果了?)", endpoint, want)
port, ok := readPortRecord(l.profileDir)
if !ok {
t.Fatal("成功之后必须记下本次用的端口")
}
if endpoint != endpointURL(port) {
t.Errorf("endpoint = %q, want %q", endpoint, endpointURL(port))
}
if port == stale {
t.Errorf("陈端口(%d)被当成本次结果了", stale)
}
})
@@ -140,23 +210,23 @@ func TestDial_ClearsStalePortFileThenLaunches(t *testing.T) {
t.Parallel()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{
Stderr: []string{"起来了,但从不写端口记录"}, SleepMS: 3000,
Stderr: []string{"起来了,但从不答 CDP"}, SleepMS: 3000,
}, 300*time.Millisecond, log)
stale := deadPort(t)
writeDevToolsPortFile(t, l.profileDir, stale)
writePortRecordFile(t, l.profileDir, stale)
endpoint, _, err := l.Dial(context.Background())
if err == nil {
t.Fatalf("陈记录被清掉、新进程又不写端口 ⇒ 必须报错;实得 endpoint=%q", endpoint)
t.Fatalf("陈记录被清掉、新进程又从不就绪 ⇒ 必须报错;实得 endpoint=%q", endpoint)
}
t.Logf("实测 error=%v\n实测日志=%s", err, buf.String())
if !errors.Is(err, ErrLaunch) {
t.Fatalf("仍须包住 ErrLaunch: %v", err)
}
if !strings.Contains(err.Error(), launchMarkerPortTimeout) {
t.Errorf("应当读成「等 DevToolsActivePort 超时」: %v", err)
t.Errorf("应当读成「等调试端口就绪超时」: %v", err)
}
if endpoint == fmt.Sprintf("http://127.0.0.1:%d", stale) {
if endpoint == endpointURL(stale) {
t.Errorf("把陈端口当成本次结果了(endpoint=%q)", endpoint)
}
if portFileExists(l.profileDir) {
@@ -191,7 +261,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
defer srv.Close()
log, buf := newCapturedLog(slog.LevelDebug)
l := newFakeChrome(t, fakeBrowserScript{Stderr: []string{fakeBrowserLure}, ExitCode: 9}, 3*time.Second, log)
writeDevToolsPortFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
writePortRecordFile(t, l.profileDir, portOfEndpoint(t, srv.URL))
_, stop, err := l.Dial(context.Background())
if stop != nil {
@@ -207,7 +277,7 @@ func TestDial_ReportsLiveButUnusableEndpoint(t *testing.T) {
if !strings.Contains(err.Error(), launchMarkerEndpointBroken) {
t.Errorf("错误里应点明这一类(端口在听但不是 CDP 端点): %v", err)
}
for _, want := range []string{"处置", devToolsPortFile, l.profileDir} {
for _, want := range []string{"处置", portRecordFile, l.profileDir} {
if !containsInLog(err.Error(), want) {
t.Errorf("可操作的处置建议里缺少 %q: %v", want, err)
}