@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/README.md +7 -7
- package/README.zh.md +7 -7
- package/bin/browser-attach.mjs +3 -3
- package/docs/AGENT-INTEGRATION.md +3 -3
- package/docs/ARCHITECTURE.md +12 -5
- package/docs/DESIGN-REVIEW-0.15.1.md +27 -0
- package/docs/TEST-REPORT.md +25 -1
- package/docs/USAGE.md +32 -5
- package/extension/background.js +7 -98
- package/extension/batch.js +2 -0
- package/extension/click.js +34 -5
- package/extension/connection.js +142 -0
- package/extension/controller.js +3 -2
- package/extension/drag.js +15 -3
- package/extension/input.js +3 -2
- package/extension/manifest.json +1 -1
- package/extension/motion.js +50 -0
- package/extension/snapshot.js +16 -11
- package/extension/version.js +11 -0
- 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 +20 -6
- package/src/setup.mjs +87 -16
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
|
-
},
|
|
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,
|
|
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 ||
|
|
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
|
-
|
|
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 });
|
|
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.
|
|
172
|
-
await Promise.allSettled([...browsers.values()].
|
|
173
|
-
|
|
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 {
|
|
195
|
-
|
|
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
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
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/, ''));
|