@evan_zhong/browser-attach 0.23.0 → 0.23.2

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.23.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
3
+ 当前实现为 **0.23.2**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
4
4
 
5
5
  ## 组件与数据流
6
6
 
@@ -102,7 +102,7 @@ HTTP 状态复用 network.js 的 Network.responseReceived 观察,按主文档
102
102
  → 等待可信事件回执
103
103
  ```
104
104
 
105
- - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。
105
+ - **type 与 fill**:fill 替换文字,type 保留选区,逐字符复用原生按键与 keydown 回执;字符之间可延迟,不把 insertText 伪装成键盘序列。浏览器处理 keypress/input 及 preventDefault,非 ASCII 不模拟系统 IME。fill 对 input/textarea 精确核验 value;native 在 insertText 前另核对替换选区与焦点,丢失则停止,避免已知失效选区造成追加。contenteditable 的 native 核验临时选中内容并读取浏览器 Selection.toString,finally 恢复原选区及方向;复用浏览器对编辑占位 br/div 的纯文本语义,不实现 HTML 解析器。此过程可能触发 selectionchange,不插入额外文本。dom 使用 innerText setter 生成可见 br,再核验 innerText,无 HTML sink、不主动聚焦。两者统一 CRLF/CR 为 LF,native 仅允许请求中的普通空格在同位置以 NBSP 保留;不合并空白或空行,不等待异步保存,差异仍报告结果未知。
106
106
  - **焦点输入与语义定位**:type 的 focused=true 与 ref/selector 互斥,复用 press_key 的 frame/开放 Shadow DOM 焦点解析,固定最初可编辑元素并逐字检查,不重新聚焦。selector_type 的 label/placeholder 在隔离世界按真实 HTML 关联/属性查询,role/name 每次读取所选 frame 的 AX 树并严格匹配,以 AX 树顺序支持 nth;统一返回原有节点结构,继续复用输入、命中核验及 batch 流程,无组件库适配器。
107
107
  - **只重试准备**:等待阶段可以反复轮询,但一旦派发就不再重试。超时分为"未派发"和"已派发、结果未知"两种错误文本。
108
108
  - **原生指针就绪检查**:准备前和派发前各做一次至多 500 ms 的有界检查;要求未冻结/丢弃的 tab 与未最小化的窗口。无修饰键的左键单击可在非活动标签尝试执行;hover、drag、其他按钮、双击及带修饰键的点击仍要求活动标签。检查失败以 TAB_NOT_READY 明确报告当前指针输入未派发,并提示 tab_control(action=activate) → snapshot → 所请求动作。click/hover 成功结果的 page_state.tab_active 记录派发前检查观察到的活动状态。普通操作不主动激活;仅显式 tab_control(action=activate) 在 focus_window=true 时请求窗口聚焦和 Page.bringToFront。
@@ -1,9 +1,26 @@
1
1
  # 验证报告
2
2
 
3
- 当前版本 **0.23.0**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
3
+ 当前版本 **0.23.2**(2026-10-25)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
4
4
 
5
5
  ## 当前版本
6
6
 
7
+ - `npm test`:275 项通过。覆盖浏览器所选纯文本的换行编码、NBSP 和实际文本差异;核验恢复选区端点及方向,DOM 不操作选区;替换选区丢失不发送 insertText。type 参数错误区分非字符串、长度和控制字符。
8
+ - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 364 次调用、34 项检查通过。新增 Gmail 同形 DOM 的空行、连续空行、首尾换行、空文本,以及含旧段落内容的连续原生替换;原生 input 可信,核验后光标仍折叠。DOM 写入可见 br 换行并保持已有焦点;真实空行数量不匹配仍返回未知结果、不重放。
9
+ - Trusted Types 专用页面确认 innerHTML 字符串赋值抛出 TypeError;native/DOM 均能在该页完成含空行文本及单行替换。页面在 focus 后主动折叠替换选区时,返回 fill_selection_lost,正文不变且 input 事件为 0。type 的换行错误经真实 MCP 提示 press_key/Enter。
10
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥。未增加工具、参数、依赖或权限,按输入及核验修复递增至 0.23.2。
11
+ - 本机部署:协调升级因现有 MCP 连接占用拒绝后,按 stop/start 重启;Hub 为 0.23.2 / ready,Chrome、Edge 扩展均为 0.23.2。稳定扩展副本逐文件核对,manifest 保留机器 CSP,其余非生成文件 SHA-256 一致。
12
+ - 当前边界:native 核验暂时选择文本,可能触发 selectionchange;选区检查与 insertText 不具备原子性,不能消除所有页面竞态。未操作真实 Gmail 草稿,不证明自动保存或发送正确。read_text 的 visible/value 保持 innerText;type 仍只接受可打印字符,换行显式 press_key Enter;hash 路由变化仍按现有规则使 ref 失效。重启后的客户端需重连并重新发现、接管。
13
+
14
+ ## 0.23.1 验证
15
+
16
+ - `npm test`:273 项通过。新增 contenteditable 按输入模式核验的 19 个子用例,覆盖 native 渲染换行、CRLF/CR、普通空格以 NBSP 保留、显式 NBSP、空文本和 Unicode;DOM 精确文本;拒绝另一种文本表示掩盖的段落、字符、空格与空行变化,失败保持未知结果且不补发。
17
+ - Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 302 次调用、32 项检查通过。真实原生输入验证首尾 NBSP、CRLF/CR 与中文多行;DOM 精确文本和同步格式化造成的跨表示不匹配经过扩展 → Hub → MCP 返回 `value_not_retained`。两种模式的受控不匹配均只产生 1 次 input,不切换模式或重放;多行原生插入本身可产生多个 input 事件。
18
+ - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥。未增加工具、参数、依赖或扩展权限,版本按校验修复递增至 0.23.1。
19
+ - 本机部署:协调升级因已有 MCP 连接拒绝后,按 stop/start 重启;Hub 为 0.23.1 / ready,Chrome、Edge 扩展均为 0.23.1。稳定扩展目录 `F:\.nosis\browser-attach\extension` 的 43 个文件已核对:manifest 版本/身份一致,其余非生成源码/资源 SHA-256 一致。
20
+ - 当前边界:native contenteditable 不裁剪空白、不合并空行,富文本占位换行或异步重建仍可能返回结果未知;不增加编辑器适配、自动重试或保存核验。未在真实 Gmail 草稿上验证;本机已有 MCP 客户端需重连并重新发现、接管。
21
+
22
+ ## 0.23.0 验证
23
+
7
24
  - `npm test`:253 项通过。新增结构化字段保留重复标签、真实 HTML label 来源与附近 context、DOM/AX 值区分、原生多选与 mixed 状态、隐藏字段显式读取、密码值省略、节点身份缺失不按名称合并、完整行双预算分页、session 级 DOM 捕获复用与文件选择只读采集。覆盖输入后 cursor 失效且 ref 保留、新观察替换旧 ref、子 frame 变化只移除子树、文档捕获中导航不恢复旧引用。
8
25
  - 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
26
  - `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥;新增 fields 模块纳入包边界检查。没有新工具、依赖或扩展权限,观察能力扩展升至 0.23.0。
package/docs/USAGE.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # BrowserAttach 使用指南
2
2
 
3
- 适用于 **0.23.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md),宿主配置见 [Agent 集成](AGENT-INTEGRATION.md),内部机制见 [架构说明](ARCHITECTURE.md)。
3
+ 适用于 **0.23.2**: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
 
@@ -339,7 +339,11 @@ network_idle 的 idle_ms 默认 500,范围 100–10000。它只覆盖接管后
339
339
 
340
340
  支持文本类 input(text/search/email/password/tel/url)、textarea 和 contenteditable。native(默认)聚焦、选中并插入文本,不产生逐字 keydown;dom 写入值并派发合成 input/change,不主动聚焦。
341
341
 
342
- 两种模式均核对最终文本,返回 value_verified=true;失焦、替换或同步格式化导致不匹配时报错,不回显字段内容。工具不自动 blur 或按 Enter。需要失焦校验时显式 Tab 或点击其他目标;自定义下拉须在输入过滤后点击具体选项。
342
+ 两种模式均核对写入后的文本,返回 value_verified=true。input/textarea 按 value 精确比较。contenteditable 的 native 模式在插入前确认替换选区仍覆盖字段,选区丢失时以 fill_selection_lost 停止,不插入文本;插入后临时选择字段内容,按浏览器所选纯文本核验并立即恢复原选区及方向。这能区分真实空行与空 div/br 的编辑占位,不用 innerText 的额外换行判定失败;临时选择可能产生 selectionchange。dom 模式通过 innerText setter 写入文本与可见 br 换行,不使用 innerHTML 或 TrustedHTML,也不主动聚焦,按 innerText 核验。
343
+
344
+ contenteditable 比较将 CRLF/CR 视为 LF;native 另允许请求中的普通空格在相同位置以 NBSP 保留(显式请求的 NBSP 仍须原样保留)。不裁剪首尾空白、不合并空格或空行。节点替换、失焦、页面格式化或异步重建仍可能导致结果未知;不回显字段内容,不自动重放,也不等待编辑器保存。read_text 的 visible/value 仍是 innerText,不是浏览器所选纯文本。
345
+
346
+ 工具不自动 blur 或按 Enter。需要失焦校验时显式 Tab 或点击其他目标;自定义下拉须在输入过滤后点击具体选项。
343
347
 
344
348
  ### browser_type
345
349
 
@@ -124,9 +124,16 @@ export function createInput(cdp, value, sessions) {
124
124
  budget.dispatch();
125
125
  value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
126
126
  functionDeclaration: prepareFill.toString(), arguments: [{ value: mode }, { value: args.text }] }));
127
- if (mode === 'native') await send('Input.insertText', { text: args.text });
127
+ if (mode === 'native') {
128
+ const selected = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
129
+ functionDeclaration: verifyReplacementSelection.toString() }));
130
+ if (selected !== true) throw Object.assign(new Error('Fill replacement selection was lost before text insertion; no replacement text was inserted. Observe the field before continuing.'), {
131
+ outcome_unknown: true, diagnostics: { reason: 'fill_selection_lost', frame_id: node.frameId },
132
+ });
133
+ await send('Input.insertText', { text: args.text });
134
+ }
128
135
  const matches = value(await send('Runtime.callFunctionOn', { objectId: node.objectId, returnByValue: true,
129
- functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }] }));
136
+ functionDeclaration: verifyFill.toString(), arguments: [{ value: args.text }, { value: mode }] }));
130
137
  if (matches !== true) throw Object.assign(new Error(`Fill did not retain the requested text. Input may have been swallowed, focus may have moved, or the page may have reformatted it. Outcome is unknown. ${BUSY}`), {
131
138
  outcome_unknown: true, diagnostics: { reason: 'value_not_retained', frame_id: node.frameId },
132
139
  });
@@ -261,7 +268,7 @@ function prepareFill(mode, text) {
261
268
  if (tag === 'input' && !['text', 'search', 'email', 'password', 'tel', 'url', ''].includes(this.type)) throw new Error('Input type cannot be filled with text');
262
269
  if (mode === 'dom') {
263
270
  const win = this.ownerDocument.defaultView;
264
- if (this.isContentEditable) this.textContent = text;
271
+ if (this.isContentEditable) this.innerText = text;
265
272
  else {
266
273
  const prototype = tag === 'textarea' ? win.HTMLTextAreaElement.prototype : win.HTMLInputElement.prototype;
267
274
  Object.getOwnPropertyDescriptor(prototype, 'value').set.call(this, text);
@@ -280,9 +287,48 @@ function prepareFill(mode, text) {
280
287
  return true;
281
288
  }
282
289
 
283
- function verifyFill(text) {
284
- // Native editing may represent line breaks using nested blocks or <br>.
285
- return this.isConnected && (this.isContentEditable ? this.textContent === text || this.innerText === text : this.value === text);
290
+ function verifyReplacementSelection() {
291
+ if (!this.isConnected) return false;
292
+ const active = this.getRootNode().activeElement;
293
+ if (active !== this && !this.contains(active)) return false;
294
+ if (!this.isContentEditable) return true;
295
+ const selection = this.ownerDocument.getSelection();
296
+ if (!selection || selection.rangeCount !== 1) return false;
297
+ const expected = this.ownerDocument.createRange(); expected.selectNodeContents(this);
298
+ const actual = selection.getRangeAt(0);
299
+ return actual.compareBoundaryPoints(0, expected) === 0 && actual.compareBoundaryPoints(2, expected) === 0;
300
+ }
301
+
302
+ function verifyFill(text, mode) {
303
+ if (!this.isConnected) return false;
304
+ if (!this.isContentEditable) return this.value === text;
305
+ let retained;
306
+ if (mode === 'dom') retained = this.innerText;
307
+ else {
308
+ const active = this.getRootNode().activeElement;
309
+ if (active !== this && !this.contains(active)) return false;
310
+ const selection = this.ownerDocument.getSelection();
311
+ if (!selection) return false;
312
+ const { anchorNode, anchorOffset, focusNode, focusOffset } = selection;
313
+ const range = this.ownerDocument.createRange(); range.selectNodeContents(this);
314
+ try {
315
+ // Chromium's selected text understands editing placeholders such as an
316
+ // empty <div><br></div>; innerText counts their line breaks twice.
317
+ selection.removeAllRanges(); selection.addRange(range);
318
+ retained = selection.toString();
319
+ } finally {
320
+ selection.removeAllRanges();
321
+ if (anchorNode?.isConnected && focusNode?.isConnected) selection.setBaseAndExtent(anchorNode, anchorOffset, focusNode, focusOffset);
322
+ }
323
+ }
324
+ const actual = retained.replace(/\r\n?/g, '\n'), expected = text.replace(/\r\n?/g, '\n');
325
+ if (actual.length !== expected.length) return false;
326
+ for (let index = 0; index < expected.length; index++) {
327
+ // Chromium may preserve an inserted ordinary space as NBSP. Keep its position
328
+ // and count, and still require an explicitly requested NBSP to be retained.
329
+ if (actual[index] !== expected[index] && !(mode === 'native' && expected[index] === ' ' && actual[index] === '\u00a0')) return false;
330
+ }
331
+ return true;
286
332
  }
287
333
 
288
334
  function clickElement() {
@@ -329,7 +375,9 @@ export function selectElement(choice, apply = true) {
329
375
  }
330
376
 
331
377
  export function typeOptions(args) {
332
- 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');
378
+ if (typeof args.text !== 'string') throw new Error('type text must be a string');
379
+ if (args.text.length > 10000) throw new Error('type text must contain at most 10000 characters');
380
+ if (/[\x00-\x1f]/.test(args.text)) throw new Error('type accepts printable characters only; use press_key with Enter for line breaks or Tab for tabs');
333
381
  timingOption(args.delay_ms ?? 0, 'delay_ms');
334
382
  }
335
383
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.23.0",
4
+ "version": "0.23.2",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.23.0",
3
+ "version": "0.23.2",
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.23.0';
18
+ export const VERSION = '0.23.2';
19
19
 
20
20
  export async function loadConfig(path = DEFAULT_CONFIG) {
21
21
  let config;
package/src/mcp.mjs CHANGED
@@ -166,7 +166,7 @@ export function createMcp(client) {
166
166
  }, null, args => { targetOptions(args); selectOptions(args); return args; });
167
167
  const focused = { focused: z.literal(true).optional().describe('Alternative to ref/selector: pin the currently focused editable element without refocusing it. Stops if focus changes.') };
168
168
  tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Choose ref, selector or focused=true. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, ...focused, text: z.string().max(10000), delay_ms: typeDelay }, null, args => { targetOptions(args, { focused: true }); typeOptions(args); return args; });
169
- 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; });
169
+ tool('fill', 'Replace input/textarea/contenteditable text. Native mode focuses/selects and inserts text without character keydown; DOM mode sets text with visible line breaks without focus and emits input/change. Native contenteditable verification temporarily selects its contents, reads browser-selected plain text and restores the selection; DOM verifies innerText. CRLF/CR count as LF; native NBSP may retain a requested space. 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; });
170
170
  const batchTarget = { ...selector, selector: z.string().min(1).max(10000), ...frameScope, nth: target.nth };
171
171
  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() };
172
172
  const stepOptions = { timeout_ms: integerRange('step timeout_ms', 100, 30000).optional(), dialog_policy: z.enum(['ask', 'accept', 'dismiss']).optional() };