safari-mcp 2.15.3 → 2.15.5

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 +39 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.15.3",
3
+ "version": "2.15.5",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 96 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -670,7 +670,7 @@ async function resolveActiveTab() {
670
670
  // Parse result — can be "N" (found) or "0:tabCount" (not found)
671
671
  const resultStr = String(result);
672
672
  if (resultStr.includes(':')) {
673
- // Not found — clamp stale index to tabCount
673
+ // Not found — every exit below fails closed (drops the index); nothing here guesses.
674
674
  const tabCount = Number(resultStr.split(':')[1]) || 1;
675
675
  _st().lastTabCount = tabCount;
676
676
  _st().activeTabURL = null;
@@ -682,8 +682,16 @@ async function resolveActiveTab() {
682
682
  return null;
683
683
  }
684
684
  if (_st().activeTabIndex && _st().activeTabIndex > tabCount) {
685
- console.error(`[Safari MCP] Tab ghost proactive fix: index ${_st().activeTabIndex} > tabCount ${tabCount}, clamping to ${tabCount}`);
686
- _st().activeTabIndex = tabCount;
685
+ // Our index is past the end of the window: the user closed a tab or tore one
686
+ // into its own window, so the index no longer names any tab — least of all ours.
687
+ // This used to clamp to `tabCount` ("tab ghost proactive fix"), which silently
688
+ // retargeted us at the LAST tab in the window — the user's. That predates the
689
+ // identity marker (clamp: Mar 31, marker: v2.8.3 Apr 14) and was the one exit
690
+ // here that still guessed. Fail closed like the two branches above; callers
691
+ // re-anchor via _assertNotFallingBackToUserTab's "re-run safari_new_tab" error.
692
+ console.error(`[Safari MCP] Tab identity lost (index ${_st().activeTabIndex} > tabCount ${tabCount}) — clearing index to avoid targeting the user's tab`);
693
+ _st().activeTabIndex = null;
694
+ return null;
687
695
  }
688
696
  return _st().activeTabIndex;
689
697
  }
@@ -1169,6 +1177,18 @@ async function _withTargetTabFronted(fn) {
1169
1177
  }
1170
1178
  }
1171
1179
 
1180
+ // Atomic tab-identity guard, as a JS prefix. Belongs here and not at a call site: it is the
1181
+ // only check that is independent of how `idx` was resolved, so every path that builds a
1182
+ // targeted `do JavaScript` needs it — most of all the retry paths, which run precisely when
1183
+ // the index has already proven untrustworthy. Empty string when the caller named a tab
1184
+ // explicitly, or when this session owns no marked tab (front-document fallback is then
1185
+ // intentional). See #64.
1186
+ function _tabIdentityGuard(explicitTabIndex) {
1187
+ const marker = _st().activeTabMarker;
1188
+ if (!marker || explicitTabIndex) return '';
1189
+ return `if(window.name!=='${marker}'&&window.__mcpTabMarker!=='${marker}'){throw new Error('MCP_WRONG_TAB')};`;
1190
+ }
1191
+
1172
1192
  // Run JavaScript in Safari — fastest path, no focus stealing
1173
1193
  // Uses osascriptFast (persistent process, ~5ms) for short scripts,
1174
1194
  // falls back to osascript (~80ms) for long scripts that exceed stdin limits
@@ -1215,9 +1235,7 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
1215
1235
  // does not carry OUR marker. Makes the op fail-closed at EXECUTION time regardless of how
1216
1236
  // `idx` resolved — a drift to the user's tab (shared profile window) throws instead of
1217
1237
  // running on their page. Skipped when the caller passed an explicit tabIndex.
1218
- const _guard = (_st().activeTabMarker && !tabIndex)
1219
- ? `if(window.name!=='${_st().activeTabMarker}'&&window.__mcpTabMarker!=='${_st().activeTabMarker}'){throw new Error('MCP_WRONG_TAB')};`
1220
- : '';
1238
+ const _guard = _tabIdentityGuard(tabIndex);
1221
1239
  const script = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${target}`;
1222
1240
  try {
1223
1241
  if (script.length < 50000) {
@@ -1254,7 +1272,10 @@ async function runJS(js, { tabIndex, timeout = 15000 } = {}) {
1254
1272
  if (newIdx && newIdx !== idx) {
1255
1273
  console.error(`[Safari MCP] Tab ghost resolved: ${idx} → ${newIdx}`);
1256
1274
  const newTarget = `tab ${newIdx} of ${getTargetWindowRef()}`;
1257
- const retryScript = `tell application "Safari" to do JavaScript "${escaped}" in ${newTarget}`;
1275
+ // Keep the guard on the retry. `newIdx` came from a re-resolve triggered by the
1276
+ // index being wrong; dropping the check here made the least trustworthy path the
1277
+ // only unchecked one (#64).
1278
+ const retryScript = `tell application "Safari" to do JavaScript "${_guard}${escaped}" in ${newTarget}`;
1258
1279
  if (retryScript.length < 50000) return osascriptFast(retryScript, { timeout });
1259
1280
  return osascript(retryScript, { timeout });
1260
1281
  }
@@ -1313,7 +1334,10 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
1313
1334
  .replace(/\n/g, " ")
1314
1335
  .replace(/\r/g, "")
1315
1336
  .replace(/\t/g, " ");
1316
- const appleScript = `tell application "Safari" to do JavaScript "${escaped}" in ${target}`;
1337
+ // Same execution-time identity check runJS gets. These are the upload / paste-image ops —
1338
+ // the largest blast radius in the toolset — and they were the one targeted path with no
1339
+ // guard at all (#64).
1340
+ const appleScript = `tell application "Safari" to do JavaScript "${_tabIdentityGuard(tabIndex)}${escaped}" in ${target}`;
1317
1341
  const tmpFile = join(tmpdir(), `safari-mcp-${Date.now()}.scpt`);
1318
1342
  await writeFile(tmpFile, appleScript, "utf8");
1319
1343
  // Save frontmost app via daemon before subprocess execution
@@ -1326,6 +1350,13 @@ async function runJSLarge(js, { tabIndex, timeout = 30000 } = {}) {
1326
1350
  maxBuffer: 10 * 1024 * 1024,
1327
1351
  });
1328
1352
  return stdout.trim();
1353
+ } catch (err) {
1354
+ // No retry here, unlike runJS: these payloads carry file data, and re-running one on a
1355
+ // freshly resolved tab is exactly the guess this guard exists to prevent.
1356
+ if ((err.message || '').includes('MCP_WRONG_TAB')) {
1357
+ throw new Error('Tab tracking lost during runJSLarge — target tab does not carry this session\'s marker (atomic guard fail-closed). Call safari_new_tab to reopen.', { cause: err });
1358
+ }
1359
+ throw err;
1329
1360
  } finally {
1330
1361
  unlink(tmpFile).catch(() => {});
1331
1362
  // Awaited restore — caller must not return to user-space while Safari is still frontmost.