@evan_zhong/browser-attach 0.23.1 → 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.
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/TEST-REPORT.md +10 -1
- package/docs/USAGE.md +4 -2
- package/extension/input.js +45 -7
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +1 -1
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.23.
|
|
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。fill 对 input/textarea 精确核验 value;contenteditable 的 native
|
|
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。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.23.
|
|
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
|
+
|
|
7
16
|
- `npm test`:273 项通过。新增 contenteditable 按输入模式核验的 19 个子用例,覆盖 native 渲染换行、CRLF/CR、普通空格以 NBSP 保留、显式 NBSP、空文本和 Unicode;DOM 精确文本;拒绝另一种文本表示掩盖的段落、字符、空格与空行变化,失败保持未知结果且不补发。
|
|
8
17
|
- Chromium 156、Edge 155 headless 完整 stdio MCP `live-smoke`:各 302 次调用、32 项检查通过。真实原生输入验证首尾 NBSP、CRLF/CR 与中文多行;DOM 精确文本和同步格式化造成的跨表示不匹配经过扩展 → Hub → MCP 返回 `value_not_retained`。两种模式的受控不匹配均只产生 1 次 input,不切换模式或重放;多行原生插入本身可产生多个 input 事件。
|
|
9
18
|
- `npm run test:package`:包外安装与 stdio MCP 通过,23 个工具、61 个发布文件,无配对密钥。未增加工具、参数、依赖或扩展权限,版本按校验修复递增至 0.23.1。
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.23.
|
|
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,9 @@ 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。input/textarea 按 value
|
|
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,不是浏览器所选纯文本。
|
|
343
345
|
|
|
344
346
|
工具不自动 blur 或按 Enter。需要失焦校验时显式 Tab 或点击其他目标;自定义下拉须在输入过滤后点击具体选项。
|
|
345
347
|
|
package/extension/input.js
CHANGED
|
@@ -124,7 +124,14 @@ 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')
|
|
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
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}`), {
|
|
@@ -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.
|
|
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,17 +287,46 @@ function prepareFill(mode, text) {
|
|
|
280
287
|
return true;
|
|
281
288
|
}
|
|
282
289
|
|
|
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
|
+
|
|
283
302
|
function verifyFill(text, mode) {
|
|
284
303
|
if (!this.isConnected) return false;
|
|
285
304
|
if (!this.isContentEditable) return this.value === text;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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');
|
|
289
325
|
if (actual.length !== expected.length) return false;
|
|
290
326
|
for (let index = 0; index < expected.length; index++) {
|
|
291
327
|
// Chromium may preserve an inserted ordinary space as NBSP. Keep its position
|
|
292
328
|
// and count, and still require an explicitly requested NBSP to be retained.
|
|
293
|
-
if (actual[index] !== expected[index] && !(expected[index] === ' ' && actual[index] === '\u00a0')) return false;
|
|
329
|
+
if (actual[index] !== expected[index] && !(mode === 'native' && expected[index] === ' ' && actual[index] === '\u00a0')) return false;
|
|
294
330
|
}
|
|
295
331
|
return true;
|
|
296
332
|
}
|
|
@@ -339,7 +375,9 @@ export function selectElement(choice, apply = true) {
|
|
|
339
375
|
}
|
|
340
376
|
|
|
341
377
|
export function typeOptions(args) {
|
|
342
|
-
if (typeof args.text !== 'string'
|
|
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');
|
|
343
381
|
timingOption(args.delay_ms ?? 0, 'delay_ms');
|
|
344
382
|
}
|
|
345
383
|
|
package/extension/manifest.json
CHANGED
package/package.json
CHANGED
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.
|
|
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.
|
|
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() };
|