safari-mcp 2.11.8 โ†’ 2.12.0

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 CHANGED
@@ -46,7 +46,7 @@ Your AI drives the **Safari you're already logged into** โ€” Gmail, GitHub, Ahre
46
46
 
47
47
  Native WebKit. ~60% less CPU. Background operation. 80 tools. One `npx` command. macOS only.
48
48
 
49
- > ๐Ÿ“ฐ **Featured on HackerNoon:** [I Had to Reverse-Engineer React, Shadow DOM, and CSP to Automate Safari Without Chrome](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
49
+ > ๐Ÿ“ฐ **Featured on freeCodeCamp:** [How to Connect Your AI Coding Agent to a Browser on macOS](https://www.freecodecamp.org/news/how-to-connect-your-ai-coding-agent-to-a-browser-on-macos/) ยท [HackerNoon: Reverse-Engineering React, Shadow DOM, and CSP](https://hackernoon.com/i-had-to-reverse-engineer-react-shadow-dom-and-csp-to-automate-safari-without-chrome)
50
50
 
51
51
  ---
52
52
 
@@ -145,14 +145,18 @@ async function pollForCommands() {
145
145
  });
146
146
  clearTimeout(timeout);
147
147
  if (res.status === 200) {
148
- const msg = await res.json();
149
- await executeAndReply(msg);
148
+ // A single malformed/truncated body must NOT tear down the poll loop โ€” a bad
149
+ // packet used to throw SyntaxError here, fall through to "server gone", and
150
+ // trigger a multi-second reconnect backoff. Skip the bad packet and keep polling.
151
+ const msg = await res.json().catch(() => null);
152
+ if (msg) await executeAndReply(msg);
150
153
  }
151
154
  // 204 = no command, loop immediately to keep connection active
152
155
  } catch (err) {
153
156
  if (err.name === "AbortError") {
154
- // If still connected, this was a safety timeout โ€” reconnect
155
- if (isConnected && _enabled) { pollForCommands(); return; }
157
+ // 90s safety-timeout abort while still connected โ€” continue the loop in place.
158
+ // (Re-calling pollForCommands() here risked two overlapping loops posting dup results.)
159
+ if (isConnected && _enabled) continue;
156
160
  return; // Intentional abort (disable/new connect)
157
161
  }
158
162
  // Server gone โ€” reconnect via shared scheduler (prevents duplicate timers)
@@ -241,7 +245,12 @@ async function handleCommand(type, payload) {
241
245
  return true;
242
246
  }, [], tabId).catch(() => true);
243
247
 
244
- if (!hasContent) {
248
+ // Don't hard-reload OAuth/redirect callback pages: a sparse-but-valid callback
249
+ // (code=/token=/state= in the URL) would be reloaded, dropping its POST data and
250
+ // breaking the auth flow. Only reload normal http(s) pages without auth params.
251
+ const reloadable = /^https?:/i.test(payload.url || "") &&
252
+ !/[?&#](code|token|access_token|id_token|state|session_state)=/i.test(payload.url || "");
253
+ if (!hasContent && reloadable) {
245
254
  // Try hard reload once
246
255
  await browser.tabs.reload(tabId, { bypassCache: true });
247
256
  await waitForTabLoad(tabId, 15000);
@@ -459,12 +468,17 @@ async function handleCommand(type, payload) {
459
468
  return null;
460
469
  }
461
470
  const m = refs[refId];
471
+ // Escape attribute values + guard the query โ€” a page-controlled aria-label/name/
472
+ // placeholder containing a double-quote would otherwise build an invalid selector
473
+ // and throw a DOMException that surfaces as a misleading "element not found".
474
+ const dqAttr = (sel) => { try { return dq(sel); } catch (_e) { return null; } };
475
+ const aq = (v) => String(v).replace(/["\\]/g, "\\$&");
462
476
  if (m.id) { el = document.getElementById(m.id); if (el) return el; }
463
- if (m.nameAttr) { el = dq('[name="' + m.nameAttr + '"]'); if (el) return el; }
464
- if (m.al) { el = dq('[aria-label="' + m.al + '"]'); if (el) return el; }
465
- if (m.ph) { el = dq('[placeholder="' + m.ph + '"]'); if (el) return el; }
477
+ if (m.nameAttr) { el = dqAttr('[name="' + aq(m.nameAttr) + '"]'); if (el) return el; }
478
+ if (m.al) { el = dqAttr('[aria-label="' + aq(m.al) + '"]'); if (el) return el; }
479
+ if (m.ph) { el = dqAttr('[placeholder="' + aq(m.ph) + '"]'); if (el) return el; }
466
480
  // Type attribute fallback (input type="email", type="url", etc.)
467
- if (m.inputType) { el = dq(m.tag.toLowerCase() + '[type="' + m.inputType + '"]'); if (el) return el; }
481
+ if (m.inputType) { el = dqAttr(m.tag.toLowerCase() + '[type="' + aq(m.inputType) + '"]'); if (el) return el; }
468
482
  // Coordinate fallback โ€” scroll into view then hit-test
469
483
  if (m.cx !== undefined && m.cy !== undefined) {
470
484
  window.scrollTo(window.scrollX, Math.max(0, m.cy - window.innerHeight / 2));
@@ -1233,27 +1247,31 @@ async function handleCommand(type, payload) {
1233
1247
  attrs = ` ref="${refId}"`;
1234
1248
  }
1235
1249
 
1250
+ // Escape page-controlled attribute values: a crafted aria-label/title/value with
1251
+ // a double-quote could otherwise break the pseudo-XML snapshot and inject a fake
1252
+ // ref=""/role="" that steers the agent into clicking the wrong element.
1253
+ const esc = (s) => String(s).replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
1236
1254
  const role = el.getAttribute("role");
1237
- if (role) attrs += ` role="${role}"`;
1238
- if (el.id) attrs += ` id="${el.id}"`;
1255
+ if (role) attrs += ` role="${esc(role)}"`;
1256
+ if (el.id) attrs += ` id="${esc(el.id)}"`;
1239
1257
  const al = el.getAttribute("aria-label");
1240
- if (al) attrs += ` aria-label="${al}"`;
1258
+ if (al) attrs += ` aria-label="${esc(al)}"`;
1241
1259
  const title = el.getAttribute("title");
1242
- if (title) attrs += ` title="${title.substring(0, 80)}"`;
1260
+ if (title) attrs += ` title="${esc(title.substring(0, 80))}"`;
1243
1261
  if (el.value && ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName)) {
1244
- attrs += ` value="${String(el.value).substring(0, 50)}"`;
1262
+ attrs += ` value="${esc(String(el.value).substring(0, 50))}"`;
1245
1263
  }
1246
- if (el.type && el.tagName === "INPUT") attrs += ` type="${el.type}"`;
1247
- if (el.href && el.tagName === "A") attrs += ` href="${el.href.substring(0, 100)}"`;
1264
+ if (el.type && el.tagName === "INPUT") attrs += ` type="${esc(el.type)}"`;
1265
+ if (el.href && el.tagName === "A") attrs += ` href="${esc(el.href.substring(0, 100))}"`;
1248
1266
  if (el.disabled) attrs += " disabled";
1249
1267
  const ph = el.getAttribute("placeholder");
1250
- if (ph) attrs += ` placeholder="${ph}"`;
1268
+ if (ph) attrs += ` placeholder="${esc(ph)}"`;
1251
1269
  // For interactive elements with no visible text โ€” show alt/aria-describedby hint
1252
- if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${el.alt.substring(0, 80)}"`;
1270
+ if (interactive && el.tagName === "IMG" && el.alt) attrs += ` alt="${esc(el.alt.substring(0, 80))}"`;
1253
1271
  const ariaDesc = el.getAttribute("aria-describedby");
1254
1272
  if (ariaDesc) {
1255
1273
  const descEl = document.getElementById(ariaDesc);
1256
- if (descEl) attrs += ` described="${descEl.textContent.trim().substring(0, 80)}"`;
1274
+ if (descEl) attrs += ` described="${esc(descEl.textContent.trim().substring(0, 80))}"`;
1257
1275
  }
1258
1276
 
1259
1277
  // Self-closing for some tags
@@ -28,10 +28,12 @@ if (!window.__mcpShadowPatched) {
28
28
 
29
29
  if (!window.__mcpTrustedPolicy && window.trustedTypes && typeof window.trustedTypes.createPolicy === "function") {
30
30
  try {
31
+ // Register ONLY createScript โ€” the single capability the bridge uses (background.js
32
+ // evaluate sets script.textContent via createScript). A world-accessible pass-through
33
+ // createHTML would let the page's own scripts wrap arbitrary HTML as trusted, defeating
34
+ // its Trusted-Types protection; createScriptURL is likewise unused. Least privilege.
31
35
  window.__mcpTrustedPolicy = window.trustedTypes.createPolicy("mcpBridge", {
32
- createScript: function (s) { return s; },
33
- createScriptURL: function (s) { return s; },
34
- createHTML: function (s) { return s; }
36
+ createScript: function (s) { return s; }
35
37
  });
36
38
  } catch (_e) {
37
39
  // Page already restricts policies โ€” rare since content script runs at document_start
@@ -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.1",
5
+ "version": "2.10.2",
6
6
  "icons": {
7
7
  "48": "images/icon-48.png",
8
8
  "96": "images/icon-96.png",
package/index.js CHANGED
@@ -96,13 +96,13 @@ function _isURLOwned(url) {
96
96
  if (new URL(owned).origin === urlOrigin && urlBase.startsWith(normalize(owned))) return true;
97
97
  } catch {}
98
98
  }
99
- // Broader same-origin check: if we own ANY URL on this origin, treat all same-origin URLs as owned.
100
- // This handles redirects like /article/new โ†’ /article/edit/ID where path prefix doesn't match.
101
- for (const owned of _ownedTabURLs) {
102
- try {
103
- if (new URL(owned).origin === urlOrigin) return true;
104
- } catch {}
105
- }
99
+ // NOTE: a broader "own ANY URL on this origin โ†’ treat ALL same-origin URLs as owned"
100
+ // check used to live here. It defeated tab-safety: once this session opened a single
101
+ // tab on github.com / google.com / etc., EVERY tab on that origin โ€” including the
102
+ // user's own open tabs โ€” counted as ours, so a click/fill could land in the user's tab.
103
+ // Same-origin redirects stay covered by the path-prefix check above plus final-URL
104
+ // tracking in safari_navigate (a redirect that changes the path entirely now needs an
105
+ // explicit safari_navigate, which is the safe default).
106
106
  } catch {}
107
107
  return false;
108
108
  }
@@ -373,7 +373,7 @@ try {
373
373
  // POST /result โ€” extension sends command result
374
374
  if (req.method === "POST" && req.url === "/result") {
375
375
  let body = "";
376
- req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
376
+ req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
377
377
  req.on("end", () => {
378
378
  try {
379
379
  const msg = JSON.parse(body);
@@ -414,7 +414,7 @@ try {
414
414
  // POST /verify-profile โ€” extension asks server to check which profile has a nonce tab
415
415
  if (req.method === "POST" && req.url === "/verify-profile") {
416
416
  let body = "";
417
- req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
417
+ req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
418
418
  req.on("end", async () => {
419
419
  try {
420
420
  const { nonce, expectedProfile } = JSON.parse(body);
@@ -482,7 +482,7 @@ try {
482
482
  return;
483
483
  }
484
484
  let body = "";
485
- req.on("data", (chunk) => { body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
485
+ req.on("data", (chunk) => { if (res.headersSent) return; body += chunk; if (body.length > MAX_BODY_SIZE) { res.writeHead(413); res.end("Payload too large"); req.destroy(); } });
486
486
  req.on("end", async () => {
487
487
  try {
488
488
  const { type, payload } = JSON.parse(body);
@@ -547,8 +547,8 @@ async function _checkPrimaryExtension() {
547
547
  } catch {
548
548
  _primaryHasExtension = false;
549
549
  }
550
- // Re-check every 10s
551
- setTimeout(_checkPrimaryExtension, 10000);
550
+ // Re-check every 10s (unref'd โ€” must not keep the Node process alive on its own)
551
+ setTimeout(_checkPrimaryExtension, 10000).unref();
552
552
  }
553
553
 
554
554
  // Send command to primary instance's extension via proxy
@@ -568,7 +568,7 @@ async function _proxyToExtension(type, payload, timeoutMs = 30000) {
568
568
  let _extensionLastPollTime = 0;
569
569
  // Detect stale HTTP connection (no poll in 30s = disconnected)
570
570
  // Only applies to primary instance (extension host) โ€” not proxy mode
571
- setInterval(() => {
571
+ const _staleHttpTimer = setInterval(() => {
572
572
  if (_isExtensionHost && _extensionConnected && !_extensionWs && _extensionLastPollTime > 0) {
573
573
  if (Date.now() - _extensionLastPollTime > 30000) {
574
574
  _extensionConnected = false;
@@ -577,6 +577,7 @@ setInterval(() => {
577
577
  }
578
578
  }
579
579
  }, 5000);
580
+ _staleHttpTimer.unref(); // stale-detection must not keep the Node process alive on its own
580
581
 
581
582
  // ========== SHARED EXTENSION LOGIC ==========
582
583
 
@@ -749,11 +750,12 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
749
750
  }
750
751
  } finally {
751
752
  safari.setFocusGuard(false);
753
+ // Restore focus in finally โ€” even when the operation threw, Safari may have come to
754
+ // the front mid-op and must be sent back, or the user is left staring at the
755
+ // automation window with their keystrokes landing in it.
756
+ await safari.restoreFocusIfStolen(savedApp);
752
757
  }
753
758
 
754
- // Restore focus if Safari stole it
755
- await safari.restoreFocusIfStolen(savedApp);
756
-
757
759
  return result;
758
760
  }
759
761
 
@@ -1478,6 +1480,7 @@ server.tool(
1478
1480
  await extensionOrFallback("switch_tab", { index: resolved.index }, () => safari.switchTab(resolved.index));
1479
1481
  safari.setActiveTabIndex(resolved.index);
1480
1482
  safari.setActiveTabURL(resolved.url);
1483
+ _trackTab(resolved.index, resolved.url); // own the popup, else the next interaction trips the tab-safety guard
1481
1484
  return { content: [{ type: "text", text: `Found new tab: ${resolved.title} (${resolved.url})` }] };
1482
1485
  }
1483
1486
  continue;
@@ -1486,6 +1489,7 @@ server.tool(
1486
1489
  await extensionOrFallback("switch_tab", { index: tab.index }, () => safari.switchTab(tab.index));
1487
1490
  safari.setActiveTabIndex(tab.index);
1488
1491
  safari.setActiveTabURL(tab.url);
1492
+ _trackTab(tab.index, tab.url); // own the new tab, else the next interaction trips the tab-safety guard
1489
1493
  return { content: [{ type: "text", text: `Found new tab: ${tab.title} (${tab.url})` }] };
1490
1494
  }
1491
1495
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "safari-mcp",
3
- "version": "2.11.8",
3
+ "version": "2.12.0",
4
4
  "mcpName": "io.github.achiya-automation/safari-mcp",
5
5
  "description": "Safari browser automation for AI agents โ€” native macOS, zero Chrome overhead. 80 tools via AppleScript + JavaScript.",
6
6
  "type": "module",
package/safari.js CHANGED
@@ -8,7 +8,7 @@ import { promisify } from "node:util";
8
8
  import { tmpdir } from "node:os";
9
9
  import { join, dirname, resolve as resolvePath } from "node:path";
10
10
  import { readFile, writeFile, unlink, appendFile } from "node:fs/promises";
11
- import { readFileSync } from "node:fs";
11
+ import { readFileSync, realpathSync } from "node:fs";
12
12
  import { fileURLToPath } from "node:url";
13
13
  import { randomUUID } from "node:crypto";
14
14
  // Extension bridge is handled by index.js (WebSocket server on port 9223)
@@ -307,13 +307,23 @@ if (SAFARI_PROFILE) {
307
307
 
308
308
  // Initialize profile window at startup (ES module top-level await)
309
309
  if (SAFARI_PROFILE) {
310
- await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
311
- await refreshTargetWindow(true);
312
- if (_targetWindowRef) {
313
- _logProfile(`Startup: Profile "${SAFARI_PROFILE}" โ†’ targeting ${_targetWindowRef}`);
314
- } else {
315
- _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
316
- }
310
+ // Run profile-window detection off the critical path so module init โ€” and
311
+ // therefore the MCP initialize handshake โ€” completes immediately. Tool calls
312
+ // that arrive before this finishes already trigger lazy refresh via
313
+ // getTargetWindowRef(), so correctness is preserved.
314
+ // Why this matters: a blocking `await refreshTargetWindow(true)` here could
315
+ // run >30s when Safari was busy or AppleScript was stalled, tripping Claude
316
+ // Code's 30s MCP timeout and leaving the conversation's tool catalog without
317
+ // safari tools until a new conversation is started.
318
+ (async () => {
319
+ await new Promise(r => setTimeout(r, 50)); // Let helper process initialize
320
+ await refreshTargetWindow(true);
321
+ if (_targetWindowRef) {
322
+ _logProfile(`Startup: Profile "${SAFARI_PROFILE}" โ†’ targeting ${_targetWindowRef}`);
323
+ } else {
324
+ _logProfile(`WARNING: Profile "${SAFARI_PROFILE}" window NOT found at startup`);
325
+ }
326
+ })();
317
327
  }
318
328
 
319
329
  // Detect stale window ID errors and invalidate cache
@@ -1021,7 +1031,9 @@ export async function navigate(url) {
1021
1031
  targetUrl = "https://" + targetUrl;
1022
1032
  }
1023
1033
 
1024
- const safeUrl = targetUrl.replace(/"/g, '\\"');
1034
+ // Escape backslash first, then quotes; strip CR/LF โ€” a newline would break out of
1035
+ // the AppleScript string literal and allow AppleScript injection.
1036
+ const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
1025
1037
  // Resolve tab by URL first (in case indices shifted)
1026
1038
  if (_activeTabURL) await resolveActiveTab();
1027
1039
  _assertNotFallingBackToUserTab('navigate');
@@ -1102,7 +1114,7 @@ export async function navigate(url) {
1102
1114
  try {
1103
1115
  const parsed = JSON.parse(result);
1104
1116
  if (parsed.blocked && url.startsWith("http://")) {
1105
- const httpUrl = url.replace(/"/g, '\\"');
1117
+ const httpUrl = url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
1106
1118
  await osascriptFast(
1107
1119
  `tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
1108
1120
  );
@@ -1151,37 +1163,58 @@ export async function navigate(url) {
1151
1163
  return result;
1152
1164
  }
1153
1165
 
1166
+ // Poll document.readyState from the Node side and return {title,url[,text]} once the
1167
+ // page settles. `do JavaScript` returns immediately and never awaits an async IIFE
1168
+ // (see _evaluateAsync), so any in-page `await` loop is fire-and-forget โ€” page-load
1169
+ // waits MUST be driven from Node. Shared by goBack/goForward/reload/navigateAndRead.
1170
+ async function _pollReadyAndRead(navIndex, { maxLength } = {}) {
1171
+ const readExpr = maxLength != null
1172
+ ? `JSON.stringify({title:document.title,url:location.href,text:document.body?document.body.innerText.substring(0,${Number(maxLength)}):''})`
1173
+ : `JSON.stringify({title:document.title,url:location.href})`;
1174
+ let result = '{}';
1175
+ for (let poll = 0; poll < 60; poll++) {
1176
+ await new Promise(r => setTimeout(r, poll < 10 ? 200 : 500));
1177
+ try {
1178
+ const state = await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 });
1179
+ if (state === 'complete' || state === 'interactive') {
1180
+ result = await runJS(readExpr, { tabIndex: navIndex, timeout: 5000 });
1181
+ if (state === 'complete') break;
1182
+ if (poll > 10) break; // interactive after ~2s is good enough
1183
+ }
1184
+ } catch { /* page still loading, retry */ }
1185
+ }
1186
+ return result;
1187
+ }
1188
+
1154
1189
  export async function goBack() {
1155
- // Navigate back + smart wait: check immediately, then poll only if loading
1156
- const result = await runJS(
1157
- `(async function(){history.back();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
1158
- { timeout: 10000 }
1159
- );
1190
+ await refreshTargetWindow();
1191
+ const navIndex = _activeTabIndex;
1192
+ // history.back() is synchronous; the page-load wait is polled from Node (see _pollReadyAndRead).
1193
+ await runJS("history.back()", { tabIndex: navIndex, timeout: 5000 });
1194
+ const result = await _pollReadyAndRead(navIndex);
1160
1195
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1161
1196
  return result;
1162
1197
  }
1163
1198
 
1164
1199
  export async function goForward() {
1165
- const result = await runJS(
1166
- `(async function(){history.forward();await new Promise(function(r){setTimeout(r,50)});if(document.readyState!=='complete'){for(var i=0;i<30;i++){await new Promise(function(r){setTimeout(r,150)});if(document.readyState==='complete')break;}}return JSON.stringify({title:document.title,url:location.href});})()`,
1167
- { timeout: 10000 }
1168
- );
1200
+ await refreshTargetWindow();
1201
+ const navIndex = _activeTabIndex;
1202
+ await runJS("history.forward()", { tabIndex: navIndex, timeout: 5000 });
1203
+ const result = await _pollReadyAndRead(navIndex);
1169
1204
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1170
1205
  return result;
1171
1206
  }
1172
1207
 
1173
1208
  export async function reload(hardReload = false) {
1174
- // Reload destroys JS context โ€” must wait separately then re-query
1175
- await runJS(hardReload ? "location.reload(true)" : "location.reload()");
1176
- await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
1177
- // Poll readyState in a single call
1178
- const result = await runJS(
1179
- `(async function(){for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return JSON.stringify({title:document.title,url:location.href});})()`,
1180
- { timeout: 10000 }
1181
- );
1209
+ await refreshTargetWindow();
1210
+ const navIndex = _activeTabIndex;
1211
+ // Reload destroys JS context โ€” fire it, then poll readyState from Node.
1212
+ await runJS(hardReload ? "location.reload(true)" : "location.reload()", { tabIndex: navIndex });
1213
+ await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start
1214
+ const result = await _pollReadyAndRead(navIndex);
1182
1215
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1183
1216
  // A reload destroys the JS context โ€” re-stamp marker + visibility spoof.
1184
- await _stampTab(_activeTabIndex);
1217
+ await _stampTab(navIndex);
1185
1218
  return result;
1186
1219
  }
1187
1220
 
@@ -2275,9 +2308,10 @@ export async function pressKey({ key, modifiers = [] }) {
2275
2308
  // real paste pipeline.
2276
2309
  async function _nativeTypeViaClipboard(text) {
2277
2310
  await _acquireClipboardLock();
2311
+ let savedClipboard;
2278
2312
  try {
2279
2313
  // Save current clipboard
2280
- const savedClipboard = await _saveClipboard();
2314
+ savedClipboard = await _saveClipboard();
2281
2315
 
2282
2316
  // Set clipboard to our text via pipe (safe from shell injection)
2283
2317
  await new Promise((resolve, reject) => {
@@ -2292,23 +2326,19 @@ async function _nativeTypeViaClipboard(text) {
2292
2326
  // MUST have windowId โ€” global CGEvent (windowId=0) would steal focus and move mouse
2293
2327
  const geo = await _getSafariWindowGeometry();
2294
2328
  if (!geo.windowId) {
2295
- // Can't get window ID โ€” restore clipboard and let catch block release lock
2296
- await _restoreClipboard(savedClipboard);
2297
2329
  throw new Error("Cannot native-paste without Safari window ID โ€” would steal focus");
2298
2330
  }
2299
2331
  // keyCode 9 = V key, flags: ["cmd"]
2300
2332
  await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
2301
2333
 
2302
- // Wait for paste to settle, then restore clipboard immediately
2334
+ // Wait for paste to settle
2303
2335
  await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
2304
- await _restoreClipboard(savedClipboard);
2305
- _releaseClipboardLock();
2306
-
2307
2336
  return `Typed ${text.length} chars (native paste into iframe)`;
2308
- } catch (err) {
2309
- // Ensure lock is released on ANY error (including the windowId check above)
2337
+ } finally {
2338
+ // ALWAYS restore the user's clipboard + release the lock โ€” even if the daemon died
2339
+ // mid-paste โ€” so the user never silently inherits the tool's pasted text.
2340
+ if (savedClipboard !== undefined) await _restoreClipboard(savedClipboard).catch(() => {});
2310
2341
  if (_clipboardLocked) _releaseClipboardLock();
2311
- throw err;
2312
2342
  }
2313
2343
  }
2314
2344
 
@@ -2665,9 +2695,12 @@ export async function screenshot({ fullPage = false } = {}) {
2665
2695
  const skipScreencapture = isBackgroundTab;
2666
2696
 
2667
2697
  // Try screencapture โ€” use osascript's do shell script to bypass VS Code permission issue
2668
- const windowId = !skipScreencapture ? await osascript(
2698
+ const windowIdRaw = !skipScreencapture ? await osascript(
2669
2699
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
2670
2700
  ).catch(() => null) : null;
2701
+ // Window IDs are OS-assigned integers โ€” reject anything non-numeric before it reaches
2702
+ // `do shell script "screencapture -l<id>"` (defense-in-depth against odd AppleScript stdout).
2703
+ const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
2671
2704
 
2672
2705
  // On macOS Tahoe, screencapture -l may briefly steal focus.
2673
2706
  // Save frontmost app via daemon so we can hide Safari if it stole focus.
@@ -2765,7 +2798,10 @@ export async function screenshot({ fullPage = false } = {}) {
2765
2798
  { timeout: 30000 }
2766
2799
  );
2767
2800
 
2768
- if (dataUrl && dataUrl !== "FALLBACK_TEXT") {
2801
+ // canvas/SVG returns a Promise `do JavaScript` can't await โ†’ guard against the
2802
+ // unsettled "[object Promise]"/empty value and only return a real base64 PNG.
2803
+ const looksBase64 = typeof dataUrl === 'string' && dataUrl.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(dataUrl.slice(0, 120));
2804
+ if (looksBase64) {
2769
2805
  return dataUrl;
2770
2806
  }
2771
2807
 
@@ -2829,25 +2865,32 @@ export async function screenshotElement({ selector }) {
2829
2865
  { timeout: 15000 }
2830
2866
  );
2831
2867
 
2832
- if (result === "SVG_RENDER_FAILED" || result.startsWith("Element")) {
2868
+ // The canvas/SVG path returns a Promise that `do JavaScript` can't await (so it yields
2869
+ // "[object Promise]"/empty), and foreignObject can't render cross-origin images/fonts.
2870
+ // Treat anything that isn't a valid base64 PNG as a render failure and fall through to
2871
+ // the reliable screencapture+crop path.
2872
+ const looksBase64 = typeof result === 'string' && result.length > 100 && /^[A-Za-z0-9+/]+={0,2}$/.test(result.slice(0, 120));
2873
+ if (!looksBase64) {
2833
2874
  // Fallback: use screencapture + crop
2834
2875
  const tmpFile = join(tmpdir(), `safari-el-${Date.now()}.png`);
2876
+ let cropFile = null;
2835
2877
  try {
2836
- const windowId = await osascript(`tell application "Safari" to return id of ${getTargetWindowRef()}`).catch(() => null);
2878
+ const windowIdRaw = await osascript(`tell application "Safari" to return id of ${getTargetWindowRef()}`).catch(() => null);
2879
+ const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
2837
2880
  if (!windowId) throw new Error("Cannot get Safari window ID");
2838
2881
 
2839
2882
  // Get element bounds relative to screen
2840
2883
  const bounds = await runJS(
2841
2884
  `(function(){var el=document.querySelector('${sel}');if(!el)return '';var r=el.getBoundingClientRect();return JSON.stringify({x:Math.round(r.x),y:Math.round(r.y),w:Math.round(r.width),h:Math.round(r.height)});})()`
2842
2885
  );
2843
- if (!bounds) throw new Error(result);
2886
+ if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
2844
2887
 
2845
2888
  // Full window screenshot then crop with sips
2846
2889
  await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
2847
2890
  const { x, y, w, h } = JSON.parse(bounds);
2848
2891
  // Use sips to crop (macOS built-in)
2849
2892
  const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
2850
- const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
2893
+ cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
2851
2894
  await execFileAsync("sips", [
2852
2895
  "-c", String(h), String(w),
2853
2896
  "--cropOffset", String(y + toolbarHeight), String(x),
@@ -2859,8 +2902,7 @@ export async function screenshotElement({ selector }) {
2859
2902
  if (data.length > 100) return data.toString("base64");
2860
2903
  } catch (e) {
2861
2904
  await unlink(tmpFile).catch(() => {});
2862
- // Also clean cropFile if it was created before the error
2863
- await unlink(tmpFile.replace('safari-el-', 'safari-el-crop-')).catch(() => {});
2905
+ if (cropFile) await unlink(cropFile).catch(() => {}); // captured path โ€” old code rebuilt it with the wrong timestamp and leaked it
2864
2906
  throw new Error(`Element screenshot failed: ${e.message}`);
2865
2907
  }
2866
2908
  }
@@ -2916,7 +2958,7 @@ export async function listTabs() {
2916
2958
 
2917
2959
  export async function newTab(url = "") {
2918
2960
  await refreshTargetWindow();
2919
- const safeUrl = url ? url.replace(/"/g, '\\"') : "";
2961
+ const safeUrl = url ? url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '') : "";
2920
2962
  try {
2921
2963
  if (url) {
2922
2964
  await osascript(`tell application "Safari"\ntell ${getTargetWindowRef()}\nset userTab to current tab\nmake new tab with properties {URL:"${safeUrl}"}\nset current tab to userTab\nend tell\nend tell`);
@@ -3263,11 +3305,11 @@ export async function getCookies() {
3263
3305
 
3264
3306
  export async function getLocalStorage({ key }) {
3265
3307
  if (key) {
3266
- const safeKey = key.replace(/'/g, "\\'");
3308
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3267
3309
  return runJS(`localStorage.getItem('${safeKey}')`);
3268
3310
  }
3269
3311
  return runJS(
3270
- "JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){return[k,localStorage.getItem(k).substring(0,200)]})))"
3312
+ "JSON.stringify(Object.fromEntries(Object.keys(localStorage).map(function(k){var v=localStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
3271
3313
  );
3272
3314
  }
3273
3315
 
@@ -3322,15 +3364,23 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
3322
3364
  // Prevent reading sensitive system files via upload/paste tools
3323
3365
  function _validateFilePath(filePath) {
3324
3366
  const resolved = resolvePath(filePath);
3325
- if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
3367
+ // resolve() already collapses "..", so checking the RESOLVED path for it is a dead no-op.
3368
+ // Reject traversal sequences in the RAW input instead; the allowlist below is the real guard.
3369
+ if (/(^|[/\\])\.\.([/\\]|$)/.test(filePath)) throw new Error("Path traversal not allowed: " + filePath);
3326
3370
  const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
3327
- const lower = resolved.toLowerCase();
3328
- for (const b of blocked) {
3329
- if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
3330
- }
3331
- // Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
3332
- if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
3333
- throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
3371
+ // Resolve symlinks when the target exists, so a symlink under /Users/ pointing at /etc
3372
+ // can't slip past the allowlist. Falls back to the lexical path when the file doesn't
3373
+ // exist yet (e.g. savePDF's output path).
3374
+ let real = resolved;
3375
+ try { real = realpathSync(resolved); } catch { /* not created yet โ€” keep lexical path */ }
3376
+ for (const checkPath of new Set([resolved, real])) {
3377
+ const lower = checkPath.toLowerCase();
3378
+ for (const b of blocked) {
3379
+ if (lower.includes(b)) throw new Error("Blocked: sensitive path " + filePath);
3380
+ }
3381
+ if (!checkPath.startsWith('/Users/') && !checkPath.startsWith('/tmp/') && !checkPath.startsWith('/var/folders/') && !checkPath.startsWith('/private/tmp/')) {
3382
+ throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
3383
+ }
3334
3384
  }
3335
3385
  }
3336
3386
 
@@ -3412,9 +3462,11 @@ export async function uploadFile({ selector, filePath }) {
3412
3462
  const mime = mimeMap[ext] || "application/octet-stream";
3413
3463
  const safeName = fileName.replace(/'/g, "\\'");
3414
3464
 
3415
- // Send to Safari via runJSLarge (handles files >260KB via temp file)
3465
+ // Send to Safari via runJSLarge (handles files >260KB via temp file).
3466
+ // Fully synchronous IIFE โ€” `do JavaScript` can't await a Promise, so an `async`
3467
+ // wrapper would hand back "[object Promise]" before the body settled.
3416
3468
  const result = await runJSLarge(
3417
- `(async function(){
3469
+ `(function(){
3418
3470
  // Deep query: main document โ†’ shadow DOM โ†’ iframes
3419
3471
  function deepQuery(sel) {
3420
3472
  var el = document.querySelector(sel);
@@ -3461,8 +3513,8 @@ export async function uploadFile({ selector, filePath }) {
3461
3513
  el.dispatchEvent(new Event('change', { bubbles: true }));
3462
3514
  el.dispatchEvent(new Event('input', { bubbles: true }));
3463
3515
 
3464
- // Wait briefly for framework to process drop events before checking
3465
- await new Promise(function(r) { setTimeout(r, 200); });
3516
+ // No await here: do-JavaScript cannot await a Promise, and the drop events above
3517
+ // already dispatched synchronously. A framework that needs a tick is surfaced by the hint below.
3466
3518
 
3467
3519
  // Re-check after drop
3468
3520
  if (el.files && el.files.length > 0) {
@@ -3476,6 +3528,8 @@ export async function uploadFile({ selector, filePath }) {
3476
3528
  { timeout: 30000 }
3477
3529
  );
3478
3530
 
3531
+ // Clean up the temp PNG produced by any sips image conversion above (was leaked before).
3532
+ if (resolvedPath !== filePath) await unlink(resolvedPath).catch(() => {});
3479
3533
  return result;
3480
3534
  }
3481
3535
 
@@ -3567,11 +3621,12 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
3567
3621
  `(function(){var m=document.querySelector('meta[name=viewport]');if(!m){m=document.createElement('meta');m.name='viewport';document.head.appendChild(m);}m.content='width=${w},initial-scale=${scale}';})()`
3568
3622
  );
3569
3623
 
3570
- // Reload to apply changes
3571
- // Reload and wait via inline JS polling (not fixed sleep)
3572
- await runJS(
3573
- `(async function(){location.reload();await new Promise(function(r){setTimeout(r,300)});for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return 'done';})()`
3574
- );
3624
+ // Reload to apply changes, then wait for load โ€” polled from Node (`do JavaScript`
3625
+ // can't await an in-page loop).
3626
+ const navIndex = _activeTabIndex;
3627
+ await runJS("location.reload()", { tabIndex: navIndex });
3628
+ await new Promise(r => setTimeout(r, 200));
3629
+ await _pollReadyAndRead(navIndex);
3575
3630
 
3576
3631
  return JSON.stringify({
3577
3632
  device: device || "custom",
@@ -3591,9 +3646,11 @@ export async function resetEmulation() {
3591
3646
  await osascript(
3592
3647
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, 1440, 900}`
3593
3648
  );
3594
- await runJS(
3595
- `(async function(){location.reload();await new Promise(function(r){setTimeout(r,300)});for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}return 'done';})()`
3596
- );
3649
+ // Reload + wait for load โ€” polled from Node (`do JavaScript` can't await an in-page loop).
3650
+ const navIndex = _activeTabIndex;
3651
+ await runJS("location.reload()", { tabIndex: navIndex });
3652
+ await new Promise(r => setTimeout(r, 200));
3653
+ await _pollReadyAndRead(navIndex);
3597
3654
  return "Emulation reset to desktop";
3598
3655
  }
3599
3656
 
@@ -3618,6 +3675,7 @@ export async function clearConsoleCapture() {
3618
3675
 
3619
3676
  export async function savePDF({ path: pdfPath }) {
3620
3677
  await refreshTargetWindow();
3678
+ _validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths โ€” prevents arbitrary overwrite
3621
3679
  // NO focus stealing โ€” uses screencapture + Python Quartz to generate PDF
3622
3680
 
3623
3681
  // Step 1: Get full page dimensions
@@ -3635,9 +3693,11 @@ export async function savePDF({ path: pdfPath }) {
3635
3693
  await new Promise(r => setTimeout(r, 500)); // Let page reflow
3636
3694
 
3637
3695
  // Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
3638
- const windowId = await osascript(
3696
+ const windowIdRaw = await osascript(
3639
3697
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
3640
3698
  );
3699
+ const windowId = windowIdRaw != null && /^\d+$/.test(String(windowIdRaw).trim()) ? String(windowIdRaw).trim() : null;
3700
+ if (!windowId) throw new Error("Cannot get Safari window ID for PDF capture");
3641
3701
  const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
3642
3702
  try {
3643
3703
  // Use do shell script to inherit osascript's Screen Recording permission
@@ -3656,17 +3716,17 @@ export async function savePDF({ path: pdfPath }) {
3656
3716
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
3657
3717
  ).catch(() => {});
3658
3718
 
3659
- // Step 5: Convert screenshot to PDF using Python3 + macOS Quartz (no external deps)
3660
- const safePdfPath = pdfPath.replace(/'/g, "'\\''");
3661
- const safePngPath = tmpPng.replace(/'/g, "'\\''");
3719
+ // Step 5: Convert screenshot to PDF using Python3 + macOS Quartz (no external deps).
3720
+ // Paths are passed as argv โ€” NOT interpolated into the source โ€” so there is no shell
3721
+ // or Python string-escaping to get wrong, and no code-injection surface.
3662
3722
  try {
3663
3723
  await execFileAsync("python3", ["-c", `
3664
3724
  import sys
3665
3725
  from Quartz import CGImageSourceCreateWithURL, CGImageSourceCreateImageAtIndex, CGImageGetWidth, CGImageGetHeight, CGPDFContextCreateWithURL, CGRectMake, CGPDFContextBeginPage, CGPDFContextEndPage, CGContextDrawImage
3666
3726
  from CoreFoundation import CFURLCreateFromFileSystemRepresentation
3667
3727
 
3668
- png_path = b'${safePngPath}'
3669
- pdf_path = b'${safePdfPath}'
3728
+ png_path = sys.argv[1].encode('utf-8')
3729
+ pdf_path = sys.argv[2].encode('utf-8')
3670
3730
 
3671
3731
  src_url = CFURLCreateFromFileSystemRepresentation(None, png_path, len(png_path), False)
3672
3732
  img_src = CGImageSourceCreateWithURL(src_url, None)
@@ -3685,7 +3745,7 @@ CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), img)
3685
3745
  CGPDFContextEndPage(ctx)
3686
3746
  del ctx
3687
3747
  print(f"OK {w}x{h}")
3688
- `.trim()], { timeout: 15000 });
3748
+ `.trim(), tmpPng, pdfPath], { timeout: 15000 });
3689
3749
  } catch (err) {
3690
3750
  throw new Error(`PDF conversion failed: ${err.message}`);
3691
3751
  } finally {
@@ -3977,8 +4037,8 @@ export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
3977
4037
  // ========== COOKIE CRUD ==========
3978
4038
 
3979
4039
  export async function setCookie({ name, value, domain, path: cookiePath, expires, secure, sameSite, httpOnly }) {
3980
- const safeName = name.replace(/'/g, "\\'");
3981
- const safeValue = value.replace(/'/g, "\\'");
4040
+ const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4041
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3982
4042
  let cookie = `${safeName}=${safeValue}`;
3983
4043
  if (cookiePath) cookie += `; path=${cookiePath}`;
3984
4044
  if (domain) cookie += `; domain=${domain}`;
@@ -3995,7 +4055,7 @@ export async function deleteCookies({ name, all }) {
3995
4055
  );
3996
4056
  }
3997
4057
  if (name) {
3998
- const safeName = name.replace(/'/g, "\\'");
4058
+ const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3999
4059
  return runJS(
4000
4060
  `document.cookie='${safeName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; 'Deleted cookie: ${safeName}'`
4001
4061
  );
@@ -4007,29 +4067,29 @@ export async function deleteCookies({ name, all }) {
4007
4067
 
4008
4068
  export async function getSessionStorage({ key }) {
4009
4069
  if (key) {
4010
- const safeKey = key.replace(/'/g, "\\'");
4070
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4011
4071
  return runJS(`sessionStorage.getItem('${safeKey}')`);
4012
4072
  }
4013
4073
  return runJS(
4014
- "JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){return[k,sessionStorage.getItem(k).substring(0,200)]})))"
4074
+ "JSON.stringify(Object.fromEntries(Object.keys(sessionStorage).map(function(k){var v=sessionStorage.getItem(k);return[k,v==null?null:v.substring(0,200)]})))"
4015
4075
  );
4016
4076
  }
4017
4077
 
4018
4078
  export async function setSessionStorage({ key, value }) {
4019
- const safeKey = key.replace(/'/g, "\\'");
4020
- const safeValue = value.replace(/'/g, "\\'");
4079
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4080
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4021
4081
  return runJS(`sessionStorage.setItem('${safeKey}','${safeValue}'); 'Set sessionStorage: ${safeKey}'`);
4022
4082
  }
4023
4083
 
4024
4084
  export async function setLocalStorage({ key, value }) {
4025
- const safeKey = key.replace(/'/g, "\\'");
4026
- const safeValue = value.replace(/'/g, "\\'");
4085
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4086
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4027
4087
  return runJS(`localStorage.setItem('${safeKey}','${safeValue}'); 'Set localStorage: ${safeKey}'`);
4028
4088
  }
4029
4089
 
4030
4090
  export async function deleteLocalStorage({ key }) {
4031
4091
  if (key) {
4032
- const safeKey = key.replace(/'/g, "\\'");
4092
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4033
4093
  return runJS(`localStorage.removeItem('${safeKey}'); 'Deleted localStorage: ${safeKey}'`);
4034
4094
  }
4035
4095
  return runJS("var n=localStorage.length; localStorage.clear(); 'Cleared localStorage: '+n+' items'");
@@ -4037,7 +4097,7 @@ export async function deleteLocalStorage({ key }) {
4037
4097
 
4038
4098
  export async function deleteSessionStorage({ key }) {
4039
4099
  if (key) {
4040
- const safeKey = key.replace(/'/g, "\\'");
4100
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4041
4101
  return runJS(`sessionStorage.removeItem('${safeKey}'); 'Deleted sessionStorage: ${safeKey}'`);
4042
4102
  }
4043
4103
  return runJS("var n=sessionStorage.length; sessionStorage.clear(); 'Cleared sessionStorage: '+n+' items'");
@@ -4138,8 +4198,10 @@ export async function clipboardWrite({ text, restore = true }) {
4138
4198
 
4139
4199
  // Intercept fetch/XHR requests matching a URL pattern and return mock responses
4140
4200
  export async function mockNetworkRoute({ urlPattern, response }) {
4141
- const safePattern = urlPattern.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
4142
- const safeBody = (response.body || "").replace(/'/g, "\\'").replace(/\\/g, "\\\\").replace(/\n/g, "\\n");
4201
+ // Escape backslash FIRST, then quotes โ€” the reverse order double-escapes the quote
4202
+ // (\' โ†’ \\') and breaks out of the JS string literal.
4203
+ const safePattern = urlPattern.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4204
+ const safeBody = (response.body || "").replace(/\\/g, "\\\\").replace(/'/g, "\\'").replace(/\n/g, "\\n");
4143
4205
  const status = response.status || 200;
4144
4206
  const contentType = response.contentType || "application/json";
4145
4207
 
@@ -4463,7 +4525,7 @@ export async function overrideGeolocation({ latitude, longitude, accuracy = 100
4463
4525
  export async function getComputedStyles({ selector, properties }) {
4464
4526
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4465
4527
  const propsFilter = properties
4466
- ? `.filter(function(p){return [${properties.map((p) => `'${p}'`).join(",")}].includes(p)})`
4528
+ ? `.filter(function(p){return [${properties.map((p) => `'${String(p).replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`).join(",")}].includes(p)})`
4467
4529
  : "";
4468
4530
  return runJS(
4469
4531
  `(function(){
@@ -4483,7 +4545,8 @@ export async function getComputedStyles({ selector, properties }) {
4483
4545
  export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
4484
4546
  const safeDb = dbName.replace(/'/g, "\\'");
4485
4547
  const safeStore = storeName.replace(/'/g, "\\'");
4486
- return runJS(
4548
+ // `do JavaScript` can't await a Promise โ€” route async work through the Node-side poller.
4549
+ return _evaluateAsync(
4487
4550
  `(async function(){
4488
4551
  return new Promise(function(resolve, reject) {
4489
4552
  var request = indexedDB.open('${safeDb}');
@@ -4506,13 +4569,12 @@ export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
4506
4569
  cursor.onerror = function() { resolve(JSON.stringify({ error: 'Cursor error' })); db.close(); };
4507
4570
  };
4508
4571
  });
4509
- })()`,
4510
- { timeout: 15000 }
4572
+ })()`
4511
4573
  );
4512
4574
  }
4513
4575
 
4514
4576
  export async function listIndexedDBs() {
4515
- return runJS(
4577
+ return _evaluateAsync(
4516
4578
  `(async function(){
4517
4579
  try {
4518
4580
  var dbs = await indexedDB.databases();
@@ -4606,34 +4668,31 @@ export async function scrollToElement({ selector, text, block = "center", timeou
4606
4668
  );
4607
4669
  }
4608
4670
  if (text) {
4609
- // Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.)
4671
+ // Virtual DOM scroll: scroll down repeatedly until text appears (for Airtable, etc.).
4672
+ // Each check+scroll is one synchronous step; the loop is driven from Node because
4673
+ // `do JavaScript` can't await an in-page delay (see _evaluateAsync).
4610
4674
  const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4611
- return runJS(
4612
- `(async function(){
4613
- var deadline = Date.now() + ${Number(timeout)};
4614
- var scrollable = document.querySelector('[class*="grid"],[class*="virtual"],[class*="scroll"],[role="grid"],[role="table"]') || document.scrollingElement || document.documentElement;
4615
- var lastY = -1;
4616
- while (Date.now() < deadline) {
4617
- // Check if text exists in DOM
4618
- var tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
4619
- while(tw.nextNode()){
4620
- if(tw.currentNode.textContent.trim().includes('${safeText}')){
4621
- var el = tw.currentNode.parentElement;
4622
- el.scrollIntoView({behavior:'smooth',block:'${block}'});
4623
- return 'Found and scrolled to: "' + el.textContent.trim().substring(0,50) + '"';
4624
- }
4625
- }
4626
- // Scroll down
4627
- var curY = scrollable.scrollTop;
4628
- if (curY === lastY) return 'Text not found: ${safeText} (scrolled to bottom)';
4629
- lastY = curY;
4630
- scrollable.scrollBy(0, 500);
4631
- await new Promise(function(r){setTimeout(r,300)});
4632
- }
4633
- return 'Timeout: text not found within ${timeout}ms';
4634
- })()`,
4635
- { timeout: timeout + 5000 }
4636
- );
4675
+ const safeBlock = String(block).replace(/[^a-z]/gi, '') || 'center';
4676
+ const navIndex = _activeTabIndex;
4677
+ const stepJs =
4678
+ `(function(){` +
4679
+ `var scrollable=document.querySelector('[class*="grid"],[class*="virtual"],[class*="scroll"],[role="grid"],[role="table"]')||document.scrollingElement||document.documentElement;` +
4680
+ `var tw=document.createTreeWalker(document.body,NodeFilter.SHOW_TEXT,null);` +
4681
+ `while(tw.nextNode()){if(tw.currentNode.textContent.trim().includes('${safeText}')){var el=tw.currentNode.parentElement;el.scrollIntoView({behavior:'smooth',block:'${safeBlock}'});return 'Found and scrolled to: "'+el.textContent.trim().substring(0,50)+'"';}}` +
4682
+ `var curY=scrollable.scrollTop;scrollable.scrollBy(0,500);return 'SCROLL:'+curY;})()`;
4683
+ const deadline = Date.now() + Number(timeout);
4684
+ let lastY = -1;
4685
+ while (Date.now() < deadline) {
4686
+ const r = await runJS(stepJs, { tabIndex: navIndex, timeout: 5000 }).catch(() => '');
4687
+ if (typeof r === 'string' && r.startsWith('Found')) return r;
4688
+ if (typeof r === 'string' && r.startsWith('SCROLL:')) {
4689
+ const curY = parseInt(r.slice(7), 10);
4690
+ if (curY === lastY) return `Text not found: ${text} (scrolled to bottom)`;
4691
+ lastY = curY;
4692
+ }
4693
+ await new Promise(res => setTimeout(res, 300));
4694
+ }
4695
+ return `Timeout: text not found within ${timeout}ms`;
4637
4696
  }
4638
4697
  throw new Error("scrollToElement requires selector or text");
4639
4698
  }
@@ -4647,23 +4706,17 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
4647
4706
  await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
4648
4707
  let targetUrl = url;
4649
4708
  if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
4650
- const safeUrl = targetUrl.replace(/"/g, '\\"');
4709
+ // Escape backslash first, then quotes; strip CR/LF โ€” a newline would break out of
4710
+ // the AppleScript string literal and allow AppleScript injection.
4711
+ const safeUrl = targetUrl.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
4651
4712
  if (_activeTabURL) await resolveActiveTab();
4652
4713
  _assertNotFallingBackToUserTab('navigateAndRead');
4653
- const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
4714
+ const navIndex = _activeTabIndex;
4715
+ const navTarget = navIndex ? `tab ${navIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
4654
4716
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
4655
4717
  _activeTabURL = targetUrl;
4656
- // Single JS call: poll readyState + return page data โ€” 1 call instead of 30+
4657
- const navResult = await runJS(
4658
- `(async function(){
4659
- for(var i=0;i<60;i++){
4660
- if(document.readyState==='complete')break;
4661
- await new Promise(function(r){setTimeout(r,i<10?200:500)});
4662
- }
4663
- return JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})});
4664
- })()`,
4665
- { timeout: 30000 }
4666
- );
4718
+ // Poll readyState from Node, then read โ€” `do JavaScript` can't await an async IIFE.
4719
+ const navResult = await _pollReadyAndRead(navIndex, { maxLength });
4667
4720
  // Update _activeTabURL with the actual URL after navigation
4668
4721
  try {
4669
4722
  const parsed = JSON.parse(navResult);
@@ -4674,13 +4727,14 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
4674
4727
 
4675
4728
  // Click + wait for navigation or element โ€” common after clicking a link/button
4676
4729
  export async function clickAndWait({ selector, text, waitFor: waitSelector, timeout = 10000 }) {
4677
- const safeSel = selector ? selector.replace(/'/g, "\\'") : "";
4678
- const safeText = text ? text.replace(/'/g, "\\'") : "";
4679
- const safeWait = waitSelector ? waitSelector.replace(/'/g, "\\'") : "";
4680
- // Single JS call: click + wait โ€” 1 call instead of 20+
4681
- return runJS(
4682
- `(async function(){
4683
- // Find and click
4730
+ const esc = (s) => s.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4731
+ const safeSel = selector ? esc(selector) : "";
4732
+ const safeText = text ? esc(text) : "";
4733
+ const safeWait = waitSelector ? esc(waitSelector) : "";
4734
+ const navIndex = _activeTabIndex;
4735
+ // Step 1: find + click โ€” fully synchronous, so it runs inside one `do JavaScript`.
4736
+ const clickResult = await runJS(
4737
+ `(function(){
4684
4738
  var el;
4685
4739
  ${safeSel ? `el = document.querySelector('${safeSel}');` : ""}
4686
4740
  ${safeText && !safeSel ? `
@@ -4690,31 +4744,35 @@ export async function clickAndWait({ selector, text, waitFor: waitSelector, time
4690
4744
  if(!el) return JSON.stringify({error:'Element not found'});
4691
4745
  el.scrollIntoView({block:'center'});
4692
4746
  el.click();
4693
- // Wait
4694
- var deadline = Date.now() + ${Number(timeout)};
4695
- ${safeWait ? `
4696
- while(Date.now()<deadline){
4697
- if(document.querySelector('${safeWait}'))break;
4698
- await new Promise(function(r){setTimeout(r,200)});
4699
- }
4700
- ` : `
4701
- await new Promise(function(r){setTimeout(r,300)});
4702
- while(Date.now()<deadline){
4703
- if(document.readyState==='complete')break;
4704
- await new Promise(function(r){setTimeout(r,200)});
4705
- }
4706
- `}
4707
- return JSON.stringify({title:document.title,url:location.href,clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
4747
+ return JSON.stringify({clicked:el.tagName+' "'+el.textContent.trim().substring(0,50)+'"'});
4708
4748
  })()`,
4709
- { timeout: timeout + 5000 }
4749
+ { tabIndex: navIndex, timeout: 10000 }
4710
4750
  );
4751
+ let clickedInfo = '';
4752
+ try { const c = JSON.parse(clickResult); if (c.error) return clickResult; clickedInfo = c.clicked || ''; } catch {}
4753
+ // Step 2: wait from Node โ€” `do JavaScript` can't await an in-page loop.
4754
+ const deadline = Date.now() + Number(timeout);
4755
+ await new Promise(r => setTimeout(r, 300));
4756
+ while (Date.now() < deadline) {
4757
+ try {
4758
+ if (safeWait) {
4759
+ if (await runJS(`document.querySelector('${safeWait}')?'1':''`, { tabIndex: navIndex, timeout: 5000 }) === '1') break;
4760
+ } else if (await runJS('document.readyState', { tabIndex: navIndex, timeout: 5000 }) === 'complete') {
4761
+ break;
4762
+ }
4763
+ } catch { /* page navigating */ }
4764
+ await new Promise(r => setTimeout(r, 200));
4765
+ }
4766
+ const final = await runJS(`JSON.stringify({title:document.title,url:location.href})`, { tabIndex: navIndex, timeout: 5000 });
4767
+ try { const p = JSON.parse(final); p.clicked = clickedInfo; return JSON.stringify(p); } catch { return final; }
4711
4768
  }
4712
4769
 
4713
4770
  // Fill form + submit โ€” common for login, search, etc.
4714
4771
  export async function fillAndSubmit({ fields, submitSelector }) {
4715
4772
  await fillForm({ fields });
4773
+ const navIndex = _activeTabIndex;
4716
4774
  if (submitSelector) {
4717
- const sel = submitSelector.replace(/'/g, "\\'");
4775
+ const sel = submitSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4718
4776
  await runJS(
4719
4777
  `(function(){var el=document.querySelector('${sel}');if(el)el.click();})()`
4720
4778
  );
@@ -4724,15 +4782,9 @@ export async function fillAndSubmit({ fields, submitSelector }) {
4724
4782
  `(function(){var btn=document.querySelector('[type=submit],button:not([type])');if(btn)btn.click();})()`
4725
4783
  );
4726
4784
  }
4727
- // Wait for navigation/reload via inline JS (not fixed sleep)
4728
- return runJS(
4729
- `(async function(){
4730
- await new Promise(function(r){setTimeout(r,300)});
4731
- for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}
4732
- return JSON.stringify({title:document.title,url:location.href});
4733
- })()`,
4734
- { timeout: 15000 }
4735
- );
4785
+ // Wait for navigation/reload โ€” polled from Node (`do JavaScript` can't await).
4786
+ await new Promise(r => setTimeout(r, 300));
4787
+ return _pollReadyAndRead(navIndex);
4736
4788
  }
4737
4789
 
4738
4790
  // Full page analysis โ€” extracts everything in ONE call