gstack 浏览器架构决策:为什么放弃连接真实 Chrome,改用 Playwright 打包的 Chromium
本文基于 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 导入流程。原始设计包含三步:
- 通过
chromium.connectOverCDP(wsUrl)以 CDP(Chrome DevTools Protocol)连接一个正在运行的 Chrome; - 优雅地退出 Chrome,再以
--remote-debugging-port=9222重新启动; - 从而获得用户真实浏览器上下文的访问能力。
为支撑这一设想,仓库中曾存在 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.json、background.js 等)。扩展加载不出来,headed 模式的核心体验就不成立。
于是实现上回退到了 Playwright 打包的 Chromium:它通过 chromium.launchPersistentContext() 启动,并能可靠地以 --load-extension + --disable-extensions-except 加载扩展。但命名却没有跟着改——connectCDP()、connectionMode: 'cdp'、BROWSE_CDP_URL、chrome-launcher.ts 都保留了下来。原始设想(访问用户真实浏览器状态)实际上从未实现:每次启动的都是一个全新浏览器,功能上等价于 Playwright Chromium,却留下了 361 行死代码和一系列误导性命名。
发现:五个误导性命名的死代码(2026-03-22)
设计文档记录了一次 /office-hours 设计会话中对 browse 子系统的架构追溯,发现五处命名与实际行为不符的问题:
connectCDP()并没有使用 CDP——它实际调用的是launchPersistentContext();connectionMode: 'cdp'名不副实——它只代表 “headed 模式”;chrome-launcher.ts是死代码——它唯一的导入点位于一个不可达的attemptReconnect()方法内;preExistingTabIds是为“保护真实 Chrome 标签页”设计的,但我们从不连接真实 Chrome;$B handoff(headless → headed 切换)当时走的是另一套 API(launch()+newContext()),无法加载扩展,由此产生了两种不一致的 “headed 体验”。
前四条均可在当前仓库源码中得到印证(反向印证:这些符号已从代码中消失):chrome-launcher.ts 已不在 browse/src/ 目录中,attemptReconnect、preExistingTabIds 在全仓库搜索中已无命中。
修复:改名、删除、收敛、门控
重命名(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.ts 与 watchdog.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.ts 的 handoff() 实现中可以逐行核实:
- 注释明确写明 “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 附近可见其收尾的applyStealth与newTab),使用 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” 的解释有两层:
- 真实 Chrome 的行为:当 Chrome 由 Playwright 启动时,
--load-extension被静默忽略。这是 Chromium 系浏览器的安全特性——通过命令行参数加载扩展的能力受到限制,以防止恶意扩展注入。 - Playwright 打包 Chromium 的差异:它面向测试与自动化场景设计,没有这项限制;并且通过 Playwright 的
ignoreDefaultArgs选项,还能进一步剥离 Playwright 自身会附加的、阻碍扩展加载的默认参数。
当前仓库在 stealth.ts 中将这部分集中管理:STEALTH_IGNORE_DEFAULT_ARGS 常量列出要通过 ignoreDefaultArgs 剥离的 Playwright 默认参数(包括扩展加载阻塞项、--enable-automation 等自动化特征),并 “spread into ignoreDefaultArgs” 被三个启动路径共享,保证 headless、headed 与 handoff 的反检测姿态一致。
围绕“真实浏览器状态”这一原始目标,文档给出的替代路径不是重连真实 Chrome,而是:
- Cookie 导入(
$B cookie-import,现已可用,实现见 cookie-import-browser.ts); - Conductor 会话注入(未来方向——侧边栏向工作区 agent 发送消息)。
源码纵深:headed 启动的完整细节
除了文档本身的设计叙述,以下几个源码细节解释了 headed 模式在生产环境中的健壮性,建议结合阅读:
- 扩展路径查找与自定义 Chromium 门控:
findExtensionPath()(browser-manager.ts#L377)定位 gstack 扩展目录;isCustomChromium()(L41)判断是否运行在 gbrowser/GStack Browser 这类把扩展“烧录”为组件扩展的自定义 Chromium 构建上——此时跳过--load-extension,因为重复加载会触发ServiceWorkerState::SetWorkerIdDCHECK 崩溃(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.ts、BROWSE_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。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00