browserclaw 0.4.3 → 0.5.1

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.cjs CHANGED
@@ -7,14 +7,36 @@ var net = require('net');
7
7
  var child_process = require('child_process');
8
8
  var playwrightCore = require('playwright-core');
9
9
  var promises = require('dns/promises');
10
+ var dns = require('dns');
10
11
  var promises$1 = require('fs/promises');
12
+ var crypto = require('crypto');
13
+ var ipaddr = require('ipaddr.js');
11
14
 
12
15
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
13
16
 
17
+ function _interopNamespace(e) {
18
+ if (e && e.__esModule) return e;
19
+ var n = Object.create(null);
20
+ if (e) {
21
+ Object.keys(e).forEach(function (k) {
22
+ if (k !== 'default') {
23
+ var d = Object.getOwnPropertyDescriptor(e, k);
24
+ Object.defineProperty(n, k, d.get ? d : {
25
+ enumerable: true,
26
+ get: function () { return e[k]; }
27
+ });
28
+ }
29
+ });
30
+ }
31
+ n.default = e;
32
+ return Object.freeze(n);
33
+ }
34
+
14
35
  var os__default = /*#__PURE__*/_interopDefault(os);
15
36
  var path__default = /*#__PURE__*/_interopDefault(path);
16
37
  var fs__default = /*#__PURE__*/_interopDefault(fs);
17
38
  var net__default = /*#__PURE__*/_interopDefault(net);
39
+ var ipaddr__namespace = /*#__PURE__*/_interopNamespace(ipaddr);
18
40
 
19
41
  // src/chrome-launcher.ts
20
42
  var CHROMIUM_BUNDLE_IDS = /* @__PURE__ */ new Set([
@@ -624,8 +646,31 @@ async function stopChrome(running, timeoutMs = 2500) {
624
646
  var cached = null;
625
647
  var connectingByUrl = /* @__PURE__ */ new Map();
626
648
  var pageStates = /* @__PURE__ */ new WeakMap();
649
+ var contextStates = /* @__PURE__ */ new WeakMap();
627
650
  var observedContexts = /* @__PURE__ */ new WeakSet();
628
651
  var observedPages = /* @__PURE__ */ new WeakSet();
652
+ var nextUploadArmId = 0;
653
+ var nextDialogArmId = 0;
654
+ var nextDownloadArmId = 0;
655
+ function bumpUploadArmId() {
656
+ nextUploadArmId += 1;
657
+ return nextUploadArmId;
658
+ }
659
+ function bumpDialogArmId() {
660
+ nextDialogArmId += 1;
661
+ return nextDialogArmId;
662
+ }
663
+ function bumpDownloadArmId() {
664
+ nextDownloadArmId += 1;
665
+ return nextDownloadArmId;
666
+ }
667
+ function ensureContextState(context) {
668
+ const existing = contextStates.get(context);
669
+ if (existing) return existing;
670
+ const state = { traceActive: false };
671
+ contextStates.set(context, state);
672
+ return state;
673
+ }
629
674
  var roleRefsByTarget = /* @__PURE__ */ new Map();
630
675
  var MAX_ROLE_REFS_CACHE = 50;
631
676
  var MAX_CONSOLE_MESSAGES = 500;
@@ -645,7 +690,10 @@ function ensurePageState(page) {
645
690
  errors: [],
646
691
  requests: [],
647
692
  requestIds: /* @__PURE__ */ new WeakMap(),
648
- nextRequestId: 0
693
+ nextRequestId: 0,
694
+ armIdUpload: 0,
695
+ armIdDialog: 0,
696
+ armIdDownload: 0
649
697
  };
650
698
  pageStates.set(page, state);
651
699
  if (!observedPages.has(page)) {
@@ -722,6 +770,7 @@ function applyStealthToPage(page) {
722
770
  function observeContext(context) {
723
771
  if (observedContexts.has(context)) return;
724
772
  observedContexts.add(context);
773
+ ensureContextState(context);
725
774
  context.addInitScript(STEALTH_SCRIPT).catch((e) => {
726
775
  if (process.env.DEBUG) console.warn("[browserclaw] stealth initScript failed:", e.message);
727
776
  });
@@ -817,6 +866,35 @@ async function disconnectBrowser() {
817
866
  if (cur) await cur.browser.close().catch(() => {
818
867
  });
819
868
  }
869
+ async function forceDisconnectPlaywrightForTarget(opts) {
870
+ const normalized = normalizeCdpUrl(opts.cdpUrl);
871
+ const cur = cached;
872
+ if (!cur || cur.cdpUrl !== normalized) return;
873
+ cached = null;
874
+ connectingByUrl.delete(normalized);
875
+ const targetId = opts.targetId?.trim() || "";
876
+ if (targetId) {
877
+ try {
878
+ const pages = await getAllPages(cur.browser);
879
+ for (const page of pages) {
880
+ const tid = await pageTargetId(page).catch(() => null);
881
+ if (tid === targetId) {
882
+ const session = await page.context().newCDPSession(page);
883
+ try {
884
+ await session.send("Runtime.terminateExecution");
885
+ } finally {
886
+ await session.detach().catch(() => {
887
+ });
888
+ }
889
+ break;
890
+ }
891
+ }
892
+ } catch {
893
+ }
894
+ }
895
+ cur.browser.close().catch(() => {
896
+ });
897
+ }
820
898
  async function getAllPages(browser) {
821
899
  return browser.contexts().flatMap((c) => c.pages());
822
900
  }
@@ -1338,6 +1416,11 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
1338
1416
  var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1339
1417
  var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
1340
1418
  var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
1419
+ var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set([
1420
+ "localhost",
1421
+ "localhost.localdomain",
1422
+ "metadata.google.internal"
1423
+ ]);
1341
1424
  function isAllowedNonNetworkNavigationUrl(parsed) {
1342
1425
  return SAFE_NON_NETWORK_URLS.has(parsed.href);
1343
1426
  }
@@ -1351,6 +1434,261 @@ function hasProxyEnvConfigured(env = process.env) {
1351
1434
  }
1352
1435
  return false;
1353
1436
  }
1437
+ function normalizeHostname(hostname) {
1438
+ let h = String(hostname ?? "").trim().toLowerCase();
1439
+ if (h.startsWith("[") && h.endsWith("]")) h = h.slice(1, -1);
1440
+ if (h.endsWith(".")) h = h.slice(0, -1);
1441
+ return h;
1442
+ }
1443
+ function isBlockedHostnameNormalized(normalized) {
1444
+ if (BLOCKED_HOSTNAMES.has(normalized)) return true;
1445
+ return normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal");
1446
+ }
1447
+ function isStrictDecimalOctet(part) {
1448
+ if (!/^[0-9]+$/.test(part)) return false;
1449
+ const n = parseInt(part, 10);
1450
+ if (n < 0 || n > 255) return false;
1451
+ if (String(n) !== part) return false;
1452
+ return true;
1453
+ }
1454
+ function isUnsupportedIPv4Literal(ip) {
1455
+ if (/^[0-9]+$/.test(ip)) return true;
1456
+ const parts = ip.split(".");
1457
+ if (parts.length !== 4) return true;
1458
+ if (!parts.every(isStrictDecimalOctet)) return true;
1459
+ return false;
1460
+ }
1461
+ var BLOCKED_IPV4_RANGES = /* @__PURE__ */ new Set([
1462
+ "unspecified",
1463
+ "broadcast",
1464
+ "multicast",
1465
+ "linkLocal",
1466
+ "loopback",
1467
+ "carrierGradeNat",
1468
+ "private",
1469
+ "reserved"
1470
+ ]);
1471
+ var BLOCKED_IPV6_RANGES = /* @__PURE__ */ new Set([
1472
+ "unspecified",
1473
+ "loopback",
1474
+ "linkLocal",
1475
+ "uniqueLocal",
1476
+ "multicast"
1477
+ ]);
1478
+ var RFC2544_BENCHMARK_PREFIX = [ipaddr__namespace.IPv4.parse("198.18.0.0"), 15];
1479
+ function isBlockedSpecialUseIpv4Address(address, opts) {
1480
+ const inRfc2544 = address.match(RFC2544_BENCHMARK_PREFIX);
1481
+ if (inRfc2544 && opts?.allowRfc2544BenchmarkRange === true) return false;
1482
+ return BLOCKED_IPV4_RANGES.has(address.range()) || inRfc2544;
1483
+ }
1484
+ function isBlockedSpecialUseIpv6Address(address) {
1485
+ if (BLOCKED_IPV6_RANGES.has(address.range())) return true;
1486
+ return (address.parts[0] & 65472) === 65216;
1487
+ }
1488
+ function extractEmbeddedIpv4FromIpv6(v6, opts) {
1489
+ if (v6.isIPv4MappedAddress()) {
1490
+ return isBlockedSpecialUseIpv4Address(v6.toIPv4Address(), opts);
1491
+ }
1492
+ const parts = v6.parts;
1493
+ if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1494
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1495
+ try {
1496
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1497
+ } catch {
1498
+ return true;
1499
+ }
1500
+ }
1501
+ if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 1) {
1502
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1503
+ try {
1504
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1505
+ } catch {
1506
+ return true;
1507
+ }
1508
+ }
1509
+ if (parts[0] === 8194) {
1510
+ const ip4str = `${parts[1] >> 8 & 255}.${parts[1] & 255}.${parts[2] >> 8 & 255}.${parts[2] & 255}`;
1511
+ try {
1512
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1513
+ } catch {
1514
+ return true;
1515
+ }
1516
+ }
1517
+ if (parts[0] === 8193 && parts[1] === 0) {
1518
+ const hiXored = parts[6] ^ 65535;
1519
+ const loXored = parts[7] ^ 65535;
1520
+ const ip4str = `${hiXored >> 8 & 255}.${hiXored & 255}.${loXored >> 8 & 255}.${loXored & 255}`;
1521
+ try {
1522
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1523
+ } catch {
1524
+ return true;
1525
+ }
1526
+ }
1527
+ if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1528
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1529
+ try {
1530
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1531
+ } catch {
1532
+ return true;
1533
+ }
1534
+ }
1535
+ if ((parts[4] & 65023) === 0 && parts[5] === 24318) {
1536
+ const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1537
+ try {
1538
+ return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1539
+ } catch {
1540
+ return true;
1541
+ }
1542
+ }
1543
+ return null;
1544
+ }
1545
+ function isPrivateIpAddress(address, opts) {
1546
+ let normalized = address.trim().toLowerCase();
1547
+ if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
1548
+ if (!normalized) return false;
1549
+ try {
1550
+ const parsed = ipaddr__namespace.parse(normalized);
1551
+ if (parsed.kind() === "ipv4") {
1552
+ return isBlockedSpecialUseIpv4Address(parsed, opts);
1553
+ }
1554
+ const v6 = parsed;
1555
+ if (isBlockedSpecialUseIpv6Address(v6)) return true;
1556
+ const embeddedV4 = extractEmbeddedIpv4FromIpv6(v6, opts);
1557
+ if (embeddedV4 !== null) return embeddedV4;
1558
+ return false;
1559
+ } catch {
1560
+ }
1561
+ if (!normalized.includes(":") && isUnsupportedIPv4Literal(normalized)) return true;
1562
+ if (normalized.includes(":")) return true;
1563
+ return false;
1564
+ }
1565
+ function normalizeHostnameSet(values) {
1566
+ if (!values || values.length === 0) return /* @__PURE__ */ new Set();
1567
+ return new Set(values.map((v) => normalizeHostname(v)).filter(Boolean));
1568
+ }
1569
+ function normalizeHostnameAllowlist(values) {
1570
+ if (!values || values.length === 0) return [];
1571
+ return Array.from(
1572
+ new Set(
1573
+ values.map((v) => normalizeHostname(v)).filter((v) => v !== "*" && v !== "*." && v.length > 0)
1574
+ )
1575
+ );
1576
+ }
1577
+ function isHostnameAllowedByPattern(hostname, pattern) {
1578
+ if (pattern.startsWith("*.")) {
1579
+ const suffix = pattern.slice(2);
1580
+ if (!suffix || hostname === suffix) return false;
1581
+ return hostname.endsWith(`.${suffix}`);
1582
+ }
1583
+ return hostname === pattern;
1584
+ }
1585
+ function matchesHostnameAllowlist(hostname, allowlist) {
1586
+ if (allowlist.length === 0) return true;
1587
+ return allowlist.some((pattern) => isHostnameAllowedByPattern(hostname, pattern));
1588
+ }
1589
+ function dedupeAndPreferIpv4(results) {
1590
+ const seen = /* @__PURE__ */ new Set();
1591
+ const ipv4 = [];
1592
+ const ipv6 = [];
1593
+ for (const r of results) {
1594
+ if (seen.has(r.address)) continue;
1595
+ seen.add(r.address);
1596
+ if (r.family === 4) ipv4.push(r.address);
1597
+ else ipv6.push(r.address);
1598
+ }
1599
+ return [...ipv4, ...ipv6];
1600
+ }
1601
+ function createPinnedLookup(params) {
1602
+ const normalizedHost = normalizeHostname(params.hostname);
1603
+ const fallback = params.fallback ?? dns.lookup;
1604
+ const records = params.addresses.map((address) => ({
1605
+ address,
1606
+ family: address.includes(":") ? 6 : 4
1607
+ }));
1608
+ let index = 0;
1609
+ return ((host, options, callback) => {
1610
+ const cb = typeof options === "function" ? options : callback;
1611
+ if (!cb) return;
1612
+ const normalized = normalizeHostname(host);
1613
+ if (!normalized || normalized !== normalizedHost) {
1614
+ if (typeof options === "function" || options === void 0) return fallback(host, cb);
1615
+ return fallback(host, options, cb);
1616
+ }
1617
+ const opts = typeof options === "object" && options !== null ? options : {};
1618
+ const requestedFamily = typeof options === "number" ? options : typeof opts.family === "number" ? opts.family : 0;
1619
+ const candidates = requestedFamily === 4 || requestedFamily === 6 ? records.filter((entry) => entry.family === requestedFamily) : records;
1620
+ const usable = candidates.length > 0 ? candidates : records;
1621
+ if (opts.all) {
1622
+ cb(null, usable);
1623
+ return;
1624
+ }
1625
+ const chosen = usable[index % usable.length];
1626
+ index += 1;
1627
+ cb(null, chosen.address, chosen.family);
1628
+ });
1629
+ }
1630
+ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1631
+ const normalized = normalizeHostname(hostname);
1632
+ if (!normalized) throw new InvalidBrowserNavigationUrlError(`Invalid hostname: "${hostname}"`);
1633
+ const allowPrivateNetwork = isPrivateNetworkAllowedByPolicy(params.policy);
1634
+ const allowedHostnames = normalizeHostnameSet(params.policy?.allowedHostnames);
1635
+ const hostnameAllowlist = normalizeHostnameAllowlist(params.policy?.hostnameAllowlist);
1636
+ const isExplicitlyAllowed = allowedHostnames.has(normalized);
1637
+ const skipPrivateNetworkChecks = allowPrivateNetwork || isExplicitlyAllowed;
1638
+ if (!matchesHostnameAllowlist(normalized, hostnameAllowlist)) {
1639
+ throw new InvalidBrowserNavigationUrlError(
1640
+ `Navigation blocked: hostname "${hostname}" is not in the allowlist.`
1641
+ );
1642
+ }
1643
+ if (!skipPrivateNetworkChecks) {
1644
+ if (isBlockedHostnameNormalized(normalized)) {
1645
+ throw new InvalidBrowserNavigationUrlError(
1646
+ `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1647
+ );
1648
+ }
1649
+ const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1650
+ if (isPrivateIpAddress(normalized, ipOpts)) {
1651
+ throw new InvalidBrowserNavigationUrlError(
1652
+ `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1653
+ );
1654
+ }
1655
+ }
1656
+ const lookupFn = params.lookupFn ?? promises.lookup;
1657
+ let results;
1658
+ try {
1659
+ results = await lookupFn(normalized, { all: true });
1660
+ } catch {
1661
+ throw new InvalidBrowserNavigationUrlError(
1662
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1663
+ );
1664
+ }
1665
+ if (!results || results.length === 0) {
1666
+ throw new InvalidBrowserNavigationUrlError(
1667
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1668
+ );
1669
+ }
1670
+ if (!skipPrivateNetworkChecks) {
1671
+ const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1672
+ for (const r of results) {
1673
+ if (isPrivateIpAddress(r.address, ipOpts)) {
1674
+ throw new InvalidBrowserNavigationUrlError(
1675
+ `Navigation to internal/loopback address blocked: "${hostname}" resolves to "${r.address}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1676
+ );
1677
+ }
1678
+ }
1679
+ }
1680
+ const addresses = dedupeAndPreferIpv4(results);
1681
+ if (addresses.length === 0) {
1682
+ throw new InvalidBrowserNavigationUrlError(
1683
+ `Navigation to internal/loopback address blocked: unable to resolve "${hostname}".`
1684
+ );
1685
+ }
1686
+ return {
1687
+ hostname: normalized,
1688
+ addresses,
1689
+ lookup: createPinnedLookup({ hostname: normalized, addresses })
1690
+ };
1691
+ }
1354
1692
  async function assertBrowserNavigationAllowed(opts) {
1355
1693
  const rawUrl = String(opts.url ?? "").trim();
1356
1694
  let parsed;
@@ -1368,22 +1706,10 @@ async function assertBrowserNavigationAllowed(opts) {
1368
1706
  "Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set"
1369
1707
  );
1370
1708
  }
1371
- const policy = opts.ssrfPolicy;
1372
- if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1373
- const allowedHostnames = [
1374
- ...policy?.allowedHostnames ?? [],
1375
- ...policy?.hostnameAllowlist ?? []
1376
- ];
1377
- if (allowedHostnames.length) {
1378
- const hostname = parsed.hostname.toLowerCase();
1379
- if (allowedHostnames.some((h) => h.toLowerCase() === hostname)) return;
1380
- }
1381
- const ipOpts = { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange };
1382
- if (await isInternalUrlResolved(rawUrl, opts.lookupFn, ipOpts)) {
1383
- throw new InvalidBrowserNavigationUrlError(
1384
- `Navigation to internal/loopback address blocked: "${rawUrl}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1385
- );
1386
- }
1709
+ await resolvePinnedHostnameWithPolicy(parsed.hostname, {
1710
+ lookupFn: opts.lookupFn,
1711
+ policy: opts.ssrfPolicy
1712
+ });
1387
1713
  }
1388
1714
  async function assertSafeOutputPath(path2, allowedRoots) {
1389
1715
  if (!path2 || typeof path2 !== "string") {
@@ -1426,111 +1752,6 @@ async function assertSafeOutputPath(path2, allowedRoots) {
1426
1752
  }
1427
1753
  }
1428
1754
  }
1429
- function expandIPv6(ip) {
1430
- let normalized = ip;
1431
- const v4Match = normalized.match(/^(.+:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1432
- if (v4Match) {
1433
- const octets = v4Match[2].split(".").map(Number);
1434
- if (octets.some((o) => o > 255)) return null;
1435
- const hexHi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
1436
- const hexLo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
1437
- normalized = v4Match[1] + hexHi + ":" + hexLo;
1438
- }
1439
- const halves = normalized.split("::");
1440
- if (halves.length > 2) return null;
1441
- if (halves.length === 2) {
1442
- const left = halves[0] !== "" ? halves[0].split(":") : [];
1443
- const right = halves[1] !== "" ? halves[1].split(":") : [];
1444
- const needed = 8 - left.length - right.length;
1445
- if (needed < 0) return null;
1446
- const groups2 = [...left, ...Array(needed).fill("0"), ...right];
1447
- if (groups2.length !== 8) return null;
1448
- return groups2.map((g) => g.padStart(4, "0")).join(":");
1449
- }
1450
- const groups = normalized.split(":");
1451
- if (groups.length !== 8) return null;
1452
- return groups.map((g) => g.padStart(4, "0")).join(":");
1453
- }
1454
- function hexToIPv4(hiHex, loHex) {
1455
- const hi = parseInt(hiHex, 16);
1456
- const lo = parseInt(loHex, 16);
1457
- return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1458
- }
1459
- function extractEmbeddedIPv4(lower) {
1460
- if (lower.startsWith("::ffff:")) {
1461
- return lower.slice(7);
1462
- }
1463
- const expanded = expandIPv6(lower);
1464
- if (expanded === null) return "";
1465
- const groups = expanded.split(":");
1466
- if (groups.length !== 8) return "";
1467
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "0000") {
1468
- return hexToIPv4(groups[6], groups[7]);
1469
- }
1470
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0001") {
1471
- return hexToIPv4(groups[6], groups[7]);
1472
- }
1473
- if (groups[0] === "2002") {
1474
- return hexToIPv4(groups[1], groups[2]);
1475
- }
1476
- if (groups[0] === "2001" && groups[1] === "0000") {
1477
- const hiXored = (parseInt(groups[6], 16) ^ 65535).toString(16).padStart(4, "0");
1478
- const loXored = (parseInt(groups[7], 16) ^ 65535).toString(16).padStart(4, "0");
1479
- return hexToIPv4(hiXored, loXored);
1480
- }
1481
- return null;
1482
- }
1483
- function isStrictDecimalOctet(part) {
1484
- if (!/^[0-9]+$/.test(part)) return false;
1485
- const n = parseInt(part, 10);
1486
- if (n < 0 || n > 255) return false;
1487
- if (String(n) !== part) return false;
1488
- return true;
1489
- }
1490
- function isUnsupportedIPv4Literal(ip) {
1491
- if (/^[0-9]+$/.test(ip)) return true;
1492
- const parts = ip.split(".");
1493
- if (parts.length !== 4) return true;
1494
- if (!parts.every(isStrictDecimalOctet)) return true;
1495
- return false;
1496
- }
1497
- function isInternalIP(ip, opts) {
1498
- if (!ip.includes(":") && isUnsupportedIPv4Literal(ip)) return true;
1499
- if (/^127\./.test(ip)) return true;
1500
- if (/^10\./.test(ip)) return true;
1501
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
1502
- if (/^192\.168\./.test(ip)) return true;
1503
- if (/^169\.254\./.test(ip)) return true;
1504
- if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true;
1505
- if (ip === "0.0.0.0") return true;
1506
- if (!opts?.allowRfc2544BenchmarkRange && /^198\.1[89]\./.test(ip)) return true;
1507
- const lower = ip.toLowerCase();
1508
- if (lower === "::1") return true;
1509
- if (lower.startsWith("fe80:")) return true;
1510
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
1511
- if (lower.startsWith("ff")) return true;
1512
- const embedded = extractEmbeddedIPv4(lower);
1513
- if (embedded !== null) {
1514
- if (embedded === "") return true;
1515
- return isInternalIP(embedded, opts);
1516
- }
1517
- return false;
1518
- }
1519
- function isInternalUrl(url, opts) {
1520
- let parsed;
1521
- try {
1522
- parsed = new URL(url);
1523
- } catch {
1524
- return true;
1525
- }
1526
- const hostname = parsed.hostname.toLowerCase();
1527
- if (hostname === "localhost") return true;
1528
- if (isInternalIP(hostname, opts)) return true;
1529
- if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) {
1530
- return true;
1531
- }
1532
- return false;
1533
- }
1534
1755
  async function assertSafeUploadPaths(paths) {
1535
1756
  for (const filePath of paths) {
1536
1757
  let stat;
@@ -1547,21 +1768,48 @@ async function assertSafeUploadPaths(paths) {
1547
1768
  }
1548
1769
  }
1549
1770
  }
1550
- async function isInternalUrlResolved(url, lookupFn = promises.lookup, opts) {
1551
- if (isInternalUrl(url, opts)) return true;
1552
- let parsed;
1771
+ function sanitizeUntrustedFileName(fileName, fallbackName) {
1772
+ const trimmed = String(fileName ?? "").trim();
1773
+ if (!trimmed) return fallbackName;
1774
+ let base = path.posix.basename(trimmed);
1775
+ base = path.win32.basename(base);
1776
+ let cleaned = "";
1777
+ for (let i = 0; i < base.length; i++) {
1778
+ const code = base.charCodeAt(i);
1779
+ if (code < 32 || code === 127) continue;
1780
+ cleaned += base[i];
1781
+ }
1782
+ base = cleaned.trim();
1783
+ if (!base || base === "." || base === "..") return fallbackName;
1784
+ if (base.length > 200) base = base.slice(0, 200);
1785
+ return base;
1786
+ }
1787
+ function buildSiblingTempPath(targetPath) {
1788
+ const id = crypto.randomUUID();
1789
+ const safeTail = sanitizeUntrustedFileName(path.basename(targetPath), "output.bin");
1790
+ return path.join(path.dirname(targetPath), `.browserclaw-output-${id}-${safeTail}.part`);
1791
+ }
1792
+ async function writeViaSiblingTempPath(params) {
1793
+ const rootDir = await promises$1.realpath(path.resolve(params.rootDir)).catch(() => path.resolve(params.rootDir));
1794
+ const requestedTargetPath = path.resolve(params.targetPath);
1795
+ const targetPath = await promises$1.realpath(path.dirname(requestedTargetPath)).then((realDir) => path.join(realDir, path.basename(requestedTargetPath))).catch(() => requestedTargetPath);
1796
+ const relativeTargetPath = path.relative(rootDir, targetPath);
1797
+ if (!relativeTargetPath || relativeTargetPath === ".." || relativeTargetPath.startsWith(`..${path.sep}`) || isAbsolute(relativeTargetPath)) {
1798
+ throw new Error("Target path is outside the allowed root");
1799
+ }
1800
+ const tempPath = buildSiblingTempPath(targetPath);
1801
+ let renameSucceeded = false;
1553
1802
  try {
1554
- parsed = new URL(url);
1555
- } catch {
1556
- return true;
1557
- }
1558
- try {
1559
- const { address } = await lookupFn(parsed.hostname);
1560
- if (isInternalIP(address, opts)) return true;
1561
- } catch {
1562
- return true;
1803
+ await params.writeTemp(tempPath);
1804
+ await promises$1.rename(tempPath, targetPath);
1805
+ renameSucceeded = true;
1806
+ } finally {
1807
+ if (!renameSucceeded) await promises$1.rm(tempPath, { force: true }).catch(() => {
1808
+ });
1563
1809
  }
1564
- return false;
1810
+ }
1811
+ function isAbsolute(p) {
1812
+ return p.startsWith("/") || /^[a-zA-Z]:/.test(p);
1565
1813
  }
1566
1814
  async function assertBrowserNavigationResultAllowed(opts) {
1567
1815
  const rawUrl = String(opts.url ?? "").trim();
@@ -1669,13 +1917,12 @@ async function fillFormViaPlaywright(opts) {
1669
1917
  ensurePageState(page);
1670
1918
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1671
1919
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
1672
- for (let i = 0; i < opts.fields.length; i++) {
1673
- const field = opts.fields[i];
1920
+ for (const field of opts.fields) {
1674
1921
  const ref = field.ref.trim();
1675
1922
  const type = (typeof field.type === "string" ? field.type.trim() : "") || "text";
1676
1923
  const rawValue = field.value;
1677
- const value = rawValue == null ? "" : String(rawValue);
1678
- if (!ref) throw new Error(`fill(): field at index ${i} has empty ref`);
1924
+ const value = typeof rawValue === "string" ? rawValue : typeof rawValue === "number" || typeof rawValue === "boolean" ? String(rawValue) : "";
1925
+ if (!ref) continue;
1679
1926
  const locator = refLocator(page, ref);
1680
1927
  if (type === "checkbox" || type === "radio") {
1681
1928
  const checked = rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
@@ -1719,61 +1966,78 @@ async function setInputFilesViaPlaywright(opts) {
1719
1966
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1720
1967
  ensurePageState(page);
1721
1968
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1722
- const locator = opts.ref ? refLocator(page, opts.ref) : opts.element ? page.locator(opts.element).first() : null;
1723
- if (!locator) throw new Error("Either ref or element is required for setInputFiles");
1969
+ if (!opts.paths.length) throw new Error("paths are required");
1970
+ const inputRef = typeof opts.ref === "string" ? opts.ref.trim() : "";
1971
+ const element = typeof opts.element === "string" ? opts.element.trim() : "";
1972
+ if (inputRef && element) throw new Error("ref and element are mutually exclusive");
1973
+ if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
1974
+ const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
1724
1975
  await assertSafeUploadPaths(opts.paths);
1725
1976
  try {
1726
1977
  await locator.setInputFiles(opts.paths);
1727
1978
  } catch (err) {
1728
- throw toAIFriendlyError(err, opts.ref ?? opts.element ?? "unknown");
1979
+ throw toAIFriendlyError(err, inputRef || element);
1980
+ }
1981
+ try {
1982
+ const handle = await locator.elementHandle();
1983
+ if (handle) {
1984
+ await handle.evaluate((el) => {
1985
+ el.dispatchEvent(new Event("input", { bubbles: true }));
1986
+ el.dispatchEvent(new Event("change", { bubbles: true }));
1987
+ });
1988
+ }
1989
+ } catch {
1729
1990
  }
1730
1991
  }
1731
1992
  async function armDialogViaPlaywright(opts) {
1732
1993
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1733
- ensurePageState(page);
1734
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
1735
- return new Promise((resolve2, reject) => {
1736
- const timer = setTimeout(() => {
1737
- page.removeListener("dialog", handler);
1738
- reject(new Error(`No dialog appeared within ${timeout}ms`));
1739
- }, timeout);
1740
- const handler = async (dialog) => {
1741
- clearTimeout(timer);
1742
- try {
1743
- if (opts.accept) {
1744
- await dialog.accept(opts.promptText);
1745
- } else {
1746
- await dialog.dismiss();
1747
- }
1748
- resolve2();
1749
- } catch (err) {
1750
- reject(err);
1751
- }
1752
- };
1753
- page.once("dialog", handler);
1994
+ const state = ensurePageState(page);
1995
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
1996
+ state.armIdDialog = bumpDialogArmId();
1997
+ const armId = state.armIdDialog;
1998
+ page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
1999
+ if (state.armIdDialog !== armId) return;
2000
+ if (opts.accept) await dialog.accept(opts.promptText);
2001
+ else await dialog.dismiss();
2002
+ }).catch(() => {
1754
2003
  });
1755
2004
  }
1756
2005
  async function armFileUploadViaPlaywright(opts) {
1757
2006
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1758
- ensurePageState(page);
1759
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
1760
- return new Promise((resolve2, reject) => {
1761
- const timer = setTimeout(() => {
1762
- page.removeListener("filechooser", handler);
1763
- reject(new Error(`No file chooser appeared within ${timeout}ms`));
1764
- }, timeout);
1765
- const handler = async (fc) => {
1766
- clearTimeout(timer);
2007
+ const state = ensurePageState(page);
2008
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2009
+ state.armIdUpload = bumpUploadArmId();
2010
+ const armId = state.armIdUpload;
2011
+ page.waitForEvent("filechooser", { timeout }).then(async (fileChooser) => {
2012
+ if (state.armIdUpload !== armId) return;
2013
+ if (!opts.paths?.length) {
1767
2014
  try {
1768
- const paths = opts.paths ?? [];
1769
- if (paths.length > 0) await assertSafeUploadPaths(paths);
1770
- await fc.setFiles(paths);
1771
- resolve2();
1772
- } catch (err) {
1773
- reject(err);
2015
+ await page.keyboard.press("Escape");
2016
+ } catch {
1774
2017
  }
1775
- };
1776
- page.once("filechooser", handler);
2018
+ return;
2019
+ }
2020
+ try {
2021
+ await assertSafeUploadPaths(opts.paths);
2022
+ } catch {
2023
+ try {
2024
+ await page.keyboard.press("Escape");
2025
+ } catch {
2026
+ }
2027
+ return;
2028
+ }
2029
+ await fileChooser.setFiles(opts.paths);
2030
+ try {
2031
+ const input = typeof fileChooser.element === "function" ? await Promise.resolve(fileChooser.element()) : null;
2032
+ if (input) {
2033
+ await input.evaluate((el) => {
2034
+ el.dispatchEvent(new Event("input", { bubbles: true }));
2035
+ el.dispatchEvent(new Event("change", { bubbles: true }));
2036
+ });
2037
+ }
2038
+ } catch {
2039
+ }
2040
+ }).catch(() => {
1777
2041
  });
1778
2042
  }
1779
2043
 
@@ -1787,17 +2051,35 @@ async function pressKeyViaPlaywright(opts) {
1787
2051
  }
1788
2052
 
1789
2053
  // src/actions/navigation.ts
2054
+ function isRetryableNavigateError(err) {
2055
+ const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
2056
+ return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
2057
+ }
1790
2058
  async function navigateViaPlaywright(opts) {
1791
2059
  const url = String(opts.url ?? "").trim();
1792
2060
  if (!url) throw new Error("url is required");
1793
2061
  const policy = opts.allowInternal ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
1794
- await assertBrowserNavigationAllowed({ url, ssrfPolicy: policy });
1795
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2062
+ await assertBrowserNavigationAllowed({ url, ...withBrowserNavigationPolicy(policy) });
2063
+ const timeout = Math.max(1e3, Math.min(12e4, opts.timeoutMs ?? 2e4));
2064
+ let page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1796
2065
  ensurePageState(page);
1797
- const response = await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
2066
+ const navigate = async () => await page.goto(url, { timeout });
2067
+ let response;
2068
+ try {
2069
+ response = await navigate();
2070
+ } catch (err) {
2071
+ if (!isRetryableNavigateError(err)) throw err;
2072
+ await forceDisconnectPlaywrightForTarget({
2073
+ cdpUrl: opts.cdpUrl,
2074
+ targetId: opts.targetId}).catch(() => {
2075
+ });
2076
+ page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2077
+ ensurePageState(page);
2078
+ response = await navigate();
2079
+ }
1798
2080
  await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...withBrowserNavigationPolicy(policy) });
1799
2081
  const finalUrl = page.url();
1800
- await assertBrowserNavigationResultAllowed({ url: finalUrl, ssrfPolicy: policy });
2082
+ await assertBrowserNavigationResultAllowed({ url: finalUrl, ...withBrowserNavigationPolicy(policy) });
1801
2083
  return { url: finalUrl };
1802
2084
  }
1803
2085
  async function listPagesViaPlaywright(opts) {
@@ -1823,11 +2105,12 @@ async function createPageViaPlaywright(opts) {
1823
2105
  }
1824
2106
  const { browser } = await connectBrowser(opts.cdpUrl);
1825
2107
  const context = browser.contexts()[0] ?? await browser.newContext();
2108
+ ensureContextState(context);
1826
2109
  const page = await context.newPage();
1827
2110
  ensurePageState(page);
1828
2111
  if (targetUrl !== "about:blank") {
1829
2112
  const navigationPolicy = withBrowserNavigationPolicy(policy);
1830
- const response = await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) }).catch(() => null);
2113
+ const response = await page.goto(targetUrl, { timeout: 3e4 }).catch(() => null);
1831
2114
  await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...navigationPolicy });
1832
2115
  await assertBrowserNavigationResultAllowed({ url: page.url(), ssrfPolicy: policy });
1833
2116
  }
@@ -1936,68 +2219,136 @@ async function evaluateInAllFramesViaPlaywright(opts) {
1936
2219
  }
1937
2220
  return results;
1938
2221
  }
2222
+ async function awaitEvalWithAbort(evalPromise, abortPromise) {
2223
+ if (!abortPromise) return await evalPromise;
2224
+ try {
2225
+ return await Promise.race([evalPromise, abortPromise]);
2226
+ } catch (err) {
2227
+ evalPromise.catch(() => {
2228
+ });
2229
+ throw err;
2230
+ }
2231
+ }
2232
+ var BROWSER_EVALUATOR = new Function("args", `
2233
+ "use strict";
2234
+ var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2235
+ try {
2236
+ var candidate = eval("(" + fnBody + ")");
2237
+ var result = typeof candidate === "function" ? candidate() : candidate;
2238
+ if (result && typeof result.then === "function") {
2239
+ return Promise.race([
2240
+ result,
2241
+ new Promise(function(_, reject) {
2242
+ setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2243
+ })
2244
+ ]);
2245
+ }
2246
+ return result;
2247
+ } catch (err) {
2248
+ throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2249
+ }
2250
+ `);
2251
+ var ELEMENT_EVALUATOR = new Function("el", "args", `
2252
+ "use strict";
2253
+ var fnBody = args.fnBody, timeoutMs = args.timeoutMs;
2254
+ try {
2255
+ var candidate = eval("(" + fnBody + ")");
2256
+ var result = typeof candidate === "function" ? candidate(el) : candidate;
2257
+ if (result && typeof result.then === "function") {
2258
+ return Promise.race([
2259
+ result,
2260
+ new Promise(function(_, reject) {
2261
+ setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);
2262
+ })
2263
+ ]);
2264
+ }
2265
+ return result;
2266
+ } catch (err) {
2267
+ throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));
2268
+ }
2269
+ `);
1939
2270
  async function evaluateViaPlaywright(opts) {
1940
2271
  const fnText = String(opts.fn ?? "").trim();
1941
2272
  if (!fnText) throw new Error("function is required");
1942
2273
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1943
2274
  ensurePageState(page);
1944
2275
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1945
- const timeout = opts.timeoutMs != null ? opts.timeoutMs : void 0;
1946
- if (opts.ref) {
1947
- const locator = refLocator(page, opts.ref);
1948
- return await locator.evaluate(
1949
- // eslint-disable-next-line no-eval
1950
- (el, fnBody) => {
1951
- try {
1952
- const candidate = (0, eval)("(" + fnBody + ")");
1953
- return typeof candidate === "function" ? candidate(el) : candidate;
1954
- } catch (err) {
1955
- throw new Error("Invalid evaluate function: " + (err instanceof Error ? err.message : String(err)));
1956
- }
1957
- },
1958
- fnText,
1959
- { timeout }
2276
+ const outerTimeout = normalizeTimeoutMs(opts.timeoutMs, 2e4);
2277
+ let evaluateTimeout = Math.max(1e3, Math.min(12e4, outerTimeout - 500));
2278
+ evaluateTimeout = Math.min(evaluateTimeout, outerTimeout);
2279
+ const signal = opts.signal;
2280
+ let abortListener;
2281
+ let abortReject;
2282
+ let abortPromise;
2283
+ if (signal) {
2284
+ abortPromise = new Promise((_, reject) => {
2285
+ abortReject = reject;
2286
+ });
2287
+ abortPromise.catch(() => {
2288
+ });
2289
+ }
2290
+ if (signal) {
2291
+ const disconnect = () => {
2292
+ forceDisconnectPlaywrightForTarget({
2293
+ cdpUrl: opts.cdpUrl,
2294
+ targetId: opts.targetId}).catch(() => {
2295
+ });
2296
+ };
2297
+ if (signal.aborted) {
2298
+ disconnect();
2299
+ throw signal.reason ?? new Error("aborted");
2300
+ }
2301
+ abortListener = () => {
2302
+ disconnect();
2303
+ abortReject?.(signal.reason ?? new Error("aborted"));
2304
+ };
2305
+ signal.addEventListener("abort", abortListener, { once: true });
2306
+ if (signal.aborted) {
2307
+ abortListener();
2308
+ throw signal.reason ?? new Error("aborted");
2309
+ }
2310
+ }
2311
+ try {
2312
+ if (opts.ref) {
2313
+ const locator = refLocator(page, opts.ref);
2314
+ return await awaitEvalWithAbort(
2315
+ locator.evaluate(ELEMENT_EVALUATOR, { fnBody: fnText, timeoutMs: evaluateTimeout }),
2316
+ abortPromise
2317
+ );
2318
+ }
2319
+ return await awaitEvalWithAbort(
2320
+ page.evaluate(BROWSER_EVALUATOR, { fnBody: fnText, timeoutMs: evaluateTimeout }),
2321
+ abortPromise
1960
2322
  );
2323
+ } finally {
2324
+ if (signal && abortListener) signal.removeEventListener("abort", abortListener);
1961
2325
  }
1962
- const evalPromise = page.evaluate(
1963
- // eslint-disable-next-line no-eval
1964
- (fnBody) => {
1965
- try {
1966
- const candidate = (0, eval)("(" + fnBody + ")");
1967
- return typeof candidate === "function" ? candidate() : candidate;
1968
- } catch (err) {
1969
- throw new Error("Invalid evaluate function: " + (err instanceof Error ? err.message : String(err)));
1970
- }
1971
- },
1972
- fnText
1973
- );
1974
- if (!opts.signal) return evalPromise;
1975
- return Promise.race([
1976
- evalPromise,
1977
- new Promise((_, reject) => {
1978
- opts.signal.addEventListener("abort", () => reject(new Error("Evaluate aborted")), { once: true });
1979
- })
1980
- ]);
1981
2326
  }
1982
-
1983
- // src/actions/download.ts
1984
2327
  async function downloadViaPlaywright(opts) {
1985
2328
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
1986
2329
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1987
- ensurePageState(page);
2330
+ const state = ensurePageState(page);
1988
2331
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1989
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
2332
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
1990
2333
  const locator = refLocator(page, opts.ref);
2334
+ state.armIdDownload = bumpDownloadArmId();
1991
2335
  try {
1992
2336
  const [download] = await Promise.all([
1993
2337
  page.waitForEvent("download", { timeout }),
1994
2338
  locator.click({ timeout })
1995
2339
  ]);
1996
- await download.saveAs(opts.path);
2340
+ const outPath = opts.path;
2341
+ await writeViaSiblingTempPath({
2342
+ rootDir: path.dirname(outPath),
2343
+ targetPath: outPath,
2344
+ writeTemp: async (tempPath) => {
2345
+ await download.saveAs(tempPath);
2346
+ }
2347
+ });
1997
2348
  return {
1998
2349
  url: download.url(),
1999
2350
  suggestedFilename: download.suggestedFilename(),
2000
- path: opts.path
2351
+ path: outPath
2001
2352
  };
2002
2353
  } catch (err) {
2003
2354
  throw toAIFriendlyError(err, opts.ref);
@@ -2006,11 +2357,17 @@ async function downloadViaPlaywright(opts) {
2006
2357
  async function waitForDownloadViaPlaywright(opts) {
2007
2358
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2008
2359
  ensurePageState(page);
2009
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
2360
+ const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2010
2361
  const download = await page.waitForEvent("download", { timeout });
2011
2362
  const savePath = opts.path ?? download.suggestedFilename();
2012
2363
  await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2013
- await download.saveAs(savePath);
2364
+ await writeViaSiblingTempPath({
2365
+ rootDir: path.dirname(savePath),
2366
+ targetPath: savePath,
2367
+ writeTemp: async (tempPath) => {
2368
+ await download.saveAs(tempPath);
2369
+ }
2370
+ });
2014
2371
  return {
2015
2372
  url: download.url(),
2016
2373
  suggestedFilename: download.suggestedFilename(),
@@ -2023,33 +2380,51 @@ async function emulateMediaViaPlaywright(opts) {
2023
2380
  await page.emulateMedia({ colorScheme: opts.colorScheme });
2024
2381
  }
2025
2382
  async function setDeviceViaPlaywright(opts) {
2026
- const device = playwrightCore.devices[opts.name];
2383
+ const name = String(opts.name ?? "").trim();
2384
+ if (!name) throw new Error("device name is required");
2385
+ const device = playwrightCore.devices[name];
2027
2386
  if (!device) {
2028
- const available = Object.keys(playwrightCore.devices).slice(0, 10).join(", ");
2029
- throw new Error(`Unknown device "${opts.name}". Some available devices: ${available}...`);
2387
+ throw new Error(`Unknown device "${name}".`);
2030
2388
  }
2031
2389
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2032
2390
  ensurePageState(page);
2033
2391
  if (device.viewport) {
2034
- await page.setViewportSize(device.viewport);
2392
+ await page.setViewportSize({
2393
+ width: device.viewport.width,
2394
+ height: device.viewport.height
2395
+ });
2035
2396
  }
2036
- if (device.userAgent) {
2037
- const context = page.context();
2038
- const session = await context.newCDPSession(page);
2039
- try {
2397
+ const session = await page.context().newCDPSession(page);
2398
+ try {
2399
+ const locale = device.locale;
2400
+ if (device.userAgent || locale) {
2040
2401
  await session.send("Emulation.setUserAgentOverride", {
2041
- userAgent: device.userAgent
2402
+ userAgent: device.userAgent ?? "",
2403
+ acceptLanguage: locale ?? void 0
2042
2404
  });
2043
- } finally {
2044
- await session.detach().catch(() => {
2405
+ }
2406
+ if (device.viewport) {
2407
+ await session.send("Emulation.setDeviceMetricsOverride", {
2408
+ mobile: Boolean(device.isMobile),
2409
+ width: device.viewport.width,
2410
+ height: device.viewport.height,
2411
+ deviceScaleFactor: device.deviceScaleFactor ?? 1,
2412
+ screenWidth: device.viewport.width,
2413
+ screenHeight: device.viewport.height
2045
2414
  });
2046
2415
  }
2416
+ if (device.hasTouch) {
2417
+ await session.send("Emulation.setTouchEmulationEnabled", { enabled: true });
2418
+ }
2419
+ } finally {
2420
+ await session.detach().catch(() => {
2421
+ });
2047
2422
  }
2048
2423
  }
2049
2424
  async function setExtraHTTPHeadersViaPlaywright(opts) {
2050
2425
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2051
2426
  ensurePageState(page);
2052
- await page.setExtraHTTPHeaders(opts.headers);
2427
+ await page.context().setExtraHTTPHeaders(opts.headers);
2053
2428
  }
2054
2429
  async function setGeolocationViaPlaywright(opts) {
2055
2430
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
@@ -2057,38 +2432,53 @@ async function setGeolocationViaPlaywright(opts) {
2057
2432
  const context = page.context();
2058
2433
  if (opts.clear) {
2059
2434
  await context.setGeolocation(null);
2060
- await context.clearPermissions();
2435
+ await context.clearPermissions().catch(() => {
2436
+ });
2061
2437
  return;
2062
2438
  }
2063
- if (opts.latitude === void 0 || opts.longitude === void 0) {
2439
+ if (typeof opts.latitude !== "number" || typeof opts.longitude !== "number") {
2064
2440
  throw new Error("latitude and longitude are required (or set clear=true)");
2065
2441
  }
2066
- await context.grantPermissions(["geolocation"], opts.origin ? { origin: opts.origin } : void 0);
2067
2442
  await context.setGeolocation({
2068
2443
  latitude: opts.latitude,
2069
2444
  longitude: opts.longitude,
2070
- accuracy: opts.accuracy
2445
+ accuracy: typeof opts.accuracy === "number" ? opts.accuracy : void 0
2446
+ });
2447
+ const origin = opts.origin?.trim() || (() => {
2448
+ try {
2449
+ return new URL(page.url()).origin;
2450
+ } catch {
2451
+ return "";
2452
+ }
2453
+ })();
2454
+ if (origin) await context.grantPermissions(["geolocation"], { origin }).catch(() => {
2071
2455
  });
2072
2456
  }
2073
2457
  async function setHttpCredentialsViaPlaywright(opts) {
2074
2458
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2075
2459
  ensurePageState(page);
2076
- const context = page.context();
2077
2460
  if (opts.clear) {
2078
- await context.setHTTPCredentials({ username: "", password: "" });
2461
+ await page.context().setHTTPCredentials(null);
2079
2462
  return;
2080
2463
  }
2081
- await context.setHTTPCredentials({
2082
- username: opts.username ?? "",
2083
- password: opts.password ?? ""
2084
- });
2464
+ const username = String(opts.username ?? "");
2465
+ const password = String(opts.password ?? "");
2466
+ if (!username) throw new Error("username is required (or set clear=true)");
2467
+ await page.context().setHTTPCredentials({ username, password });
2085
2468
  }
2086
2469
  async function setLocaleViaPlaywright(opts) {
2087
2470
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2088
2471
  ensurePageState(page);
2472
+ const locale = String(opts.locale ?? "").trim();
2473
+ if (!locale) throw new Error("locale is required");
2089
2474
  const session = await page.context().newCDPSession(page);
2090
2475
  try {
2091
- await session.send("Emulation.setLocaleOverride", { locale: opts.locale });
2476
+ try {
2477
+ await session.send("Emulation.setLocaleOverride", { locale });
2478
+ } catch (err) {
2479
+ if (String(err).includes("Another locale override is already in effect")) return;
2480
+ throw err;
2481
+ }
2092
2482
  } finally {
2093
2483
  await session.detach().catch(() => {
2094
2484
  });
@@ -2102,9 +2492,18 @@ async function setOfflineViaPlaywright(opts) {
2102
2492
  async function setTimezoneViaPlaywright(opts) {
2103
2493
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2104
2494
  ensurePageState(page);
2495
+ const timezoneId = String(opts.timezoneId ?? "").trim();
2496
+ if (!timezoneId) throw new Error("timezoneId is required");
2105
2497
  const session = await page.context().newCDPSession(page);
2106
2498
  try {
2107
- await session.send("Emulation.setTimezoneOverride", { timezoneId: opts.timezoneId });
2499
+ try {
2500
+ await session.send("Emulation.setTimezoneOverride", { timezoneId });
2501
+ } catch (err) {
2502
+ const msg = String(err);
2503
+ if (msg.includes("Timezone override is already in effect")) return;
2504
+ if (msg.includes("Invalid timezone")) throw new Error(`Invalid timezone ID: ${timezoneId}`, { cause: err });
2505
+ throw err;
2506
+ }
2108
2507
  } finally {
2109
2508
  await session.detach().catch(() => {
2110
2509
  });
@@ -2180,24 +2579,38 @@ async function pdfViaPlaywright(opts) {
2180
2579
  ensurePageState(page);
2181
2580
  return { buffer: await page.pdf({ printBackground: true }) };
2182
2581
  }
2183
-
2184
- // src/capture/trace.ts
2185
2582
  async function traceStartViaPlaywright(opts) {
2186
2583
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2187
2584
  ensurePageState(page);
2188
2585
  const context = page.context();
2586
+ const ctxState = ensureContextState(context);
2587
+ if (ctxState.traceActive) {
2588
+ throw new Error("Trace already running. Stop the current trace before starting a new one.");
2589
+ }
2189
2590
  await context.tracing.start({
2190
2591
  screenshots: opts.screenshots ?? true,
2191
2592
  snapshots: opts.snapshots ?? true,
2192
- sources: opts.sources
2593
+ sources: opts.sources ?? false
2193
2594
  });
2595
+ ctxState.traceActive = true;
2194
2596
  }
2195
2597
  async function traceStopViaPlaywright(opts) {
2196
2598
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
2197
2599
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2198
2600
  ensurePageState(page);
2199
2601
  const context = page.context();
2200
- await context.tracing.stop({ path: opts.path });
2602
+ const ctxState = ensureContextState(context);
2603
+ if (!ctxState.traceActive) {
2604
+ throw new Error("No active trace. Start a trace before stopping it.");
2605
+ }
2606
+ await writeViaSiblingTempPath({
2607
+ rootDir: path.dirname(opts.path),
2608
+ targetPath: opts.path,
2609
+ writeTemp: async (tempPath) => {
2610
+ await context.tracing.stop({ path: tempPath });
2611
+ }
2612
+ });
2613
+ ctxState.traceActive = false;
2201
2614
  }
2202
2615
 
2203
2616
  // src/capture/response.ts
@@ -2280,8 +2693,8 @@ async function cookiesSetViaPlaywright(opts) {
2280
2693
  const cookie = opts.cookie;
2281
2694
  if (!cookie.name || cookie.value === void 0) throw new Error("cookie name and value are required");
2282
2695
  const hasUrl = typeof cookie.url === "string" && cookie.url.trim();
2283
- const hasDomain = typeof cookie.domain === "string" && cookie.domain.trim();
2284
- if (!hasUrl && !hasDomain) throw new Error("cookie requires url or domain");
2696
+ const hasDomainPath = typeof cookie.domain === "string" && cookie.domain.trim() && typeof cookie.path === "string" && cookie.path.trim();
2697
+ if (!hasUrl && !hasDomainPath) throw new Error("cookie requires url, or domain+path");
2285
2698
  await page.context().addCookies([cookie]);
2286
2699
  }
2287
2700
  async function cookiesClearViaPlaywright(opts) {
@@ -3360,11 +3773,17 @@ exports.InvalidBrowserNavigationUrlError = InvalidBrowserNavigationUrlError;
3360
3773
  exports.assertBrowserNavigationAllowed = assertBrowserNavigationAllowed;
3361
3774
  exports.assertBrowserNavigationRedirectChainAllowed = assertBrowserNavigationRedirectChainAllowed;
3362
3775
  exports.assertBrowserNavigationResultAllowed = assertBrowserNavigationResultAllowed;
3776
+ exports.createPinnedLookup = createPinnedLookup;
3777
+ exports.ensureContextState = ensureContextState;
3778
+ exports.forceDisconnectPlaywrightForTarget = forceDisconnectPlaywrightForTarget;
3363
3779
  exports.getChromeWebSocketUrl = getChromeWebSocketUrl;
3364
3780
  exports.isChromeCdpReady = isChromeCdpReady;
3365
3781
  exports.isChromeReachable = isChromeReachable;
3366
3782
  exports.normalizeCdpHttpBaseForJsonEndpoints = normalizeCdpHttpBaseForJsonEndpoints;
3367
3783
  exports.requiresInspectableBrowserNavigationRedirects = requiresInspectableBrowserNavigationRedirects;
3784
+ exports.resolvePinnedHostnameWithPolicy = resolvePinnedHostnameWithPolicy;
3785
+ exports.sanitizeUntrustedFileName = sanitizeUntrustedFileName;
3368
3786
  exports.withBrowserNavigationPolicy = withBrowserNavigationPolicy;
3787
+ exports.writeViaSiblingTempPath = writeViaSiblingTempPath;
3369
3788
  //# sourceMappingURL=index.cjs.map
3370
3789
  //# sourceMappingURL=index.cjs.map