mcp-browser-extension 0.9.29 → 0.9.30
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/dist/shared/page-fns.d.ts +7 -1
- package/dist/shared/page-fns.js +37 -2
- package/dist/shared/policy.js +10 -1
- package/dist/shared/snapshot.js +15 -4
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +14 -0
- package/dist/src/executor/extension-executor.js +14 -4
- package/dist/src/executor/types.d.ts +4 -0
- package/dist/src/mcp/helpers.js +11 -2
- package/dist/src/mcp/tools.js +39 -8
- package/extension-dist/background.js +238 -64
- package/extension-dist/manifest.json +3 -3
- package/package.json +2 -1
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
* One resolver, used by every op, is what closes it.
|
|
17
17
|
*/
|
|
18
18
|
/** Ops the page-side dispatcher understands. */
|
|
19
|
-
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
19
|
+
export type PageOpName = 'probe' | 'text' | 'html' | 'click' | 'check' | 'type' | 'focus' | 'point' | 'hover' | 'select' | 'measure' | 'scroll' | 'storage' | 'waitSelector' | 'waitFor';
|
|
20
20
|
export interface PageOpArgs {
|
|
21
21
|
op: PageOpName;
|
|
22
22
|
selector?: string | null;
|
|
23
23
|
text?: string;
|
|
24
24
|
clear?: boolean;
|
|
25
|
+
/** type: send keydown/keypress/keyup around each character. */
|
|
26
|
+
keyEvents?: boolean;
|
|
27
|
+
/** type: press Enter after the text (submits the field's form like a person would). */
|
|
28
|
+
pressEnter?: boolean;
|
|
29
|
+
/** check: the state a checkbox/radio should end up in. */
|
|
30
|
+
checked?: boolean;
|
|
25
31
|
outer?: boolean;
|
|
26
32
|
values?: string[];
|
|
27
33
|
x?: number | null;
|
package/dist/shared/page-fns.js
CHANGED
|
@@ -208,6 +208,18 @@ function pageOp(a) {
|
|
|
208
208
|
el.click();
|
|
209
209
|
return { found: true };
|
|
210
210
|
}
|
|
211
|
+
case 'check': {
|
|
212
|
+
if (!el)
|
|
213
|
+
return { found: false };
|
|
214
|
+
// Click only when the state differs: a click toggles, so clicking an
|
|
215
|
+
// already-checked box to "check" it would clear it.
|
|
216
|
+
const box = el;
|
|
217
|
+
if (box.checked !== (a.checked !== false)) {
|
|
218
|
+
el.scrollIntoView({ block: 'center' });
|
|
219
|
+
el.click();
|
|
220
|
+
}
|
|
221
|
+
return { found: true, checked: box.checked };
|
|
222
|
+
}
|
|
211
223
|
case 'type': {
|
|
212
224
|
if (!el)
|
|
213
225
|
return { found: false };
|
|
@@ -226,9 +238,32 @@ function pageOp(a) {
|
|
|
226
238
|
if (!node)
|
|
227
239
|
return { found: false };
|
|
228
240
|
node.focus();
|
|
229
|
-
|
|
230
|
-
|
|
241
|
+
if (a.keyEvents) {
|
|
242
|
+
// One character at a time, each inside its own key events, for pages
|
|
243
|
+
// that react to keystrokes rather than to the value.
|
|
244
|
+
if (a.clear)
|
|
245
|
+
setValue(node, '');
|
|
246
|
+
for (const ch of a.text ?? '') {
|
|
247
|
+
const ev = { key: ch, bubbles: true, cancelable: true };
|
|
248
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev));
|
|
249
|
+
if (go && node.dispatchEvent(new KeyboardEvent('keypress', ev)))
|
|
250
|
+
setValue(node, (node.value ?? '') + ch);
|
|
251
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
setValue(node, (a.clear ? '' : node.value ?? '') + (a.text ?? ''));
|
|
256
|
+
}
|
|
231
257
|
node.dispatchEvent(new Event('change', { bubbles: true }));
|
|
258
|
+
if (a.pressEnter) {
|
|
259
|
+
const ev = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
|
260
|
+
const go = node.dispatchEvent(new KeyboardEvent('keydown', ev)) && node.dispatchEvent(new KeyboardEvent('keypress', ev));
|
|
261
|
+
node.dispatchEvent(new KeyboardEvent('keyup', ev));
|
|
262
|
+
// Synthetic key events never trigger implicit submission; do what the
|
|
263
|
+
// browser would for Enter in a single-line field of a form.
|
|
264
|
+
if (go && node instanceof HTMLInputElement && node.form)
|
|
265
|
+
node.form.requestSubmit();
|
|
266
|
+
}
|
|
232
267
|
return { found: true };
|
|
233
268
|
}
|
|
234
269
|
case 'focus': {
|
package/dist/shared/policy.js
CHANGED
|
@@ -34,6 +34,12 @@ const READ_CONTENT = new Set([
|
|
|
34
34
|
'frames_list',
|
|
35
35
|
'observers',
|
|
36
36
|
'print_pdf',
|
|
37
|
+
// The page's accessibility tree, its cookies (session secrets), and its
|
|
38
|
+
// web storage are content too. A storage write is refused in safe mode by
|
|
39
|
+
// the extension's storage handler; the domain gate here covers every op.
|
|
40
|
+
'snapshot',
|
|
41
|
+
'get_cookies',
|
|
42
|
+
'storage',
|
|
37
43
|
]);
|
|
38
44
|
/** Content-mutating actions — URL-gated AND mutation-gated. */
|
|
39
45
|
const MUTATE_CONTENT = new Set([
|
|
@@ -44,6 +50,7 @@ const MUTATE_CONTENT = new Set([
|
|
|
44
50
|
'hover',
|
|
45
51
|
'scroll',
|
|
46
52
|
'fill_form',
|
|
53
|
+
'select_option',
|
|
47
54
|
]);
|
|
48
55
|
/** Navigation — URL-gated by the DESTINATION url, and mutation-gated. */
|
|
49
56
|
const NAVIGATION = new Set([
|
|
@@ -72,7 +79,9 @@ function isUrlGated(method) {
|
|
|
72
79
|
MUTATE_CONTENT.has(method) ||
|
|
73
80
|
NAVIGATION.has(method) ||
|
|
74
81
|
method === 'eval' ||
|
|
75
|
-
method === 'upload_file'
|
|
82
|
+
method === 'upload_file' ||
|
|
83
|
+
// Gated on where the bytes come from: the URL when one is given, else the tab.
|
|
84
|
+
method === 'download_file');
|
|
76
85
|
}
|
|
77
86
|
// ---------------------------------------------------------------------------
|
|
78
87
|
// Domain matching
|
package/dist/shared/snapshot.js
CHANGED
|
@@ -72,6 +72,13 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
72
72
|
if (t)
|
|
73
73
|
return t;
|
|
74
74
|
}
|
|
75
|
+
// <label for> or a wrapping <label>: how ordinary forms name their fields.
|
|
76
|
+
const labels = el.labels;
|
|
77
|
+
if (labels && labels.length > 0) {
|
|
78
|
+
const t = Array.from(labels).map((l) => l.innerText).join(' ').replace(/\s+/g, ' ').trim();
|
|
79
|
+
if (t)
|
|
80
|
+
return t;
|
|
81
|
+
}
|
|
75
82
|
const ph = el.getAttribute('placeholder');
|
|
76
83
|
if (ph)
|
|
77
84
|
return ph.trim();
|
|
@@ -113,8 +120,11 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
113
120
|
// recursively. Defensive against null/closed shadow roots and re-visits.
|
|
114
121
|
const seen = new Set();
|
|
115
122
|
const candidates = [];
|
|
123
|
+
// Gather past `max` so hidden candidates dropped below still leave enough to
|
|
124
|
+
// fill it, and so a page with more than `max` can be reported as truncated.
|
|
125
|
+
const cap = Math.max(max * 2, max + 50);
|
|
116
126
|
const collect = (root) => {
|
|
117
|
-
if (candidates.length >=
|
|
127
|
+
if (candidates.length >= cap)
|
|
118
128
|
return;
|
|
119
129
|
let matched;
|
|
120
130
|
try {
|
|
@@ -124,7 +134,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
124
134
|
matched = [];
|
|
125
135
|
}
|
|
126
136
|
for (let i = 0; i < matched.length; i++) {
|
|
127
|
-
if (candidates.length >=
|
|
137
|
+
if (candidates.length >= cap)
|
|
128
138
|
break;
|
|
129
139
|
const el = matched[i];
|
|
130
140
|
if (seen.has(el))
|
|
@@ -142,7 +152,7 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
142
152
|
hosts = [];
|
|
143
153
|
}
|
|
144
154
|
for (let i = 0; i < hosts.length; i++) {
|
|
145
|
-
if (candidates.length >=
|
|
155
|
+
if (candidates.length >= cap)
|
|
146
156
|
break;
|
|
147
157
|
const sr = hosts[i].shadowRoot;
|
|
148
158
|
if (sr)
|
|
@@ -230,7 +240,8 @@ function collectSnapshot(interactiveOnly = true, max = 200, locator = null) {
|
|
|
230
240
|
break;
|
|
231
241
|
picked.push(read(el));
|
|
232
242
|
}
|
|
233
|
-
|
|
243
|
+
// At the cap there may be more on the page than were even looked at.
|
|
244
|
+
truncated = els.length > picked.length || candidates.length >= cap;
|
|
234
245
|
}
|
|
235
246
|
// -- phase 2: writes (one attribute per returned node) ----------------------
|
|
236
247
|
const nodes = [];
|
|
@@ -97,6 +97,9 @@ export declare class CdpExecutor implements Executor {
|
|
|
97
97
|
selectOption(t: Target, values: string[], opts?: {
|
|
98
98
|
tabId?: TabId;
|
|
99
99
|
}): Promise<ActionOk>;
|
|
100
|
+
setChecked(t: Target, checked: boolean, opts?: {
|
|
101
|
+
tabId?: TabId;
|
|
102
|
+
}): Promise<ActionOk>;
|
|
100
103
|
fill(t: Target, value: string, opts?: {
|
|
101
104
|
tabId?: TabId;
|
|
102
105
|
}): Promise<ActionOk>;
|
|
@@ -272,8 +272,15 @@ class CdpExecutor {
|
|
|
272
272
|
catch (err) {
|
|
273
273
|
const msg = err.message ?? '';
|
|
274
274
|
if (CLOSED_TARGET.test(msg)) {
|
|
275
|
+
const stale = this.context;
|
|
275
276
|
this.context = null;
|
|
276
277
|
this.tabs.clear();
|
|
278
|
+
// A browser we launched is ours to close; otherwise the relaunch on the
|
|
279
|
+
// next call leaves this one running. Never close the user's Chrome.
|
|
280
|
+
if (this.opts.mode !== 'connect' && this.launched) {
|
|
281
|
+
this.launched = false;
|
|
282
|
+
void stale?.close().catch(() => undefined);
|
|
283
|
+
}
|
|
277
284
|
throw new types_1.ExecutorError('DETACHED', 'browser target closed or crashed; reconnect and retry');
|
|
278
285
|
}
|
|
279
286
|
throw err;
|
|
@@ -397,6 +404,13 @@ class CdpExecutor {
|
|
|
397
404
|
return this.ok;
|
|
398
405
|
});
|
|
399
406
|
}
|
|
407
|
+
async setChecked(t, checked, opts) {
|
|
408
|
+
return this.guard(async () => {
|
|
409
|
+
const p = await this.resolveTab(opts?.tabId);
|
|
410
|
+
await this.locator(p, t).setChecked(checked);
|
|
411
|
+
return this.ok;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
400
414
|
async fill(t, value, opts) {
|
|
401
415
|
return this.guard(async () => {
|
|
402
416
|
const p = await this.resolveTab(opts?.tabId);
|
|
@@ -168,10 +168,20 @@ class ExtensionExecutor {
|
|
|
168
168
|
return (await this.send('type', { ...targetParams(t), ...frameParams(opts), text: value, clear: true, keyEvents: false }, { tabId: opts?.tabId }));
|
|
169
169
|
}
|
|
170
170
|
async fillFields(fields, opts) {
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
171
|
+
// No capability pre-check alone: a server relaying through another one (a
|
|
172
|
+
// second AI session) cannot see the extension's caps, so ask and read the
|
|
173
|
+
// answer. An extension that predates the op says it has no such method:
|
|
174
|
+
// then let the caller go field by field.
|
|
175
|
+
let res;
|
|
176
|
+
try {
|
|
177
|
+
res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM) && err instanceof types_1.ExecutorError && /unhandled method/.test(err.message)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
175
185
|
if (res.error) {
|
|
176
186
|
// Say how far the batch got: the fields before this one DID land, and a
|
|
177
187
|
// blind retry of the whole form would write them a second time.
|
|
@@ -361,6 +361,10 @@ export interface Executor {
|
|
|
361
361
|
} & FrameOpts): Promise<{
|
|
362
362
|
filled: number;
|
|
363
363
|
} | null>;
|
|
364
|
+
/** Put a checkbox/radio into `checked` (no-op when it already is). */
|
|
365
|
+
setChecked?(t: Target, checked: boolean, opts?: {
|
|
366
|
+
tabId?: TabId;
|
|
367
|
+
} & FrameOpts): Promise<ActionOk>;
|
|
364
368
|
/** Every frame of a tab that the extension can inject into, with its URL. */
|
|
365
369
|
framesList?(opts?: {
|
|
366
370
|
tabId?: TabId;
|
package/dist/src/mcp/helpers.js
CHANGED
|
@@ -166,8 +166,17 @@ async function fillForm(ex, args) {
|
|
|
166
166
|
for (const { selector, value } of ops) {
|
|
167
167
|
const target = { selector };
|
|
168
168
|
if (typeof value === 'boolean') {
|
|
169
|
-
// Checkbox/radio:
|
|
170
|
-
|
|
169
|
+
// Checkbox/radio: set the state. A bare click toggles, so it is only
|
|
170
|
+
// right for a backend that cannot set it, and only to turn it on.
|
|
171
|
+
if (ex.setChecked)
|
|
172
|
+
await ex.setChecked(target, value, opts);
|
|
173
|
+
else if (value)
|
|
174
|
+
await ex.click(target, opts);
|
|
175
|
+
else {
|
|
176
|
+
// Unticking needs the current state, which this backend cannot read;
|
|
177
|
+
// a silent no-op would report a field as filled that was not.
|
|
178
|
+
throw new types_1.ExecutorError('UNSUPPORTED', `cannot set ${selector} to unchecked with this browser backend; update the MCP Browser Extension`);
|
|
179
|
+
}
|
|
171
180
|
}
|
|
172
181
|
else {
|
|
173
182
|
await ex.fill(target, value, opts);
|
package/dist/src/mcp/tools.js
CHANGED
|
@@ -105,7 +105,7 @@ const maxBytesField = zod_1.z
|
|
|
105
105
|
const waitUntilField = zod_1.z.enum(['load', 'domcontentloaded', 'networkidle']).describe('When to consider navigation done').optional();
|
|
106
106
|
exports.TOOL_DEFINITIONS = [
|
|
107
107
|
{ name: 'tabs_list', description: 'List open browser tabs.', inputSchema: {} },
|
|
108
|
-
{ name: 'tab_select', description:
|
|
108
|
+
{ name: 'tab_select', description: "Make a tab the agent's tab by tabId, in the background: calls without tabId go to it and the user stays on their own tab. Pass show:true only to bring it to the front for the user.", inputSchema: { tabId: zod_1.z.string(), show: zod_1.z.boolean().optional() } },
|
|
109
109
|
{ name: 'tab_new', description: "Open a NEW tab (optionally at a URL) in the background, leaving the user's tab alone; it becomes the agent's tab, where calls without tabId go. Pass active:true to show it to the user.", inputSchema: { url: zod_1.z.string().optional(), active: zod_1.z.boolean().optional() } },
|
|
110
110
|
{ name: 'tab_close', description: 'Close a tab by tabId.', inputSchema: { tabId: zod_1.z.string() } },
|
|
111
111
|
{ name: 'navigate', description: "Navigate the agent's tab to a URL, replacing its page. With no agent's tab yet it opens one in the background: the user's own tab is only navigated when you pass its tabId.", inputSchema: { url: zod_1.z.string(), tabId: tabIdField, waitUntil: waitUntilField, failOnAuthWall: authWallField } },
|
|
@@ -707,14 +707,27 @@ exports.TOOL_HANDLERS = {
|
|
|
707
707
|
tabs_list: async (_a, ctx) => (0, envelopes_1.jsonResult)(await ctx.ex.tabsList()),
|
|
708
708
|
tab_select: async (a, ctx) => {
|
|
709
709
|
await gate(ctx, 'tab_select');
|
|
710
|
-
const
|
|
710
|
+
const id = (0, validators_1.requireString)(a, 'tabId');
|
|
711
|
+
// Showing the tab pulls the person off whatever they are doing, so only on request.
|
|
712
|
+
if ((0, validators_1.optionalBoolean)(a, 'show') === true) {
|
|
713
|
+
const tab = await ctx.ex.tabSelect(id);
|
|
714
|
+
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
715
|
+
return (0, envelopes_1.jsonResult)(tab);
|
|
716
|
+
}
|
|
717
|
+
const tab = (await ctx.ex.tabsList()).find((t) => t.tabId === id);
|
|
718
|
+
if (!tab)
|
|
719
|
+
throw new validators_1.McpToolError(`no open tab with tabId ${id}; call tabs_list for the current ids`);
|
|
711
720
|
workTab = tab.tabId; // an explicit pick: later calls follow it
|
|
712
|
-
return (0, envelopes_1.jsonResult)(tab);
|
|
721
|
+
return (0, envelopes_1.jsonResult)({ ...tab, shown: false });
|
|
713
722
|
},
|
|
714
723
|
tab_new: async (a, ctx) => {
|
|
715
724
|
await gate(ctx, 'tab_new');
|
|
725
|
+
// Opening a tab at a URL loads it like navigate does, so gate it the same way.
|
|
726
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
727
|
+
if (url !== undefined)
|
|
728
|
+
await gate(ctx, 'navigate', { url });
|
|
716
729
|
// Background unless asked: focusing it would pull the person off their tab.
|
|
717
|
-
const tab = await ctx.ex.tabNew(
|
|
730
|
+
const tab = await ctx.ex.tabNew(url, { active: (0, validators_1.optionalBoolean)(a, 'active') ?? false });
|
|
718
731
|
workTab = tab.tabId;
|
|
719
732
|
return (0, envelopes_1.jsonResult)(tab);
|
|
720
733
|
},
|
|
@@ -948,7 +961,11 @@ exports.TOOL_HANDLERS = {
|
|
|
948
961
|
},
|
|
949
962
|
get_cookies: async (a, ctx) => {
|
|
950
963
|
await gate(ctx, 'get_text', { tabId: tabId(a) }); // reads tab-scoped secrets; same domain gate as content reads
|
|
951
|
-
|
|
964
|
+
// Cookies for another URL are that site's secrets: it must be allowed too.
|
|
965
|
+
const url = (0, validators_1.optionalString)(a, 'url');
|
|
966
|
+
if (url !== undefined)
|
|
967
|
+
await gate(ctx, 'get_text', { url });
|
|
968
|
+
return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url }));
|
|
952
969
|
},
|
|
953
970
|
storage: async (a, ctx) => {
|
|
954
971
|
const op = (0, validators_1.requireString)(a, 'op');
|
|
@@ -1054,11 +1071,14 @@ exports.TOOL_HANDLERS = {
|
|
|
1054
1071
|
return (0, envelopes_1.jsonResult)(res);
|
|
1055
1072
|
},
|
|
1056
1073
|
download_file: async (a, ctx) => {
|
|
1057
|
-
await gate(ctx, 'download_file');
|
|
1058
1074
|
const url = (0, validators_1.optionalString)(a, 'url');
|
|
1059
1075
|
const target = (0, validators_1.optionalTarget)(a);
|
|
1060
1076
|
if (!url && !target)
|
|
1061
1077
|
throw new validators_1.McpToolError('provide "url" or a target (selector|ref)');
|
|
1078
|
+
// By URL, the bytes come from that URL with the person's cookies: gate the
|
|
1079
|
+
// URL, not whichever tab the agent is on. By element, gate the tab it is in
|
|
1080
|
+
// (the extension then gates where the link leads).
|
|
1081
|
+
await gate(ctx, 'download_file', url ? { url } : { tabId: tabId(a) });
|
|
1062
1082
|
return (0, envelopes_1.jsonResult)(await ctx.ex.download({ url, target, tabId: tabId(a), suggestedName: (0, validators_1.optionalString)(a, 'suggestedName') }));
|
|
1063
1083
|
},
|
|
1064
1084
|
upload_file: async (a, ctx) => {
|
|
@@ -1074,9 +1094,20 @@ exports.TOOL_HANDLERS = {
|
|
|
1074
1094
|
if (!ctx.policy.uploadsDir) {
|
|
1075
1095
|
throw new validators_1.McpToolError('upload denied: uploads require an --uploads-dir to be configured (refusing unrestricted local-file access)');
|
|
1076
1096
|
}
|
|
1077
|
-
|
|
1097
|
+
// Compare real paths: Chrome reads a symlink's target, so a link inside the
|
|
1098
|
+
// dir pointing outside it must be judged by where it points.
|
|
1099
|
+
const real = (p) => {
|
|
1100
|
+
try {
|
|
1101
|
+
return (0, node_fs_1.realpathSync)(p);
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
// Not there (yet): resolve its folder, so /tmp vs /private/tmp still compare.
|
|
1105
|
+
return p === (0, node_path_1.dirname)(p) ? p : (0, node_path_1.join)(real((0, node_path_1.dirname)(p)), (0, node_path_1.basename)(p));
|
|
1106
|
+
}
|
|
1107
|
+
};
|
|
1108
|
+
const dir = real((0, node_path_1.resolve)(ctx.policy.uploadsDir));
|
|
1078
1109
|
for (const f of files) {
|
|
1079
|
-
const abs = (0, node_path_1.resolve)(f);
|
|
1110
|
+
const abs = real((0, node_path_1.resolve)(f));
|
|
1080
1111
|
if (abs !== dir && !abs.startsWith(dir + node_path_1.sep)) {
|
|
1081
1112
|
throw new validators_1.McpToolError(`upload denied: "${f}" is outside the allowed uploads dir (${dir})`);
|
|
1082
1113
|
}
|
|
@@ -162,7 +162,13 @@
|
|
|
162
162
|
// All three are page CONTENT and gate exactly like the reads above.
|
|
163
163
|
"frames_list",
|
|
164
164
|
"observers",
|
|
165
|
-
"print_pdf"
|
|
165
|
+
"print_pdf",
|
|
166
|
+
// The page's accessibility tree, its cookies (session secrets), and its
|
|
167
|
+
// web storage are content too. A storage write is refused in safe mode by
|
|
168
|
+
// the extension's storage handler; the domain gate here covers every op.
|
|
169
|
+
"snapshot",
|
|
170
|
+
"get_cookies",
|
|
171
|
+
"storage"
|
|
166
172
|
]);
|
|
167
173
|
var MUTATE_CONTENT = /* @__PURE__ */ new Set([
|
|
168
174
|
"click",
|
|
@@ -171,7 +177,8 @@
|
|
|
171
177
|
"press",
|
|
172
178
|
"hover",
|
|
173
179
|
"scroll",
|
|
174
|
-
"fill_form"
|
|
180
|
+
"fill_form",
|
|
181
|
+
"select_option"
|
|
175
182
|
]);
|
|
176
183
|
var NAVIGATION = /* @__PURE__ */ new Set([
|
|
177
184
|
"navigate",
|
|
@@ -188,7 +195,8 @@
|
|
|
188
195
|
return MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || TAB_MUTATE.has(method);
|
|
189
196
|
}
|
|
190
197
|
function isUrlGated(method) {
|
|
191
|
-
return READ_CONTENT.has(method) || MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || method === "eval" || method === "upload_file"
|
|
198
|
+
return READ_CONTENT.has(method) || MUTATE_CONTENT.has(method) || NAVIGATION.has(method) || method === "eval" || method === "upload_file" || // Gated on where the bytes come from: the URL when one is given, else the tab.
|
|
199
|
+
method === "download_file";
|
|
192
200
|
}
|
|
193
201
|
function hostOf(url) {
|
|
194
202
|
try {
|
|
@@ -377,6 +385,11 @@
|
|
|
377
385
|
const t = labelledby.split(/\s+/).map((id) => document.getElementById(id)?.innerText ?? "").join(" ").trim();
|
|
378
386
|
if (t) return t;
|
|
379
387
|
}
|
|
388
|
+
const labels = el.labels;
|
|
389
|
+
if (labels && labels.length > 0) {
|
|
390
|
+
const t = Array.from(labels).map((l) => l.innerText).join(" ").replace(/\s+/g, " ").trim();
|
|
391
|
+
if (t) return t;
|
|
392
|
+
}
|
|
380
393
|
const ph = el.getAttribute("placeholder");
|
|
381
394
|
if (ph) return ph.trim();
|
|
382
395
|
const title = el.getAttribute("title");
|
|
@@ -405,8 +418,9 @@
|
|
|
405
418
|
};
|
|
406
419
|
const seen = /* @__PURE__ */ new Set();
|
|
407
420
|
const candidates = [];
|
|
421
|
+
const cap = Math.max(max * 2, max + 50);
|
|
408
422
|
const collect = (root) => {
|
|
409
|
-
if (candidates.length >=
|
|
423
|
+
if (candidates.length >= cap) return;
|
|
410
424
|
let matched;
|
|
411
425
|
try {
|
|
412
426
|
matched = root.querySelectorAll(sel);
|
|
@@ -414,7 +428,7 @@
|
|
|
414
428
|
matched = [];
|
|
415
429
|
}
|
|
416
430
|
for (let i = 0; i < matched.length; i++) {
|
|
417
|
-
if (candidates.length >=
|
|
431
|
+
if (candidates.length >= cap) break;
|
|
418
432
|
const el = matched[i];
|
|
419
433
|
if (seen.has(el)) continue;
|
|
420
434
|
seen.add(el);
|
|
@@ -427,7 +441,7 @@
|
|
|
427
441
|
hosts = [];
|
|
428
442
|
}
|
|
429
443
|
for (let i = 0; i < hosts.length; i++) {
|
|
430
|
-
if (candidates.length >=
|
|
444
|
+
if (candidates.length >= cap) break;
|
|
431
445
|
const sr = hosts[i].shadowRoot;
|
|
432
446
|
if (sr) collect(sr);
|
|
433
447
|
}
|
|
@@ -491,7 +505,7 @@
|
|
|
491
505
|
if (picked.length >= max) break;
|
|
492
506
|
picked.push(read(el));
|
|
493
507
|
}
|
|
494
|
-
truncated = els.length > picked.length;
|
|
508
|
+
truncated = els.length > picked.length || candidates.length >= cap;
|
|
495
509
|
}
|
|
496
510
|
const nodes = [];
|
|
497
511
|
let n = 0;
|
|
@@ -632,6 +646,15 @@
|
|
|
632
646
|
el2.click();
|
|
633
647
|
return { found: true };
|
|
634
648
|
}
|
|
649
|
+
case "check": {
|
|
650
|
+
if (!el2) return { found: false };
|
|
651
|
+
const box = el2;
|
|
652
|
+
if (box.checked !== (a.checked !== false)) {
|
|
653
|
+
el2.scrollIntoView({ block: "center" });
|
|
654
|
+
el2.click();
|
|
655
|
+
}
|
|
656
|
+
return { found: true, checked: box.checked };
|
|
657
|
+
}
|
|
635
658
|
case "type": {
|
|
636
659
|
if (!el2) return { found: false };
|
|
637
660
|
if (el2.isContentEditable) {
|
|
@@ -644,9 +667,24 @@
|
|
|
644
667
|
const node = el2;
|
|
645
668
|
if (!node) return { found: false };
|
|
646
669
|
node.focus();
|
|
647
|
-
|
|
648
|
-
|
|
670
|
+
if (a.keyEvents) {
|
|
671
|
+
if (a.clear) setValue(node, "");
|
|
672
|
+
for (const ch of a.text ?? "") {
|
|
673
|
+
const ev = { key: ch, bubbles: true, cancelable: true };
|
|
674
|
+
const go = node.dispatchEvent(new KeyboardEvent("keydown", ev));
|
|
675
|
+
if (go && node.dispatchEvent(new KeyboardEvent("keypress", ev))) setValue(node, (node.value ?? "") + ch);
|
|
676
|
+
node.dispatchEvent(new KeyboardEvent("keyup", ev));
|
|
677
|
+
}
|
|
678
|
+
} else {
|
|
679
|
+
setValue(node, (a.clear ? "" : node.value ?? "") + (a.text ?? ""));
|
|
680
|
+
}
|
|
649
681
|
node.dispatchEvent(new Event("change", { bubbles: true }));
|
|
682
|
+
if (a.pressEnter) {
|
|
683
|
+
const ev = { key: "Enter", code: "Enter", keyCode: 13, which: 13, bubbles: true, cancelable: true };
|
|
684
|
+
const go = node.dispatchEvent(new KeyboardEvent("keydown", ev)) && node.dispatchEvent(new KeyboardEvent("keypress", ev));
|
|
685
|
+
node.dispatchEvent(new KeyboardEvent("keyup", ev));
|
|
686
|
+
if (go && node instanceof HTMLInputElement && node.form) node.form.requestSubmit();
|
|
687
|
+
}
|
|
650
688
|
return { found: true };
|
|
651
689
|
}
|
|
652
690
|
case "focus": {
|
|
@@ -1153,6 +1191,7 @@
|
|
|
1153
1191
|
const u = cmd.params.url;
|
|
1154
1192
|
return typeof u === "string" ? u : "";
|
|
1155
1193
|
}
|
|
1194
|
+
if (cmd.method === "download_file" && typeof cmd.params.url === "string") return cmd.params.url;
|
|
1156
1195
|
return observedTabUrl(cmd, tab);
|
|
1157
1196
|
}
|
|
1158
1197
|
async function observedTabUrl(cmd, tab) {
|
|
@@ -1202,23 +1241,41 @@
|
|
|
1202
1241
|
done = resolve;
|
|
1203
1242
|
});
|
|
1204
1243
|
const isTop = (d) => d.tabId === tabId && d.frameId === 0 && d.timeStamp >= start - 1;
|
|
1244
|
+
let started = false;
|
|
1245
|
+
let startUrl = "";
|
|
1246
|
+
let committed = false;
|
|
1247
|
+
const reached = () => {
|
|
1248
|
+
if (waitUntil === "networkidle") setTimeout(done, NETWORK_QUIET_MS);
|
|
1249
|
+
else done();
|
|
1250
|
+
};
|
|
1251
|
+
const onBefore = (d) => {
|
|
1252
|
+
if (!isTop(d)) return;
|
|
1253
|
+
started = true;
|
|
1254
|
+
startUrl = d.url;
|
|
1255
|
+
committed = false;
|
|
1256
|
+
};
|
|
1257
|
+
const onCommitted = (d) => {
|
|
1258
|
+
if (isTop(d)) committed = true;
|
|
1259
|
+
};
|
|
1205
1260
|
const onDom = (d) => {
|
|
1206
|
-
if (isTop(d) && waitUntil === "domcontentloaded") done();
|
|
1261
|
+
if (isTop(d) && committed && waitUntil === "domcontentloaded") done();
|
|
1207
1262
|
};
|
|
1208
1263
|
const onCompleted = (d) => {
|
|
1209
|
-
if (
|
|
1210
|
-
if (waitUntil === "networkidle") setTimeout(done, NETWORK_QUIET_MS);
|
|
1211
|
-
else done();
|
|
1264
|
+
if (isTop(d) && committed) reached();
|
|
1212
1265
|
};
|
|
1213
1266
|
const onSameDoc = (d) => {
|
|
1214
1267
|
if (isTop(d)) done();
|
|
1215
1268
|
};
|
|
1216
1269
|
const onError = (d) => {
|
|
1217
|
-
if (isTop(d))
|
|
1270
|
+
if (!isTop(d) || !started) return;
|
|
1271
|
+
if (/ERR_ABORTED/.test(d.error) && d.url !== startUrl) return;
|
|
1272
|
+
done();
|
|
1218
1273
|
};
|
|
1219
1274
|
const onUpdated = (id, info) => {
|
|
1220
|
-
if (id === tabId && info.status === "complete")
|
|
1275
|
+
if (id === tabId && info.status === "complete" && committed) reached();
|
|
1221
1276
|
};
|
|
1277
|
+
chrome.webNavigation.onBeforeNavigate.addListener(onBefore);
|
|
1278
|
+
chrome.webNavigation.onCommitted.addListener(onCommitted);
|
|
1222
1279
|
chrome.webNavigation.onDOMContentLoaded.addListener(onDom);
|
|
1223
1280
|
chrome.webNavigation.onCompleted.addListener(onCompleted);
|
|
1224
1281
|
chrome.webNavigation.onHistoryStateUpdated.addListener(onSameDoc);
|
|
@@ -1226,6 +1283,8 @@
|
|
|
1226
1283
|
chrome.webNavigation.onErrorOccurred.addListener(onError);
|
|
1227
1284
|
chrome.tabs.onUpdated.addListener(onUpdated);
|
|
1228
1285
|
const cleanup = () => {
|
|
1286
|
+
chrome.webNavigation.onBeforeNavigate.removeListener(onBefore);
|
|
1287
|
+
chrome.webNavigation.onCommitted.removeListener(onCommitted);
|
|
1229
1288
|
chrome.webNavigation.onDOMContentLoaded.removeListener(onDom);
|
|
1230
1289
|
chrome.webNavigation.onCompleted.removeListener(onCompleted);
|
|
1231
1290
|
chrome.webNavigation.onHistoryStateUpdated.removeListener(onSameDoc);
|
|
@@ -1246,6 +1305,20 @@
|
|
|
1246
1305
|
cancel: cleanup
|
|
1247
1306
|
};
|
|
1248
1307
|
}
|
|
1308
|
+
async function historyStep(tabId, step) {
|
|
1309
|
+
const went = await execInTab(
|
|
1310
|
+
tabId,
|
|
1311
|
+
(d) => {
|
|
1312
|
+
const nav = window.navigation;
|
|
1313
|
+
const can = nav ? d < 0 ? nav.canGoBack : nav.canGoForward : d < 0 && history.length > 1;
|
|
1314
|
+
if (can) history.go(d);
|
|
1315
|
+
return can;
|
|
1316
|
+
},
|
|
1317
|
+
[step],
|
|
1318
|
+
"MAIN"
|
|
1319
|
+
).catch(() => false);
|
|
1320
|
+
return went === true;
|
|
1321
|
+
}
|
|
1249
1322
|
function resolveSelector(cmd) {
|
|
1250
1323
|
const s = cmd.params.selector;
|
|
1251
1324
|
if (typeof s === "string" && s.length > 0) return s;
|
|
@@ -1299,17 +1372,41 @@
|
|
|
1299
1372
|
dbgSessions.set(tabId, { detachTimer: null });
|
|
1300
1373
|
}
|
|
1301
1374
|
var DETACHED_RE = /not attached|no target with given id|detached/i;
|
|
1302
|
-
|
|
1375
|
+
var DEBUGGER_OP_MS = 2e4;
|
|
1376
|
+
async function withinMs(work, ms, what, onExpire) {
|
|
1377
|
+
let timer;
|
|
1378
|
+
const expired = new Promise((_, reject) => {
|
|
1379
|
+
timer = setTimeout(() => {
|
|
1380
|
+
onExpire?.();
|
|
1381
|
+
reject(new CmdError("TIMEOUT", `${what} within ${ms / 1e3}s; it may be showing a dialog (alert/confirm), busy, or not painting`));
|
|
1382
|
+
}, ms);
|
|
1383
|
+
});
|
|
1384
|
+
try {
|
|
1385
|
+
return await Promise.race([work, expired]);
|
|
1386
|
+
} finally {
|
|
1387
|
+
clearTimeout(timer);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
function withDeadline(tabId, work, ms) {
|
|
1391
|
+
return withinMs(work, ms, "Chrome did not answer for the page", () => {
|
|
1392
|
+
forgetDebugger(tabId);
|
|
1393
|
+
chrome.debugger.detach({ tabId }).catch(() => void 0);
|
|
1394
|
+
});
|
|
1395
|
+
}
|
|
1396
|
+
async function withDebugger(tabId, fn, timeoutMs = DEBUGGER_OP_MS) {
|
|
1303
1397
|
return locks.run(`dbg:${tabId}`, async () => {
|
|
1304
1398
|
const target = { tabId };
|
|
1305
|
-
|
|
1399
|
+
const once = async () => {
|
|
1400
|
+
await ensureAttached(tabId);
|
|
1401
|
+
return fn(target);
|
|
1402
|
+
};
|
|
1306
1403
|
try {
|
|
1307
|
-
return await
|
|
1404
|
+
return await withDeadline(tabId, once(), timeoutMs);
|
|
1308
1405
|
} catch (err) {
|
|
1406
|
+
if (err instanceof CmdError && err.code === "TIMEOUT") throw err;
|
|
1309
1407
|
if (!DETACHED_RE.test(String(err?.message ?? err))) throw err;
|
|
1310
1408
|
forgetDebugger(tabId);
|
|
1311
|
-
await
|
|
1312
|
-
return await fn(target);
|
|
1409
|
+
return await withDeadline(tabId, once(), timeoutMs);
|
|
1313
1410
|
} finally {
|
|
1314
1411
|
scheduleDetach(tabId);
|
|
1315
1412
|
}
|
|
@@ -1334,9 +1431,15 @@
|
|
|
1334
1431
|
}
|
|
1335
1432
|
await chrome.debugger.sendCommand(t, "Input.insertText", { text });
|
|
1336
1433
|
if (!pressEnter) return;
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1434
|
+
await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", {
|
|
1435
|
+
type: "keyDown",
|
|
1436
|
+
key: "Enter",
|
|
1437
|
+
code: "Enter",
|
|
1438
|
+
windowsVirtualKeyCode: 13,
|
|
1439
|
+
text: "\r",
|
|
1440
|
+
unmodifiedText: "\r"
|
|
1441
|
+
});
|
|
1442
|
+
await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", { type: "keyUp", key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
|
|
1340
1443
|
});
|
|
1341
1444
|
return true;
|
|
1342
1445
|
}
|
|
@@ -1371,7 +1474,7 @@
|
|
|
1371
1474
|
};
|
|
1372
1475
|
}
|
|
1373
1476
|
async function screenshotViaDebugger(tabId, fullPage, enc, selector, frameIds) {
|
|
1374
|
-
const measured = await measurePage(tabId, selector, frameIds);
|
|
1477
|
+
const measured = await withinMs(measurePage(tabId, selector, frameIds), DEBUGGER_OP_MS, "the page did not answer");
|
|
1375
1478
|
if (!measured) throw new CmdError("CDP_ERROR", "could not read page dimensions");
|
|
1376
1479
|
if (selector && measured.missing) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${selector}`);
|
|
1377
1480
|
const plan = planScreenshot(measured.dims, { fullPage, element: measured.element, scale: enc.scale });
|
|
@@ -1403,19 +1506,28 @@
|
|
|
1403
1506
|
}
|
|
1404
1507
|
async function screenshotViaVisibleTab(tabId, fullPage, enc) {
|
|
1405
1508
|
let t = await chrome.tabs.get(tabId);
|
|
1509
|
+
let restore;
|
|
1406
1510
|
if (!t.active) {
|
|
1511
|
+
const [previous] = await chrome.tabs.query({ active: true, windowId: t.windowId });
|
|
1512
|
+
restore = previous?.id;
|
|
1407
1513
|
await chrome.tabs.update(tabId, { active: true });
|
|
1408
|
-
await chrome.windows.update(t.windowId, { focused: true }).catch(() => void 0);
|
|
1409
1514
|
await delay(150);
|
|
1410
1515
|
t = await chrome.tabs.get(tabId);
|
|
1411
1516
|
}
|
|
1517
|
+
try {
|
|
1518
|
+
return await captureActiveTab(tabId, t.windowId, fullPage, enc);
|
|
1519
|
+
} finally {
|
|
1520
|
+
if (restore !== void 0) await chrome.tabs.update(restore, { active: true }).catch(() => void 0);
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async function captureActiveTab(tabId, windowId, fullPage, enc) {
|
|
1412
1524
|
const dims = await execInTab(
|
|
1413
1525
|
tabId,
|
|
1414
1526
|
() => ({ w: window.innerWidth, h: window.innerHeight, full: document.documentElement.scrollHeight }),
|
|
1415
1527
|
[]
|
|
1416
1528
|
);
|
|
1417
1529
|
const dataUrl = await chrome.tabs.captureVisibleTab(
|
|
1418
|
-
|
|
1530
|
+
windowId,
|
|
1419
1531
|
enc.format === "jpeg" ? { format: "jpeg", quality: enc.quality } : { format: "png" }
|
|
1420
1532
|
);
|
|
1421
1533
|
const viewportH = dims?.h ?? 0;
|
|
@@ -1483,6 +1595,43 @@
|
|
|
1483
1595
|
this.getPolicy = getPolicy;
|
|
1484
1596
|
}
|
|
1485
1597
|
getPolicy;
|
|
1598
|
+
/** The absolute URL a link (or img/source) element points at, for a download
|
|
1599
|
+
* by element. Gated like a download by URL: the router only checked the tab
|
|
1600
|
+
* the link sits in, and the link can lead to any site. */
|
|
1601
|
+
async linkTarget(cmd) {
|
|
1602
|
+
const id = await targetTab(cmd);
|
|
1603
|
+
const sel = requireSelector(cmd);
|
|
1604
|
+
const frameIds = await this.frames(cmd, id);
|
|
1605
|
+
if (!await waitForSelector(id, sel, frameIds)) throw new CmdError("SELECTOR_NOT_FOUND", `no element for selector: ${sel}`);
|
|
1606
|
+
const url = await execInTab(
|
|
1607
|
+
id,
|
|
1608
|
+
(s) => {
|
|
1609
|
+
const el = document.querySelector(s);
|
|
1610
|
+
const raw = el?.getAttribute("href") ?? el?.getAttribute("src");
|
|
1611
|
+
try {
|
|
1612
|
+
return raw ? new URL(raw, document.baseURI).href : "";
|
|
1613
|
+
} catch {
|
|
1614
|
+
return "";
|
|
1615
|
+
}
|
|
1616
|
+
},
|
|
1617
|
+
[sel],
|
|
1618
|
+
void 0,
|
|
1619
|
+
frameIds
|
|
1620
|
+
);
|
|
1621
|
+
if (!url) throw new CmdError("DOWNLOAD_FAILED", `the element ${sel} has no href or src to download`);
|
|
1622
|
+
this.assertAllowed(url, "download_file");
|
|
1623
|
+
return url;
|
|
1624
|
+
}
|
|
1625
|
+
allowed(url, method) {
|
|
1626
|
+
const policy = this.getPolicy();
|
|
1627
|
+
return !!policy && evaluatePolicy(url, method, policy).ok;
|
|
1628
|
+
}
|
|
1629
|
+
/** Gate a URL the router could not see (it gates the tab, not arguments). */
|
|
1630
|
+
assertAllowed(url, method) {
|
|
1631
|
+
const policy = this.getPolicy();
|
|
1632
|
+
const verdict = policy ? evaluatePolicy(url, method, policy) : { ok: false, reason: "no policy has arrived yet" };
|
|
1633
|
+
if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
|
|
1634
|
+
}
|
|
1486
1635
|
/**
|
|
1487
1636
|
* Resolve `frameId` / `allFrames` params into the frame ids an injection may
|
|
1488
1637
|
* touch, dropping any whose own URL the policy does not allow for this method.
|
|
@@ -1551,6 +1700,7 @@
|
|
|
1551
1700
|
}
|
|
1552
1701
|
case "tab_new": {
|
|
1553
1702
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
|
|
1703
|
+
if (url !== void 0) this.assertAllowed(url, "navigate");
|
|
1554
1704
|
const active = cmd.params.active !== false;
|
|
1555
1705
|
const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
|
|
1556
1706
|
const claim = await locks.run("tab_new", async () => {
|
|
@@ -1565,14 +1715,14 @@
|
|
|
1565
1715
|
claimedTabs.add(blank.id);
|
|
1566
1716
|
return { id: blank.id, reused: true, needsNav: url !== void 0 };
|
|
1567
1717
|
}
|
|
1568
|
-
const created = await chrome.tabs.create({ url, active: false });
|
|
1718
|
+
const created = await chrome.tabs.create({ url: url === void 0 ? void 0 : "about:blank", active: false });
|
|
1569
1719
|
if (created.id === void 0) throw new CmdError("TARGET_GONE", "failed to create a tab");
|
|
1570
1720
|
claimedTabs.add(created.id);
|
|
1571
1721
|
await rememberOpenedByUs(created.id, present);
|
|
1572
|
-
return { id: created.id, reused: false, needsNav:
|
|
1722
|
+
return { id: created.id, reused: false, needsNav: url !== void 0 };
|
|
1573
1723
|
});
|
|
1574
1724
|
if (claim.needsNav) {
|
|
1575
|
-
const nav = armNavigationWait(claim.id, "load");
|
|
1725
|
+
const nav = armNavigationWait(claim.id, "load", 25e3);
|
|
1576
1726
|
await chrome.tabs.update(claim.id, { url }).catch((err) => {
|
|
1577
1727
|
nav.cancel();
|
|
1578
1728
|
throw err;
|
|
@@ -1602,25 +1752,19 @@
|
|
|
1602
1752
|
});
|
|
1603
1753
|
await nav.wait();
|
|
1604
1754
|
const t = await chrome.tabs.get(id);
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
case "back": {
|
|
1608
|
-
const id = await targetTab2(cmd);
|
|
1609
|
-
const nav = armNavigationWait(id, "load", 15e3);
|
|
1610
|
-
const moved = await chrome.tabs.goBack(id).then(() => true, () => false);
|
|
1611
|
-
if (moved) await nav.wait();
|
|
1612
|
-
else nav.cancel();
|
|
1613
|
-
const t = await chrome.tabs.get(id);
|
|
1614
|
-
return { url: t.url ?? "", title: t.title ?? "" };
|
|
1755
|
+
const landed = t.url ?? url;
|
|
1756
|
+
return { url: landed, title: this.allowed(landed, "get_text") ? t.title ?? "" : "" };
|
|
1615
1757
|
}
|
|
1758
|
+
case "back":
|
|
1616
1759
|
case "forward": {
|
|
1617
1760
|
const id = await targetTab2(cmd);
|
|
1761
|
+
const step = cmd.method === "back" ? -1 : 1;
|
|
1618
1762
|
const nav = armNavigationWait(id, "load", 15e3);
|
|
1619
|
-
const moved = await chrome.tabs.goForward(id).then(() => true, () => false);
|
|
1763
|
+
const moved = await (step < 0 ? chrome.tabs.goBack(id) : chrome.tabs.goForward(id)).then(() => true, () => false) || await historyStep(id, step);
|
|
1620
1764
|
if (moved) await nav.wait();
|
|
1621
1765
|
else nav.cancel();
|
|
1622
1766
|
const t = await chrome.tabs.get(id);
|
|
1623
|
-
return { url: t.url ?? "", title: t.title ?? "" };
|
|
1767
|
+
return { url: t.url ?? "", title: t.title ?? "", moved };
|
|
1624
1768
|
}
|
|
1625
1769
|
case "reload": {
|
|
1626
1770
|
const id = await targetTab2(cmd);
|
|
@@ -1699,6 +1843,7 @@
|
|
|
1699
1843
|
const t = await chrome.tabs.get(id);
|
|
1700
1844
|
const url = typeof cmd.params.url === "string" ? cmd.params.url : t.url;
|
|
1701
1845
|
if (!url) throw new CmdError("BAD_ARGS", "no url to read cookies for");
|
|
1846
|
+
if (url !== t.url) this.assertAllowed(url, "get_cookies");
|
|
1702
1847
|
const cookies = await chrome.cookies.getAll({ url });
|
|
1703
1848
|
return {
|
|
1704
1849
|
cookies: cookies.map((c) => ({
|
|
@@ -1719,6 +1864,9 @@
|
|
|
1719
1864
|
const key = typeof cmd.params.key === "string" ? cmd.params.key : null;
|
|
1720
1865
|
const value = typeof cmd.params.value === "string" ? cmd.params.value : null;
|
|
1721
1866
|
const session = cmd.params.session === true;
|
|
1867
|
+
if (op !== "get" && !this.getPolicy()?.enableMutations) {
|
|
1868
|
+
throw new CmdError("POLICY_DENIED", `storage "${op}" changes the page and is disabled (safe-mode). Pass --enable-mutations to allow it.`);
|
|
1869
|
+
}
|
|
1722
1870
|
const frameIds = await this.frames(cmd, id);
|
|
1723
1871
|
const out = await execOp(id, { op: "storage", storageOp: op, key, value, session }, frameIds);
|
|
1724
1872
|
const { found: _found, frameId: _frameId, ...rest } = out;
|
|
@@ -1812,7 +1960,11 @@
|
|
|
1812
1960
|
}
|
|
1813
1961
|
return { ok: true };
|
|
1814
1962
|
}
|
|
1815
|
-
const out = await execOp(
|
|
1963
|
+
const out = await execOp(
|
|
1964
|
+
id,
|
|
1965
|
+
withWait({ op: "type", selector: sel, text, clear, keyEvents: cmd.params.keyEvents === true, pressEnter: cmd.params.pressEnter === true }),
|
|
1966
|
+
frameIds
|
|
1967
|
+
);
|
|
1816
1968
|
if (!out.found) throw notFound(sel, out);
|
|
1817
1969
|
return { ok: true, frameId: out.frameId };
|
|
1818
1970
|
}
|
|
@@ -1834,7 +1986,7 @@
|
|
|
1834
1986
|
const out = await execOp(
|
|
1835
1987
|
id,
|
|
1836
1988
|
withWait(
|
|
1837
|
-
typeof op.value === "boolean" ? { op: "
|
|
1989
|
+
typeof op.value === "boolean" ? { op: "check", selector: op.selector, checked: op.value } : { op: "type", selector: op.selector, text: op.value, clear: true }
|
|
1838
1990
|
),
|
|
1839
1991
|
await this.frames(cmd, id)
|
|
1840
1992
|
);
|
|
@@ -1849,15 +2001,25 @@
|
|
|
1849
2001
|
case "press": {
|
|
1850
2002
|
const id = await targetTab2(cmd);
|
|
1851
2003
|
const key = String(cmd.params.key ?? "");
|
|
2004
|
+
const modifiers = Array.isArray(cmd.params.modifiers) ? cmd.params.modifiers.map((m) => String(m).toLowerCase()) : [];
|
|
1852
2005
|
await execInTab(
|
|
1853
2006
|
id,
|
|
1854
|
-
(k) => {
|
|
2007
|
+
(k, mods) => {
|
|
2008
|
+
const m = mods;
|
|
2009
|
+
const has = (...names) => names.some((n) => m.includes(n));
|
|
2010
|
+
const init = {
|
|
2011
|
+
key: k,
|
|
2012
|
+
bubbles: true,
|
|
2013
|
+
cancelable: true,
|
|
2014
|
+
ctrlKey: has("control", "ctrl"),
|
|
2015
|
+
shiftKey: has("shift"),
|
|
2016
|
+
altKey: has("alt", "option"),
|
|
2017
|
+
metaKey: has("meta", "cmd", "command")
|
|
2018
|
+
};
|
|
1855
2019
|
const el = document.activeElement ?? document.body;
|
|
1856
|
-
for (const type of ["keydown", "keypress", "keyup"])
|
|
1857
|
-
el.dispatchEvent(new KeyboardEvent(type, { key: k, bubbles: true }));
|
|
1858
|
-
}
|
|
2020
|
+
for (const type of ["keydown", "keypress", "keyup"]) el.dispatchEvent(new KeyboardEvent(type, init));
|
|
1859
2021
|
},
|
|
1860
|
-
[key]
|
|
2022
|
+
[key, modifiers]
|
|
1861
2023
|
);
|
|
1862
2024
|
return { ok: true };
|
|
1863
2025
|
}
|
|
@@ -1903,7 +2065,7 @@
|
|
|
1903
2065
|
try {
|
|
1904
2066
|
return await screenshotViaDebugger(id, fullPage, enc, selector, frameIds);
|
|
1905
2067
|
} catch (err) {
|
|
1906
|
-
if (err instanceof CmdError && err.code === "SELECTOR_NOT_FOUND") throw err;
|
|
2068
|
+
if (err instanceof CmdError && (err.code === "SELECTOR_NOT_FOUND" || err.code === "TIMEOUT")) throw err;
|
|
1907
2069
|
return await screenshotViaVisibleTab(id, fullPage, enc);
|
|
1908
2070
|
}
|
|
1909
2071
|
}
|
|
@@ -1913,15 +2075,16 @@
|
|
|
1913
2075
|
const expr = String(cmd.params.expression ?? "");
|
|
1914
2076
|
const result = await execInTab(
|
|
1915
2077
|
id,
|
|
1916
|
-
(e) => {
|
|
2078
|
+
async (e, wait) => {
|
|
1917
2079
|
try {
|
|
1918
|
-
|
|
2080
|
+
let v = (0, eval)(e);
|
|
2081
|
+
if (wait && v && typeof v.then === "function") v = await v;
|
|
1919
2082
|
return { ok: true, value: v, type: typeof v };
|
|
1920
2083
|
} catch (err) {
|
|
1921
2084
|
return { ok: false, error: String(err) };
|
|
1922
2085
|
}
|
|
1923
2086
|
},
|
|
1924
|
-
[expr],
|
|
2087
|
+
[expr, cmd.params.awaitPromise === true],
|
|
1925
2088
|
"MAIN",
|
|
1926
2089
|
await this.frames(cmd, id)
|
|
1927
2090
|
);
|
|
@@ -1953,8 +2116,7 @@
|
|
|
1953
2116
|
// into the active task's downloads/). We wait for completion and report
|
|
1954
2117
|
// the absolute on-disk path so the server can relocate it. --
|
|
1955
2118
|
case "download_file": {
|
|
1956
|
-
const url = typeof cmd.params.url === "string" ? cmd.params.url :
|
|
1957
|
-
if (!url) throw new CmdError("DOWNLOAD_FAILED", "the extension download path requires a url");
|
|
2119
|
+
const url = typeof cmd.params.url === "string" ? cmd.params.url : await this.linkTarget(cmd);
|
|
1958
2120
|
const name = sanitizeDownloadName(
|
|
1959
2121
|
typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
|
|
1960
2122
|
);
|
|
@@ -2090,7 +2252,7 @@
|
|
|
2090
2252
|
const data = await withDebugger(id, async (t2) => {
|
|
2091
2253
|
const res = await chrome.debugger.sendCommand(t2, "Page.printToPDF", params);
|
|
2092
2254
|
return res.data ?? "";
|
|
2093
|
-
});
|
|
2255
|
+
}, 25e3);
|
|
2094
2256
|
if (!data) throw new CmdError("CDP_ERROR", "Chrome returned an empty PDF");
|
|
2095
2257
|
const t = await chrome.tabs.get(id);
|
|
2096
2258
|
return { dataBase64: data, mimeType: "application/pdf", url: t.url ?? "", title: t.title ?? "" };
|
|
@@ -2305,18 +2467,30 @@
|
|
|
2305
2467
|
const tab = await chrome.tabs.get(tabId).catch(() => null);
|
|
2306
2468
|
return !!tab?.url && evaluatePolicy(tab.url, "get_text", policy).ok;
|
|
2307
2469
|
}
|
|
2308
|
-
|
|
2470
|
+
var PAGE_WAIT_MS = 2e3;
|
|
2471
|
+
async function bounded(work, fallback) {
|
|
2472
|
+
let timer;
|
|
2473
|
+
const late = new Promise((resolve) => {
|
|
2474
|
+
timer = setTimeout(() => resolve(fallback), PAGE_WAIT_MS);
|
|
2475
|
+
});
|
|
2309
2476
|
try {
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
return css;
|
|
2314
|
-
} catch {
|
|
2315
|
-
return "";
|
|
2477
|
+
return await Promise.race([work.catch(() => fallback), late]);
|
|
2478
|
+
} finally {
|
|
2479
|
+
clearTimeout(timer);
|
|
2316
2480
|
}
|
|
2317
2481
|
}
|
|
2482
|
+
async function paint(tabId) {
|
|
2483
|
+
const theme = await bounded(
|
|
2484
|
+
chrome.scripting.executeScript({ target: { tabId }, func: readPageTheme }).then(([res]) => res?.result),
|
|
2485
|
+
null
|
|
2486
|
+
);
|
|
2487
|
+
if (theme === null) return "";
|
|
2488
|
+
const css = borderCss(pickBorderColor(theme ?? { themeColor: "", background: "", prefersDark: false }));
|
|
2489
|
+
const ok = await bounded(chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" }).then(() => true), false);
|
|
2490
|
+
return ok ? css : "";
|
|
2491
|
+
}
|
|
2318
2492
|
async function unpaint(tabId, css) {
|
|
2319
|
-
if (css) await chrome.scripting.removeCSS({ target: { tabId }, css, origin: "USER" })
|
|
2493
|
+
if (css) await bounded(chrome.scripting.removeCSS({ target: { tabId }, css, origin: "USER" }), void 0);
|
|
2320
2494
|
}
|
|
2321
2495
|
var TabBorder = class {
|
|
2322
2496
|
constructor(getPolicy) {
|
|
@@ -2393,7 +2567,7 @@
|
|
|
2393
2567
|
if (left > 0) return void this.capturing.set(tabId, left);
|
|
2394
2568
|
this.capturing.delete(tabId);
|
|
2395
2569
|
if ((await loadMarked())[tabId] !== css) return;
|
|
2396
|
-
await chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" })
|
|
2570
|
+
await bounded(chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" }), void 0);
|
|
2397
2571
|
});
|
|
2398
2572
|
}
|
|
2399
2573
|
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
|
-
"name": "MCP Browser Extension",
|
|
4
|
-
"version": "0.9.
|
|
5
|
-
"description": "Lets your AI agent
|
|
3
|
+
"name": "MCP Browser Extension – AI agent in your logged-in Chrome",
|
|
4
|
+
"version": "0.9.30",
|
|
5
|
+
"description": "Lets your AI agent use the Chrome you are signed into, through a local MCP server. Deny-all by default.",
|
|
6
6
|
"homepage_url": "https://mcp-browser-extension.com",
|
|
7
7
|
"minimum_chrome_version": "116",
|
|
8
8
|
"icons": {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-browser-extension",
|
|
3
3
|
"mcpName": "io.github.Mehmoodqureshi/mcp-browser-extension",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.30",
|
|
5
5
|
"description": "Drive your real Chrome browser over MCP — real logins, real cookies. A stdio MCP server (CLI) plus an MV3 extension, driving Chrome via chrome.scripting/chrome.tabs. Multi-tab batch automation, accessibility snapshots, deny-all security by default.",
|
|
6
6
|
"author": "Mehmood Ur Rehman Qureshi",
|
|
7
7
|
"license": "MIT",
|
|
@@ -57,6 +57,7 @@
|
|
|
57
57
|
"typecheck:ext": "tsc -p tsconfig.ext.json",
|
|
58
58
|
"clean": "rimraf dist",
|
|
59
59
|
"test": "npm run clean && npm run build && npm run build:ext && node --test dist/test/*.test.js",
|
|
60
|
+
"test:e2e": "npm run build && npm run build:ext && RUN_E2E=1 node --test --test-concurrency=1 dist/test/e2e/*.test.js",
|
|
60
61
|
"test:hitl": "npm run build && node dist/hitl/index.js",
|
|
61
62
|
"prepack": "npm run clean && npm run build && npm run build:ext"
|
|
62
63
|
},
|