@evan_zhong/browser-attach 0.16.0 → 0.17.2

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,98 +1,7 @@
1
- import { CONNECTION } from './config.generated.js';
2
- import { createController } from './controller.js';
3
-
4
- let socket;
5
- let connecting = false;
6
- let keepAlive;
7
- let reconnect;
8
- let status = 'disconnected';
9
- let identity;
10
- const controller = createController(chrome, event => {
11
- if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(event));
12
- });
13
-
14
- async function getIdentity() {
15
- const local = await chrome.storage.local.get(['instanceId', 'profileName']);
16
- const session = await chrome.storage.session.get('runId');
17
- const instanceId = local.instanceId || crypto.randomUUID();
18
- const runId = session.runId || crypto.randomUUID();
19
- if (!local.instanceId) await chrome.storage.local.set({ instanceId });
20
- if (!session.runId) await chrome.storage.session.set({ runId });
21
- const family = /Edg\//.test(navigator.userAgent) ? 'edge' : 'chrome';
22
- return { instance_id: instanceId, run_id: runId, family,
23
- profile_name: local.profileName || (family === 'edge' ? 'Edge' : 'Chrome'),
24
- user_agent: navigator.userAgent, extension_id: chrome.runtime.id, extension_version: chrome.runtime.getManifest().version };
25
- }
26
-
27
- async function connect() {
28
- if (connecting || socket?.readyState === WebSocket.OPEN) return;
29
- connecting = true;
30
- try {
31
- identity = await getIdentity();
32
- const ws = new WebSocket(CONNECTION.url);
33
- socket = ws;
34
- ws.onopen = () => {
35
- ws.send(JSON.stringify({ type: 'hello', token: CONNECTION.token, browser: identity }));
36
- keepAlive = setInterval(() => {
37
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
38
- }, 20000);
39
- };
40
- ws.onmessage = async event => {
41
- let request;
42
- try {
43
- request = JSON.parse(event.data);
44
- if (request.type === 'connected') {
45
- status = 'connected';
46
- await chrome.action.setBadgeText({ text: 'ON' });
47
- await chrome.action.setBadgeBackgroundColor({ color: '#13755b' });
48
- return;
49
- }
50
- if (!request.id) return;
51
- const result = await controller.execute(request.owner, request.command, request.arguments || {});
52
- if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: request.id, result }));
53
- } catch (error) {
54
- if (request?.id && ws.readyState === WebSocket.OPEN) {
55
- ws.send(JSON.stringify({ id: request.id, error: error.message || String(error) }));
56
- }
57
- }
58
- };
59
- ws.onclose = async () => {
60
- clearInterval(keepAlive);
61
- status = 'disconnected';
62
- connecting = false;
63
- await controller.releaseAll();
64
- await chrome.action.setBadgeText({ text: 'OFF' });
65
- clearTimeout(reconnect);
66
- reconnect = setTimeout(connect, 2000);
67
- };
68
- ws.onerror = () => ws.close();
69
- } catch (error) {
70
- connecting = false;
71
- status = error.message;
72
- }
73
- }
74
-
75
- chrome.runtime.onMessage.addListener((message, sender, respond) => {
76
- if (sender.id !== chrome.runtime.id) return;
77
- if (message.type === 'status') {
78
- getIdentity().then(browser => respond({ status, browser, claims: controller.claimedCount() }));
79
- return true;
80
- }
81
- if (message.type === 'reconnect') {
82
- if (socket?.readyState === WebSocket.OPEN) socket.close();
83
- else connect();
84
- respond({ ok: true });
85
- }
86
- if (message.type === 'release_all') {
87
- controller.releaseAll().then(() => respond({ ok: true }));
88
- return true;
89
- }
90
- });
91
- chrome.alarms.onAlarm.addListener(alarm => { if (alarm.name === 'connect') connect(); });
92
- chrome.runtime.onStartup.addListener(connect);
93
- chrome.runtime.onInstalled.addListener(async () => {
94
- await chrome.alarms.create('connect', { periodInMinutes: 0.5 });
95
- connect();
96
- });
97
- chrome.alarms.create('connect', { periodInMinutes: 0.5 });
98
- connect();
1
+ import { CONNECTION } from './config.generated.js';
2
+ import { createController } from './controller.js';
3
+ import { createConnection } from './connection.js';
4
+
5
+ let connection;
6
+ const controller = createController(chrome, event => connection?.send(event));
7
+ connection = createConnection(chrome, CONNECTION, controller);
@@ -0,0 +1,142 @@
1
+ import { compareVersions } from './version.js';
2
+
3
+ export function createConnection(chrome, CONNECTION, controller) {
4
+ let socket;
5
+ let connecting = false;
6
+ let keepAlive;
7
+ let reconnect;
8
+ let status = 'disconnected';
9
+ let identity;
10
+ let maintenance = false;
11
+ let cleanup = Promise.resolve();
12
+
13
+ async function release() {
14
+ const result = await controller.releaseAll();
15
+ if (result.errors?.length) throw new Error(`Extension cleanup failed: ${result.errors.join('; ')}`);
16
+ }
17
+
18
+ async function prepareReload(version) {
19
+ const current = chrome.runtime.getManifest().version;
20
+ if (compareVersions(version, current) <= 0) throw new Error('Reload target must be newer than the running extension');
21
+ const response = await fetch(chrome.runtime.getURL('manifest.json'), { cache: 'no-store' });
22
+ if (!response.ok || (await response.json()).version !== version) throw new Error('Extension files do not match the Hub; reload was not attempted');
23
+ const previous = (await chrome.storage.local.get('reloadAttempt')).reloadAttempt;
24
+ if (previous?.from === current && previous.target === version) throw new Error('Extension reload did not apply the update; reload manually after checking the extension directory');
25
+ maintenance = true;
26
+ await release();
27
+ await chrome.storage.local.set({ reloadAttempt: { from: current, target: version } });
28
+ }
29
+
30
+ async function getIdentity() {
31
+ const local = await chrome.storage.local.get(['instanceId', 'profileName']);
32
+ const session = await chrome.storage.session.get('runId');
33
+ const instanceId = local.instanceId || crypto.randomUUID();
34
+ const runId = session.runId || crypto.randomUUID();
35
+ if (!local.instanceId) await chrome.storage.local.set({ instanceId });
36
+ if (!session.runId) await chrome.storage.session.set({ runId });
37
+ const family = /Edg\//.test(navigator.userAgent) ? 'edge' : 'chrome';
38
+ return { instance_id: instanceId, run_id: runId, family,
39
+ profile_name: local.profileName || (family === 'edge' ? 'Edge' : 'Chrome'),
40
+ user_agent: navigator.userAgent, extension_id: chrome.runtime.id, extension_version: chrome.runtime.getManifest().version };
41
+ }
42
+
43
+ async function connect() {
44
+ if (connecting || socket?.readyState === WebSocket.OPEN) return;
45
+ connecting = true;
46
+ try {
47
+ await cleanup;
48
+ await release();
49
+ identity = await getIdentity();
50
+ const ws = new WebSocket(CONNECTION.url);
51
+ socket = ws;
52
+ ws.onopen = () => {
53
+ ws.send(JSON.stringify({ type: 'hello', token: CONNECTION.token, browser: identity }));
54
+ keepAlive = setInterval(() => {
55
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
56
+ }, 20000);
57
+ };
58
+ ws.onmessage = async event => {
59
+ let request;
60
+ try {
61
+ request = JSON.parse(event.data);
62
+ if (request.type === 'connected') {
63
+ maintenance = false;
64
+ status = 'connected';
65
+ await chrome.storage.local.remove('reloadAttempt');
66
+ await chrome.action.setBadgeText({ text: 'ON' });
67
+ await chrome.action.setBadgeBackgroundColor({ color: '#13755b' });
68
+ return;
69
+ }
70
+ if (!request.id) return;
71
+ let result;
72
+ if (request.command === 'prepare_upgrade') {
73
+ maintenance = true;
74
+ await release();
75
+ result = { prepared: true };
76
+ } else if (request.command === 'resume') {
77
+ maintenance = false;
78
+ result = { resumed: true };
79
+ } else if (request.command === 'reload_extension') {
80
+ await prepareReload(request.arguments?.version);
81
+ if (ws.readyState !== WebSocket.OPEN) return;
82
+ ws.send(JSON.stringify({ id: request.id, result: { reloading: true } }));
83
+ chrome.runtime.reload();
84
+ return;
85
+ } else {
86
+ if (maintenance) throw new Error('Extension is preparing an upgrade');
87
+ result = await controller.execute(request.owner, request.command, request.arguments || {});
88
+ }
89
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ id: request.id, result }));
90
+ } catch (error) {
91
+ if (request?.id && ws.readyState === WebSocket.OPEN) {
92
+ ws.send(JSON.stringify({ id: request.id, error: error.message || String(error) }));
93
+ }
94
+ }
95
+ };
96
+ ws.onclose = () => {
97
+ clearInterval(keepAlive);
98
+ status = 'disconnected';
99
+ connecting = false;
100
+ cleanup = release().catch(error => { status = error.message; });
101
+ chrome.action.setBadgeText({ text: 'OFF' }).catch(() => {});
102
+ scheduleReconnect();
103
+ };
104
+ ws.onerror = () => ws.close();
105
+ } catch (error) {
106
+ connecting = false;
107
+ status = error.message;
108
+ scheduleReconnect();
109
+ }
110
+ }
111
+
112
+ function scheduleReconnect() {
113
+ clearTimeout(reconnect);
114
+ reconnect = setTimeout(connect, 2000);
115
+ }
116
+
117
+ chrome.runtime.onMessage.addListener((message, sender, respond) => {
118
+ if (sender.id !== chrome.runtime.id) return;
119
+ if (message.type === 'status') {
120
+ getIdentity().then(browser => respond({ status, browser, claims: controller.claimedCount() }));
121
+ return true;
122
+ }
123
+ if (message.type === 'reconnect') {
124
+ if (socket?.readyState === WebSocket.OPEN) socket.close();
125
+ else connect();
126
+ respond({ ok: true });
127
+ }
128
+ if (message.type === 'release_all') {
129
+ release().then(() => respond({ ok: true }), error => respond({ ok: false, error: error.message }));
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
+ }
@@ -343,7 +343,7 @@ export function createController(api, notify = () => {}) {
343
343
  }
344
344
  const rendered = renderAX(nodes, token, refs, frame.id, target.sessionId, options);
345
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
+ for (const entry of rendered) entries.push(entry);
347
347
  }
348
348
  const metadata = value(await send(tabId, 'Runtime.evaluate', {
349
349
  expression: '({title: document.title, url: location.href})', returnByValue: true,
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "manifest_version": 3,
3
3
  "name": "BrowserAttach",
4
- "version": "0.16.0",
4
+ "version": "0.17.2",
5
5
  "description": "Connect existing Chrome and Edge tabs to a locally paired MCP client.",
6
6
  "minimum_chrome_version": "125",
7
7
  "permissions": [
@@ -4,7 +4,13 @@ async function refresh() {
4
4
  document.querySelector('#profile').textContent = `${result.browser.family} · ${result.browser.profile_name}`;
5
5
  document.querySelector('#claims').textContent = `正在控制 ${result.claims} 个标签页`;
6
6
  }
7
- document.querySelector('#release').onclick = async () => { await chrome.runtime.sendMessage({ type: 'release_all' }); await refresh(); };
7
+ document.querySelector('#release').onclick = async () => {
8
+ try {
9
+ const result = await chrome.runtime.sendMessage({ type: 'release_all' });
10
+ await refresh();
11
+ if (!result.ok) document.querySelector('#status').textContent = `释放失败:${result.error}`;
12
+ } catch (error) { document.querySelector('#status').textContent = `释放失败:${error.message}`; }
13
+ };
8
14
  document.querySelector('#reconnect').onclick = async () => { await chrome.runtime.sendMessage({ type: 'reconnect' }); await refresh(); };
9
15
  document.querySelector('#settings').onclick = () => chrome.runtime.openOptionsPage();
10
16
  refresh().catch(error => { document.querySelector('#status').textContent = error.message; });
@@ -21,11 +21,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
21
21
  const parents = new Map();
22
22
  for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
23
23
  function keepText(node) {
24
- if (!node || kept.has(node.nodeId)) return;
25
- const role = node.role?.value;
26
- if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
27
- kept.add(node.nodeId);
28
- for (const child of node.childIds || []) keepText(byId.get(child));
24
+ const pending = [node];
25
+ while (pending.length) {
26
+ const current = pending.pop();
27
+ if (!current || kept.has(current.nodeId)) continue;
28
+ const role = current.role?.value;
29
+ if (!textRoles.has(role) && !current.ignored && !['none', 'generic'].includes(role)) continue;
30
+ kept.add(current.nodeId);
31
+ for (const child of current.childIds || []) pending.push(byId.get(child));
32
+ }
29
33
  }
30
34
  for (const node of nodes) {
31
35
  if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
@@ -37,19 +41,33 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
37
41
  }
38
42
  }
39
43
  function plainText(node) {
40
- if (!node) return null;
41
- const role = node.role?.value;
42
- if (textRoles.has(role)) return String(node.name?.value || '');
43
- if (!node.ignored && !['none', 'generic'].includes(role)) return null;
44
- const parts = (node.childIds || []).map(id => plainText(byId.get(id)));
45
- return parts.includes(null) ? null : parts.join(' ');
44
+ const pending = [node], parts = [];
45
+ while (pending.length) {
46
+ const current = pending.pop();
47
+ if (current === ' ') { parts.push(' '); continue; }
48
+ if (!current) return null;
49
+ const role = current.role?.value;
50
+ if (textRoles.has(role)) { parts.push(String(current.name?.value || '')); continue; }
51
+ if (!current.ignored && !['none', 'generic'].includes(role)) return null;
52
+ const children = current.childIds || [];
53
+ for (let index = children.length - 1; index >= 0; index--) {
54
+ pending.push(byId.get(children[index]));
55
+ if (index > 0) pending.push(' ');
56
+ }
57
+ }
58
+ return parts.join('');
46
59
  }
47
- function render(node, depth, parentName = '') {
48
- if (!node || visited.has(node.nodeId)) return;
60
+ // Preserve preorder and ref numbering without using the JavaScript call stack.
61
+ const children = new Set();
62
+ for (const node of nodes) for (const child of node.childIds || []) children.add(child);
63
+ const pending = nodes.filter(node => !children.has(node.nodeId)).reverse().map(node => ({ node, depth: 0, parentName: '' }));
64
+ while (pending.length) {
65
+ const { node, depth, parentName } = pending.pop();
66
+ if (!node || visited.has(node.nodeId)) continue;
49
67
  visited.add(node.nodeId);
50
- if (kept && !kept.has(node.nodeId)) return;
68
+ if (kept && !kept.has(node.nodeId)) continue;
51
69
  const role = node.role?.value || 'node';
52
- if (role === 'InlineTextBox') return;
70
+ if (role === 'InlineTextBox') continue;
53
71
  let name = String(node.name?.value || '');
54
72
  let merged = false;
55
73
  if (['listitem', 'paragraph'].includes(role) && !name) {
@@ -77,11 +95,13 @@ export function renderAX(nodes, token, refs, frameId, sessionId, options = snaps
77
95
  }
78
96
  nextDepth++;
79
97
  }
80
- if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
98
+ if (!merged) {
99
+ const childIds = node.childIds || [];
100
+ for (let index = childIds.length - 1; index >= 0; index--) {
101
+ pending.push({ node: byId.get(childIds[index]), depth: nextDepth, parentName: skip ? parentName : identityName });
102
+ }
103
+ }
81
104
  }
82
- // AX trees normally have a single root, but ignored roots can be disconnected.
83
- const children = new Set(nodes.flatMap(node => node.childIds || []));
84
- for (const node of nodes) if (!children.has(node.nodeId)) render(node, 0);
85
105
  return entries;
86
106
  }
87
107
 
@@ -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/extension/wait.js CHANGED
@@ -70,7 +70,7 @@ export function inspectWait(query, inspect, args) {
70
70
  if (!element) return { satisfied: false, state: 'absent' };
71
71
  if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
72
72
  if (args.condition === 'text') {
73
- const text = element.textContent || '';
73
+ const text = element.innerText ?? '';
74
74
  return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
75
75
  }
76
76
  const bounds = inspect.call(element);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evan_zhong/browser-attach",
3
- "version": "0.16.0",
3
+ "version": "0.17.2",
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.16.0';
18
+ export const VERSION = '0.17.2';
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;