mcp-browser-extension 0.9.30 → 0.9.32

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.
Files changed (50) hide show
  1. package/README.md +77 -14
  2. package/SETUP.md +40 -7
  3. package/dist/shared/activity.d.ts +25 -0
  4. package/dist/shared/activity.js +72 -0
  5. package/dist/shared/browser-permissions.d.ts +20 -0
  6. package/dist/shared/browser-permissions.js +40 -0
  7. package/dist/shared/client-auth.d.ts +28 -0
  8. package/dist/shared/client-auth.js +38 -0
  9. package/dist/shared/observers.d.ts +1 -1
  10. package/dist/shared/observers.js +1 -3
  11. package/dist/shared/policy.d.ts +5 -7
  12. package/dist/shared/policy.js +11 -26
  13. package/dist/shared/protocol.d.ts +4 -0
  14. package/dist/shared/protocol.js +3 -3
  15. package/dist/shared/screenshot.d.ts +4 -2
  16. package/dist/shared/screenshot.js +13 -8
  17. package/dist/src/access-browser.d.ts +15 -0
  18. package/dist/src/access-browser.js +202 -0
  19. package/dist/src/access.d.ts +2 -0
  20. package/dist/src/access.js +92 -18
  21. package/dist/src/bridge/client-identity.d.ts +10 -0
  22. package/dist/src/bridge/client-identity.js +28 -0
  23. package/dist/src/bridge/connection.d.ts +5 -0
  24. package/dist/src/bridge/connection.js +4 -12
  25. package/dist/src/bridge/peer.d.ts +4 -19
  26. package/dist/src/bridge/peer.js +17 -8
  27. package/dist/src/bridge/server.d.ts +7 -16
  28. package/dist/src/bridge/server.js +64 -17
  29. package/dist/src/bridge/site-grants.d.ts +1 -2
  30. package/dist/src/bridge/site-grants.js +4 -3
  31. package/dist/src/cli.js +1 -3
  32. package/dist/src/config.js +16 -5
  33. package/dist/src/executor/cdp-executor.d.ts +3 -0
  34. package/dist/src/executor/cdp-executor.js +69 -25
  35. package/dist/src/mcp/server.js +5 -3
  36. package/dist/src/mcp/tools.d.ts +0 -12
  37. package/dist/src/mcp/tools.js +48 -22
  38. package/dist/src/remote/key.js +12 -2
  39. package/dist/src/security/policy.d.ts +4 -0
  40. package/dist/src/security/policy.js +3 -1
  41. package/docs/BLUEPRINT.md +6 -1
  42. package/docs/CLIENT-APPROVAL.md +150 -0
  43. package/extension-dist/activity-dashboard.css +43 -0
  44. package/extension-dist/background.js +1036 -73
  45. package/extension-dist/manifest.json +10 -9
  46. package/extension-dist/options.html +101 -32
  47. package/extension-dist/options.js +558 -70
  48. package/extension-dist/popup.html +164 -90
  49. package/extension-dist/popup.js +576 -79
  50. package/package.json +3 -2
@@ -1,5 +1,41 @@
1
1
  "use strict";
2
2
  (() => {
3
+ // shared/client-auth.ts
4
+ var CLIENT_APPROVAL_CAP = "client-approval-v1";
5
+ var CLIENT_GRANT_MS = 8 * 60 * 60 * 1e3;
6
+ var CLIENT_CLOCK_SKEW_MS = 2 * 60 * 1e3;
7
+ var CLIENT_REQUEST_MS = 10 * 60 * 1e3;
8
+ var CLIENT_STORAGE_KEY = "clientConnections";
9
+ function clientName(value) {
10
+ return value.replace(/[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/g, "").trim().slice(0, 80) || "Unnamed app";
11
+ }
12
+ function canonical(value) {
13
+ if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
14
+ if (value && typeof value === "object") {
15
+ const obj = value;
16
+ return `{${Object.keys(obj).sort().map((k) => `${JSON.stringify(k)}:${canonical(obj[k])}`).join(",")}}`;
17
+ }
18
+ return JSON.stringify(value);
19
+ }
20
+ function clientSigningPayload(cmd, proof) {
21
+ return canonical(JSON.parse(JSON.stringify({
22
+ domain: CLIENT_APPROVAL_CAP,
23
+ v: cmd.v,
24
+ method: cmd.method,
25
+ params: cmd.params,
26
+ tabId: cmd.tabId ?? null,
27
+ timeoutMs: cmd.timeoutMs,
28
+ publicKey: proof.publicKey,
29
+ name: proof.name,
30
+ challenge: proof.challenge,
31
+ issuedAt: proof.issuedAt,
32
+ nonce: proof.nonce
33
+ })));
34
+ }
35
+ function isHealthProbe(cmd) {
36
+ return cmd.method === "ping_probe" && Object.keys(cmd.params ?? {}).length === 0;
37
+ }
38
+
3
39
  // shared/protocol.ts
4
40
  var PROTOCOL_VERSION = 1;
5
41
  var CLOSE_SUPERSEDED = 4e3;
@@ -40,6 +76,338 @@
40
76
  "ping_probe"
41
77
  ];
42
78
 
79
+ // extension/src/sw/cmd-error.ts
80
+ var CmdError = class extends Error {
81
+ constructor(code, message) {
82
+ super(message);
83
+ this.code = code;
84
+ }
85
+ code;
86
+ };
87
+
88
+ // extension/src/sw/client-approvals.ts
89
+ var MAX_CLIENTS = 64;
90
+ var MAX_NONCES = 4096;
91
+ var MAX_QUEUED_COMMANDS = 64;
92
+ var denied = (message) => {
93
+ throw new CmdError("POLICY_DENIED", message);
94
+ };
95
+ function decode(value) {
96
+ const raw = atob(value);
97
+ return Uint8Array.from(raw, (c) => c.charCodeAt(0));
98
+ }
99
+ var ClientApprovals = class {
100
+ constructor(deps) {
101
+ this.deps = deps;
102
+ this.now = deps.now ?? Date.now;
103
+ }
104
+ deps;
105
+ clients = [];
106
+ loaded = false;
107
+ storageFault = false;
108
+ revokingAll = false;
109
+ queuedCommands = 0;
110
+ revoking = /* @__PURE__ */ new Set();
111
+ queue = Promise.resolve();
112
+ nonces = /* @__PURE__ */ new Map();
113
+ now;
114
+ serial(fn) {
115
+ const result = this.queue.then(fn);
116
+ this.queue = result.catch(() => void 0);
117
+ return result;
118
+ }
119
+ async load() {
120
+ if (this.loaded) return;
121
+ const saved = await this.deps.read();
122
+ if (saved !== void 0 && (!Array.isArray(saved) || saved.length > MAX_CLIENTS || saved.some((c) => !c || typeof c.id !== "string" || !/^[a-f0-9]{64}$/.test(c.id) || typeof c.publicKey !== "string" || typeof c.name !== "string" || !["pending", "approved", "revoked"].includes(c.state) || !Number.isFinite(c.requestedAt) || !Number.isFinite(c.expiresAt)))) {
123
+ denied("Client approval storage is invalid. Browser access is blocked.");
124
+ }
125
+ this.clients = saved ?? [];
126
+ this.loaded = true;
127
+ this.deps.changed?.(this.clients);
128
+ }
129
+ async save(clients) {
130
+ await this.deps.write(clients);
131
+ this.clients = clients;
132
+ this.deps.changed?.(clients);
133
+ }
134
+ /** The returned check runs again immediately before execution, after tab/policy awaits. */
135
+ async authorize(cmd, verified) {
136
+ if (isHealthProbe(cmd)) return () => void 0;
137
+ if (this.queuedCommands >= MAX_QUEUED_COMMANDS) return denied("Too many pending client commands. Retry after the current commands finish.");
138
+ this.queuedCommands++;
139
+ return this.serial(async () => {
140
+ if (this.storageFault) return denied("Client approval storage failed. Save the decision again in the extension popup or Settings before continuing.");
141
+ try {
142
+ await this.load();
143
+ } catch {
144
+ return denied("Cannot load client approvals. Browser access is blocked.");
145
+ }
146
+ const p = cmd.authorization;
147
+ const now = this.now();
148
+ if (cmd.v !== PROTOCOL_VERSION || !p || typeof p.publicKey !== "string" || p.publicKey.length !== 124 || typeof p.signature !== "string" || p.signature.length !== 88 || typeof p.name !== "string" || p.name !== clientName(p.name) || typeof p.nonce !== "string" || !/^[a-f0-9-]{36}$/.test(p.nonce) || !Number.isSafeInteger(p.issuedAt) || Math.abs(now - p.issuedAt) > CLIENT_CLOCK_SKEW_MS || !this.deps.challenge() || p.challenge !== this.deps.challenge()) {
149
+ return denied("Client authorization is missing, stale or invalid. Update the server and extension, then reconnect.");
150
+ }
151
+ let id;
152
+ try {
153
+ const der = decode(p.publicKey);
154
+ const key = await crypto.subtle.importKey("spki", der, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]);
155
+ const valid = await crypto.subtle.verify(
156
+ { name: "ECDSA", hash: "SHA-256" },
157
+ key,
158
+ decode(p.signature),
159
+ new TextEncoder().encode(clientSigningPayload(cmd, p))
160
+ );
161
+ if (!valid) return denied("Client signature is invalid. Browser access is blocked.");
162
+ id = Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", der)), (b) => b.toString(16).padStart(2, "0")).join("");
163
+ } catch {
164
+ return denied("Client signature is invalid. Browser access is blocked.");
165
+ }
166
+ let client = this.clients.find((c) => c.id === id);
167
+ if (client && (client.publicKey !== p.publicKey || client.name !== p.name)) {
168
+ return denied("Client identity changed. Start a new connection and approve it in Chrome.");
169
+ }
170
+ verified?.(id, p.name);
171
+ if (this.revokingAll) return denied("Browser access is being stopped.");
172
+ if (this.revoking.has(id) || client?.state === "revoked") return denied(`Client access was revoked (${id.slice(0, 12)}). Only you can restore it in the extension popup or Settings.`);
173
+ if (!client || client.expiresAt <= now) {
174
+ const live = this.clients.filter((c) => c.id !== id && c.expiresAt > now);
175
+ if (live.length >= MAX_CLIENTS) return denied("Too many client connections. Remove access for unused apps in the extension popup or Settings and retry later.");
176
+ client = { id, publicKey: p.publicKey, name: p.name, state: "pending", requestedAt: now, expiresAt: now + CLIENT_REQUEST_MS };
177
+ try {
178
+ await this.save([...live, client]);
179
+ } catch {
180
+ return denied("Cannot save the approval request. Browser access is blocked.");
181
+ }
182
+ }
183
+ for (const [clientId, seen2] of this.nonces) {
184
+ for (const [nonce, expires] of seen2) if (expires < now) seen2.delete(nonce);
185
+ if (!seen2.size) this.nonces.delete(clientId);
186
+ }
187
+ let seen = this.nonces.get(id);
188
+ if (!seen) {
189
+ seen = /* @__PURE__ */ new Map();
190
+ this.nonces.set(id, seen);
191
+ }
192
+ if (seen.has(p.nonce)) return denied("Replayed client command refused.");
193
+ if (seen.size >= MAX_NONCES) return denied("Too many client commands from this app. Wait two minutes and retry.");
194
+ seen.set(p.nonce, p.issuedAt + CLIENT_CLOCK_SKEW_MS);
195
+ if (client.state !== "approved") {
196
+ return denied(`Please approve "${client.name}" (check key ${id.slice(0, 12)}): Chrome \u2192 MCP Browser Extension icon \u2192 App connections \u2192 Approve for 8 hours.`);
197
+ }
198
+ const authorizedGrant = client;
199
+ const check = () => {
200
+ const grant = this.clients.find((c) => c.id === id);
201
+ if (this.storageFault || this.revokingAll || this.revoking.has(id) || p.challenge !== this.deps.challenge() || grant !== authorizedGrant || grant.state !== "approved" || grant.expiresAt <= this.now()) {
202
+ denied("Client access expired, was revoked or disconnected. Approve the connection in Chrome before retrying.");
203
+ }
204
+ };
205
+ check();
206
+ return check;
207
+ }).finally(() => {
208
+ this.queuedCommands--;
209
+ });
210
+ }
211
+ revokeAll() {
212
+ this.revokingAll = true;
213
+ return this.serial(async () => {
214
+ try {
215
+ await this.load();
216
+ this.clients = this.clients.map((c) => ({ ...c, state: "revoked", expiresAt: this.now() + CLIENT_GRANT_MS }));
217
+ await this.save(this.clients);
218
+ this.storageFault = false;
219
+ this.revokingAll = false;
220
+ } catch (err) {
221
+ this.storageFault = true;
222
+ throw err;
223
+ }
224
+ });
225
+ }
226
+ /** Called exclusively by messages from the extension's own approval UI. */
227
+ decide(id, allow) {
228
+ if (!allow) this.revoking.add(id);
229
+ return this.serial(async () => {
230
+ await this.load();
231
+ const client = this.clients.find((c) => c.id === id);
232
+ if (!client) {
233
+ this.revoking.delete(id);
234
+ return denied("That connection no longer exists.");
235
+ }
236
+ if (allow && client.state === "pending" && client.expiresAt <= this.now()) {
237
+ return denied("This request expired. Retry the tool to request access again.");
238
+ }
239
+ const next = { ...client, state: allow ? "approved" : "revoked", expiresAt: this.now() + CLIENT_GRANT_MS };
240
+ if (!allow) this.clients = this.clients.map((c) => c.id === id ? next : c);
241
+ try {
242
+ await this.save(this.clients.map((c) => c.id === id ? next : c));
243
+ this.storageFault = false;
244
+ this.revoking.delete(id);
245
+ } catch (err) {
246
+ this.storageFault = true;
247
+ throw err;
248
+ }
249
+ });
250
+ }
251
+ };
252
+ function isApprovalPage(sender) {
253
+ return sender.id === chrome.runtime.id && [chrome.runtime.getURL("options.html"), chrome.runtime.getURL("popup.html")].includes(sender.url ?? "") && (sender.frameId === void 0 || sender.frameId === 0);
254
+ }
255
+
256
+ // shared/activity.ts
257
+ var ACCESS_STOPPED_KEY = "accessStopped";
258
+ var ACTIVITY_KEY = "recentActivity";
259
+ var ACTIVITY_LIMIT = 100;
260
+ function activityHost(url) {
261
+ if (url === "about:blank") return url;
262
+ if (url === "chrome://newtab/" || url === "chrome://newtab" || url === "chrome://new-tab-page/") return "chrome://newtab";
263
+ try {
264
+ const parsed = new URL(url);
265
+ return ["http:", "https:"].includes(parsed.protocol) ? parsed.hostname.slice(0, 253) : "";
266
+ } catch {
267
+ return "";
268
+ }
269
+ }
270
+ function activityEntry(value) {
271
+ return {
272
+ id: value.id.slice(0, 80),
273
+ at: value.at,
274
+ method: WIRE_METHODS.includes(value.method) ? value.method : "unknown",
275
+ host: activityHost(value.host) || activityHost(`https://${value.host}/`),
276
+ ...Number.isSafeInteger(value.tabId) && value.tabId >= 0 ? { tabId: value.tabId } : {},
277
+ app: value.key ? clientName(value.app) : "Unverified app",
278
+ key: /^[a-f0-9]{64}$/.test(value.key) ? value.key : "",
279
+ outcome: ["running", "done", "blocked", "failed", "interrupted"].includes(value.outcome) ? value.outcome : "failed"
280
+ };
281
+ }
282
+
283
+ // extension/src/sw/access-switch.ts
284
+ var AccessSwitch = class {
285
+ constructor(deps) {
286
+ this.deps = deps;
287
+ }
288
+ deps;
289
+ stopped = true;
290
+ loaded = false;
291
+ epoch = 0;
292
+ queue = Promise.resolve();
293
+ serial(fn) {
294
+ const result = this.queue.then(fn);
295
+ this.queue = result.catch(() => void 0);
296
+ return result;
297
+ }
298
+ initialize() {
299
+ return this.serial(async () => {
300
+ if (this.loaded) return;
301
+ const epoch = this.epoch;
302
+ const saved = await this.deps.read();
303
+ if (epoch !== this.epoch || this.epoch !== 0) return;
304
+ this.stopped = saved !== void 0 && saved !== false;
305
+ this.loaded = true;
306
+ });
307
+ }
308
+ check() {
309
+ const epoch = this.epoch;
310
+ const check = () => {
311
+ if (!this.loaded || this.stopped || epoch !== this.epoch) {
312
+ throw new CmdError("POLICY_DENIED", "Browser access is stopped. Use Resume connection in the extension popup or Settings, then approve your app again.");
313
+ }
314
+ };
315
+ check();
316
+ return check;
317
+ }
318
+ stop() {
319
+ this.stopped = true;
320
+ this.epoch++;
321
+ this.deps.disconnect();
322
+ return this.serial(async () => {
323
+ await this.deps.write(true);
324
+ this.loaded = true;
325
+ await this.deps.revoke();
326
+ });
327
+ }
328
+ resume() {
329
+ const epoch = this.epoch;
330
+ return this.serial(async () => {
331
+ if (epoch !== this.epoch) return;
332
+ this.stopped = true;
333
+ await this.deps.write(true);
334
+ await this.deps.revoke();
335
+ if (epoch !== this.epoch) return;
336
+ await this.deps.write(false);
337
+ if (epoch !== this.epoch) {
338
+ await this.deps.write(true);
339
+ return;
340
+ }
341
+ this.loaded = true;
342
+ this.stopped = false;
343
+ });
344
+ }
345
+ };
346
+
347
+ // extension/src/sw/activity.ts
348
+ var ActivityLog = class {
349
+ constructor(deps) {
350
+ this.deps = deps;
351
+ }
352
+ deps;
353
+ queue = Promise.resolve();
354
+ entries = [];
355
+ loaded = false;
356
+ generation = 0;
357
+ pending = 0;
358
+ serial(fn) {
359
+ const result = this.queue.then(fn);
360
+ this.queue = result.catch(() => void 0);
361
+ return result;
362
+ }
363
+ async load() {
364
+ if (this.loaded) return;
365
+ const saved = await this.deps.read();
366
+ this.entries = (Array.isArray(saved) ? saved : []).filter(
367
+ (v) => v && typeof v.id === "string" && Number.isFinite(v.at) && typeof v.method === "string" && typeof v.host === "string" && typeof v.app === "string" && typeof v.key === "string"
368
+ ).slice(-ACTIVITY_LIMIT).map((v) => activityEntry({
369
+ ...v,
370
+ outcome: v.outcome === "running" ? "interrupted" : v.outcome
371
+ }));
372
+ this.loaded = true;
373
+ }
374
+ /** Clear history also prevents already-running requests from reappearing. */
375
+ token() {
376
+ return this.generation;
377
+ }
378
+ record(entry, generation = this.generation) {
379
+ if (this.pending >= 256) return Promise.reject(new Error("Activity history is busy."));
380
+ this.pending++;
381
+ const safe = activityEntry(entry);
382
+ return this.serial(async () => {
383
+ if (generation !== this.generation) return;
384
+ await this.load();
385
+ const next = this.entries.filter((e) => e.id !== safe.id);
386
+ next.push(safe);
387
+ next.sort((a, b) => a.at - b.at);
388
+ this.entries = next.slice(-ACTIVITY_LIMIT);
389
+ await this.deps.write(this.entries);
390
+ }).finally(() => {
391
+ this.pending--;
392
+ });
393
+ }
394
+ clear() {
395
+ this.generation++;
396
+ return this.serial(async () => {
397
+ await this.deps.write([]);
398
+ this.entries = [];
399
+ this.loaded = true;
400
+ });
401
+ }
402
+ interrupt() {
403
+ return this.serial(async () => {
404
+ await this.load();
405
+ this.entries = this.entries.map((e) => e.outcome === "running" ? { ...e, outcome: "interrupted" } : e);
406
+ await this.deps.write(this.entries);
407
+ });
408
+ }
409
+ };
410
+
43
411
  // extension/src/sw/ws-client.ts
44
412
  function chromeVersion() {
45
413
  const m = /Chrome\/(\d+)/.exec(navigator.userAgent);
@@ -52,6 +420,7 @@
52
420
  deps;
53
421
  ws = null;
54
422
  state = "idle";
423
+ approvalChallenge = "";
55
424
  isConnected() {
56
425
  return this.state === "connected" && this.ws?.readyState === WebSocket.OPEN;
57
426
  }
@@ -68,11 +437,14 @@
68
437
  return;
69
438
  }
70
439
  this.ws = ws2;
440
+ this.approvalChallenge = crypto.randomUUID();
71
441
  ws2.onopen = () => {
442
+ if (this.ws !== ws2) return;
72
443
  const hello = {
73
444
  type: "hello",
74
445
  v: PROTOCOL_VERSION,
75
446
  token,
447
+ approvalChallenge: this.approvalChallenge,
76
448
  ext: { id: chrome.runtime.id, version: chrome.runtime.getManifest().version, chrome: chromeVersion() },
77
449
  profile: profile && profile.trim() ? profile.trim() : void 0,
78
450
  installId,
@@ -80,11 +452,12 @@
80
452
  // This build gates fail-closed and reports tab URLs on results, so the
81
453
  // server may skip its pre-flight tabs_list. An older build omits this and
82
454
  // the server keeps fetching the URL itself.
83
- caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM, WIRE_CAP_PROXY]
455
+ caps: [WIRE_CAP_TAB_URL, WIRE_CAP_FILL_FORM, WIRE_CAP_PROXY, CLIENT_APPROVAL_CAP]
84
456
  };
85
457
  ws2.send(JSON.stringify(hello));
86
458
  };
87
459
  ws2.onmessage = (ev) => {
460
+ if (this.ws !== ws2) return;
88
461
  let frame;
89
462
  try {
90
463
  frame = JSON.parse(typeof ev.data === "string" ? ev.data : "");
@@ -108,7 +481,13 @@
108
481
  this.send({ type: "pong", v: PROTOCOL_VERSION, ts: frame.ts });
109
482
  break;
110
483
  case "command":
111
- this.deps.onCommand(frame);
484
+ if (this.state === "connected" && frame.v === PROTOCOL_VERSION) {
485
+ this.deps.onCommand(frame, (result) => {
486
+ if (this.ws === ws2 && this.state === "connected" && ws2.readyState === WebSocket.OPEN) {
487
+ ws2.send(JSON.stringify(result));
488
+ }
489
+ });
490
+ }
112
491
  break;
113
492
  case "policy":
114
493
  this.deps.onPolicy(frame.policy);
@@ -121,8 +500,10 @@
121
500
  };
122
501
  ws2.onclose = (ev) => {
123
502
  const current = this.ws === ws2;
124
- if (current) this.ws = null;
125
- if (current && ev.code === CLOSE_SUPERSEDED) {
503
+ if (!current) return;
504
+ this.ws = null;
505
+ this.approvalChallenge = "";
506
+ if (ev.code === CLOSE_SUPERSEDED) {
126
507
  this.setState("superseded");
127
508
  this.deps.log("another browser paired under the same profile name took this connection");
128
509
  } else if (this.state !== "unauthorized" && this.state !== "superseded") {
@@ -144,6 +525,7 @@
144
525
  } catch {
145
526
  }
146
527
  this.ws = null;
528
+ this.approvalChallenge = "";
147
529
  }
148
530
  setState(state, detail) {
149
531
  this.state = state;
@@ -210,15 +592,14 @@
210
592
  }
211
593
  function normalizeDomainPattern(pattern) {
212
594
  let p = pattern.trim().toLowerCase();
213
- if (p === "*" || p === "*://*/*") return "*";
595
+ if (p === "*" || p === "*://*/*" || p === "<all_urls>") return "";
214
596
  p = p.replace(/^[a-z][a-z0-9+.-]*:\/\//, "");
215
597
  p = p.replace(/[/?#].*$/, "");
216
598
  p = p.replace(/^[^@]*@/, "").replace(/:\d+$/, "");
217
- return p;
599
+ return p === "*" || p === "*." ? "" : p;
218
600
  }
219
601
  function globMatches(host, pattern) {
220
602
  const p = normalizeDomainPattern(pattern);
221
- if (p === "*") return true;
222
603
  if (p.startsWith("*.")) {
223
604
  const base = p.slice(2);
224
605
  return host === base || host.endsWith("." + base);
@@ -259,14 +640,12 @@
259
640
  if (isAboutBlank(url) && NAVIGATION.has(method)) return { ok: true };
260
641
  if (!isDomainAllowed(url, policy)) {
261
642
  const host = hostOf(url) || url;
262
- return { ok: false, reason: blockedDomainMessage(method, host, policy) };
643
+ return { ok: false, reason: blockedDomainMessage(host) };
263
644
  }
264
645
  return { ok: true };
265
646
  }
266
- function blockedDomainMessage(method, host, policy) {
267
- const allowed = policy.allowDomains.filter((d) => d !== "*");
268
- const allowedLine = allowed.length ? `Currently allowed: ${allowed.join(", ")}.` : `Right now no sites are allowed.`;
269
- return `Blocked: "${method}" can't run on ${host} because it isn't on this browser tool's allowed-sites list. This is a safety limit (the tool drives your real, logged-in browser, so it only touches sites you've approved) \u2014 not an error. ${allowedLine} To allow ${host} right now, ask the user to click the MCP Browser Extension's toolbar icon in Chrome and click Allow next to ${host}: it applies at once, no restart. You cannot approve a site yourself. Or add --allow-domain "${host}" (or "*.${host}" to include subdomains) to the MCP Browser Extension server settings (its entry in your MCP config) and restart. To allow every site (less safe), use --unsafe-all-domains.`;
647
+ function blockedDomainMessage(host) {
648
+ return `Please allow access to ${host}: Chrome \u2192 MCP Browser Extension icon \u2192 Allow next to ${host}, then try again.`;
270
649
  }
271
650
 
272
651
  // shared/fill-form.ts
@@ -909,7 +1288,6 @@
909
1288
  for (const raw of policy.allowDomains ?? []) {
910
1289
  const p = normalizeDomainPattern(raw);
911
1290
  if (!p) continue;
912
- if (p === "*") return ["<all_urls>"];
913
1291
  out.add(`*://${p}/*`);
914
1292
  }
915
1293
  return [...out];
@@ -922,12 +1300,20 @@
922
1300
  var MAX_CAPTURE_PX = 16384;
923
1301
  function planScreenshot(dims, opts = {}) {
924
1302
  const dpr = dims.dpr && dims.dpr > 0 ? dims.dpr : 1;
1303
+ const zoom = dims.zoom && dims.zoom > 0 ? dims.zoom : 1;
925
1304
  const scale = (opts.scale && opts.scale > 0 ? opts.scale : DEFAULT_SCREENSHOT_SCALE) / dpr;
1305
+ const clip = (x, y, width, height) => ({
1306
+ x: x * zoom,
1307
+ y: y * zoom,
1308
+ width: width * zoom,
1309
+ height: height * zoom,
1310
+ scale
1311
+ });
926
1312
  if (opts.element) {
927
1313
  const realH = Math.max(1, Math.round(opts.element.h));
928
1314
  const clipH = Math.min(opts.element.h, MAX_CAPTURE_PX);
929
1315
  return {
930
- clip: { x: opts.element.x, y: opts.element.y, width: opts.element.w, height: clipH, scale },
1316
+ clip: clip(opts.element.x, opts.element.y, opts.element.w, clipH),
931
1317
  captureBeyondViewport: true,
932
1318
  width: Math.max(1, Math.round(opts.element.w)),
933
1319
  height: Math.min(realH, MAX_CAPTURE_PX),
@@ -938,7 +1324,7 @@
938
1324
  if (opts.fullPage) {
939
1325
  const clipH = Math.min(dims.fullH, MAX_CAPTURE_PX);
940
1326
  return {
941
- clip: { x: 0, y: 0, width: dims.fullW, height: clipH, scale },
1327
+ clip: clip(0, 0, dims.fullW, clipH),
942
1328
  captureBeyondViewport: true,
943
1329
  width: dims.fullW,
944
1330
  height: clipH,
@@ -947,7 +1333,7 @@
947
1333
  };
948
1334
  }
949
1335
  return {
950
- ...scale !== 1 ? { clip: { x: dims.scrollX ?? 0, y: dims.scrollY ?? 0, width: dims.w, height: dims.h, scale } } : {},
1336
+ ...scale !== 1 || zoom !== 1 ? { clip: clip(dims.scrollX ?? 0, dims.scrollY ?? 0, dims.w, dims.h) } : {},
951
1337
  captureBeyondViewport: false,
952
1338
  width: dims.w,
953
1339
  height: dims.h,
@@ -1000,6 +1386,34 @@
1000
1386
  };
1001
1387
  }
1002
1388
 
1389
+ // shared/browser-permissions.ts
1390
+ function siteOrigins(input) {
1391
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(input) && !/^https?:\/\//i.test(input)) return [];
1392
+ const pattern = normalizeDomainPattern(input);
1393
+ const host = pattern.startsWith("*.") ? pattern.slice(2) : pattern;
1394
+ if (!host || host.length > 253 || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)*$/.test(host)) return [];
1395
+ return [`http://${pattern}/*`, `https://${pattern}/*`];
1396
+ }
1397
+ function originForUrl(url) {
1398
+ try {
1399
+ const parsed = new URL(url);
1400
+ return /^https?:$/.test(parsed.protocol) ? `${parsed.protocol}//${parsed.hostname}/*` : null;
1401
+ } catch {
1402
+ return null;
1403
+ }
1404
+ }
1405
+ function isGlobalOrigin(origin) {
1406
+ return origin === "<all_urls>" || /^(?:\*|[a-z]+):\/\/\*\/.*$/i.test(origin);
1407
+ }
1408
+ var OPTIONAL_FEATURES = {
1409
+ cookies: { label: "Cookies", explanation: "Read cookies on websites you have allowed. Cookie values still follow your server settings." },
1410
+ downloads: { label: "Downloads", explanation: "Save files from websites you have allowed." }
1411
+ };
1412
+ var FEATURE_REQUESTS_KEY = "browserPermissionRequests";
1413
+ var PERMISSIONS_MIGRATION_KEY = "optionalBrowserPermissionsV1";
1414
+ var PROXY_AUTH_PERMISSIONS = ["webRequest", "webRequestAuthProvider"];
1415
+ var IP_LOOKUP_ORIGINS = ["https://ipinfo.io/*", "https://api.ipify.org/*"];
1416
+
1003
1417
  // extension/src/sw/proxy.ts
1004
1418
  var BYPASS = ["127.0.0.1", "localhost", "[::1]", "<local>"];
1005
1419
  var IP_LOOKUPS = [
@@ -1032,6 +1446,7 @@
1032
1446
  else await setting.clear({});
1033
1447
  }
1034
1448
  async function applyProxy(log) {
1449
+ if (!await chrome.permissions.contains({ permissions: ["proxy"] })) return;
1035
1450
  const cfg = await loadProxyConfig();
1036
1451
  if (cfg?.enabled) {
1037
1452
  await chrome.proxy.settings.set({
@@ -1070,6 +1485,9 @@
1070
1485
  });
1071
1486
  }
1072
1487
  async function lookupExitIp() {
1488
+ if (!await chrome.permissions.contains({ origins: IP_LOOKUP_ORIGINS })) {
1489
+ return { exitIp: null, error: "Please open Settings \u2192 Proxy \u2192 Check IP to allow the IP lookup." };
1490
+ }
1073
1491
  let lastError = "lookup failed";
1074
1492
  for (const svc of IP_LOOKUPS) {
1075
1493
  try {
@@ -1091,6 +1509,17 @@
1091
1509
  }
1092
1510
  async function proxyStatus() {
1093
1511
  const cfg = await loadProxyConfig();
1512
+ if (!await chrome.permissions.contains({ permissions: ["proxy"] })) {
1513
+ return {
1514
+ enabled: false,
1515
+ server: null,
1516
+ auth: false,
1517
+ control: "not_granted",
1518
+ webrtcProtected: false,
1519
+ exitIp: null,
1520
+ error: "Proxy permission is off. To use it, open Settings \u2192 Proxy \u2192 Save proxy."
1521
+ };
1522
+ }
1094
1523
  const current = await readSetting(chrome.proxy.settings, { incognito: false });
1095
1524
  const webrtcProtected = await hasPrivacyPermission() && (await readSetting(chrome.privacy.network.webRTCIPHandlingPolicy, {})).value === "disable_non_proxied_udp";
1096
1525
  return {
@@ -1103,15 +1532,52 @@
1103
1532
  };
1104
1533
  }
1105
1534
 
1535
+ // extension/src/sw/browser-permissions.ts
1536
+ var initialization;
1537
+ function initializeBrowserPermissions() {
1538
+ return initialization ??= (async () => {
1539
+ await removeGlobalOrigins();
1540
+ const saved = await chrome.storage.local.get([PERMISSIONS_MIGRATION_KEY, PROXY_STORAGE_KEY]);
1541
+ if (!saved[PERMISSIONS_MIGRATION_KEY]) {
1542
+ const permissions = ["cookies", "downloads"];
1543
+ if (!parseProxyConfig(saved[PROXY_STORAGE_KEY])?.enabled) permissions.push(...PROXY_AUTH_PERMISSIONS);
1544
+ await chrome.permissions.remove({ permissions });
1545
+ await chrome.storage.local.set({ [PERMISSIONS_MIGRATION_KEY]: true });
1546
+ }
1547
+ })();
1548
+ }
1549
+ async function removeGlobalOrigins() {
1550
+ const all = await chrome.permissions.getAll();
1551
+ const origins = (all.origins ?? []).filter(isGlobalOrigin);
1552
+ if (origins.length) await chrome.permissions.remove({ origins });
1553
+ const remaining = await chrome.permissions.getAll();
1554
+ if (remaining.origins?.some(isGlobalOrigin)) throw new Error("Please remove all-site access in Chrome before continuing.");
1555
+ }
1556
+ async function requireSitePermission(url) {
1557
+ await initializeBrowserPermissions();
1558
+ if (url === "about:blank") return;
1559
+ const origin = originForUrl(url);
1560
+ if (origin && await chrome.permissions.contains({ origins: [origin] })) return;
1561
+ throw new CmdError("POLICY_DENIED", blockedDomainMessage(hostOf(url) || url));
1562
+ }
1563
+ var requestWrite = Promise.resolve();
1564
+ async function requireFeaturePermission(feature) {
1565
+ await initializeBrowserPermissions();
1566
+ if (await chrome.permissions.contains({ permissions: [feature] })) return;
1567
+ const write = requestWrite.then(async () => {
1568
+ const saved = (await chrome.storage.local.get(FEATURE_REQUESTS_KEY))[FEATURE_REQUESTS_KEY];
1569
+ const requests = Array.isArray(saved) ? saved.filter((x) => typeof x === "string" && x in OPTIONAL_FEATURES) : [];
1570
+ await chrome.storage.local.set({ [FEATURE_REQUESTS_KEY]: [.../* @__PURE__ */ new Set([...requests, feature])] });
1571
+ });
1572
+ requestWrite = write.catch(() => void 0);
1573
+ await write;
1574
+ const label = OPTIONAL_FEATURES[feature].label;
1575
+ throw new CmdError("POLICY_DENIED", `Please allow ${label.toLowerCase()}: Chrome \u2192 MCP Browser Extension icon \u2192 Extra permissions \u2192 Allow ${label.toLowerCase()}, then try again.`);
1576
+ }
1577
+
1106
1578
  // extension/src/sw/executor.ts
1107
- var CmdError = class extends Error {
1108
- constructor(code, message) {
1109
- super(message);
1110
- this.code = code;
1111
- }
1112
- code;
1113
- };
1114
1579
  var DOWNLOAD_TIMEOUT_MS = 12e4;
1580
+ var MAX_DOWNLOAD_BYTES2 = 50 * 1024 * 1024;
1115
1581
  function waitForDownloadComplete(id) {
1116
1582
  return new Promise((resolve, reject) => {
1117
1583
  let settled = false;
@@ -1183,6 +1649,7 @@
1183
1649
  }
1184
1650
  var TABLESS = /* @__PURE__ */ new Set(["tabs_list", "tab_new", "ping_probe", "download_file", "proxy_status"]);
1185
1651
  async function resolveTab(cmd) {
1652
+ if (cmd.method === "download_file" && typeof cmd.params.url !== "string") return targetTab(cmd);
1186
1653
  if (TABLESS.has(cmd.method)) return null;
1187
1654
  return targetTab(cmd);
1188
1655
  }
@@ -1233,8 +1700,9 @@
1233
1700
  const w = cmd.params.waitUntil;
1234
1701
  return w === "domcontentloaded" || w === "networkidle" ? w : "load";
1235
1702
  }
1236
- function armNavigationWait(tabId, waitUntil, timeoutMs = 3e4) {
1703
+ function armNavigationWait(tabId, waitUntil, timeoutMs = 3e4, expectedUrl) {
1237
1704
  const start = Date.now();
1705
+ const destination = expectedUrl === void 0 ? void 0 : new URL(expectedUrl).href;
1238
1706
  const NETWORK_QUIET_MS = 500;
1239
1707
  let done = () => void 0;
1240
1708
  const finished = new Promise((resolve) => {
@@ -1250,21 +1718,26 @@
1250
1718
  };
1251
1719
  const onBefore = (d) => {
1252
1720
  if (!isTop(d)) return;
1721
+ if (destination && !started && d.url !== destination) return;
1253
1722
  started = true;
1254
1723
  startUrl = d.url;
1255
1724
  committed = false;
1256
1725
  };
1257
1726
  const onCommitted = (d) => {
1258
- if (isTop(d)) committed = true;
1727
+ if (!isTop(d)) return;
1728
+ const redirected = d.transitionQualifiers.some((q) => q === "server_redirect" || q === "client_redirect");
1729
+ if (destination && (!started || d.url !== startUrl && !redirected)) return;
1730
+ startUrl = d.url;
1731
+ committed = true;
1259
1732
  };
1260
1733
  const onDom = (d) => {
1261
- if (isTop(d) && committed && waitUntil === "domcontentloaded") done();
1734
+ if (isTop(d) && committed && (!destination || d.url === startUrl) && waitUntil === "domcontentloaded") done();
1262
1735
  };
1263
1736
  const onCompleted = (d) => {
1264
- if (isTop(d) && committed) reached();
1737
+ if (isTop(d) && committed && (!destination || d.url === startUrl)) reached();
1265
1738
  };
1266
1739
  const onSameDoc = (d) => {
1267
- if (isTop(d)) done();
1740
+ if (isTop(d) && (!destination || d.url === destination)) done();
1268
1741
  };
1269
1742
  const onError = (d) => {
1270
1743
  if (!isTop(d) || !started) return;
@@ -1397,6 +1870,7 @@
1397
1870
  return locks.run(`dbg:${tabId}`, async () => {
1398
1871
  const target = { tabId };
1399
1872
  const once = async () => {
1873
+ await requireSitePermission((await chrome.tabs.get(tabId)).url ?? "");
1400
1874
  await ensureAttached(tabId);
1401
1875
  return fn(target);
1402
1876
  };
@@ -1458,7 +1932,7 @@
1458
1932
  const found = await execOp(tabId, { op: "measure", selector: selector ?? null }, frameIds);
1459
1933
  const top = selector && frameIds && frameIds.length > 0 ? await execOp(tabId, { op: "measure", selector: null }) : found;
1460
1934
  return {
1461
- dims: top.dims,
1935
+ dims: { ...top.dims, zoom: await chrome.tabs.getZoom(tabId) },
1462
1936
  element: found.element ?? null,
1463
1937
  missing: found.missing === true
1464
1938
  };
@@ -1504,6 +1978,43 @@
1504
1978
  fullHeight: plan.fullHeight
1505
1979
  };
1506
1980
  }
1981
+ var SETTLE_MS = 5e3;
1982
+ var SETTLE_QUIET_MS = 400;
1983
+ async function waitForSettled(tabId, quietMs = 0) {
1984
+ const deadline = Date.now() + SETTLE_MS;
1985
+ let last = "";
1986
+ let since = Date.now();
1987
+ while (Date.now() < deadline) {
1988
+ const t = await chrome.tabs.get(tabId).catch(() => null);
1989
+ if (!t) return;
1990
+ const state = `${t.status}|${t.url}`;
1991
+ if (state !== last) {
1992
+ last = state;
1993
+ since = Date.now();
1994
+ }
1995
+ if (t.status === "complete" && Date.now() - since >= quietMs) return;
1996
+ await delay(100);
1997
+ }
1998
+ }
1999
+ async function pageMoved(tabId, url) {
2000
+ const t = await chrome.tabs.get(tabId).catch(() => null);
2001
+ return !!t && (t.status === "loading" || t.url !== url);
2002
+ }
2003
+ async function scaleShot(dataUrl, width, height, enc) {
2004
+ const raw = dataUrl.split(",")[1] ?? "";
2005
+ if (!(width > 0 && height > 0)) return raw;
2006
+ const bitmap = await createImageBitmap(await (await fetch(dataUrl)).blob());
2007
+ if (Math.abs(bitmap.width - width) <= 1) return raw;
2008
+ const canvas = new OffscreenCanvas(width, height);
2009
+ canvas.getContext("2d")?.drawImage(bitmap, 0, 0, width, height);
2010
+ const blob = await canvas.convertToBlob(
2011
+ enc.format === "jpeg" ? { type: "image/jpeg", quality: enc.quality / 100 } : { type: "image/png" }
2012
+ );
2013
+ const bytes = new Uint8Array(await blob.arrayBuffer());
2014
+ let bin = "";
2015
+ for (let i = 0; i < bytes.length; i += 32768) bin += String.fromCharCode(...bytes.subarray(i, i + 32768));
2016
+ return btoa(bin);
2017
+ }
1507
2018
  async function screenshotViaVisibleTab(tabId, fullPage, enc) {
1508
2019
  let t = await chrome.tabs.get(tabId);
1509
2020
  let restore;
@@ -1532,8 +2043,11 @@
1532
2043
  );
1533
2044
  const viewportH = dims?.h ?? 0;
1534
2045
  const fullH = dims?.full ?? viewportH;
2046
+ const scale = enc.scale ?? DEFAULT_SCREENSHOT_SCALE;
2047
+ const out = Math.round((dims?.w ?? 0) * scale);
2048
+ const outH = Math.round(viewportH * scale);
1535
2049
  return {
1536
- dataBase64: dataUrl.split(",")[1] ?? "",
2050
+ dataBase64: await scaleShot(dataUrl, out, outH, enc).catch(() => dataUrl.split(",")[1] ?? ""),
1537
2051
  mimeType: enc.format === "jpeg" ? "image/jpeg" : "image/png",
1538
2052
  width: dims?.w ?? 0,
1539
2053
  height: viewportH,
@@ -1591,10 +2105,12 @@
1591
2105
  * allowlisted page embedding a third-party iframe is the ordinary case, not an
1592
2106
  * exotic one.
1593
2107
  */
1594
- constructor(getPolicy = () => null) {
2108
+ constructor(getPolicy = () => null, onBlocked) {
1595
2109
  this.getPolicy = getPolicy;
2110
+ this.onBlocked = onBlocked;
1596
2111
  }
1597
2112
  getPolicy;
2113
+ onBlocked;
1598
2114
  /** The absolute URL a link (or img/source) element points at, for a download
1599
2115
  * by element. Gated like a download by URL: the router only checked the tab
1600
2116
  * the link sits in, and the link can lead to any site. */
@@ -1619,7 +2135,7 @@
1619
2135
  frameIds
1620
2136
  );
1621
2137
  if (!url) throw new CmdError("DOWNLOAD_FAILED", `the element ${sel} has no href or src to download`);
1622
- this.assertAllowed(url, "download_file");
2138
+ await this.assertAllowed(url, "download_file");
1623
2139
  return url;
1624
2140
  }
1625
2141
  allowed(url, method) {
@@ -1627,10 +2143,21 @@
1627
2143
  return !!policy && evaluatePolicy(url, method, policy).ok;
1628
2144
  }
1629
2145
  /** Gate a URL the router could not see (it gates the tab, not arguments). */
1630
- assertAllowed(url, method) {
2146
+ async assertAllowed(url, method) {
1631
2147
  const policy = this.getPolicy();
1632
2148
  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);
2149
+ if (!verdict.ok) {
2150
+ const host = hostOf(url);
2151
+ if (policy && host && !isDomainAllowed(url, policy)) this.onBlocked?.(host, method);
2152
+ throw new CmdError("POLICY_DENIED", verdict.reason);
2153
+ }
2154
+ try {
2155
+ await requireSitePermission(url);
2156
+ } catch (err) {
2157
+ const host = hostOf(url);
2158
+ if (host) this.onBlocked?.(host, method);
2159
+ throw err;
2160
+ }
1634
2161
  }
1635
2162
  /**
1636
2163
  * Resolve `frameId` / `allFrames` params into the frame ids an injection may
@@ -1664,15 +2191,15 @@
1664
2191
  const policy = this.getPolicy();
1665
2192
  if (!policy) throw new CmdError("POLICY_DENIED", "no policy has arrived yet, so no frame is authorized");
1666
2193
  const allowed = [];
1667
- const denied = [];
2194
+ const denied2 = [];
1668
2195
  for (const f of seen) {
1669
2196
  if (evaluatePolicy(f.url, cmd.method, policy).ok) allowed.push(f.frameId);
1670
- else denied.push(f.url || `frame ${f.frameId}`);
2197
+ else denied2.push(f.url || `frame ${f.frameId}`);
1671
2198
  }
1672
2199
  if (allowed.length === 0) {
1673
2200
  throw new CmdError(
1674
2201
  "POLICY_DENIED",
1675
- `no targeted frame is on the allowed-sites list (skipped: ${denied.slice(0, 5).join(", ")})`
2202
+ `no targeted frame is on the allowed-sites list (skipped: ${denied2.slice(0, 5).join(", ")})`
1676
2203
  );
1677
2204
  }
1678
2205
  return allowed;
@@ -1700,7 +2227,7 @@
1700
2227
  }
1701
2228
  case "tab_new": {
1702
2229
  const url = typeof cmd.params.url === "string" ? cmd.params.url : void 0;
1703
- if (url !== void 0) this.assertAllowed(url, "navigate");
2230
+ if (url !== void 0) await this.assertAllowed(url, "navigate");
1704
2231
  const active = cmd.params.active !== false;
1705
2232
  const BLANK = /^(about:blank|chrome:\/\/newtab|chrome:\/\/new-tab-page|edge:\/\/newtab)/i;
1706
2233
  const claim = await locks.run("tab_new", async () => {
@@ -1722,7 +2249,7 @@
1722
2249
  return { id: created.id, reused: false, needsNav: url !== void 0 };
1723
2250
  });
1724
2251
  if (claim.needsNav) {
1725
- const nav = armNavigationWait(claim.id, "load", 25e3);
2252
+ const nav = armNavigationWait(claim.id, "load", 25e3, url);
1726
2253
  await chrome.tabs.update(claim.id, { url }).catch((err) => {
1727
2254
  nav.cancel();
1728
2255
  throw err;
@@ -1745,7 +2272,7 @@
1745
2272
  case "navigate": {
1746
2273
  const id = await targetTab2(cmd);
1747
2274
  const url = String(cmd.params.url);
1748
- const nav = armNavigationWait(id, waitUntilOf(cmd));
2275
+ const nav = armNavigationWait(id, waitUntilOf(cmd), 3e4, url);
1749
2276
  await chrome.tabs.update(id, { url }).catch((err) => {
1750
2277
  nav.cancel();
1751
2278
  throw err;
@@ -1843,7 +2370,8 @@
1843
2370
  const t = await chrome.tabs.get(id);
1844
2371
  const url = typeof cmd.params.url === "string" ? cmd.params.url : t.url;
1845
2372
  if (!url) throw new CmdError("BAD_ARGS", "no url to read cookies for");
1846
- if (url !== t.url) this.assertAllowed(url, "get_cookies");
2373
+ if (url !== t.url) await this.assertAllowed(url, "get_cookies");
2374
+ await requireFeaturePermission("cookies");
1847
2375
  const cookies = await chrome.cookies.getAll({ url });
1848
2376
  return {
1849
2377
  cookies: cookies.map((c) => ({
@@ -2062,9 +2590,24 @@
2062
2590
  if (selector && !await waitForSelector(id, selector, frameIds, READ_WAIT_MS)) {
2063
2591
  throw notFound(selector, await execOp(id, { op: "waitSelector", selector, timeoutMs: 0 }, frameIds));
2064
2592
  }
2065
- try {
2593
+ await waitForSettled(id);
2594
+ const shoot = async () => {
2595
+ const at = (await chrome.tabs.get(id)).url;
2596
+ if (at) await this.assertAllowed(at, "screenshot");
2597
+ const shot = await screenshotViaDebugger(id, fullPage, enc, selector, frameIds).catch(async (err) => {
2598
+ if (!await pageMoved(id, at)) throw err;
2599
+ return null;
2600
+ });
2601
+ if (shot && !await pageMoved(id, at)) return shot;
2602
+ await waitForSettled(id, SETTLE_QUIET_MS);
2603
+ const now = (await chrome.tabs.get(id)).url;
2604
+ if (now) await this.assertAllowed(now, "screenshot");
2066
2605
  return await screenshotViaDebugger(id, fullPage, enc, selector, frameIds);
2606
+ };
2607
+ try {
2608
+ return await shoot();
2067
2609
  } catch (err) {
2610
+ if (err instanceof CmdError && err.code === "POLICY_DENIED") throw err;
2068
2611
  if (err instanceof CmdError && (err.code === "SELECTOR_NOT_FOUND" || err.code === "TIMEOUT")) throw err;
2069
2612
  return await screenshotViaVisibleTab(id, fullPage, enc);
2070
2613
  }
@@ -2117,10 +2660,74 @@
2117
2660
  // the absolute on-disk path so the server can relocate it. --
2118
2661
  case "download_file": {
2119
2662
  const url = typeof cmd.params.url === "string" ? cmd.params.url : await this.linkTarget(cmd);
2663
+ await this.assertAllowed(url, "download_file");
2664
+ await requireFeaturePermission("downloads");
2120
2665
  const name = sanitizeDownloadName(
2121
2666
  typeof cmd.params.suggestedName === "string" ? cmd.params.suggestedName : void 0
2122
2667
  );
2123
- const downloadId = await chrome.downloads.download({ url, filename: name });
2668
+ let response;
2669
+ try {
2670
+ response = await fetch(url, { credentials: "omit", redirect: "error", signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS) });
2671
+ } catch {
2672
+ throw new CmdError("DOWNLOAD_FAILED", "download request failed or its redirect was not permitted");
2673
+ }
2674
+ if (!response.url) {
2675
+ await response.body?.cancel().catch(() => void 0);
2676
+ throw new CmdError("DOWNLOAD_FAILED", "download did not resolve to a valid address");
2677
+ }
2678
+ try {
2679
+ await this.assertAllowed(response.url, "download_file");
2680
+ } catch (err) {
2681
+ await response.body?.cancel().catch(() => void 0);
2682
+ throw err;
2683
+ }
2684
+ if (!response.ok) {
2685
+ await response.body?.cancel().catch(() => void 0);
2686
+ throw new CmdError("DOWNLOAD_FAILED", `download returned HTTP ${response.status}`);
2687
+ }
2688
+ const declaredLength = Number(response.headers.get("content-length"));
2689
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_DOWNLOAD_BYTES2) {
2690
+ await response.body?.cancel().catch(() => void 0);
2691
+ throw new CmdError("DOWNLOAD_FAILED", "download exceeds the 50 MiB extension limit");
2692
+ }
2693
+ const chunks = [];
2694
+ let totalBytes = 0;
2695
+ const reader = response.body?.getReader();
2696
+ if (reader) {
2697
+ try {
2698
+ while (true) {
2699
+ const { done, value } = await reader.read();
2700
+ if (done) break;
2701
+ totalBytes += value.byteLength;
2702
+ if (totalBytes > MAX_DOWNLOAD_BYTES2) {
2703
+ await reader.cancel();
2704
+ throw new CmdError("DOWNLOAD_FAILED", "download exceeds the 50 MiB extension limit");
2705
+ }
2706
+ chunks.push(value);
2707
+ }
2708
+ } catch (err) {
2709
+ await reader.cancel().catch(() => void 0);
2710
+ throw err;
2711
+ }
2712
+ }
2713
+ const bytesView = new Uint8Array(totalBytes);
2714
+ let copied = 0;
2715
+ for (const chunk of chunks) {
2716
+ bytesView.set(chunk, copied);
2717
+ copied += chunk.byteLength;
2718
+ }
2719
+ const encoded = [];
2720
+ const base64ChunkBytes = 24576;
2721
+ for (let offset = 0; offset < bytesView.length; offset += base64ChunkBytes) {
2722
+ const end = Math.min(bytesView.length, offset + base64ChunkBytes);
2723
+ let binary = "";
2724
+ for (let i = offset; i < end; i += 16384) {
2725
+ binary += String.fromCharCode(...bytesView.subarray(i, Math.min(end, i + 16384)));
2726
+ }
2727
+ encoded.push(btoa(binary));
2728
+ }
2729
+ const dataUrl = `data:application/octet-stream;base64,${encoded.join("")}`;
2730
+ const downloadId = await chrome.downloads.download({ url: dataUrl, filename: name, saveAs: false });
2124
2731
  const item = await waitForDownloadComplete(downloadId);
2125
2732
  const bytes = item.fileSize > 0 ? item.fileSize : item.bytesReceived;
2126
2733
  return {
@@ -2287,8 +2894,25 @@
2287
2894
  }
2288
2895
  }
2289
2896
  deps;
2290
- async dispatch(cmd) {
2897
+ async dispatch(cmd, send = this.deps.send) {
2898
+ const record = this.deps.activity?.();
2899
+ const entry = cmd.method === "ping_probe" ? null : {
2900
+ id: crypto.randomUUID(),
2901
+ at: Date.now(),
2902
+ method: cmd.method,
2903
+ host: "",
2904
+ app: "",
2905
+ key: "",
2906
+ outcome: "running"
2907
+ };
2908
+ let ran = false;
2909
+ let checkStopped;
2291
2910
  try {
2911
+ checkStopped = this.deps.access?.();
2912
+ if (isHealthProbe(cmd)) {
2913
+ send({ type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data: { ok: true } });
2914
+ return;
2915
+ }
2292
2916
  const policy = this.deps.getPolicy();
2293
2917
  if (!policy && cmd.method !== "ping_probe") {
2294
2918
  throw new CmdError(
@@ -2296,25 +2920,101 @@
2296
2920
  "no policy has arrived from the MCP Browser Extension server yet, so this extension is refusing every command"
2297
2921
  );
2298
2922
  }
2923
+ if (!this.deps.authorize) throw new CmdError("POLICY_DENIED", "Client approval enforcement is unavailable.");
2924
+ const checkApproval = await this.deps.authorize(cmd, (id, name) => {
2925
+ if (entry) {
2926
+ entry.key = id;
2927
+ entry.app = name;
2928
+ }
2929
+ });
2930
+ const checkAccess = () => {
2931
+ checkStopped?.();
2932
+ checkApproval();
2933
+ };
2934
+ if (cmd.method === "ping_probe") {
2935
+ checkAccess();
2936
+ send({ type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data: {} });
2937
+ return;
2938
+ }
2299
2939
  const tab = await resolveTab(cmd);
2940
+ if (entry && tab !== null) entry.tabId = tab;
2941
+ if (entry && cmd.method === "tab_new") entry.host = activityHost(
2942
+ typeof cmd.params.url === "string" ? cmd.params.url : "chrome://newtab"
2943
+ );
2944
+ if (cmd.method === "tab_new" && typeof cmd.params.url === "string" && cmd.params.url !== "about:blank") {
2945
+ const url = cmd.params.url;
2946
+ if (entry) entry.host = activityHost(url);
2947
+ if (isOwnExtensionPage(url)) throw new CmdError("POLICY_DENIED", "MCP Browser Extension never acts on its own pages");
2948
+ const verdict = evaluatePolicy(url, "navigate", policy);
2949
+ if (!verdict.ok) {
2950
+ this.deps.onBlocked?.(hostOf(url), cmd.method);
2951
+ throw new CmdError("POLICY_DENIED", verdict.reason);
2952
+ }
2953
+ try {
2954
+ await requireSitePermission(url);
2955
+ } catch (err) {
2956
+ this.deps.onBlocked?.(hostOf(url), cmd.method);
2957
+ throw err;
2958
+ }
2959
+ }
2960
+ const targetUrl = tab !== null ? await observedTabUrl(cmd, tab) : "";
2961
+ if (tab !== null && isOwnExtensionPage(targetUrl)) {
2962
+ throw new CmdError("POLICY_DENIED", "MCP Browser Extension never acts on its own pages");
2963
+ }
2964
+ if (entry && tab !== null && !isUrlGated(cmd.method)) entry.host = activityHost(targetUrl);
2300
2965
  if (policy) {
2301
2966
  const url = isUrlGated(cmd.method) ? await urlForCommand(cmd, tab) : "";
2967
+ if (entry && url) entry.host = activityHost(url);
2302
2968
  if (url && isOwnExtensionPage(url)) {
2303
2969
  throw new CmdError("POLICY_DENIED", "MCP Browser Extension never acts on its own pages");
2304
2970
  }
2305
2971
  const verdict = evaluatePolicy(url, cmd.method, policy);
2306
- if (!verdict.ok) throw new CmdError("POLICY_DENIED", verdict.reason);
2972
+ if (!verdict.ok) {
2973
+ const host = hostOf(url);
2974
+ if (host && isUrlGated(cmd.method) && !isDomainAllowed(url, policy)) this.deps.onBlocked?.(host, cmd.method);
2975
+ throw new CmdError("POLICY_DENIED", verdict.reason);
2976
+ }
2977
+ if (isUrlGated(cmd.method) && url !== "about:blank") {
2978
+ try {
2979
+ await requireSitePermission(url);
2980
+ } catch (err) {
2981
+ const host = hostOf(url);
2982
+ if (host) this.deps.onBlocked?.(host, cmd.method);
2983
+ throw err;
2984
+ }
2985
+ }
2307
2986
  }
2987
+ checkAccess();
2988
+ const run = () => {
2989
+ checkAccess();
2990
+ ran = true;
2991
+ if (entry) record?.({ ...entry });
2992
+ return this.deps.exec.run(cmd, tab);
2993
+ };
2308
2994
  const border2 = this.deps.border;
2309
- const data = border2 && CAPTURES.has(cmd.method) ? await border2.aroundCapture(tab, () => this.deps.exec.run(cmd, tab)) : await this.deps.exec.run(cmd, tab);
2995
+ const data = border2 && CAPTURES.has(cmd.method) ? await border2.aroundCapture(tab, run) : await run();
2996
+ checkAccess();
2997
+ if (entry && cmd.method === "tab_new") {
2998
+ const opened = data;
2999
+ const id = Number(opened?.tabId?.split(":")[2]);
3000
+ if (Number.isSafeInteger(id) && id >= 0) entry.tabId = id;
3001
+ if (opened?.url) entry.host = activityHost(opened.url) || entry.host;
3002
+ }
2310
3003
  if (border2) void this.markBorder(border2, cmd, tab, data);
2311
3004
  const frame = { type: "result", v: PROTOCOL_VERSION, id: cmd.id, ok: true, data };
2312
3005
  const tabUrl = await observedTabUrl(cmd, tab);
3006
+ checkAccess();
2313
3007
  if (tabUrl) frame.tabUrl = tabUrl;
2314
- this.deps.send(frame);
3008
+ if (entry) {
3009
+ if (!entry.host && tab !== null && tabUrl) entry.host = activityHost(tabUrl);
3010
+ const result = data;
3011
+ entry.outcome = cmd.method === "fill_form" && result?.error || cmd.method === "eval" && result?.ok === false ? "failed" : "done";
3012
+ }
3013
+ send(frame);
2315
3014
  } catch (err) {
2316
3015
  const code = err instanceof CmdError ? err.code : "CDP_ERROR";
2317
3016
  const message = err instanceof Error ? err.message : String(err);
3017
+ if (entry) entry.outcome = ran && code === "POLICY_DENIED" ? "interrupted" : code === "POLICY_DENIED" ? "blocked" : "failed";
2318
3018
  this.deps.log(`command "${cmd.method}" failed: ${message}`);
2319
3019
  const frame = {
2320
3020
  type: "error",
@@ -2323,7 +3023,9 @@
2323
3023
  ok: false,
2324
3024
  error: { code, message }
2325
3025
  };
2326
- this.deps.send(frame);
3026
+ send(frame);
3027
+ } finally {
3028
+ if (entry) record?.(entry);
2327
3029
  }
2328
3030
  }
2329
3031
  /** Border the tab a successful command worked in. Never delays or fails the reply. */
@@ -2343,10 +3045,18 @@
2343
3045
 
2344
3046
  // extension/src/sw/observers.ts
2345
3047
  var SCRIPT_ID = "chrome-mcp-observers";
2346
- async function syncObserverScript(policy, log) {
3048
+ var updates = Promise.resolve();
3049
+ function syncObserverScript(policy, log) {
3050
+ updates = updates.then(() => applyObserverScript(policy, log));
3051
+ return updates;
3052
+ }
3053
+ async function applyObserverScript(policy, log) {
2347
3054
  try {
3055
+ await initializeBrowserPermissions();
2348
3056
  const existing = await chrome.scripting.getRegisteredContentScripts({ ids: [SCRIPT_ID] }).catch(() => []);
2349
- const wanted = policy ? observerMatches(policy) : [];
3057
+ const candidates = (policy ? observerMatches(policy) : []).flatMap((pattern) => pattern.startsWith("*://") ? [`http${pattern.slice(1)}`, `https${pattern.slice(1)}`] : [pattern]);
3058
+ const checks = await Promise.all(candidates.map(async (pattern) => await chrome.permissions.contains({ origins: [pattern] }) ? pattern : null));
3059
+ const wanted = checks.filter((pattern) => pattern !== null);
2350
3060
  if (wanted.length === 0) {
2351
3061
  if (existing.length > 0) {
2352
3062
  await chrome.scripting.unregisterContentScripts({ ids: [SCRIPT_ID] });
@@ -2536,7 +3246,7 @@
2536
3246
  delete marked[tabId];
2537
3247
  } else {
2538
3248
  await unpaint(tabId, marked[tabId]);
2539
- marked[tabId] = await paint(tabId);
3249
+ marked[tabId] = this.capturing.has(tabId) ? "" : await paint(tabId);
2540
3250
  }
2541
3251
  await saveMarked(marked);
2542
3252
  });
@@ -2554,11 +3264,10 @@
2554
3264
  if (tabId === null) return fn();
2555
3265
  const css = await this.locked(async () => {
2556
3266
  const c = (await loadMarked())[tabId] ?? "";
2557
- if (c) this.capturing.set(tabId, (this.capturing.get(tabId) ?? 0) + 1);
3267
+ this.capturing.set(tabId, (this.capturing.get(tabId) ?? 0) + 1);
2558
3268
  await unpaint(tabId, c);
2559
3269
  return c;
2560
3270
  });
2561
- if (!css) return fn();
2562
3271
  try {
2563
3272
  return await fn();
2564
3273
  } finally {
@@ -2566,7 +3275,7 @@
2566
3275
  const left = (this.capturing.get(tabId) ?? 1) - 1;
2567
3276
  if (left > 0) return void this.capturing.set(tabId, left);
2568
3277
  this.capturing.delete(tabId);
2569
- if ((await loadMarked())[tabId] !== css) return;
3278
+ if (!css || (await loadMarked())[tabId] !== css) return;
2570
3279
  await bounded(chrome.scripting.insertCSS({ target: { tabId }, css, origin: "USER" }), void 0);
2571
3280
  });
2572
3281
  }
@@ -2581,6 +3290,15 @@
2581
3290
  }
2582
3291
  };
2583
3292
 
3293
+ // extension/src/site-access.ts
3294
+ async function permittedSites(sites) {
3295
+ const checks = await Promise.all(sites.map(async (site) => {
3296
+ const origins = siteOrigins(site);
3297
+ return origins.length && await chrome.permissions.contains({ origins }).catch(() => false) ? site : null;
3298
+ }));
3299
+ return checks.filter((site) => site !== null);
3300
+ }
3301
+
2584
3302
  // extension/src/connect.ts
2585
3303
  var NATIVE_HOST = "com.mehmoodqureshi.mcp_browser_extension";
2586
3304
  async function fetchPairing() {
@@ -2608,18 +3326,33 @@
2608
3326
  pairingSource: "native"
2609
3327
  });
2610
3328
  }
3329
+ async function requestPairingFromClick() {
3330
+ let granted = false;
3331
+ try {
3332
+ granted = await chrome.permissions.request({ permissions: ["nativeMessaging"] });
3333
+ } catch (err) {
3334
+ return { ok: false, message: err instanceof Error ? err.message : String(err) };
3335
+ }
3336
+ if (!granted) return { ok: false, message: "Chrome needs that permission to fetch the pairing for you. You can also pair by hand in Settings." };
3337
+ return fetchPairing();
3338
+ }
2611
3339
 
2612
3340
  // extension/src/sw/background.ts
3341
+ var pendingClients = 0;
3342
+ var pendingFeatures = 0;
2613
3343
  var KEEPALIVE_ALARM = "chrome-mcp-keepalive";
2614
3344
  var MAX_BLOCKED = 10;
2615
3345
  var currentPolicy = null;
2616
- var executor = new ChromeExecutor(() => currentPolicy);
3346
+ var reportBlocked = (host, method) => {
3347
+ void rememberBlocked(host, method).catch(() => void 0);
3348
+ };
3349
+ var executor = new ChromeExecutor(() => currentPolicy, reportBlocked);
2617
3350
  var border = new TabBorder(() => currentPolicy);
2618
3351
  var RECONNECT_DELAYS_MS = [1e3, 2e3, 4e3, 8e3, 1e4];
2619
3352
  var reconnectAttempt = 0;
2620
3353
  var reconnectTimer = null;
2621
3354
  function scheduleReconnect() {
2622
- if (reconnectTimer) return;
3355
+ if (access.stopped || reconnectTimer) return;
2623
3356
  const delay2 = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
2624
3357
  reconnectAttempt++;
2625
3358
  reconnectTimer = setTimeout(() => {
@@ -2633,7 +3366,7 @@
2633
3366
  reconnectAttempt = 0;
2634
3367
  }
2635
3368
  var ws = new WsClient({
2636
- onCommand: (cmd) => void router.dispatch(cmd),
3369
+ onCommand: (cmd, reply) => void router.dispatch(cmd, reply),
2637
3370
  onState: (state) => {
2638
3371
  void persistState(state);
2639
3372
  if (state === "connected") clearReconnect();
@@ -2642,6 +3375,14 @@
2642
3375
  if (state === "unauthorized") void adoptBundledPairing().then((took) => took ? true : healFromHelper());
2643
3376
  },
2644
3377
  onPolicy: (policy) => {
3378
+ if (access.stopped) return;
3379
+ if (!policy || !Array.isArray(policy.allowDomains) || policy.allowDomains.some((site) => typeof site !== "string")) {
3380
+ currentPolicy = null;
3381
+ void syncObserverScript(null, (m) => console.debug("[mcp-browser-extension]", m));
3382
+ void chrome.storage.local.set({ allowedSites: [] });
3383
+ return;
3384
+ }
3385
+ policy = { ...policy, allowDomains: policy.allowDomains.map(normalizeDomainPattern).filter(Boolean) };
2645
3386
  currentPolicy = policy;
2646
3387
  void syncObserverScript(policy, (m) => console.debug("[mcp-browser-extension]", m));
2647
3388
  void onAllowlist(policy);
@@ -2653,10 +3394,59 @@
2653
3394
  onLatestExtension: (version) => void chrome.storage.local.set({ latestExtension: version }),
2654
3395
  log: (m) => console.debug("[mcp-browser-extension]", m)
2655
3396
  });
3397
+ var approvals = new ClientApprovals({
3398
+ read: async () => (await chrome.storage.local.get(CLIENT_STORAGE_KEY))[CLIENT_STORAGE_KEY],
3399
+ write: async (clients) => {
3400
+ await chrome.storage.local.set({ [CLIENT_STORAGE_KEY]: clients });
3401
+ },
3402
+ challenge: () => ws.approvalChallenge,
3403
+ changed: (clients) => {
3404
+ pendingClients = clients.filter((c) => c.state === "pending" && c.expiresAt > Date.now()).length;
3405
+ reflectBadge(ws.state);
3406
+ }
3407
+ });
3408
+ var activity = new ActivityLog({
3409
+ read: async () => (await chrome.storage.session.get(ACTIVITY_KEY))[ACTIVITY_KEY],
3410
+ write: async (entries) => {
3411
+ await chrome.storage.session.set({ [ACTIVITY_KEY]: entries });
3412
+ }
3413
+ });
3414
+ var historyHasFailed = false;
3415
+ var historyFailed = () => {
3416
+ if (historyHasFailed) return;
3417
+ historyHasFailed = true;
3418
+ void chrome.storage.session.set({ activityError: true }).catch(() => void 0);
3419
+ };
3420
+ var access = new AccessSwitch({
3421
+ read: async () => (await chrome.storage.local.get(ACCESS_STOPPED_KEY))[ACCESS_STOPPED_KEY],
3422
+ write: async (stopped) => {
3423
+ await chrome.storage.local.set({ [ACCESS_STOPPED_KEY]: stopped });
3424
+ },
3425
+ revoke: () => approvals.revokeAll(),
3426
+ disconnect: () => {
3427
+ clearReconnect();
3428
+ ws.close();
3429
+ ws.state = "idle";
3430
+ currentPolicy = null;
3431
+ void persistState("idle");
3432
+ void border.clearAll();
3433
+ void syncObserverScript(null, (m) => console.debug("[mcp-browser-extension]", m));
3434
+ void activity.interrupt().catch(historyFailed);
3435
+ }
3436
+ });
2656
3437
  var router = new CommandRouter({
3438
+ authorize: (cmd, verified) => approvals.authorize(cmd, verified),
3439
+ access: () => access.check(),
3440
+ activity: () => {
3441
+ const generation = activity.token();
3442
+ return (entry) => {
3443
+ void activity.record(entry, generation).catch(historyFailed);
3444
+ };
3445
+ },
2657
3446
  exec: executor,
2658
3447
  send: (frame) => ws.send(frame),
2659
3448
  getPolicy: () => currentPolicy,
3449
+ onBlocked: reportBlocked,
2660
3450
  log: (m) => console.debug("[mcp-browser-extension]", m),
2661
3451
  border
2662
3452
  });
@@ -2716,19 +3506,24 @@
2716
3506
  return null;
2717
3507
  }
2718
3508
  async function onAllowlist(policy) {
3509
+ await initializeBrowserPermissions();
3510
+ const effective = { ...policy, allowDomains: await permittedSites(policy.allowDomains) };
2719
3511
  const { blockedSites } = await chrome.storage.local.get("blockedSites");
2720
3512
  const still = (Array.isArray(blockedSites) ? blockedSites : []).filter(
2721
- (b) => !isDomainAllowed(`https://${b.host}/`, policy)
3513
+ (b) => !isDomainAllowed(`https://${b.host}/`, effective)
2722
3514
  );
2723
3515
  await chrome.storage.local.set({ allowedSites: policy.allowDomains, blockedSites: still });
2724
3516
  pendingBlocked = still.length;
2725
3517
  reflectBadge(ws.state);
2726
3518
  }
2727
3519
  async function rememberBlocked(host, method) {
2728
- const { blockedSites } = await chrome.storage.local.get("blockedSites");
3520
+ const { blockedSites, dismissedSites } = await chrome.storage.local.get(["blockedSites", "dismissedSites"]);
2729
3521
  const prior = (Array.isArray(blockedSites) ? blockedSites : []).filter((b) => b.host !== host);
2730
3522
  const next = [{ host, method, at: Date.now() }, ...prior].slice(0, MAX_BLOCKED);
2731
- await chrome.storage.local.set({ blockedSites: next });
3523
+ await chrome.storage.local.set({
3524
+ blockedSites: next,
3525
+ dismissedSites: (Array.isArray(dismissedSites) ? dismissedSites : []).filter((site) => site !== host)
3526
+ });
2732
3527
  pendingBlocked = next.length;
2733
3528
  reflectBadge(ws.state);
2734
3529
  }
@@ -2753,7 +3548,7 @@
2753
3548
  }
2754
3549
  };
2755
3550
  function reflectBadge(state) {
2756
- const b = state === "connected" && pendingBlocked > 0 ? BLOCKED_BADGE : BADGE[state] ?? BADGE.idle;
3551
+ const b = access.stopped ? { text: "OFF", color: "#b91c1c", title: "MCP Browser Extension \u2014 access stopped" } : pendingClients > 0 ? { text: "?", color: "#b45309", title: "App connection awaiting approval. Open Settings to review." } : state === "connected" && pendingBlocked > 0 ? BLOCKED_BADGE : pendingFeatures > 0 ? { text: "?", color: "#b45309", title: "Permission needed. Open the extension popup \u2192 Extra permissions." } : BADGE[state] ?? BADGE.idle;
2757
3552
  try {
2758
3553
  void chrome.action.setBadgeText({ text: b.text });
2759
3554
  void chrome.action.setBadgeBackgroundColor({ color: b.color });
@@ -2773,10 +3568,21 @@
2773
3568
  }
2774
3569
  }
2775
3570
  async function ensureConnected() {
3571
+ try {
3572
+ await access.initialize();
3573
+ } catch {
3574
+ reflectBadge("idle");
3575
+ return;
3576
+ }
3577
+ if (access.stopped) {
3578
+ reflectBadge("idle");
3579
+ return;
3580
+ }
2776
3581
  if (ws.isConnected() || ws.state === "unauthorized" || ws.state === "superseded") return;
2777
3582
  const cfg = await getConfig();
2778
3583
  if (!cfg) return;
2779
- ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId, await installType());
3584
+ const install = await installType();
3585
+ if (!access.stopped) ws.connect(cfg.wsPort, cfg.token, cfg.profile, cfg.installId, install);
2780
3586
  }
2781
3587
  async function keepalivePulse() {
2782
3588
  await chrome.storage.local.get("connState");
@@ -2796,10 +3602,45 @@
2796
3602
  if (info.status === "complete") void border.loaded(tabId);
2797
3603
  });
2798
3604
  chrome.tabs.onRemoved.addListener((tabId) => void border.closed(tabId));
2799
- chrome.webRequest.onAuthRequired.addListener(onProxyAuth, { urls: ["<all_urls>"] }, [
2800
- "asyncBlocking"
2801
- ]);
3605
+ function syncProxyAuthListener() {
3606
+ const event = chrome.webRequest?.onAuthRequired;
3607
+ if (!event) return;
3608
+ void chrome.permissions.contains({ permissions: ["webRequest", "webRequestAuthProvider"] }).then((granted) => {
3609
+ if (event.hasListener(onProxyAuth)) event.removeListener(onProxyAuth);
3610
+ if (granted) event.addListener(onProxyAuth, { urls: ["http://*/*", "https://*/*"] }, ["asyncBlocking"]);
3611
+ });
3612
+ }
3613
+ syncProxyAuthListener();
3614
+ async function permissionsChanged() {
3615
+ syncProxyAuthListener();
3616
+ await refreshFeatureBadge();
3617
+ if (currentPolicy) {
3618
+ await onAllowlist(currentPolicy);
3619
+ await syncObserverScript(currentPolicy, (m) => console.debug("[mcp-browser-extension]", m));
3620
+ }
3621
+ await border.clearAll();
3622
+ }
3623
+ var featureBadgeGeneration = 0;
3624
+ async function refreshFeatureBadge() {
3625
+ const generation = ++featureBadgeGeneration;
3626
+ const saved = (await chrome.storage.local.get(FEATURE_REQUESTS_KEY))[FEATURE_REQUESTS_KEY];
3627
+ const requested = Array.isArray(saved) ? saved.filter((p) => typeof p === "string" && p in OPTIONAL_FEATURES) : [];
3628
+ const missing = await Promise.all(requested.map(async (permission) => !await chrome.permissions.contains({ permissions: [permission] })));
3629
+ if (generation !== featureBadgeGeneration) return;
3630
+ pendingFeatures = missing.filter(Boolean).length;
3631
+ reflectBadge(ws.state);
3632
+ }
3633
+ chrome.permissions.onAdded.addListener((permissions) => {
3634
+ void (async () => {
3635
+ if (permissions.origins?.some(isGlobalOrigin)) await removeGlobalOrigins();
3636
+ await permissionsChanged();
3637
+ })().catch((err) => console.warn("[mcp-browser-extension] permission change failed", err));
3638
+ });
3639
+ chrome.permissions.onRemoved.addListener(() => {
3640
+ void permissionsChanged().catch((err) => console.warn("[mcp-browser-extension] permission change failed", err));
3641
+ });
2802
3642
  chrome.storage.onChanged.addListener((changes, area) => {
3643
+ if (area === "local" && changes[FEATURE_REQUESTS_KEY]) void refreshFeatureBadge();
2803
3644
  if (area === "local" && changes.tabBorder?.newValue === false) void border.clearAll();
2804
3645
  if (area === "local" && changes[PROXY_STORAGE_KEY]) void syncProxy();
2805
3646
  if (area === "local" && (changes.wsPort || changes.token || changes.profile)) {
@@ -2810,21 +3651,118 @@
2810
3651
  }
2811
3652
  });
2812
3653
  chrome.runtime.onMessage.addListener((msg, sender, reply) => {
3654
+ if (msg?.type === "connect_with_click") {
3655
+ if (!isApprovalPage(sender) || access.stopped) {
3656
+ reply({ ok: false, message: "Use Resume connection in the extension popup or Settings first." });
3657
+ return;
3658
+ }
3659
+ void requestPairingFromClick().then(async (result) => {
3660
+ if (!result.ok) {
3661
+ reply(result);
3662
+ return;
3663
+ }
3664
+ if (access.stopped) {
3665
+ reply({ ok: false, message: "Access is stopped. Use Resume connection first." });
3666
+ return;
3667
+ }
3668
+ await applyPairing(result);
3669
+ clearReconnect();
3670
+ ws.close();
3671
+ ws.state = "idle";
3672
+ await ensureConnected();
3673
+ reply({ ok: true });
3674
+ }).catch(() => reply({ ok: false, message: "Could not connect. Please try again." }));
3675
+ return true;
3676
+ }
3677
+ if (msg?.type === "feature_access") {
3678
+ if (!isApprovalPage(sender) || !Object.hasOwn(OPTIONAL_FEATURES, msg.feature) || typeof msg.allow !== "boolean") {
3679
+ reply({ ok: false, error: "Use Extra permissions in the extension popup or Settings." });
3680
+ return;
3681
+ }
3682
+ const operation = msg.allow ? chrome.permissions.request({ permissions: [msg.feature] }) : chrome.permissions.remove({ permissions: [msg.feature] });
3683
+ void operation.then((changed) => reply({
3684
+ ok: changed || !msg.allow,
3685
+ ...!changed && msg.allow ? { error: "No access was added. You can allow it whenever you are ready." } : {}
3686
+ }), () => reply({ ok: false, error: "Could not change this permission. Please try again." }));
3687
+ return true;
3688
+ }
3689
+ if (["access_stop", "access_resume", "activity_clear"].includes(msg?.type)) {
3690
+ if (!isApprovalPage(sender)) {
3691
+ reply({ ok: false, error: "Use the extension popup or Settings to change browser access or history." });
3692
+ return;
3693
+ }
3694
+ const operation = msg.type === "access_stop" ? access.stop() : msg.type === "access_resume" ? access.resume().then(() => ensureConnected()) : activity.clear().then(async () => {
3695
+ await chrome.storage.session.set({ activityError: false });
3696
+ historyHasFailed = false;
3697
+ });
3698
+ void operation.then(() => {
3699
+ reflectBadge(ws.state);
3700
+ reply({ ok: true });
3701
+ }, () => reply({ ok: false, error: msg.type === "activity_clear" ? "Could not clear history. Please try again." : "Access is blocked in this session, but the setting could not be saved. Try Stop access again before restarting Chrome." }));
3702
+ return true;
3703
+ }
3704
+ if (msg?.type === "client_decision") {
3705
+ if (!isApprovalPage(sender) || typeof msg.id !== "string" || typeof msg.allow !== "boolean") {
3706
+ reply({ ok: false, error: "Client approval requires the extension popup or Settings page." });
3707
+ return;
3708
+ }
3709
+ if (msg.allow && access.stopped) {
3710
+ reply({ ok: false, error: "Access is stopped. Use Resume connection first, then approve the app." });
3711
+ return;
3712
+ }
3713
+ void approvals.decide(msg.id, msg.allow).then(
3714
+ () => reply({ ok: true }),
3715
+ (err) => reply({ ok: false, error: err instanceof Error ? err.message : "Could not save the decision." })
3716
+ );
3717
+ return true;
3718
+ }
2813
3719
  if (msg?.type === "reconnect") {
3720
+ if (!isApprovalPage(sender)) return;
3721
+ if (access.stopped) {
3722
+ reply({ ok: false, error: "Access is stopped. Use Resume connection in the extension popup or Settings." });
3723
+ return;
3724
+ }
2814
3725
  clearReconnect();
2815
3726
  ws.close();
2816
3727
  ws.state = "idle";
2817
3728
  void ensureConnected();
2818
3729
  }
2819
- if (msg?.type === "site_grant" && typeof msg.host === "string") {
2820
- if (sender.id !== chrome.runtime.id || !sender.url?.startsWith(chrome.runtime.getURL(""))) return;
3730
+ if (["site_grant", "site_access"].includes(msg?.type) && typeof msg.host === "string") {
3731
+ if (!isApprovalPage(sender) || typeof msg.allow !== "boolean") {
3732
+ reply({ ok: false, error: "Please approve this website in the extension popup or Settings." });
3733
+ return;
3734
+ }
2821
3735
  if (!ws.isConnected()) {
2822
3736
  reply({ ok: false, error: "not connected to the MCP Browser Extension server" });
2823
3737
  return;
2824
3738
  }
2825
- const frame = { type: "site_grant", v: PROTOCOL_VERSION, host: msg.host, allow: msg.allow !== false };
2826
- ws.send(frame);
2827
- reply({ ok: true });
3739
+ const origins = siteOrigins(msg.host);
3740
+ if (!origins.length) {
3741
+ reply({ ok: false, error: "Please enter one website. All-site access is not available." });
3742
+ return;
3743
+ }
3744
+ const permission = msg.type === "site_access" && msg.allow ? chrome.permissions.request({ origins }) : Promise.resolve(true);
3745
+ void (async () => {
3746
+ if (!await permission) {
3747
+ reply({ ok: false, error: "No access was added. You can click Allow whenever you are ready." });
3748
+ return;
3749
+ }
3750
+ await initializeBrowserPermissions();
3751
+ if (!origins.length || msg.allow && !await chrome.permissions.contains({ origins })) {
3752
+ reply({ ok: false, error: "Please click Allow for this website and accept Chrome\u2019s permission request." });
3753
+ return;
3754
+ }
3755
+ if (!msg.allow) await chrome.permissions.remove({ origins });
3756
+ if (!ws.isConnected() || msg.allow && access.stopped) {
3757
+ reply({ ok: false, error: "Access is stopped or disconnected. Reconnect before allowing this website." });
3758
+ return;
3759
+ }
3760
+ const frame = { type: "site_grant", v: PROTOCOL_VERSION, host: normalizeDomainPattern(msg.host), allow: msg.allow };
3761
+ ws.send(frame);
3762
+ if (currentPolicy) await onAllowlist(currentPolicy);
3763
+ reply({ ok: true });
3764
+ })().catch(() => reply({ ok: false, error: "Could not update this website permission. Please try again." }));
3765
+ return true;
2828
3766
  }
2829
3767
  const fromOwnPage = sender.id === chrome.runtime.id && sender.url?.startsWith(chrome.runtime.getURL(""));
2830
3768
  if (msg?.type === "proxy_apply" && fromOwnPage) void syncProxy();
@@ -2833,12 +3771,24 @@
2833
3771
  return true;
2834
3772
  }
2835
3773
  if (msg?.type === "dismiss_blocked" && typeof msg.host === "string") {
2836
- void chrome.storage.local.get("blockedSites").then(async ({ blockedSites }) => {
3774
+ if (!isApprovalPage(sender)) {
3775
+ reply({ ok: false, error: "Use the extension popup or Settings." });
3776
+ return;
3777
+ }
3778
+ const host = normalizeDomainPattern(msg.host);
3779
+ if (!siteOrigins(host).length) {
3780
+ reply({ ok: false, error: "Invalid website." });
3781
+ return;
3782
+ }
3783
+ void chrome.storage.local.get(["blockedSites", "dismissedSites"]).then(async ({ blockedSites, dismissedSites }) => {
2837
3784
  const still = (Array.isArray(blockedSites) ? blockedSites : []).filter((b) => b.host !== msg.host);
2838
- await chrome.storage.local.set({ blockedSites: still });
3785
+ const dismissed = Array.isArray(dismissedSites) ? dismissedSites.filter((site) => typeof site === "string" && site !== host) : [];
3786
+ await chrome.storage.local.set({ blockedSites: still, dismissedSites: [...dismissed, host].slice(-100) });
2839
3787
  pendingBlocked = still.length;
2840
3788
  reflectBadge(ws.state);
2841
- });
3789
+ reply({ ok: true });
3790
+ }).catch(() => reply({ ok: false, error: "Could not dismiss this website. Please try again." }));
3791
+ return true;
2842
3792
  }
2843
3793
  });
2844
3794
  async function healFromHelper() {
@@ -2861,7 +3811,20 @@
2861
3811
  console.warn("[mcp-browser-extension] could not apply the proxy setting", err);
2862
3812
  }
2863
3813
  }
3814
+ var historyInitialized = false;
2864
3815
  async function bootstrap() {
3816
+ await chrome.storage.local.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
3817
+ await chrome.storage.session.setAccessLevel({ accessLevel: "TRUSTED_CONTEXTS" });
3818
+ await chrome.storage.session.remove("actionRequests");
3819
+ await access.initialize();
3820
+ if (!historyInitialized) {
3821
+ historyInitialized = true;
3822
+ await activity.interrupt().catch(historyFailed);
3823
+ }
3824
+ await initializeBrowserPermissions();
3825
+ await refreshFeatureBadge();
3826
+ const savedClients = (await chrome.storage.local.get(CLIENT_STORAGE_KEY))[CLIENT_STORAGE_KEY];
3827
+ pendingClients = (Array.isArray(savedClients) ? savedClients : []).filter((c) => c.state === "pending" && c.expiresAt > Date.now()).length;
2865
3828
  await chrome.alarms.create(KEEPALIVE_ALARM, { periodInMinutes: 0.5 });
2866
3829
  await syncProxy();
2867
3830
  await reloadIfFilesChanged();