@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/src/hub.mjs CHANGED
@@ -3,6 +3,8 @@ import http from 'node:http';
3
3
  import { randomUUID, timingSafeEqual } from 'node:crypto';
4
4
  import { WebSocketServer, WebSocket } from 'ws';
5
5
  import { VERSION } from './config.mjs';
6
+ import { syncExtension } from './setup.mjs';
7
+ import { compareVersions } from '../extension/version.js';
6
8
 
7
9
  const MAX_MESSAGE = 24 * 1024 * 1024;
8
10
  const OPERATIONS = new Set(['release_tab', 'activate_tab', 'close_tab', 'snapshot', 'read_text', 'type', 'hover', 'drag', 'wait', 'select_option', 'click', 'fill', 'press_key', 'batch', 'scroll', 'navigate', 'screenshot', 'handle_dialog', 'upload_file', 'evaluate', 'logs']);
@@ -12,6 +14,8 @@ const send = (ws, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.
12
14
 
13
15
  export async function startHub(config, { port = config.port, operationTimeout = HUB_OPERATION_TIMEOUT_MS } = {}) {
14
16
  const browsers = new Map(), clients = new Set(), claims = new Map(), tabClaims = new Map(), queues = new Map();
17
+ const cleanups = new Set();
18
+ let state = 'starting', upgradeError;
15
19
  const authorized = token => typeof token === 'string' && Buffer.byteLength(token) === Buffer.byteLength(config.token)
16
20
  && timingSafeEqual(Buffer.from(token), Buffer.from(config.token));
17
21
  const bearer = request => authorized(request.headers.authorization?.replace(/^Bearer /, ''));
@@ -28,13 +32,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
28
32
  result.then(done, done);
29
33
  return result;
30
34
  }
31
- function requestBrowser(browser, owner, command, args) {
35
+ function requestBrowser(browser, owner, command, args, timeout = operationTimeout) {
32
36
  return new Promise((resolve, reject) => {
33
37
  const id = randomUUID();
34
38
  const timer = setTimeout(() => {
35
39
  browser.pending.delete(id);
36
40
  reject(new OperationTimeout('Browser operation timed out; outcome may be unknown. Connection will release its tabs. List and claim again.'));
37
- }, operationTimeout);
41
+ }, timeout);
38
42
  browser.pending.set(id, { resolve: value => { clearTimeout(timer); resolve(value); }, reject: error => { clearTimeout(timer); reject(error); } });
39
43
  if (browser.ws.readyState !== WebSocket.OPEN) {
40
44
  clearTimeout(timer); browser.pending.delete(id); reject(new Error('Browser disconnected')); return;
@@ -44,12 +48,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
44
48
  }
45
49
  async function execute(owner, observed, method, args) {
46
50
  if (method === 'list_browsers') return {
47
- browsers: [...browsers.values()].map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
51
+ browsers: [...browsers.values()].filter(browser => browser.ready).map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
52
+ upgrades: [...browsers.values()].filter(browser => !browser.ready).map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, error: browser.upgradeError })),
48
53
  hint: 'Load the paired extension in each Chrome/Edge profile if empty.',
49
54
  };
50
55
  if (method === 'list_tabs' || method === 'claim_tab' || method === 'open_tab' || method === 'fetch') {
51
56
  const browser = browsers.get(args.browser_id);
52
- if (!browser) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
57
+ if (!browser?.ready) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
53
58
  if (method === 'open_tab' || method === 'fetch') return await requestBrowser(browser, owner, method, args);
54
59
  if (method === 'list_tabs') {
55
60
  const result = await requestBrowser(browser, owner, method, {});
@@ -93,10 +98,36 @@ export async function startHub(config, { port = config.port, operationTimeout =
93
98
  }
94
99
 
95
100
  let closing;
96
- const server = http.createServer((request, response) => {
101
+ const server = http.createServer(async (request, response) => {
97
102
  if (request.headers.origin || !bearer(request)) { response.writeHead(403).end(); return; }
98
103
  response.setHeader('Content-Type', 'application/json');
99
- if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION, browsers: browsers.size }));
104
+ if (request.url === '/health' && request.method === 'GET') response.end(JSON.stringify({ service: 'browser-attach', version: VERSION, state,
105
+ browsers: [...browsers.values()].filter(browser => browser.ready).length, clients: clients.size, upgrade_error: upgradeError,
106
+ extensions: [...browsers.values()].map(browser => ({ instance_id: browser.info.instance_id, version: browser.info.extension_version, ready: browser.ready, error: browser.upgradeError })) }));
107
+ else if (request.url === '/upgrade' && request.method === 'POST') {
108
+ try {
109
+ const target = request.headers['x-browser-attach-version'];
110
+ if (compareVersions(target, VERSION) <= 0) { response.writeHead(409).end(JSON.stringify({ error: 'Upgrade target must be newer than this Hub' })); return; }
111
+ if (state !== 'ready' || clients.size || cleanups.size || [...browsers.values()].some(browser => browser.pending.size)) {
112
+ response.writeHead(409).end(JSON.stringify({ error: 'Hub is in use; close other MCP connections and retry the upgrade' })); return;
113
+ }
114
+ state = 'upgrading';
115
+ upgradeError = undefined;
116
+ const connected = [...browsers.values()];
117
+ const outcomes = await Promise.allSettled(connected.map(async browser => {
118
+ const result = await requestBrowser(browser, null, 'prepare_upgrade', {}, 8000);
119
+ if (result.errors?.length || result.prepared !== true) throw new Error(result.errors?.join('; ') || 'Extension did not confirm cleanup');
120
+ }));
121
+ const failed = outcomes.find(item => item.status === 'rejected');
122
+ if (failed) {
123
+ await Promise.allSettled(connected.map(browser => requestBrowser(browser, null, 'resume', {}, 1000)));
124
+ upgradeError = `Extension cleanup failed: ${failed.reason.message}`;
125
+ state = 'ready'; throw new Error(upgradeError);
126
+ }
127
+ response.end(JSON.stringify({ stopping: true, extensions: connected.map(browser => browser.info.instance_id) }));
128
+ setImmediate(() => close());
129
+ } catch (error) { response.writeHead(409).end(JSON.stringify({ error: error.message })); }
130
+ }
100
131
  else if (request.url === '/shutdown' && request.method === 'POST') {
101
132
  response.end('{"stopping":true}'); setImmediate(() => close());
102
133
  } else response.writeHead(404).end();
@@ -105,8 +136,9 @@ export async function startHub(config, { port = config.port, operationTimeout =
105
136
  server.on('upgrade', (request, socket, head) => {
106
137
  const extension = request.url === '/extension';
107
138
  const valid = extension ? request.headers.origin === `chrome-extension://${config.extension_id}`
108
- : request.url === '/client' && !request.headers.origin && bearer(request);
139
+ : request.url === '/client' && !request.headers.origin && bearer(request) && request.headers['x-browser-attach-version'] === VERSION;
109
140
  if (!valid) { socket.end('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n'); return; }
141
+ if (state !== 'ready') { socket.end('HTTP/1.1 503 Service Unavailable\r\nConnection: close\r\n\r\n'); return; }
110
142
  wss.handleUpgrade(request, socket, head, ws => {
111
143
  ws.on('pong', () => { ws.isAlive = true; });
112
144
  if (extension) connectExtension(ws); else connectClient(ws);
@@ -120,18 +152,23 @@ export async function startHub(config, { port = config.port, operationTimeout =
120
152
  try {
121
153
  const data = JSON.parse(raw.toString());
122
154
  if (!browser) {
155
+ if (state !== 'ready') { ws.close(1013, 'Hub is preparing an upgrade'); return; }
123
156
  const info = data.browser;
124
- if (data.type !== 'hello' || !authorized(data.token) || !info || info.extension_version !== VERSION || !['chrome', 'edge'].includes(info.family) ||
157
+ if (data.type !== 'hello' || !authorized(data.token) || !info || !['chrome', 'edge'].includes(info.family) ||
125
158
  !['instance_id', 'run_id', 'profile_name'].every(key => typeof info[key] === 'string' && info[key].length >= 1 && info[key].length <= 128))
126
159
  throw new Error('Invalid pairing or browser identity');
127
160
  const id = `${info.instance_id}:${info.run_id}`;
128
161
  if (browsers.has(id)) throw new Error('Browser instance is already connected');
129
- browser = { id, ws, info, pending: new Map() };
162
+ const comparison = compareVersions(info.extension_version, VERSION);
163
+ if (comparison > 0) throw new Error('Extension is newer than this Hub');
164
+ browser = { id, ws, info, ready: comparison === 0, pending: new Map() };
130
165
  browsers.set(id, browser); clearTimeout(helloTimer);
131
- send(ws, { type: 'connected', browser_id: id }); return;
166
+ if (browser.ready) send(ws, { type: 'connected', browser_id: id });
167
+ else requestBrowser(browser, null, 'reload_extension', { version: VERSION }).catch(error => { browser.upgradeError = error.message; });
168
+ return;
132
169
  }
133
170
  if (data.type === 'ping') send(ws, { type: 'pong' });
134
- else if (data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
171
+ else if (browser.ready && data.type === 'detached') forget(tabClaims.get(keyFor(browser.id, data.tab_id)));
135
172
  else if (browser.pending.has(data.id)) {
136
173
  const pending = browser.pending.get(data.id); browser.pending.delete(data.id);
137
174
  if ('error' in data) pending.reject(new Error(String(data.error))); else pending.resolve(data.result || {});
@@ -168,10 +205,13 @@ export async function startHub(config, { port = config.port, operationTimeout =
168
205
  });
169
206
  ws.on('close', () => {
170
207
  disconnected = true; clients.delete(ws);
171
- chain.finally(async () => {
172
- await Promise.allSettled([...browsers.values()].map(browser => requestBrowser(browser, owner, 'release_owner', {})));
173
- for (const [handle, claim] of claims) if (claim.owner === owner) forget(handle);
174
- });
208
+ const cleanup = chain.then(async () => {
209
+ await Promise.allSettled([...browsers.values()].filter(browser => browser.ready).map(async browser => {
210
+ const result = await requestBrowser(browser, owner, 'release_owner', {});
211
+ if (!result.errors?.length) for (const [handle, claim] of claims) if (claim.owner === owner && claim.browser_id === browser.id) forget(handle);
212
+ }));
213
+ }).finally(() => cleanups.delete(cleanup));
214
+ cleanups.add(cleanup);
175
215
  });
176
216
  }
177
217
  const heartbeat = setInterval(() => {
@@ -183,6 +223,7 @@ export async function startHub(config, { port = config.port, operationTimeout =
183
223
  async function close() {
184
224
  if (closing) return closing;
185
225
  closing = (async () => {
226
+ state = 'stopping';
186
227
  clearInterval(heartbeat);
187
228
  for (const ws of wss.clients) ws.close(1001, 'Hub stopped');
188
229
  const timer = setTimeout(() => { for (const ws of wss.clients) ws.terminate(); }, 1000);
@@ -191,7 +232,11 @@ export async function startHub(config, { port = config.port, operationTimeout =
191
232
  })();
192
233
  return closing;
193
234
  }
194
- try { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); }); }
195
- catch (error) { clearInterval(heartbeat); wss.close(); throw error; }
235
+ try {
236
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); });
237
+ config.port = server.address().port;
238
+ if (config.extension_dir) await syncExtension(config);
239
+ state = 'ready';
240
+ } catch (error) { await close(); throw error; }
196
241
  return { server, close, port: server.address().port, claims, browsers };
197
242
  }
package/src/mcp.mjs CHANGED
@@ -147,7 +147,7 @@ export function createMcp(client) {
147
147
  }, null, args => { dragOptions(args); return args; });
148
148
  tool('wait', 'Wait without scrolling or input. actionable requires a stable, visible, enabled, unobscured target in the viewport; network_idle uses observed page requests. Timeout includes the last observed state.', {
149
149
  ...handle, ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000), ...selector, ...frameScope, nth: target.nth,
150
- condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text: textContent substring; for url: main-frame URL substring. Text may use previous instead.'), previous: z.string().max(100000).optional().describe('Text only: wait for full textContent to differ; alternative to expected.'), idle_ms: integerRange('idle_ms', 100, 10000).default(500),
150
+ condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().max(2000).optional().describe('For text: rendered innerText substring; for url: main-frame URL substring. Text may use previous instead.'), previous: z.string().max(100000).optional().describe('Text only: wait for full rendered innerText to differ; alternative to expected.'), idle_ms: integerRange('idle_ms', 100, 10000).default(500),
151
151
  }, null, args => { waitOptions(args); return args; });
152
152
  tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
153
153
  ...handle, ...inputTimeout, ...target, value: z.string().optional().describe('Choose exactly one of value, label or zero-based index; value/label match exactly.'), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional(),
@@ -194,6 +194,7 @@ export function createMcp(client) {
194
194
 
195
195
  export async function runMcp(configPath) {
196
196
  const client = new AttachClient(await ensureHub(configPath));
197
+ await client.connect();
197
198
  const server = createMcp(client);
198
199
  const transport = new StdioServerTransport();
199
200
  transport.onclose = () => client.close();
package/src/setup.mjs CHANGED
@@ -1,9 +1,10 @@
1
1
  import { randomBytes, createHash } from 'node:crypto';
2
- import { cp, mkdir, readFile, writeFile, chmod } from 'node:fs/promises';
3
- import { join, resolve, dirname } from 'node:path';
2
+ import { cp, copyFile, mkdir, mkdtemp, readFile, writeFile, chmod, rename, rm, rmdir, readdir, stat } from 'node:fs/promises';
3
+ import { join, resolve, dirname, relative, isAbsolute, sep } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
5
  import { execFileSync } from 'node:child_process';
6
- import { ROOT, DEFAULT_CONFIG, loadConfig, writeJson } from './config.mjs';
6
+ import { ROOT, DEFAULT_CONFIG, VERSION, loadConfig, writeJson } from './config.mjs';
7
+ import { compareVersions } from '../extension/version.js';
7
8
 
8
9
  export const mcpSettings = (configPath = DEFAULT_CONFIG) => ({ command: process.execPath,
9
10
  args: [join(ROOT, 'bin/browser-attach.mjs'), 'mcp', '--config', resolve(configPath)],
@@ -19,33 +20,103 @@ async function privateDirectory(path) {
19
20
  } else await chmod(path, 0o700);
20
21
  }
21
22
 
22
- export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port = 47831 } = {}) {
23
+ export async function setup({ configPath = DEFAULT_CONFIG, extensionDir, port } = {}) {
23
24
  configPath = resolve(configPath);
24
- if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
25
25
  await privateDirectory(dirname(configPath));
26
- extensionDir = resolve(extensionDir || join(dirname(configPath), 'extension'));
27
- if (extensionDir !== resolve(ROOT, 'extension')) {
28
- await cp(join(ROOT, 'extension'), extensionDir, { recursive: true, filter: source => !source.endsWith('config.generated.js') });
29
- }
30
- await privateDirectory(extensionDir);
31
- const manifestPath = join(extensionDir, 'manifest.json');
32
- const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
26
+ const manifest = JSON.parse(await readFile(join(ROOT, 'extension/manifest.json'), 'utf8'));
33
27
  const extensionId = [...createHash('sha256').update(Buffer.from(manifest.key, 'base64')).digest('hex').slice(0, 32)]
34
28
  .map(char => String.fromCharCode(97 + parseInt(char, 16))).join('');
35
29
  let config;
36
30
  try { config = await loadConfig(configPath); }
37
31
  catch (error) { if (error.cause?.code !== 'ENOENT') throw error; }
32
+ port ??= config?.port ?? 47831;
33
+ if (!Number.isInteger(port) || port < 1024 || port > 65535) throw new Error('port must be 1024..65535');
34
+ extensionDir = resolve(extensionDir || config?.extension_dir || join(dirname(configPath), 'extension'));
35
+ const configRelative = relative(extensionDir, configPath);
36
+ if (dirname(extensionDir) === extensionDir || (!isAbsolute(configRelative) && configRelative !== '..' && !configRelative.startsWith('..' + sep)))
37
+ throw new Error('Choose a dedicated extension directory outside the pairing file');
38
38
  config ||= { port, token: randomBytes(48).toString('base64url'), extension_id: extensionId };
39
39
  if (config.port !== port || config.extension_id !== extensionId) throw new Error('Pairing differs from requested port/extension. Stop the hub and remove the pairing config before a fresh setup.');
40
+ if (config.extension_dir && resolve(config.extension_dir) !== extensionDir) throw new Error('Use the paired extension directory, or a separate pairing config for a different directory.');
40
41
  config.extension_dir = extensionDir;
41
- manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${port}` };
42
- await writeJson(manifestPath, manifest);
43
- await writeFile(join(extensionDir, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
44
- JSON.stringify({ url: `ws://127.0.0.1:${port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
45
42
  await writeJson(configPath, config);
46
43
  return { config, configPath, extensionDir };
47
44
  }
48
45
 
46
+ // Only the Hub holding the listening port may replace the stable copy.
47
+ export async function syncExtension(config) {
48
+ const source = resolve(ROOT, 'extension'), destination = resolve(config.extension_dir);
49
+ const manifest = JSON.parse(await readFile(join(source, 'manifest.json'), 'utf8'));
50
+ const backup = destination + '.previous';
51
+ async function installed(directory) {
52
+ try {
53
+ const value = JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8'));
54
+ if (value.key !== manifest.key) throw new Error(`Unrecognized extension directory: ${directory}`);
55
+ return value;
56
+ } catch (error) {
57
+ if (error.code !== 'ENOENT') throw error;
58
+ try { if ((await readdir(directory)).length) throw new Error(`Unrecognized extension directory: ${directory}`); }
59
+ catch (missing) { if (missing.code !== 'ENOENT') throw missing; }
60
+ return null;
61
+ }
62
+ }
63
+ async function files(directory) {
64
+ const entries = await readdir(directory, { recursive: true });
65
+ const kinds = await Promise.all(entries.map(path => stat(join(directory, path))));
66
+ return entries.filter((_, index) => kinds[index].isFile());
67
+ }
68
+ async function install(directory) {
69
+ await privateDirectory(destination);
70
+ const incoming = await files(directory), existing = await files(destination);
71
+ // Keep the loaded directory itself stable and commit its manifest last.
72
+ for (const path of incoming.filter(path => path !== 'manifest.json')) {
73
+ const target = join(destination, path);
74
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
75
+ await copyFile(join(directory, path), target + '.updating');
76
+ await rename(target + '.updating', target);
77
+ }
78
+ for (const path of existing) if (!incoming.includes(path)) await rm(join(destination, path));
79
+ await writeJson(join(destination, 'manifest.json'), JSON.parse(await readFile(join(directory, 'manifest.json'), 'utf8')));
80
+ }
81
+ let current = await installed(destination);
82
+ if (current && compareVersions(current.version, VERSION) > 0) throw new Error('Refusing to downgrade the installed extension');
83
+ const previous = destination === source ? null : await installed(backup);
84
+ if (previous) {
85
+ await install(backup);
86
+ current = previous;
87
+ await rm(backup, { recursive: true });
88
+ }
89
+ await mkdir(dirname(destination), { recursive: true, mode: 0o700 });
90
+ const stage = destination === source ? source : await mkdtemp(destination + '.staging-');
91
+ try {
92
+ if (stage !== source) await cp(source, stage, { recursive: true, filter: path => !path.endsWith('config.generated.js') });
93
+ await privateDirectory(stage);
94
+ manifest.content_security_policy = { extension_pages: `script-src 'self'; object-src 'self'; connect-src ws://127.0.0.1:${config.port}` };
95
+ await writeJson(join(stage, 'manifest.json'), manifest);
96
+ await writeFile(join(stage, 'config.generated.js'), '// Machine-local pairing secret. Never publish.\nexport const CONNECTION = ' +
97
+ JSON.stringify({ url: `ws://127.0.0.1:${config.port}/extension`, token: config.token }) + ';\n', { mode: 0o600 });
98
+ if (stage !== source) {
99
+ if (current) {
100
+ await privateDirectory(stage + '.previous');
101
+ await cp(destination, stage + '.previous', { recursive: true });
102
+ await rename(stage + '.previous', backup);
103
+ } else {
104
+ try { await rmdir(destination); } catch (error) { if (error.code !== 'ENOENT') throw error; }
105
+ await rename(stage, destination);
106
+ return;
107
+ }
108
+ try { await install(stage); }
109
+ catch (error) { if (current) await install(backup); throw error; }
110
+ if (current) await rm(backup, { recursive: true });
111
+ }
112
+ } finally {
113
+ if (stage !== source) {
114
+ await rm(stage, { recursive: true, force: true });
115
+ await rm(stage + '.previous', { recursive: true, force: true });
116
+ }
117
+ }
118
+ }
119
+
49
120
  export async function configureNosis(directory = join(homedir(), '.nosis'), { configPath = DEFAULT_CONFIG } = {}) {
50
121
  const path = join(directory, 'agent_config.json');
51
122
  const data = JSON.parse((await readFile(path, 'utf8')).replace(/^\uFEFF/, ''));