safari-mcp 2.2.0 → 2.3.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 +60 -1
- package/extension/background.js +7 -1
- package/index.js +21 -9
- package/mcp-helpers.js +1 -1
- package/package.json +1 -1
- package/safari.js +93 -34
package/README.md
CHANGED
|
@@ -69,7 +69,12 @@
|
|
|
69
69
|
npm install -g safari-mcp
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
**Option B —
|
|
72
|
+
**Option B — Homebrew:**
|
|
73
|
+
```bash
|
|
74
|
+
brew install achiya-automation/tap/safari-mcp
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
**Option C — from source:**
|
|
73
78
|
```bash
|
|
74
79
|
git clone https://github.com/achiya-automation/safari-mcp.git
|
|
75
80
|
cd safari-mcp
|
|
@@ -142,6 +147,29 @@ Add to your MCP client config:
|
|
|
142
147
|
|
|
143
148
|
---
|
|
144
149
|
|
|
150
|
+
## Usage Workflow
|
|
151
|
+
|
|
152
|
+
The recommended pattern for AI agents using Safari MCP:
|
|
153
|
+
|
|
154
|
+
```
|
|
155
|
+
1. safari_snapshot → Get page state (accessibility tree)
|
|
156
|
+
2. safari_click/fill/... → Interact with elements by ref
|
|
157
|
+
3. safari_snapshot → Verify the result
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
**Element targeting** — tools accept multiple targeting strategies:
|
|
161
|
+
|
|
162
|
+
| Strategy | Example | Best for |
|
|
163
|
+
|----------|---------|----------|
|
|
164
|
+
| CSS selector | `#login-btn`, `.submit` | Unique elements |
|
|
165
|
+
| Visible text | `"Sign In"`, `"Submit"` | Buttons, links |
|
|
166
|
+
| Coordinates | `x: 100, y: 200` | Canvas, custom widgets |
|
|
167
|
+
| Ref from snapshot | `ref: "e42"` | Any element from accessibility tree |
|
|
168
|
+
|
|
169
|
+
> **Tip:** Start with `safari_snapshot` to get element refs, then use refs for precise targeting. This is faster and more reliable than CSS selectors.
|
|
170
|
+
|
|
171
|
+
---
|
|
172
|
+
|
|
145
173
|
## Tools (80)
|
|
146
174
|
|
|
147
175
|
### Navigation (4)
|
|
@@ -317,6 +345,20 @@ Add to your MCP client config:
|
|
|
317
345
|
|
|
318
346
|
---
|
|
319
347
|
|
|
348
|
+
## Security
|
|
349
|
+
|
|
350
|
+
Safari MCP runs locally on your Mac with minimal attack surface:
|
|
351
|
+
|
|
352
|
+
| Aspect | Detail |
|
|
353
|
+
|--------|--------|
|
|
354
|
+
| Network | **No remote connections** — all communication is local (stdio + localhost) |
|
|
355
|
+
| Permissions | macOS system permissions required (Screen Recording for screenshots) |
|
|
356
|
+
| Data | No telemetry, no analytics, no data sent anywhere |
|
|
357
|
+
| Extension | Communicates only with `localhost:9224`, validated by Safari |
|
|
358
|
+
| Code | Fully open source (MIT) — audit every line |
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
320
362
|
## Safari MCP vs Alternatives
|
|
321
363
|
|
|
322
364
|
| Feature | Safari MCP | Chrome DevTools MCP | Playwright MCP |
|
|
@@ -335,6 +377,23 @@ Add to your MCP client config:
|
|
|
335
377
|
|
|
336
378
|
> **Tip:** Use Safari MCP for daily browsing tasks (95% of work) and Chrome DevTools MCP only for Lighthouse/Performance audits.
|
|
337
379
|
|
|
380
|
+
### Safari MCP Servers Comparison
|
|
381
|
+
|
|
382
|
+
| Feature | **safari-mcp** | MCPSafari | safari-mcp-server |
|
|
383
|
+
|---------|:--------------:|:---------:|:-----------------:|
|
|
384
|
+
| Tools | **80** | 23 | ~10 |
|
|
385
|
+
| License | **MIT** | None | MIT |
|
|
386
|
+
| Install | npm / Homebrew | Binary | npm |
|
|
387
|
+
| Storage (cookies, localStorage) | **10 tools** | None | None |
|
|
388
|
+
| Data extraction (tables, links) | **5 tools** | None | None |
|
|
389
|
+
| Network mocking | **Yes** | No | No |
|
|
390
|
+
| Device emulation | **Yes** | No | No |
|
|
391
|
+
| File upload (no dialog) | **Yes** | No | No |
|
|
392
|
+
| PDF export | **Yes** | No | No |
|
|
393
|
+
| Console capture | **4 tools** | 1 | No |
|
|
394
|
+
| Performance metrics | **Yes** | No | No |
|
|
395
|
+
| Fallback engine | **Dual (Extension + AppleScript)** | Extension only | WebDriver |
|
|
396
|
+
|
|
338
397
|
---
|
|
339
398
|
|
|
340
399
|
## Architecture
|
package/extension/background.js
CHANGED
|
@@ -1825,7 +1825,13 @@ async function execInAllFrames(func, args = [], tabId = null) {
|
|
|
1825
1825
|
}
|
|
1826
1826
|
}
|
|
1827
1827
|
|
|
1828
|
-
function waitForTabLoad(tabId, timeout = 30000) {
|
|
1828
|
+
async function waitForTabLoad(tabId, timeout = 30000) {
|
|
1829
|
+
// Check if already complete BEFORE registering listeners (prevents missing instant-complete events)
|
|
1830
|
+
try {
|
|
1831
|
+
const tab = await browser.tabs.get(tabId);
|
|
1832
|
+
if (tab.status === "complete") return;
|
|
1833
|
+
} catch { return; } // Tab already gone
|
|
1834
|
+
|
|
1829
1835
|
return new Promise((resolve) => {
|
|
1830
1836
|
function cleanup() {
|
|
1831
1837
|
clearTimeout(timer);
|
package/index.js
CHANGED
|
@@ -13,6 +13,9 @@ import { WebSocketServer } from "ws";
|
|
|
13
13
|
import { createServer } from "node:http";
|
|
14
14
|
import { randomUUID } from "node:crypto";
|
|
15
15
|
import { execFileSync } from "node:child_process";
|
|
16
|
+
import { readFileSync } from "node:fs";
|
|
17
|
+
import { dirname, join } from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
16
19
|
|
|
17
20
|
// ========== SINGLETON: kill stale instances from previous sessions ==========
|
|
18
21
|
// NOTE: Claude Code VSCode may start 2 instances simultaneously (~40ms apart).
|
|
@@ -66,11 +69,18 @@ function _untrackTab(tabIndex) {
|
|
|
66
69
|
async function _cleanupTabs() {
|
|
67
70
|
if (_openedTabs.size === 0) return;
|
|
68
71
|
console.error(`[Safari MCP] Cleanup: closing ${_openedTabs.size} MCP-opened tabs`);
|
|
69
|
-
|
|
70
|
-
|
|
72
|
+
// Close by URL (not index) — indices shift as tabs are closed
|
|
73
|
+
const urlsToClose = [..._openedTabs.values()].map(v => v.url).filter(Boolean);
|
|
74
|
+
for (const url of urlsToClose) {
|
|
71
75
|
try {
|
|
72
|
-
|
|
73
|
-
await safari.
|
|
76
|
+
// Re-resolve index by URL before each close (indices shift after each closure)
|
|
77
|
+
const tabs = await safari.listTabs();
|
|
78
|
+
const parsed = typeof tabs === 'string' ? JSON.parse(tabs) : tabs;
|
|
79
|
+
const match = parsed.find(t => t.url === url);
|
|
80
|
+
if (match) {
|
|
81
|
+
safari.setActiveTabIndex(match.index);
|
|
82
|
+
await safari.closeTab();
|
|
83
|
+
}
|
|
74
84
|
} catch {}
|
|
75
85
|
}
|
|
76
86
|
_openedTabs.clear();
|
|
@@ -286,8 +296,8 @@ try {
|
|
|
286
296
|
try {
|
|
287
297
|
const { nonce, expectedProfile } = JSON.parse(body);
|
|
288
298
|
// Use AppleScript to find which window contains the nonce in a tab title
|
|
289
|
-
const safeNonce = nonce.replace(/
|
|
290
|
-
const safeProfile = (expectedProfile || "").replace(/
|
|
299
|
+
const safeNonce = String(nonce).replace(/[^0-9]/g, ''); // nonce is numeric only
|
|
300
|
+
const safeProfile = (expectedProfile || "").replace(/[^\p{L}\p{N}\s\-_]/gu, ''); // whitelist: letters, numbers, spaces, hyphens, underscores
|
|
291
301
|
// Check via AppleScript — look for the nonce in the profile window
|
|
292
302
|
const { execFile } = await import("node:child_process");
|
|
293
303
|
const { promisify } = await import("node:util");
|
|
@@ -494,7 +504,7 @@ function sendToExtension(type, payload = {}, timeoutMs = 30000) {
|
|
|
494
504
|
// Per-command timeouts — fast commands get short timeouts, nav/screenshot get longer ones
|
|
495
505
|
const _commandTimeouts = {
|
|
496
506
|
click: 10000, fill: 5000, read_page: 30000, get_source: 10000, evaluate: 30000,
|
|
497
|
-
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element:
|
|
507
|
+
type_text: 5000, press_key: 5000, scroll: 3000, scroll_to: 3000, scroll_to_element: 15000,
|
|
498
508
|
hover: 5000, list_tabs: 5000, new_tab: 15000, close_tab: 5000, switch_tab: 30000,
|
|
499
509
|
wait_for: 30000, navigate: 30000, navigate_and_read: 30000, go_back: 10000, go_forward: 10000,
|
|
500
510
|
reload: 15000, screenshot: 15000, snapshot: 30000, click_and_read: 15000,
|
|
@@ -547,9 +557,11 @@ async function extensionOrFallback(extensionType, extensionPayload, fallbackFn)
|
|
|
547
557
|
return result;
|
|
548
558
|
}
|
|
549
559
|
|
|
560
|
+
// Read version from package.json to avoid hardcoded mismatch
|
|
561
|
+
const _pkgVersion = JSON.parse(readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'package.json'), 'utf8')).version;
|
|
550
562
|
const server = new McpServer({
|
|
551
563
|
name: "safari-mcp",
|
|
552
|
-
version:
|
|
564
|
+
version: _pkgVersion,
|
|
553
565
|
description: "Safari browser automation - lightweight, keeps logins",
|
|
554
566
|
});
|
|
555
567
|
|
|
@@ -639,7 +651,7 @@ server.tool(
|
|
|
639
651
|
const gen = safari.getNextSnapshotGen();
|
|
640
652
|
const result = await extensionOrFallback(
|
|
641
653
|
"snapshot", { selector: args.selector, gen },
|
|
642
|
-
() => safari.takeSnapshot(args)
|
|
654
|
+
() => safari.takeSnapshot({ ...args, _gen: gen })
|
|
643
655
|
);
|
|
644
656
|
return { content: [{ type: "text", text: typeof result === 'string' ? result : JSON.stringify(result) }] };
|
|
645
657
|
}
|
package/mcp-helpers.js
CHANGED
|
@@ -67,7 +67,7 @@ if (window.__mcpVersion !== 5) {
|
|
|
67
67
|
window.mcpIsVisible = function(el) {
|
|
68
68
|
if (!el || el.nodeType !== 1 || !el.isConnected) return false;
|
|
69
69
|
var cs = window.getComputedStyle(el);
|
|
70
|
-
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || cs.
|
|
70
|
+
if (!cs || cs.display === 'none' || cs.visibility === 'hidden' || cs.visibility === 'collapse' || parseFloat(cs.opacity) === 0) return false;
|
|
71
71
|
var r = el.getBoundingClientRect();
|
|
72
72
|
return r.width > 0 && r.height > 0;
|
|
73
73
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safari-mcp",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.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
|
@@ -19,6 +19,7 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
19
19
|
// Persistent process — no subprocess spawn overhead (~5ms vs ~90ms)
|
|
20
20
|
let _helperProc = null;
|
|
21
21
|
const _helperQueue = []; // callbacks waiting for responses
|
|
22
|
+
let _helperConsecutiveTimeouts = 0; // Track consecutive timeouts — only kill after 3
|
|
22
23
|
|
|
23
24
|
// Reject all pending callbacks when helper crashes
|
|
24
25
|
function _drainHelperQueue(reason) {
|
|
@@ -336,9 +337,15 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
336
337
|
resolved = true;
|
|
337
338
|
const idx = _helperQueue.indexOf(cb);
|
|
338
339
|
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
340
|
+
_helperConsecutiveTimeouts++;
|
|
341
|
+
// Only kill the daemon after 3 consecutive timeouts (single slow script shouldn't kill concurrent ops)
|
|
342
|
+
if (_helperConsecutiveTimeouts >= 3) {
|
|
343
|
+
console.error(`[Safari MCP] safari-helper: ${_helperConsecutiveTimeouts} consecutive timeouts — killing daemon`);
|
|
344
|
+
_helperProc?.kill();
|
|
345
|
+
_helperProc = null;
|
|
346
|
+
_helperConsecutiveTimeouts = 0;
|
|
347
|
+
setTimeout(startHelper, 100);
|
|
348
|
+
}
|
|
342
349
|
reject(new Error("safari-helper timeout"));
|
|
343
350
|
}, timeout);
|
|
344
351
|
|
|
@@ -346,6 +353,7 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
346
353
|
if (resolved) return;
|
|
347
354
|
resolved = true;
|
|
348
355
|
clearTimeout(timer);
|
|
356
|
+
_helperConsecutiveTimeouts = 0; // Reset on success
|
|
349
357
|
try {
|
|
350
358
|
const parsed = JSON.parse(line);
|
|
351
359
|
if (parsed.error) reject(new Error(parsed.error));
|
|
@@ -355,14 +363,21 @@ function _osascriptFastHelper(script, timeout) {
|
|
|
355
363
|
}
|
|
356
364
|
}
|
|
357
365
|
|
|
358
|
-
|
|
359
|
-
if (!_helperProc || !_helperProc.stdin) {
|
|
360
|
-
|
|
361
|
-
_helperQueue.pop(); // Remove the callback we just pushed
|
|
366
|
+
// Check process availability BEFORE pushing to queue (prevents dangling callbacks)
|
|
367
|
+
if (!_helperProc || !_helperProc.stdin || !_helperProc.stdin.writable) {
|
|
368
|
+
clearTimeout(timer);
|
|
362
369
|
reject(new Error("safari-helper not available"));
|
|
363
370
|
return;
|
|
364
371
|
}
|
|
365
|
-
|
|
372
|
+
_helperQueue.push(cb);
|
|
373
|
+
try {
|
|
374
|
+
_helperProc.stdin.write(JSON.stringify({ script }) + "\n");
|
|
375
|
+
} catch (writeErr) {
|
|
376
|
+
const idx = _helperQueue.indexOf(cb);
|
|
377
|
+
if (idx >= 0) _helperQueue.splice(idx, 1);
|
|
378
|
+
clearTimeout(timer);
|
|
379
|
+
reject(new Error("safari-helper write failed: " + writeErr.message));
|
|
380
|
+
}
|
|
366
381
|
});
|
|
367
382
|
}
|
|
368
383
|
|
|
@@ -623,17 +638,21 @@ export async function navigate(url) {
|
|
|
623
638
|
|
|
624
639
|
export async function goBack() {
|
|
625
640
|
// Navigate back + smart wait: check immediately, then poll only if loading
|
|
626
|
-
|
|
641
|
+
const result = await runJS(
|
|
627
642
|
`(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});})()`,
|
|
628
643
|
{ timeout: 10000 }
|
|
629
644
|
);
|
|
645
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
646
|
+
return result;
|
|
630
647
|
}
|
|
631
648
|
|
|
632
649
|
export async function goForward() {
|
|
633
|
-
|
|
650
|
+
const result = await runJS(
|
|
634
651
|
`(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});})()`,
|
|
635
652
|
{ timeout: 10000 }
|
|
636
653
|
);
|
|
654
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
655
|
+
return result;
|
|
637
656
|
}
|
|
638
657
|
|
|
639
658
|
export async function reload(hardReload = false) {
|
|
@@ -641,10 +660,12 @@ export async function reload(hardReload = false) {
|
|
|
641
660
|
await runJS(hardReload ? "location.reload(true)" : "location.reload()");
|
|
642
661
|
await new Promise((r) => setTimeout(r, 100)); // Brief wait for reload to start (was 500ms)
|
|
643
662
|
// Poll readyState in a single call
|
|
644
|
-
|
|
663
|
+
const result = await runJS(
|
|
645
664
|
`(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});})()`,
|
|
646
665
|
{ timeout: 10000 }
|
|
647
666
|
);
|
|
667
|
+
try { const p = JSON.parse(result); if (p.url) _activeTabURL = p.url; } catch {}
|
|
668
|
+
return result;
|
|
648
669
|
}
|
|
649
670
|
|
|
650
671
|
// ========== PAGE INFO ==========
|
|
@@ -1239,7 +1260,7 @@ export async function fill({ selector, value, ref }) {
|
|
|
1239
1260
|
|
|
1240
1261
|
const fillResult = await runJS(
|
|
1241
1262
|
`(function(){` +
|
|
1242
|
-
`var el=document.querySelector('${selector.replace(/'/g, "\\'")}');` +
|
|
1263
|
+
`var el=document.querySelector('${selector.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}');` +
|
|
1243
1264
|
`if(!el)return 'Element not found';` +
|
|
1244
1265
|
`el.focus();el.click();` +
|
|
1245
1266
|
`var sel=window.getSelection();if(sel.rangeCount){var r=document.createRange();r.selectNodeContents(el);r.collapse(false);sel.removeAllRanges();sel.addRange(r);}` +
|
|
@@ -1624,15 +1645,19 @@ export async function screenshot({ fullPage = false } = {}) {
|
|
|
1624
1645
|
await osascript(
|
|
1625
1646
|
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {0, 0, ${Number(w)}, ${Math.min(Number(h) + 100, 5000)}}`
|
|
1626
1647
|
);
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1648
|
+
try {
|
|
1649
|
+
await new Promise((r) => setTimeout(r, 500));
|
|
1650
|
+
// Use do shell script to inherit osascript's Screen Recording permission
|
|
1651
|
+
await osascript(
|
|
1652
|
+
`do shell script "screencapture -l${windowId} -o -x '${tmpFile}'"`,
|
|
1653
|
+
{ timeout: 15000 }
|
|
1654
|
+
);
|
|
1655
|
+
} finally {
|
|
1656
|
+
// Always restore bounds — even if screencapture fails
|
|
1657
|
+
await osascript(
|
|
1658
|
+
`tell application "Safari" to set bounds of ${getTargetWindowRef()} to {${bounds}}`
|
|
1659
|
+
).catch(() => {});
|
|
1660
|
+
}
|
|
1636
1661
|
} else {
|
|
1637
1662
|
// Try direct execFile first (works if VS Code has Screen Recording permission)
|
|
1638
1663
|
try {
|
|
@@ -1867,8 +1892,9 @@ export async function newTab(url = "") {
|
|
|
1867
1892
|
else { await osascript('tell application "Safari" to make new document'); }
|
|
1868
1893
|
}
|
|
1869
1894
|
}
|
|
1895
|
+
// Get count atomically from the same tell block as tab creation (avoids TOCTOU if user opens tabs concurrently)
|
|
1870
1896
|
const tabCount = await osascriptFast(`tell application "Safari" to return count of tabs of ${getTargetWindowRef()}`);
|
|
1871
|
-
_activeTabIndex = Number(tabCount);
|
|
1897
|
+
_activeTabIndex = Number(tabCount); // New tab is always appended as last
|
|
1872
1898
|
_activeTabURL = url || null;
|
|
1873
1899
|
_lastResolveTime = Date.now();
|
|
1874
1900
|
// Wait for page load if URL given
|
|
@@ -2058,11 +2084,11 @@ export async function handleDialog({ action = "accept", text }) {
|
|
|
2058
2084
|
}
|
|
2059
2085
|
if (action === "accept") {
|
|
2060
2086
|
await runJS(
|
|
2061
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
2087
|
+
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return true;};window.__origAlert=window.__origAlert||window.alert;window.alert=function(){window.alert=window.__origAlert;};"
|
|
2062
2088
|
);
|
|
2063
2089
|
} else {
|
|
2064
2090
|
await runJS(
|
|
2065
|
-
"window.__origConfirm=window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2091
|
+
"window.__origConfirm=window.__origConfirm||window.confirm;window.confirm=function(){window.confirm=window.__origConfirm;return false;};"
|
|
2066
2092
|
);
|
|
2067
2093
|
}
|
|
2068
2094
|
return `Dialog handler set: ${action}${text ? ' with "' + text + '"' : ""}`;
|
|
@@ -2106,19 +2132,23 @@ export async function getNetworkRequests({ limit = 50 } = {}) {
|
|
|
2106
2132
|
|
|
2107
2133
|
export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, targetX, targetY }) {
|
|
2108
2134
|
if (sourceSelector && targetSelector) {
|
|
2109
|
-
const srcSel = sourceSelector.replace(/'/g, "\\'");
|
|
2110
|
-
const tgtSel = targetSelector.replace(/'/g, "\\'");
|
|
2135
|
+
const srcSel = sourceSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2136
|
+
const tgtSel = targetSelector.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
|
|
2111
2137
|
return runJS(
|
|
2112
2138
|
`(function(){` +
|
|
2113
2139
|
`var src=document.querySelector('${srcSel}');var tgt=document.querySelector('${tgtSel}');` +
|
|
2114
2140
|
`if(!src)return 'Source not found: ${srcSel}';if(!tgt)return 'Target not found: ${tgtSel}';` +
|
|
2115
2141
|
`var sr=src.getBoundingClientRect();var tr=tgt.getBoundingClientRect();` +
|
|
2116
2142
|
`var sx=sr.x+sr.width/2,sy=sr.y+sr.height/2,tx=tr.x+tr.width/2,ty=tr.y+tr.height/2;` +
|
|
2143
|
+
`var dt=new DataTransfer();` +
|
|
2144
|
+
`src.dispatchEvent(new DragEvent('dragstart',{clientX:sx,clientY:sy,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2117
2145
|
`src.dispatchEvent(new MouseEvent('mousedown',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2118
2146
|
`src.dispatchEvent(new MouseEvent('mousemove',{clientX:sx,clientY:sy,bubbles:true}));` +
|
|
2147
|
+
`tgt.dispatchEvent(new DragEvent('dragover',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2119
2148
|
`tgt.dispatchEvent(new MouseEvent('mousemove',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2120
2149
|
`tgt.dispatchEvent(new MouseEvent('mouseup',{clientX:tx,clientY:ty,bubbles:true}));` +
|
|
2121
|
-
`tgt.dispatchEvent(new DragEvent('drop',{bubbles:true}));` +
|
|
2150
|
+
`tgt.dispatchEvent(new DragEvent('drop',{clientX:tx,clientY:ty,bubbles:true,cancelable:true,dataTransfer:dt}));` +
|
|
2151
|
+
`src.dispatchEvent(new DragEvent('dragend',{bubbles:true}));` +
|
|
2122
2152
|
`return 'Dragged from '+src.tagName+' to '+tgt.tagName;})()`
|
|
2123
2153
|
);
|
|
2124
2154
|
}
|
|
@@ -2137,9 +2167,27 @@ export async function drag({ sourceSelector, targetSelector, sourceX, sourceY, t
|
|
|
2137
2167
|
throw new Error("drag requires sourceSelector+targetSelector or sourceX/Y+targetX/Y");
|
|
2138
2168
|
}
|
|
2139
2169
|
|
|
2170
|
+
// ========== FILE PATH SAFETY ==========
|
|
2171
|
+
// Prevent reading sensitive system files via upload/paste tools
|
|
2172
|
+
function _validateFilePath(filePath) {
|
|
2173
|
+
const { resolve } = require("node:path");
|
|
2174
|
+
const resolved = resolve(filePath);
|
|
2175
|
+
if (resolved.includes('..')) throw new Error("Path traversal not allowed: " + filePath);
|
|
2176
|
+
const blocked = ['.ssh', '.gnupg', '.aws', '.config/gcloud', 'credentials', '.env', '.npmrc', '.netrc', 'id_rsa', 'id_ed25519', '.keychain'];
|
|
2177
|
+
const lower = resolved.toLowerCase();
|
|
2178
|
+
for (const b of blocked) {
|
|
2179
|
+
if (lower.includes(b)) throw new Error("Blocked: reading sensitive path " + filePath);
|
|
2180
|
+
}
|
|
2181
|
+
// Must be under /Users/ or /tmp/ or /var/folders/ (macOS temp)
|
|
2182
|
+
if (!resolved.startsWith('/Users/') && !resolved.startsWith('/tmp/') && !resolved.startsWith('/var/folders/') && !resolved.startsWith('/private/tmp/')) {
|
|
2183
|
+
throw new Error("File path must be under /Users/, /tmp/, or /var/folders/: " + filePath);
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
|
|
2140
2187
|
// ========== UPLOAD FILE ==========
|
|
2141
2188
|
|
|
2142
2189
|
export async function uploadFile({ selector, filePath }) {
|
|
2190
|
+
_validateFilePath(filePath);
|
|
2143
2191
|
// Read file in Node.js, send as base64 to Safari JS, create File + DataTransfer
|
|
2144
2192
|
// NO file dialog, NO System Events, NO focus stealing
|
|
2145
2193
|
|
|
@@ -2257,6 +2305,7 @@ export async function uploadFile({ selector, filePath }) {
|
|
|
2257
2305
|
// ========== PASTE IMAGE FROM FILE ==========
|
|
2258
2306
|
|
|
2259
2307
|
export async function pasteImageFromFile({ filePath }) {
|
|
2308
|
+
_validateFilePath(filePath);
|
|
2260
2309
|
// Paste image via JS ClipboardEvent — NO clipboard touch, NO System Events, NO focus steal
|
|
2261
2310
|
const { extname } = await import("node:path");
|
|
2262
2311
|
const ext = extname(filePath).toLowerCase().replace(".", "");
|
|
@@ -2357,9 +2406,9 @@ export async function emulate({ device, width, height, userAgent, scale = 1 }) {
|
|
|
2357
2406
|
|
|
2358
2407
|
export async function resetEmulation() {
|
|
2359
2408
|
await refreshTargetWindow();
|
|
2360
|
-
// Reset user agent
|
|
2409
|
+
// Reset user agent — remove the defineProperty override set by emulate()
|
|
2361
2410
|
await runJS(
|
|
2362
|
-
"
|
|
2411
|
+
"try{var d=Object.getOwnPropertyDescriptor(Navigator.prototype,'userAgent');if(d){Object.defineProperty(navigator,'userAgent',d);}else{delete navigator.userAgent;}}catch(_){}"
|
|
2363
2412
|
);
|
|
2364
2413
|
// Maximize window
|
|
2365
2414
|
await osascript(
|
|
@@ -2445,10 +2494,14 @@ export async function savePDF({ path: pdfPath }) {
|
|
|
2445
2494
|
// instead of guessing CSS selectors. Much faster, no hallucination risk.
|
|
2446
2495
|
|
|
2447
2496
|
let _snapshotGen = 0;
|
|
2497
|
+
// getNextSnapshotGen is used by the MCP tool path (index.js) to reserve a gen for the extension.
|
|
2498
|
+
// If the extension fails and falls back to takeSnapshot(), takeSnapshot uses _snapshotGen directly
|
|
2499
|
+
// (which was already incremented by getNextSnapshotGen) — so no double-increment occurs.
|
|
2448
2500
|
export function getNextSnapshotGen() { return _snapshotGen++; }
|
|
2449
2501
|
|
|
2450
|
-
export async function takeSnapshot({ selector } = {}) {
|
|
2451
|
-
|
|
2502
|
+
export async function takeSnapshot({ selector, _gen } = {}) {
|
|
2503
|
+
// Use provided gen (from tool path) or allocate a new one (direct call)
|
|
2504
|
+
const gen = _gen != null ? _gen : _snapshotGen++;
|
|
2452
2505
|
const root = selector ? `document.querySelector('${selector.replace(/'/g, "\\'")}')` : "document.body";
|
|
2453
2506
|
|
|
2454
2507
|
const result = await runJS(
|
|
@@ -2817,7 +2870,12 @@ export async function importStorageState({ state }) {
|
|
|
2817
2870
|
cmds.push(`sessionStorage.setItem('${esc(k)}','${esc(v)}')`);
|
|
2818
2871
|
}
|
|
2819
2872
|
}
|
|
2820
|
-
|
|
2873
|
+
// Use runJSLarge for large sessions (many cookies/localStorage keys can exceed 260KB limit of runJS)
|
|
2874
|
+
const script = cmds.join(";") + "; 'Imported ' + " + cmds.length + " + ' items'";
|
|
2875
|
+
if (script.length > 200000) {
|
|
2876
|
+
return runJSLarge(script, { timeout: 30000 });
|
|
2877
|
+
}
|
|
2878
|
+
return runJS(script);
|
|
2821
2879
|
}
|
|
2822
2880
|
|
|
2823
2881
|
// ========== CLIPBOARD ==========
|
|
@@ -2939,8 +2997,9 @@ export async function clearNetworkMocks() {
|
|
|
2939
2997
|
// ========== WAIT FOR TIME ==========
|
|
2940
2998
|
|
|
2941
2999
|
export async function waitForTime({ ms }) {
|
|
2942
|
-
|
|
2943
|
-
|
|
3000
|
+
const capped = Math.min(Number(ms) || 0, 60000); // Cap at 60 seconds
|
|
3001
|
+
await new Promise((r) => setTimeout(r, capped));
|
|
3002
|
+
return capped < Number(ms) ? `Waited ${capped}ms (capped from ${ms}ms — max 60s)` : `Waited ${ms}ms`;
|
|
2944
3003
|
}
|
|
2945
3004
|
|
|
2946
3005
|
// ========== NETWORK CAPTURE (Detailed) ==========
|