safari-mcp 2.21.13 → 2.22.0

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.
@@ -1144,146 +1144,20 @@ async function handleCommand(type, payload) {
1144
1144
  }, [payload.maxLength || 200000], tabId);
1145
1145
  }
1146
1146
 
1147
- // --- JavaScript Execution — multi-strategy to handle CSP restrictions ---
1148
- // Strategy 1: indirect eval (fast, works when CSP allows unsafe-eval)
1149
- // Strategy 2: script element injection (bypasses CSP in MAIN world context)
1147
+ // --- JavaScript Execution — see _evaluateOnce and _evalRunner ---
1150
1148
  case "list_frames": {
1151
1149
  return await listFrames(tabId);
1152
1150
  }
1153
1151
 
1154
1152
  case "evaluate": {
1155
- // Strategy 0: pages that stall injection outright. Every strategy below reaches
1156
- // the page through scripting.executeScript, which on business.facebook.com never
1157
- // resolves — so all three died on the caller's timeout and evaluate was simply
1158
- // unavailable there. The content-script bridge needs no injection at all, so try
1159
- // it FIRST once a tab is known to block injection, and fall back to it below
1160
- // when a fresh tab turns out to block it too.
1161
1153
  const evalTabId = tabId || (await getActiveTab()).id;
1162
- // A frame selector short-circuits every strategy below: those all target the
1163
- // main frame, which is exactly what makes an embedded app unreachable.
1154
+ // A frame selector short-circuits the page-level path: that one targets the main
1155
+ // frame, which is exactly what makes an embedded app unreachable.
1164
1156
  if (payload.frame !== undefined && payload.frame !== null && payload.frame !== "") {
1165
1157
  const _fid = await resolveFrameId(evalTabId, payload.frame);
1166
1158
  return await evaluateInFrame(evalTabId, _fid, payload.script);
1167
1159
  }
1168
- const viaBridge = async () => {
1169
- const r = await sendContentCommand(
1170
- evalTabId, "mcp-content-eval", { source: payload.script }, 10000
1171
- );
1172
- if (!r || r.ok !== true) throw new Error((r && r.error) || "content bridge failed");
1173
- _injectionBlockedTabs.add(evalTabId);
1174
- if (evalOrigin) _injectionBlockedOrigins.set(evalOrigin, Date.now() + _INJECTION_BLOCK_TTL_MS);
1175
- return r.value;
1176
- };
1177
- const evalOrigin = _originKey(targetTab?.url);
1178
- const originBlocked = !!evalOrigin && (_injectionBlockedOrigins.get(evalOrigin) || 0) > Date.now();
1179
- if (_injectionBlockedTabs.has(evalTabId) || originBlocked) {
1180
- try { return await viaBridge(); }
1181
- catch (_e) { // page changed — re-probe
1182
- _injectionBlockedTabs.delete(evalTabId);
1183
- if (evalOrigin) _injectionBlockedOrigins.delete(evalOrigin);
1184
- }
1185
- }
1186
-
1187
- // First contact with a hardened page: every execInTab strategy below will stall.
1188
- // Catch that here so it costs one bounded probe, not the caller's whole timeout.
1189
- const evalStrategies = async () => {
1190
- // Strategy 1: Direct eval via execInTab (fast, works when CSP allows unsafe-eval)
1191
- const evalResult = await execInTab(async (script) => {
1192
- try {
1193
- const result = await (0, eval)(script);
1194
- if (result === undefined || result === null) return null;
1195
- return typeof result === "object" ? JSON.stringify(result) : String(result);
1196
- } catch (e) {
1197
- if (e.message.includes("unsafe-eval") || e.message.includes("trusted-types") || e.message.includes("Trusted Type")) {
1198
- return "__CSP_BLOCKED__";
1199
- }
1200
- return "Error: " + e.message;
1201
- }
1202
- }, [payload.script], tabId);
1203
-
1204
- if (evalResult !== "__CSP_BLOCKED__") return evalResult;
1205
-
1206
- // Strategy 2: Script element injection (works when inline scripts are allowed)
1207
- const injectResult = await execInTab(async (script) => {
1208
- return await new Promise((resolve) => {
1209
- // Unpredictable key — a Date.now()-based name let a hostile page pre-seed
1210
- // window["__mcp_eval_<now>"] with a fabricated {done:true,v:...} result.
1211
- const id = "__mcp_eval_" + (crypto.randomUUID
1212
- ? crypto.randomUUID().replace(/-/g, "")
1213
- : Date.now().toString(36) + Math.random().toString(36).slice(2));
1214
- window[id] = { done: false };
1215
- const s = document.createElement("script");
1216
- const code = "try{var __r=(function(){" + script + "})();if(__r&&typeof __r.then==='function'){__r.then(function(v){window['" + id + "']={done:true,v:v};}).catch(function(e){window['" + id + "']={done:true,e:e.message};});}else{window['" + id + "']={done:true,v:__r};}}catch(e){window['" + id + "']={done:true,e:e.message};}";
1217
- // Prefer the policy pre-registered by content.js at document_start —
1218
- // pages that block new policy creation post-load (GSC, modern Google admin)
1219
- // still accept ours because it was grandfathered in before their CSP applied.
1220
- if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
1221
- try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
1222
- catch (_) { s.textContent = code; }
1223
- } else if (window.trustedTypes && window.trustedTypes.createPolicy) {
1224
- try {
1225
- const policy = window.trustedTypes.createPolicy("mcpEval_" + Date.now(), { createScript: (s) => s });
1226
- s.textContent = policy.createScript(code);
1227
- } catch (_) { s.textContent = code; }
1228
- } else {
1229
- s.textContent = code;
1230
- }
1231
- document.documentElement.appendChild(s);
1232
- s.remove();
1233
- let attempts = 0;
1234
- const poll = () => {
1235
- const r = window[id];
1236
- if (r && r.done) {
1237
- delete window[id];
1238
- if (r.e) resolve("Error: " + r.e);
1239
- else resolve(r.v === undefined || r.v === null ? null : typeof r.v === "object" ? JSON.stringify(r.v) : String(r.v));
1240
- return;
1241
- }
1242
- if (++attempts > 100) { delete window[id]; resolve("Error: timeout"); return; }
1243
- setTimeout(poll, 50);
1244
- };
1245
- poll();
1246
- });
1247
- }, [payload.script], tabId);
1248
-
1249
- // If script injection also failed due to CSP, try Worker thread (separate CSP context)
1250
- const isInjectCsp = injectResult && typeof injectResult === "string" && (injectResult.includes("unsafe-eval") || injectResult.includes("trusted-types") || injectResult.includes("Content Security Policy"));
1251
- if (!isInjectCsp) return injectResult;
1252
-
1253
- // Strategy 3: Web Worker — has its own CSP context, can execute arbitrary JS.
1254
- // Cannot access page DOM — only for pure computations. DOM scripts fall to AppleScript.
1255
- // SECURITY: This is a browser automation MCP tool — executing user scripts is its core purpose.
1256
- const workerResult = await execInTab(async (script) => {
1257
- if (/\b(document|window|querySelector|getElementById|innerHTML|textContent|style|className)\b/.test(script)) {
1258
- return "__CSP_NEEDS_DOM__";
1259
- }
1260
- return await new Promise((resolve) => {
1261
- try {
1262
- const wSrc = 'self.onmessage=function(e){try{var r=(0,self["ev"+"al"])(e.data);self.postMessage({ok:true,r:typeof r==="object"?JSON.stringify(r):String(r!=null?r:"null")})}catch(err){self.postMessage({ok:false,e:err.message})}};';
1263
- const blob = new Blob([wSrc], { type: "application/javascript" });
1264
- const url = URL.createObjectURL(blob);
1265
- const w = new Worker(url);
1266
- const timer = setTimeout(() => { w.terminate(); URL.revokeObjectURL(url); resolve("Error: Worker timeout"); }, 10000);
1267
- w.onmessage = (ev) => { clearTimeout(timer); w.terminate(); URL.revokeObjectURL(url); resolve(ev.data.ok ? ev.data.r : "Error: " + ev.data.e); };
1268
- w.onerror = (ev) => { clearTimeout(timer); w.terminate(); URL.revokeObjectURL(url); resolve("Error: " + ev.message); };
1269
- w.postMessage(script);
1270
- } catch (e) { resolve("Error: Worker failed: " + e.message); }
1271
- });
1272
- }, [payload.script], tabId);
1273
-
1274
- if (workerResult !== "__CSP_NEEDS_DOM__") return workerResult;
1275
- return "Error: CSP blocked all strategies (script needs DOM). Falling back to AppleScript.";
1276
- };
1277
-
1278
- try {
1279
- return await evalStrategies();
1280
- } catch (err) {
1281
- // "injection stalled" means the page refuses executeScript in both worlds, so
1282
- // none of the strategies above can ever reach it. The bridge does not inject.
1283
- if (!/injection stalled/.test(err?.message || "")) throw err;
1284
- console.warn("Safari MCP: injection blocked on this page, using content bridge");
1285
- return await viaBridge();
1286
- }
1160
+ return await _evaluateOnce(evalTabId, targetTab?.url, payload.script);
1287
1161
  }
1288
1162
 
1289
1163
  // --- Screenshot ---
@@ -3803,20 +3677,21 @@ async function sendContentCommand(tabId, type, payload, timeoutMs = 1500) {
3803
3677
  }
3804
3678
  }
3805
3679
 
3806
- // Every probe plus the content bridge must fit inside the server's 30s command
3807
- // timeout with room to spare: helpers 3 + MAIN 3 + ISOLATED 3 + bridge 10 ≈ 19s.
3808
- // Earlier values (8s each) added up to ~29s and the caller gave up first, so the
3809
- // bridge never got to answer and the tab was never marked as injection-blocked.
3680
+ // Every probe must fit inside the server's 30s command timeout with room to spare:
3681
+ // helpers 3 + MAIN 3 + ISOLATED 3 ≈ 9s. Earlier values (8s each) added up to ~29s and
3682
+ // the caller gave up first. evaluate waits this long for its injection to answer before
3683
+ // bringing in the content bridge, within its own EVAL_BUDGET_MS.
3810
3684
  const MAIN_WORLD_INJECT_MS = 3000;
3811
- // Tabs where BOTH injection worlds stalled. Only the first command on such a page pays
3812
- // the probing cost; afterwards we go straight to the content bridge, which keeps these
3813
- // pages fast instead of merely working. Cleared when the tab navigates or closes, so a
3814
- // page that stops blocking injection is re-probed rather than downgraded forever.
3685
+ // Tabs where the injected evaluate never answered and the content bridge did. Only the
3686
+ // first evaluate on such a page pays that wait; afterwards we go straight to the bridge,
3687
+ // which keeps these pages fast instead of merely working. Cleared when the tab navigates
3688
+ // or closes, so a page that stops blocking injection is re-probed rather than downgraded
3689
+ // forever - and when the injection answers after all (the script was merely slow).
3815
3690
  const _injectionBlockedTabs = new Set();
3816
3691
  // Same fact, keyed by origin: the per-tab set is cleared on every navigation, so a hardened
3817
3692
  // SPA (business.facebook.com) re-paid the ~10s probe on the first evaluate after EACH
3818
3693
  // navigate — 356 evaluates ≥10s in one week. An origin that blocked injection once is sent
3819
- // straight to the bridge for a few hours; a bridge failure clears it and re-probes.
3694
+ // straight to the bridge for a few hours; a tab with no bridge clears it and re-probes.
3820
3695
  const _injectionBlockedOrigins = new Map(); // origin → expiry (ms)
3821
3696
  const _INJECTION_BLOCK_TTL_MS = 6 * 60 * 60 * 1000;
3822
3697
  function _originKey(url) {
@@ -4307,31 +4182,264 @@ async function resolveFrameId(tabId, frame) {
4307
4182
  return hits[0].frameId;
4308
4183
  }
4309
4184
 
4185
+ // ========== EVALUATE ==========
4186
+ // One safari_evaluate starts the caller's script at most once. The old ladder bounded each
4187
+ // executeScript at 3s - a bound on the whole run, not on the injection starting - and on
4188
+ // expiry started the same script again: in the ISOLATED world, then through the content
4189
+ // bridge, whose own 4s fallback started it once more. A script that merely ran longer than
4190
+ // 3s ran up to four times, and every POST in it repeated (TOI, 22.9.26: two live duplicate
4191
+ // posts; 27.9.26: four drafts from one call). The slow script also filed its origin as
4192
+ // injection-blocked, so later calls went bridge-first, hit the bridge's 10s limit and
4193
+ // re-probed the whole ladder: more runs.
4194
+ //
4195
+ // Now the script starts once, in MAIN. Three seconds without an answer only bring in the
4196
+ // content bridge under the same runId: in MAIN it JOINS the run the injection started (on
4197
+ // business.facebook.com that executeScript answer never arrives), or starts the run when
4198
+ // the injection never began. It never starts a second one - see _evalRunner.
4199
+ const EVAL_BUDGET_MS = 25000;
4200
+ // What executeScript rejects with when it never reached the page. Any other failure may
4201
+ // come after the script started (it navigated the page, say), so it is never retried.
4202
+ const _EVAL_NOT_STARTED = /does not have access|Invalid call to scripting\.executeScript|Cannot access|Missing host permission|No tab with id/i;
4203
+
4204
+ async function _evaluateOnce(tabId, pageUrl, script) {
4205
+ const runId = crypto.randomUUID().replace(/-/g, "");
4206
+ const deadline = Date.now() + EVAL_BUDGET_MS;
4207
+ const origin = _originKey(pageUrl);
4208
+ const setBlocked = (blocked) => {
4209
+ if (blocked) {
4210
+ _injectionBlockedTabs.add(tabId);
4211
+ if (origin) _injectionBlockedOrigins.set(origin, Date.now() + _INJECTION_BLOCK_TTL_MS);
4212
+ } else {
4213
+ _injectionBlockedTabs.delete(tabId);
4214
+ if (origin) _injectionBlockedOrigins.delete(origin);
4215
+ }
4216
+ };
4217
+
4218
+ // Strategy 0: a page known to stall injection goes straight to the bridge. Only a tab with
4219
+ // no bridge listening falls through to injection: once the bridge holds the script, a
4220
+ // failure is final, because the script may have run.
4221
+ if (_injectionBlockedTabs.has(tabId) || (_injectionBlockedOrigins.get(origin) || 0) > Date.now()) {
4222
+ if (await _contentBridgeListening(tabId)) {
4223
+ return _evalOutcome(await _bridgeEval(tabId, runId, script, deadline));
4224
+ }
4225
+ setBlocked(false);
4226
+ }
4227
+
4228
+ let mainAnswered = false;
4229
+ const main = browser.scripting.executeScript({
4230
+ target: { tabId },
4231
+ world: "MAIN",
4232
+ func: _evalRunner,
4233
+ args: [runId, script, EVAL_BUDGET_MS + 5000],
4234
+ }).then((results) => {
4235
+ mainAnswered = true;
4236
+ const first = results && results[0];
4237
+ if (first && first.error) throw new Error(first.error);
4238
+ // Safari hands back no result at all on some CSP pages (LinkedIn); that was, and stays, null.
4239
+ return (first && first.result) || { ok: true, value: null };
4240
+ });
4241
+ const mainReply = main.then((reply) => reply, (err) => ({ failed: String((err && err.message) || err) }));
4242
+
4243
+ const early = await Promise.race([mainReply, sleep(MAIN_WORLD_INJECT_MS).then(() => null)]);
4244
+ if (early && early.failed !== undefined) {
4245
+ throw new Error((_EVAL_NOT_STARTED.test(early.failed)
4246
+ ? "evaluate did not start: "
4247
+ : "evaluate failed after the script may have started (it was not run again): ") + early.failed);
4248
+ }
4249
+ if (early && !early.notRun) return _evalOutcome(early);
4250
+
4251
+ // Slow, answered into the void (business.facebook.com), or never started: the bridge
4252
+ // tells these apart by runId - it joins a run that started and starts one that did not.
4253
+ const bridge = _bridgeEval(tabId, runId, script, deadline);
4254
+ const outcome = await new Promise((resolve) => {
4255
+ let open = 2;
4256
+ const timer = setTimeout(() => resolve(null), Math.max(0, deadline - Date.now()));
4257
+ const take = (reply, fromBridge) => {
4258
+ if (reply && (reply.ok === true || reply.ok === false)) {
4259
+ clearTimeout(timer);
4260
+ resolve({ reply, fromBridge });
4261
+ } else if (--open === 0) {
4262
+ clearTimeout(timer);
4263
+ resolve(null);
4264
+ }
4265
+ };
4266
+ mainReply.then((reply) => take(reply, false));
4267
+ bridge.then((reply) => take(reply, true));
4268
+ });
4269
+ if (outcome && outcome.fromBridge && !mainAnswered) {
4270
+ // Filed as blocked so the next evaluate here goes bridge-first - unless the injection
4271
+ // answers after all: then the script was just slow and the page takes injection fine.
4272
+ setBlocked(true);
4273
+ main.then(() => setBlocked(false), () => {});
4274
+ }
4275
+ if (outcome) return _evalOutcome(outcome.reply);
4276
+ throw new Error("evaluate: the page gave no result within " + EVAL_BUDGET_MS / 1000 + "s. The script was " +
4277
+ "started at most once and may still be running there; it was not run again - check the page before retrying.");
4278
+ }
4279
+
4280
+ // A reply with `ok` is the script's own outcome: its value, or the error it threw. Anything
4281
+ // else means no path produced one.
4282
+ function _evalOutcome(reply) {
4283
+ if (reply && reply.ok === true) return reply.value;
4284
+ if (reply && reply.ok === false) {
4285
+ return reply.csp
4286
+ ? "Error: CSP blocked all strategies - the script did not run. Falling back to AppleScript."
4287
+ : "Error: " + reply.error;
4288
+ }
4289
+ throw new Error("evaluate: the content bridge gave no result (" + ((reply && reply.error) || "no answer") +
4290
+ "). The script may have run in the page; it was not run again - check the page before retrying.");
4291
+ }
4292
+
4293
+ // Hands the evaluate to the content bridge (command-content.js, then content.js in MAIN)
4294
+ // under its runId and waits out the evaluate's deadline. Never throws.
4295
+ async function _bridgeEval(tabId, runId, script, deadline) {
4296
+ const budgetMs = Math.max(0, deadline - Date.now());
4297
+ let timer;
4298
+ try {
4299
+ const reply = await Promise.race([
4300
+ browser.tabs.sendMessage(tabId, {
4301
+ type: "mcp-content-eval-once",
4302
+ payload: { id: runId, source: script, budgetMs, keepMs: EVAL_BUDGET_MS + 5000 },
4303
+ }),
4304
+ new Promise((resolve) => { timer = setTimeout(() => resolve({ pending: true }), budgetMs); }),
4305
+ ]);
4306
+ return reply && typeof reply === "object" ? reply : { unavailable: true };
4307
+ } catch (err) {
4308
+ return { unavailable: true, error: String((err && err.message) || err) };
4309
+ } finally {
4310
+ clearTimeout(timer);
4311
+ }
4312
+ }
4313
+
4314
+ // Whether this tab's command bridge answers at all. A tab opened before the extension
4315
+ // loaded has none - the one case where bridge-first may fall back to injection knowing
4316
+ // the script cannot have started.
4317
+ async function _contentBridgeListening(tabId) {
4318
+ let timer;
4319
+ try {
4320
+ const answer = await Promise.race([
4321
+ browser.tabs.sendMessage(tabId, { type: "mcp-content-ping" }),
4322
+ new Promise((resolve) => { timer = setTimeout(resolve, 1500); }),
4323
+ ]);
4324
+ return answer === true;
4325
+ } catch (_) {
4326
+ return false;
4327
+ } finally {
4328
+ clearTimeout(timer);
4329
+ }
4330
+ }
4331
+
4332
+ // Runs one evaluate in the page's MAIN world, at most once per runId. Every path that can
4333
+ // start the script shares the runId and claims it first: this injected runner, the bridge
4334
+ // in content.js (also MAIN) and command-content.js's ISOLATED fallback. A claim is a
4335
+ // document listener cancelling the event "__mcp_claim_<runId>", so a cancelled dispatch
4336
+ // means some world already holds the run: DOM events cross the MAIN/ISOLATED boundary
4337
+ // synchronously, window properties do not. Within MAIN the run's promise also stays on
4338
+ // window for keepMs, so the bridge can join a run whose executeScript answer never
4339
+ // arrives. Self-contained: Safari serialises it into the page.
4340
+ async function _evalRunner(runId, script, keepMs) {
4341
+ const key = "__mcp_run_" + runId;
4342
+ if (window[key]) return window[key];
4343
+ const claim = "__mcp_claim_" + runId;
4344
+ if (!document.dispatchEvent(new Event(claim, { cancelable: true }))) return { notRun: true };
4345
+ document.addEventListener(claim, (e) => e.preventDefault());
4346
+ const text = (v) => (v === undefined || v === null ? null : typeof v === "object" ? JSON.stringify(v) : String(v));
4347
+ const fail = (e) => ({ ok: false, error: String((e && e.message) || e) });
4348
+ const run = (async () => {
4349
+ // Strategy 1: indirect eval, where the page's CSP allows unsafe-eval. The "0" probe tells
4350
+ // a refused eval (nothing ran) from a script that throws by itself: that one ran, and
4351
+ // must not run again through a later strategy.
4352
+ let evalAllowed = true;
4353
+ try { (0, eval)("0"); } catch (_) { evalAllowed = false; }
4354
+ if (evalAllowed) {
4355
+ try { return { ok: true, value: text(await (0, eval)(script)) }; } catch (e) { return fail(e); }
4356
+ }
4357
+ // Strategy 2: an inline <script>, for pages that refuse eval but accept the Trusted Types
4358
+ // policy content.js registered at document_start, or the page's own nonce: a nonce-based
4359
+ // script-src runs nothing else (facebook.com allowed 'unsafe-eval' on 2026-08-23, no longer
4360
+ // on 2026-09-27). An inline script runs inside appendChild, so by the next line the hook has
4361
+ // started it, or nothing ran. It goes in as an expression first, so `document.title`
4362
+ // answers as through eval; a statement list is a SyntaxError there, which runs nothing,
4363
+ // and then goes in as a function body, where only `return` yields a value.
4364
+ const hook = key + "_start";
4365
+ let started = null;
4366
+ window[hook] = (fn) => { started = (async () => fn())(); };
4367
+ let policy = window.__mcpTrustedPolicy;
4368
+ if (!(policy && typeof policy.createScript === "function") && window.trustedTypes && window.trustedTypes.createPolicy) {
4369
+ try { policy = window.trustedTypes.createPolicy("mcpEval_" + runId, { createScript: (c) => c }); } catch (_) { policy = null; }
4370
+ }
4371
+ const nonced = document.querySelector("script[nonce]");
4372
+ for (const body of ["return (" + script.replace(/[\s;]+$/, "") + "\n);", script]) {
4373
+ try {
4374
+ const code = "window[" + JSON.stringify(hook) + "](function(){" + body + "\n})";
4375
+ const s = document.createElement("script");
4376
+ try { s.textContent = policy ? policy.createScript(code) : code; } catch (_) { s.textContent = code; }
4377
+ if (nonced && nonced.nonce) s.nonce = nonced.nonce;
4378
+ document.documentElement.appendChild(s);
4379
+ s.remove();
4380
+ } catch (_) {
4381
+ // Trusted Types refused the plain string: the script did not start.
4382
+ }
4383
+ if (started) break;
4384
+ }
4385
+ delete window[hook];
4386
+ if (started) {
4387
+ try { return { ok: true, value: text(await started) }; } catch (e) { return fail(e); }
4388
+ }
4389
+ // Strategy 3: a Worker has its own CSP context - pure computation only, no DOM. A worker
4390
+ // that cannot start, or whose eval is refused too, ran nothing: say so ({csp}), so the
4391
+ // caller may still try AppleScript. Its script's own errors are only reported.
4392
+ if (/\b(document|window|querySelector|getElementById|innerHTML|textContent|style|className)\b/.test(script)) {
4393
+ return { ok: false, csp: true };
4394
+ }
4395
+ return await new Promise((resolve) => {
4396
+ try {
4397
+ const src = 'self.onmessage=function(e){try{(0,self["ev"+"al"])("0")}catch(err){self.postMessage({csp:true});return}try{var r=(0,self["ev"+"al"])(e.data);self.postMessage({ok:true,r:typeof r==="object"?JSON.stringify(r):String(r!=null?r:"null")})}catch(err){self.postMessage({ok:false,e:err.message})}};';
4398
+ const url = URL.createObjectURL(new Blob([src], { type: "application/javascript" }));
4399
+ const w = new Worker(url);
4400
+ const end = (reply) => { clearTimeout(timer); w.terminate(); URL.revokeObjectURL(url); resolve(reply); };
4401
+ const timer = setTimeout(() => end({ ok: false, error: "Worker timeout" }), 10000);
4402
+ w.onmessage = (ev) => end(ev.data.csp ? { ok: false, csp: true }
4403
+ : ev.data.ok ? { ok: true, value: ev.data.r } : { ok: false, error: ev.data.e });
4404
+ w.onerror = () => end({ ok: false, csp: true });
4405
+ w.postMessage(script);
4406
+ } catch (_) {
4407
+ resolve({ ok: false, csp: true });
4408
+ }
4409
+ });
4410
+ })();
4411
+ Object.defineProperty(window, key, { value: run, configurable: true });
4412
+ setTimeout(() => { delete window[key]; }, keepMs);
4413
+ return run;
4414
+ }
4415
+
4310
4416
  // Run one script inside a single frame. MAIN world first so the script sees the
4311
4417
  // page's own globals; ISOLATED as the fallback for frames whose CSP blocks eval —
4312
4418
  // it still reads and mutates the DOM, which is what clicking and filling need.
4313
4419
  async function evaluateInFrame(tabId, frameId, script) {
4314
4420
  const id = tabId || (await getActiveTab()).id;
4315
4421
  const runner = async (src) => {
4422
+ // Only a refused eval moves on to ISOLATED: it ran nothing. A script that throws by
4423
+ // itself - even a CSP error from an eval of its own - ran, and must not run again.
4424
+ try { (0, eval)("0"); } catch (_) { return "__CSP_BLOCKED__"; }
4316
4425
  try {
4317
4426
  const result = await (0, eval)(src);
4318
4427
  if (result === undefined || result === null) return null;
4319
4428
  return typeof result === "object" ? JSON.stringify(result) : String(result);
4320
4429
  } catch (e) {
4321
- const m = String(e && e.message);
4322
- if (m.includes("unsafe-eval") || m.includes("trusted-types") || m.includes("Trusted Type")) {
4323
- return "__CSP_BLOCKED__";
4324
- }
4325
- return "Error: " + m;
4430
+ return "Error: " + String(e && e.message);
4326
4431
  }
4327
4432
  };
4328
4433
  const run = async (world) => {
4434
+ // Bounded by the evaluate's budget, not the 3s injection probe: a slow script is
4435
+ // still running, and giving up on it early only invited a retry.
4329
4436
  const results = await _withInjectionDeadline(browser.scripting.executeScript({
4330
4437
  target: { tabId: id, frameIds: [frameId] },
4331
4438
  world,
4332
4439
  func: runner,
4333
4440
  args: [script],
4334
- }));
4441
+ }), EVAL_BUDGET_MS, "evaluate in frame: no result within " + EVAL_BUDGET_MS / 1000 +
4442
+ "s - the script may still be running; it was not run again");
4335
4443
  const first = results[0];
4336
4444
  if (first && first.error) throw new Error(first.error);
4337
4445
  return first && first.result;
@@ -479,17 +479,24 @@ _initializeMcpKeepalive();
479
479
  sendResponse(handleFill(message.payload || {}));
480
480
  return true;
481
481
  }
482
- // Last-resort path for evaluate. On hardened SPAs (business.facebook.com) BOTH
483
- // scripting.executeScript worlds hang forever instead of rejecting, while this
484
- // already-injected listener keeps answering — clicks kept working there the whole
485
- // time. Nothing is injected here, so there is no injection to stall on.
486
- if (message.type === "mcp-content-eval") {
487
- // The relayed function is often async (the evaluate handler awaits inside it), so
488
- // this must resolve before answering — returning the promise itself serialised to
489
- // "{}" and silently produced an empty result.
482
+ // Last-resort path for evaluate. On hardened SPAs (business.facebook.com) the
483
+ // scripting.executeScript answer never arrives, while this already-injected listener
484
+ // keeps answering — clicks kept working there the whole time. Nothing is injected
485
+ // here, so there is no injection to stall on. The type is new with the runId
486
+ // protocol: a bridge left over from an older build must not take these, because it
487
+ // would start the script without checking the claim.
488
+ if (message.type === "mcp-content-eval-once") {
489
+ // The script is often async, so this must resolve before answering — returning the
490
+ // promise itself serialised to "{}" and silently produced an empty result.
490
491
  handleEval(message.payload || {}).then(sendResponse);
491
492
  return true;
492
493
  }
494
+ // Lets the background tell "no bridge in this tab" (nothing can have run) from a
495
+ // bridge that took an evaluate and then failed (the script may have run).
496
+ if (message.type === "mcp-content-ping") {
497
+ sendResponse(true);
498
+ return true;
499
+ }
493
500
  return false;
494
501
  };
495
502
 
@@ -505,39 +512,42 @@ _initializeMcpKeepalive();
505
512
  // governs eval here, and a hardened page (business.facebook.com) forbids it — an
506
513
  // earlier attempt with new Function() died exactly there. content.js is already in
507
514
  // MAIN world from document_start and holds a grandfathered Trusted Types policy, so
508
- // hand the work to it over a CustomEvent and wait for its reply.
515
+ // hand the work to it and wait for its reply. The id is the evaluate's runId: content.js
516
+ // joins a run the injected runner already started instead of starting it again.
509
517
  function handleEval(payload) {
510
518
  return new Promise((resolve) => {
511
- const id = "b" + Math.random().toString(36).slice(2) + Date.now().toString(36);
519
+ const id = String(payload.id || "");
520
+ const source = String(payload.source || "");
512
521
  let settled = false;
513
522
  const finish = (result) => {
514
523
  if (settled) return;
515
524
  settled = true;
516
525
  window.removeEventListener("message", onReply);
517
- clearTimeout(timer);
526
+ clearTimeout(fallback);
527
+ clearTimeout(bound);
518
528
  resolve(result);
519
529
  };
520
530
  function onReply(ev) {
521
531
  if (ev.source !== window) return;
522
532
  const d = ev.data;
523
- if (!d || d.__mcp !== "eval_res" || d.id !== id) return;
533
+ if (!d || d.__mcp !== "eval_once_res" || d.id !== id) return;
524
534
  finish(d.result && typeof d.result === "object"
525
535
  ? d.result
526
536
  : { ok: false, error: "malformed bridge reply" });
527
537
  }
528
- // Bounded independently of the background's own deadline, so a page that never
529
- // answers cannot hold the command open. If MAIN never replies (its listener failed
530
- // to install, or the page tore it out), try here: a content script is governed by
531
- // the PAGE's CSP, and the pages this bridge targets do allow 'unsafe-eval'.
532
- const timer = setTimeout(() => {
533
- let out;
534
- try {
535
- out = (0, eval)(String(payload.source || ""));
536
- } catch (err) {
537
- finish({ ok: false, error: "eval bridge did not answer; isolated retry: " + ((err && err.message) || err) });
538
+ // No reply means no content.js in MAIN — the manifest leaves it out of LinkedIn, X and
539
+ // the Google apps — so after 4s the script runs here, under the PAGE's CSP. Unless a
540
+ // world already claimed the runId: that run is under way, and its answer comes from
541
+ // content.js or from the injected runner, never from a second start.
542
+ const fallback = setTimeout(() => {
543
+ const claim = "__mcp_claim_" + id;
544
+ if (!document.dispatchEvent(new Event(claim, { cancelable: true }))) return;
545
+ document.addEventListener(claim, (e) => e.preventDefault());
546
+ try { (0, eval)("0"); } catch (_) {
547
+ finish({ ok: false, csp: true, error: "eval is blocked by the page's CSP" });
538
548
  return;
539
549
  }
540
- Promise.resolve(out).then(
550
+ (async () => (0, eval)(source))().then(
541
551
  (v) => {
542
552
  if (v === undefined || v === null) return finish({ ok: true, value: null });
543
553
  if (typeof v === "object") {
@@ -549,14 +559,12 @@ function handleEval(payload) {
549
559
  (err) => finish({ ok: false, error: (err && err.message) || String(err) })
550
560
  );
551
561
  }, 4000);
562
+ // The background has stopped waiting by now; free the listener without an answer.
563
+ const bound = setTimeout(() => finish({ pending: true }), (Number(payload.budgetMs) || 25000) + 1000);
552
564
  // postMessage, not CustomEvent: a CustomEvent's detail arrives as null in the MAIN
553
565
  // world, so the listener there fired with nothing to run.
554
566
  window.addEventListener("message", onReply);
555
- try {
556
- window.postMessage({ __mcp: "eval_req", id, source: String(payload.source || "") }, "*");
557
- } catch (err) {
558
- finish({ ok: false, error: (err && err.message) || String(err) });
559
- }
567
+ window.postMessage({ __mcp: "eval_once", id, source, keepMs: payload.keepMs }, "*");
560
568
  });
561
569
  }
562
570