Phoenix-PC 内置浏览器(In-App Browser, IAB)技术方案

参照 OpenAI Codex 桌面端 IAB 1:1 反编译实测 · 融合语雀 3-Backend 共存调研 · 适配 phoenix-pc 现有 BrowserPreview 与 packages/app/src/browser/runtime/ 体系

Author: 反编译 + 工程探索综合 · Branch: feat/invoke-dispatch · Date: 2026-05-15 · Status: DRAFT 待评审

  1. 摘要 / 一页概览
  2. 背景与目标
    1. phoenix-pc 现状(Backend A/B + 已有 BrowserPreview)
    2. 为什么要 Backend C(IAB)
    3. 本次范围 / 不在范围
    4. 已确认的关键决策
  3. 总体架构
    1. phoenix-pc 三 Backend 共存定位
    2. IAB 内部三层架构(Owner / Main / Agent)
    3. 核心数据流
  4. 关键技术决策(带 Codex 实测证据)
  5. 代码地图与目录布局
  6. 详细模块设计
    1. M1 · WebContentsView 双视图(toolbar + content)+ 双形态(sidebar / standalone)
    2. M2 · CDP 桥(webContents.debugger)
    3. M3 · Input 翻译器(in-page JS,保 focus)
    4. M4 · 截图双策略(capturePage / Page.captureScreenshot)
    5. M5 · 单 partition + data-conversation-id 隔离
    6. M6 · IAB Lifecycle Registry(tabIdsByPageKey / routeKeysByTabId)
    7. M7 · IabTransport(适配 ExtensionTransport 接口,复用 tier0-actions)
    8. M8 · browser tool 增加 'iab' backend
    9. M9 · IPC 通道清单
  7. 与已有 BrowserPreview 的演化关系
  8. 实施路线(W1-W4 MVP)
  9. 验收标准
  10. 风险与坑(基于 Codex 反编译提炼)
  11. Alternatives(本次未采纳但保留)
  12. 后续演进(P1/P2/P3 Roadmap)
  13. 附录 · 关键文件路径与对照

1 · 摘要 / 一页概览

一句话:在 apps/desktop/ 内基于 Electron WebContentsView 实现 Backend C(IAB),技术形态参考 Codex IAB 反编译实测;不重写 agent 协议,通过 IabTransportwebContents.debugger 适配成现有 ExtensionTransport 接口,直接复用 packages/app/src/browser/runtime/actions/* 全套 tier0 actions 与 SDK 剪枝管线。MVP 4 周交付,作为 Linux 兜底 + 无扩展用户 onboarding 补充 backend,不替代 A/B。
核心目标给 Phoenix-PC 桌面端引入"自带 Chromium 内核 / 不依赖用户 Chrome 扩展"的浏览器 backend
技术参考Codex.app v26.513.20950 反编译实测(app.asar 解包后逐函数验证)
架构定位Backend C,与 Backend A(Desktop Relay+CDP)、Backend B(Web Extension Port)并行共存;复用顶层 Tool Schema / Snapshot Pruning / aria-tree parser / Ref Registry
核心模块① IAB BrowserSidebarManager ② CDP Bridge ③ Input 翻译器 ④ Lifecycle Registry ⑤ IabTransport ⑥ browser tool 'iab' 路由
MVP 范围侧边栏 + 独立窗口双形态 / agent 控制 snapshot+act+screenshot+navigate / 内嵌于主聊天窗口
不做Comment 标注层、外部 CLI 接入(native pipe + peer auth)、node_repl 范式、Confirmation Taxonomy 4 级、Tab claim/finalize/group
工期MVP 4 周(单人) → alpha;P1+P2 跟进语雀 3-Backend 重构与 Codex 工具脚本接入
关键风险action 暂时复用 B 的 ExtensionTransport 接口,会让 action 实现"暂时变三套",P1 必须跟进抽 BrowserDriver

2 · 背景与目标

2.1 phoenix-pc 现状

本节基于直接探索 apps/desktop/src/main/browser/window.tspackages/app/src/browser/runtime/packages/sdk/src/vm/tools/function-tools/search/browser.tspackages/sdk/src/browser/ 得出。

当前实现规模状态
Tool 公开契约packages/sdk/src/vm/tools/function-tools/search/browser.ts — 双 backend 路由('sdk' Playwright / 'app' Extension+CDP)已成熟复用,本次扩到三 backend
Backend A · Desktoppackages/sdk/src/browser/cdp/ + packages/sdk/src/browser/relay/ + packages/sdk/src/browser/playwright/;通过 WS Relay Server 接入用户本机 Chrome,经 chrome-extension/accio-browser-relay 落 chrome.debugger2891+2306+2636 行本次不动
Backend B · Webpackages/app/src/browser/runtime/ — AppBrowserRuntime + ExtensionTransport(chrome.runtime.connect external port)+ tier0-actions(snapshot/act/screenshot/navigate/scroll/console/errors/requests/waitFor/drag)~10 文件,actions/* 9 个协议本次复用
SDK 共享层packages/sdk/src/browser/snapshot/pruning/(intent/diff/enforce/ref-stab/shadow 5 阶段管线)+ aria-tree parser + ref-registry + state2521+3015 行100% 复用
共用 Chrome 扩展assets/chrome-extension/accio-browser-relay/ — A/B 共享,MV3多文件C 不依赖
独立 BrowserPreview(已存在!)apps/desktop/src/main/browser/window.ts — 独立 BrowserWindow,WebContentsView 双 view(48px toolbar + content),partition persist:browser-preview,IPC browser-preview:*,支持 URL/HTML 加载、injectAuth、cookie 同步、前进后退/刷新/关闭510 行本次演化为 IAB 的 standalone 模式
关键幸运点:phoenix-pc 已经有相当完备的工程基线 —— ① WebContentsView 双 view 模式已落地;② tier0 actions 协议已经在用 Accessibility.getFullAXTree + ref=eN 这套(比 Codex IAB 自家的 in-page DOM walker 更标准);③ DOM 剪枝管线比 Codex 还成熟(语雀语)。本次 IAB 主要工作量是新写 transport + 改造窗口形态 + 接入 tool 路由,而不是从零写浏览器协议。

2.2 为什么要 Backend C(IAB)

语雀调研 Q1 给出的结论(直接引用):

支持 C,作为「无登录态场景」+「Linux 兜底」+「无扩展用户的 onboarding 体验」补充 backend,不替代 A/B。
缺口场景当前问题IAB 如何解决
Linux 用户Backend A 依赖用户装 Chrome + 扩展,Backend B 依赖 Chrome 内打开;Linux 桌面 Chrome 装机率不稳定IAB 自带 Chromium(Electron 内置),零依赖
无扩展 onboarding用户首次使用时若未装 accio-browser-relay 扩展,无法立即体验 browser 能力IAB 立即可用,扩展引导可以放到后续
无登录态场景Backend A/B 借用户已登录的 Chrome session,某些场景需要 fresh sessionIAB 默认 fresh session(单 partition,可清),agent 不污染用户登录态
对标 Codex/悟空同类竞品都已有内置浏览器能力对齐

2.3 本次范围 / 不在范围

本次 MVP 范围(IN)

  • 侧边栏(主聊天窗口右侧)+ 独立窗口双形态
  • 地址栏 / 前进 / 后退 / 刷新 / 关闭
  • agent 通过 browser tool 控制:navigate / snapshot / act / screenshot / scroll 等 tier0 全集
  • 单 partition persist:phoenix-iab,可显式 clear-storage
  • Page/Emulation/Target CDP 域 + Input 翻译器
  • navigation blocked / dialog auto-accept / window-open 接管
  • browser tool 增加第三个 backend 'iab',可被 tool router 选中

不在范围(OUT)

  • × Comment 标注层(hover outline + 评论气泡 + anchor 注入 agent 上下文)
  • × 外部 CLI 接入(Codex 的 native pipe + peer auth)
  • × node_repl + JS SDK 范式(语雀 §4 强推但应独立立项)
  • × Tab claim / finalize / group 协议(Backend A/B 共用能力,见 §13)
  • × Confirmation Taxonomy 4 级
  • × Touch ID 二次确认
  • ×BrowserDriver 接口 + shared-actions/ 的 P0 重构 — IAB 与之并行,见 §13

2.4 已确认的关键决策

#决策点选定方向理由
D1窗口形态侧边栏 + 独立窗口双形态侧边栏对齐 Codex IAB 体验;独立窗口复用现有 BrowserPreview 演化路径
D2agent 通道(IPC vs CDP 命令风格)复用 ExtensionTransport 接口形态,新写 IabTransport零协议改造,自动复用 tier0-actions 全集与 SDK 剪枝管线
D3外部 CLI / 进程接入不开放(MVP)仅服务桌面端内 agent runtime;若未来需要再加 native pipe 层
D4Comment 标注层MVP 不做Codex 该层是 36MB React 包,价值高但与 agent 控制核心解耦,可独立后续做
D5重构顺序(C 先 vs P0 重构先)路线 X · 先做 C,actions 暂时复用 B用户原始诉求是"内置浏览器",路线 Y 工期 4-6 周且影响线上稳定性 ROI 太低;P1 跟进 BrowserDriver 抽象
D6node_repl 范式本次不实施,列入 §13 后续 Roadmap跨度涉及 SKILL.md / 模型路由 / 评测体系,应独立立项

3 · 总体架构

3.1 phoenix-pc 三 Backend 共存定位

┌──────────────────────────────────────────┐ │ browser tool 公开契约(SDK,18 个 verb) │ │ packages/sdk/.../browser.ts │ │ toolImplementation: 'sdk'│'app'│'iab' │← 本次新增 'iab' └──────────────────────────────────────────┘ │ ┌─────────────────────────────┼─────────────────────────────┐ ▼ ▼ ▼ ┌────────────────┐ ┌────────────────────┐ ┌──────────────────────┐ │ Backend A │ │ Backend B │ │ Backend C ★ 本次新增 │ │ Desktop │ │ Web │ │ Embedded (IAB) │ ├────────────────┤ ├────────────────────┤ ├──────────────────────┤ │ Playwright + │ │ AppBrowserRuntime │ │ IabTransport │ │ WS Relay │ │ + ExtensionTransp. │ │ + webContents. │ │ → user Chrome │ │ → chrome.runtime. │ │ debugger.attach │ │ (CDP) │ │ connect(extId) │ │ → guest WebContents │ │ │ │ → 同上扩展 │ │ (内嵌 Chromium) │ │ │ │ │ │ + Input 翻译器(in- │ │ │ │ │ │ page JS,保 focus) │ │ │ │ │ │ + 单 partition │ │ │ │ │ │ + Lifecycle Registry │ └────────┬───────┘ └─────────┬──────────┘ └──────────┬───────────┘ │ │ │ │ ★ Driver / Transport / 数据源 这一层 3 套必须分离 ★ │ │ │ │ └───────────────────────────┴──────────────────────────────┘ │ ┌──────────────────▼─────────────────────┐ │ 100% 共享层(顶层 + 底层) │ │ • Tool Schema / Backend Selector │ │ • DOM Snapshot Pruning(intent/diff/ │ │ enforce/ref-stab/shadow 5 阶段) │ │ • aria-tree YAML Parser │ │ • Ref Registry(node_id ↔ backend) │ │ • State / Endpoint Resolver │ │ • Permission(后续) │ └────────────────────────────────────────┘
注意 Action 实现层:语雀指出 A/B 当前各写一套 action 实现是 P0 必须解决的问题;C 进入若不抽 BrowserDriver,会让 action 实现"暂时变三套"。本次方案选定的路线 X是:C 阶段不重写 action,通过 IabTransport 模拟 B 的 ExtensionTransport 接口,让 C 直接走 B 的 tier0-actions/* —— 这意味着 action 实现暂时还是两套(A 一套 + B/C 共用一套),P1 阶段跟进抽象。

3.2 IAB 内部三层架构(Owner / Main / Agent)

┌────────────────────────────────────────────────────────────────────────────┐ │ Phoenix-PC Desktop (Electron 31+, electron-vite, electron-builder) │ │ │ │ ┌─── Owner BrowserWindow (Renderer · React 19 + Vite + Tailwind) ─────┐ │ │ │ │ │ │ │ 主聊天 UI ┃ ┏━━━ IAB Sidebar (本次新增) ━━━━━━━━━━━━━━━━━━━━━┓ │ │ │ │ ┃ ┃ ┌────────── toolbar WebContentsView ────────┐ ┃ │ │ │ │ ┃ ┃ │ ◀ ▶ ⟳ [ https://example.com ⌫ ] ⊘ ⤢ ✕ │ ┃ │ │ │ │ ┃ ┃ └────────────────────────────────────────────┘ ┃ │ │ │ │ ┃ ┃ ┌────────── content WebContentsView ────────┐ ┃ │ │ │ │ ┃ ┃ │ │ ┃ │ │ │ │ ┃ ┃ │ guest webContents (Chromium) │ ┃ │ │ │ │ ┃ ┃ │ data-iab-conversation-id= │ ┃ │ │ │ │ ┃ ┃ │ partition=persist:phoenix-iab │ ┃ │ │ │ │ ┃ ┃ │ (preload: iab-content.js) │ ┃ │ │ │ │ ┃ ┃ └────────────────────────────────────────────┘ ┃ │ │ │ │ ┃ ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ │ │ │ └────────────┃─────────────────────────────────────────────────────────┘ │ │ ┃ │ │ IPC: 'phoenix-iab:command' / 'phoenix-iab:state' │ │ ┃ │ │ ┌─── Main Process (Node) ─────────────────────────────────────────────┐ │ │ │ ✦ IabSidebarManager — 形态切换(sidebar↔standalone)、绑定 │ │ │ │ ✦ IabSessionRegistry — tabIdsByPageKey / routeKeysByTabId │ │ │ │ ✦ IabCdpBridge — webContents.debugger.attach('1.3') │ │ │ │ Page.* / Emulation.* / Target.* (实际 CDP 通道) │ │ │ │ Input.* → executeJavaScript(translatorJs) (in-page 翻译) │ │ │ │ capturePage()(优先) / Page.captureScreenshot(全页兜底) │ │ │ │ ✦ IabTransport — 实现 ExtensionTransport 接口 │ │ │ │ send(method, params) → debugger.sendCommand 或 翻译器 │ │ │ │ (复用 packages/app/src/browser/runtime/actions/*) │ │ │ │ ✦ IabIpcRouter — 'phoenix-iab:*' channel 路由 │ │ │ │ ✦ NavigationBlockedHandler — Page.navigationBlocked + 白名单 │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ │ │ │ ┌─── Agent Runtime (in-process) ─────────────────────────────────────┐ │ │ │ browser tool 调用 → router 选 backend = 'iab' → │ │ │ │ AppBrowserRuntime(传入 IabTransport)→ executeTier0Action() │ │ │ │ ↓ │ │ │ │ actions/snapshot.ts → transport.call('CDP.send', { cdpMethod: │ │ │ │ 'Accessibility.getFullAXTree' }) → IabTransport 转 debugger │ │ │ │ ↓ │ │ │ │ actions/act.ts → DOM.resolveNode → DOM.getBoxModel → │ │ │ │ Input.dispatchMouseEvent(被 IabTransport 拦截 → 翻译为 in-page │ │ │ │ PointerEvent/MouseEvent + 元素 token 校验) │ │ │ └─────────────────────────────────────────────────────────────────────┘ │ └────────────────────────────────────────────────────────────────────────────┘
与 Codex IAB 的 1:1 对应:

3.3 核心数据流

3.3.1 用户视角:打开侧边栏 + agent 浏览页面

[用户在主聊天里] "帮我打开 example.com,登录然后截图" │ ▼ [Renderer] IPC 'phoenix-iab:command' { type:'open', url:'example.com', conversationId:'c1' } │ ▼ [Main · IabSidebarManager] 1. 若 sidebar 未开 → 通知 Owner Renderer 渲染右侧 sidebar 容器 2. 创建 toolbarView + contentView (WebContentsView) 3. contentView.webContents.loadURL('https://example.com'),partition='persist:phoenix-iab' 4. webContents.debugger.attach('1.3') 5. tabId = 'iab-tab-${seq++}',注册到 IabSessionRegistry routeKey = `${windowId}:${conversationId}` tabIdsByPageKey.set(routeKey, tabId) 6. 通知 Owner 当前 IAB tab 状态 → sidebar 渲染地址栏 + content │ ▼ [Agent Runtime · browser tool] 调用 browser_navigate({ url:'example.com' }) → router.select(backend='iab') → IabBrowserRuntime → IabTransport.call('CDP.send', { targetId, cdpMethod:'Page.navigate', ... }) → IabCdpBridge → webContents.debugger.sendCommand('Page.navigate', ...) │ ▼ [Agent] browser_snapshot → IabTransport → debugger.sendCommand('Accessibility.getFullAXTree') → 返回 AX 树 → 走 packages/sdk/src/browser/snapshot/pruning/ 5 阶段管线 → 返回 ARIA YAML + ref=eN 给模型 │ ▼ [Agent] browser_act({ kind:'click', ref:'e12' }) → IabTransport 解析 ref → DOM.resolveNode → DOM.getBoxModel → Input.dispatchMouseEvent → ★ IabTransport 拦截 ★ → 改为 webContents.executeJavaScript(`(${translatorJs})(${JSON.stringify(cmd)})`) → in-page 翻译器执行 PointerEvent/MouseEvent 序列(保 owner focus) │ ▼ [Agent] browser_screenshot → capturePage() (默认) → PNG buffer → base64 → 返回模型

4 · 关键技术决策(带 Codex 实测证据)

证据来源:/Applications/Codex.app/Contents/Resources/app.asar v26.513.20950 解包后逐函数验证(main-kSlb32Yb.js 1.21 MB / comment-preload.js 36.4 MB / app-session-O7kcZj7R.js 4.43 MB / bootstrap.js 3.7 KB / package.json)。

#决策Codex 实测证据phoenix-pc 适配
K1 嵌入容器:WebContentsView(非 <webview> tag,非 BrowserView) Codex 用的是 <webview> tag(webPreferences.webviewTag:true 命中 2 次,will-attach-webview 4 次,did-attach-webview 5 次) 选 WebContentsView(主动偏离 Codex)。理由:① <webview> 已被 Electron 标记 deprecated,② phoenix-pc 现有 BrowserPreview 已用 WebContentsView,生态一致,③ 性能/稳定性更好,④ 主进程直接管理,无需中转。代价:preload 没有 sendToHost,所有事件走 main 中转(我们也走 main,符合 sidebar manager 是单一事实来源的设计)
K2 CDP 接入方式 e.webContents.debugger.attach('1.3')(命中 2),debugger.sendCommand(命中 4)。不开 9222 端口 同款。直接用 Electron 内建 webContents.debugger,不暴露 OS 端口
K3 实际启用的 CDP 域 main.js 字符串字面量 grep 完整集合:
Page.navigate / reload / captureScreenshot / getLayoutMetrics / getNavigationHistory / navigateToHistoryEntry / close / navigationBlocked
Input.dispatchKeyEvent / dispatchMouseEvent / insertText / synthesizeScrollGesture实际被翻译,未真正发出
Emulation.setDeviceMetricsOverride / clearDeviceMetricsOverride / setEmulatedMedia
Target.getTargets / closeTargetemulated
DOM/Accessibility/DOMSnapshot/Network/Runtime 域:0 命中
phoenix-pc 既然要复用 tier0-actions(actions/snapshot.ts 用 Accessibility.getFullAXTree),IAB transport 必须真启用 DOM + Accessibility 域。Codex 的"只 Page/Emulation/Target"是因为它走 in-page DOM walker 不依赖 CDP DOM,我们走 phoenix 现有协议必须把 DOM/Accessibility 域打开。不显式 enable(直接 sendCommand 即可,Electron debugger 默认配置就支持)
K4 Input 命令:翻译为 in-page JS,不真正发 CDP Input.* main.js 函数 executeTranslatedInputCommand + LL(e)=`(${gP.toString()})(${JSON.stringify(e)});` + 5 KB gP() in-page 翻译器(elementFromPoint + shadow DOM + same-origin iframe + cross-origin 抛错 + PointerEvent/MouseEvent 双发 + __codexIabExpectedInputTargetToken 元素 token 校验 + 自动 focus label.control / 最近 focusable 父节点)。
实际错误信息:"input CDP commands are translated to JavaScript to preserve focus"
1:1 移植。把 Codex 的 gP() 抠出来改写成 apps/desktop/src/main/browser/iab/input-translator.ts,IabTransport 拦截 Input.* 命令时走 webContents.executeJavaScript(translatorIife)。理由:Codex 选这个路径是为了"agent 操作期间 Owner 主聊天框不失焦",对 phoenix-pc 同样关键
K5 截图双策略 capturePage()(默认,2 处)+ Page.captureScreenshot(captureBeyondViewport 全页时,1 处)。函数 TL() 专门拆 viewport vs 全页路径,captureBeyondViewport 必须特判 同款。实现 iabScreenshot(targetId, opts):默认 capturePage(),opts.fullPage===truePage.captureScreenshot({ captureBeyondViewport:true })性能优势:capturePage 走 GPU compositor,比 CDP 截图快 2-3x
K6 partition 隔离策略 单 partition:persist:codex-browser-app-route:app(常量 JL='persist:codex-browser-app-route:',Yp(e)=encodeURIComponent);conversationId 通过 data-browser-sidebar-conversation-id HTML 属性挂在 <webview> 上(常量 qL);存储数据通过 session.fromPartition().clearStorageData({ storages:['cookies'/'siteData'/'cache'] }) 显式清 同款:partition persist:phoenix-iab(单值);conversationId 用 contentView.webContents.executeJavaScript('document.documentElement.dataset.iabConversationId="..."') 或在 IabSessionRegistry 维护;提供 iab:clear-storage IPC 给用户。不按 conversation 分 partition(否则同站点要重复登录,UX 差)
K7 CDP 命令必须包超时 函数 RL(promise, timeout, msg);字段 cdpCommandTimeoutMs 同款。IabTransport 内置 cdpCommandTimeoutMs(默认 30s),所有 sendCommand 包 race
K8 Page.captureScreenshotcaptureBeyondViewport 必须特判 函数 TL() 显式拆分:viewport 截图与全页截图走两条路径,commandParams.captureBeyondViewport===true 时额外读 clip.width/height 校验 同款。IabCdpBridge 截图路由必须按 captureBeyondViewport 分流;不可统一走一条
K9 cross-origin iframe 显式抛错 gP() 翻译器:碰到 cross-origin iframe 调用 d()=throw Error("Input targets inside cross-origin or inaccessible iframes are not currently supported in the in-app browser") 同款。错误信息改写为 phoenix 命名空间;不静默吞掉,让 agent 能感知到失败原因
K10 元素 token 校验防 race __codexIabExpectedInputTargetToken:翻译器执行前先在目标元素打 token,翻译过程中读 elementFromPoint 拿到元素再校验 token,不一致就抛 "Focused input target no longer matches the resolved locator" 同款。改名 __phoenixIabExpectedInputTargetToken。这是高速 agent 操作下"页面 mutate 导致点错"的核心防护
K11 will-attach-* 强制安全配置 Codex 在 non_mcp_app_sandbox_webview 分支显式:sandbox=true; contextIsolation=true; nodeIntegration=false; webviewTag=false(防 webview 套娃逃逸) WebContentsView 不需要 will-attach-webview 钩子,但创建 view 时 webPreferences 必须:sandbox:true, contextIsolation:true, nodeIntegration:false, webviewTag:false
K12 navigation blocked 接管 Page.navigationBlocked 事件 + 内部白名单(hide-local-server / unhide-local-server) MVP 实现 Page.navigationBlocked 监听 + 钓鱼站基础白名单;hide-local-server 推到 P2
K13 dialog auto-accept auto-accept alert / beforeunload,confirm/prompt 显式处理(否则 agent 会卡死) 同款。contentView.webContents.session.on('will-prevent-unload', ...) + setWindowOpenHandler
K14 quit 时清理 每个 IAB tab 关闭时:debugger.detach() + cleanupDebuggerForTabBestEffort IabSessionRegistry 在 contentView.webContents destroy / app quit 时统一 detach
K15 命名约定 IAB_LIFECYCLE(49 处日志)、browser-use-iab-apibrowser-use-iab-tab:${cdpTabId}tabIdsByPageKey(8)、routeKeysByTabId(7) phoenix 改名:PHOENIX_IAB_LIFECYCLEphoenix-iab-apiphoenix-iab-tab:${cdpTabId}tabIdsByPageKeyrouteKeysByTabId(沿用 Codex 命名,因为表达力好)

5 · 代码地图与目录布局

新增文件(标 ★)

phoenix-pc/
├── apps/desktop/
│   └── src/
│       ├── main/
│       │   └── browser/
│       │       ├── window.ts                          # 现有(独立 BrowserPreview)
│       │       ├── normalize-url.ts                   # 现有
│       │       └── iab/                               ★ 本次新增
│       │           ├── index.ts                       ★ 入口:registerIabSidebar()
│       │           ├── sidebar-manager.ts             ★ IabSidebarManager(双形态切换)
│       │           ├── session-registry.ts            ★ IabSessionRegistry(tabIdsByPageKey 等)
│       │           ├── cdp-bridge.ts                  ★ IabCdpBridge(debugger.attach/sendCommand)
│       │           ├── input-translator.ts            ★ in-page 翻译器(从 Codex gP() 移植)
│       │           ├── iab-transport.ts               ★ 适配 ExtensionTransport 接口
│       │           ├── screenshot.ts                  ★ capturePage / Page.captureScreenshot 双策略
│       │           ├── navigation-guard.ts            ★ Page.navigationBlocked 接管
│       │           ├── dialog-handler.ts              ★ alert/beforeunload auto-accept
│       │           ├── ipc-handlers.ts                ★ 'phoenix-iab:*' channel 路由
│       │           └── constants.ts                   ★ partition 名 / token 名 / 超时常量
│       ├── preload/
│       │   └── browser/
│       │       ├── content.ts                         # 现有(BrowserPreview)
│       │       ├── toolbar.ts                         # 现有
│       │       └── iab/                               ★ 本次新增
│       │           ├── iab-content.ts                 ★ guest webContents preload(轻量,只暴露 inspect token)
│       │           └── iab-toolbar.ts                 ★ toolbar webContents preload
│       └── renderer/
│           ├── browser/
│           │   ├── toolbar.html                       # 现有
│           │   └── toolbar.ts                         # 现有
│           └── iab/                                   ★ 本次新增
│               ├── sidebar.tsx                        ★ React sidebar 组件(嵌入主聊天 UI)
│               ├── toolbar.html                       ★ IAB toolbar HTML
│               ├── toolbar.tsx                        ★ IAB toolbar React 组件
│               └── styles.css                         ★ IAB UI 样式
│
├── packages/
│   ├── sdk/
│   │   └── src/
│   │       └── vm/tools/function-tools/search/
│   │           ├── browser.ts                         ⚙ 修改:增加 backend='iab' 路由
│   │           └── browser_utils/
│   │               ├── handle-iab-action.ts           ★ 类似 handleRelayDirectAction 的 IAB 适配器
│   │               └── iab-runtime-bridge.ts          ★ 主进程 ↔ tool 桥(走 ipcMain.handle)
│   │
│   └── app/
│       └── src/
│           └── browser/
│               └── runtime/
│                   ├── extension-transport.ts          # 现有(B 用)
│                   ├── browser-runtime.ts              # 现有
│                   ├── tier0-actions.ts                # 现有
│                   ├── actions/                        # 现有 9 个 action,本次 0 改动 ★
│                   └── iab-transport.ts                ★ 新增:IabTransport(实现 ExtensionTransport 接口)
│                                                      # 注:与 main 进程的 iab-transport.ts 是配对关系,
│                                                      # 这里是 renderer/agent 侧的桩,通过 IPC 转到 main
│
└── docs/browser/
    ├── embed-browser-research.md                       # 现有(已评审)
    └── embed-browser-plan.html                         ★ 本文档

修改文件清单

文件改动影响面
apps/desktop/src/main/index.ts新增 registerIabSidebar({...}) 调用,注入 mainWindow / accessToken / cookie10 行内
apps/desktop/src/preload/index.ts新增 iabBridge(contextBridge.exposeInMainWorld)给 Owner Renderer20 行内
apps/desktop/electron.vite.config.tspreload entry 增加 browser/iab/iab-contentbrowser/iab/iab-toolbar;renderer entry 增加 iab/toolbarvite config block
packages/sdk/src/vm/tools/function-tools/search/browser.ts路由层增加 case 'iab': return handleIabAction(...)对应 backend selector 分支
packages/sdk/src/vm/tools/function-tools/search/browser_shared/contracts.tsBrowserBackend 类型增加 'iab'类型层
主聊天窗口 layout 组件(待定位)右侧布局留出 IAB sidebar 插槽,可拖拽宽度;支持 detach 按钮把 IAB 拆出独立窗口需要确认 layout 组件路径

6 · 详细模块设计

M1 · WebContentsView 双视图 + 双形态(sidebar / standalone)

核心逻辑:

// apps/desktop/src/main/browser/iab/sidebar-manager.ts (骨架)
import { BrowserWindow, WebContentsView, session } from 'electron'
import { IAB_PARTITION, TOOLBAR_HEIGHT } from './constants'

export type IabMode = 'sidebar' | 'standalone'

export class IabSidebarManager {
  private toolbarView: WebContentsView | null = null
  private contentView: WebContentsView | null = null
  private hostWindow: BrowserWindow | null = null
  private mode: IabMode = 'sidebar'
  private sidebarWidth = 480

  constructor(private opts: {
    mainWindowProvider: () => BrowserWindow | null
    onTabReady: (tabId: string, webContents: Electron.WebContents) => void
  }) {}

  async open(url: string, conversationId: string, mode: IabMode = 'sidebar') {
    this.ensureViews()
    await this.setMode(mode)
    this.setConversationId(conversationId)
    await this.contentView!.webContents.loadURL(url)
  }

  private ensureViews() {
    if (this.contentView) return
    const sess = session.fromPartition(IAB_PARTITION)
    this.toolbarView = new WebContentsView({
      webPreferences: {
        preload: getPreloadPath('browser/iab/iab-toolbar'),
        sandbox: false,        // toolbar 是我们自己的 UI,需要 IPC
        contextIsolation: true,
        nodeIntegration: false,
        session: sess,
      },
    })
    this.contentView = new WebContentsView({
      webPreferences: {
        preload: getPreloadPath('browser/iab/iab-content'),
        sandbox: true,         // guest 页面,严格沙盒
        contextIsolation: true,
        nodeIntegration: false,
        webviewTag: false,
        session: sess,
      },
    })
    // CDP 桥与生命周期注册由 IabCdpBridge / IabSessionRegistry 接管
    this.opts.onTabReady('iab-tab-1', this.contentView.webContents)
  }

  async setMode(mode: IabMode) {
    if (this.mode === mode && this.hostWindow) return
    // 1. detach from current host
    if (this.hostWindow && !this.hostWindow.isDestroyed()) {
      this.hostWindow.contentView.removeChildView(this.toolbarView!)
      this.hostWindow.contentView.removeChildView(this.contentView!)
    }
    // 2. resolve new host
    if (mode === 'sidebar') {
      this.hostWindow = this.opts.mainWindowProvider()
    } else {
      this.hostWindow = this.createStandaloneWindow()
    }
    // 3. attach
    this.hostWindow!.contentView.addChildView(this.toolbarView!)
    this.hostWindow!.contentView.addChildView(this.contentView!)
    this.mode = mode
    this.layoutBounds()
  }

  private layoutBounds() {
    if (!this.hostWindow) return
    const b = this.hostWindow.getContentBounds()
    if (this.mode === 'sidebar') {
      const x = b.width - this.sidebarWidth
      this.toolbarView!.setBounds({ x, y: 0, width: this.sidebarWidth, height: TOOLBAR_HEIGHT })
      this.contentView!.setBounds({ x, y: TOOLBAR_HEIGHT, width: this.sidebarWidth, height: b.height - TOOLBAR_HEIGHT })
    } else {
      this.toolbarView!.setBounds({ x: 0, y: 0, width: b.width, height: TOOLBAR_HEIGHT })
      this.contentView!.setBounds({ x: 0, y: TOOLBAR_HEIGHT, width: b.width, height: b.height - TOOLBAR_HEIGHT })
    }
  }
}

M2 · CDP 桥(webContents.debugger)

// apps/desktop/src/main/browser/iab/cdp-bridge.ts
import type { WebContents } from 'electron'
import { IAB_CDP_TIMEOUT_MS } from './constants'
import { translateInputCommand } from './input-translator'

export class IabCdpBridge {
  constructor(private wc: WebContents) {}

  async attach() {
    if (!this.wc.debugger.isAttached()) {
      this.wc.debugger.attach('1.3')
    }
    this.wc.debugger.on('message', (_e, method, params) => this.dispatchEvent(method, params))
  }

  async sendCommand(method: string, params: Record<string, unknown> = {}) {
    // K4 · Input.* 翻译为 in-page JS
    if (method.startsWith('Input.')) {
      return translateInputCommand(this.wc, method, params)
    }
    // K5 · captureBeyondViewport 走真实 CDP
    // K7 · 必须包超时
    return withTimeout(
      this.wc.debugger.sendCommand(method, params),
      IAB_CDP_TIMEOUT_MS,
      `Timed out running CDP command "${method}"`,
    )
  }

  detach() {
    if (this.wc.debugger.isAttached()) {
      try { this.wc.debugger.detach() } catch {}
    }
  }

  private cdpEventListeners = new Set<(method: string, params: unknown) => void>()
  onCdpEvent(cb: (method: string, params: unknown) => void) {
    this.cdpEventListeners.add(cb)
    return () => this.cdpEventListeners.delete(cb)
  }
  private dispatchEvent(method: string, params: unknown) {
    for (const cb of this.cdpEventListeners) cb(method, params)
  }
}

function withTimeout<T>(p: Promise<T>, ms: number, msg: string): Promise<T> {
  return new Promise((resolve, reject) => {
    let done = false
    const t = setTimeout(() => { if (!done) { done = true; reject(new Error(msg)) } }, ms)
    t.unref?.()
    p.then(v => { if (!done) { done = true; clearTimeout(t); resolve(v) } },
            e => { if (!done) { done = true; clearTimeout(t); reject(e) } })
  })
}

M3 · Input 翻译器(in-page JS,保 focus)

来源:Codex main-kSlb32Yb.js 函数 gP(),完整 5KB,从反编译里直接抠出。本次方案原样移植(改名 / 去掉 Codex 业务命名空间)。

// apps/desktop/src/main/browser/iab/input-translator.ts
import type { WebContents } from 'electron'

const TRANSLATOR_IIFE = `(${iabInputTranslator.toString()})`

export async function translateInputCommand(
  wc: WebContents,
  method: string,
  params: Record<string, unknown>,
) {
  const expr = `${TRANSLATOR_IIFE}(${JSON.stringify({ method, params })});`
  const r = await wc.executeJavaScript(expr, true /* userGesture */)
  if (r && typeof r === 'object' && (r as any).ok === true) return {}
  const err = (r && (r as any).error) || 'unknown translation failure'
  throw new Error(`Unable to translate ${method} in IAB: ${err}`)
}

// 注入到 page 内执行的翻译器(不是真的从这里 export,需要改成字符串模板)
function iabInputTranslator(cmd: { method: string; params: any }) {
  // ── 关键实现要点(完整版从 Codex gP() 1:1 抠出) ──
  // 1. elementFromPoint(x, y) + shadow DOM 递归 + same-origin iframe 递归
  // 2. cross-origin iframe 显式抛错(K9)
  // 3. PointerEvent + MouseEvent 双发(modern + legacy 兼容)
  // 4. mousedown → focus(label.control 或最近 focusable 父节点) → click 自动序列
  // 5. wheel → 自定义滚动容器查找 + scrollBy({behavior:'auto'})
  // 6. __phoenixIabExpectedInputTargetToken 元素 token 校验(K10)
  // 7. 鼠标键映射:left=0 / middle=1 / right=2 / back=3 / forward=4
  return { ok: true /* | error: 'msg' */ }
}

M4 · 截图双策略

// apps/desktop/src/main/browser/iab/screenshot.ts
import type { WebContents } from 'electron'

export async function iabScreenshot(
  wc: WebContents,
  bridge: IabCdpBridge,
  opts: { fullPage?: boolean; clip?: { x: number; y: number; width: number; height: number } } = {},
): Promise<Buffer> {
  // K5 · 默认走 capturePage(),GPU compositor,2-3x 快
  if (!opts.fullPage && !opts.clip) {
    const img = await wc.capturePage()
    return img.toPNG()
  }
  // K8 · 全页或带 clip 走真实 CDP,必须特判 captureBeyondViewport
  const params: Record<string, unknown> = {
    format: 'png',
    captureBeyondViewport: opts.fullPage === true,
    fromSurface: true,
  }
  if (opts.clip) params.clip = opts.clip
  const r = await bridge.sendCommand('Page.captureScreenshot', params) as { data: string }
  return Buffer.from(r.data, 'base64')
}

M5 · 单 partition + data-conversation-id 隔离

// apps/desktop/src/main/browser/iab/constants.ts
export const IAB_PARTITION = 'persist:phoenix-iab'             // K6 · 单 partition
export const IAB_CDP_TIMEOUT_MS = 30_000                        // K7
export const IAB_CONVERSATION_ATTR = 'iab-conversation-id'      // 挂到 dataset 里
export const IAB_INPUT_TOKEN_KEY = '__phoenixIabExpectedInputTargetToken'  // K10
export const TOOLBAR_HEIGHT = 48

设置 conversationId 不放在 <webview> data-* 上(我们用 WebContentsView 没有 webview 元素),改放在 IabSessionRegistry 的 routeKey 里,事件回流时通过 webContents.id 反查。partition 全局只有一个,登录态跨会话共享(对齐 Codex 体验)。

清理:

// 提供 IPC: 'phoenix-iab:clear-storage' { storages: ['cookies'|'siteData'|'cache'] }
import { session } from 'electron'
async function clearIabStorage(storages: string[]) {
  const sess = session.fromPartition(IAB_PARTITION)
  await Promise.all(storages.map(s => {
    if (s === 'cache') return sess.clearCache()
    return sess.clearStorageData({ storages: s === 'cookies' ? ['cookies'] : ['cookies', 'localstorage', 'indexdb', 'serviceworkers'] })
  }))
}

M6 · IAB Lifecycle Registry

对应 Codex 的 tabIdsByPageKey / routeKeysByTabId / debuggerListeners / cdpEventListeners 注册表,字段命名沿用 Codex(表达力好)。

// apps/desktop/src/main/browser/iab/session-registry.ts
import type { WebContents } from 'electron'
import { IabCdpBridge } from './cdp-bridge'

export interface IabTabState {
  tabId: string                    // 'phoenix-iab-tab:1'
  cdpTabId: string                 // 与 tabId 同义,用于对外暴露
  conversationId: string
  windowId: number                 // 宿主 BrowserWindow.id
  routeKey: string                 // `${windowId}:${conversationId}`
  webContents: WebContents
  bridge: IabCdpBridge
  url: string
  title: string
  faviconUrl: string | null
}

export class IabSessionRegistry {
  private tabsById = new Map<string, IabTabState>()
  private tabIdsByPageKey = new Map<string, string>()  // routeKey → tabId
  private routeKeysByTabId = new Map<string, string>()
  private seq = 0

  registerTab(conversationId: string, windowId: number, wc: WebContents, bridge: IabCdpBridge) {
    const tabId = `phoenix-iab-tab:${++this.seq}`
    const routeKey = `${windowId}:${conversationId}`
    const state: IabTabState = {
      tabId, cdpTabId: tabId, conversationId, windowId, routeKey,
      webContents: wc, bridge, url: '', title: '', faviconUrl: null,
    }
    this.tabsById.set(tabId, state)
    this.tabIdsByPageKey.set(routeKey, tabId)
    this.routeKeysByTabId.set(tabId, routeKey)
    wc.once('destroyed', () => this.unregisterTab(tabId))
    return state
  }

  unregisterTab(tabId: string) {
    const t = this.tabsById.get(tabId)
    if (!t) return
    t.bridge.detach()
    this.tabsById.delete(tabId)
    this.routeKeysByTabId.delete(tabId)
    this.tabIdsByPageKey.delete(t.routeKey)
  }

  getTabByConversation(conversationId: string, windowId: number) {
    const tabId = this.tabIdsByPageKey.get(`${windowId}:${conversationId}`)
    return tabId ? this.tabsById.get(tabId) : null
  }

  getTab(tabId: string) { return this.tabsById.get(tabId) }
}

M7 · IabTransport(适配 ExtensionTransport 接口,复用 tier0-actions)

关键:让 C 复用 B 的 packages/app/src/browser/runtime/actions/* 不改一行代码。做法:在 packages/app/src/browser/runtime/iab-transport.ts 实现一个与 ExtensionTransport 同型的 transport,内部走 IPC 转发到 main 进程的 IabCdpBridge

// packages/app/src/browser/runtime/iab-transport.ts
// 与 extension-transport.ts 同型 — 只在桌面端 main 进程或注入 IPC bridge 的 renderer 用

export interface IabTransportConfig {
  send: (method: string, params: unknown) => Promise<unknown>  // 由 main 进程注入
}

export class IabTransport {
  isConnected = true
  constructor(private cfg: IabTransportConfig) {}

  /**
   * tier0-actions 调用形态:
   *   transport.call('CDP.send', { targetId, cdpMethod:'Page.navigate', cdpParams:{url} })
   *   transport.call('snapshot', {...})
   *   transport.call('aria-snapshot', {...})  // 由 actions/snapshot.ts 决定
   */
  async call(method: string, params: unknown): Promise<unknown> {
    return this.cfg.send(method, params)
  }
}

// main 进程注入的 send 实现:
// async function iabSendImpl(method, params) {
//   if (method === 'CDP.send') {
//     const { targetId, cdpMethod, cdpParams } = params as any
//     const tab = registry.getTab(targetId)
//     if (!tab) throw new Error(`Unknown IAB tab: ${targetId}`)
//     return tab.bridge.sendCommand(cdpMethod, cdpParams ?? {})
//   }
//   throw new Error(`IAB transport: method '${method}' not supported`)
// }
路线 X 的"借"成本:这一层是 P1 BrowserDriver 抽象的入口。当前我们让 IabTransport 装作 是一个 ExtensionTransport,actions/* 不感知 backend 区别。P1 阶段会改造成:actions/* 直接接受 BrowserDriver 接口(cdpSend / capturePage / clearStorage / ...),IabTransport 这一层就被替换为 EmbeddedDriver implements BrowserDriver。本次先打这个临时桥。

M8 · browser tool 增加 'iab' backend

// packages/sdk/src/vm/tools/function-tools/search/browser_shared/contracts.ts (修改)
- export type BrowserBackend = 'sdk' | 'app'
+ export type BrowserBackend = 'sdk' | 'app' | 'iab'

// packages/sdk/src/vm/tools/function-tools/search/browser.ts (路由层修改)
const backend: BrowserBackend = resolveBackend(options, runtime)
switch (backend) {
  case 'sdk':
    return executePlaywrightAction(...)
  case 'app':
    return handleRelayDirectAction(...)
  case 'iab':                                              // ★ 新增
    return handleIabAction(action, params, options)        // ★
}

// packages/sdk/src/vm/tools/function-tools/search/browser_utils/handle-iab-action.ts (新增)
export async function handleIabAction(action, params, opts) {
  // 通过 IPC 桥(IabRuntimeBridge)转发到 main 进程,main 进程内构造 IabTransport
  // 再走 packages/app/src/browser/runtime/tier0-actions.ts 的 executeTier0Action()
  return await iabRuntimeBridge.execute(action, params)
}

backend 选择策略(resolveBackend):

条件选择
用户显式 backend: 'iab'iab
Linux 平台(无扩展)iab
未装 accio-browser-relay 扩展iab(onboarding 兜底)
有扩展且 desktop 客户端app(现状,优先)
web 端app(走扩展)
其他sdk(Playwright)

M9 · IPC 通道清单

Channel方向载荷说明
phoenix-iab:openrenderer → main · invoke{ url, conversationId, mode?: 'sidebar'\|'standalone' }打开/激活 IAB
phoenix-iab:commandrenderer → main · send{ type:'navigate'\|'back'\|'forward'\|'reload'\|'close'\|'clear-storage', ... }toolbar 操作 + 维护命令
phoenix-iab:set-moderenderer → main · send{ mode: 'sidebar'\|'standalone' }形态切换
phoenix-iab:resize-sidebarrenderer → main · send{ width:number }侧边栏宽度变更
phoenix-iab:statemain → renderer · send{ tabId, url, title, canGoBack, canGoForward, mode, isAgentControlling }状态推送给 toolbar / sidebar
phoenix-iab:agent-execute(in-process)main 内 · invoke{ action, params }browser tool 调用 IAB 的内部桥(non-IPC,直接函数调用)
phoenix-iab:navigation-blockedmain → renderer · send{ url, reason }导航拦截事件

7 · 与已有 BrowserPreview 的演化关系

当前 apps/desktop/src/main/browser/window.ts(510 行)是一个独立 BrowserWindow 形态的 BrowserPreview。本次 IAB 不删除它,而是按下面方式演化:

能力当前 BrowserPreview本次 IAB(MVP)未来归宿
独立窗口形态✅ 唯一形态✅ standalone 模式统一为 IAB standalone,删 window.ts 独立逻辑
侧边栏形态✅ sidebar 模式IAB 主形态
partitionpersist:browser-previewpersist:phoenix-iab合并为 persist:phoenix-iab;数据迁移可选
injectAuth(注入 Bearer/Cookie)✅ 复用同款 webRequest.onBeforeSendHeaders 逻辑沉到 IAB sidebar-manager 内
cookie 同步(defaultSession → 自有 partition)✅ 复用沉到 IAB
send-to-conversation(从 toolbar 把 URL/截图发回对话)P0 复用IAB toolbar 内置同款功能
agent CDP 控制✅ 核心新增IAB 唯一来源
IPC 命名空间browser-preview:*phoenix-iab:*新代码全走 phoenix-iab,旧 channel 保留 1 个 release 周期向后兼容
迁移策略:① W1 先把 BrowserPreview 现有功能拆成可复用 helper(injectAuth / syncCookies / normalizeUrl 等),不动 window.ts 主体;② IAB 直接 import 这些 helper;③ alpha 验证后再决定是否把 window.ts 整体改成调用 IAB standalone。

8 · 实施路线(W1-W4 MVP)

Week交付物关键工作验收
W1
骨架
IAB 能打开页面、双形态切换、toolbar 可用 ① 创建 apps/desktop/src/main/browser/iab/ 目录与 8 个文件骨架
② 实现 IabSidebarManager 双 view 创建 + sidebar/standalone 切换 + bounds 同步
renderer/iab/sidebar.tsx 在主聊天右侧渲染容器(可拖宽 / detach 按钮)
④ toolbar HTML/TS(从现有 renderer/browser/toolbar.html 复用 + 扩展)
phoenix-iab:open / command / set-mode / resize-sidebar / state 5 个 IPC 通道
手动测试:聊天里点按钮 → 右侧出现 IAB 加载 google.com → 切 standalone → 切回 sidebar,会话保留
W2
CDP 桥 + Input 翻译
agent 能通过 CLI/REPL 直接调 CDP 命令 IabCdpBridge.attach + sendCommand + 超时包装 + event listener
② Input 翻译器:把 Codex gP() 5KB 移植成 TS,改名 phoenix 命名空间
③ 元素 token 机制(K10)
④ 截图双策略(K5/K8)
⑤ navigation-guard / dialog-handler / window-open
IabSessionRegistry(K15 命名)
node REPL 跑通:bridge.sendCommand('Page.navigate', {url}) / 'DOM.getDocument' / 'Accessibility.getFullAXTree' / 'Input.dispatchMouseEvent'(走翻译器)
W3
tool 接入
browser tool 'iab' backend 上线,模型可用 BrowserBackend 类型扩 'iab',resolveBackend 加策略
IabTransport(packages/app)+ iabRuntimeBridge(packages/sdk)+ handleIabAction
③ main 进程暴露 phoenix-iab:agent-execute 内部调用接口
apps/desktop/src/main/index.ts 接入 registerIabSidebar
⑤ Clean storage / agent-controlling 状态指示(toolbar 加锁图标)
真实 agent 跑 5 个任务:Google 搜索 / 表单填写 / 多 tab(同一 IAB 标签内多次 navigate)/ 滚动加载 / 截图回流。结果跟 Backend B 走 Chrome Extension 等价
W4
打磨 + 评测
alpha 可发布 ① 错误路径:CDP 超时、debugger detach、cross-origin iframe 抛错信息
② 性能:capturePage vs CDP 截图 benchmark / 多次 snapshot 内存稳定性
③ Linux 平台跑通(electron-builder linux target)
④ 集成测试 4-5 条:apps/desktop/src/main/browser/iab/__tests__/ + 录屏回归
docs/browser/embed-browser-plan.html 转成发布文档,补"用户首次使用引导"
① CI 通过;② alpha 发版灰度 10 个内部用户;③ 跑通 30 个真实用户任务,与 Backend B 对比 ≥95% 一致(snapshot 字节、action 成功率)

9 · 验收标准

类别标准
功能① IAB 能在主聊天右侧正常渲染,可拖宽,可 detach 成独立窗口;② toolbar 前进/后退/刷新/关闭 / 地址栏可用;③ agent 通过 browser tool 'iab' backend 能跑通 navigate / snapshot / act / screenshot / scroll(tier0 全集)
性能① 截图 P95 ≤ 200ms(viewport)/ ≤ 800ms(全页);② snapshot P95 ≤ 1s(中等页面);③ 形态切换无白屏闪烁
稳定性① 30 个真实任务跑完,IAB tab 内存稳定,debugger 不泄漏;② 关闭 sidebar 后再打开,partition 数据保留;③ Cmd+Q 退出时 debugger 全部 detach
跨平台macOS arm64/x64 + Windows x64 + Linux x64 三平台 IAB 都可用
对齐 Backend B同样 30 个任务跑 Backend B vs Backend C,snapshot 字节差 ≤5%、action 成功率差 ≤2%、screenshot 像素对比 PSNR ≥ 30dB
对齐 Codex 风格K1-K15 中除了 K1 主动偏离(用 WebContentsView 而非 webview)外全部对齐

10 · 风险与坑(基于 Codex 反编译提炼)

#风险来源缓解
R1Input 命令翻译器对 cross-origin iframe 不支持Codex gP() 显式抛错错误信息明确告知 agent;P2 评估是否补 OOPIF 路径(走真 CDP Input.dispatchMouseEvent,接受 focus 抢占代价)
R2WebContentsView 在某些 Electron 版本有 bounds 计算 bug电影业反馈 + Electron issue tracker锁定 Electron 版本;form 切换时强制 layoutBounds() 二次刷新(参考 BrowserPreview 现有 syncToolbarBounds 模式)
R3action 暂时变三套实现(SDK Playwright / B tier0-actions / C 借 B 同款 → 仍是两套但 transport 三套)语雀指出明确 P1 跟进 BrowserDriver 抽象;在 IabTransport 注释里贴 ADR 链接
R4partition 单值意味着所有会话共享 cookie/localStorage;某些场景需要"会话级隔离"Codex 同款,UX 决策提供 iab:clear-storage 显式清;后续如有强需求,加 partition-per-conversation 选项,但默认共享
R5navigation 拦截白名单需要业务侧定义Codex 有 hide-local-serverMVP 用最小白名单(本机 dev server + 知名钓鱼站黑名单);P2 接入 phoenix 现有的 url 安全策略
R6AccessibilityTree 在某些站点(如 SPA + 大量 generic 节点)体积爆炸已知问题复用 SDK snapshot/pruning/ 5 阶段管线;extreme case 走 ref-stab 阶段降级
R7Electron debugger 不能并发:同一 webContents 同一时刻只能一个 debugger 客户端Electron 文档IabCdpBridge 设计为单实例 per webContents;主进程串行化命令(已通过 Promise 链保证)
R8guest 页面运行 service worker / 长 JS 阻塞会卡死 executeJavaScriptCodex 用 cdpCommandTimeoutMs 包了一层K7 已覆盖;超时后明确告知 agent,不让 agent 死等
R9IAB 与现有 BrowserPreview 同时存在期间 partition 命名不同,登录态隔离本方案演化决策W1 实施时先在 BrowserPreview 加迁移逻辑(读旧 partition cookie 写到新 partition);或显式文档说明"alpha 期间登录可能需要重新输入"
R10preload 路径在 build vs dev 不一致electron-vite 现状统一用 __dirname + 相对路径,参考现有 main/browser/window.tsgetPreloadPath() 模式

11 · Alternatives(本次未采纳但保留)

方向本次未选原因什么时候应该重新考虑
<webview> tag(完全照 Codex)选了 WebContentsView① Electron 已 deprecated;② phoenix-pc 现有 BrowserPreview 已用 WebContentsView;③ 性能 + 主进程统一管理除非发现 WebContentsView 在 sidebar attach 模式下有不可解 bug
路线 Y(先抽 BrowserDriver 再做 C)选了路线 X用户原始诉求是"内置浏览器",路线 Y 工期 4-6 周且影响 A/B 线上稳定性P1 阶段独立 RFC,届时把 IAB 一起重构进 BrowserDriver
node_repl + JS SDK 范式本次不实施跨度涉及 SKILL.md / 模型路由 / 评测体系;独立议题独立立项,语雀 §4 已有完整 MVP 路径
Comment 标注层(36MB React 包)MVP 不做价值高但与 agent 控制核心解耦,且 Codex 自己用了 30+MB 资源,phoenix 短期吞不下P2,作为产品化"用户标注网页元素发问"功能时
外部 CLI 接入(native pipe + peer auth)不开放phoenix-pc 没有独立 CLI 进程要远程操作 IAB如果未来出 Phoenix CLI 需要
Codex 自家 in-page DOM walker(snapshot 不走 CDP)不采用phoenix tier0-actions/snapshot.ts 已用更标准的 Accessibility.getFullAXTree + 5 阶段剪枝管线,语雀评价"比 Codex 更强"不会重新考虑
多 partition(per-conversation)单 partition对齐 Codex,UX 更好(同站点不重复登录)有"会话强隔离"硬需求时,作为 opt-in 选项
Sparkle/Squirrel/MSIX 自更新沿用现有 phoenix-pc electron-builder 自更新phoenix-pc 已有 dist:beta/stable 全套不重新考虑

12 · 后续演进(P1/P2/P3 Roadmap)

这些来自语雀调研 §3.4 / §B.3,IAB 上线后跟进。本次 plan 不实施,但列出来让 reader 看到全景。

优先级动作来源收益
P1 BrowserDriver 接口 + shared-actions/,把 A/B/C 的 action 实现统一为单一来源 语雀 §3.3,本次 R3 消除"action 实现两套(P1 后)/ 三套(本次 X 路线)"的代码漂移
P1 node_repl + JS SDK MVP(setupAtlasRuntime 等价物 + display() + 翻译 SKILL.md) 语雀 §4 LLM 调用降 50-70% / token 降 40%+ / 端到端耗时降 50%+
P1 移植 Tab claim / finalize / group 协议(SDK 接口 + 扩展 chrome.tabs.group API) 语雀 §B.2 / Codex SKILL.md § User Tab Claiming UX 大幅提升;Backend A/B 受益,IAB 简化版即可
P1 抄 Codex 三脚本:installed-browsers.js / chrome-is-running.js / open-chrome-window.js 语雀 §B.1 Backend A 初次启动体验完整闭环;~3 天工作量纯收益
P1 翻译 Codex SKILL.md 60% 段落作为模型 system prompt 语雀 §B.1 模型行为质量直接提升一档
P2 合并 Codex generic/listitem/group collapse 启发式到 SDK enforce 阶段 语雀 §B.2 / Codex domSnapshot snapshot 体积再降 ~20%
P2 暴露 Playwright locator 链给模型(getByRole/Text/Label) 语雀 §B.2 / Codex Playwright shim 消除"必须先 snapshot 拿 ref"的硬约束
P2 Confirmation Taxonomy 4 级文档化 + 映射到 Phoenix 现有 confirm 路径 语雀 §B.2 / Codex SKILL.md § Browser Use Confirmations Policy 授权策略对外可解释
P2 IAB Comment 标注层(hover outline + 评论气泡 + anchor 注入 agent 上下文) Codex comment-preload.js 用户能直接在网页上标注问 agent;但与 agent 控制核心解耦
P3 剪贴板 API(read/write/readText/writeText) Codex tab.clipboard 补漏
P3 iframe / frameLocator 支持 Codex Playwright shim 补漏
P3 下载流(Browser.setDownloadBehavior) Codex downloadMedia 补漏
P3 外部 CLI 接入(unix socket + peer auth) Codex native pipe + browser-use-peer-authorization.node 如未来出 Phoenix CLI 需要
P3 Touch ID 二次确认(macOS LocalAuthentication) Codex 预留接入点 支付/敏感场景

13 · 附录 · 关键文件路径与对照

13.1 phoenix-pc 现有相关文件(已存在,本次复用 / 演化)

文件角色
apps/desktop/src/main/browser/window.ts独立 BrowserPreview(510 行),W1 拆 helper 复用
apps/desktop/src/main/browser/normalize-url.tsURL 归一化,IAB 直接复用
apps/desktop/src/preload/browser/content.tsBrowserPreview content preload(参考结构)
apps/desktop/src/preload/browser/toolbar.tsBrowserPreview toolbar preload(参考结构)
apps/desktop/src/renderer/browser/toolbar.htmlBrowserPreview toolbar HTML(参考样式)
apps/desktop/src/main/index.ts主进程入口,W3 接入 registerIabSidebar
apps/desktop/electron.vite.config.ts构建配置,W1 加 preload/renderer entry
packages/sdk/src/vm/tools/function-tools/search/browser.tstool 公开契约,W3 加 'iab' backend 路由
packages/sdk/src/vm/tools/function-tools/search/browser_shared/contracts.tsBrowserBackend 类型,W3 扩 'iab'
packages/app/src/browser/runtime/extension-transport.tsExtensionTransport 接口,W3 IabTransport 同型
packages/app/src/browser/runtime/browser-runtime.tsAppBrowserRuntime,W3 复用
packages/app/src/browser/runtime/tier0-actions.tsaction 路由,W3 0 改动复用
packages/app/src/browser/runtime/actions/*.ts9 个 action 实现,W3 0 改动复用
packages/sdk/src/browser/snapshot/pruning/5 阶段剪枝管线,自动复用
docs/browser/embed-browser-research.md第一轮调研(已评审,部分结论已修正)
docs/app-browser-cloud-plan.mdApp Browser 云端方案(背景)
docs/app-browser-playwright-migration.mdtier0-actions 优化背景
docs/chrome-cdp-analysis.mdCDP 直连分析(背景)

13.2 Codex IAB 反编译关键证据(可自行复现)

# 1) parse asar pickle header(无需 npm 安装,纯 python)
python3 - <<'EOF'
import struct, json
with open("/Applications/Codex.app/Contents/Resources/app.asar","rb") as f:
    head = f.read(16)
    outer = struct.unpack('<I', head[4:8])[0]
    jlen  = struct.unpack('<I', head[12:16])[0]
    f.seek(16); manifest = json.loads(f.read(jlen).decode('utf-8'))
print(list(manifest['files']['.vite']['files']['build']['files'].keys()))
EOF
# 输出包含:bootstrap.js / main-kSlb32Yb.js / app-session-O7kcZj7R.js /
#           comment-preload.js / preload.js / sandbox-preload.js / ...

# 2) 关键字符串 grep(确认 K1-K15 全部命中)
grep -oE "webviewTag|will-attach-webview|debugger\.attach|capturePage|IAB_LIFECYCLE|\
browser-use-iab|tabIdsByPageKey|routeKeysByTabId|captureBeyondViewport|\
__codexIabExpectedInputTargetToken|persist:codex-browser-app-route|non_mcp_app_sandbox_webview" \
  /tmp/codex-extract/main.js | sort | uniq -c

13.3 Codex 新增命名(phoenix-pc 沿用语义,改 namespace)

Codex 名phoenix-pc 名
BrowserSidebarManagerIabSidebarManager
BrowserSessionRegistryIabSessionRegistry
browser-use-iab-apiphoenix-iab-api
browser-use-iab-tab:${cdpTabId}phoenix-iab-tab:${cdpTabId}
persist:codex-browser-app-route:apppersist:phoenix-iab
data-browser-sidebar-conversation-id(改用 IabSessionRegistry 维护,不挂 dataset)
__codexIabExpectedInputTargetToken__phoenixIabExpectedInputTargetToken
IAB_LIFECYCLE 日志前缀PHOENIX_IAB_LIFECYCLE
BROWSER_PREVIEW_OPEN_CHANNEL(现有)phoenix-iab:open(新)

本文档为技术方案 DRAFT,等待评审。落地实施前还需:① 主聊天窗口 layout 组件路径定位与改造方案确认;② phoenix-pc Electron 版本与 WebContentsView API 兼容性核对;③ 与 packages/sdk owner 同步 backend selector 策略变更。