@evan_zhong/browser-attach 0.16.0 → 0.17.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 +5 -5
- package/README.zh.md +5 -5
- package/bin/browser-attach.mjs +3 -3
- package/docs/AGENT-INTEGRATION.md +3 -3
- package/docs/ARCHITECTURE.md +8 -5
- package/docs/TEST-REPORT.md +8 -1
- package/docs/USAGE.md +3 -1
- package/extension/background.js +7 -98
- package/extension/connection.js +142 -0
- package/extension/manifest.json +1 -1
- package/extension/version.js +11 -0
- package/package.json +2 -1
- package/scripts/check-package.mjs +1 -0
- package/src/client.mjs +58 -15
- package/src/config.mjs +5 -1
- package/src/hub.mjs +62 -17
- package/src/mcp.mjs +1 -0
- package/src/setup.mjs +87 -16
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Merge the following into your client's MCP configuration, or see [mcp-config.jso
|
|
|
67
67
|
|
|
68
68
|
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.
|
|
69
69
|
|
|
70
|
-
`@latest`
|
|
70
|
+
`@latest` selects the npm package at launch. For a pair already running 0.17 or newer, startup coordinates an idle Hub upgrade, synchronizes the paired extension directory and asks older connected extensions to reload themselves. First-time extension loading and the first upgrade from a pre-0.17 pair still require one manual extension reload; a pre-0.17 Hub must be stopped once before the new MCP can start. If another MCP connection is using the Hub, close it and reconnect to retry the upgrade.
|
|
71
71
|
|
|
72
72
|
Assembly steps for supported hosts are in [agent integration](docs/AGENT-INTEGRATION.md); other clients use the stdio entry above.
|
|
73
73
|
|
|
@@ -113,13 +113,12 @@ Override it explicitly with the absolute path `BROWSER_ATTACH_HOME`. The Windows
|
|
|
113
113
|
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).
|
|
114
114
|
|
|
115
115
|
```sh
|
|
116
|
-
browser-attach status
|
|
117
|
-
browser-attach stop
|
|
118
116
|
npm install --global @evan_zhong/browser-attach
|
|
119
|
-
browser-attach setup
|
|
120
117
|
```
|
|
121
118
|
|
|
122
|
-
|
|
119
|
+
Then restart the MCP connection. With `@latest`, restarting MCP is sufficient for later upgrades once Hub and extension are on 0.17 or newer; `setup` and `start` use the same coordination when run explicitly. Upgrading from an older pair first requires `browser-attach stop`, a restart and one manual extension reload. Custom pairing paths, ports and extension directories are retained. Closed browser profiles update when they next connect. An older MCP package cannot downgrade a newer Hub or installed extension.
|
|
120
|
+
|
|
121
|
+
An upgrade waits for extension cleanup and confirms the new version after reload; cleanup or reload failures are reported instead of replaying page actions. Manual reload remains available on the extension options page for recovery. Hub and extension versions must match for page control. The default port is `47831`; choose another port with `setup --port` when creating a pairing.
|
|
123
122
|
|
|
124
123
|
## Architecture and boundaries
|
|
125
124
|
|
|
@@ -164,6 +163,7 @@ Real-browser tests need a Chromium / Edge executable that allows loading extensi
|
|
|
164
163
|
npm run test:live -- --executable /path/to/chromium --family chromium
|
|
165
164
|
npm run test:interaction -- --executable /path/to/chromium --family chromium
|
|
166
165
|
npm run test:interaction -- --tab-indicators --executable /path/to/chromium --family chromium
|
|
166
|
+
npm run test:upgrade -- --executable /path/to/chromium
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
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).
|
package/README.zh.md
CHANGED
|
@@ -67,7 +67,7 @@ npx --yes --package=@evan_zhong/browser-attach@latest browser-attach setup
|
|
|
67
67
|
|
|
68
68
|
Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局安装时,也可以配置 `"command": "browser-attach", "args": ["mcp"]`。重启 MCP 客户端后发现工具。
|
|
69
69
|
|
|
70
|
-
`@latest`
|
|
70
|
+
`@latest` 在启动时选择 npm 包版本。当配对已经运行 0.17 或更高版本时,MCP 启动会协调空闲 Hub 升级、同步已配对的扩展目录,并通知较旧扩展自行重新加载。首次加载扩展,以及从 0.17 之前版本进行的第一次升级,仍需手动重载一次扩展;旧 Hub 必须先停止,新 MCP 才能启动。如果其他 MCP 连接正在使用 Hub,请关闭这些连接后重连,以重试升级。
|
|
71
71
|
|
|
72
72
|
各宿主的装配步骤见 [Agent 集成](docs/AGENT-INTEGRATION.md);其他客户端直接使用上面的 stdio 配置。
|
|
73
73
|
|
|
@@ -113,13 +113,12 @@ Windows 客户端若要求完整命令扩展名,使用 `npx.cmd`。已全局
|
|
|
113
113
|
宿主装配步骤写入客户端条目时,会固定当前 Node、CLI 和实际配对文件的绝对路径,并设置 MCP 的 `BROWSER_ATTACH_HOME`;支持与自定义 `--config` 一起使用。MCP 启动 Hub 时继续传入同一绝对配对路径及目录,避免不同启动环境重新推导出另一份配置。详见 [Agent 集成](docs/AGENT-INTEGRATION.md)。
|
|
114
114
|
|
|
115
115
|
```sh
|
|
116
|
-
browser-attach status
|
|
117
|
-
browser-attach stop
|
|
118
116
|
npm install --global @evan_zhong/browser-attach
|
|
119
|
-
browser-attach setup
|
|
120
117
|
```
|
|
121
118
|
|
|
122
|
-
|
|
119
|
+
安装新版后重连 MCP。使用 `@latest` 时,在 Hub 与扩展已经运行 0.17 或更高版本后,重连 MCP 即可协调后续升级;显式运行 `setup` 或 `start` 也使用同一流程。从更旧版本升级时,需要先执行 `browser-attach stop`、重启 MCP,并手动重载一次扩展。自定义配对路径、端口和扩展目录会保留。未打开的浏览器 profile 在下次连接时完成更新。旧 MCP 包不会降级新版 Hub 或已安装扩展。
|
|
120
|
+
|
|
121
|
+
升级会等待扩展清理,并在重载后确认新版本;清理或重载失败会明确报告,不重放页面动作。扩展设置页保留手动重新加载按钮用于恢复。正常页面控制仍要求 Hub 与扩展版本一致。默认端口为 `47831`,创建配对时可通过 `setup --port` 选择其他端口。
|
|
123
122
|
|
|
124
123
|
## 架构与使用边界
|
|
125
124
|
|
|
@@ -164,6 +163,7 @@ node bin/browser-attach.mjs mcp
|
|
|
164
163
|
npm run test:live -- --executable /path/to/chromium --family chromium
|
|
165
164
|
npm run test:interaction -- --executable /path/to/chromium --family chromium
|
|
166
165
|
npm run test:interaction -- --tab-indicators --executable /path/to/chromium --family chromium
|
|
166
|
+
npm run test:upgrade -- --executable /path/to/chromium
|
|
167
167
|
```
|
|
168
168
|
|
|
169
169
|
这些测试使用临时配置和本地 fixture。测试覆盖与限制见 [验证报告](docs/TEST-REPORT.md),实现与设计取舍见 [架构与设计](docs/ARCHITECTURE.md)。
|
package/bin/browser-attach.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { ensureHub, hubIsRunning, AttachClient } from '../src/client.mjs';
|
|
|
6
6
|
try {
|
|
7
7
|
const { values, positionals } = parseArgs({ allowPositionals: true, options: {
|
|
8
8
|
config: { type: 'string', default: DEFAULT_CONFIG },
|
|
9
|
-
'extension-dir': { type: 'string' }, port: { type: 'string'
|
|
9
|
+
'extension-dir': { type: 'string' }, port: { type: 'string' },
|
|
10
10
|
nosis: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h' },
|
|
11
11
|
} });
|
|
12
12
|
const action = positionals[0] || 'mcp', configPath = values.config;
|
|
@@ -20,10 +20,10 @@ try {
|
|
|
20
20
|
const stop = () => hub.close(); process.once('SIGINT', stop); process.once('SIGTERM', stop);
|
|
21
21
|
} else if (action === 'setup') {
|
|
22
22
|
const { setup, configureNosis } = await import('../src/setup.mjs');
|
|
23
|
-
const result = await setup({ configPath, extensionDir: values['extension-dir'], port: Number(values.port) });
|
|
23
|
+
const result = await setup({ configPath, extensionDir: values['extension-dir'], port: values.port === undefined ? undefined : Number(values.port) });
|
|
24
24
|
console.log(`Extension folder: ${result.extensionDir}\nExtension ID: ${result.config.extension_id}\nPairing: ${result.configPath}`);
|
|
25
25
|
if (values.nosis) console.log(JSON.stringify(await configureNosis(undefined, { configPath: result.configPath })));
|
|
26
|
-
await ensureHub(result.configPath); console.log('Local hub
|
|
26
|
+
await ensureHub(result.configPath); console.log('Local hub ready. Load the extension in each Chrome/Edge profile on first use; subsequent upgrades reload it automatically.');
|
|
27
27
|
} else if (action === 'start') { await ensureHub(configPath); console.log('Local hub started.'); }
|
|
28
28
|
else if (action === 'status') {
|
|
29
29
|
const config = await loadConfig(configPath);
|
|
@@ -26,7 +26,7 @@ node <安装位置>/bin/browser-attach.mjs mcp --config <配对文件绝对路
|
|
|
26
26
|
|
|
27
27
|
- `BROWSER_ATTACH_HOME` 指向配对文件所在目录。
|
|
28
28
|
- Node 可执行文件、CLI 与配对文件一律使用绝对路径。宿主的启动环境(PATH、工作目录、AppData 重定向)不同,重新推导会得到第二份配对。
|
|
29
|
-
-
|
|
29
|
+
- 安装位置变化后重新执行 `setup` 并更新宿主条目。Hub 与扩展已经运行 0.17 或更高版本时,安装新版后重连 MCP 会协调空闲 Hub 与扩展自动升级;从更旧版本首次升级需先停止 Hub、重启 MCP 并手动重载一次扩展。其他 MCP 仍连接时关闭它们后重试。正常页面控制要求同版本。
|
|
30
30
|
|
|
31
31
|
工具前缀由宿主决定,原始工具名不变(`browser_discover`、`browser_claim_tab` 等)。装配后的最小验证在各宿主一致:`browser_discover(kind="browsers")` → `kind="tabs"` → `browser_claim_tab` → `browser_snapshot` → `browser_tab_control(action="release")`。
|
|
32
32
|
|
|
@@ -110,7 +110,7 @@ npm root -g # 全局安装目录;CLI 位于 <root>/@evan_zhong/browser-a
|
|
|
110
110
|
- `web` profile 开启 HMR,修改条目会就地重连;`headless`、`sdk`、`acp` 关闭 HMR,需要重启 harness。
|
|
111
111
|
- MCP 工具与原生工具一样经过 DSH 的权限检查与审批策略。
|
|
112
112
|
|
|
113
|
-
|
|
113
|
+
安装新版后重启或重连 harness,由 MCP 启动流程协调 0.17+ 的后续 Hub 与扩展升级;首次加载扩展以及从更旧版本首次升级仍需手动重载。
|
|
114
114
|
|
|
115
115
|
## 新增宿主
|
|
116
116
|
|
|
@@ -122,4 +122,4 @@ npm root -g # 全局安装目录;CLI 位于 <root>/@evan_zhong/browser-a
|
|
|
122
122
|
- 失效句柄不能跨连接复用,错误后应重新 discover + claim。
|
|
123
123
|
- 文件上传只在浏览器本机读取路径,内容不经过 Hub 或模型。
|
|
124
124
|
- 宿主把大结果保存为 Artifact 时,嵌套 JSON 长行可能被宿主的读取工具截断;这是宿主的分页行为,不能靠调大 Attach 的预算解决,也不等同于快照游标分页。
|
|
125
|
-
- `Connection closed` 时依次检查:宿主的 stderr、配对文件是否可见、`hub.log
|
|
125
|
+
- `Connection closed` 时依次检查:宿主的 stderr、配对文件是否可见、`hub.log`、是否有其他 MCP 连接阻止升级,以及扩展清理或自动重载是否失败。扩展设置页可手动重新加载以恢复。
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# 架构与设计边界
|
|
2
2
|
|
|
3
|
-
当前实现为 **0.
|
|
3
|
+
当前实现为 **0.17.0**。本文说明 BrowserAttach 的组件、控制模型与设计取舍;工具参数见 [使用指南](USAGE.md)。
|
|
4
4
|
|
|
5
5
|
## 组件与数据流
|
|
6
6
|
|
|
@@ -17,11 +17,12 @@ MCP 客户端
|
|
|
17
17
|
|---|---|
|
|
18
18
|
| `bin/browser-attach.mjs` | `setup` / `mcp` / `hub` / `status` / `stop` 入口 |
|
|
19
19
|
| `src/config.mjs` | 数据目录、配对文件校验、版本常量 |
|
|
20
|
-
| `src/setup.mjs` |
|
|
20
|
+
| `src/setup.mjs` | 生成配对密钥、暂存并替换稳定扩展目录、写 Nosis 配置 |
|
|
21
21
|
| `src/hub.mjs` | 浏览器与会话注册、独占 claim、每标签串行队列、超时 |
|
|
22
|
-
| `src/client.mjs` | MCP
|
|
22
|
+
| `src/client.mjs` | MCP 到 Hub 的 WebSocket 客户端、启动升级协调与就绪检查 |
|
|
23
23
|
| `src/mcp.mjs` | 23 个工具的 schema、结果与图片封装 |
|
|
24
24
|
| `extension/controller.js` | 操作实现;传输层从不暴露原始 CDP |
|
|
25
|
+
| `extension/connection.js` | 扩展连接、重连清理、升级维护与自行重载 |
|
|
25
26
|
| `extension/sessions.js` | 扁平 debugger session 与 frame/OOPIF 归属 |
|
|
26
27
|
| `extension/{click,input,actionability,scroll}.js` | 原生/DOM 输入、逐字按键、可操作性、容器滚动与有界查找 |
|
|
27
28
|
| `extension/{drag,wait}.js` | 复用鼠标准备和严格定位的拖拽、只读条件等待;网络空闲复用被动请求表 |
|
|
@@ -119,7 +120,7 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
119
120
|
## 安全与隐私
|
|
120
121
|
|
|
121
122
|
- Hub 只监听 `127.0.0.1`;HTTP 端点拒绝任何带 `Origin` 的请求,客户端必须携带 Bearer 密钥。
|
|
122
|
-
- 扩展连接固定校验 `chrome-extension://<extension_id>` Origin
|
|
123
|
+
- 扩展连接固定校验 `chrome-extension://<extension_id>` Origin 与随机 token。同版本才注册为可控浏览器;已配对的较旧扩展仅进入维护连接,收到重载请求后校验本机文件、完成清理再自行重载。客户端握手同样要求版本一致。
|
|
123
124
|
- 配对文件 `config.json` 与生成的 `extension/config.generated.js` 含密钥:目录在 Windows 上用当前用户 SID 收紧 ACL,在 POSIX 上为 `0700`/`0600`;发布包检查会拒绝包含 `config.generated.js`。
|
|
124
125
|
- 扩展具备 `debugger` 与 `tabGroups` 权限,操作使用用户现有登录态;只应接入信任的 MCP 客户端。网页内容、控制台与网络日志均视为非可信数据。
|
|
125
126
|
- 文件上传只传路径,Hub 与模型都不接触文件内容;选择文件可能立即把内容发给网站,因此只传任务明确指定的文件。
|
|
@@ -142,4 +143,6 @@ HTTP 状态复用 diagnostics 的 Network.responseReceived 观察,按主文档
|
|
|
142
143
|
|
|
143
144
|
## 版本与兼容
|
|
144
145
|
|
|
145
|
-
`package.json`、`src/config.mjs`、`extension/manifest.json`
|
|
146
|
+
`package.json`、`src/config.mjs`、`extension/manifest.json` 的版本必须一致。MCP 示例配置使用 `@latest`,MCP 启动、`start` 和 `setup` 共用升级协调:检查已配对 Hub → 空闲 Hub 暂停接入并确认扩展清理 → 旧 Hub 退出 → 新 Hub 独占端口后同步扩展 → 旧扩展自行重载 → 确认新版重连。该自动路径要求 Hub 与扩展已经具备 0.17 的 `connection.js` 维护协议;更旧版本的首次升级必须先停止 Hub、重启 MCP 并手动重载扩展(旧 Hub 没有升级端点,旧扩展也不处理重载命令)。MCP 在 stdio 就绪前即连接 Hub,尚未调用工具的 MCP 会话也会阻止升级。有客户端、断连清理或在途请求时返回占用错误,关闭其他 MCP 连接后重试;不强制打断页面动作。
|
|
147
|
+
|
|
148
|
+
Hub 的监听端口同时保证同一配对的文件同步只有一个执行者。扩展先完整暂存,保留稳定目录本身,逐文件替换并最后提交 manifest;替换失败恢复旧文件,进程在替换中退出时通过 `.previous` 副本恢复。配对密钥、端口和自定义目录保留,旧包不得降级新版 Hub 或扩展。扩展重载记录尝试,防止失败后无限循环;派发重载不代表升级成功,启动协调仍等待实际版本。未打开的 profile 在下次连接时处理;首次安装仍需手动加载扩展。此流程不热替换正在运行的 MCP 进程。
|
package/docs/TEST-REPORT.md
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
# 验证报告
|
|
2
2
|
|
|
3
|
-
当前版本 **0.
|
|
3
|
+
当前版本 **0.17.0**(2026-10-23)。浏览器验证使用本地 fixture 和临时 profile;测试链路在各项下说明。
|
|
4
4
|
|
|
5
5
|
## 当前版本
|
|
6
6
|
|
|
7
|
+
- `npm test`:196 项通过。新增并发升级与冷启动、无浏览器启动和离线 profile 迟到更新、尚未调用工具的 MCP 会话阻止升级、清理失败恢复、禁止降级、独占端口后才同步、文件中断恢复、自定义配对保留、维护期间拒绝页面动作、本机版本核验、重载循环抑制、detach 失败后禁止重连,以及旧配对缺少 `extension_dir` 时回落到同目录 `extension` 的兼容处理。
|
|
8
|
+
- Chromium 156 与 Edge 155:`npm run test:upgrade -- --executable <路径>` 均通过。临时 profile 开启开发者模式后,两个并发启动者将 Hub 和真实扩展从当前代码改版本号构造的兼容旧协议 0.16.9 升到 0.17.0;确认同一 profile、实际 runtime 重载后的版本,以及升级后发现/接管 → AX 查询 → DOM 点击 → 查询结果 → 释放。CDP 只用于临时 profile 的开发者模式设置,页面操作走扩展与 Hub。该脚本验证升级协议本身,不代表真实 0.16 扩展能自行重载;真实旧版本首次升级仍需手动重载。
|
|
9
|
+
- `npm run test:package`:仓库外安装运行通过,23 个工具、50 个发布文件;由已安装包中的 Hub 同步稳定扩展目录并运行 stdio MCP,不含配对密钥,无新增运行依赖或扩展权限。
|
|
10
|
+
- 本机部署:Hub 已从工作区重启并返回 0.17.0 / ready;`F:\.nosis\browser-attach\extension` 已同步,manifest 为 0.17.0。Chrome、Edge 均上报 0.17.0 并已重连。
|
|
11
|
+
|
|
12
|
+
## 0.16.0 验证
|
|
13
|
+
|
|
7
14
|
- `npm test`:184 项通过。新增 offset 的变换/越界处理、延迟区间校验、默认路径不采样随机数、拖拽曲线与精确终点、batch 参数预检、真实 stdio MCP 参数转发,以及 settle/拖拽超时后停止和调试分离取消待点击的回归。
|
|
8
15
|
- Chromium 156 与 Edge 155 headless 输入集成:各 19 个场景通过(扩展 → 独立 Hub 客户端)。新增非中心可信点击、offset hover、settle、区间逐字输入、曲线 pointer 与 HTML5 拖拽;实际记录移动坐标、可信事件和按键时间,保留原有默认路径及跨屏拖拽验证。不据此声称能通过任意网站的行为验证。
|
|
9
16
|
- Chromium 156 完整 stdio MCP 集成:244 次调用通过,保留发现/接管、输入、导航、OOPIF、日志、分页及临时页清理覆盖。
|
package/docs/USAGE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# BrowserAttach 使用指南
|
|
2
2
|
|
|
3
|
-
适用于 **0.
|
|
3
|
+
适用于 **0.17.0**:Node.js 22+、Chrome / Edge 125+、23 个 MCP 工具。安装、配对与升级见 [README](../README.md);内部实现与安全模型见 [架构说明](ARCHITECTURE.md)。
|
|
4
4
|
|
|
5
5
|
工具发现采用三层说明:初始化 `instructions` 提供跨工具规则;工具 description 说明用途、独有行为与结果语义;参数 schema 及其短说明提供类型、默认值和选择规则。本文保留完整原理、边界和示例。客户端集成时应把 MCP 初始化响应中的 instructions 传入模型上下文;仅读取 tools/list 不会包含完整的通用调用规则。
|
|
6
6
|
|
|
@@ -31,6 +31,8 @@
|
|
|
31
31
|
|
|
32
32
|
`discover.kind` 与 `tab_control.action` 必填,无默认值。`browser_id` 仅在 `kind=tabs` 时使用且必填;`focus_window` 仅在 `action=activate` 时使用,省略时为 `true`。其他分支传入这些参数会在派发前报错。新建标签仍需加载后发现并显式接管,不会自动取得控制权。
|
|
33
33
|
|
|
34
|
+
`discover(kind="browsers")` 仅在 `browsers` 中列出同版本、可接受页面操作的扩展。0.17+ 的较旧扩展自动重载期间列在 `upgrades` 中,失败时附带 `error`;维护连接不提供可用于接管的浏览器身份。从 0.17 之前版本升级时,旧扩展不具备自动重载能力,需要手动重载一次。
|
|
35
|
+
|
|
34
36
|
### 观察
|
|
35
37
|
|
|
36
38
|
| 工具 | 主要参数 | 说明 |
|
package/extension/background.js
CHANGED
|
@@ -1,98 +1,7 @@
|
|
|
1
|
-
import { CONNECTION } from './config.generated.js';
|
|
2
|
-
import { createController } from './controller.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
let
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
let status = 'disconnected';
|
|
9
|
-
let identity;
|
|
10
|
-
const controller = createController(chrome, event => {
|
|
11
|
-
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event));
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
async function getIdentity() {
|
|
15
|
-
const local = await chrome.storage.local.get(['instanceId', 'profileName']);
|
|
16
|
-
const session = await chrome.storage.session.get('runId');
|
|
17
|
-
const instanceId = local.instanceId || crypto.randomUUID();
|
|
18
|
-
const runId = session.runId || crypto.randomUUID();
|
|
19
|
-
if (!local.instanceId) await chrome.storage.local.set({ instanceId });
|
|
20
|
-
if (!session.runId) await chrome.storage.session.set({ runId });
|
|
21
|
-
const family = /Edg\//.test(navigator.userAgent) ? 'edge' : 'chrome';
|
|
22
|
-
return { instance_id: instanceId, run_id: runId, family,
|
|
23
|
-
profile_name: local.profileName || (family === 'edge' ? 'Edge' : 'Chrome'),
|
|
24
|
-
user_agent: navigator.userAgent, extension_id: chrome.runtime.id, extension_version: chrome.runtime.getManifest().version };
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
async function connect() {
|
|
28
|
-
if (connecting || socket?.readyState === WebSocket.OPEN) return;
|
|
29
|
-
connecting = true;
|
|
30
|
-
try {
|
|
31
|
-
identity = await getIdentity();
|
|
32
|
-
const ws = new WebSocket(CONNECTION.url);
|
|
33
|
-
socket = ws;
|
|
34
|
-
ws.onopen = () => {
|
|
35
|
-
ws.send(JSON.stringify({ type: 'hello', token: CONNECTION.token, browser: identity }));
|
|
36
|
-
keepAlive = setInterval(() => {
|
|
37
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
|
|
38
|
-
}, 20000);
|
|
39
|
-
};
|
|
40
|
-
ws.onmessage = async event => {
|
|
41
|
-
let request;
|
|
42
|
-
try {
|
|
43
|
-
request = JSON.parse(event.data);
|
|
44
|
-
if (request.type === 'connected') {
|
|
45
|
-
status = 'connected';
|
|
46
|
-
await chrome.action.setBadgeText({ text: 'ON' });
|
|
47
|
-
await chrome.action.setBadgeBackgroundColor({ color: '#13755b' });
|
|
48
|
-
return;
|
|
49
|
-
}
|
|
50
|
-
if (!request.id) return;
|
|
51
|
-
const result = await controller.execute(request.owner, request.command, request.arguments || {});
|
|
52
|
-
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: request.id, result }));
|
|
53
|
-
} catch (error) {
|
|
54
|
-
if (request?.id && ws.readyState === WebSocket.OPEN) {
|
|
55
|
-
ws.send(JSON.stringify({ id: request.id, error: error.message || String(error) }));
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
};
|
|
59
|
-
ws.onclose = async () => {
|
|
60
|
-
clearInterval(keepAlive);
|
|
61
|
-
status = 'disconnected';
|
|
62
|
-
connecting = false;
|
|
63
|
-
await controller.releaseAll();
|
|
64
|
-
await chrome.action.setBadgeText({ text: 'OFF' });
|
|
65
|
-
clearTimeout(reconnect);
|
|
66
|
-
reconnect = setTimeout(connect, 2000);
|
|
67
|
-
};
|
|
68
|
-
ws.onerror = () => ws.close();
|
|
69
|
-
} catch (error) {
|
|
70
|
-
connecting = false;
|
|
71
|
-
status = error.message;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
chrome.runtime.onMessage.addListener((message, sender, respond) => {
|
|
76
|
-
if (sender.id !== chrome.runtime.id) return;
|
|
77
|
-
if (message.type === 'status') {
|
|
78
|
-
getIdentity().then(browser => respond({ status, browser, claims: controller.claimedCount() }));
|
|
79
|
-
return true;
|
|
80
|
-
}
|
|
81
|
-
if (message.type === 'reconnect') {
|
|
82
|
-
if (socket?.readyState === WebSocket.OPEN) socket.close();
|
|
83
|
-
else connect();
|
|
84
|
-
respond({ ok: true });
|
|
85
|
-
}
|
|
86
|
-
if (message.type === 'release_all') {
|
|
87
|
-
controller.releaseAll().then(() => respond({ ok: true }));
|
|
88
|
-
return true;
|
|
89
|
-
}
|
|
90
|
-
});
|
|
91
|
-
chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'connect') connect(); });
|
|
92
|
-
chrome.runtime.onStartup.addListener(connect);
|
|
93
|
-
chrome.runtime.onInstalled.addListener(async () => {
|
|
94
|
-
await chrome.alarms.create('connect', { periodInMinutes: 0.5 });
|
|
95
|
-
connect();
|
|
96
|
-
});
|
|
97
|
-
chrome.alarms.create('connect', { periodInMinutes: 0.5 });
|
|
98
|
-
connect();
|
|
1
|
+
import { CONNECTION } from './config.generated.js';
|
|
2
|
+
import { createController } from './controller.js';
|
|
3
|
+
import { createConnection } from './connection.js';
|
|
4
|
+
|
|
5
|
+
let connection;
|
|
6
|
+
const controller = createController(chrome, event => connection?.send(event));
|
|
7
|
+
connection = createConnection(chrome, CONNECTION, controller);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { compareVersions } from './version.js';
|
|
2
|
+
|
|
3
|
+
export function createConnection(chrome, CONNECTION, controller) {
|
|
4
|
+
let socket;
|
|
5
|
+
let connecting = false;
|
|
6
|
+
let keepAlive;
|
|
7
|
+
let reconnect;
|
|
8
|
+
let status = 'disconnected';
|
|
9
|
+
let identity;
|
|
10
|
+
let maintenance = false;
|
|
11
|
+
let cleanup = Promise.resolve();
|
|
12
|
+
|
|
13
|
+
async function release() {
|
|
14
|
+
const result = await controller.releaseAll();
|
|
15
|
+
if (result.errors?.length) throw new Error(`Extension cleanup failed: ${result.errors.join('; ')}`);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async function prepareReload(version) {
|
|
19
|
+
const current = chrome.runtime.getManifest().version;
|
|
20
|
+
if (compareVersions(version, current) <= 0) throw new Error('Reload target must be newer than the running extension');
|
|
21
|
+
const response = await fetch(chrome.runtime.getURL('manifest.json'), { cache: 'no-store' });
|
|
22
|
+
if (!response.ok || (await response.json()).version !== version) throw new Error('Extension files do not match the Hub; reload was not attempted');
|
|
23
|
+
const previous = (await chrome.storage.local.get('reloadAttempt')).reloadAttempt;
|
|
24
|
+
if (previous?.from === current && previous.target === version) throw new Error('Extension reload did not apply the update; reload manually after checking the extension directory');
|
|
25
|
+
maintenance = true;
|
|
26
|
+
await release();
|
|
27
|
+
await chrome.storage.local.set({ reloadAttempt: { from: current, target: version } });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function getIdentity() {
|
|
31
|
+
const local = await chrome.storage.local.get(['instanceId', 'profileName']);
|
|
32
|
+
const session = await chrome.storage.session.get('runId');
|
|
33
|
+
const instanceId = local.instanceId || crypto.randomUUID();
|
|
34
|
+
const runId = session.runId || crypto.randomUUID();
|
|
35
|
+
if (!local.instanceId) await chrome.storage.local.set({ instanceId });
|
|
36
|
+
if (!session.runId) await chrome.storage.session.set({ runId });
|
|
37
|
+
const family = /Edg\//.test(navigator.userAgent) ? 'edge' : 'chrome';
|
|
38
|
+
return { instance_id: instanceId, run_id: runId, family,
|
|
39
|
+
profile_name: local.profileName || (family === 'edge' ? 'Edge' : 'Chrome'),
|
|
40
|
+
user_agent: navigator.userAgent, extension_id: chrome.runtime.id, extension_version: chrome.runtime.getManifest().version };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function connect() {
|
|
44
|
+
if (connecting || socket?.readyState === WebSocket.OPEN) return;
|
|
45
|
+
connecting = true;
|
|
46
|
+
try {
|
|
47
|
+
await cleanup;
|
|
48
|
+
await release();
|
|
49
|
+
identity = await getIdentity();
|
|
50
|
+
const ws = new WebSocket(CONNECTION.url);
|
|
51
|
+
socket = ws;
|
|
52
|
+
ws.onopen = () => {
|
|
53
|
+
ws.send(JSON.stringify({ type: 'hello', token: CONNECTION.token, browser: identity }));
|
|
54
|
+
keepAlive = setInterval(() => {
|
|
55
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
|
|
56
|
+
}, 20000);
|
|
57
|
+
};
|
|
58
|
+
ws.onmessage = async event => {
|
|
59
|
+
let request;
|
|
60
|
+
try {
|
|
61
|
+
request = JSON.parse(event.data);
|
|
62
|
+
if (request.type === 'connected') {
|
|
63
|
+
maintenance = false;
|
|
64
|
+
status = 'connected';
|
|
65
|
+
await chrome.storage.local.remove('reloadAttempt');
|
|
66
|
+
await chrome.action.setBadgeText({ text: 'ON' });
|
|
67
|
+
await chrome.action.setBadgeBackgroundColor({ color: '#13755b' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (!request.id) return;
|
|
71
|
+
let result;
|
|
72
|
+
if (request.command === 'prepare_upgrade') {
|
|
73
|
+
maintenance = true;
|
|
74
|
+
await release();
|
|
75
|
+
result = { prepared: true };
|
|
76
|
+
} else if (request.command === 'resume') {
|
|
77
|
+
maintenance = false;
|
|
78
|
+
result = { resumed: true };
|
|
79
|
+
} else if (request.command === 'reload_extension') {
|
|
80
|
+
await prepareReload(request.arguments?.version);
|
|
81
|
+
if (ws.readyState !== WebSocket.OPEN) return;
|
|
82
|
+
ws.send(JSON.stringify({ id: request.id, result: { reloading: true } }));
|
|
83
|
+
chrome.runtime.reload();
|
|
84
|
+
return;
|
|
85
|
+
} else {
|
|
86
|
+
if (maintenance) throw new Error('Extension is preparing an upgrade');
|
|
87
|
+
result = await controller.execute(request.owner, request.command, request.arguments || {});
|
|
88
|
+
}
|
|
89
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: request.id, result }));
|
|
90
|
+
} catch (error) {
|
|
91
|
+
if (request?.id && ws.readyState === WebSocket.OPEN) {
|
|
92
|
+
ws.send(JSON.stringify({ id: request.id, error: error.message || String(error) }));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
ws.onclose = () => {
|
|
97
|
+
clearInterval(keepAlive);
|
|
98
|
+
status = 'disconnected';
|
|
99
|
+
connecting = false;
|
|
100
|
+
cleanup = release().catch(error => { status = error.message; });
|
|
101
|
+
chrome.action.setBadgeText({ text: 'OFF' }).catch(() => {});
|
|
102
|
+
scheduleReconnect();
|
|
103
|
+
};
|
|
104
|
+
ws.onerror = () => ws.close();
|
|
105
|
+
} catch (error) {
|
|
106
|
+
connecting = false;
|
|
107
|
+
status = error.message;
|
|
108
|
+
scheduleReconnect();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function scheduleReconnect() {
|
|
113
|
+
clearTimeout(reconnect);
|
|
114
|
+
reconnect = setTimeout(connect, 2000);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
chrome.runtime.onMessage.addListener((message, sender, respond) => {
|
|
118
|
+
if (sender.id !== chrome.runtime.id) return;
|
|
119
|
+
if (message.type === 'status') {
|
|
120
|
+
getIdentity().then(browser => respond({ status, browser, claims: controller.claimedCount() }));
|
|
121
|
+
return true;
|
|
122
|
+
}
|
|
123
|
+
if (message.type === 'reconnect') {
|
|
124
|
+
if (socket?.readyState === WebSocket.OPEN) socket.close();
|
|
125
|
+
else connect();
|
|
126
|
+
respond({ ok: true });
|
|
127
|
+
}
|
|
128
|
+
if (message.type === 'release_all') {
|
|
129
|
+
controller.releaseAll().then(() => respond({ ok: true }));
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'connect') connect(); });
|
|
134
|
+
chrome.runtime.onStartup.addListener(connect);
|
|
135
|
+
chrome.runtime.onInstalled.addListener(async () => {
|
|
136
|
+
await chrome.alarms.create('connect', { periodInMinutes: 0.5 });
|
|
137
|
+
connect();
|
|
138
|
+
});
|
|
139
|
+
chrome.alarms.create('connect', { periodInMinutes: 0.5 });
|
|
140
|
+
connect();
|
|
141
|
+
return { send(event) { if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event)); } };
|
|
142
|
+
}
|
package/extension/manifest.json
CHANGED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function compareVersions(a, b) {
|
|
2
|
+
const parse = value => {
|
|
3
|
+
if (typeof value !== 'string' || !/^\d+\.\d+\.\d+$/.test(value)) throw new Error('Invalid BrowserAttach version');
|
|
4
|
+
const parts = value.split('.').map(Number);
|
|
5
|
+
if (!parts.every(Number.isSafeInteger)) throw new Error('Invalid BrowserAttach version');
|
|
6
|
+
return parts;
|
|
7
|
+
};
|
|
8
|
+
const left = parse(a), right = parse(b);
|
|
9
|
+
for (let i = 0; i < left.length; i++) if (left[i] !== right[i]) return Math.sign(left[i] - right[i]);
|
|
10
|
+
return 0;
|
|
11
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evan_zhong/browser-attach",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
"test": "node --test tests/*.test.mjs",
|
|
32
32
|
"test:live": "node scripts/live-smoke.mjs",
|
|
33
33
|
"test:interaction": "node scripts/interaction-smoke.mjs",
|
|
34
|
+
"test:upgrade": "node scripts/upgrade-smoke.mjs",
|
|
34
35
|
"test:package": "node scripts/package-smoke.mjs",
|
|
35
36
|
"prepack": "node scripts/check-package.mjs"
|
|
36
37
|
},
|
|
@@ -14,6 +14,7 @@ const names = result.files.map(file => file.path);
|
|
|
14
14
|
for (const path of names) {
|
|
15
15
|
if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
|
|
16
16
|
}
|
|
17
|
+
for (const path of ['extension/connection.js', 'extension/version.js']) if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
17
18
|
for (const path of ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.js', 'extension/fetch.js', 'extension/locator.js', 'extension/batch.js', 'extension/wait.js', 'extension/outcomes.js', 'extension/scroll.js', 'extension/drag.js', 'extension/text.js', 'extension/diagnostics.js', 'extension/sessions.js', 'extension/actionability.js', 'extension/limits.js', 'extension/icons/BrowserAttach.png']) {
|
|
18
19
|
if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
19
20
|
}
|
package/src/client.mjs
CHANGED
|
@@ -5,43 +5,86 @@ import { dirname, join, resolve } from 'node:path';
|
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
6
6
|
import WebSocket from 'ws';
|
|
7
7
|
import { loadConfig, VERSION } from './config.mjs';
|
|
8
|
+
import { compareVersions } from '../extension/version.js';
|
|
8
9
|
|
|
9
|
-
|
|
10
|
+
async function hubHealth(config) {
|
|
10
11
|
let response;
|
|
11
12
|
try {
|
|
12
13
|
response = await fetch(`http://127.0.0.1:${config.port}/health`, {
|
|
13
14
|
headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(1000),
|
|
14
15
|
});
|
|
15
16
|
} catch (error) {
|
|
16
|
-
if (error.cause?.code
|
|
17
|
+
if (['ECONNREFUSED', 'ECONNRESET', 'ECONNABORTED', 'UND_ERR_SOCKET'].includes(error.cause?.code) || error.name === 'TimeoutError') return null;
|
|
17
18
|
throw error;
|
|
18
19
|
}
|
|
19
20
|
if (!response.ok) throw new Error('BrowserAttach port is occupied by an unpaired service');
|
|
20
21
|
const health = await response.json();
|
|
21
|
-
if (health.service !== 'browser-attach'
|
|
22
|
-
|
|
23
|
-
return
|
|
22
|
+
if (health.service !== 'browser-attach') throw new Error('BrowserAttach port is occupied by another service');
|
|
23
|
+
compareVersions(health.version, VERSION);
|
|
24
|
+
return health;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function hubIsRunning(config) {
|
|
28
|
+
const health = await hubHealth(config);
|
|
29
|
+
if (!health) return false;
|
|
30
|
+
if (health.version !== VERSION) throw new Error(`Hub ${health.version} differs from this package ${VERSION}; reconnect MCP to coordinate an upgrade`);
|
|
31
|
+
return health.state === 'ready';
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
export async function ensureHub(configPath) {
|
|
27
35
|
configPath = resolve(configPath);
|
|
28
36
|
const config = await loadConfig(configPath);
|
|
29
|
-
|
|
37
|
+
const deadline = Date.now() + 18000;
|
|
38
|
+
const expected = new Set();
|
|
39
|
+
let spawned = false, requested = false, readyAfter = 0, spawnError;
|
|
40
|
+
while (Date.now() < deadline) {
|
|
41
|
+
if (spawnError) throw spawnError;
|
|
42
|
+
const health = await hubHealth(config);
|
|
43
|
+
if (!health) {
|
|
44
|
+
if (!spawned) {
|
|
45
|
+
spawned = true;
|
|
46
|
+
await spawnHub(configPath, error => { spawnError = error; });
|
|
47
|
+
readyAfter = Date.now() + 2500;
|
|
48
|
+
}
|
|
49
|
+
} else {
|
|
50
|
+
const comparison = compareVersions(health.version, VERSION);
|
|
51
|
+
if (comparison > 0) throw new Error(`Hub ${health.version} is newer than this package ${VERSION}; refusing to downgrade`);
|
|
52
|
+
if (comparison < 0 && health.state == null) {
|
|
53
|
+
throw new Error(`Hub ${health.version} predates coordinated upgrades; run browser-attach stop, restart MCP, and reload the extension manually if it was already loaded`);
|
|
54
|
+
}
|
|
55
|
+
if (comparison < 0 && health.state === 'ready' && !requested) {
|
|
56
|
+
requested = true;
|
|
57
|
+
for (const item of health.extensions) expected.add(item.instance_id);
|
|
58
|
+
const response = await fetch(`http://127.0.0.1:${config.port}/upgrade`, { method: 'POST',
|
|
59
|
+
headers: { Authorization: `Bearer ${config.token}`, 'X-Browser-Attach-Version': VERSION }, signal: AbortSignal.timeout(10000) });
|
|
60
|
+
const result = await response.json();
|
|
61
|
+
if (!response.ok) {
|
|
62
|
+
const current = await hubHealth(config);
|
|
63
|
+
if (current && compareVersions(current.version, VERSION) < 0 && !['upgrading', 'stopping'].includes(current.state)) throw new Error(result.error);
|
|
64
|
+
} else for (const id of result.extensions) expected.add(id);
|
|
65
|
+
} else if (comparison < 0 && health.state === 'ready' && requested) {
|
|
66
|
+
throw new Error(health.upgrade_error || 'Hub upgrade did not complete; reconnect MCP to retry');
|
|
67
|
+
} else if (comparison === 0 && health.state === 'ready') {
|
|
68
|
+
const failure = health.extensions.find(item => item.error);
|
|
69
|
+
if (failure) throw new Error(`Extension upgrade failed (${failure.instance_id}): ${failure.error}`);
|
|
70
|
+
for (const item of health.extensions) if (item.ready) expected.delete(item.instance_id);
|
|
71
|
+
if (!expected.size && health.extensions.every(item => item.ready) && Date.now() >= readyAfter) return config;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
75
|
+
}
|
|
76
|
+
throw new Error(`Hub/extension upgrade did not become ready${expected.size ? `; waiting for ${[...expected].join(', ')}` : ''}. See ${join(dirname(configPath), 'hub.log')}`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function spawnHub(configPath, onError) {
|
|
30
80
|
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
31
81
|
const log = await open(join(dirname(configPath), 'hub.log'), 'a', 0o600);
|
|
32
82
|
const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/browser-attach.mjs', import.meta.url)), 'hub', '--config', configPath], {
|
|
33
83
|
detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd],
|
|
34
84
|
env: { ...process.env, BROWSER_ATTACH_HOME: dirname(configPath) },
|
|
35
85
|
});
|
|
36
|
-
|
|
37
|
-
child.on('error', error => { spawnError = error; });
|
|
86
|
+
child.on('error', onError);
|
|
38
87
|
child.unref(); await log.close();
|
|
39
|
-
for (let i = 0; i < 40; i++) {
|
|
40
|
-
if (spawnError) throw spawnError;
|
|
41
|
-
await new Promise(resolve => setTimeout(resolve, 150));
|
|
42
|
-
if (await hubIsRunning(config)) return config;
|
|
43
|
-
}
|
|
44
|
-
throw new Error(`Hub did not start. See ${join(dirname(configPath), 'hub.log')}`);
|
|
45
88
|
}
|
|
46
89
|
|
|
47
90
|
export class AttachClient {
|
|
@@ -51,7 +94,7 @@ export class AttachClient {
|
|
|
51
94
|
if (this.connecting) return this.connecting;
|
|
52
95
|
this.connecting = new Promise((resolve, reject) => {
|
|
53
96
|
const ws = new WebSocket(`ws://127.0.0.1:${this.config.port}/client`, {
|
|
54
|
-
headers: { Authorization: `Bearer ${this.config.token}
|
|
97
|
+
headers: { Authorization: `Bearer ${this.config.token}`, 'X-Browser-Attach-Version': VERSION }, maxPayload: 24 * 1024 * 1024,
|
|
55
98
|
});
|
|
56
99
|
this.socket = ws;
|
|
57
100
|
ws.once('open', resolve); ws.once('error', reject);
|
package/src/config.mjs
CHANGED
|
@@ -15,13 +15,17 @@ 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.17.0';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
|
22
22
|
try { config = JSON.parse(await readFile(path, 'utf8')); }
|
|
23
23
|
catch (error) { throw new Error(`Cannot read pairing config ${path}${error.code ? ` (${error.code})` : ''}. Run browser-attach setup.`, { cause: error }); }
|
|
24
|
+
// Pre-0.17 pairings did not record the extension directory; their setup default
|
|
25
|
+
// was a sibling `extension` directory next to the pairing file.
|
|
26
|
+
if (config.extension_dir === undefined) config.extension_dir = join(dirname(resolve(path)), 'extension');
|
|
24
27
|
if (typeof config.token !== 'string' || config.token.length < 32 ||
|
|
28
|
+
typeof config.extension_dir !== 'string' || !isAbsolute(config.extension_dir) ||
|
|
25
29
|
!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535 ||
|
|
26
30
|
!/^[a-p]{32}$/.test(config.extension_id)) throw new Error('Invalid pairing config. Run browser-attach setup.');
|
|
27
31
|
return config;
|
package/src/hub.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import http from 'node:http';
|
|
|
3
3
|
import { randomUUID, timingSafeEqual } from 'node:crypto';
|
|
4
4
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
5
5
|
import { VERSION } from './config.mjs';
|
|
6
|
+
import { syncExtension } from './setup.mjs';
|
|
7
|
+
import { compareVersions } from '../extension/version.js';
|
|
6
8
|
|
|
7
9
|
const MAX_MESSAGE = 24 * 1024 * 1024;
|
|
8
10
|
const OPERATIONS = new Set(['release_tab', 'activate_tab', 'close_tab', 'snapshot', 'read_text', 'type', 'hover', 'drag', 'wait', 'select_option', 'click', 'fill', 'press_key', 'batch', 'scroll', 'navigate', 'screenshot', 'handle_dialog', 'upload_file', 'evaluate', 'logs']);
|
|
@@ -12,6 +14,8 @@ const send = (ws, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.
|
|
|
12
14
|
|
|
13
15
|
export async function startHub(config, { port = config.port, operationTimeout = HUB_OPERATION_TIMEOUT_MS } = {}) {
|
|
14
16
|
const browsers = new Map(), clients = new Set(), claims = new Map(), tabClaims = new Map(), queues = new Map();
|
|
17
|
+
const cleanups = new Set();
|
|
18
|
+
let state = 'starting', upgradeError;
|
|
15
19
|
const authorized = token => typeof token === 'string' && Buffer.byteLength(token) === Buffer.byteLength(config.token)
|
|
16
20
|
&& timingSafeEqual(Buffer.from(token), Buffer.from(config.token));
|
|
17
21
|
const bearer = request => authorized(request.headers.authorization?.replace(/^Bearer /, ''));
|
|
@@ -28,13 +32,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
28
32
|
result.then(done, done);
|
|
29
33
|
return result;
|
|
30
34
|
}
|
|
31
|
-
function requestBrowser(browser, owner, command, args) {
|
|
35
|
+
function requestBrowser(browser, owner, command, args, timeout = operationTimeout) {
|
|
32
36
|
return new Promise((resolve, reject) => {
|
|
33
37
|
const id = randomUUID();
|
|
34
38
|
const timer = setTimeout(() => {
|
|
35
39
|
browser.pending.delete(id);
|
|
36
40
|
reject(new OperationTimeout('Browser operation timed out; outcome may be unknown. Connection will release its tabs. List and claim again.'));
|
|
37
|
-
},
|
|
41
|
+
}, timeout);
|
|
38
42
|
browser.pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
|
|
39
43
|
if (browser.ws.readyState !== WebSocket.OPEN) {
|
|
40
44
|
clearTimeout(timer); browser.pending.delete(id); reject(new Error('Browser disconnected')); return;
|
|
@@ -44,12 +48,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
44
48
|
}
|
|
45
49
|
async function execute(owner, observed, method, args) {
|
|
46
50
|
if (method === 'list_browsers') return {
|
|
47
|
-
browsers: [...browsers.values()].map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
|
|
51
|
+
browsers: [...browsers.values()].filter(browser => browser.ready).map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
|
|
52
|
+
upgrades: [...browsers.values()].filter(browser => !browser.ready).map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, error: browser.upgradeError })),
|
|
48
53
|
hint: 'Load the paired extension in each Chrome/Edge profile if empty.',
|
|
49
54
|
};
|
|
50
55
|
if (method === 'list_tabs' || method === 'claim_tab' || method === 'open_tab' || method === 'fetch') {
|
|
51
56
|
const browser = browsers.get(args.browser_id);
|
|
52
|
-
if (!browser) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
|
|
57
|
+
if (!browser?.ready) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
|
|
53
58
|
if (method === 'open_tab' || method === 'fetch') return await requestBrowser(browser, owner, method, args);
|
|
54
59
|
if (method === 'list_tabs') {
|
|
55
60
|
const result = await requestBrowser(browser, owner, method, {});
|
|
@@ -93,10 +98,36 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
93
98
|
}
|
|
94
99
|
|
|
95
100
|
let closing;
|
|
96
|
-
const server = http.createServer((request, response) => {
|
|
101
|
+
const server = http.createServer(async (request, response) => {
|
|
97
102
|
if (request.headers.origin || !bearer(request)) { response.writeHead(403).end(); return; }
|
|
98
103
|
response.setHeader('Content-Type', 'application/json');
|
|
99
|
-
if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION,
|
|
104
|
+
if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION, state,
|
|
105
|
+
browsers: [...browsers.values()].filter(browser => browser.ready).length, clients: clients.size, upgrade_error: upgradeError,
|
|
106
|
+
extensions: [...browsers.values()].map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, ready: browser.ready, error: browser.upgradeError })) }));
|
|
107
|
+
else if (request.url === '/upgrade' && request.method === 'POST') {
|
|
108
|
+
try {
|
|
109
|
+
const target = request.headers['x-browser-attach-version'];
|
|
110
|
+
if (compareVersions(target, VERSION) <= 0) { response.writeHead(409).end(JSON.stringify({ error: 'Upgrade target must be newer than this Hub' })); return; }
|
|
111
|
+
if (state !== 'ready' || clients.size || cleanups.size || [...browsers.values()].some(browser => browser.pending.size)) {
|
|
112
|
+
response.writeHead(409).end(JSON.stringify({ error: 'Hub is in use; close other MCP connections and retry the upgrade' })); return;
|
|
113
|
+
}
|
|
114
|
+
state = 'upgrading';
|
|
115
|
+
upgradeError = undefined;
|
|
116
|
+
const connected = [...browsers.values()];
|
|
117
|
+
const outcomes = await Promise.allSettled(connected.map(async browser => {
|
|
118
|
+
const result = await requestBrowser(browser, null, 'prepare_upgrade', {}, 8000);
|
|
119
|
+
if (result.errors?.length || result.prepared !== true) throw new Error(result.errors?.join('; ') || 'Extension did not confirm cleanup');
|
|
120
|
+
}));
|
|
121
|
+
const failed = outcomes.find(item => item.status === 'rejected');
|
|
122
|
+
if (failed) {
|
|
123
|
+
await Promise.allSettled(connected.map(browser => requestBrowser(browser, null, 'resume', {}, 1000)));
|
|
124
|
+
upgradeError = `Extension cleanup failed: ${failed.reason.message}`;
|
|
125
|
+
state = 'ready'; throw new Error(upgradeError);
|
|
126
|
+
}
|
|
127
|
+
response.end(JSON.stringify({ stopping: true, extensions: connected.map(browser => browser.info.instance_id) }));
|
|
128
|
+
setImmediate(() => close());
|
|
129
|
+
} catch (error) { response.writeHead(409).end(JSON.stringify({ error: error.message })); }
|
|
130
|
+
}
|
|
100
131
|
else if (request.url === '/shutdown' && request.method === 'POST') {
|
|
101
132
|
response.end('{"stopping":true}'); setImmediate(() => close());
|
|
102
133
|
} else response.writeHead(404).end();
|
|
@@ -105,8 +136,9 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
105
136
|
server.on('upgrade', (request, socket, head) => {
|
|
106
137
|
const extension = request.url === '/extension';
|
|
107
138
|
const valid = extension ? request.headers.origin === `chrome-extension://${config.extension_id}`
|
|
108
|
-
: request.url === '/client' && !request.headers.origin && bearer(request);
|
|
139
|
+
: request.url === '/client' && !request.headers.origin && bearer(request) && request.headers['x-browser-attach-version'] === VERSION;
|
|
109
140
|
if (!valid) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
|
|
141
|
+
if (state !== 'ready') { socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); return; }
|
|
110
142
|
wss.handleUpgrade(request, socket, head, ws => {
|
|
111
143
|
ws.on('pong', () => { ws.isAlive = true; });
|
|
112
144
|
if (extension) connectExtension(ws); else connectClient(ws);
|
|
@@ -120,18 +152,23 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
120
152
|
try {
|
|
121
153
|
const data = JSON.parse(raw.toString());
|
|
122
154
|
if (!browser) {
|
|
155
|
+
if (state !== 'ready') { ws.close(1013, 'Hub is preparing an upgrade'); return; }
|
|
123
156
|
const info = data.browser;
|
|
124
|
-
if (data.type !== 'hello' || !authorized(data.token) || !info ||
|
|
157
|
+
if (data.type !== 'hello' || !authorized(data.token) || !info || !['chrome', 'edge'].includes(info.family) ||
|
|
125
158
|
!['instance_id', 'run_id', 'profile_name'].every(key => typeof info[key] === 'string' && info[key].length >= 1 && info[key].length <= 128))
|
|
126
159
|
throw new Error('Invalid pairing or browser identity');
|
|
127
160
|
const id = `${info.instance_id}:${info.run_id}`;
|
|
128
161
|
if (browsers.has(id)) throw new Error('Browser instance is already connected');
|
|
129
|
-
|
|
162
|
+
const comparison = compareVersions(info.extension_version, VERSION);
|
|
163
|
+
if (comparison > 0) throw new Error('Extension is newer than this Hub');
|
|
164
|
+
browser = { id, ws, info, ready: comparison === 0, pending: new Map() };
|
|
130
165
|
browsers.set(id, browser); clearTimeout(helloTimer);
|
|
131
|
-
send(ws, { type: 'connected', browser_id: id });
|
|
166
|
+
if (browser.ready) send(ws, { type: 'connected', browser_id: id });
|
|
167
|
+
else requestBrowser(browser, null, 'reload_extension', { version: VERSION }).catch(error => { browser.upgradeError = error.message; });
|
|
168
|
+
return;
|
|
132
169
|
}
|
|
133
170
|
if (data.type === 'ping') send(ws, { type: 'pong' });
|
|
134
|
-
else if (data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
|
|
171
|
+
else if (browser.ready && data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
|
|
135
172
|
else if (browser.pending.has(data.id)) {
|
|
136
173
|
const pending = browser.pending.get(data.id); browser.pending.delete(data.id);
|
|
137
174
|
if ('error' in data) pending.reject(new Error(String(data.error))); else pending.resolve(data.result || {});
|
|
@@ -168,10 +205,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
168
205
|
});
|
|
169
206
|
ws.on('close', () => {
|
|
170
207
|
disconnected = true; clients.delete(ws);
|
|
171
|
-
chain.
|
|
172
|
-
await Promise.allSettled([...browsers.values()].
|
|
173
|
-
|
|
174
|
-
|
|
208
|
+
const cleanup = chain.then(async () => {
|
|
209
|
+
await Promise.allSettled([...browsers.values()].filter(browser => browser.ready).map(async browser => {
|
|
210
|
+
const result = await requestBrowser(browser, owner, 'release_owner', {});
|
|
211
|
+
if (!result.errors?.length) for (const [handle, claim] of claims) if (claim.owner === owner && claim.browser_id === browser.id) forget(handle);
|
|
212
|
+
}));
|
|
213
|
+
}).finally(() => cleanups.delete(cleanup));
|
|
214
|
+
cleanups.add(cleanup);
|
|
175
215
|
});
|
|
176
216
|
}
|
|
177
217
|
const heartbeat = setInterval(() => {
|
|
@@ -183,6 +223,7 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
183
223
|
async function close() {
|
|
184
224
|
if (closing) return closing;
|
|
185
225
|
closing = (async () => {
|
|
226
|
+
state = 'stopping';
|
|
186
227
|
clearInterval(heartbeat);
|
|
187
228
|
for (const ws of wss.clients) ws.close(1001, 'Hub stopped');
|
|
188
229
|
const timer = setTimeout(() => { for (const ws of wss.clients) ws.terminate(); }, 1000);
|
|
@@ -191,7 +232,11 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
191
232
|
})();
|
|
192
233
|
return closing;
|
|
193
234
|
}
|
|
194
|
-
try {
|
|
195
|
-
|
|
235
|
+
try {
|
|
236
|
+
await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
|
|
237
|
+
config.port = server.address().port;
|
|
238
|
+
if (config.extension_dir) await syncExtension(config);
|
|
239
|
+
state = 'ready';
|
|
240
|
+
} catch (error) { await close(); throw error; }
|
|
196
241
|
return { server, close, port: server.address().port, claims, browsers };
|
|
197
242
|
}
|
package/src/mcp.mjs
CHANGED
|
@@ -194,6 +194,7 @@ export function createMcp(client) {
|
|
|
194
194
|
|
|
195
195
|
export async function runMcp(configPath) {
|
|
196
196
|
const client = new AttachClient(await ensureHub(configPath));
|
|
197
|
+
await client.connect();
|
|
197
198
|
const server = createMcp(client);
|
|
198
199
|
const transport = new StdioServerTransport();
|
|
199
200
|
transport.onclose = () => client.close();
|
package/src/setup.mjs
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { randomBytes, createHash } from 'node:crypto';
|
|
2
|
-
import { cp, mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
|
|
3
|
-
import { join, resolve, dirname } from 'node:path';
|
|
2
|
+
import { cp, copyFile, mkdir, mkdtemp, readFile, writeFile, chmod, rename, rm, rmdir, readdir, stat } from 'node:fs/promises';
|
|
3
|
+
import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
|
-
import { ROOT, DEFAULT_CONFIG, loadConfig, writeJson } from './config.mjs';
|
|
6
|
+
import { ROOT, DEFAULT_CONFIG, VERSION, loadConfig, writeJson } from './config.mjs';
|
|
7
|
+
import { compareVersions } from '../extension/version.js';
|
|
7
8
|
|
|
8
9
|
export const mcpSettings = (configPath = DEFAULT_CONFIG) => ({ command: process.execPath,
|
|
9
10
|
args: [join(ROOT, 'bin/browser-attach.mjs'), 'mcp', '--config', resolve(configPath)],
|
|
@@ -19,33 +20,103 @@ async function privateDirectory(path) {
|
|
|
19
20
|
} else await chmod(path, 0o700);
|
|
20
21
|
}
|
|
21
22
|
|
|
22
|
-
export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port
|
|
23
|
+
export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port } = {}) {
|
|
23
24
|
configPath = resolve(configPath);
|
|
24
|
-
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
|
|
25
25
|
await privateDirectory(dirname(configPath));
|
|
26
|
-
|
|
27
|
-
if (extensionDir !== resolve(ROOT, 'extension')) {
|
|
28
|
-
await cp(join(ROOT, 'extension'), extensionDir, { recursive: true, filter: source => !source.endsWith('config.generated.js') });
|
|
29
|
-
}
|
|
30
|
-
await privateDirectory(extensionDir);
|
|
31
|
-
const manifestPath = join(extensionDir, 'manifest.json');
|
|
32
|
-
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
26
|
+
const manifest = JSON.parse(await readFile(join(ROOT, 'extension/manifest.json'), 'utf8'));
|
|
33
27
|
const extensionId = [...createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32)]
|
|
34
28
|
.map(char => String.fromCharCode(97 + parseInt(char, 16))).join('');
|
|
35
29
|
let config;
|
|
36
30
|
try { config = await loadConfig(configPath); }
|
|
37
31
|
catch (error) { if (error.cause?.code !== 'ENOENT') throw error; }
|
|
32
|
+
port ??= config?.port ?? 47831;
|
|
33
|
+
if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
|
|
34
|
+
extensionDir = resolve(extensionDir || config?.extension_dir || join(dirname(configPath), 'extension'));
|
|
35
|
+
const configRelative = relative(extensionDir, configPath);
|
|
36
|
+
if (dirname(extensionDir) === extensionDir || (!isAbsolute(configRelative) && configRelative !== '..' && !configRelative.startsWith('..' + sep)))
|
|
37
|
+
throw new Error('Choose a dedicated extension directory outside the pairing file');
|
|
38
38
|
config ||= { port, token: randomBytes(48).toString('base64url'), extension_id: extensionId };
|
|
39
39
|
if (config.port !== port || config.extension_id !== extensionId) throw new Error('Pairing differs from requested port/extension. Stop the hub and remove the pairing config before a fresh setup.');
|
|
40
|
+
if (config.extension_dir && resolve(config.extension_dir) !== extensionDir) throw new Error('Use the paired extension directory, or a separate pairing config for a different directory.');
|
|
40
41
|
config.extension_dir = extensionDir;
|
|
41
|
-
manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${port}` };
|
|
42
|
-
await writeJson(manifestPath, manifest);
|
|
43
|
-
await writeFile(join(extensionDir, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
|
|
44
|
-
JSON.stringify({ url: `ws://127.0.0.1:${port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
|
|
45
42
|
await writeJson(configPath, config);
|
|
46
43
|
return { config, configPath, extensionDir };
|
|
47
44
|
}
|
|
48
45
|
|
|
46
|
+
// Only the Hub holding the listening port may replace the stable copy.
|
|
47
|
+
export async function syncExtension(config) {
|
|
48
|
+
const source = resolve(ROOT, 'extension'), destination = resolve(config.extension_dir);
|
|
49
|
+
const manifest = JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8'));
|
|
50
|
+
const backup = destination + '.previous';
|
|
51
|
+
async function installed(directory) {
|
|
52
|
+
try {
|
|
53
|
+
const value = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8'));
|
|
54
|
+
if (value.key !== manifest.key) throw new Error(`Unrecognized extension directory: ${directory}`);
|
|
55
|
+
return value;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error.code !== 'ENOENT') throw error;
|
|
58
|
+
try { if ((await readdir(directory)).length) throw new Error(`Unrecognized extension directory: ${directory}`); }
|
|
59
|
+
catch (missing) { if (missing.code !== 'ENOENT') throw missing; }
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
async function files(directory) {
|
|
64
|
+
const entries = await readdir(directory, { recursive: true });
|
|
65
|
+
const kinds = await Promise.all(entries.map(path => stat(join(directory, path))));
|
|
66
|
+
return entries.filter((_, index) => kinds[index].isFile());
|
|
67
|
+
}
|
|
68
|
+
async function install(directory) {
|
|
69
|
+
await privateDirectory(destination);
|
|
70
|
+
const incoming = await files(directory), existing = await files(destination);
|
|
71
|
+
// Keep the loaded directory itself stable and commit its manifest last.
|
|
72
|
+
for (const path of incoming.filter(path => path !== 'manifest.json')) {
|
|
73
|
+
const target = join(destination, path);
|
|
74
|
+
await mkdir(dirname(target), { recursive: true, mode: 0o700 });
|
|
75
|
+
await copyFile(join(directory, path), target + '.updating');
|
|
76
|
+
await rename(target + '.updating', target);
|
|
77
|
+
}
|
|
78
|
+
for (const path of existing) if (!incoming.includes(path)) await rm(join(destination, path));
|
|
79
|
+
await writeJson(join(destination, 'manifest.json'), JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')));
|
|
80
|
+
}
|
|
81
|
+
let current = await installed(destination);
|
|
82
|
+
if (current && compareVersions(current.version, VERSION) > 0) throw new Error('Refusing to downgrade the installed extension');
|
|
83
|
+
const previous = destination === source ? null : await installed(backup);
|
|
84
|
+
if (previous) {
|
|
85
|
+
await install(backup);
|
|
86
|
+
current = previous;
|
|
87
|
+
await rm(backup, { recursive: true });
|
|
88
|
+
}
|
|
89
|
+
await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
|
|
90
|
+
const stage = destination === source ? source : await mkdtemp(destination + '.staging-');
|
|
91
|
+
try {
|
|
92
|
+
if (stage !== source) await cp(source, stage, { recursive: true, filter: path => !path.endsWith('config.generated.js') });
|
|
93
|
+
await privateDirectory(stage);
|
|
94
|
+
manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${config.port}` };
|
|
95
|
+
await writeJson(join(stage, 'manifest.json'), manifest);
|
|
96
|
+
await writeFile(join(stage, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
|
|
97
|
+
JSON.stringify({ url: `ws://127.0.0.1:${config.port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
|
|
98
|
+
if (stage !== source) {
|
|
99
|
+
if (current) {
|
|
100
|
+
await privateDirectory(stage + '.previous');
|
|
101
|
+
await cp(destination, stage + '.previous', { recursive: true });
|
|
102
|
+
await rename(stage + '.previous', backup);
|
|
103
|
+
} else {
|
|
104
|
+
try { await rmdir(destination); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
105
|
+
await rename(stage, destination);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
try { await install(stage); }
|
|
109
|
+
catch (error) { if (current) await install(backup); throw error; }
|
|
110
|
+
if (current) await rm(backup, { recursive: true });
|
|
111
|
+
}
|
|
112
|
+
} finally {
|
|
113
|
+
if (stage !== source) {
|
|
114
|
+
await rm(stage, { recursive: true, force: true });
|
|
115
|
+
await rm(stage + '.previous', { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
49
120
|
export async function configureNosis(directory = join(homedir(), '.nosis'), { configPath = DEFAULT_CONFIG } = {}) {
|
|
50
121
|
const path = join(directory, 'agent_config.json');
|
|
51
122
|
const data = JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));
|