safari-mcp
Version:
Safari browser automation for AI agents — native macOS, zero Chrome overhead. 98 tools via AppleScript + JavaScript.
84 lines (79 loc) • 3.66 kB
JavaScript
// ownership-match.js — pure tab-ownership matching + TTL pruning for safari-mcp.
// Extracted from index.js so the safety-critical semantics are unit-testable
// (test/ownership-match.test.mjs locks them):
// • exact match, then normalized match (query / fragment / trailing slashes ignored)
// • same-origin path-prefix match REQUIRES a path-segment boundary —
// owning /org must never own /org-evil
// • the blank-tab sentinel (not a parseable URL) never matches a real page
// Keep this file dependency-free.
/**
* True when the URL carries an mcp-tab receipt this server minted (12+ opaque chars).
* A receipt is effectively unguessable, so such an entry can never collide with a page
* the user opened themselves — which is why it may outlive the short default TTL.
* Kept in sync with the switch_tab/close_tab receipt check in index.js.
*/
export function hasDurableReceipt(url) {
return /(?:[?#&])mcp-tab=[A-Za-z0-9_-]{12,}(?:[&#]|$)/.test(url || "");
}
/** Strip query, fragment and trailing slashes. */
export function normalizeURL(u) {
return u.split("?")[0].split("#")[0].replace(/\/+$/, "");
}
/**
* Find which owned entry matches `url`.
* Returns the matching owned entry (so the caller can touch its TTL timestamp),
* or null when the URL is not owned.
* @param {string} url
* @param {Iterable<string>} ownedUrls
* @returns {string|null}
*/
export function findOwnedMatch(url, ownedUrls) {
if (!url) return null;
const ownedSet = ownedUrls instanceof Set ? ownedUrls : new Set(ownedUrls);
if (ownedSet.has(url)) return url;
// Match ignoring query params / fragments / trailing slashes (the URL may change
// slightly after load).
const urlBase = normalizeURL(url);
for (const owned of ownedSet) {
if (urlBase === normalizeURL(owned)) return owned;
}
// Same-origin redirect: a tab that navigated from an owned URL to a deeper path
// on the same origin (e.g. /login/device → /login/device/select_account) is still
// ours. The "+ '/'" boundary is load-bearing: owning /org must NOT own /org-evil.
// (A broader "own anything on the origin" rule used to exist and defeated
// tab-safety entirely — see CHANGELOG v2.12.0.)
try {
const urlOrigin = new URL(url).origin;
for (const owned of ownedSet) {
try {
if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalizeURL(owned) + "/")) return owned;
} catch { /* owned entry isn't a parseable URL (e.g. the blank-tab sentinel) */ }
}
} catch { /* url isn't parseable — no origin-based matching possible */ }
return null;
}
/**
* Remove entries whose timestamp is older than ttlMs from BOTH structures, in place.
* @param {Set<string>} ownedUrls
* @param {Map<string, number>} timestamps
* @param {number} ttlMs
* @param {number} [now]
* @returns {boolean} whether anything was removed
*/
export function pruneExpired(ownedUrls, timestamps, ttlMs, now = Date.now(), receiptTtlMs = ttlMs) {
const cutoff = now - ttlMs;
const receiptCutoff = now - receiptTtlMs;
let changed = false;
for (const [url, ts] of timestamps) {
// A receipt-bearing entry is unguessable, so it cannot start matching a user's tab as
// it ages. Expiring it after 30min only broke long tasks: a tab opened at the start of
// an hour-long job became un-switchable halfway through, while still sitting there
// carrying our own marker. Plain URLs keep the short TTL — they CAN collide.
if (ts <= (hasDurableReceipt(url) ? receiptCutoff : cutoff)) {
ownedUrls.delete(url);
timestamps.delete(url);
changed = true;
}
}
return changed;
}