首页
/ gstack 浏览器架构决策:为什么放弃连接真实 Chrome,改用 Playwright 打包的 Chromium

gstack 浏览器架构决策:为什么放弃连接真实 Chrome,改用 Playwright 打包的 Chromium

2026-09-05 15:52:44作者:平淮齐Percy

本文基于 gstack 的设计记录 CHROME_VS_CHROMIUM_EXPLORATION.md,完整还原 gstack 浏览器子系统(browse server)从“通过 CDP 连接用户真实 Chrome”的原始设想,到最终收敛为“Playwright 打包 Chromium + 持久化上下文 + Side Panel 扩展”这一 headed 架构的决策全过程。读完后,你能掌握 gstack 中 headless/headed 两种浏览器状态的实际实现差异、扩展加载的技术前提(launchPersistentContext--load-extension)、Side Panel 数据桥接机制,以及如何在仓库源码中自行验证这些架构事实。

原始设想:连接用户的真实 Chrome

gstack 的 $B connect 最初的设计目标是接入用户正在使用的真实 Chrome 浏览器——带着用户自己的 cookies、登录会话、扩展和已打开的标签页,从此不再需要 cookie 导入流程。原始设计包含三步:

  1. 通过 chromium.connectOverCDP(wsUrl) 以 CDP(Chrome DevTools Protocol)连接一个正在运行的 Chrome;
  2. 优雅地退出 Chrome,再以 --remote-debugging-port=9222 重新启动;
  3. 从而获得用户真实浏览器上下文的访问能力。

为支撑这一设想,仓库中曾存在 chrome-launcher.ts——约 361 行代码,负责浏览器二进制发现、CDP 端口探测与运行时检测——方法也因此被命名为 connectCDP(),环境变量则叫 BROWSE_CDP_URL

现实阻碍:真实 Chrome 静默拒绝 --load-extension

设想落地的关键障碍是:真实 Chrome 在被 Playwright 以 channel: 'chrome' 方式启动时,会静默忽略 --load-extension 启动参数,扩展根本加载不出来。

而 gstack 的 Side Panel(侧边栏面板:Activity 活动流、@ref 引用叠加层、Chat 对话)完全依赖这个 Chrome 扩展(见 extension/ 目录,含 manifest.jsonbackground.js 等)。扩展加载不出来,headed 模式的核心体验就不成立。

于是实现上回退到了 Playwright 打包的 Chromium:它通过 chromium.launchPersistentContext() 启动,并能可靠地以 --load-extension + --disable-extensions-except 加载扩展。但命名却没有跟着改——connectCDP()connectionMode: 'cdp'BROWSE_CDP_URLchrome-launcher.ts 都保留了下来。原始设想(访问用户真实浏览器状态)实际上从未实现:每次启动的都是一个全新浏览器,功能上等价于 Playwright Chromium,却留下了 361 行死代码和一系列误导性命名。

发现:五个误导性命名的死代码(2026-03-22)

设计文档记录了一次 /office-hours 设计会话中对 browse 子系统的架构追溯,发现五处命名与实际行为不符的问题:

  1. connectCDP() 并没有使用 CDP——它实际调用的是 launchPersistentContext()
  2. connectionMode: 'cdp' 名不副实——它只代表 “headed 模式”;
  3. chrome-launcher.ts 是死代码——它唯一的导入点位于一个不可达的 attemptReconnect() 方法内;
  4. preExistingTabIds 是为“保护真实 Chrome 标签页”设计的,但我们从不连接真实 Chrome;
  5. $B handoff(headless → headed 切换)当时走的是另一套 API(launch() + newContext()),无法加载扩展,由此产生了两种不一致的 “headed 体验”。

前四条均可在当前仓库源码中得到印证(反向印证:这些符号已从代码中消失):chrome-launcher.ts 已不在 browse/src/ 目录中,attemptReconnectpreExistingTabIds 在全仓库搜索中已无命中。

修复:改名、删除、收敛、门控

重命名(Renamed)

旧名(误导性) 新名(如实描述)
connectCDP() launchHeaded()
connectionMode: 'cdp' connectionMode: 'headed'
BROWSE_CDP_URL BROWSE_HEADED

当前源码中可以看到改名后的完整形态:browser-manager.ts 里状态字段已收敛为 connectionMode: 'launched' | 'headed'(不存在 cdp 取值);CLI 启动路径在 cli.ts 中写入 BROWSE_HEADED='1';服务端在 server.ts 读取该变量决定父进程看门狗行为(headed 模式下禁用,避免守护进程误杀用户可见窗口);无 DISPLAY 的 Linux 环境则据此在 xvfb.ts 决定是否拉起 Xvfb。对应的回归测试见 restart-env.test.tswatchdog.test.ts

删除(Deleted)

  • chrome-launcher.ts(361 行死代码);
  • attemptReconnect()(不可达的死方法);
  • preExistingTabIds(死概念);
  • reconnecting 字段(死状态);
  • cdp-connect.test.ts(针对已删除代码的测试)。

收敛(Converged)

$B handoff 现在与 $B connect 使用同一条启动路径:launchPersistentContext() + 扩展加载,headed 模式从此只有一种,不再是两种。Handoff 也因此免费获得了扩展与 Side Panel。这一点在 browser-manager.tshandoff() 实现中可以逐行核实:

  • 注释明确写明 “Launch new headed browser with extension (same as launchHeaded)”(L1700-L1701);
  • launchHeaded() 相同的扩展参数:--disable-extensions-except / --load-extension(L1713-L1714);
  • 相同的 profile 解析(resolveChromiumProfile())与单实例锁清理(cleanSingletonLocks),注释还指出这正是修复过的“第三条启动路径漂移”(L1722-L1728);
  • 相同的反自动化标识剥离:ignoreDefaultArgs: STEALTH_IGNORE_DEFAULT_ARGS(L1764)。

handoff 的完整流程采用“先启动、后关闭”的安全回滚顺序:保存 headless 状态 → 启动新的 headed 浏览器 → 恢复状态 → 关闭旧 headless 浏览器;任一步失败则 headless 浏览器原封不动。BROWSER.md 中对该命令的产品级描述是:“Open visible Chrome at current page for user takeover (CAPTCHA, MFA, complex auth)”(BROWSER.md)。

门控(Gated)

设计文档中的原始门控方案:

  • 侧边栏 Chat 功能置于 --chat 标志之后;
  • $B connect(默认):仅 Activity feed + refs;
  • $B connect --chat:额外启用实验性的独立 chat agent。

需要注意当前仓库的最新状态:根据 CHANGELOG.md 的记录,侧边栏 agent 后来已经“ungated”——不再要求 --chat 标志,在 headed 模式下始终可用,且安全模型与宿主 agent 本身一致(Bash、Read、Glob、Grep 作用于 localhost)。因此 --chat 门控属于该设计阶段的中间状态,阅读旧文档时需以 CHANGELOG 的后续条目为准。

修复后的架构全景

设计文档给出的最终架构图如下:

Browser States:
  HEADLESS (default) ←→ HEADED ($B connect or $B handoff)
     Playwright            Playwright (same engine)
     launch()              launchPersistentContext()
     invisible             visible + extension + side panel

Sidebar (orthogonal add-on, headed only):
  Activity tab    — always on, shows live browse commands
  Refs tab        — always on, shows @ref overlays
  Chat tab        — opt-in via --chat, experimental standalone agent

Data Bridge (sidebar → workspace):
  Sidebar writes to .context/sidebar-inbox/*.json
  Workspace reads via $B inbox

对照当前源码,这张图中的每条边都有落点:

  • HEADED 状态launchHeaded()browser-manager.ts#L537,核心是 chromium.launchPersistentContext(userDataDir, {...})(L674),传入 headless: false、扩展加载参数、自定义 User-Agent、代理配置与 ignoreDefaultArgs。代码注释直接点题(L588-L590):“Extensions REQUIRE launchPersistentContext (not launch + newContext). Real Chrome (executablePath/channel) silently blocks --load-extension, so we use Playwright's bundled Chromium which reliably loads extensions.”
  • HEADLESS 状态launch() 路径(同文件 L525 附近可见其收尾的 applyStealthnewTab),使用 Playwright 的 launch() + 上下文,无窗口。
  • Data Bridge$B inbox 元命令实现在 meta-commands.ts#L851,直接读取 git 仓库根下 .context/sidebar-inbox/ 目录中的 JSON 消息文件,支持 inbox [--clear] 清空(命令注册见 commands.ts#L168)。侧边栏把 scout 消息写成 JSON 文件,工作区 agent 通过 inbox 命令消费——这是一个纯文件系统的异步消息桥,不依赖任何额外 IPC。

为什么不用真实 Chrome:安全策略与替代路径

设计文档对 “Why Not Real Chrome” 的解释有两层:

  1. 真实 Chrome 的行为:当 Chrome 由 Playwright 启动时,--load-extension 被静默忽略。这是 Chromium 系浏览器的安全特性——通过命令行参数加载扩展的能力受到限制,以防止恶意扩展注入。
  2. Playwright 打包 Chromium 的差异:它面向测试与自动化场景设计,没有这项限制;并且通过 Playwright 的 ignoreDefaultArgs 选项,还能进一步剥离 Playwright 自身会附加的、阻碍扩展加载的默认参数。

当前仓库在 stealth.ts 中将这部分集中管理:STEALTH_IGNORE_DEFAULT_ARGS 常量列出要通过 ignoreDefaultArgs 剥离的 Playwright 默认参数(包括扩展加载阻塞项、--enable-automation 等自动化特征),并 “spread into ignoreDefaultArgs” 被三个启动路径共享,保证 headless、headed 与 handoff 的反检测姿态一致。

围绕“真实浏览器状态”这一原始目标,文档给出的替代路径不是重连真实 Chrome,而是:

  1. Cookie 导入$B cookie-import,现已可用,实现见 cookie-import-browser.ts);
  2. Conductor 会话注入(未来方向——侧边栏向工作区 agent 发送消息)。

源码纵深:headed 启动的完整细节

除了文档本身的设计叙述,以下几个源码细节解释了 headed 模式在生产环境中的健壮性,建议结合阅读:

  • 扩展路径查找与自定义 Chromium 门控findExtensionPath()browser-manager.ts#L377)定位 gstack 扩展目录;isCustomChromium()(L41)判断是否运行在 gbrowser/GStack Browser 这类把扩展“烧录”为组件扩展的自定义 Chromium 构建上——此时跳过 --load-extension,因为重复加载会触发 ServiceWorkerState::SetWorkerId DCHECK 崩溃(L560-L567 注释)。
  • 可自定义的二进制入口GSTACK_CHROMIUM_PATH 环境变量可指向 GStack Browser.app 捆绑的 Chromium(L604-L606),这是“不改签名 bundle、只在外层 wrapper 做品牌化”的 #2242 事故后的设计约束——就地重命名签名单元会导致 macOS 上 GPU 进程崩溃,代码注释明确警告不得重新引入对该 bundle 的写入(L608-L634),并有 browse/test/rebrand-signed-bundle.test.ts 守护。
  • 单实例锁自愈:Chromium 的 ProcessSingleton 在存在上次硬崩溃遗留的 SingletonLock/Socket/Cookie 时会拒绝启动,因此 launchHeaded()handoff() 都在启动前调用 cleanSingletonLocks(userDataDir)(L602、L1728)。
  • 反自动化特征剥离STEALTH_LAUNCH_ARGS(blink 级 --disable-blink-features=AutomationControlled 等)+ buildGStackLaunchArgs()(针对 gbrowser 定制 Chromium 构建的硬件/GPU/UA-CH 覆盖补丁开关,对标准 Playwright Chromium 是无操作)构成启动参数主体;启动后再经 applyStealth() 做 JS 层的 Layer C 隐身(navigator.webdriver 掩码、window.chrome.* 形状恢复等,stealth.ts 注释强调三个启动路径共用同一实现以避免漂移)。

适用前提与限制

  • 本文所述架构与命名以当前仓库状态为准:launchHeaded() / handoff() / BROWSE_HEADED 等均为修复后的现行符号;文档中提到的 connectCDP()chrome-launcher.tsBROWSE_CDP_URL 仅存在于历史记录与设计叙述中。
  • headed 模式依赖 Playwright 打包的 Chromium 或其替代 bundle(GSTACK_CHROMIUM_PATH);Linux 无显示环境时由 browse server 自动拉起 Xvfb(xvfb.ts),distroless 等精简镜像可能还需字体/dbus/gtk 库才能渲染 headed 窗口(BROWSER.md)。
  • 侧边栏 Chat 的门控状态经历过变化(--chat 门控 → 取消门控),引用行为时请以 CHANGELOG.md 对应版本条目为准。
  • 该架构决策的根因(Chrome 对 --load-extension 的静默拦截)属于上游浏览器行为,gstack 侧的约束是:headed 模式必须依赖可加载扩展的 Chromium 构建,这是 Side Panel 体验的硬前提。

小结

这篇设计文档的价值在于完整记录了一次“架构诚实化”过程:一个从未实现的功能(CDP 连接真实 Chrome)在代码中留下了方法名、环境变量、361 行启动器与一组守护状态;通过一次架构追溯,团队将命名修正为与行为一致(launchHeaded/headed/BROWSE_HEADED)、删除了全部死代码、并把 handoff 与 connect 收敛到同一条 launchPersistentContext() 扩展加载路径上。对使用 gstack 的开发者而言,实际结论是:$B connect$B handoff 现在提供同一种 headed 体验——可见窗口、gstack 扩展与 Side Panel(Activity/Refs 常驻),通过 .context/sidebar-inbox/*.json + $B inbox 完成侧边栏到工作区的数据回传;而“访问用户真实会话”的目标走 cookie 导入与会话注入路线,而非重连真实 Chrome。

登录后查看全文
热门项目推荐
相关项目推荐