safari-mcp 2.10.1 → 2.10.3

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/safari.js +70 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.10.1",
3
+ "version": "2.10.3",
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
@@ -166,6 +166,13 @@ let _activeTabURL = null; // URL-based tracking (stable even when tabs shift)
166
166
  // _activeTabIndex or fail loudly — never silently target the user's tab.
167
167
  let _lastNewTabAt = 0;
168
168
  const NEW_TAB_GRACE_MS = 30000;
169
+ // Becomes true once safari_new_tab is called for the first time in this session.
170
+ // Once true, write operations (navigate/click/fill) MUST NOT fall back to
171
+ // "current tab of window" — that targets the USER'S active tab. The 30s grace
172
+ // window was insufficient: tab tracking can be lost much later in a session
173
+ // (e.g. tab ghost recovery in runJS), and silently falling back to the user's
174
+ // tab caused incidents where the user's working tab was overwritten.
175
+ let _hasOwnedTab = false;
169
176
  let _lastResolveTime = 0; // Cache: skip resolve if verified recently
170
177
  let _lastTabCount = null; // Track tab count for smart cache invalidation
171
178
  let _activeTabMarker = null; // window.__mcpTabMarker — survives same-tab navigation, bulletproof tab identity
@@ -285,13 +292,18 @@ function isStaleWindowError(err) {
285
292
  // target whatever tab the user is looking at when our cached index is lost.
286
293
  function _assertNotFallingBackToUserTab(opName) {
287
294
  if (_activeTabIndex) return; // we have a tracked index — fine
288
- const sinceNew = Date.now() - _lastNewTabAt;
289
- if (sinceNew < NEW_TAB_GRACE_MS) {
295
+ // If we ever opened our own tab in this session, we MUST NOT fall back to
296
+ // "current tab of window" — that's the USER'S active tab. Always throw,
297
+ // regardless of how long ago the tab was opened. The previous 30-second
298
+ // grace window was insufficient: long-running sessions (Reddit warmup,
299
+ // multi-step workflows) routinely exceed it, and the danger persists.
300
+ if (_hasOwnedTab) {
290
301
  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.`
302
+ `Tab tracking lost — refusing to ${opName} via fallback to "current tab of window" (would target the user's active tab). ` +
303
+ `This session previously opened its own tab via safari_new_tab; re-run safari_new_tab to recover, or call safari_list_tabs and safari_switch_tab to re-anchor to a known tab.`
293
304
  );
294
305
  }
306
+ // No tab ever owned by this session — fallback to front document is intentional.
295
307
  }
296
308
 
297
309
  function getFallbackTarget() {
@@ -779,7 +791,18 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
779
791
  return osascript(retryScript, { timeout });
780
792
  }
781
793
  }
782
- // If still can't resolve, try current tab of the window
794
+ // If still can't resolve and we previously owned a tab — refuse to
795
+ // fall back to "current tab of window" (which is the USER'S active tab).
796
+ // Falling back would silently run our JS (potentially writes:
797
+ // document.title=, location.href=) on the user's working page.
798
+ if (_hasOwnedTab) {
799
+ throw new Error(
800
+ `Tab tracking lost during runJS — original tab ${idx} no longer exists, and URL-based resolution failed. ` +
801
+ `Refusing to fall back to "current tab of window" (would target user's active tab). ` +
802
+ `Call safari_new_tab to open a fresh tab and retry.`
803
+ );
804
+ }
805
+ // No owned tab in this session — front-document fallback is intentional.
783
806
  const fallbackScript = `tell application "Safari" to do JavaScript "${escaped}" in current tab of ${getTargetWindowRef()}`;
784
807
  console.error(`[Safari MCP] Falling back to current tab`);
785
808
  if (fallbackScript.length < 50000) return osascriptFast(fallbackScript, { timeout });
@@ -800,6 +823,14 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
800
823
  if (resolved) { idx = resolved; _lastResolveTime = Date.now(); }
801
824
  }
802
825
  if (!idx) idx = _activeTabIndex;
826
+ // If we previously owned a tab but lost tracking, refuse to target current tab
827
+ // (which is the USER'S active tab). Same protection as navigate/runJS fallback.
828
+ if (!idx && _hasOwnedTab) {
829
+ throw new Error(
830
+ `Tab tracking lost during runJSLarge — refusing to fall back to "current tab of window" (would target user's active tab). ` +
831
+ `Call safari_new_tab to open a fresh tab and retry.`
832
+ );
833
+ }
803
834
  const target = idx
804
835
  ? `tab ${idx} of ${getTargetWindowRef()}`
805
836
  : getFallbackTarget();
@@ -1330,6 +1361,35 @@ export async function fill({ selector, value, ref }) {
1330
1361
  const val = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n").replace(/\r/g, "");
1331
1362
  const result = await runJS(
1332
1363
  `(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'){` +
1364
+ // Quill editor detection (LinkedIn share composer in 2026 — they migrated from
1365
+ // ProseMirror, Slack message editor, many enterprise apps). The Quill instance is
1366
+ // attached to `.ql-container.__quill` (v2) or accessible via React Fiber's
1367
+ // memoizedProps/stateNode. Without proper API access, fill-by-DOM crashes Quill's
1368
+ // internal Delta state and dismisses the dialog (the v2.10.0 LinkedIn bug repro).
1369
+ `var qlEditor=el.classList&&el.classList.contains('ql-editor')?el:el.closest('.ql-editor');` +
1370
+ `if(qlEditor){` +
1371
+ `var qlContainer=qlEditor.closest('.ql-container')||qlEditor.parentElement;` +
1372
+ `var quill=(qlContainer&&qlContainer.__quill)||null;` +
1373
+ // Fallback: walk React Fiber to find the Quill instance attached as a prop or stateNode
1374
+ `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;}}}` +
1375
+ `if(quill&&typeof quill.setContents==='function'){` +
1376
+ `try{` +
1377
+ // Convert escaped string back to real string for Quill API.
1378
+ // The val template var has \\n / \\\\ / \\' applied, so we need to unescape for setText.
1379
+ `var qlText='${val}'.replace(/\\\\n/g,'\\n').replace(/\\\\'/g,"'").replace(/\\\\\\\\/g,'\\\\');` +
1380
+ // setContents with a plain text Delta — bypasses clipboard, no synthetic events,
1381
+ // doesn't trigger LinkedIn's focusout-dismiss handler.
1382
+ `quill.setContents([{insert: qlText + '\\n'}], 'api');` +
1383
+ `quill.setSelection(qlText.length, 0, 'api');` +
1384
+ // Verify the text actually committed
1385
+ `var qlActual=quill.getText().replace(/\\n+$/,'');` +
1386
+ `if(qlActual.indexOf(qlText.substring(0, Math.min(20, qlText.length)))>=0){return 'Filled CE (Quill setContents)';}` +
1387
+ `}catch(_qE){}` +
1388
+ `}` +
1389
+ // Quill instance not found via direct or Fiber — route to native paste fallback.
1390
+ // Quill respects real isTrusted clipboard events, so CGEvent Cmd+V works.
1391
+ `return '__NATIVE_PASTE_DIALOG__';` +
1392
+ `}` +
1333
1393
  // Lexical editor detection (LinkedIn share composer, modern Meta/Shopify apps).
1334
1394
  // Three lookup strategies, cheapest first:
1335
1395
  // A) [data-lexical-editor="true"] on ancestor with __lexicalEditor property.
@@ -2577,7 +2637,9 @@ export async function newTab(url = "") {
2577
2637
  _activeTabURL = url || null;
2578
2638
  _lastResolveTime = Date.now();
2579
2639
  _lastTabCount = Number(tabCount); // Update tab count cache
2580
- _lastNewTabAt = Date.now(); // Start grace window — block fallback to user's tab
2640
+ _lastNewTabAt = Date.now(); // (legacy — grace window superseded by _hasOwnedTab)
2641
+ _hasOwnedTab = true; // Permanently true: this session has opened its own tab,
2642
+ // so write ops must NEVER fall back to the user's current tab.
2581
2643
  // Set bulletproof tab marker — survives same-tab navigation, redirects, query changes (v2.8.3)
2582
2644
  _activeTabMarker = `MCP_${SESSION_ID}_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
2583
2645
  await osascriptFast(
@@ -4185,6 +4247,8 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
4185
4247
  let targetUrl = url;
4186
4248
  if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
4187
4249
  const safeUrl = targetUrl.replace(/"/g, '\\"');
4250
+ if (_activeTabURL) await resolveActiveTab();
4251
+ _assertNotFallingBackToUserTab('navigateAndRead');
4188
4252
  const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
4189
4253
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
4190
4254
  _activeTabURL = targetUrl;