Files
Cyrene_v2/internal/capability/browser/launch.go
T
AskaEth f1fbc249fd
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(browser): B1 接线 + B2 薄层工具面(V5)
- browsercfg 接进组合根:配置读 $DATA_DIR/config/plugins/browser.json(零 env)→ 探测(本机
  Chromium → 已在跑的 CDP)→ 不可用就不注册(原因码入日志)
- 自研最小出站 CDP 客户端(不 Enabled Runtime、隔离世界求值、不发 --enable-automation)+
  导航 + 截图 0600 落盘 + 指纹自检;工具 browser(open/screenshot/info,owner-only)
- 3 个变异自检(启动恢复 --enable-automation/无视可用性/空闲回收失效)
- 真机对照(两站各 1 次,**如实结论**):两侧都 pass ⇒ 「headless 被拦 / headful 通过」**未被证明**;
  但测到两处硬差异:无头 UA 带 HeadlessChrome、screen 800 vs 2632;两模式 webdriver 均 false
  (把 L6 的推断变成实测)。跑完 0 残留 Chrome、无对外监听端口
⚠️ 未做:MCP 自动接入(口径委派未落地)/自动注入上下文/面板页/搜索降级/docker 配方/Windows 用
  pipe(Go ExtraFiles 不支持);SSRF 落了第三份同策实现待收口;浏览器会话未挂进程关闭链
2026-10-06 20:25:16 +08:00

271 lines
12 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
import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/browsercfg"
)
// 启动参数与就绪等待的具名常量(AGENTS §10:不出现裸魔法数字)。
const (
// devToolsPortFile 是 Chromium 在 `--remote-debugging-port=0` 时写回端口号的
// 文件名(profile 目录下):首行端口、次行 browser_guid
// (reference/browser-stealth/10 §2.4,依据 content/browser/devtools/devtools_http_handler.cc)。
devToolsPortFile = "DevToolsActivePort"
// portPollInterval 是「等调试端口就绪」的轮询间隔:冷启动通常 0.3~1.5s,
// 100ms 的粒度既不迟钝也不白烧 CPU。
portPollInterval = 100 * time.Millisecond
// defaultLang 是启动时声明的 UI 语言。口径:与「本机 / 出口 IP 所在地」对齐
// (design/28 §2 指纹一致性);写死 zh-CN 是本部署的实情,配置面(browser.json)
// 尚未开放语言项 —— 见本片报告「未做」。
defaultLang = "zh-CN"
)
// adoptProbeTimeout 是「要不要认领已在跑的实例」这一次探测的超时(也是远端端点探活
// 用的那一档):它只问「端口有没有在听」,给得短。
const adoptProbeTimeout = 2 * time.Second
// dialer 产出一个可用的 CDP HTTP 端点(`http://host:port`)与停止函数。它把
// 「启动本机浏览器」与「连接已跑端点」两条路统一成同一个会话装配入口。
type dialer interface {
// Dial 返回端点;stop 释放本次会话占用的资源(本机路径 = 杀掉进程,容器路径 = 空操作)。
Dial(ctx context.Context) (endpoint string, stop func(), err error)
// Describe 返回一句人类可读的来源描述(进日志与工具结果,不进提示词模板)。
Describe() string
// Local 报告这个端点是不是**我们自己的本机浏览器**:只有它为 true 时,会话收尾
// 才允许关闭浏览器。容器/手动实例(remoteCDP)是别人的,绝不代关。
Local() bool
}
// localChrome 是主路径:在**宿主真桌面**上起一个真 Chromium(默认有头)。
//
// 启动参数按 `DevDocs/reference/browser-stealth/10 §2.1` 的白/黑名单落地:
// - **要传**:`--user-data-dir`、`--window-size`、`--lang`、`--no-first-run`、
// `--no-default-browser-check`、`--disable-blink-features=AutomationControlled`、
// `--remote-debugging-port=0`;
// - **不传**:`--enable-automation`(会点亮「受自动化控制」,且 Puppeteer 默认就传它)、
// `--disable-extensions` / `--disable-default-apps` /
// `--disable-component-extensions-with-background-pages` / `--disable-component-update` /
// `--disable-popup-blocking`(Playwright/Puppeteer 的默认参数,真浏览器不会那样)、
// 以及一切 `--headless*`(有头就是不能有它)。
//
// 未传 `--remote-debugging-pipe`(reference/10 §2.4 的首选)是**平台限制**而不是取舍:
// 它要求把 CDP 通道给到子进程的 fd 3/4,而 Go 的 `exec.Cmd.ExtraFiles` **在 Windows
// 上不支持**。因此 Windows 主路径用次选 `--remote-debugging-port=0`(内核分配随机
// 回环端口,从 profile 目录的 DevToolsActivePort 读回):**仍是我们出站连它**,
// 不开任何对外监听端口(AGENTS §9)。
type localChrome struct {
exe string
profileDir string
headless bool
viewportW int
viewportH int
timeout time.Duration
log *slog.Logger
}
// Describe 实现 dialer。
func (l *localChrome) Describe() string {
mode := "headful"
if l.headless {
mode = "headless"
}
return fmt.Sprintf("本机浏览器 %s(%s)", l.exe, mode)
}
// Local 实现 dialer:本机浏览器归我们管,会话收尾可以(也应该)关掉它。
func (l *localChrome) Local() bool { return true }
// Dial 拉起浏览器并等调试端口就绪。任何一步失败都返回带分类原因的 ErrLaunch。
//
// 两条**先于启动**的判定(都是为了让「上一次会话留下的浏览器」不至于把功能卡死):
//
// 1. **认领已在跑的实例**:profile 目录的 DevToolsActivePort 还活着(端口在听)⇒ 直接
// 出站连接它,**不再起第二个**。这不是优化,是**正确性**:同一个 user-data-dir 上起
// 第二个实例时 Chromium 会「委托给已在跑的那个」然后自己退出,于是新进程永远不写
// DevToolsActivePort ⇒ 功能会一直报启动超时,直到人工杀掉那个进程。认领把这一步
// 变成「复用」,也更贴合 design/28 §2「profile 长期保留、任务间复用」。
// 2. **端口文件先清掉**:只在真的要走启动路径时才清,否则会把旧端口当成本次结果。
//
// 停止函数只对**本次启动**的进程有效(认领来的实例不归我们杀):进程退出后 Chrome 的
// 渲染进程随之退出,因此进程级 Kill 足够。
func (l *localChrome) Dial(ctx context.Context) (string, func(), error) {
if strings.TrimSpace(l.exe) == "" {
return "", nil, fmt.Errorf("%w: 未指定可执行文件", ErrLaunch)
}
if endpoint, ok := l.adoptExisting(ctx); ok {
l.log.Info("browser session adopted", "endpoint", endpoint, "profile", l.profileDir)
return endpoint, func() {}, nil
}
if err := os.MkdirAll(l.profileDir, 0o700); err != nil {
return "", nil, fmt.Errorf("%w: 建 profile 目录 %s: %v", ErrLaunch, l.profileDir, err)
}
_ = os.Remove(filepath.Join(l.profileDir, devToolsPortFile))
cmd := exec.Command(l.exe, l.launchArgs()...)
if err := cmd.Start(); err != nil {
return "", nil, fmt.Errorf("%w: 启动 %s: %v", ErrLaunch, l.exe, err)
}
exited := make(chan struct{})
go func() {
defer close(exited)
_, _ = cmd.Process.Wait()
}()
port, err := l.waitDevToolsPort(ctx, exited)
if err != nil {
stopProcess(cmd)
return "", nil, err
}
l.log.Debug("browser launched", "exe", l.exe, "port", port, "profile", l.profileDir)
return fmt.Sprintf("http://127.0.0.1:%d", port), func() { stopProcess(cmd) }, nil
}
// adoptExisting 报告 profile 目录里记录的调试端点是否**仍然活着**;活着就返回它。
// 探测超时给得很短:这是一个「要不要复用」的判断,不该拖住启动路径。
func (l *localChrome) adoptExisting(ctx context.Context) (string, bool) {
port, ok := readDevToolsPort(filepath.Join(l.profileDir, devToolsPortFile))
if !ok {
return "", false
}
endpoint := fmt.Sprintf("http://127.0.0.1:%d", port)
probeCtx, cancel := context.WithTimeout(ctx, adoptProbeTimeout)
defer cancel()
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(probeCtx, endpoint+"/json/version", &version); err != nil {
return "", false
}
return endpoint, true
}
// launchArgs 组装启动参数(**顺序稳定、可断言**:单元测试钉住白/黑名单)。
func (l *localChrome) launchArgs() []string {
args := []string{
// profile 必传:内含 cookie / 登录态,且是「并发上限 1」的锁载体(design/28 §3)。
"--user-data-dir=" + l.profileDir,
// 内核分配随机回环端口;端口从 DevToolsActivePort 读回(见本类型文档)。
"--remote-debugging-port=0",
// 非浏览器 WS 客户端在部分 Chrome 版本上会被 DevTools 的 Origin 校验拒绝;
// reference/07 §4 的命令行里同样有它。它只放宽「谁能连这个回环调试端点」,
// 不开任何对外服务面。
"--remote-allow-origins=*",
"--no-first-run",
"--no-default-browser-check",
fmt.Sprintf("--window-size=%d,%d", l.viewportW, l.viewportH),
"--lang=" + defaultLang,
// 兜底防线:真正的关键是**不传** `--enable-automation`(见类型文档)。
"--disable-blink-features=AutomationControlled",
"about:blank",
}
if l.headless {
// 只在面板显式降级时出现;默认档(有头)**绝不传**任何 `--headless*`。
args = append([]string{"--headless=new"}, args...)
}
return args
}
// waitDevToolsPort 等 DevToolsActivePort 出现并解析出端口号。三条出口都要**说清原因**:
// 进程立刻退出(多半是 profile 锁被占 / 浏览器已在运行)、ctx 取消、超时。
func (l *localChrome) waitDevToolsPort(ctx context.Context, exited <-chan struct{}) (int, error) {
path := filepath.Join(l.profileDir, devToolsPortFile)
deadline := time.NewTimer(l.timeout)
defer deadline.Stop()
ticker := time.NewTicker(portPollInterval)
defer ticker.Stop()
for {
if port, ok := readDevToolsPort(path); ok {
return port, nil
}
select {
case <-exited:
// 浏览器进程自己退了,端口不会再出现 —— **立刻**报,不把超时白等完。
// 最常见的原因是同一 profile 已被另一个实例占用(Chromium 的 profile 锁)
// 或浏览器已被别处启动:委托出去的新进程不写 DevToolsActivePort。
return 0, fmt.Errorf("%w: 浏览器进程立刻退出(多半是该 profile 已被另一个实例占用)", ErrLaunch)
case <-ctx.Done():
return 0, fmt.Errorf("%w: 等待调试端口时被取消: %v", ErrLaunch, ctx.Err())
case <-deadline.C:
return 0, fmt.Errorf("%w: 等 %s 超时(%s)", ErrLaunch, devToolsPortFile, l.timeout)
case <-ticker.C:
}
}
}
// readDevToolsPort 读端口文件的首行;文件还没出现 / 还没写完 / 内容不合法都返回 false
// (交给调用方继续轮询,而不是把「正在写」误判成「配错」)。
func readDevToolsPort(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
}
// remoteCDP 是次选路径:连接一个**已在跑**的 CDP 端点(容器 + Xvfb + 真 Chromium,
// 或主人手动起的实例)。它是纯出站客户端,不启动任何进程、不监听任何端口。
type remoteCDP struct{ endpoint string }
// Describe 实现 dialer。
func (r *remoteCDP) Describe() string { return "已跑的 CDP 端点 " + r.endpoint }
// Local 实现 dialer:容器/手动实例**不是我们的**,会话收尾绝不代它关闭。
func (r *remoteCDP) Local() bool { return false }
// Dial 先探活再交出端点;连不上就如实报,不偷偷回退本机(design/28 §6 坑 5)。
func (r *remoteCDP) Dial(ctx context.Context) (string, func(), error) {
endpoint := strings.TrimRight(strings.TrimSpace(r.endpoint), "/")
if endpoint == "" {
return "", nil, fmt.Errorf("%w: CDP 端点为空白", ErrUnavailable)
}
var version struct {
Browser string `json:"Browser"`
}
if err := httpGetJSON(ctx, endpoint+"/json/version", &version); err != nil {
return "", nil, fmt.Errorf("%w: %v", ErrUnavailable, err)
}
return endpoint, func() {}, nil
}
// stopProcess 结束本进程拉起的浏览器(保留为具名函数:Kill 的语义在这一处写清楚,
// 调用点不必解释;Wait 由 Dial 里的等待协程负责,这里只 Kill,避免双重 Wait)。
// Windows 上渲染进程随浏览器进程一起退,因此进程级 Kill 足够。
func stopProcess(cmd *exec.Cmd) {
if cmd == nil || cmd.Process == nil {
return
}
_ = cmd.Process.Kill()
}
// newDialer 按「配置显式端点 → 配置显式可执行文件 → 本机探测结果」组装 dialer。
// 可用性判定由 browsercfg.Resolve 做(优先级单一出处),这里只把结论映射成具体 dialer。
func newDialer(avail browsercfg.Availability, cfg browsercfg.Config, log *slog.Logger) dialer {
if e := strings.TrimSpace(avail.Endpoint); e != "" {
return &remoteCDP{endpoint: e}
}
return &localChrome{
exe: strings.TrimSpace(avail.Executable),
profileDir: cfg.ProfileDir,
headless: cfg.Headless,
viewportW: cfg.ViewportWidth,
viewportH: cfg.ViewportHeight,
timeout: cfg.Timeout(),
log: log,
}
}