@mehmoodqureshi/chrome-mcp 0.9.1 → 0.9.3
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 +44 -2
- package/dist/shared/auth-wall.d.ts +51 -0
- package/dist/shared/auth-wall.js +140 -0
- package/dist/shared/page-fns.js +208 -182
- package/dist/shared/screenshot.d.ts +14 -0
- package/dist/shared/screenshot.js +19 -4
- package/dist/shared/snapshot.d.ts +24 -2
- package/dist/shared/snapshot.js +117 -33
- package/dist/src/bridge/connection.d.ts +13 -0
- package/dist/src/bridge/connection.js +55 -1
- package/dist/src/bridge/server.d.ts +2 -0
- package/dist/src/bridge/server.js +5 -0
- package/dist/src/bridge/workspace.d.ts +1 -1
- package/dist/src/bridge/workspace.js +2 -2
- package/dist/src/config.js +7 -0
- package/dist/src/executor/cdp-executor.d.ts +3 -2
- package/dist/src/executor/cdp-executor.js +21 -6
- package/dist/src/executor/extension-executor.d.ts +6 -2
- package/dist/src/executor/extension-executor.js +18 -5
- package/dist/src/executor/stub-executor.d.ts +15 -0
- package/dist/src/executor/stub-executor.js +26 -0
- package/dist/src/executor/types.d.ts +28 -3
- package/dist/src/mcp/locate.d.ts +7 -4
- package/dist/src/mcp/locate.js +15 -8
- package/dist/src/mcp/tools.js +137 -40
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +2 -0
- package/docs/BLUEPRINT.md +2 -2
- package/extension-dist/background.js +494 -263
- package/extension-dist/manifest.json +3 -2
- package/package.json +1 -1
|
@@ -14,7 +14,20 @@ export interface PageDims {
|
|
|
14
14
|
/** Full content box (document) width/height. */
|
|
15
15
|
fullW: number;
|
|
16
16
|
fullH: number;
|
|
17
|
+
/** window.devicePixelRatio (default 1). Output pixels = CSS px * dpr * clip.scale. */
|
|
18
|
+
dpr?: number;
|
|
19
|
+
/** Current scroll offset of the top document (default 0,0); a viewport clip starts here. */
|
|
20
|
+
scrollX?: number;
|
|
21
|
+
scrollY?: number;
|
|
17
22
|
}
|
|
23
|
+
export type ScreenshotFormat = 'png' | 'jpeg';
|
|
24
|
+
/** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
|
|
25
|
+
* typical page and still perfectly legible to a vision model. */
|
|
26
|
+
export declare const DEFAULT_SCREENSHOT_FORMAT: ScreenshotFormat;
|
|
27
|
+
export declare const DEFAULT_JPEG_QUALITY = 70;
|
|
28
|
+
/** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
|
|
29
|
+
* viewport is NOT captured at 2x); 2 = device px on a 2x display. */
|
|
30
|
+
export declare const DEFAULT_SCREENSHOT_SCALE = 1;
|
|
18
31
|
/** An element's box in DOCUMENT coordinates (viewport rect + scroll offset), CSS px. */
|
|
19
32
|
export interface ElementRect {
|
|
20
33
|
x: number;
|
|
@@ -55,4 +68,5 @@ export declare const MAX_CAPTURE_PX = 16384;
|
|
|
55
68
|
export declare function planScreenshot(dims: PageDims, opts?: {
|
|
56
69
|
fullPage?: boolean;
|
|
57
70
|
element?: ElementRect | null;
|
|
71
|
+
scale?: number;
|
|
58
72
|
}): ScreenshotPlan;
|
|
@@ -8,8 +8,15 @@
|
|
|
8
8
|
* `ScreenshotResult` reports. No chrome.* calls — just arithmetic.
|
|
9
9
|
*/
|
|
10
10
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
-
exports.MAX_CAPTURE_PX = void 0;
|
|
11
|
+
exports.MAX_CAPTURE_PX = exports.DEFAULT_SCREENSHOT_SCALE = exports.DEFAULT_JPEG_QUALITY = exports.DEFAULT_SCREENSHOT_FORMAT = void 0;
|
|
12
12
|
exports.planScreenshot = planScreenshot;
|
|
13
|
+
/** Default encoding: JPEG at this quality is ~5-10x smaller than PNG on a
|
|
14
|
+
* typical page and still perfectly legible to a vision model. */
|
|
15
|
+
exports.DEFAULT_SCREENSHOT_FORMAT = 'jpeg';
|
|
16
|
+
exports.DEFAULT_JPEG_QUALITY = 70;
|
|
17
|
+
/** Output size multiplier relative to CSS pixels. 1 = CSS px (so a Retina
|
|
18
|
+
* viewport is NOT captured at 2x); 2 = device px on a 2x display. */
|
|
19
|
+
exports.DEFAULT_SCREENSHOT_SCALE = 1;
|
|
13
20
|
/**
|
|
14
21
|
* Practical single-capture height ceiling. Skia/CDP cannot encode arbitrarily
|
|
15
22
|
* tall images; beyond this we clamp the clip and flag `truncated`.
|
|
@@ -20,11 +27,15 @@ exports.MAX_CAPTURE_PX = 16384;
|
|
|
20
27
|
* viewport capture. Heights are clamped to MAX_CAPTURE_PX with `truncated` set.
|
|
21
28
|
*/
|
|
22
29
|
function planScreenshot(dims, opts = {}) {
|
|
30
|
+
// CDP's clip.scale multiplies on top of the device scale factor, so dividing
|
|
31
|
+
// by the DPR yields exactly `opts.scale` output pixels per CSS pixel.
|
|
32
|
+
const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
|
|
33
|
+
const scale = (opts.scale && opts.scale > 0 ? opts.scale : exports.DEFAULT_SCREENSHOT_SCALE) / dpr;
|
|
23
34
|
if (opts.element) {
|
|
24
35
|
const realH = Math.max(1, Math.round(opts.element.h));
|
|
25
36
|
const clipH = Math.min(opts.element.h, exports.MAX_CAPTURE_PX);
|
|
26
37
|
return {
|
|
27
|
-
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale
|
|
38
|
+
clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
|
|
28
39
|
captureBeyondViewport: true,
|
|
29
40
|
width: Math.max(1, Math.round(opts.element.w)),
|
|
30
41
|
height: Math.min(realH, exports.MAX_CAPTURE_PX),
|
|
@@ -35,7 +46,7 @@ function planScreenshot(dims, opts = {}) {
|
|
|
35
46
|
if (opts.fullPage) {
|
|
36
47
|
const clipH = Math.min(dims.fullH, exports.MAX_CAPTURE_PX);
|
|
37
48
|
return {
|
|
38
|
-
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale
|
|
49
|
+
clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
|
|
39
50
|
captureBeyondViewport: true,
|
|
40
51
|
width: dims.fullW,
|
|
41
52
|
height: clipH,
|
|
@@ -43,8 +54,12 @@ function planScreenshot(dims, opts = {}) {
|
|
|
43
54
|
fullHeight: dims.fullH,
|
|
44
55
|
};
|
|
45
56
|
}
|
|
46
|
-
// Plain viewport:
|
|
57
|
+
// Plain viewport: capture what's visible. A clip is only needed to apply a
|
|
58
|
+
// scale; at scale 1 on a 1x display the bare capture is identical and cheaper.
|
|
47
59
|
return {
|
|
60
|
+
...(scale !== 1
|
|
61
|
+
? { clip: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } }
|
|
62
|
+
: {}),
|
|
48
63
|
captureBeyondViewport: false,
|
|
49
64
|
width: dims.w,
|
|
50
65
|
height: dims.h,
|
|
@@ -23,6 +23,28 @@ export interface RawSnapshot {
|
|
|
23
23
|
title: string;
|
|
24
24
|
nodes: RawSnapshotNode[];
|
|
25
25
|
truncated: boolean;
|
|
26
|
+
/** Locator mode, nothing matched: up to 8 "role \"name\"" strings of the
|
|
27
|
+
* same role, so the caller can say what WAS there without a second read. */
|
|
28
|
+
nearby?: string[];
|
|
26
29
|
}
|
|
27
|
-
/**
|
|
28
|
-
export
|
|
30
|
+
/** A role/name query resolved IN the page (see `collectSnapshot`). */
|
|
31
|
+
export interface SnapshotLocator {
|
|
32
|
+
role?: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with
|
|
37
|
+
* fresh refs.
|
|
38
|
+
*
|
|
39
|
+
* With a `locator`, the walk is the same but the SCORING happens here: only the
|
|
40
|
+
* strongest-tier matches come back (and only they get a ref), so resolving
|
|
41
|
+
* "the Sign in button" ships a handful of nodes instead of the whole tree and
|
|
42
|
+
* touches one or two DOM attributes instead of hundreds. The tiers mirror
|
|
43
|
+
* `src/mcp/locate.ts` exactly (exact, case-insensitive, prefix, contains), and
|
|
44
|
+
* the server re-scores what it receives, so both ends always agree.
|
|
45
|
+
*
|
|
46
|
+
* Reads (visibility, names, values) all happen BEFORE the ref attributes are
|
|
47
|
+
* written: interleaving them made every `innerText` after a `setAttribute`
|
|
48
|
+
* re-run style, which on a big page is most of the snapshot's cost.
|
|
49
|
+
*/
|
|
50
|
+
export declare function collectSnapshot(interactiveOnly?: boolean, max?: number, locator?: SnapshotLocator | null): RawSnapshot;
|
package/dist/shared/snapshot.js
CHANGED
|
@@ -10,30 +10,47 @@
|
|
|
10
10
|
*/
|
|
11
11
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
12
12
|
exports.collectSnapshot = collectSnapshot;
|
|
13
|
-
/**
|
|
14
|
-
|
|
13
|
+
/**
|
|
14
|
+
* Runs IN THE PAGE. Returns interactive (and optionally landmark) elements with
|
|
15
|
+
* fresh refs.
|
|
16
|
+
*
|
|
17
|
+
* With a `locator`, the walk is the same but the SCORING happens here: only the
|
|
18
|
+
* strongest-tier matches come back (and only they get a ref), so resolving
|
|
19
|
+
* "the Sign in button" ships a handful of nodes instead of the whole tree and
|
|
20
|
+
* touches one or two DOM attributes instead of hundreds. The tiers mirror
|
|
21
|
+
* `src/mcp/locate.ts` exactly (exact, case-insensitive, prefix, contains), and
|
|
22
|
+
* the server re-scores what it receives, so both ends always agree.
|
|
23
|
+
*
|
|
24
|
+
* Reads (visibility, names, values) all happen BEFORE the ref attributes are
|
|
25
|
+
* written: interleaving them made every `innerText` after a `setAttribute`
|
|
26
|
+
* re-run style, which on a big page is most of the snapshot's cost.
|
|
27
|
+
*/
|
|
28
|
+
function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
15
29
|
const INTERACTIVE = 'a[href],button,input,select,textarea,[role=button],[role=link],[role=tab],[role=checkbox],[role=radio],[role=menuitem],[role=option],[role=switch],[contenteditable=true],[onclick]';
|
|
16
30
|
const LANDMARK = 'h1,h2,h3,[role=heading],nav,main,header,footer,[role=navigation]';
|
|
17
31
|
const sel = interactiveOnly ? INTERACTIVE : `${INTERACTIVE},${LANDMARK}`;
|
|
18
32
|
const visible = (el) => {
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
if (
|
|
24
|
-
return false;
|
|
25
|
-
// Prefer the native check (accounts for ancestors, content-visibility, etc.).
|
|
26
|
-
const cv = el.checkVisibility;
|
|
27
|
-
if (typeof cv === 'function') {
|
|
33
|
+
const h = el;
|
|
34
|
+
// The native check covers display:none / visibility:hidden on the element
|
|
35
|
+
// AND its ancestors plus content-visibility, in one call and without a
|
|
36
|
+
// getComputedStyle. Only a zero-size box needs the (cheap) rect on top.
|
|
37
|
+
if (typeof h.checkVisibility === 'function') {
|
|
28
38
|
try {
|
|
29
|
-
|
|
39
|
+
if (!h.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true }))
|
|
40
|
+
return false;
|
|
41
|
+
const r = h.getBoundingClientRect();
|
|
42
|
+
return r.width !== 0 || r.height !== 0;
|
|
30
43
|
}
|
|
31
44
|
catch {
|
|
32
|
-
/* fall through to manual
|
|
45
|
+
/* fall through to the manual walk */
|
|
33
46
|
}
|
|
34
47
|
}
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
const r = h.getBoundingClientRect();
|
|
49
|
+
if (r.width === 0 && r.height === 0)
|
|
50
|
+
return false;
|
|
51
|
+
const s = window.getComputedStyle(h);
|
|
52
|
+
if (s.visibility === 'hidden' || s.display === 'none')
|
|
53
|
+
return false;
|
|
37
54
|
let p = el.parentElement;
|
|
38
55
|
while (p) {
|
|
39
56
|
const ps = window.getComputedStyle(p);
|
|
@@ -41,7 +58,7 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
|
41
58
|
return false;
|
|
42
59
|
p = p.parentElement;
|
|
43
60
|
}
|
|
44
|
-
if (
|
|
61
|
+
if (h.offsetParent === null && s.position !== 'fixed')
|
|
45
62
|
return false;
|
|
46
63
|
return true;
|
|
47
64
|
};
|
|
@@ -101,45 +118,40 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
|
101
118
|
return;
|
|
102
119
|
let matched;
|
|
103
120
|
try {
|
|
104
|
-
matched =
|
|
121
|
+
matched = root.querySelectorAll(sel);
|
|
105
122
|
}
|
|
106
123
|
catch {
|
|
107
124
|
matched = [];
|
|
108
125
|
}
|
|
109
|
-
for (
|
|
126
|
+
for (let i = 0; i < matched.length; i++) {
|
|
110
127
|
if (candidates.length >= max)
|
|
111
128
|
break;
|
|
129
|
+
const el = matched[i];
|
|
112
130
|
if (seen.has(el))
|
|
113
131
|
continue;
|
|
114
132
|
seen.add(el);
|
|
115
133
|
candidates.push(el);
|
|
116
134
|
}
|
|
117
|
-
// Descend into any open shadow roots hosted under this root.
|
|
135
|
+
// Descend into any open shadow roots hosted under this root. A plain index
|
|
136
|
+
// loop over the live NodeList: no Array.from copy of every element on the page.
|
|
118
137
|
let hosts;
|
|
119
138
|
try {
|
|
120
|
-
hosts =
|
|
139
|
+
hosts = root.querySelectorAll('*');
|
|
121
140
|
}
|
|
122
141
|
catch {
|
|
123
142
|
hosts = [];
|
|
124
143
|
}
|
|
125
|
-
for (
|
|
144
|
+
for (let i = 0; i < hosts.length; i++) {
|
|
126
145
|
if (candidates.length >= max)
|
|
127
146
|
break;
|
|
128
|
-
const sr =
|
|
147
|
+
const sr = hosts[i].shadowRoot;
|
|
129
148
|
if (sr)
|
|
130
149
|
collect(sr);
|
|
131
150
|
}
|
|
132
151
|
};
|
|
133
152
|
collect(document);
|
|
134
|
-
const
|
|
135
|
-
|
|
136
|
-
let n = 0;
|
|
137
|
-
for (const el of els) {
|
|
138
|
-
if (nodes.length >= max)
|
|
139
|
-
break;
|
|
140
|
-
const ref = `e${++n}`;
|
|
141
|
-
el.setAttribute('data-mcp-ref', ref);
|
|
142
|
-
const node = { ref, role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
|
|
153
|
+
const read = (el) => {
|
|
154
|
+
const node = { role: roleOf(el), name: accName(el), tag: el.tagName.toLowerCase() };
|
|
143
155
|
// A password field's characters never leave the page. The node still
|
|
144
156
|
// appears (so the model can target it) and is flagged `secret`, but the
|
|
145
157
|
// value is not something a caller has a use for and every caller would
|
|
@@ -154,8 +166,80 @@ function collectSnapshot(interactiveOnly = true, max = 200) {
|
|
|
154
166
|
node.disabled = true;
|
|
155
167
|
if (el.checked)
|
|
156
168
|
node.checked = true;
|
|
157
|
-
|
|
169
|
+
return { el, node };
|
|
170
|
+
};
|
|
171
|
+
const els = candidates.filter(visible);
|
|
172
|
+
let picked;
|
|
173
|
+
let truncated = false;
|
|
174
|
+
let nearby;
|
|
175
|
+
let refPrefix = 'e';
|
|
176
|
+
if (locator && (locator.role !== undefined || locator.name !== undefined)) {
|
|
177
|
+
// Locator mode: score every visible candidate, keep the strongest tier.
|
|
178
|
+
const norm = (x) => x.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
179
|
+
const wantRole = locator.role !== undefined ? norm(locator.role) : null;
|
|
180
|
+
const wantName = locator.name;
|
|
181
|
+
const score = (role, name) => {
|
|
182
|
+
if (wantRole !== null && norm(role) !== wantRole)
|
|
183
|
+
return 0;
|
|
184
|
+
if (!wantName)
|
|
185
|
+
return 1;
|
|
186
|
+
const have = norm(name);
|
|
187
|
+
const need = norm(wantName);
|
|
188
|
+
if (!have)
|
|
189
|
+
return 0;
|
|
190
|
+
if (name.trim() === wantName.trim())
|
|
191
|
+
return 4;
|
|
192
|
+
if (have === need)
|
|
193
|
+
return 3;
|
|
194
|
+
if (have.startsWith(need))
|
|
195
|
+
return 2;
|
|
196
|
+
if (have.includes(need))
|
|
197
|
+
return 1;
|
|
198
|
+
return 0;
|
|
199
|
+
};
|
|
200
|
+
let best = 0;
|
|
201
|
+
picked = [];
|
|
202
|
+
const sameRole = [];
|
|
203
|
+
for (const el of els) {
|
|
204
|
+
const s = read(el);
|
|
205
|
+
const sc = score(s.node.role, s.node.name);
|
|
206
|
+
if (sc === 0) {
|
|
207
|
+
if ((wantRole === null || norm(s.node.role) === wantRole) && sameRole.length < 8) {
|
|
208
|
+
sameRole.push(`${s.node.role} "${s.node.name}"`);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (sc > best) {
|
|
213
|
+
best = sc;
|
|
214
|
+
picked = [s];
|
|
215
|
+
}
|
|
216
|
+
else if (sc === best) {
|
|
217
|
+
picked.push(s);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (picked.length === 0)
|
|
221
|
+
nearby = sameRole;
|
|
222
|
+
// Refs from a locate must not collide with a prior snapshot's e1..eN, which
|
|
223
|
+
// may still be stamped on OTHER elements: use a distinct, per-call prefix.
|
|
224
|
+
refPrefix = `l${Date.now().toString(36).slice(-4)}-`;
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
picked = [];
|
|
228
|
+
for (const el of els) {
|
|
229
|
+
if (picked.length >= max)
|
|
230
|
+
break;
|
|
231
|
+
picked.push(read(el));
|
|
232
|
+
}
|
|
233
|
+
truncated = els.length > picked.length;
|
|
234
|
+
}
|
|
235
|
+
// -- phase 2: writes (one attribute per returned node) ----------------------
|
|
236
|
+
const nodes = [];
|
|
237
|
+
let n = 0;
|
|
238
|
+
for (const { el, node } of picked) {
|
|
239
|
+
const ref = `${refPrefix}${++n}`;
|
|
240
|
+
el.setAttribute('data-mcp-ref', ref);
|
|
241
|
+
nodes.push({ ref, ...node });
|
|
158
242
|
}
|
|
159
|
-
return { url: location.href, title: document.title, nodes, truncated
|
|
243
|
+
return { url: location.href, title: document.title, nodes, truncated, ...(nearby ? { nearby } : {}) };
|
|
160
244
|
}
|
|
161
245
|
//# sourceMappingURL=snapshot.js.map
|
|
@@ -36,6 +36,10 @@ export declare class ExtensionConnection {
|
|
|
36
36
|
private readonly reportsTabUrl;
|
|
37
37
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
38
38
|
private activeUrl;
|
|
39
|
+
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
40
|
+
* `tabs_list` result fills this for EVERY tab at once, which is what lets a
|
|
41
|
+
* parallel batch over N tabs gate on one round-trip instead of N. */
|
|
42
|
+
private readonly tabUrls;
|
|
39
43
|
private readonly onEvent?;
|
|
40
44
|
private readonly onClose?;
|
|
41
45
|
private readonly onLog?;
|
|
@@ -49,6 +53,15 @@ export declare class ExtensionConnection {
|
|
|
49
53
|
isOpen(): boolean;
|
|
50
54
|
private handleMessage;
|
|
51
55
|
private settle;
|
|
56
|
+
/**
|
|
57
|
+
* Per-tab cache. Two feeds: a result for an explicitly-targeted tab carries
|
|
58
|
+
* that tab's landing URL; a `tabs_list` result carries every tab's URL, so one
|
|
59
|
+
* listing primes the gate for every op of a batch that follows it. A closed
|
|
60
|
+
* tab is forgotten; a blank URL (Chrome hiding it) is forgotten too, never kept.
|
|
61
|
+
*/
|
|
62
|
+
private rememberTabUrls;
|
|
63
|
+
/** A specific tab's last reported URL if younger than `maxAgeMs`, else null. */
|
|
64
|
+
lastTabUrl(tabId: string, maxAgeMs: number): string | null;
|
|
52
65
|
/**
|
|
53
66
|
* Cache the URL a result rode home with — but ONLY when it describes the active
|
|
54
67
|
* tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
|
|
@@ -38,6 +38,8 @@ function mapWireErrorCode(code) {
|
|
|
38
38
|
}
|
|
39
39
|
/** Commands that can change WHICH tab is active, invalidating a cached URL. */
|
|
40
40
|
const ACTIVE_TAB_CHANGERS = new Set(['tab_select', 'tab_new', 'tab_close']);
|
|
41
|
+
/** Bound on the per-tab URL cache; entries beyond it are evicted oldest-first. */
|
|
42
|
+
const MAX_TAB_URL_ENTRIES = 256;
|
|
41
43
|
class ExtensionConnection {
|
|
42
44
|
extId;
|
|
43
45
|
sessionId;
|
|
@@ -51,6 +53,10 @@ class ExtensionConnection {
|
|
|
51
53
|
reportsTabUrl;
|
|
52
54
|
/** Last URL the ACTIVE tab reported, with the wall-clock it arrived. */
|
|
53
55
|
activeUrl = null;
|
|
56
|
+
/** Last URL each explicitly-targeted tab reported, keyed by wire tab id. A
|
|
57
|
+
* `tabs_list` result fills this for EVERY tab at once, which is what lets a
|
|
58
|
+
* parallel batch over N tabs gate on one round-trip instead of N. */
|
|
59
|
+
tabUrls = new Map();
|
|
54
60
|
onEvent;
|
|
55
61
|
onClose;
|
|
56
62
|
onLog;
|
|
@@ -98,7 +104,7 @@ class ExtensionConnection {
|
|
|
98
104
|
reject(new types_1.ExecutorError('TIMEOUT', `"${method}" timed out after ${timeoutMs}ms`));
|
|
99
105
|
}, timeoutMs);
|
|
100
106
|
timer.unref?.();
|
|
101
|
-
this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined });
|
|
107
|
+
this.pending.set(id, { resolve, reject, timer, method, activeTab: opts?.tabId === undefined, tabId: opts?.tabId });
|
|
102
108
|
try {
|
|
103
109
|
this.ws.send(JSON.stringify(frame));
|
|
104
110
|
}
|
|
@@ -161,15 +167,63 @@ class ExtensionConnection {
|
|
|
161
167
|
this.pending.delete(id);
|
|
162
168
|
if (frame.type === 'result') {
|
|
163
169
|
this.rememberActiveUrl(p, frame);
|
|
170
|
+
this.rememberTabUrls(p, frame);
|
|
164
171
|
p.resolve(frame.data);
|
|
165
172
|
}
|
|
166
173
|
else {
|
|
167
174
|
// A failed command tells us nothing reliable about where the tab ended up.
|
|
168
175
|
if (p.activeTab)
|
|
169
176
|
this.activeUrl = null;
|
|
177
|
+
if (p.tabId)
|
|
178
|
+
this.tabUrls.delete(p.tabId);
|
|
170
179
|
p.reject(new types_1.ExecutorError(mapWireErrorCode(frame.error.code), frame.error.message));
|
|
171
180
|
}
|
|
172
181
|
}
|
|
182
|
+
/**
|
|
183
|
+
* Per-tab cache. Two feeds: a result for an explicitly-targeted tab carries
|
|
184
|
+
* that tab's landing URL; a `tabs_list` result carries every tab's URL, so one
|
|
185
|
+
* listing primes the gate for every op of a batch that follows it. A closed
|
|
186
|
+
* tab is forgotten; a blank URL (Chrome hiding it) is forgotten too, never kept.
|
|
187
|
+
*/
|
|
188
|
+
rememberTabUrls(p, frame) {
|
|
189
|
+
if (!this.reportsTabUrl)
|
|
190
|
+
return;
|
|
191
|
+
const now = Date.now();
|
|
192
|
+
if (p.method === 'tab_close' && p.tabId) {
|
|
193
|
+
this.tabUrls.delete(p.tabId);
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (p.method === 'tabs_list' && Array.isArray(frame.data)) {
|
|
197
|
+
for (const t of frame.data) {
|
|
198
|
+
if (typeof t.tabId !== 'string')
|
|
199
|
+
continue;
|
|
200
|
+
if (typeof t.url === 'string' && t.url)
|
|
201
|
+
this.tabUrls.set(t.tabId, { url: t.url, at: now });
|
|
202
|
+
else
|
|
203
|
+
this.tabUrls.delete(t.tabId);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
else if (p.tabId) {
|
|
207
|
+
if (frame.tabUrl)
|
|
208
|
+
this.tabUrls.set(p.tabId, { url: frame.tabUrl, at: now });
|
|
209
|
+
else
|
|
210
|
+
this.tabUrls.delete(p.tabId);
|
|
211
|
+
}
|
|
212
|
+
// Map iteration is insertion-ordered; drop the oldest until bounded.
|
|
213
|
+
while (this.tabUrls.size > MAX_TAB_URL_ENTRIES) {
|
|
214
|
+
const oldest = this.tabUrls.keys().next().value;
|
|
215
|
+
if (oldest === undefined)
|
|
216
|
+
break;
|
|
217
|
+
this.tabUrls.delete(oldest);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
/** A specific tab's last reported URL if younger than `maxAgeMs`, else null. */
|
|
221
|
+
lastTabUrl(tabId, maxAgeMs) {
|
|
222
|
+
const hit = this.tabUrls.get(tabId);
|
|
223
|
+
if (!hit)
|
|
224
|
+
return null;
|
|
225
|
+
return Date.now() - hit.at <= maxAgeMs ? hit.url : null;
|
|
226
|
+
}
|
|
173
227
|
/**
|
|
174
228
|
* Cache the URL a result rode home with — but ONLY when it describes the active
|
|
175
229
|
* tab (no explicit tabId) and actually resolved. A blank `tabUrl` means the
|
|
@@ -71,6 +71,8 @@ export declare class BridgeServer {
|
|
|
71
71
|
* to report URLs, a tab shuffle since, or simply nothing recent enough.
|
|
72
72
|
*/
|
|
73
73
|
lastActiveUrl(profile: string | undefined, maxAgeMs: number): string | null;
|
|
74
|
+
/** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
|
|
75
|
+
lastTabUrl(profile: string | undefined, tabId: string, maxAgeMs: number): string | null;
|
|
74
76
|
private noPairMessage;
|
|
75
77
|
status(): {
|
|
76
78
|
extensionConnected: boolean;
|
|
@@ -187,6 +187,11 @@ class BridgeServer {
|
|
|
187
187
|
const conn = this.conns.get(routeKey(profile));
|
|
188
188
|
return conn?.isOpen() ? conn.lastActiveUrl(maxAgeMs) : null;
|
|
189
189
|
}
|
|
190
|
+
/** Same, for an explicitly-targeted tab (wire id) — see ExtensionConnection.lastTabUrl. */
|
|
191
|
+
lastTabUrl(profile, tabId, maxAgeMs) {
|
|
192
|
+
const conn = this.conns.get(routeKey(profile));
|
|
193
|
+
return conn?.isOpen() ? conn.lastTabUrl(tabId, maxAgeMs) : null;
|
|
194
|
+
}
|
|
190
195
|
noPairMessage(profile) {
|
|
191
196
|
return (`No browser is paired for profile "${profile}". In that Chrome's chrome-mcp ` +
|
|
192
197
|
`extension Options, set Port ${this.boundPort}, paste the token, set Profile to ` +
|
|
@@ -44,7 +44,7 @@ export declare function saveResult(tool: string, ext: string, body: string): str
|
|
|
44
44
|
*/
|
|
45
45
|
export declare function saveBinary(tool: string, ext: string, bytes: Buffer): string | null;
|
|
46
46
|
/** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
|
|
47
|
-
export declare function saveScreenshot(dataBase64: string): string | null;
|
|
47
|
+
export declare function saveScreenshot(dataBase64: string, ext?: 'png' | 'jpg'): string | null;
|
|
48
48
|
/**
|
|
49
49
|
* Move a file Chrome saved to the user's Downloads dir into the active task's
|
|
50
50
|
* `downloads/`. The name is re-hardened via {@link sanitizeDownloadName} and the
|
|
@@ -123,12 +123,12 @@ function saveBinary(tool, ext, bytes) {
|
|
|
123
123
|
}
|
|
124
124
|
}
|
|
125
125
|
/** Save a screenshot PNG (base64) into the active task's `screenshots/`. */
|
|
126
|
-
function saveScreenshot(dataBase64) {
|
|
126
|
+
function saveScreenshot(dataBase64, ext = 'png') {
|
|
127
127
|
const w = peekActiveWorkspace();
|
|
128
128
|
if (!w)
|
|
129
129
|
return null;
|
|
130
130
|
try {
|
|
131
|
-
const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}
|
|
131
|
+
const path = (0, node_path_1.join)(w.screenshotsDir, `${stem('screenshot')}.${ext}`);
|
|
132
132
|
(0, node_fs_1.writeFileSync)(path, Buffer.from(dataBase64, 'base64'), { mode: 0o600 });
|
|
133
133
|
return path;
|
|
134
134
|
}
|
package/dist/src/config.js
CHANGED
|
@@ -150,6 +150,9 @@ function parseArgs(argv) {
|
|
|
150
150
|
policyFlags.redact = true;
|
|
151
151
|
(policyFlags.redactPatterns ??= []).push(requireValue(argv[++i], '--redact-pattern'));
|
|
152
152
|
break;
|
|
153
|
+
case '--fail-on-auth-wall':
|
|
154
|
+
policyFlags.failOnAuthWall = true;
|
|
155
|
+
break;
|
|
153
156
|
case '--cdp-fallback':
|
|
154
157
|
cdpFallback = true;
|
|
155
158
|
break;
|
|
@@ -310,6 +313,10 @@ Security (default: deny-all safe mode):
|
|
|
310
313
|
tokens, private keys) out of page reads. Password field
|
|
311
314
|
values are always suppressed, with or without this.
|
|
312
315
|
--redact-pattern <re> Add a redaction regex (repeatable; implies --redact)
|
|
316
|
+
--fail-on-auth-wall Fail any navigate/click/type/press/back/reload/wait_for
|
|
317
|
+
with [AUTH_REQUIRED] when the page it lands on is a
|
|
318
|
+
high-confidence sign-in wall (expired session), so an
|
|
319
|
+
eval harness never scores it as some other failure.
|
|
313
320
|
|
|
314
321
|
Misc:
|
|
315
322
|
--log-level <lvl> silent | info | debug (default info)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* stealth init on the launch path only, and tab resolution. `tabId`s are stamped
|
|
13
13
|
* `cdp:<sessionId>:<n>` so a handle never mis-routes across a backend switch.
|
|
14
14
|
*/
|
|
15
|
-
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
15
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type KeyModifier, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
16
16
|
export interface CdpOptions {
|
|
17
17
|
mode: 'connect' | 'launch';
|
|
18
18
|
cdpEndpoint?: string;
|
|
@@ -124,6 +124,7 @@ export declare class CdpExecutor implements Executor {
|
|
|
124
124
|
tabId?: TabId;
|
|
125
125
|
interactiveOnly?: boolean;
|
|
126
126
|
max?: number;
|
|
127
|
+
locator?: SnapshotLocator;
|
|
127
128
|
}): Promise<SnapshotResult>;
|
|
128
129
|
getCookies(opts?: {
|
|
129
130
|
tabId?: TabId;
|
|
@@ -142,7 +143,7 @@ export declare class CdpExecutor implements Executor {
|
|
|
142
143
|
tabId?: TabId;
|
|
143
144
|
fullPage?: boolean;
|
|
144
145
|
target?: Target;
|
|
145
|
-
}): Promise<ScreenshotResult>;
|
|
146
|
+
} & ScreenshotEncoding): Promise<ScreenshotResult>;
|
|
146
147
|
eval(expression: string, opts?: {
|
|
147
148
|
tabId?: TabId;
|
|
148
149
|
awaitPromise?: boolean;
|
|
@@ -22,6 +22,7 @@ const node_crypto_1 = require("node:crypto");
|
|
|
22
22
|
const playwright_1 = require("playwright");
|
|
23
23
|
const download_1 = require("../../shared/download");
|
|
24
24
|
const snapshot_1 = require("../../shared/snapshot");
|
|
25
|
+
const screenshot_1 = require("../../shared/screenshot");
|
|
25
26
|
const types_1 = require("./types");
|
|
26
27
|
const SINGLETON_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie'];
|
|
27
28
|
const STEALTH = `Object.defineProperty(navigator,'webdriver',{get:()=>undefined});`;
|
|
@@ -436,11 +437,11 @@ class CdpExecutor {
|
|
|
436
437
|
return this.guard(async () => {
|
|
437
438
|
const p = await this.resolveTab(opts?.tabId);
|
|
438
439
|
// Inject collectSnapshot's source and run it in the page (it can't close over module scope).
|
|
439
|
-
const raw = await p.evaluate(([fnSrc, interactiveOnly, max]) => {
|
|
440
|
+
const raw = await p.evaluate(([fnSrc, interactiveOnly, max, locator]) => {
|
|
440
441
|
// eslint-disable-next-line no-eval
|
|
441
442
|
const fn = (0, eval)(`(${fnSrc})`);
|
|
442
|
-
return fn(interactiveOnly, max);
|
|
443
|
-
}, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200]);
|
|
443
|
+
return fn(interactiveOnly, max, locator ?? null);
|
|
444
|
+
}, [snapshot_1.collectSnapshot.toString(), opts?.interactiveOnly ?? true, opts?.max ?? 200, opts?.locator ?? null]);
|
|
444
445
|
return raw;
|
|
445
446
|
});
|
|
446
447
|
}
|
|
@@ -488,11 +489,25 @@ class CdpExecutor {
|
|
|
488
489
|
async screenshot(opts) {
|
|
489
490
|
return this.guard(async () => {
|
|
490
491
|
const p = await this.resolveTab(opts?.tabId);
|
|
492
|
+
const type = opts?.format ?? screenshot_1.DEFAULT_SCREENSHOT_FORMAT;
|
|
493
|
+
// Playwright's `scale: 'css'` = one output pixel per CSS pixel (the same
|
|
494
|
+
// default the extension path uses); 'device' = the display's native DPR.
|
|
495
|
+
const enc = {
|
|
496
|
+
type,
|
|
497
|
+
...(type === 'jpeg' ? { quality: opts?.quality ?? screenshot_1.DEFAULT_JPEG_QUALITY } : {}),
|
|
498
|
+
scale: (opts?.scale ?? screenshot_1.DEFAULT_SCREENSHOT_SCALE) >= 2 ? 'device' : 'css',
|
|
499
|
+
};
|
|
491
500
|
const buf = opts?.target
|
|
492
|
-
? await this.locator(p, opts.target).screenshot()
|
|
493
|
-
: await p.screenshot({ fullPage: opts?.fullPage });
|
|
501
|
+
? await this.locator(p, opts.target).screenshot(enc)
|
|
502
|
+
: await p.screenshot({ fullPage: opts?.fullPage, ...enc });
|
|
494
503
|
const size = p.viewportSize() ?? { width: 0, height: 0 };
|
|
495
|
-
return {
|
|
504
|
+
return {
|
|
505
|
+
dataBase64: buf.toString('base64'),
|
|
506
|
+
mimeType: type === 'jpeg' ? 'image/jpeg' : 'image/png',
|
|
507
|
+
width: size.width,
|
|
508
|
+
height: size.height,
|
|
509
|
+
truncated: false,
|
|
510
|
+
};
|
|
496
511
|
});
|
|
497
512
|
}
|
|
498
513
|
async eval(expression, opts) {
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* method-specific arguments travel in `params`. Results are trusted shapes
|
|
8
8
|
* produced by the extension router (validated there).
|
|
9
9
|
*/
|
|
10
|
-
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotResult, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
10
|
+
import { type ActionOk, type BackendKind, type CookieItem, type DownloadResult, type EvalResult, type Executor, type ExecutorStatus, type FrameInfo, type FrameOpts, type ObserverArgs, type ObserverReadResult, type PdfResult, type KeyModifier, type MouseButton, type NavResult, type ScreenshotEncoding, type ScreenshotResult, type SnapshotLocator, type SnapshotResult, type StorageOp, type StorageResult, type TabId, type TabInfo, type Target, type WaitResult, type WaitUntil } from './types';
|
|
11
11
|
import type { BridgeServer } from '../bridge/server';
|
|
12
12
|
export declare class ExtensionExecutor implements Executor {
|
|
13
13
|
private readonly bridge;
|
|
@@ -24,6 +24,9 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
24
24
|
/** The active tab's URL as reported by the last command on this profile, if it
|
|
25
25
|
* is fresh enough to gate against. See `ACTIVE_URL_TTL_MS`. */
|
|
26
26
|
cachedActiveUrl(): string | null;
|
|
27
|
+
/** A specific tab's URL as last reported (by a result for that tab, or by a
|
|
28
|
+
* `tabs_list`), if fresh enough to gate against. */
|
|
29
|
+
cachedTabUrl(tabId: TabId): string | null;
|
|
27
30
|
tabsList(): Promise<TabInfo[]>;
|
|
28
31
|
tabSelect(tabId: TabId): Promise<TabInfo>;
|
|
29
32
|
tabNew(url?: string, opts?: {
|
|
@@ -94,6 +97,7 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
94
97
|
tabId?: TabId;
|
|
95
98
|
interactiveOnly?: boolean;
|
|
96
99
|
max?: number;
|
|
100
|
+
locator?: SnapshotLocator;
|
|
97
101
|
} & FrameOpts): Promise<SnapshotResult>;
|
|
98
102
|
getCookies(opts?: {
|
|
99
103
|
tabId?: TabId;
|
|
@@ -112,7 +116,7 @@ export declare class ExtensionExecutor implements Executor {
|
|
|
112
116
|
tabId?: TabId;
|
|
113
117
|
fullPage?: boolean;
|
|
114
118
|
target?: Target;
|
|
115
|
-
} & FrameOpts): Promise<ScreenshotResult>;
|
|
119
|
+
} & ScreenshotEncoding & FrameOpts): Promise<ScreenshotResult>;
|
|
116
120
|
eval(expression: string, opts?: {
|
|
117
121
|
tabId?: TabId;
|
|
118
122
|
awaitPromise?: boolean;
|