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.
- package/README.md +77 -14
- package/SETUP.md +40 -7
- package/dist/shared/activity.d.ts +25 -0
- package/dist/shared/activity.js +72 -0
- package/dist/shared/browser-permissions.d.ts +20 -0
- package/dist/shared/browser-permissions.js +40 -0
- package/dist/shared/client-auth.d.ts +28 -0
- package/dist/shared/client-auth.js +38 -0
- package/dist/shared/observers.d.ts +1 -1
- package/dist/shared/observers.js +1 -3
- package/dist/shared/policy.d.ts +5 -7
- package/dist/shared/policy.js +11 -26
- package/dist/shared/protocol.d.ts +4 -0
- package/dist/shared/protocol.js +3 -3
- package/dist/shared/screenshot.d.ts +4 -2
- package/dist/shared/screenshot.js +13 -8
- package/dist/src/access-browser.d.ts +15 -0
- package/dist/src/access-browser.js +202 -0
- package/dist/src/access.d.ts +2 -0
- package/dist/src/access.js +92 -18
- package/dist/src/bridge/client-identity.d.ts +10 -0
- package/dist/src/bridge/client-identity.js +28 -0
- package/dist/src/bridge/connection.d.ts +5 -0
- package/dist/src/bridge/connection.js +4 -12
- package/dist/src/bridge/peer.d.ts +4 -19
- package/dist/src/bridge/peer.js +17 -8
- package/dist/src/bridge/server.d.ts +7 -16
- package/dist/src/bridge/server.js +64 -17
- package/dist/src/bridge/site-grants.d.ts +1 -2
- package/dist/src/bridge/site-grants.js +4 -3
- package/dist/src/cli.js +1 -3
- package/dist/src/config.js +16 -5
- package/dist/src/executor/cdp-executor.d.ts +3 -0
- package/dist/src/executor/cdp-executor.js +69 -25
- package/dist/src/mcp/server.js +5 -3
- package/dist/src/mcp/tools.d.ts +0 -12
- package/dist/src/mcp/tools.js +48 -22
- package/dist/src/remote/key.js +12 -2
- package/dist/src/security/policy.d.ts +4 -0
- package/dist/src/security/policy.js +3 -1
- package/docs/BLUEPRINT.md +6 -1
- package/docs/CLIENT-APPROVAL.md +150 -0
- package/extension-dist/activity-dashboard.css +43 -0
- package/extension-dist/background.js +1036 -73
- package/extension-dist/manifest.json +10 -9
- package/extension-dist/options.html +101 -32
- package/extension-dist/options.js +558 -70
- package/extension-dist/popup.html +164 -90
- package/extension-dist/popup.js +576 -79
- 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.
|
|
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)
|
|
125
|
-
|
|
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(
|
|
643
|
+
return { ok: false, reason: blockedDomainMessage(host) };
|
|
263
644
|
}
|
|
264
645
|
return { ok: true };
|
|
265
646
|
}
|
|
266
|
-
function blockedDomainMessage(
|
|
267
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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))
|
|
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)
|
|
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
|
|
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
|
|
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: ${
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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}/`,
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
2800
|
-
|
|
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
|
|
2820
|
-
if (sender
|
|
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
|
|
2826
|
-
|
|
2827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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();
|