跳到主要内容

数据截至 (上游 commit fd0b7e1d9ed9)

第 2 章:浏览器与页面模型

这章讲什么: Chrome 是什么时候、以哪种方式被拿到的;页面在内部怎么被编号和跟踪;以及为什么这个 server 敢让模型写文件。


2.1 拿到一台浏览器:启动还是接管

它要解决的小问题

用户可能想要三种东西:开一台干净的新 Chrome、接管自己正开着的那台 Chrome、或者连一台远程/容器里的 Chrome。三种的收尾语义完全不同——自己开的要负责关掉,别人的绝不能关。

两条路

分叉点在 src/index.ts:139-170:

有 browserUrl / wsEndpoint / autoConnect ?

├── 是 ──→ ensureBrowserConnected() src/browser.ts:47
│ browserMode = 'connected'
│ 退出时 disconnect() ← 用户的 Chrome 继续活着

└── 否 ──→ ensureBrowserLaunched() src/browser.ts:279
browserMode = 'launched'
退出时 close() ← 子进程被回收

两个函数都先看 browser?.connected,已有连接就直接复用——browserbrowserMode模块级变量(src/browser.ts:21-22),整个进程一台浏览器。

一个真实的时序坑

两处赋值都写成了"先 mode 后 browser",并配了注释(src/browser.ts:129-134:285):

const connected = await puppeteer.connect(connectOptions);
browserMode = 'connected';
browser = connected;

原因:如果反过来,一个并发的 closeBrowser() 可能看到 browser 已设而 browserMode 还是 undefined,于是走到 disconnect() 分支——把一台自己启动的 Chrome 变成孤儿进程

autoConnect:从 profile 目录里读端口

只给了 --userDataDir 没给地址时,代码去读该目录下的 DevToolsActivePort 文件(src/browser.ts:85-104),第一行是端口、第二行是路径,拼成 ws://127.0.0.1:<port><path>。读不到就报一句带修复建议的错:"检查 Chrome 是否在运行、是否开了远程调试,去 chrome://inspect/#remote-debugging 看看"。

启动参数里的几个决定

设置为什么
userDataDir~/.cache/chrome-devtools-mcp/chrome-profile[-channel]默认持久化 profile,登录态跨会话保留;--isolated 可关掉
pipetrue用管道而非 websocket 通信
defaultViewportnull不强加视口,页面用真实窗口尺寸
handleDevToolsAsPagetrueDevTools 窗口本身也当页面看待,这是第 6 章读取"用户选中元素"的前提
headless 时--screen-info={3840x2160}给无头模式一块大屏
总是加--hide-crash-restore-bubble挡掉崩溃恢复气泡,免得挡住页面

还有一个 targetFilter(src/browser.ts:24):过滤掉 chrome://chrome-untrusted://(未开扩展时还有 chrome-extension://),但放行 chrome://newtab/chrome://inspect——因为它们可能是浏览器里唯一开着的页面,过滤掉就一个页面都没有了。

已经在跑的报错

启动失败且错误里带 "The browser is already running" 时,换成一句可操作的话(src/browser.ts:263-274):

The browser is already running for <dir>. Use --isolated to run multiple browser instances.


2.2 McpContext:浏览器级状态

职责

McpContext(src/McpContext.ts:81)持有一台浏览器上的所有跨页面状态:

状态字段说明
页面表#mcpPages: Map<Page, McpPage>Puppeteer 页面 → 本项目的包装
当前选中页#selectedPage大多数工具的隐式目标
隔离上下文#isolatedContexts: Map<string, BrowserContext>名字 → 独立 cookie/存储空间
扩展 service worker#extensionServiceWorkers编号为 sw-1sw-2
性能追踪#isRunningTrace#traceResults只保留最新一条 trace
堆快照#heapSnapshotManager见第 6 章
路径沙箱#roots#allowUnrestrictedPaths见 2.4

构造是私有的,只能走 McpContext.from(:198),因为 #init(:139)必须先跑:建页面快照、建扩展 worker 快照、订阅 targetcreated/targetdestroyed

上下文什么时候重建

getContext() 里(src/index.ts:172):

if (context?.browser !== browser) {
context?.dispose(); // 摘监听、清页面、关堆快照 worker
context = await McpContext.from(browser, …, {reconnected: context !== undefined});
}

判据是"浏览器实例变了",不是"连接断了"。浏览器换了(重连出一个新实例),整个上下文重来。

重连通知

重建时带上 reconnected: true,consumeReconnectNotice()(src/McpContext.ts:455)会在下一次响应里吐一句一次性提示(src/McpResponse.ts:840-845):

Note: the browser was restarted or reconnected since the last call. Page ids have changed. Call list_pages to see open pages.

消费即清除。这样模型不会拿着旧 id 一头撞死。


2.3 页面 id:一个计数器的讲究

关键设计

// src/McpContext.ts:74-78
// Page ids are handed out from a process-wide counter so they stay unique
// across all contexts, in particular across browser reconnects.
let nextPageId = 1;

计数器是模块级的,不是实例级的。 意味着重连后新页面从 4、5、6 继续发号,而不是重新从 1 开始。

为什么重要:如果重新从 1 开始,模型手里那个"页面 1"会静默地指向另一个完全不相干的页面——点错东西且毫无征兆。现在它只会得到 getPageById 抛的 No page found(:433-439),一个响亮的失败远好过一个安静的错误。

页面表怎么维护

两条路径并存:

事件驱动(实时) 轮询快照(兜底)
browser.on('targetcreated') createPagesSnapshot()
│ │
▼ ▼
#createMcpPage(page) #fetchBrowserPages()
│ │
│ 剔除已消失的页面
│ │
└──────→ #mcpPages ←────────────┘

#createMcpPage(:521)幂等:同一个 Puppeteer Page 只会有一个 McpPage

#fetchBrowserPages(:575)额外做两件事:按 experimentalDevToolsDebugging 决定要不要显示 devtools:// 页面;把 chrome-extension:// 的 page 型 target 也捞进来(它们不在 browser.pages() 里)。

选中页失效时的回退

createPagesSnapshot(:538)末尾有一段带注释的判断:

// Only fall back when the selected page is actually gone. Gating on
// `isClosed()` instead of `pages` membership avoids silently swapping a
// live page that is momentarily missing from the snapshot.
if ((!this.#selectedPage || this.#selectedPage.pptrPage.isClosed()) && pages[0]) {}

判据是 isClosed() 而不是"是否在列表里"——页面可能只是暂时没被快照捞到,那不该换掉用户的选择。真的换了,会记在 #selectedPageFallback 里,响应中提示"之前选中的页面已关闭,现在选中页面 N"(src/McpResponse.ts:936-947)。

隔离上下文

new_pageisolatedContext 参数(src/tools/pages.ts:115)按名字复用/创建一个 Puppeteer BrowserContext——同名共享 cookie 与存储,不同名完全隔离。适合让 agent 同时以两个账号登录同一站点。

#getBrowserContextToNameMap(src/McpContext.ts:529)还会自动发现外部创建的隐身上下文,给它们编号 isolated-context-1isolated-context-2……

dispose() 里有一条明确的注释:隔离上下文故意不关(:158-161)——要么整个浏览器会关,要么是断连,不该顺手销毁浏览器状态。


2.4 路径沙箱:凭什么敢让模型写文件

它要解决的小问题

很多工具能写盘:take_screenshot --filePathperformance_stop_trace --filePathtake_heapsnapshotevaluate_script --filePath。如果路径不受限,一次提示注入就能让 agent 往 ~/.ssh/authorized_keys 写东西。

三层防线

第一层:允许的根目录从哪来
MCP 客户端在握手时提供的 roots(工作区目录)
+ 永远追加的系统临时目录 src/McpContext.ts:214-222

第二层:路径归一化
resolveCanonicalPath() 走 fs.realpath,把符号链接全解开
文件还不存在?向上找到最近的存在的祖先再拼回来 src/utils/files.ts:18

第三层:写入时不跟随符号链接
O_WRONLY|O_CREAT|O_TRUNC|O_NOFOLLOW,mode 0o600 src/McpContext.ts:644-657

校验逻辑

validatePath(src/McpContext.ts:234)把候选路径与每个 root 的 realpath 比:相等,或者以 root + path.sep 开头,才放行。

注意 + path.sep 这个细节(:275):没有它,/home/user-evil 会被 /home/user 前缀匹配通过。

各个 root 用 Promise.allSettled 并行解析,解析失败的 root 只是跳过并 warn,不影响其他 root(:259-289)。

roots 从哪来、什么时候刷

src/index.ts:82-97updateRoots:

时机是否带超时为什么
握手完成 oninitialized后台刷新,没人在等
收到 roots/list_changed 通知同上
工具调用时还没有任何 roots是,5 秒此时握着工具互斥锁,不能无限等

lastRoots 存在 server 层而不是 context 层,注释解释得很清楚(src/index.ts:76-77):roots 是客户端状态,浏览器重连不该让它失效。

客户端不支持 roots 怎么办

没协商 roots 且没加 --allow-unrestricted-paths 时,会在 stderr 打一句警告(src/index.ts:112-119),然后回落到只允许写系统临时目录——因为 roots() 总是包含 tmpdir。

加了 --allow-unrestricted-paths 才恢复旧的不设限行为(src/McpContext.ts:259-261)。这是一次"默认安全"的收紧,注释里写了它替换的是"previous permissive behavior"。

顺带:URL 也有黑白名单

loadResource(src/McpContext.ts:886)是给 DevTools 前端用的资源加载回调(见第 6 章),它按协议分流:

协议处理
http: / https:先过 blocklist,再过 allowlist,才 fetch
file:validatePath 同一套沙箱
其他直接拒绝

匹配用的是 URLPattern(:831:840),模式串来自 --blockedUrlPattern / --allowedUrlPattern


2.5 McpPage:页面级状态

它装了什么

McpPage(src/McpPage.ts:113)的类注释直说:这是把"原先散落在 McpContext 各个 Map 里"的 per-page 状态收拢成一个对象。

状态字段/方法
最近一次快照textSnapshotuniqueBackendNodeIdToMcpId
对话框#dialog(构造时订阅 dialog 事件)、throwIfDialogOpen()
模拟设置emulationSettingsemulate()restoreEmulation()
数据收集networkCollectorconsoleCollector
DevTools 二级会话#devtoolsUniverse
页面自带工具thirdPartyDeveloperTools

init 里的两件事

// src/McpPage.ts:179-184
await Promise.allSettled([
this.#initDevToolsUniverseNoThrow(),
this.#initFocusEmulationNoThrow(),
]);

两个都是 NoThrow 后缀 + allSettled:页面初始化不能因为附加能力失败而失败

其中 emulateFocusedPage(true) 的注释点明动机:"支持多 agent 工作流"——所有页面都被当作有焦点,这样后台标签页里的动画、定时器、:focus 样式行为跟前台一致,多个 agent 同时操作不同标签页才不会互相干扰。

模拟与超时的联动

emulate()(:712)最后一定调 updateTimeouts()(:827),而后者会按节流倍率放大超时:

默认超时 5s × cpu 倍率
导航超时 10s × 网络倍率 × cpu 倍率

网络倍率(src/utils/WaitForHelper.ts:287):
Fast 4G → 1 Slow 4G → 2.5 Fast 3G → 5 Slow 3G → 10

开了 Slow 3G + 4 倍 CPU 节流,导航超时就是 10s × 10 × 4 = 400 秒。不这么放大,一开节流所有操作就会假性超时。

另外 emulate 里还有一处冲突检测(:725-730):配了网络黑白名单时禁止网络节流,因为两者在 Puppeteer 里都用同一套规则,会互相覆盖。

清理

dispose()(:433)摘 dialog 监听、销毁两个收集器、销毁 DevTools universe 并 detach 二级 CDP 会话——detach 是 void + catch,不阻塞也不抛。


2.6 代码地图

主题文件路径符号名
启动/接管/收尾src/browser.tsensureBrowserLaunchedensureBrowserConnectedlaunchcloseBrowsermakeTargetFilter
浏览器级状态src/McpContext.tsMcpContextfromcreatePagesSnapshotnewPagegetSelectedPageFallback
页面 idsrc/McpContext.tsnextPageIdgetPageByIdresetPageIdsForTesting
路径沙箱src/McpContext.tssrc/utils/files.tsvalidatePathroots#writeFileresolveCanonicalPathgetTempFilePath
roots 协商src/index.tsupdateRootsROOTS_REQUEST_TIMEOUTsetRoots
URL 黑白名单src/McpContext.ts#validateUrlNotBlocked#validateUrlAllowedloadResource
页面级状态src/McpPage.tsMcpPageinitemulateupdateTimeoutsdispose
页面类工具src/tools/pages.tslistPagesselectPagenewPagenavigatePagehandleDialog