@evan_zhong/browser-attach 0.11.2 → 0.13.1
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 +1 -1
- package/README.zh.md +1 -1
- package/docs/AGENT-INTEGRATION.md +125 -125
- package/docs/ARCHITECTURE.md +14 -6
- package/docs/TEST-REPORT.md +16 -4
- package/docs/USAGE.md +29 -3
- package/extension/actionability.js +1 -0
- package/extension/click.js +8 -3
- package/extension/controller.js +91 -33
- package/extension/drag.js +19 -2
- package/extension/fetch.js +60 -0
- package/extension/manifest.json +1 -1
- package/extension/snapshot.js +44 -5
- package/package.json +1 -1
- package/scripts/check-package.mjs +1 -1
- package/src/config.mjs +1 -1
- package/src/hub.mjs +3 -3
- package/src/mcp.mjs +62 -51
package/extension/controller.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Browser operations live here; the WebSocket transport never exposes raw CDP.
|
|
2
2
|
import { createVisuals } from './visuals.js';
|
|
3
3
|
import { createClickVisuals } from './click-visuals.js';
|
|
4
|
-
import { renderAX, snapshotPage } from './snapshot.js';
|
|
4
|
+
import { renderAX, snapshotOptions, snapshotPage } from './snapshot.js';
|
|
5
5
|
import { createNavigation } from './navigation.js';
|
|
6
6
|
import { createClick, clickOptions, framePoint } from './click.js';
|
|
7
7
|
import { createInput, inputMode, selectOptions, typeOptions } from './input.js';
|
|
@@ -17,9 +17,12 @@ import { validateSteps } from './batch.js';
|
|
|
17
17
|
import { waitFor } from './wait.js';
|
|
18
18
|
import { boundedRead, installOutcomeObserver } from './outcomes.js';
|
|
19
19
|
import { createDrag, dragOptions } from './drag.js';
|
|
20
|
+
import { createFetches, capturePage } from './fetch.js';
|
|
20
21
|
|
|
21
22
|
export function createController(api, notify = () => {}) {
|
|
22
23
|
const claims = new Map();
|
|
24
|
+
const temporaryTabs = new Map();
|
|
25
|
+
const fetches = createFetches(fetchPage);
|
|
23
26
|
const snapshots = new Map();
|
|
24
27
|
const documents = new Map();
|
|
25
28
|
const pageContexts = new Map();
|
|
@@ -38,7 +41,7 @@ export function createController(api, notify = () => {}) {
|
|
|
38
41
|
for (const key of pageContexts.keys()) { const [tab, session] = JSON.parse(key); if (tab === tabId && session === sessionId) pageContexts.delete(key); }
|
|
39
42
|
}, tabId => diagnostics.networkOptions(tabId));
|
|
40
43
|
const mouse = createClick(cdp, value, viewport, position, sessions, tabId => api.tabs.get(tabId), windowId => api.windows.get(windowId));
|
|
41
|
-
const gestures = createDrag(cdp, value, mouse);
|
|
44
|
+
const gestures = createDrag(cdp, value, mouse, viewport);
|
|
42
45
|
const input = createInput(cdp, value, sessions);
|
|
43
46
|
const evaluations = createEvaluations(cdp);
|
|
44
47
|
|
|
@@ -155,8 +158,8 @@ export function createController(api, notify = () => {}) {
|
|
|
155
158
|
}
|
|
156
159
|
|
|
157
160
|
async function releaseMatching(predicate) {
|
|
158
|
-
const targets = [...claims
|
|
159
|
-
const outcomes = await Promise.allSettled(targets.map(([tabId]) => queued(tabId, () => release(tabId))));
|
|
161
|
+
const targets = new Map([...claims, ...temporaryTabs].filter(([, claim]) => predicate(claim)));
|
|
162
|
+
const outcomes = await Promise.allSettled([...targets].map(([tabId]) => queued(tabId, () => temporaryTabs.has(tabId) ? closeTemporary(tabId) : release(tabId))));
|
|
160
163
|
return { released: outcomes.filter(item => item.status === 'fulfilled').length,
|
|
161
164
|
errors: outcomes.filter(item => item.status === 'rejected').map(item => String(item.reason)) };
|
|
162
165
|
}
|
|
@@ -241,7 +244,7 @@ export function createController(api, notify = () => {}) {
|
|
|
241
244
|
}
|
|
242
245
|
if (method === 'Page.javascriptDialogClosed') dialogs.delete(source.tabId);
|
|
243
246
|
});
|
|
244
|
-
api.tabs.onRemoved.addListener(forget);
|
|
247
|
+
api.tabs.onRemoved.addListener(tabId => { temporaryTabs.delete(tabId); forget(tabId); });
|
|
245
248
|
api.tabs.onUpdated.addListener((tabId, change) => {
|
|
246
249
|
// A subframe reload can also set the TAB status to loading. CDP main-frame
|
|
247
250
|
// events identify document changes; a status-only update carries no identity.
|
|
@@ -291,6 +294,7 @@ export function createController(api, notify = () => {}) {
|
|
|
291
294
|
if (!state) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
292
295
|
return snapshotPage(state, args);
|
|
293
296
|
}
|
|
297
|
+
const options = snapshotOptions(args);
|
|
294
298
|
snapshots.delete(tabId);
|
|
295
299
|
const documentVersion = documents.get(tabId);
|
|
296
300
|
const versions = new Map(frameStates.get(tabId)?.versions);
|
|
@@ -329,12 +333,12 @@ export function createController(api, notify = () => {}) {
|
|
|
329
333
|
knownFrames.add(childId); frames.push({ id: childId, url });
|
|
330
334
|
}
|
|
331
335
|
entries.push({ node: false, frameId: frame.id, line: `Frame ${frame.id} ${frame.url}` });
|
|
332
|
-
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId));
|
|
336
|
+
entries.push(...renderAX(nodes, token, refs, frame.id, target.sessionId, options));
|
|
333
337
|
}
|
|
334
338
|
const metadata = value(await send(tabId, 'Runtime.evaluate', {
|
|
335
339
|
expression: '({title: document.title, url: location.href})', returnByValue: true,
|
|
336
340
|
}));
|
|
337
|
-
const state = { id: token, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
341
|
+
const state = { id: token, options, refs, entries, invalidFrames: new Set(), totalNodes: entries.filter(entry => entry.node).length,
|
|
338
342
|
metadata: { title: metadata.title, url: metadata.url, viewport: await (budget ? budget.run(() => viewport(tabId)) : viewport(tabId)), omitted_frames: omittedFrames } };
|
|
339
343
|
if (documentVersion !== documents.get(tabId)) throw new Error('Document changed while reading the snapshot. Take a new browser_snapshot.');
|
|
340
344
|
for (const frame of frames) if (versions.get(frame.id) !== frameStates.get(tabId)?.versions.get(frame.id)) {
|
|
@@ -697,7 +701,82 @@ export function createController(api, notify = () => {}) {
|
|
|
697
701
|
return { x: 0, y: 0, width: cssContentSize.width, height: cssContentSize.height, scale: 1 };
|
|
698
702
|
}
|
|
699
703
|
|
|
704
|
+
async function closeTemporary(tabId) {
|
|
705
|
+
try { await api.tabs.remove(tabId); }
|
|
706
|
+
catch (error) {
|
|
707
|
+
// A closed tab is already clean; otherwise retain ownership for later cleanup.
|
|
708
|
+
let tab;
|
|
709
|
+
try { tab = await api.tabs.get(tabId); }
|
|
710
|
+
catch (lookup) { if (!/No tab with id|Invalid tab ID/i.test(lookup.message)) throw error; }
|
|
711
|
+
if (tab) throw error;
|
|
712
|
+
}
|
|
713
|
+
temporaryTabs.delete(tabId);
|
|
714
|
+
if (claims.has(tabId)) forget(tabId);
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
async function fetchPage(owner, args, cap, checkConnection) {
|
|
718
|
+
const url = httpUrl(args.url);
|
|
719
|
+
const budget = deadline(args.timeout_ms ?? 15000, 'fetch');
|
|
720
|
+
const tab = await api.tabs.create({ url: 'about:blank', active: false });
|
|
721
|
+
const handle = crypto.randomUUID();
|
|
722
|
+
temporaryTabs.set(tab.id, { owner, handle });
|
|
723
|
+
return await queued(tab.id, async () => {
|
|
724
|
+
let failure;
|
|
725
|
+
try {
|
|
726
|
+
checkConnection();
|
|
727
|
+
await attach(tab.id, owner, { tab_handle: handle }, tab, budget);
|
|
728
|
+
const claim = claims.get(tab.id);
|
|
729
|
+
checkConnection();
|
|
730
|
+
if (budget.remaining() < 100) throw new Error('Fetch preparation timed out; no navigation was dispatched');
|
|
731
|
+
await navigation.navigate(tab.id, url, { wait_until: 'load', timeout_ms: Math.max(100, budget.remaining()) });
|
|
732
|
+
checkConnection();
|
|
733
|
+
if (args.wait_until === 'network_idle') await waitFor(cdp, value, sessions, diagnostics, mouse, tab.id, { condition: 'network_idle', budget });
|
|
734
|
+
checkConnection();
|
|
735
|
+
const version = documents.get(tab.id);
|
|
736
|
+
const { frameTree } = await budget.run(() => cdp(tab.id, 'Page.getFrameTree'));
|
|
737
|
+
const { executionContextId } = await budget.run(() => cdp(tab.id, 'Page.createIsolatedWorld', { frameId: frameTree.frame.id, worldName: 'browser-attach-fetch' }));
|
|
738
|
+
const result = value(await budget.run(() => cdp(tab.id, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true, userGesture: false,
|
|
739
|
+
expression: `(${capturePage.toString()})(${JSON.stringify(args.format ?? 'text')},${cap})` })));
|
|
740
|
+
if (claims.get(tab.id) !== claim || documents.get(tab.id) !== version) throw new Error('Document or control changed during fetch; capture discarded');
|
|
741
|
+
httpUrl(result.url);
|
|
742
|
+
return result;
|
|
743
|
+
} catch (error) { failure = error; throw error; }
|
|
744
|
+
finally {
|
|
745
|
+
try { await closeTemporary(tab.id); }
|
|
746
|
+
catch (error) { throw new Error(`${failure ? failure.message + '; ' : ''}Fetch temporary tab ${tab.id} could not be closed: ${error.message}. Cleanup will be retried on disconnect.`); }
|
|
747
|
+
}
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
async function attach(tabId, owner, args, tab, budget) {
|
|
752
|
+
const run = operation => budget ? budget.run(operation) : operation();
|
|
753
|
+
await run(() => api.debugger.attach({ tabId }, '1.3'));
|
|
754
|
+
try {
|
|
755
|
+
diagnostics.start(tabId);
|
|
756
|
+
await run(() => sessions.start(tabId));
|
|
757
|
+
const { frameTree } = await run(() => cdp(tabId, 'Page.getFrameTree'));
|
|
758
|
+
const state = { mainId: frameTree.frame.id, parents: new Map(), versions: new Map(), sessions: new Map() };
|
|
759
|
+
const remember = tree => {
|
|
760
|
+
if (tree.frame.parentId) state.parents.set(tree.frame.id, tree.frame.parentId);
|
|
761
|
+
(tree.childFrames || []).forEach(remember);
|
|
762
|
+
};
|
|
763
|
+
remember(frameTree); frameStates.set(tabId, state);
|
|
764
|
+
claims.set(tabId, { owner, handle: args.tab_handle });
|
|
765
|
+
let visualWarning;
|
|
766
|
+
try { if (!temporaryTabs.has(tabId)) await visuals.install(tabId); }
|
|
767
|
+
catch (error) { visualWarning = error.message; }
|
|
768
|
+
return { claimed: true, activation_requested: false, title: tab.title, url: tab.url,
|
|
769
|
+
...(visualWarning ? { visual_warning: visualWarning } : {}) };
|
|
770
|
+
} catch (error) {
|
|
771
|
+
diagnostics.forget(tabId);
|
|
772
|
+
sessions.forget(tabId);
|
|
773
|
+
if (!temporaryTabs.has(tabId)) await api.debugger.detach({ tabId });
|
|
774
|
+
throw error;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
|
|
700
778
|
async function execute(owner, command, args) {
|
|
779
|
+
if (command === 'fetch') return await fetches.fetch(owner, args);
|
|
701
780
|
if (command === 'open_tab') {
|
|
702
781
|
const url = httpUrl(args.url);
|
|
703
782
|
if (args.active != null && typeof args.active !== 'boolean') throw new Error('active must be a boolean');
|
|
@@ -706,15 +785,16 @@ export function createController(api, notify = () => {}) {
|
|
|
706
785
|
note: 'List tabs after loading, then claim tab_id with optional exact URL/title guards before controlling this tab.' };
|
|
707
786
|
}
|
|
708
787
|
if (command === 'list_tabs') {
|
|
709
|
-
const tabs = await api.tabs.query({});
|
|
788
|
+
const tabs = (await api.tabs.query({})).filter(tab => !temporaryTabs.has(tab.id));
|
|
710
789
|
return { tabs: tabs.map(tab => ({ tab_id: tab.id, window_id: tab.windowId, title: tab.title || '', url: tab.url || '',
|
|
711
790
|
active: tab.active, group_id: tab.groupId, controllable: /^https?:\/\//.test(tab.url || '') })) };
|
|
712
791
|
}
|
|
713
|
-
if (command === 'release_owner') return await releaseMatching(claim => claim.owner === owner);
|
|
792
|
+
if (command === 'release_owner') { fetches.forget(owner); return await releaseMatching(claim => claim.owner === owner); }
|
|
714
793
|
const tabId = args.tab_id;
|
|
715
794
|
if (!Number.isInteger(tabId)) throw new Error('Invalid tab ID');
|
|
716
795
|
return await queued(tabId, async () => {
|
|
717
796
|
if (command === 'claim_tab') {
|
|
797
|
+
if (temporaryTabs.has(tabId)) throw new Error('Tab is already controlled by a fetch operation');
|
|
718
798
|
const tab = await api.tabs.get(tabId);
|
|
719
799
|
if ((args.expected_url != null && tab.url !== args.expected_url) || (args.expected_title != null && tab.title !== args.expected_title)) throw new Error('Tab title/URL changed. List tabs again or omit optional identity guards to claim by tab_id.');
|
|
720
800
|
httpUrl(tab.url);
|
|
@@ -723,29 +803,7 @@ export function createController(api, notify = () => {}) {
|
|
|
723
803
|
if (existing.owner === owner && existing.handle === args.tab_handle) return { claimed: true };
|
|
724
804
|
throw new Error('Tab is already controlled by another session');
|
|
725
805
|
}
|
|
726
|
-
await
|
|
727
|
-
try {
|
|
728
|
-
diagnostics.start(tabId);
|
|
729
|
-
await sessions.start(tabId);
|
|
730
|
-
const { frameTree } = await cdp(tabId, 'Page.getFrameTree');
|
|
731
|
-
const state = { mainId: frameTree.frame.id, parents: new Map(), versions: new Map(), sessions: new Map() };
|
|
732
|
-
const remember = tree => {
|
|
733
|
-
if (tree.frame.parentId) state.parents.set(tree.frame.id, tree.frame.parentId);
|
|
734
|
-
(tree.childFrames || []).forEach(remember);
|
|
735
|
-
};
|
|
736
|
-
remember(frameTree); frameStates.set(tabId, state);
|
|
737
|
-
claims.set(tabId, { owner, handle: args.tab_handle });
|
|
738
|
-
let visualWarning;
|
|
739
|
-
try { await visuals.install(tabId); }
|
|
740
|
-
catch (error) { visualWarning = error.message; }
|
|
741
|
-
return { claimed: true, activation_requested: false, title: tab.title, url: tab.url,
|
|
742
|
-
...(visualWarning ? { visual_warning: visualWarning } : {}) };
|
|
743
|
-
} catch (error) {
|
|
744
|
-
diagnostics.forget(tabId);
|
|
745
|
-
sessions.forget(tabId);
|
|
746
|
-
await api.debugger.detach({ tabId });
|
|
747
|
-
throw error;
|
|
748
|
-
}
|
|
806
|
+
return await attach(tabId, owner, args, tab);
|
|
749
807
|
}
|
|
750
808
|
const claim = claims.get(tabId);
|
|
751
809
|
if (!claim || claim.owner !== owner || claim.handle !== args.tab_handle) throw new Error('Tab is not claimed by this connection');
|
|
@@ -779,7 +837,7 @@ export function createController(api, notify = () => {}) {
|
|
|
779
837
|
});
|
|
780
838
|
}
|
|
781
839
|
|
|
782
|
-
return { execute, releaseAll: () => releaseMatching(() => true), claimedCount: () => claims.size };
|
|
840
|
+
return { execute, releaseAll: () => { fetches.clear(); return releaseMatching(() => true); }, claimedCount: () => claims.size };
|
|
783
841
|
}
|
|
784
842
|
|
|
785
843
|
function scrollAtPoint(x, y, dx, dy) {
|
package/extension/drag.js
CHANGED
|
@@ -3,12 +3,15 @@ import { targetOptions } from './locator.js';
|
|
|
3
3
|
|
|
4
4
|
export function dragOptions(args) {
|
|
5
5
|
if (!args.source || !args.destination) throw new Error('drag requires source and destination');
|
|
6
|
-
for (const target of [args.source, args.destination])
|
|
6
|
+
for (const target of [args.source, args.destination]) {
|
|
7
|
+
targetOptions(target, { coordinates: true });
|
|
8
|
+
if (target.coordinate_space === 'document' && (target.x < 0 || target.y < 0)) throw new Error('Document coordinates must be finite and nonnegative');
|
|
9
|
+
}
|
|
7
10
|
if (!['pointer', 'html5'].includes(args.mode ?? 'pointer')) throw new Error('drag mode must be pointer or html5');
|
|
8
11
|
if (!Number.isInteger(args.steps ?? 20) || (args.steps ?? 20) < 2 || (args.steps ?? 20) > 100) throw new Error('steps must be 2..100');
|
|
9
12
|
}
|
|
10
13
|
|
|
11
|
-
export function createDrag(cdp, value, mouse) {
|
|
14
|
+
export function createDrag(cdp, value, mouse, viewport) {
|
|
12
15
|
const pending = new Map();
|
|
13
16
|
function event(tabId, method, params, sessionId) {
|
|
14
17
|
const operation = pending.get(tabId);
|
|
@@ -36,6 +39,20 @@ export function createDrag(cdp, value, mouse) {
|
|
|
36
39
|
dragOptions(args);
|
|
37
40
|
const budget = args.budget || deadline(args.timeout_ms ?? 5000), html5 = args.mode === 'html5', steps = args.steps ?? 20;
|
|
38
41
|
await mouse.assertReady(tabId, budget);
|
|
42
|
+
if (args.source.coordinate_space === 'document' && args.destination.coordinate_space === 'document') {
|
|
43
|
+
const view = await budget.run(() => viewport(tabId)), a = args.source, b = args.destination;
|
|
44
|
+
// Keep both endpoints in one viewport when possible. Centering them
|
|
45
|
+
// independently can erase the requested delta before interpolation.
|
|
46
|
+
if (Math.abs(a.x - b.x) < view.width && Math.abs(a.y - b.y) < view.height) {
|
|
47
|
+
const left = Math.min(a.x, b.x) < view.scroll_x || Math.max(a.x, b.x) >= view.scroll_x + view.width
|
|
48
|
+
? Math.max(0, (a.x + b.x - view.width) / 2) : view.scroll_x;
|
|
49
|
+
const top = Math.min(a.y, b.y) < view.scroll_y || Math.max(a.y, b.y) >= view.scroll_y + view.height
|
|
50
|
+
? Math.max(0, (a.y + b.y - view.height) / 2) : view.scroll_y;
|
|
51
|
+
if (left !== view.scroll_x || top !== view.scroll_y) value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', {
|
|
52
|
+
expression: `scrollTo({left:${left},top:${top},behavior:'instant'})`, returnByValue: true,
|
|
53
|
+
})));
|
|
54
|
+
}
|
|
55
|
+
}
|
|
39
56
|
const source = await mouse.prepare(tabId, await resolve(args.source, budget), { ...args.source, budget });
|
|
40
57
|
const name = `__attachDrag_${crypto.randomUUID().replaceAll('-', '')}`, contexts = new Set(), guards = [], frames = new Set();
|
|
41
58
|
let intercept; const intercepted = new Promise(resolve => { intercept = resolve; });
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const CAPTURE_CHARS = 1000000;
|
|
2
|
+
|
|
3
|
+
export function fetchOptions(args) {
|
|
4
|
+
if (!Number.isInteger(args.max_chars ?? 20000) || (args.max_chars ?? 20000) < 1 || (args.max_chars ?? 20000) > 100000) throw new Error('max_chars must be 1..100000');
|
|
5
|
+
if (args.cursor != null) {
|
|
6
|
+
if (typeof args.cursor !== 'string' || !args.cursor || ['url', 'format', 'wait_until', 'timeout_ms'].some(key => args[key] != null)) throw new Error('Cursor continuation accepts only browser_id, cursor and max_chars');
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
const url = new URL(args.url);
|
|
10
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('fetch requires an HTTP(S) URL without credentials');
|
|
11
|
+
if (!['text', 'html', 'json'].includes(args.format ?? 'text')) throw new Error('format must be text, html or json');
|
|
12
|
+
if (!['load', 'network_idle'].includes(args.wait_until ?? 'load')) throw new Error('wait_until must be load or network_idle');
|
|
13
|
+
if (!Number.isInteger(args.timeout_ms ?? 15000) || (args.timeout_ms ?? 15000) < 100 || (args.timeout_ms ?? 15000) > 30000) throw new Error('timeout_ms must be 100..30000');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Executed in an isolated world. Read the rendered main document, never a second request.
|
|
17
|
+
export function capturePage(format, cap) {
|
|
18
|
+
if (!/^https?:$/.test(location.protocol)) throw new Error('Final document is outside HTTP(S) scope');
|
|
19
|
+
let body = format === 'html' ? document.documentElement.outerHTML : format === 'json'
|
|
20
|
+
? (document.querySelector('pre') || document.body)?.textContent || '' : document.body?.innerText || '';
|
|
21
|
+
if (format === 'json') {
|
|
22
|
+
if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
|
|
23
|
+
body = JSON.stringify(JSON.parse(body));
|
|
24
|
+
if (body.length > cap) throw new Error('JSON document exceeds the 1000000 character capture limit');
|
|
25
|
+
}
|
|
26
|
+
return { url: location.href, title: document.title, contentType: document.contentType, format,
|
|
27
|
+
body: body.slice(0, cap), capture_truncated: body.length > cap };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// One bounded immutable capture per owner; closing the temporary tab does not expire it.
|
|
31
|
+
export function createFetches(read) {
|
|
32
|
+
const results = new Map();
|
|
33
|
+
async function fetch(owner, args) {
|
|
34
|
+
fetchOptions(args);
|
|
35
|
+
let state;
|
|
36
|
+
if (args.cursor != null) {
|
|
37
|
+
state = results.get(owner);
|
|
38
|
+
if (!state?.result || state.cursor !== args.cursor) throw new Error('Fetch cursor is stale or belongs to another connection; a new fetch replaces the previous capture');
|
|
39
|
+
} else {
|
|
40
|
+
state = { offset: 0 };
|
|
41
|
+
results.set(owner, state);
|
|
42
|
+
try {
|
|
43
|
+
const result = await read(owner, args, CAPTURE_CHARS, () => {
|
|
44
|
+
if (results.get(owner) !== state) throw new Error('Fetch connection ended; capture discarded');
|
|
45
|
+
});
|
|
46
|
+
if (results.get(owner) !== state) throw new Error('Fetch connection ended; capture discarded');
|
|
47
|
+
state.result = result;
|
|
48
|
+
} catch (error) { if (results.get(owner) === state) results.delete(owner); throw error; }
|
|
49
|
+
}
|
|
50
|
+
const { body, ...metadata } = state.result;
|
|
51
|
+
const end = Math.min(body.length, state.offset + (args.max_chars ?? 20000));
|
|
52
|
+
const chunk = body.slice(state.offset, end);
|
|
53
|
+
state.offset = end;
|
|
54
|
+
state.cursor = end < body.length ? crypto.randomUUID() : null;
|
|
55
|
+
const result = { ...metadata, body: chunk, truncated: end < body.length || metadata.capture_truncated, next_cursor: state.cursor };
|
|
56
|
+
if (!state.cursor) results.delete(owner);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
return { fetch, forget: owner => results.delete(owner), clear: () => results.clear() };
|
|
60
|
+
}
|
package/extension/manifest.json
CHANGED
package/extension/snapshot.js
CHANGED
|
@@ -1,9 +1,39 @@
|
|
|
1
1
|
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
-
export function
|
|
2
|
+
export function snapshotOptions({ profile = 'full', text = true, text_limit } = {}) {
|
|
3
|
+
if (!['full', 'interactive'].includes(profile)) throw new Error('profile must be full or interactive');
|
|
4
|
+
if (typeof text !== 'boolean') throw new Error('text must be a boolean');
|
|
5
|
+
if (text_limit != null && (!Number.isInteger(text_limit) || text_limit < 0 || text_limit > 10000)) throw new Error('text_limit must be 0..10000');
|
|
6
|
+
return { profile, text, text_limit: text_limit ?? (profile === 'interactive' ? 80 : null) };
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function renderAX(nodes, token, refs, frameId, sessionId, options = snapshotOptions()) {
|
|
3
10
|
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
4
11
|
const visited = new Set();
|
|
5
12
|
const entries = [];
|
|
6
13
|
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
14
|
+
const controls = new Set(['button', 'checkbox', 'combobox', 'DisclosureTriangle', 'link', 'listbox', 'menuitem',
|
|
15
|
+
'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'scrollbar', 'searchbox', 'slider', 'spinbutton', 'switch', 'tab', 'textbox', 'treeitem']);
|
|
16
|
+
let kept;
|
|
17
|
+
if (options.profile === 'interactive') {
|
|
18
|
+
kept = new Set();
|
|
19
|
+
const parents = new Map();
|
|
20
|
+
for (const node of nodes) for (const child of node.childIds || []) parents.set(child, node.nodeId);
|
|
21
|
+
function keepText(node) {
|
|
22
|
+
if (!node || kept.has(node.nodeId)) return;
|
|
23
|
+
const role = node.role?.value;
|
|
24
|
+
if (!textRoles.has(role) && !node.ignored && !['none', 'generic'].includes(role)) return;
|
|
25
|
+
kept.add(node.nodeId);
|
|
26
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
27
|
+
}
|
|
28
|
+
for (const node of nodes) {
|
|
29
|
+
if (node.ignored || !node.backendDOMNodeId || !(controls.has(node.role?.value) ||
|
|
30
|
+
node.properties?.some(prop => prop.name === 'focusable' && prop.value?.value === true ||
|
|
31
|
+
prop.name === 'editable' && prop.value?.value && prop.value.value !== 'false'))) continue;
|
|
32
|
+
for (const child of node.childIds || []) keepText(byId.get(child));
|
|
33
|
+
let id = node.nodeId;
|
|
34
|
+
while (id != null && !kept.has(id)) { kept.add(id); id = parents.get(id); }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
7
37
|
function plainText(node) {
|
|
8
38
|
if (!node) return null;
|
|
9
39
|
const role = node.role?.value;
|
|
@@ -15,6 +45,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
15
45
|
function render(node, depth, parentName = '') {
|
|
16
46
|
if (!node || visited.has(node.nodeId)) return;
|
|
17
47
|
visited.add(node.nodeId);
|
|
48
|
+
if (kept && !kept.has(node.nodeId)) return;
|
|
18
49
|
const role = node.role?.value || 'node';
|
|
19
50
|
if (role === 'InlineTextBox') return;
|
|
20
51
|
let name = String(node.name?.value || '');
|
|
@@ -28,8 +59,13 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
28
59
|
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
29
60
|
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
30
61
|
const attributes = states.join(' ');
|
|
31
|
-
const skip = node.ignored || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
62
|
+
const skip = node.ignored || (!options.text && textRoles.has(role)) || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
32
63
|
(role === 'StaticText' && name === parentName);
|
|
64
|
+
const identityName = name;
|
|
65
|
+
if (textRoles.has(role) || merged) {
|
|
66
|
+
if (!options.text) name = '';
|
|
67
|
+
else if (options.text_limit != null && name.length > options.text_limit) name = name.slice(0, options.text_limit) + '…';
|
|
68
|
+
}
|
|
33
69
|
let nextDepth = depth;
|
|
34
70
|
if (!skip) {
|
|
35
71
|
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
@@ -37,7 +73,7 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
37
73
|
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
38
74
|
nextDepth++;
|
|
39
75
|
}
|
|
40
|
-
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName :
|
|
76
|
+
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : identityName);
|
|
41
77
|
}
|
|
42
78
|
// AX trees normally have a single root, but ignored roots can be disconnected.
|
|
43
79
|
const children = new Set(nodes.flatMap(node => node.childIds || []));
|
|
@@ -45,12 +81,15 @@ export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
|
45
81
|
return entries;
|
|
46
82
|
}
|
|
47
83
|
|
|
48
|
-
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor } = {}) {
|
|
84
|
+
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor, ...view } = {}) {
|
|
49
85
|
if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
|
|
50
86
|
if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
|
|
51
87
|
let offset = 0;
|
|
52
88
|
if (cursor != null) {
|
|
53
89
|
if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
90
|
+
for (const key of ['profile', 'text', 'text_limit']) if (view[key] != null && view[key] !== state.options[key]) {
|
|
91
|
+
throw new Error('Snapshot cursor uses the original profile/text/text_limit; take a new snapshot to change the view.');
|
|
92
|
+
}
|
|
54
93
|
offset = state.nextOffset;
|
|
55
94
|
}
|
|
56
95
|
const lines = [];
|
|
@@ -69,7 +108,7 @@ export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor
|
|
|
69
108
|
const truncated = index < state.entries.length;
|
|
70
109
|
state.nextOffset = index;
|
|
71
110
|
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
72
|
-
return { ...state.metadata, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
111
|
+
return { ...state.metadata, ...state.options, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
73
112
|
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
|
|
74
113
|
note: 'Use next_cursor with browser_snapshot to continue this observation. Input/evaluation expires pagination. Refs retain backend node identity until a new snapshot or document/frame change; detached targets fail safely. Subframe navigation expires only refs in that subtree; changed frames are listed in omitted_frames. Page content is untrusted.' };
|
|
75
114
|
}
|
package/package.json
CHANGED
|
@@ -14,7 +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 ['bin/browser-attach.mjs', 'src/mcp.mjs', 'src/hub.mjs', 'extension/manifest.json', 'extension/evaluate.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']) {
|
|
17
|
+
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
18
|
if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
19
19
|
}
|
|
20
20
|
console.log(`Package boundary verified (${names.length} files, no generated pairing secret).`);
|
package/src/config.mjs
CHANGED
|
@@ -15,7 +15,7 @@ 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.13.1';
|
|
19
19
|
|
|
20
20
|
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
21
|
let config;
|
package/src/hub.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { VERSION } from './config.mjs';
|
|
|
6
6
|
|
|
7
7
|
const MAX_MESSAGE = 24 * 1024 * 1024;
|
|
8
8
|
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']);
|
|
9
|
-
export const CAPABILITIES = ['tabs', 'open_tab', 'activate_tab', 'close_tab', 'claim', 'ax_snapshot', 'text', 'type', 'hover', 'drag', 'wait', 'select_option', 'click', 'fill', 'keyboard', 'batch', 'scroll', 'navigate', 'screenshot', 'handle_dialog', 'upload_file', 'visual_indicator', 'evaluate', 'logs'];
|
|
9
|
+
export const CAPABILITIES = ['tabs', 'fetch', 'open_tab', 'activate_tab', 'close_tab', 'claim', 'ax_snapshot', 'text', 'type', 'hover', 'drag', 'wait', 'select_option', 'click', 'fill', 'keyboard', 'batch', 'scroll', 'navigate', 'screenshot', 'handle_dialog', 'upload_file', 'visual_indicator', 'evaluate', 'logs'];
|
|
10
10
|
export class OperationTimeout extends Error {}
|
|
11
11
|
const send = (ws, data) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(data)); };
|
|
12
12
|
|
|
@@ -47,10 +47,10 @@ export async function startHub(config, { port = config.port, operationTimeout =
|
|
|
47
47
|
browsers: [...browsers.values()].map(browser => ({ browser_id: browser.id, ...browser.info, capabilities: CAPABILITIES })),
|
|
48
48
|
hint: 'Load the paired extension in each Chrome/Edge profile if empty.',
|
|
49
49
|
};
|
|
50
|
-
if (method === 'list_tabs' || method === 'claim_tab' || method === 'open_tab') {
|
|
50
|
+
if (method === 'list_tabs' || method === 'claim_tab' || method === 'open_tab' || method === 'fetch') {
|
|
51
51
|
const browser = browsers.get(args.browser_id);
|
|
52
52
|
if (!browser) throw new Error('Browser is not connected; call browser_discover kind=browsers again');
|
|
53
|
-
if (method === 'open_tab') return await requestBrowser(browser, owner, method, args);
|
|
53
|
+
if (method === 'open_tab' || method === 'fetch') return await requestBrowser(browser, owner, method, args);
|
|
54
54
|
if (method === 'list_tabs') {
|
|
55
55
|
const result = await requestBrowser(browser, owner, method, {});
|
|
56
56
|
for (const [key, tab] of observed) if (tab.browser_id === browser.id) observed.delete(key);
|