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.
- 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 +95 -27
- package/package.json +1 -1
- package/safari.js +56 -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
|
|
package/extension/content.js
CHANGED
|
@@ -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:
|
|
14
|
-
//
|
|
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
|
|
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
|
-
|
|
22
|
-
|
|
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 !== "
|
|
37
|
+
if (!d || d.__mcp !== "eval_once" || typeof d.id !== "string") return;
|
|
31
38
|
var id = d.id;
|
|
32
|
-
var
|
|
33
|
-
var
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
+
s.textContent = code;
|
|
102
83
|
}
|
|
103
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
};
|
|
108
|
-
|
|
109
|
-
}
|
|
110
|
-
|
|
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
|
|
package/extension/manifest.json
CHANGED
|
@@ -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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
913
|
-
clearInterval(checkInterval);
|
|
914
|
+
done();
|
|
914
915
|
res.writeHead(200, { "Content-Type": "application/json" });
|
|
915
916
|
res.end(JSON.stringify(cmd));
|
|
916
917
|
}
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
918
|
+
};
|
|
919
|
+
const timer = setTimeout(() => {
|
|
920
|
+
done();
|
|
921
|
+
res.writeHead(204);
|
|
922
|
+
res.end();
|
|
923
|
+
}, 5000);
|
|
924
|
+
const done = () => {
|
|
921
925
|
clearTimeout(timer);
|
|
922
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
{
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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):
|
|
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
|
|
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
|
|
423
|
-
|
|
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
|
-
//
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4181
|
-
|
|
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
|
-
|
|
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
|
}
|