@evan_zhong/browser-attach 0.15.0 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
@@ -10,6 +10,8 @@ import { scrollOptions } from '../extension/scroll.js';
10
10
  import { selectOptions, typeOptions } from '../extension/input.js';
11
11
  import { targetOptions } from '../extension/locator.js';
12
12
  import { fetchOptions } from '../extension/fetch.js';
13
+ import { clickOptions } from '../extension/click.js';
14
+ import { offsetOption } from '../extension/motion.js';
13
15
 
14
16
  const text = data => ({ content: [{ type: 'text', text: JSON.stringify(data) }] });
15
17
  function integerRange(name, min, max) {
@@ -34,6 +36,10 @@ export function createMcp(client) {
34
36
  const target = { ...frameScope, ref: z.string().optional(), ...selector, frame_id: z.string().optional().describe('Selector scope; defaults to main frame.'),
35
37
  nth: integerRange('nth', 0, Number.MAX_SAFE_INTEGER).optional().describe('Zero-based selector match index in document order.') };
36
38
  const inputTimeout = { ...dialogPolicy, timeout_ms: integerRange('timeout_ms', 100, 30000).default(5000).describe('Covers preparation, dispatch and verification.') };
39
+ const timing = name => z.union([integerRange(name, 0, 1000), z.tuple([integerRange(name, 0, 1000), integerRange(name, 0, 1000)]).refine(([min, max]) => min <= max, 'Use an ordered [min, max] pair')]);
40
+ const offset = { offset: z.object({ x_ratio: z.number().min(0).max(1), y_ratio: z.number().min(0).max(1) }).strict().optional().describe('Native element only: ratios within its content quad; offscreen/covered points fail.') };
41
+ const settle = { settle_ms: timing('settle_ms').optional().describe('Pause after target preparation, before dispatch: fixed ms or uniform [min, max].') };
42
+ const typeDelay = timing('delay_ms').default(0).describe('Between characters: fixed ms or independently sampled uniform [min, max].');
37
43
  function tool(name, description, schema, method, transform) {
38
44
  server.registerTool(`browser_${name}`, { description, inputSchema: z.object(schema) }, async args => {
39
45
  try {
@@ -86,12 +92,14 @@ export function createMcp(client) {
86
92
  if (args.focus_window != null) throw new Error('focus_window is only valid for action=activate');
87
93
  return { tab_handle: args.tab_handle };
88
94
  });
89
- tool('snapshot', 'Read an AX tree with refs and frame coverage. next_cursor continues the same observation with unchanged view options.', {
95
+ tool('snapshot', 'Read an AX tree with refs and frame coverage, or find nodes by query without returning the whole tree. A new observation replaces refs; next_cursor continues unchanged view options.', {
90
96
  ...handle, max_nodes: integerRange('max_nodes', 1, 3000).default(600),
91
97
  max_chars: integerRange('max_chars', 1, 2000000).default(24000), cursor: z.string().optional(),
92
98
  profile: z.enum(['full', 'interactive']).optional().describe('Default full; interactive keeps controls, focusable/editable nodes and ancestors.'),
93
99
  text: z.boolean().optional().describe('Default true; false hides prose, preserving control names and values.'),
94
100
  text_limit: integerRange('text_limit', 0, 10000).optional().describe('Prose preview length, marked with …; control names/values stay complete. Default 80 for interactive, unlimited for full.'),
101
+ query: z.string().min(1).max(2000).optional().describe('Case-insensitive literal substring of AX names/values in this view, before text previews. Returns matching lines and refs; zero matches is not an error. Uses normal budgets/cursor.'),
102
+ verbose: z.boolean().default(false).describe('Include viewport and snapshot_id.'),
95
103
  });
96
104
  tool('read_text', 'Read rendered page/frame text, optionally scoped to a selector. mode=value reads a field; mode=editor reads a Monaco/CodeMirror 5 model. complete_source reports completeness; next_offset continues live text, which may change.', {
97
105
  ...handle, ...selector, ...frameScope, mode: z.enum(['visible', 'text_content', 'value', 'editor']).default('visible'),
@@ -120,17 +128,21 @@ export function createMcp(client) {
120
128
  return args;
121
129
  });
122
130
  tool('click', 'Click one ref, selector or x/y; element targets auto-scroll. Native mode verifies the hit target; native select hits require select_option.', {
131
+ ...offset, ...settle,
123
132
  ...handle, ...inputTimeout, ...target, button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]), x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional().describe('Coordinates only; document scrolls the top-level page first.'), mode: z.enum(['native', 'dom']).default('native').describe('DOM click requires an element target and a left single click without modifiers.'),
124
133
  }, null, args => {
125
134
  targetOptions(args, { coordinates: true });
135
+ clickOptions(args);
126
136
  if (args.mode === 'dom' && !args.ref && !args.selector) throw new Error('DOM click requires ref or selector; coordinates are unsupported.');
127
137
  return args;
128
138
  });
129
139
  tool('hover', 'Move the native pointer to a ref, selector or x/y without clicking; verify trusted mousemove and leave it at the target.', {
140
+ ...offset,
130
141
  ...handle, ...inputTimeout, ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional(),
131
- }, null, args => { targetOptions(args, { coordinates: true }); return args; });
132
- const dragTarget = z.object({ ...target, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
142
+ }, null, args => { targetOptions(args, { coordinates: true }); offsetOption(args); return args; });
143
+ const dragTarget = z.object({ ...target, ...offset, x: z.number().optional(), y: z.number().optional(), coordinate_space: z.enum(['viewport', 'document']).optional() }).strict();
133
144
  tool('drag', 'Perform one native pointer or HTML5 drag; destination auto-scrolls into view while held.', {
145
+ ...settle, trajectory: z.object({ curve_px: z.number().min(-100).max(100), duration_ms: integerRange('duration_ms', 100, 5000) }).strict().optional().describe('Opt in to curved, variable-speed steps and varied intervals. Signed curve control-point offset in CSS px; duration covers interpolation waits only.'),
134
146
  ...handle, ...inputTimeout, source: dragTarget, destination: dragTarget, mode: z.enum(['pointer', 'html5']).default('pointer').describe('pointer for mouse-driven dragging; html5 for HTML drag-and-drop.'), steps: integerRange('steps', 2, 100).default(20),
135
147
  }, null, args => { dragOptions(args); return args; });
136
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.', {
@@ -140,7 +152,7 @@ export function createMcp(client) {
140
152
  tool('select_option', 'Set one HTML select option without native UI, using synthetic input/change (events_trusted=false). Verifies retained selection only.', {
141
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(),
142
154
  }, null, args => { targetOptions(args); selectOptions(args); return args; });
143
- tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0).describe('Pause between characters.') }, null, args => { targetOptions(args); typeOptions(args); return args; });
155
+ tool('type', 'Type printable characters at the current selection using trusted key events, without clearing the field. Stops on lost focus or detachment.', { ...handle, ...inputTimeout, ...target, text: z.string().max(10000), delay_ms: typeDelay }, null, args => { targetOptions(args); typeOptions(args); return args; });
144
156
  tool('fill', 'Replace input/textarea/contenteditable text. Native mode focuses/selects and inserts text without character keydown; DOM mode sets text without focus and emits input/change. Does not blur or commit dropdown selections.', { ...handle, ...inputTimeout, ...target, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }, null, args => { targetOptions(args); return args; });
145
157
  const batchTarget = { ...selector, selector: z.string().min(1).max(10000), ...frameScope, nth: target.nth };
146
158
  const scrollFields = { until: z.object({ text: z.string().min(1).max(2000), selector: z.string().min(1).optional() }).strict().optional(), max_steps: integerRange('max_steps', 1, 500).default(100), mode: z.enum(['dom', 'wheel']).default('dom'), delta_y: z.number().optional().describe('Positive scrolls down.'), delta_x: z.number().default(0), x: z.number().optional(), y: z.number().optional() };
@@ -149,10 +161,11 @@ export function createMcp(client) {
149
161
  ...handle, ...dialogPolicy, start_at: integerRange('start_at', 0, 19).default(0).describe('Explicitly skip a reviewed completed prefix.'), timeout_ms: integerRange('timeout_ms', 100, 30000).default(15000),
150
162
  steps: z.array(z.discriminatedUnion('action', [
151
163
  z.object({ action: z.literal('click'), ...stepOptions, ...batchTarget, mode: z.enum(['native', 'dom']).default('native'),
164
+ ...offset, ...settle,
152
165
  button: z.enum(['left', 'middle', 'right']).default('left'), click_count: z.union([z.literal(1), z.literal(2)]).default(1), modifiers: z.array(z.enum(['Alt', 'Control', 'Meta', 'Shift'])).default([]) }).strict(),
153
166
  z.object({ action: z.literal('fill'), ...stepOptions, ...batchTarget, text: z.string(), mode: z.enum(['native', 'dom']).default('native') }).strict(),
154
- z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: integerRange('delay_ms', 0, 1000).default(0) }).strict(),
155
- z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget }).strict(),
167
+ z.object({ action: z.literal('type'), ...stepOptions, ...batchTarget, text: z.string().max(10000), delay_ms: typeDelay }).strict(),
168
+ z.object({ action: z.literal('hover'), ...stepOptions, ...batchTarget, ...offset }).strict(),
156
169
  z.object({ action: z.literal('select_option'), ...stepOptions, ...batchTarget, value: z.string().optional(), label: z.string().optional(), index: integerRange('index', 0, Number.MAX_SAFE_INTEGER).optional() }).strict(),
157
170
  z.object({ action: z.literal('scroll'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, ...scrollFields }).strict(),
158
171
  z.object({ action: z.literal('wait'), ...stepOptions, ...selector, ...frameScope, nth: target.nth, condition: z.enum(['attached', 'visible', 'actionable', 'text', 'url', 'network_idle']), expected: z.string().optional(), previous: z.string().optional(), idle_ms: integerRange('idle_ms', 100, 10000).default(500) }).strict(),
@@ -181,6 +194,7 @@ export function createMcp(client) {
181
194
 
182
195
  export async function runMcp(configPath) {
183
196
  const client = new AttachClient(await ensureHub(configPath));
197
+ await client.connect();
184
198
  const server = createMcp(client);
185
199
  const transport = new StdioServerTransport();
186
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/, ''));