safari-pilot 0.1.33 → 0.1.36
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/bin/Safari Pilot.app/Contents/CodeResources +0 -0
- package/bin/Safari Pilot.app/Contents/Info.plist +9 -9
- package/bin/Safari Pilot.app/Contents/MacOS/Safari Pilot +0 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Info.plist +8 -8
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/MacOS/Safari Pilot Extension +0 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/background.js +345 -19
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-isolated.js +128 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/content-main.js +610 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/cs-readiness.js +58 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/session-filter.js +47 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/lib/tab-url-matcher.js +148 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/locator.js +1088 -0
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Resources/manifest.json +1 -1
- package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/_CodeSignature/CodeResources +43 -10
- package/bin/Safari Pilot.app/Contents/Resources/Assets.car +0 -0
- package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/Info.plist +0 -0
- package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/MainMenu.nib +0 -0
- package/bin/Safari Pilot.app/Contents/Resources/Base.lproj/Main.storyboardc/NSWindowController-B8D-0N-5wS.nib +0 -0
- package/bin/Safari Pilot.app/Contents/_CodeSignature/CodeResources +9 -9
- package/bin/Safari Pilot.zip +0 -0
- package/bin/SafariPilotd +0 -0
- package/dist/cli/stats.js +45 -2
- package/dist/cli/stats.js.map +1 -1
- package/dist/config.d.ts +12 -0
- package/dist/config.js +6 -0
- package/dist/config.js.map +1 -1
- package/dist/engine-selector.js +21 -1
- package/dist/engine-selector.js.map +1 -1
- package/dist/engines/applescript.d.ts +6 -1
- package/dist/engines/applescript.js +43 -12
- package/dist/engines/applescript.js.map +1 -1
- package/dist/engines/extension.d.ts +2 -0
- package/dist/engines/extension.js +91 -9
- package/dist/engines/extension.js.map +1 -1
- package/dist/engines/js-helpers.d.ts +18 -1
- package/dist/engines/js-helpers.js +30 -6
- package/dist/engines/js-helpers.js.map +1 -1
- package/dist/errors.d.ts +47 -1
- package/dist/errors.js +151 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.js +27 -2
- package/dist/index.js.map +1 -1
- package/dist/locator.d.ts +60 -0
- package/dist/locator.js +29 -0
- package/dist/locator.js.map +1 -1
- package/dist/security/loop-detector.d.ts +32 -0
- package/dist/security/loop-detector.js +109 -0
- package/dist/security/loop-detector.js.map +1 -0
- package/dist/security/wall-cap.d.ts +32 -0
- package/dist/security/wall-cap.js +101 -0
- package/dist/security/wall-cap.js.map +1 -0
- package/dist/server.d.ts +4 -0
- package/dist/server.js +152 -4
- package/dist/server.js.map +1 -1
- package/dist/tools/auth.js +3 -3
- package/dist/tools/auth.js.map +1 -1
- package/dist/tools/batch.d.ts +19 -0
- package/dist/tools/batch.js +147 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/extraction.d.ts +17 -0
- package/dist/tools/extraction.js +320 -22
- package/dist/tools/extraction.js.map +1 -1
- package/dist/tools/file-upload.js +3 -3
- package/dist/tools/file-upload.js.map +1 -1
- package/dist/tools/final-proof.d.ts +15 -0
- package/dist/tools/final-proof.js +139 -0
- package/dist/tools/final-proof.js.map +1 -0
- package/dist/tools/frames.js +5 -4
- package/dist/tools/frames.js.map +1 -1
- package/dist/tools/interaction.d.ts +16 -0
- package/dist/tools/interaction.js +175 -15
- package/dist/tools/interaction.js.map +1 -1
- package/dist/tools/navigation.js +19 -0
- package/dist/tools/navigation.js.map +1 -1
- package/dist/tools/network.js +10 -9
- package/dist/tools/network.js.map +1 -1
- package/dist/tools/overlays.js +7 -1
- package/dist/tools/overlays.js.map +1 -1
- package/dist/tools/page-info.d.ts +39 -0
- package/dist/tools/page-info.js +126 -0
- package/dist/tools/page-info.js.map +1 -0
- package/dist/tools/pdf.js +3 -2
- package/dist/tools/pdf.js.map +1 -1
- package/dist/tools/permissions.js +6 -5
- package/dist/tools/permissions.js.map +1 -1
- package/dist/tools/playbooks.d.ts +43 -0
- package/dist/tools/playbooks.js +193 -0
- package/dist/tools/playbooks.js.map +1 -0
- package/dist/tools/selector-pack.js +3 -2
- package/dist/tools/selector-pack.js.map +1 -1
- package/dist/tools/shadow.js +3 -2
- package/dist/tools/shadow.js.map +1 -1
- package/dist/tools/storage.js +15 -14
- package/dist/tools/storage.js.map +1 -1
- package/dist/tools/structured-extraction.d.ts +14 -0
- package/dist/tools/structured-extraction.js +112 -10
- package/dist/tools/structured-extraction.js.map +1 -1
- package/dist/tools/wait.d.ts +8 -0
- package/dist/tools/wait.js +9 -1
- package/dist/tools/wait.js.map +1 -1
- package/dist/types.d.ts +17 -1
- package/extension/background.js +345 -19
- package/extension/content-isolated.js +128 -0
- package/extension/content-main.js +610 -0
- package/extension/lib/cs-readiness.js +58 -0
- package/extension/lib/session-filter.js +47 -0
- package/extension/lib/tab-url-matcher.js +148 -0
- package/extension/locator.js +1088 -0
- package/extension/manifest.json +1 -1
- package/package.json +1 -1
- package/safari-pilot.config.json +3 -1
|
Binary file
|
|
@@ -23,29 +23,29 @@
|
|
|
23
23
|
<key>CFBundlePackageType</key>
|
|
24
24
|
<string>APPL</string>
|
|
25
25
|
<key>CFBundleShortVersionString</key>
|
|
26
|
-
<string>0.1.
|
|
26
|
+
<string>0.1.36</string>
|
|
27
27
|
<key>CFBundleSupportedPlatforms</key>
|
|
28
28
|
<array>
|
|
29
29
|
<string>MacOSX</string>
|
|
30
30
|
</array>
|
|
31
31
|
<key>CFBundleVersion</key>
|
|
32
|
-
<string>
|
|
32
|
+
<string>202605190102</string>
|
|
33
33
|
<key>DTCompiler</key>
|
|
34
34
|
<string>com.apple.compilers.llvm.clang.1_0</string>
|
|
35
35
|
<key>DTPlatformBuild</key>
|
|
36
|
-
<string>
|
|
36
|
+
<string>25F70</string>
|
|
37
37
|
<key>DTPlatformName</key>
|
|
38
38
|
<string>macosx</string>
|
|
39
39
|
<key>DTPlatformVersion</key>
|
|
40
|
-
<string>26.
|
|
40
|
+
<string>26.5</string>
|
|
41
41
|
<key>DTSDKBuild</key>
|
|
42
|
-
<string>
|
|
42
|
+
<string>25F70</string>
|
|
43
43
|
<key>DTSDKName</key>
|
|
44
|
-
<string>macosx26.
|
|
44
|
+
<string>macosx26.5</string>
|
|
45
45
|
<key>DTXcode</key>
|
|
46
|
-
<string>
|
|
46
|
+
<string>2650</string>
|
|
47
47
|
<key>DTXcodeBuild</key>
|
|
48
|
-
<string>
|
|
48
|
+
<string>17F42</string>
|
|
49
49
|
<key>LSMinimumSystemVersion</key>
|
|
50
50
|
<string>26.4</string>
|
|
51
51
|
<key>NSMainStoryboardFile</key>
|
|
@@ -53,6 +53,6 @@
|
|
|
53
53
|
<key>NSPrincipalClass</key>
|
|
54
54
|
<string>NSApplication</string>
|
|
55
55
|
<key>SFSafariWebExtensionConverterVersion</key>
|
|
56
|
-
<string>26.
|
|
56
|
+
<string>26.5</string>
|
|
57
57
|
</dict>
|
|
58
58
|
</plist>
|
|
Binary file
|
package/bin/Safari Pilot.app/Contents/PlugIns/Safari Pilot Extension.appex/Contents/Info.plist
CHANGED
|
@@ -19,29 +19,29 @@
|
|
|
19
19
|
<key>CFBundlePackageType</key>
|
|
20
20
|
<string>XPC!</string>
|
|
21
21
|
<key>CFBundleShortVersionString</key>
|
|
22
|
-
<string>0.1.
|
|
22
|
+
<string>0.1.36</string>
|
|
23
23
|
<key>CFBundleSupportedPlatforms</key>
|
|
24
24
|
<array>
|
|
25
25
|
<string>MacOSX</string>
|
|
26
26
|
</array>
|
|
27
27
|
<key>CFBundleVersion</key>
|
|
28
|
-
<string>
|
|
28
|
+
<string>202605190102</string>
|
|
29
29
|
<key>DTCompiler</key>
|
|
30
30
|
<string>com.apple.compilers.llvm.clang.1_0</string>
|
|
31
31
|
<key>DTPlatformBuild</key>
|
|
32
|
-
<string>
|
|
32
|
+
<string>25F70</string>
|
|
33
33
|
<key>DTPlatformName</key>
|
|
34
34
|
<string>macosx</string>
|
|
35
35
|
<key>DTPlatformVersion</key>
|
|
36
|
-
<string>26.
|
|
36
|
+
<string>26.5</string>
|
|
37
37
|
<key>DTSDKBuild</key>
|
|
38
|
-
<string>
|
|
38
|
+
<string>25F70</string>
|
|
39
39
|
<key>DTSDKName</key>
|
|
40
|
-
<string>macosx26.
|
|
40
|
+
<string>macosx26.5</string>
|
|
41
41
|
<key>DTXcode</key>
|
|
42
|
-
<string>
|
|
42
|
+
<string>2650</string>
|
|
43
43
|
<key>DTXcodeBuild</key>
|
|
44
|
-
<string>
|
|
44
|
+
<string>17F42</string>
|
|
45
45
|
<key>LSMinimumSystemVersion</key>
|
|
46
46
|
<string>10.14</string>
|
|
47
47
|
<key>NSExtension</key>
|
|
Binary file
|
|
@@ -63,29 +63,110 @@ async function saveTabCache() {
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
+
// v0.1.36 F1.2 dashboard-URL handshake (2026-05-18 evening rework).
|
|
67
|
+
//
|
|
68
|
+
// The daemon identifies each MCP session by the URL of its dashboard tab
|
|
69
|
+
// (http://127.0.0.1:19475/session?id=sess_<n>). Every daemon-side command
|
|
70
|
+
// carries that URL in `cmd.sessionDashboardUrl`. The extension watches
|
|
71
|
+
// tabs.onUpdated / onCreated for that URL pattern and records
|
|
72
|
+
// dashboardUrl → tab.windowId (WebExtension namespace) so the candidate
|
|
73
|
+
// filter can resolve session → window in the SAME namespace the cache
|
|
74
|
+
// uses. The previous design (passing AppleScript window IDs) silently
|
|
75
|
+
// dropped every candidate because the two ID schemes never matched.
|
|
76
|
+
const SESSION_DASHBOARD_URL_PREFIX = 'http://127.0.0.1:19475/session?id=';
|
|
77
|
+
const sessionDashboardUrlToWindowId = new Map();
|
|
78
|
+
// Reverse mapping (tabId → dashboardUrl) so tabs.onRemoved can drop the
|
|
79
|
+
// forward entry without iterating the whole Map.
|
|
80
|
+
const dashboardTabIdToUrl = new Map();
|
|
81
|
+
|
|
82
|
+
function registerDashboardTab(tab) {
|
|
83
|
+
if (!tab || tab.id == null || tab.windowId == null) return;
|
|
84
|
+
const url = tab.url;
|
|
85
|
+
if (typeof url !== 'string' || !url.startsWith(SESSION_DASHBOARD_URL_PREFIX)) return;
|
|
86
|
+
const prevUrl = dashboardTabIdToUrl.get(tab.id);
|
|
87
|
+
if (prevUrl && prevUrl !== url) {
|
|
88
|
+
// Tab navigated from one dashboard URL to another (or away then back).
|
|
89
|
+
// Drop the stale forward entry. The new one is rewritten below.
|
|
90
|
+
if (sessionDashboardUrlToWindowId.get(prevUrl) === tab.windowId) {
|
|
91
|
+
sessionDashboardUrlToWindowId.delete(prevUrl);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
sessionDashboardUrlToWindowId.set(url, tab.windowId);
|
|
95
|
+
dashboardTabIdToUrl.set(tab.id, url);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// MV3 event pages are recycled aggressively. If Safari already has session
|
|
99
|
+
// dashboard tabs open when the extension wakes up — common when a session
|
|
100
|
+
// was created before the extension's event page was alive, or when the
|
|
101
|
+
// page was unloaded and re-loaded mid-session — the tabs.onCreated /
|
|
102
|
+
// onUpdated events for those tabs already fired and won't fire again. The
|
|
103
|
+
// map would be empty, and spFilterBySession would fall back to its
|
|
104
|
+
// startup-race "return all candidates" path → cross-session pollution
|
|
105
|
+
// becomes possible.
|
|
106
|
+
//
|
|
107
|
+
// Solution: every time the event page loads, scan existing tabs once and
|
|
108
|
+
// populate the map for any dashboard URLs already open. Idempotent —
|
|
109
|
+
// re-registering the same (tab, url, windowId) triple is a no-op.
|
|
110
|
+
async function populateSessionMapFromExistingTabs() {
|
|
111
|
+
try {
|
|
112
|
+
const tabs = await browser.tabs.query({});
|
|
113
|
+
for (const t of tabs) {
|
|
114
|
+
registerDashboardTab(t);
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// browser.tabs.query can fail in event-page wake contexts where the
|
|
118
|
+
// host hasn't fully initialised. The tabs.onCreated/onUpdated paths
|
|
119
|
+
// remain as the fallback registration mechanism.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
populateSessionMapFromExistingTabs();
|
|
123
|
+
|
|
66
124
|
// Top-level tab lifecycle listeners — MUST be registered synchronously at script
|
|
67
125
|
// load time so Safari wakes the event page when tabs change.
|
|
68
126
|
browser.tabs.onCreated.addListener((tab) => {
|
|
69
127
|
if (tab.id != null) {
|
|
70
|
-
|
|
128
|
+
// v0.1.36 reviewer F1.2 — store windowId so findTargetTab can filter
|
|
129
|
+
// candidates by the originating MCP session's window. Cross-session
|
|
130
|
+
// matchers would otherwise route one agent's command into another
|
|
131
|
+
// agent's tab when two MCP sessions share a Safari instance (typical
|
|
132
|
+
// at bench concurrency).
|
|
133
|
+
tabCacheMap.set(tab.id, {
|
|
134
|
+
url: tab.url || '',
|
|
135
|
+
title: tab.title || '',
|
|
136
|
+
windowId: tab.windowId,
|
|
137
|
+
});
|
|
71
138
|
saveTabCache();
|
|
139
|
+
registerDashboardTab(tab);
|
|
72
140
|
}
|
|
73
141
|
});
|
|
74
142
|
|
|
75
143
|
browser.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
|
|
76
|
-
const entry = tabCacheMap.get(tabId) || { url: '', title: '' };
|
|
144
|
+
const entry = tabCacheMap.get(tabId) || { url: '', title: '', windowId: undefined };
|
|
77
145
|
if (changeInfo.url !== undefined) entry.url = changeInfo.url;
|
|
78
146
|
if (changeInfo.title !== undefined) entry.title = changeInfo.title;
|
|
79
147
|
// Also pick up from the full tab object if changeInfo is sparse
|
|
80
148
|
if (tab.url && !entry.url) entry.url = tab.url;
|
|
81
149
|
if (tab.title && !entry.title) entry.title = tab.title;
|
|
150
|
+
// F1.2: keep windowId fresh from the full tab object; tabs.onUpdated does
|
|
151
|
+
// not emit a windowId changeInfo even when the tab is moved between
|
|
152
|
+
// windows, so always sync from `tab.windowId`.
|
|
153
|
+
if (tab.windowId !== undefined) entry.windowId = tab.windowId;
|
|
82
154
|
tabCacheMap.set(tabId, entry);
|
|
83
155
|
saveTabCache();
|
|
156
|
+
registerDashboardTab(tab);
|
|
84
157
|
});
|
|
85
158
|
|
|
86
159
|
browser.tabs.onRemoved.addListener((tabId) => {
|
|
87
160
|
tabCacheMap.delete(tabId);
|
|
88
161
|
saveTabCache();
|
|
162
|
+
// F1.2: clean up dashboard URL mapping if this was a session dashboard
|
|
163
|
+
// tab. Leaving stale entries would mean a later command targeting the
|
|
164
|
+
// closed session's URL would still try to filter by its dead windowId.
|
|
165
|
+
const url = dashboardTabIdToUrl.get(tabId);
|
|
166
|
+
if (url) {
|
|
167
|
+
dashboardTabIdToUrl.delete(tabId);
|
|
168
|
+
sessionDashboardUrlToWindowId.delete(url);
|
|
169
|
+
}
|
|
89
170
|
});
|
|
90
171
|
|
|
91
172
|
// T79: clear tab-scoped selectorPack storage on tab close. Keys live under
|
|
@@ -236,10 +317,191 @@ async function removePendingEntry(commandId) {
|
|
|
236
317
|
}
|
|
237
318
|
|
|
238
319
|
// ─── Command execution ───────────────────────────────────────────────────────
|
|
239
|
-
async function findTargetTab(tabUrl) {
|
|
240
|
-
if (tabUrl) {
|
|
241
|
-
const target = tabUrl.replace(/\/$/, '');
|
|
242
320
|
|
|
321
|
+
// v0.1.36 Track A Fix 3 — content-script readiness map (inlined from
|
|
322
|
+
// extension/lib/cs-readiness.js; tested in test/unit/extension/
|
|
323
|
+
// cs-readiness.test.ts). Content scripts write a heartbeat to
|
|
324
|
+
// `sp_cs_ready_<tabId>` on load; we mirror it into spCsReadyMap and use
|
|
325
|
+
// it to choose between a fast-fail (5s) vs. normal (30s) storage-bus
|
|
326
|
+
// timeout. Without this, dispatching to a freshly opened/navigated tab
|
|
327
|
+
// blocks the full 30s before the agent can recover.
|
|
328
|
+
const SP_CS_READY_MAX_AGE_MS = 60_000;
|
|
329
|
+
const SP_CS_NOT_READY_FAST_FAIL_MS = 10_000;
|
|
330
|
+
const spCsReadyMap = new Map();
|
|
331
|
+
function spRecordCsReady(tabId, now) { spCsReadyMap.set(tabId, { timestamp: now }); }
|
|
332
|
+
function spIsCsReady(tabId, now, maxAgeMs) {
|
|
333
|
+
const entry = spCsReadyMap.get(tabId);
|
|
334
|
+
if (!entry) return false;
|
|
335
|
+
return (now - entry.timestamp) <= (maxAgeMs ?? SP_CS_READY_MAX_AGE_MS);
|
|
336
|
+
}
|
|
337
|
+
function spDecideStorageBusTimeout(tabId, now, callerDefaultMs) {
|
|
338
|
+
if (spIsCsReady(tabId, now)) {
|
|
339
|
+
return { timeoutMs: callerDefaultMs, reason: 'cs_ready' };
|
|
340
|
+
}
|
|
341
|
+
return {
|
|
342
|
+
timeoutMs: Math.min(callerDefaultMs, SP_CS_NOT_READY_FAST_FAIL_MS),
|
|
343
|
+
reason: 'cs_not_ready',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
// Storage listener: mirror `sp_cs_ready_<tabId>` keys into the in-memory map.
|
|
347
|
+
// Content-isolated.js writes one on load; on navigation, the new content
|
|
348
|
+
// script writes a fresh timestamp, refreshing the readiness window.
|
|
349
|
+
browser.storage.onChanged.addListener((changes, area) => {
|
|
350
|
+
if (area !== 'local') return;
|
|
351
|
+
for (const k of Object.keys(changes)) {
|
|
352
|
+
if (!k.startsWith('sp_cs_ready_')) continue;
|
|
353
|
+
const tabId = parseInt(k.slice('sp_cs_ready_'.length), 10);
|
|
354
|
+
const nv = changes[k].newValue;
|
|
355
|
+
if (!Number.isFinite(tabId)) continue;
|
|
356
|
+
if (nv && typeof nv.ts === 'number') spRecordCsReady(tabId, nv.ts);
|
|
357
|
+
if (nv === undefined || nv === null) spCsReadyMap.delete(tabId);
|
|
358
|
+
}
|
|
359
|
+
});
|
|
360
|
+
// Cleanup readiness state when a tab closes.
|
|
361
|
+
browser.tabs.onRemoved.addListener((tabId) => {
|
|
362
|
+
spCsReadyMap.delete(tabId);
|
|
363
|
+
try { browser.storage.local.remove('sp_cs_ready_' + tabId).catch(() => {}); } catch { /* shrug */ }
|
|
364
|
+
});
|
|
365
|
+
// Rehydrate readiness map from storage on event-page startup. MV3 wakes the
|
|
366
|
+
// event page repeatedly — any heartbeats written while it was asleep would be
|
|
367
|
+
// invisible to the in-memory map without this scan.
|
|
368
|
+
(async () => {
|
|
369
|
+
try {
|
|
370
|
+
const stored = await browser.storage.local.get(null);
|
|
371
|
+
for (const k of Object.keys(stored)) {
|
|
372
|
+
if (!k.startsWith('sp_cs_ready_')) continue;
|
|
373
|
+
const tabId = parseInt(k.slice('sp_cs_ready_'.length), 10);
|
|
374
|
+
const v = stored[k];
|
|
375
|
+
if (!Number.isFinite(tabId)) continue;
|
|
376
|
+
if (v && typeof v.ts === 'number') spRecordCsReady(tabId, v.ts);
|
|
377
|
+
}
|
|
378
|
+
} catch { /* storage transiently unavailable — gate falls back to fast-fail */ }
|
|
379
|
+
})();
|
|
380
|
+
// On navigation completion, prior heartbeat is stale: a new content script
|
|
381
|
+
// will load and post a fresh one. We DON'T evict eagerly here — we let the
|
|
382
|
+
// fresh write overwrite, and the max-age window covers the rare gap.
|
|
383
|
+
|
|
384
|
+
// v0.1.36 Track A Fix 1 — tolerant URL matcher (inlined from
|
|
385
|
+
// extension/lib/tab-url-matcher.js; tested in test/unit/extension/
|
|
386
|
+
// tab-url-matcher.test.ts). MV3 background can't import ES modules, so the
|
|
387
|
+
// implementation is duplicated here. Keep behaviour in sync with the lib.
|
|
388
|
+
const SP_TRACKING_PARAM_PREFIXES = ['utm_'];
|
|
389
|
+
const SP_TRACKING_PARAM_EXACT = new Set([
|
|
390
|
+
'gclid', 'fbclid', 'msclkid', 'mc_eid', 'mc_cid',
|
|
391
|
+
'ref', 'referrer', 'source', 'campaign',
|
|
392
|
+
'_ga', '_gl', 'igshid', 'yclid', 'twclid', 'dclid',
|
|
393
|
+
]);
|
|
394
|
+
function spStripTrailingSlash(s) { return s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s; }
|
|
395
|
+
function spNormalizeForMatch(url) {
|
|
396
|
+
if (typeof url !== 'string' || url.length === 0) return url || '';
|
|
397
|
+
let u;
|
|
398
|
+
try { u = new URL(url); } catch { return spStripTrailingSlash(url); }
|
|
399
|
+
const scheme = u.protocol.toLowerCase();
|
|
400
|
+
let host = u.hostname.toLowerCase();
|
|
401
|
+
if (host.startsWith('www.')) host = host.slice(4);
|
|
402
|
+
const params = new URLSearchParams();
|
|
403
|
+
for (const [k, v] of u.searchParams) {
|
|
404
|
+
const lk = k.toLowerCase();
|
|
405
|
+
if (SP_TRACKING_PARAM_EXACT.has(lk)) continue;
|
|
406
|
+
if (SP_TRACKING_PARAM_PREFIXES.some((p) => lk.startsWith(p))) continue;
|
|
407
|
+
params.append(k, v);
|
|
408
|
+
}
|
|
409
|
+
const queryStr = params.toString();
|
|
410
|
+
const port = u.port ? ':' + u.port : '';
|
|
411
|
+
const path = spStripTrailingSlash(u.pathname || '/');
|
|
412
|
+
return `${scheme}//${host}${port}${path}${queryStr ? '?' + queryStr : ''}`;
|
|
413
|
+
}
|
|
414
|
+
function spOriginAndPath(url) {
|
|
415
|
+
try {
|
|
416
|
+
const u = new URL(url);
|
|
417
|
+
let host = u.hostname.toLowerCase();
|
|
418
|
+
if (host.startsWith('www.')) host = host.slice(4);
|
|
419
|
+
return {
|
|
420
|
+
origin: `${u.protocol.toLowerCase()}//${host}${u.port ? ':' + u.port : ''}`,
|
|
421
|
+
path: spStripTrailingSlash(u.pathname || '/'),
|
|
422
|
+
};
|
|
423
|
+
} catch { return null; }
|
|
424
|
+
}
|
|
425
|
+
function spPathIsPrefix(requestedPath, candidatePath) {
|
|
426
|
+
if (candidatePath === requestedPath) return true;
|
|
427
|
+
if (!candidatePath.startsWith(requestedPath)) return false;
|
|
428
|
+
return candidatePath.charAt(requestedPath.length) === '/';
|
|
429
|
+
}
|
|
430
|
+
/** Returns the matched candidate's id (whatever the caller passes in `id`),
|
|
431
|
+
* or null if no tier matches. Candidates: Array<{id, url}>. */
|
|
432
|
+
function spMatchTabUrl(requestedUrl, candidates) {
|
|
433
|
+
if (typeof requestedUrl !== 'string' || requestedUrl.length === 0) return null;
|
|
434
|
+
if (!Array.isArray(candidates) || candidates.length === 0) return null;
|
|
435
|
+
// Tier 0 — exact (trailing-slash tolerant).
|
|
436
|
+
const targetExact = spStripTrailingSlash(requestedUrl);
|
|
437
|
+
for (const c of candidates) {
|
|
438
|
+
if (spStripTrailingSlash(c.url || '') === targetExact) return c.id;
|
|
439
|
+
}
|
|
440
|
+
// Tier 1 — normalized (ambiguity guard, F1.1). First-match-wins routed
|
|
441
|
+
// commands into stale or dead tabs when two candidates normalize-identically
|
|
442
|
+
// (live SPA-drifted tab + stale closed-tab leftover). Mirror Tier 2's
|
|
443
|
+
// ambiguity contract: return id only when exactly one candidate matches.
|
|
444
|
+
const targetNorm = spNormalizeForMatch(requestedUrl);
|
|
445
|
+
let tier1Id = null;
|
|
446
|
+
let tier1Count = 0;
|
|
447
|
+
for (const c of candidates) {
|
|
448
|
+
if (spNormalizeForMatch(c.url || '') === targetNorm) {
|
|
449
|
+
tier1Id = c.id;
|
|
450
|
+
tier1Count += 1;
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
if (tier1Count === 1) return tier1Id;
|
|
454
|
+
// Tier 2 — origin + path-prefix (longest unambiguous).
|
|
455
|
+
const reqOriginPath = spOriginAndPath(requestedUrl);
|
|
456
|
+
if (!reqOriginPath) return null;
|
|
457
|
+
let bestId = null;
|
|
458
|
+
let bestLen = -1;
|
|
459
|
+
let bestCount = 0;
|
|
460
|
+
for (const c of candidates) {
|
|
461
|
+
const cop = spOriginAndPath(c.url || '');
|
|
462
|
+
if (!cop) continue;
|
|
463
|
+
if (cop.origin !== reqOriginPath.origin) continue;
|
|
464
|
+
if (!spPathIsPrefix(reqOriginPath.path, cop.path)) continue;
|
|
465
|
+
if (cop.path.length > bestLen) {
|
|
466
|
+
bestLen = cop.path.length; bestId = c.id; bestCount = 1;
|
|
467
|
+
} else if (cop.path.length === bestLen) {
|
|
468
|
+
bestCount += 1;
|
|
469
|
+
}
|
|
470
|
+
}
|
|
471
|
+
return bestId !== null && bestCount === 1 ? bestId : null;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// v0.1.36 reviewer F1.2 — session-scoped candidate filter. Pre-filters the
|
|
475
|
+
// tab list to only those belonging to the originating MCP session's window
|
|
476
|
+
// BEFORE the URL matcher fires, so cross-session tabs cannot be matched.
|
|
477
|
+
// Logic mirrors extension/lib/session-filter.js (tested in
|
|
478
|
+
// test/unit/extension/session-filter.test.ts). Inlined because MV3
|
|
479
|
+
// background can't import ES modules.
|
|
480
|
+
//
|
|
481
|
+
// 2026-05-18 evening rework: signature changed from `sessionWindowId`
|
|
482
|
+
// (AppleScript int — wrong namespace) to `sessionDashboardUrl` (stable
|
|
483
|
+
// string identifier). The Map sessionDashboardUrlToWindowId is populated
|
|
484
|
+
// by tabs.onUpdated / onCreated above whenever a tab loads a URL matching
|
|
485
|
+
// SESSION_DASHBOARD_URL_PREFIX. Filtering happens in the WebExtension
|
|
486
|
+
// API's windowId namespace where the cache entries also live.
|
|
487
|
+
function spFilterBySession(candidates, sessionDashboardUrl) {
|
|
488
|
+
if (sessionDashboardUrl === undefined || sessionDashboardUrl === null) {
|
|
489
|
+
return candidates;
|
|
490
|
+
}
|
|
491
|
+
const wid = sessionDashboardUrlToWindowId.get(sessionDashboardUrl);
|
|
492
|
+
if (wid === undefined) {
|
|
493
|
+
// Startup race or unknown session — fail OPEN (per spec). The TS-side
|
|
494
|
+
// TabOwnershipRegistry still enforces per-session isolation by URL.
|
|
495
|
+
return candidates;
|
|
496
|
+
}
|
|
497
|
+
return candidates.filter((c) => c.windowId === wid);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
async function findTargetTab(tabUrl, opts) {
|
|
501
|
+
const sessionDashboardUrl = (opts && opts.sessionDashboardUrl !== undefined)
|
|
502
|
+
? opts.sessionDashboardUrl
|
|
503
|
+
: undefined;
|
|
504
|
+
if (tabUrl) {
|
|
243
505
|
// Test-only escape hatch: if `__sp_test_skip_tabs_query__` is set in
|
|
244
506
|
// storage, the tabs.query primary path is skipped. Used by e2e tests to
|
|
245
507
|
// simulate Safari's alarm-wake context where tabs.query({}) returns [].
|
|
@@ -249,22 +511,32 @@ async function findTargetTab(tabUrl) {
|
|
|
249
511
|
|
|
250
512
|
|
|
251
513
|
if (!skipTabsQuery) {
|
|
252
|
-
// Primary: browser.tabs.query
|
|
514
|
+
// Primary: browser.tabs.query, run through the 3-tier matcher so SPA
|
|
515
|
+
// URL drift / www-prefix / tracking-params don't trigger TAB_NOT_FOUND.
|
|
253
516
|
const all = await browser.tabs.query({});
|
|
254
517
|
if (all.length > 0) {
|
|
255
|
-
const
|
|
256
|
-
|
|
518
|
+
const liveCandidates = all.map((t) => ({ id: t.id, url: t.url || '', windowId: t.windowId }));
|
|
519
|
+
const sessionScoped = spFilterBySession(liveCandidates, sessionDashboardUrl);
|
|
520
|
+
const matchedId = spMatchTabUrl(tabUrl, sessionScoped);
|
|
521
|
+
if (matchedId != null) {
|
|
522
|
+
const t = all.find((x) => x.id === matchedId);
|
|
523
|
+
if (t) return t;
|
|
524
|
+
}
|
|
257
525
|
}
|
|
258
526
|
}
|
|
259
527
|
|
|
260
528
|
// Fallback: persistent tab cache (works when tabs.query returns [] in
|
|
261
529
|
// alarm-triggered wake context — Safari event page lifecycle limitation).
|
|
262
530
|
if (tabCacheMap.size > 0) {
|
|
531
|
+
const cacheList = [];
|
|
263
532
|
for (const [tabId, info] of tabCacheMap) {
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
533
|
+
cacheList.push({ id: tabId, url: info.url || '', windowId: info.windowId });
|
|
534
|
+
}
|
|
535
|
+
const sessionScoped = spFilterBySession(cacheList, sessionDashboardUrl);
|
|
536
|
+
const matchedId = spMatchTabUrl(tabUrl, sessionScoped);
|
|
537
|
+
if (matchedId != null) {
|
|
538
|
+
const info = tabCacheMap.get(matchedId);
|
|
539
|
+
return { id: matchedId, url: info.url, title: info.title };
|
|
268
540
|
}
|
|
269
541
|
}
|
|
270
542
|
|
|
@@ -294,13 +566,46 @@ async function executeCommand(cmd) {
|
|
|
294
566
|
return result;
|
|
295
567
|
}
|
|
296
568
|
|
|
297
|
-
|
|
569
|
+
// F1.2: scope candidates to the originating MCP session's Safari window
|
|
570
|
+
// when the command carries a sessionDashboardUrl. Commands without one
|
|
571
|
+
// (legacy callers, health probes, startup-race) keep pre-F1.2
|
|
572
|
+
// cross-session behaviour — see spFilterBySession header above.
|
|
573
|
+
const tab = await findTargetTab(cmd.tabUrl, { sessionDashboardUrl: cmd.sessionDashboardUrl });
|
|
298
574
|
if (!tab || tab.id == null) {
|
|
299
575
|
// T27: structured error so the daemon's ExtensionBridge.handleResult
|
|
300
576
|
// lifts `name` into StructuredError.code. The TS-side ExtensionEngine
|
|
301
577
|
// round-trips that as the error code, surfacing TAB_NOT_FOUND to MCP.
|
|
578
|
+
//
|
|
579
|
+
// v0.1.36 Fix 1: enrich the error with the closest same-origin
|
|
580
|
+
// candidate URL so the agent can update its stored tabUrl on retry.
|
|
581
|
+
// (Tier 2 matching already covers most drift cases; this branch only
|
|
582
|
+
// fires when even the path-prefix tier missed — e.g. agent's URL is
|
|
583
|
+
// on origin X but the only X-origin tab is at an unrelated path.)
|
|
584
|
+
let hint = '';
|
|
585
|
+
if (cmd.tabUrl) {
|
|
586
|
+
try {
|
|
587
|
+
const u = new URL(cmd.tabUrl);
|
|
588
|
+
const reqOrigin = u.protocol + '//' + u.hostname.replace(/^www\./, '');
|
|
589
|
+
const seen = new Set();
|
|
590
|
+
const sameOriginUrls = [];
|
|
591
|
+
for (const [, info] of tabCacheMap) {
|
|
592
|
+
if (!info.url) continue;
|
|
593
|
+
try {
|
|
594
|
+
const cu = new URL(info.url);
|
|
595
|
+
const co = cu.protocol + '//' + cu.hostname.replace(/^www\./, '');
|
|
596
|
+
if (co === reqOrigin && !seen.has(info.url)) {
|
|
597
|
+
seen.add(info.url);
|
|
598
|
+
sameOriginUrls.push(info.url);
|
|
599
|
+
}
|
|
600
|
+
} catch { /* skip unparsable */ }
|
|
601
|
+
}
|
|
602
|
+
if (sameOriginUrls.length > 0) {
|
|
603
|
+
hint = ` Same-origin tabs in cache: ${sameOriginUrls.slice(0, 3).join(', ')}. Update tabUrl in subsequent calls.`;
|
|
604
|
+
}
|
|
605
|
+
} catch { /* unparsable requested URL — no hint */ }
|
|
606
|
+
}
|
|
302
607
|
const error = cmd.tabUrl
|
|
303
|
-
? { name: 'TAB_NOT_FOUND', message: `No agent-owned tab matches url="${cmd.tabUrl}" (extension cache miss)` }
|
|
608
|
+
? { name: 'TAB_NOT_FOUND', message: `No agent-owned tab matches url="${cmd.tabUrl}" (extension cache miss).${hint}` }
|
|
304
609
|
: { message: `No target tab for url="${cmd.tabUrl}"` };
|
|
305
610
|
const result = { ok: false, error };
|
|
306
611
|
await updatePendingEntry(commandId, { status: 'completed', result });
|
|
@@ -723,7 +1028,16 @@ async function executeCommand(cmd) {
|
|
|
723
1028
|
const cmdKey = 'sp_cmd_' + commandId;
|
|
724
1029
|
const resultKey = 'sp_result_' + commandId;
|
|
725
1030
|
const isFrameTargeted = cmd.frameId != null && cmd.frameId !== 0;
|
|
726
|
-
|
|
1031
|
+
// v0.1.36 Fix 3 (initial-soft) — gate timeout on content-script readiness.
|
|
1032
|
+
// Initial shipping behaviour: track heartbeats for telemetry but do NOT
|
|
1033
|
+
// fast-fail. The in-memory readiness map is wiped whenever Safari restarts
|
|
1034
|
+
// the MV3 event page, which falsely flags long-lived tabs as not-ready.
|
|
1035
|
+
// The tighter fast-fail behaviour will return in v0.1.37 once heartbeat
|
|
1036
|
+
// rehydration from storage is robust.
|
|
1037
|
+
const baseTimeout = isFrameTargeted ? 10000 : 30000;
|
|
1038
|
+
const isCsReadyNow = spIsCsReady(tab.id, Date.now());
|
|
1039
|
+
const TIMEOUT_MS = baseTimeout;
|
|
1040
|
+
const timeoutReason = isCsReadyNow ? 'cs_ready' : 'cs_not_ready_observed';
|
|
727
1041
|
const storageCmd = {
|
|
728
1042
|
commandId,
|
|
729
1043
|
tabId: tab.id,
|
|
@@ -750,10 +1064,22 @@ async function executeCommand(cmd) {
|
|
|
750
1064
|
const resultTimeout = setTimeout(() => {
|
|
751
1065
|
clearInterval(keepAlive);
|
|
752
1066
|
browser.storage.onChanged.removeListener(resultListener);
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
1067
|
+
// v0.1.36 Fix 3 — emit CONTENT_SCRIPT_NOT_READY when the timeout was
|
|
1068
|
+
// gated short because no recent heartbeat existed. This is a recoverable
|
|
1069
|
+
// error: agent should call safari_wait_for or safari_navigate, then retry.
|
|
1070
|
+
let errorCode;
|
|
1071
|
+
let errorMessage;
|
|
1072
|
+
if (isFrameTargeted) {
|
|
1073
|
+
errorCode = 'FRAME_UNREACHABLE';
|
|
1074
|
+
errorMessage = `Frame ${cmd.frameId} unreachable — content script did not respond within ${TIMEOUT_MS}ms (sandbox/CSP/injection failure?)`;
|
|
1075
|
+
} else if (timeoutReason === 'cs_not_ready_observed') {
|
|
1076
|
+
// Heartbeat absent at decision time AND full timeout elapsed.
|
|
1077
|
+
errorCode = 'CONTENT_SCRIPT_NOT_READY';
|
|
1078
|
+
errorMessage = `Content script did not respond within ${TIMEOUT_MS}ms; no readiness heartbeat observed for this tab. Page may still be loading; call safari_wait_for with selector="body" before retrying.`;
|
|
1079
|
+
} else {
|
|
1080
|
+
errorCode = 'STORAGE_BUS_TIMEOUT';
|
|
1081
|
+
errorMessage = `Storage bus timeout (${TIMEOUT_MS}ms) — content script registered but did not respond in time`;
|
|
1082
|
+
}
|
|
757
1083
|
resultResolver({ ok: false, error: { name: errorCode, message: errorMessage } });
|
|
758
1084
|
}, TIMEOUT_MS);
|
|
759
1085
|
|