@evan_zhong/browser-attach 0.15.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 +7 -7
- package/README.zh.md +7 -7
- package/bin/browser-attach.mjs +3 -3
- package/docs/AGENT-INTEGRATION.md +3 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DESIGN-REVIEW-0.15.1.md +27 -0
- package/docs/TEST-REPORT.md +25 -1
- package/docs/USAGE.md +32 -5
- package/extension/background.js +7 -98
- package/extension/batch.js +2 -0
- package/extension/click.js +34 -5
- package/extension/connection.js +142 -0
- package/extension/controller.js +3 -2
- package/extension/drag.js +15 -3
- package/extension/input.js +3 -2
- package/extension/manifest.json +1 -1
- package/extension/motion.js +50 -0
- package/extension/snapshot.js +16 -11
- 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 +20 -6
- package/src/setup.mjs +87 -16
package/extension/click.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { deadline, waitForTarget, delivery } from './actionability.js';
|
|
2
|
+
import { offsetOption, timingOption, pause } from './motion.js';
|
|
2
3
|
|
|
3
4
|
// Native mouse dispatch stays in protocol order without serial compositor acknowledgments.
|
|
4
5
|
export function createClick(cdp, value, viewport, position, sessions, getTab, getWindow) {
|
|
@@ -45,10 +46,14 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
45
46
|
}
|
|
46
47
|
function cancel(tabId) { pending.get(tabId)?.reject(new Error('Tab detached during click; outcome is unknown. Observe before retrying.')); }
|
|
47
48
|
async function prepare(tabId, node, args) {
|
|
49
|
+
offsetOption(args);
|
|
48
50
|
if (!!node === (args.x != null || args.y != null)) throw new Error('Provide either ref/selector or x/y');
|
|
49
51
|
if (args.coordinate_space != null && !['viewport', 'document'].includes(args.coordinate_space)) throw new Error('coordinate_space must be viewport or document');
|
|
50
52
|
if (node && args.coordinate_space != null) throw new Error('coordinate_space applies to coordinate clicks only');
|
|
51
53
|
const budget = args.budget || deadline(args.timeout_ms);
|
|
54
|
+
const obstruction = detail => Object.assign(new Error(`${detail}. ${budget.has_dispatched()
|
|
55
|
+
? 'Input has already been dispatched. Outcome is unknown; observe before continuing. Do not replay automatically.'
|
|
56
|
+
: 'No input was dispatched.'}`), { input_dispatched: budget.has_dispatched() });
|
|
52
57
|
let target = node?.target || { tabId };
|
|
53
58
|
const send = (method, params) => budget.run(() => cdp(target, method, params));
|
|
54
59
|
let point, view;
|
|
@@ -59,7 +64,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
59
64
|
const { quads } = await send('DOM.getContentQuads', { backendNodeId: node.backendNodeId });
|
|
60
65
|
view = await budget.run(() => viewport(target));
|
|
61
66
|
for (const quad of quads || []) {
|
|
62
|
-
point = clippedPoint(quad, view);
|
|
67
|
+
point = clippedPoint(quad, view, args.offset);
|
|
63
68
|
if (point) break;
|
|
64
69
|
}
|
|
65
70
|
if (!point) throw new Error('Element has no visible click point. Inspect a screenshot.');
|
|
@@ -120,7 +125,7 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
120
125
|
objectId: node.objectId, functionDeclaration: 'function(hit) { for (let e = hit; e; e = e.parentNode || e.host) if (e === this) return true; return false; }',
|
|
121
126
|
arguments: [{ objectId: hit.object.objectId }], returnByValue: true,
|
|
122
127
|
}));
|
|
123
|
-
if (!unblocked) throw
|
|
128
|
+
if (!unblocked) throw obstruction(`Another element covers this ref. hit_target=${JSON.stringify(hitTarget)}; point=${JSON.stringify(point)}; frame_id=${JSON.stringify(node.frameId)}`);
|
|
124
129
|
}
|
|
125
130
|
// Dispatch through the root widget so overlays in ancestor documents cannot
|
|
126
131
|
// be bypassed by sending directly to an OOPIF widget. Guard every boundary.
|
|
@@ -136,7 +141,16 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
136
141
|
const parentHit = await budget.run(() => cdp(parent.target, 'DOM.getNodeForLocation', {
|
|
137
142
|
x: Math.floor(rootPoint.x + parentView.scroll_x), y: Math.floor(rootPoint.y + parentView.scroll_y), includeUserAgentShadowDOM: true,
|
|
138
143
|
}));
|
|
139
|
-
if (parentHit.backendNodeId !== owner.backendNodeId)
|
|
144
|
+
if (parentHit.backendNodeId !== owner.backendNodeId) {
|
|
145
|
+
let blocker;
|
|
146
|
+
try {
|
|
147
|
+
const diagnostic = budget.child(200);
|
|
148
|
+
const resolved = await diagnostic.run(() => cdp(parent.target, 'DOM.resolveNode', { backendNodeId: parentHit.backendNodeId, objectGroup: 'browser-attach' }));
|
|
149
|
+
blocker = value(await diagnostic.run(() => cdp(parent.target, 'Runtime.callFunctionOn', { objectId: resolved.object.objectId,
|
|
150
|
+
functionDeclaration: describeHit.toString(), returnByValue: true })));
|
|
151
|
+
} catch { /* Preserve the obstruction error even if its node disappears. */ }
|
|
152
|
+
throw obstruction(`Another element covers this frame. hit_target=${JSON.stringify(blocker ?? { backend_node_id: parentHit.backendNodeId })}; point=${JSON.stringify(rootPoint)}; blocked_frame_id=${JSON.stringify(parent.frameId)}`);
|
|
153
|
+
}
|
|
140
154
|
const ownerFrameId = parent.ownerFrameId || (await budget.run(() => cdp(parent.target, 'Page.getFrameTree'))).frameTree.frame.id;
|
|
141
155
|
boundaries.push({ target: parent.target, backendNodeId: owner.backendNodeId, frameId: ownerFrameId });
|
|
142
156
|
current = parent.target; currentView = parentView;
|
|
@@ -151,7 +165,8 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
151
165
|
if (!options.hover && hitTarget.native_select) throw new Error("Native select popup is unsupported. No input was dispatched. Use browser_select_option by label/value/index; press Esc if a native popup is already open.");
|
|
152
166
|
const name = `__attachClick_${crypto.randomUUID().replaceAll('-', '')}`;
|
|
153
167
|
const guards = [], contexts = new Set();
|
|
154
|
-
|
|
168
|
+
let failure;
|
|
169
|
+
const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject(error) { failure = error; reject(error); } }));
|
|
155
170
|
receipt.catch(() => {});
|
|
156
171
|
try {
|
|
157
172
|
const targets = [{ target, frameId: node?.frameId || frameId, backendNodeId: node?.backendNodeId || backendNodeId, leaf: true }, ...boundaries];
|
|
@@ -173,7 +188,10 @@ export function createClick(cdp, value, viewport, position, sessions, getTab, ge
|
|
|
173
188
|
}
|
|
174
189
|
// Recheck the root page after target preparation: a user may have changed
|
|
175
190
|
// tabs/focus while we resolved a frame or installed the event guards.
|
|
191
|
+
await pause(budget, args.settle_ms);
|
|
192
|
+
if (failure) throw failure;
|
|
176
193
|
const pageState = await assertReady(tabId, budget);
|
|
194
|
+
if (failure) throw failure;
|
|
177
195
|
budget.dispatch();
|
|
178
196
|
const dispatch = params => budget.run(() => cdp(tabId, 'Input.dispatchMouseEvent', { ...params, ...rootPoint }));
|
|
179
197
|
const commands = [dispatch({ type: 'mouseMoved', button: 'none', buttons: 0, modifiers: options.modifiers })];
|
|
@@ -259,7 +277,13 @@ function installGuard(name, options, expiryMs, verifyDelivery) {
|
|
|
259
277
|
return { stop };
|
|
260
278
|
}
|
|
261
279
|
|
|
262
|
-
export function clippedPoint(quad, view) {
|
|
280
|
+
export function clippedPoint(quad, view, offset) {
|
|
281
|
+
if (offset) {
|
|
282
|
+
const { x_ratio: x, y_ratio: y } = offset;
|
|
283
|
+
const point = { x: (1 - y) * ((1 - x) * quad[0] + x * quad[2]) + y * ((1 - x) * quad[6] + x * quad[4]),
|
|
284
|
+
y: (1 - y) * ((1 - x) * quad[1] + x * quad[3]) + y * ((1 - x) * quad[7] + x * quad[5]) };
|
|
285
|
+
return point.x >= 0 && point.y >= 0 && point.x < view.width && point.y < view.height ? point : null;
|
|
286
|
+
}
|
|
263
287
|
let polygon = Array.from({ length: 4 }, (_, i) => ({ x: quad[i * 2], y: quad[i * 2 + 1] }));
|
|
264
288
|
for (const [axis, bound, lower] of [['x', 0, true], ['x', view.width - 0.01, false], ['y', 0, true], ['y', view.height - 0.01, false]]) {
|
|
265
289
|
const input = polygon; polygon = [];
|
|
@@ -287,6 +311,11 @@ export function clippedPoint(quad, view) {
|
|
|
287
311
|
}
|
|
288
312
|
|
|
289
313
|
export function clickOptions(args) {
|
|
314
|
+
offsetOption(args);
|
|
315
|
+
if (args.settle_ms != null) {
|
|
316
|
+
timingOption(args.settle_ms, 'settle_ms');
|
|
317
|
+
if (args.mode === 'dom') throw new Error('settle_ms requires native pointer input');
|
|
318
|
+
}
|
|
290
319
|
const button = args.button ?? 'left', click_count = args.click_count ?? 1, modifiers = args.modifiers ?? [];
|
|
291
320
|
const buttons = { left: [1, 0], middle: [4, 1], right: [2, 2] }, masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
|
|
292
321
|
if (!Object.hasOwn(buttons, button)) throw new Error('button must be left, middle or right');
|
|
@@ -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/controller.js
CHANGED
|
@@ -341,8 +341,9 @@ export function createController(api, notify = () => {}) {
|
|
|
341
341
|
const url = srcIndex >= 0 ? new URL(attributes[srcIndex + 1], frame.url).href : 'about:blank';
|
|
342
342
|
knownFrames.add(childId); frames.push({ id: childId, url });
|
|
343
343
|
}
|
|
344
|
-
|
|
345
|
-
entries.push(
|
|
344
|
+
const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
|
|
345
|
+
if (rendered.length || options.query == null) entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
|
|
346
|
+
entries.push(...rendered);
|
|
346
347
|
}
|
|
347
348
|
const metadata = value(await send(tabId, 'Runtime.evaluate', {
|
|
348
349
|
expression: '({title: document.title, url: location.href})', returnByValue: true,
|
package/extension/drag.js
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import { deadline, delivery } from './actionability.js';
|
|
2
2
|
import { targetOptions } from './locator.js';
|
|
3
|
+
import { offsetOption, timingOption, pause, trajectoryOption, dragPath } from './motion.js';
|
|
3
4
|
|
|
4
5
|
export function dragOptions(args) {
|
|
5
6
|
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
6
7
|
for (const target of [args.source, args.destination]) {
|
|
7
8
|
targetOptions(target, { coordinates: true });
|
|
9
|
+
offsetOption(target);
|
|
8
10
|
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
9
11
|
}
|
|
10
12
|
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
11
13
|
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
14
|
+
timingOption(args.settle_ms ?? 0, 'settle_ms');
|
|
15
|
+
trajectoryOption(args.trajectory);
|
|
12
16
|
}
|
|
13
17
|
|
|
14
18
|
export function createDrag(cdp, value, mouse, viewport) {
|
|
@@ -87,6 +91,7 @@ export function createDrag(cdp, value, mouse, viewport) {
|
|
|
87
91
|
try {
|
|
88
92
|
await guard(source, 'source');
|
|
89
93
|
if (html5) { interceptEnabled = true; await send('Input.setInterceptDrags', { enabled: true }); }
|
|
94
|
+
await pause(budget, args.settle_ms);
|
|
90
95
|
await mouse.assertReady(tabId, budget);
|
|
91
96
|
budget.dispatch();
|
|
92
97
|
await move(point);
|
|
@@ -100,12 +105,19 @@ export function createDrag(cdp, value, mouse, viewport) {
|
|
|
100
105
|
const destination = await mouse.prepare(tabId, await resolve(args.destination, budget), { ...args.destination, budget });
|
|
101
106
|
await guard(destination, 'destination');
|
|
102
107
|
const from = point;
|
|
108
|
+
const path = dragPath(from, destination.point, steps, args.trajectory);
|
|
109
|
+
if (args.trajectory) {
|
|
110
|
+
const view = await budget.run(() => viewport(tabId));
|
|
111
|
+
if (path.some(({ point }) => point.x < 0 || point.y < 0 || point.x >= view.width || point.y >= view.height)) {
|
|
112
|
+
throw new Error('Drag trajectory leaves the viewport. Outcome is unknown; observe before continuing.');
|
|
113
|
+
}
|
|
114
|
+
}
|
|
103
115
|
if (html5) await send('Input.dispatchDragEvent', { type: 'dragEnter', ...point, data });
|
|
104
|
-
for (
|
|
105
|
-
point =
|
|
116
|
+
for (const sample of path) {
|
|
117
|
+
point = sample.point;
|
|
106
118
|
if (html5) await send('Input.dispatchDragEvent', { type: 'dragOver', ...point, data });
|
|
107
119
|
else await move(point);
|
|
108
|
-
await budget.run(() => new Promise(resolve => setTimeout(resolve,
|
|
120
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, sample.dt)));
|
|
109
121
|
}
|
|
110
122
|
if (html5) await send('Input.dispatchDragEvent', { type: 'drop', ...point, data });
|
|
111
123
|
await send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...point, button: 'left', buttons: 0, clickCount: 1 });
|
package/extension/input.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deadline, waitForTarget, delivery } from './actionability.js';
|
|
2
2
|
import { clickOptions } from './click.js';
|
|
3
|
+
import { timingOption, pause } from './motion.js';
|
|
3
4
|
|
|
4
5
|
const BUSY = 'Browser UI may be capturing input (a tab drag or native menu). Finish that interaction, then observe the page before deciding whether to try again. Never replay an unknown outcome automatically.';
|
|
5
6
|
|
|
@@ -157,7 +158,7 @@ export function createInput(cdp, value, sessions) {
|
|
|
157
158
|
try { await pressKey(tabId, character, { budget, character, expectedNode: node }); }
|
|
158
159
|
catch (error) { error.message = `Typing stopped after ${typed} verified characters; the current character may already have been delivered. ${error.message}`; throw error; }
|
|
159
160
|
typed++;
|
|
160
|
-
if (
|
|
161
|
+
if (typed < length) await pause(budget, args.delay_ms);
|
|
161
162
|
}
|
|
162
163
|
return { typed, method: 'native', events_trusted: true, delivery_verified: true, application_result: 'not_checked', observe_again: true };
|
|
163
164
|
}
|
|
@@ -304,7 +305,7 @@ export function selectElement(choice) {
|
|
|
304
305
|
|
|
305
306
|
export function typeOptions(args) {
|
|
306
307
|
if (typeof args.text !== 'string' || args.text.length > 10000 || /[\r\n\t\x00-\x1f]/.test(args.text)) throw new Error('type text must contain at most 10000 characters');
|
|
307
|
-
|
|
308
|
+
timingOption(args.delay_ms ?? 0, 'delay_ms');
|
|
308
309
|
}
|
|
309
310
|
|
|
310
311
|
export function characterParameters(character) {
|
package/extension/manifest.json
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export function timingOption(value, name, max = 1000) {
|
|
2
|
+
const values = Array.isArray(value) ? value : [value];
|
|
3
|
+
if ((Array.isArray(value) && value.length !== 2) || values.some(n => !Number.isInteger(n) || n < 0 || n > max) || values.length === 2 && values[0] > values[1]) {
|
|
4
|
+
throw new Error(`${name} must be 0..${max} or an ordered [min, max] integer pair`);
|
|
5
|
+
}
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function sampleTiming(value = 0) {
|
|
9
|
+
return Array.isArray(value) ? value[0] + Math.floor(Math.random() * (value[1] - value[0] + 1)) : value;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function pause(budget, value) {
|
|
13
|
+
const ms = sampleTiming(value);
|
|
14
|
+
if (ms) await budget.run(() => new Promise(resolve => setTimeout(resolve, ms)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function offsetOption(args) {
|
|
18
|
+
if (args.offset == null) return;
|
|
19
|
+
if ((!args.ref && !args.selector) || args.mode === 'dom') throw new Error('offset requires a native element target');
|
|
20
|
+
const offset = args.offset;
|
|
21
|
+
if (typeof offset !== 'object' || Array.isArray(offset) || Object.keys(offset).some(key => !['x_ratio', 'y_ratio'].includes(key)) ||
|
|
22
|
+
![offset.x_ratio, offset.y_ratio].every(n => Number.isFinite(n) && n >= 0 && n <= 1)) throw new Error('offset requires x_ratio and y_ratio in 0..1');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function trajectoryOption(trajectory) {
|
|
26
|
+
if (trajectory == null) return;
|
|
27
|
+
if (typeof trajectory !== 'object' || Array.isArray(trajectory) || Object.keys(trajectory).some(key => !['curve_px', 'duration_ms'].includes(key)) ||
|
|
28
|
+
!Number.isFinite(trajectory.curve_px) || Math.abs(trajectory.curve_px) > 100) throw new Error('trajectory requires curve_px in -100..100 and duration_ms in 100..5000');
|
|
29
|
+
if (!Number.isInteger(trajectory.duration_ms) || trajectory.duration_ms < 100 || trajectory.duration_ms > 5000) throw new Error('trajectory duration_ms must be 100..5000');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Keep both endpoints exact; vary progress and per-step timing only when opted in.
|
|
33
|
+
export function dragPath(from, to, steps, trajectory) {
|
|
34
|
+
const dx = to.x - from.x, dy = to.y - from.y, length = Math.hypot(dx, dy);
|
|
35
|
+
if (!trajectory) return Array.from({ length: steps }, (_, index) => ({
|
|
36
|
+
point: { x: from.x + dx * (index + 1) / steps, y: from.y + dy * (index + 1) / steps }, dt: 16,
|
|
37
|
+
}));
|
|
38
|
+
const weights = Array.from({ length: steps }, () => 0.5 + Math.random());
|
|
39
|
+
const total = weights.reduce((sum, n) => sum + n, 0);
|
|
40
|
+
let elapsed = 0;
|
|
41
|
+
return Array.from({ length: steps }, (_, index) => {
|
|
42
|
+
const dt = weights[index] / total * trajectory.duration_ms;
|
|
43
|
+
elapsed += dt;
|
|
44
|
+
const t = index === steps - 1 ? 1 : elapsed / trajectory.duration_ms;
|
|
45
|
+
const progress = t * t * (3 - 2 * t);
|
|
46
|
+
const bend = length ? 2 * progress * (1 - progress) * trajectory.curve_px : 0;
|
|
47
|
+
return { point: index === steps - 1 ? { ...to } : { x: from.x + dx * progress - (length ? dy / length * bend : 0),
|
|
48
|
+
y: from.y + dy * progress + (length ? dx / length * bend : 0) }, dt };
|
|
49
|
+
});
|
|
50
|
+
}
|
package/extension/snapshot.js
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
-
export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
|
|
2
|
+
export function snapshotOptions({ profile = 'full', text = true, text_limit, query } = {}) {
|
|
3
3
|
if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
|
|
4
4
|
if (typeof text !== 'boolean') throw new Error('text must be a boolean');
|
|
5
5
|
if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
|
|
6
|
-
|
|
6
|
+
if (query != null && (typeof query !== 'string' || !query.trim() || query.length > 2000)) throw new Error('query must contain 1..2000 characters and not be blank');
|
|
7
|
+
return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null), ...(query != null ? { query } : {}) };
|
|
7
8
|
}
|
|
8
9
|
|
|
9
10
|
export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
|
|
10
11
|
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
11
12
|
const visited = new Set();
|
|
12
13
|
const entries = [];
|
|
14
|
+
const query = options.query?.toLowerCase();
|
|
13
15
|
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
14
16
|
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
15
17
|
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
@@ -68,9 +70,11 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
68
70
|
}
|
|
69
71
|
let nextDepth = depth;
|
|
70
72
|
if (!skip) {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
73
|
+
if (query == null || identityName.toLowerCase().includes(query) || String(node.value?.value ?? '').toLowerCase().includes(query)) {
|
|
74
|
+
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
75
|
+
if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
|
|
76
|
+
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
77
|
+
}
|
|
74
78
|
nextDepth++;
|
|
75
79
|
}
|
|
76
80
|
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
|
|
@@ -81,14 +85,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
|
|
|
81
85
|
return entries;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
|
|
88
|
+
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, verbose = false, ...view } = {}) {
|
|
85
89
|
if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
|
|
86
90
|
if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
|
|
91
|
+
if (typeof verbose !== 'boolean') throw new Error('verbose must be a boolean');
|
|
87
92
|
let offset = 0;
|
|
88
93
|
if (cursor != null) {
|
|
89
94
|
if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
90
|
-
for (const key of ['profile', 'text', 'text_limit']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
91
|
-
throw new Error('Snapshot cursor uses the original profile/text/text_limit; take a new snapshot to change the view.');
|
|
95
|
+
for (const key of ['profile', 'text', 'text_limit', 'query']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
96
|
+
throw new Error('Snapshot cursor uses the original profile/text/text_limit/query; take a new snapshot to change the view.');
|
|
92
97
|
}
|
|
93
98
|
offset = state.nextOffset;
|
|
94
99
|
}
|
|
@@ -108,7 +113,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
|
|
|
108
113
|
const truncated = index < state.entries.length;
|
|
109
114
|
state.nextOffset = index;
|
|
110
115
|
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
116
|
+
const { viewport, ...metadata } = state.metadata;
|
|
117
|
+
return { ...metadata, ...state.options, ...(verbose ? { viewport, snapshot_id: state.id } : {}), snapshot: lines.join('\n'), node_count: count,
|
|
118
|
+
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor };
|
|
114
119
|
}
|
|
@@ -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;
|