# CDP 实测事实 > 接入任何新的 CDP 命令之前查这份。**全部结论来自本机实测**,不是协议文档推断。 > 环境:Electron **44.0.0** / Chromium 152.0.7977.54 / Windows。 > V37–V40 在 Electron 43.2.0 上复跑过,逐项一致;U4 的对照在纯 Chrome 153.0.8010.36 上做。 > > 原始日志在 `.workbuddy/tmp/`(`probe.jsonl` / `st.jsonl` / `gl.jsonl` / `u6.jsonl` / `u8.jsonl` …)。 > 复现模板见 `cdp-behavior-probe` skill。 > > 运行器路径:`deepseek-harness/node_modules/.pnpm/electron@44.0.0_*/node_modules/electron/dist/electron.exe` > —— **根 `node_modules/electron/dist` 不存在**(pnpm 不 hoist),按老路径找会误判成「本机没有 44」。 ## 0. 一句话 **CDP 的状态作用域是逐 domain、甚至逐命令不同的,不能一概而论。** 判据不是「哪个 domain」,而是「**这条命令写的是不是 target 级行为状态**」。 - `Emulation` 全类跨 client 覆盖、后写赢;`Network` 域里 `emulateNetworkConditions` / `setExtraHTTPHeaders` 同样共享,而**同域的 `setBlockedURLs` 却是 session 私有** —— **连同一个 domain 内都不能类推**。 - `Page.addScriptToEvaluateOnNewDocument` 也是 target 级共享,且副作用要到**下一次导航**才显形。 --- ## 1. 事实表 | # | 事实 | 日志 | |---|---|---| | V1 | 外部 CDP client 能与 `webContents.debugger` 共存同一 target,交替发命令互不阻塞 | `probe.jsonl` | | V2 | 人工断点暂停 10s 期间,agent 的 `Runtime.evaluate` / `Accessibility.getFullAXTree` / `Page.captureScreenshot` 全部正常返回(1~65ms) | `probe.jsonl` | | V3 | **`attach` 期间调 `openDevTools` 静默失败**(`isDevToolsOpened()`=false,不抛错),但 `/json/list` 会多一个 target | `dt.jsonl` | | V4 | `detach()` 后 `openDevTools` 成功(`isDevToolsOpened()`=true) | `dt.jsonl` | | V5 | **DevTools 已打开时 `debugger.attach` 成功**,`Runtime.evaluate` 返回正常 | `dt.jsonl` | | V34 | `devtools-opened` 回调内**同步** attach 即可(回调内 / tick0 / 延迟 1s / 延迟 2s 四档全部 ok),无需延迟 | `dt2.jsonl` | | V16 | **detach 期间命令同步抛 `Error: No target available`**(**立即失败,不会挂起**),re-attach 后恢复 | `g4.jsonl` | | V23 | `detach` 事件 reason **恒为 `target closed`**(**主动 detach 也是**),不能用它区分「人为让位」与「页面真的没了」 | `g4.jsonl` | | V6 | `Emulation.setUserAgentOverride` 跨 client 可见、后写赢 | `st.jsonl` | | V7 | **写入异步可见**:设完立刻回读仍是旧值,约 **170ms** 后才正确 | `st.jsonl` | | V8 | 只有 enable 过某 domain 的 session 才收该域事件(B 未 enable 时收到 0 个) | `st.jsonl` | | V9 | `Emulation.setDeviceMetricsOverride` 跨 client 覆盖、后写赢(A=400 → B=600 → A 读到 600) | `gl.jsonl` | | V10 | `clearDeviceMetricsOverride` **只还原 dpr,视口尺寸永久残留**:900 → set 400 → clear 后读 400;再 set 800 → clear 后又读 400 | `cl.jsonl` | | V11 | `clearDeviceMetricsOverride` 是 per-session **弹自己那层**(B 从未 set 时 clear,A 的值不受影响) | `gl2.jsonl` | | V29 | V10 的残留是 **Electron 特有**:纯 Chrome 153 上 `clear` 完全还原(754→400→**754**) | `ch.jsonl` | | V12 | `Network.setBlockedURLs` **session 私有**,互不影响;**空数组是有效清除** | `gl2.jsonl` | | V30 | `Runtime.setAsyncCallStackDepth` **session 私有** | `sc2.jsonl` | | V25 | `Network.emulateNetworkConditions` **跨 client 共享、后写赢**(A 置离线 → B 置 `offline:false` → A 立刻恢复联网) | `sc.jsonl` | | V26 | `Network.setExtraHTTPHeaders` **跨 client 共享、后写赢**(A 设 `x-probe:A` → B 设 `B` → A 读到 `B`) | `sc.jsonl` | | V27 | `Network.setCacheDisabled` **作用域无法判定** —— 见 §5,不是「未测」 | `sc2.jsonl` `ca.jsonl` `c4.jsonl` | | V28 | 本机 `fetch()` 响应**不进 http 缓存**(无 CDP / 有 CDP / 显式 `cacheDisabled=false` 三种条件下同 URL 三次请求全部打到服务端);`` 子资源第二次**命中缓存**,但该命中走渲染进程 MemoryCache,`cacheDisabled` 管不到 | `c2.jsonl` `c3.jsonl` `c4.jsonl` | | V35 | **`Page.addScriptToEvaluateOnNewDocument` 是 target 级共享**:A 注册后**不导航**,由 B 触发导航 → 新文档里脚本照样被注入(B 和 A 都读到 `window.__U6 === "A"`)。但注册项**绑在 A 的 session 上**:A detach 后再导航则不再注入,旧 identifier 报 `Script not found` | `u6.jsonl` `u6b2.jsonl` | | V13 | `Runtime.enable` **全量重放** console 历史,**每次 enable 都重放一遍**(3 次 attach 收到 `M1M2` → `M1M2M3` → `M1M2M3M4`) | `rp2.jsonl` | | V14 | 重放**覆盖 detach 窗口**:detach 期间页面自己产生的消息,re-attach 后照样能收到 | `rp3.jsonl` | | V24 | console **重放上限 1000 条**(实时 1500/1500,重放只回 1000) | `lm.jsonl` | | V17 | `Runtime.consoleAPICalled` 带 `executionContextId` + 微秒精度 `timestamp`,可作去重键(实测三条各异:`…445.069` / `…445.167` / `…445.198`) | `g4.jsonl` | | V37 | **`Log.entryAdded` 也会重放**(含 detach 窗口),但字段口径与 `Runtime` 不同:`timestamp` 是 **number**(`1789289442861.96`)、**没有 `executionContextId`**、有 `source`(`javascript` / `network` / `other`) | `u8.jsonl` | | V39 | **`Log` 的重放上限也是 1000**,且**超限会显式发一条截断提示条目**:`{source:'other', text:'2010 log entries are not shown.', timestamp:0}`(3010−1000=2010,精确对上)。`Runtime` **没有**等价提示,它的截断是无声的 | `u9.jsonl` | | V38 | **`Network` 事件不做历史重放**:detach 窗口内**已完成**的请求,re-enable 后一条都不补 —— **永久丢失** | `u8.jsonl` | | V40 | **跨 detach 的「进行中」请求**:attach 期发起、re-attach 后才完成的 → 收尾链完整(四条齐);**detach 期间发起**的 → 能收到响应侧事件,但 **`requestWillBeSent` 不补发** | `u9b.jsonl` | | V18 | **`requestId` 跨 session 完全一致**(A、B 收到同一个 `37668.2`);A 用自己收到的 id 调 `getResponseBody` 成功 —— 省掉了原本以为要做的 per-session 映射表 | `g4.jsonl` | | V19 | **重复 `DOM.getDocument` 会导致 nodeId 重新分配**(同一元素 6 → 13);各 session 独立编号 → nodeId **既不稳定也不跨 session 通用** | `g4.jsonl` | | V36 | **`backendNodeId` 是稳定定位句柄**:连续三次 `getDocument` 后同一 `#box` 的 nodeId 为 6→15→24,而 backendNodeId **恒为 6**;`DOM.resolveNode({backendNodeId})` 在**从未调 `DOM.enable`** 的情况下成功拿到 objectId,配 `Runtime.callFunctionOn` 取到 rect。元素被 `replaceWith` 换掉后,旧 backendNodeId 的 `resolveNode` **仍然成功**,但 `this.isConnected` 变 `false`(**只查 resolveNode 会漏**) | `u7.jsonl` | | V15 | `Overlay.enable` 依赖 `DOM.enable`,否则报 `DOM should be enabled first` | `gl.jsonl` | | V20 | `Overlay.highlightNode` 需本 session 先 `Overlay.enable`,否则 `-32600 Overlay must be enabled before a tool can use it` | `g4.jsonl` | | V31 | **`Overlay.highlightNode` 多 client 各自独立、互不取消**(红 719900 + 蓝 239971 同时存在;A 的 `hideHighlight` 不影响 B)—— 推翻了「高亮是单例、会与人工 Elements 面板互相取消」的假设 | `hn.jsonl` `hn-*.png` | | V32 | **`Overlay.highlightRect` 的 `color` 会把整个视口染色**:rect 内深色填充,**rect 之外整个视口叠同色浅色遮罩**(红像素 ≈ 视口面积 82%);`highlightNode` + `highlightConfig` 只有目标元素被覆盖 | `ov.jsonl` `shot-*.png` | | V21 | `Page.bringToFront` 双方都能成功,无互斥 —— 它不是「锁」,是 target 级操作(最后调用者赢) | `g4.jsonl` | | V22 | `Runtime.evaluate` 返回值边界,见 §4 | `g4.jsonl` | | V33 | **`Page.captureScreenshot` 在 `show:false` 的窗口上永久挂起**(不报错、不返回);`show:true` 立刻正常 | 本轮探针 | --- ## 2. 逐工具接入表与硬规则 | 工具 | 新引入 domain | 写状态? | 作用域(实测) | 应对 | |---|---|---|---|---| | `webpage_console` | `Runtime` / `Log` | 只 enable | enable 后事件各 session 一份(V8),但**两域都会全量重放**(V13/V14/V37),**上限都是 1000 条**(V24/V39) | 每次 attach 后重新 `Runtime.enable` **和** `Log.enable`;去重**按域分桶**的高水位,见 §3 | | `webpage_network` | `Network`(只 enable + 读事件) | 只 enable | 事件各 session 一份;**无历史重放**(V38) | 用 `Network.enable` 读事件;**禁止**用 `Fetch.enable` 做拦截;半截记录必须显式处理,见 §3 | | 网络条件模拟(若做) | `Network.emulateNetworkConditions` | 写 | **跨 client 共享、后写赢**(V25) | **必须走 targetState 簿记**(见 `架构与实现.md` §11) | | `webpage_execute` | 任意 | 任意 | 取决于内部命令 | **高**。见 §4 | | 人工接管 / 回收 | 无(编排逻辑) | — | — | 语义 = agent 停操作 + 重新观察,**不是断开连接**(V5 实测 DevTools 开着也能 re-attach);接管**不推 ref 纪元** | | `webpage_find` | 无(本地文本检索) | 否 | — | **零风险** | | `webpage_locate` | `DOM` / `Overlay` | 视觉状态 | `Overlay.highlightNode` 多 client 各自独立、互不取消(V31) | 定位走 `backendNodeId` → `resolveNode` → `callFunctionOn`(V36);高亮用 `highlightNode`(**别用 `highlightRect`**,V32) | --- ### 硬规则①:永远不要用 `clear*` 做还原 V10 已证明 `Emulation.clearDeviceMetricsOverride` **连自己都还原不干净**(只还原 dpr,视口宽度永久残留)。 「设了 → 用完 clear 还原」这个写法是错的。 - 要还原就**显式 set 回真实尺寸**。 - `clear` 是 per-session **弹自己那层**(V11)—— 所以并发写入时不会互相清掉, 但也意味着**你不能靠 clear 收回别人的状态**。 - 适用范围要记住:V10 的残留是 **Electron 特有**(V29,纯 Chrome 153 上完全还原)。 约束照旧写(插件跑在 Electron 里),但注释里要标明边界,以便将来 Electron 修掉后能安全放宽 —— 那时只需重跑一次探针就能判定。 ### 硬规则②:按顺序,能用前一条就别用后一条 1. **校验写入结果时不能立即回读断言**:写入异步可见(V7),设完立刻回读会读到旧值,约 170ms 后才正确。 要么延迟、要么重试。 2. **不产生 > session 私有 > 记账**:能用「只 enable 读事件」或纯计算就别写状态; 需要设置行为时优先选 session 私有的命令(如 `Network.setBlockedURLs`,V12),它们是天然隔离; 必须写 target 级状态时才走簿记(见 `架构与实现.md` §11)。 3. **`Network` 的 enable 有个已知缺口**:跨 detach 的请求会产出「无请求头的半截记录」(V40), 采集器必须显式处理,见 §3。 --- ## 3. 两条采集器的实现约束 ### console 去重:按域分桶的高水位 **去重键必须按域设计**。`(executionContextId, source, timestamp)` 这个三元组对 `Log` 域**根本不成立** (`executionContextId` 缺席),两域的时间戳类型也不同(微秒小数 vs number),跨域比大小是错的。 | 域 | 桶键 | 依据 | |---|---|---| | `Runtime.consoleAPICalled` | `('rt', contextId, type)` | V17 | | `Log.entryAdded` | `('log', source)` | V37 —— 只剩 `source` 可分桶 | - 重放是**按序**的(V13),所以只需记住「该流已处理到的最新 timestamp」,比它旧的一律丢。 内存 O(上下文数) 而非 O(消息数),长时间运行不涨。 - **`timestamp <= 0` 的条目必须绕过高水位**:`Log` 的截断提示条目 `timestamp` 恒为 **0**(V39), 任何高水位都会把它当旧消息丢掉 —— 而它是「内容被截断」的**唯一信号**,`Runtime` 侧没有等价提示。 - 副作用要认:**同一桶内**时间戳相同(或早于高水位)的消息会被丢掉一条。两域实测精度都在亚毫秒级, 同桶撞车概率低 —— 但要在注释里写明,别让人以为是 bug。 **环形缓冲 ≥1000 不是去重的正确性前提,而是窗口一致性要求**:实时采集能留存的历史, 不应短于 re-attach 能补齐的量(两域都是 1000,V24/V39),否则补齐时反而要丢掉大部分重放消息、兜底白做。 (若改用 seen-set 查重,1000 就变成**硬约束**:容量不足则重放回来的旧消息查不到、被当新消息重复入账。 本仓选的是高水位方案,注释里要写清是哪一条理由,别留下「因为要去重所以要 1000」这种似是而非的话。) ### network:跨 detach 是「半截」不是「全丢」 | 情形 | `requestWillBeSent` | 响应侧事件 | |---|---|---| | 发起与完成**都在 attach 期间** | ✅ | ✅ | | attach 期发起,**detach 期间完成** | ✅ | ❌ **永久丢失**(V38) | | attach 期发起,**re-attach 之后才完成** | ✅ | ✅ **完整补上**(四条齐) | | **detach 期间发起**,re-attach 后完成 | ❌ **不补发** | ✅ 收到 —— **但缺请求头** | 最后一行是要害:`requestWillBeSent` 带着 method / URL / 请求头 / postData,是**建档的唯一来源**; 它缺席之后那几个响应事件就是无头孤儿。而「只在 `requestWillBeSent` 时建 entry、后续按 `requestId` 更新」 恰恰是最自然的写法 —— 那样这批事件会被**静默丢弃**。 → 收到找不到对应 entry 的 `responseReceived` 时**不要直接丢**:建一条降级 entry, 标 `partial: true` + `reason: 'request-headers-missing'`;URL 可从 `response.url` 取(实测有值), 但 method / 请求头 / postData **明确标为未知**。 与「detach 期间已完成」那一档区分开 —— 那档是真丢,属已知且接受的能力边界,统计里如实反映。 --- ## 4. `webpage_execute` 的允许 / 拒绝列表 **用允许列表(默认拒)。** 拒绝列表的理由(「新命令会不断加入」)恰恰是反的: 命令不断加入意味着黑名单永远追不上,任何未列出的新命令默认**放行**。 **允许**(只读 / session 私有 / 只导航): `Runtime.evaluate`、`Runtime.getProperties`、`DOM.getDocument`、`DOM.querySelector`、 `Page.navigate`、`Page.reload`、`Page.captureScreenshot`、`Accessibility.getFullAXTree`、 `Network.enable`、`Network.getResponseBody`、`Log.enable`。 **必须拒绝的**: | 命令 | 理由 | |---|---| | `Target.*` | `attachToTarget` 能拿**其它标签页**的 session,越出本会话边界 | | `Browser.*` | `Browser.close` 直接关掉整个浏览器 | | `Emulation.*` | 跨 client 污染(V6/V9) | | `Fetch.*` | 跨 client 污染,还能**拦截、改写请求** | | `Overlay.*` | **理由不是「跨 client」**(V31 实测高亮互不干扰),而是它是**给眼睛看的副作用** —— 会污染截图、干扰人工在 Elements 里的高亮,而 `highlightRect` 还会把整个视口染色(V32)。agent 的高亮走 `webpage_locate` 这条受控通道 | | `Input.*` | 绕过 `BROWSER_TOOL_CAPABILITIES` 的 `read`/`mutate` 分级,架空它 | | `Network.emulateNetworkConditions` / `setExtraHTTPHeaders` | V25/V26 实测**跨 client 覆盖、后写赢**。早先版本把整个 `Network` 域列进「允许」是漏的 | | `Network.setCacheDisabled` | 作用域无法判定(V27),按最坏假设拒 | | `Page.addScriptToEvaluateOnNewDocument` / `removeScriptToEvaluateOnNewDocument` | V35 实测跨 client 共享,**别人触发的导航也吃注入**。副作用要到「下一次导航」才显形,尤其危险 | | `Page.setBypassCSP`、`Debugger.*`、`Security.*` 等其它写状态 / 改行为的命令 | 先实测,**默认先拒** | **返回值边界(V22,全部实测)**: | 表达式 | 结果 | |---|---| | `document.body` + `returnByValue` | **静默返回 `{}`** —— 不报错,最危险 | | 循环引用对象 | 抛 `Object reference chain is too long` | | `window` | 抛 `Object reference chain is too long` | | `Symbol('s')` | 抛 `Object couldn't be returned by value` | | `new Array(100000).fill(1).length` | 正常返回 `100000` | → 强制 `returnByValue`;**不能只判断 `result.value === undefined`**(DOM 节点那种情况 `value` 是个 `{}`, 看着「有值」其实是垃圾),要**同时检查 `result.type` / `result.subtype`**。 自己实现超时 —— V2 证明页面被断点暂停时命令仍能正常返回,但挂起风险不能因此排除。 --- ## 5. U1c 为什么是「无法判定」而不是「未测」 `Network.setCacheDisabled` 试了四种观测路径,全部不成立: | 观测路径 | 结果 | |---|---| | 跨源 data URL 页面 `fetch()` 同 URL ×3 | `hit=1,2,3` —— 每次都到服务端 | | 同源 http 页面 `fetch()` + `Network.responseReceived.fromDiskCache` | `fromDiskCache=false` ×3 | | **完全不用 CDP** 的对照组 | `srv=1,2,3` —— **所以不是 CDP 的问题** | | `` 子资源(走 MemoryCache) | 命中缓存 ✓,但**显式设 `cacheDisabled=true` 也照样命中** → 该缓存不受此设置管辖,**没有分辨力** | → 本机 http 缓存对 `fetch()` 响应不生效,唯一可命中的 `` 走的是 `cacheDisabled` 管不到的 MemoryCache。 **没有观测面,就判不了作用域。** **处置:按最坏假设处理** —— 要么不使用 `setCacheDisabled`,要么与 `Emulation` 同等对待(纳入簿记)。 --- ## 6. 方法论 **同一 domain 内的不同命令,作用域也可以不同。** 所以**每接入一个写状态的 CDP 命令都要单独实测, 默认按「共享」处理**。复现模板见 `cdp-behavior-probe` skill。 三条曾推翻自己结论的教训: 1. **V35 为什么必须单独测**:副作用**在「下一次导航」才显形**,不在写入那一刻 —— 它躲得过所有「写完立刻回读」的检查,也躲得过黑名单(它不在 `Emulation.*` 里,字面看像 `Page` 域的普通导航辅助命令)。 测法:注册者 **A 不导航**,让 **B 触发导航**,再看新文档里有没有 A 的脚本。 顺带一条判据细节:**「共享」与「持久」是两件事** —— 它不跨 session 存活,但只要注册者活着, 任何 client 的导航都吃它的脚本,这才是「跨 client 污染」的判据。 2. **V36 纠正过一次错误判断**:曾写「`RefTarget` 没有 selector,所以定位必须先加字段」—— 那是把 selector 路线当成了唯一路线。实测 `backendNodeId` 本来就是稳定句柄,而且更安全: selector 是「**匹配**」语义(重构后可能匹配到另一个元素 → 静默点错); `backendNodeId` 是「**指向**」语义(节点没了只能失败 → 可检出)。 3. **写下「必须先改造 X」之前,先确认现有句柄是不是已经够用了。** --- ## 7. 回归用例的边界(这几条才是「不漏也不重」真正的边界) 新增采集 / 定位 / execute 相关改动时,回归必须覆盖: 1. **console**:① 人工接管窗口内页面产生 console,接管结束后采集器**既不漏也不重**; ② 3000 条 Log 后 detach → re-enable,验上限截断**不会变成重复**,且那条 `timestamp=0` 的提示没被高水位吃掉; ③ `Runtime` 与 `Log` 各按自己的桶去重、互不串扰。 2. **network**:① 都在 attach 期间 → 完整;② re-attach 之后才完成 → 完整补上(V40); ③ detach 期间发起 → 收到响应事件且被**显式标为 `partial`**(不得静默丢弃); ④ detach 期间**已完成**的 → 断言「确实没来」,不是等它出现。 3. **execute**:① `document.body` 必须被识别为不可序列化,而不是返回 `{}`; ② `Network.emulateNetworkConditions` / `setExtraHTTPHeaders` / `setCacheDisabled` / `Page.addScriptToEvaluateOnNewDocument` 逐条断言被拒;③ 被拒时错误消息带上 method 全文。 4. **locate**:① 窗口保持可见(V33);② agent 高亮与人工在 DevTools 里选元素同时进行,互不干扰; ③ **元素被 `replaceWith` 换掉后 locate 必须报 `BROWSER_STALE_REF`** —— 专验 `isConnected` 守卫, 这是 V36 里唯一「只查 resolveNode 会漏」的场景。 5. **DevTools**:连开 / 关 5 次,**ref 纪元一次都不该被推进**。这条现在必然通过 (`invalidate()` 只由地址变化触发),它的价值是**防回归** —— 接入「观察失效」时最容易顺手拿 `Inspector.detached` 推纪元。