这是「omp 编程工具使用探讨」专栏的又一篇追加文章。前面几篇讲的都是 agent 的”脑”——Rules、TTSR、Skill、Memory、多 agent、内部 URL;这篇讲”手”:怎么让 omp 真正操作你日常在用的那个浏览器。素材来自我刚做完的一次实测——omp 在 WSL2 里,Chrome 在 Windows 上,最后 agent 成功驱动了真实标签页。
TL;DR:想用你的登录态,共享 user-data-dir 是死路(DPAPI 加密 + profile 锁 + 正常启动的 Chrome 根本不监听 CDP 端口)。正解是 browser.relay:WSL 里跑一个冒充 Chrome CDP 发现端点的本地中继,Windows Chrome 里的 MV3 扩展用 chrome.debugger 主动拨回来,omp 的 puppeteer 连中继就能落到你真实浏览器的标签页上。relay 模式不启动任何浏览器——在 WSL 里开 Chrome 是错的解法。最大的坑是 relay 会过滤 chrome:// 等内部页,没有普通网页时报 No page targets available on the attached browser。
目标不是”能上网”,是”用你的身份上网”
先把需求说准。让 agent 上网不难,难的是让它用你已登录的身份上网:后台管理台、内网系统、登录过的 SaaS——agent 自己打开是登录页,用你日常那个浏览器打开就是登录态。
omp 的 browser 工具(Eval 里的 browser facade)有几种浏览器来源(docs/tools/browser.md):
| 模式 | 触发方式 | 浏览器从哪来 | 有你的登录态吗 |
|---|---|---|---|
| headless | 默认 | omp 在项目里共享的 Chromium | ❌ |
| spawned | app.path | 指定可执行文件新起一个 | ❌(独立 profile) |
| connected | app.cdp_url | 连一个已在跑的 CDP 端点 | 取决于那个端点 |
| relay | app.relay: true 或 browser.relay | 你自己那个 Chrome | ✅ |
显式指定时的优先级是 app.cdp_url → app.path → app.relay;不显式指定时,先看 relay 设置,再看配置的 CDP 端点,然后 cmux(omp 连接 WKWebView 表面的后端),最后才回落到项目共享的无头 Chromium(tools/browser.ts 的 resolveBrowserKind)。
命名规则:
app.*是单次browser.open的调用参数,browser.*是全局配置项。app.cdp_url与browser.cdpUrl就是同一个东西的两种形态。
为什么”共享用户数据目录”走不通
最初的问题很直接:能不能让 omp 的浏览器和 Windows 上的 Chrome 共享用户数据(把 --user-data-dir 指过去),这样登录信息就直接可用?三条硬伤:
- Windows Chrome 的 cookie/密码是 DPAPI 加密的,密钥绑定 Windows 用户账户。WSL 里的 Linux 浏览器进程解不开这些密文,profile 拷过去登录态全部失效。
- 同一 profile 目录不能被两个实例同时占用——Chrome 有单例锁,正开着的时候第二个进程进不去。
- CDP 直连也走不通(再退一步:不共享目录,直接去连正在运行的那个 Chrome 呢?):正常双击启动的 Chrome 根本不监听调试端口。本次实测里主进程的命令行就是干干净净的
"C:\Program Files\Google\Chrome\Application\chrome.exe",没有任何 flag。omp 的浏览器启动路径遇到”同机已有 Chrome 在跑、却没有可复用 CDP 端点”时,会直接报错(tools/browser/attach.ts的findReusableCdp):
Cannot launch chrome.exe because it is already running without a reusable CDP endpoint.Close chrome.exe, relaunch it with --remote-debugging-port, or pass app.cdp_url for an existing endpoint.(注意这条来自”同机启动浏览器”的路径;在 WSL + Windows 的组合里,你更可能先撞上后文那个 No page targets available。)
要让 Chrome 监听调试端口,就得带 --remote-debugging-port 重启;而 Chrome 官方近年来专门发文收紧了这类开关(Changes to remote debugging switches to improve security)。就算你愿意重启,代价也是把当前所有窗口关掉。
于是 relay 的定位就清楚了:不碰你的 profile,不要求你重启浏览器,而是从浏览器内部开一扇门。
Relay 架构:一次”倒着拨号”的 CDP 中继
+------------------------------+ +----------------------------------+| Chrome (Default profile) | | omp session || +-- your tabs (logged in) | | +-- prelude (puppeteer) || +-- Relay ext (MV3) | | | || | chrome.debugger API | | | /cdp || | | | v || +-- /ext -------------+ | | --> relay @127.0.0.1:9224 |+------------------------------+ +----------------------------------+ (扩展主动拨出 ws://127.0.0.1:9224/ext)几个关键点,逐条对应源码:
- 中继冒充 Chrome 的 CDP 发现端点。
tools/browser/relay/kind.ts的注释原文是 “The relay impersonates Chrome’s CDP discovery endpoint”。它对外长得就像一台 Chrome,于是 omp 那套”连接已有浏览器”的机制(registry、tab supervisor、tab worker)一行都不用改:GET /json/version→ 扩展连上后返回webSocketDebuggerUrl;扩展没连上时返回 503(relay/server.ts)。puppeteer 侧会轮询这个端点(有超时上限,不是无限等),所以 503 表示”等扩展”,不是故障。GET /json/list→ 可驱动的页面目标(源码注明是 debugging aid)。WS /cdp→ 下游 CDP 客户端(puppeteer)。WS /ext→ 扩展专用入口,配了 token 就校验?token=。
- 为什么必须是浏览器扩展。 MV3 的 service worker 只能主动拨出,不能监听端口——
relay/daemon.ts注释写得很明白:“The MV3 extension can only dial OUT (service workers cannot listen on sockets)“。所以必须有一个原生进程占住端口,而进入 Chrome 内部唯一的路是扩展的chrome.debuggerAPI。中继就是这个翻译层:把chrome.debugger包装成 CDP。 - 为什么 WSL 里只有中继、没有浏览器。 浏览器自始至终是你 Windows 上那个 Chrome。在 WSL 里开 Chrome 解决不了任何问题:那是另一个 profile、另一套 cookie,和你的登录态毫无关系,只会多一层困惑。relay 模式下 omp 不启动任何浏览器进程——它只起一个中继守护进程。
- 跨 WSL/Windows 的那一跳。 WSL2 默认开启 localhost 转发,Windows 侧访问
127.0.0.1:9224会落到 WSL 里的监听端口。这条在本次实测的机器上验证过:WSL 里绑一个回环服务,Windows 侧Invoke-WebRequest http://127.0.0.1:9225拿到响应。若你改过.wslconfig关掉localhostForwarding,需要改回来或改用 mirrored 网络模式。
架构不复杂,动手也就四步。
装起来:四步
# 1. 生成扩展(在 WSL 里执行),默认写到 ~/.omp/browser-relay/extensionomp browser-relay install
# 2. 复制到 Windows 侧(建议,原因见下)cp -r ~/.omp/browser-relay/extension /mnt/c/Users/<你>/omp-relay-ext
# 3. Chrome → chrome://extensions → 打开开发者模式# → “加载已解压的扩展程序” → 选第 2 步复制到 Windows 的那个目录
# 4. 打开开关omp config set browser.relay true第 4 步之后不需要手动起中继。omp 在真正需要浏览器时才懒启动一个 broker 托管的守护进程(relay/daemon.ts):守护进程名 omp.browser.relay,broker scope browser-relay,是机器全局单例——多个项目共享同一个中继,任何一个项目退出都不会把它拆掉。想手动起(比如加 token 或调试)才用 omp browser-relay。
换端口要成对配置:omp browser-relay serve -p 9333 之后,omp 侧 omp config set browser.relayUrl http://127.0.0.1:9333,扩展 options 页里也填同一个端口(配了 token 同理)。
第 2 步为什么建议复制:直接从 \\wsl.localhost\... 的 UNC 路径加载扩展也能用(本次实测就是这么装的,Chrome 接受了 UNC 路径),但很脆弱——WSL 没启动、发行版改名、路径变动,Chrome 启动时扩展就会加载失败。复制到 Windows 本地可以解除对 WSL 启动顺序的依赖。
扩展本身很轻:MV3,权限只有 debugger、tabs、tabGroups、storage、alarms(extension-assets/manifest.json.txt)。它会每 20 秒 ping 一次中继,断线后按 1s→10s 指数退避重连,另外挂了一个 0.5 分钟的 keepalive alarm 兜住 service worker 被回收的情况(background.js)。连通后工具栏图标会显示 on。
第 5 步:验证与排错
装完先确认三件事:
- 工具栏图标变成
on——扩展已连上中继。 - Chrome 里至少开着一个普通网页,否则第一次调用必然报
No page targets available on the attached browser(原因见下文「机制细节 1」)。 - 最小冒烟测试:
const tab = await browser.open({ app: { relay: true }, url: "https://example.com" });await tab.title(); // "Example Domain"读回 URL/Title 就算通。失败时按这个顺序查:
| 现象 | 先查什么 |
|---|---|
图标不是 on | 扩展是否启用、options 页端口是否与中继一致 |
图标是 on,但报 No page targets | 开一个普通网页(chrome:// 不算) |
| 连接被拒 / 中继起不来 | .wslconfig 的 localhostForwarding 是否被关掉 |
| 想看清中继在干什么 | omp ps logs omp.browser.relay --global browser-relay |
实测证据链
下面是我这次装完之后的实测结果。
| 步骤 | 结果 |
|---|---|
| 扩展位置 | Chrome Default profile,路径 \\wsl.localhost\Ubuntu-24.04\home\shanyou\.omp\browser-relay\extension,location=4(未打包扩展) |
| 中继连通 | WSL 内 omp.browser.relay 监听 127.0.0.1:9224;/json/version → Chrome/152.0.0.0(实测时你 Chrome 的实时版本,由扩展从 UA 提取)、UA Windows NT 10.0 |
| 新建标签页 | 经中继发 CDP Target.createTarget → 返回 PAGE848164796;Chrome 里真的开出 example.com,中继日志出现 grouped tabs(收进 “omp” 标签组) |
| 驱动页面 | browser.open({ app: { relay: true } }) 复用该标签,URL/Title 正确;tab.evaluate 在页面里执行 JS 得到 links=1 |
| 自动启动路径 | 停掉手动中继后,browser.relay=true 触发 broker 自动拉起守护进程并重连成功 |
| 清理 | 测试标签页用 CDP Target.closeTarget 关闭;用户自己的标签页未受影响 |
Default profile 这点也有据可查:Secure Preferences 里该扩展的 path/location 与上表一致。也就是说,agent 打开你登录过的站点,拿到的就是已登录状态。顺带一提,relay 模式下 tab.close({ kill: true }) / browser.close({ kill: true }) 只释放 omp 侧的句柄,不会关闭或杀掉你的 Chrome(docs/tools/browser.md)。
证据链之外,还有三个机制细节值得单独拎出来——第一个就是最容易踩的坑。
三个必须知道的机制细节
1. chrome:// 会被过滤 —— “No page targets”
这是最容易踩的坑。中继有一条过滤正则(relay/bridge.ts):
/** URLs `chrome.debugger` cannot attach to; hidden from downstream discovery entirely. */const INELIGIBLE_URL = /^(chrome|devtools|edge|view-source|chrome-extension|chrome-untrusted|chrome-search):/i;chrome.debugger 无法 attach 这些内部页,所以它们从下游的发现结果里被整体隐藏。本次实测一开始就栽在这里:扩展首次连上中继时只上报了 2 个标签页(hello 是它的握手消息),而 /json/list 返回 []——那 2 个都是 chrome:// 内部页。此时 omp 报:
No page targets available on the attached browser(tools/browser/attach.ts 的 pickElectronTarget:可发现的 page target 和 browser.pages() 都为空时抛出。)
结论:relay 需要至少一个普通网页标签。要么你自己开一个,要么让 agent 直接 browser.open({ url: ... }) 新建——新建走的是扩展的 createTab(chrome.tabs.create),不受这条过滤影响,实测中它正是这样开出了第一个可用标签。
2. 标签组是”agent 正在控制”的可视信号
relay 默认把 agent 真正驱动的标签收进一个名为 omp 的 Chrome 标签组(DEFAULT_GROUP = { title: "omp", color: "cyan" },relay/server.ts)。两条相关规则:
- 把标签拖出这个组 = 该中继进程存续期间的 opt-out。 中继看到
groupId变了就标记groupOptOut,之后不再把该标签分组——源码注释:“the relay never fights the user over grouping”。注意这是中继进程内的内存状态,中继重启后会重置。 - 扩展断线时解散分组。
background.js在 socketonclose里调用restoreGroups(),把 agent 分组的标签退回原状,不会在你浏览器里留下一堆幽灵分组。
不想要分组:omp browser-relay serve --no-group。
3. 中继只绑回环,且有 token 与 Origin 两道门
relay/server.ts 绑定 127.0.0.1,源码注释把风险写得很直白:“anything that can reach this port can drive the user’s logged-in browser.”
两道防护:
/cdp拒绝任何带 Origin 头的请求——浏览器里的网页发 WebSocket 会带 Origin,原生 CDP 客户端不会,所以这条恰好挡住”网页操纵中继”。返回 403。/ext在配置了 token 时要求扩展带?token=。起中继时加omp browser-relay serve --token <secret>,再在扩展的 options 页(点工具栏图标打开)填同样的 token 和端口。
安全边界:它操作的是”你”
这条比任何技术细节都重要。官方文档的措辞值得原样引用(docs/tools/browser.md):
Relay and attached modes operate on real logged-in sessions; sites attribute actions to the user. Name a target or create a dedicated tab. Never navigate the user’s visible tab or take a consequential action without direct authorization.
实践上:
- 给 agent 明确目标:
browser.open({ app: { relay: true, target: "后台" }, url: ... })。app.target是 URL/标题子串;不传时会采用当前可见的可用标签——这正是最需要小心的默认行为。 - 不可逆动作逐次确认:转账、删除、发帖、发消息。站点看到的是你的账号和 IP。
- 想让 agent 别碰主浏览器:不要开
browser.relay;需要临时紧急禁用(比如中继挂了导致浏览器工具整体不可用)就用PI_BROWSER_RELAY=0——它是 relay 模式的最终开关,=1则强制开启。也可以给它一个独立的 Chrome profile / 独立 CDP 端点。
什么时候不要用 relay
| 场景 | 更合适的选择 |
|---|---|
| 抓公开网页、跑无头脚本 | 默认 headless(更快、无副作用) |
| 需要干净环境复现问题 | app.path 起一个独立 profile |
| 已有稳定的调试端点 | app.cdp_url(或 browser.cdpUrl 设默认) |
| 只是要读一篇静态文章 | read <url>(根本不启动浏览器) |
总结
一句话:
omp 跑在 WSL、Chrome 跑在 Windows、登录态在 Chrome 里——relay 用一个冒充 CDP 发现端点的本地中继加一个 MV3 扩展,把
chrome.debugger翻译成 CDP,让 puppeteer 直接落到你真实浏览器的标签页上。
值得带走的四点:
- 别共享 profile:DPAPI 加密、profile 单例锁、没有 CDP 端口,三条路都堵死
- relay 模式不启动浏览器:WSL 里只跑中继;在 WSL 里开 Chrome 是错的解法,因为那是另一个身份
chrome://会被过滤:没有普通网页就是No page targets available,先开一个页面或让 omp 新建- 它操作的是”你”:站点看到的是你的账号与 IP,不可逆动作要逐次确认
上一篇埋下的 local:// 钩子,下一篇来还:用 write local://plan.md + task 指令引用,把超长上下文传给子代理而不撑爆对话(之前只在内部 URL 那篇带过一笔)。
本文是「omp 编程工具使用探讨」专栏的追加篇。源码引用来自 oh-my-pi 当前版本(相对 packages/coding-agent/src/:tools/browser/relay/、tools/browser/attach.ts、cli/browser-relay-cli.ts、config/settings-schema.ts、docs/tools/browser.md),具体实现可能随版本变化,建议以实际源码为准。