safari-mcp 2.10.0 → 2.10.2

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.
@@ -342,9 +342,15 @@ async function handleCommand(type, payload) {
342
342
  window[id] = { done: false };
343
343
  const s = document.createElement("script");
344
344
  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};}";
345
- if (window.trustedTypes && window.trustedTypes.createPolicy) {
345
+ // Prefer the policy pre-registered by content.js at document_start —
346
+ // pages that block new policy creation post-load (GSC, modern Google admin)
347
+ // still accept ours because it was grandfathered in before their CSP applied.
348
+ if (window.__mcpTrustedPolicy && typeof window.__mcpTrustedPolicy.createScript === "function") {
349
+ try { s.textContent = window.__mcpTrustedPolicy.createScript(code); }
350
+ catch (_) { s.textContent = code; }
351
+ } else if (window.trustedTypes && window.trustedTypes.createPolicy) {
346
352
  try {
347
- const policy = window.trustedTypes.createPolicy("mcpEval", { createScript: (s) => s });
353
+ const policy = window.trustedTypes.createPolicy("mcpEval_" + Date.now(), { createScript: (s) => s });
348
354
  s.textContent = policy.createScript(code);
349
355
  } catch (_) { s.textContent = code; }
350
356
  } else {
@@ -1,7 +1,11 @@
1
- // Content script — runs at document_start in MAIN world (before page scripts)
2
- // Purpose: Monkey-patch attachShadow to capture CLOSED shadow roots.
3
- // Without this, Reddit and other sites using closed shadow DOM
4
- // are invisible to snapshot/click/fill.
1
+ // Content script — runs at document_start in MAIN world (before page scripts).
2
+ // Two responsibilities:
3
+ // 1. Monkey-patch attachShadow to capture CLOSED shadow roots (Reddit, etc.).
4
+ // 2. Pre-register a Trusted Types policy named "mcpBridge" BEFORE the page sets
5
+ // its own require-trusted-types-for directive. Our policy is then grandfathered
6
+ // and survives even on pages (Google Search Console, Google admin, modern banks)
7
+ // that block new policy creation after page load. MCP evaluate strategies
8
+ // consult `window.__mcpTrustedPolicy` first.
5
9
  // Runs in MAIN world via manifest "world": "MAIN" — no script injection needed,
6
10
  // so CSP cannot block it.
7
11
 
@@ -21,3 +25,16 @@ if (!window.__mcpShadowPatched) {
21
25
  return el.shadowRoot || _closedRoots.get(el) || null;
22
26
  };
23
27
  }
28
+
29
+ if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
30
+ try {
31
+ window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
32
+ createScript: function (s) { return s; },
33
+ createScriptURL: function (s) { return s; },
34
+ createHTML: function (s) { return s; }
35
+ });
36
+ } catch (_e) {
37
+ // Page already restricts policies — rare since content script runs at document_start
38
+ // before page scripts. Leave undefined; evaluate fallbacks will probe other paths.
39
+ }
40
+ }
@@ -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.9.1",
5
+ "version": "2.10.1",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.10.0",
3
+ "version": "2.10.2",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -158,6 +158,14 @@ async function _restoreClipboard(savedContent) {
158
158
  // to the current index.
159
159
  let _activeTabIndex = null; // null = use front document (default)
160
160
  let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
161
+ // Timestamp of most recent new_tab — within this grace window we MUST NOT fall
162
+ // back to "current tab of window" (which is the USER'S active tab). New tabs
163
+ // often start at about:blank if their URL fails to load (file://, blocked, etc.),
164
+ // and the URL-based marker resolution can fail until the page actually loads.
165
+ // During the grace window, navigate/click/fill operations either use the cached
166
+ // _activeTabIndex or fail loudly — never silently target the user's tab.
167
+ let _lastNewTabAt = 0;
168
+ const NEW_TAB_GRACE_MS = 30000;
161
169
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
162
170
  let _lastTabCount = null; // Track tab count for smart cache invalidation
163
171
  let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
@@ -272,6 +280,20 @@ function isStaleWindowError(err) {
272
280
 
273
281
  // Safe fallback target: when no tab index is known, use the profile window's current tab
274
282
  // instead of "front document" which can target the user's personal profile window
283
+ // Throw if a write operation is about to fall back to the user's active tab
284
+ // during the new-tab grace window. Without this, navigate/fill/click silently
285
+ // target whatever tab the user is looking at when our cached index is lost.
286
+ function _assertNotFallingBackToUserTab(opName) {
287
+ if (_activeTabIndex) return; // we have a tracked index — fine
288
+ const sinceNew = Date.now() - _lastNewTabAt;
289
+ if (sinceNew < NEW_TAB_GRACE_MS) {
290
+ throw new Error(
291
+ `Tab tracking lost within ${Math.round(sinceNew/1000)}s of safari_new_tab — refusing to ${opName} via fallback (would target the user's active tab). ` +
292
+ `Re-run safari_new_tab and retry.`
293
+ );
294
+ }
295
+ }
296
+
275
297
  function getFallbackTarget() {
276
298
  return SAFARI_PROFILE ? `current tab of ${getTargetWindowRef()}` : "front document";
277
299
  }
@@ -826,6 +848,7 @@ export async function navigate(url) {
826
848
  const safeUrl = targetUrl.replace(/"/g, '\\"');
827
849
  // Resolve tab by URL first (in case indices shifted)
828
850
  if (_activeTabURL) await resolveActiveTab();
851
+ _assertNotFallingBackToUserTab('navigate');
829
852
  const navTarget = _activeTabIndex
830
853
  ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}`
831
854
  : getFallbackTarget();
@@ -904,6 +927,17 @@ export async function navigate(url) {
904
927
  }
905
928
  _lastResolveTime = Date.now();
906
929
 
930
+ // Re-inject the tab marker — navigation creates a new JS context which wipes the
931
+ // window.__mcpTabMarker we set in newTab. Without re-injection, resolveActiveTab's
932
+ // marker-based search fails on the very next call and falls back to URL search,
933
+ // which can mis-target if the tab is still loading or shows an error page.
934
+ if (_activeTabIndex && _activeTabMarker) {
935
+ const safeMarker = _activeTabMarker.replace(/'/g, "\\'");
936
+ await osascriptFast(
937
+ `tell application "Safari" to do JavaScript "window.__mcpTabMarker='${safeMarker}'" in tab ${_activeTabIndex} of ${getTargetWindowRef()}`
938
+ ).catch(() => {});
939
+ }
940
+
907
941
  return result;
908
942
  }
909
943
 
@@ -1296,6 +1330,35 @@ export async function fill({ selector, value, ref }) {
1296
1330
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1297
1331
  const result = await runJS(
1298
1332
  `(function(){try{var el=document.querySelector('${sel}');if(!el){var q=function(r){var a=r.querySelectorAll('*');for(var i=0;i<a.length;i++){if(a[i].shadowRoot){el=a[i].shadowRoot.querySelector('${sel}');if(el)return el;el=q(a[i].shadowRoot);if(el)return el;}}return null;};el=q(document);}if(!el)return 'Element not found: ${sel}';el.focus();if(el.isContentEditable||el.getAttribute('contenteditable')==='true'){` +
1333
+ // Quill editor detection (LinkedIn share composer in 2026 — they migrated from
1334
+ // ProseMirror, Slack message editor, many enterprise apps). The Quill instance is
1335
+ // attached to `.ql-container.__quill` (v2) or accessible via React Fiber's
1336
+ // memoizedProps/stateNode. Without proper API access, fill-by-DOM crashes Quill's
1337
+ // internal Delta state and dismisses the dialog (the v2.10.0 LinkedIn bug repro).
1338
+ `var qlEditor=el.classList&&el.classList.contains('ql-editor')?el:el.closest('.ql-editor');` +
1339
+ `if(qlEditor){` +
1340
+ `var qlContainer=qlEditor.closest('.ql-container')||qlEditor.parentElement;` +
1341
+ `var quill=(qlContainer&&qlContainer.__quill)||null;` +
1342
+ // Fallback: walk React Fiber to find the Quill instance attached as a prop or stateNode
1343
+ `if(!quill&&qlContainer){var qfk=Object.keys(qlContainer).find(function(k){return k.indexOf('__reactFiber')===0||k.indexOf('__reactInternalInstance')===0;});if(qfk){var qf=qlContainer[qfk];for(var qd=0;qd<25&&qf;qd++){var qp=qf.memoizedProps;if(qp&&qp.quill&&typeof qp.quill.setContents==='function'){quill=qp.quill;break;}var qsn=qf.stateNode;if(qsn&&qsn.quill&&typeof qsn.quill.setContents==='function'){quill=qsn.quill;break;}qf=qf.return;}}}` +
1344
+ `if(quill&&typeof quill.setContents==='function'){` +
1345
+ `try{` +
1346
+ // Convert escaped string back to real string for Quill API.
1347
+ // The val template var has \\n / \\\\ / \\' applied, so we need to unescape for setText.
1348
+ `var qlText='${val}'.replace(/\\\\n/g,'\\n').replace(/\\\\'/g,"'").replace(/\\\\\\\\/g,'\\\\');` +
1349
+ // setContents with a plain text Delta — bypasses clipboard, no synthetic events,
1350
+ // doesn't trigger LinkedIn's focusout-dismiss handler.
1351
+ `quill.setContents([{insert: qlText + '\\n'}], 'api');` +
1352
+ `quill.setSelection(qlText.length, 0, 'api');` +
1353
+ // Verify the text actually committed
1354
+ `var qlActual=quill.getText().replace(/\\n+$/,'');` +
1355
+ `if(qlActual.indexOf(qlText.substring(0, Math.min(20, qlText.length)))>=0){return 'Filled CE (Quill setContents)';}` +
1356
+ `}catch(_qE){}` +
1357
+ `}` +
1358
+ // Quill instance not found via direct or Fiber — route to native paste fallback.
1359
+ // Quill respects real isTrusted clipboard events, so CGEvent Cmd+V works.
1360
+ `return '__NATIVE_PASTE_DIALOG__';` +
1361
+ `}` +
1299
1362
  // Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
1300
1363
  // Three lookup strategies, cheapest first:
1301
1364
  // A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
@@ -2543,6 +2606,7 @@ export async function newTab(url = "") {
2543
2606
  _activeTabURL = url || null;
2544
2607
  _lastResolveTime = Date.now();
2545
2608
  _lastTabCount = Number(tabCount); // Update tab count cache
2609
+ _lastNewTabAt = Date.now(); // Start grace window — block fallback to user's tab
2546
2610
  // Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
2547
2611
  _activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2548
2612
  await osascriptFast(