@evan_zhong/browser-attach 0.0.0-stage → 0.10.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/CONTRIBUTING.md +27 -0
- package/LICENSE +21 -0
- package/README.md +169 -3
- package/bin/browser-attach.mjs +43 -0
- package/docs/ARCHITECTURE.md +129 -0
- package/docs/NOSIS-INTEGRATION.md +53 -0
- package/docs/TEST-REPORT.md +39 -0
- package/docs/USAGE.md +370 -0
- package/extension/actionability.js +66 -0
- package/extension/background.js +98 -0
- package/extension/batch.js +32 -0
- package/extension/click-visuals.js +82 -0
- package/extension/click.js +308 -0
- package/extension/controller.js +812 -0
- package/extension/diagnostics.js +205 -0
- package/extension/drag.js +163 -0
- package/extension/evaluate.js +131 -0
- package/extension/icons/BrowserAttach.png +0 -0
- package/extension/input.js +318 -0
- package/extension/limits.js +6 -0
- package/extension/locator.js +115 -0
- package/extension/manifest.json +34 -0
- package/extension/navigation.js +65 -0
- package/extension/options.html +6 -0
- package/extension/options.js +15 -0
- package/extension/outcomes.js +15 -0
- package/extension/popup.html +5 -0
- package/extension/popup.js +10 -0
- package/extension/scroll.js +29 -0
- package/extension/sessions.js +100 -0
- package/extension/snapshot.js +75 -0
- package/extension/text.js +37 -0
- package/extension/ui.css +12 -0
- package/extension/visuals.js +93 -0
- package/extension/wait.js +81 -0
- package/mcp-config.json +8 -0
- package/package.json +54 -6
- package/scripts/check-package.mjs +20 -0
- package/src/client.mjs +91 -0
- package/src/config.mjs +36 -0
- package/src/hub.mjs +197 -0
- package/src/mcp.mjs +167 -0
- package/src/setup.mjs +59 -0
- package/src/upload.mjs +15 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Flat debugger sessions are scoped to a claimed tab. Frame and object ids
|
|
2
|
+
// must always travel with their owning session, including nested OOPIFs.
|
|
3
|
+
export function createSessions(cdp, invalidate, detached = () => {}, networkOptions = () => ({ maxTotalBufferSize: 0, maxResourceBufferSize: 0, maxPostDataSize: 0 })) {
|
|
4
|
+
const tabs = new Map();
|
|
5
|
+
const autoAttach = { autoAttach: true, waitForDebuggerOnStart: false, flatten: true,
|
|
6
|
+
filter: [{ type: 'iframe', exclude: false }, { exclude: true }] };
|
|
7
|
+
async function enable(target, state) {
|
|
8
|
+
const send = (method, params) => {
|
|
9
|
+
if (tabs.get(target.tabId) !== state || (target.sessionId && !state.sessions.has(target.sessionId))) throw new Error('Frame session detached during setup');
|
|
10
|
+
return cdp(target, method, params);
|
|
11
|
+
};
|
|
12
|
+
for (const method of ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable', 'DOM.enable', 'Accessibility.enable']) {
|
|
13
|
+
await send(method, method === 'Network.enable' ? networkOptions(target.tabId) : {});
|
|
14
|
+
}
|
|
15
|
+
await send('Page.setLifecycleEventsEnabled', { enabled: true });
|
|
16
|
+
await send('Page.setInterceptFileChooserDialog', { enabled: true });
|
|
17
|
+
await send('Target.setAutoAttach', autoAttach);
|
|
18
|
+
}
|
|
19
|
+
async function start(tabId) {
|
|
20
|
+
const state = { sessions: new Map(), frames: new Map(), parents: new Map() };
|
|
21
|
+
tabs.set(tabId, state);
|
|
22
|
+
await enable({ tabId }, state);
|
|
23
|
+
await settle(tabId);
|
|
24
|
+
}
|
|
25
|
+
async function settle(tabId) {
|
|
26
|
+
const state = tabs.get(tabId);
|
|
27
|
+
if (!state) throw new Error('Tab sessions are unavailable');
|
|
28
|
+
let count;
|
|
29
|
+
do {
|
|
30
|
+
count = state.sessions.size;
|
|
31
|
+
await Promise.all([...state.sessions.values()].map(item => item.ready));
|
|
32
|
+
} while (count !== state.sessions.size);
|
|
33
|
+
}
|
|
34
|
+
function event(source, method, params) {
|
|
35
|
+
const state = tabs.get(source.tabId);
|
|
36
|
+
if (!state) return;
|
|
37
|
+
if (method === 'Target.attachedToTarget' && params.targetInfo.type === 'iframe') {
|
|
38
|
+
const target = { tabId: source.tabId, sessionId: params.sessionId };
|
|
39
|
+
const entry = { target, parent: source.sessionId, frameId: params.targetInfo.targetId, url: params.targetInfo.url };
|
|
40
|
+
state.sessions.set(params.sessionId, entry);
|
|
41
|
+
state.frames.set(entry.frameId, params.sessionId);
|
|
42
|
+
entry.ready = enable(target, state).catch(error => { entry.error = error; });
|
|
43
|
+
} else if (method === 'Target.detachedFromTarget') {
|
|
44
|
+
const removed = new Set([params.sessionId]);
|
|
45
|
+
let size;
|
|
46
|
+
do {
|
|
47
|
+
size = removed.size;
|
|
48
|
+
for (const [id, entry] of state.sessions) if (removed.has(entry.parent)) removed.add(id);
|
|
49
|
+
} while (size !== removed.size);
|
|
50
|
+
for (const id of removed) {
|
|
51
|
+
state.sessions.delete(id);
|
|
52
|
+
detached(source.tabId, id);
|
|
53
|
+
for (const [frame, session] of state.frames) if (session === id) { state.frames.delete(frame); invalidate(source.tabId, frame); }
|
|
54
|
+
}
|
|
55
|
+
} else if (method === 'Page.frameAttached') {
|
|
56
|
+
state.parents.set(params.frameId, params.parentFrameId);
|
|
57
|
+
} else if (method === 'Page.frameNavigated') {
|
|
58
|
+
if (params.frame.parentId) state.parents.set(params.frame.id, params.frame.parentId);
|
|
59
|
+
state.frames.set(params.frame.id, source.sessionId);
|
|
60
|
+
} else if (method === 'Runtime.executionContextCreated' && params.context.auxData?.frameId) {
|
|
61
|
+
state.frames.set(params.context.auxData.frameId, source.sessionId);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function target(tabId, frameId) {
|
|
65
|
+
const state = tabs.get(tabId), sessionId = state?.frames.get(frameId);
|
|
66
|
+
const entry = sessionId && state.sessions.get(sessionId);
|
|
67
|
+
if (entry?.error) throw new Error(`Frame session setup failed: ${entry.error.message}`);
|
|
68
|
+
return { tabId, ...(sessionId ? { sessionId } : {}) };
|
|
69
|
+
}
|
|
70
|
+
async function frames(tabId, send = cdp) {
|
|
71
|
+
await settle(tabId);
|
|
72
|
+
const state = tabs.get(tabId), result = new Map();
|
|
73
|
+
const collect = (tree, sessionId) => {
|
|
74
|
+
if (tree.frame.parentId) state.parents.set(tree.frame.id, tree.frame.parentId);
|
|
75
|
+
const existing = result.get(tree.frame.id);
|
|
76
|
+
result.set(tree.frame.id, { ...existing, ...tree.frame, parentId: tree.frame.parentId || existing?.parentId });
|
|
77
|
+
// A root tree can mention an OOPIF without owning its execution context.
|
|
78
|
+
if (sessionId || !state.frames.has(tree.frame.id)) state.frames.set(tree.frame.id, sessionId);
|
|
79
|
+
(tree.childFrames || []).forEach(child => collect(child, sessionId));
|
|
80
|
+
};
|
|
81
|
+
collect((await send(tabId, 'Page.getFrameTree')).frameTree);
|
|
82
|
+
for (const entry of state.sessions.values()) {
|
|
83
|
+
if (!result.has(entry.frameId)) result.set(entry.frameId, { id: entry.frameId, url: entry.url });
|
|
84
|
+
if (entry.error) continue;
|
|
85
|
+
try { collect((await send(entry.target, 'Page.getFrameTree')).frameTree, entry.target.sessionId); }
|
|
86
|
+
catch (error) { entry.error = error; }
|
|
87
|
+
}
|
|
88
|
+
return [...result.values()];
|
|
89
|
+
}
|
|
90
|
+
return { start, settle, event, target, frames,
|
|
91
|
+
rememberParent: (tabId, child, parent) => tabs.get(tabId)?.parents.set(child, parent),
|
|
92
|
+
parent(tabId, sessionId) {
|
|
93
|
+
const entry = tabs.get(tabId)?.sessions.get(sessionId);
|
|
94
|
+
if (!entry) throw new Error('Frame session is detached; observe again.');
|
|
95
|
+
return { frameId: entry.frameId, ownerFrameId: tabs.get(tabId).parents.get(entry.frameId), target: { tabId, ...(entry.parent ? { sessionId: entry.parent } : {}) } };
|
|
96
|
+
},
|
|
97
|
+
has: source => !source.sessionId || !!tabs.get(source.tabId)?.sessions.has(source.sessionId),
|
|
98
|
+
targets: tabId => [{ tabId }, ...[...(tabs.get(tabId)?.sessions.values() || [])].map(entry => entry.target)],
|
|
99
|
+
forget: tabId => tabs.delete(tabId) };
|
|
100
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// Render semantic AX nodes once; page through the same observation without losing refs.
|
|
2
|
+
export function renderAX(nodes, token, refs, frameId, sessionId) {
|
|
3
|
+
const byId = new Map(nodes.map(node => [node.nodeId, node]));
|
|
4
|
+
const visited = new Set();
|
|
5
|
+
const entries = [];
|
|
6
|
+
const textRoles = new Set(['StaticText', 'InlineTextBox', 'ListMarker']);
|
|
7
|
+
function plainText(node) {
|
|
8
|
+
if (!node) return null;
|
|
9
|
+
const role = node.role?.value;
|
|
10
|
+
if (textRoles.has(role)) return String(node.name?.value || '');
|
|
11
|
+
if (!node.ignored && !['none', 'generic'].includes(role)) return null;
|
|
12
|
+
const parts = (node.childIds || []).map(id => plainText(byId.get(id)));
|
|
13
|
+
return parts.includes(null) ? null : parts.join(' ');
|
|
14
|
+
}
|
|
15
|
+
function render(node, depth, parentName = '') {
|
|
16
|
+
if (!node || visited.has(node.nodeId)) return;
|
|
17
|
+
visited.add(node.nodeId);
|
|
18
|
+
const role = node.role?.value || 'node';
|
|
19
|
+
if (role === 'InlineTextBox') return;
|
|
20
|
+
let name = String(node.name?.value || '');
|
|
21
|
+
let merged = false;
|
|
22
|
+
if (['listitem', 'paragraph'].includes(role) && !name) {
|
|
23
|
+
const parts = (node.childIds || []).map(id => plainText(byId.get(id)));
|
|
24
|
+
if (parts.length && !parts.includes(null)) { name = parts.filter(Boolean).join(' '); merged = true; }
|
|
25
|
+
}
|
|
26
|
+
const states = (node.properties || []).filter(prop =>
|
|
27
|
+
['disabled', 'focused', 'expanded', 'checked', 'selected', 'required', 'invalid', 'level'].includes(prop.name)
|
|
28
|
+
).map(prop => `${prop.name}=${JSON.stringify(prop.value?.value)}`);
|
|
29
|
+
if (node.value?.value != null) states.push(`value=${JSON.stringify(node.value.value)}`);
|
|
30
|
+
const attributes = states.join(' ');
|
|
31
|
+
const skip = node.ignored || (['none', 'generic'].includes(role) && !name && !attributes) ||
|
|
32
|
+
(role === 'StaticText' && name === parentName);
|
|
33
|
+
let nextDepth = depth;
|
|
34
|
+
if (!skip) {
|
|
35
|
+
const ref = node.backendDOMNodeId ? `${token}:${refs.size + 1}` : null;
|
|
36
|
+
if (ref) refs.set(ref, { backendNodeId: node.backendDOMNodeId, frameId, sessionId, role: node.role?.value, name: String(node.name?.value || '') });
|
|
37
|
+
entries.push({ node: true, frameId, line: `${' '.repeat(Math.min(depth, 24))}${ref ? `[${ref}] ` : ''}${role} ${JSON.stringify(name)}${attributes ? ' ' + attributes : ''}` });
|
|
38
|
+
nextDepth++;
|
|
39
|
+
}
|
|
40
|
+
if (!merged) for (const child of node.childIds || []) render(byId.get(child), nextDepth, skip ? parentName : name);
|
|
41
|
+
}
|
|
42
|
+
// AX trees normally have a single root, but ignored roots can be disconnected.
|
|
43
|
+
const children = new Set(nodes.flatMap(node => node.childIds || []));
|
|
44
|
+
for (const node of nodes) if (!children.has(node.nodeId)) render(node, 0);
|
|
45
|
+
return entries;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function snapshotPage(state, { max_nodes = 600, max_chars = 24000, cursor } = {}) {
|
|
49
|
+
if (!Number.isInteger(max_nodes) || max_nodes < 1 || max_nodes > 3000) throw new Error('max_nodes must be 1..3000');
|
|
50
|
+
if (!Number.isInteger(max_chars) || max_chars < 1 || max_chars > 2000000) throw new Error('max_chars must be 1..2000000');
|
|
51
|
+
let offset = 0;
|
|
52
|
+
if (cursor != null) {
|
|
53
|
+
if (cursor !== state.nextCursor) throw new Error('Snapshot cursor is stale or unknown. Take a new browser_snapshot.');
|
|
54
|
+
offset = state.nextOffset;
|
|
55
|
+
}
|
|
56
|
+
const lines = [];
|
|
57
|
+
let count = 0, chars = 0, index = offset, reason = null;
|
|
58
|
+
for (; index < state.entries.length; index++) {
|
|
59
|
+
const entry = state.entries[index];
|
|
60
|
+
if (state.invalidFrames.has(entry.frameId)) continue;
|
|
61
|
+
const size = JSON.stringify(entry.line).length + 2;
|
|
62
|
+
if (entry.node && count >= max_nodes) { reason = 'node_limit'; break; }
|
|
63
|
+
if (chars + size > max_chars) {
|
|
64
|
+
if (!lines.length) throw new Error(`A snapshot line requires max_chars >= ${size}. Increase max_chars to continue without losing text.`);
|
|
65
|
+
reason = 'text_budget'; break;
|
|
66
|
+
}
|
|
67
|
+
lines.push(entry.line); chars += size; count += entry.node ? 1 : 0;
|
|
68
|
+
}
|
|
69
|
+
const truncated = index < state.entries.length;
|
|
70
|
+
state.nextOffset = index;
|
|
71
|
+
state.nextCursor = truncated ? `${state.id}@${index}` : null;
|
|
72
|
+
return { ...state.metadata, snapshot_id: state.id, snapshot: lines.join('\n'), node_count: count,
|
|
73
|
+
total_node_count: state.totalNodes, truncated, truncation_reason: reason, next_cursor: state.nextCursor,
|
|
74
|
+
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
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { queryElement } from './locator.js';
|
|
2
|
+
|
|
3
|
+
export async function readText(cdp, value, tabId, args) {
|
|
4
|
+
const max = args.max_chars ?? 20000, offset = args.offset ?? 0, mode = args.mode ?? 'visible';
|
|
5
|
+
if (!Number.isInteger(max) || max < 1 || max > 100000) throw new Error('max_chars must be 1..100000');
|
|
6
|
+
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('offset must be 0..9007199254740991');
|
|
7
|
+
if (!['visible', 'text_content', 'value', 'editor'].includes(mode)) throw new Error('read_text mode must be visible, text_content, value or editor');
|
|
8
|
+
if (args.selector != null && (typeof args.selector !== 'string' || !args.selector.trim() || args.selector.length > 10000)) throw new Error('selector must contain 1..10000 characters');
|
|
9
|
+
if (args.selector_type != null && !['css', 'xpath'].includes(args.selector_type)) throw new Error('selector_type must be css or xpath');
|
|
10
|
+
if (mode === 'value' || mode === 'editor') {
|
|
11
|
+
if (!args.selector) throw new Error(`${mode} requires selector to identify exactly one field/editor`);
|
|
12
|
+
}
|
|
13
|
+
return value(await cdp(tabId, 'Runtime.evaluate', { returnByValue: true, ...(args.context_id != null ? { contextId: args.context_id } : {}),
|
|
14
|
+
expression: `(${extractText.toString()})(${JSON.stringify({ selector: args.selector, selector_type: args.selector_type, mode, offset, max_chars: max })},${queryElement.toString()})` }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function extractText(args, query) {
|
|
18
|
+
const element = args.selector ? query(args.selector, args.selector_type ?? 'css') : document.body;
|
|
19
|
+
let text, source = args.mode, complete = true;
|
|
20
|
+
if (args.mode === 'visible') { text = element?.innerText ?? ''; complete = false; }
|
|
21
|
+
else if (args.mode === 'text_content') { text = element?.textContent ?? ''; complete = false; }
|
|
22
|
+
else if (args.mode === 'value') {
|
|
23
|
+
if (['INPUT', 'TEXTAREA'].includes(element.tagName)) text = element.value;
|
|
24
|
+
else if (element.isContentEditable) { text = element.innerText; complete = false; }
|
|
25
|
+
else throw new Error('value mode requires input, textarea or contenteditable');
|
|
26
|
+
} else {
|
|
27
|
+
const editors = globalThis.monaco?.editor?.getEditors?.() ?? [];
|
|
28
|
+
const matches = editors.filter(editor => { const dom = editor.getDomNode(); return dom && (dom === element || element.contains(dom) || dom.contains(element)); });
|
|
29
|
+
if (matches.length === 1 && matches[0].getModel()) { text = matches[0].getModel().getValue(); source = 'monaco_model'; }
|
|
30
|
+
else if (element.CodeMirror?.getValue) { text = element.CodeMirror.getValue(); source = 'codemirror5_model'; }
|
|
31
|
+
else throw new Error('No supported editor model found. Use a site-specific evaluate adapter; DOM text cannot guarantee virtualized editor completeness.');
|
|
32
|
+
}
|
|
33
|
+
const end = Math.min(text.length, args.offset + args.max_chars);
|
|
34
|
+
return { title: document.title, url: location.href, text: text.slice(args.offset, end), returned_chars: Math.max(0, end - args.offset),
|
|
35
|
+
total_chars: text.length, truncated: end < text.length, next_offset: end < text.length ? end : null,
|
|
36
|
+
source, complete_source: complete, scope: 'main_frame' };
|
|
37
|
+
}
|
package/extension/ui.css
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
:root { color-scheme: light dark; font-family: system-ui, sans-serif; font-size: 14px; }
|
|
2
|
+
body { margin: 0; min-width: 320px; }
|
|
3
|
+
main { padding: 22px; max-width: 540px; }
|
|
4
|
+
h1 { font-size: 20px; margin: 0 0 16px; }
|
|
5
|
+
p { line-height: 1.6; }
|
|
6
|
+
button, input { box-sizing: border-box; display: block; width: 100%; margin: 10px 0; padding: 10px 12px; font: inherit; border-radius: 6px; border: 1px solid #8888; }
|
|
7
|
+
button { cursor: pointer; }
|
|
8
|
+
button:hover { background: #13755b; color: white; }
|
|
9
|
+
.hint { color: #888; font-size: 12px; }
|
|
10
|
+
|
|
11
|
+
.brand { display:flex; align-items:center; gap:10px; margin-bottom:18px; font-size:20px; font-weight:650; }
|
|
12
|
+
.brand img { object-fit:contain; background:#153129; border-radius:8px; padding:4px; }
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
// Native tab-strip indicators only. No page scripts, DOM nodes or click overlays.
|
|
2
|
+
const STORAGE_KEY = 'browserAttachTabIndicators';
|
|
3
|
+
const TITLE = '';
|
|
4
|
+
const COLOR = 'red';
|
|
5
|
+
|
|
6
|
+
export function createVisuals(api) {
|
|
7
|
+
const records = new Map();
|
|
8
|
+
let writes = Promise.resolve();
|
|
9
|
+
let restores = Promise.resolve();
|
|
10
|
+
const persist = () => {
|
|
11
|
+
writes = writes.catch(() => {}).then(() => api.storage.session.set({ [STORAGE_KEY]: [...records.entries()] }));
|
|
12
|
+
return writes;
|
|
13
|
+
};
|
|
14
|
+
async function badge(tabId, controlled) {
|
|
15
|
+
await api.action.setBadgeText({ tabId, text: controlled ? 'RUN' : '' });
|
|
16
|
+
await api.action.setTitle({ tabId, title: controlled ? 'Agent 正在接管此标签页' : 'BrowserAttach' });
|
|
17
|
+
if (controlled) await api.action.setBadgeBackgroundColor({ tabId, color: '#12634f' });
|
|
18
|
+
}
|
|
19
|
+
async function restore(tabId) {
|
|
20
|
+
const record = records.get(tabId);
|
|
21
|
+
if (!record) return;
|
|
22
|
+
let tab;
|
|
23
|
+
try { tab = await api.tabs.get(tabId); } catch { /* Closed tabs have nothing to restore. */ }
|
|
24
|
+
// Restore only our own unchanged group. A user move/rename takes precedence.
|
|
25
|
+
let current;
|
|
26
|
+
if (tab && tab.groupId === record.groupId) {
|
|
27
|
+
try { current = await api.tabGroups.get(record.groupId); } catch { /* Group was removed. */ }
|
|
28
|
+
}
|
|
29
|
+
if (current && (!record.applied || (current.title === TITLE && current.color === COLOR))) {
|
|
30
|
+
const original = record.previousGroup;
|
|
31
|
+
if (!original || tab.windowId !== record.windowId) await api.tabs.ungroup(tabId);
|
|
32
|
+
else {
|
|
33
|
+
let group;
|
|
34
|
+
try { group = await api.tabGroups.get(original.id); } catch { /* The original group may have become empty. */ }
|
|
35
|
+
if (group?.windowId !== tab.windowId) group = null;
|
|
36
|
+
let groupId = group?.id;
|
|
37
|
+
if (groupId == null) {
|
|
38
|
+
const originalId = original.id;
|
|
39
|
+
groupId = await api.tabs.group({ tabIds: [tabId] });
|
|
40
|
+
await api.tabGroups.update(groupId, { title: original.title, color: original.color, collapsed: original.collapsed });
|
|
41
|
+
// Other claimed tabs from the same now-empty group must rejoin it.
|
|
42
|
+
for (const other of records.values()) {
|
|
43
|
+
if (other.previousGroup?.id === originalId && other.windowId === record.windowId) other.previousGroup.id = groupId;
|
|
44
|
+
}
|
|
45
|
+
} else await api.tabs.group({ tabIds: [tabId], groupId });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
records.delete(tabId);
|
|
49
|
+
await persist();
|
|
50
|
+
await badge(tabId, false).catch(() => {});
|
|
51
|
+
}
|
|
52
|
+
// An extension reload loses debugger claims, but browser groups survive. Keep
|
|
53
|
+
// enough session state to clean them up before accepting new claims.
|
|
54
|
+
const ready = (async () => {
|
|
55
|
+
const saved = await api.storage.session.get(STORAGE_KEY);
|
|
56
|
+
for (const [tabId, record] of saved[STORAGE_KEY] || []) records.set(tabId, record);
|
|
57
|
+
for (const tabId of [...records.keys()]) await restore(tabId);
|
|
58
|
+
})();
|
|
59
|
+
ready.catch(() => {});
|
|
60
|
+
|
|
61
|
+
async function install(tabId) {
|
|
62
|
+
await ready;
|
|
63
|
+
await badge(tabId, true);
|
|
64
|
+
const tab = await api.tabs.get(tabId);
|
|
65
|
+
if (tab.pinned) throw new Error('Pinned tabs cannot display a native tab-group marker. Unpin the tab before claiming to show a red group; the extension RUN badge remains available.');
|
|
66
|
+
let previousGroup = null;
|
|
67
|
+
if (tab.groupId >= 0) {
|
|
68
|
+
const group = await api.tabGroups.get(tab.groupId);
|
|
69
|
+
previousGroup = { id: group.id, title: group.title || '', color: group.color, collapsed: group.collapsed };
|
|
70
|
+
}
|
|
71
|
+
const groupId = await api.tabs.group({ tabIds: [tabId] });
|
|
72
|
+
const record = { groupId, windowId: tab.windowId, previousGroup, applied: false };
|
|
73
|
+
records.set(tabId, record);
|
|
74
|
+
try {
|
|
75
|
+
await persist();
|
|
76
|
+
await api.tabGroups.update(groupId, { title: TITLE, color: COLOR, collapsed: false });
|
|
77
|
+
record.applied = true;
|
|
78
|
+
await persist();
|
|
79
|
+
} catch (error) {
|
|
80
|
+
// Roll back an incomplete marker even if setting its title failed.
|
|
81
|
+
try { await restore(tabId); } catch { /* Keep the record for later release/recovery. */ }
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function remove(tabId) {
|
|
86
|
+
await ready;
|
|
87
|
+
const next = restores.catch(() => {}).then(() => restore(tabId));
|
|
88
|
+
restores = next;
|
|
89
|
+
await next;
|
|
90
|
+
await badge(tabId, false).catch(() => {});
|
|
91
|
+
}
|
|
92
|
+
return { install, remove, forget: tabId => { void remove(tabId).catch(() => {}); } };
|
|
93
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { deadline, inspectTarget } from './actionability.js';
|
|
2
|
+
import { queryElement, targetOptions, resolveSelector, resolveFrame } from './locator.js';
|
|
3
|
+
|
|
4
|
+
export function waitOptions(args) {
|
|
5
|
+
if (!['attached', 'visible', 'actionable', 'text', 'url', 'network_idle'].includes(args.condition)) throw new Error('Unknown wait condition');
|
|
6
|
+
const element = ['attached', 'visible', 'actionable', 'text'].includes(args.condition);
|
|
7
|
+
if (element) {
|
|
8
|
+
targetOptions(args);
|
|
9
|
+
if (!args.selector) throw new Error('Element waits require a reusable selector');
|
|
10
|
+
} else if (args.selector != null || args.ref != null || args.frame_id != null || args.frame != null || args.nth != null || args.selector_type != null) throw new Error('This wait condition does not use an element target');
|
|
11
|
+
if (args.condition === 'text' && (Number(typeof args.expected === 'string') + Number(typeof args.previous === 'string') !== 1)) throw new Error('text wait requires exactly one of expected (substring) or previous (full prior text)');
|
|
12
|
+
if (args.condition === 'url' && typeof args.expected !== 'string') throw new Error('url wait requires expected text (substring)');
|
|
13
|
+
if (args.condition !== 'text' && args.previous != null) throw new Error('previous is only valid for text waits');
|
|
14
|
+
if (args.condition === 'network_idle' && (!Number.isInteger(args.idle_ms ?? 500) || (args.idle_ms ?? 500) < 100 || (args.idle_ms ?? 500) > 10000)) throw new Error('idle_ms must be 100..10000');
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function waitFor(cdp, value, sessions, diagnostics, mouse, tabId, args) {
|
|
18
|
+
waitOptions(args);
|
|
19
|
+
const budget = args.budget || deadline(args.timeout_ms ?? 5000, 'wait');
|
|
20
|
+
let current = 'not observed', previous;
|
|
21
|
+
const description = `${args.condition}${args.selector ? ` selector=${JSON.stringify(args.selector)}` : ''}${args.expected != null ? ` expected substring=${JSON.stringify(args.expected)}` : ''}${args.previous != null ? ` text different from=${JSON.stringify(args.previous).slice(0, 500)}` : ''}`;
|
|
22
|
+
try {
|
|
23
|
+
while (true) {
|
|
24
|
+
if (args.condition === 'network_idle') {
|
|
25
|
+
current = diagnostics.networkStatus(tabId);
|
|
26
|
+
if (!current.tracking_overflow && current.inflight === 0 && current.idle_ms >= (args.idle_ms ?? 500)) return { satisfied: true, condition: args.condition, current };
|
|
27
|
+
} else if (args.condition === 'url') {
|
|
28
|
+
current = value(await budget.run(() => cdp(tabId, 'Runtime.evaluate', { expression: 'location.href', returnByValue: true })));
|
|
29
|
+
if (current.includes(args.expected)) return { satisfied: true, condition: args.condition, current };
|
|
30
|
+
} else {
|
|
31
|
+
try {
|
|
32
|
+
const frameId = await resolveFrame(cdp, value, sessions, tabId, args, budget);
|
|
33
|
+
const target = sessions.target(tabId, frameId);
|
|
34
|
+
const { executionContextId } = await budget.run(() => cdp(target, 'Page.createIsolatedWorld', { frameId, worldName: 'browser-attach-locator' }));
|
|
35
|
+
current = value(await budget.run(() => cdp(target, 'Runtime.evaluate', { contextId: executionContextId, returnByValue: true,
|
|
36
|
+
expression: `(${inspectWait.toString()})((${queryElement.toString()}),(${inspectTarget.toString()}),${JSON.stringify(args)})` })));
|
|
37
|
+
const stable = current?.ready && previous?.ready && JSON.stringify(current) === JSON.stringify(previous);
|
|
38
|
+
if (current?.satisfied && (args.condition !== 'actionable' || stable)) {
|
|
39
|
+
if (args.condition === 'actionable') {
|
|
40
|
+
try {
|
|
41
|
+
const node = await resolveSelector(cdp, value, sessions, tabId, args, budget);
|
|
42
|
+
try {
|
|
43
|
+
await mouse.prepare(tabId, node, { budget, scroll: false });
|
|
44
|
+
current = { ...current, state: 'actionable' };
|
|
45
|
+
} finally {
|
|
46
|
+
await budget.run(() => cdp(node.target, 'Runtime.releaseObjectGroup', { objectGroup: 'browser-attach' }));
|
|
47
|
+
}
|
|
48
|
+
} catch (error) {
|
|
49
|
+
if (!/covers|no visible click point|detached before resolution/i.test(error.message)) throw error;
|
|
50
|
+
current = { satisfied: false, reason: error.message };
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (current.satisfied) return { satisfied: true, condition: args.condition, current };
|
|
54
|
+
}
|
|
55
|
+
previous = current;
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (!/context.*(destroyed|not found)|cannot find context|no frame with given id|inspected target navigated/i.test(error.message)) throw error;
|
|
58
|
+
current = 'document/frame changing'; previous = null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
await budget.run(() => new Promise(resolve => setTimeout(resolve, 50)));
|
|
62
|
+
}
|
|
63
|
+
} catch (error) {
|
|
64
|
+
throw Object.assign(new Error(`Wait for ${description} failed: ${error.message}; current=${JSON.stringify(current)}`), { input_dispatched: false });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function inspectWait(query, inspect, args) {
|
|
69
|
+
const element = query(args.selector, args.selector_type ?? 'css', args.nth, true);
|
|
70
|
+
if (!element) return { satisfied: false, state: 'absent' };
|
|
71
|
+
if (args.condition === 'attached') return { satisfied: true, state: 'attached' };
|
|
72
|
+
if (args.condition === 'text') {
|
|
73
|
+
const text = element.textContent || '';
|
|
74
|
+
return { satisfied: args.previous != null ? text !== args.previous : text.includes(args.expected), text: text.slice(0, 500) };
|
|
75
|
+
}
|
|
76
|
+
const bounds = inspect.call(element);
|
|
77
|
+
if (args.condition === 'visible') return { ...bounds,
|
|
78
|
+
// Visibility is independent of disabled/inert state.
|
|
79
|
+
satisfied: element.getBoundingClientRect().width > 0 && element.getBoundingClientRect().height > 0 && element.ownerDocument.defaultView.getComputedStyle(element).visibility === 'visible' };
|
|
80
|
+
return { ...bounds, satisfied: !!bounds.ready };
|
|
81
|
+
}
|
package/mcp-config.json
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,54 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@evan_zhong/browser-attach",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
1
|
+
{
|
|
2
|
+
"name": "@evan_zhong/browser-attach",
|
|
3
|
+
"version": "0.10.1",
|
|
4
|
+
"description": "MCP for existing signed-in Chrome and Edge tabs through a locally paired extension",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"bin": {
|
|
8
|
+
"browser-attach": "bin/browser-attach.mjs"
|
|
9
|
+
},
|
|
10
|
+
"engines": {
|
|
11
|
+
"node": ">=22"
|
|
12
|
+
},
|
|
13
|
+
"repository": {
|
|
14
|
+
"type": "git",
|
|
15
|
+
"url": "git+https://github.com/EvannZhongg/BrowserAttach.git"
|
|
16
|
+
},
|
|
17
|
+
"publishConfig": {
|
|
18
|
+
"access": "public"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"bin/",
|
|
22
|
+
"src/",
|
|
23
|
+
"extension/",
|
|
24
|
+
"scripts/check-package.mjs",
|
|
25
|
+
"README.md",
|
|
26
|
+
"CONTRIBUTING.md",
|
|
27
|
+
"docs/",
|
|
28
|
+
"mcp-config.json"
|
|
29
|
+
],
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "node --test tests/*.test.mjs",
|
|
32
|
+
"test:live": "node scripts/live-smoke.mjs",
|
|
33
|
+
"test:interaction": "node scripts/interaction-smoke.mjs",
|
|
34
|
+
"test:package": "node scripts/package-smoke.mjs",
|
|
35
|
+
"prepack": "node scripts/check-package.mjs"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@modelcontextprotocol/sdk": "1.32.1",
|
|
39
|
+
"ws": "8.22.0",
|
|
40
|
+
"zod": "4.6.7"
|
|
41
|
+
},
|
|
42
|
+
"homepage": "https://github.com/EvannZhongg/BrowserAttach#readme",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/EvannZhongg/BrowserAttach/issues"
|
|
45
|
+
},
|
|
46
|
+
"keywords": [
|
|
47
|
+
"mcp",
|
|
48
|
+
"chrome",
|
|
49
|
+
"edge",
|
|
50
|
+
"browser",
|
|
51
|
+
"automation",
|
|
52
|
+
"browser-attach"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { readFile } from 'node:fs/promises';
|
|
3
|
+
import { ROOT, VERSION } from '../src/config.mjs';
|
|
4
|
+
|
|
5
|
+
for (const path of ['package.json', 'extension/manifest.json']) {
|
|
6
|
+
if (JSON.parse(await readFile(new URL(`../${path}`, import.meta.url), 'utf8')).version !== VERSION) throw new Error(`Version mismatch: ${path}`);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const npm = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
|
10
|
+
const result = JSON.parse(execFileSync(npm, ['pack', '--dry-run', '--ignore-scripts', '--json'], {
|
|
11
|
+
cwd: ROOT, encoding: 'utf8', shell: process.platform === 'win32', stdio: ['ignore', 'pipe', 'pipe'],
|
|
12
|
+
}))[0];
|
|
13
|
+
const names = result.files.map(file => file.path);
|
|
14
|
+
for (const path of names) {
|
|
15
|
+
if (/config\.generated\.js|(^|\/)(\.local|\.venv|test-results|node_modules)\//.test(path)) throw new Error(`Private file included in package: ${path}`);
|
|
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']) {
|
|
18
|
+
if (!names.includes(path)) throw new Error(`Package is missing ${path}`);
|
|
19
|
+
}
|
|
20
|
+
console.log(`Package boundary verified (${names.length} files, no generated pairing secret).`);
|
package/src/client.mjs
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { CLIENT_RESPONSE_TIMEOUT_MS } from '../extension/limits.js';
|
|
2
|
+
import { spawn } from 'node:child_process';
|
|
3
|
+
import { open, mkdir } from 'node:fs/promises';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import WebSocket from 'ws';
|
|
7
|
+
import { loadConfig, VERSION } from './config.mjs';
|
|
8
|
+
|
|
9
|
+
export async function hubIsRunning(config) {
|
|
10
|
+
let response;
|
|
11
|
+
try {
|
|
12
|
+
response = await fetch(`http://127.0.0.1:${config.port}/health`, {
|
|
13
|
+
headers: { Authorization: `Bearer ${config.token}` }, signal: AbortSignal.timeout(1000),
|
|
14
|
+
});
|
|
15
|
+
} catch (error) {
|
|
16
|
+
if (error.cause?.code === 'ECONNREFUSED' || error.name === 'TimeoutError') return false;
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
if (!response.ok) throw new Error('BrowserAttach port is occupied by an unpaired service');
|
|
20
|
+
const health = await response.json();
|
|
21
|
+
if (health.service !== 'browser-attach' || health.version !== VERSION)
|
|
22
|
+
throw new Error('Hub version differs from this package; stop the hub and start again.');
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function ensureHub(configPath) {
|
|
27
|
+
configPath = resolve(configPath);
|
|
28
|
+
const config = await loadConfig(configPath);
|
|
29
|
+
if (await hubIsRunning(config)) return config;
|
|
30
|
+
await mkdir(dirname(configPath), { recursive: true, mode: 0o700 });
|
|
31
|
+
const log = await open(join(dirname(configPath), 'hub.log'), 'a', 0o600);
|
|
32
|
+
const child = spawn(process.execPath, [fileURLToPath(new URL('../bin/browser-attach.mjs', import.meta.url)), 'hub', '--config', configPath], {
|
|
33
|
+
detached: true, windowsHide: true, stdio: ['ignore', log.fd, log.fd],
|
|
34
|
+
env: { ...process.env, BROWSER_ATTACH_HOME: dirname(configPath) },
|
|
35
|
+
});
|
|
36
|
+
let spawnError;
|
|
37
|
+
child.on('error', error => { spawnError = error; });
|
|
38
|
+
child.unref(); await log.close();
|
|
39
|
+
for (let i = 0; i < 40; i++) {
|
|
40
|
+
if (spawnError) throw spawnError;
|
|
41
|
+
await new Promise(resolve => setTimeout(resolve, 150));
|
|
42
|
+
if (await hubIsRunning(config)) return config;
|
|
43
|
+
}
|
|
44
|
+
throw new Error(`Hub did not start. See ${join(dirname(configPath), 'hub.log')}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export class AttachClient {
|
|
48
|
+
constructor(config) { this.config = config; this.pending = new Map(); this.counter = 0; }
|
|
49
|
+
async connect() {
|
|
50
|
+
if (this.socket?.readyState === WebSocket.OPEN) return;
|
|
51
|
+
if (this.connecting) return this.connecting;
|
|
52
|
+
this.connecting = new Promise((resolve, reject) => {
|
|
53
|
+
const ws = new WebSocket(`ws://127.0.0.1:${this.config.port}/client`, {
|
|
54
|
+
headers: { Authorization: `Bearer ${this.config.token}` }, maxPayload: 24 * 1024 * 1024,
|
|
55
|
+
});
|
|
56
|
+
this.socket = ws;
|
|
57
|
+
ws.once('open', resolve); ws.once('error', reject);
|
|
58
|
+
ws.on('message', raw => {
|
|
59
|
+
let data;
|
|
60
|
+
try { data = JSON.parse(raw.toString()); } catch { ws.close(); return; }
|
|
61
|
+
const item = this.pending.get(data.id);
|
|
62
|
+
if (!item) return;
|
|
63
|
+
this.pending.delete(data.id); clearTimeout(item.timer);
|
|
64
|
+
if ('error' in data) item.reject(new Error(String(data.error))); else item.resolve(data.result);
|
|
65
|
+
});
|
|
66
|
+
ws.on('close', () => {
|
|
67
|
+
reject(new Error('Hub connection closed'));
|
|
68
|
+
for (const item of this.pending.values()) { clearTimeout(item.timer); item.reject(new Error('Hub disconnected; outcome may be unknown. Observe again.')); }
|
|
69
|
+
this.pending.clear(); this.connecting = null;
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
return this.connecting;
|
|
73
|
+
}
|
|
74
|
+
async call(method, args = {}) {
|
|
75
|
+
await this.connect();
|
|
76
|
+
return new Promise((resolve, reject) => {
|
|
77
|
+
const id = ++this.counter;
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
this.pending.delete(id); this.socket.close();
|
|
80
|
+
reject(new Error('Hub response timed out; outcome may be unknown. Observe again.'));
|
|
81
|
+
}, CLIENT_RESPONSE_TIMEOUT_MS);
|
|
82
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
83
|
+
this.socket.send(JSON.stringify({ id, method, arguments: args }));
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
async close() {
|
|
87
|
+
const ws = this.socket;
|
|
88
|
+
if (!ws || ws.readyState === WebSocket.CLOSED) return;
|
|
89
|
+
await new Promise(resolve => { ws.once('close', resolve); ws.close(); });
|
|
90
|
+
}
|
|
91
|
+
}
|
package/src/config.mjs
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { homedir } from 'node:os';
|
|
2
|
+
import { join, resolve, dirname, isAbsolute } from 'node:path';
|
|
3
|
+
import { readFile, mkdir, writeFile, rename, chmod } from 'node:fs/promises';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const ROOT = fileURLToPath(new URL('../', import.meta.url));
|
|
7
|
+
const configuredHome = process.env.BROWSER_ATTACH_HOME;
|
|
8
|
+
if (configuredHome && (!isAbsolute(configuredHome) ||
|
|
9
|
+
(process.platform === 'win32' && !/^(?:[a-z]:[\\/]|[\\/]{2}[^\\/]+[\\/][^\\/]+)/i.test(configuredHome)))) {
|
|
10
|
+
throw new Error('BROWSER_ATTACH_HOME must be an absolute directory shared by setup, MCP and Hub.');
|
|
11
|
+
}
|
|
12
|
+
export const DATA_DIR = configuredHome
|
|
13
|
+
? resolve(configuredHome)
|
|
14
|
+
// Packaged Windows hosts can redirect AppData into different private views.
|
|
15
|
+
: process.platform === 'win32' ? join(homedir(), '.browser-attach')
|
|
16
|
+
: join(process.env.XDG_DATA_HOME || join(homedir(), '.local', 'share'), 'browser-attach');
|
|
17
|
+
export const DEFAULT_CONFIG = join(DATA_DIR, 'config.json');
|
|
18
|
+
export const VERSION = '0.10.1';
|
|
19
|
+
|
|
20
|
+
export async function loadConfig(path = DEFAULT_CONFIG) {
|
|
21
|
+
let config;
|
|
22
|
+
try { config = JSON.parse(await readFile(path, 'utf8')); }
|
|
23
|
+
catch (error) { throw new Error(`Cannot read pairing config ${path}${error.code ? ` (${error.code})` : ''}. Run browser-attach setup.`, { cause: error }); }
|
|
24
|
+
if (typeof config.token !== 'string' || config.token.length < 32 ||
|
|
25
|
+
!Number.isInteger(config.port) || config.port < 1024 || config.port > 65535 ||
|
|
26
|
+
!/^[a-p]{32}$/.test(config.extension_id)) throw new Error('Invalid pairing config. Run browser-attach setup.');
|
|
27
|
+
return config;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function writeJson(path, data) {
|
|
31
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
32
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
33
|
+
await writeFile(temporary, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
|
|
34
|
+
await rename(temporary, path);
|
|
35
|
+
if (process.platform !== 'win32') await chmod(path, 0o600);
|
|
36
|
+
}
|