@evan_zhong/browser-attach 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,10 +15,11 @@ Drive the Chrome / Edge tabs you already have open and already signed in from an
15
15
  ## What it does
16
16
 
17
17
  - Discover multiple browser profiles, pick one precisely, claim a tab exclusively, and release it when done.
18
- - Read the accessibility tree, field values and supported editor models; click, replace-fill and type character by character through current snapshot refs or strict CSS/XPath locators.
18
+ - Read the accessibility tree, query it by literal name/value, inspect field values and supported editor models; click, replace-fill and type character by character through current snapshot refs or strict CSS/XPath locators.
19
19
  - Native pointer hover, stepped drags and conditional waits; select HTML options without opening a native popup.
20
20
  - Navigate and go back/forward, open or close tabs, handle JavaScript dialogs, upload local files, and capture the whole page or a single element.
21
21
  - Evaluate JavaScript expressions in the main page; read console, page-error and network lifecycle logs captured after claiming; opt in to bounded, redacted request/response details when XHR/fetch needs verification.
22
+ - Read one HTTP(S) URL in a temporary background tab with the profile's sign-in, returning rendered text, current DOM, or complete JSON with the main-document HTTP status when observed.
22
23
  - Standard stdio MCP with 23 tools. The Hub listens on loopback only and pairs with the extension through a random key.
23
24
 
24
25
  ## Requirements
@@ -88,7 +89,8 @@ Claiming and ordinary input never activate a tab or window; call `browser_tab_co
88
89
  | `browser_discover` | `kind=browsers/tabs`; discover browser profiles, tab identity and control status |
89
90
  | `browser_claim_tab` | Exclusively claim a discovered tab |
90
91
  | `browser_open_tab` / `browser_tab_control` | Create a tab; explicit `release/activate/close` of a claimed tab |
91
- | `browser_snapshot` / `browser_read_text` | AX snapshot, element refs and page text |
92
+ | `browser_fetch` | Read one HTTP(S) URL in a temporary background tab using the profile's sign-in; `text`/`html`/`json`, optional HTTP status and cursor continuation |
93
+ | `browser_snapshot` / `browser_read_text` | AX snapshot, literal name/value query with actionable refs, and page text |
92
94
  | `browser_wait` | Read-only wait for an element, text, URL or network idle |
93
95
  | `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | Native/DOM input, pointer hover and drag, option selection, character typing and scrolling |
94
96
  | `browser_batch` | Run 1–20 click/fill/type/press_key/hover/scroll/select_option/wait steps in one queue; stops on the first failure and returns partial results |
package/README.zh.md CHANGED
@@ -15,10 +15,11 @@
15
15
  ## 能做什么
16
16
 
17
17
  - 发现多个浏览器配置,精确选择并独占接管标签页,完成后释放。
18
- - 读取可访问性树、字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
18
+ - 读取可访问性树,按字面名称或 value 查询并取得可操作引用,检查字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
19
19
  - 原生指针悬停、分步拖拽、条件等待;无需打开系统弹层即可选择 HTML 下拉选项。
20
20
  - 导航与历史前进/后退、打开或关闭标签页,处理 JavaScript 对话框,上传本机文件并截取整页或单个元素。
21
21
  - 在主页面执行 JavaScript 表达式,读取接管后的控制台、页面异常和网络生命周期日志;需要校验 XHR/fetch 时显式开启有界、脱敏的请求/响应详情。
22
+ - 在临时后台标签中复用配置的登录态读取一个 HTTP(S) URL,返回渲染文本、当前 DOM 或完整 JSON,并在可观察时附带主文档 HTTP 状态。
22
23
  - 使用标准 stdio MCP,共提供 23 个工具。Hub 仅监听本机 loopback,通过随机密钥配对扩展。
23
24
 
24
25
  ## 环境要求
@@ -88,7 +89,8 @@ Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局
88
89
  | `browser_discover` | `kind=browsers/tabs`,发现浏览器配置、标签身份及控制状态 |
89
90
  | `browser_claim_tab` | 独占接管已发现的标签 |
90
91
  | `browser_open_tab` / `browser_tab_control` | 创建标签;显式 `release/activate/close` 释放控制、激活或关闭已接管的标签 |
91
- | `browser_snapshot` / `browser_read_text` | AX 快照、元素引用和正文 |
92
+ | `browser_fetch` | 在临时后台标签中复用登录态读取一个 HTTP(S) URL;支持 `text`/`html`/`json`、可选 HTTP 状态和游标续读 |
93
+ | `browser_snapshot` / `browser_read_text` | AX 快照、按字面名称/value 查询及可操作引用、正文 |
92
94
  | `browser_wait` | 只读等待元素、文本、URL 或网络空闲 |
93
95
  | `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | 原生/DOM 输入、指针悬停与拖拽、下拉选择、逐字输入与滚动 |
94
96
  | `browser_batch` | 单队列顺序执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,失败即停并返回部分结果 |
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.14.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.16.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -26,6 +26,7 @@ MCP 客户端
26
26
  | `extension/{click,input,actionability,scroll}.js` | 原生/DOM 输入、逐字按键、可操作性、容器滚动与有界查找 |
27
27
  | `extension/{drag,wait}.js` | 复用鼠标准备和严格定位的拖拽、只读条件等待;网络空闲复用被动请求表 |
28
28
  | `extension/batch.js` | 有限输入序列的参数预检;执行复用 controller 的单步流程与标签队列 |
29
+ | `extension/motion.js` | 无状态的可选落点/延迟/拖拽参数校验、区间采样及拖拽插值;不维护动作历史或改变焦点 |
29
30
  | `extension/outcomes.js` | 临时 DOM 变动观察器与有界只读探测,不推断业务成功 |
30
31
  | `extension/fetch.js` | 单页读取的参数、隔离世界采集与每 owner 有界缓存分页;临时页生命周期复用 controller 的 attach、导航和标签队列 |
31
32
  | `extension/{snapshot,navigation,evaluate,diagnostics}.js` | 观察、导航、有界求值结果缓存与分页、日志缓冲 |
@@ -57,16 +58,17 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
57
58
 
58
59
  ## 单页读取
59
60
 
60
- `browser_fetch` 是一次 URL 读取:Hub 按显式 `browser_id` 把请求交给配对扩展;controller 创建后台 `about:blank` 临时标签,在其队列内复用 attach/session 初始化后只导航一次,按 load 或 load + network_idle 等待,在隔离世界读取主文档,再于 finally 关闭。它不接管已有页面,不创建浏览器或窗口。临时标签在扩展内记录 owner,并从标签发现列表排除;即使关闭或初始化失败,也不能被其他连接接管。关闭失败保留临时页记录并报告,release_owner/releaseAll 再次尝试关闭;正常已有标签的 detach 边界保持不变。
61
+ `browser_fetch` 是一次 URL 读取:Hub 按显式 `browser_id` 把请求交给配对扩展;controller 创建后台 `about:blank` 临时标签,在其队列内复用 attach/session 初始化后只导航一次,按显式 commit / domcontentloaded / load / network_idle 等待,在隔离世界读取主文档,再于 finally 关闭。总 deadline 预留读取与清理时间;等待未满足时可采集当前文档并标记降级,创建和关闭也有界。创建的迟到回执只能触发清理。它不接管已有页面,不创建浏览器或窗口。临时标签在扩展内记录 owner,并从标签发现列表排除;即使关闭或初始化失败,也不能被其他连接接管。关闭失败保留临时页记录并报告,release_owner/releaseAll 再次尝试关闭;正常已有标签的 detach 边界保持不变。
61
62
 
62
- 正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars 拆分。读取期间文档或控制权变化会丢弃结果,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
63
+ 正文按 owner 保留至多 4 份各最多 1000000 字符的不可变采集,cursor 定位采集而非 URL;LRU 淘汰最久未读取的未完成采集,读完、owner 或扩展断连清除。一次读完和失败的请求不淘汰已有缓存。分页不执行脚本、不导航,临时标签关闭不会删除缓存。JSON 在采集上限内整份返回,不按 max_chars 拆分。导航等待跟随同一标签后续主文档 loader,完成时保留实际满足条件的文档身份;采集期间文档变化最多只读重采 3 次,状态码始终匹配本次采集的 frame/loader。控制权变化立即失败,断连后的迟到结果不能复活缓存。格式、采集上限与分页语义见使用指南。
63
64
 
64
- HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档 frame/loader 精确匹配;独立于可淘汰的日志正文保留至多 500 项文档状态元数据,断连/detach 清理。不新增 webRequest 或 host 权限,不重复请求页面。未匹配时 status/ok 返回 null;ok 仅表示 HTTP 2xx。HTML 标记 dom=rendered,保留观察到的 DOM,不声称是服务器原文;空标题省略。
65
+ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档 frame/loader 精确匹配;额外保留 requestId、响应 URL、MIME、空体证据和加载错误码,只从匹配状态的 ExtraInfo 补充 Content-Length,防止重定向 hop、跨 session 请求和缓存 304 混淆。独立于可淘汰的日志正文保留至多 500 项文档状态元数据,断连/detach 清理。不新增 webRequest 或 host 权限,不重复请求页面。未匹配时 status/ok 返回 null;ok 仅表示 HTTP 2xx。ERR_HTTP_RESPONSE_CODE_FAILURE 若有已匹配的 HTTP 响应,返回状态及已确认的空体;不能证明正文为空时标记 unavailable,不采集浏览器错误页为目标正文。其他传输错误仍失败。HTML 标记 dom=rendered,保留观察到的 DOM,不声称是服务器原文;空标题省略。
65
66
 
66
67
  ## 观察模型
67
68
 
68
69
  - **AX 渲染**:从 `Accessibility.getFullAXTree` 读取,移除 `InlineTextBox`,合并纯文本列表项与段落名称,丢弃 `generic` / `none` 且无名称无状态的节点,只保留少量状态属性和浏览器提供的 value、invalid。每行 `[token:n] role "name"`,`token` 是本次观察的随机前缀;这些字段不证明应用已保存。
69
70
  - **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
71
+ - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)或 value 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
70
72
  - **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
71
73
  - **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
72
74
  - **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
@@ -90,9 +92,11 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
90
92
  - **焦点信号边界**:OS 窗口失焦、遮挡或 document.visibilityState=hidden 不一定阻止 CDP 输入;document.hasFocus() 在 iframe 导航后也可能为 false。这些值作为 page_state 诊断,不独立拒绝点击。实际送达仍由隔离世界的可信事件回执校验;没有回执时不自动重放。
91
93
  - **送达预算**:原生 click/hover/press_key 的派发及可信回执另有 1250 ms 上限,清理最多 250 ms;系统菜单捕获输入时快速释放队列,仍报告结果未知并提示 Esc,不自动重放。
92
94
  - **hover 与 drag**:hover 复用点击准备与隔离校验,仅派发一次 mouseMoved,清理不移走指针。drag 显式区分 pointer 与 HTML5;后者从真实 dragstart 截获 DragData,再用 CDP 派发 dragover/drop,校验可信 dragstart/drop/dragend。目的节点在按住后滚入视口;所有步骤共用标签队列和总预算,失败时只取消/释放本次手势。
95
+ - **可选输入参数**:显式 offset 在元素 content quad 上插值后沿用命中与 OOPIF 边界检查;settle_ms 在 click/drag 首次派发前等待并复查就绪。drag trajectory 只替换既有插值段,采用法向控制点、平滑进度及随机间隔权重;type 的 delay_ms 可逐间隔独立区间采样。所有等待共用总预算,超时后没有继续派发的链;省略参数时不使用随机数,沿用既有确定性路径。不新增 tab 级历史、冷却、自动激活或重放路径。
93
96
  - **条件等待**:wait 复用严格 selector、可操作性和命中检查,支持出现、可见、可操作、文本/URL 子串及网络空闲;只读轮询,错误附带条件和最后状态。
94
97
  - **原生下拉**:select_option 直接用 selectedIndex setter 和合成 input/change 设置一个 HTML 选项,核验保留结果并返回 events_trusted=false。native click 的 select 命中(含 UA shadow 内部节点和关联 label)在派发前拒绝。
95
98
  - **命中提示**:普通命中保留实际 hit node,沿父元素、Shadow host、slot 查找最近的 HTML/ARIA 可点击候选,返回 clickable_target;hint=false 表示未识别候选,不代表无法响应事件。CSSPseudoElement 先解析为事件所属的 originating element,提示标记 pseudo_element=true,并使用该元素安装事件校验。语义候选不会被用于替换点击目标。
99
+ - **遮挡失败诊断**:当前 frame 的遮挡直接复用已读取的 hit_target;祖先 frame 遮挡额外用至多 200 ms 读取命中描述,诊断节点消失时保留原遮挡错误与 backend node ID。错误包含命中点和受阻 frame。根据共享预算的派发状态区分“未派发”和“结果未知”,避免 drag 按住后准备目的节点时误报零输入。
96
100
  - **送达不等于业务完成**:`target_verified` / `delivery_verified` / `value_verified` 分别证明可信事件命中、keydown 送达、字段文本写入;点击、按键、fill 和 batch 返回 `application_result: "not_checked"`。fill 不证明 React 状态、选项提交、blur 校验或保存完成。
97
101
  - **有限多步输入**:batch 在现有标签队列内执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,共享至多 30 秒预算,每步可另设更短超时;不增加后台任务、循环或脚本引擎;batch 本身只接受每步重新解析的 selector。所有参数先验证,selector 每步重解析,尚未出现的目标只在准备阶段等待;歧义立即报错。首个失败、主文档变化或接管结束后停止,返回此前结果与失败索引;不回滚、不重放、不自动激活或切换输入模式。start_at 显式跳过已完成前缀,返回 failed_step、next_unattempted_step、retry_failed_step_safe;没有后台作业或自动选择续跑点。
98
102
  - **OOPIF 坐标**:点击从顶层派发。逐层用 `DOM.getBoxModel` 把坐标换算到祖先文档,并用 `DOM.getNodeForLocation` 确认该位置确实落在 frame owner 上,防止祖先页面的遮挡被跳过。
@@ -0,0 +1,27 @@
1
+ # 0.15.1 工具效率与设计取舍
2
+
3
+ 本次对照 `src/mcp.mjs`、`src/hub.mjs`、扩展实现与现有测试核查九项建议。结论是:返回内容和失败诊断存在可直接改进之处;等待、显式 frame、接管和输入结果边界主要是有意的可靠性策略。不能单凭一次调用的字符数或延迟判定策略有缺陷。题述 BA/PW 对照未在同一页面、版本和序列化口径下重跑,以下不把这些数值当作本次验收结果。
4
+
5
+ ## 逐项判断
6
+
7
+ | 建议 | 当前实现与性质 | 通用性、复杂度及本次选择 |
8
+ |---|---|---|
9
+ | 1. 默认精简、interactive、delta | full 同时服务阅读与交互,是覆盖面策略;每页重复规则和固定诊断元数据则是输出冗余。input/evaluate 使 cursor 失效,但仍连接且语义未变的 refs 可复用,并非每次输入都必须重新 snapshot。 | 精简通用且成本低,已移除重复说明,viewport/snapshot_id 改为 verbose 可选。保留 full 默认与结果验证字段。interactive 已有,按任务选择。delta 需处理新旧观察、删除/替换、祖先上下文和子 frame 失效,复杂度高,本次不引入。 |
10
+ | 2. AX 索引查询 | 当前只能把树传给客户端后查找,确有返回量缺口;read_text 不能替代可操作 ref。 | 已在现有 snapshot 加 query,复用渲染、ref、frame 和分页,工具仍为 23 个。查询新采集 AX,不引入可能过期的第二份索引缓存。仅支持字面子串;正则及独立 find 工具暂不增加。能省返回内容和一次往返,不能保证 AX 采集变快。 |
11
+ | 3. 日志摘要与明细 | 当前已有 after_seq、limit、max_chars、level、url_contains 与有界缓冲;默认不保存 headers/body,必须显式启用 capture。网络输出是 request/redirect/response/finished 等事件,并非一条完整请求对象。 | 请求摘要具有通用价值,但正确合并需要处理 session/request_id/hop、未完成请求、分页与淘汰;单次投影也不能自然变成完整请求的耗时列表。seq 明细可复用 logs 扩展,无须先增加工具。默认隐藏静态资源会漏掉 CSS/脚本故障,本次保留,先用现有过滤与预算。 |
12
+ | 4. 零命中立即失败、诊断原因 | resolveSelector 的零命中等待是支持异步渲染和 batch 前一步创建后一步目标的明确策略;多命中和语法错误已立即失败。deadline 已保留最后的 selector/可见性/尺寸等状态。遮挡分支却只给泛化错误,属于诊断不足。 | 已补实际 hit_target、点和受阻 frame,不增加准备重试或动作重放。普通点击明确未派发;drag 已按住后的目的节点准备失败仍报告结果未知。保留零命中等待,紧预算可用 timeout_ms(最小 100 ms),只读查询零命中不等待出现。 |
13
+ | 5. deep-css、自动跨 frame | 当前 CSS/XPath 明确只解析指定文档;ref 可操作 AX 暴露的 Shadow 节点;evaluate(result_type=element) 可显式选取 open shadow 元素,frame URL/title/path 已可逐次解析。属于范围和消歧策略。 | 显式 deep-css 对组件站点可能有用,但要定义复合选择器、遍历顺序、nth 和多 root 歧义,成本中等。跨 frame 再涉及 OOPIF、重复控件、未加载/分离文档和多层命中校验,成本更高。本次保留显式 frame 与现有 ref/element 路径。 |
14
+ | 6. batch 结果传递 | 当前 batch 是预检后的有限输入序列;没有 evaluate 或 drag 步骤,element 步骤只用 selector。因此单加占位符不能实现“evaluate 测量 → drag”。这是有限编排的边界,也确有限制。 | 动态数据流需增加结果生产步骤、类型/路径检查、替换后的再校验,以及部分完成的恢复规则,复杂度中高。原生输入仍不能由页面 JS 调用。常见“定位 → 输入”已可用 selector 或 evaluate 返回元素 ref;坐标测量保留独立检查点,本次不扩展 batch。 |
15
+ | 7. tabs 一站式 | MCP 已把浏览器/标签发现合并为 discover,把 release/activate/close 合并为 tab_control。发现后按本连接观察的 tab_id 独占 claim 是授权模型,激活则是另一个显式意图。 | 客户端可保存 profile 与连接内句柄减少重复 setup。再增加 tabs 聚合入口主要重复现有分支;若自动选标签或激活,反而改变边界。本次不改。工具入口数量与必须经过的授权步骤不能简单等同。 |
16
+ | 8. 渐进披露 schema | 描述已在 0.13.1 分层精简;本次修改前实际 tools/list 紧凑 JSON 为 36701 字符、36703 UTF-8 字节,格式化 JSON 为 75945 字节,正好对应题述约 75.9 KB,23 个工具。两种序列化是同一 schema,不意味着宿主实际省掉一半 token。是否每轮重新计入、是否缓存,取决于 MCP 宿主,仓库不能保证。 | 工具分组对更大工具集有价值,但会新增启动配置、发现差异和支持组合。当前不加 caps 开关;query 复用已有工具,新增少量 schema 描述以保持自描述。包内文档链接不能替代模型调用所需的关键约束。 |
17
+ | 9. 可回放执行记录 | 当前不持久化操作轨迹;batch 调用输入本身就是可保存的步骤 JSON,输出已有部分完成、failed_step/start_at 等恢复信息。缺少长期审计是能力缺口,不是当前执行可靠性缺陷。 | 审计记录与可重新执行的计划应区分:已送达的动作不能因“可回放”自动重发,ref 也不能跨观察/连接复用。默认回传完整 steps 又与节省输出冲突,持久化还会包含表单数据。需要明确的保存/导出需求后再做显式功能,本次不新增存储或回放引擎。 |
18
+
19
+ ## 实际改动
20
+
21
+ - `extension/snapshot.js`:query 在当前视图的完整 AX 名称(包括合并正文)或 value 上做大小写不敏感的字面子串匹配,只为匹配行生成 refs。复用原预算和游标;query 绑定观察,零命中返回空结果。
22
+ - `extension/controller.js`:查询仅输出有匹配行的 frame 标题;仍报告不可读或失效的 frame。每个新查询沿用新快照的采集与 ref 生命周期,续页不重复采集。
23
+ - 快照默认不再输出 viewport、snapshot_id 和重复说明;verbose 可恢复前两项。保留 profile/text/text_limit、frame 覆盖与分页字段。click/evaluate 的事件送达、分页或捕获损失等语义字段继续保留。
24
+ - `extension/click.js`:当前 frame 直接复用已有命中诊断;祖先 frame 最多额外读 200 ms,失败时保留原遮挡错误。读取预算已派发标记,防止复用准备逻辑的 drag 被误标为“无输入”。
25
+ - 版本按本次明确要求设为 **0.15.1**。没有新增工具、运行依赖、扩展权限、用户 UI、自动重放或独立状态管理层。
26
+
27
+ 使用示例和边界见 [使用指南](USAGE.md),验证结果见 [验证报告](TEST-REPORT.md)。
@@ -1,9 +1,33 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.14.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
3
+ 当前版本 **0.16.0**(2026-10-22)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:184 项通过。新增 offset 的变换/越界处理、延迟区间校验、默认路径不采样随机数、拖拽曲线与精确终点、batch 参数预检、真实 stdio MCP 参数转发,以及 settle/拖拽超时后停止和调试分离取消待点击的回归。
8
+ - Chromium 156 与 Edge 155 headless 输入集成:各 19 个场景通过(扩展 → 独立 Hub 客户端)。新增非中心可信点击、offset hover、settle、区间逐字输入、曲线 pointer 与 HTML5 拖拽;实际记录移动坐标、可信事件和按键时间,保留原有默认路径及跨屏拖拽验证。不据此声称能通过任意网站的行为验证。
9
+ - Chromium 156 完整 stdio MCP 集成:244 次调用通过,保留发现/接管、输入、导航、OOPIF、日志、分页及临时页清理覆盖。
10
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、48 个发布文件,版本 0.16.0;无新增依赖或扩展权限,不包含本机配对密钥。
11
+ - 本机部署:Hub 已从工作区重启,健康接口返回 0.16.0;扩展同步至 `F:\.nosis\browser-attach\extension`,29 个源码/资源文件哈希一致,manifest 版本为 0.16.0。Chrome 与 Edge 均已重新连接并上报 0.16.0;MCP 客户端需刷新连接以获取新 schema。
12
+
13
+ ## 0.15.1 验证
14
+
15
+ - `npm test`:176 项通过。新增 AX 字面查询(名称/合并正文/value、预览前匹配、interactive 视图、零命中)、查询分页与新旧 ref 生命周期、默认精简/verbose 元数据、当前/祖先 frame 的遮挡诊断、诊断节点失效保留主错误,以及 drag 已派发预算不能误报“未派发”的回归。
16
+ - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 244 次调用通过。新增真实 AX 查询返回的 ref 可用于原生 hover、查询分页和 verbose 续页、零命中及 ancestor frame 的实际遮挡文字;保留输入、导航、OOPIF、日志、分页和临时页关闭链路。
17
+ - 同一构造表单 AX(2 个字段、2 个按钮,固定 URL/ref 前缀)比较 JSON 字符数:0.15.0 默认 908,0.15.1 默认 443(减少 51.2%),query=save 为 332。这里只衡量固定 fixture 的序列化输出,不代表题述表单、token 数或 AX 采集耗时。
18
+ - 实际 MCP tools/list:仍为 23 个工具;紧凑 JSON 从 36703 增至 37116 UTF-8 字节(新增 query/verbose 参数及说明,约 +1.1%),格式化 JSON 从 75945 增至 76502 字节。未声称 schema 总量下降;不新增工具分组、依赖或扩展权限。
19
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、47 个发布文件,版本为 0.15.1;发布包不含本机配对密钥。
20
+ - 本机部署:已把扩展同步至 `F:\.nosis\browser-attach\extension`,关键模块与源码一致,Hub 健康接口返回 0.15.1。临时 Chromium 加载该实际副本连接共享 Hub,AX 查询 → 原生点击 → 查询确认页面变化通过。验证结束时用户原有浏览器尚未重新连接;仍需在 Chrome/Edge 重新加载扩展,并刷新 MCP 连接以获取新 schema。
21
+
22
+ ## 0.15.0 验证
23
+
24
+ - `npm test`:172 项通过。新增 commit / DOMContentLoaded、等待超时后的降级采集、主文档替换后跟随 loader、采集期间只读重试、等待完成后换文档的身份核对、HTTP 错误响应关联及空体判断,以及标签创建/关闭超时和迟到清理;保留断连、控制权变化与不重放导航的回归覆盖。
25
+ - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 238 次调用通过。新增阻塞 load 时的早期等待及超时降级、load 前 location.replace、SSE 阻塞 network_idle,以及空体 418/500/503 的状态码和正文来源;同时验证既有输入、导航、OOPIF、分页与临时页关闭链路。
26
+ - Chromium 临时配置访问真实 URL:`https://httpbin.org/status/500` 与 `/status/503` 分别在约 689 ms、384 ms 返回对应状态、`ok=false`、空正文及 `http_error_response` 标记。`https://chatgpt.com/` 在约 2253 ms 返回 403 验证页的文档结果,未复现客户端跳转,不将该结果视为登录应用验收。
27
+ - `npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.15.0;无新增扩展权限与依赖。
28
+
29
+ ## 0.14.0 验证
30
+
7
31
  - `npm test`:165 项通过。新增主文档 frame/loader 状态匹配、未知状态、日志淘汰后状态保留、HTTP 错误正文、渲染后 DOM 标记、空标题省略、JSON 整份返回、四份缓存交叉续读与 LRU 淘汰;读完/失败的请求不淘汰已有缓存,同 URL 独立采集,保留断连及迟到回执不续跑的回归覆盖。
8
32
  - Chromium 156 与 Edge 155 headless 完整 stdio MCP 集成:各 230 次调用通过。实际验证重定向后的 HTTP 200、404/500 保留正文并返回 ok=false、200 登录墙不误判成 HTTP 错误、子框架 404 不污染主文档状态、动态 DOM 插入与 dom=rendered、900000 字符 JSON 在 max_chars=400 下整份返回、超限 JSON 报错、四份缓存交叉续读与第 5 份 LRU 淘汰,以及临时页关闭和不激活。
9
33
  - `npm run test:package`:仓库外安装运行通过,23 个工具、46 个发布文件,版本为 0.14.0;无新增扩展权限与依赖。
@@ -54,6 +78,7 @@ node scripts/interaction-smoke.mjs --extended --headless --executable /path/to/b
54
78
  - `select_option` 不制造可信事件;`network_idle` 只覆盖 claim 后观察到的页面/iframe 请求。
55
79
  - Monaco / CodeMirror 5 分支通过接口级测试,未针对第三方生产编辑器或特定 React/Vue 版本做站点验收。
56
80
  - 计时数据仅代表本机 fixture,不构成对任意网站的耗时承诺。
81
+ - 未针对已登录 Gmail / Overleaf / ChatGPT 生产页面验收;这些页面可受登录状态、验证页、网络与应用初始化影响。降级保证以实际可读文档为准,不保证业务正文在期限内出现。
57
82
 
58
83
  ## 历史验证
59
84
 
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.14.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.16.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
4
4
 
5
5
  工具发现采用三层说明:初始化 `instructions` 提供跨工具规则;工具 description 说明用途、独有行为与结果语义;参数 schema 及其短说明提供类型、默认值和选择规则。本文保留完整原理、边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
6
6
 
@@ -35,7 +35,7 @@
35
35
 
36
36
  | 工具 | 主要参数 | 说明 |
37
37
  |---|---|---|
38
- | `browser_snapshot` | `profile`, `text`, `text_limit`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
38
+ | `browser_snapshot` | `profile`, `text`, `text_limit`, `query`, `verbose=false`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、按名称/value 查询、元素 ref、frame 覆盖、待处理文件选择器 |
39
39
  | `browser_wait` | `condition`, `selector`, `expected` / `previous`, `idle_ms=500`, `timeout_ms=5000` | 只读等待元素、文本、URL 或被动网络空闲 |
40
40
  | `browser_fetch` | `browser_id`, `url`, `wait_until=load`, `format=text`, `max_chars=20000`, `cursor` | 后台临时页读取一次,关闭后按缓存续读 |
41
41
  | `browser_read_text` | `mode`, `selector`, `offset=0`, `max_chars=20000` | 主 frame 的 DOM、字段值或支持的编辑器模型 |
@@ -73,24 +73,28 @@
73
73
 
74
74
  ```js
75
75
  browser_fetch({browser_id, url: "https://example.com/article", wait_until: "network_idle", format: "text", max_chars: 20000})
76
- // → {url, title?, status, ok, contentType, format, body, capture_truncated, truncated, next_cursor}
76
+ // → {url, title?, status, ok, body, ready_state, wait_satisfied, degraded, degraded_reason, document_changed, ...}
77
77
  browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
78
78
  ```
79
79
 
80
80
  `browser_fetch` 在选定配置中新建 `active=false` 的临时标签,复用该配置的登录态和导航链路,采集后关闭;不需要对临时页显式 discover/claim。它不操作已有标签,不创建浏览器或窗口,不跟随页面链接。后台标签会短暂出现在标签栏,不能保证浏览器完全不可见。网页脚本、子资源、重定向仍照常执行,打开 URL 也可能产生网站状态变化;“读一页”指工具范围,不代表 HTTP 请求无副作用。
81
81
 
82
- - `wait_until=load` 为默认;`network_idle` 在 load 后再等被动请求表中连续 500 ms 无活动请求,复用 `browser_wait` 的网络空闲条件,不保证应用内容全部完成。`timeout_ms` 为 100–30000,默认 15000;调试初始化、导航和读取共享预算,标签创建和关闭受 Hub 超时约束。
82
+ - `wait_until` 支持 `commit`、`domcontentloaded`、`load`(默认)、`network_idle`。最后一种在 load 后等待被动请求表连续 500 ms 无活动请求;长连接可能使网络无法空闲。`commit` 只表示文档提交,正文可能仍为空,不保证应用完成加载。
83
+ - `timeout_ms` 为 100–30000,默认 15000,涵盖临时标签创建、调试初始化、等待、采集和关闭。总预算内预留采集与清理时间,所以等待阶段可能早于总 deadline 结束。等待超时时,只要仍有可读的 HTTP(S) 文档且控制权未变化,就读取当前内容;不重发导航。浏览器不响应或尚无 HTTP(S) 文档时仍可报错;无法保证任意登录应用在期限内提供有效业务正文。
84
+ - 返回 `ready_state`(`loading` / `interactive` / `complete`)、`wait_until`、`wait_satisfied`、`degraded` 和 `degraded_reason`。`wait_satisfied` 表示本次等待条件已对采集的文档满足,不等于应用内容完整。未满足时 `degraded=true`,原因可能为 `wait_timeout`、`document_changed` 或 `navigation_interrupted`;正常为 `degraded=false`、原因 `null`。即使 `ready_state=complete`,network_idle 仍可能未满足。
85
+ - 客户端 `location.replace` 等换文档会在同一次导航的等待期间跟随当前主文档 loader;采集期间上下文失效或文档变化,最多只读重采 3 次,共用原预算。不会为了重试而重新 navigate。控制权、连接变化始终失败;文档持续变化或重采超时也明确报错,不混用旧状态码和新正文。`document_changed` 表示相对本次首次提交的 HTTP(S) 主文档,是否观察到后续主文档替换;初始 about:blank 和同文档 URL 路由变化不计入。不会预知采集结束之后的未来跳转。
83
86
  - 只读当前主文档。`text` 使用 `body.innerText`;`html` 返回当前 DOM `outerHTML`,同时标记 `dom: "rendered"`,包含页面脚本及浏览器扩展对 DOM 的修改,不能用作服务器原始响应或字节存档;不删除或猜测注入来源。`json` 从浏览器呈现的正文解析、校验后序列化。不另发 fetch/XHR,不返回原始响应体或响应头。`contentType` 来自 `document.contentType`,`url` 为最终采集地址;没有文档标题时省略 `title`。非 HTTP(S) 最终文档报错。
84
87
  - `status` 来自已有 CDP 网络观察,按采集主文档的 frame 与 loader 匹配最终响应,排除重定向中间响应、子框架和 XHR/fetch。`ok` 仅表示 HTTP 200–299;未观察到匹配状态时两者均为 `null`,不猜成 200。404/500 仍返回正文及 `ok:false`,不因此设置 MCP `isError`。返回 200 的登录墙、验证码页或软错误仍为 `ok:true`,它不代表内容有效、已授权或业务成功。缓存/service worker 响应使用浏览器所报告的文档响应状态,不保证等于服务器本次实际传输的状态行。
88
+ - `body_source=rendered_document` 表示正文来自当前文档。空体 HTTP 错误若使浏览器报告 `ERR_HTTP_RESPONSE_CODE_FAILURE`,会保留已观察到的状态码和原 URL;确认 `Content-Length: 0` 时返回 `body:""`、`body_source=http_response`。如果响应正文无法取得且不能证明为空,返回 `body_source=unavailable` 和 `body_unavailable=true`,不能把它当成空响应。两者均标记 `degraded_reason=http_error_response`、`ready_state=null`,并附带 `navigation_error`;浏览器生成的错误页不冒充服务器正文。DNS/TLS 等未取得 HTTP 响应的错误仍失败。JSON 模式维持有效 JSON 约定,无可解析 JSON 文档时明确报错。
85
89
  - `body` 始终为字符串。文本/HTML 按 `max_chars` 分页,范围 1–100000、默认 20000,按 JavaScript 字符串长度计算。JSON 忽略分页预算,在 1000000 字符采集上限内整份返回,可直接 `JSON.parse(body)`;`truncated=false`、`next_cursor=null`,不会返回半截 JSON。JSON 超出采集上限或解析失败时直接报错。
86
90
  - 文本/HTML 一次最多采集 1000000 字符。`next_cursor` 非空表示缓存尚有后续片段;`truncated` 表示尚未返回全部内容或采集有损。`capture_truncated=true` 表示超出采集上限,丢失的尾部不能通过 cursor 取回。
87
91
  - 每个连接、每个浏览器配置最多保留 4 份尚未读完的不可变采集(每份最多 1000000 字符),可交叉续读;第 5 份需要续读的采集会淘汰最久未读取的一份。一次读完或失败的请求不占缓存槽、不淘汰已有采集。相同 URL 的多次采集各自独立,不按 URL 合并,cursor 自身定位对应采集,无需 fetch_id。读完或断连清除;过期/淘汰的 cursor 明确报错,不重新访问 URL。
88
92
  - 续读只接受 `browser_id`、`cursor` 和可选 `max_chars`,只读缓存,不打开页面。缓存不因临时页关闭而失效,也不能跨连接读取。
89
- - 超时、解析错误或断连会清理临时页,不自动重新导航。关闭失败会明确报错并保留清理责任,在断连时再次尝试关闭;不会静默移交给其他连接。临时页不出现在 `discover(kind="tabs")` 的结果中。
93
+ - 采集完成、无法降级的错误或断连都会清理临时页,不自动重新导航。标签创建超时后的迟到结果只用于关闭,不能继续导航;关闭等待受原 deadline 限制,底层关闭回执迟到时仍会清理状态。关闭失败会明确报错并保留清理责任,在断连时再次尝试关闭;不会静默移交给其他连接。临时页不出现在 `discover(kind="tabs")` 的结果中。
90
94
 
91
95
  ## 观察:快照与引用
92
96
 
93
- `browser_snapshot` 返回 `snapshot` 文本(每行 `[token:n] role "name" 状态`)、`snapshot_id`、`node_count`、`total_node_count`、`truncated`、`truncation_reason`(`node_limit` / `text_budget`)、`next_cursor`、`viewport`、`omitted_frames`,以及当前 `title` / `url`。
97
+ `browser_snapshot` 返回 `snapshot` 文本(每行 `[token:n] role "name" 状态`)、`node_count`、`total_node_count`、`truncated`、`truncation_reason`(`node_limit` / `text_budget`)、`next_cursor`、`omitted_frames`,以及当前 `title` / `url`;`verbose: true` 时额外返回 `viewport` 和 `snapshot_id`。
94
98
 
95
99
  - 渲染时移除 `InlineTextBox`,合并纯文本列表项与段落名称,保留 `disabled`、`focused`、`expanded`、`checked`、`selected`、`required`、`invalid`、`level` 等状态,以及浏览器提供的 `value`;`[token:n]` 即后续动作使用的 `ref`。未提供 invalid/selected 等属性不代表有效或已提交;value 也可能只是下拉框的过滤文本。
96
100
  - `max_nodes` 为 1–3000(默认 600);`max_chars` 为 1–2000000(默认 24000),按行序列化长度累计,与节点数是两个独立预算。
@@ -98,9 +102,19 @@ browser_fetch({browser_id, cursor: next_cursor, max_chars: 20000})
98
102
  - `text` 默认 `true`;`false` 隐去独立文本和纯文本段落/列表项的合并正文,仍保留可访问名称及控件值。`text_limit` 为 0–10000,限制每个文本/合并正文名称并以 `…` 标明省略;`interactive` 默认 80,`full` 默认不截断。需要更多正文时重新请求 `full`,或显式提高 `text_limit`。控件名称、值及 ref 校验所用身份始终完整。
99
103
  - 返回 `profile` / `text` / `text_limit` 表明本次视图;`node_count` 和 `total_node_count` 统计筛选后的节点。`truncated` / `next_cursor` 只表示该视图尚有分页,不表示视图包含全部网页正文。筛选发生在完整 AX 树读取后,减少返回内容与分页次数,不减少 CDP 的 AX 树采集量。
100
104
  - `truncated: true` 时把 `next_cursor` 传回同一个工具继续读取同一份观察,refs 在续读期间保持有效;也可以调整预算。单个超长节点放不下时会明确报出所需预算,不静默丢尾部。
101
- - 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
105
+ - 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit` / `query`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
102
106
  - 覆盖主 frame、同进程 iframe 与递归 OOPIF;读取失败、读取期间变化或附着失败的 frame 列入 `omitted_frames`。
103
107
 
108
+ 默认输出省略 viewport、snapshot_id 和重复的使用说明;需要视口几何与观察标识时传 `verbose: true`,也可在同一 cursor 的续页上开启。frame 覆盖、分页与截断字段始终保留。默认仍是 full 视图,阅读与交互任务可自行选择。
109
+
110
+ 已知目标文字时可直接查询,无需先返回整棵树:
111
+
112
+ ```json
113
+ {"tab_handle":"...","query":"保存","max_nodes":20}
114
+ ```
115
+
116
+ `query`(1–2000 字符,不能全为空白)在当前视图的 AX 完整名称(含合并正文)或 value 中匹配不区分大小写的字面子串,保留原始空格,不解释正则;不匹配 role、ref 或未暴露给 AX 的 DOM 内容。匹配发生在文本预览截取之前,返回匹配行及所属 frame 标题,有 backend node 的节点带可操作 ref。`total_node_count` 统计匹配节点;零命中正常返回空 snapshot,不等待目标出现。多命中按原有 max_nodes/max_chars 和 cursor 续读。每个新查询仍读取完整 AX 树、替换旧观察并使旧 refs 失效;只减少返回量,不能据此推断采集时间缩短。`profile=interactive` 会先筛掉正文,阅读查询保留默认 full。
117
+
104
118
  失效规则:
105
119
 
106
120
  | 事件 | 影响 |
@@ -144,6 +158,8 @@ ref 每次用 frame/session + backendNodeId 重新解析,并以局部 AX 查
144
158
  | `button` | `left`(默认)/ `middle` / `right` | |
145
159
  | `click_count` | `1`(默认)/ `2` | 双击按顺序重复 down/up |
146
160
  | `modifiers` | `Alt` / `Control` / `Meta` / `Shift` | 默认空数组 |
161
+ | `offset` | `{x_ratio, y_ratio}`,两项均为 0–1 | 原生元素目标的 content quad 内落点;省略沿用可见区域中心 |
162
+ | `settle_ms` | 0–1000 或 `[min,max]` | 原生点击准备后的派发前等待;区间每次均匀抽样,省略为 0 |
147
163
  | `mode` | `native`(默认)/ `dom` | DOM 模式接受 ref 或 selector |
148
164
 
149
165
  原生模式按序派发 move → down → up,显式设置 `buttons` 与修饰键,并在隔离世界安装命中校验:成功返回 `target_verified: true`、`application_result: "not_checked"`。左键单击 / 双击分别校验 `click` / `dblclick`,中键校验 `auxclick`,右键校验 `contextmenu`。目标被遮挡、在 hover 后移动或派发中变化时会明确报错并阻止不匹配事件;已送达的前序事件可能已有副作用,不能撤销或自动重试。
@@ -176,6 +192,18 @@ hover 接受与原生 click 相同的目标,执行一次 mouseMoved,不派
176
192
 
177
193
  drag 的 source / destination 各自接受 ref、严格 selector(可带 frame_id/nth)或 x/y(可带 coordinate_space)。mode 必须按页面交互选择,默认 pointer;steps 默认 20,范围 2–100,每步间隔约 16 ms。
178
194
 
195
+ 可选参数不改变默认输入路径:
196
+
197
+ - click、hover 以及 drag 的 source/destination 元素目标可指定 `offset: {x_ratio: 0.3, y_ratio: 0.65}`。比例沿 content quad 两个方向插值,支持旋转/倾斜元素;指定点不在视口或被遮挡时失败,不改投中心。多行元素按浏览器返回的 quad 顺序选择第一个落点在视口内的片段。坐标目标和 DOM click 不接受 offset。
198
+ - 原生 click 与 drag 可传 `settle_ms: [80,250]`,也可传固定毫秒数;在目标准备和校验器安装后、首次鼠标派发前等待,之后重新检查标签就绪。不引入自动激活。hover 不增加停留等待。
199
+ - drag 可传 `trajectory: {curve_px: 12, duration_ms: 600}`,并沿用 `steps` 控制采样数。curve_px 为二次曲线控制点相对路径法线的有符号偏移(−100–100 CSS px),实际曲线最大偏移约为其一半;duration_ms 为插值段等待总和(100–5000 ms)。采用平滑变速和独立随机权重分配间隔,最后一点严格等于目标。源点到起拖阈值的移动仍沿用现有实现,HTML5 仍使用真实截获的拖拽数据。总耗时还包含准备、CDP 和收尾;曲线离开视口时停止并释放本次手势。
200
+
201
+ ```json
202
+ {"tab_handle":"...","source":{"selector":"#card","offset":{"x_ratio":0.3,"y_ratio":0.6}},"destination":{"selector":"#column"},"steps":24,"settle_ms":[80,250],"trajectory":{"curve_px":12,"duration_ms":600}}
203
+ ```
204
+
205
+ 这些参数复用原有总 timeout、命中校验和失败清理,不保证满足网站的行为验证。省略时不采样随机数,保持原有落点、拖拽直线和 16 ms 间隔。batch 的 click/hover 支持对应 offset,click 支持 settle_ms。
206
+
179
207
  两端均为 document 坐标且能同时容纳于一个顶层视口时,在按下前统一滚动位置,保留两端的 x/y 差值。已在视口内的 document 坐标不重复居中;需要滚动时只调整越界的轴。跨屏目标仍在按住后滚入视口,因此返回的 from/to 是各自派发时的视口坐标,不能用两者之差推断跨屏的文档距离。
180
208
 
181
209
  ```json
@@ -229,7 +257,7 @@ fill 是替换文本,原生路径用 insertText,不制造逐字 keydown。
229
257
  {"tab_handle":"...","selector":"input[role=combobox]","text":"上海","delay_ms":50}
230
258
  ```
231
259
 
232
- delay_ms 为 0–1000,是字符之间的最小额外间隔,准备与协议开销会增加实际耗时;整串 text 最多 10000 个 UTF-16 单位,仍受同一 30 秒以内的总预算约束。换行/Tab/控制字符用显式 press_key。检查原字段连接与焦点,观察到失焦或替换后停止;失败报告已核验字符数,当前字符仍可能已送达,不能重放整串。delivery_verified 证明 keydown,不证明站点接受文本或保存。
260
+ delay_ms 接受固定的 0–1000 毫秒数(默认 0),或有序区间 `[min,max]`(两端均为 0–1000 整数),如 `[40,180]`;区间在每两个字符之间独立均匀抽样,末字符后不额外等待。它是字符之间的最小额外间隔,准备与协议开销会增加实际耗时;整串 text 最多 10000 个 UTF-16 单位,仍受同一 30 秒以内的总预算约束。batch 的 type 步骤接受相同参数。换行/Tab/控制字符用显式 press_key。检查原字段连接与焦点,观察到失焦或替换后停止;失败报告已核验字符数,当前字符仍可能已送达,不能重放整串。delivery_verified 证明 keydown,不证明站点接受文本或保存。
233
261
 
234
262
  ### browser_batch
235
263
 
@@ -396,5 +424,6 @@ handle_dialog 返回原始 `dialog.type/message/default_prompt` 和 `accepted`
396
424
  - 标签拖动或浏览器原生菜单可能捕获原生输入,CDP 返回成功不等于页面收到事件;结束该交互后再依赖原生输入。不要用 `Input.setIgnoreInputEvents(false)` 强行解除用户的菜单 / 拖动状态。
397
425
  - 后台操作要求浏览器运行且页面未冻结;网站自己弹窗或权限 UI 仍可能改变焦点。
398
426
  - `target_verified`、`delivery_verified`、`value_verified` 只证明事件送达或文本写入,不证明提交、付款、服务端上传等业务完成。
427
+ - 点击/hover 准备时发现遮挡会返回 `hit_target`、命中点及受阻 frame;祖先 frame 的节点描述若失效则保留 backend node ID。普通点击在此失败尚未派发输入;drag 若已经按住,再准备目的节点时遇到遮挡仍按结果未知处理。不可见/禁用/尺寸不稳定及 selector 尚未出现沿用准备等待与最后状态诊断。
399
428
  - Hub 日志位于配对文件同目录的 `hub.log`;`browser-attach status` 查看连接状态,`browser-attach stop` 停止 Hub。
400
429
  - 宿主把大结果保存为 Artifact 时,长行可能被宿主截断;这是宿主读取行为,不是快照游标分页。相关说明见 [Agent 集成](AGENT-INTEGRATION.md)。
@@ -3,6 +3,7 @@ import { clickOptions } from './click.js';
3
3
  import { inputMode, keyParameters, selectOptions, typeOptions } from './input.js';
4
4
  import { scrollOptions } from './scroll.js';
5
5
  import { waitOptions } from './wait.js';
6
+ import { offsetOption } from './motion.js';
6
7
 
7
8
  export function validateSteps(steps, startAt = 0) {
8
9
  if (!Array.isArray(steps) || steps.length < 1 || steps.length > 20) throw new Error('steps must contain 1..20 actions');
@@ -24,6 +25,7 @@ export function validateSteps(steps, startAt = 0) {
24
25
  targetOptions(step);
25
26
  if (['click', 'fill'].includes(step.action)) inputMode(step);
26
27
  if (step.action === 'click') clickOptions(step);
28
+ if (step.action === 'hover') offsetOption(step);
27
29
  if (step.action === 'fill' && typeof step.text !== 'string') throw new Error('Batch fill requires text');
28
30
  if (step.action === 'type') typeOptions(step);
29
31
  if (step.action === 'select_option') selectOptions(step);
@@ -1,4 +1,5 @@
1
1
  import { deadline, waitForTarget, delivery } from './actionability.js';
2
+ import { offsetOption, timingOption, pause } from './motion.js';
2
3
 
3
4
  // Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
4
5
  export function createClick(cdp, value, viewport, position, sessions, getTab, getWindow) {
@@ -45,10 +46,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
45
46
  }
46
47
  function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
47
48
  async function prepare(tabId, node, args) {
49
+ offsetOption(args);
48
50
  if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
49
51
  if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
50
52
  if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
51
53
  const budget = args.budget || deadline(args.timeout_ms);
54
+ const obstruction = detail => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
55
+ ? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
56
+ : 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched() });
52
57
  let target = node?.target || { tabId };
53
58
  const send = (method, params) => budget.run(() => cdp(target, method, params));
54
59
  let point, view;
@@ -59,7 +64,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
59
64
  const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
60
65
  view = await budget.run(() => viewport(target));
61
66
  for (const quad of quads || []) {
62
- point = clippedPoint(quad, view);
67
+ point = clippedPoint(quad, view, args.offset);
63
68
  if (point) break;
64
69
  }
65
70
  if (!point) throw new Error('Element has no visible click point. Inspect a screenshot.');
@@ -120,7 +125,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
120
125
  objectId: node.objectId, functionDeclaration: 'function(hit) { for (let e = hit; e; e = e.parentNode || e.host) if (e === this) return true; return false; }',
121
126
  arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
122
127
  }));
123
- if (!unblocked) throw new Error('Another element covers this ref. Inspect a new snapshot or screenshot.');
128
+ if (!unblocked) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`);
124
129
  }
125
130
  // Dispatch through the root widget so overlays in ancestor documents cannot
126
131
  // be bypassed by sending directly to an OOPIF widget. Guard every boundary.
@@ -136,7 +141,16 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
136
141
  const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
137
142
  x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
138
143
  }));
139
- if (parentHit.backendNodeId !== owner.backendNodeId) throw new Error('Another element covers this frame. Inspect a new snapshot or screenshot.');
144
+ if (parentHit.backendNodeId !== owner.backendNodeId) {
145
+ let blocker;
146
+ try {
147
+ const diagnostic = budget.child(200);
148
+ const resolved = await diagnostic.run(() => cdp(parent.target, 'DOM.resolveNode', { backendNodeId: parentHit.backendNodeId, objectGroup: 'browser-attach' }));
149
+ blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
150
+ functionDeclaration: describeHit.toString(), returnByValue: true })));
151
+ } catch { /* Preserve the obstruction error even if its node disappears. */ }
152
+ throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`);
153
+ }
140
154
  const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
141
155
  boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
142
156
  current = parent.target; currentView = parentView;
@@ -151,7 +165,8 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
151
165
  if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
152
166
  const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
153
167
  const guards = [], contexts = new Set();
154
- const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
168
+ let failure;
169
+ const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject(error) { failure = error; reject(error); } }));
155
170
  receipt.catch(() => {});
156
171
  try {
157
172
  const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
@@ -173,7 +188,10 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
173
188
  }
174
189
  // Recheck the root page after target preparation: a user may have changed
175
190
  // tabs/focus while we resolved a frame or installed the event guards.
191
+ await pause(budget, args.settle_ms);
192
+ if (failure) throw failure;
176
193
  const pageState = await assertReady(tabId, budget);
194
+ if (failure) throw failure;
177
195
  budget.dispatch();
178
196
  const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
179
197
  const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
@@ -259,7 +277,13 @@ function installGuard(name, options, expiryMs, verifyDelivery) {
259
277
  return { stop };
260
278
  }
261
279
 
262
- export function clippedPoint(quad, view) {
280
+ export function clippedPoint(quad, view, offset) {
281
+ if (offset) {
282
+ const { x_ratio: x, y_ratio: y } = offset;
283
+ const point = { x: (1 - y) * ((1 - x) * quad[0] + x * quad[2]) + y * ((1 - x) * quad[6] + x * quad[4]),
284
+ y: (1 - y) * ((1 - x) * quad[1] + x * quad[3]) + y * ((1 - x) * quad[7] + x * quad[5]) };
285
+ return point.x >= 0 && point.y >= 0 && point.x < view.width && point.y < view.height ? point : null;
286
+ }
263
287
  let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
264
288
  for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
265
289
  const input = polygon; polygon = [];
@@ -287,6 +311,11 @@ export function clippedPoint(quad, view) {
287
311
  }
288
312
 
289
313
  export function clickOptions(args) {
314
+ offsetOption(args);
315
+ if (args.settle_ms != null) {
316
+ timingOption(args.settle_ms, 'settle_ms');
317
+ if (args.mode === 'dom') throw new Error('settle_ms requires native pointer input');
318
+ }
290
319
  const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
291
320
  const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
292
321
  if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
@@ -205,6 +205,15 @@ export function createController(api, notify = () => {}) {
205
205
  const frames = frameStates.get(source.tabId);
206
206
  if (method === 'Page.frameAttached') frames?.parents.set(params.frameId, params.parentFrameId);
207
207
  const mainNavigation = !source.sessionId && method === 'Page.frameNavigated' && params.frame && !params.frame.parentId;
208
+ const temporary = temporaryTabs.get(source.tabId);
209
+ if (mainNavigation && temporary && params.frame.loaderId) {
210
+ const document = JSON.stringify([params.frame.id, params.frame.loaderId]);
211
+ if (!temporary.firstDocument && /^https?:/.test(params.frame.url || '') && !params.frame.unreachableUrl) temporary.firstDocument = document;
212
+ if (temporary.firstDocument) {
213
+ temporary.document = document;
214
+ temporary.document_changed ||= document !== temporary.firstDocument;
215
+ }
216
+ }
208
217
  if (mainNavigation && frames) {
209
218
  frames.mainId = params.frame.id;
210
219
  frames.parents.clear(); frames.versions.clear(); frames.sessions.clear();
@@ -332,8 +341,9 @@ export function createController(api, notify = () => {}) {
332
341
  const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
333
342
  knownFrames.add(childId); frames.push({ id: childId, url });
334
343
  }
335
- entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
336
- entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
344
+ const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
345
+ if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
346
+ entries.push(...rendered);
337
347
  }
338
348
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
339
349
  expression: '({title: document.title, url: location.href})', returnByValue: true,
@@ -464,7 +474,7 @@ export function createController(api, notify = () => {}) {
464
474
  evaluations.forget(tabId);
465
475
  snapshots.delete(tabId);
466
476
  fileChoosers.delete(tabId);
467
- const result = await navigation.navigate(tabId, url, args);
477
+ const { document: navigationDocument, ...result } = await navigation.navigate(tabId, url, args);
468
478
  const budget = deadline(NAVIGATION_OBSERVATION_TIMEOUT_MS, 'observation');
469
479
  for (let attempt = 0; attempt < 2; attempt++) try {
470
480
  if (args.include_snapshot === false) {
@@ -715,35 +725,92 @@ export function createController(api, notify = () => {}) {
715
725
  }
716
726
 
717
727
  async function fetchPage(owner, args, cap, checkConnection) {
718
- const url = httpUrl(args.url);
719
- const budget = deadline(args.timeout_ms ?? 15000, 'fetch');
720
- const tab = await api.tabs.create({ url: 'about:blank', active: false });
721
- const handle = crypto.randomUUID();
722
- temporaryTabs.set(tab.id, { owner, handle });
728
+ const url = httpUrl(args.url), timeout = args.timeout_ms ?? 15000, waitUntil = args.wait_until ?? 'load';
729
+ const end = Date.now() + timeout, cleanupReserve = Math.min(250, Math.floor(timeout / 10));
730
+ const budget = deadline(timeout, 'fetch', end - cleanupReserve);
731
+ const handle = crypto.randomUUID(), temporary = { owner, handle };
732
+ const creation = api.tabs.create({ url: 'about:blank', active: false }).then(tab => {
733
+ temporaryTabs.set(tab.id, temporary);
734
+ return tab;
735
+ });
736
+ let tab;
737
+ try { tab = await budget.run(() => creation); }
738
+ catch (error) {
739
+ // A late tabs.create response must not start navigation or leak its tab.
740
+ creation.then(created => queued(created.id, () => closeTemporary(created.id))).catch(() => {});
741
+ throw error;
742
+ }
723
743
  return await queued(tab.id, async () => {
724
744
  let failure;
725
745
  try {
726
746
  checkConnection();
727
747
  await attach(tab.id, owner, { tab_handle: handle }, tab, budget);
728
748
  const claim = claims.get(tab.id);
729
- checkConnection();
749
+ const checkControl = () => {
750
+ checkConnection();
751
+ if (claims.get(tab.id) !== claim) throw new Error('Tab control ended during fetch; capture discarded');
752
+ };
753
+ const metadata = (satisfied, reason, readyState) => ({ wait_until: waitUntil, wait_satisfied: satisfied,
754
+ degraded: !satisfied, degraded_reason: reason, ready_state: readyState, document_changed: !!temporary.document_changed });
755
+ const httpFailure = response => {
756
+ if ((args.format ?? 'text') === 'json') throw new Error(`HTTP ${response.status}: navigation produced an error page without a readable JSON document`);
757
+ return { url: httpUrl(response.url), contentType: response.contentType, format: args.format ?? 'text', body: '',
758
+ body_source: response.empty_body ? 'http_response' : 'unavailable', ...(!response.empty_body ? { body_unavailable: true } : {}),
759
+ capture_truncated: false, status: response.status, ok: response.status >= 200 && response.status < 300,
760
+ ...metadata(false, 'http_error_response', null), navigation_error: response.error };
761
+ };
762
+ checkControl();
730
763
  if (budget.remaining() < 100) throw new Error('Fetch preparation timed out; no navigation was dispatched');
731
- await navigation.navigate(tab.id, url, { wait_until: 'load', timeout_ms: Math.max(100, budget.remaining()) });
732
- checkConnection();
733
- if (args.wait_until === 'network_idle') await waitFor(cdp, value, sessions, diagnostics, mouse, tab.id, { condition: 'network_idle', budget });
734
- checkConnection();
735
- const version = documents.get(tab.id);
736
- const { frameTree } = await budget.run(() => cdp(tab.id, 'Page.getFrameTree'));
737
- const { executionContextId } = await budget.run(() => cdp(tab.id, 'Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'browser-attach-fetch' }));
738
- const result = value(await budget.run(() => cdp(tab.id, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true, userGesture: false,
739
- expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
740
- if (claims.get(tab.id) !== claim || documents.get(tab.id) !== version) throw new Error('Document or control changed during fetch; capture discarded');
741
- httpUrl(result.url);
742
- const status = diagnostics.documentStatus(tab.id, frameTree.frame);
743
- return { ...result, status, ok: status == null ? null : status >= 200 && status < 300 };
764
+ const readReserve = Math.min(2000, Math.floor(budget.remaining() / 3));
765
+ const waitEnd = Date.now() + budget.remaining() - readReserve;
766
+ const waitBudget = deadline(timeout, 'wait', waitEnd);
767
+ let satisfied = false, reason = null, waitedDocument;
768
+ try {
769
+ const navigationResult = await navigation.navigate(tab.id, url, { wait_until: waitUntil === 'network_idle' ? 'load' : waitUntil,
770
+ timeout_ms: Math.max(100, waitBudget.remaining()) });
771
+ waitedDocument = JSON.stringify([navigationResult.document.frameId, navigationResult.document.loaderId]);
772
+ checkControl();
773
+ if (waitUntil === 'network_idle') await waitFor(cdp, value, sessions, diagnostics, mouse, tab.id, { condition: 'network_idle', budget: waitBudget });
774
+ satisfied = true;
775
+ } catch (error) {
776
+ checkControl();
777
+ if (error.navigation_error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE') {
778
+ const response = diagnostics.failedDocument(tab.id, frameStates.get(tab.id)?.mainId);
779
+ if (response) return httpFailure(response);
780
+ throw error;
781
+ }
782
+ if (error.code === 'NAVIGATION_TIMEOUT' || (waitUntil === 'network_idle' && waitBudget.remaining() === 0)) reason = 'wait_timeout';
783
+ else if (error.navigation_error === 'net::ERR_ABORTED' && temporary.document) reason = 'navigation_interrupted';
784
+ else throw error;
785
+ }
786
+ for (let attempt = 0; attempt < 3; attempt++) {
787
+ checkControl();
788
+ const version = documents.get(tab.id);
789
+ try {
790
+ const { frameTree } = await budget.run(() => cdp(tab.id, 'Page.getFrameTree'));
791
+ const response = frameTree.frame.unreachableUrl && diagnostics.failedDocument(tab.id, frameTree.frame.id);
792
+ if (response && response.url === frameTree.frame.unreachableUrl) return httpFailure(response);
793
+ httpUrl(frameTree.frame.url);
794
+ const { executionContextId } = await budget.run(() => cdp(tab.id, 'Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'browser-attach-fetch' }));
795
+ const result = value(await budget.run(() => cdp(tab.id, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true, userGesture: false,
796
+ expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
797
+ checkControl();
798
+ if (documents.get(tab.id) !== version) throw Object.assign(new Error('Document changed during fetch capture'), { code: 'FETCH_DOCUMENT_CHANGED' });
799
+ httpUrl(result.url);
800
+ const status = diagnostics.documentStatus(tab.id, frameTree.frame);
801
+ if (satisfied && waitedDocument !== JSON.stringify([frameTree.frame.id, frameTree.frame.loaderId])) { satisfied = false; reason = 'document_changed'; }
802
+ return { ...result, status, ok: status == null ? null : status >= 200 && status < 300,
803
+ ...metadata(satisfied, reason, result.ready_state) };
804
+ } catch (error) {
805
+ checkControl();
806
+ const changing = documents.get(tab.id) !== version || error.code === 'FETCH_DOCUMENT_CHANGED' || /context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated/i.test(error.message);
807
+ if (!changing || attempt === 2) throw error;
808
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, 25)));
809
+ }
810
+ }
744
811
  } catch (error) { failure = error; throw error; }
745
812
  finally {
746
- try { await closeTemporary(tab.id); }
813
+ try { await boundedRead(() => closeTemporary(tab.id), Math.max(1, end - Date.now())); }
747
814
  catch (error) { throw new Error(`${failure ? failure.message + '; ' : ''}Fetch temporary tab ${tab.id} could not be closed: ${error.message}. Cleanup will be retried on disconnect.`); }
748
815
  }
749
816
  });
@@ -52,7 +52,7 @@ function location(frame = {}) {
52
52
 
53
53
  export function createDiagnostics(cdp) {
54
54
  const tabs = new Map();
55
- function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), documentResponses: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
55
+ function start(tabId) { tabs.set(tabId, { started: Date.now(), console: buffer(), network: buffer(), requests: new Map(), documentResponses: new Map(), pendingDocumentExtraInfo: new Map(), networkActivity: Date.now(), requestsOverflow: false, contexts: new Map(), capture: { enabled: false, max_body_bytes: 65536 }, captureVersion: 0, detailed: new Set(), pending: new Set() }); }
56
56
  function event(source, method, params) {
57
57
  const state = tabs.get(source.tabId);
58
58
  if (!state) return;
@@ -108,14 +108,37 @@ export function createDiagnostics(cdp) {
108
108
  if (params.type === 'Document') {
109
109
  const documentKey = JSON.stringify([source.sessionId ?? null, params.frameId]);
110
110
  state.documentResponses.delete(documentKey);
111
- state.documentResponses.set(documentKey, { loaderId: params.loaderId, status: response.status });
111
+ const header = name => Object.entries(response.headers || {}).find(([key]) => key.toLowerCase() === name)?.[1];
112
+ const pendingKey = JSON.stringify([source.sessionId ?? null, id, response.status]);
113
+ const extra = state.pendingDocumentExtraInfo.get(pendingKey);
114
+ const length = extra?.length ?? header('content-length');
115
+ state.pendingDocumentExtraInfo.delete(pendingKey);
116
+ state.documentResponses.set(documentKey, { requestId: id, loaderId: params.loaderId, status: response.status, url: response.url,
117
+ contentType: response.mimeType || String(header('content-type') || '').split(';')[0] || null, empty_body: String(length ?? '').trim() === '0' });
112
118
  if (state.documentResponses.size > MAX_ENTRIES) state.documentResponses.delete(state.documentResponses.keys().next().value);
113
119
  }
114
120
  add('response', { ...(state.detailed.has(key) ? { headers: redactHeaders(response.headers) } : {}), url: clip(response.url), status: response.status, status_text: clip(response.statusText, 200),
115
121
  mime_type: clip(response.mimeType, 200), protocol: clip(response.protocol, 50),
116
122
  from_cache: !!(response.fromDiskCache || response.fromPrefetchCache), from_service_worker: !!response.fromServiceWorker });
123
+ } else if (method === 'Network.responseReceivedExtraInfo') {
124
+ // ExtraInfo can arrive on either side of responseReceived. Enrich only
125
+ // its matching status, never a redirect hop or a wire-level cache 304.
126
+ const length = Object.entries(params.headers || {}).find(([key]) => key.toLowerCase() === 'content-length')?.[1];
127
+ if (length != null) {
128
+ let matched = false;
129
+ for (const [documentKey, response] of state.documentResponses) if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && response.status === params.statusCode) {
130
+ response.empty_body = String(length).trim() === '0'; matched = true;
131
+ }
132
+ if (!matched && state.requests.get(key)?.resource_type === 'Document') {
133
+ state.pendingDocumentExtraInfo.set(JSON.stringify([source.sessionId ?? null, id, params.statusCode]), { length });
134
+ if (state.pendingDocumentExtraInfo.size > MAX_ENTRIES) state.pendingDocumentExtraInfo.delete(state.pendingDocumentExtraInfo.keys().next().value);
135
+ }
136
+ }
117
137
  } else if (method === 'Network.loadingFinished' || method === 'Network.loadingFailed') {
118
138
  const failed = method === 'Network.loadingFailed';
139
+ if (failed) for (const [documentKey, response] of state.documentResponses) {
140
+ if (JSON.parse(documentKey)[0] === (source.sessionId ?? null) && response.requestId === id && (!response.error || params.errorText === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE')) response.error = params.errorText;
141
+ }
119
142
  add(failed ? 'failed' : 'finished', failed ? { error: clip(params.errorText), canceled: !!params.canceled,
120
143
  blocked_reason: clip(params.blockedReason) } : { encoded_data_length: params.encodedDataLength });
121
144
  if (!failed && state.detailed.has(key) && state.capture.enabled && cdp) {
@@ -177,6 +200,9 @@ export function createDiagnostics(cdp) {
177
200
  return { start, event, read, readDetails, networkOptions, documentStatus(tabId, frame) {
178
201
  const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frame.id]));
179
202
  return frame.loaderId && response?.loaderId === frame.loaderId && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? response.status : null;
203
+ }, failedDocument(tabId, frameId) {
204
+ const response = tabs.get(tabId)?.documentResponses.get(JSON.stringify([null, frameId]));
205
+ return response?.error === 'net::ERR_HTTP_RESPONSE_CODE_FAILURE' && Number.isInteger(response.status) && response.status >= 100 && response.status <= 599 ? { ...response } : null;
180
206
  }, networkStatus(tabId) {
181
207
  const state = tabs.get(tabId);
182
208
  if (!state) throw new Error('Tab control ended while waiting for network idle');
@@ -185,7 +211,7 @@ export function createDiagnostics(cdp) {
185
211
  const state = tabs.get(tabId);
186
212
  if (!state) return;
187
213
  for (const key of state.detailed) if (JSON.parse(key)[0] === sessionId) state.detailed.delete(key);
188
- for (const map of [state.contexts, state.requests, state.documentResponses]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
214
+ for (const map of [state.contexts, state.requests, state.documentResponses, state.pendingDocumentExtraInfo]) for (const key of map.keys()) if (JSON.parse(key)[0] === sessionId) map.delete(key);
189
215
  }, forget: tabId => tabs.delete(tabId) };
190
216
  }
191
217
 
package/extension/drag.js CHANGED
@@ -1,14 +1,18 @@
1
1
  import { deadline, delivery } from './actionability.js';
2
2
  import { targetOptions } from './locator.js';
3
+ import { offsetOption, timingOption, pause, trajectoryOption, dragPath } from './motion.js';
3
4
 
4
5
  export function dragOptions(args) {
5
6
  if (!args.source || !args.destination) throw new Error('drag requires source and destination');
6
7
  for (const target of [args.source, args.destination]) {
7
8
  targetOptions(target, { coordinates: true });
9
+ offsetOption(target);
8
10
  if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
9
11
  }
10
12
  if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
11
13
  if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
14
+ timingOption(args.settle_ms ?? 0, 'settle_ms');
15
+ trajectoryOption(args.trajectory);
12
16
  }
13
17
 
14
18
  export function createDrag(cdp, value, mouse, viewport) {
@@ -87,6 +91,7 @@ export function createDrag(cdp, value, mouse, viewport) {
87
91
  try {
88
92
  await guard(source, 'source');
89
93
  if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
94
+ await pause(budget, args.settle_ms);
90
95
  await mouse.assertReady(tabId, budget);
91
96
  budget.dispatch();
92
97
  await move(point);
@@ -100,12 +105,19 @@ export function createDrag(cdp, value, mouse, viewport) {
100
105
  const destination = await mouse.prepare(tabId, await resolve(args.destination, budget), { ...args.destination, budget });
101
106
  await guard(destination, 'destination');
102
107
  const from = point;
108
+ const path = dragPath(from, destination.point, steps, args.trajectory);
109
+ if (args.trajectory) {
110
+ const view = await budget.run(() => viewport(tabId));
111
+ if (path.some(({ point }) => point.x < 0 || point.y < 0 || point.x >= view.width || point.y >= view.height)) {
112
+ throw new Error('Drag trajectory leaves the viewport. Outcome is unknown; observe before continuing.');
113
+ }
114
+ }
103
115
  if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
104
- for (let index = 1; index <= steps; index++) {
105
- point = { x: from.x + (destination.point.x - from.x) * index / steps, y: from.y + (destination.point.y - from.y) * index / steps };
116
+ for (const sample of path) {
117
+ point = sample.point;
106
118
  if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
107
119
  else await move(point);
108
- await budget.run(() => new Promise(resolve => setTimeout(resolve, 16)));
120
+ await budget.run(() => new Promise(resolve => setTimeout(resolve, sample.dt)));
109
121
  }
110
122
  if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
111
123
  await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
@@ -9,7 +9,7 @@ export function fetchOptions(args) {
9
9
  const url = new URL(args.url);
10
10
  if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('fetch requires an HTTP(S) URL without credentials');
11
11
  if (!['text', 'html', 'json'].includes(args.format ?? 'text')) throw new Error('format must be text, html or json');
12
- if (!['load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be load or network_idle');
12
+ if (!['commit', 'domcontentloaded', 'load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be commit, domcontentloaded, load or network_idle');
13
13
  if (!Number.isInteger(args.timeout_ms ?? 15000) || (args.timeout_ms ?? 15000) < 100 || (args.timeout_ms ?? 15000) > 30000) throw new Error('timeout_ms must be 100..30000');
14
14
  }
15
15
 
@@ -24,6 +24,7 @@ export function capturePage(format, cap) {
24
24
  if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
25
25
  }
26
26
  return { url: location.href, ...(document.title ? { title: document.title } : {}), contentType: document.contentType, format,
27
+ ready_state: document.readyState, body_source: 'rendered_document',
27
28
  ...(format === 'html' ? { dom: 'rendered' } : {}),
28
29
  body: body.slice(0, cap), capture_truncated: body.length > cap };
29
30
  }
@@ -1,5 +1,6 @@
1
1
  import { deadline, waitForTarget, delivery } from './actionability.js';
2
2
  import { clickOptions } from './click.js';
3
+ import { timingOption, pause } from './motion.js';
3
4
 
4
5
  const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
5
6
 
@@ -157,7 +158,7 @@ export function createInput(cdp, value, sessions) {
157
158
  try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
158
159
  catch (error) { error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
159
160
  typed++;
160
- if (args.delay_ms && typed < length) await budget.run(() => new Promise(resolve => setTimeout(resolve, args.delay_ms)));
161
+ if (typed < length) await pause(budget, args.delay_ms);
161
162
  }
162
163
  return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
163
164
  }
@@ -304,7 +305,7 @@ export function selectElement(choice) {
304
305
 
305
306
  export function typeOptions(args) {
306
307
  if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
307
- if (!Number.isInteger(args.delay_ms ?? 0) || (args.delay_ms ?? 0) < 0 || (args.delay_ms ?? 0) > 1000) throw new Error('delay_ms must be 0..1000');
308
+ timingOption(args.delay_ms ?? 0, 'delay_ms');
308
309
  }
309
310
 
310
311
  export function characterParameters(character) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.14.0",
4
+ "version": "0.16.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -0,0 +1,50 @@
1
+ export function timingOption(value, name, max = 1000) {
2
+ const values = Array.isArray(value) ? value : [value];
3
+ if ((Array.isArray(value) && value.length !== 2) || values.some(n => !Number.isInteger(n) || n < 0 || n > max) || values.length === 2 && values[0] > values[1]) {
4
+ throw new Error(`${name} must be 0..${max} or an ordered [min, max] integer pair`);
5
+ }
6
+ }
7
+
8
+ export function sampleTiming(value = 0) {
9
+ return Array.isArray(value) ? value[0] + Math.floor(Math.random() * (value[1] - value[0] + 1)) : value;
10
+ }
11
+
12
+ export async function pause(budget, value) {
13
+ const ms = sampleTiming(value);
14
+ if (ms) await budget.run(() => new Promise(resolve => setTimeout(resolve, ms)));
15
+ }
16
+
17
+ export function offsetOption(args) {
18
+ if (args.offset == null) return;
19
+ if ((!args.ref && !args.selector) || args.mode === 'dom') throw new Error('offset requires a native element target');
20
+ const offset = args.offset;
21
+ if (typeof offset !== 'object' || Array.isArray(offset) || Object.keys(offset).some(key => !['x_ratio', 'y_ratio'].includes(key)) ||
22
+ ![offset.x_ratio, offset.y_ratio].every(n => Number.isFinite(n) && n >= 0 && n <= 1)) throw new Error('offset requires x_ratio and y_ratio in 0..1');
23
+ }
24
+
25
+ export function trajectoryOption(trajectory) {
26
+ if (trajectory == null) return;
27
+ if (typeof trajectory !== 'object' || Array.isArray(trajectory) || Object.keys(trajectory).some(key => !['curve_px', 'duration_ms'].includes(key)) ||
28
+ !Number.isFinite(trajectory.curve_px) || Math.abs(trajectory.curve_px) > 100) throw new Error('trajectory requires curve_px in -100..100 and duration_ms in 100..5000');
29
+ if (!Number.isInteger(trajectory.duration_ms) || trajectory.duration_ms < 100 || trajectory.duration_ms > 5000) throw new Error('trajectory duration_ms must be 100..5000');
30
+ }
31
+
32
+ // Keep both endpoints exact; vary progress and per-step timing only when opted in.
33
+ export function dragPath(from, to, steps, trajectory) {
34
+ const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy);
35
+ if (!trajectory) return Array.from({ length: steps }, (_, index) => ({
36
+ point: { x: from.x + dx * (index + 1) / steps, y: from.y + dy * (index + 1) / steps }, dt: 16,
37
+ }));
38
+ const weights = Array.from({ length: steps }, () => 0.5 + Math.random());
39
+ const total = weights.reduce((sum, n) => sum + n, 0);
40
+ let elapsed = 0;
41
+ return Array.from({ length: steps }, (_, index) => {
42
+ const dt = weights[index] / total * trajectory.duration_ms;
43
+ elapsed += dt;
44
+ const t = index === steps - 1 ? 1 : elapsed / trajectory.duration_ms;
45
+ const progress = t * t * (3 - 2 * t);
46
+ const bend = length ? 2 * progress * (1 - progress) * trajectory.curve_px : 0;
47
+ return { point: index === steps - 1 ? { ...to } : { x: from.x + dx * progress - (length ? dy / length * bend : 0),
48
+ y: from.y + dy * progress + (length ? dx / length * bend : 0) }, dt };
49
+ });
50
+ }
@@ -26,7 +26,7 @@ export function createNavigation(cdp) {
26
26
  if (!['http:', 'https:'].includes(destination.protocol) || destination.username || destination.password) throw new Error('History entry is outside supported HTTP(S) scope; no navigation was dispatched');
27
27
  mainId = (await prepare('Page.getFrameTree')).frameTree.frame.id;
28
28
  }
29
- let result, resolveDone, rejectDone, timer;
29
+ let result, currentDocument, completedDocument, resolveDone, rejectDone, timer;
30
30
  const events = [];
31
31
  const done = new Promise((resolve, reject) => { resolveDone = resolve; rejectDone = reject; });
32
32
  function check() {
@@ -40,25 +40,34 @@ export function createNavigation(cdp) {
40
40
  }
41
41
  return;
42
42
  }
43
- // A successful reply without loaderId denotes a same-document navigation.
44
- if (wait_until === 'commit' || !result.loaderId || events.some(event =>
45
- event.frameId === result.frameId && event.loaderId === result.loaderId &&
46
- event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) resolveDone(result);
43
+ // A client-side redirect can replace the loader before the original reply.
44
+ const current = currentDocument || result;
45
+ if (wait_until === 'commit' || !current.loaderId || events.some(event =>
46
+ event.frameId === current.frameId && event.loaderId === current.loaderId &&
47
+ event.name === (wait_until === 'load' ? 'load' : 'DOMContentLoaded'))) {
48
+ completedDocument ??= current;
49
+ resolveDone(result);
50
+ }
47
51
  }
48
52
  pending.set(tabId, {
49
- event(method, params) { if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); } },
53
+ event(method, params) {
54
+ if (!direction && method === 'Page.frameNavigated' && !params.frame.parentId && params.frame.loaderId) currentDocument = { frameId: params.frame.id, loaderId: params.frame.loaderId };
55
+ if (['Page.lifecycleEvent', 'Page.frameNavigated', 'Page.navigatedWithinDocument'].includes(method)) { events.push({ method, ...params }); check(); }
56
+ },
50
57
  cancel(reason) { rejectDone(reason || new Error('Tab detached during navigation; outcome is unknown.')); },
51
58
  });
52
- timer = setTimeout(() => rejectDone(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`)), Math.max(0, end - Date.now()));
59
+ timer = setTimeout(() => rejectDone(Object.assign(new Error(`Navigation did not reach ${wait_until} within ${timeout_ms} ms. It may still be loading; observe the tab before another action.`), { code: 'NAVIGATION_TIMEOUT' })), Math.max(0, end - Date.now()));
53
60
  try {
54
61
  // Attach rejection handlers immediately, including when the deadline wins first.
55
62
  const command = cdp(tabId, direction ? 'Page.navigateToHistoryEntry' : 'Page.navigate', direction ? { entryId: entry.id } : { url }).then(reply => {
56
- if (reply.errorText) throw new Error(`Navigation failed: ${reply.errorText}`);
63
+ if (reply.errorText) throw Object.assign(new Error(`Navigation failed: ${reply.errorText}`), { code: 'NAVIGATION_FAILED', navigation_error: reply.errorText });
57
64
  if (reply.isDownload) throw new Error('Navigation started a download instead of a document.');
58
65
  result = direction ? {} : reply; check(); return done;
59
66
  });
60
67
  await Promise.race([command, done]);
61
- return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId };
68
+ const document = completedDocument || result;
69
+ return { navigated_to: entry?.url || url, ...(direction ? { direction } : {}), wait_until, same_document: !result.loaderId,
70
+ document: { frameId: document.frameId, loaderId: document.loaderId } };
62
71
  } finally { clearTimeout(timer); pending.delete(tabId); }
63
72
  }
64
73
  return { event, cancel, navigate };
@@ -1,15 +1,17 @@
1
1
  // Render semantic AX nodes once; page through the same observation without losing refs.
2
- export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
2
+ export function snapshotOptions({ profile = 'full', text = true, text_limit, query } = {}) {
3
3
  if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
4
4
  if (typeof text !== 'boolean') throw new Error('text must be a boolean');
5
5
  if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
6
- return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null) };
6
+ if (query != null && (typeof query !== 'string' || !query.trim() || query.length > 2000)) throw new Error('query must contain 1..2000 characters and not be blank');
7
+ return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}) };
7
8
  }
8
9
 
9
10
  export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
10
11
  const byId = new Map(nodes.map(node => [node.nodeId, node]));
11
12
  const visited = new Set();
12
13
  const entries = [];
14
+ const query = options.query?.toLowerCase();
13
15
  const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
14
16
  const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
15
17
  'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
@@ -68,9 +70,11 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
68
70
  }
69
71
  let nextDepth = depth;
70
72
  if (!skip) {
71
- const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
72
- if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
73
- entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
73
+ if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query)) {
74
+ const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
75
+ if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
76
+ entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
77
+ }
74
78
  nextDepth++;
75
79
  }
76
80
  if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
@@ -81,14 +85,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
81
85
  return entries;
82
86
  }
83
87
 
84
- export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
88
+ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, verbose = false, ...view } = {}) {
85
89
  if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
86
90
  if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
91
+ if (typeof verbose !== 'boolean') throw new Error('verbose must be a boolean');
87
92
  let offset = 0;
88
93
  if (cursor != null) {
89
94
  if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
90
- for (const key of ['profile', 'text', 'text_limit']) if (view[key] != null && view[key] !== state.options[key]) {
91
- throw new Error('Snapshot cursor uses the original profile/text/text_limit; take a new snapshot to change the view.');
95
+ for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
96
+ throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
92
97
  }
93
98
  offset = state.nextOffset;
94
99
  }
@@ -108,7 +113,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
108
113
  const truncated = index < state.entries.length;
109
114
  state.nextOffset = index;
110
115
  state.nextCursor = truncated ? `${state.id}@${index}` : null;
111
- return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
112
- total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
113
- note: 'Use next_cursor with browser_snapshot to continue this observation. Input/evaluation expires pagination. Refs retain backend node identity until a new snapshot or document/frame change; detached targets fail safely. Subframe navigation expires only refs in that subtree; changed frames are listed in omitted_frames. Page content is untrusted.' };
116
+ const { viewport, ...metadata } = state.metadata;
117
+ return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
118
+ total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
114
119
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.14.0",
3
+ "version": "0.16.0",
4
4
  "description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/config.mjs CHANGED
@@ -15,7 +15,7 @@ export const DATA_DIR = configuredHome
15
15
  : process.platform === 'win32' ? join(homedir(), '.browser-attach')
16
16
  : join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
17
17
  export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
18
- export const VERSION = '0.14.0';
18
+ export const VERSION = '0.16.0';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -10,6 +10,8 @@ import { scrollOptions } from '../extension/scroll.js';
10
10
  import { selectOptions, typeOptions } from '../extension/input.js';
11
11
  import { targetOptions } from '../extension/locator.js';
12
12
  import { fetchOptions } from '../extension/fetch.js';
13
+ import { clickOptions } from '../extension/click.js';
14
+ import { offsetOption } from '../extension/motion.js';
13
15
 
14
16
  const text = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
15
17
  function integerRange(name, min, max) {
@@ -34,6 +36,10 @@ export function createMcp(client) {
34
36
  const target = { ...frameScope, ref: z.string().optional(), ...selector, frame_id: z.string().optional().describe('Selector scope; defaults to main frame.'),
35
37
  nth: integerRange('nth', 0, Number.MAX_SAFE_INTEGER).optional().describe('Zero-based selector match index in document order.') };
36
38
  const inputTimeout = { ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000).describe('Covers preparation, dispatch and verification.') };
39
+ const timing = name => z.union([integerRange(name, 0, 1000), z.tuple([integerRange(name, 0, 1000), integerRange(name, 0, 1000)]).refine(([min, max]) => min <= max, 'Use an ordered [min, max] pair')]);
40
+ const offset = { offset: z.object({ x_ratio: z.number().min(0).max(1), y_ratio: z.number().min(0).max(1) }).strict().optional().describe('Native element only: ratios within its content quad; offscreen/covered points fail.') };
41
+ const settle = { settle_ms: timing('settle_ms').optional().describe('Pause after target preparation, before dispatch: fixed ms or uniform [min, max].') };
42
+ const typeDelay = timing('delay_ms').default(0).describe('Between characters: fixed ms or independently sampled uniform [min, max].');
37
43
  function tool(name, description, schema, method, transform) {
38
44
  server.registerTool(`browser_${name}`, { description, inputSchema: z.object(schema) }, async args => {
39
45
  try {
@@ -67,13 +73,13 @@ export function createMcp(client) {
67
73
  tool('open_tab', 'Open an HTTP(S) tab in the selected profile. Returns tab_id; discover tabs after loading before claiming it.', {
68
74
  browser_id: z.string(), url: z.string(), active: z.boolean().default(false),
69
75
  });
70
- tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. status/ok describe HTTP only (unknown=null), not content validity. body is a string; JSON returns whole. next_cursor reads immutable cache without revisiting the URL; up to 4 captures per connection/profile, evicted by LRU.', {
76
+ tool('fetch', 'Read a URL\'s main document in a temporary background tab using the profile\'s sign-in, then close it. status/ok describe HTTP only (unknown=null). ready_state, wait_satisfied, degraded and document_changed report capture readiness. body is a string; JSON returns whole. next_cursor reads immutable cache without revisiting the URL; up to 4 captures per connection/profile, evicted by LRU.', {
71
77
  browser_id: z.string().min(1), url: z.string().optional().describe('HTTP(S), no URL credentials; required unless cursor is supplied.'),
72
78
  cursor: z.string().optional().describe('Continuation accepts only browser_id, cursor and max_chars.'),
73
- wait_until: z.enum(['load', 'network_idle']).optional().describe('Default load; network_idle adds 500ms with no observed requests after load.'),
79
+ wait_until: z.enum(['commit', 'domcontentloaded', 'load', 'network_idle']).optional().describe('Default load; network_idle waits for load then 500ms without observed requests. Unmet waits return a degraded capture if readable.'),
74
80
  format: z.enum(['text', 'html', 'json']).optional().describe('Default text (rendered); html=current DOM with dom=rendered; json=complete validated JSON.'),
75
81
  max_chars: integerRange('max_chars', 1, 100000).default(20000).describe('Text/HTML page budget; JSON ignores this and returns whole, up to 1M chars. capture_truncated means text/HTML loss; oversized JSON errors.'),
76
- timeout_ms: integerRange('timeout_ms', 100, 30000).optional().describe('Default 15000; preparation/navigation/read budget.'),
82
+ timeout_ms: integerRange('timeout_ms', 100, 30000).optional().describe('Default 15000; total deadline including creation/cleanup, with time reserved for capture after an unmet wait.'),
77
83
  }, null, args => { fetchOptions(args); return args; });
78
84
  tool('claim_tab', 'Exclusively claim a tab_id from this connection\'s latest tab discovery. Optional expected_url/title must match exactly. Returns the current identity.', {
79
85
  browser_id: z.string(), tab_id: z.number().int(), expected_url: z.string().optional(), expected_title: z.string().optional(),
@@ -86,12 +92,14 @@ export function createMcp(client) {
86
92
  if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
87
93
  return { tab_handle: args.tab_handle };
88
94
  });
89
- tool('snapshot', 'Read an AX tree with refs and frame coverage. next_cursor continues the same observation with unchanged view options.', {
95
+ tool('snapshot', 'Read an AX tree with refs and frame coverage, or find nodes by query without returning the whole tree. A new observation replaces refs; next_cursor continues unchanged view options.', {
90
96
  ...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
91
97
  max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
92
98
  profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls, focusable/editable nodes and ancestors.'),
93
99
  text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
94
100
  text_limit: integerRange('text_limit', 0, 10000).optional().describe('Prose preview length, marked with …; control names/values stay complete. Default 80 for interactive, unlimited for full.'),
101
+ query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of AX names/values in this view, before text previews. Returns matching lines and refs; zero matches is not an error. Uses normal budgets/cursor.'),
102
+ verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
95
103
  });
96
104
  tool('read_text', 'Read rendered page/frame text, optionally scoped to a selector. mode=value reads a field; mode=editor reads a Monaco/CodeMirror 5 model. complete_source reports completeness; next_offset continues live text, which may change.', {
97
105
  ...handle, ...selector, ...frameScope, mode: z.enum(['visible', 'text_content', 'value', 'editor']).default('visible'),
@@ -120,17 +128,21 @@ export function createMcp(client) {
120
128
  return args;
121
129
  });
122
130
  tool('click', 'Click one ref, selector or x/y; element targets auto-scroll. Native mode verifies the hit target; native select hits require select_option.', {
131
+ ...offset, ...settle,
123
132
  ...handle, ...inputTimeout, ...target, button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]), x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional().describe('Coordinates only; document scrolls the top-level page first.'), mode: z.enum(['native', 'dom']).default('native').describe('DOM click requires an element target and a left single click without modifiers.'),
124
133
  }, null, args => {
125
134
  targetOptions(args, { coordinates: true });
135
+ clickOptions(args);
126
136
  if (args.mode === 'dom' && !args.ref && !args.selector) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
127
137
  return args;
128
138
  });
129
139
  tool('hover', 'Move the native pointer to a ref, selector or x/y without clicking; verify trusted mousemove and leave it at the target.', {
140
+ ...offset,
130
141
  ...handle, ...inputTimeout, ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional(),
131
- }, null, args => { targetOptions(args, { coordinates: true }); return args; });
132
- const dragTarget = z.object({ ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
142
+ }, null, args => { targetOptions(args, { coordinates: true }); offsetOption(args); return args; });
143
+ const dragTarget = z.object({ ...target, ...offset, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
133
144
  tool('drag', 'Perform one native pointer or HTML5 drag; destination auto-scrolls into view while held.', {
145
+ ...settle, trajectory: z.object({ curve_px: z.number().min(-100).max(100), duration_ms: integerRange('duration_ms', 100, 5000) }).strict().optional().describe('Opt in to curved, variable-speed steps and varied intervals. Signed curve control-point offset in CSS px; duration covers interpolation waits only.'),
134
146
  ...handle, ...inputTimeout, source: dragTarget, destination: dragTarget, mode: z.enum(['pointer', 'html5']).default('pointer').describe('pointer for mouse-driven dragging; html5 for HTML drag-and-drop.'), steps: integerRange('steps', 2, 100).default(20),
135
147
  }, null, args => { dragOptions(args); return args; });
136
148
  tool('wait', 'Wait without scrolling or input. actionable requires a stable, visible, enabled, unobscured target in the viewport; network_idle uses observed page requests. Timeout includes the last observed state.', {
@@ -140,7 +152,7 @@ export function createMcp(client) {
140
152
  tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
141
153
  ...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),
142
154
  }, null, args => { targetOptions(args); selectOptions(args); return args; });
143
- tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0).describe('Pause between characters.') }, null, args => { targetOptions(args); typeOptions(args); return args; });
155
+ tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: typeDelay }, null, args => { targetOptions(args); typeOptions(args); return args; });
144
156
  tool('fill', 'Replace input/textarea/contenteditable text. Native mode focuses/selects and inserts text without character keydown; DOM mode sets text without focus and emits input/change. Does not blur or commit dropdown selections.', { ...handle, ...inputTimeout, ...target, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }, null, args => { targetOptions(args); return args; });
145
157
  const batchTarget = { ...selector, selector: z.string().min(1).max(10000), ...frameScope, nth: target.nth };
146
158
  const scrollFields = { until: z.object({ text: z.string().min(1).max(2000), selector: z.string().min(1).optional() }).strict().optional(), max_steps: integerRange('max_steps', 1, 500).default(100), mode: z.enum(['dom', 'wheel']).default('dom'), delta_y: z.number().optional().describe('Positive scrolls down.'), delta_x: z.number().default(0), x: z.number().optional(), y: z.number().optional() };
@@ -149,10 +161,11 @@ export function createMcp(client) {
149
161
  ...handle, ...dialogPolicy, start_at: integerRange('start_at', 0, 19).default(0).describe('Explicitly skip a reviewed completed prefix.'), timeout_ms: integerRange('timeout_ms', 100, 30000).default(15000),
150
162
  steps: z.array(z.discriminatedUnion('action', [
151
163
  z.object({ action: z.literal('click'), ...stepOptions, ...batchTarget, mode: z.enum(['native', 'dom']).default('native'),
164
+ ...offset, ...settle,
152
165
  button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]) }).strict(),
153
166
  z.object({ action: z.literal('fill'), ...stepOptions, ...batchTarget, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }).strict(),
154
- z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0) }).strict(),
155
- z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget }).strict(),
167
+ z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: typeDelay }).strict(),
168
+ z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget, ...offset }).strict(),
156
169
  z.object({ action: z.literal('select_option'), ...stepOptions, ...batchTarget, value: z.string().optional(), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional() }).strict(),
157
170
  z.object({ action: z.literal('scroll'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, ...scrollFields }).strict(),
158
171
  z.object({ action: z.literal('wait'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().optional(), previous: z.string().optional(), idle_ms: integerRange('idle_ms', 100, 10000).default(500) }).strict(),