safari-mcp 2.15.7 → 2.15.8

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.
@@ -226,7 +226,14 @@ async function handleCommand(type, payload) {
226
226
  if (anyOwned) {
227
227
  throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — not opened by this MCP session. Use safari_new_tab first.`);
228
228
  }
229
- // If no tabs owned yet, allow operation (backward compatibility for sessions that don't use new_tab)
229
+ // No tabs owned yet → allow, for sessions that never call new_tab. That leniency is
230
+ // survivable for a write (worst case: it edits the page the user is already on) but not
231
+ // for a close, which throws their tab away. It also fires for a session re-initialised
232
+ // after a transport drop, which reports owning nothing while its tab is still open —
233
+ // the state in which close_tab destroyed a user's tab (#68).
234
+ if (_destructiveTabCommands.has(type)) {
235
+ throw new Error(`⚠️ Tab safety: refusing "${type}" on tab ${tabId} (${targetTab.url || 'unknown'}) — this session owns no tabs, so it has none to close. Use safari_new_tab first.`);
236
+ }
230
237
  }
231
238
 
232
239
  switch (type) {
@@ -1117,6 +1124,11 @@ async function handleCommand(type, payload) {
1117
1124
  // could resolve the wrong tab or leave a stale last-tab verdict).
1118
1125
  const target = _winTabs[payload.index - 1];
1119
1126
  if (target) {
1127
+ // The ownership guard above checked `tabId` — this branch closes a tab chosen by
1128
+ // index instead, so it must be checked on its own or the guard is decorative.
1129
+ if (!_isTabOwnedBySession(sessionId, target.id)) {
1130
+ throw new Error(`⚠️ Tab safety: refusing to close tab ${payload.index} (${target.url || 'unknown'}) — not opened by this MCP session.`);
1131
+ }
1120
1132
  _removeOwnedTab(sessionId, target.id);
1121
1133
  if (_isLastTab) {
1122
1134
  await browser.tabs.update(target.id, { url: "about:blank" });
@@ -1828,6 +1840,10 @@ function _isTabOwnedBySession(sessionId, tabId) {
1828
1840
  return set ? set.has(tabId) : false;
1829
1841
  }
1830
1842
 
1843
+ // Commands that destroy a tab rather than change a page. They get no "no tabs owned yet"
1844
+ // leniency: a session that owns nothing has nothing to close (#68).
1845
+ const _destructiveTabCommands = new Set(["close_tab"]);
1846
+
1831
1847
  // Read-only commands that don't modify the page — allowed on any tab
1832
1848
  const _readOnlyCommands = new Set([
1833
1849
  "list_tabs", "read_page", "get_source", "snapshot", "accessibility_snapshot",
package/index.js CHANGED
@@ -81,10 +81,9 @@ async function _cleanupTabs() {
81
81
  const tabs = await safari.listTabs();
82
82
  const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
83
83
  const match = parsed.find(t => t.url === url);
84
- if (match) {
85
- safari.setActiveTabIndex(match.index);
86
- await safari.closeTab();
87
- }
84
+ // Name the tab explicitly: it was just resolved out of our own opened-tab table, and
85
+ // closeTab refuses anything it cannot prove it owns (#68).
86
+ if (match) await safari.closeTab(match.index);
88
87
  } catch {}
89
88
  }
90
89
  _openedTabs.clear();
@@ -153,10 +152,7 @@ async function _closeOldestMCPTab() {
153
152
  const tabs = await safari.listTabs();
154
153
  const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
155
154
  const match = parsed.find(t => t.url === info.url);
156
- if (match) {
157
- safari.setActiveTabIndex(match.index);
158
- await safari.closeTab();
159
- }
155
+ if (match) await safari.closeTab(match.index);
160
156
  }
161
157
  } catch {}
162
158
  _untrackTab(oldestIdx);
@@ -657,8 +653,12 @@ const _nullMeansFailure = new Set([
657
653
 
658
654
  // Operations that don't need tab ownership (read-only or tab management)
659
655
  const _noOwnershipCheck = new Set([
660
- // Tab management
661
- "new_tab", "list_tabs", "close_tab", "switch_tab",
656
+ // Tab management. NOTE: "close_tab" is deliberately NOT here. It sat in this set as
657
+ // "tab management" next to new_tab/list_tabs/switch_tab, but it is the only member that
658
+ // destroys a user tab: new_tab creates one, list_tabs reads, and switch_tab carries its
659
+ // own ownership check at the tool. close_tab had none — exempt from the shared guard and
660
+ // unguarded underneath, which is how it closed a user's tab in #68.
661
+ "new_tab", "list_tabs", "switch_tab",
662
662
  // Extension self-management (doesn't touch tabs)
663
663
  "reload_extension",
664
664
  // Read-only — don't modify the page
@@ -1378,8 +1378,7 @@ server.tool(
1378
1378
  if (oldestIdx !== null) {
1379
1379
  console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
1380
1380
  try {
1381
- safari.setActiveTabIndex(oldestIdx);
1382
- await safari.closeTab();
1381
+ await safari.closeTab(oldestIdx);
1383
1382
  } catch {}
1384
1383
  _untrackTab(oldestIdx);
1385
1384
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.15.7",
3
+ "version": "2.15.8",
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
@@ -3484,8 +3484,37 @@ export async function newTab(url = "") {
3484
3484
  return info;
3485
3485
  }
3486
3486
 
3487
- export async function closeTab() {
3487
+ // A tab index this session can prove it owns, or null. Destructive paths only: they may
3488
+ // never guess, so "can't prove it" has to read as null rather than as the front document.
3489
+ // `resolveActiveTab()` re-finds the tab through its identity marker (surviving the index
3490
+ // shifts of #54) and fails closed to null when the marker is on no tab at all.
3491
+ async function _provenOwnTabIndex() {
3492
+ return (await resolveActiveTab()) || null;
3493
+ }
3494
+
3495
+ // `explicitIndex` — the caller naming the tab, the same opt-out convention the identity
3496
+ // guard uses. Internal cleanup resolves its own indices out of the opened-tab table and
3497
+ // passes them here; everything else must prove ownership.
3498
+ export async function closeTab(explicitIndex) {
3488
3499
  await refreshTargetWindow();
3500
+
3501
+ // ── Guard: close nothing this session cannot prove it owns. There is deliberately no
3502
+ // front-document fallback here, unlike the read paths: `close current tab of window` is
3503
+ // the tab the USER is looking at whenever our index is unknown — and "unknown" is exactly
3504
+ // what a session re-initialised after a transport drop reports, mid-task, while a tab of
3505
+ // its own is still open. That fail-open destroyed a user's tab (#68, the destructive
3506
+ // sibling of #64). An unmarked front document stays readable for a genuinely fresh
3507
+ // session, because a bad read costs information; a bad close costs their work.
3508
+ const idx = explicitIndex || (await _provenOwnTabIndex());
3509
+ if (!idx) {
3510
+ throw new Error(
3511
+ `Tab tracking lost — refusing to close a tab this session cannot prove it opened ` +
3512
+ `(closing "current tab of window" would close the user's active tab). ` +
3513
+ `Call safari_new_tab to open a tab this session owns, or safari_list_tabs and ` +
3514
+ `safari_switch_tab to re-anchor to a known one.`
3515
+ );
3516
+ }
3517
+
3489
3518
  // ── Guard: never close a window's LAST tab. Closing it shuts the window —
3490
3519
  // which quits Safari if it's the only window, AND (for profile-targeted
3491
3520
  // instances) makes the target window vanish so every later op throws
@@ -3500,10 +3529,11 @@ export async function closeTab() {
3500
3529
  10
3501
3530
  );
3502
3531
  if (Number.isFinite(_winTabs) && _winTabs <= 1) {
3503
- const _ref = _st().activeTabIndex
3504
- ? `tab ${_st().activeTabIndex} of ${getTargetWindowRef()}`
3505
- : `current tab of ${getTargetWindowRef()}`;
3506
- await osascript(`tell application "Safari" to set URL of ${_ref} to "about:blank"`);
3532
+ // Blanking is destructive too — it throws away whatever page is loaded — so it
3533
+ // targets the proven index, never the front document.
3534
+ await osascript(
3535
+ `tell application "Safari" to set URL of tab ${idx} of ${getTargetWindowRef()} to "about:blank"`
3536
+ );
3507
3537
  _st().activeTabIndex = null;
3508
3538
  _st().activeTabURL = null;
3509
3539
  _st().lastTabCount = null;
@@ -3512,16 +3542,15 @@ export async function closeTab() {
3512
3542
  }
3513
3543
  } catch { /* count check failed — fall through to normal close */ }
3514
3544
 
3515
- if (_st().activeTabIndex) {
3516
- await osascript(
3517
- `tell application "Safari" to close tab ${_st().activeTabIndex} of ${getTargetWindowRef()}`
3518
- );
3545
+ await osascript(
3546
+ `tell application "Safari" to close tab ${idx} of ${getTargetWindowRef()}`
3547
+ );
3548
+ if (!explicitIndex || idx === _st().activeTabIndex) {
3519
3549
  _st().activeTabIndex = null;
3520
3550
  _st().activeTabURL = null;
3521
- } else {
3522
- await osascript(
3523
- `tell application "Safari" to close current tab of ${getTargetWindowRef()}`
3524
- );
3551
+ // The tab is gone, so its marker is too — keeping it would let a later resolve
3552
+ // match a stale identity.
3553
+ _st().activeTabMarker = null;
3525
3554
  }
3526
3555
  _st().lastTabCount = null; // Invalidate — tab count changed
3527
3556
  _st().lastResolveTime = 0; // Force re-resolve on next operation