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.
- package/extension/background.js +41 -15
- package/extension/manifest.json +1 -1
- package/index.js +49 -5
- package/package.json +1 -1
package/extension/background.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
781
|
-
|
|
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))
|
|
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)
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/extension/manifest.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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",
|