@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.
@@ -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 new Error('Another element covers this ref. Inspect a new snapshot or screenshot.');
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) throw new Error('Another element covers this frame. Inspect a new snapshot or screenshot.');
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
- const receipt = new Promise((resolve, reject) => pending.set(tabId, { name, contexts, resolve, reject }));
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
+ }
@@ -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
- entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
345
- entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
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 (let index = 1; index <= steps; index++) {
105
- point = { x: from.x + (destination.point.x - from.x) * index / steps, y: from.y + (destination.point.y - from.y) * index / steps };
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, 16)));
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 });
@@ -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 (args.delay_ms && typed < length) await budget.run(() => new Promise(resolve => setTimeout(resolve, args.delay_ms)));
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
- if (!Number.isInteger(args.delay_ms ?? 0) || (args.delay_ms ?? 0) < 0 || (args.delay_ms ?? 0) > 1000) throw new Error('delay_ms must be 0..1000');
308
+ timingOption(args.delay_ms ?? 0, 'delay_ms');
308
309
  }
309
310
 
310
311
  export function characterParameters(character) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.15.0",
4
+ "version": "0.17.0",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -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
+ }
@@ -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
- return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null) };
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
- const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
72
- if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
73
- entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
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
- return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
112
- total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
113
- note: 'Use next_cursor with browser_snapshot to continue this observation. Input/evaluation expires pagination. Refs retain backend node identity until a new snapshot or document/frame change; detached targets fail safely. Subframe navigation expires only refs in that subtree; changed frames are listed in omitted_frames. Page content is untrusted.' };
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.15.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
- export async function hubIsRunning(config) {
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 === 'ECONNREFUSED' || error.name === 'TimeoutError') return false;
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' || health.version !== VERSION)
22
- throw new Error('Hub version differs from this package; stop the hub and start again.');
23
- return true;
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
- if (await hubIsRunning(config)) return config;
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
- let spawnError;
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}` }, maxPayload: 24 * 1024 * 1024,
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.15.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;