@evan_zhong/browser-attach 0.22.1 → 0.23.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.
@@ -1,6 +1,6 @@
1
1
  # 架构与设计边界
2
2
 
3
- 当前实现为 **0.22.1**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.23.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -34,6 +34,7 @@ MCP 客户端
34
34
  | `extension/outcomes.js` | 动作前临时 DOM 观察器、未知结果后的有界只读探测与证据封装,不推断业务成功 |
35
35
  | `extension/fetch.js`、`fetch-page.js` | 单页读取参数与缓存分页、临时页创建/导航/采集/关闭;复用 controller 的 attach 和标签队列 |
36
36
  | `extension/observation.js`、`snapshot.js` | 文档/frame 观察版本、AX 采集、ref 身份与失效历史;AX 渲染和快照分页 |
37
+ | `extension/fields.js` | 每次观察按 debugger session 采集 DOMSnapshot,按 frame/backend 节点身份关联 AX,提取结构化字段事实和原生文件选择状态 |
37
38
  | `extension/network.js` | 一份被动请求与文档响应状态,供 fetch、网络空闲等待及诊断日志使用 |
38
39
  | `extension/diagnostics.js`、`log-data.js` | 日志缓冲与分页、可选正文捕获、截断及脱敏;不拥有网络空闲或文档状态 |
39
40
  | `extension/{navigation,evaluate}.js` | 导航等待、有界求值结果缓存与分页 |
@@ -82,6 +83,7 @@ HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档
82
83
  - **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
83
84
  - **文本查询与精简输出**:`snapshot(query=...)` 复用同一次 AX 渲染,按当前视图中节点的完整名称(含合并正文)、value 或字段 context 做不区分大小写的字面子串筛选,只输出匹配行和对应 frame 标题;不搜索 role/ref 或任意 DOM 属性,不新增索引缓存。新查询仍采集新观察并使旧 refs 失效,query 与分页绑定。默认省略 viewport、snapshot_id 和重复规则说明,`verbose=true` 返回前两项;frame 覆盖、视图、预算和游标语义保留。
84
85
  - **字段上下文**:表单控件可附带 context 并参与 query,保留至多 160 字符的前一兄弟分支 AX 文本;只在四层以内 generic/none/group 祖先、单控件容器中查找,每次容器/文本遍历分别限制 64/32 节点。不将其伪装为 accessible name,不用于 label 定位或 ref 身份验证;interactive/text=false 也保留提示。
86
+ - **结构化字段**:profile=fields 复用相同观察状态、ref 注册、上下文提取、双预算分页与失效模型,以 fields 数组输出 DOM 与 AX 事实。每个 debugger session 在本次观察内至多一次 DOMSnapshot.captureSnapshot;按文档 frameId 与 backendNodeId 关联,不用选择器或名称推断身份,不逐字段读取普通 DOM 属性。DOMSnapshot 同时保留闭合 Shadow DOM、原生实时值与选项状态;只对文件 input 在隔离世界读取 FileList 的数量/名称,并释放 object group。默认限于 AX 暴露的原生字段与 AX 字段角色;include_hidden 显式加入 AX 缺失的原生字段,其 ref 可空。密码值不进入字段结果及查询。名称来自 AX,labels 来自 HTML label 名称来源,context 保持提示语义;原生/AX 值不合并成业务提交值。会话内 DOM 捕获只存在于本次采集,续读使用既有 entries,不增加持久 DOM 缓存。AX/DOM/FileList 与跨 frame 读取不是原子事务。
85
87
  - **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
86
88
  - **ref 失效诊断**:controller 每标签保留最近至多 1000 个已清理 ref 的 cause/frame_id,不保留 DOM 对象、字段值或替代定位信息。新观察、文档/frame 变化及对话框清理记录各自原因;解析时单独报告节点脱离、session 或 AX 身份变化。释放、断连、关闭和 detach 清空历史,淘汰后返回 unknown_ref。
87
89
  - **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
@@ -1,9 +1,17 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.22.1**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
3
+ 当前版本 **0.23.0**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:253 项通过。新增结构化字段保留重复标签、真实 HTML label 来源与附近 context、DOM/AX 值区分、原生多选与 mixed 状态、隐藏字段显式读取、密码值省略、节点身份缺失不按名称合并、完整行双预算分页、session 级 DOM 捕获复用与文件选择只读采集。覆盖输入后 cursor 失效且 ref 保留、新观察替换旧 ref、子 frame 变化只移除子树、文档捕获中导航不恢复旧引用。
8
+ - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 281 次调用、31 项检查通过。真实 DOMSnapshot 与 AX 关联验证重复 HTML 标签、TEXTS28 字段名查询、格式化/只读值、多选原始值/标签、checkbox mixed、隐藏输入、密码省略、闭合 Shadow DOM、同源 iframe、OOPIF、文件选择数量/中文文件名、分页与字段 ref 操作;单纯观察不派发 input。保留输入、截图、上传、对话框、日志与 12000 节点 AX 分页回归。
9
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥;新增 fields 模块纳入包边界检查。没有新工具、依赖或扩展权限,观察能力扩展升至 0.23.0。
10
+ - 本机部署:协调升级因已有 MCP 连接拒绝后,按 stop/start 重启;Hub 为 0.23.0 / ready,Chrome、Edge 扩展均为 0.23.0。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 43 个文件已核对:manifest 版本一致,其余非生成源码/资源 SHA-256 一致。
11
+ - 当前边界:跨 frame、AX/DOM/FileList 分步采集不具备事务或并发写入归属;自定义组件内部模型、最终提交值和服务端附件状态仍需任务相关检查。隐藏原生字段可能无 AX/ref,未关联 DOM 身份的 AX 行省略值;缺失或失败的 frame 保留 omitted_frames。已有 MCP 客户端需重连刷新字段视图参数并重新发现/接管。
12
+
13
+ ## 0.22.1 验证
14
+
7
15
  - `npm test`:246 项通过。新增隔离世界选择器诊断经 CDP 异常对象读取、单步/read_text/wait/batch 传递及 frame 路径底层原因保留;诊断读取失败保留原异常并清理 wrapper。补充准备/派发超时分类、派发后不沿用准备诊断、fill 值不匹配保留未知结果且不回显字段值。后续新增的 click 目标变化原因断言另通过 15 项指针回归。
8
16
  - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 267 次工具调用、29 项检查通过。新增真实 CSS/XPath 语法错误、CSS/AX role 歧义、缺失 selector、read_text/wait/batch 错误原因经过扩展 → Hub → MCP 的检查;原生 fill 同步格式化返回 value_not_retained、input_dispatched=true 及 post_action,真实 input 事件仅 1 次。保留上传、对话框、OOPIF、日志、后台输入与大 AX 树回归。
9
17
  - Edge 首次与 Chromium 并行执行时,原有遮挡检查在 500 ms 准备预算内超时,未派发输入;未修改该检查预算,独立复跑完整脚本通过。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.22.1**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.23.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
4
4
 
5
5
  本文供按需查阅,不会自动通过 MCP 发送给 agent。MCP 初始化 `instructions` 提供通用规则,工具 description 与参数 schema 提供调用说明;宿主应把 instructions 纳入模型上下文。
6
6
 
@@ -34,7 +34,7 @@
34
34
  | `browser_open_tab` | 在指定配置中打开 HTTP(S) 标签 |
35
35
  | `browser_claim_tab` | 独占接管已发现的标签 |
36
36
  | `browser_tab_control` | 释放、激活或关闭标签 |
37
- | `browser_snapshot` | AX 语义树、元素 ref、查询与 frame 覆盖 |
37
+ | `browser_snapshot` | AX 语义树或结构化字段数组、元素 ref、查询与 frame 覆盖 |
38
38
  | `browser_read_text` | 正文、字段值或支持的编辑器模型 |
39
39
  | `browser_fetch` | 使用登录态在临时后台标签读取一个 URL |
40
40
  | `browser_click` | 原生点击或显式 DOM 点击 |
@@ -138,20 +138,21 @@ snapshot 返回 AX 语义树,每行 `[token:n] role "name" 状态`;`[token:n
138
138
 
139
139
  | 参数 | 用途与默认值 |
140
140
  |---|---|
141
- | `profile` | `full`;interactive 只保留控件、可聚焦/编辑节点及祖先链,自定义控件可能被省略 |
141
+ | `profile` | `full`;interactive 保留控件、可聚焦/编辑节点及祖先链;fields 返回结构化字段数组,见下文 |
142
+ | `include_hidden` | 仅 fields,默认 false;true 额外读取未暴露到 AX 的原生字段,如隐藏 input |
142
143
  | `text` | true;false 隐去独立正文,保留控件名称和值 |
143
144
  | `text_limit` | 正文名称预览上限 0–10000;interactive 默认 80,full 默认不截断;控件名称和值保留完整 |
144
145
  | `query` | 1–2000 字符的名称/value/context 字面子串,不区分大小写;零命中正常返回 |
145
146
  | `max_nodes` / `max_chars` | 600 / 24000;范围分别为 1–3000 / 1–2000000,两个预算独立 |
146
147
  | `verbose` | false;true 额外返回 viewport 和 snapshot_id |
147
148
 
148
- 查询在当前视图中匹配完整名称/value,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
149
+ full/interactive 查询在当前视图中匹配完整名称/value/context,再截取正文预览;不搜索 role、ref 或未暴露给 AX 的属性。阅读查询保留 full,interactive 会先筛掉正文。视图和查询减少返回量,不减少完整 AX 树采集。
149
150
 
150
151
  ```json
151
152
  {"tab_handle":"...","query":"保存","max_nodes":20}
152
153
  ```
153
154
 
154
- `next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query;显式提供时须与原观察一致。单个节点放不进预算会报出所需大小。主 frame、同进程 iframe 和跨进程 iframe(OOPIF)均可覆盖;未能读取或期间变化的 frame 见 omitted_frames。
155
+ `next_cursor` 继续同一份观察,保留 refs。续读可省略 profile/text/text_limit/query/include_hidden;显式提供时须与原观察一致。单个节点或字段放不进预算会报出所需大小。主 frame、同进程 iframe 和跨进程 iframe(OOPIF)均可覆盖;未能读取或期间变化的 frame 见 omitted_frames。
155
156
 
156
157
  | 事件 | ref / 快照分页的影响 |
157
158
  |---|---|
@@ -168,6 +169,32 @@ ref 错误的 diagnostics.reason=stale_ref,cause 区分 snapshot_replaced、do
168
169
 
169
170
  表单控件可附带 `context="性别"` 等邻近文本,在 interactive 或 text=false 下也保留,并参与 query。context 来自有限祖先范围内、仅含一个控件的容器中前一兄弟节点的 AX 文本;只是观察提示,不代表真实标签关联,不改写 AX name,也不参与 ref 身份校验。复杂布局可能没有 context。
170
171
 
172
+ ### 结构化字段观察
173
+
174
+ ```json
175
+ {"tab_handle":"...","profile":"fields"}
176
+ {"tab_handle":"...","profile":"fields","query":"TEXTS28"}
177
+ {"tab_handle":"...","profile":"fields","include_hidden":true,"query":"rawFee"}
178
+ ```
179
+
180
+ fields 视图用 `fields` 数组替代 snapshot 文本,保留重复标签和字段名,避免以标签作为键覆盖数据。`node_count/total_node_count` 与 max_nodes 计数字段,max_chars 仍按完整序列化项计算。`frames` 列出本次观察的 frame_id/URL,读取失败及变化见 omitted_frames。每行示例:
181
+
182
+ ```json
183
+ {
184
+ "ref":"token:1", "frame_id":"frame-id",
185
+ "labels":["签证费"], "context":null,
186
+ "ax":{"role":"textbox","name":"签证费","value":"1,234.00","readonly":true},
187
+ "dom":{"tag":"input","id":"fee","name":"TEXTS28","type":"text","value":"1,234.00","value_source":"input_value"}
188
+ }
189
+ ```
190
+
191
+ - `ax` 是浏览器提供的名称、角色、值及 disabled/readonly/required/invalid/checked/selected/expanded/multiselectable 状态;缺失属性不推断为 false。`labels` 来自浏览器 AX 名称的真实 HTML label 来源,context 则是现有有界附近文本,两者不改写 AX name。
192
+ - `dom.id/name/type/placeholder` 来自原生 DOM 快照属性;input 缺少 type 时记录 text。`dom.value` 的 value_source 区分 input_value、textarea_value、selected_option、text_content。select 附 multiple 和 selected_options 的值/标签;checkbox/radio 附原生 checked,AX checked=mixed 另行保留。contenteditable/output 的值是捕获的 textContent,不声称为编辑器完整模型。
193
+ - 默认包括暴露到 AX 的原生 input/textarea/select/output/contenteditable,以及 AX 字段角色的自定义控件;不把普通按钮当字段。文件 input 即使 AX 角色是 button 也识别为字段。include_hidden=true 才额外包括未暴露到 AX 的原生字段;此类行 ax/ref 可为 null,只提供可观察的 DOM 事实。密码字段省略 DOM 与 AX 值,返回 value_redacted=true。
194
+ - 文件 input 的 `dom.files={available,count,names}` 只报告隔离世界读取时的当前本地选择,不读取文件内容,不代表服务端附件数量或接受结果;读取失败为 available=false。上传后组件清空 input 时 count 可为 0。
195
+ - query 在 AX 名称/值、labels/context、DOM id/name/placeholder/value 和选中选项值/标签上作字面子串匹配。text/text_limit 只控制正文,不截断字段值。分页续读不重新采集;要核对当前值应发起新的字段观察。
196
+ - DOM 与 AX 按所属 frame/session 的 backend 节点身份关联,支持闭合 Shadow DOM 和 OOPIF。两次捕获之间节点被替换时不按 id/name 猜测新节点,只有 AX 事实的行 dom=null;无法判断原生字段类型时也省略 AX value。各 frame 与文件列表分步读取,不提供跨来源原子快照、业务提交值、组件内部模型或并发写入归属判断。读取最终保存结果仍需任务相关的页面或接口证据。
197
+
171
198
  ### browser_read_text
172
199
 
173
200
  `browser_read_text({tab_handle})` 默认读取主 frame 的可见正文。可用 selector 限定范围,或用 frame/frame_id 选择子 frame。
@@ -0,0 +1,130 @@
1
+ import { axContext, FIELD_ROLES, registerAXRef } from './snapshot.js';
2
+
3
+ const INPUT_BUTTONS = new Set(['button', 'submit', 'reset', 'image']);
4
+ const AX_STATES = new Set(['disabled', 'readonly', 'required', 'invalid', 'checked', 'selected', 'expanded', 'multiselectable']);
5
+
6
+ // One native DOM capture per debugger session, joined by frame and backend id.
7
+ // No DOM query, component getter or per-field resolve is needed for ordinary fields.
8
+ export function createFieldReader(send, value) {
9
+ const captures = new Map();
10
+ return async (target, frameId, nodes, token, refs, options) => {
11
+ const key = target.sessionId ?? '';
12
+ if (!captures.has(key)) captures.set(key, await send(target, 'DOMSnapshot.captureSnapshot', { computedStyles: [] }));
13
+ const capture = captures.get(key);
14
+ const document = capture.documents.find(doc => capture.strings[doc.frameId] === frameId);
15
+ if (!document) throw new Error('Frame document is unavailable in the DOM field capture');
16
+ const entries = renderFields(nodes, document.nodes, capture.strings, token, refs, frameId, target.sessionId, options);
17
+ const files = entries.filter(entry => entry.field.dom?.tag === 'input' && entry.field.dom.type === 'file');
18
+ if (files.length) {
19
+ const group = 'browser-attach-fields';
20
+ try {
21
+ const { executionContextId } = await send(target, 'Page.createIsolatedWorld', { frameId, worldName: group });
22
+ for (const entry of files) {
23
+ try {
24
+ const { object } = await send(target, 'DOM.resolveNode', { backendNodeId: entry.backendNodeId, executionContextId, objectGroup: group });
25
+ if (!object?.objectId) throw new Error('File input detached');
26
+ entry.field.dom.files = value(await send(target, 'Runtime.callFunctionOn', { objectId: object.objectId,
27
+ functionDeclaration: readFileSelection.toString(), returnByValue: true }));
28
+ } catch (error) { entry.field.dom.files = { available: false, error: error.message }; }
29
+ }
30
+ } finally { await send(target, 'Runtime.releaseObjectGroup', { objectGroup: group }).catch(() => {}); }
31
+ }
32
+ return entries;
33
+ };
34
+ }
35
+
36
+ function readFileSelection() {
37
+ if (!this.isConnected || this.tagName !== 'INPUT' || this.type !== 'file') return { available: false, error: 'File input changed during observation' };
38
+ return { available: true, count: this.files.length, names: Array.from(this.files, file => file.name) };
39
+ }
40
+
41
+ export function renderFields(axNodes, domNodes, strings, token, refs, frameId, sessionId, options) {
42
+ const { fieldContext } = axContext(axNodes);
43
+ const axByBackend = new Map();
44
+ for (const node of axNodes) if (node.backendDOMNodeId && (!node.ignored || !axByBackend.has(node.backendDOMNodeId))) axByBackend.set(node.backendDOMNodeId, node);
45
+ const rare = data => new Map((data?.index || []).map((index, n) => [index, data.value?.[n]]));
46
+ const inputValues = rare(domNodes.inputValue), textValues = rare(domNodes.textValue);
47
+ const checked = new Set(domNodes.inputChecked?.index || []), selected = new Set(domNodes.optionSelected?.index || []);
48
+ const children = new Map();
49
+ for (let i = 0; i < domNodes.parentIndex.length; i++) {
50
+ const parent = domNodes.parentIndex[i];
51
+ if (!children.has(parent)) children.set(parent, []);
52
+ children.get(parent).push(i);
53
+ }
54
+ const attributes = index => {
55
+ const list = domNodes.attributes[index] || [], result = new Map();
56
+ for (let i = 0; i < list.length; i += 2) result.set(strings[list[i]], strings[list[i + 1]]);
57
+ return result;
58
+ };
59
+ function descendants(index, visit) {
60
+ const pending = [index];
61
+ while (pending.length) {
62
+ const current = pending.pop(); visit(current);
63
+ const list = children.get(current) || [];
64
+ for (let i = list.length - 1; i >= 0; i--) pending.push(list[i]);
65
+ }
66
+ }
67
+ function textContent(index) {
68
+ const parts = [];
69
+ descendants(index, current => { if (domNodes.nodeType[current] === 3) parts.push(strings[domNodes.nodeValue[current]] || ''); });
70
+ return parts.join('');
71
+ }
72
+ const entries = [], seen = new Set(), query = options.query?.toLowerCase();
73
+ function add(node, dom, backendNodeId) {
74
+ const protectedValue = dom?.tag === 'input' && dom.type === 'password';
75
+ const ax = node && !node.ignored ? { role: node.role?.value, name: String(node.name?.value || '') } : null;
76
+ if (ax) {
77
+ if (dom && node.value?.value != null && !protectedValue) ax.value = node.value.value;
78
+ for (const property of node.properties || []) if (AX_STATES.has(property.name)) ax[property.name] = property.value?.value;
79
+ }
80
+ const labels = [];
81
+ for (const source of node?.name?.sources || []) {
82
+ if (!['label', 'labelfor', 'labelwrapped'].includes(source.nativeSource) || source.invalid) continue;
83
+ const related = source.nativeSourceValue?.relatedNodes ?? source.value?.relatedNodes ?? [];
84
+ const texts = related.map(item => item.text).filter(text => typeof text === 'string');
85
+ if (!texts.length && typeof source.value?.value === 'string') texts.push(source.value.value);
86
+ for (const text of texts) { const label = text.replace(/\s+/g, ' ').trim(); if (label && !labels.includes(label)) labels.push(label); }
87
+ }
88
+ const context = ax ? fieldContext(node) : null;
89
+ const field = { ref: null, frame_id: frameId, labels, context, ax, dom };
90
+ const terms = [ax?.name, ax?.value, context, ...labels, dom?.id, dom?.name, dom?.placeholder, dom?.value,
91
+ ...(dom?.selected_options || []).flatMap(option => [option.value, option.label])];
92
+ if (query != null && !terms.some(term => term != null && String(term).toLowerCase().includes(query))) return;
93
+ if (ax) field.ref = registerAXRef(node, token, refs, frameId, sessionId);
94
+ entries.push({ node: true, frameId, backendNodeId, field });
95
+ }
96
+ for (let index = 0; index < domNodes.backendNodeId.length; index++) {
97
+ if (domNodes.nodeType[index] !== 1) continue;
98
+ const backendNodeId = domNodes.backendNodeId[index], node = axByBackend.get(backendNodeId);
99
+ const attrs = attributes(index), tag = strings[domNodes.nodeName[index]].toLowerCase();
100
+ const type = tag === 'input' ? (attrs.get('type') || 'text').toLowerCase() : null;
101
+ const editable = attrs.has('contenteditable') && ['', 'true', 'plaintext-only'].includes(attrs.get('contenteditable').toLowerCase());
102
+ const native = tag === 'input' && !INPUT_BUTTONS.has(type) || ['textarea', 'select', 'output'].includes(tag) || editable;
103
+ if (!native && (!node || node.ignored || !FIELD_ROLES.has(node.role?.value))) continue;
104
+ if ((!node || node.ignored) && !options.include_hidden) continue;
105
+ seen.add(backendNodeId);
106
+ const dom = { tag, id: attrs.get('id') ?? null, name: attrs.get('name') ?? null };
107
+ if (type != null) dom.type = type;
108
+ if (attrs.has('placeholder')) dom.placeholder = attrs.get('placeholder');
109
+ if (type === 'password') dom.value_redacted = true;
110
+ else if (tag === 'input' && inputValues.has(index)) { dom.value = strings[inputValues.get(index)]; dom.value_source = 'input_value'; }
111
+ else if (tag === 'textarea' && textValues.has(index)) { dom.value = strings[textValues.get(index)]; dom.value_source = 'textarea_value'; }
112
+ else if (tag === 'select') {
113
+ dom.multiple = attrs.has('multiple'); dom.selected_options = [];
114
+ descendants(index, current => {
115
+ if (strings[domNodes.nodeName[current]] !== 'OPTION' || !selected.has(current)) return;
116
+ const optionAttrs = attributes(current), text = textContent(current).replace(/[\t\n\f\r ]+/g, ' ').replace(/^ | $/g, '');
117
+ dom.selected_options.push({ value: optionAttrs.get('value') ?? text, label: optionAttrs.get('label') ?? text });
118
+ });
119
+ dom.value = dom.selected_options[0]?.value ?? ''; dom.value_source = 'selected_option';
120
+ } else if (editable || tag === 'output') { dom.value = textContent(index); dom.value_source = 'text_content'; }
121
+ if (['checkbox', 'radio'].includes(type)) dom.checked = checked.has(index);
122
+ add(node, dom, backendNodeId);
123
+ }
124
+ // A DOM replacement between the AX and DOM captures must not be joined to
125
+ // a new element just because its name/id looks the same.
126
+ for (const node of axNodes) if (!node.ignored && node.backendDOMNodeId && !seen.has(node.backendDOMNodeId) && FIELD_ROLES.has(node.role?.value)) {
127
+ add(node, null, node.backendDOMNodeId);
128
+ }
129
+ return entries;
130
+ }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.22.1",
4
+ "version": "0.23.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": [
@@ -1,4 +1,5 @@
1
1
  import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
2
+ import { createFieldReader } from './fields.js';
2
3
 
3
4
  // Observation identity is separate from debugger session ownership.
4
5
  export function createObservations(cdp, value, sessions, viewport, currentClaim, changed) {
@@ -130,6 +131,7 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
130
131
  const knownFrames = new Set(frames.map(frame => frame.id));
131
132
  const token = crypto.randomUUID().slice(0, 8);
132
133
  const refs = new Map(), entries = [], omittedFrames = [];
134
+ const readFields = options.profile === 'fields' ? createFieldReader(send, value) : null;
133
135
  for (const frame of frames) {
134
136
  let nodes;
135
137
  let target;
@@ -156,15 +158,27 @@ export function createObservations(cdp, value, sessions, viewport, currentClaim,
156
158
  const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
157
159
  knownFrames.add(childId); frames.push({ id: childId, url });
158
160
  }
159
- const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
160
- if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
161
+ let rendered;
162
+ if (readFields) {
163
+ try { rendered = await readFields(target, frame.id, nodes, token, refs, options); }
164
+ catch (error) {
165
+ if (frame.id === mainId) throw error;
166
+ for (const [ref, node] of refs) if (node.frameId === frame.id) refs.delete(ref);
167
+ omittedFrames.push({ frame_id: frame.id, url: frame.url, reason: error.message });
168
+ continue;
169
+ }
170
+ } else {
171
+ rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
172
+ if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
173
+ }
161
174
  for (const entry of rendered) entries.push(entry);
162
175
  }
163
176
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
164
177
  expression: '({title: document.title, url: location.href})', returnByValue: true,
165
178
  }));
166
179
  const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
167
- metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
180
+ metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames,
181
+ ...(readFields ? { frames: frames.map(frame => ({ frame_id: frame.id, url: frame.url })) } : {}) } };
168
182
  if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
169
183
  for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
170
184
  omitFrame(tabId, state, frame.id, 'Frame changed while reading the snapshot; take a new snapshot to observe it');
@@ -1,21 +1,20 @@
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, query } = {}) {
3
- if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
2
+ export function snapshotOptions({ profile = 'full', text = true, text_limit, query, include_hidden } = {}) {
3
+ if (!['full', 'interactive', 'fields'].includes(profile)) throw new Error('profile must be full, interactive or fields');
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
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
+ if (include_hidden != null && (profile !== 'fields' || typeof include_hidden !== 'boolean')) throw new Error('include_hidden is a boolean for profile=fields only');
8
+ return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}),
9
+ ...(profile === 'fields' ? { include_hidden: include_hidden ?? false } : {}) };
8
10
  }
9
11
 
10
- export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
12
+ export const FIELD_ROLES = new Set(['checkbox', 'combobox', 'listbox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
13
+ const CONTROLS = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
14
+ 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
15
+
16
+ export function axContext(nodes) {
11
17
  const byId = new Map(nodes.map(node => [node.nodeId, node]));
12
- const visited = new Set();
13
- const entries = [];
14
- const query = options.query?.toLowerCase();
15
- const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
16
- const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
17
- 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
18
- const fields = new Set(['checkbox', 'combobox', 'radio', 'searchbox', 'slider', 'spinbutton', 'switch', 'textbox']);
19
18
  const parents = new Map();
20
19
  for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
21
20
  function contextText(node) {
@@ -53,7 +52,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
53
52
  while (pending.length && ++visited <= 64 && count < 2) {
54
53
  const current = pending.pop();
55
54
  if (!current) return null;
56
- if (!current.ignored && controls.has(current.role?.value)) count++;
55
+ if (!current.ignored && CONTROLS.has(current.role?.value)) count++;
57
56
  if (visited + pending.length + (current.childIds?.length || 0) > 64) return null;
58
57
  for (const id of current.childIds || []) pending.push(byId.get(id));
59
58
  }
@@ -67,6 +66,21 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
67
66
  }
68
67
  return null;
69
68
  }
69
+ return { byId, parents, fieldContext };
70
+ }
71
+
72
+ export function registerAXRef(node, token, refs, frameId, sessionId) {
73
+ if (!node.backendDOMNodeId || node.ignored) return null;
74
+ const ref = `${token}:${refs.size + 1}`;
75
+ refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
76
+ return ref;
77
+ }
78
+
79
+ export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
80
+ const { byId, parents, fieldContext } = axContext(nodes);
81
+ const visited = new Set(), entries = [];
82
+ const query = options.query?.toLowerCase();
83
+ const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
70
84
  let kept;
71
85
  if (options.profile === 'interactive') {
72
86
  kept = new Set();
@@ -82,7 +96,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
82
96
  }
83
97
  }
84
98
  for (const node of nodes) {
85
- if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
99
+ if (node.ignored || !node.backendDOMNodeId || !(CONTROLS.has(node.role?.value) ||
86
100
  node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
87
101
  prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
88
102
  for (const child of node.childIds || []) keepText(byId.get(child));
@@ -128,7 +142,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
128
142
  ['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
129
143
  ).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
130
144
  if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
131
- const context = fields.has(role) && !node.ignored ? fieldContext(node) : null;
145
+ const context = FIELD_ROLES.has(role) && !node.ignored ? fieldContext(node) : null;
132
146
  if (context && context !== name) states.push(`context=${JSON.stringify(context)}`);
133
147
  const attributes = states.join(' ');
134
148
  const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
@@ -141,8 +155,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
141
155
  let nextDepth = depth;
142
156
  if (!skip) {
143
157
  if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query) || context?.toLowerCase().includes(query)) {
144
- const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
145
- if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
158
+ const ref = registerAXRef(node, token, refs, frameId, sessionId);
146
159
  entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
147
160
  }
148
161
  nextDepth++;
@@ -164,28 +177,30 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
164
177
  let offset = 0;
165
178
  if (cursor != null) {
166
179
  if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
167
- for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
168
- throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
180
+ for (const key of ['profile', 'text', 'text_limit', 'query', 'include_hidden']) if (view[key] != null && view[key] !== state.options[key]) {
181
+ throw new Error('Snapshot cursor uses the original profile/text/text_limit/query/include_hidden; take a new snapshot to change the view.');
169
182
  }
170
183
  offset = state.nextOffset;
171
184
  }
172
- const lines = [];
185
+ const lines = [], fields = [];
186
+ const structured = state.options.profile === 'fields';
173
187
  let count = 0, chars = 0, index = offset, reason = null;
174
188
  for (; index < state.entries.length; index++) {
175
189
  const entry = state.entries[index];
176
190
  if (state.invalidFrames.has(entry.frameId)) continue;
177
- const size = JSON.stringify(entry.line).length + 2;
191
+ const size = JSON.stringify(structured ? entry.field : entry.line).length + 2;
178
192
  if (entry.node && count >= max_nodes) { reason = 'node_limit'; break; }
179
193
  if (chars + size > max_chars) {
180
- if (!lines.length) throw new Error(`A snapshot line requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
194
+ if (!lines.length && !fields.length) throw new Error(`A snapshot ${structured ? 'field' : 'line'} requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
181
195
  reason = 'text_budget'; break;
182
196
  }
183
- lines.push(entry.line); chars += size; count += entry.node ? 1 : 0;
197
+ if (structured) fields.push(entry.field); else lines.push(entry.line);
198
+ chars += size; count += entry.node ? 1 : 0;
184
199
  }
185
200
  const truncated = index < state.entries.length;
186
201
  state.nextOffset = index;
187
202
  state.nextCursor = truncated ? `${state.id}@${index}` : null;
188
203
  const { viewport, ...metadata } = state.metadata;
189
- return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
204
+ return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), ...(structured ? { fields } : { snapshot: lines.join('\n') }), node_count: count,
190
205
  total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
191
206
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.22.1",
3
+ "version": "0.23.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",
@@ -15,7 +15,7 @@ for (const path of names) {
15
15
  if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
16
16
  }
17
17
  for (const path of ['extension/connection.js', 'extension/version.js', 'extension/errors.js', 'extension/page-url.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
- for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
18
+ for (const path of ['src/hub-runtime.mjs', 'extension/observation.js', 'extension/fields.js', 'extension/pointer.js', 'extension/dialogs.js', 'extension/network.js', 'extension/log-data.js', 'extension/fetch-page.js', 'extension/screenshot.js', 'extension/upload.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
19
19
  for (const path of ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.js', 'extension/fetch.js', 'extension/locator.js', 'extension/batch.js', 'extension/wait.js', 'extension/outcomes.js', 'extension/scroll.js', 'extension/drag.js', 'extension/text.js', 'extension/diagnostics.js', 'extension/sessions.js', 'extension/actionability.js', 'extension/limits.js', 'extension/icons/BrowserAttach.png']) {
20
20
  if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
21
21
  }
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.22.1';
18
+ export const VERSION = '0.23.0';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -103,13 +103,14 @@ export function createMcp(client) {
103
103
  if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
104
104
  return { tab_handle: args.tab_handle };
105
105
  });
106
- 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.', {
106
+ tool('snapshot', 'Read an AX tree, or profile=fields for structured DOM/AX field facts with refs, HTML labels, nearby context and native values. Field rows distinguish DOM values from AX values, not submitted data or application success. A new observation replaces refs; next_cursor continues the captured view.', {
107
107
  ...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
108
108
  max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
109
- profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls, focusable/editable nodes and ancestors.'),
109
+ profile: z.enum(['full', 'interactive', 'fields']).optional().describe('Default full; interactive keeps controls and ancestors; fields returns a fields array instead of AX text.'),
110
+ include_hidden: z.boolean().optional().describe('fields only, default false: also include native fields omitted from AX, such as hidden inputs. These may have ref=null. Password values are omitted.'),
110
111
  text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
111
112
  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.'),
112
- query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of AX names, values or field context, before text previews. Context is nearby text, not an accessible name. Returns matching lines and refs; zero matches is not an error.'),
113
+ query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of names, values or nearby context. fields also searches HTML labels, DOM id/name/placeholder and selected options. Zero matches is not an error.'),
113
114
  verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
114
115
  });
115
116
  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.', {