@evan_zhong/browser-attach 0.11.1 → 0.12.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 +75 -71
- package/README.zh.md +173 -0
- package/docs/AGENT-INTEGRATION.md +125 -0
- package/docs/ARCHITECTURE.md +3 -2
- package/docs/TEST-REPORT.md +7 -3
- package/docs/USAGE.md +12 -6
- package/extension/click.js +8 -3
- package/extension/controller.js +18 -9
- package/extension/drag.js +28 -8
- package/extension/locator.js +15 -4
- package/extension/manifest.json +1 -1
- package/extension/scroll.js +8 -1
- package/extension/snapshot.js +44 -5
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/src/mcp.mjs +5 -2
- package/docs/NOSIS-INTEGRATION.md +0 -53
package/README.md
CHANGED
|
@@ -4,50 +4,54 @@
|
|
|
4
4
|
|
|
5
5
|
# BrowserAttach
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
<p align="center">
|
|
8
|
+
<strong>English</strong> · <a href="README.zh.md">简体中文</a>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
Drive the Chrome / Edge tabs you already have open and already signed in from an MCP client. BrowserAttach connects to the browser through a locally paired extension, keeps your existing sign-in state, and needs neither a remote debugging port nor a separate automation browser.
|
|
8
12
|
|
|
9
|
-
[npm](https://www.npmjs.com/package/@evan_zhong/browser-attach) · [
|
|
13
|
+
[npm](https://www.npmjs.com/package/@evan_zhong/browser-attach) · [Usage guide](docs/USAGE.md) · [Architecture](docs/ARCHITECTURE.md) · [Agent integration](docs/AGENT-INTEGRATION.md) · [Test report](docs/TEST-REPORT.md) · [Issues](https://github.com/EvannZhongg/BrowserAttach/issues) · [Contributing](CONTRIBUTING.md)
|
|
10
14
|
|
|
11
|
-
##
|
|
15
|
+
## What it does
|
|
12
16
|
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
17
|
-
-
|
|
18
|
-
-
|
|
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.
|
|
19
|
+
- Native pointer hover, stepped drags and conditional waits; select HTML options without opening a native popup.
|
|
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
|
+
- 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
|
+
- Standard stdio MCP with 22 tools. The Hub listens on loopback only and pairs with the extension through a random key.
|
|
19
23
|
|
|
20
|
-
##
|
|
24
|
+
## Requirements
|
|
21
25
|
|
|
22
|
-
- Node.js **22
|
|
23
|
-
- Chrome / Edge **125
|
|
24
|
-
-
|
|
26
|
+
- Node.js **22 or newer**, plus npm.
|
|
27
|
+
- Chrome / Edge **125 or newer** with permission to load unpacked extensions. Latest stable is recommended.
|
|
28
|
+
- An MCP client that speaks stdio MCP and can find Node/npm on `PATH`.
|
|
25
29
|
|
|
26
|
-
##
|
|
30
|
+
## Quick start
|
|
27
31
|
|
|
28
|
-
### 1.
|
|
32
|
+
### 1. Install and pair
|
|
29
33
|
|
|
30
34
|
```sh
|
|
31
35
|
npm install --global @evan_zhong/browser-attach
|
|
32
36
|
browser-attach setup
|
|
33
37
|
```
|
|
34
38
|
|
|
35
|
-
`setup`
|
|
39
|
+
`setup` prints a stable extension directory and starts the local Hub. The directory is usually:
|
|
36
40
|
|
|
37
|
-
- Windows
|
|
38
|
-
- macOS / Linux
|
|
41
|
+
- Windows: `%USERPROFILE%\.browser-attach\extension`
|
|
42
|
+
- macOS / Linux: `~/.local/share/browser-attach/extension`
|
|
39
43
|
|
|
40
|
-
|
|
44
|
+
Open `chrome://extensions` or `edge://extensions`, enable developer mode, choose "Load unpacked" and load that directory. Load it once for every browser profile you want to expose; the extension options page lets you set a recognizable profile name.
|
|
41
45
|
|
|
42
|
-
|
|
46
|
+
Or without a global install:
|
|
43
47
|
|
|
44
48
|
```sh
|
|
45
49
|
npx --yes --package=@evan_zhong/browser-attach@latest browser-attach setup
|
|
46
50
|
```
|
|
47
51
|
|
|
48
|
-
### 2.
|
|
52
|
+
### 2. Configure the MCP client
|
|
49
53
|
|
|
50
|
-
|
|
54
|
+
Merge the following into your client's MCP configuration, or see [mcp-config.json](mcp-config.json):
|
|
51
55
|
|
|
52
56
|
```json
|
|
53
57
|
{
|
|
@@ -60,51 +64,51 @@ npx --yes --package=@evan_zhong/browser-attach@latest browser-attach setup
|
|
|
60
64
|
}
|
|
61
65
|
```
|
|
62
66
|
|
|
63
|
-
Windows
|
|
67
|
+
On Windows clients that require a full command extension, use `npx.cmd`. With a global install you can also configure `"command": "browser-attach", "args": ["mcp"]`. Restart the MCP client to discover the tools.
|
|
64
68
|
|
|
65
|
-
`@latest`
|
|
69
|
+
`@latest` resolves the newest npm version at launch. The MCP package and the loaded extension must be the same version; after an upgrade, rerun `browser-attach setup` and reload the extension on the extensions page, otherwise the Hub rejects the old extension connection.
|
|
66
70
|
|
|
67
|
-
|
|
71
|
+
Assembly steps for supported hosts are in [agent integration](docs/AGENT-INTEGRATION.md); other clients use the stdio entry above.
|
|
68
72
|
|
|
69
|
-
### 3.
|
|
73
|
+
### 3. Start driving
|
|
70
74
|
|
|
71
|
-
1. `browser_discover(kind="browsers")` → `browser_discover(kind="tabs")
|
|
72
|
-
2. `browser_claim_tab
|
|
73
|
-
3. `browser_snapshot`
|
|
74
|
-
4.
|
|
75
|
+
1. `browser_discover(kind="browsers")` → `browser_discover(kind="tabs")` to confirm the browser, tab title and URL.
|
|
76
|
+
2. `browser_claim_tab` with the `tab_id` from `discover(kind="tabs")` (optional `expected_url` / `expected_title` exact guards) to take control and receive this connection's `tab_handle`.
|
|
77
|
+
3. `browser_snapshot` to get element `ref`s, then click, fill or run other actions.
|
|
78
|
+
4. Observe again after each action, and finish with `browser_tab_control(action="release")`.
|
|
75
79
|
|
|
76
|
-
|
|
80
|
+
When titles change easily, claim can omit `expected_title`; `expected_url` is optional too. Click/fill can instead use a `selector` that is re-resolved on every call. Native element clicks scroll into view automatically; full-page coordinate clicks use `coordinate_space: "document"`. Evaluation results continue from the cache through `cursor` without re-running the script. Design trade-offs: [architecture notes](docs/ARCHITECTURE.md#设计取舍).
|
|
77
81
|
|
|
78
|
-
|
|
82
|
+
Claiming and ordinary input never activate a tab or window; call `browser_tab_control(action="activate")` explicitly when foreground rendering is needed. When an outcome is unknown, observe again first and avoid duplicate submissions. Full parameters, pagination, uploads and input semantics: [usage guide](docs/USAGE.md).
|
|
79
83
|
|
|
80
|
-
##
|
|
84
|
+
## Tools
|
|
81
85
|
|
|
82
|
-
|
|
|
86
|
+
| Tool | Purpose |
|
|
83
87
|
| --- | --- |
|
|
84
|
-
| `browser_discover` | `kind=browsers/tabs
|
|
85
|
-
| `browser_claim_tab` |
|
|
86
|
-
| `browser_open_tab` / `browser_tab_control` |
|
|
87
|
-
| `browser_snapshot` / `browser_read_text` | AX
|
|
88
|
-
| `browser_wait` |
|
|
89
|
-
| `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` |
|
|
90
|
-
| `browser_batch` |
|
|
91
|
-
| `browser_navigate` | HTTP(S)
|
|
92
|
-
| `browser_handle_dialog` | alert
|
|
93
|
-
| `browser_upload_file` |
|
|
94
|
-
| `browser_screenshot` |
|
|
95
|
-
| `browser_evaluate` |
|
|
96
|
-
| `browser_logs` |
|
|
97
|
-
|
|
98
|
-
##
|
|
99
|
-
|
|
100
|
-
|
|
|
88
|
+
| `browser_discover` | `kind=browsers/tabs`; discover browser profiles, tab identity and control status |
|
|
89
|
+
| `browser_claim_tab` | Exclusively claim a discovered tab |
|
|
90
|
+
| `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_wait` | Read-only wait for an element, text, URL or network idle |
|
|
93
|
+
| `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
|
+
| `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 |
|
|
95
|
+
| `browser_navigate` | HTTP(S) navigation and load waiting |
|
|
96
|
+
| `browser_handle_dialog` | alert, confirm, prompt |
|
|
97
|
+
| `browser_upload_file` | Native file input or an intercepted file chooser |
|
|
98
|
+
| `browser_screenshot` | Viewport or full-page PNG with coordinate mapping |
|
|
99
|
+
| `browser_evaluate` | Main-page JS evaluation, promise awaiting, bounded results |
|
|
100
|
+
| `browser_logs` | Select console, error or request logs with `kind=console/network`; supports filters and cursor continuation |
|
|
101
|
+
|
|
102
|
+
## Local data and upgrades
|
|
103
|
+
|
|
104
|
+
| Platform | Default data directory |
|
|
101
105
|
| --- | --- |
|
|
102
106
|
| Windows | `%USERPROFILE%/.browser-attach` |
|
|
103
107
|
| macOS / Linux | `${XDG_DATA_HOME:-~/.local/share}/browser-attach` |
|
|
104
108
|
|
|
105
|
-
|
|
109
|
+
Override it explicitly with the absolute path `BROWSER_ATTACH_HOME`. The Windows default avoids AppData, which packaged hosts may virtualize; no other host's private cache is searched or merged automatically. The directory holds the pairing configuration, Hub logs and the stable extension copy, and depends on neither the clone location, the client working directory nor the npm cache. If you already use a custom directory, keep using the same environment variable or `--config` instead of accidentally creating a second pairing under the new default.
|
|
106
110
|
|
|
107
|
-
|
|
111
|
+
A host assembly step that writes the client entry pins the absolute paths of the current Node, CLI and the actual pairing file and sets `BROWSER_ATTACH_HOME`; it works together with a custom `--config`. When the MCP launches the Hub it passes the same absolute pairing path and directory, so different launch environments cannot derive a second configuration. See [agent integration](docs/AGENT-INTEGRATION.md).
|
|
108
112
|
|
|
109
113
|
```sh
|
|
110
114
|
browser-attach status
|
|
@@ -113,27 +117,27 @@ npm install --global @evan_zhong/browser-attach
|
|
|
113
117
|
browser-attach setup
|
|
114
118
|
```
|
|
115
119
|
|
|
116
|
-
|
|
120
|
+
After upgrading, reload the extension on the extensions page, then restart the MCP connection. Hub and extension versions must match. The first time you use this project's new name, stop any previously running Hub and pair again; the default port is `47831` and `setup --port` changes it.
|
|
117
121
|
|
|
118
|
-
##
|
|
122
|
+
## Architecture and boundaries
|
|
119
123
|
|
|
120
124
|
```text
|
|
121
|
-
MCP
|
|
122
|
-
↕
|
|
123
|
-
Chrome / Edge MV3
|
|
125
|
+
MCP client → Node.js stdio MCP → 127.0.0.1 Hub
|
|
126
|
+
↕ local pairing
|
|
127
|
+
Chrome / Edge MV3 extension
|
|
124
128
|
↓ chrome.debugger / CDP
|
|
125
|
-
|
|
129
|
+
existing HTTP(S) tabs
|
|
126
130
|
```
|
|
127
131
|
|
|
128
|
-
|
|
132
|
+
Each MCP connection holds its own control and handles cannot be used across connections. Disconnecting or stopping debugging releases control. The client endpoint requires a Bearer key and rejects web page Origins; the extension verifies a fixed Origin, key and version. `browser_evaluate` can read and write data with the page's existing sign-in state; after it runs, snapshot pagination expires while refs to the original nodes still resolve; a timeout or error may already have had an effect and must not be replayed automatically. No generic CDP tool is exposed.
|
|
129
133
|
|
|
130
|
-
|
|
134
|
+
Logs are collected from claim time, survive navigation and are cleared on release; each kind keeps at most 500 entries and 1,048,576 JSON characters, and eviction reports `data_loss`. Network logs store lifecycle metadata only by default; an explicit capture reads bounded, redacted headers/bodies of subsequent XHR/fetch requests without changing caches or intercepting traffic. Full parameters and limits: [usage guide](docs/USAGE.md#诊断日志).
|
|
131
135
|
|
|
132
|
-
|
|
136
|
+
The extension holds debugger permission and client actions can use existing site sign-in state; only connect clients you trust. Web page content must not be treated as user authorization. The pairing key and the generated `config.generated.js` must not be committed or published; the packaging check rejects packages that contain that file.
|
|
133
137
|
|
|
134
|
-
|
|
138
|
+
Only HTTP(S) pages in Chrome / Edge are supported today. Download management, network interception and Firefox / WebKit are not supported. Cross-process iframes use recursive session routing covering snapshots, input, uploads and logs; clicks in perspective-transformed iframes are refused explicitly. Native pointer input requires an active, unsuspended tab in a non-minimized window, and focus and document visibility are diagnostic only; when the tab is not ready it returns without dispatch and suggests tab_control(action=activate) instead of stealing focus. Background screenshots may time out because the browser pauses rendering; native menus and tab drags may also suppress input.
|
|
135
139
|
|
|
136
|
-
##
|
|
140
|
+
## Local development
|
|
137
141
|
|
|
138
142
|
```sh
|
|
139
143
|
git clone https://github.com/EvannZhongg/BrowserAttach.git
|
|
@@ -143,16 +147,16 @@ npm test
|
|
|
143
147
|
npm run test:package
|
|
144
148
|
```
|
|
145
149
|
|
|
146
|
-
|
|
150
|
+
Run the source directly and pair the extension in the repository:
|
|
147
151
|
|
|
148
152
|
```sh
|
|
149
153
|
node bin/browser-attach.mjs setup --extension-dir ./extension
|
|
150
154
|
node bin/browser-attach.mjs mcp
|
|
151
155
|
```
|
|
152
156
|
|
|
153
|
-
|
|
157
|
+
Development mode deliberately uses the repository extension path. For normal installs, use `setup` without `--extension-dir` so it keeps working after the repository moves. On Windows you can also run `./scripts/install.ps1`.
|
|
154
158
|
|
|
155
|
-
|
|
159
|
+
Real-browser tests need a Chromium / Edge executable that allows loading extensions from the command line; the path is supplied by the developer:
|
|
156
160
|
|
|
157
161
|
```sh
|
|
158
162
|
npm run test:live -- --executable /path/to/chromium --family chromium
|
|
@@ -160,10 +164,10 @@ npm run test:interaction -- --executable /path/to/chromium --family chromium
|
|
|
160
164
|
npm run test:interaction -- --tab-indicators --executable /path/to/chromium --family chromium
|
|
161
165
|
```
|
|
162
166
|
|
|
163
|
-
|
|
167
|
+
These tests use temporary profiles and local fixtures. Coverage and limits: [test report](docs/TEST-REPORT.md); implementation and design trade-offs: [architecture](docs/ARCHITECTURE.md).
|
|
164
168
|
|
|
165
|
-
##
|
|
169
|
+
## Contributing
|
|
166
170
|
|
|
167
|
-
|
|
171
|
+
Reproducible issues via [Issues](https://github.com/EvannZhongg/BrowserAttach/issues), or Pull Requests. Please include Node, OS, browser and extension versions, and remove pairing keys and private page data. See [CONTRIBUTING.md](CONTRIBUTING.md) for the process.
|
|
168
172
|
|
|
169
|
-
|
|
173
|
+
Released under the [MIT License](LICENSE).
|
package/README.zh.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/EvannZhongg/BrowserAttach/main/extension/icons/BrowserAttach.png" width="112" alt="BrowserAttach logo">
|
|
3
|
+
</p>
|
|
4
|
+
|
|
5
|
+
# BrowserAttach
|
|
6
|
+
|
|
7
|
+
<p align="center">
|
|
8
|
+
<a href="README.md">English</a> · <strong>简体中文</strong>
|
|
9
|
+
</p>
|
|
10
|
+
|
|
11
|
+
让 MCP 客户端操作你已经打开、已经登录的 Chrome / Edge 标签页。BrowserAttach 通过本机配对扩展连接浏览器,保留现有登录状态,不需要启动远程调试端口或单独的自动化浏览器。
|
|
12
|
+
|
|
13
|
+
[npm](https://www.npmjs.com/package/@evan_zhong/browser-attach) · [使用指南](docs/USAGE.md) · [架构与设计](docs/ARCHITECTURE.md) · [Agent 集成](docs/AGENT-INTEGRATION.md) · [验证报告](docs/TEST-REPORT.md) · [问题反馈](https://github.com/EvannZhongg/BrowserAttach/issues) · [贡献指南](CONTRIBUTING.md)
|
|
14
|
+
|
|
15
|
+
## 能做什么
|
|
16
|
+
|
|
17
|
+
- 发现多个浏览器配置,精确选择并独占接管标签页,完成后释放。
|
|
18
|
+
- 读取可访问性树、字段值和支持的编辑器模型,通过当前快照引用或严格 CSS/XPath 定位点击、替换填写和逐字输入。
|
|
19
|
+
- 原生指针悬停、分步拖拽、条件等待;无需打开系统弹层即可选择 HTML 下拉选项。
|
|
20
|
+
- 导航与历史前进/后退、打开或关闭标签页,处理 JavaScript 对话框,上传本机文件并截取整页或单个元素。
|
|
21
|
+
- 在主页面执行 JavaScript 表达式,读取接管后的控制台、页面异常和网络生命周期日志;需要校验 XHR/fetch 时显式开启有界、脱敏的请求/响应详情。
|
|
22
|
+
- 使用标准 stdio MCP,共提供 22 个工具。Hub 仅监听本机 loopback,通过随机密钥配对扩展。
|
|
23
|
+
|
|
24
|
+
## 环境要求
|
|
25
|
+
|
|
26
|
+
- Node.js **22 或更新版本**,以及 npm。
|
|
27
|
+
- Chrome / Edge **125 或更新版本**,且允许加载未打包扩展。推荐使用最新稳定版。
|
|
28
|
+
- 支持 stdio MCP 的客户端,能从 `PATH` 找到 Node/npm。
|
|
29
|
+
|
|
30
|
+
## 快速开始
|
|
31
|
+
|
|
32
|
+
### 1. 安装与配对
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
npm install --global @evan_zhong/browser-attach
|
|
36
|
+
browser-attach setup
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`setup` 会输出稳定的扩展目录并启动本地 Hub。扩展目录通常是:
|
|
40
|
+
|
|
41
|
+
- Windows:`%USERPROFILE%\.browser-attach\extension`
|
|
42
|
+
- macOS / Linux:`~/.local/share/browser-attach/extension`
|
|
43
|
+
|
|
44
|
+
打开 `chrome://extensions` 或 `edge://extensions`,启用开发者模式,选择“加载已解压的扩展程序”,加载该目录。每个需要接入的浏览器配置分别加载一次;扩展选项可设置容易辨认的配置名称。
|
|
45
|
+
|
|
46
|
+
也可以不全局安装:
|
|
47
|
+
|
|
48
|
+
```sh
|
|
49
|
+
npx --yes --package=@evan_zhong/browser-attach@latest browser-attach setup
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### 2. 配置 MCP 客户端
|
|
53
|
+
|
|
54
|
+
将以下配置合并到客户端的 MCP 配置中,或参考 [mcp-config.json](mcp-config.json):
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"browser_attach": {
|
|
60
|
+
"command": "npx",
|
|
61
|
+
"args": ["--yes", "--package=@evan_zhong/browser-attach@latest", "browser-attach", "mcp"]
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局安装时,也可以配置 `"command": "browser-attach", "args": ["mcp"]`。重启 MCP 客户端后发现工具。
|
|
68
|
+
|
|
69
|
+
`@latest` 会在启动时解析 npm 最新版本。MCP 包与已加载扩展必须为同一版本;升级后重新运行 `browser-attach setup` 并在扩展页重新加载扩展,否则 Hub 会拒绝旧扩展连接。
|
|
70
|
+
|
|
71
|
+
各宿主的装配步骤见 [Agent 集成](docs/AGENT-INTEGRATION.md);其他客户端直接使用上面的 stdio 配置。
|
|
72
|
+
|
|
73
|
+
### 3. 开始操作
|
|
74
|
+
|
|
75
|
+
1. `browser_discover(kind="browsers")` → `browser_discover(kind="tabs")`,确认浏览器、标签页标题和 URL。
|
|
76
|
+
2. `browser_claim_tab`,用 `discover(kind="tabs")` 的 `tab_id` 接管(可选 `expected_url` / `expected_title` 精确校验),取得本连接的 `tab_handle`。
|
|
77
|
+
3. `browser_snapshot` 取得元素 `ref`,再执行点击、填写或其他动作。
|
|
78
|
+
4. 动作后重新观察,结束时调用 `browser_tab_control(action="release")`。
|
|
79
|
+
|
|
80
|
+
标题易变时,claim 可省略 `expected_title`;`expected_url` 也可选。点击/填写可改用每次重新解析的 `selector`。原生元素点击自动滚动;整页坐标点击使用 `coordinate_space: "document"`。求值结果使用 `cursor` 续读缓存,不会重新运行脚本。设计取舍见 [架构说明](docs/ARCHITECTURE.md#设计取舍)。
|
|
81
|
+
|
|
82
|
+
接管和普通输入不会主动激活标签或窗口;需要前台渲染时显式调用 `browser_tab_control(action="activate")`。操作结果未知时先重新观察,避免重复提交。完整参数、分页、上传与输入语义见 [使用指南](docs/USAGE.md)。
|
|
83
|
+
|
|
84
|
+
## 工具
|
|
85
|
+
|
|
86
|
+
| 工具 | 用途 |
|
|
87
|
+
| --- | --- |
|
|
88
|
+
| `browser_discover` | `kind=browsers/tabs`,发现浏览器配置、标签身份及控制状态 |
|
|
89
|
+
| `browser_claim_tab` | 独占接管已发现的标签 |
|
|
90
|
+
| `browser_open_tab` / `browser_tab_control` | 创建标签;显式 `release/activate/close` 释放控制、激活或关闭已接管的标签 |
|
|
91
|
+
| `browser_snapshot` / `browser_read_text` | AX 快照、元素引用和正文 |
|
|
92
|
+
| `browser_wait` | 只读等待元素、文本、URL 或网络空闲 |
|
|
93
|
+
| `browser_click` / `browser_hover` / `browser_drag` / `browser_select_option` / `browser_type` / `browser_fill` / `browser_press_key` / `browser_scroll` | 原生/DOM 输入、指针悬停与拖拽、下拉选择、逐字输入与滚动 |
|
|
94
|
+
| `browser_batch` | 单队列顺序执行 1–20 步 click/fill/type/press_key/hover/scroll/select_option/wait,失败即停并返回部分结果 |
|
|
95
|
+
| `browser_navigate` | HTTP(S) 导航与加载等待 |
|
|
96
|
+
| `browser_handle_dialog` | alert、confirm、prompt |
|
|
97
|
+
| `browser_upload_file` | 原生文件 input 或拦截的文件选择器 |
|
|
98
|
+
| `browser_screenshot` | 视口或整页 PNG,附坐标映射 |
|
|
99
|
+
| `browser_evaluate` | 主页面 JS 求值、Promise 等待、有界结果 |
|
|
100
|
+
| `browser_logs` | 用 `kind=console/network` 选择控制台、异常或请求日志,支持过滤和游标续读 |
|
|
101
|
+
|
|
102
|
+
## 本机数据与升级
|
|
103
|
+
|
|
104
|
+
| 平台 | 默认数据目录 |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| Windows | `%USERPROFILE%/.browser-attach` |
|
|
107
|
+
| macOS / Linux | `${XDG_DATA_HOME:-~/.local/share}/browser-attach` |
|
|
108
|
+
|
|
109
|
+
可通过绝对路径 `BROWSER_ATTACH_HOME` 显式覆盖。Windows 默认避开可能被打包宿主虚拟化的 AppData;不会搜索或自动合并其他宿主的私有缓存。目录包含配对配置、Hub 日志及稳定的扩展副本;不依赖 clone 位置、客户端工作目录或 npm 缓存。已有自定义目录请继续使用相同环境变量或 `--config`,不要在新默认目录意外创建另一套配对。
|
|
110
|
+
|
|
111
|
+
宿主装配步骤写入客户端条目时,会固定当前 Node、CLI 和实际配对文件的绝对路径,并设置 MCP 的 `BROWSER_ATTACH_HOME`;支持与自定义 `--config` 一起使用。MCP 启动 Hub 时继续传入同一绝对配对路径及目录,避免不同启动环境重新推导出另一份配置。详见 [Agent 集成](docs/AGENT-INTEGRATION.md)。
|
|
112
|
+
|
|
113
|
+
```sh
|
|
114
|
+
browser-attach status
|
|
115
|
+
browser-attach stop
|
|
116
|
+
npm install --global @evan_zhong/browser-attach
|
|
117
|
+
browser-attach setup
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
升级后在浏览器扩展管理页重新加载扩展,再重启 MCP 连接。Hub 与扩展版本必须一致。首次使用本项目的新名称时,应停止之前运行的 Hub 并重新配对;默认端口为 `47831`,可用 `setup --port` 修改。
|
|
121
|
+
|
|
122
|
+
## 架构与使用边界
|
|
123
|
+
|
|
124
|
+
```text
|
|
125
|
+
MCP 客户端 → Node.js stdio MCP → 127.0.0.1 Hub
|
|
126
|
+
↕ 本机配对
|
|
127
|
+
Chrome / Edge MV3 扩展
|
|
128
|
+
↓ chrome.debugger / CDP
|
|
129
|
+
现有 HTTP(S) 标签页
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
每个 MCP 连接拥有独立控制权,句柄不能跨连接使用。断连或停止调试会释放控制。客户端端点要求 Bearer 密钥并拒绝网页 Origin;扩展验证固定 Origin、密钥和版本。`browser_evaluate` 可以使用页面现有登录态读写数据,执行后快照分页失效,原节点 refs 可继续解析;超时或报错前可能已产生作用,不应自动重放。未暴露通用 CDP 工具。
|
|
133
|
+
|
|
134
|
+
日志从接管开始采集,跨导航保留,释放时清除;每类最多保留 500 条、1,048,576 个 JSON 字符,超限淘汰会报告 `data_loss`。网络日志默认只记录生命周期元数据;显式 capture 才读取之后的 XHR/fetch 有界、脱敏 headers/正文,不改变缓存或拦截流量。完整参数及边界见[使用指南](docs/USAGE.md#诊断日志)。
|
|
135
|
+
|
|
136
|
+
浏览器扩展具有调试权限,客户端操作可使用现有网站登录态;只应将其接入你信任的 MCP 客户端。网页内容不应被视为用户授权。配对密钥和生成的 `config.generated.js` 不应提交或发布,打包检查会拒绝包含该文件的包。
|
|
137
|
+
|
|
138
|
+
目前仅支持 Chrome / Edge 的 HTTP(S) 页面。下载管理、网络拦截和 Firefox / WebKit 尚不支持。跨进程 iframe 使用递归 session 路由,覆盖快照、输入、上传和日志;透视变换的 iframe 点击会明确拒绝。原生指针输入要求活动且未暂停的标签及未最小化窗口,焦点和文档可见性只作诊断;不就绪时快速返回未派发及 tab_control(action=activate) 提示;不会自动抢焦点。后台截图可能因浏览器暂停渲染而超时;原生菜单和标签拖动也可能抑制输入。
|
|
139
|
+
|
|
140
|
+
## 本地开发
|
|
141
|
+
|
|
142
|
+
```sh
|
|
143
|
+
git clone https://github.com/EvannZhongg/BrowserAttach.git
|
|
144
|
+
cd BrowserAttach
|
|
145
|
+
npm ci
|
|
146
|
+
npm test
|
|
147
|
+
npm run test:package
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
直接运行源码并配对仓库中的扩展:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
node bin/browser-attach.mjs setup --extension-dir ./extension
|
|
154
|
+
node bin/browser-attach.mjs mcp
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
开发模式有意使用仓库扩展路径。一般安装请使用不带 `--extension-dir` 的 setup,确保移动仓库后仍可运行。Windows 也可运行 `./scripts/install.ps1`。
|
|
158
|
+
|
|
159
|
+
真实浏览器测试需要一个允许命令行加载扩展的 Chromium / Edge 可执行文件,路径由开发者传入:
|
|
160
|
+
|
|
161
|
+
```sh
|
|
162
|
+
npm run test:live -- --executable /path/to/chromium --family chromium
|
|
163
|
+
npm run test:interaction -- --executable /path/to/chromium --family chromium
|
|
164
|
+
npm run test:interaction -- --tab-indicators --executable /path/to/chromium --family chromium
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
这些测试使用临时配置和本地 fixture。测试覆盖与限制见 [验证报告](docs/TEST-REPORT.md),实现与设计取舍见 [架构与设计](docs/ARCHITECTURE.md)。
|
|
168
|
+
|
|
169
|
+
## 参与贡献
|
|
170
|
+
|
|
171
|
+
欢迎通过 [Issues](https://github.com/EvannZhongg/BrowserAttach/issues) 提交可复现问题,或提交 Pull Request。请附上 Node、系统、浏览器与扩展版本,移除配对密钥和网页隐私数据。具体流程见 [CONTRIBUTING.md](CONTRIBUTING.md)。
|
|
172
|
+
|
|
173
|
+
本项目采用 [MIT License](LICENSE)。
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# Agent 集成
|
|
2
|
+
|
|
3
|
+
BrowserAttach 是标准 stdio MCP,共 22 个工具;任何能启动子进程并发现 MCP 工具的 Agent 宿主都可以接入。本文按宿主给出装配步骤;配对与路径要求先读「通用装配」。
|
|
4
|
+
|
|
5
|
+
| 宿主 | 装配方式 | 配置位置 |
|
|
6
|
+
| --- | --- | --- |
|
|
7
|
+
| Nosis | `browser-attach setup --nosis` 自动写入 | `~/.nosis/agent_config.json` |
|
|
8
|
+
| DeepSeek Harness | 手动追加 patch 条目 | `$DSH_HOME/profiles/<name>/cordis.patch.yml` |
|
|
9
|
+
|
|
10
|
+
## 通用装配
|
|
11
|
+
|
|
12
|
+
先完成配对:
|
|
13
|
+
|
|
14
|
+
```sh
|
|
15
|
+
npm install --global @evan_zhong/browser-attach
|
|
16
|
+
browser-attach setup
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
`setup` 打印稳定的扩展目录并启动本机 Hub。按 [README](../README.md) 第 1 步在 `chrome://extensions` 或 `edge://extensions` 加载该目录;每个要暴露的浏览器 profile 各加载一次。
|
|
20
|
+
|
|
21
|
+
宿主侧只需启动 stdio 进程:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
node <安装位置>/bin/browser-attach.mjs mcp --config <配对文件绝对路径>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
- `BROWSER_ATTACH_HOME` 指向配对文件所在目录。
|
|
28
|
+
- Node 可执行文件、CLI 与配对文件一律使用绝对路径。宿主的启动环境(PATH、工作目录、AppData 重定向)不同,重新推导会得到第二份配对。
|
|
29
|
+
- 安装位置变化或升级后重新执行 `setup` 并更新宿主条目。MCP 包与已加载扩展必须同版本,否则 Hub 直接拒绝连接。
|
|
30
|
+
|
|
31
|
+
工具前缀由宿主决定,原始工具名不变(`browser_discover`、`browser_claim_tab` 等)。装配后的最小验证在各宿主一致:`browser_discover(kind="browsers")` → `kind="tabs"` → `browser_claim_tab` → `browser_snapshot` → `browser_tab_control(action="release")`。
|
|
32
|
+
|
|
33
|
+
## Nosis
|
|
34
|
+
|
|
35
|
+
`setup --nosis` 要求 `~/.nosis/agent_config.json` 已存在(Nosis 已初始化)。该命令在完成普通配对后备份并更新此文件(备份名 `agent_config.json.browser-attach-<时间戳>.bak`),写入:
|
|
36
|
+
|
|
37
|
+
| 字段 | 值 |
|
|
38
|
+
|---|---|
|
|
39
|
+
| 服务器名 | `browser_attach` |
|
|
40
|
+
| `command` | 执行 setup 的 Node 可执行文件绝对路径 |
|
|
41
|
+
| `args` | 当前包 `bin/browser-attach.mjs` 绝对路径、`mcp`、`--config <绝对配对路径>` |
|
|
42
|
+
| `env.BROWSER_ATTACH_HOME` | 配对文件所在目录 |
|
|
43
|
+
| `startup_timeout_seconds` / `call_timeout_seconds` | `20` / `60` |
|
|
44
|
+
| `tools.approval` | `always` |
|
|
45
|
+
|
|
46
|
+
同时设置 `mcp.enabled = true`。命令与配对路径都固定为绝对路径,因此本机安装位置变化后需要重新执行 `setup --nosis`;请从长期保留的位置安装,而不是临时解压目录。重启或重建 Nosis Runtime 后重新发现工具。
|
|
47
|
+
|
|
48
|
+
自定义运行目录:
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
browser-attach setup --config /absolute/shared-directory/config.json --nosis
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
`setup` 会把解析后的配对路径写进配置,MCP 与 Hub 后续都显式传递同一路径,不会在不同启动环境中重新推导出第二套密钥。`setup` 不会搜索或自动迁移其他应用的私有缓存;已有配对请显式保留原目录。
|
|
55
|
+
|
|
56
|
+
Windows 安装脚本可直接带该开关:`./scripts/install.ps1 -ConfigureNosis`。
|
|
57
|
+
|
|
58
|
+
验证:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
python scripts/verify-nosis.py --nosis-root /path/to/Nosis --family chrome
|
|
62
|
+
python scripts/verify-nosis.py --nosis-root /path/to/Nosis --family edge
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
脚本读取本机 Nosis 配置,通过 Nosis ToolCatalog 发现 22 个工具,覆盖发现、标签控制、页面操作和结果封装,其中 `browser_logs` 分别覆盖 console/network,验证结果封装、截图到模型消息的图片适配以及 `isError` 后连接仍可用。它用 `browser_open_tab` 在选定配置中创建本地 fixture;同一 family 有多个 profile 时用 `--browser-id <精确 browser_id>` 指定,结束后释放控制。Python 仅用于该测试,不是运行依赖。
|
|
66
|
+
|
|
67
|
+
## DeepSeek Harness
|
|
68
|
+
|
|
69
|
+
DSH 通过 `@deepseek-ai/dsh-mcp-client` 条目接入 stdio MCP:一个服务器一个条目,工具暴露为 `mcp__<serverName>__<工具名>`。客户端插件随 harness 提供,不需要为 BrowserAttach 额外安装包。
|
|
70
|
+
|
|
71
|
+
把条目追加到 patch 层:
|
|
72
|
+
|
|
73
|
+
- 单个 profile:`$DSH_HOME/profiles/<name>/cordis.patch.yml`;`dsh web` 对应 `web`。
|
|
74
|
+
- 本机所有 profile:`$DSH_HOME/cordis.patch.yml`。
|
|
75
|
+
- 临时试用:任意 patch 文件,用 `dsh web --patch ./browser-attach.cordis.yml` 叠加。
|
|
76
|
+
|
|
77
|
+
`$DSH_HOME` 未设置时为 `~/.dsh`。patch 文件是 patch 列表,直接追加 `insert` 条目,不要覆盖其中已有的用户 patch。
|
|
78
|
+
|
|
79
|
+
```yaml
|
|
80
|
+
- insert:
|
|
81
|
+
- id: browser-attach
|
|
82
|
+
name: '@deepseek-ai/dsh-mcp-client'
|
|
83
|
+
config:
|
|
84
|
+
serverName: browser_attach
|
|
85
|
+
transport: stdio
|
|
86
|
+
command: 'C:\Program Files\nodejs\node.exe'
|
|
87
|
+
args:
|
|
88
|
+
- 'C:\Users\<用户>\AppData\Roaming\npm\node_modules\@evan_zhong\browser-attach\bin\browser-attach.mjs'
|
|
89
|
+
- mcp
|
|
90
|
+
- --config
|
|
91
|
+
- 'C:\Users\<用户>\.browser-attach\config.json'
|
|
92
|
+
env:
|
|
93
|
+
BROWSER_ATTACH_HOME: 'C:\Users\<用户>\.browser-attach'
|
|
94
|
+
toolCallTimeoutMs: 60000
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
三个路径按本机实际值填写:
|
|
98
|
+
|
|
99
|
+
```sh
|
|
100
|
+
where node # Windows;macOS / Linux 用 which node
|
|
101
|
+
npm root -g # 全局安装目录;CLI 位于 <root>/@evan_zhong/browser-attach/bin/browser-attach.mjs
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
开发检出可直接指向 `<仓库>/bin/browser-attach.mjs`。macOS / Linux 把示例中的 Windows 路径换成对应的绝对路径,字段不变。
|
|
105
|
+
|
|
106
|
+
- 子进程启动前,DSH 会删除名字匹配 `KEY|PASSWORD|SECRET|TOKEN` 的环境变量以及全部 `DSH_*`,其余环境变量继承;显式写在 `env` 里的值保留。配对路径已由 `--config` 固定,不依赖工作目录。
|
|
107
|
+
- `toolCallTimeoutMs` 默认 `60000`,与 BrowserAttach 推荐的调用超时一致;单个调用确实更慢时再调大。
|
|
108
|
+
- `failOnStartupError` 默认 `false`:连接失败只记日志、工具不出现,harness 照常启动。排查连接问题时设为 `true` 可让启动失败直接暴露。
|
|
109
|
+
- 工具发现是异步的;启动后等 `mcp__browser_attach__browser_discover` 出现再发第一条指令。
|
|
110
|
+
- `web` profile 开启 HMR,修改条目会就地重连;`headless`、`sdk`、`acp` 关闭 HMR,需要重启 harness。
|
|
111
|
+
- MCP 工具与原生工具一样经过 DSH 的权限检查与审批策略。
|
|
112
|
+
|
|
113
|
+
升级后重新加载扩展并重启 harness;包与扩展版本必须一致。
|
|
114
|
+
|
|
115
|
+
## 新增宿主
|
|
116
|
+
|
|
117
|
+
追加小节时至少覆盖:配置位置与可粘贴片段、启动命令的三个绝对路径(Node、CLI、配对文件)、`BROWSER_ATTACH_HOME`、调用与启动超时、审批方式、工具前缀、验证步骤,以及升级后如何重新连接。配对与安全边界只在「通用装配」维护,不逐宿主重复。
|
|
118
|
+
|
|
119
|
+
## 故障排查
|
|
120
|
+
|
|
121
|
+
- 工具结果走 MCP 文本内容,截图走标准图片内容;能否进入模型取决于宿主的图片适配、大小预算与模型能力。
|
|
122
|
+
- 失效句柄不能跨连接复用,错误后应重新 discover + claim。
|
|
123
|
+
- 文件上传只在浏览器本机读取路径,内容不经过 Hub 或模型。
|
|
124
|
+
- 宿主把大结果保存为 Artifact 时,嵌套 JSON 长行可能被宿主的读取工具截断;这是宿主的分页行为,不能靠调大 Attach 的预算解决,也不等同于快照游标分页。
|
|
125
|
+
- `Connection closed` 时依次检查:宿主的 stderr、配对文件是否可见、`hub.log`、以及 Hub 与扩展的版本是否一致(版本不一致会直接拒绝连接)。
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.12.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -57,6 +57,7 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
|
|
|
57
57
|
## 观察模型
|
|
58
58
|
|
|
59
59
|
- **AX 渲染**:从 `Accessibility.getFullAXTree` 读取,移除 `InlineTextBox`,合并纯文本列表项与段落名称,丢弃 `generic` / `none` 且无名称无状态的节点,只保留少量状态属性和浏览器提供的 value、invalid。每行 `[token:n] role "name"`,`token` 是本次观察的随机前缀;这些字段不证明应用已保存。
|
|
60
|
+
- **交互视图**:可选 `profile=interactive` 在同一渲染器中仅保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内文本,不以 backendNodeId/ref 是否存在判断交互性。独立文本和合并正文可隐藏或截取预览,控件名称、值和 ref 的原始 AX 身份保留;默认 `full` 行为不变。筛选仍读取完整 AX 树,只降低输出与分页成本;视图参数绑定分页观察。
|
|
60
61
|
- **ref 解析**:ref 保存 `backendNodeId`、所属 frame/session 和观察时的 AX role/name;动作时重新 `DOM.resolveNode` 并用局部 AX 查询核对语义身份。输入与求值不清空 refs,只使快照分页失效;原节点仍连接时可复用,节点替换、role/name 改变、文档或 frame 变化时安全失败,不按 role/name 猜测替代节点。
|
|
61
62
|
- **双预算**:`max_nodes` 限制节点数,`max_chars` 限制每行序列化后的累计字符数;两者独立,任一耗尽都会返回 `next_cursor`。
|
|
62
63
|
- **续读**:续读沿用同一份 `state`(`entries`、`refs`、`nextOffset`),所以 refs 在分页过程中保持有效。单个节点放不进预算时直接报错要求提高预算,避免静默截断尾部。
|
|
@@ -88,7 +89,7 @@ claim 与普通输入都不激活标签或窗口,也不移动系统鼠标;
|
|
|
88
89
|
- **OOPIF 坐标**:点击从顶层派发。逐层用 `DOM.getBoxModel` 把坐标换算到祖先文档,并用 `DOM.getNodeForLocation` 确认该位置确实落在 frame owner 上,防止祖先页面的遮挡被跳过。
|
|
89
90
|
- **DOM 模式**:`mode: "dom"` 是显式的、受控的降级路径,用于原生菜单或标签拖动暂时捕获输入时操作普通 HTML 控件。它调用 `HTMLElement.click()` 或原生 value setter,事件非可信、无用户激活,且绝不会在原生失败后自动启用。
|
|
90
91
|
- **对话框策略**:ask 仍为默认;单操作/单步骤的显式 accept/dismiss 在事件回调中解除 JS 模态阻塞,保留消息与处理结果,最多自动处理 10 个。beforeunload dismiss 快速取消本次导航等待,实际主文档变化仍会停止 batch。
|
|
91
|
-
- **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver,仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM
|
|
92
|
+
- **未知结果探测**:在可能写入的操作准备期安装目标 frame 的临时 MutationObserver,仅在不确定失败后做一次有界只读探测,回传对话框、导航、DOM 变动。assessment 为 observable_effects 或 no_observable_effects;观察不到变化不等于没有派发,不将证据升级成业务成功,不自动重放。
|
|
92
93
|
- **元素截图/求值 ref**:复用既有截图 clip/元数据与 ref 注册/失效模型,分别增加元素 border box 截图和显式单 DOM Element 结果;不暴露 Runtime objectId。JSON 求值自动调用函数一次并等待 Promise,缓存续读不执行脚本。
|
|
93
94
|
- **历史导航**:在已有 navigate 中选择 URL 或 back/forward,按 history entry 与主 frame 生命周期等待;同文档/BFCache 恢复不等新的加载事件,不用 evaluate 重放导航。
|
|
94
95
|
- **不做通用 CDP 透传**:`browser_evaluate` 是页面表达式 API,不是 CDP 通道;没有暴露任意 domain/method 的工具。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.12.0**(2026-10-22)。验证使用本地 fixture 和临时 profile,链路为扩展 → 本机 Hub → stdio MCP。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
-
- `npm test`:
|
|
8
|
-
- `npm run test:package`:仓库外含空格路径安装运行通过,22 个 MCP 工具、
|
|
7
|
+
- `npm test`:149 项通过。覆盖 interactive 快照的控件/祖先筛选、文本预览与原始 ref 身份、视图绑定分页、独占控制、快照/ref 生命周期、严格 selector 与有界歧义候选、原生/DOM 输入、逐字输入、hover、pointer/HTML5 拖拽、document 拖拽坐标差值与按轴滚动、滚动边界后的延迟加载、条件等待、HTML select、batch、历史导航、OOPIF、日志、上传、截图、对话框和未知结果不重放。
|
|
8
|
+
- `npm run test:package`:仓库外含空格路径安装运行通过,22 个 MCP 工具、45 个发布文件;包含 `drag.js` / `wait.js` / `scroll.js` / `outcomes.js`,不包含配对密钥。版本为 0.12.0,示例配置使用 `@latest`。
|
|
9
|
+
- Chromium headless `--primitives`:16 个场景通过,包含 interactive 快照、document 坐标纵向 pointer/HTML5 拖拽、普通 pointer/HTML5 拖拽、取消 pointerdown 默认行为后的可信回执、滚动边界后延迟增长和歧义候选。含 200 段正文的 fixture 中,默认 full 首屏 23302 字符且仍需续页,interactive 为 947 字符且无需续页;该比较只衡量返回内容,不代表 AX 采集提速。
|
|
10
|
+
|
|
11
|
+
## 升级前的浏览器验证
|
|
12
|
+
|
|
9
13
|
- Edge 155 headless `--primitives`:10 个场景通过,包含 hover 停留后点击子项、一次快照的 ref 连续输入并跨只读 evaluate 保持有效、替换节点拒绝、label/value/index 下拉选择、三种等待失败、pointer/HTML5 拖拽和目的元素滚入视口。
|
|
10
14
|
- Edge 155 完整 stdio MCP 集成:192 个调用通过,包含嵌套 OOPIF、原生/DOM 输入、遮挡、上传、日志、按键、滚动与导航;扩展报告 `extension_version=0.10.0`。
|
|
11
15
|
- Chromium 156、Edge 155 headless `--extended`:各 12 个场景通过,包含 ref 生命周期、选择框、拖拽、等待、历史导航和 OOPIF。
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.12.0**:Node.js 22+、Chrome / Edge 125+、22 个 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` | `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
|
|
38
|
+
| `browser_snapshot` | `profile`, `text`, `text_limit`, `max_nodes=600`, `max_chars=24000`, `cursor` | AX 语义树、元素 ref、frame 覆盖、待处理文件选择器 |
|
|
39
39
|
| `browser_wait` | `condition`, `selector`, `expected` / `previous`, `idle_ms=500`, `timeout_ms=5000` | 只读等待元素、文本、URL 或被动网络空闲 |
|
|
40
40
|
| `browser_read_text` | `mode`, `selector`, `offset=0`, `max_chars=20000` | 主 frame 的 DOM、字段值或支持的编辑器模型 |
|
|
41
41
|
|
|
@@ -72,7 +72,11 @@
|
|
|
72
72
|
|
|
73
73
|
- 渲染时移除 `InlineTextBox`,合并纯文本列表项与段落名称,保留 `disabled`、`focused`、`expanded`、`checked`、`selected`、`required`、`invalid`、`level` 等状态,以及浏览器提供的 `value`;`[token:n]` 即后续动作使用的 `ref`。未提供 invalid/selected 等属性不代表有效或已提交;value 也可能只是下拉框的过滤文本。
|
|
74
74
|
- `max_nodes` 为 1–3000(默认 600);`max_chars` 为 1–2000000(默认 24000),按行序列化长度累计,与节点数是两个独立预算。
|
|
75
|
+
- `profile` 默认为 `full`。`interactive` 保留 AX 控件角色、可聚焦/可编辑节点及其祖先链和控件内的文本,略去无关正文;禁用控件仍保留。仅按是否有 ref 筛选无法有效压缩,因为段落、列表项等也可能有 ref。自定义控件未提供可访问角色或可聚焦/可编辑属性时可能被省略,需用 `full` 观察。
|
|
76
|
+
- `text` 默认 `true`;`false` 隐去独立文本和纯文本段落/列表项的合并正文,仍保留可访问名称及控件值。`text_limit` 为 0–10000,限制每个文本/合并正文名称并以 `…` 标明省略;`interactive` 默认 80,`full` 默认不截断。需要更多正文时重新请求 `full`,或显式提高 `text_limit`。控件名称、值及 ref 校验所用身份始终完整。
|
|
77
|
+
- 返回 `profile` / `text` / `text_limit` 表明本次视图;`node_count` 和 `total_node_count` 统计筛选后的节点。`truncated` / `next_cursor` 只表示该视图尚有分页,不表示视图包含全部网页正文。筛选发生在完整 AX 树读取后,减少返回内容与分页次数,不减少 CDP 的 AX 树采集量。
|
|
75
78
|
- `truncated: true` 时把 `next_cursor` 传回同一个工具继续读取同一份观察,refs 在续读期间保持有效;也可以调整预算。单个超长节点放不下时会明确报出所需预算,不静默丢尾部。
|
|
79
|
+
- 续读可省略视图参数,沿用原 `profile` / `text` / `text_limit`;显式提供时必须与原观察相同。调整视图须请求新快照,并按下表使旧 ref 失效。
|
|
76
80
|
- 覆盖主 frame、同进程 iframe 与递归 OOPIF;读取失败、读取期间变化或附着失败的 frame 列入 `omitted_frames`。
|
|
77
81
|
|
|
78
82
|
失效规则:
|
|
@@ -130,7 +134,7 @@ ref 每次用 frame/session + backendNodeId 重新解析,并以局部 AX 查
|
|
|
130
134
|
|
|
131
135
|
hint 仅为 HTML/ARIA 启发式判断,false 表示未找到候选,不能证明元素不可点击,也不能覆盖所有事件代理。它不会替换真正的鼠标命中节点或改变派发目标。坐标点击无法推断调用者的业务意图,DOM/网络变化也不能证明业务成功,需按具体任务观察。
|
|
132
136
|
|
|
133
|
-
`selector` 可复用,但不是永久元素身份。输入准备阶段会等待匹配出现,受同一 timeout_ms 限制;省略 nth
|
|
137
|
+
`selector` 可复用,但不是永久元素身份。输入准备阶段会等待匹配出现,受同一 timeout_ms 限制;省略 nth 时多个匹配立即报错,附带前 10 个候选的 nth、tag、最多 120 字符的 DOM 文本、disabled 和 visible,超出部分标明省略数量。显式 nth 按当前文档顺序选择,尚未出现该索引时等待;行顺序变化后须重新确认目标。解析后元素被替换会安全失败,不自动重放。CSS/XPath 不穿透 Shadow DOM,那里继续使用 AX ref;iframe 用明确 `frame_id`。例如 `browser_fill({tab_handle, selector:'input[placeholder="公司名称"]', nth:1, text:"第二段经历", mode:"native"})`;XPath 同样支持 nth。
|
|
134
138
|
|
|
135
139
|
坐标命中 CSS 伪元素时,以其 originating element 做描述和事件校验,`hit_target.pseudo_element=true`。截图坐标仍按截图元数据换算;优先用 ref/selector 避免布局漂移。
|
|
136
140
|
|
|
@@ -150,11 +154,13 @@ hover 接受与原生 click 相同的目标,执行一次 mouseMoved,不派
|
|
|
150
154
|
|
|
151
155
|
drag 的 source / destination 各自接受 ref、严格 selector(可带 frame_id/nth)或 x/y(可带 coordinate_space)。mode 必须按页面交互选择,默认 pointer;steps 默认 20,范围 2–100,每步间隔约 16 ms。
|
|
152
156
|
|
|
157
|
+
两端均为 document 坐标且能同时容纳于一个顶层视口时,在按下前统一滚动位置,保留两端的 x/y 差值。已在视口内的 document 坐标不重复居中;需要滚动时只调整越界的轴。跨屏目标仍在按住后滚入视口,因此返回的 from/to 是各自派发时的视口坐标,不能用两者之差推断跨屏的文档距离。
|
|
158
|
+
|
|
153
159
|
```json
|
|
154
160
|
{"tab_handle":"...","source":{"selector":"#card"},"destination":{"selector":"#column"},"mode":"html5","steps":20}
|
|
155
161
|
```
|
|
156
162
|
|
|
157
|
-
pointer 发送 mousePressed → 多步 mouseMoved → mouseReleased
|
|
163
|
+
pointer 发送 mousePressed → 多步 mouseMoved → mouseReleased,核验可信 pointerdown/pointermove/pointerup,因此控件取消 pointerdown 的默认行为、抑制兼容鼠标事件时仍可校验送达;HTML5 模式先通过真实按下/移动触发 dragstart 并截获其 DragData,再派发 dragEnter/dragOver/drop,核验可信 dragstart/drop/dragend。不会凭空构造拖拽数据,不自动切换模式。目的元素在按住后调用已有滚入视口逻辑,包含嵌套容器,然后重新命中检查;这是目标驱动的滚动,不是持续的视口边缘滚动任务。固定坐标始终受视口边界约束,视口外坐标用 document 或改用元素目标。
|
|
158
164
|
|
|
159
165
|
成功返回 events_trusted=true、delivery_verified=true、application_result=not_checked;不保证应用已保存排序。任一步失败、主文档/相关 frame 变化或接管结束即停止,尝试取消/释放本次手势;不回滚、不重放。HTML5 CDP 路径仍受浏览器和站点原生拖拽支持限制。
|
|
160
166
|
|
|
@@ -244,7 +250,7 @@ timeout_ms 默认 15000,范围 100–30000,是整批准备、派发和校验
|
|
|
244
250
|
{"tab_handle":"...","selector":"#list","until":{"text":"Row 0487","selector":"button"},"delta_y":200,"max_steps":100,"timeout_ms":15000}
|
|
245
251
|
```
|
|
246
252
|
|
|
247
|
-
until 是显式 DOM 滚动搜索,不与 wheel 组合;省略 delta_y 时每次滚约 0.8 个容器高度。max_steps 默认 100、范围 1–500
|
|
253
|
+
until 是显式 DOM 滚动搜索,不与 wheel 组合;省略 delta_y 时每次滚约 0.8 个容器高度。max_steps 默认 100、范围 1–500,只计算实际 scrollBy 次数。搜索容器内渲染且有尺寸的最深匹配元素,文本为子串;重复匹配报错,使用 until.selector 消歧。滚动无位移时继续只读查找并观察容器位置、内容尺寸与视口尺寸,状态变化后才继续滚动;边界本身不证明内容已加载完毕。导航、相关 frame 变化、步数用尽或总预算超时即停止,不自动点击;返回 found/ref/frame_id/scroll_steps。未找到只表示本次有界搜索未命中,内容仍可能稍后加载。滚动本身会改变页面状态,即使未找到也可能已触发加载。它不承诺读到服务端未提供的数据,也不把虚拟列表未渲染行当作已存在。batch 的 scroll 步骤使用同一实现。
|
|
248
254
|
|
|
249
255
|
### 结果未知时
|
|
250
256
|
|
|
@@ -369,4 +375,4 @@ handle_dialog 返回原始 `dialog.type/message/default_prompt` 和 `accepted`
|
|
|
369
375
|
- 后台操作要求浏览器运行且页面未冻结;网站自己弹窗或权限 UI 仍可能改变焦点。
|
|
370
376
|
- `target_verified`、`delivery_verified`、`value_verified` 只证明事件送达或文本写入,不证明提交、付款、服务端上传等业务完成。
|
|
371
377
|
- Hub 日志位于配对文件同目录的 `hub.log`;`browser-attach status` 查看连接状态,`browser-attach stop` 停止 Hub。
|
|
372
|
-
- 宿主把大结果保存为 Artifact 时,长行可能被宿主截断;这是宿主读取行为,不是快照游标分页。相关说明见 [
|
|
378
|
+
- 宿主把大结果保存为 Artifact 时,长行可能被宿主截断;这是宿主读取行为,不是快照游标分页。相关说明见 [Agent 集成](AGENT-INTEGRATION.md)。
|
package/extension/click.js
CHANGED
|
@@ -67,9 +67,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
67
67
|
view = await budget.run(() => viewport(target));
|
|
68
68
|
if (args.coordinate_space === 'document') {
|
|
69
69
|
if (!Number.isFinite(args.x) || !Number.isFinite(args.y) || args.x < 0 || args.y < 0) throw new Error('Document coordinates must be finite and nonnegative');
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
70
|
+
const x = args.x - view.scroll_x, y = args.y - view.scroll_y;
|
|
71
|
+
if (args.scroll !== false && (x < 0 || y < 0 || x >= view.width || y >= view.height)) {
|
|
72
|
+
const left = x < 0 || x >= view.width ? Math.max(0, args.x - view.width / 2) : view.scroll_x;
|
|
73
|
+
const top = y < 0 || y >= view.height ? Math.max(0, args.y - view.height / 2) : view.scroll_y;
|
|
74
|
+
value(await send('Runtime.evaluate', { returnByValue: true,
|
|
75
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})` }));
|
|
76
|
+
view = await budget.run(() => viewport(target));
|
|
77
|
+
}
|
|
73
78
|
point = position(view, args.x - view.scroll_x, args.y - view.scroll_y);
|
|
74
79
|
} else point = position(view, args.x, args.y);
|
|
75
80
|
}
|
package/extension/controller.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Browser operations live here; the WebSocket transport never exposes raw CDP.
|
|
2
2
|
import { createVisuals } from './visuals.js';
|
|
3
3
|
import { createClickVisuals } from './click-visuals.js';
|
|
4
|
-
import { renderAX, snapshotPage } from './snapshot.js';
|
|
4
|
+
import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
|
|
5
5
|
import { createNavigation } from './navigation.js';
|
|
6
6
|
import { createClick, clickOptions, framePoint } from './click.js';
|
|
7
7
|
import { createInput, inputMode, selectOptions, typeOptions } from './input.js';
|
|
@@ -12,7 +12,7 @@ import { createDiagnostics } from './diagnostics.js';
|
|
|
12
12
|
import { createSessions } from './sessions.js';
|
|
13
13
|
import { NAVIGATION_OBSERVATION_TIMEOUT_MS } from './limits.js';
|
|
14
14
|
import { deadline } from './actionability.js';
|
|
15
|
-
import { scrollOptions, scrollElement, findScrollText } from './scroll.js';
|
|
15
|
+
import { scrollOptions, scrollElement, findScrollText, scrollState } from './scroll.js';
|
|
16
16
|
import { validateSteps } from './batch.js';
|
|
17
17
|
import { waitFor } from './wait.js';
|
|
18
18
|
import { boundedRead, installOutcomeObserver } from './outcomes.js';
|
|
@@ -38,7 +38,7 @@ export function createController(api, notify = () => {}) {
|
|
|
38
38
|
for (const key of pageContexts.keys()) { const [tab, session] = JSON.parse(key); if (tab === tabId && session === sessionId) pageContexts.delete(key); }
|
|
39
39
|
}, tabId => diagnostics.networkOptions(tabId));
|
|
40
40
|
const mouse = createClick(cdp, value, viewport, position, sessions, tabId => api.tabs.get(tabId), windowId => api.windows.get(windowId));
|
|
41
|
-
const gestures = createDrag(cdp, value, mouse);
|
|
41
|
+
const gestures = createDrag(cdp, value, mouse, viewport);
|
|
42
42
|
const input = createInput(cdp, value, sessions);
|
|
43
43
|
const evaluations = createEvaluations(cdp);
|
|
44
44
|
|
|
@@ -291,6 +291,7 @@ export function createController(api, notify = () => {}) {
|
|
|
291
291
|
if (!state) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
292
292
|
return snapshotPage(state, args);
|
|
293
293
|
}
|
|
294
|
+
const options = snapshotOptions(args);
|
|
294
295
|
snapshots.delete(tabId);
|
|
295
296
|
const documentVersion = documents.get(tabId);
|
|
296
297
|
const versions = new Map(frameStates.get(tabId)?.versions);
|
|
@@ -329,12 +330,12 @@ export function createController(api, notify = () => {}) {
|
|
|
329
330
|
knownFrames.add(childId); frames.push({ id: childId, url });
|
|
330
331
|
}
|
|
331
332
|
entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
|
|
332
|
-
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId));
|
|
333
|
+
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
|
|
333
334
|
}
|
|
334
335
|
const metadata = value(await send(tabId, 'Runtime.evaluate', {
|
|
335
336
|
expression: '({title: document.title, url: location.href})', returnByValue: true,
|
|
336
337
|
}));
|
|
337
|
-
const state = { id: token, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
338
|
+
const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
338
339
|
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
|
|
339
340
|
if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
|
|
340
341
|
for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
|
|
@@ -566,7 +567,7 @@ export function createController(api, notify = () => {}) {
|
|
|
566
567
|
const root = node || await resolveSelector(cdp, value, sessions, tabId, { selector: 'html' }, args.budget);
|
|
567
568
|
const claim = claims.get(tabId), version = documents.get(tabId), frameVersion = frameStates.get(tabId)?.versions.get(root.frameId);
|
|
568
569
|
let last;
|
|
569
|
-
for (let step = 0; step <= maxSteps;
|
|
570
|
+
for (let step = 0; step <= maxSteps;) {
|
|
570
571
|
if (claims.get(tabId) !== claim || documents.get(tabId) !== version || frameStates.get(tabId)?.versions.get(root.frameId) !== frameVersion) throw new Error('Document/frame/control changed during scroll search; observe before continuing');
|
|
571
572
|
const found = await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
|
|
572
573
|
functionDeclaration: findScrollText.toString(), arguments: [{ value: text }, { value: selector }], objectGroup: 'browser-attach-search' }));
|
|
@@ -576,13 +577,21 @@ export function createController(api, notify = () => {}) {
|
|
|
576
577
|
await args.budget.run(() => cdp(root.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach-search' }));
|
|
577
578
|
return { found: true, ...result, scroll_steps: step, application_result: 'not_checked', observe_again: true };
|
|
578
579
|
}
|
|
579
|
-
if (step === maxSteps
|
|
580
|
+
if (step === maxSteps) break;
|
|
581
|
+
if (last?.scrolled === false) {
|
|
582
|
+
// A boundary may precede lazy loading. Observe without repeating a
|
|
583
|
+
// no-op scroll until content grows, a match appears, or time runs out.
|
|
584
|
+
const current = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
|
|
585
|
+
functionDeclaration: scrollState.toString(), returnByValue: true })));
|
|
586
|
+
if (JSON.stringify(current) === JSON.stringify(last.after)) { await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50))); continue; }
|
|
587
|
+
}
|
|
580
588
|
args.budget.dispatch();
|
|
581
589
|
last = value(await args.budget.run(() => cdp(root.target, 'Runtime.callFunctionOn', { objectId: root.objectId,
|
|
582
590
|
functionDeclaration: scrollElement.toString(), arguments: [{ value: args.delta_x ?? 0 }, { value: args.delta_y ?? null }], returnByValue: true })));
|
|
591
|
+
step++;
|
|
583
592
|
await args.budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
|
|
584
593
|
}
|
|
585
|
-
throw new Error(`Scroll search did not find ${JSON.stringify(text)} within ${maxSteps} steps
|
|
594
|
+
throw new Error(`Scroll search did not find ${JSON.stringify(text)} within ${maxSteps} steps; current=${JSON.stringify(last)}. Content may still load later. Scrolling may already have occurred.`);
|
|
586
595
|
}
|
|
587
596
|
if (command === 'scroll') {
|
|
588
597
|
const mode = args.mode ?? 'dom';
|
|
@@ -648,7 +657,7 @@ export function createController(api, notify = () => {}) {
|
|
|
648
657
|
error.post_action = { delivery: 'unknown', frame_id: observationFrame, frame_navigation_observed: frameChanged, dialog: dialogs.get(tabId) || null, dialogs: state.messages,
|
|
649
658
|
navigation_observed: documents.get(tabId) !== version || !!current && current.url !== startTab.url,
|
|
650
659
|
current_url: current?.url ?? null, dom_changed: dom?.dom_changed ?? null,
|
|
651
|
-
assessment: dialogs.has(tabId) || state.messages.length || documents.get(tabId) !== version || frameChanged || dom?.dom_changed ? 'observable_effects' : '
|
|
660
|
+
assessment: dialogs.has(tabId) || state.messages.length || documents.get(tabId) !== version || frameChanged || dom?.dom_changed ? 'observable_effects' : 'no_observable_effects',
|
|
652
661
|
note: 'These observations do not prove business success or that an unchanged page received no input. Do not replay automatically.' };
|
|
653
662
|
error.message += ` Post-action observation: ${JSON.stringify(error.post_action)}`;
|
|
654
663
|
} else if (state.messages.length) error.message += ` Dialogs: ${JSON.stringify(state.messages)}`;
|
package/extension/drag.js
CHANGED
|
@@ -3,12 +3,15 @@ import { targetOptions } from './locator.js';
|
|
|
3
3
|
|
|
4
4
|
export function dragOptions(args) {
|
|
5
5
|
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
6
|
-
for (const target of [args.source, args.destination])
|
|
6
|
+
for (const target of [args.source, args.destination]) {
|
|
7
|
+
targetOptions(target, { coordinates: true });
|
|
8
|
+
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
9
|
+
}
|
|
7
10
|
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
8
11
|
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function createDrag(cdp, value, mouse) {
|
|
14
|
+
export function createDrag(cdp, value, mouse, viewport) {
|
|
12
15
|
const pending = new Map();
|
|
13
16
|
function event(tabId, method, params, sessionId) {
|
|
14
17
|
const operation = pending.get(tabId);
|
|
@@ -27,7 +30,7 @@ export function createDrag(cdp, value, mouse) {
|
|
|
27
30
|
const { type, rejected } = JSON.parse(params.payload);
|
|
28
31
|
if (rejected) { operation.error = new Error('Drag target changed during dispatch; outcome is unknown. Observe before continuing.'); operation.reject(operation.error); }
|
|
29
32
|
operation.seen.add(type);
|
|
30
|
-
const required = operation.html5 ? ['mousedown', 'dragstart', 'drop', 'dragend'] : ['
|
|
33
|
+
const required = operation.html5 ? ['mousedown', 'dragstart', 'drop', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'];
|
|
31
34
|
if (required.every(type => operation.seen.has(type))) operation.resolve();
|
|
32
35
|
} catch (error) { operation.reject(error); }
|
|
33
36
|
}
|
|
@@ -36,6 +39,20 @@ export function createDrag(cdp, value, mouse) {
|
|
|
36
39
|
dragOptions(args);
|
|
37
40
|
const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
|
|
38
41
|
await mouse.assertReady(tabId, budget);
|
|
42
|
+
if (args.source.coordinate_space === 'document' && args.destination.coordinate_space === 'document') {
|
|
43
|
+
const view = await budget.run(() => viewport(tabId)), a = args.source, b = args.destination;
|
|
44
|
+
// Keep both endpoints in one viewport when possible. Centering them
|
|
45
|
+
// independently can erase the requested delta before interpolation.
|
|
46
|
+
if (Math.abs(a.x - b.x) < view.width && Math.abs(a.y - b.y) < view.height) {
|
|
47
|
+
const left = Math.min(a.x, b.x) < view.scroll_x || Math.max(a.x, b.x) >= view.scroll_x + view.width
|
|
48
|
+
? Math.max(0, (a.x + b.x - view.width) / 2) : view.scroll_x;
|
|
49
|
+
const top = Math.min(a.y, b.y) < view.scroll_y || Math.max(a.y, b.y) >= view.scroll_y + view.height
|
|
50
|
+
? Math.max(0, (a.y + b.y - view.height) / 2) : view.scroll_y;
|
|
51
|
+
if (left !== view.scroll_x || top !== view.scroll_y) value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
|
|
52
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})`, returnByValue: true,
|
|
53
|
+
})));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
39
56
|
const source = await mouse.prepare(tabId, await resolve(args.source, budget), { ...args.source, budget });
|
|
40
57
|
const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
|
|
41
58
|
let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
|
|
@@ -117,18 +134,21 @@ export function createDrag(cdp, value, mouse) {
|
|
|
117
134
|
|
|
118
135
|
function installDragReceipt(name, stage, html5, expiryMs) {
|
|
119
136
|
const target = this, win = target.ownerDocument.defaultView, report = win[name];
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
// Cancelling pointerdown suppresses compatibility mouse down/up, but does
|
|
138
|
+
// not prevent a pointer-based control from receiving the drag.
|
|
139
|
+
const down = html5 ? 'mousedown' : 'pointerdown';
|
|
140
|
+
const types = stage === 'source' ? (html5 ? ['mousedown', 'dragstart', 'dragend'] : ['pointerdown', 'pointermove', 'pointerup'])
|
|
141
|
+
: stage === 'source_boundary' ? [down] : (html5 ? ['drop'] : ['pointerup']);
|
|
122
142
|
const listeners = [];
|
|
123
143
|
function listener(event, expected) {
|
|
124
144
|
if (!event.isTrusted) return;
|
|
125
145
|
const hit = event.composedPath().includes(expected);
|
|
126
|
-
if ((event.type ===
|
|
146
|
+
if ((event.type === down || event.type === 'drop') && (!hit || !target.isConnected)) {
|
|
127
147
|
event.preventDefault(); event.stopImmediatePropagation(); report(JSON.stringify({ rejected: true })); return;
|
|
128
148
|
}
|
|
129
149
|
if (stage.endsWith('_boundary') || expected !== target) return;
|
|
130
150
|
if (['dragstart', 'dragend'].includes(event.type) && !hit) return;
|
|
131
|
-
if (event.type === '
|
|
151
|
+
if (event.type === 'pointermove' && event.buttons !== 1) return;
|
|
132
152
|
if (html5 && !['mousedown', 'dragstart', 'drop', 'dragend'].includes(event.type)) return;
|
|
133
153
|
report(JSON.stringify({ type: event.type }));
|
|
134
154
|
}
|
|
@@ -148,7 +168,7 @@ function installDragReceipt(name, stage, html5, expiryMs) {
|
|
|
148
168
|
}
|
|
149
169
|
// Pointer capture and movement can leave the source's closed shadow root.
|
|
150
170
|
if (stage === 'source' && target.getRootNode().host && !html5) {
|
|
151
|
-
const callback = event => listener(event, target), types = ['
|
|
171
|
+
const callback = event => listener(event, target), types = ['pointermove', 'pointerup'];
|
|
152
172
|
listeners.push({ receiver: win, callback, types });
|
|
153
173
|
for (const type of types) win.addEventListener(type, callback, { capture: true, passive: false });
|
|
154
174
|
}
|
package/extension/locator.js
CHANGED
|
@@ -17,17 +17,28 @@ export function targetOptions(args, { coordinates = false } = {}) {
|
|
|
17
17
|
|
|
18
18
|
export function queryElement(selector, type = 'css', nth, waitForMatch = false) {
|
|
19
19
|
if (nth != null && (!Number.isSafeInteger(nth) || nth < 0)) throw new Error('nth must be a nonnegative safe integer (zero-based)');
|
|
20
|
-
let element, count;
|
|
20
|
+
let element, count, at;
|
|
21
21
|
if (type === 'xpath') {
|
|
22
22
|
const result = document.evaluate(selector, document, null, XPathResult.ORDERED_NODE_SNAPSHOT_TYPE, null);
|
|
23
|
-
count = result.snapshotLength;
|
|
23
|
+
count = result.snapshotLength; at = index => result.snapshotItem(index);
|
|
24
24
|
} else {
|
|
25
25
|
const elements = document.querySelectorAll(selector);
|
|
26
|
-
count = elements.length;
|
|
26
|
+
count = elements.length; at = index => elements[index];
|
|
27
27
|
}
|
|
28
28
|
if (waitForMatch && (count === 0 || (nth != null && nth >= count))) return null;
|
|
29
|
-
if (nth == null && count !== 1)
|
|
29
|
+
if (nth == null && count !== 1) {
|
|
30
|
+
const candidates = Array.from({ length: Math.min(count, 10) }, (_, nth) => {
|
|
31
|
+
const node = at(nth), text = (node.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 120);
|
|
32
|
+
if (node.nodeType !== 1) return { nth, node_type: node.nodeType, text };
|
|
33
|
+
const rect = node.getBoundingClientRect(), style = node.ownerDocument.defaultView.getComputedStyle(node);
|
|
34
|
+
return { nth, tag: node.tagName.toLowerCase(), text,
|
|
35
|
+
disabled: node.matches(':disabled') || !!node.closest('[inert], [aria-disabled="true"]'),
|
|
36
|
+
visible: rect.width > 0 && rect.height > 0 && style.visibility === 'visible' };
|
|
37
|
+
});
|
|
38
|
+
throw new Error(`Selector must match exactly one element; matched ${count}. Scope the selector or provide an explicit zero-based nth. Candidates: ${JSON.stringify(candidates)}${count > candidates.length ? ` (${count - candidates.length} more omitted)` : ''}`);
|
|
39
|
+
}
|
|
30
40
|
if (nth != null && nth >= count) throw new Error(`Selector nth=${nth} is out of range; matched ${count}`);
|
|
41
|
+
element = at(nth ?? 0);
|
|
31
42
|
if (element.nodeType !== 1) throw new Error('Selector must resolve to an element');
|
|
32
43
|
return element;
|
|
33
44
|
}
|
package/extension/manifest.json
CHANGED
package/extension/scroll.js
CHANGED
|
@@ -14,10 +14,17 @@ export function scrollElement(dx, dy) {
|
|
|
14
14
|
if (!this.isConnected) throw new Error('Scroll container is detached');
|
|
15
15
|
const before = { x: this.scrollLeft, y: this.scrollTop };
|
|
16
16
|
this.scrollBy({ left: dx, top: dy ?? Math.max(1, this.clientHeight * 0.8), behavior: 'instant' });
|
|
17
|
-
const after = { x: this.scrollLeft, y: this.scrollTop
|
|
17
|
+
const after = { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
|
|
18
|
+
client_width: this.clientWidth, client_height: this.clientHeight };
|
|
18
19
|
return { scrolled: before.x !== after.x || before.y !== after.y, before, after, method: 'dom_scroll', scope: 'element' };
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export function scrollState() {
|
|
23
|
+
if (!this.isConnected) throw new Error('Scroll container is detached');
|
|
24
|
+
return { x: this.scrollLeft, y: this.scrollTop, width: this.scrollWidth, height: this.scrollHeight,
|
|
25
|
+
client_width: this.clientWidth, client_height: this.clientHeight };
|
|
26
|
+
}
|
|
27
|
+
|
|
21
28
|
export function findScrollText(text, selector) {
|
|
22
29
|
if (!this.isConnected) throw new Error('Scroll container detached');
|
|
23
30
|
const elements = Array.from(this.querySelectorAll(selector || '*'));
|
package/extension/snapshot.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
-
export function
|
|
2
|
+
export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
|
|
3
|
+
if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
|
|
4
|
+
if (typeof text !== 'boolean') throw new Error('text must be a boolean');
|
|
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) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
|
|
3
10
|
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
4
11
|
const visited = new Set();
|
|
5
12
|
const entries = [];
|
|
6
13
|
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
14
|
+
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
15
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
16
|
+
let kept;
|
|
17
|
+
if (options.profile === 'interactive') {
|
|
18
|
+
kept = new Set();
|
|
19
|
+
const parents = new Map();
|
|
20
|
+
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
21
|
+
function keepText(node) {
|
|
22
|
+
if (!node || kept.has(node.nodeId)) return;
|
|
23
|
+
const role = node.role?.value;
|
|
24
|
+
if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
|
|
25
|
+
kept.add(node.nodeId);
|
|
26
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
27
|
+
}
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
|
|
30
|
+
node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
|
|
31
|
+
prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
|
|
32
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
33
|
+
let id = node.nodeId;
|
|
34
|
+
while (id != null && !kept.has(id)) { kept.add(id); id = parents.get(id); }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
7
37
|
function plainText(node) {
|
|
8
38
|
if (!node) return null;
|
|
9
39
|
const role = node.role?.value;
|
|
@@ -15,6 +45,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
15
45
|
function render(node, depth, parentName = '') {
|
|
16
46
|
if (!node || visited.has(node.nodeId)) return;
|
|
17
47
|
visited.add(node.nodeId);
|
|
48
|
+
if (kept && !kept.has(node.nodeId)) return;
|
|
18
49
|
const role = node.role?.value || 'node';
|
|
19
50
|
if (role === 'InlineTextBox') return;
|
|
20
51
|
let name = String(node.name?.value || '');
|
|
@@ -28,8 +59,13 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
28
59
|
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
29
60
|
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
30
61
|
const attributes = states.join(' ');
|
|
31
|
-
const skip = node.ignored || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
62
|
+
const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
32
63
|
(role === 'StaticText' && name === parentName);
|
|
64
|
+
const identityName = name;
|
|
65
|
+
if (textRoles.has(role) || merged) {
|
|
66
|
+
if (!options.text) name = '';
|
|
67
|
+
else if (options.text_limit != null && name.length > options.text_limit) name = name.slice(0, options.text_limit) + '…';
|
|
68
|
+
}
|
|
33
69
|
let nextDepth = depth;
|
|
34
70
|
if (!skip) {
|
|
35
71
|
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
@@ -37,7 +73,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
37
73
|
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
38
74
|
nextDepth++;
|
|
39
75
|
}
|
|
40
|
-
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName :
|
|
76
|
+
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
|
|
41
77
|
}
|
|
42
78
|
// AX trees normally have a single root, but ignored roots can be disconnected.
|
|
43
79
|
const children = new Set(nodes.flatMap(node => node.childIds || []));
|
|
@@ -45,12 +81,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
45
81
|
return entries;
|
|
46
82
|
}
|
|
47
83
|
|
|
48
|
-
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor } = {}) {
|
|
84
|
+
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
|
|
49
85
|
if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
|
|
50
86
|
if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
|
|
51
87
|
let offset = 0;
|
|
52
88
|
if (cursor != null) {
|
|
53
89
|
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.');
|
|
92
|
+
}
|
|
54
93
|
offset = state.nextOffset;
|
|
55
94
|
}
|
|
56
95
|
const lines = [];
|
|
@@ -69,7 +108,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
|
|
|
69
108
|
const truncated = index < state.entries.length;
|
|
70
109
|
state.nextOffset = index;
|
|
71
110
|
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
72
|
-
return { ...state.metadata, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
111
|
+
return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
73
112
|
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
|
|
74
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.' };
|
|
75
114
|
}
|
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.
|
|
18
|
+
export const VERSION = '0.12.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/mcp.mjs
CHANGED
|
@@ -79,9 +79,12 @@ export function createMcp(client) {
|
|
|
79
79
|
if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
|
|
80
80
|
return { tab_handle: args.tab_handle };
|
|
81
81
|
});
|
|
82
|
-
tool('snapshot', 'Read a semantic AX tree with refs and frame coverage. Pass next_cursor as cursor to continue the same observation.', {
|
|
82
|
+
tool('snapshot', 'Read a semantic AX tree with refs and frame coverage. profile=interactive keeps controls and ancestor context. Pass next_cursor as cursor to continue the same observation; view options remain fixed.', {
|
|
83
83
|
...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
|
|
84
84
|
max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
|
|
85
|
+
profile: z.enum(['full', 'interactive']).optional().describe('Default full. Interactive keeps AX controls, focusable/editable nodes and their ancestors; omits unrelated reading content.'),
|
|
86
|
+
text: z.boolean().optional().describe('Default true. False omits standalone text and merged prose, preserving accessible names and control values.'),
|
|
87
|
+
text_limit: integerRange('text_limit', 0, 10000).optional().describe('Limit each text/prose name, marking truncation with …; accessible control names and values stay complete. Default 80 for interactive, unlimited for full.'),
|
|
85
88
|
});
|
|
86
89
|
tool('read_text', 'Read whole-page rendered text when selector is omitted; optionally scope a frame or selector. Also read field value or a supported Monaco/CodeMirror 5 model. Optional selector scopes the read. complete_source reports completeness; next_offset continues live text, which may change between reads.', {
|
|
87
90
|
...handle, ...selector, ...frameScope, mode: z.enum(['visible', 'text_content', 'value', 'editor']).default('visible'),
|
|
@@ -149,7 +152,7 @@ export function createMcp(client) {
|
|
|
149
152
|
])).min(1).max(20),
|
|
150
153
|
});
|
|
151
154
|
tool('press_key', 'Send a native key/chord to current focus and verify keydown delivery. Supports F1-F12, navigation/editing keys and US ASCII punctuation. Examples: Control+A, Shift+/, Control++, Plus.', { ...handle, ...inputTimeout, key: z.string() });
|
|
152
|
-
tool('scroll', 'Scroll an explicit element container or at viewport CSS x/y (default center); positive delta_y is down. DOM mode uses scrollBy without wheel events; wheel mode sends a native wheel for custom handlers/OOPIFs. until.text performs a bounded DOM
|
|
155
|
+
tool('scroll', 'Scroll an explicit element container or at viewport CSS x/y (default center); positive delta_y is down. DOM mode uses scrollBy without wheel events; wheel mode sends a native wheel for custom handlers/OOPIFs. until.text performs a bounded DOM search and returns a fresh ref; at an unchanged boundary it observes until content changes or timeout. max_steps bounds scroll calls. A search miss does not prove content will never load. Wheel acknowledgement does not prove scrolling.', {
|
|
153
156
|
...handle, ...inputTimeout, ...target, ...scrollFields,
|
|
154
157
|
}, null, args => { scrollOptions(args); if (args.ref != null || args.selector != null || args.frame || args.frame_id) targetOptions(args); return args; });
|
|
155
158
|
tool('navigate', 'Navigate to an HTTP(S) URL or back/forward history entry, wait until wait_until, and return a fresh snapshot by default. Observation has a separate 10s deadline with one transient-error retry; observation_error preserves navigation success.', {
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# Nosis 集成
|
|
2
|
-
|
|
3
|
-
BrowserAttach 是标准 stdio MCP,也可以通过 `setup --nosis` 直接写入 Nosis 的用户配置。
|
|
4
|
-
|
|
5
|
-
## 写入内容
|
|
6
|
-
|
|
7
|
-
```sh
|
|
8
|
-
browser-attach setup --nosis
|
|
9
|
-
```
|
|
10
|
-
|
|
11
|
-
该命令在完成普通配对后,备份并更新 `~/.nosis/agent_config.json`(备份名为 `agent_config.json.browser-attach-<时间戳>.bak`),写入:
|
|
12
|
-
|
|
13
|
-
| 字段 | 值 |
|
|
14
|
-
|---|---|
|
|
15
|
-
| 服务器名 | `browser_attach` |
|
|
16
|
-
| `command` | 执行 setup 的 Node 可执行文件绝对路径 |
|
|
17
|
-
| `args` | 当前包 `bin/browser-attach.mjs` 绝对路径、`mcp`、`--config <绝对配对路径>` |
|
|
18
|
-
| `env.BROWSER_ATTACH_HOME` | 配对文件所在目录 |
|
|
19
|
-
| `startup_timeout_seconds` / `call_timeout_seconds` | `20` / `60` |
|
|
20
|
-
| `tools.approval` | `always` |
|
|
21
|
-
|
|
22
|
-
同时会设置 `mcp.enabled = true`。命令与配对路径都固定为绝对路径,因此本机安装位置变化后需要重新执行 `setup --nosis`;请从长期保留的位置安装,而不是临时解压目录。
|
|
23
|
-
|
|
24
|
-
重启或重建 Nosis Runtime 后重新发现工具。
|
|
25
|
-
|
|
26
|
-
## 自定义运行目录
|
|
27
|
-
|
|
28
|
-
Windows 默认目录是 `%USERPROFILE%/.browser-attach`,避开打包应用与普通宿主可能重定向到不同实际文件的 AppData;macOS / Linux 使用 XDG 用户数据目录。`BROWSER_ATTACH_HOME` 必须是绝对目录。
|
|
29
|
-
|
|
30
|
-
已有可共享目录可直接沿用:
|
|
31
|
-
|
|
32
|
-
```sh
|
|
33
|
-
browser-attach setup --config /absolute/shared-directory/config.json --nosis
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
setup 会把解析后的配对路径写进配置,MCP 与 Hub 后续都显式传递同一路径,不会在不同启动环境中重新推导出第二套密钥。setup 不会搜索或自动迁移其他应用的私有缓存;已有配对请显式保留原目录。
|
|
37
|
-
|
|
38
|
-
## 验证
|
|
39
|
-
|
|
40
|
-
```sh
|
|
41
|
-
python scripts/verify-nosis.py --nosis-root /path/to/Nosis --family chrome
|
|
42
|
-
python scripts/verify-nosis.py --nosis-root /path/to/Nosis --family edge
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
脚本读取本机 Nosis 配置,通过 Nosis ToolCatalog 发现 22 个工具,覆盖发现、标签控制、页面操作和结果封装,其中 `browser_logs` 分别覆盖 console/network,验证结果封装、截图到模型消息的图片适配以及 `isError` 后连接仍可用。它用 `browser_open_tab` 在选定配置中创建本地 fixture;同一 family 有多个 profile 时用 `--browser-id <精确 browser_id>` 指定,结束后释放控制。Python 仅用于该测试,不是运行依赖。
|
|
46
|
-
|
|
47
|
-
## 故障排查
|
|
48
|
-
|
|
49
|
-
- 工具结果走 MCP 文本内容,截图走标准图片内容;能否进入模型取决于宿主的图片适配、大小预算与模型能力。
|
|
50
|
-
- 失效句柄不能跨连接复用,错误后应重新 list + claim。
|
|
51
|
-
- 文件上传只在浏览器本机读取路径,内容不经过 Hub 或模型。
|
|
52
|
-
- 宿主把大结果保存为 Artifact 时,嵌套 JSON 长行可能被宿主的读取工具截断;这是宿主的分页行为,不能靠调大 Attach 的预算解决,也不等同于快照游标分页。
|
|
53
|
-
- `Connection closed` 时依次检查:宿主的 stderr、配对文件是否可见、`hub.log`、以及 Hub 与扩展的版本是否一致(版本不一致会直接拒绝连接)。
|