Files
Cyrene_v2/internal/capability/browser/portrecord.go
T
AskaEth 6fe68ecfac
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): 方案 A 落地 —— 显式端口 + 去掉 stealth flag(主人 2026-10-07 拍板)
依据 `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 整链路(未碰)。
2026-10-07 11:18:25 +08:00

177 lines
8.9 KiB
Go
Raw 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
// 本文件是「调试端口的来源与落点」这一件事的**唯一出处**:取一个空闲端口、记在 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
}