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.
@@ -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;
@@ -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
- const next = (a.clear ? '' : node.value ?? '') + (a.text ?? '');
230
- setValue(node, next);
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': {
@@ -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
@@ -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 >= max)
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 >= max)
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 >= max)
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
- truncated = els.length > picked.length;
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
- // An extension that predates the op never advertised it: let the caller go field by field.
172
- if (!this.bridge.hasCap(this.activeProfile(), protocol_1.WIRE_CAP_FILL_FORM))
173
- return null;
174
- const res = (await this.send('fill_form', { ops: fields, ...frameParams(opts) }, { tabId: opts?.tabId, timeoutMs: (0, protocol_1.fillFormTimeoutMs)(fields.length) }));
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;
@@ -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: a click toggles it.
170
- await ex.click(target, opts);
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);
@@ -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: 'Make a tab active by tabId.', inputSchema: { tabId: zod_1.z.string() } },
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 tab = await ctx.ex.tabSelect((0, validators_1.requireString)(a, 'tabId'));
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((0, validators_1.optionalString)(a, 'url'), { active: (0, validators_1.optionalBoolean)(a, 'active') ?? false });
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
- return (0, envelopes_1.jsonResult)(await ctx.ex.getCookies({ tabId: tabId(a), url: (0, validators_1.optionalString)(a, 'url') }));
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
- const dir = (0, node_path_1.resolve)(ctx.policy.uploadsDir);
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 >= max) return;
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 >= max) break;
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 >= max) break;
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
- const next = (a.clear ? "" : node.value ?? "") + (a.text ?? "");
648
- setValue(node, next);
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 (!isTop(d)) return;
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)) done();
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") done();
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
- async function withDebugger(tabId, fn) {
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
- await ensureAttached(tabId);
1399
+ const once = async () => {
1400
+ await ensureAttached(tabId);
1401
+ return fn(target);
1402
+ };
1306
1403
  try {
1307
- return await fn(target);
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 ensureAttached(tabId);
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
- for (const type of ["keyDown", "keyUp"]) {
1338
- await chrome.debugger.sendCommand(t, "Input.dispatchKeyEvent", { type, key: "Enter", code: "Enter", windowsVirtualKeyCode: 13 });
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
- t.windowId,
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: false };
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
- return { url: t.url ?? url, title: t.title ?? "" };
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(id, withWait({ op: "type", selector: sel, text, clear }), frameIds);
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: "click", selector: op.selector } : { op: "type", selector: op.selector, text: op.value, clear: true }
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
- const v = (0, eval)(e);
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 : void 0;
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
- async function paint(tabId) {
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
- const [res] = await chrome.scripting.executeScript({ target: { tabId }, func: readPageTheme });
2311
- const css = borderCss(pickBorderColor(res?.result ?? { themeColor: "", background: "", prefersDark: false }));
2312
- await chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" });
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" }).catch(() => void 0);
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" }).catch(() => void 0);
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.29",
5
- "description": "Lets your AI agent (Claude, Cursor, Codex) use the Chrome you are signed into, through a local MCP server. Deny-all by default.",
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.29",
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
  },