safari-mcp 2.22.15 → 2.22.17

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.
@@ -62,7 +62,14 @@ async function _bridgeFetch(url, init = {}) {
62
62
  const headers = new Headers(init.headers || {});
63
63
  headers.set("X-Safari-MCP-Token", await _bridgeAuthToken());
64
64
  headers.set("X-Safari-MCP-Worker", _bridgeWorkerId);
65
- return fetch(url, { ...init, headers });
65
+ const response = await fetch(url, { ...init, headers });
66
+ // The daemon names the browser session on every authenticated response (#143). Adopt it
67
+ // before the caller acts on the body, so a command never resolves a receipt under an
68
+ // epoch that this response has already replaced.
69
+ try {
70
+ await _noteBrowserEpochHint(response.headers.get("X-Safari-MCP-Browser-Epoch"));
71
+ } catch {} // a failure here must never take the bridge down; the old epoch stays in force
72
+ return response;
66
73
  }
67
74
  const _CONTENT_WAKE_TOKEN_STORAGE_KEY = "mcpContentWakeTokenV1";
68
75
  const _CONTENT_WAKE_LABEL = "safari-mcp-content-wakeup-v1";
@@ -601,7 +608,7 @@ async function connect() {
601
608
  const connectHeaders = reloadHandoffToken
602
609
  ? { "X-Safari-MCP-Reload-Handoff": reloadHandoffToken }
603
610
  : {};
604
- const res = await _bridgeFetch(`${HTTP_URL}/connect?verifier=existing-tab-v1&protocol=popup-opener-lease-v2`, {
611
+ const res = await _bridgeFetch(`${HTTP_URL}/connect?verifier=existing-tab-v1&protocol=popup-opener-lease-v2&build=${_EXTENSION_BUILD}`, {
605
612
  method: "POST",
606
613
  headers: connectHeaders,
607
614
  signal: AbortSignal.timeout(1500),
@@ -2686,7 +2693,7 @@ const MAX_SESSIONS = 50; // Hard cap on session cache size
2686
2693
  const _sessionOwnedTabs = new Map(); // sessionId → Set<tabId>
2687
2694
  // Mirror of the above in storage.local. storage.session is absent on Safari, so it is
2688
2695
  // the only place ownership actually survives a worker suspend.
2689
- const _OWNED_TABS_LOCAL_KEY = "mcpOwnedTabsLocal";
2696
+ const _OWNED_TABS_LOCAL_KEY = "mcpOwnedTabsLocalV2";
2690
2697
  // Long enough to span a working session, short enough that tab ids from a previous
2691
2698
  // browser run have expired rather than being reused by unrelated tabs.
2692
2699
  const _OWNED_TABS_TTL_MS = 6 * 60 * 60 * 1000;
@@ -2697,9 +2704,21 @@ const _OWNED_TABS_TTL_MS = 6 * 60 * 60 * 1000;
2697
2704
  // "no tabs owned yet" compatibility path then silently allowed write commands
2698
2705
  // on ANY tab, including the user's.
2699
2706
  const _OWNED_TABS_KEY = "mcpSessionOwnedTabs";
2700
- const _TAB_RECEIPTS_KEY = "mcpOwnedTabReceiptsV3";
2701
- const _TAB_RECEIPTS_VERSION = 3;
2707
+ // V4/V2: earlier builds keep writing "mcpOwnedTabReceiptsV3"/"mcpOwnedTabsLocal" for as long as Safari
2708
+ // keeps one of their contexts alive after an app update, under an epoch of their own, over
2709
+ // whatever this build stored. Reading and writing other keys keeps that out of this build's
2710
+ // state (#143).
2711
+ const _TAB_RECEIPTS_KEY = "mcpOwnedTabReceiptsV4";
2712
+ const _TAB_RECEIPTS_VERSION = 4;
2713
+ // Announced on /connect. The daemon reloads a lease holder that announces none (worker-upgrade.js).
2714
+ // Must equal EXTENSION_BUILD in worker-upgrade.js.
2715
+ const _EXTENSION_BUILD = "isolated-envelopes-1";
2702
2716
  const _BROWSER_SESSION_EPOCH_KEY = "mcpBrowserSessionEpochV1";
2717
+ // The prefix of `_bridgeWorkerId` that the daemon log prints for this worker. Every
2718
+ // ownership envelope written to storage.local carries it, so the on-disk history shows
2719
+ // which worker instance wrote each one and under which epoch (#143). Diagnostic only:
2720
+ // hydration never reads it.
2721
+ const _ENVELOPE_WRITER = _bridgeWorkerId.slice(0, 8);
2703
2722
  // Receipts are bearer capabilities stored entirely out of band. They are never added
2704
2723
  // to a live page URL: doing so corrupts signed queries and application hash routers.
2705
2724
  const _receiptByToken = new Map(); // token → { tabId, windowId, receiptOrigin, identityDigest, issuedAt }
@@ -2780,7 +2799,78 @@ function _adoptBrowserSessionEpoch(epoch) {
2780
2799
  return epoch;
2781
2800
  }
2782
2801
 
2802
+ // ---- The daemon names the browser session (#143) ----
2803
+ // Safari gives every worker instance its own `storage.session`, so an epoch minted there
2804
+ // rotated with each worker succession and differed between instances alive together. The
2805
+ // next tab removal then rewrote the shared receipt envelope under another epoch and dropped
2806
+ // every receipt the previous one held. The daemon knows which Safari process is running and
2807
+ // sends its identity ("<pid>:<start time>") on every authenticated response: the same for
2808
+ // every instance, changing only when Safari itself restarts, which is exactly when tab ids
2809
+ // stop meaning anything. The epoch is derived from it; `storage.session` stays the fallback
2810
+ // for a daemon that sends none.
2811
+ const _SERVER_EPOCH_KEY = "mcpServerBrowserEpochV1";
2812
+ // A stored copy older than this is not trusted by a cold worker: Safari has probably been
2813
+ // relaunched since, and the daemon has not yet said so.
2814
+ const _SERVER_EPOCH_MAX_AGE_MS = 24 * 60 * 60 * 1000;
2815
+ const _SERVER_EPOCH_REFRESH_MS = 60 * 60 * 1000;
2816
+ let _serverBrowserEpoch = "";
2817
+ let _serverEpochStoredAt = 0;
2818
+ let _serverEpochRestorePromise = null;
2819
+
2820
+ async function _deriveServerEpoch(hint) {
2821
+ const bytes = new TextEncoder().encode(`safari-mcp-browser-session-v1:${hint}`);
2822
+ const digest = await crypto.subtle.digest("SHA-256", bytes);
2823
+ return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 36);
2824
+ }
2825
+
2826
+ async function _storeServerEpoch(epoch) {
2827
+ try {
2828
+ await browser.storage.local.set({ [_SERVER_EPOCH_KEY]: { epoch, at: Date.now() } });
2829
+ _serverEpochStoredAt = Date.now();
2830
+ } catch {} // best effort: the next response names it again
2831
+ }
2832
+
2833
+ // A cold worker starts with the identity the previous worker last stored, so that a tab
2834
+ // event delivered before its first bridge response is not read under a made-up epoch. The
2835
+ // first response replaces it if Safari has restarted since.
2836
+ function _restoreServerEpoch() {
2837
+ if (_serverBrowserEpoch) return Promise.resolve();
2838
+ if (!_serverEpochRestorePromise) {
2839
+ _serverEpochRestorePromise = (async () => {
2840
+ try {
2841
+ const stored = (await browser.storage.local.get(_SERVER_EPOCH_KEY))?.[_SERVER_EPOCH_KEY];
2842
+ if (!_serverBrowserEpoch && stored && /^[a-f0-9]{36}$/.test(String(stored.epoch || "")) &&
2843
+ typeof stored.at === "number" && (Date.now() - stored.at) < _SERVER_EPOCH_MAX_AGE_MS) {
2844
+ _serverBrowserEpoch = stored.epoch;
2845
+ _serverEpochStoredAt = stored.at;
2846
+ }
2847
+ } catch {}
2848
+ })();
2849
+ }
2850
+ return _serverEpochRestorePromise;
2851
+ }
2852
+
2853
+ async function _noteBrowserEpochHint(hint) {
2854
+ const text = String(hint || "");
2855
+ if (!/^\d{1,8}:[ -~]{8,40}$/.test(text)) return;
2856
+ const epoch = await _deriveServerEpoch(text);
2857
+ if (epoch === _serverBrowserEpoch) {
2858
+ if (Date.now() - _serverEpochStoredAt > _SERVER_EPOCH_REFRESH_MS) await _storeServerEpoch(epoch);
2859
+ return;
2860
+ }
2861
+ _serverBrowserEpoch = epoch;
2862
+ await _storeServerEpoch(epoch);
2863
+ if (_browserSessionEpoch !== epoch) {
2864
+ // Bumps the generation, so work stamped with the previous epoch aborts instead of
2865
+ // writing, and forces the next hydration to read storage under the new one.
2866
+ _adoptBrowserSessionEpoch(epoch);
2867
+ _ownedTabsHydrated = false;
2868
+ }
2869
+ }
2870
+
2783
2871
  async function _ensureBrowserSessionEpoch() {
2872
+ await _restoreServerEpoch();
2873
+ if (_serverBrowserEpoch) return _adoptBrowserSessionEpoch(_serverBrowserEpoch);
2784
2874
  if (!_browserSessionStorageAvailable) {
2785
2875
  throw new Error("Browser session storage is unavailable; refusing durable tab authority");
2786
2876
  }
@@ -2969,7 +3059,23 @@ async function _hydrateOwnedTabs() {
2969
3059
  if (_ownedTabsHydrationPromise === hydration) _ownedTabsHydrationPromise = null;
2970
3060
  }
2971
3061
  }
3062
+ // Only the worker that holds the daemon's lease writes the shared envelopes. A superseded or
3063
+ // disconnected instance still gets tab events and still holds the maps from when it last
3064
+ // hydrated; letting it write replaced the live worker's receipts with its stale view (#143).
3065
+ // A command running here (a durable write) is the lease holder by construction, unless it
3066
+ // has been superseded.
3067
+ function _mayPersistOwnership(requireDurableReceipt) {
3068
+ if (requireDurableReceipt) return !_bridgeWorkerSuperseded;
3069
+ return isConnected && _enabled && !_bridgeWorkerSuperseded && !_bridgeWorkerRetiring;
3070
+ }
3071
+
2972
3072
  async function _persistOwnedTabs(requireDurableReceipt = false) {
3073
+ if (!_mayPersistOwnership(requireDurableReceipt)) {
3074
+ if (requireDurableReceipt) {
3075
+ throw new Error("Could not persist tab receipt: this worker no longer holds the bridge lease");
3076
+ }
3077
+ return [];
3078
+ }
2973
3079
  const browserEpoch = await _ensureBrowserSessionEpoch();
2974
3080
  const generation = _browserEpochGeneration;
2975
3081
  const owners = {};
@@ -2977,6 +3083,7 @@ async function _persistOwnedTabs(requireDurableReceipt = false) {
2977
3083
  const receiptEnvelope = {
2978
3084
  version: _TAB_RECEIPTS_VERSION,
2979
3085
  browserEpoch,
3086
+ writer: _ENVELOPE_WRITER,
2980
3087
  at: Date.now(),
2981
3088
  records: [..._receiptByToken.values()].map((record) => ({ ...record })),
2982
3089
  };
@@ -2995,7 +3102,7 @@ async function _persistOwnedTabs(requireDurableReceipt = false) {
2995
3102
  try {
2996
3103
  localWrite = Promise.resolve(browser.storage.local.set({
2997
3104
  [_TAB_RECEIPTS_KEY]: receiptEnvelope,
2998
- [_OWNED_TABS_LOCAL_KEY]: { browserEpoch, at: Date.now(), tabs: owners },
3105
+ [_OWNED_TABS_LOCAL_KEY]: { browserEpoch, writer: _ENVELOPE_WRITER, at: Date.now(), tabs: owners },
2999
3106
  }));
3000
3107
  } catch (error) {
3001
3108
  localWrite = Promise.reject(error);
@@ -3145,6 +3252,16 @@ async function _refreshAllReceiptIdentities() {
3145
3252
  async function _resolveReceiptTab(token, { allowOriginChange = false } = {}) {
3146
3253
  return _withReceiptMutationLock(async () => {
3147
3254
  const normalized = String(token || "");
3255
+ if (!_receiptByToken.has(normalized) && /^[A-Za-z0-9_-]{24,}$/.test(normalized)) {
3256
+ // Another worker instance may have minted it after this one hydrated: read the
3257
+ // shared envelope again before refusing (#143). Every mutation persists before it
3258
+ // returns, so nothing in memory is lost by rebuilding the maps from storage.
3259
+ await (async () => {
3260
+ if (_ownedTabsHydrationPromise) await _ownedTabsHydrationPromise.catch(() => {});
3261
+ _ownedTabsHydrated = false;
3262
+ await _hydrateOwnedTabs();
3263
+ })().catch(() => {});
3264
+ }
3148
3265
  const record = _receiptByToken.get(normalized);
3149
3266
  if (!/^[A-Za-z0-9_-]{24,}$/.test(normalized) || !_isValidReceiptRecord(normalized, record)) {
3150
3267
  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.");
package/index.js CHANGED
@@ -21,13 +21,15 @@ import {
21
21
  } from "./ownership-state.js";
22
22
  import { WebSocketServer } from "ws";
23
23
  import { createServer } from "node:http";
24
- import { randomUUID, randomBytes, timingSafeEqual, createHmac } from "node:crypto";
24
+ import { randomUUID, randomBytes, timingSafeEqual, createHmac, createHash } from "node:crypto";
25
25
  import { execFile, execFileSync } from "node:child_process";
26
26
  import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, renameSync } from "node:fs";
27
27
  import { dirname, join } from "node:path";
28
28
  import { fileURLToPath } from "node:url";
29
29
  import { homedir } from "node:os";
30
30
  import { jpegSize } from "./image-size.js";
31
+ import { createSafariSessionHintReader } from "./safari-session-hint.js";
32
+ import { createStaleWorkerPolicy, leaseBuildVerdict, LEASE_BUILD_PREFERENCE_MS, HOME_CONTEXT_ALIVE_MS } from "./worker-upgrade.js";
31
33
  import {
32
34
  createLedger as createWorkerLedger,
33
35
  noteConnect as ledgerNoteConnect,
@@ -630,6 +632,93 @@ function _isAllowedWebSocketOrigin(origin) {
630
632
  let _extensionWs = null;
631
633
  let _extensionConnected = false;
632
634
  let _activeHttpWorkerId = "";
635
+ // The build the lease holder announced on /connect ("" for a context from an older app).
636
+ let _activeHttpWorkerBuild = "";
637
+ // Which extension origin the lease holder's requests came from (a short hash), for the log.
638
+ let _activeHttpWorkerOrigin = "";
639
+ // The identity of the running Safari process, sent on every authenticated bridge response so
640
+ // the extension can name its browser session the same way from every worker instance (#143).
641
+ const _safariSessionHint = createSafariSessionHintReader();
642
+
643
+ // Safari keeps the extension context of every app generation it ever loaded alive (measured
644
+ // 13.10.26: its process had resources of a dozen builds mapped, the deleted ones included), and
645
+ // each wakes every minute and verifies. The ones from before the isolated envelopes announce no
646
+ // build, so while a current one has verified recently they may not take the lease, and a current
647
+ // one takes it from them at once (see worker-upgrade.js).
648
+ let _currentBuildWorkerSeenAt = 0;
649
+ function _noteCurrentBuildWorker(build) {
650
+ if (build) _currentBuildWorkerSeenAt = Date.now();
651
+ }
652
+ function _currentBuildWorkerSeenRecently(now = Date.now()) {
653
+ return _currentBuildWorkerSeenAt > 0 && now - _currentBuildWorkerSeenAt <= LEASE_BUILD_PREFERENCE_MS;
654
+ }
655
+ // Current contexts can be several as well, each with tab ids of its own: a receipt resolves only in
656
+ // the context that minted it (see worker-upgrade.js). The first current context to lead is the home
657
+ // context, and no instance of another origin leads while the home context is alive.
658
+ let _homeOrigin = "";
659
+ let _homeOriginSeenAt = 0;
660
+ function _homeContextAlive(now = Date.now()) {
661
+ return !!_homeOrigin && now - _homeOriginSeenAt <= HOME_CONTEXT_ALIVE_MS;
662
+ }
663
+ function _noteHomeContext(build, origin) {
664
+ if (build && origin && origin === _homeOrigin) _homeOriginSeenAt = Date.now();
665
+ }
666
+ function _pinHomeContext(build, origin) {
667
+ if (!build || !origin || origin === "none") return;
668
+ if (origin !== _homeOrigin) console.error(`[Safari MCP] home context: ${_homeOrigin || "none"} -> ${origin}`);
669
+ _homeOrigin = origin;
670
+ _homeOriginSeenAt = Date.now();
671
+ }
672
+ const _contextRefusedLoggedAt = new Map();
673
+ function _logContextRefused(workerId, origin) {
674
+ const now = Date.now();
675
+ if (now - (_contextRefusedLoggedAt.get(origin) || 0) < 5 * 60_000) return;
676
+ if (_contextRefusedLoggedAt.size >= 64) _contextRefusedLoggedAt.clear();
677
+ _contextRefusedLoggedAt.set(origin, now);
678
+ console.error(`[Safari MCP] lease: worker ${workerId.slice(0, 8)} of context ${origin} waits, context ${_homeOrigin} leads`);
679
+ }
680
+ function _extensionOriginId(origin) {
681
+ return typeof origin === "string" && origin ? createHash("sha256").update(origin).digest("hex").slice(0, 6) : "none";
682
+ }
683
+ function _logLeaseChange(fromWorkerId, toWorkerId, toBuild, toOrigin) {
684
+ const from = fromWorkerId ? `${fromWorkerId.slice(0, 8)} (${_activeHttpWorkerBuild || "no build"}, origin ${_activeHttpWorkerOrigin || "?"})` : "nobody";
685
+ console.error(`[Safari MCP] lease: ${from} -> ${toWorkerId.slice(0, 8)} (${toBuild || "no build"}, origin ${toOrigin || "?"})`);
686
+ }
687
+
688
+ // When no current context is around, a lease holder that announced no build is reloaded
689
+ // instead, once (see worker-upgrade.js). SAFARI_MCP_NO_AUTO_UPGRADE=1 turns this off.
690
+ const _staleWorkerPolicy = createStaleWorkerPolicy();
691
+ let _staleReloadInFlight = false;
692
+ async function _reloadStaleWorker(workerId) {
693
+ if (process.env.SAFARI_MCP_NO_AUTO_UPGRADE === "1" || _staleReloadInFlight) return;
694
+ if (workerId !== _activeHttpWorkerId || _activeHttpWorkerBuild) return;
695
+ // A current context is about to replace this one, and reloading restarts the same old one.
696
+ if (_currentBuildWorkerSeenRecently()) return;
697
+ // Asked on every poll of the lease holder until it has been reloaded: a command in flight or a
698
+ // reload a moment ago only delays it, so a context that verified at a busy moment cannot stay
699
+ // the lease holder for good.
700
+ const verdict = _staleWorkerPolicy.check({
701
+ build: _activeHttpWorkerBuild,
702
+ active: true,
703
+ busy: _pendingRequests.size > 0,
704
+ });
705
+ if (!verdict.reload) return;
706
+ _staleReloadInFlight = true;
707
+ _staleWorkerPolicy.commit();
708
+ console.error(`[Safari MCP] worker ${workerId.slice(0, 8)} announced no extension build (a context Safari kept from an older app); reloading it`);
709
+ // The reload needs the same handoff as safari_reload_extension (see /proxy-command): it is
710
+ // what lets the replacement worker take the lease from this one.
711
+ let handoff = null;
712
+ try {
713
+ handoff = _prepareReloadHttpWorkerHandoff();
714
+ await sendToExtension("reload_extension", { reloadHandoff: handoff.token }, 30000);
715
+ } catch (error) {
716
+ if (handoff) _cancelReloadHttpWorkerHandoff(handoff);
717
+ console.error(`[Safari MCP] reloading worker ${workerId.slice(0, 8)} failed: ${error.message}`);
718
+ } finally {
719
+ _staleReloadInFlight = false;
720
+ }
721
+ }
633
722
  const _connectingHttpWorkers = new Map();
634
723
  const _HTTP_WORKER_TTL_MS = 60 * 1000;
635
724
  const _HTTP_RELOAD_HANDOFF_TTL_MS = 30 * 1000;
@@ -653,12 +742,12 @@ function _reloadHandoffToken(req) {
653
742
  return typeof value === "string" && /^[A-Za-z0-9_-]{24,}$/.test(value) ? value : "";
654
743
  }
655
744
 
656
- function _rememberConnectingHttpWorker(workerId, reloadHandoffToken = "") {
745
+ function _rememberConnectingHttpWorker(workerId, reloadHandoffToken = "", build = "", origin = "") {
657
746
  const now = Date.now();
658
747
  for (const [id, record] of _connectingHttpWorkers) {
659
748
  if (now - record.connectedAt > _HTTP_WORKER_TTL_MS) _connectingHttpWorkers.delete(id);
660
749
  }
661
- _connectingHttpWorkers.set(workerId, { connectedAt: now, reloadHandoffToken });
750
+ _connectingHttpWorkers.set(workerId, { connectedAt: now, reloadHandoffToken, build, origin });
662
751
  }
663
752
 
664
753
  function _connectingHttpWorker(workerId) {
@@ -734,8 +823,19 @@ function _activeHttpWorkerHasDispatchedRequest() {
734
823
  );
735
824
  }
736
825
 
737
- function _mayReplaceActiveHttpWorker(workerId, presentedToken, now = Date.now()) {
738
- if (!_activeHttpWorkerId || workerId === _activeHttpWorkerId) return true;
826
+ function _mayReplaceActiveHttpWorker(workerId, presentedToken, now = Date.now(), build = "", origin = "") {
827
+ if (workerId === _activeHttpWorkerId) return true;
828
+ const preference = leaseBuildVerdict({
829
+ candidateBuild: build,
830
+ holderBuild: _activeHttpWorkerBuild,
831
+ hasHolder: !!_activeHttpWorkerId,
832
+ currentSeenRecently: _currentBuildWorkerSeenRecently(now),
833
+ candidateOrigin: origin,
834
+ homeOrigin: _homeOrigin,
835
+ homeAlive: _homeContextAlive(now),
836
+ });
837
+ if (preference === "deny") return false;
838
+ if (!_activeHttpWorkerId) return true;
739
839
  // No successor capability may bypass a mutation already delivered to the
740
840
  // active worker. A reload result removes its pending fence before arming the
741
841
  // exact handoff, so the intended reload path remains available.
@@ -759,7 +859,7 @@ function _mayReplaceActiveHttpWorker(workerId, presentedToken, now = Date.now())
759
859
  // to the ordinary healthy-worker lease instead of leaving a permanent blockade.
760
860
  if (_reloadHttpWorkerHandoff === handoff) _reloadHttpWorkerHandoff = null;
761
861
  }
762
- return !_activeHttpWorkerIsFresh(now);
862
+ return preference === "take" || !_activeHttpWorkerIsFresh(now);
763
863
  }
764
864
 
765
865
  function _requireActiveHttpWorker(req, res) {
@@ -1034,6 +1134,13 @@ try {
1034
1134
  res.end("Unauthorized");
1035
1135
  return;
1036
1136
  }
1137
+ if (extensionBridgeEndpoint) {
1138
+ const sessionHint = _safariSessionHint();
1139
+ if (sessionHint) {
1140
+ res.setHeader("X-Safari-MCP-Browser-Epoch", sessionHint);
1141
+ res.setHeader("Access-Control-Expose-Headers", "X-Safari-MCP-Browser-Epoch");
1142
+ }
1143
+ }
1037
1144
 
1038
1145
  // GET /poll — extension asks for next command (long-poll, up to 5s)
1039
1146
  if (req.method === "GET" && req.url === "/poll") {
@@ -1048,6 +1155,8 @@ try {
1048
1155
  return;
1049
1156
  }
1050
1157
  _extensionLastPollTime = Date.now(); // Keep connection alive — critical for stale detection
1158
+ _noteHomeContext(_activeHttpWorkerBuild, _activeHttpWorkerOrigin);
1159
+ _reloadStaleWorker(pollingWorkerId);
1051
1160
  if (!_extensionConnected) {
1052
1161
  _extensionConnected = true;
1053
1162
  console.error("[Safari MCP] Extension reconnected via poll");
@@ -1146,21 +1255,31 @@ try {
1146
1255
  // Only an accepted code generation may proceed to /verify-profile and
1147
1256
  // /extension-verified. Remembering rejected workers would let a stale process
1148
1257
  // skip the protocol gate by posting the second handshake step directly.
1149
- _rememberConnectingHttpWorker(workerId, _reloadHandoffToken(req));
1258
+ const announcedBuild = String(connectUrl.searchParams.get("build") || "");
1259
+ const build = /^[A-Za-z0-9._-]{1,40}$/.test(announcedBuild) ? announcedBuild : "";
1260
+ const originId = _extensionOriginId(req.headers.origin);
1261
+ _rememberConnectingHttpWorker(workerId, _reloadHandoffToken(req), build, originId);
1150
1262
  ledgerNoteConnect(_workerLedger, workerId);
1151
1263
  _noteUnverifiedWorker(workerId);
1152
1264
  // When SAFARI_PROFILE is set, don't mark as connected until profile is verified.
1153
1265
  // A personal-profile extension connecting first would incorrectly set the flag.
1154
1266
  if (!process.env.SAFARI_PROFILE) {
1155
1267
  const reloadHandoffToken = _reloadHandoffToken(req);
1156
- if (!_mayReplaceActiveHttpWorker(workerId, reloadHandoffToken)) {
1268
+ _noteCurrentBuildWorker(build);
1269
+ _noteHomeContext(build, originId);
1270
+ if (!_mayReplaceActiveHttpWorker(workerId, reloadHandoffToken, Date.now(), build, originId)) {
1271
+ if (build) _logContextRefused(workerId, originId);
1157
1272
  _connectingHttpWorkers.delete(workerId);
1158
1273
  res.writeHead(423, { "Content-Type": "application/json" });
1159
1274
  res.end(JSON.stringify({ status: "worker_lease_held" }));
1160
1275
  return;
1161
1276
  }
1162
1277
  const workerChanged = workerId !== _activeHttpWorkerId;
1278
+ if (workerChanged) _logLeaseChange(_activeHttpWorkerId, workerId, build, originId);
1279
+ _pinHomeContext(build, originId);
1163
1280
  _activeHttpWorkerId = workerId;
1281
+ _activeHttpWorkerBuild = build;
1282
+ _activeHttpWorkerOrigin = originId;
1164
1283
  _connectingHttpWorkers.delete(workerId);
1165
1284
  if (workerChanged) {
1166
1285
  _adoptReloadHandoff(workerId);
@@ -1216,14 +1335,21 @@ try {
1216
1335
  res.end("Unverified worker");
1217
1336
  return;
1218
1337
  }
1219
- if (!_mayReplaceActiveHttpWorker(workerId, connectingWorker.reloadHandoffToken)) {
1338
+ _noteCurrentBuildWorker(connectingWorker.build);
1339
+ _noteHomeContext(connectingWorker.build, connectingWorker.origin);
1340
+ if (!_mayReplaceActiveHttpWorker(workerId, connectingWorker.reloadHandoffToken, Date.now(), connectingWorker.build || "", connectingWorker.origin || "")) {
1341
+ if (connectingWorker.build) _logContextRefused(workerId, connectingWorker.origin);
1220
1342
  _connectingHttpWorkers.delete(workerId);
1221
1343
  res.writeHead(423, { "Content-Type": "application/json" });
1222
1344
  res.end(JSON.stringify({ status: "worker_lease_held" }));
1223
1345
  return;
1224
1346
  }
1225
1347
  const workerChanged = workerId !== _activeHttpWorkerId;
1348
+ if (workerChanged) _logLeaseChange(_activeHttpWorkerId, workerId, connectingWorker.build, connectingWorker.origin);
1349
+ _pinHomeContext(connectingWorker.build, connectingWorker.origin);
1226
1350
  _activeHttpWorkerId = workerId;
1351
+ _activeHttpWorkerBuild = connectingWorker.build || "";
1352
+ _activeHttpWorkerOrigin = connectingWorker.origin || "";
1227
1353
  _connectingHttpWorkers.delete(workerId);
1228
1354
  if (workerChanged) {
1229
1355
  _adoptReloadHandoff(workerId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.22.15",
3
+ "version": "2.22.17",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
@@ -71,6 +71,8 @@
71
71
  "ownership-state.js",
72
72
  "image-size.js",
73
73
  "worker-ledger.js",
74
+ "safari-session-hint.js",
75
+ "worker-upgrade.js",
74
76
  "node-compat.js",
75
77
  "mcp-helpers.js",
76
78
  "injected-validators.js",
@@ -0,0 +1,68 @@
1
+ /**
2
+ * The identity of the running Safari process, as the daemon sees it.
3
+ *
4
+ * A tab receipt is valid for one browser session: Safari hands out tab ids afresh after a
5
+ * restart, so a receipt minted before one must never be taken for a tab after it. The
6
+ * extension used to name the session itself, with a value kept in `storage.session`. Safari
7
+ * gives every extension worker instance its own `storage.session`, so that value rotated
8
+ * with each worker succession (and differed between instances alive together), and the next
9
+ * tab removal rewrote the shared receipt envelope under a different epoch and dropped every
10
+ * receipt the previous one held (#143).
11
+ *
12
+ * The daemon names the session instead. "<pid>:<start time>" of the Safari process is the
13
+ * same for every worker, survives every worker restart, and changes exactly when Safari
14
+ * itself restarts. The extension derives its epoch from it.
15
+ */
16
+ import { execFileSync } from "node:child_process";
17
+
18
+ const HINT_RE = /^\d{1,8}:[ -~]{8,40}$/;
19
+
20
+ /**
21
+ * "<pid>:<lstart>" when both are well formed, otherwise "" (the caller sends no hint).
22
+ * @param {unknown} pid
23
+ * @param {unknown} lstart
24
+ */
25
+ export function formatSafariSessionHint(pid, lstart) {
26
+ const id = String(pid ?? "").trim();
27
+ const started = String(lstart ?? "").trim().replace(/\s+/g, " ");
28
+ const hint = `${id}:${started}`;
29
+ return /^\d{1,8}$/.test(id) && HINT_RE.test(hint) ? hint : "";
30
+ }
31
+
32
+ /**
33
+ * The oldest process named exactly `Safari`; helper processes carry other names.
34
+ * @param {(file: string, args: string[], options?: object) => string | Buffer} [run]
35
+ */
36
+ export function readSafariSessionHintSync(run = /** @type {any} */ (execFileSync)) {
37
+ try {
38
+ const pid = String(run("/usr/bin/pgrep", ["-o", "-x", "Safari"], { encoding: "utf8", timeout: 2000 }))
39
+ .trim().split("\n")[0];
40
+ if (!/^\d+$/.test(pid)) return "";
41
+ const lstart = run("/bin/ps", ["-o", "lstart=", "-p", pid], { encoding: "utf8", timeout: 2000 });
42
+ return formatSafariSessionHint(pid, lstart);
43
+ } catch {
44
+ return ""; // pgrep exits 1 when Safari is not running
45
+ }
46
+ }
47
+
48
+ /**
49
+ * A reader that re-asks at most once per `ttlMs`. Polls from many workers arrive every few
50
+ * seconds; the process table is read once a second at most, synchronously, so that the
51
+ * first response after Safari relaunches already carries the new identity.
52
+ */
53
+ export function createSafariSessionHintReader({
54
+ ttlMs = 1000,
55
+ read = readSafariSessionHintSync,
56
+ now = Date.now,
57
+ } = {}) {
58
+ let value = "";
59
+ let at = -Infinity;
60
+ return function currentSafariSessionHint() {
61
+ const t = now();
62
+ if (t - at >= ttlMs) {
63
+ value = read();
64
+ at = t;
65
+ }
66
+ return value;
67
+ };
68
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Contexts that still run an older build (#143).
3
+ *
4
+ * Replacing the extension app does not stop the contexts Safari had already started, and Safari
5
+ * keeps the context of every app generation it ever loaded (13.10.26: its process had the
6
+ * resources of a dozen builds mapped, among them deleted ones). Each wakes every minute, verifies
7
+ * and becomes the daemon's lease holder, each with its own in-memory receipts, so a lease that
8
+ * moves between an old context and a new one loses the receipts the other minted.
9
+ *
10
+ * Reloading an old context does not remove it: it restarts the same old generation, so the stream
11
+ * of old contexts never ends (45 reloads in 90 minutes). What the daemon controls is who leads.
12
+ * A worker that announced no build never takes the lease while a current one has verified within
13
+ * `LEASE_BUILD_PREFERENCE_MS`, and a current one takes it from an old holder at once. Reloading
14
+ * is the fallback for when no current context is around at all: the reload restarts the context
15
+ * from the app now on disk, and the replacement announces the build.
16
+ *
17
+ * A worker that announces any build is never reloaded here, so a newer extension talking to an
18
+ * older daemon is left alone. The rate limit bounds the damage if a reload ever fails to change
19
+ * the code the worker runs.
20
+ */
21
+
22
+ /** Sent by the extension on `/connect` (`&build=`). Absent on every older build. */
23
+ export const EXTENSION_BUILD = "isolated-envelopes-1";
24
+
25
+ /** How long after a current context verified the daemon keeps older ones away from the lease. */
26
+ export const LEASE_BUILD_PREFERENCE_MS = 15 * 60_000;
27
+
28
+ /**
29
+ * How long the context that leads (the home context) may stay silent before another current
30
+ * context may lead. Its instances wake on a one-minute alarm, so this is a little over one cycle.
31
+ */
32
+ export const HOME_CONTEXT_ALIVE_MS = 100_000;
33
+
34
+ /**
35
+ * Whether the builds and contexts alone settle who may lead. The caller still applies its other
36
+ * rules (a command in flight, a reload handoff) before acting on "take".
37
+ * deny an older context while a current one is around, or a current context while another
38
+ * current one (the home context, by extension origin) is alive: it never leads
39
+ * take a current context against a holder from an older app: replace it, even while it polls
40
+ * ordinary the usual rule decides (the holder has been quiet for a while)
41
+ *
42
+ * Several current contexts can be alive at once as well, and each has tab ids of its own: a receipt
43
+ * resolves only in the context that minted it, and a write from another context erased it from the
44
+ * shared envelope (13.10.26). Their requests carry different extension origins, so the first current
45
+ * context to lead stays the one that leads while it is alive.
46
+ * @param {{ candidateBuild: string, holderBuild: string, hasHolder: boolean, currentSeenRecently: boolean,
47
+ * candidateOrigin?: string, homeOrigin?: string, homeAlive?: boolean }} s
48
+ * @returns {"deny" | "take" | "ordinary"}
49
+ */
50
+ export function leaseBuildVerdict({
51
+ candidateBuild, holderBuild, hasHolder, currentSeenRecently,
52
+ candidateOrigin = "", homeOrigin = "", homeAlive = false,
53
+ }) {
54
+ if (!candidateBuild && currentSeenRecently) return "deny";
55
+ if (candidateBuild && homeAlive && homeOrigin && candidateOrigin !== homeOrigin) return "deny";
56
+ if (candidateBuild && hasHolder && !holderBuild) return "take";
57
+ return "ordinary";
58
+ }
59
+
60
+ /**
61
+ * `check` decides without recording anything; `commit` records an attempt once the reload is
62
+ * really about to be sent. A "no" for busy or too soon is only for now: the daemon asks again on
63
+ * every poll of the lease holder, which is what keeps a context that verified at a busy moment
64
+ * from staying the lease holder for good.
65
+ * @param {{ now?: () => number, minGapMs?: number, windowMs?: number, maxPerWindow?: number }} [options]
66
+ * @returns {{ check: (worker: { build: string, active: boolean, busy: boolean }) => { reload: boolean, reason: string }, commit: () => void }}
67
+ */
68
+ export function createStaleWorkerPolicy({
69
+ now = Date.now,
70
+ minGapMs = 20_000,
71
+ windowMs = 10 * 60_000,
72
+ maxPerWindow = 10,
73
+ } = {}) {
74
+ /** @type {number[]} */
75
+ const attempts = [];
76
+ const prune = (t) => { while (attempts.length && t - attempts[0] > windowMs) attempts.shift(); };
77
+ return {
78
+ check({ build, active, busy }) {
79
+ if (build) return { reload: false, reason: "announced a build" };
80
+ if (!active) return { reload: false, reason: "not the lease holder" };
81
+ const t = now();
82
+ prune(t);
83
+ if (attempts.length >= maxPerWindow) return { reload: false, reason: "reload limit reached" };
84
+ if (busy) return { reload: false, reason: "a command is in flight" };
85
+ if (attempts.length && t - attempts[attempts.length - 1] < minGapMs) return { reload: false, reason: "reloaded one a moment ago" };
86
+ return { reload: true, reason: "no build announced" };
87
+ },
88
+ commit() {
89
+ const t = now();
90
+ prune(t);
91
+ attempts.push(t);
92
+ },
93
+ };
94
+ }