@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.
- package/README.md +5 -5
- package/README.zh.md +5 -5
- package/bin/browser-attach.mjs +3 -3
- package/docs/AGENT-INTEGRATION.md +3 -3
- package/docs/ARCHITECTURE.md +14 -9
- package/docs/TEST-REPORT.md +16 -1
- package/docs/USAGE.md +430 -429
- package/extension/background.js +7 -98
- package/extension/connection.js +142 -0
- package/extension/controller.js +1 -1
- package/extension/manifest.json +1 -1
- package/extension/popup.js +7 -1
- package/extension/snapshot.js +39 -19
- package/extension/version.js +11 -0
- package/extension/wait.js +1 -1
- 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 +2 -1
- package/src/setup.mjs +87 -16
package/extension/background.js
CHANGED
|
@@ -1,98 +1,7 @@
|
|
|
1
|
-
import { CONNECTION } from './config.generated.js';
|
|
2
|
-
import { createController } from './controller.js';
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
let
|
|
6
|
-
|
|
7
|
-
|
|
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
|
+
}
|
package/extension/controller.js
CHANGED
|
@@ -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(
|
|
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,
|
package/extension/manifest.json
CHANGED
package/extension/popup.js
CHANGED
|
@@ -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 () => {
|
|
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; });
|
package/extension/snapshot.js
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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))
|
|
68
|
+
if (kept && !kept.has(node.nodeId)) continue;
|
|
51
69
|
const role = node.role?.value || 'node';
|
|
52
|
-
if (role === 'InlineTextBox')
|
|
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)
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.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;
|