@glittering0717/ai-browser 2.0.0 → 2.1.1
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/bin/ai-browser.cjs +9 -0
- package/build/icon.icns +0 -0
- package/build/icon.png +0 -0
- package/llms-install.md +44 -7
- package/package.json +5 -2
- package/scripts/cli_call.cjs +49 -5
- package/scripts/ensure_running.cjs +195 -0
- package/skills/ai-browser/SKILL.md +26 -25
- package/skills/ai-browser/references/cli.md +81 -0
- package/src/main/index.js +32 -0
- package/src/main/mcp_server.js +17 -87
- package/src/main/page_manager.js +57 -9
- package/src/main/window_open_shim_source.js +4 -1
- package/src/main/ws_server.js +13 -1
- package/src/shared/version.js +1 -1
package/bin/ai-browser.cjs
CHANGED
|
@@ -25,6 +25,8 @@ Usage:
|
|
|
25
25
|
Install the bundled agent skills into your client
|
|
26
26
|
ai-browser call <method> [json|-]
|
|
27
27
|
Call one JSON-RPC method and print the result
|
|
28
|
+
(starts the browser first if it is not running)
|
|
29
|
+
ai-browser ensure Start the browser if needed, then exit (idempotent)
|
|
28
30
|
ai-browser status Is the browser running? pid / port / tabs
|
|
29
31
|
ai-browser --version Print version
|
|
30
32
|
ai-browser --help Show this help
|
|
@@ -83,6 +85,13 @@ async function main() {
|
|
|
83
85
|
process.exit(await runStatus(argv.slice(1)));
|
|
84
86
|
break;
|
|
85
87
|
}
|
|
88
|
+
case 'ensure': {
|
|
89
|
+
// 只把浏览器弄到"能连"的状态,然后退出。存在的理由:让 `call` 之外还有一条
|
|
90
|
+
// **显式**的启动动作,脚本/CI 里想先起再调时用它(幂等,已在跑就是 no-op)。
|
|
91
|
+
const { runEnsure } = require('../scripts/cli_call.cjs');
|
|
92
|
+
process.exit(await runEnsure(argv.slice(1)));
|
|
93
|
+
break;
|
|
94
|
+
}
|
|
86
95
|
case '--version':
|
|
87
96
|
case '-v':
|
|
88
97
|
case 'version':
|
package/build/icon.icns
ADDED
|
Binary file
|
package/build/icon.png
ADDED
|
Binary file
|
package/llms-install.md
CHANGED
|
@@ -11,8 +11,9 @@ semantic tree (roles / labels / values / actions, stable `data-ai-id` per elemen
|
|
|
11
11
|
not screenshots. Input is trusted CDP keyboard/mouse events. A human can use the
|
|
12
12
|
window's mouse and keyboard at any time (login, QR scan, CAPTCHA, payment).
|
|
13
13
|
|
|
14
|
-
Two equivalent interfaces: **MCP stdio** (
|
|
15
|
-
JSON-RPC API at `ws://localhost:9223` (`ui.*` methods).
|
|
14
|
+
Two equivalent interfaces: **MCP stdio** (17 `browse_*` tools) and a raw WebSocket
|
|
15
|
+
JSON-RPC API at `ws://localhost:9223` (`ui.*` methods). A CLI (`ai-browser call ui.xxx`) speaks
|
|
16
|
+
the same JSON-RPC and needs **no registration at all** — see "Fastest path" below.
|
|
16
17
|
|
|
17
18
|
## Requirements
|
|
18
19
|
|
|
@@ -65,6 +66,28 @@ ls node_modules/electron/dist # must contain Electron.app (macOS)
|
|
|
65
66
|
Optional end-to-end check: `npx -y @glittering0717/ai-browser start` (or `npm start` from source) opens
|
|
66
67
|
a 1280×800 window and logs `AI Browser WS server listening on ws://localhost:9223`.
|
|
67
68
|
|
|
69
|
+
## Fastest path — works with zero registration
|
|
70
|
+
|
|
71
|
+
If you just need to drive the browser now, skip the MCP section entirely. The CLI reaches the
|
|
72
|
+
same engine, and **starts the browser itself if it is not running** (idempotent; no separate
|
|
73
|
+
`start` step):
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npm i -g @glittering0717/ai-browser # or: npm link (from a source checkout)
|
|
77
|
+
ai-browser call ui.navigate '{"url":"https://example.com"}'
|
|
78
|
+
ai-browser call ui.get_tree '{"subset":"interactive"}'
|
|
79
|
+
ai-browser call ui.act '{"action":"click","target":"e:a-3"}'
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
No global install? Prefix every command with `npx -y @glittering0717/ai-browser`.
|
|
83
|
+
|
|
84
|
+
Why this matters more than it looks: registering an MCP server passes through **three gates the
|
|
85
|
+
agent does not control** — the config file, a one-time trust/approval for executing a local
|
|
86
|
+
program, and the client's tool snapshot taken at session start. If any one of them has not
|
|
87
|
+
completed, `browse_*` simply does not exist in the tool list, and the symptom is identical to
|
|
88
|
+
"the capability is not installed". A weaker model then spends a long time concluding it is
|
|
89
|
+
misconfigured. The CLI has none of those gates: it only needs the command to run.
|
|
90
|
+
|
|
68
91
|
## Register as an MCP server
|
|
69
92
|
|
|
70
93
|
Add this to the client config (Claude Desktop config, `~/.cursor/mcp.json`, project `.mcp.json`,
|
|
@@ -105,7 +128,7 @@ call can take longer if the engine download hasn't happened yet — the progress
|
|
|
105
128
|
A long-lived shared window is also fine — run the `start` command manually; a second launch only
|
|
106
129
|
focuses the existing window (single instance lock).
|
|
107
130
|
|
|
108
|
-
## The
|
|
131
|
+
## The 17 tools
|
|
109
132
|
|
|
110
133
|
- `browse_navigate` — load a URL in the active or given tab.
|
|
111
134
|
- `browse_get_tree` — semantic tree + context (modals, toasts, required-field
|
|
@@ -142,10 +165,24 @@ focuses the existing window (single instance lock).
|
|
|
142
165
|
`switch` sets the default target for later calls, so you rarely pass `tab`.
|
|
143
166
|
- `browse_quit` — shut the whole browser down and release the process.
|
|
144
167
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
168
|
+
**Which door should you use?** Prefer the **CLI** unless the tool list already contains
|
|
169
|
+
`browse_*`. MCP is a nicer interface (schemas are self-describing) but it is not under your
|
|
170
|
+
control; the CLI is. Both hit one implementation (`page_manager`) — there is no capability that
|
|
171
|
+
only exists over MCP.
|
|
172
|
+
|
|
173
|
+
```bash
|
|
174
|
+
ai-browser status # is it running? pid / port / tabs
|
|
175
|
+
ai-browser ensure # start it if needed, then exit
|
|
176
|
+
ai-browser call ui.list_tabs
|
|
177
|
+
ai-browser call ui.navigate '{"url":"..."}'
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
CLI method names are the tool names with `browse_` → `ui.` (`get_tree`, `act`, `navigate`,
|
|
181
|
+
`evaluate`, `scroll`, `wait`, `screenshot`, `errors`, `capabilities`, `downloads`, `quit`,
|
|
182
|
+
`auth_state`, `repaint`). The four that do not map one-to-one: `tabs` → `ui.list_tabs` /
|
|
183
|
+
`ui.new_tab` / `ui.close_tab` / `ui.set_active_tab`; `network` → `ui.network_list` /
|
|
184
|
+
`ui.network_get`; `canvas` → `ui.canvas_list` / `ui.canvas_read` / `ui.canvas_capture` /
|
|
185
|
+
`ui.canvas_configure`; `diff` → `ui.snapshot` / `ui.diff`.
|
|
149
186
|
|
|
150
187
|
There is no `browse_subscribe` and no `browse_network_body`: real-time events are
|
|
151
188
|
raw-WebSocket only (`ui.subscribe`), because MCP stdio drops server→client
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@glittering0717/ai-browser",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.1.1",
|
|
4
4
|
"description": "AI-native browser — renders pixels for humans, structured semantic-tree API for agents. No screenshots, no OCR, no coordinate guessing.",
|
|
5
5
|
"main": "src/main/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"examples/AGENT_INSTRUCTIONS.md",
|
|
16
16
|
"examples/mcp-config.json",
|
|
17
17
|
"llms-install.md",
|
|
18
|
-
"LICENSE"
|
|
18
|
+
"LICENSE",
|
|
19
|
+
"build/icon.png",
|
|
20
|
+
"build/icon.icns"
|
|
19
21
|
],
|
|
20
22
|
"type": "module",
|
|
21
23
|
"license": "MIT",
|
|
@@ -72,6 +74,7 @@
|
|
|
72
74
|
"audit": "node e2e/audit_ws.cjs",
|
|
73
75
|
"e2e:all": "node e2e/run_all.cjs",
|
|
74
76
|
"readcov": "node e2e/readcov_ws.cjs",
|
|
77
|
+
"winopen": "node e2e/window_open_ws.cjs",
|
|
75
78
|
"coverage": "node tools/coverage_probe.cjs"
|
|
76
79
|
},
|
|
77
80
|
"dependencies": {
|
package/scripts/cli_call.cjs
CHANGED
|
@@ -22,25 +22,33 @@
|
|
|
22
22
|
// 选项:
|
|
23
23
|
// --port <n> 默认 9223(也读 AI_BROWSER_WS_PORT)
|
|
24
24
|
// --timeout <ms> 默认 30000
|
|
25
|
+
// --no-start 连不上**不要**自动拉起浏览器(脚本里想快速失败时用)
|
|
25
26
|
// --compact 单行输出(默认就是紧凑 JSON,加这个只是显式)
|
|
26
27
|
// --pretty 缩进输出(给人看时用)
|
|
28
|
+
//
|
|
29
|
+
// 关于自动拉起(2026-10-20 改):`call` 连不上时默认会先把浏览器拉起来再重试一次。
|
|
30
|
+
// 理由:agent 装完包应该一条命令就能干活,而不是先学"要 start 再 call"两步。
|
|
31
|
+
// `status` 不这么做 —— 它问的是"在跑吗",回答了"不在"才算答对。
|
|
27
32
|
'use strict';
|
|
28
33
|
|
|
29
34
|
const WS_PORT_DEFAULT = 9223;
|
|
30
35
|
const TIMEOUT_DEFAULT = 30000;
|
|
31
36
|
|
|
32
37
|
function parseArgs(argv) {
|
|
33
|
-
const out = { port: null, timeout: TIMEOUT_DEFAULT, pretty: false, rest: [] };
|
|
38
|
+
const out = { port: null, timeout: TIMEOUT_DEFAULT, pretty: false, start: true, rest: [] };
|
|
34
39
|
for (let i = 0; i < argv.length; i++) {
|
|
35
40
|
const a = argv[i];
|
|
36
41
|
if (a === '--port') { out.port = Number(argv[++i]); continue; }
|
|
37
42
|
if (a === '--timeout') { out.timeout = Number(argv[++i]); continue; }
|
|
38
43
|
if (a === '--pretty') { out.pretty = true; continue; }
|
|
39
44
|
if (a === '--compact') { out.pretty = false; continue; }
|
|
45
|
+
if (a === '--no-start') { out.start = false; continue; }
|
|
40
46
|
out.rest.push(a);
|
|
41
47
|
}
|
|
42
48
|
if (!out.port || !Number.isFinite(out.port)) {
|
|
43
|
-
|
|
49
|
+
// AI_BROWSER_PORT 是 Electron 侧(src/shared/config.js)用的名字,读它才和
|
|
50
|
+
// "AI_BROWSER_PORT=9701 ai-browser ensure" 这种用法对得上;WS_PORT 是历史别名。
|
|
51
|
+
const envPort = Number(process.env.AI_BROWSER_PORT ?? process.env.AI_BROWSER_WS_PORT);
|
|
44
52
|
out.port = Number.isFinite(envPort) && envPort > 0 ? envPort : WS_PORT_DEFAULT;
|
|
45
53
|
}
|
|
46
54
|
if (!Number.isFinite(out.timeout) || out.timeout <= 0) out.timeout = TIMEOUT_DEFAULT;
|
|
@@ -103,7 +111,7 @@ function rpcCall({ port, timeout, method, params }) {
|
|
|
103
111
|
ws.on('error', (e) => {
|
|
104
112
|
clearTimeout(timer);
|
|
105
113
|
const hint = /ECONNREFUSED/i.test(e.message)
|
|
106
|
-
? `浏览器没在 127.0.0.1:${port}
|
|
114
|
+
? `浏览器没在 127.0.0.1:${port} 上跑。\`ai-browser ensure\` 会拉起它,\`ai-browser start\` 是前台启动。`
|
|
107
115
|
: e.message;
|
|
108
116
|
done(reject, Object.assign(new Error(hint), { code: 'connect_failed' }));
|
|
109
117
|
});
|
|
@@ -138,7 +146,43 @@ async function runCall(argv) {
|
|
|
138
146
|
printResult(result, opt.pretty);
|
|
139
147
|
return 0;
|
|
140
148
|
} catch (e) {
|
|
141
|
-
|
|
149
|
+
// 连不上 → 默认把浏览器拉起来再试一次(见文件头"关于自动拉起")。
|
|
150
|
+
// 只在 connect_failed 时做:方法不存在、参数错这类不该去启动进程。
|
|
151
|
+
if (e.code !== 'connect_failed' || !opt.start) {
|
|
152
|
+
process.stderr.write(`[ai-browser] ${e.message}\n`);
|
|
153
|
+
return 1;
|
|
154
|
+
}
|
|
155
|
+
const { ensureRunning } = require('./ensure_running.cjs');
|
|
156
|
+
try {
|
|
157
|
+
const info = await ensureRunning({
|
|
158
|
+
port: opt.port,
|
|
159
|
+
log: (m) => process.stderr.write(m + '\n'),
|
|
160
|
+
});
|
|
161
|
+
if (info.started) process.stderr.write(`[ai-browser] browser started (via ${info.via})\n`);
|
|
162
|
+
const result = await rpcCall({ port: opt.port, timeout: opt.timeout, method, params });
|
|
163
|
+
printResult(result, opt.pretty);
|
|
164
|
+
return 0;
|
|
165
|
+
} catch (e2) {
|
|
166
|
+
// ensure 的报错已经带原因和下一步;原始连接错误作为补充,别只丢一句 ECONNREFUSED。
|
|
167
|
+
process.stderr.write(`[ai-browser] ${e2.message}\n`);
|
|
168
|
+
return 1;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** `ai-browser ensure` — 只负责把浏览器弄到"能连"的状态,不调任何业务方法。 */
|
|
174
|
+
async function runEnsure(argv) {
|
|
175
|
+
const opt = parseArgs(argv);
|
|
176
|
+
const { ensureRunning } = require('./ensure_running.cjs');
|
|
177
|
+
try {
|
|
178
|
+
const info = await ensureRunning({
|
|
179
|
+
port: opt.port,
|
|
180
|
+
log: (m) => process.stderr.write(m + '\n'),
|
|
181
|
+
});
|
|
182
|
+
printResult({ ...info, port: info.port ?? opt.port }, opt.pretty);
|
|
183
|
+
return 0;
|
|
184
|
+
} catch (e) {
|
|
185
|
+
printResult({ running: false, port: opt.port, error: e.message }, opt.pretty);
|
|
142
186
|
return 1;
|
|
143
187
|
}
|
|
144
188
|
}
|
|
@@ -175,4 +219,4 @@ function readStdin() {
|
|
|
175
219
|
}
|
|
176
220
|
}
|
|
177
221
|
|
|
178
|
-
module.exports = { runCall, runStatus, parsePayload, parseArgs, rpcCall, WS_PORT_DEFAULT };
|
|
222
|
+
module.exports = { runCall, runStatus, runEnsure, parsePayload, parseArgs, rpcCall, WS_PORT_DEFAULT };
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/ensure_running.cjs — 幂等地把浏览器弄起来,CLI 与 MCP **共用这一份**。
|
|
3
|
+
//
|
|
4
|
+
// # 为什么抽出来(2026-10-20)
|
|
5
|
+
//
|
|
6
|
+
// 原来这段只活在 mcp_server.js 里(ensureElectronRunning)。后果是:
|
|
7
|
+
// • 走 MCP 的 agent:浏览器没开也能用(服务端自动拉起);
|
|
8
|
+
// • 走 CLI 的 agent:连不上只会看到 "先 ai-browser start" —— 得学两步。
|
|
9
|
+
// 而「装完就能用」要求的是**一条命令**。所以把拉起逻辑挪到共享模块,
|
|
10
|
+
// 两个壳调用同一份实现(与项目一贯的"一套实现、多个薄壳"对齐)。
|
|
11
|
+
//
|
|
12
|
+
// # 三个坑(都是实测踩出来的,别删)
|
|
13
|
+
//
|
|
14
|
+
// ① `ELECTRON_RUN_AS_NODE` 必须删掉。本机 shell 就带着它,此时 electron 二进制
|
|
15
|
+
// 会当普通 Node 跑,拿 Node 加载 ESM 入口直接崩,端口永不监听,而报错是一串
|
|
16
|
+
// Node 的 ESM 栈 —— 完全不提 Electron。
|
|
17
|
+
// ② 必须 `detached` + `unref()`。否则调用方(CLI 进程)一退出,浏览器被一起带走。
|
|
18
|
+
// ③ 失败必须**带原因**。之前 return false 被上层 catch 吞掉,agent 先重试 120 秒,
|
|
19
|
+
// 最后只拿到 "connection refused",没有任何线索说明浏览器根本没起来。
|
|
20
|
+
//
|
|
21
|
+
// # macOS 的沙箱兜底(第四个坑)
|
|
22
|
+
//
|
|
23
|
+
// 在受限运行环境里(agent 的沙箱 shell)直接 spawn Chromium,会以
|
|
24
|
+
// "sandbox initialization failed: Operation not permitted" 挂掉 —— 沙箱能力
|
|
25
|
+
// 随父进程继承,`--no-sandbox` 也救不了。此时改走 LaunchServices:
|
|
26
|
+
// open -n -a <Electron.app> --args <appRoot>
|
|
27
|
+
// 新进程由 launchd 拉起,不继承父进程的沙箱限制。这条兜底不是 hack,是 macOS
|
|
28
|
+
// 上启动 GUI 应用的**正规方式**(Finder 双击走的就是它)。
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
const { spawn } = require('node:child_process');
|
|
32
|
+
const net = require('node:net');
|
|
33
|
+
const path = require('node:path');
|
|
34
|
+
|
|
35
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
36
|
+
const DEFAULT_PORT = 9223;
|
|
37
|
+
const DEFAULT_TIMEOUT = 30000;
|
|
38
|
+
|
|
39
|
+
/** 端口上有没有人在听。超时 500ms —— 只探本地回环,不该慢。 */
|
|
40
|
+
function checkPort(port, host = '127.0.0.1') {
|
|
41
|
+
return new Promise((resolve) => {
|
|
42
|
+
const sock = net.connect({ port, host });
|
|
43
|
+
let done = false;
|
|
44
|
+
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
|
|
45
|
+
sock.once('connect', () => finish(true));
|
|
46
|
+
sock.once('error', () => finish(false));
|
|
47
|
+
sock.once('timeout', () => finish(false));
|
|
48
|
+
sock.setTimeout(500);
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** 轮询等到端口起来,或超时。`abort()` 说"别等了"时立刻放弃(子进程已经没了)。 */
|
|
53
|
+
async function waitForPort(port, timeoutMs, stepMs = 400, abort = null) {
|
|
54
|
+
const start = Date.now();
|
|
55
|
+
while (Date.now() - start < timeoutMs) {
|
|
56
|
+
if (await checkPort(port)) return true;
|
|
57
|
+
if (abort && abort()) return false; // 子进程已经报错退出,再等只是浪费
|
|
58
|
+
await new Promise((r) => setTimeout(r, stepMs));
|
|
59
|
+
}
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 从 electron 二进制路径反推 macOS 的 .app 包路径。
|
|
65
|
+
* `/x/Electron.app/Contents/MacOS/Electron` → `/x/Electron.app`
|
|
66
|
+
* 非 mac / 路径不含 .app 时返回 null。
|
|
67
|
+
*/
|
|
68
|
+
function appBundleFromBinary(bin) {
|
|
69
|
+
if (!bin) return null;
|
|
70
|
+
const i = bin.indexOf('.app' + path.sep);
|
|
71
|
+
if (i < 0) return null;
|
|
72
|
+
return bin.slice(0, i + 4);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** 用 LaunchServices 启动(macOS)。返回是否成功把进程扔出去(不代表端口会开)。 */
|
|
76
|
+
function launchViaOpen(bundle, root, log) {
|
|
77
|
+
try {
|
|
78
|
+
const child = spawn('open', ['-n', '-a', bundle, '--args', root], {
|
|
79
|
+
stdio: 'ignore',
|
|
80
|
+
detached: true,
|
|
81
|
+
});
|
|
82
|
+
child.unref();
|
|
83
|
+
return true;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
log(`[ensure] open -a ${bundle} failed: ${e.message}`);
|
|
86
|
+
return false;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 确保浏览器在跑。幂等:已经在跑就立刻返回。
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [opts]
|
|
94
|
+
* @param {number} [opts.port=9223]
|
|
95
|
+
* @param {number} [opts.timeoutMs=30000] 首轮等待上限
|
|
96
|
+
* @param {(msg: string) => void} [opts.log] 进度输出(走 stderr,别污染 stdout 的 result)
|
|
97
|
+
* @param {boolean} [opts.allowLaunchServices=true] macOS 沙箱失败时是否兜底走 open
|
|
98
|
+
* @returns {Promise<{running:boolean, started:boolean, via:string|null, port:number}>}
|
|
99
|
+
* @throws 端口始终没起来时抛错,message 里带**可执行的下一步**
|
|
100
|
+
*/
|
|
101
|
+
async function ensureRunning(opts = {}) {
|
|
102
|
+
const port = Number(opts.port) || DEFAULT_PORT;
|
|
103
|
+
const timeoutMs = Number(opts.timeoutMs) || DEFAULT_TIMEOUT;
|
|
104
|
+
const log = opts.log || ((m) => console.error(m));
|
|
105
|
+
const allowLS = opts.allowLaunchServices !== false;
|
|
106
|
+
|
|
107
|
+
if (await checkPort(port)) return { running: true, started: false, via: 'already', port };
|
|
108
|
+
|
|
109
|
+
// AI_BROWSER_ELECTRON_BIN:显式指定二进制(打包版、自定义构建、e2e 用假路径)。
|
|
110
|
+
// 没设就交给共享解析器(它会在二进制缺失时自己跑一遍 electron 的安装脚本)。
|
|
111
|
+
let bin;
|
|
112
|
+
const override = process.env.AI_BROWSER_ELECTRON_BIN;
|
|
113
|
+
if (override) {
|
|
114
|
+
bin = override;
|
|
115
|
+
} else {
|
|
116
|
+
const { resolveElectronBinary } = require('./electron_resolve.cjs');
|
|
117
|
+
try {
|
|
118
|
+
bin = resolveElectronBinary({ log });
|
|
119
|
+
} catch (e) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`could not locate the Electron binary: ${e.message}\n`
|
|
122
|
+
+ ' fix: run `npm install` once inside the ai-browser package (this also downloads Chromium).'
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
log(`[ensure] port ${port} not listening — starting ai-browser`);
|
|
128
|
+
|
|
129
|
+
// —— 第一轮:直接 spawn(Linux/Windows 的正常路径,macOS 也先试这个)——
|
|
130
|
+
const env = { ...process.env, AI_BROWSER_LAUNCHED_BY_AGENT: '1' };
|
|
131
|
+
delete env.ELECTRON_RUN_AS_NODE; // 坑 ①
|
|
132
|
+
let earlyExit = null;
|
|
133
|
+
let child = null;
|
|
134
|
+
try {
|
|
135
|
+
child = spawn(bin, [ROOT], { cwd: ROOT, detached: true, stdio: 'ignore', env }); // 坑 ②
|
|
136
|
+
child.unref();
|
|
137
|
+
child.on('exit', (code, signal) => { earlyExit = { code, signal }; });
|
|
138
|
+
child.on('error', (e) => { earlyExit = { error: e.message }; });
|
|
139
|
+
} catch (e) {
|
|
140
|
+
log(`[ensure] spawn failed: ${e.message}`);
|
|
141
|
+
earlyExit = { error: e.message };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 子进程自报错(ENOENT 之类)就别等满超时了 —— 那只会把真实原因埋掉。
|
|
145
|
+
let ok = await waitForPort(port, timeoutMs, 400, () => !!(earlyExit && earlyExit.error));
|
|
146
|
+
if (ok) return { running: true, started: true, via: 'spawn', port };
|
|
147
|
+
|
|
148
|
+
// —— 第二轮:macOS 沙箱兜底,走 LaunchServices ——
|
|
149
|
+
let lsTried = false;
|
|
150
|
+
if (allowLS && process.platform === 'darwin') {
|
|
151
|
+
const bundle = appBundleFromBinary(bin);
|
|
152
|
+
if (bundle) {
|
|
153
|
+
lsTried = true;
|
|
154
|
+
log(`[ensure] direct spawn did not come up (${describeExit(earlyExit)}) — retrying via LaunchServices`);
|
|
155
|
+
if (launchViaOpen(bundle, ROOT, log)) {
|
|
156
|
+
ok = await waitForPort(port, 20000);
|
|
157
|
+
if (ok) return { running: true, started: true, via: 'launchservices', port };
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
throw new Error(buildFailureMessage({ port, earlyExit, lsTried }));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** 把子进程退出状态说人话。 */
|
|
166
|
+
function describeExit(earlyExit) {
|
|
167
|
+
if (!earlyExit) return 'no exit event, port still closed';
|
|
168
|
+
if (earlyExit.error) return `spawn error: ${earlyExit.error}`;
|
|
169
|
+
return `exited early (code=${earlyExit.code}, signal=${earlyExit.signal})`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** 失败原因 + 用户/agent 照着能做的下一步。 */
|
|
173
|
+
function buildFailureMessage({ port, earlyExit, lsTried }) {
|
|
174
|
+
const lines = [`could not start the browser: nothing is listening on 127.0.0.1:${port}`];
|
|
175
|
+
lines.push(` cause: ${describeExit(earlyExit)}`);
|
|
176
|
+
if (lsTried) lines.push(' a LaunchServices launch (open -n -a Electron.app) was also tried and did not come up');
|
|
177
|
+
lines.push(
|
|
178
|
+
' common causes:',
|
|
179
|
+
' • ELECTRON_RUN_AS_NODE is set in the environment (unset it)',
|
|
180
|
+
' • Chromium sandbox is blocked inside a container/sandbox — launch once from a normal terminal: `ai-browser start`',
|
|
181
|
+
' • Chromium binary missing or partially extracted — `npm install` in the ai-browser package',
|
|
182
|
+
' • a stale lock: another instance owns this userData dir (quit it, or use --port for a separate profile)'
|
|
183
|
+
);
|
|
184
|
+
return lines.join('\n');
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
module.exports = {
|
|
188
|
+
ensureRunning,
|
|
189
|
+
checkPort,
|
|
190
|
+
waitForPort,
|
|
191
|
+
appBundleFromBinary,
|
|
192
|
+
DEFAULT_PORT,
|
|
193
|
+
DEFAULT_TIMEOUT,
|
|
194
|
+
APP_ROOT: ROOT,
|
|
195
|
+
};
|
|
@@ -7,48 +7,49 @@ description: >-
|
|
|
7
7
|
|
|
8
8
|
# ai-browser
|
|
9
9
|
|
|
10
|
-
##
|
|
10
|
+
## 两条路,选一条就能干活
|
|
11
11
|
|
|
12
|
-
`
|
|
13
|
-
stdout 只有 result):`ai-browser status`、`ai-browser call ui.navigate '{"url":"…"}'`。
|
|
14
|
-
|
|
15
|
-
## 三步(照抄即可)
|
|
12
|
+
工具在就 `browse_navigate {"url":"https://example.com"}`;**不在也不用配任何东西**,走 CLI(同一引擎,装完即用):
|
|
16
13
|
|
|
17
14
|
```
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
ai-browser call ui.get_tree '{"subset":"interactive"}' # 读页面,拿 e:xxx
|
|
16
|
+
ai-browser call ui.act '{"action":"click","target":"e:a-3"}' # 点它
|
|
17
|
+
ai-browser call ui.navigate '{"url":"https://example.com"}'
|
|
21
18
|
```
|
|
22
19
|
|
|
23
|
-
|
|
24
|
-
|
|
20
|
+
没这个命令就加前缀 `npx -y @glittering0717/ai-browser`。浏览器没开时会自动拉起。
|
|
21
|
+
|
|
22
|
+
CLI 方法 = 工具名去掉 `browse_` 换 `ui.`(`get_tree` `act` `navigate` … 一一对应)。
|
|
23
|
+
不一一对应的 4 个(tabs/network/canvas/diff)见 `references/cli.md`。
|
|
24
|
+
|
|
25
|
+
## 点东西只认 id
|
|
26
|
+
|
|
27
|
+
`get_tree` 每个节点有 `id`(形如 `e:a-3`)。`act` 的 `target` **只接受这个 id**,
|
|
28
|
+
传页面上的文字会报 `target_not_found`。
|
|
25
29
|
|
|
26
|
-
##
|
|
30
|
+
## 操作后先看 effect_state
|
|
27
31
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
| `committed` | 已生效 | 别重试,继续下一步 |
|
|
31
|
-
| `none` | 页面没被动过 | 修掉原因(换 target / 重读树)再试 |
|
|
32
|
-
| `unknown` | 说不准(超时等) | **先 `browse_get_tree` 看现状**,盲目重试会提交两次 |
|
|
32
|
+
`committed` 已生效 → 继续|`none` 页面没动 → 换 target 或重读树|
|
|
33
|
+
`unknown` 说不准 → **先重读树再决定**,盲目重试会提交两次。
|
|
33
34
|
|
|
34
35
|
## 卡住时
|
|
35
36
|
|
|
36
37
|
| 现象 | 先做什么 |
|
|
37
38
|
|---|---|
|
|
38
|
-
| 树里没有要点的东西 | `
|
|
39
|
-
| `target_not_found`
|
|
40
|
-
| `target_not_found`
|
|
41
|
-
| 点了没反应 | `
|
|
42
|
-
| 怀疑掉登录 | `
|
|
43
|
-
| 以上都不对 | `
|
|
39
|
+
| 树里没有要点的东西 | 等 `{"condition":"text_contains","text":"关键词"}` 再重读 |
|
|
40
|
+
| `target_not_found` + `stale_ref` | 页面变了 → 重读树拿新 id |
|
|
41
|
+
| `target_not_found` + `never_seen` | id 本身错 → 别重读,改参数 |
|
|
42
|
+
| 点了没反应 | `screenshot` 看画面;`network_list` 看请求 |
|
|
43
|
+
| 怀疑掉登录 | `auth_state` |
|
|
44
|
+
| 以上都不对 | `errors` |
|
|
44
45
|
|
|
45
46
|
## 需要人帮忙
|
|
46
47
|
|
|
47
|
-
|
|
48
|
-
不要编造账号密码,不要反复重试注定失败的点击。
|
|
48
|
+
登录/扫码/滑块/支付:**请用户在自己的窗口里操作**,他说"好了"再重读树确认。不要编造密码。
|
|
49
49
|
|
|
50
|
-
##
|
|
50
|
+
## 按需深挖
|
|
51
51
|
|
|
52
|
+
- `references/cli.md` — CLI 全部方法与参数对照
|
|
52
53
|
- `references/troubleshooting.md` — 失败排查顺序
|
|
53
54
|
- `references/hard-pages.md` — canvas / 富文本 / iframe / 上传
|
|
54
55
|
- `references/network.md` — 抓请求、取结构化数据
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# CLI — 不用 MCP 也能干活的那条路
|
|
2
|
+
|
|
3
|
+
## 什么时候得用它
|
|
4
|
+
|
|
5
|
+
`browse_*` 工具是 MCP 层的东西。MCP 要挂上,得经过客户端的三道闸门:配置文件、一次性授权、
|
|
6
|
+
会话启动时的工具快照。任何一环没走完(最常见:**改完配置没开新会话**),工具就不出现。
|
|
7
|
+
|
|
8
|
+
这时候别顺着"配置 → 授权 → 重启"往下修 —— 那条路在客户端手里,你改不动。
|
|
9
|
+
CLI 是同一个 JSON-RPC 的另一个门,只依赖一件事:命令能在 PATH 里跑起来。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
ai-browser call ui.get_tree '{"subset":"interactive"}'
|
|
13
|
+
# 没装全局命令时:
|
|
14
|
+
npx -y @glittering0717/ai-browser call ui.get_tree '{"subset":"interactive"}'
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
stdout 只有 result(紧凑 JSON,可直接 `| jq`),进度和错误走 stderr。
|
|
18
|
+
|
|
19
|
+
## 先确认它是不是活着
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
ai-browser status # {"running":true,"pid":…,"port":9223,"tabs":[…]}
|
|
23
|
+
ai-browser ensure # 幂等:没开就拉起,开了就直接返回
|
|
24
|
+
ai-browser call ui.get_tree # 连不上会自动 ensure 再重试一次
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`status` **不会**拉起浏览器 —— 它问的是"在跑吗",答"不在"才算答对。
|
|
28
|
+
`call` 会。想快速失败(脚本里)加 `--no-start`。
|
|
29
|
+
|
|
30
|
+
## 方法对照表
|
|
31
|
+
|
|
32
|
+
MCP 工具名去掉 `browse_`,换成 `ui.`,就是 CLI 方法名。
|
|
33
|
+
|
|
34
|
+
| 一一对应 | get_tree · act · navigate · evaluate · scroll · wait · screenshot · errors · capabilities · downloads · quit · auth_state · repaint |
|
|
35
|
+
|---|---|
|
|
36
|
+
| `browse_tabs` | `ui.list_tabs` / `ui.new_tab` / `ui.close_tab` / `ui.set_active_tab` |
|
|
37
|
+
| `browse_network` | `ui.network_list` / `ui.network_get`(两个,不是一个) |
|
|
38
|
+
| `browse_canvas` | `ui.canvas_list` / `ui.canvas_read` / `ui.canvas_capture` / `ui.canvas_configure` |
|
|
39
|
+
| `browse_diff` | `ui.snapshot` / `ui.diff` |
|
|
40
|
+
|
|
41
|
+
## 常用参数(底层契约,CLI 直传)
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
ai-browser call ui.navigate '{"url":"https://example.com","tab":1,"wait_until":"load"}'
|
|
45
|
+
ai-browser call ui.get_tree '{"tab":1,"subset":"interactive"}' # 交互树
|
|
46
|
+
ai-browser call ui.get_tree '{"tab":1,"ax":true}' # 阅读树(长正文用它)
|
|
47
|
+
ai-browser call ui.act '{"action":"click","target":"e:a-3"}'
|
|
48
|
+
ai-browser call ui.act '{"action":"type","target":"e:input-1","text":"关键词"}'
|
|
49
|
+
ai-browser call ui.evaluate '{"js":"document.title"}'
|
|
50
|
+
ai-browser call ui.evaluate '{"frame_ref":"frame-1","js":"document.body.innerText"}'
|
|
51
|
+
ai-browser call ui.wait '{"condition":"text_contains","text":"加载完成"}'
|
|
52
|
+
ai-browser call ui.network_list '{"tab":1}'
|
|
53
|
+
ai-browser call ui.screenshot '{"tab":1}'
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
`ui.act` 的 `target` 只认树里的 `id`(形如 `e:a-3`),不认页面上的文字。
|
|
57
|
+
|
|
58
|
+
长 JSON 别跟 shell 引号搏斗,用 `-` 从 stdin 读:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
echo '{"action":"type","target":"e:input-1","text":"很长很长的内容"}' | ai-browser call ui.act -
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## 没连上时它在说什么
|
|
65
|
+
|
|
66
|
+
| stderr 里出现 | 意思 | 下一步 |
|
|
67
|
+
|---|---|---|
|
|
68
|
+
| `没在 127.0.0.1:9223 上跑` | 浏览器没起(`--no-start` 时最常见) | 去掉 `--no-start`,或 `ai-browser ensure` |
|
|
69
|
+
| `could not start the browser: …cause: exited early` | 拉起了但立刻崩 | 看 cause:`ELECTRON_RUN_AS_NODE` 没清 / Chromium 沙箱被拦 / 二进制没装全 |
|
|
70
|
+
| `Method not found (ui.xxx)` | 方法名错(拼错,或用了 MCP 的名字) | 对照上面的表 |
|
|
71
|
+
| `no reply from … within …ms` | 方法执行太慢 | 加大 `--timeout` |
|
|
72
|
+
|
|
73
|
+
## 端口与多实例
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
ai-browser status --port 9701
|
|
77
|
+
AI_BROWSER_PORT=9701 AI_BROWSER_USER_DATA=/tmp/ab-test ai-browser ensure
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
同一 `AI_BROWSER_USER_DATA` 有单实例锁 —— 换 profile 才能开第二个。
|
|
81
|
+
搞 e2e / 并行任务时必须换,否则会撞锁,端口永远不开。
|
package/src/main/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// One process, one WS server, multiple tabs with real tab bar.
|
|
3
3
|
import { app, BrowserWindow, BrowserView, ipcMain, net, session, dialog } from 'electron';
|
|
4
4
|
import path from 'node:path';
|
|
5
|
+
import fs from 'node:fs';
|
|
5
6
|
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { startWSServer } from './ws_server.js';
|
|
7
8
|
import { PageManager } from './page_manager.js';
|
|
@@ -326,10 +327,40 @@ if (gotLock) {
|
|
|
326
327
|
app.quit();
|
|
327
328
|
}
|
|
328
329
|
|
|
330
|
+
// === 应用图标(2026-10-20)===
|
|
331
|
+
//
|
|
332
|
+
// macOS 上从源码启动时 Dock 图标是 Electron 的默认火箭 —— 因为进程本体就是
|
|
333
|
+
// node_modules/electron/dist/Electron.app,**Dock 跟的是可执行文件,不是我们的
|
|
334
|
+
// 代码**。打包版没有这个问题(Info.plist 里已经是自定义 icns),但 `npm start`
|
|
335
|
+
// / `npx` 启动占日常使用的大头;图标错了,用户的判断就是"这不是 AI Browser"。
|
|
336
|
+
// (2026-10-20 原话:"我看现在系统里运行的 ai-browser 都是默认图标了"。)
|
|
337
|
+
//
|
|
338
|
+
// app.dock.setIcon 是 Electron 提供的运行时入口,不影响签名与打包。
|
|
339
|
+
// Windows/Linux 走 BrowserWindow 的 icon 选项(见 createWindow)。
|
|
340
|
+
function appIconPath() {
|
|
341
|
+
const p = path.join(__dirname, '..', '..', 'build', 'icon.png');
|
|
342
|
+
try { return fs.existsSync(p) ? p : null; } catch (_) { return null; }
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function applyAppIcon() {
|
|
346
|
+
if (process.platform !== 'darwin') return false; // 其他平台由窗口 icon 选项负责
|
|
347
|
+
const icon = appIconPath();
|
|
348
|
+
if (!icon) return false; // 装的是精简包(没带 build/)→ 静默跳过,不值得启动失败
|
|
349
|
+
try {
|
|
350
|
+
if (app.dock && typeof app.dock.setIcon === 'function') { app.dock.setIcon(icon); return true; }
|
|
351
|
+
} catch (e) {
|
|
352
|
+
console.error('[icon] dock.setIcon failed:', e && e.message);
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
|
|
329
357
|
function createWindow() {
|
|
330
358
|
mainWindow = new BrowserWindow({
|
|
331
359
|
width: 1280,
|
|
332
360
|
height: 800,
|
|
361
|
+
// Windows/Linux 的任务栏图标来自这里(macOS 走 app.dock.setIcon,
|
|
362
|
+
// 见 applyAppIcon:Dock 跟的是进程可执行文件,不是窗口选项)。
|
|
363
|
+
icon: appIconPath() || undefined,
|
|
333
364
|
// 先不显示:如何显示由 showInitialWindow() 按焦点策略决定 —— 窗口必须真实可见
|
|
334
365
|
// (产品承诺人可随时介入),但被 agent 拉起时不该把用户正在做的事打断。
|
|
335
366
|
show: false,
|
|
@@ -764,6 +795,7 @@ function normalizeUrlInput(input) {
|
|
|
764
795
|
}
|
|
765
796
|
|
|
766
797
|
app.whenReady().then(() => {
|
|
798
|
+
applyAppIcon();
|
|
767
799
|
createWindow();
|
|
768
800
|
|
|
769
801
|
// === 自检钩子:AI_BROWSER_SELFTEST=crash ===
|
package/src/main/mcp_server.js
CHANGED
|
@@ -9,14 +9,11 @@ import {
|
|
|
9
9
|
ListResourcesRequestSchema,
|
|
10
10
|
ListResourceTemplatesRequestSchema,
|
|
11
11
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
12
|
-
import net from 'node:net';
|
|
13
|
-
import { spawn } from 'node:child_process';
|
|
14
12
|
import path from 'node:path';
|
|
15
13
|
import { fileURLToPath } from 'node:url';
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
|
|
19
|
-
import electronResolve from '../../scripts/electron_resolve.cjs';
|
|
14
|
+
// 启动逻辑与 CLI 共用一份(scripts/ensure_running.cjs)。曾经这里有一份私有实现,
|
|
15
|
+
// 结果 MCP 能自动拉起浏览器、CLI 不能 —— 同一件事两个行为。
|
|
16
|
+
import ensureMod from '../../scripts/ensure_running.cjs';
|
|
20
17
|
import { MCP_TOOLS } from './mcp_tools.js';
|
|
21
18
|
import { actParams, networkParams } from './mcp_args.js';
|
|
22
19
|
import { evaluateGuardError } from '../shared/guards.js';
|
|
@@ -33,96 +30,29 @@ const ELECTRON_PORT = config.wsPort;
|
|
|
33
30
|
const __filename = fileURLToPath(import.meta.url);
|
|
34
31
|
const __dirname = path.dirname(__filename);
|
|
35
32
|
|
|
36
|
-
|
|
37
|
-
// node_modules/@glittering0717/ai-browser: src/main/ → up two levels.
|
|
38
|
-
const APP_ROOT = path.resolve(__dirname, '..', '..');
|
|
39
|
-
const { resolveElectronBinary } = electronResolve;
|
|
33
|
+
const { ensureRunning, checkPort } = ensureMod;
|
|
40
34
|
|
|
41
35
|
// Persistent WS client — one reused connection with incrementing ids.
|
|
42
36
|
// Lazy launch: no Electron is started at boot. The first browse_* call that
|
|
43
|
-
// fails to connect triggers `ensureRunning`, which
|
|
44
|
-
//
|
|
45
|
-
//
|
|
37
|
+
// fails to connect triggers `ensureRunning`, which starts Electron on demand.
|
|
38
|
+
//
|
|
39
|
+
// 2026-10-20: the launch logic moved to scripts/ensure_running.cjs so the CLI
|
|
40
|
+
// (`ai-browser call` / `ai-browser ensure`) shares ONE implementation with this
|
|
41
|
+
// server. Duplicating it here is what made "install and use" a two-step dance
|
|
42
|
+
// for CLI callers: MCP auto-started the browser, the CLI did not.
|
|
46
43
|
const wsClient = createWsClient({
|
|
47
44
|
url: WS_URL,
|
|
48
45
|
timeout: WS_TIMEOUT,
|
|
49
|
-
ensureRunning:
|
|
46
|
+
ensureRunning: () => ensureBrowser(),
|
|
50
47
|
});
|
|
51
48
|
|
|
52
|
-
|
|
53
|
-
function
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const finish = (ok) => { if (!done) { done = true; sock.destroy(); resolve(ok); } };
|
|
58
|
-
sock.once('connect', () => finish(true));
|
|
59
|
-
sock.once('error', () => finish(false));
|
|
60
|
-
setTimeout(() => finish(false), 500);
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function waitForPort(port, timeoutMs) {
|
|
65
|
-
const start = Date.now();
|
|
66
|
-
return new Promise(async (resolve) => {
|
|
67
|
-
while (Date.now() - start < timeoutMs) {
|
|
68
|
-
if (await checkPort(port)) return resolve(true);
|
|
69
|
-
await new Promise(r => setTimeout(r, 500));
|
|
70
|
-
}
|
|
71
|
-
resolve(false);
|
|
49
|
+
/** 拉起浏览器(幂等)。失败时抛带原因的 Error,由 mcp_ws 并进最终错误信息。 */
|
|
50
|
+
async function ensureBrowser() {
|
|
51
|
+
const info = await ensureRunning({
|
|
52
|
+
port: ELECTRON_PORT,
|
|
53
|
+
log: (m) => console.error('[mcp] ' + m),
|
|
72
54
|
});
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
// Spawn Electron detached so it survives this MCP subprocess.
|
|
76
|
-
//
|
|
77
|
-
// 两个坑都是实测踩出来的(2026-10-12):
|
|
78
|
-
//
|
|
79
|
-
// ① `ELECTRON_RUN_AS_NODE` 必须删掉。本机 shell 就带着它,此时 `electron .` 会
|
|
80
|
-
// 被当作普通 Node 运行,拿 Node 加载 ESM 入口直接崩,端口永不监听,而报错是
|
|
81
|
-
// 一串 Node 的 ESM 栈 —— 完全不提 Electron。e2e 脚本都删了它,只有这里漏了。
|
|
82
|
-
// 详见 scripts/start.cjs 顶部。
|
|
83
|
-
//
|
|
84
|
-
// ② 启动失败必须**说出来**。此前这里 return false,而 mcp_ws.js 又把它 catch 掉
|
|
85
|
-
// 吞了,于是 agent 先重试 120 秒,最后只拿到 "WS reconnect timeout:
|
|
86
|
-
// connection refused" —— 没有任何线索表明浏览器根本没起来,更别说为什么。
|
|
87
|
-
// 现在抛一个带原因的 Error,由 mcp_ws 并进最终错误信息。
|
|
88
|
-
async function ensureElectronRunning() {
|
|
89
|
-
if (await checkPort(ELECTRON_PORT)) return true;
|
|
90
|
-
console.error('[mcp] port 9223 not listening — spawning the ai-browser Electron app');
|
|
91
|
-
// Spawn the electron binary directly (resolved from this package's
|
|
92
|
-
// dependencies), pointed at the app root. Do NOT shell out to `npm start`:
|
|
93
|
-
// npm is not guaranteed on PATH for MCP clients, and on Windows the
|
|
94
|
-
// executable is npm.cmd — direct spawn avoids both classes of failure.
|
|
95
|
-
// AI_BROWSER_LAUNCHED_BY_AGENT:告诉 Electron 它是被 agent 拉起来的,因此
|
|
96
|
-
// 窗口出现时**不要**抢焦点(showInactive)—— 否则 agent 第一次连上来就会把
|
|
97
|
-
// 用户正在做的事打断。人手动 `ai-browser` / `npm start` 时没有这个变量。
|
|
98
|
-
const childEnv = { ...process.env, AI_BROWSER_LAUNCHED_BY_AGENT: '1' };
|
|
99
|
-
delete childEnv.ELECTRON_RUN_AS_NODE;
|
|
100
|
-
let child;
|
|
101
|
-
try {
|
|
102
|
-
child = spawn(resolveElectronBinary(), [APP_ROOT], {
|
|
103
|
-
cwd: APP_ROOT,
|
|
104
|
-
detached: true,
|
|
105
|
-
stdio: 'ignore',
|
|
106
|
-
env: childEnv,
|
|
107
|
-
});
|
|
108
|
-
} catch (e) {
|
|
109
|
-
throw new Error(`could not start the browser: ${e.message}`);
|
|
110
|
-
}
|
|
111
|
-
child.unref();
|
|
112
|
-
// 子进程自己提前退出(例如上面那个 Node ESM 崩溃)时,等满 30 秒是纯浪费 ——
|
|
113
|
-
// 而且会掩盖真实原因。这里挂一个 exit 监听,把退出码留给下面的诊断。
|
|
114
|
-
let earlyExit = null;
|
|
115
|
-
child.on('exit', (code, signal) => { earlyExit = { code, signal }; });
|
|
116
|
-
// Give Electron up to 30s to start the WS server.
|
|
117
|
-
const ok = await waitForPort(ELECTRON_PORT, 30000);
|
|
118
|
-
if (!ok) {
|
|
119
|
-
const why = earlyExit
|
|
120
|
-
? `Electron exited early (code=${earlyExit.code}, signal=${earlyExit.signal}) — a common cause is ELECTRON_RUN_AS_NODE being set in the environment, or the electron binary being missing (check node_modules/electron/dist)`
|
|
121
|
-
: 'Electron did not come up within 30s (slow first-run binary download? try ELECTRON_MIRROR for users behind a slow network)';
|
|
122
|
-
console.error('[mcp] ' + why);
|
|
123
|
-
throw new Error(`could not start the browser: ${why}`);
|
|
124
|
-
}
|
|
125
|
-
return true;
|
|
55
|
+
return info.running;
|
|
126
56
|
}
|
|
127
57
|
|
|
128
58
|
const server = new Server(
|
package/src/main/page_manager.js
CHANGED
|
@@ -81,6 +81,11 @@ const CANVAS_BRIDGE_KEY = '__aiCanvasBridge';
|
|
|
81
81
|
// 请求头都拿不到)。关闭开关:AI_BROWSER_NETWORK_CAPTURE=0。
|
|
82
82
|
const NETWORK_CAPTURE_DEFAULT = !(process.env.AI_BROWSER_NETWORK_CAPTURE === '0' || process.env.AI_BROWSER_NETWORK_CAPTURE === 'false');
|
|
83
83
|
|
|
84
|
+
// window.open("") 派生 tab 的"等垫片再导航"上限。超过就照常导航(退化成
|
|
85
|
+
// 该 tab 没有垫片),页面绝不能因为垫片安装慢而永久空白。
|
|
86
|
+
// 实测正常路径约 120–300ms(about:blank 的 did-finish-load + 两次 CDP 命令)。
|
|
87
|
+
const WINDOW_OPEN_NAV_WAIT_MS = 2000;
|
|
88
|
+
|
|
84
89
|
// 具名按键 → CDP Input.dispatchKeyEvent 参数。agent 用自然名字('Delete'、
|
|
85
90
|
// 'Escape'、'ArrowRight'、'a')而不是让人去查 Windows 虚拟键码。
|
|
86
91
|
// vk 用 Windows 虚拟键码(Chromium 在 macOS 上也按它解释)。
|
|
@@ -174,6 +179,12 @@ class PageManager {
|
|
|
174
179
|
this._openedTabs = new Map(); // sourceTabId -> { id, url } (new tab opened by window.open)
|
|
175
180
|
this._windowOpenTokens = new Map(); // token -> { tabId, sourceTabId } (window.open shim, preload bridge.cjs)
|
|
176
181
|
this._windowOpenShimTabs = new Set(); // 已注入 window.open 垫片的 tabId
|
|
182
|
+
// tabId -> Promise<void>:该 tab 的垫片**注册完成**的信号(不是"注入成功",
|
|
183
|
+
// 失败也 resolve —— 调用方要的是"可以往下走了",不是"一切顺利")。
|
|
184
|
+
// 存在的唯一理由:window.open("") 派生的 tab 里,ai:window_open_nav 必须等
|
|
185
|
+
// 垫片登记完再导航,否则真实文档先到、垫片只对"下一次文档"生效,那个 tab
|
|
186
|
+
// 就成了没有垫片的窗中窗(见 newTab 末尾长注释)。
|
|
187
|
+
this._windowOpenShimReady = new Map();
|
|
177
188
|
this._axDiffCache = new Map(); // tabId -> lite interactive snapshot (get_tree {mode:'diff'})
|
|
178
189
|
this._repaintTimers = new Map();// tabId -> 正在排队/已调度的重绘(合并同一批导航事件)
|
|
179
190
|
this._snapshots = new Map(); // snapshotId -> { tabId, js, value, at }(ui.snapshot 存基线)
|
|
@@ -328,16 +339,41 @@ class PageManager {
|
|
|
328
339
|
const entry = this._windowOpenTokens.get(p.token);
|
|
329
340
|
const url = typeof p.url === 'string' ? p.url : '';
|
|
330
341
|
if (!entry || !url) return;
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
342
|
+
const nav = () => {
|
|
343
|
+
const view = this._getView(entry.tabId);
|
|
344
|
+
if (!view) {
|
|
345
|
+
// tab 已被用户/Agent 关掉:赋值静默丢弃(真实浏览器里关掉的
|
|
346
|
+
// 窗口被赋 location 也是无人接收)。
|
|
347
|
+
console.warn('[window_open] nav target tab gone:', entry.tabId, url);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
// loadURL 的 rejection 由页面自身处理(站点可能会立刻再导航一次),
|
|
351
|
+
// 这里只防 unhandled。
|
|
335
352
|
view.webContents.loadURL(url).catch(() => {});
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
353
|
+
};
|
|
354
|
+
// ⚠️ **必须等垫片登记完再导航**(2026-10-20 修,超星"进入学习"第二层)。
|
|
355
|
+
//
|
|
356
|
+
// 原来这里直接 loadURL。看上去时序够用(站点 ajax 往返几十毫秒,
|
|
357
|
+
// 而 about:blank 的 did-finish-load 通常更早),但那是**赌**:
|
|
358
|
+
// 只要 nav 抢在 did-finish-load 之前到达,真实文档就先把 renderer 占住,
|
|
359
|
+
// 随后 addScriptToEvaluateOnNewDocument 只对"下一次文档"生效 ——
|
|
360
|
+
// 新 tab 里 window.open 是原生实现,站点再走一次
|
|
361
|
+
// `window.open("") + w.location = url` 就得到 null,静默不跳转。
|
|
362
|
+
// 实测(2026-10-20):列表页的"进入学习"能开,新 tab 的 shim 是 false。
|
|
363
|
+
const ready = this._windowOpenShimReady.get(entry.tabId);
|
|
364
|
+
if (!ready) { nav(); return; } // 老路径/测试桩:没有信号就直接走
|
|
365
|
+
let timer = null;
|
|
366
|
+
const cap = new Promise((resolve) => {
|
|
367
|
+
timer = setTimeout(resolve, WINDOW_OPEN_NAV_WAIT_MS);
|
|
368
|
+
if (timer.unref) timer.unref();
|
|
369
|
+
});
|
|
370
|
+
Promise.race([ready, cap]).then(() => {
|
|
371
|
+
if (timer) clearTimeout(timer);
|
|
372
|
+
nav();
|
|
373
|
+
}, () => {
|
|
374
|
+
if (timer) clearTimeout(timer);
|
|
375
|
+
nav();
|
|
376
|
+
});
|
|
341
377
|
});
|
|
342
378
|
|
|
343
379
|
ipcMain.on('ai:window_open_close', (_event, payload) => {
|
|
@@ -3093,11 +3129,18 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3093
3129
|
const wc0 = view.webContents;
|
|
3094
3130
|
const targetUrl = url || null;
|
|
3095
3131
|
if (wc0 && !wc0.isDestroyed()) {
|
|
3132
|
+
// 垫片就绪信号:先挂上去,再去加载 about:blank。顺序不能反 ——
|
|
3133
|
+
// ai:window_open_nav 可能在这一行之后立刻到达(IPC 保序,但它不等我们)。
|
|
3134
|
+
let signalReady = () => {};
|
|
3135
|
+
const ready = new Promise((resolve) => { signalReady = resolve; });
|
|
3136
|
+
this._windowOpenShimReady.set(tabId, ready);
|
|
3096
3137
|
let proceeded = false;
|
|
3097
3138
|
const proceed = () => {
|
|
3098
3139
|
if (proceeded) return;
|
|
3099
3140
|
proceeded = true;
|
|
3100
3141
|
Promise.resolve(this._ensureWindowOpenShim(tabId, view)).finally(() => {
|
|
3142
|
+
// 失败也放行:宁可这个 tab 没有垫片,也不能把它卡在 about:blank。
|
|
3143
|
+
signalReady();
|
|
3101
3144
|
if (targetUrl && wc0 && !wc0.isDestroyed()) {
|
|
3102
3145
|
wc0.loadURL(targetUrl).catch(() => {});
|
|
3103
3146
|
}
|
|
@@ -3338,6 +3381,7 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3338
3381
|
if (entry.tabId === tabId) this._windowOpenTokens.delete(token);
|
|
3339
3382
|
}
|
|
3340
3383
|
this._windowOpenShimTabs.delete(tabId);
|
|
3384
|
+
this._windowOpenShimReady.delete(tabId);
|
|
3341
3385
|
// Release per-tab network resources so a closed tab can never leak its
|
|
3342
3386
|
// debugger attachment or cached request entries / stale monitors.
|
|
3343
3387
|
this._cleanupTabResources(tabId);
|
|
@@ -3815,6 +3859,10 @@ return {ok:true,name:file.name,size:file.size,via:via,host_sel:hostSel,clicked:c
|
|
|
3815
3859
|
);
|
|
3816
3860
|
return true;
|
|
3817
3861
|
} catch (e) {
|
|
3862
|
+
// 失败时撤掉记账:留着就等于给这个 tab 判了"永久无垫片"。
|
|
3863
|
+
// 撤掉后下一次 window.open 还有机会补上(CDP 偶发挂住是环境问题,
|
|
3864
|
+
// 不是这个 tab 的固有属性)。
|
|
3865
|
+
this._windowOpenShimTabs.delete(tabId);
|
|
3818
3866
|
console.error('[window_open] shim inject failed:', tabId, e && e.message);
|
|
3819
3867
|
return false;
|
|
3820
3868
|
}
|
|
@@ -49,7 +49,10 @@ export const WINDOW_OPEN_SHIM_SOURCE = `(function () {
|
|
|
49
49
|
window.open = function (url, name, _features) {
|
|
50
50
|
var target = (url === undefined || url === null) ? '' : String(url);
|
|
51
51
|
var token = 'wo' + (++seq) + '_' + Date.now();
|
|
52
|
-
|
|
52
|
+
// ⚠️ 相对地址必须在这里就解析成绝对。主进程拿到的 '/foo.html' 没有 base,
|
|
53
|
+
// loadURL 会直接报 ERR_INVALID_URL(表现为一个 file:// 错误页)。
|
|
54
|
+
// 实测 2026-10-20:window.open('/l2.html','_blank') 开出的是错误页。
|
|
55
|
+
var ok = send('open', token, target ? abs(target) : '', name || '');
|
|
53
56
|
if (!ok) {
|
|
54
57
|
// 没有桥:退回原生语义(deny → null),至少不像成功那样骗站点。
|
|
55
58
|
return null;
|
package/src/main/ws_server.js
CHANGED
|
@@ -206,7 +206,19 @@ export function startWSServer(pageManager, port = config.wsPort, onQuit = null)
|
|
|
206
206
|
break;
|
|
207
207
|
}
|
|
208
208
|
case 'ui.evaluate': {
|
|
209
|
-
|
|
209
|
+
// 参数名是 js;expr 是 MCP 层的叫法,作为别名保留 —— 文档里写错一次
|
|
210
|
+
// (2026-10-20 技能里写成 expr)就会得到 `{value: undefined}`,
|
|
211
|
+
// 看上去像"页面没东西",实际是表达式根本没执行。别名 + 空表达式报错,
|
|
212
|
+
// 把"静默返回 undefined"这条路彻底堵掉。
|
|
213
|
+
const rawJs = params.js !== undefined ? params.js : params.expr;
|
|
214
|
+
const js = String(rawJs === undefined || rawJs === null ? '' : rawJs);
|
|
215
|
+
if (!js.trim()) {
|
|
216
|
+
send({
|
|
217
|
+
jsonrpc: '2.0', id,
|
|
218
|
+
error: { code: -32602, message: 'empty expression: pass {"js":"<expression>"} (alias: "expr")' },
|
|
219
|
+
});
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
210
222
|
// Guard rails (shared single source) — enforce length + reject
|
|
211
223
|
// obvious Node-exfil patterns. The MCP layer applies the same guard;
|
|
212
224
|
// a direct ws client bypasses MCP, so enforce the same contract here.
|
package/src/shared/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Kept in JS (not imported from package.json) to avoid Node import-attribute
|
|
3
3
|
// (JSON module) compatibility issues inside Electron. package.json "version"
|
|
4
4
|
// is manually synced to this value; tests assert they match.
|
|
5
|
-
export const VERSION = '2.
|
|
5
|
+
export const VERSION = '2.1.1';
|