safari-mcp 2.22.16 → 2.22.18
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.
- package/README.md +4 -2
- package/extension/background.js +116 -5
- package/index.js +149 -9
- package/package.json +3 -1
- package/safari-session-hint.js +68 -0
- package/worker-upgrade.js +94 -0
package/README.md
CHANGED
|
@@ -752,8 +752,10 @@ xcodebuild -project "xcode/Safari MCP/Safari MCP.xcodeproj" \
|
|
|
752
752
|
APP_PATH=$(find ~/Library/Developer/Xcode/DerivedData/Safari_MCP-*/Build/Products/Release -name "Safari MCP.app" -maxdepth 2 | head -1)
|
|
753
753
|
|
|
754
754
|
# 4. Re-sign safari-helper with the Apple Events entitlement
|
|
755
|
-
# (helps macOS surface the TCC Automation prompt reliably)
|
|
756
|
-
|
|
755
|
+
# (helps macOS surface the TCC Automation prompt reliably).
|
|
756
|
+
# Keep the stable identifier: macOS keys the Accessibility grant to it.
|
|
757
|
+
codesign --sign - --force --identifier com.achiya-automation.safari-mcp \
|
|
758
|
+
--entitlements safari-helper.entitlements safari-helper
|
|
757
759
|
|
|
758
760
|
# 5. Open the app (needed once so Safari registers the extension)
|
|
759
761
|
open "$APP_PATH"
|
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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 = "
|
|
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,8 +2704,15 @@ 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
|
-
|
|
2701
|
-
|
|
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";
|
|
2703
2717
|
// The prefix of `_bridgeWorkerId` that the daemon log prints for this worker. Every
|
|
2704
2718
|
// ownership envelope written to storage.local carries it, so the on-disk history shows
|
|
@@ -2785,7 +2799,78 @@ function _adoptBrowserSessionEpoch(epoch) {
|
|
|
2785
2799
|
return epoch;
|
|
2786
2800
|
}
|
|
2787
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
|
+
|
|
2788
2871
|
async function _ensureBrowserSessionEpoch() {
|
|
2872
|
+
await _restoreServerEpoch();
|
|
2873
|
+
if (_serverBrowserEpoch) return _adoptBrowserSessionEpoch(_serverBrowserEpoch);
|
|
2789
2874
|
if (!_browserSessionStorageAvailable) {
|
|
2790
2875
|
throw new Error("Browser session storage is unavailable; refusing durable tab authority");
|
|
2791
2876
|
}
|
|
@@ -2974,7 +3059,23 @@ async function _hydrateOwnedTabs() {
|
|
|
2974
3059
|
if (_ownedTabsHydrationPromise === hydration) _ownedTabsHydrationPromise = null;
|
|
2975
3060
|
}
|
|
2976
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
|
+
|
|
2977
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
|
+
}
|
|
2978
3079
|
const browserEpoch = await _ensureBrowserSessionEpoch();
|
|
2979
3080
|
const generation = _browserEpochGeneration;
|
|
2980
3081
|
const owners = {};
|
|
@@ -3151,6 +3252,16 @@ async function _refreshAllReceiptIdentities() {
|
|
|
3151
3252
|
async function _resolveReceiptTab(token, { allowOriginChange = false } = {}) {
|
|
3152
3253
|
return _withReceiptMutationLock(async () => {
|
|
3153
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
|
+
}
|
|
3154
3265
|
const record = _receiptByToken.get(normalized);
|
|
3155
3266
|
if (!/^[A-Za-z0-9_-]{24,}$/.test(normalized) || !_isValidReceiptRecord(normalized, record)) {
|
|
3156
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
@@ -1989,6 +2115,18 @@ function _sanitizeTabResult(value) {
|
|
|
1989
2115
|
};
|
|
1990
2116
|
}
|
|
1991
2117
|
|
|
2118
|
+
// After a navigation, the session's current page is the URL it asked for. safari.navigate() records
|
|
2119
|
+
// one for the AppleScript path, but a navigation the extension served left safari.js holding the URL
|
|
2120
|
+
// the tab had left, which the tool then released: without SAFARI_PROFILE every later write of that
|
|
2121
|
+
// client was judged by a URL it no longer owned, and refused (#172). The requested URL, not the one
|
|
2122
|
+
// the page reports: extensionOrFallback has already claimed it, while a redirect target is the
|
|
2123
|
+
// site's choice (see run_script's newTab). Skipped when a call alongside has made another tab the
|
|
2124
|
+
// session's current one: the navigated page is not that tab's.
|
|
2125
|
+
function _pinNavigatedUrl(url, usedReceipt) {
|
|
2126
|
+
if ((usedReceipt || "") !== (_getActiveReceipt() || "")) return;
|
|
2127
|
+
safari.setActiveTabURL(url);
|
|
2128
|
+
}
|
|
2129
|
+
|
|
1992
2130
|
// A receipt is bound to the origin it was minted on, so a cross-origin navigation the
|
|
1993
2131
|
// caller itself asked for used to strand every following call ("not valid for this
|
|
1994
2132
|
// origin", ~80×/week). The caller chose the destination, so rotating here is safe —
|
|
@@ -2630,6 +2768,7 @@ server.tool(
|
|
|
2630
2768
|
);
|
|
2631
2769
|
// Tab kept its identity, just changed URL — drop the stale old URL from ownership.
|
|
2632
2770
|
if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
|
|
2771
|
+
_pinNavigatedUrl(url, usedReceipt);
|
|
2633
2772
|
return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
|
|
2634
2773
|
}
|
|
2635
2774
|
);
|
|
@@ -2733,6 +2872,7 @@ server.tool(
|
|
|
2733
2872
|
}
|
|
2734
2873
|
);
|
|
2735
2874
|
if (oldUrl && oldUrl !== url && oldUrl !== 'about:blank') _removeOwnedURL(oldUrl);
|
|
2875
|
+
_pinNavigatedUrl(url, usedReceipt);
|
|
2736
2876
|
return textResult(await _rotateReceiptAfterNavigate(result, usedReceipt, oldUrl));
|
|
2737
2877
|
}
|
|
2738
2878
|
);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.22.
|
|
3
|
+
"version": "2.22.18",
|
|
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
|
+
}
|