browserclaw 0.4.2 → 0.5.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/dist/index.js CHANGED
@@ -1,11 +1,14 @@
1
1
  import os from 'os';
2
- import path, { normalize, resolve, dirname, sep } from 'path';
2
+ import path, { posix, win32, resolve, dirname, join, basename, relative, sep, normalize } from 'path';
3
3
  import fs from 'fs';
4
4
  import net from 'net';
5
5
  import { spawn, execFileSync } from 'child_process';
6
6
  import { devices, chromium } from 'playwright-core';
7
- import { lookup } from 'dns/promises';
8
- import { lstat, realpath } from 'fs/promises';
7
+ import { lookup as lookup$1 } from 'dns/promises';
8
+ import { lookup } from 'dns';
9
+ import { realpath, rename, rm, lstat } from 'fs/promises';
10
+ import { randomUUID } from 'crypto';
11
+ import * as ipaddr from 'ipaddr.js';
9
12
 
10
13
  // src/chrome-launcher.ts
11
14
  var CHROMIUM_BUNDLE_IDS = /* @__PURE__ */ new Set([
@@ -615,8 +618,31 @@ async function stopChrome(running, timeoutMs = 2500) {
615
618
  var cached = null;
616
619
  var connectingByUrl = /* @__PURE__ */ new Map();
617
620
  var pageStates = /* @__PURE__ */ new WeakMap();
621
+ var contextStates = /* @__PURE__ */ new WeakMap();
618
622
  var observedContexts = /* @__PURE__ */ new WeakSet();
619
623
  var observedPages = /* @__PURE__ */ new WeakSet();
624
+ var nextUploadArmId = 0;
625
+ var nextDialogArmId = 0;
626
+ var nextDownloadArmId = 0;
627
+ function bumpUploadArmId() {
628
+ nextUploadArmId += 1;
629
+ return nextUploadArmId;
630
+ }
631
+ function bumpDialogArmId() {
632
+ nextDialogArmId += 1;
633
+ return nextDialogArmId;
634
+ }
635
+ function bumpDownloadArmId() {
636
+ nextDownloadArmId += 1;
637
+ return nextDownloadArmId;
638
+ }
639
+ function ensureContextState(context) {
640
+ const existing = contextStates.get(context);
641
+ if (existing) return existing;
642
+ const state = { traceActive: false };
643
+ contextStates.set(context, state);
644
+ return state;
645
+ }
620
646
  var roleRefsByTarget = /* @__PURE__ */ new Map();
621
647
  var MAX_ROLE_REFS_CACHE = 50;
622
648
  var MAX_CONSOLE_MESSAGES = 500;
@@ -636,7 +662,10 @@ function ensurePageState(page) {
636
662
  errors: [],
637
663
  requests: [],
638
664
  requestIds: /* @__PURE__ */ new WeakMap(),
639
- nextRequestId: 0
665
+ nextRequestId: 0,
666
+ armIdUpload: 0,
667
+ armIdDialog: 0,
668
+ armIdDownload: 0
640
669
  };
641
670
  pageStates.set(page, state);
642
671
  if (!observedPages.has(page)) {
@@ -713,6 +742,7 @@ function applyStealthToPage(page) {
713
742
  function observeContext(context) {
714
743
  if (observedContexts.has(context)) return;
715
744
  observedContexts.add(context);
745
+ ensureContextState(context);
716
746
  context.addInitScript(STEALTH_SCRIPT).catch((e) => {
717
747
  if (process.env.DEBUG) console.warn("[browserclaw] stealth initScript failed:", e.message);
718
748
  });
@@ -808,6 +838,35 @@ async function disconnectBrowser() {
808
838
  if (cur) await cur.browser.close().catch(() => {
809
839
  });
810
840
  }
841
+ async function forceDisconnectPlaywrightForTarget(opts) {
842
+ const normalized = normalizeCdpUrl(opts.cdpUrl);
843
+ const cur = cached;
844
+ if (!cur || cur.cdpUrl !== normalized) return;
845
+ cached = null;
846
+ connectingByUrl.delete(normalized);
847
+ const targetId = opts.targetId?.trim() || "";
848
+ if (targetId) {
849
+ try {
850
+ const pages = await getAllPages(cur.browser);
851
+ for (const page of pages) {
852
+ const tid = await pageTargetId(page).catch(() => null);
853
+ if (tid === targetId) {
854
+ const session = await page.context().newCDPSession(page);
855
+ try {
856
+ await session.send("Runtime.terminateExecution");
857
+ } finally {
858
+ await session.detach().catch(() => {
859
+ });
860
+ }
861
+ break;
862
+ }
863
+ }
864
+ } catch {
865
+ }
866
+ }
867
+ cur.browser.close().catch(() => {
868
+ });
869
+ }
811
870
  async function getAllPages(browser) {
812
871
  return browser.contexts().flatMap((c) => c.pages());
813
872
  }
@@ -1329,6 +1388,11 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
1329
1388
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1330
1389
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
1331
1390
  var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1391
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
1392
+ "localhost",
1393
+ "localhost.localdomain",
1394
+ "metadata.google.internal"
1395
+ ]);
1332
1396
  function isAllowedNonNetworkNavigationUrl(parsed) {
1333
1397
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
1334
1398
  }
@@ -1342,6 +1406,218 @@ function hasProxyEnvConfigured(env = process.env) {
1342
1406
  }
1343
1407
  return false;
1344
1408
  }
1409
+ function normalizeHostname(hostname) {
1410
+ let h = String(hostname ?? "").trim().toLowerCase();
1411
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
1412
+ if (h.endsWith(".")) h = h.slice(0, -1);
1413
+ return h;
1414
+ }
1415
+ function isBlockedHostnameNormalized(normalized) {
1416
+ if (BLOCKED_HOSTNAMES.has(normalized)) return true;
1417
+ return normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal");
1418
+ }
1419
+ function isStrictDecimalOctet(part) {
1420
+ if (!/^[0-9]+$/.test(part)) return false;
1421
+ const n = parseInt(part, 10);
1422
+ if (n < 0 || n > 255) return false;
1423
+ if (String(n) !== part) return false;
1424
+ return true;
1425
+ }
1426
+ function isUnsupportedIPv4Literal(ip) {
1427
+ if (/^[0-9]+$/.test(ip)) return true;
1428
+ const parts = ip.split(".");
1429
+ if (parts.length !== 4) return true;
1430
+ if (!parts.every(isStrictDecimalOctet)) return true;
1431
+ return false;
1432
+ }
1433
+ var BLOCKED_IPV4_RANGES = /* @__PURE__ */ new Set([
1434
+ "unspecified",
1435
+ "broadcast",
1436
+ "multicast",
1437
+ "linkLocal",
1438
+ "loopback",
1439
+ "carrierGradeNat",
1440
+ "private",
1441
+ "reserved"
1442
+ ]);
1443
+ var BLOCKED_IPV6_RANGES = /* @__PURE__ */ new Set([
1444
+ "unspecified",
1445
+ "loopback",
1446
+ "linkLocal",
1447
+ "uniqueLocal",
1448
+ "multicast"
1449
+ ]);
1450
+ var RFC2544_BENCHMARK_PREFIX = [ipaddr.IPv4.parse("198.18.0.0"), 15];
1451
+ function isBlockedSpecialUseIpv4Address(address, opts) {
1452
+ const inRfc2544 = address.match(RFC2544_BENCHMARK_PREFIX);
1453
+ if (inRfc2544 && opts?.allowRfc2544BenchmarkRange === true) return false;
1454
+ return BLOCKED_IPV4_RANGES.has(address.range()) || inRfc2544;
1455
+ }
1456
+ function isBlockedSpecialUseIpv6Address(address) {
1457
+ if (BLOCKED_IPV6_RANGES.has(address.range())) return true;
1458
+ return (address.parts[0] & 65472) === 65216;
1459
+ }
1460
+ function extractEmbeddedIpv4FromIpv6(v6, opts) {
1461
+ if (v6.isIPv4MappedAddress()) {
1462
+ return isBlockedSpecialUseIpv4Address(v6.toIPv4Address(), opts);
1463
+ }
1464
+ const parts = v6.parts;
1465
+ if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1466
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1467
+ try {
1468
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1469
+ } catch {
1470
+ return true;
1471
+ }
1472
+ }
1473
+ if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 1) {
1474
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1475
+ try {
1476
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1477
+ } catch {
1478
+ return true;
1479
+ }
1480
+ }
1481
+ if (parts[0] === 8194) {
1482
+ const ip4str = `${parts[1] >> 8 & 255}.${parts[1] & 255}.${parts[2] >> 8 & 255}.${parts[2] & 255}`;
1483
+ try {
1484
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1485
+ } catch {
1486
+ return true;
1487
+ }
1488
+ }
1489
+ if (parts[0] === 8193 && parts[1] === 0) {
1490
+ const hiXored = parts[6] ^ 65535;
1491
+ const loXored = parts[7] ^ 65535;
1492
+ const ip4str = `${hiXored >> 8 & 255}.${hiXored & 255}.${loXored >> 8 & 255}.${loXored & 255}`;
1493
+ try {
1494
+ return isBlockedSpecialUseIpv4Address(ipaddr.IPv4.parse(ip4str), opts);
1495
+ } catch {
1496
+ return true;
1497
+ }
1498
+ }
1499
+ return null;
1500
+ }
1501
+ function isPrivateIpAddress(address, opts) {
1502
+ let normalized = address.trim().toLowerCase();
1503
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
1504
+ if (!normalized) return false;
1505
+ try {
1506
+ const parsed = ipaddr.parse(normalized);
1507
+ if (parsed.kind() === "ipv4") {
1508
+ return isBlockedSpecialUseIpv4Address(parsed, opts);
1509
+ }
1510
+ const v6 = parsed;
1511
+ if (isBlockedSpecialUseIpv6Address(v6)) return true;
1512
+ const embeddedV4 = extractEmbeddedIpv4FromIpv6(v6, opts);
1513
+ if (embeddedV4 !== null) return embeddedV4;
1514
+ return false;
1515
+ } catch {
1516
+ }
1517
+ if (!normalized.includes(":") && isUnsupportedIPv4Literal(normalized)) return true;
1518
+ if (normalized.includes(":")) return true;
1519
+ return false;
1520
+ }
1521
+ function dedupeAndPreferIpv4(results) {
1522
+ const seen = /* @__PURE__ */ new Set();
1523
+ const ipv4 = [];
1524
+ const ipv6 = [];
1525
+ for (const r of results) {
1526
+ if (seen.has(r.address)) continue;
1527
+ seen.add(r.address);
1528
+ if (r.family === 4) ipv4.push(r.address);
1529
+ else ipv6.push(r.address);
1530
+ }
1531
+ return [...ipv4, ...ipv6];
1532
+ }
1533
+ function createPinnedLookup(params) {
1534
+ const normalizedHost = normalizeHostname(params.hostname);
1535
+ const fallback = params.fallback ?? lookup;
1536
+ const records = params.addresses.map((address) => ({
1537
+ address,
1538
+ family: address.includes(":") ? 6 : 4
1539
+ }));
1540
+ let index = 0;
1541
+ return ((host, options, callback) => {
1542
+ const cb = typeof options === "function" ? options : callback;
1543
+ if (!cb) return;
1544
+ const normalized = normalizeHostname(host);
1545
+ if (!normalized || normalized !== normalizedHost) {
1546
+ if (typeof options === "function" || options === void 0) return fallback(host, cb);
1547
+ return fallback(host, options, cb);
1548
+ }
1549
+ const opts = typeof options === "object" && options !== null ? options : {};
1550
+ const requestedFamily = typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
1551
+ const candidates = requestedFamily === 4 || requestedFamily === 6 ? records.filter((entry) => entry.family === requestedFamily) : records;
1552
+ const usable = candidates.length > 0 ? candidates : records;
1553
+ if (opts.all) {
1554
+ cb(null, usable);
1555
+ return;
1556
+ }
1557
+ const chosen = usable[index % usable.length];
1558
+ index += 1;
1559
+ cb(null, chosen.address, chosen.family);
1560
+ });
1561
+ }
1562
+ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1563
+ const normalized = normalizeHostname(hostname);
1564
+ if (!normalized) throw new InvalidBrowserNavigationUrlError(`Invalid hostname: "${hostname}"`);
1565
+ const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy);
1566
+ const allowedHostnames = [
1567
+ ...params.policy?.allowedHostnames ?? [],
1568
+ ...params.policy?.hostnameAllowlist ?? []
1569
+ ].map((h) => normalizeHostname(h));
1570
+ const isExplicitlyAllowed = allowedHostnames.some((h) => h === normalized);
1571
+ const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitlyAllowed;
1572
+ if (!skipPrivateNetworkChecks) {
1573
+ if (isBlockedHostnameNormalized(normalized)) {
1574
+ throw new InvalidBrowserNavigationUrlError(
1575
+ `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1576
+ );
1577
+ }
1578
+ const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1579
+ if (isPrivateIpAddress(normalized, ipOpts)) {
1580
+ throw new InvalidBrowserNavigationUrlError(
1581
+ `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1582
+ );
1583
+ }
1584
+ }
1585
+ const lookupFn = params.lookupFn ?? lookup$1;
1586
+ let results;
1587
+ try {
1588
+ results = await lookupFn(normalized, { all: true });
1589
+ } catch {
1590
+ throw new InvalidBrowserNavigationUrlError(
1591
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1592
+ );
1593
+ }
1594
+ if (!results || results.length === 0) {
1595
+ throw new InvalidBrowserNavigationUrlError(
1596
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1597
+ );
1598
+ }
1599
+ if (!skipPrivateNetworkChecks) {
1600
+ const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1601
+ for (const r of results) {
1602
+ if (isPrivateIpAddress(r.address, ipOpts)) {
1603
+ throw new InvalidBrowserNavigationUrlError(
1604
+ `Navigation to internal/loopback address blocked: "${hostname}" resolves to "${r.address}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1605
+ );
1606
+ }
1607
+ }
1608
+ }
1609
+ const addresses = dedupeAndPreferIpv4(results);
1610
+ if (addresses.length === 0) {
1611
+ throw new InvalidBrowserNavigationUrlError(
1612
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}".`
1613
+ );
1614
+ }
1615
+ return {
1616
+ hostname: normalized,
1617
+ addresses,
1618
+ lookup: createPinnedLookup({ hostname: normalized, addresses })
1619
+ };
1620
+ }
1345
1621
  async function assertBrowserNavigationAllowed(opts) {
1346
1622
  const rawUrl = String(opts.url ?? "").trim();
1347
1623
  let parsed;
@@ -1361,20 +1637,10 @@ async function assertBrowserNavigationAllowed(opts) {
1361
1637
  }
1362
1638
  const policy = opts.ssrfPolicy;
1363
1639
  if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1364
- const allowedHostnames = [
1365
- ...policy?.allowedHostnames ?? [],
1366
- ...policy?.hostnameAllowlist ?? []
1367
- ];
1368
- if (allowedHostnames.length) {
1369
- const hostname = parsed.hostname.toLowerCase();
1370
- if (allowedHostnames.some((h) => h.toLowerCase() === hostname)) return;
1371
- }
1372
- const ipOpts = { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange };
1373
- if (await isInternalUrlResolved(rawUrl, opts.lookupFn, ipOpts)) {
1374
- throw new InvalidBrowserNavigationUrlError(
1375
- `Navigation to internal/loopback address blocked: "${rawUrl}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1376
- );
1377
- }
1640
+ await resolvePinnedHostnameWithPolicy(parsed.hostname, {
1641
+ lookupFn: opts.lookupFn,
1642
+ policy
1643
+ });
1378
1644
  }
1379
1645
  async function assertSafeOutputPath(path2, allowedRoots) {
1380
1646
  if (!path2 || typeof path2 !== "string") {
@@ -1417,111 +1683,6 @@ async function assertSafeOutputPath(path2, allowedRoots) {
1417
1683
  }
1418
1684
  }
1419
1685
  }
1420
- function expandIPv6(ip) {
1421
- let normalized = ip;
1422
- const v4Match = normalized.match(/^(.+:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1423
- if (v4Match) {
1424
- const octets = v4Match[2].split(".").map(Number);
1425
- if (octets.some((o) => o > 255)) return null;
1426
- const hexHi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
1427
- const hexLo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
1428
- normalized = v4Match[1] + hexHi + ":" + hexLo;
1429
- }
1430
- const halves = normalized.split("::");
1431
- if (halves.length > 2) return null;
1432
- if (halves.length === 2) {
1433
- const left = halves[0] !== "" ? halves[0].split(":") : [];
1434
- const right = halves[1] !== "" ? halves[1].split(":") : [];
1435
- const needed = 8 - left.length - right.length;
1436
- if (needed < 0) return null;
1437
- const groups2 = [...left, ...Array(needed).fill("0"), ...right];
1438
- if (groups2.length !== 8) return null;
1439
- return groups2.map((g) => g.padStart(4, "0")).join(":");
1440
- }
1441
- const groups = normalized.split(":");
1442
- if (groups.length !== 8) return null;
1443
- return groups.map((g) => g.padStart(4, "0")).join(":");
1444
- }
1445
- function hexToIPv4(hiHex, loHex) {
1446
- const hi = parseInt(hiHex, 16);
1447
- const lo = parseInt(loHex, 16);
1448
- return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1449
- }
1450
- function extractEmbeddedIPv4(lower) {
1451
- if (lower.startsWith("::ffff:")) {
1452
- return lower.slice(7);
1453
- }
1454
- const expanded = expandIPv6(lower);
1455
- if (expanded === null) return "";
1456
- const groups = expanded.split(":");
1457
- if (groups.length !== 8) return "";
1458
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "0000") {
1459
- return hexToIPv4(groups[6], groups[7]);
1460
- }
1461
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0001") {
1462
- return hexToIPv4(groups[6], groups[7]);
1463
- }
1464
- if (groups[0] === "2002") {
1465
- return hexToIPv4(groups[1], groups[2]);
1466
- }
1467
- if (groups[0] === "2001" && groups[1] === "0000") {
1468
- const hiXored = (parseInt(groups[6], 16) ^ 65535).toString(16).padStart(4, "0");
1469
- const loXored = (parseInt(groups[7], 16) ^ 65535).toString(16).padStart(4, "0");
1470
- return hexToIPv4(hiXored, loXored);
1471
- }
1472
- return null;
1473
- }
1474
- function isStrictDecimalOctet(part) {
1475
- if (!/^[0-9]+$/.test(part)) return false;
1476
- const n = parseInt(part, 10);
1477
- if (n < 0 || n > 255) return false;
1478
- if (String(n) !== part) return false;
1479
- return true;
1480
- }
1481
- function isUnsupportedIPv4Literal(ip) {
1482
- if (/^[0-9]+$/.test(ip)) return true;
1483
- const parts = ip.split(".");
1484
- if (parts.length !== 4) return true;
1485
- if (!parts.every(isStrictDecimalOctet)) return true;
1486
- return false;
1487
- }
1488
- function isInternalIP(ip, opts) {
1489
- if (!ip.includes(":") && isUnsupportedIPv4Literal(ip)) return true;
1490
- if (/^127\./.test(ip)) return true;
1491
- if (/^10\./.test(ip)) return true;
1492
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
1493
- if (/^192\.168\./.test(ip)) return true;
1494
- if (/^169\.254\./.test(ip)) return true;
1495
- if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true;
1496
- if (ip === "0.0.0.0") return true;
1497
- if (!opts?.allowRfc2544BenchmarkRange && /^198\.1[89]\./.test(ip)) return true;
1498
- const lower = ip.toLowerCase();
1499
- if (lower === "::1") return true;
1500
- if (lower.startsWith("fe80:")) return true;
1501
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
1502
- if (lower.startsWith("ff")) return true;
1503
- const embedded = extractEmbeddedIPv4(lower);
1504
- if (embedded !== null) {
1505
- if (embedded === "") return true;
1506
- return isInternalIP(embedded, opts);
1507
- }
1508
- return false;
1509
- }
1510
- function isInternalUrl(url, opts) {
1511
- let parsed;
1512
- try {
1513
- parsed = new URL(url);
1514
- } catch {
1515
- return true;
1516
- }
1517
- const hostname = parsed.hostname.toLowerCase();
1518
- if (hostname === "localhost") return true;
1519
- if (isInternalIP(hostname, opts)) return true;
1520
- if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) {
1521
- return true;
1522
- }
1523
- return false;
1524
- }
1525
1686
  async function assertSafeUploadPaths(paths) {
1526
1687
  for (const filePath of paths) {
1527
1688
  let stat;
@@ -1538,21 +1699,48 @@ async function assertSafeUploadPaths(paths) {
1538
1699
  }
1539
1700
  }
1540
1701
  }
1541
- async function isInternalUrlResolved(url, lookupFn = lookup, opts) {
1542
- if (isInternalUrl(url, opts)) return true;
1543
- let parsed;
1702
+ function sanitizeUntrustedFileName(fileName, fallbackName) {
1703
+ const trimmed = String(fileName ?? "").trim();
1704
+ if (!trimmed) return fallbackName;
1705
+ let base = posix.basename(trimmed);
1706
+ base = win32.basename(base);
1707
+ let cleaned = "";
1708
+ for (let i = 0; i < base.length; i++) {
1709
+ const code = base.charCodeAt(i);
1710
+ if (code < 32 || code === 127) continue;
1711
+ cleaned += base[i];
1712
+ }
1713
+ base = cleaned.trim();
1714
+ if (!base || base === "." || base === "..") return fallbackName;
1715
+ if (base.length > 200) base = base.slice(0, 200);
1716
+ return base;
1717
+ }
1718
+ function buildSiblingTempPath(targetPath) {
1719
+ const id = randomUUID();
1720
+ const safeTail = sanitizeUntrustedFileName(basename(targetPath), "output.bin");
1721
+ return join(dirname(targetPath), `.browserclaw-output-${id}-${safeTail}.part`);
1722
+ }
1723
+ async function writeViaSiblingTempPath(params) {
1724
+ const rootDir = await realpath(resolve(params.rootDir)).catch(() => resolve(params.rootDir));
1725
+ const requestedTargetPath = resolve(params.targetPath);
1726
+ const targetPath = await realpath(dirname(requestedTargetPath)).then((realDir) => join(realDir, basename(requestedTargetPath))).catch(() => requestedTargetPath);
1727
+ const relativeTargetPath = relative(rootDir, targetPath);
1728
+ if (!relativeTargetPath || relativeTargetPath === ".." || relativeTargetPath.startsWith(`..${sep}`) || isAbsolute(relativeTargetPath)) {
1729
+ throw new Error("Target path is outside the allowed root");
1730
+ }
1731
+ const tempPath = buildSiblingTempPath(targetPath);
1732
+ let renameSucceeded = false;
1544
1733
  try {
1545
- parsed = new URL(url);
1546
- } catch {
1547
- return true;
1548
- }
1549
- try {
1550
- const { address } = await lookupFn(parsed.hostname);
1551
- if (isInternalIP(address, opts)) return true;
1552
- } catch {
1553
- return true;
1734
+ await params.writeTemp(tempPath);
1735
+ await rename(tempPath, targetPath);
1736
+ renameSucceeded = true;
1737
+ } finally {
1738
+ if (!renameSucceeded) await rm(tempPath, { force: true }).catch(() => {
1739
+ });
1554
1740
  }
1555
- return false;
1741
+ }
1742
+ function isAbsolute(p) {
1743
+ return p.startsWith("/") || /^[a-zA-Z]:/.test(p);
1556
1744
  }
1557
1745
  async function assertBrowserNavigationResultAllowed(opts) {
1558
1746
  const rawUrl = String(opts.url ?? "").trim();
@@ -1660,13 +1848,12 @@ async function fillFormViaPlaywright(opts) {
1660
1848
  ensurePageState(page);
1661
1849
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1662
1850
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
1663
- for (let i = 0; i < opts.fields.length; i++) {
1664
- const field = opts.fields[i];
1851
+ for (const field of opts.fields) {
1665
1852
  const ref = field.ref.trim();
1666
1853
  const type = (typeof field.type === "string" ? field.type.trim() : "") || "text";
1667
1854
  const rawValue = field.value;
1668
- const value = rawValue == null ? "" : String(rawValue);
1669
- if (!ref) throw new Error(`fill(): field at index ${i} has empty ref`);
1855
+ const value = typeof rawValue === "string" ? rawValue : typeof rawValue === "number" || typeof rawValue === "boolean" ? String(rawValue) : "";
1856
+ if (!ref) continue;
1670
1857
  const locator = refLocator(page, ref);
1671
1858
  if (type === "checkbox" || type === "radio") {
1672
1859
  const checked = rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
@@ -1710,61 +1897,78 @@ async function setInputFilesViaPlaywright(opts) {
1710
1897
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1711
1898
  ensurePageState(page);
1712
1899
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1713
- const locator = opts.ref ? refLocator(page, opts.ref) : opts.element ? page.locator(opts.element).first() : null;
1714
- if (!locator) throw new Error("Either ref or element is required for setInputFiles");
1900
+ if (!opts.paths.length) throw new Error("paths are required");
1901
+ const inputRef = typeof opts.ref === "string" ? opts.ref.trim() : "";
1902
+ const element = typeof opts.element === "string" ? opts.element.trim() : "";
1903
+ if (inputRef && element) throw new Error("ref and element are mutually exclusive");
1904
+ if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
1905
+ const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
1715
1906
  await assertSafeUploadPaths(opts.paths);
1716
1907
  try {
1717
1908
  await locator.setInputFiles(opts.paths);
1718
1909
  } catch (err) {
1719
- throw toAIFriendlyError(err, opts.ref ?? opts.element ?? "unknown");
1910
+ throw toAIFriendlyError(err, inputRef || element);
1911
+ }
1912
+ try {
1913
+ const handle = await locator.elementHandle();
1914
+ if (handle) {
1915
+ await handle.evaluate((el) => {
1916
+ el.dispatchEvent(new Event("input", { bubbles: true }));
1917
+ el.dispatchEvent(new Event("change", { bubbles: true }));
1918
+ });
1919
+ }
1920
+ } catch {
1720
1921
  }
1721
1922
  }
1722
1923
  async function armDialogViaPlaywright(opts) {
1723
1924
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1724
- ensurePageState(page);
1725
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
1726
- return new Promise((resolve2, reject) => {
1727
- const timer = setTimeout(() => {
1728
- page.removeListener("dialog", handler);
1729
- reject(new Error(`No dialog appeared within ${timeout}ms`));
1730
- }, timeout);
1731
- const handler = async (dialog) => {
1732
- clearTimeout(timer);
1733
- try {
1734
- if (opts.accept) {
1735
- await dialog.accept(opts.promptText);
1736
- } else {
1737
- await dialog.dismiss();
1738
- }
1739
- resolve2();
1740
- } catch (err) {
1741
- reject(err);
1742
- }
1743
- };
1744
- page.once("dialog", handler);
1925
+ const state = ensurePageState(page);
1926
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
1927
+ state.armIdDialog = bumpDialogArmId();
1928
+ const armId = state.armIdDialog;
1929
+ page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
1930
+ if (state.armIdDialog !== armId) return;
1931
+ if (opts.accept) await dialog.accept(opts.promptText);
1932
+ else await dialog.dismiss();
1933
+ }).catch(() => {
1745
1934
  });
1746
1935
  }
1747
1936
  async function armFileUploadViaPlaywright(opts) {
1748
1937
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1749
- ensurePageState(page);
1750
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
1751
- return new Promise((resolve2, reject) => {
1752
- const timer = setTimeout(() => {
1753
- page.removeListener("filechooser", handler);
1754
- reject(new Error(`No file chooser appeared within ${timeout}ms`));
1755
- }, timeout);
1756
- const handler = async (fc) => {
1757
- clearTimeout(timer);
1938
+ const state = ensurePageState(page);
1939
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
1940
+ state.armIdUpload = bumpUploadArmId();
1941
+ const armId = state.armIdUpload;
1942
+ page.waitForEvent("filechooser", { timeout }).then(async (fileChooser) => {
1943
+ if (state.armIdUpload !== armId) return;
1944
+ if (!opts.paths?.length) {
1758
1945
  try {
1759
- const paths = opts.paths ?? [];
1760
- if (paths.length > 0) await assertSafeUploadPaths(paths);
1761
- await fc.setFiles(paths);
1762
- resolve2();
1763
- } catch (err) {
1764
- reject(err);
1946
+ await page.keyboard.press("Escape");
1947
+ } catch {
1765
1948
  }
1766
- };
1767
- page.once("filechooser", handler);
1949
+ return;
1950
+ }
1951
+ try {
1952
+ await assertSafeUploadPaths(opts.paths);
1953
+ } catch {
1954
+ try {
1955
+ await page.keyboard.press("Escape");
1956
+ } catch {
1957
+ }
1958
+ return;
1959
+ }
1960
+ await fileChooser.setFiles(opts.paths);
1961
+ try {
1962
+ const input = typeof fileChooser.element === "function" ? await Promise.resolve(fileChooser.element()) : null;
1963
+ if (input) {
1964
+ await input.evaluate((el) => {
1965
+ el.dispatchEvent(new Event("input", { bubbles: true }));
1966
+ el.dispatchEvent(new Event("change", { bubbles: true }));
1967
+ });
1968
+ }
1969
+ } catch {
1970
+ }
1971
+ }).catch(() => {
1768
1972
  });
1769
1973
  }
1770
1974
 
@@ -1778,17 +1982,35 @@ async function pressKeyViaPlaywright(opts) {
1778
1982
  }
1779
1983
 
1780
1984
  // src/actions/navigation.ts
1985
+ function isRetryableNavigateError(err) {
1986
+ const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
1987
+ return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
1988
+ }
1781
1989
  async function navigateViaPlaywright(opts) {
1782
1990
  const url = String(opts.url ?? "").trim();
1783
1991
  if (!url) throw new Error("url is required");
1784
1992
  const policy = opts.allowInternal ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
1785
- await assertBrowserNavigationAllowed({ url, ssrfPolicy: policy });
1786
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1993
+ await assertBrowserNavigationAllowed({ url, ...withBrowserNavigationPolicy(policy) });
1994
+ const timeout = Math.max(1e3, Math.min(12e4, opts.timeoutMs ?? 2e4));
1995
+ let page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1787
1996
  ensurePageState(page);
1788
- const response = await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
1997
+ const navigate = async () => await page.goto(url, { timeout });
1998
+ let response;
1999
+ try {
2000
+ response = await navigate();
2001
+ } catch (err) {
2002
+ if (!isRetryableNavigateError(err)) throw err;
2003
+ await forceDisconnectPlaywrightForTarget({
2004
+ cdpUrl: opts.cdpUrl,
2005
+ targetId: opts.targetId}).catch(() => {
2006
+ });
2007
+ page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2008
+ ensurePageState(page);
2009
+ response = await navigate();
2010
+ }
1789
2011
  await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...withBrowserNavigationPolicy(policy) });
1790
2012
  const finalUrl = page.url();
1791
- await assertBrowserNavigationResultAllowed({ url: finalUrl, ssrfPolicy: policy });
2013
+ await assertBrowserNavigationResultAllowed({ url: finalUrl, ...withBrowserNavigationPolicy(policy) });
1792
2014
  return { url: finalUrl };
1793
2015
  }
1794
2016
  async function listPagesViaPlaywright(opts) {
@@ -1814,11 +2036,12 @@ async function createPageViaPlaywright(opts) {
1814
2036
  }
1815
2037
  const { browser } = await connectBrowser(opts.cdpUrl);
1816
2038
  const context = browser.contexts()[0] ?? await browser.newContext();
2039
+ ensureContextState(context);
1817
2040
  const page = await context.newPage();
1818
2041
  ensurePageState(page);
1819
2042
  if (targetUrl !== "about:blank") {
1820
2043
  const navigationPolicy = withBrowserNavigationPolicy(policy);
1821
- const response = await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) });
2044
+ const response = await page.goto(targetUrl, { timeout: 3e4 }).catch(() => null);
1822
2045
  await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...navigationPolicy });
1823
2046
  await assertBrowserNavigationResultAllowed({ url: page.url(), ssrfPolicy: policy });
1824
2047
  }
@@ -1927,68 +2150,136 @@ async function evaluateInAllFramesViaPlaywright(opts) {
1927
2150
  }
1928
2151
  return results;
1929
2152
  }
2153
+ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2154
+ if (!abortPromise) return await evalPromise;
2155
+ try {
2156
+ return await Promise.race([evalPromise, abortPromise]);
2157
+ } catch (err) {
2158
+ evalPromise.catch(() => {
2159
+ });
2160
+ throw err;
2161
+ }
2162
+ }
2163
+ var BROWSER_EVALUATOR = new Function("args", `
2164
+ "use strict";
2165
+ var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2166
+ try {
2167
+ var candidate = eval("(" + fnBody + ")");
2168
+ var result = typeof candidate === "function" ? candidate() : candidate;
2169
+ if (result && typeof result.then === "function") {
2170
+ return Promise.race([
2171
+ result,
2172
+ new Promise(function(_, reject) {
2173
+ setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2174
+ })
2175
+ ]);
2176
+ }
2177
+ return result;
2178
+ } catch (err) {
2179
+ throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2180
+ }
2181
+ `);
2182
+ var ELEMENT_EVALUATOR = new Function("el", "args", `
2183
+ "use strict";
2184
+ var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2185
+ try {
2186
+ var candidate = eval("(" + fnBody + ")");
2187
+ var result = typeof candidate === "function" ? candidate(el) : candidate;
2188
+ if (result && typeof result.then === "function") {
2189
+ return Promise.race([
2190
+ result,
2191
+ new Promise(function(_, reject) {
2192
+ setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2193
+ })
2194
+ ]);
2195
+ }
2196
+ return result;
2197
+ } catch (err) {
2198
+ throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2199
+ }
2200
+ `);
1930
2201
  async function evaluateViaPlaywright(opts) {
1931
2202
  const fnText = String(opts.fn ?? "").trim();
1932
2203
  if (!fnText) throw new Error("function is required");
1933
2204
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1934
2205
  ensurePageState(page);
1935
2206
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1936
- const timeout = opts.timeoutMs != null ? opts.timeoutMs : void 0;
1937
- if (opts.ref) {
1938
- const locator = refLocator(page, opts.ref);
1939
- return await locator.evaluate(
1940
- // eslint-disable-next-line no-eval
1941
- (el, fnBody) => {
1942
- try {
1943
- const candidate = (0, eval)("(" + fnBody + ")");
1944
- return typeof candidate === "function" ? candidate(el) : candidate;
1945
- } catch (err) {
1946
- throw new Error("Invalid evaluate function: " + (err instanceof Error ? err.message : String(err)));
1947
- }
1948
- },
1949
- fnText,
1950
- { timeout }
2207
+ const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 2e4);
2208
+ let evaluateTimeout = Math.max(1e3, Math.min(12e4, outerTimeout - 500));
2209
+ evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
2210
+ const signal = opts.signal;
2211
+ let abortListener;
2212
+ let abortReject;
2213
+ let abortPromise;
2214
+ if (signal) {
2215
+ abortPromise = new Promise((_, reject) => {
2216
+ abortReject = reject;
2217
+ });
2218
+ abortPromise.catch(() => {
2219
+ });
2220
+ }
2221
+ if (signal) {
2222
+ const disconnect = () => {
2223
+ forceDisconnectPlaywrightForTarget({
2224
+ cdpUrl: opts.cdpUrl,
2225
+ targetId: opts.targetId}).catch(() => {
2226
+ });
2227
+ };
2228
+ if (signal.aborted) {
2229
+ disconnect();
2230
+ throw signal.reason ?? new Error("aborted");
2231
+ }
2232
+ abortListener = () => {
2233
+ disconnect();
2234
+ abortReject?.(signal.reason ?? new Error("aborted"));
2235
+ };
2236
+ signal.addEventListener("abort", abortListener, { once: true });
2237
+ if (signal.aborted) {
2238
+ abortListener();
2239
+ throw signal.reason ?? new Error("aborted");
2240
+ }
2241
+ }
2242
+ try {
2243
+ if (opts.ref) {
2244
+ const locator = refLocator(page, opts.ref);
2245
+ return await awaitEvalWithAbort(
2246
+ locator.evaluate(ELEMENT_EVALUATOR, { fnBody: fnText, timeoutMs: evaluateTimeout }),
2247
+ abortPromise
2248
+ );
2249
+ }
2250
+ return await awaitEvalWithAbort(
2251
+ page.evaluate(BROWSER_EVALUATOR, { fnBody: fnText, timeoutMs: evaluateTimeout }),
2252
+ abortPromise
1951
2253
  );
2254
+ } finally {
2255
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
1952
2256
  }
1953
- const evalPromise = page.evaluate(
1954
- // eslint-disable-next-line no-eval
1955
- (fnBody) => {
1956
- try {
1957
- const candidate = (0, eval)("(" + fnBody + ")");
1958
- return typeof candidate === "function" ? candidate() : candidate;
1959
- } catch (err) {
1960
- throw new Error("Invalid evaluate function: " + (err instanceof Error ? err.message : String(err)));
1961
- }
1962
- },
1963
- fnText
1964
- );
1965
- if (!opts.signal) return evalPromise;
1966
- return Promise.race([
1967
- evalPromise,
1968
- new Promise((_, reject) => {
1969
- opts.signal.addEventListener("abort", () => reject(new Error("Evaluate aborted")), { once: true });
1970
- })
1971
- ]);
1972
2257
  }
1973
-
1974
- // src/actions/download.ts
1975
2258
  async function downloadViaPlaywright(opts) {
1976
2259
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
1977
2260
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1978
- ensurePageState(page);
2261
+ const state = ensurePageState(page);
1979
2262
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1980
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
2263
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
1981
2264
  const locator = refLocator(page, opts.ref);
2265
+ state.armIdDownload = bumpDownloadArmId();
1982
2266
  try {
1983
2267
  const [download] = await Promise.all([
1984
2268
  page.waitForEvent("download", { timeout }),
1985
2269
  locator.click({ timeout })
1986
2270
  ]);
1987
- await download.saveAs(opts.path);
2271
+ const outPath = opts.path;
2272
+ await writeViaSiblingTempPath({
2273
+ rootDir: dirname(outPath),
2274
+ targetPath: outPath,
2275
+ writeTemp: async (tempPath) => {
2276
+ await download.saveAs(tempPath);
2277
+ }
2278
+ });
1988
2279
  return {
1989
2280
  url: download.url(),
1990
2281
  suggestedFilename: download.suggestedFilename(),
1991
- path: opts.path
2282
+ path: outPath
1992
2283
  };
1993
2284
  } catch (err) {
1994
2285
  throw toAIFriendlyError(err, opts.ref);
@@ -1997,11 +2288,17 @@ async function downloadViaPlaywright(opts) {
1997
2288
  async function waitForDownloadViaPlaywright(opts) {
1998
2289
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1999
2290
  ensurePageState(page);
2000
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
2291
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2001
2292
  const download = await page.waitForEvent("download", { timeout });
2002
2293
  const savePath = opts.path ?? download.suggestedFilename();
2003
2294
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2004
- await download.saveAs(savePath);
2295
+ await writeViaSiblingTempPath({
2296
+ rootDir: dirname(savePath),
2297
+ targetPath: savePath,
2298
+ writeTemp: async (tempPath) => {
2299
+ await download.saveAs(tempPath);
2300
+ }
2301
+ });
2005
2302
  return {
2006
2303
  url: download.url(),
2007
2304
  suggestedFilename: download.suggestedFilename(),
@@ -2014,33 +2311,51 @@ async function emulateMediaViaPlaywright(opts) {
2014
2311
  await page.emulateMedia({ colorScheme: opts.colorScheme });
2015
2312
  }
2016
2313
  async function setDeviceViaPlaywright(opts) {
2017
- const device = devices[opts.name];
2314
+ const name = String(opts.name ?? "").trim();
2315
+ if (!name) throw new Error("device name is required");
2316
+ const device = devices[name];
2018
2317
  if (!device) {
2019
- const available = Object.keys(devices).slice(0, 10).join(", ");
2020
- throw new Error(`Unknown device "${opts.name}". Some available devices: ${available}...`);
2318
+ throw new Error(`Unknown device "${name}".`);
2021
2319
  }
2022
2320
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2023
2321
  ensurePageState(page);
2024
2322
  if (device.viewport) {
2025
- await page.setViewportSize(device.viewport);
2323
+ await page.setViewportSize({
2324
+ width: device.viewport.width,
2325
+ height: device.viewport.height
2326
+ });
2026
2327
  }
2027
- if (device.userAgent) {
2028
- const context = page.context();
2029
- const session = await context.newCDPSession(page);
2030
- try {
2328
+ const session = await page.context().newCDPSession(page);
2329
+ try {
2330
+ const locale = device.locale;
2331
+ if (device.userAgent || locale) {
2031
2332
  await session.send("Emulation.setUserAgentOverride", {
2032
- userAgent: device.userAgent
2333
+ userAgent: device.userAgent ?? "",
2334
+ acceptLanguage: locale ?? void 0
2033
2335
  });
2034
- } finally {
2035
- await session.detach().catch(() => {
2336
+ }
2337
+ if (device.viewport) {
2338
+ await session.send("Emulation.setDeviceMetricsOverride", {
2339
+ mobile: Boolean(device.isMobile),
2340
+ width: device.viewport.width,
2341
+ height: device.viewport.height,
2342
+ deviceScaleFactor: device.deviceScaleFactor ?? 1,
2343
+ screenWidth: device.viewport.width,
2344
+ screenHeight: device.viewport.height
2036
2345
  });
2037
2346
  }
2347
+ if (device.hasTouch) {
2348
+ await session.send("Emulation.setTouchEmulationEnabled", { enabled: true });
2349
+ }
2350
+ } finally {
2351
+ await session.detach().catch(() => {
2352
+ });
2038
2353
  }
2039
2354
  }
2040
2355
  async function setExtraHTTPHeadersViaPlaywright(opts) {
2041
2356
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2042
2357
  ensurePageState(page);
2043
- await page.setExtraHTTPHeaders(opts.headers);
2358
+ await page.context().setExtraHTTPHeaders(opts.headers);
2044
2359
  }
2045
2360
  async function setGeolocationViaPlaywright(opts) {
2046
2361
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
@@ -2048,38 +2363,53 @@ async function setGeolocationViaPlaywright(opts) {
2048
2363
  const context = page.context();
2049
2364
  if (opts.clear) {
2050
2365
  await context.setGeolocation(null);
2051
- await context.clearPermissions();
2366
+ await context.clearPermissions().catch(() => {
2367
+ });
2052
2368
  return;
2053
2369
  }
2054
- if (opts.latitude === void 0 || opts.longitude === void 0) {
2370
+ if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") {
2055
2371
  throw new Error("latitude and longitude are required (or set clear=true)");
2056
2372
  }
2057
- await context.grantPermissions(["geolocation"], opts.origin ? { origin: opts.origin } : void 0);
2058
2373
  await context.setGeolocation({
2059
2374
  latitude: opts.latitude,
2060
2375
  longitude: opts.longitude,
2061
- accuracy: opts.accuracy
2376
+ accuracy: typeof opts.accuracy === "number" ? opts.accuracy : void 0
2377
+ });
2378
+ const origin = opts.origin?.trim() || (() => {
2379
+ try {
2380
+ return new URL(page.url()).origin;
2381
+ } catch {
2382
+ return "";
2383
+ }
2384
+ })();
2385
+ if (origin) await context.grantPermissions(["geolocation"], { origin }).catch(() => {
2062
2386
  });
2063
2387
  }
2064
2388
  async function setHttpCredentialsViaPlaywright(opts) {
2065
2389
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2066
2390
  ensurePageState(page);
2067
- const context = page.context();
2068
2391
  if (opts.clear) {
2069
- await context.setHTTPCredentials({ username: "", password: "" });
2392
+ await page.context().setHTTPCredentials(null);
2070
2393
  return;
2071
2394
  }
2072
- await context.setHTTPCredentials({
2073
- username: opts.username ?? "",
2074
- password: opts.password ?? ""
2075
- });
2395
+ const username = String(opts.username ?? "");
2396
+ const password = String(opts.password ?? "");
2397
+ if (!username) throw new Error("username is required (or set clear=true)");
2398
+ await page.context().setHTTPCredentials({ username, password });
2076
2399
  }
2077
2400
  async function setLocaleViaPlaywright(opts) {
2078
2401
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2079
2402
  ensurePageState(page);
2403
+ const locale = String(opts.locale ?? "").trim();
2404
+ if (!locale) throw new Error("locale is required");
2080
2405
  const session = await page.context().newCDPSession(page);
2081
2406
  try {
2082
- await session.send("Emulation.setLocaleOverride", { locale: opts.locale });
2407
+ try {
2408
+ await session.send("Emulation.setLocaleOverride", { locale });
2409
+ } catch (err) {
2410
+ if (String(err).includes("Another locale override is already in effect")) return;
2411
+ throw err;
2412
+ }
2083
2413
  } finally {
2084
2414
  await session.detach().catch(() => {
2085
2415
  });
@@ -2093,9 +2423,18 @@ async function setOfflineViaPlaywright(opts) {
2093
2423
  async function setTimezoneViaPlaywright(opts) {
2094
2424
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2095
2425
  ensurePageState(page);
2426
+ const timezoneId = String(opts.timezoneId ?? "").trim();
2427
+ if (!timezoneId) throw new Error("timezoneId is required");
2096
2428
  const session = await page.context().newCDPSession(page);
2097
2429
  try {
2098
- await session.send("Emulation.setTimezoneOverride", { timezoneId: opts.timezoneId });
2430
+ try {
2431
+ await session.send("Emulation.setTimezoneOverride", { timezoneId });
2432
+ } catch (err) {
2433
+ const msg = String(err);
2434
+ if (msg.includes("Timezone override is already in effect")) return;
2435
+ if (msg.includes("Invalid timezone")) throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
2436
+ throw err;
2437
+ }
2099
2438
  } finally {
2100
2439
  await session.detach().catch(() => {
2101
2440
  });
@@ -2171,24 +2510,38 @@ async function pdfViaPlaywright(opts) {
2171
2510
  ensurePageState(page);
2172
2511
  return { buffer: await page.pdf({ printBackground: true }) };
2173
2512
  }
2174
-
2175
- // src/capture/trace.ts
2176
2513
  async function traceStartViaPlaywright(opts) {
2177
2514
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2178
2515
  ensurePageState(page);
2179
2516
  const context = page.context();
2517
+ const ctxState = ensureContextState(context);
2518
+ if (ctxState.traceActive) {
2519
+ throw new Error("Trace already running. Stop the current trace before starting a new one.");
2520
+ }
2180
2521
  await context.tracing.start({
2181
2522
  screenshots: opts.screenshots ?? true,
2182
2523
  snapshots: opts.snapshots ?? true,
2183
- sources: opts.sources
2524
+ sources: opts.sources ?? false
2184
2525
  });
2526
+ ctxState.traceActive = true;
2185
2527
  }
2186
2528
  async function traceStopViaPlaywright(opts) {
2187
2529
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
2188
2530
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2189
2531
  ensurePageState(page);
2190
2532
  const context = page.context();
2191
- await context.tracing.stop({ path: opts.path });
2533
+ const ctxState = ensureContextState(context);
2534
+ if (!ctxState.traceActive) {
2535
+ throw new Error("No active trace. Start a trace before stopping it.");
2536
+ }
2537
+ await writeViaSiblingTempPath({
2538
+ rootDir: dirname(opts.path),
2539
+ targetPath: opts.path,
2540
+ writeTemp: async (tempPath) => {
2541
+ await context.tracing.stop({ path: tempPath });
2542
+ }
2543
+ });
2544
+ ctxState.traceActive = false;
2192
2545
  }
2193
2546
 
2194
2547
  // src/capture/response.ts
@@ -2271,8 +2624,8 @@ async function cookiesSetViaPlaywright(opts) {
2271
2624
  const cookie = opts.cookie;
2272
2625
  if (!cookie.name || cookie.value === void 0) throw new Error("cookie name and value are required");
2273
2626
  const hasUrl = typeof cookie.url === "string" && cookie.url.trim();
2274
- const hasDomain = typeof cookie.domain === "string" && cookie.domain.trim();
2275
- if (!hasUrl && !hasDomain) throw new Error("cookie requires url or domain");
2627
+ const hasDomainPath = typeof cookie.domain === "string" && cookie.domain.trim() && typeof cookie.path === "string" && cookie.path.trim();
2628
+ if (!hasUrl && !hasDomainPath) throw new Error("cookie requires url, or domain+path");
2276
2629
  await page.context().addCookies([cookie]);
2277
2630
  }
2278
2631
  async function cookiesClearViaPlaywright(opts) {
@@ -3345,6 +3698,6 @@ var BrowserClaw = class _BrowserClaw {
3345
3698
  }
3346
3699
  };
3347
3700
 
3348
- export { BrowserClaw, CrawlPage, InvalidBrowserNavigationUrlError, assertBrowserNavigationAllowed, assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, getChromeWebSocketUrl, isChromeCdpReady, isChromeReachable, normalizeCdpHttpBaseForJsonEndpoints, requiresInspectableBrowserNavigationRedirects, withBrowserNavigationPolicy };
3701
+ export { BrowserClaw, CrawlPage, InvalidBrowserNavigationUrlError, assertBrowserNavigationAllowed, assertBrowserNavigationRedirectChainAllowed, assertBrowserNavigationResultAllowed, createPinnedLookup, ensureContextState, forceDisconnectPlaywrightForTarget, getChromeWebSocketUrl, isChromeCdpReady, isChromeReachable, normalizeCdpHttpBaseForJsonEndpoints, requiresInspectableBrowserNavigationRedirects, resolvePinnedHostnameWithPolicy, sanitizeUntrustedFileName, withBrowserNavigationPolicy, writeViaSiblingTempPath };
3349
3702
  //# sourceMappingURL=index.js.map
3350
3703
  //# sourceMappingURL=index.js.map