@glittering0717/ai-browser 0.0.0-stage → 1.2.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/LICENSE +21 -0
- package/README.md +784 -2
- package/bin/ai-browser.cjs +78 -0
- package/examples/AGENT_INSTRUCTIONS.md +166 -0
- package/examples/mcp-config.json +12 -0
- package/llms-install.md +158 -0
- package/package.json +78 -4
- package/scripts/electron_resolve.cjs +241 -0
- package/scripts/start.cjs +60 -0
- package/src/main/axExtractor.js +436 -0
- package/src/main/canvas_hook_source.js +654 -0
- package/src/main/canvas_monitor.js +269 -0
- package/src/main/capability_probe.js +415 -0
- package/src/main/download_monitor.js +137 -0
- package/src/main/index.js +359 -0
- package/src/main/mcp_server.js +408 -0
- package/src/main/mcp_tools.js +289 -0
- package/src/main/mcp_ws.js +178 -0
- package/src/main/network_monitor.js +805 -0
- package/src/main/page_manager.js +2743 -0
- package/src/main/snapshot_diff.js +371 -0
- package/src/main/ws_server.js +440 -0
- package/src/preload/actions.cjs +190 -0
- package/src/preload/bridge.cjs +75 -0
- package/src/preload/extractor.cjs +936 -0
- package/src/preload/watcher.cjs +241 -0
- package/src/renderer/tab_bar.html +115 -0
- package/src/shared/config.js +32 -0
- package/src/shared/deadline.js +89 -0
- package/src/shared/guards.js +17 -0
- package/src/shared/protocol.js +116 -0
- package/src/shared/value_contract.cjs +132 -0
- package/src/shared/version.js +5 -0
- package/src/shared/watchdog.js +57 -0
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
// preload/watcher.cjs — Mutation/state/captcha/message/JS-error watchers (CommonJS)
|
|
2
|
+
// Split out of bridge.js v6.9. No Electron dependency — side effects are
|
|
3
|
+
// emitted through an injected `onEvent(eventType, data)` callback so the
|
|
4
|
+
// module can be unit-tested under jsdom (bridge.js passes ipcRenderer.send).
|
|
5
|
+
// Exports: startTracking(callbacks), stopTracking()
|
|
6
|
+
|
|
7
|
+
// ============================================================
|
|
8
|
+
// 4. CAPTCHA + MESSAGE WATCHERS
|
|
9
|
+
// ============================================================
|
|
10
|
+
|
|
11
|
+
var observer = null;
|
|
12
|
+
var lastCaptchaSig = "";
|
|
13
|
+
var lastMessageSig = "";
|
|
14
|
+
var mutationQueue = [];
|
|
15
|
+
var mutationFlushTimer = null;
|
|
16
|
+
var scanTimer = null;
|
|
17
|
+
var MUTATION_FLUSH_DELAY = 500;
|
|
18
|
+
var SCAN_INTERVAL = 5000;
|
|
19
|
+
|
|
20
|
+
function scanCaptcha() {
|
|
21
|
+
var byCSS = document.querySelectorAll("[class*=captcha],[class*=verify],[class*=slider],[class*=slide],[class*=drag],[id*=captcha],[id*=verify],[class*=JDJRV],[class*=geetest],[class*=yidun],[class*=small-jd]");
|
|
22
|
+
var byAlt = document.querySelectorAll("img[alt*=验证码],img[alt*=captcha],img[alt*=verify],img[alt*=滑块],img[alt*=slide]");
|
|
23
|
+
var all = [];
|
|
24
|
+
for (var bi = 0; bi < byCSS.length; bi++) all.push(byCSS[bi]);
|
|
25
|
+
for (var ai = 0; ai < byAlt.length; ai++) { if (all.indexOf(byAlt[ai]) < 0) all.push(byAlt[ai]); }
|
|
26
|
+
|
|
27
|
+
var visible = [];
|
|
28
|
+
for (var i = 0; i < all.length; i++) {
|
|
29
|
+
var r = all[i].getBoundingClientRect();
|
|
30
|
+
if (r.width >= 0 || r.height >= 0) {
|
|
31
|
+
visible.push({
|
|
32
|
+
tag: all[i].tagName,
|
|
33
|
+
cls: String(all[i].className || "").slice(0, 80),
|
|
34
|
+
id: all[i].id || "",
|
|
35
|
+
text: String(all[i].textContent || "").trim().slice(0, 50),
|
|
36
|
+
bounds: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) }
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Also scan for canvases in captcha areas
|
|
42
|
+
var canvases = document.querySelectorAll("canvas");
|
|
43
|
+
for (var ci = 0; ci < canvases.length; ci++) {
|
|
44
|
+
var cr = canvases[ci].getBoundingClientRect();
|
|
45
|
+
if (cr.width > 10 && cr.height > 10) {
|
|
46
|
+
// Check if canvas is inside a captcha-like container
|
|
47
|
+
var parent = canvases[ci].parentElement;
|
|
48
|
+
var pCls = (parent && parent.className || "") + (parent && parent.id || "");
|
|
49
|
+
if (/captcha|verify|slider|slide|drag|yidun|geetest/i.test(pCls)) {
|
|
50
|
+
visible.push({
|
|
51
|
+
tag: "CANVAS",
|
|
52
|
+
cls: (canvases[ci].className || "").slice(0, 40),
|
|
53
|
+
id: canvases[ci].id || "",
|
|
54
|
+
text: "[canvas captcha]",
|
|
55
|
+
bounds: { x: Math.round(cr.x), y: Math.round(cr.y), width: Math.round(cr.width), height: Math.round(cr.height) },
|
|
56
|
+
parent_cls: pCls.slice(0, 60)
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return visible;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function scanMessages() {
|
|
66
|
+
var selectors = [
|
|
67
|
+
"[class*=toast]", "[class*=message]", "[class*=notification]", "[class*=alert]",
|
|
68
|
+
"[class*=snackbar]", "[class*=notice]", "[class*=tip]",
|
|
69
|
+
"[role=alert]", "[role=status]", "[class*=banner]"
|
|
70
|
+
];
|
|
71
|
+
var msgs = [];
|
|
72
|
+
for (var si = 0; si < selectors.length; si++) {
|
|
73
|
+
var els = document.querySelectorAll(selectors[si]);
|
|
74
|
+
for (var ei = 0; ei < els.length; ei++) {
|
|
75
|
+
if (els[ei].offsetParent === null && window.getComputedStyle(els[ei]).display === "none") continue;
|
|
76
|
+
var r = els[ei].getBoundingClientRect();
|
|
77
|
+
var t = (els[ei].textContent || "").trim();
|
|
78
|
+
if (t && t.length < 200) {
|
|
79
|
+
msgs.push({ text: t.slice(0, 100), bounds: { x: Math.round(r.x), y: Math.round(r.y), width: Math.round(r.width), height: Math.round(r.height) } });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return msgs;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function runScan(emit) {
|
|
87
|
+
scanTimer = null;
|
|
88
|
+
var cap = scanCaptcha();
|
|
89
|
+
if (cap.length > 0) {
|
|
90
|
+
var sig = JSON.stringify(cap.map(function(c) { return c.cls + "|" + c.bounds.width + "x" + c.bounds.height; }));
|
|
91
|
+
if (sig !== lastCaptchaSig) {
|
|
92
|
+
lastCaptchaSig = sig;
|
|
93
|
+
emit("captcha_appeared", { elements: cap });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
var msg = scanMessages();
|
|
97
|
+
if (msg.length > 0) {
|
|
98
|
+
var msig = JSON.stringify(msg.map(function(m) { return m.text; }));
|
|
99
|
+
if (msig !== lastMessageSig) {
|
|
100
|
+
lastMessageSig = msig;
|
|
101
|
+
emit("message_appeared", { messages: msg });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function flushMutations(emit) {
|
|
107
|
+
mutationFlushTimer = null;
|
|
108
|
+
var mutations = mutationQueue;
|
|
109
|
+
mutationQueue = [];
|
|
110
|
+
if (mutations.length === 0) return;
|
|
111
|
+
|
|
112
|
+
var stateChanges = [];
|
|
113
|
+
|
|
114
|
+
for (var mi = 0; mi < mutations.length; mi++) {
|
|
115
|
+
var m = mutations[mi];
|
|
116
|
+
|
|
117
|
+
if (m.type === "attributes") {
|
|
118
|
+
var attrName = (m.attributeName || "").toLowerCase();
|
|
119
|
+
var trackedAttrs = ["disabled", "class", "open", "checked", "aria-expanded", "aria-selected", "hidden", "value", "readonly"];
|
|
120
|
+
if (trackedAttrs.indexOf(attrName) >= 0) {
|
|
121
|
+
var el = m.target;
|
|
122
|
+
if (el instanceof HTMLElement) {
|
|
123
|
+
var aiId = el.getAttribute("data-ai-id");
|
|
124
|
+
if (aiId) {
|
|
125
|
+
stateChanges.push({
|
|
126
|
+
type: "state_changed",
|
|
127
|
+
targetId: aiId,
|
|
128
|
+
attribute: attrName,
|
|
129
|
+
value: el.getAttribute(attrName)
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (m.type === "childList") {
|
|
137
|
+
var parentId = m.target instanceof HTMLElement ? m.target.getAttribute("data-ai-id") : null;
|
|
138
|
+
if (m.addedNodes.length > 0 || m.removedNodes.length > 0) {
|
|
139
|
+
stateChanges.push({
|
|
140
|
+
type: "dom_changed",
|
|
141
|
+
parentId: parentId,
|
|
142
|
+
added: m.addedNodes.length,
|
|
143
|
+
removed: m.removedNodes.length
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (stateChanges.length > 0) {
|
|
150
|
+
var batch = [];
|
|
151
|
+
for (var si = 0; si < Math.min(stateChanges.length, 20); si++) batch.push(stateChanges[si]);
|
|
152
|
+
emit("state_changed", { changes: batch });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function bindObserver(emit) {
|
|
157
|
+
if (observer) observer.disconnect();
|
|
158
|
+
mutationQueue = [];
|
|
159
|
+
if (mutationFlushTimer) {
|
|
160
|
+
clearTimeout(mutationFlushTimer);
|
|
161
|
+
mutationFlushTimer = null;
|
|
162
|
+
}
|
|
163
|
+
if (scanTimer) {
|
|
164
|
+
clearInterval(scanTimer);
|
|
165
|
+
scanTimer = null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
observer = new MutationObserver(function(mutations) {
|
|
169
|
+
mutationQueue.push.apply(mutationQueue, mutations);
|
|
170
|
+
if (!mutationFlushTimer) {
|
|
171
|
+
mutationFlushTimer = setTimeout(function() { flushMutations(emit); }, MUTATION_FLUSH_DELAY);
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
observer.observe(document.body, {
|
|
176
|
+
childList: true,
|
|
177
|
+
subtree: true,
|
|
178
|
+
attributes: true,
|
|
179
|
+
attributeFilter: ["disabled", "class", "open", "checked", "aria-expanded", "aria-selected", "hidden", "value", "readonly"]
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Captcha/message scan runs on its own low-frequency timer to avoid
|
|
183
|
+
// blocking the renderer with querySelectorAll during page load.
|
|
184
|
+
scanTimer = setInterval(function() { runScan(emit); }, SCAN_INTERVAL);
|
|
185
|
+
setTimeout(function() { runScan(emit); }, 1000);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// ============================================================
|
|
189
|
+
// JS error capture
|
|
190
|
+
// ============================================================
|
|
191
|
+
|
|
192
|
+
var oldOnError = null;
|
|
193
|
+
|
|
194
|
+
function hookJsErrors(emit) {
|
|
195
|
+
oldOnError = window.onerror;
|
|
196
|
+
window.onerror = function(msg, url, line, col, err) {
|
|
197
|
+
emit("js_error", { message: String(msg), url: String(url), line: line, col: col, stack: (err && err.stack ? String(err.stack).slice(0, 300) : "") });
|
|
198
|
+
if (oldOnError) return oldOnError.apply(this, arguments);
|
|
199
|
+
return false;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
window.addEventListener("unhandledrejection", function(ev) {
|
|
203
|
+
var reason = ev.reason;
|
|
204
|
+
var msg = reason instanceof Error ? reason.message : String(reason);
|
|
205
|
+
emit("js_error", { message: msg, type: "unhandledrejection", stack: (reason instanceof Error && reason.stack ? String(reason.stack).slice(0, 300) : "") });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ============================================================
|
|
210
|
+
// Public API
|
|
211
|
+
// ============================================================
|
|
212
|
+
|
|
213
|
+
function startTracking(callbacks) {
|
|
214
|
+
callbacks = callbacks || {};
|
|
215
|
+
function nullEmit() {}
|
|
216
|
+
var emit = typeof callbacks.onEvent === "function" ? callbacks.onEvent : nullEmit;
|
|
217
|
+
bindObserver(emit);
|
|
218
|
+
hookJsErrors(emit);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function stopTracking() {
|
|
222
|
+
if (observer) {
|
|
223
|
+
observer.disconnect();
|
|
224
|
+
observer = null;
|
|
225
|
+
}
|
|
226
|
+
mutationQueue = [];
|
|
227
|
+
if (mutationFlushTimer) {
|
|
228
|
+
clearTimeout(mutationFlushTimer);
|
|
229
|
+
mutationFlushTimer = null;
|
|
230
|
+
}
|
|
231
|
+
if (scanTimer) {
|
|
232
|
+
clearInterval(scanTimer);
|
|
233
|
+
scanTimer = null;
|
|
234
|
+
}
|
|
235
|
+
if (oldOnError !== null) {
|
|
236
|
+
window.onerror = oldOnError;
|
|
237
|
+
oldOnError = null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { startTracking, stopTracking };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<style>
|
|
6
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
7
|
+
body {
|
|
8
|
+
height: 36px;
|
|
9
|
+
background: #1a1a1a;
|
|
10
|
+
display: flex;
|
|
11
|
+
align-items: center;
|
|
12
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
13
|
+
font-size: 12px;
|
|
14
|
+
color: #ccc;
|
|
15
|
+
user-select: none;
|
|
16
|
+
-webkit-app-region: drag;
|
|
17
|
+
}
|
|
18
|
+
#tabs {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex: 1;
|
|
21
|
+
overflow: hidden;
|
|
22
|
+
-webkit-app-region: no-drag;
|
|
23
|
+
}
|
|
24
|
+
.tab {
|
|
25
|
+
display: flex;
|
|
26
|
+
align-items: center;
|
|
27
|
+
max-width: 200px;
|
|
28
|
+
min-width: 80px;
|
|
29
|
+
padding: 0 12px;
|
|
30
|
+
height: 36px;
|
|
31
|
+
cursor: pointer;
|
|
32
|
+
border-right: 1px solid #2a2a2a;
|
|
33
|
+
transition: background 0.15s;
|
|
34
|
+
}
|
|
35
|
+
.tab:hover { background: #2a2a2a; }
|
|
36
|
+
.tab.active { background: #2d2d30; color: #fff; border-bottom: 2px solid #007acc; }
|
|
37
|
+
.tab-title {
|
|
38
|
+
flex: 1;
|
|
39
|
+
overflow: hidden;
|
|
40
|
+
text-overflow: ellipsis;
|
|
41
|
+
white-space: nowrap;
|
|
42
|
+
margin-right: 8px;
|
|
43
|
+
}
|
|
44
|
+
.tab-close {
|
|
45
|
+
width: 16px;
|
|
46
|
+
height: 16px;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
border-radius: 3px;
|
|
51
|
+
font-size: 14px;
|
|
52
|
+
color: #666;
|
|
53
|
+
cursor: pointer;
|
|
54
|
+
}
|
|
55
|
+
.tab-close:hover { background: #3a3a3a; color: #fff; }
|
|
56
|
+
#new-tab {
|
|
57
|
+
width: 36px;
|
|
58
|
+
height: 36px;
|
|
59
|
+
display: flex;
|
|
60
|
+
align-items: center;
|
|
61
|
+
justify-content: center;
|
|
62
|
+
font-size: 20px;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
color: #888;
|
|
65
|
+
-webkit-app-region: no-drag;
|
|
66
|
+
}
|
|
67
|
+
#new-tab:hover { background: #2a2a2a; color: #fff; }
|
|
68
|
+
.tab-id { color: #555; font-size: 10px; margin-right: 4px; }
|
|
69
|
+
</style>
|
|
70
|
+
</head>
|
|
71
|
+
<body>
|
|
72
|
+
<div id="tabs"></div>
|
|
73
|
+
<div id="new-tab">+</div>
|
|
74
|
+
<script>
|
|
75
|
+
const { ipcRenderer } = require('electron');
|
|
76
|
+
|
|
77
|
+
async function renderTabs() {
|
|
78
|
+
try {
|
|
79
|
+
const tabs = await ipcRenderer.invoke('tab:list');
|
|
80
|
+
const container = document.getElementById('tabs');
|
|
81
|
+
container.innerHTML = '';
|
|
82
|
+
for (const tab of tabs) {
|
|
83
|
+
const el = document.createElement('div');
|
|
84
|
+
el.className = 'tab' + (tab.active ? ' active' : '');
|
|
85
|
+
const title = (tab.title || tab.url || 'New Tab').slice(0, 30);
|
|
86
|
+
el.innerHTML = '<span class="tab-id">[' + tab.id + ']</span>' +
|
|
87
|
+
'<span class="tab-title">' + title + '</span>' +
|
|
88
|
+
'<span class="tab-close" data-id="' + tab.id + '">×</span>';
|
|
89
|
+
el.addEventListener('click', function(e) {
|
|
90
|
+
if (e.target.classList.contains('tab-close')) {
|
|
91
|
+
ipcRenderer.send('tab:close', parseInt(e.target.dataset.id));
|
|
92
|
+
} else {
|
|
93
|
+
ipcRenderer.send('tab:activate', tab.id);
|
|
94
|
+
}
|
|
95
|
+
});
|
|
96
|
+
container.appendChild(el);
|
|
97
|
+
}
|
|
98
|
+
} catch(e) {
|
|
99
|
+
// ipcRenderer not ready yet
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// expose for main process to call
|
|
104
|
+
window.renderTabs = renderTabs;
|
|
105
|
+
|
|
106
|
+
document.getElementById('new-tab').addEventListener('click', function() {
|
|
107
|
+
ipcRenderer.send('tab:new');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Initial render + periodic refresh
|
|
111
|
+
renderTabs();
|
|
112
|
+
setInterval(renderTabs, 1500);
|
|
113
|
+
</script>
|
|
114
|
+
</body>
|
|
115
|
+
</html>
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// shared/config.js — Centralized runtime configuration, single source of truth.
|
|
2
|
+
// Replaces hardcoded port 9223 / User-Agent / userData dir scattered across
|
|
3
|
+
// main/. Each value respects an AI_BROWSER_* env override, with defaults that
|
|
4
|
+
// are byte-identical to the previous hardcoded values (no behavior change by
|
|
5
|
+
// default).
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import os from 'node:os';
|
|
8
|
+
|
|
9
|
+
const DEFAULT_UA =
|
|
10
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36';
|
|
11
|
+
|
|
12
|
+
export const config = {
|
|
13
|
+
// WebSocket JSON-RPC server + MCP auto-launch probe port
|
|
14
|
+
wsPort: Number(process.env.AI_BROWSER_PORT) || 9223,
|
|
15
|
+
// Electron userData dir — session/cookie persistence survives restart
|
|
16
|
+
userDataDir: process.env.AI_BROWSER_USER_DATA || path.join(os.homedir(), '.ai-browser'),
|
|
17
|
+
// Spoofed UA to mask the automation fingerprint
|
|
18
|
+
userAgent: process.env.AI_BROWSER_UA || DEFAULT_UA,
|
|
19
|
+
// Height of the in-app tab strip below the window title bar
|
|
20
|
+
tabBarHeight: 36,
|
|
21
|
+
// The session every tab's BrowserView uses. **Must stay a single source of
|
|
22
|
+
// truth**: session-level hooks (will-download, permission requests) are
|
|
23
|
+
// installed on `session.fromPartition(viewPartition)`. Hooking
|
|
24
|
+
// `session.defaultSession` instead silently does nothing — the views are not
|
|
25
|
+
// in that session. Measured 2026-10-12: a download was requested by the page,
|
|
26
|
+
// no will-download ever fired, and no file landed.
|
|
27
|
+
viewPartition: process.env.AI_BROWSER_VIEW_PARTITION || 'persist:ai-browser',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function wsUrl(port = config.wsPort) {
|
|
31
|
+
return `ws://localhost:${port}`;
|
|
32
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// shared/deadline.js — 给"会进页面"的调用加超时(纯函数,可无 Electron 单测)
|
|
2
|
+
//
|
|
3
|
+
// # 为什么需要这个文件
|
|
4
|
+
// ws_server 的每个 case 都是 `await pageManager.xxx(...)`,顶层只有 try/catch ——
|
|
5
|
+
// 抓得住"抛错",抓不住"不返回"。而 Chromium 的 CDP 命令**真的会不返回**:
|
|
6
|
+
// `Input.dispatchMouseEvent` 要等渲染进程 ack,渲染进程主线程被占住时 ack 永远不来。
|
|
7
|
+
//
|
|
8
|
+
// 实测(tools/_oneoff/blocked_renderer_probe.cjs,页面加载后 while 循环占住主线程
|
|
9
|
+
// 120s,单项上限 45s):
|
|
10
|
+
//
|
|
11
|
+
// ui.get_tree 5006ms ERR getTree timeout ← 本来就有超时
|
|
12
|
+
// ui.evaluate 10012ms ERR evaluate timeout ← CDP 5s + preload 回落 5s
|
|
13
|
+
// ui.get_focused 5006ms ERR getTree timeout
|
|
14
|
+
// ui.capabilities 15014ms ok
|
|
15
|
+
// ui.act 45001ms **仍无回包** ← 唯一真挂的一条
|
|
16
|
+
//
|
|
17
|
+
// 也就是说这不是"慢",是 agent 的工具调用永久悬挂、整个会话死掉,而且**没有任何错误
|
|
18
|
+
// 可看**。比抛错严重得多:抛错至少能让 agent 换策略。
|
|
19
|
+
//
|
|
20
|
+
// # 为什么超时必须按动作算,而不是一个固定值
|
|
21
|
+
// 固定小值会误杀合法长操作。实测这些路径自己就会睡很久:
|
|
22
|
+
// type/setContent 每字 interval_ms(默认 60ms)→ 500 字就是 30s
|
|
23
|
+
// drag duration_ms(默认 420ms)+ steps×~40ms,上限 5s
|
|
24
|
+
// clear 要走编辑器状态提交,多处 80~150ms 等待 + 回读校验
|
|
25
|
+
// 所以预算 = max(基线, 该动作自报的预期耗时 × 2 + 余量),并留 env / 单次调用的逃生门。
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_ACTION_DEADLINE_MS = 15000;
|
|
28
|
+
export const MAX_ACTION_DEADLINE_MS = 300000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 把一个 promise 套上超时。超时抛出的 error 带 `__deadline` 标记,调用方可以
|
|
32
|
+
* 据此把它翻译成"成功=false + 明确原因",而不是当成未知异常往上抛。
|
|
33
|
+
*
|
|
34
|
+
* 原 promise 迟到拒绝会被接住(不会变成 unhandled rejection)—— 超时之后它仍可能
|
|
35
|
+
* 完成或失败,那是后台的事,不该把进程吵醒。
|
|
36
|
+
*/
|
|
37
|
+
export function withDeadline(promise, ms, label) {
|
|
38
|
+
const budget = Math.max(1, Number(ms) || DEFAULT_ACTION_DEADLINE_MS);
|
|
39
|
+
let timer = null;
|
|
40
|
+
const timeout = new Promise((_, reject) => {
|
|
41
|
+
timer = setTimeout(() => {
|
|
42
|
+
const err = new Error(`${label} timeout after ${budget}ms`);
|
|
43
|
+
err.__deadline = true;
|
|
44
|
+
err.deadline_ms = budget;
|
|
45
|
+
reject(err);
|
|
46
|
+
}, budget);
|
|
47
|
+
});
|
|
48
|
+
// 防止"超时已报错,原 promise 之后才拒绝"变成 unhandled rejection。
|
|
49
|
+
Promise.resolve(promise).catch(() => {});
|
|
50
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
51
|
+
if (timer) clearTimeout(timer);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 算一个动作的合理超时预算(毫秒)。
|
|
57
|
+
*
|
|
58
|
+
* 优先级:单次调用 `params.timeout_ms` > env `AI_BROWSER_ACTION_TIMEOUT_MS` >
|
|
59
|
+
* 按动作自报耗时推算。三者都是显式逃生门:agent 知道自己要 type 3000 字时,
|
|
60
|
+
* 不该被一个猜出来的默认值卡死。
|
|
61
|
+
*/
|
|
62
|
+
export function actionDeadlineMs(action, params = {}) {
|
|
63
|
+
const p = params || {};
|
|
64
|
+
let need = DEFAULT_ACTION_DEADLINE_MS;
|
|
65
|
+
|
|
66
|
+
const text = String(p.text || '');
|
|
67
|
+
if (action === 'type' || action === 'setContent') {
|
|
68
|
+
const iv = Number(p.interval_ms) > 0 ? Number(p.interval_ms) : 60;
|
|
69
|
+
need = Math.max(need, text.length * iv + 5000);
|
|
70
|
+
} else if (action === 'drag') {
|
|
71
|
+
const dur = Number(p.duration_ms) > 0 ? Number(p.duration_ms) : 420;
|
|
72
|
+
const steps = Number(p.steps) > 0 ? Number(p.steps) : 14;
|
|
73
|
+
need = Math.max(need, dur + steps * 40 + 5000);
|
|
74
|
+
} else if (action === 'press') {
|
|
75
|
+
const rep = Number(p.repeat) > 0 ? Number(p.repeat) : 1;
|
|
76
|
+
const iv = Number(p.interval_ms) > 0 ? Number(p.interval_ms) : 60;
|
|
77
|
+
need = Math.max(need, rep * iv + 5000);
|
|
78
|
+
} else if (action === 'clear') {
|
|
79
|
+
// clear 要走编辑器状态提交与回读校验(多处 80~150ms 等待)。
|
|
80
|
+
need = Math.max(need, 20000);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const env = Number(process.env.AI_BROWSER_ACTION_TIMEOUT_MS);
|
|
84
|
+
if (Number.isFinite(env) && env > 0) need = env;
|
|
85
|
+
const perCall = Number(p.timeout_ms);
|
|
86
|
+
if (Number.isFinite(perCall) && perCall > 0) need = perCall;
|
|
87
|
+
|
|
88
|
+
return Math.min(Math.max(need, 1000), MAX_ACTION_DEADLINE_MS);
|
|
89
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// shared/guards.js — Centralized eval-safety guard, single source of truth.
|
|
2
|
+
// Used by BOTH the raw WebSocket path (ws_server.js `ui.evaluate`) and the MCP
|
|
3
|
+
// path (mcp_server.js `browse_evaluate`). Keeping the limits + blocklist in one
|
|
4
|
+
// place prevents one entry being hardened and the other silently left open.
|
|
5
|
+
|
|
6
|
+
export const EVAL_MAX_LENGTH = 5000;
|
|
7
|
+
const NODE_IDENTIFIER_RE = /\bprocess\.\b|\brequire\s*\(|\bchild_process\b|\bglobalThis\.process\b/;
|
|
8
|
+
|
|
9
|
+
// Returns null when the script is safe to run, or the error message that
|
|
10
|
+
// should be returned to the caller. Message text is part of the contract —
|
|
11
|
+
// external ws/mcp clients may match on it, so keep it stable.
|
|
12
|
+
export function evaluateGuardError(js) {
|
|
13
|
+
if (typeof js !== 'string') return 'script must be a string';
|
|
14
|
+
if (js.length > EVAL_MAX_LENGTH) return `script exceeds ${EVAL_MAX_LENGTH} char limit`;
|
|
15
|
+
if (NODE_IDENTIFIER_RE.test(js)) return 'script contains disallowed Node-specific identifier';
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// shared/protocol.js — JSON-RPC 2.0 subset for AI Browser
|
|
2
|
+
// Used by both main process (WS server) and preload (IPC bridge)
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create a JSON-RPC request string
|
|
6
|
+
*/
|
|
7
|
+
export function createRequest(id, method, params = {}) {
|
|
8
|
+
return JSON.stringify({ jsonrpc: '2.0', id, method, params });
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Create a JSON-RPC success response string
|
|
13
|
+
*/
|
|
14
|
+
export function createResponse(id, result) {
|
|
15
|
+
return JSON.stringify({ jsonrpc: '2.0', id, result });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a JSON-RPC error response string
|
|
20
|
+
*/
|
|
21
|
+
export function createError(id, code, message, data = null) {
|
|
22
|
+
const error = { code, message };
|
|
23
|
+
if (data) error.data = data;
|
|
24
|
+
return JSON.stringify({ jsonrpc: '2.0', id, error });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Create a server-pushed event (no id — notification per JSON-RPC)
|
|
29
|
+
*/
|
|
30
|
+
export function createEvent(event, data) {
|
|
31
|
+
return JSON.stringify({ jsonrpc: '2.0', method: event, params: data });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Parse a JSON-RPC message string into object
|
|
36
|
+
*/
|
|
37
|
+
export function parseMessage(msg) {
|
|
38
|
+
try {
|
|
39
|
+
const obj = JSON.parse(msg);
|
|
40
|
+
if (obj.jsonrpc !== '2.0') {
|
|
41
|
+
throw new Error('Invalid JSON-RPC version');
|
|
42
|
+
}
|
|
43
|
+
return obj;
|
|
44
|
+
} catch (e) {
|
|
45
|
+
throw new Error(`Failed to parse JSON-RPC message: ${e.message}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Every ui.* method the WebSocket server actually dispatches.
|
|
51
|
+
*
|
|
52
|
+
* This list had drifted badly: it named 7 methods while the server served 28,
|
|
53
|
+
* so anyone reading it would conclude most of the API did not exist. A
|
|
54
|
+
* machine-readable contract that lies is worse than no contract — so
|
|
55
|
+
* `tests/test_protocol.js` now derives the real set from the `case 'ui.x':`
|
|
56
|
+
* labels in `src/main/ws_server.js` and fails if the two diverge. Add a method
|
|
57
|
+
* to the server and this list must follow, or the suite breaks.
|
|
58
|
+
*/
|
|
59
|
+
export const VALID_METHODS = [
|
|
60
|
+
'ui.get_tree',
|
|
61
|
+
'ui.act',
|
|
62
|
+
'ui.navigate',
|
|
63
|
+
'ui.repaint',
|
|
64
|
+
'ui.snapshot',
|
|
65
|
+
'ui.diff',
|
|
66
|
+
'ui.capabilities',
|
|
67
|
+
'ui.evaluate',
|
|
68
|
+
'ui.subscribe',
|
|
69
|
+
'ui.unsubscribe',
|
|
70
|
+
'ui.downloads',
|
|
71
|
+
'ui.get_focused',
|
|
72
|
+
'ui.peek',
|
|
73
|
+
'ui.wait',
|
|
74
|
+
'ui.scroll',
|
|
75
|
+
'ui.new_tab',
|
|
76
|
+
'ui.close_tab',
|
|
77
|
+
'ui.list_tabs',
|
|
78
|
+
'ui.set_active_tab',
|
|
79
|
+
'ui.canvas_configure',
|
|
80
|
+
'ui.canvas_list',
|
|
81
|
+
'ui.canvas_read',
|
|
82
|
+
'ui.canvas_capture',
|
|
83
|
+
'ui.network_list',
|
|
84
|
+
'ui.network_get',
|
|
85
|
+
'ui.network_clear',
|
|
86
|
+
'ui.network_configure',
|
|
87
|
+
'ui.network_body',
|
|
88
|
+
'ui.quit',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Standard JSON-RPC error codes
|
|
93
|
+
*/
|
|
94
|
+
export const ERROR_CODES = {
|
|
95
|
+
PARSE_ERROR: -32700,
|
|
96
|
+
INVALID_REQUEST: -32600,
|
|
97
|
+
METHOD_NOT_FOUND: -32601,
|
|
98
|
+
INVALID_PARAMS: -32602,
|
|
99
|
+
INTERNAL_ERROR: -32603,
|
|
100
|
+
ACTION_FAILED: -32001,
|
|
101
|
+
TARGET_NOT_FOUND: -32002,
|
|
102
|
+
INVALID_ACTION: -32003,
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// For CJS compatibility in Electron preload
|
|
106
|
+
if (typeof module !== 'undefined' && module.exports) {
|
|
107
|
+
module.exports = {
|
|
108
|
+
createRequest,
|
|
109
|
+
createResponse,
|
|
110
|
+
createError,
|
|
111
|
+
createEvent,
|
|
112
|
+
parseMessage,
|
|
113
|
+
VALID_METHODS,
|
|
114
|
+
ERROR_CODES,
|
|
115
|
+
};
|
|
116
|
+
}
|