safari-mcp 2.21.7 → 2.21.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.
@@ -639,6 +639,10 @@ async function connect() {
639
639
  if (_restartInvalidatedConnect(pollGeneration)) return;
640
640
  isConnected = true;
641
641
  _reconnectDelay = 3000; // Reset backoff on success
642
+ // A retry scheduled before this attempt succeeded (the wake alarm, a lease-held
643
+ // backoff) would run connect() again seconds later, invalidate this healthy loop and
644
+ // drop the command it had just been handed (15.9.26: every keepalive list_tabs).
645
+ if (_reconnectTimer) { clearTimeout(_reconnectTimer); _reconnectTimer = null; }
642
646
  updateBadge("ON");
643
647
  _startHeartbeat(); // Keep service worker alive between polls
644
648
  _connecting = false;
@@ -693,7 +697,12 @@ async function pollForCommands(pollGeneration) {
693
697
  const res = await _bridgeFetch(`${bridgeUrl}/poll`, {
694
698
  signal: controller.signal,
695
699
  });
696
- if (pollGeneration !== _pollLoopGeneration) return;
700
+ // The response is here, so a reconnect must no longer abort this fetch (that would
701
+ // kill the body), and a loop it invalidated must still run a command it received:
702
+ // the host marked it dispatched to this worker, so nobody else ever will.
703
+ if (pollAbort === controller) pollAbort = null;
704
+ const invalidated = pollGeneration !== _pollLoopGeneration;
705
+ if (invalidated && res.status !== 200) return;
697
706
  if (res.status === 423) {
698
707
  // A replacement worker completed profile verification. This stale worker must
699
708
  // stay dormant until Safari reloads it; reconnecting would steal the lease back.
@@ -719,6 +728,7 @@ async function pollForCommands(pollGeneration) {
719
728
  const claim = new Promise((resolve) => { releaseClaim = resolve; });
720
729
  _commandExecutionTail = previousExecution.catch(() => {}).then(() => claim);
721
730
  let beat = null;
731
+ let guard = null;
722
732
  try {
723
733
  // A single malformed/truncated body must NOT tear down the poll loop — a bad
724
734
  // packet used to throw SyntaxError here, fall through to "server gone", and
@@ -738,13 +748,15 @@ async function pollForCommands(pollGeneration) {
738
748
  // waited on the same claim, and only a Safari restart brought the profile back.
739
749
  // Bound it: past the host's hard ceiling we answer with an error, release the
740
750
  // claim and keep polling; the abandoned promise may settle later into the void.
751
+ guard = _wedgedCommandGuard(msg, bridgeUrl);
741
752
  await Promise.race([
742
753
  executeAndReply(msg, bridgeUrl),
743
- _wedgedCommandGuard(msg, bridgeUrl),
754
+ guard,
744
755
  ]);
745
756
  }
746
757
  } finally {
747
758
  if (beat) clearInterval(beat);
759
+ if (guard) guard.cancel();
748
760
  releaseClaim();
749
761
  }
750
762
  }
@@ -777,8 +789,9 @@ async function pollForCommands(pollGeneration) {
777
789
  // longest tool timeout is 75s, so nothing legitimate is still awaited after five minutes.
778
790
  const _WEDGED_COMMAND_MS = 330000;
779
791
  function _wedgedCommandGuard(msg, bridgeUrl) {
780
- return new Promise((resolve) => {
781
- setTimeout(async () => {
792
+ let timer = null;
793
+ const fired = new Promise((resolve) => {
794
+ timer = setTimeout(async () => {
782
795
  console.log("Safari MCP: command wedged, releasing the poll loop", msg && msg.type);
783
796
  try {
784
797
  await _bridgeFetch(`${bridgeUrl}/result`, {
@@ -798,6 +811,10 @@ function _wedgedCommandGuard(msg, bridgeUrl) {
798
811
  resolve();
799
812
  }, _WEDGED_COMMAND_MS);
800
813
  });
814
+ // The poll loop cancels the guard however the command ends. An uncancelled timer fired
815
+ // 330s after EVERY command and posted "did not settle" for an id the host had already
816
+ // answered (15.9.26: one bogus result per command, five and a half minutes late).
817
+ return Object.assign(fired, { cancel: () => clearTimeout(timer) });
801
818
  }
802
819
 
803
820
  // ========== SHARED: Execute command and send response ==========
@@ -890,12 +907,10 @@ async function handleCommand(type, payload) {
890
907
  let targetTab;
891
908
  try {
892
909
  if (suppliedReceipt) {
910
+ // Throws a refusal naming the failed check; it never returns without a tab.
893
911
  targetTab = await _resolveReceiptTab(suppliedReceipt, {
894
912
  allowOriginChange: allowReceiptOriginChange,
895
913
  });
896
- if (!targetTab) {
897
- throw new Error("Tab safety: receipt is forged, stale, ambiguous, or not valid for this origin");
898
- }
899
914
  receiptResolved = true;
900
915
  payload._receiptTabId = targetTab.id;
901
916
  // A valid receipt names one concrete tab. It may move a stateless session into
@@ -3210,18 +3225,25 @@ async function _refreshAllReceiptIdentities() {
3210
3225
  });
3211
3226
  }
3212
3227
 
3228
+ // Each refusal names the check that failed. They used to share one sentence ("forged,
3229
+ // stale, ambiguous, or not valid for this origin"), and the server appends getReceipt
3230
+ // advice for an origin change to anything that says "not valid for this origin" — so a
3231
+ // receipt this worker had simply never seen was told to rotate a tab that never moved.
3213
3232
  async function _resolveReceiptTab(token, { allowOriginChange = false } = {}) {
3214
3233
  return _withReceiptMutationLock(async () => {
3215
3234
  const normalized = String(token || "");
3216
- if (!/^[A-Za-z0-9_-]{24,}$/.test(normalized)) return null;
3217
3235
  const record = _receiptByToken.get(normalized);
3218
- if (!_isValidReceiptRecord(normalized, record)) return null;
3236
+ if (!/^[A-Za-z0-9_-]{24,}$/.test(normalized) || !_isValidReceiptRecord(normalized, record)) {
3237
+ throw new Error("Tab safety: this extension has no record of that receipt. It was never issued by this extension instance, or it ended when its tab closed, when getReceipt replaced it, or when Safari or the extension restarted. Open the tab again with safari_new_tab.");
3238
+ }
3219
3239
  const browserEpoch = await _ensureBrowserSessionEpoch();
3220
- if (record.browserEpoch !== browserEpoch) return null;
3240
+ if (record.browserEpoch !== browserEpoch) {
3241
+ throw new Error("Tab safety: receipt belongs to an earlier browser session — Safari or the extension restarted after it was issued. Open the tab again with safari_new_tab.");
3242
+ }
3221
3243
 
3222
3244
  const allTabs = await browser.tabs.query({});
3223
3245
  const direct = allTabs.find((tab) => tab.id === record.tabId) || null;
3224
- if (!direct) return null;
3246
+ if (!direct) throw new Error("Tab safety: the receipt's tab is closed. Open a new one with safari_new_tab.");
3225
3247
  const directDigest = await _digestTabUrl(direct.url || "");
3226
3248
  if (directDigest === record.identityDigest ||
3227
3249
  (_tokenByTabId.get(direct.id) === normalized && _isTabOwnedByAnySession(direct.id))) {
@@ -3239,11 +3261,14 @@ async function _resolveReceiptTab(token, { allowOriginChange = false } = {}) {
3239
3261
  }
3240
3262
  }
3241
3263
  } else {
3242
- return null;
3264
+ throw new Error("Tab safety: the receipt's tab has moved to a different page and no MCP session owns it any more. Open the tab again with safari_new_tab.");
3243
3265
  }
3244
3266
 
3245
3267
  const currentOrigin = _receiptOrigin(direct.url);
3246
- if (!allowOriginChange && currentOrigin !== record.receiptOrigin) return null;
3268
+ // index.js keys its getReceipt advice on "not valid for this origin"; keep that phrase here only.
3269
+ if (!allowOriginChange && currentOrigin !== record.receiptOrigin) {
3270
+ throw new Error("Tab safety: receipt is not valid for this origin.");
3271
+ }
3247
3272
  return direct;
3248
3273
  });
3249
3274
  }
@@ -4599,8 +4624,9 @@ function _stopHeartbeat() {
4599
4624
  browser.alarms.create("keepalive", { periodInMinutes: 1 });
4600
4625
  browser.alarms.onAlarm.addListener((alarm) => {
4601
4626
  if (alarm.name === "keepalive" || alarm.name === "reconnect") {
4602
- // Only reconnect if disconnected, enabled, and no reconnect already scheduled
4603
- if (!isConnected && _enabled && !_bridgeWorkerSuperseded && !_bridgeWorkerRetiring && !_reconnectTimer) {
4627
+ // Only reconnect if disconnected, enabled, and no reconnect already scheduled or in
4628
+ // flight — a worker woken by this alarm is still connecting, not disconnected
4629
+ if (!isConnected && !_connecting && _enabled && !_bridgeWorkerSuperseded && !_bridgeWorkerRetiring && !_reconnectTimer) {
4604
4630
  scheduleReconnect();
4605
4631
  }
4606
4632
  // Restart heartbeat in case it was lost on worker restart
@@ -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.10.15",
5
+ "version": "2.10.17",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -492,6 +492,20 @@ function _cancelReloadHttpWorkerHandoff(handoff) {
492
492
  if (_reloadHttpWorkerHandoff === handoff) _reloadHttpWorkerHandoff = null;
493
493
  }
494
494
 
495
+ function _reloadAwaitsDelivery(handoff) {
496
+ return [..._pendingRequests.values()].some(
497
+ (pending) => pending.reloadHandoff === handoff && !pending.dispatchedWorkerId
498
+ );
499
+ }
500
+
501
+ // A new worker won the lease. A reload its predecessor never received moves with the
502
+ // lease — that worker is the one that will run it; any other prepared handoff is void.
503
+ function _adoptReloadHandoff(workerId) {
504
+ const handoff = _reloadHttpWorkerHandoff;
505
+ if (handoff && !handoff.armed && _reloadAwaitsDelivery(handoff)) handoff.fromWorkerId = workerId;
506
+ else _reloadHttpWorkerHandoff = null;
507
+ }
508
+
495
509
  function _activeHttpWorkerIsFresh(now = Date.now()) {
496
510
  const lastActivity = Math.max(_extensionLastPollTime, _extensionLastHeartbeat);
497
511
  return !!_activeHttpWorkerId && _extensionConnected && lastActivity > 0 && now - lastActivity <= _HTTP_WORKER_SUCCESSOR_GRACE_MS;
@@ -513,7 +527,14 @@ function _mayReplaceActiveHttpWorker(workerId, presentedToken, now = Date.now())
513
527
  if (handoff) {
514
528
  // While the old worker is preparing a reload, no unrelated worker may use a
515
529
  // momentary poll/heartbeat gap to clear the handoff before /result arms it.
516
- if (!handoff.armed) return false;
530
+ // A reload nobody has received is not being prepared, though: a parked worker can
531
+ // never take it, and pinning its lease deadlocked the reload until it timed out
532
+ // (15.9.26). Then only the ordinary freshness rule applies, and the successor
533
+ // inherits the reload (_adoptReloadHandoff).
534
+ if (!handoff.armed) {
535
+ if (!_reloadAwaitsDelivery(handoff)) return false;
536
+ return !_activeHttpWorkerIsFresh(now);
537
+ }
517
538
  if (handoff.expiresAt >= now) {
518
539
  return handoff.fromWorkerId === _activeHttpWorkerId && presentedToken === handoff.token;
519
540
  }
@@ -721,6 +742,9 @@ function _handleContentWakeRequest(req, res) {
721
742
  return true;
722
743
  }
723
744
 
745
+ // Binds the extension bridge port; set once the bridge server exists (see PROXY MODE).
746
+ let _listenAsExtensionHost = null;
747
+
724
748
  try {
725
749
  const httpServer = createServer((req, res) => {
726
750
  if (_handleContentWakeRequest(req, res)) return;
@@ -888,7 +912,7 @@ try {
888
912
  _activeHttpWorkerId = workerId;
889
913
  _connectingHttpWorkers.delete(workerId);
890
914
  if (workerChanged) {
891
- _reloadHttpWorkerHandoff = null;
915
+ _adoptReloadHandoff(workerId);
892
916
  _extensionConnectionGeneration += 1;
893
917
  }
894
918
  if (!_extensionConnected) {
@@ -949,7 +973,7 @@ try {
949
973
  const workerChanged = workerId !== _activeHttpWorkerId;
950
974
  _activeHttpWorkerId = workerId;
951
975
  _connectingHttpWorkers.delete(workerId);
952
- if (workerChanged) _reloadHttpWorkerHandoff = null;
976
+ if (workerChanged) _adoptReloadHandoff(workerId);
953
977
  _profileExtensionVerified = true;
954
978
  if (workerChanged) _extensionConnectionGeneration += 1;
955
979
  if (!_extensionConnected) {
@@ -1108,18 +1132,31 @@ try {
1108
1132
  res.end("Not found");
1109
1133
  });
1110
1134
 
1111
- httpServer.listen(HTTP_PORT, "127.0.0.1", () => {
1135
+ let binding = false;
1136
+ let proxying = false;
1137
+ httpServer.on("listening", () => {
1138
+ binding = false;
1112
1139
  _isExtensionHost = true;
1113
1140
  console.error(`[Safari MCP] HTTP server listening on port ${HTTP_PORT} (extension host)`);
1114
1141
  });
1115
1142
  httpServer.on("error", (err) => {
1116
- if (err.code === "EADDRINUSE") {
1143
+ binding = false;
1144
+ if (err.code === "EADDRINUSE" && !proxying) {
1145
+ proxying = true;
1117
1146
  console.error(`[Safari MCP] HTTP port ${HTTP_PORT} in use — will proxy commands to primary instance`);
1118
1147
  _isExtensionHost = false;
1119
1148
  // Check if primary instance has extension connected
1120
1149
  _checkPrimaryExtension();
1121
1150
  }
1122
1151
  });
1152
+ // Callable again after a failed bind; the guard keeps a second call from throwing
1153
+ // ERR_SERVER_ALREADY_LISTEN while a bind is still in flight.
1154
+ _listenAsExtensionHost = () => {
1155
+ if (binding || httpServer.listening) return;
1156
+ binding = true;
1157
+ httpServer.listen(HTTP_PORT, "127.0.0.1");
1158
+ };
1159
+ _listenAsExtensionHost();
1123
1160
  } catch {}
1124
1161
 
1125
1162
  // ========== PROXY MODE ==========
@@ -1156,7 +1193,14 @@ async function _checkPrimaryExtension() {
1156
1193
  }
1157
1194
  }
1158
1195
  } catch {
1196
+ // Nothing answers on the bridge port: the primary is gone. A secondary that keeps
1197
+ // proxying to nobody leaves the extension without a bridge until a restart (seen
1198
+ // 15.9.26, after a short-lived process held the port during a daemon restart), so
1199
+ // take the port over. If something else still holds it, the bind fails quietly.
1159
1200
  _primaryHasExtension = false;
1201
+ _extensionConnected = false;
1202
+ if (process.env.SAFARI_PROFILE) _profileExtensionVerified = false;
1203
+ _listenAsExtensionHost?.();
1160
1204
  }
1161
1205
  // Re-check every 10s (unref'd — must not keep the Node process alive on its own)
1162
1206
  setTimeout(_checkPrimaryExtension, 10000).unref();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.21.7",
3
+ "version": "2.21.8",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents \u2014 native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",