safari-mcp 2.11.9 → 2.13.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 +125 -26
- package/extension/content.js +17 -5
- package/extension/manifest.json +1 -1
- package/index.js +263 -82
- package/ownership-match.js +68 -0
- package/package.json +5 -3
- package/safari-helper +0 -0
- package/safari-helper.swift +57 -15
- package/safari.js +566 -287
package/extension/background.js
CHANGED
|
@@ -67,8 +67,13 @@ function updateBadge(text) {
|
|
|
67
67
|
|
|
68
68
|
// ========== HTTP LONG-POLLING TRANSPORT ==========
|
|
69
69
|
|
|
70
|
+
let _connecting = false; // re-entrancy lock — the startup promise and the alarm can race into connect()
|
|
70
71
|
async function connect() {
|
|
71
72
|
if (!_enabled) return;
|
|
73
|
+
// One connect at a time: two near-simultaneous calls (cold start + alarm wake)
|
|
74
|
+
// could each spawn a poll loop before the other assigned pollAbort.
|
|
75
|
+
if (_connecting) return;
|
|
76
|
+
_connecting = true;
|
|
72
77
|
// Cancel any existing poll
|
|
73
78
|
if (pollAbort) {
|
|
74
79
|
try { pollAbort.abort(); } catch {}
|
|
@@ -91,6 +96,7 @@ async function connect() {
|
|
|
91
96
|
if (!isCorrectProfile) {
|
|
92
97
|
console.log(`Safari MCP: wrong profile — server wants "${data.profile}", disconnecting`);
|
|
93
98
|
updateBadge("OFF");
|
|
99
|
+
_connecting = false;
|
|
94
100
|
return; // Do NOT poll — let the correct profile's extension handle commands
|
|
95
101
|
}
|
|
96
102
|
// Notify server that we passed profile verification
|
|
@@ -104,6 +110,7 @@ async function connect() {
|
|
|
104
110
|
_reconnectDelay = 3000; // Reset backoff on success
|
|
105
111
|
updateBadge("ON");
|
|
106
112
|
_startHeartbeat(); // Keep service worker alive between polls
|
|
113
|
+
_connecting = false;
|
|
107
114
|
pollForCommands();
|
|
108
115
|
return;
|
|
109
116
|
}
|
|
@@ -113,6 +120,7 @@ async function connect() {
|
|
|
113
120
|
isConnected = false;
|
|
114
121
|
updateBadge("");
|
|
115
122
|
scheduleReconnect();
|
|
123
|
+
_connecting = false;
|
|
116
124
|
}
|
|
117
125
|
|
|
118
126
|
function scheduleReconnect() {
|
|
@@ -145,14 +153,18 @@ async function pollForCommands() {
|
|
|
145
153
|
});
|
|
146
154
|
clearTimeout(timeout);
|
|
147
155
|
if (res.status === 200) {
|
|
148
|
-
|
|
149
|
-
|
|
156
|
+
// A single malformed/truncated body must NOT tear down the poll loop — a bad
|
|
157
|
+
// packet used to throw SyntaxError here, fall through to "server gone", and
|
|
158
|
+
// trigger a multi-second reconnect backoff. Skip the bad packet and keep polling.
|
|
159
|
+
const msg = await res.json().catch(() => null);
|
|
160
|
+
if (msg) await executeAndReply(msg);
|
|
150
161
|
}
|
|
151
162
|
// 204 = no command, loop immediately to keep connection active
|
|
152
163
|
} catch (err) {
|
|
153
164
|
if (err.name === "AbortError") {
|
|
154
|
-
//
|
|
155
|
-
|
|
165
|
+
// 90s safety-timeout abort while still connected — continue the loop in place.
|
|
166
|
+
// (Re-calling pollForCommands() here risked two overlapping loops posting dup results.)
|
|
167
|
+
if (isConnected && _enabled) continue;
|
|
156
168
|
return; // Intentional abort (disable/new connect)
|
|
157
169
|
}
|
|
158
170
|
// Server gone — reconnect via shared scheduler (prevents duplicate timers)
|
|
@@ -202,6 +214,10 @@ async function handleCommand(type, payload) {
|
|
|
202
214
|
throw new Error("Tab belongs to a different profile — refusing to operate on personal tabs");
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
// Rehydrate owned-tab state after a possible service-worker restart BEFORE
|
|
218
|
+
// consulting the guard — an empty post-restart Map used to disable it entirely.
|
|
219
|
+
await _hydrateOwnedTabs();
|
|
220
|
+
|
|
205
221
|
// ========== TAB OWNERSHIP GUARD ==========
|
|
206
222
|
// Block write operations on tabs not opened by this session.
|
|
207
223
|
// new_tab is always allowed (it creates owned tabs). Read-only ops are allowed on any tab.
|
|
@@ -241,7 +257,12 @@ async function handleCommand(type, payload) {
|
|
|
241
257
|
return true;
|
|
242
258
|
}, [], tabId).catch(() => true);
|
|
243
259
|
|
|
244
|
-
|
|
260
|
+
// Don't hard-reload OAuth/redirect callback pages: a sparse-but-valid callback
|
|
261
|
+
// (code=/token=/state= in the URL) would be reloaded, dropping its POST data and
|
|
262
|
+
// breaking the auth flow. Only reload normal http(s) pages without auth params.
|
|
263
|
+
const reloadable = /^https?:/i.test(payload.url || "") &&
|
|
264
|
+
!/[?&#](code|token|access_token|id_token|state|session_state)=/i.test(payload.url || "");
|
|
265
|
+
if (!hasContent && reloadable) {
|
|
245
266
|
// Try hard reload once
|
|
246
267
|
await browser.tabs.reload(tabId, { bypassCache: true });
|
|
247
268
|
await waitForTabLoad(tabId, 15000);
|
|
@@ -338,7 +359,11 @@ async function handleCommand(type, payload) {
|
|
|
338
359
|
// Strategy 2: Script element injection (works when inline scripts are allowed)
|
|
339
360
|
const injectResult = await execInTab(async (script) => {
|
|
340
361
|
return await new Promise((resolve) => {
|
|
341
|
-
|
|
362
|
+
// Unpredictable key — a Date.now()-based name let a hostile page pre-seed
|
|
363
|
+
// window["__mcp_eval_<now>"] with a fabricated {done:true,v:...} result.
|
|
364
|
+
const id = "__mcp_eval_" + (crypto.randomUUID
|
|
365
|
+
? crypto.randomUUID().replace(/-/g, "")
|
|
366
|
+
: Date.now().toString(36) + Math.random().toString(36).slice(2));
|
|
342
367
|
window[id] = { done: false };
|
|
343
368
|
const s = document.createElement("script");
|
|
344
369
|
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};}";
|
|
@@ -459,12 +484,17 @@ async function handleCommand(type, payload) {
|
|
|
459
484
|
return null;
|
|
460
485
|
}
|
|
461
486
|
const m = refs[refId];
|
|
487
|
+
// Escape attribute values + guard the query — a page-controlled aria-label/name/
|
|
488
|
+
// placeholder containing a double-quote would otherwise build an invalid selector
|
|
489
|
+
// and throw a DOMException that surfaces as a misleading "element not found".
|
|
490
|
+
const dqAttr = (sel) => { try { return dq(sel); } catch (_e) { return null; } };
|
|
491
|
+
const aq = (v) => String(v).replace(/["\\]/g, "\\$&");
|
|
462
492
|
if (m.id) { el = document.getElementById(m.id); if (el) return el; }
|
|
463
|
-
if (m.nameAttr) { el =
|
|
464
|
-
if (m.al) { el =
|
|
465
|
-
if (m.ph) { el =
|
|
493
|
+
if (m.nameAttr) { el = dqAttr('[name="' + aq(m.nameAttr) + '"]'); if (el) return el; }
|
|
494
|
+
if (m.al) { el = dqAttr('[aria-label="' + aq(m.al) + '"]'); if (el) return el; }
|
|
495
|
+
if (m.ph) { el = dqAttr('[placeholder="' + aq(m.ph) + '"]'); if (el) return el; }
|
|
466
496
|
// Type attribute fallback (input type="email", type="url", etc.)
|
|
467
|
-
if (m.inputType) { el =
|
|
497
|
+
if (m.inputType) { el = dqAttr(m.tag.toLowerCase() + '[type="' + aq(m.inputType) + '"]'); if (el) return el; }
|
|
468
498
|
// Coordinate fallback — scroll into view then hit-test
|
|
469
499
|
if (m.cx !== undefined && m.cy !== undefined) {
|
|
470
500
|
window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
|
|
@@ -1073,16 +1103,33 @@ async function handleCommand(type, payload) {
|
|
|
1073
1103
|
}
|
|
1074
1104
|
|
|
1075
1105
|
case "close_tab": {
|
|
1106
|
+
// ── Guard: never remove a window's LAST tab — doing so closes the window
|
|
1107
|
+
// (quitting Safari if it's the only one, or making a profile-targeted
|
|
1108
|
+
// window vanish). Per-window, not global, so other-profile windows don't
|
|
1109
|
+
// mask it. If the target window is down to one tab, blank it instead.
|
|
1110
|
+
// Mirrors safari.js closeTab().
|
|
1111
|
+
const _winQuery = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
|
|
1112
|
+
const _winTabs = await browser.tabs.query(_winQuery);
|
|
1113
|
+
const _isLastTab = _winTabs.length <= 1;
|
|
1076
1114
|
if (payload.index) {
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1115
|
+
// Resolve the target from the SAME query as the last-tab count — a second
|
|
1116
|
+
// query here was a TOCTOU window (a tab opened/closed between the two awaits
|
|
1117
|
+
// could resolve the wrong tab or leave a stale last-tab verdict).
|
|
1118
|
+
const target = _winTabs[payload.index - 1];
|
|
1080
1119
|
if (target) {
|
|
1081
1120
|
_removeOwnedTab(sessionId, target.id);
|
|
1121
|
+
if (_isLastTab) {
|
|
1122
|
+
await browser.tabs.update(target.id, { url: "about:blank" });
|
|
1123
|
+
return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
|
|
1124
|
+
}
|
|
1082
1125
|
await browser.tabs.remove(target.id);
|
|
1083
1126
|
}
|
|
1084
1127
|
} else {
|
|
1085
1128
|
_removeOwnedTab(sessionId, tabId);
|
|
1129
|
+
if (_isLastTab) {
|
|
1130
|
+
await browser.tabs.update(tabId, { url: "about:blank" });
|
|
1131
|
+
return "Last remaining tab blanked instead of closed (closing it would quit Safari)";
|
|
1132
|
+
}
|
|
1086
1133
|
await browser.tabs.remove(tabId);
|
|
1087
1134
|
}
|
|
1088
1135
|
return "Tab closed";
|
|
@@ -1152,6 +1199,13 @@ async function handleCommand(type, payload) {
|
|
|
1152
1199
|
}).catch(() => {});
|
|
1153
1200
|
await browser.tabs.update(tabId, { url: payload.url });
|
|
1154
1201
|
await waitForTabLoad(tabId, payload.timeout || 30000);
|
|
1202
|
+
// Update the session cache like every other navigation command — without this,
|
|
1203
|
+
// the next command within TAB_CACHE_MS resolved against the PRE-navigation URL
|
|
1204
|
+
// and could fall through to the user's active tab.
|
|
1205
|
+
try {
|
|
1206
|
+
const updated = await browser.tabs.get(tabId);
|
|
1207
|
+
_setSessionTab(sessionId, updated.id, updated.url);
|
|
1208
|
+
} catch {}
|
|
1155
1209
|
const maxLen = payload.maxLength || 50000;
|
|
1156
1210
|
const results = await browser.scripting.executeScript({
|
|
1157
1211
|
target: { tabId },
|
|
@@ -1233,27 +1287,31 @@ async function handleCommand(type, payload) {
|
|
|
1233
1287
|
attrs = ` ref="${refId}"`;
|
|
1234
1288
|
}
|
|
1235
1289
|
|
|
1290
|
+
// Escape page-controlled attribute values: a crafted aria-label/title/value with
|
|
1291
|
+
// a double-quote could otherwise break the pseudo-XML snapshot and inject a fake
|
|
1292
|
+
// ref=""/role="" that steers the agent into clicking the wrong element.
|
|
1293
|
+
const esc = (s) => String(s).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
1236
1294
|
const role = el.getAttribute("role");
|
|
1237
|
-
if (role) attrs += ` role="${role}"`;
|
|
1238
|
-
if (el.id) attrs += ` id="${el.id}"`;
|
|
1295
|
+
if (role) attrs += ` role="${esc(role)}"`;
|
|
1296
|
+
if (el.id) attrs += ` id="${esc(el.id)}"`;
|
|
1239
1297
|
const al = el.getAttribute("aria-label");
|
|
1240
|
-
if (al) attrs += ` aria-label="${al}"`;
|
|
1298
|
+
if (al) attrs += ` aria-label="${esc(al)}"`;
|
|
1241
1299
|
const title = el.getAttribute("title");
|
|
1242
|
-
if (title) attrs += ` title="${title.substring(0, 80)}"`;
|
|
1300
|
+
if (title) attrs += ` title="${esc(title.substring(0, 80))}"`;
|
|
1243
1301
|
if (el.value && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) {
|
|
1244
|
-
attrs += ` value="${String(el.value).substring(0, 50)}"`;
|
|
1302
|
+
attrs += ` value="${esc(String(el.value).substring(0, 50))}"`;
|
|
1245
1303
|
}
|
|
1246
|
-
if (el.type && el.tagName === "INPUT") attrs += ` type="${el.type}"`;
|
|
1247
|
-
if (el.href && el.tagName === "A") attrs += ` href="${el.href.substring(0, 100)}"`;
|
|
1304
|
+
if (el.type && el.tagName === "INPUT") attrs += ` type="${esc(el.type)}"`;
|
|
1305
|
+
if (el.href && el.tagName === "A") attrs += ` href="${esc(el.href.substring(0, 100))}"`;
|
|
1248
1306
|
if (el.disabled) attrs += " disabled";
|
|
1249
1307
|
const ph = el.getAttribute("placeholder");
|
|
1250
|
-
if (ph) attrs += ` placeholder="${ph}"`;
|
|
1308
|
+
if (ph) attrs += ` placeholder="${esc(ph)}"`;
|
|
1251
1309
|
// For interactive elements with no visible text — show alt/aria-describedby hint
|
|
1252
|
-
if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${el.alt.substring(0, 80)}"`;
|
|
1310
|
+
if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${esc(el.alt.substring(0, 80))}"`;
|
|
1253
1311
|
const ariaDesc = el.getAttribute("aria-describedby");
|
|
1254
1312
|
if (ariaDesc) {
|
|
1255
1313
|
const descEl = document.getElementById(ariaDesc);
|
|
1256
|
-
if (descEl) attrs += ` described="${descEl.textContent.trim().substring(0, 80)}"`;
|
|
1314
|
+
if (descEl) attrs += ` described="${esc(descEl.textContent.trim().substring(0, 80))}"`;
|
|
1257
1315
|
}
|
|
1258
1316
|
|
|
1259
1317
|
// Self-closing for some tags
|
|
@@ -1721,16 +1779,47 @@ const MAX_SESSIONS = 50; // Hard cap on session cache size
|
|
|
1721
1779
|
// Prevents operating on user's tabs — only tabs created via new_tab are "owned".
|
|
1722
1780
|
const _sessionOwnedTabs = new Map(); // sessionId → Set<tabId>
|
|
1723
1781
|
|
|
1782
|
+
// Persist owned-tab IDs in storage.session: it survives the frequent MV3
|
|
1783
|
+
// service-worker terminations (but clears when Safari quits, matching tab-ID
|
|
1784
|
+
// lifetime). Without this, every worker restart wiped the Map — and the
|
|
1785
|
+
// "no tabs owned yet" compatibility path then silently allowed write commands
|
|
1786
|
+
// on ANY tab, including the user's.
|
|
1787
|
+
const _OWNED_TABS_KEY = "mcpSessionOwnedTabs";
|
|
1788
|
+
let _ownedTabsHydrated = false;
|
|
1789
|
+
async function _hydrateOwnedTabs() {
|
|
1790
|
+
if (_ownedTabsHydrated) return;
|
|
1791
|
+
_ownedTabsHydrated = true;
|
|
1792
|
+
try {
|
|
1793
|
+
const data = (await browser.storage.session.get(_OWNED_TABS_KEY))?.[_OWNED_TABS_KEY];
|
|
1794
|
+
if (data) {
|
|
1795
|
+
for (const [sid, ids] of Object.entries(data)) {
|
|
1796
|
+
if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
|
|
1797
|
+
const set = _sessionOwnedTabs.get(sid);
|
|
1798
|
+
for (const id of ids) set.add(id);
|
|
1799
|
+
}
|
|
1800
|
+
}
|
|
1801
|
+
} catch {} // storage.session unavailable → behave as before (in-memory only)
|
|
1802
|
+
}
|
|
1803
|
+
function _persistOwnedTabs() {
|
|
1804
|
+
try {
|
|
1805
|
+
const obj = {};
|
|
1806
|
+
for (const [sid, set] of _sessionOwnedTabs) obj[sid] = [...set];
|
|
1807
|
+
browser.storage.session.set({ [_OWNED_TABS_KEY]: obj }).catch(() => {});
|
|
1808
|
+
} catch {}
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1724
1811
|
function _addOwnedTab(sessionId, tabId) {
|
|
1725
1812
|
const sid = sessionId || _DEFAULT_SESSION;
|
|
1726
1813
|
if (!_sessionOwnedTabs.has(sid)) _sessionOwnedTabs.set(sid, new Set());
|
|
1727
1814
|
_sessionOwnedTabs.get(sid).add(tabId);
|
|
1815
|
+
_persistOwnedTabs();
|
|
1728
1816
|
}
|
|
1729
1817
|
|
|
1730
1818
|
function _removeOwnedTab(sessionId, tabId) {
|
|
1731
1819
|
const sid = sessionId || _DEFAULT_SESSION;
|
|
1732
1820
|
const set = _sessionOwnedTabs.get(sid);
|
|
1733
1821
|
if (set) set.delete(tabId);
|
|
1822
|
+
_persistOwnedTabs();
|
|
1734
1823
|
}
|
|
1735
1824
|
|
|
1736
1825
|
function _isTabOwnedBySession(sessionId, tabId) {
|
|
@@ -1942,11 +2031,21 @@ async function getTargetTab(tabUrl, sessionId) {
|
|
|
1942
2031
|
match = all.find(t => t.url && (t.url.startsWith(tabUrl) || tabUrl.startsWith(t.url.split("?")[0])));
|
|
1943
2032
|
}
|
|
1944
2033
|
if (match) {
|
|
2034
|
+
// A URL match in a DIFFERENT window must not silently retarget the profile
|
|
2035
|
+
// window — a URL collision with a tab in the user's personal window would
|
|
2036
|
+
// permanently redirect every subsequent command there. Adopt the match's
|
|
2037
|
+
// window only when the tracked profile window no longer exists.
|
|
2038
|
+
if (_profileWindowId && match.windowId !== _profileWindowId) {
|
|
2039
|
+
let profileWindowGone = false;
|
|
2040
|
+
try { await browser.windows.get(_profileWindowId); }
|
|
2041
|
+
catch { profileWindowGone = true; }
|
|
2042
|
+
if (!profileWindowGone) {
|
|
2043
|
+
throw new Error("Tab not found in the MCP profile window (a same-URL tab exists in another window — refusing to cross windows). Use safari_new_tab.");
|
|
2044
|
+
}
|
|
2045
|
+
console.log("Safari MCP: profile window gone — adopting", match.windowId);
|
|
2046
|
+
}
|
|
1945
2047
|
_setSessionTab(sessionId, match.id, tabUrl);
|
|
1946
2048
|
if (!_profileWindowId || match.windowId !== _profileWindowId) {
|
|
1947
|
-
if (_profileWindowId && match.windowId !== _profileWindowId) {
|
|
1948
|
-
console.log("Safari MCP: profile window changed:", _profileWindowId, "→", match.windowId);
|
|
1949
|
-
}
|
|
1950
2049
|
_profileWindowId = match.windowId;
|
|
1951
2050
|
browser.storage.local.set({ mcpProfileWindowId: _profileWindowId }).catch(() => {});
|
|
1952
2051
|
console.log("Safari MCP: profile windowId =", _profileWindowId);
|
package/extension/content.js
CHANGED
|
@@ -20,18 +20,30 @@ if (!window.__mcpShadowPatched) {
|
|
|
20
20
|
}
|
|
21
21
|
return shadow;
|
|
22
22
|
};
|
|
23
|
-
// Expose getter for MCP tools (snapshot, deepQuery, click, fill)
|
|
24
|
-
|
|
23
|
+
// Expose getter for MCP tools (snapshot, deepQuery, click, fill).
|
|
24
|
+
// Non-enumerable + non-writable: pages that know the name can still call it
|
|
25
|
+
// (inherent to MAIN-world injection), but it doesn't surface in enumeration and —
|
|
26
|
+
// more importantly — page scripts can't REPLACE it to feed MCP fake shadow roots.
|
|
27
|
+
var _getShadowRoot = function(el) {
|
|
25
28
|
return el.shadowRoot || _closedRoots.get(el) || null;
|
|
26
29
|
};
|
|
30
|
+
try {
|
|
31
|
+
Object.defineProperty(window, "__mcpGetShadowRoot", {
|
|
32
|
+
value: _getShadowRoot, writable: false, enumerable: false, configurable: false
|
|
33
|
+
});
|
|
34
|
+
} catch (_e) {
|
|
35
|
+
window.__mcpGetShadowRoot = _getShadowRoot;
|
|
36
|
+
}
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
|
|
30
40
|
try {
|
|
41
|
+
// Register ONLY createScript — the single capability the bridge uses (background.js
|
|
42
|
+
// evaluate sets script.textContent via createScript). A world-accessible pass-through
|
|
43
|
+
// createHTML would let the page's own scripts wrap arbitrary HTML as trusted, defeating
|
|
44
|
+
// its Trusted-Types protection; createScriptURL is likewise unused. Least privilege.
|
|
31
45
|
window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
|
|
32
|
-
createScript: function (s) { return s; }
|
|
33
|
-
createScriptURL: function (s) { return s; },
|
|
34
|
-
createHTML: function (s) { return s; }
|
|
46
|
+
createScript: function (s) { return s; }
|
|
35
47
|
});
|
|
36
48
|
} catch (_e) {
|
|
37
49
|
// Page already restricts policies — rare since content script runs at document_start
|
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.2",
|
|
6
6
|
"icons": {
|
|
7
7
|
"48": "images/icon-48.png",
|
|
8
8
|
"96": "images/icon-96.png",
|