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.
- package/extension/background.js +253 -145
- package/extension/command-content.js +36 -28
- package/extension/content.js +66 -80
- package/extension/manifest.json +1 -1
- package/index.js +131 -53
- package/ownership-state.js +5 -2
- package/package.json +1 -1
- package/safari.js +89 -31
package/extension/background.js
CHANGED
|
@@ -1144,146 +1144,20 @@ async function handleCommand(type, payload) {
|
|
|
1144
1144
|
}, [payload.maxLength || 200000], tabId);
|
|
1145
1145
|
}
|
|
1146
1146
|
|
|
1147
|
-
// --- JavaScript Execution —
|
|
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
|
|
1163
|
-
//
|
|
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
|
-
|
|
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
|
|
3807
|
-
//
|
|
3808
|
-
//
|
|
3809
|
-
//
|
|
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
|
|
3812
|
-
//
|
|
3813
|
-
// pages fast instead of merely working. Cleared when the tab navigates
|
|
3814
|
-
// page that stops blocking injection is re-probed rather than downgraded
|
|
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
|
|
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
|
-
|
|
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)
|
|
483
|
-
// scripting.executeScript
|
|
484
|
-
//
|
|
485
|
-
//
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
//
|
|
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
|
|
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 =
|
|
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(
|
|
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 !== "
|
|
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
|
-
//
|
|
529
|
-
//
|
|
530
|
-
//
|
|
531
|
-
//
|
|
532
|
-
const
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
} catch (
|
|
537
|
-
finish({ ok: false, error: "eval
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|