safari-mcp 2.11.9 → 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.
@@ -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.9",
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)
@@ -1031,7 +1031,9 @@ export async function navigate(url) {
1031
1031
  targetUrl = "https://" + targetUrl;
1032
1032
  }
1033
1033
 
1034
- 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, '');
1035
1037
  // Resolve tab by URL first (in case indices shifted)
1036
1038
  if (_activeTabURL) await resolveActiveTab();
1037
1039
  _assertNotFallingBackToUserTab('navigate');
@@ -1112,7 +1114,7 @@ export async function navigate(url) {
1112
1114
  try {
1113
1115
  const parsed = JSON.parse(result);
1114
1116
  if (parsed.blocked && url.startsWith("http://")) {
1115
- const httpUrl = url.replace(/"/g, '\\"');
1117
+ const httpUrl = url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '');
1116
1118
  await osascriptFast(
1117
1119
  `tell application "Safari" to set URL of ${navTarget} to "${httpUrl}"`
1118
1120
  );
@@ -1161,37 +1163,58 @@ export async function navigate(url) {
1161
1163
  return result;
1162
1164
  }
1163
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
+
1164
1189
  export async function goBack() {
1165
- // Navigate back + smart wait: check immediately, then poll only if loading
1166
- const result = await runJS(
1167
- `(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});})()`,
1168
- { timeout: 10000 }
1169
- );
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);
1170
1195
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1171
1196
  return result;
1172
1197
  }
1173
1198
 
1174
1199
  export async function goForward() {
1175
- const result = await runJS(
1176
- `(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});})()`,
1177
- { timeout: 10000 }
1178
- );
1200
+ await refreshTargetWindow();
1201
+ const navIndex = _activeTabIndex;
1202
+ await runJS("history.forward()", { tabIndex: navIndex, timeout: 5000 });
1203
+ const result = await _pollReadyAndRead(navIndex);
1179
1204
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1180
1205
  return result;
1181
1206
  }
1182
1207
 
1183
1208
  export async function reload(hardReload = false) {
1184
- // Reload destroys JS context — must wait separately then re-query
1185
- await runJS(hardReload ? "location.reload(true)" : "location.reload()");
1186
- await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
1187
- // Poll readyState in a single call
1188
- const result = await runJS(
1189
- `(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});})()`,
1190
- { timeout: 10000 }
1191
- );
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);
1192
1215
  try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
1193
1216
  // A reload destroys the JS context — re-stamp marker + visibility spoof.
1194
- await _stampTab(_activeTabIndex);
1217
+ await _stampTab(navIndex);
1195
1218
  return result;
1196
1219
  }
1197
1220
 
@@ -2285,9 +2308,10 @@ export async function pressKey({ key, modifiers = [] }) {
2285
2308
  // real paste pipeline.
2286
2309
  async function _nativeTypeViaClipboard(text) {
2287
2310
  await _acquireClipboardLock();
2311
+ let savedClipboard;
2288
2312
  try {
2289
2313
  // Save current clipboard
2290
- const savedClipboard = await _saveClipboard();
2314
+ savedClipboard = await _saveClipboard();
2291
2315
 
2292
2316
  // Set clipboard to our text via pipe (safe from shell injection)
2293
2317
  await new Promise((resolve, reject) => {
@@ -2302,23 +2326,19 @@ async function _nativeTypeViaClipboard(text) {
2302
2326
  // MUST have windowId — global CGEvent (windowId=0) would steal focus and move mouse
2303
2327
  const geo = await _getSafariWindowGeometry();
2304
2328
  if (!geo.windowId) {
2305
- // Can't get window ID — restore clipboard and let catch block release lock
2306
- await _restoreClipboard(savedClipboard);
2307
2329
  throw new Error("Cannot native-paste without Safari window ID — would steal focus");
2308
2330
  }
2309
2331
  // keyCode 9 = V key, flags: ["cmd"]
2310
2332
  await _helperNativeKeyboard(9, ["cmd"], geo.windowId);
2311
2333
 
2312
- // Wait for paste to settle, then restore clipboard immediately
2334
+ // Wait for paste to settle
2313
2335
  await new Promise(r => setTimeout(r, 100)); // 100ms is enough for Cmd+V to process
2314
- await _restoreClipboard(savedClipboard);
2315
- _releaseClipboardLock();
2316
-
2317
2336
  return `Typed ${text.length} chars (native paste into iframe)`;
2318
- } catch (err) {
2319
- // 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(() => {});
2320
2341
  if (_clipboardLocked) _releaseClipboardLock();
2321
- throw err;
2322
2342
  }
2323
2343
  }
2324
2344
 
@@ -2675,9 +2695,12 @@ export async function screenshot({ fullPage = false } = {}) {
2675
2695
  const skipScreencapture = isBackgroundTab;
2676
2696
 
2677
2697
  // Try screencapture — use osascript's do shell script to bypass VS Code permission issue
2678
- const windowId = !skipScreencapture ? await osascript(
2698
+ const windowIdRaw = !skipScreencapture ? await osascript(
2679
2699
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
2680
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;
2681
2704
 
2682
2705
  // On macOS Tahoe, screencapture -l may briefly steal focus.
2683
2706
  // Save frontmost app via daemon so we can hide Safari if it stole focus.
@@ -2775,7 +2798,10 @@ export async function screenshot({ fullPage = false } = {}) {
2775
2798
  { timeout: 30000 }
2776
2799
  );
2777
2800
 
2778
- 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) {
2779
2805
  return dataUrl;
2780
2806
  }
2781
2807
 
@@ -2839,25 +2865,32 @@ export async function screenshotElement({ selector }) {
2839
2865
  { timeout: 15000 }
2840
2866
  );
2841
2867
 
2842
- 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) {
2843
2874
  // Fallback: use screencapture + crop
2844
2875
  const tmpFile = join(tmpdir(), `safari-el-${Date.now()}.png`);
2876
+ let cropFile = null;
2845
2877
  try {
2846
- 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;
2847
2880
  if (!windowId) throw new Error("Cannot get Safari window ID");
2848
2881
 
2849
2882
  // Get element bounds relative to screen
2850
2883
  const bounds = await runJS(
2851
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)});})()`
2852
2885
  );
2853
- if (!bounds) throw new Error(result);
2886
+ if (!bounds) throw new Error(typeof result === 'string' && result.startsWith('Element') ? result : 'Element not found for screenshot');
2854
2887
 
2855
2888
  // Full window screenshot then crop with sips
2856
2889
  await execFileAsync("screencapture", ["-l" + windowId, "-o", "-x", tmpFile]);
2857
2890
  const { x, y, w, h } = JSON.parse(bounds);
2858
2891
  // Use sips to crop (macOS built-in)
2859
2892
  const toolbarHeight = 74; // Safari toolbar approximate height (matches _getSafariWindowGeometry)
2860
- const cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
2893
+ cropFile = join(tmpdir(), `safari-el-crop-${Date.now()}.png`);
2861
2894
  await execFileAsync("sips", [
2862
2895
  "-c", String(h), String(w),
2863
2896
  "--cropOffset", String(y + toolbarHeight), String(x),
@@ -2869,8 +2902,7 @@ export async function screenshotElement({ selector }) {
2869
2902
  if (data.length > 100) return data.toString("base64");
2870
2903
  } catch (e) {
2871
2904
  await unlink(tmpFile).catch(() => {});
2872
- // Also clean cropFile if it was created before the error
2873
- 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
2874
2906
  throw new Error(`Element screenshot failed: ${e.message}`);
2875
2907
  }
2876
2908
  }
@@ -2926,7 +2958,7 @@ export async function listTabs() {
2926
2958
 
2927
2959
  export async function newTab(url = "") {
2928
2960
  await refreshTargetWindow();
2929
- const safeUrl = url ? url.replace(/"/g, '\\"') : "";
2961
+ const safeUrl = url ? url.replace(/\\/g, '\\\\').replace(/"/g, '\\"').replace(/[\r\n]/g, '') : "";
2930
2962
  try {
2931
2963
  if (url) {
2932
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`);
@@ -3273,11 +3305,11 @@ export async function getCookies() {
3273
3305
 
3274
3306
  export async function getLocalStorage({ key }) {
3275
3307
  if (key) {
3276
- const safeKey = key.replace(/'/g, "\\'");
3308
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3277
3309
  return runJS(`localStorage.getItem('${safeKey}')`);
3278
3310
  }
3279
3311
  return runJS(
3280
- "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)]})))"
3281
3313
  );
3282
3314
  }
3283
3315
 
@@ -3332,15 +3364,23 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
3332
3364
  // Prevent reading sensitive system files via upload/paste tools
3333
3365
  function _validateFilePath(filePath) {
3334
3366
  const resolved = resolvePath(filePath);
3335
- 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);
3336
3370
  const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
3337
- const lower = resolved.toLowerCase();
3338
- for (const b of blocked) {
3339
- if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
3340
- }
3341
- // Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
3342
- if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
3343
- 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
+ }
3344
3384
  }
3345
3385
  }
3346
3386
 
@@ -3422,9 +3462,11 @@ export async function uploadFile({ selector, filePath }) {
3422
3462
  const mime = mimeMap[ext] || "application/octet-stream";
3423
3463
  const safeName = fileName.replace(/'/g, "\\'");
3424
3464
 
3425
- // 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.
3426
3468
  const result = await runJSLarge(
3427
- `(async function(){
3469
+ `(function(){
3428
3470
  // Deep query: main document → shadow DOM → iframes
3429
3471
  function deepQuery(sel) {
3430
3472
  var el = document.querySelector(sel);
@@ -3471,8 +3513,8 @@ export async function uploadFile({ selector, filePath }) {
3471
3513
  el.dispatchEvent(new Event('change', { bubbles: true }));
3472
3514
  el.dispatchEvent(new Event('input', { bubbles: true }));
3473
3515
 
3474
- // Wait briefly for framework to process drop events before checking
3475
- 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.
3476
3518
 
3477
3519
  // Re-check after drop
3478
3520
  if (el.files && el.files.length > 0) {
@@ -3486,6 +3528,8 @@ export async function uploadFile({ selector, filePath }) {
3486
3528
  { timeout: 30000 }
3487
3529
  );
3488
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(() => {});
3489
3533
  return result;
3490
3534
  }
3491
3535
 
@@ -3577,11 +3621,12 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
3577
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}';})()`
3578
3622
  );
3579
3623
 
3580
- // Reload to apply changes
3581
- // Reload and wait via inline JS polling (not fixed sleep)
3582
- await runJS(
3583
- `(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';})()`
3584
- );
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);
3585
3630
 
3586
3631
  return JSON.stringify({
3587
3632
  device: device || "custom",
@@ -3601,9 +3646,11 @@ export async function resetEmulation() {
3601
3646
  await osascript(
3602
3647
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, 1440, 900}`
3603
3648
  );
3604
- await runJS(
3605
- `(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';})()`
3606
- );
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);
3607
3654
  return "Emulation reset to desktop";
3608
3655
  }
3609
3656
 
@@ -3628,6 +3675,7 @@ export async function clearConsoleCapture() {
3628
3675
 
3629
3676
  export async function savePDF({ path: pdfPath }) {
3630
3677
  await refreshTargetWindow();
3678
+ _validateFilePath(pdfPath); // allowlist (/Users//tmp//var-folders) + block sensitive paths — prevents arbitrary overwrite
3631
3679
  // NO focus stealing — uses screencapture + Python Quartz to generate PDF
3632
3680
 
3633
3681
  // Step 1: Get full page dimensions
@@ -3645,9 +3693,11 @@ export async function savePDF({ path: pdfPath }) {
3645
3693
  await new Promise(r => setTimeout(r, 500)); // Let page reflow
3646
3694
 
3647
3695
  // Step 3: Take screenshot via screencapture -l (window-targeted, NO focus steal)
3648
- const windowId = await osascript(
3696
+ const windowIdRaw = await osascript(
3649
3697
  `tell application "Safari" to return id of ${getTargetWindowRef()}`
3650
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");
3651
3701
  const tmpPng = join(tmpdir(), `safari-mcp-pdf-${Date.now()}.png`);
3652
3702
  try {
3653
3703
  // Use do shell script to inherit osascript's Screen Recording permission
@@ -3666,17 +3716,17 @@ export async function savePDF({ path: pdfPath }) {
3666
3716
  `tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${origBounds}}`
3667
3717
  ).catch(() => {});
3668
3718
 
3669
- // Step 5: Convert screenshot to PDF using Python3 + macOS Quartz (no external deps)
3670
- const safePdfPath = pdfPath.replace(/'/g, "'\\''");
3671
- 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.
3672
3722
  try {
3673
3723
  await execFileAsync("python3", ["-c", `
3674
3724
  import sys
3675
3725
  from Quartz import CGImageSourceCreateWithURL, CGImageSourceCreateImageAtIndex, CGImageGetWidth, CGImageGetHeight, CGPDFContextCreateWithURL, CGRectMake, CGPDFContextBeginPage, CGPDFContextEndPage, CGContextDrawImage
3676
3726
  from CoreFoundation import CFURLCreateFromFileSystemRepresentation
3677
3727
 
3678
- png_path = b'${safePngPath}'
3679
- pdf_path = b'${safePdfPath}'
3728
+ png_path = sys.argv[1].encode('utf-8')
3729
+ pdf_path = sys.argv[2].encode('utf-8')
3680
3730
 
3681
3731
  src_url = CFURLCreateFromFileSystemRepresentation(None, png_path, len(png_path), False)
3682
3732
  img_src = CGImageSourceCreateWithURL(src_url, None)
@@ -3695,7 +3745,7 @@ CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), img)
3695
3745
  CGPDFContextEndPage(ctx)
3696
3746
  del ctx
3697
3747
  print(f"OK {w}x{h}")
3698
- `.trim()], { timeout: 15000 });
3748
+ `.trim(), tmpPng, pdfPath], { timeout: 15000 });
3699
3749
  } catch (err) {
3700
3750
  throw new Error(`PDF conversion failed: ${err.message}`);
3701
3751
  } finally {
@@ -3987,8 +4037,8 @@ export async function getAccessibilityTree({ selector, maxDepth = 5 }) {
3987
4037
  // ========== COOKIE CRUD ==========
3988
4038
 
3989
4039
  export async function setCookie({ name, value, domain, path: cookiePath, expires, secure, sameSite, httpOnly }) {
3990
- const safeName = name.replace(/'/g, "\\'");
3991
- const safeValue = value.replace(/'/g, "\\'");
4040
+ const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4041
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
3992
4042
  let cookie = `${safeName}=${safeValue}`;
3993
4043
  if (cookiePath) cookie += `; path=${cookiePath}`;
3994
4044
  if (domain) cookie += `; domain=${domain}`;
@@ -4005,7 +4055,7 @@ export async function deleteCookies({ name, all }) {
4005
4055
  );
4006
4056
  }
4007
4057
  if (name) {
4008
- const safeName = name.replace(/'/g, "\\'");
4058
+ const safeName = name.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4009
4059
  return runJS(
4010
4060
  `document.cookie='${safeName}=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/'; 'Deleted cookie: ${safeName}'`
4011
4061
  );
@@ -4017,29 +4067,29 @@ export async function deleteCookies({ name, all }) {
4017
4067
 
4018
4068
  export async function getSessionStorage({ key }) {
4019
4069
  if (key) {
4020
- const safeKey = key.replace(/'/g, "\\'");
4070
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4021
4071
  return runJS(`sessionStorage.getItem('${safeKey}')`);
4022
4072
  }
4023
4073
  return runJS(
4024
- "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)]})))"
4025
4075
  );
4026
4076
  }
4027
4077
 
4028
4078
  export async function setSessionStorage({ key, value }) {
4029
- const safeKey = key.replace(/'/g, "\\'");
4030
- const safeValue = value.replace(/'/g, "\\'");
4079
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4080
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4031
4081
  return runJS(`sessionStorage.setItem('${safeKey}','${safeValue}'); 'Set sessionStorage: ${safeKey}'`);
4032
4082
  }
4033
4083
 
4034
4084
  export async function setLocalStorage({ key, value }) {
4035
- const safeKey = key.replace(/'/g, "\\'");
4036
- const safeValue = value.replace(/'/g, "\\'");
4085
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4086
+ const safeValue = value.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4037
4087
  return runJS(`localStorage.setItem('${safeKey}','${safeValue}'); 'Set localStorage: ${safeKey}'`);
4038
4088
  }
4039
4089
 
4040
4090
  export async function deleteLocalStorage({ key }) {
4041
4091
  if (key) {
4042
- const safeKey = key.replace(/'/g, "\\'");
4092
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4043
4093
  return runJS(`localStorage.removeItem('${safeKey}'); 'Deleted localStorage: ${safeKey}'`);
4044
4094
  }
4045
4095
  return runJS("var n=localStorage.length; localStorage.clear(); 'Cleared localStorage: '+n+' items'");
@@ -4047,7 +4097,7 @@ export async function deleteLocalStorage({ key }) {
4047
4097
 
4048
4098
  export async function deleteSessionStorage({ key }) {
4049
4099
  if (key) {
4050
- const safeKey = key.replace(/'/g, "\\'");
4100
+ const safeKey = key.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4051
4101
  return runJS(`sessionStorage.removeItem('${safeKey}'); 'Deleted sessionStorage: ${safeKey}'`);
4052
4102
  }
4053
4103
  return runJS("var n=sessionStorage.length; sessionStorage.clear(); 'Cleared sessionStorage: '+n+' items'");
@@ -4148,8 +4198,10 @@ export async function clipboardWrite({ text, restore = true }) {
4148
4198
 
4149
4199
  // Intercept fetch/XHR requests matching a URL pattern and return mock responses
4150
4200
  export async function mockNetworkRoute({ urlPattern, response }) {
4151
- const safePattern = urlPattern.replace(/'/g, "\\'").replace(/\\/g, "\\\\");
4152
- 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");
4153
4205
  const status = response.status || 200;
4154
4206
  const contentType = response.contentType || "application/json";
4155
4207
 
@@ -4473,7 +4525,7 @@ export async function overrideGeolocation({ latitude, longitude, accuracy = 100
4473
4525
  export async function getComputedStyles({ selector, properties }) {
4474
4526
  const sel = selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4475
4527
  const propsFilter = properties
4476
- ? `.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)})`
4477
4529
  : "";
4478
4530
  return runJS(
4479
4531
  `(function(){
@@ -4493,7 +4545,8 @@ export async function getComputedStyles({ selector, properties }) {
4493
4545
  export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
4494
4546
  const safeDb = dbName.replace(/'/g, "\\'");
4495
4547
  const safeStore = storeName.replace(/'/g, "\\'");
4496
- return runJS(
4548
+ // `do JavaScript` can't await a Promise — route async work through the Node-side poller.
4549
+ return _evaluateAsync(
4497
4550
  `(async function(){
4498
4551
  return new Promise(function(resolve, reject) {
4499
4552
  var request = indexedDB.open('${safeDb}');
@@ -4516,13 +4569,12 @@ export async function getIndexedDB({ dbName, storeName, limit = 20 }) {
4516
4569
  cursor.onerror = function() { resolve(JSON.stringify({ error: 'Cursor error' })); db.close(); };
4517
4570
  };
4518
4571
  });
4519
- })()`,
4520
- { timeout: 15000 }
4572
+ })()`
4521
4573
  );
4522
4574
  }
4523
4575
 
4524
4576
  export async function listIndexedDBs() {
4525
- return runJS(
4577
+ return _evaluateAsync(
4526
4578
  `(async function(){
4527
4579
  try {
4528
4580
  var dbs = await indexedDB.databases();
@@ -4616,34 +4668,31 @@ export async function scrollToElement({ selector, text, block = "center", timeou
4616
4668
  );
4617
4669
  }
4618
4670
  if (text) {
4619
- // 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).
4620
4674
  const safeText = text.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4621
- return runJS(
4622
- `(async function(){
4623
- var deadline = Date.now() + ${Number(timeout)};
4624
- var scrollable = document.querySelector('[class*="grid"],[class*="virtual"],[class*="scroll"],[role="grid"],[role="table"]') || document.scrollingElement || document.documentElement;
4625
- var lastY = -1;
4626
- while (Date.now() < deadline) {
4627
- // Check if text exists in DOM
4628
- var tw = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
4629
- while(tw.nextNode()){
4630
- if(tw.currentNode.textContent.trim().includes('${safeText}')){
4631
- var el = tw.currentNode.parentElement;
4632
- el.scrollIntoView({behavior:'smooth',block:'${block}'});
4633
- return 'Found and scrolled to: "' + el.textContent.trim().substring(0,50) + '"';
4634
- }
4635
- }
4636
- // Scroll down
4637
- var curY = scrollable.scrollTop;
4638
- if (curY === lastY) return 'Text not found: ${safeText} (scrolled to bottom)';
4639
- lastY = curY;
4640
- scrollable.scrollBy(0, 500);
4641
- await new Promise(function(r){setTimeout(r,300)});
4642
- }
4643
- return 'Timeout: text not found within ${timeout}ms';
4644
- })()`,
4645
- { timeout: timeout + 5000 }
4646
- );
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`;
4647
4696
  }
4648
4697
  throw new Error("scrollToElement requires selector or text");
4649
4698
  }
@@ -4657,23 +4706,17 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
4657
4706
  await runJS("window.onbeforeunload=null", { timeout: 2000 }).catch(() => {});
4658
4707
  let targetUrl = url;
4659
4708
  if (!/^https?:\/\//i.test(targetUrl)) targetUrl = "https://" + targetUrl;
4660
- 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, '');
4661
4712
  if (_activeTabURL) await resolveActiveTab();
4662
4713
  _assertNotFallingBackToUserTab('navigateAndRead');
4663
- const navTarget = _activeTabIndex ? `tab ${_activeTabIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
4714
+ const navIndex = _activeTabIndex;
4715
+ const navTarget = navIndex ? `tab ${navIndex} of ${getTargetWindowRef()}` : getFallbackTarget();
4664
4716
  await osascriptFast(`tell application "Safari" to set URL of ${navTarget} to "${safeUrl}"`);
4665
4717
  _activeTabURL = targetUrl;
4666
- // Single JS call: poll readyState + return page data — 1 call instead of 30+
4667
- const navResult = await runJS(
4668
- `(async function(){
4669
- for(var i=0;i<60;i++){
4670
- if(document.readyState==='complete')break;
4671
- await new Promise(function(r){setTimeout(r,i<10?200:500)});
4672
- }
4673
- return JSON.stringify({title:document.title,url:location.href,text:document.body.innerText.substring(0,${Number(maxLength)})});
4674
- })()`,
4675
- { timeout: 30000 }
4676
- );
4718
+ // Poll readyState from Node, then read — `do JavaScript` can't await an async IIFE.
4719
+ const navResult = await _pollReadyAndRead(navIndex, { maxLength });
4677
4720
  // Update _activeTabURL with the actual URL after navigation
4678
4721
  try {
4679
4722
  const parsed = JSON.parse(navResult);
@@ -4684,13 +4727,14 @@ export async function navigateAndRead(url, { maxLength = 50000 } = {}) {
4684
4727
 
4685
4728
  // Click + wait for navigation or element — common after clicking a link/button
4686
4729
  export async function clickAndWait({ selector, text, waitFor: waitSelector, timeout = 10000 }) {
4687
- const safeSel = selector ? selector.replace(/'/g, "\\'") : "";
4688
- const safeText = text ? text.replace(/'/g, "\\'") : "";
4689
- const safeWait = waitSelector ? waitSelector.replace(/'/g, "\\'") : "";
4690
- // Single JS call: click + wait — 1 call instead of 20+
4691
- return runJS(
4692
- `(async function(){
4693
- // 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(){
4694
4738
  var el;
4695
4739
  ${safeSel ? `el = document.querySelector('${safeSel}');` : ""}
4696
4740
  ${safeText && !safeSel ? `
@@ -4700,31 +4744,35 @@ export async function clickAndWait({ selector, text, waitFor: waitSelector, time
4700
4744
  if(!el) return JSON.stringify({error:'Element not found'});
4701
4745
  el.scrollIntoView({block:'center'});
4702
4746
  el.click();
4703
- // Wait
4704
- var deadline = Date.now() + ${Number(timeout)};
4705
- ${safeWait ? `
4706
- while(Date.now()<deadline){
4707
- if(document.querySelector('${safeWait}'))break;
4708
- await new Promise(function(r){setTimeout(r,200)});
4709
- }
4710
- ` : `
4711
- await new Promise(function(r){setTimeout(r,300)});
4712
- while(Date.now()<deadline){
4713
- if(document.readyState==='complete')break;
4714
- await new Promise(function(r){setTimeout(r,200)});
4715
- }
4716
- `}
4717
- 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)+'"'});
4718
4748
  })()`,
4719
- { timeout: timeout + 5000 }
4749
+ { tabIndex: navIndex, timeout: 10000 }
4720
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; }
4721
4768
  }
4722
4769
 
4723
4770
  // Fill form + submit — common for login, search, etc.
4724
4771
  export async function fillAndSubmit({ fields, submitSelector }) {
4725
4772
  await fillForm({ fields });
4773
+ const navIndex = _activeTabIndex;
4726
4774
  if (submitSelector) {
4727
- const sel = submitSelector.replace(/'/g, "\\'");
4775
+ const sel = submitSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
4728
4776
  await runJS(
4729
4777
  `(function(){var el=document.querySelector('${sel}');if(el)el.click();})()`
4730
4778
  );
@@ -4734,15 +4782,9 @@ export async function fillAndSubmit({ fields, submitSelector }) {
4734
4782
  `(function(){var btn=document.querySelector('[type=submit],button:not([type])');if(btn)btn.click();})()`
4735
4783
  );
4736
4784
  }
4737
- // Wait for navigation/reload via inline JS (not fixed sleep)
4738
- return runJS(
4739
- `(async function(){
4740
- await new Promise(function(r){setTimeout(r,300)});
4741
- for(var i=0;i<30;i++){if(document.readyState==='complete')break;await new Promise(function(r){setTimeout(r,200)});}
4742
- return JSON.stringify({title:document.title,url:location.href});
4743
- })()`,
4744
- { timeout: 15000 }
4745
- );
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);
4746
4788
  }
4747
4789
 
4748
4790
  // Full page analysis — extracts everything in ONE call