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.
@@ -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
- const msg = await res.json();
149
- await executeAndReply(msg);
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
- // If still connected, this was a safety timeout — reconnect
155
- if (isConnected && _enabled) { pollForCommands(); return; }
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
- if (!hasContent) {
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
- const id = "__mcp_eval_" + Date.now();
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 = dq('[name="' + m.nameAttr + '"]'); if (el) return el; }
464
- if (m.al) { el = dq('[aria-label="' + m.al + '"]'); if (el) return el; }
465
- if (m.ph) { el = dq('[placeholder="' + m.ph + '"]'); if (el) return 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 = dq(m.tag.toLowerCase() + '[type="' + m.inputType + '"]'); if (el) return 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
- const query = _profileWindowId ? { windowId: _profileWindowId } : { currentWindow: true };
1078
- const tabs = await browser.tabs.query(query);
1079
- const target = tabs[payload.index - 1];
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, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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);
@@ -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
- window.__mcpGetShadowRoot = function(el) {
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
@@ -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.1",
5
+ "version": "2.10.2",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",