safari-mcp 2.15.6 → 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);
@@ -210,19 +206,21 @@ function _startMemoryMonitor() {
210
206
 
211
207
  // Cleanup on exit
212
208
  let _cleaningUp = false;
209
+ async function _shutdown() {
210
+ if (_cleaningUp) return;
211
+ _cleaningUp = true;
212
+ // Restore the user's clipboard synchronously FIRST — if a native paste is mid-flight, its
213
+ // 2s restore timer would never fire once we exit, leaving the tool's text on the clipboard.
214
+ try { safari.flushClipboardRestore(); } catch {}
215
+ // Cap tab cleanup — if the daemon/Safari is wedged at shutdown (exactly when a SIGTERM
216
+ // tends to arrive), listTabs/closeTab can each block for their full timeout; never let
217
+ // that hang the exit past 3s.
218
+ await Promise.race([_cleanupTabs(), new Promise(r => setTimeout(r, 3000))]);
219
+ process.exit(0);
220
+ }
221
+
213
222
  for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) {
214
- process.on(sig, async () => {
215
- if (_cleaningUp) return; // Prevent double-exit on rapid signal repeat
216
- _cleaningUp = true;
217
- // Restore the user's clipboard synchronously FIRST — if a native paste is mid-flight, its
218
- // 2s restore timer would never fire once we exit, leaving the tool's text on the clipboard.
219
- try { safari.flushClipboardRestore(); } catch {}
220
- // Cap tab cleanup — if the daemon/Safari is wedged at shutdown (exactly when a SIGTERM
221
- // tends to arrive), listTabs/closeTab can each block for their full timeout; never let
222
- // that hang the exit past 3s.
223
- await Promise.race([_cleanupTabs(), new Promise(r => setTimeout(r, 3000))]);
224
- process.exit(0);
225
- });
223
+ process.on(sig, _shutdown);
226
224
  }
227
225
  process.on("exit", () => {
228
226
  if (_openedTabs.size > 0) {
@@ -655,8 +653,12 @@ const _nullMeansFailure = new Set([
655
653
 
656
654
  // Operations that don't need tab ownership (read-only or tab management)
657
655
  const _noOwnershipCheck = new Set([
658
- // Tab management
659
- "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",
660
662
  // Extension self-management (doesn't touch tabs)
661
663
  "reload_extension",
662
664
  // Read-only — don't modify the page
@@ -1376,8 +1378,7 @@ server.tool(
1376
1378
  if (oldestIdx !== null) {
1377
1379
  console.error(`[Safari MCP] Tab limit (${MAX_TABS}) reached — closing oldest tab #${oldestIdx}`);
1378
1380
  try {
1379
- safari.setActiveTabIndex(oldestIdx);
1380
- await safari.closeTab();
1381
+ await safari.closeTab(oldestIdx);
1381
1382
  } catch {}
1382
1383
  _untrackTab(oldestIdx);
1383
1384
  }
@@ -2420,4 +2421,12 @@ _startMemoryMonitor();
2420
2421
 
2421
2422
  // Transport is chosen at runtime: default stdio (unchanged), or a shared HTTP instance when
2422
2423
  // SAFARI_MCP_HTTP=1 (many Claude sessions → one process). buildServer is the per-session factory.
2423
- await startTransport(buildServer, process.env);
2424
+ const transportHandle = await startTransport(buildServer, process.env);
2425
+
2426
+ if (transportHandle.kind === "stdio") {
2427
+ // A client can disappear without delivering a signal. Treat its pipe closing as the stdio
2428
+ // server's lifecycle boundary, while leaving the opt-in HTTP daemon independent of stdin.
2429
+ process.stdin.once("end", _shutdown);
2430
+ process.stdin.once("close", _shutdown);
2431
+ if (process.stdin.readableEnded || process.stdin.destroyed) void _shutdown();
2432
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.15.6",
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