safari-mcp 2.21.14 → 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
 
@@ -10,16 +10,23 @@
10
10
  // so CSP cannot block it.
11
11
 
12
12
  // Last-resort evaluate path for pages that stall scripting.executeScript outright
13
- // (business.facebook.com: BOTH worlds hang forever instead of rejecting, so every
14
- // evaluate strategy above them dies on the caller's timeout).
13
+ // (business.facebook.com: the injected evaluate never answers, so every evaluate died on
14
+ // the caller's timeout).
15
15
  //
16
16
  // This script is ALREADY in MAIN world from document_start, so nothing has to be
17
17
  // injected to reach the page — that is the whole point. command-content.js lives in
18
18
  // the ISOLATED world where it can talk to the background but where the page's CSP
19
- // forbids eval; it hands the work here over a CustomEvent, and we run it through the
19
+ // forbids eval; it hands the work here over postMessage, and we run it through the
20
20
  // same grandfathered Trusted Types policy the injected strategy uses.
21
- if (!window.__mcpEvalBridge) {
22
- window.__mcpEvalBridge = true;
21
+ //
22
+ // Each request carries the evaluate's runId, and a run starts at most once per runId
23
+ // (see _evalRunner in background.js): if the injected runner already started it in this
24
+ // world we join its promise, if another world claimed it we stay out, else we claim it.
25
+ // The message tags and the guard below are new with that protocol, so a listener left
26
+ // over from an older build — which would start the script without looking — never sees
27
+ // these requests.
28
+ if (!window.__mcpEvalOnceBridge) {
29
+ window.__mcpEvalOnceBridge = true;
23
30
  // window.postMessage, not CustomEvent: a CustomEvent's `detail` does not survive the
24
31
  // ISOLATED→MAIN world boundary (it arrives as null), so the bridge received the event
25
32
  // but never the script — it simply timed out. postMessage is structured-cloned across
@@ -27,88 +34,67 @@ if (!window.__mcpEvalBridge) {
27
34
  window.addEventListener("message", function (ev) {
28
35
  if (ev.source !== window) return; // ignore other frames
29
36
  var d = ev.data;
30
- if (!d || d.__mcp !== "eval_req" || typeof d.id !== "string") return;
37
+ if (!d || d.__mcp !== "eval_once" || typeof d.id !== "string") return;
31
38
  var id = d.id;
32
- var outKey = "__mcp_eval_out_" + id;
33
- var answered = false;
34
- var reply = function (detail) {
35
- if (answered) return;
36
- answered = true;
37
- try { delete window[outKey]; } catch (_e) {}
38
- window.postMessage({ __mcp: "eval_res", id: id, result: detail }, "*");
39
+ var key = "__mcp_run_" + id;
40
+ var reply = function (result) {
41
+ window.postMessage({ __mcp: "eval_once_res", id: id, result: result }, "*");
39
42
  };
40
- var settle = function (v) {
41
- if (v === undefined || v === null) { reply({ ok: true, value: null }); return; }
42
- if (typeof v === "object") {
43
- try { reply({ ok: true, value: JSON.stringify(v) }); }
44
- catch (_e) { reply({ ok: true, value: String(v) }); }
45
- } else {
46
- reply({ ok: true, value: String(v) });
47
- }
43
+ if (window[key]) { window[key].then(reply); return; }
44
+ var claim = "__mcp_claim_" + id;
45
+ if (!document.dispatchEvent(new Event(claim, { cancelable: true }))) return;
46
+ document.addEventListener(claim, function (e) { e.preventDefault(); });
47
+ var source = String(d.source);
48
+ var text = function (v) {
49
+ return v === undefined || v === null ? null : typeof v === "object" ? JSON.stringify(v) : String(v);
48
50
  };
49
-
50
- // Direct eval first. We are in MAIN world, so this runs under the PAGE's CSP —
51
- // and business.facebook.com actually allows 'unsafe-eval'. Its script-src does
52
- // carry a nonce, which is precisely what blocks the injected-<script> path below,
53
- // so trying that first would fail on the very page this bridge exists for.
54
- try {
55
- var direct = (0, eval)(String(d.source));
56
- if (direct && typeof direct.then === "function") {
57
- direct.then(settle, function (e) { reply({ ok: false, error: String((e && e.message) || e) }); });
58
- } else {
59
- settle(direct);
51
+ var fail = function (e) { return { ok: false, error: String((e && e.message) || e) }; };
52
+ var run = (async function () {
53
+ // Direct eval first. We are in MAIN world, so this runs under the PAGE's CSP —
54
+ // and business.facebook.com actually allows 'unsafe-eval'. Its script-src does
55
+ // carry a nonce, which is precisely what blocks the injected-<script> path below,
56
+ // so trying that first would fail on the very page this bridge exists for. The
57
+ // "0" probe tells a refused eval (nothing ran) from a script that throws by
58
+ // itself: that one ran, and must not run again through the <script> path.
59
+ var evalAllowed = true;
60
+ try { (0, eval)("0"); } catch (_e) { evalAllowed = false; }
61
+ if (evalAllowed) {
62
+ try { return { ok: true, value: text(await (0, eval)(source)) }; } catch (e) { return fail(e); }
60
63
  }
61
- return;
62
- } catch (evalErr) {
63
- var m = String((evalErr && evalErr.message) || evalErr);
64
- // A real script error must surface as-is; only a CSP refusal justifies the
65
- // slower injected path.
66
- if (!/unsafe-eval|Content Security Policy|trusted-types|Trusted Type/i.test(m)) {
67
- reply({ ok: false, error: m });
68
- return;
69
- }
70
- }
71
-
72
- try {
73
- var code =
74
- "try{var __r=(function(){" + String(d.source) + "})();" +
75
- "if(__r&&typeof __r.then==='function'){__r.then(function(v){window['" + outKey + "']={done:true,v:v};}," +
76
- "function(e){window['" + outKey + "']={done:true,e:String((e&&e.message)||e)};});}" +
77
- "else{window['" + outKey + "']={done:true,v:__r};}}" +
78
- "catch(e){window['" + outKey + "']={done:true,e:String((e&&e.message)||e)};}";
79
- var s = document.createElement("script");
80
- if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
81
- try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
82
- catch (_e) { s.textContent = code; }
83
- } else {
84
- s.textContent = code;
85
- }
86
- document.documentElement.appendChild(s);
87
- s.remove();
88
- var tries = 0;
89
- var poll = function () {
90
- var r = window[outKey];
91
- if (r && r.done) {
92
- if (r.e) { reply({ ok: false, error: r.e }); return; }
93
- var v = r.v;
94
- if (v === undefined || v === null) { reply({ ok: true, value: null }); return; }
95
- // Serialise here: a CustomEvent detail crossing into the ISOLATED world must
96
- // survive structured clone, and page objects (DOM nodes, class instances) do not.
97
- if (typeof v === "object") {
98
- try { reply({ ok: true, value: JSON.stringify(v) }); }
99
- catch (_e) { reply({ ok: true, value: String(v) }); }
64
+ // An inline script runs inside appendChild, so by the next line the hook has
65
+ // started it — or nothing ran. A nonce-based script-src (facebook.com, since it no
66
+ // longer allows 'unsafe-eval') takes it only with the page's nonce. It goes in as an
67
+ // expression first, so `document.title` answers as through eval; a statement list
68
+ // is a SyntaxError there, which runs nothing, and then goes in as a function body.
69
+ var hook = key + "_start";
70
+ var started = null;
71
+ window[hook] = function (fn) { started = (async function () { return fn(); })(); };
72
+ var nonced = document.querySelector("script[nonce]");
73
+ var bodies = ["return (" + source.replace(/[\s;]+$/, "") + "\n);", source];
74
+ for (var i = 0; i < bodies.length && !started; i++) {
75
+ try {
76
+ var code = "window[" + JSON.stringify(hook) + "](function(){" + bodies[i] + "\n})";
77
+ var s = document.createElement("script");
78
+ if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
79
+ try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
80
+ catch (_e) { s.textContent = code; }
100
81
  } else {
101
- reply({ ok: true, value: String(v) });
82
+ s.textContent = code;
102
83
  }
103
- return;
84
+ if (nonced && nonced.nonce) s.nonce = nonced.nonce;
85
+ document.documentElement.appendChild(s);
86
+ s.remove();
87
+ } catch (_e) {
88
+ // Trusted Types refused the plain string: the script did not start.
104
89
  }
105
- if (++tries > 160) { reply({ ok: false, error: "eval bridge timeout" }); return; }
106
- setTimeout(poll, 50);
107
- };
108
- poll();
109
- } catch (e) {
110
- reply({ ok: false, error: String((e && e.message) || e) });
111
- }
90
+ }
91
+ delete window[hook];
92
+ if (!started) return { ok: false, csp: true, error: "the page's CSP blocks eval and inline scripts" };
93
+ try { return { ok: true, value: text(await started) }; } catch (e) { return fail(e); }
94
+ })();
95
+ Object.defineProperty(window, key, { value: run, configurable: true });
96
+ setTimeout(function () { delete window[key]; }, d.keepMs || 30000);
97
+ run.then(reply);
112
98
  });
113
99
  }
114
100
 
@@ -2,7 +2,7 @@
2
2
  "manifest_version": 3,
3
3
  "name": "Safari MCP Bridge",
4
4
  "description": "Connects AI agents to Safari — fast JS execution, screenshots, clicks with your real cookies/logins",
5
- "version": "2.10.24",
5
+ "version": "2.10.25",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -708,6 +708,14 @@ function _releaseContentWakePolls() {
708
708
  for (const finish of [..._contentWakePolls]) finish();
709
709
  }
710
710
 
711
+ // Open GET /poll long-polls, each a check() that answers its request if it can. Woken when a
712
+ // command is queued or the active worker changes — the 5ms setInterval they replace kept both
713
+ // daemons at ~200 wake-ups a second while idle.
714
+ const _httpPollWaiters = new Set();
715
+ function _wakeHttpPolls() {
716
+ for (const check of [..._httpPollWaiters]) check();
717
+ }
718
+
711
719
  function _setContentWakeCors(req, res) {
712
720
  const origin = typeof req.headers.origin === "string" ? req.headers.origin : "";
713
721
  res.setHeader("Access-Control-Allow-Origin", origin || "*");
@@ -893,34 +901,32 @@ try {
893
901
  res.writeHead(200, { "Content-Type": "application/json" });
894
902
  res.end(JSON.stringify(queuedCommand));
895
903
  } else {
896
- // Long-poll: wait up to 5 seconds for a command
897
- const timer = setTimeout(() => {
898
- res.writeHead(204);
899
- res.end();
900
- }, 5000);
901
-
902
- const checkInterval = setInterval(() => {
904
+ // Long-poll: wait up to 5 seconds for a command; _wakeHttpPolls() runs check().
905
+ const check = () => {
903
906
  if (pollingWorkerId !== _activeHttpWorkerId) {
904
- clearTimeout(timer);
905
- clearInterval(checkInterval);
907
+ done();
906
908
  res.writeHead(423);
907
909
  res.end("Worker superseded");
908
910
  return;
909
911
  }
910
912
  const cmd = _dequeueHttpCommand(pollingWorkerId);
911
913
  if (cmd) {
912
- clearTimeout(timer);
913
- clearInterval(checkInterval);
914
+ done();
914
915
  res.writeHead(200, { "Content-Type": "application/json" });
915
916
  res.end(JSON.stringify(cmd));
916
917
  }
917
- }, 5); // Check every 5ms (reduced from 20ms — cuts avg command delivery delay from 10ms to 2.5ms)
918
-
919
- // Cleanup on client disconnect
920
- req.on("close", () => {
918
+ };
919
+ const timer = setTimeout(() => {
920
+ done();
921
+ res.writeHead(204);
922
+ res.end();
923
+ }, 5000);
924
+ const done = () => {
921
925
  clearTimeout(timer);
922
- clearInterval(checkInterval);
923
- });
926
+ _httpPollWaiters.delete(check);
927
+ };
928
+ _httpPollWaiters.add(check);
929
+ req.on("close", done); // client went away
924
930
  }
925
931
  return;
926
932
  }
@@ -1003,6 +1009,7 @@ try {
1003
1009
  if (workerChanged) {
1004
1010
  _adoptReloadHandoff(workerId);
1005
1011
  _extensionConnectionGeneration += 1;
1012
+ _wakeHttpPolls(); // the superseded worker's open poll answers 423 now
1006
1013
  }
1007
1014
  if (!_extensionConnected) {
1008
1015
  _extensionConnected = true;
@@ -1062,7 +1069,10 @@ try {
1062
1069
  const workerChanged = workerId !== _activeHttpWorkerId;
1063
1070
  _activeHttpWorkerId = workerId;
1064
1071
  _connectingHttpWorkers.delete(workerId);
1065
- if (workerChanged) _adoptReloadHandoff(workerId);
1072
+ if (workerChanged) {
1073
+ _adoptReloadHandoff(workerId);
1074
+ _wakeHttpPolls();
1075
+ }
1066
1076
  _profileExtensionVerified = true;
1067
1077
  ledgerNoteVerified(_workerLedger, workerId);
1068
1078
  if (workerChanged) _extensionConnectionGeneration += 1;
@@ -1216,7 +1226,7 @@ try {
1216
1226
  res.end(JSON.stringify({ result }));
1217
1227
  } catch (err) {
1218
1228
  res.writeHead(200, { "Content-Type": "application/json" });
1219
- res.end(JSON.stringify({ error: err.message }));
1229
+ res.end(JSON.stringify({ error: err.message, dispatched: err.dispatched }));
1220
1230
  }
1221
1231
  });
1222
1232
  return;
@@ -1318,7 +1328,7 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
1318
1328
  });
1319
1329
  if (!res.ok) throw new Error(`Proxy error: ${res.status}`);
1320
1330
  const data = await res.json();
1321
- if (data.error) throw new Error(data.error);
1331
+ if (data.error) throw Object.assign(new Error(data.error), { dispatched: data.dispatched });
1322
1332
  return data.result;
1323
1333
  }
1324
1334
 
@@ -1357,6 +1367,7 @@ const _staleHttpTimer = setInterval(() => {
1357
1367
  _activeHttpWorkerId = "";
1358
1368
  _connectingHttpWorkers.clear();
1359
1369
  _reloadHttpWorkerHandoff = null;
1370
+ _wakeHttpPolls();
1360
1371
  _drainOnDisconnect("HTTP poll timeout");
1361
1372
  console.error("[Safari MCP] Extension disconnected (HTTP poll timeout)");
1362
1373
  }
@@ -1374,6 +1385,7 @@ function _dropWedgedHttpWorker(reason) {
1374
1385
  _activeHttpWorkerId = "";
1375
1386
  _connectingHttpWorkers.clear();
1376
1387
  _reloadHttpWorkerHandoff = null;
1388
+ _wakeHttpPolls();
1377
1389
  _drainOnDisconnect(`wedged worker: ${reason}`);
1378
1390
  console.error(`[Safari MCP] Extension worker dropped as wedged (${reason})`);
1379
1391
  }
@@ -1390,10 +1402,11 @@ function _drainOnDisconnect(reason) {
1390
1402
  return;
1391
1403
  }
1392
1404
  // Reject all in-flight requests immediately (instead of waiting for timeout)
1405
+ const queued = new Set(_commandQueue.map((c) => c.id));
1393
1406
  for (const [id, pending] of _pendingRequests) {
1394
1407
  clearTimeout(pending.timer);
1395
1408
  if (pending.reloadHandoff) _cancelReloadHttpWorkerHandoff(pending.reloadHandoff);
1396
- pending.reject(new Error(`Extension disconnected: ${reason}`));
1409
+ pending.reject(Object.assign(new Error(`Extension disconnected: ${reason}`), { dispatched: !queued.has(id) }));
1397
1410
  }
1398
1411
  _pendingRequests.clear();
1399
1412
  // Clear queued commands that will never be picked up
@@ -1433,7 +1446,40 @@ function _handleExtensionResponse(msg, respondingWorkerId = "") {
1433
1446
  }
1434
1447
 
1435
1448
  // Send command to extension (via WebSocket, HTTP command queue, or proxy to primary)
1449
+ // Thermal backpressure (opt-in). With SAFARI_MCP_THERMAL_FILE naming a JSON file whose `_t` is the chip
1450
+ // temperature and `_ts` its epoch seconds (e.g. a thermal monitor's latest sample), page-work commands wait
1451
+ // while the chip is at or above SAFARI_MCP_THERMAL_MAX (default 80) — until it is 2° cooler, at most
1452
+ // SAFARI_MCP_THERMAL_WAIT_MS (default 20000) — so many agents in many tabs slow down instead of heating the
1453
+ // Mac. Closing and listing tabs never wait; a stale or missing reading holds nothing.
1454
+ const _THERMAL_FILE = process.env.SAFARI_MCP_THERMAL_FILE || "";
1455
+ const _THERMAL_MAX = parseFloat(process.env.SAFARI_MCP_THERMAL_MAX || "80");
1456
+ const _THERMAL_WAIT_MS = parseInt(process.env.SAFARI_MCP_THERMAL_WAIT_MS || "20000", 10);
1457
+ const _THERMAL_FREE = new Set(["close_tab", "list_tabs", "get_tab_receipt", "get_tab_locus", "switch_tab", "reload_extension"]);
1458
+
1459
+ function _chipTemp() {
1460
+ try {
1461
+ const row = JSON.parse(readFileSync(_THERMAL_FILE, "utf8"));
1462
+ return Date.now() / 1000 - row._ts <= 15 ? Number(row._t) || 0 : 0;
1463
+ } catch {
1464
+ return 0;
1465
+ }
1466
+ }
1467
+
1468
+ async function _thermalGate(type) {
1469
+ if (!_THERMAL_FILE || _THERMAL_FREE.has(type) || _chipTemp() < _THERMAL_MAX) return;
1470
+ console.error(`[Safari MCP] chip at ${Math.round(_chipTemp())}°C — holding "${type}" up to ${_THERMAL_WAIT_MS / 1000}s so the Mac can cool`);
1471
+ const until = Date.now() + _THERMAL_WAIT_MS;
1472
+ while (Date.now() < until && _chipTemp() >= _THERMAL_MAX - 2) {
1473
+ await new Promise((r) => setTimeout(r, 1000));
1474
+ }
1475
+ }
1476
+
1436
1477
  function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1478
+ if (!_THERMAL_FILE || _THERMAL_FREE.has(type)) return _sendToExtension(type, payload, timeoutMs);
1479
+ return _thermalGate(type).then(() => _sendToExtension(type, payload, timeoutMs));
1480
+ }
1481
+
1482
+ function _sendToExtension(type, payload = {}, timeoutMs = 30000) {
1437
1483
  // If we're a secondary instance, proxy through primary
1438
1484
  if (!_isExtensionHost && _primaryHasExtension) {
1439
1485
  return _proxyToExtension(type, payload, timeoutMs);
@@ -1441,7 +1487,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1441
1487
 
1442
1488
  return new Promise((resolve, reject) => {
1443
1489
  if (!_extensionConnected) {
1444
- reject(new Error("Extension not connected"));
1490
+ reject(Object.assign(new Error("Extension not connected"), { dispatched: false }));
1445
1491
  return;
1446
1492
  }
1447
1493
  const reloadHandoff = type === "reload_extension" &&
@@ -1467,7 +1513,8 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1467
1513
  // long after the caller gave up and run a stale navigate/click out of band.
1468
1514
  const qi = _commandQueue.findIndex(c => c.id === id);
1469
1515
  if (qi >= 0) _commandQueue.splice(qi, 1);
1470
- reject(new Error(`Extension timeout after ${timeoutMs}ms`));
1516
+ // Still queued = never polled, so nothing ran; callers may retry elsewhere only then.
1517
+ reject(Object.assign(new Error(`Extension timeout after ${timeoutMs}ms`), { dispatched: qi < 0 }));
1471
1518
  // The worker took this command and never answered, even past the hard ceiling: it is
1472
1519
  // wedged (see /heartbeat). Drop its lease now instead of waiting for the stale timer,
1473
1520
  // so a fresh worker — or Safari re-spawning this one — can connect at once.
@@ -1497,6 +1544,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
1497
1544
  } else {
1498
1545
  // Otherwise, queue for HTTP polling
1499
1546
  _commandQueue.push(command);
1547
+ _wakeHttpPolls();
1500
1548
  _releaseContentWakePolls();
1501
1549
  }
1502
1550
  });
@@ -2098,7 +2146,9 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2098
2146
  // marker's text promises never actually ran and its text reached the caller as
2099
2147
  // the tool's value instead of as a failure (#106).
2100
2148
  hardCspBlock = typeof result === 'string' && result.includes('CSP blocked all strategies');
2101
- const isCspError = hardCspBlock || (typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy')));
2149
+ // An evaluate reports "nothing ran" through that marker alone. Other CSP wording in
2150
+ // its result is the script's own error: it ran, and AppleScript must not run it again.
2151
+ const isCspError = hardCspBlock || (extensionType !== "evaluate" && typeof result === 'string' && (result.includes('unsafe-eval') || result.includes('trusted-types') || result.includes('Trusted Type') || result.includes('Content Security Policy')));
2102
2152
  const isPermissionDenied = typeof result === 'string' && result.includes('__SCREENSHOT_PERMISSION_DENIED__');
2103
2153
  const isElementMiss = typeof result === 'string' && result.startsWith('Element not found');
2104
2154
  const isFailed = result === null || isElementMiss;
@@ -2134,6 +2184,12 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
2134
2184
  throw err;
2135
2185
  }
2136
2186
  if (_preferAppleScript) throw new Error(`Safari profile extension unavailable for "${extensionType}": ${err.message}`);
2187
+ // evaluate runs arbitrary side effects. Once the extension may have started the
2188
+ // script — the command reached it and did not come back with "did not start" —
2189
+ // AppleScript would run it a second time: every POST in it repeated.
2190
+ if (extensionType === "evaluate" && err?.dispatched !== false && !/evaluate did not start/.test(err.message)) {
2191
+ throw new Error(`safari_evaluate: ${err.message} — the script may already have run in the page, so it was not re-run through AppleScript`, { cause: err });
2192
+ }
2137
2193
  console.error(`[Safari MCP] ${extensionType} extension failed: ${err.message} — falling back to AppleScript`);
2138
2194
  }
2139
2195
  }
@@ -2204,6 +2260,11 @@ server.tool(
2204
2260
  receipt: z.string().optional().describe("Tab receipt from safari_new_tab — pins this call to that tab (survives reconnects/subagents)"),
2205
2261
  },
2206
2262
  async ({ url, receipt }) => {
2263
+ // The profile extension can only issue receipts for http(s) pages: after a trip to
2264
+ // about:blank or file:// no tool could reach the tab again, not even to close it.
2265
+ if (process.env.SAFARI_PROFILE && !/^https?:\/\//i.test(url.trim())) {
2266
+ throw new Error(`safari_navigate only opens http(s) URLs in a Safari profile ("${url.slice(0, 40)}" would leave the tab unreachable). To free the tab, use safari_close_tab.`);
2267
+ }
2207
2268
  const oldUrl = safari.getActiveTabURL();
2208
2269
  // Pre-register the destination as owned BEFORE navigating. We are navigating OUR
2209
2270
  // tab, so the target URL is ours even if navigate() throws mid-load on a slow SPA.
@@ -3158,10 +3219,15 @@ server.tool(
3158
3219
  server.tool(
3159
3220
  "safari_eval_file",
3160
3221
  "Execute JavaScript read from a FILE path (avoids passing huge scripts inline / manual copy). Same engine as safari_evaluate: extension-first (no focus steal), AppleScript fallback. Use to upload binary via a generated .js containing base64.",
3161
- { path: z.string().describe("Absolute path to a .js file whose contents are the script to execute") },
3222
+ {
3223
+ path: z.string().describe("Absolute path to a .js file whose contents are the script to execute"),
3224
+ receipt: z.string().optional().describe("Opaque extension-issued tab receipt — pass the one safari_new_tab returned to keep targeting that tab after an MCP reconnect"),
3225
+ },
3162
3226
  async (args) => {
3163
3227
  const script = readFileSync(args.path, "utf8");
3164
- const result = await extensionOrFallback("evaluate", { script }, () => safari.evaluate({ script }));
3228
+ const result = await extensionOrFallback(
3229
+ "evaluate", { script, ..._explicitReceipt(args) }, () => safari.evaluate({ script })
3230
+ );
3165
3231
  return evalResult(result);
3166
3232
  }
3167
3233
  );
@@ -4057,7 +4123,9 @@ try {
4057
4123
  } catch { /* banner is best-effort, never block startup */ }
4058
4124
  }
4059
4125
 
4060
- _startMemoryMonitor();
4126
+ // Under a profile the monitor can only log: _closeOldestMCPTab() refuses to touch profile tabs, and
4127
+ // its RSS reading hides compressed/swapped pages (433MB of RSS against 6.8GB of footprint, 27.9).
4128
+ if (!process.env.SAFARI_PROFILE) _startMemoryMonitor();
4061
4129
 
4062
4130
  // Runtime guard for the silent stale-identity regression (#29): postinstall re-signs the
4063
4131
  // helper to a stable id so the Accessibility grant persists, but `npm ci`, `--ignore-scripts`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.21.14",
3
+ "version": "2.22.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -198,10 +198,16 @@ process.on("unhandledRejection", (reason) => {
198
198
 
199
199
  // ========== SAFARI RUNNING CHECK ==========
200
200
  // Prevent AppleScript from auto-launching Safari when it's closed
201
+ // ponytail: a "running" answer is reused for 1s — AppleScript waits poll every 100–200ms and each
202
+ // call spawned a pgrep. Ceiling: a Safari quit inside that second can let one script relaunch it.
203
+ let _safariRunningUntil = 0;
201
204
  async function isSafariRunning() {
205
+ if (Date.now() < _safariRunningUntil) return true;
202
206
  try {
203
207
  const { stdout } = await execFileAsync("pgrep", ["-x", "Safari"], { timeout: 2000 });
204
- return stdout.trim().length > 0;
208
+ const running = stdout.trim().length > 0;
209
+ if (running) _safariRunningUntil = Date.now() + 1000;
210
+ return running;
205
211
  } catch {
206
212
  return false; // pgrep exits 1 when no match
207
213
  }
@@ -413,14 +419,17 @@ function _maybeOpenProfileWindow() {
413
419
 
414
420
  // Background verification: periodically check that cached window ID still belongs to profile
415
421
  if (SAFARI_PROFILE) {
416
- // Self-scheduling poll with exponential backoff (#81): 3s while the window is
422
+ // Self-scheduling poll with exponential backoff (#81): 15s while the window is
417
423
  // present (or flakily undetected), doubling per consecutive miss up to 60s
418
424
  // while it is absent — a closed profile window is a steady state, and the
419
425
  // fixed 3s cadence used to spawn an osascript subprocess per cycle, forever.
420
- // The first successful detection resets to 3s, so rediscovery stays
426
+ // The first successful detection resets to 15s, so rediscovery stays
421
427
  // responsive: once the user opens the window, the next poll lands within 60s
422
- // and everything after it is back on the 3s cadence.
423
- const _POLL_BASE_MS = 3000;
428
+ // and everything after it is back on the 15s cadence. This poll is only the
429
+ // background self-heal: every AppleScript caller re-validates the window itself
430
+ // (refreshTargetWindow, 1s cache), so 3s cost a pgrep + an Apple Event to
431
+ // Safari every 3 seconds per daemon for nothing.
432
+ const _POLL_BASE_MS = 15000;
424
433
  const _POLL_MAX_MS = 60000;
425
434
  const _pollOnce = async () => {
426
435
  // No cached window (e.g. flaky detection at startup) — keep trying to
@@ -4114,10 +4123,12 @@ export function _buildEvalExpr(js) {
4114
4123
  expr = `(async function(){${js}})()`;
4115
4124
  }
4116
4125
  } else {
4117
- // Indirect eval yields the completion value of an arbitrary statement list;
4118
- // the catch re-runs the body plainly when a strict CSP blocks eval.
4126
+ // Indirect eval yields the completion value of an arbitrary statement list. The
4127
+ // plain body is the fallback for a strict CSP that refuses eval — probed with "0",
4128
+ // so a script that threw by itself (it ran) is not run a second time.
4119
4129
  const escaped = js.replace(/\\/g, '\\\\').replace(/'/g, "\\'").replace(/\n/g, '\\n').replace(/\r/g, '\\r');
4120
- expr = "(function(){ try { return (0,eval)('" + escaped + "') } catch(_e) { " + js.replace(/\n/g, ' ') + " } })()";
4130
+ expr = "(function(){ var __mcpEvalOk = true; try { (0,eval)('0') } catch(_e) { __mcpEvalOk = false } " +
4131
+ "if (__mcpEvalOk) return (0,eval)('" + escaped + "'); " + js.replace(/\n/g, ' ') + " })()";
4121
4132
  }
4122
4133
  }
4123
4134
  return { isAsync, expr };
@@ -4127,24 +4138,35 @@ export function _buildEvalExpr(js) {
4127
4138
  // the moment the synchronous portion finishes, handing back an unsettled Promise.
4128
4139
  // So the work is started fire-and-forget into a page global, then that global is
4129
4140
  // polled synchronously from the Node side (the same pattern navigate() uses).
4141
+ //
4142
+ // Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
4143
+ // dot access needs no quoting/escaping through the AppleScript bridge.
4144
+ function _newEvalSlot() {
4145
+ return 'window.__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
4146
+ }
4147
+
4148
+ // Page-side statement that awaits `valueJs` and records the outcome in `slot`.
4149
+ function _settleIntoSlot(slot, valueJs) {
4150
+ return `(async function(){try{var __v=await (${valueJs});` +
4151
+ `${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
4152
+ `}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
4153
+ `finally{${slot}.done=true;}})();`;
4154
+ }
4155
+
4130
4156
  async function _evaluateAsync(expr) {
4131
- // Token is identifier-safe (base36 → [0-9a-z], `_` prefix) so `window.<token>`
4132
- // dot access needs no quoting/escaping through the AppleScript bridge.
4133
- const token = '__mcpEval_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
4134
- const slot = 'window.' + token;
4157
+ const slot = _newEvalSlot();
4135
4158
  // A SYNC outer function installs the globals, starts the async work (NOT awaited
4136
4159
  // here — `do JavaScript` would not await it anyway) and returns immediately.
4137
- const kickoff =
4138
- `(function(){${slot}={done:false};(async function(){try{` +
4139
- `var __v=await (${expr});` +
4140
- `${slot}.val=(__v===undefined||__v===null)?null:(typeof __v==='object'?JSON.stringify(__v):String(__v));` +
4141
- `}catch(__e){${slot}.err=(__e&&__e.message)||String(__e);}` +
4142
- `finally{${slot}.done=true;}})();return 'ok';})()`;
4160
+ const kickoff = `(function(){${slot}={done:false};${_settleIntoSlot(slot, expr)}return 'ok';})()`;
4143
4161
  const started = await runJS(kickoff, { timeout: 10000 });
4144
4162
  if (started !== 'ok') {
4145
4163
  return typeof started === 'string' && started ? started : '(no return value)';
4146
4164
  }
4147
- // Poll the result global until the async work settles (35s budget).
4165
+ return _awaitEvalSlot(slot);
4166
+ }
4167
+
4168
+ // Poll the result global until the async work settles (35s budget).
4169
+ async function _awaitEvalSlot(slot) {
4148
4170
  const pollJs =
4149
4171
  `(function(){var s=${slot};if(!s)return '__MCP_GONE__';` +
4150
4172
  `if(!s.done)return '';return JSON.stringify({v:s.val,e:s.err});})()`;
@@ -4172,25 +4194,28 @@ async function _evaluateAsync(expr) {
4172
4194
  }
4173
4195
  }
4174
4196
 
4197
+ // Sync: a single `do JavaScript` over one expression — `do JavaScript` only returns the
4198
+ // value of a single expression, so the whole script is one IIFE. The regex async-sniff in
4199
+ // _buildEvalExpr only catches a literal await/.then/async and misses scripts whose *value*
4200
+ // is a thenable (`Promise.resolve(5)`, an async IIFE with no inner await). Such a value is
4201
+ // awaited in place into a slot: the script already ran, and evaluating it again — as this
4202
+ // used to on "[object Promise]" — repeated every side effect in it.
4203
+ export function _buildSyncEvalJs(expr, slot) {
4204
+ return `(function(){ try { var __r = (${expr}); ` +
4205
+ `if (__r && typeof __r.then === 'function') { ${slot}={done:false}; ${_settleIntoSlot(slot, '__r')} return '__MCP_EVAL_PENDING__'; } ` +
4206
+ `return __r; } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
4207
+ }
4208
+
4175
4209
  export async function evaluate({ script }) {
4176
4210
  const js = (script || '').trim();
4177
4211
  if (!js) return '(no return value)';
4178
4212
  const { isAsync, expr } = _buildEvalExpr(js);
4179
4213
  if (isAsync) return _evaluateAsync(expr);
4180
- // Sync: a single `do JavaScript` over one expression. `do JavaScript` only
4181
- // returns the value of a single expression, so the whole script is one IIFE.
4182
- const wrappedJs = `(function(){ try { return (${expr}); } catch(__mcpErr) { return 'Error: ' + __mcpErr.message; } })()`;
4214
+ const slot = _newEvalSlot();
4215
+ const wrappedJs = _buildSyncEvalJs(expr, slot);
4183
4216
  if (process.env.MCP_DEBUG) console.error('[evaluate] wrapped:', wrappedJs.substring(0, 300));
4184
4217
  const result = await runJS(wrappedJs);
4185
- // The regex async-sniff in _buildEvalExpr only catches a literal await/.then/async.
4186
- // It misses scripts whose *value* is a thenable — `Promise.resolve(5)`, an async IIFE
4187
- // with no inner await, any fn returning a promise. `do JavaScript` can't await those,
4188
- // so they come back as the literal "[object Promise]". Re-run through the async poller
4189
- // in that case. (Pathological: a script genuinely returning the string "[object Promise]"
4190
- // re-runs to the same value, so there is no downside.)
4191
- if (typeof result === 'string' && result.trim() === '[object Promise]') {
4192
- return _evaluateAsync(expr);
4193
- }
4218
+ if (typeof result === 'string' && result.trim() === '__MCP_EVAL_PENDING__') return _awaitEvalSlot(slot);
4194
4219
  if (result === null || result === undefined || result === '') {
4195
4220
  return '(no return value)';
4196
4221
  }