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

+176
View File
@@ -0,0 +1,176 @@
package browser
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 profile
// 目录里、需要时读回来、陈记录清掉。
//
// 为什么改成自己记端口(DevDocs/reference/chromium-cdp/00 的「方案 A」,主人 2026-10-07 拍板):
//
// - Chromium 把 `--remote-debugging-port=0` 当作 **ChromeDriver 式自动化启动**,会主动打开
// Blink 的 `AutomationControlled` ⇒ `navigator.webdriver === true`(官方
// content/child/runtime_features.cc:389-404;本机实测见 07 的 g1 vs g7);**显式端口**
// 不会被这样处理(官方注释原文:调用方给具体端口时「更可能是挂调试器」,故不动该特性);
// - 我们因此**不再传** `--disable-blink-features=AutomationControlled`:它在显式端口下已是
// 空操作,却仍是整条命令行里唯一命中官方 `kBadFlags` 的参数 ⇒ 每次有头启动都弹一条
// 「不受支持的命令行标记」信息栏(01 的源码 + 07 的 g2/g7 实测)。去掉它,webdriver
// **本来就是 false**,横幅也随之消失;
// - 代价:Chromium **只在 `port==0` 时**才写 `<profile>/DevToolsActivePort`
// (官方 chrome/browser/devtools/remote_debugging_server.cc:363-395;实测 03 §3.3)——
// 改显式端口后那个文件不再出现,端口只能由**我们自己**记。
//
// 落点选 **profile 目录**里的 `cyrene-debug-port`(我们自有的记录:Chrome 既不读也不写它),
// 而不是 `$DATA_DIR/browser/` 下另开一份状态文件:
//
// - 它天然按 profile 隔离 —— 认领判定(adopt.go 的 probeExistingEndpoint)只需看一个目录,
// 不必再维护「profile → 记录」的映射与它们的失效时机;
// - 与 Chrome 自己的 `DevToolsActivePort` 同目录,两者可以并列读(debugPortCandidates);
// - profile 被删 / 被换 / 被搬走时记录随之消失,不会留下一条指向别人的陈端口。
//
// 记录内容只有一行端口号,**不含任何凭据**(profile 里的 cookie / 登录态在别的文件里)。
import (
"context"
"errors"
"fmt"
"net"
"os"
"path/filepath"
"strconv"
"strings"
)
const (
// portRecordFile 是我们自己的调试端口记录(首行端口号)。
portRecordFile = "cyrene-debug-port"
// legacyDevToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写的那个文件
// (reference/chromium-cdp/03 §3.2:首行端口、次行 browser_guid)。改用显式端口后我们
// **不再依赖它**,但仍然读一眼 —— 换二进制时上一版(port=0)起的浏览器可能还在跑:
// 不认它就会把「profile 上已经有我们的浏览器」误报成「profile 被别人占了」。
legacyDevToolsPortFile = "DevToolsActivePort"
// debugPortPickAttempts 是「取一个空闲端口」的尝试次数。做法:`listen(:0)` 让内核分配一个
// 空闲回环端口、随即关闭,把号交给 Chromium 去 bind —— 这中间存在极小的 TOCTOU 抢占窗口
// (AGENTS §9:我们自己**不开**监听端口,这里只是「借内核挑个号」再立刻放掉),抢不到就再取。
debugPortPickAttempts = 5
// launchPortAttempts 是本机启动的尝试次数:1 次正常 + 1 次**换端口**重试。重试只发生在
// 有证据表明「我们选的端口被别人占了」时(见 portTakenError);其余失败一律如实上报 ——
// 无差别重试只会把失败原因糊掉,还把预算烧在重复等待上。
launchPortAttempts = 2
)
// endpointURL 把端口号变成 CDP HTTP 端点的地址(127.0.0.1:调试端点在回环上,我们出站连它)。
func endpointURL(port int) string {
return "http://" + loopbackAddr(port)
}
// loopbackAddr 拼一个回环地址(探查、记录、启停都用同一处拼法,避免两处写法漂移)。
func loopbackAddr(port int) string {
return net.JoinHostPort("127.0.0.1", strconv.Itoa(port))
}
// pickDebugPort 取一个**当前空闲**的回环端口号交给 Chromium:listen(:0) 由内核分配、立刻
// 关闭(本进程不再持有它,AGENTS §9 的「不常驻监听端口」因此成立)。关掉之后再到手之间
// 有极小的被抢窗口,故取到一个「此刻确实没人听」的号才返回,最多试 debugPortPickAttempts 次。
func pickDebugPort(ctx context.Context) (int, error) {
for attempt := 0; attempt < debugPortPickAttempts; attempt++ {
ln, err := net.Listen("tcp", loopbackAddr(0))
if err != nil {
return 0, fmt.Errorf("listen(:0) 取空闲端口: %w", err)
}
port := ln.Addr().(*net.TCPAddr).Port
if err := ln.Close(); err != nil {
continue
}
if !portListening(ctx, port) {
return port, nil
}
}
return 0, fmt.Errorf("连续 %d 次取到的空闲端口都已被占用", debugPortPickAttempts)
}
// portRecordPath 返回这个 profile 的端口记录文件的路径。
func portRecordPath(profileDir string) string {
return filepath.Join(profileDir, portRecordFile)
}
// writePortRecord 记下**本次实际使用的**调试端口。写失败不算成功的一半:记不下端口就等于
// 下一次启动无法复用(会去起第二个进程、然后被 Chromium 委托出去),因此调用方必须把它当
// 硬失败(见 launchWithRetry)。
func writePortRecord(profileDir string, port int) error {
path := portRecordPath(profileDir)
if err := os.WriteFile(path, []byte(strconv.Itoa(port)+"\n"), 0o600); err != nil {
return fmt.Errorf("写调试端口记录 %s: %w", path, err)
}
return nil
}
// readPortRecord 读回我们自己记的端口;文件不在 / 内容不合法都返回 false(交给调用方当
// 「没有可复用的记录」处理,而不是把「正在写」误判成「配错」)。
func readPortRecord(profileDir string) (int, bool) {
return readPortNumberFile(portRecordPath(profileDir))
}
// DebugEndpoint 报告这个 profile 上**已知**的调试端点(我们自己记的端口记录,见本文件头)。
// 它只读文件、不探活、不启动任何进程,供排障与端到端用例取「这一次会话用的是哪个端点」。
// 返回 ("", false) 表示没有记录或记录不合法 —— **不猜**端口。
func DebugEndpoint(profileDir string) (string, bool) {
port, ok := readPortRecord(profileDir)
if !ok {
return "", false
}
return endpointURL(port), true
}
// debugPortCandidates 返回「这个 profile 上可能有 CDP 的端口」候选,按可信度排序:先我们
// 自己的记录,再那一眼兼容的老文件(legacyDevToolsPortFile),去重。
func debugPortCandidates(profileDir string) []int {
var out []int
if port, ok := readPortRecord(profileDir); ok {
out = append(out, port)
}
legacy := filepath.Join(profileDir, legacyDevToolsPortFile)
if port, ok := readPortNumberFile(legacy); ok && (len(out) == 0 || out[0] != port) {
out = append(out, port)
}
return out
}
// readPortNumberFile 读一个「首行是端口号」的文件;文件不在 / 还没写完 / 内容不合法都返回
// false。两种文件都套这个格式:我们自己的 portRecordFile(一行端口)与 Chrome 的
// legacyDevToolsPortFile(首行端口、次行 browser_guid)。
func readPortNumberFile(path string) (int, bool) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, false
}
line, _, _ := strings.Cut(strings.TrimSpace(string(raw)), "\n")
port, err := strconv.Atoi(strings.TrimSpace(line))
if err != nil || port <= 0 || port > 65535 {
return 0, false
}
return port, true
}
// clearStalePortRecords 清掉**上一轮**留下的端口记录(两种都清)—— 只有确认「候选端口都已经
// 没人听」才会走到这里(见 Dial 的调用顺序)。
//
// 为什么必须清:这两条记录是「这个 profile 上有一个 CDP 端点」的**唯一线索**,上一轮的端口
// 留着只会让下一次启动的认领判定去探一个死端口。
//
// 为什么可以清:它们只是浏览器启动时写下的**一条记录**,不是锁。真正在跑、真正拥有这个
// profile 的实例一定有一个**活着的**端口,那种情况上一步(probeExistingEndpoint)已经把它
// 复用走了。因此删掉指向死端口的记录,不可能从任何活着的实例手里拿走什么 —— 它影响的只有
// 我们自己的认领判定。
//
// 文件本来就不在(ErrNotExist)不算失败;别的错误(被占用 / 权限)**如实报**并且**不继续
// 启动** —— 留着一条陈记录继续启动,只会把陈端口读成本次可复用的端点。
func (l *localChrome) clearStalePortRecords() error {
for _, path := range []string{portRecordPath(l.profileDir), filepath.Join(l.profileDir, legacyDevToolsPortFile)} {
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("%w: 清掉上一轮的端口记录 %s 失败: %v(留着它会把陈端口当成本次可复用的端点)",
ErrLaunch, path, err)
}
}
return nil
}