browserclaw 0.5.2 → 0.5.5

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
@@ -829,15 +829,16 @@ async function connectBrowser(cdpUrl, authToken) {
829
829
  const headers = {};
830
830
  if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
831
831
  const browser = await playwrightCore.chromium.connectOverCDP(endpoint, { timeout, headers });
832
- const connected = { browser, cdpUrl: normalized, authToken };
833
- cached = connected;
834
- observeBrowser(browser);
835
- browser.on("disconnected", () => {
832
+ const onDisconnected = () => {
836
833
  if (cached?.browser === browser) cached = null;
837
834
  for (const key of roleRefsByTarget.keys()) {
838
835
  if (key.startsWith(normalized + "::")) roleRefsByTarget.delete(key);
839
836
  }
840
- });
837
+ };
838
+ const connected = { browser, cdpUrl: normalized, authToken, onDisconnected };
839
+ cached = connected;
840
+ observeBrowser(browser);
841
+ browser.on("disconnected", onDisconnected);
841
842
  return connected;
842
843
  } catch (err) {
843
844
  lastErr = err;
@@ -867,6 +868,14 @@ async function disconnectBrowser() {
867
868
  if (cur) await cur.browser.close().catch(() => {
868
869
  });
869
870
  }
871
+ function cdpSocketNeedsAttach(wsUrl) {
872
+ try {
873
+ const pathname = new URL(wsUrl).pathname;
874
+ return pathname === "/cdp" || pathname.endsWith("/cdp") || pathname.includes("/devtools/browser/") || pathname === "/";
875
+ } catch {
876
+ return false;
877
+ }
878
+ }
870
879
  async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
871
880
  const httpBase = normalizeCdpHttpBaseForJsonEndpoints(cdpUrl);
872
881
  const ctrl = new AbortController();
@@ -883,8 +892,10 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
883
892
  }
884
893
  if (!Array.isArray(targets)) return;
885
894
  const target = targets.find((entry) => String(entry?.id ?? "").trim() === targetId);
886
- const wsUrl = String(target?.webSocketDebuggerUrl ?? "").trim();
887
- if (!wsUrl) return;
895
+ const wsUrlRaw = String(target?.webSocketDebuggerUrl ?? "").trim();
896
+ if (!wsUrlRaw) return;
897
+ const wsUrl = normalizeCdpWsUrl(wsUrlRaw, httpBase);
898
+ const needsAttach = cdpSocketNeedsAttach(wsUrl);
888
899
  await new Promise((resolve2) => {
889
900
  let done = false;
890
901
  const finish = () => {
@@ -897,8 +908,9 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
897
908
  }
898
909
  resolve2();
899
910
  };
900
- const timer = setTimeout(finish, 2e3);
911
+ const timer = setTimeout(finish, 3e3);
901
912
  let ws;
913
+ let nextId = 1;
902
914
  try {
903
915
  ws = new WebSocket(wsUrl);
904
916
  } catch {
@@ -906,11 +918,27 @@ async function tryTerminateExecutionViaCdp(cdpUrl, targetId) {
906
918
  return;
907
919
  }
908
920
  ws.onopen = () => {
921
+ if (needsAttach) {
922
+ ws.send(JSON.stringify({ id: nextId++, method: "Target.attachToTarget", params: { targetId, flatten: true } }));
923
+ } else {
924
+ ws.send(JSON.stringify({ id: nextId++, method: "Runtime.terminateExecution" }));
925
+ setTimeout(finish, 300);
926
+ }
927
+ };
928
+ ws.onmessage = (event) => {
929
+ if (!needsAttach) return;
909
930
  try {
910
- ws.send(JSON.stringify({ id: 1, method: "Runtime.terminateExecution" }));
931
+ const msg = JSON.parse(String(event.data));
932
+ if (msg.id && msg.result?.sessionId) {
933
+ ws.send(JSON.stringify({ id: nextId++, sessionId: msg.result.sessionId, method: "Runtime.terminateExecution" }));
934
+ try {
935
+ ws.send(JSON.stringify({ id: nextId++, method: "Target.detachFromTarget", params: { sessionId: msg.result.sessionId } }));
936
+ } catch {
937
+ }
938
+ setTimeout(finish, 300);
939
+ }
911
940
  } catch {
912
941
  }
913
- setTimeout(finish, 300);
914
942
  };
915
943
  ws.onerror = () => finish();
916
944
  ws.onclose = () => finish();
@@ -922,6 +950,9 @@ async function forceDisconnectPlaywrightForTarget(opts) {
922
950
  if (!cur || cur.cdpUrl !== normalized) return;
923
951
  cached = null;
924
952
  connectingByUrl.delete(normalized);
953
+ if (cur.onDisconnected && typeof cur.browser.off === "function") {
954
+ cur.browser.off("disconnected", cur.onDisconnected);
955
+ }
925
956
  const targetId = opts.targetId?.trim() || "";
926
957
  if (targetId) {
927
958
  await tryTerminateExecutionViaCdp(normalized, targetId).catch(() => {
@@ -957,33 +988,37 @@ async function findPageByTargetId(browser, targetId, cdpUrl) {
957
988
  }
958
989
  if (tid && tid === targetId) return page;
959
990
  }
960
- if (!resolvedViaCdp && pages.length === 1) {
961
- return pages[0];
962
- }
963
991
  if (cdpUrl) {
964
992
  try {
965
993
  const listUrl = `${normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)}/json/list`;
966
994
  const headers = {};
967
995
  if (cached?.authToken) headers["Authorization"] = `Bearer ${cached.authToken}`;
968
- const response = await fetch(listUrl, { headers });
969
- if (response.ok) {
970
- const targets = await response.json();
971
- const target = targets.find((t) => t.id === targetId);
972
- if (target) {
973
- const urlMatch = pages.filter((p) => p.url() === target.url);
974
- if (urlMatch.length === 1) return urlMatch[0];
975
- if (urlMatch.length > 1) {
976
- const sameUrlTargets = targets.filter((t) => t.url === target.url);
977
- if (sameUrlTargets.length === urlMatch.length) {
978
- const idx = sameUrlTargets.findIndex((t) => t.id === targetId);
979
- if (idx >= 0 && idx < urlMatch.length) return urlMatch[idx];
996
+ const ctrl = new AbortController();
997
+ const t = setTimeout(() => ctrl.abort(), 2e3);
998
+ try {
999
+ const response = await fetch(listUrl, { headers, signal: ctrl.signal });
1000
+ if (response.ok) {
1001
+ const targets = await response.json();
1002
+ const target = targets.find((entry) => entry.id === targetId);
1003
+ if (target) {
1004
+ const urlMatch = pages.filter((p) => p.url() === target.url);
1005
+ if (urlMatch.length === 1) return urlMatch[0];
1006
+ if (urlMatch.length > 1) {
1007
+ const sameUrlTargets = targets.filter((entry) => entry.url === target.url);
1008
+ if (sameUrlTargets.length === urlMatch.length) {
1009
+ const idx = sameUrlTargets.findIndex((entry) => entry.id === targetId);
1010
+ if (idx >= 0 && idx < urlMatch.length) return urlMatch[idx];
1011
+ }
980
1012
  }
981
1013
  }
982
1014
  }
1015
+ } finally {
1016
+ clearTimeout(t);
983
1017
  }
984
1018
  } catch {
985
1019
  }
986
1020
  }
1021
+ if (!resolvedViaCdp && pages.length === 1) return pages[0];
987
1022
  return null;
988
1023
  }
989
1024
  async function getPageForTargetId(opts) {
@@ -1508,20 +1543,6 @@ function isBlockedHostnameNormalized(normalized) {
1508
1543
  if (BLOCKED_HOSTNAMES.has(normalized)) return true;
1509
1544
  return normalized.endsWith(".localhost") || normalized.endsWith(".local") || normalized.endsWith(".internal");
1510
1545
  }
1511
- function isStrictDecimalOctet(part) {
1512
- if (!/^[0-9]+$/.test(part)) return false;
1513
- const n = parseInt(part, 10);
1514
- if (n < 0 || n > 255) return false;
1515
- if (String(n) !== part) return false;
1516
- return true;
1517
- }
1518
- function isUnsupportedIPv4Literal(ip) {
1519
- if (/^[0-9]+$/.test(ip)) return true;
1520
- const parts = ip.split(".");
1521
- if (parts.length !== 4) return true;
1522
- if (!parts.every(isStrictDecimalOctet)) return true;
1523
- return false;
1524
- }
1525
1546
  var BLOCKED_IPV4_RANGES = /* @__PURE__ */ new Set([
1526
1547
  "unspecified",
1527
1548
  "broadcast",
@@ -1540,6 +1561,97 @@ var BLOCKED_IPV6_RANGES = /* @__PURE__ */ new Set([
1540
1561
  "multicast"
1541
1562
  ]);
1542
1563
  var RFC2544_BENCHMARK_PREFIX = [ipaddr__namespace.IPv4.parse("198.18.0.0"), 15];
1564
+ var EMBEDDED_IPV4_SENTINEL_RULES = [
1565
+ // IPv4-compatible (::a.b.c.d)
1566
+ {
1567
+ matches: (parts) => parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0,
1568
+ toHextets: (parts) => [parts[6], parts[7]]
1569
+ },
1570
+ // NAT64 local-use (64:ff9b:1::/48)
1571
+ {
1572
+ matches: (parts) => parts[0] === 100 && parts[1] === 65435 && parts[2] === 1 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0,
1573
+ toHextets: (parts) => [parts[6], parts[7]]
1574
+ },
1575
+ // 6to4 (2002::/16)
1576
+ {
1577
+ matches: (parts) => parts[0] === 8194,
1578
+ toHextets: (parts) => [parts[1], parts[2]]
1579
+ },
1580
+ // Teredo (2001:0000::/32) — IPv4 XOR'd
1581
+ {
1582
+ matches: (parts) => parts[0] === 8193 && parts[1] === 0,
1583
+ toHextets: (parts) => [parts[6] ^ 65535, parts[7] ^ 65535]
1584
+ },
1585
+ // ISATAP — sentinel in parts[4-5]: 0x0000:0x5efe or 0x0200:0x5efe
1586
+ {
1587
+ matches: (parts) => (parts[4] & 64767) === 0 && parts[5] === 24318,
1588
+ toHextets: (parts) => [parts[6], parts[7]]
1589
+ }
1590
+ ];
1591
+ function stripIpv6Brackets(value) {
1592
+ if (value.startsWith("[") && value.endsWith("]")) return value.slice(1, -1);
1593
+ return value;
1594
+ }
1595
+ function isNumericIpv4LiteralPart(value) {
1596
+ return /^[0-9]+$/.test(value) || /^0x[0-9a-f]+$/i.test(value);
1597
+ }
1598
+ function parseIpv6WithEmbeddedIpv4(raw) {
1599
+ if (!raw.includes(":") || !raw.includes(".")) return;
1600
+ const match = /^(.*:)([^:%]+(?:\.[^:%]+){3})(%[0-9A-Za-z]+)?$/i.exec(raw);
1601
+ if (!match) return;
1602
+ const [, prefix, embeddedIpv4, zoneSuffix = ""] = match;
1603
+ if (!ipaddr__namespace.IPv4.isValidFourPartDecimal(embeddedIpv4)) return;
1604
+ const octets = embeddedIpv4.split(".").map((part) => Number.parseInt(part, 10));
1605
+ const normalizedIpv6 = `${prefix}${(octets[0] << 8 | octets[1]).toString(16)}:${(octets[2] << 8 | octets[3]).toString(16)}${zoneSuffix}`;
1606
+ if (!ipaddr__namespace.IPv6.isValid(normalizedIpv6)) return;
1607
+ return ipaddr__namespace.IPv6.parse(normalizedIpv6);
1608
+ }
1609
+ function normalizeIpParseInput(raw) {
1610
+ const trimmed = raw?.trim();
1611
+ if (!trimmed) return;
1612
+ return stripIpv6Brackets(trimmed);
1613
+ }
1614
+ function parseCanonicalIpAddress(raw) {
1615
+ const normalized = normalizeIpParseInput(raw);
1616
+ if (!normalized) return;
1617
+ if (ipaddr__namespace.IPv4.isValid(normalized)) {
1618
+ if (!ipaddr__namespace.IPv4.isValidFourPartDecimal(normalized)) return;
1619
+ return ipaddr__namespace.IPv4.parse(normalized);
1620
+ }
1621
+ if (ipaddr__namespace.IPv6.isValid(normalized)) return ipaddr__namespace.IPv6.parse(normalized);
1622
+ return parseIpv6WithEmbeddedIpv4(normalized);
1623
+ }
1624
+ function parseLooseIpAddress(raw) {
1625
+ const normalized = normalizeIpParseInput(raw);
1626
+ if (!normalized) return;
1627
+ if (ipaddr__namespace.isValid(normalized)) return ipaddr__namespace.parse(normalized);
1628
+ return parseIpv6WithEmbeddedIpv4(normalized);
1629
+ }
1630
+ function isCanonicalDottedDecimalIPv4(raw) {
1631
+ const trimmed = raw?.trim();
1632
+ if (!trimmed) return false;
1633
+ const normalized = stripIpv6Brackets(trimmed);
1634
+ if (!normalized) return false;
1635
+ return ipaddr__namespace.IPv4.isValidFourPartDecimal(normalized);
1636
+ }
1637
+ function isLegacyIpv4Literal(raw) {
1638
+ const trimmed = raw?.trim();
1639
+ if (!trimmed) return false;
1640
+ const normalized = stripIpv6Brackets(trimmed);
1641
+ if (!normalized || normalized.includes(":")) return false;
1642
+ if (isCanonicalDottedDecimalIPv4(normalized)) return false;
1643
+ const parts = normalized.split(".");
1644
+ if (parts.length === 0 || parts.length > 4) return false;
1645
+ if (parts.some((part) => part.length === 0)) return false;
1646
+ if (!parts.every((part) => isNumericIpv4LiteralPart(part))) return false;
1647
+ return true;
1648
+ }
1649
+ function looksLikeUnsupportedIpv4Literal(address) {
1650
+ const parts = address.split(".");
1651
+ if (parts.length === 0 || parts.length > 4) return false;
1652
+ if (parts.some((part) => part.length === 0)) return true;
1653
+ return parts.every((part) => /^[0-9]+$/.test(part) || /^0x/i.test(part));
1654
+ }
1543
1655
  function isBlockedSpecialUseIpv4Address(address, opts) {
1544
1656
  const inRfc2544 = address.match(RFC2544_BENCHMARK_PREFIX);
1545
1657
  if (inRfc2544 && opts?.allowRfc2544BenchmarkRange === true) return false;
@@ -1549,89 +1661,50 @@ function isBlockedSpecialUseIpv6Address(address) {
1549
1661
  if (BLOCKED_IPV6_RANGES.has(address.range())) return true;
1550
1662
  return (address.parts[0] & 65472) === 65216;
1551
1663
  }
1552
- function extractEmbeddedIpv4FromIpv6(v6, opts) {
1553
- if (v6.isIPv4MappedAddress()) {
1554
- return isBlockedSpecialUseIpv4Address(v6.toIPv4Address(), opts);
1555
- }
1556
- const parts = v6.parts;
1557
- if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1558
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1559
- try {
1560
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1561
- } catch {
1562
- return true;
1563
- }
1564
- }
1565
- if (parts[0] === 100 && parts[1] === 65435 && parts[2] === 1) {
1566
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1567
- try {
1568
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1569
- } catch {
1570
- return true;
1571
- }
1572
- }
1573
- if (parts[0] === 8194) {
1574
- const ip4str = `${parts[1] >> 8 & 255}.${parts[1] & 255}.${parts[2] >> 8 & 255}.${parts[2] & 255}`;
1575
- try {
1576
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1577
- } catch {
1578
- return true;
1579
- }
1580
- }
1581
- if (parts[0] === 8193 && parts[1] === 0) {
1582
- const hiXored = parts[6] ^ 65535;
1583
- const loXored = parts[7] ^ 65535;
1584
- const ip4str = `${hiXored >> 8 & 255}.${hiXored & 255}.${loXored >> 8 & 255}.${loXored & 255}`;
1585
- try {
1586
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1587
- } catch {
1588
- return true;
1589
- }
1590
- }
1591
- if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 65535 && parts[5] === 0) {
1592
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1593
- try {
1594
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1595
- } catch {
1596
- return true;
1597
- }
1598
- }
1599
- if (parts[0] === 0 && parts[1] === 0 && parts[2] === 0 && parts[3] === 0 && parts[4] === 0 && parts[5] === 0) {
1600
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1601
- try {
1602
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1603
- } catch {
1604
- return true;
1605
- }
1606
- }
1607
- if ((parts[4] & 65023) === 0 && parts[5] === 24318) {
1608
- const ip4str = `${parts[6] >> 8 & 255}.${parts[6] & 255}.${parts[7] >> 8 & 255}.${parts[7] & 255}`;
1609
- try {
1610
- return isBlockedSpecialUseIpv4Address(ipaddr__namespace.IPv4.parse(ip4str), opts);
1611
- } catch {
1612
- return true;
1613
- }
1664
+ function decodeIpv4FromHextets(high, low) {
1665
+ const octets = [
1666
+ high >>> 8 & 255,
1667
+ high & 255,
1668
+ low >>> 8 & 255,
1669
+ low & 255
1670
+ ];
1671
+ return ipaddr__namespace.IPv4.parse(octets.join("."));
1672
+ }
1673
+ function extractEmbeddedIpv4FromIpv6(address) {
1674
+ if (address.isIPv4MappedAddress()) return address.toIPv4Address();
1675
+ if (address.range() === "rfc6145") return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
1676
+ if (address.range() === "rfc6052") return decodeIpv4FromHextets(address.parts[6], address.parts[7]);
1677
+ for (const rule of EMBEDDED_IPV4_SENTINEL_RULES) {
1678
+ if (!rule.matches(address.parts)) continue;
1679
+ const [high, low] = rule.toHextets(address.parts);
1680
+ return decodeIpv4FromHextets(high, low);
1614
1681
  }
1615
- return null;
1616
1682
  }
1617
- function isPrivateIpAddress(address, opts) {
1683
+ function resolveIpv4SpecialUseBlockOptions(policy) {
1684
+ return { allowRfc2544BenchmarkRange: policy?.allowRfc2544BenchmarkRange === true };
1685
+ }
1686
+ function isBlockedHostnameOrIp(hostname, policy) {
1687
+ const normalized = normalizeHostname(hostname);
1688
+ if (!normalized) return false;
1689
+ return isBlockedHostnameNormalized(normalized) || isPrivateIpAddress(normalized, policy);
1690
+ }
1691
+ function isPrivateIpAddress(address, policy) {
1618
1692
  let normalized = address.trim().toLowerCase();
1619
1693
  if (normalized.startsWith("[") && normalized.endsWith("]")) normalized = normalized.slice(1, -1);
1620
1694
  if (!normalized) return false;
1621
- try {
1622
- const parsed = ipaddr__namespace.parse(normalized);
1623
- if (parsed.kind() === "ipv4") {
1624
- return isBlockedSpecialUseIpv4Address(parsed, opts);
1625
- }
1626
- const v6 = parsed;
1695
+ const blockOptions = resolveIpv4SpecialUseBlockOptions(policy);
1696
+ const strictIp = parseCanonicalIpAddress(normalized);
1697
+ if (strictIp) {
1698
+ if (strictIp.kind() === "ipv4") return isBlockedSpecialUseIpv4Address(strictIp, blockOptions);
1699
+ const v6 = strictIp;
1627
1700
  if (isBlockedSpecialUseIpv6Address(v6)) return true;
1628
- const embeddedV4 = extractEmbeddedIpv4FromIpv6(v6, opts);
1629
- if (embeddedV4 !== null) return embeddedV4;
1701
+ const embeddedIpv4 = extractEmbeddedIpv4FromIpv6(v6);
1702
+ if (embeddedIpv4) return isBlockedSpecialUseIpv4Address(embeddedIpv4, blockOptions);
1630
1703
  return false;
1631
- } catch {
1632
1704
  }
1633
- if (!normalized.includes(":") && isUnsupportedIPv4Literal(normalized)) return true;
1634
- if (normalized.includes(":")) return true;
1705
+ if (normalized.includes(":") && !parseLooseIpAddress(normalized)) return true;
1706
+ if (!isCanonicalDottedDecimalIPv4(normalized) && isLegacyIpv4Literal(normalized)) return true;
1707
+ if (looksLikeUnsupportedIpv4Literal(normalized)) return true;
1635
1708
  return false;
1636
1709
  }
1637
1710
  function normalizeHostnameSet(values) {
@@ -1713,13 +1786,7 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1713
1786
  );
1714
1787
  }
1715
1788
  if (!skipPrivateNetworkChecks) {
1716
- if (isBlockedHostnameNormalized(normalized)) {
1717
- throw new InvalidBrowserNavigationUrlError(
1718
- `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1719
- );
1720
- }
1721
- const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1722
- if (isPrivateIpAddress(normalized, ipOpts)) {
1789
+ if (isBlockedHostnameOrIp(normalized, params.policy)) {
1723
1790
  throw new InvalidBrowserNavigationUrlError(
1724
1791
  `Navigation to internal/loopback address blocked: "${hostname}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1725
1792
  );
@@ -1740,9 +1807,8 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1740
1807
  );
1741
1808
  }
1742
1809
  if (!skipPrivateNetworkChecks) {
1743
- const ipOpts = { allowRfc2544BenchmarkRange: params.policy?.allowRfc2544BenchmarkRange };
1744
1810
  for (const r of results) {
1745
- if (isPrivateIpAddress(r.address, ipOpts)) {
1811
+ if (isBlockedHostnameOrIp(r.address, params.policy)) {
1746
1812
  throw new InvalidBrowserNavigationUrlError(
1747
1813
  `Navigation to internal/loopback address blocked: "${hostname}" resolves to "${r.address}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1748
1814
  );
@@ -1763,6 +1829,7 @@ async function resolvePinnedHostnameWithPolicy(hostname, params = {}) {
1763
1829
  }
1764
1830
  async function assertBrowserNavigationAllowed(opts) {
1765
1831
  const rawUrl = String(opts.url ?? "").trim();
1832
+ if (!rawUrl) throw new InvalidBrowserNavigationUrlError("url is required");
1766
1833
  let parsed;
1767
1834
  try {
1768
1835
  parsed = new URL(rawUrl);
@@ -1919,12 +1986,30 @@ function resolveBoundedDelayMs(value, label, maxMs) {
1919
1986
  if (normalized > maxMs) throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
1920
1987
  return normalized;
1921
1988
  }
1922
- async function clickViaPlaywright(opts) {
1989
+ function resolveInteractionTimeoutMs(timeoutMs) {
1990
+ return Math.max(500, Math.min(6e4, Math.floor(timeoutMs ?? 8e3)));
1991
+ }
1992
+ function requireRefOrSelector(ref, selector) {
1993
+ const trimmedRef = typeof ref === "string" ? ref.trim() : "";
1994
+ const trimmedSelector = typeof selector === "string" ? selector.trim() : "";
1995
+ if (!trimmedRef && !trimmedSelector) throw new Error("ref or selector is required");
1996
+ return { ref: trimmedRef || void 0, selector: trimmedSelector || void 0 };
1997
+ }
1998
+ function resolveLocator(page, resolved) {
1999
+ return resolved.ref ? refLocator(page, resolved.ref) : page.locator(resolved.selector);
2000
+ }
2001
+ async function getRestoredPageForTarget(opts) {
1923
2002
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1924
2003
  ensurePageState(page);
1925
2004
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1926
- const locator = refLocator(page, opts.ref);
1927
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2005
+ return page;
2006
+ }
2007
+ async function clickViaPlaywright(opts) {
2008
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2009
+ const page = await getRestoredPageForTarget(opts);
2010
+ const label = resolved.ref ?? resolved.selector;
2011
+ const locator = resolveLocator(page, resolved);
2012
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
1928
2013
  try {
1929
2014
  const delayMs = resolveBoundedDelayMs(opts.delayMs, "click delayMs", MAX_CLICK_DELAY_MS);
1930
2015
  if (delayMs > 0) {
@@ -1937,28 +2022,27 @@ async function clickViaPlaywright(opts) {
1937
2022
  await locator.click({ timeout, button: opts.button, modifiers: opts.modifiers });
1938
2023
  }
1939
2024
  } catch (err) {
1940
- throw toAIFriendlyError(err, opts.ref);
2025
+ throw toAIFriendlyError(err, label);
1941
2026
  }
1942
2027
  }
1943
2028
  async function hoverViaPlaywright(opts) {
1944
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1945
- ensurePageState(page);
1946
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2029
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2030
+ const page = await getRestoredPageForTarget(opts);
2031
+ const label = resolved.ref ?? resolved.selector;
2032
+ const locator = resolveLocator(page, resolved);
1947
2033
  try {
1948
- await refLocator(page, opts.ref).hover({
1949
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1950
- });
2034
+ await locator.hover({ timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1951
2035
  } catch (err) {
1952
- throw toAIFriendlyError(err, opts.ref);
2036
+ throw toAIFriendlyError(err, label);
1953
2037
  }
1954
2038
  }
1955
2039
  async function typeViaPlaywright(opts) {
2040
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
1956
2041
  const text = String(opts.text ?? "");
1957
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1958
- ensurePageState(page);
1959
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1960
- const locator = refLocator(page, opts.ref);
1961
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2042
+ const page = await getRestoredPageForTarget(opts);
2043
+ const label = resolved.ref ?? resolved.selector;
2044
+ const locator = resolveLocator(page, resolved);
2045
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
1962
2046
  try {
1963
2047
  if (opts.slowly) {
1964
2048
  await locator.click({ timeout });
@@ -1968,39 +2052,38 @@ async function typeViaPlaywright(opts) {
1968
2052
  }
1969
2053
  if (opts.submit) await locator.press("Enter", { timeout });
1970
2054
  } catch (err) {
1971
- throw toAIFriendlyError(err, opts.ref);
2055
+ throw toAIFriendlyError(err, label);
1972
2056
  }
1973
2057
  }
1974
2058
  async function selectOptionViaPlaywright(opts) {
2059
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
1975
2060
  if (!opts.values?.length) throw new Error("values are required");
1976
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1977
- ensurePageState(page);
1978
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2061
+ const page = await getRestoredPageForTarget(opts);
2062
+ const label = resolved.ref ?? resolved.selector;
2063
+ const locator = resolveLocator(page, resolved);
1979
2064
  try {
1980
- await refLocator(page, opts.ref).selectOption(opts.values, {
1981
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1982
- });
2065
+ await locator.selectOption(opts.values, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1983
2066
  } catch (err) {
1984
- throw toAIFriendlyError(err, opts.ref);
2067
+ throw toAIFriendlyError(err, label);
1985
2068
  }
1986
2069
  }
1987
2070
  async function dragViaPlaywright(opts) {
1988
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1989
- ensurePageState(page);
1990
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2071
+ const resolvedStart = requireRefOrSelector(opts.startRef, opts.startSelector);
2072
+ const resolvedEnd = requireRefOrSelector(opts.endRef, opts.endSelector);
2073
+ const page = await getRestoredPageForTarget(opts);
2074
+ const startLocator = resolveLocator(page, resolvedStart);
2075
+ const endLocator = resolveLocator(page, resolvedEnd);
2076
+ const startLabel = resolvedStart.ref ?? resolvedStart.selector;
2077
+ const endLabel = resolvedEnd.ref ?? resolvedEnd.selector;
1991
2078
  try {
1992
- await refLocator(page, opts.startRef).dragTo(refLocator(page, opts.endRef), {
1993
- timeout: normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4)
1994
- });
2079
+ await startLocator.dragTo(endLocator, { timeout: resolveInteractionTimeoutMs(opts.timeoutMs) });
1995
2080
  } catch (err) {
1996
- throw toAIFriendlyError(err, `${opts.startRef} -> ${opts.endRef}`);
2081
+ throw toAIFriendlyError(err, `${startLabel} -> ${endLabel}`);
1997
2082
  }
1998
2083
  }
1999
2084
  async function fillFormViaPlaywright(opts) {
2000
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2001
- ensurePageState(page);
2002
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2003
- const timeout = normalizeTimeoutMs(opts.timeoutMs, 8e3, 6e4);
2085
+ const page = await getRestoredPageForTarget(opts);
2086
+ const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
2004
2087
  for (const field of opts.fields) {
2005
2088
  const ref = field.ref.trim();
2006
2089
  const type = (typeof field.type === "string" ? field.type.trim() : "") || "text";
@@ -2025,21 +2108,18 @@ async function fillFormViaPlaywright(opts) {
2025
2108
  }
2026
2109
  }
2027
2110
  async function scrollIntoViewViaPlaywright(opts) {
2028
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2029
- ensurePageState(page);
2030
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2111
+ const resolved = requireRefOrSelector(opts.ref, opts.selector);
2112
+ const page = await getRestoredPageForTarget(opts);
2113
+ const label = resolved.ref ?? resolved.selector;
2114
+ const locator = resolveLocator(page, resolved);
2031
2115
  try {
2032
- await refLocator(page, opts.ref).scrollIntoViewIfNeeded({
2033
- timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4)
2034
- });
2116
+ await locator.scrollIntoViewIfNeeded({ timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
2035
2117
  } catch (err) {
2036
- throw toAIFriendlyError(err, opts.ref);
2118
+ throw toAIFriendlyError(err, label);
2037
2119
  }
2038
2120
  }
2039
2121
  async function highlightViaPlaywright(opts) {
2040
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2041
- ensurePageState(page);
2042
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2122
+ const page = await getRestoredPageForTarget(opts);
2043
2123
  try {
2044
2124
  await refLocator(page, opts.ref).highlight();
2045
2125
  } catch (err) {
@@ -2047,9 +2127,7 @@ async function highlightViaPlaywright(opts) {
2047
2127
  }
2048
2128
  }
2049
2129
  async function setInputFilesViaPlaywright(opts) {
2050
- const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2051
- ensurePageState(page);
2052
- restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2130
+ const page = await getRestoredPageForTarget(opts);
2053
2131
  if (!opts.paths.length) throw new Error("paths are required");
2054
2132
  const inputRef = typeof opts.ref === "string" ? opts.ref.trim() : "";
2055
2133
  const element = typeof opts.element === "string" ? opts.element.trim() : "";
@@ -2242,13 +2320,18 @@ async function resizeViewportViaPlaywright(opts) {
2242
2320
 
2243
2321
  // src/actions/wait.ts
2244
2322
  var MAX_WAIT_TIME_MS = 3e4;
2323
+ function resolveBoundedDelayMs2(value, label, maxMs) {
2324
+ const normalized = Math.floor(value ?? 0);
2325
+ if (!Number.isFinite(normalized) || normalized < 0) throw new Error(`${label} must be >= 0`);
2326
+ if (normalized > maxMs) throw new Error(`${label} exceeds maximum of ${maxMs}ms`);
2327
+ return normalized;
2328
+ }
2245
2329
  async function waitForViaPlaywright(opts) {
2246
2330
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2247
2331
  ensurePageState(page);
2248
2332
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 2e4);
2249
2333
  if (typeof opts.timeMs === "number" && Number.isFinite(opts.timeMs)) {
2250
- const bounded = Math.max(0, Math.min(MAX_WAIT_TIME_MS, Math.floor(opts.timeMs)));
2251
- await page.waitForTimeout(bounded);
2334
+ await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
2252
2335
  }
2253
2336
  if (opts.text) {
2254
2337
  await page.getByText(opts.text).first().waitFor({ state: "visible", timeout });
@@ -2410,55 +2493,106 @@ async function evaluateViaPlaywright(opts) {
2410
2493
  if (signal && abortListener) signal.removeEventListener("abort", abortListener);
2411
2494
  }
2412
2495
  }
2496
+ function createPageDownloadWaiter(page, timeoutMs) {
2497
+ let done = false;
2498
+ let timer;
2499
+ let handler;
2500
+ const cleanup = () => {
2501
+ if (timer) clearTimeout(timer);
2502
+ timer = void 0;
2503
+ if (handler) {
2504
+ page.off("download", handler);
2505
+ handler = void 0;
2506
+ }
2507
+ };
2508
+ return {
2509
+ promise: new Promise((resolve2, reject) => {
2510
+ handler = (download) => {
2511
+ if (done) return;
2512
+ done = true;
2513
+ cleanup();
2514
+ resolve2(download);
2515
+ };
2516
+ page.on("download", handler);
2517
+ timer = setTimeout(() => {
2518
+ if (done) return;
2519
+ done = true;
2520
+ cleanup();
2521
+ reject(new Error("Timeout waiting for download"));
2522
+ }, timeoutMs);
2523
+ }),
2524
+ cancel: () => {
2525
+ if (done) return;
2526
+ done = true;
2527
+ cleanup();
2528
+ }
2529
+ };
2530
+ }
2531
+ async function saveDownloadPayload(download, outPath) {
2532
+ await writeViaSiblingTempPath({
2533
+ rootDir: path.dirname(outPath),
2534
+ targetPath: outPath,
2535
+ writeTemp: async (tempPath) => {
2536
+ await download.saveAs(tempPath);
2537
+ }
2538
+ });
2539
+ return {
2540
+ url: download.url(),
2541
+ suggestedFilename: download.suggestedFilename(),
2542
+ path: outPath
2543
+ };
2544
+ }
2545
+ async function awaitDownloadPayload(params) {
2546
+ try {
2547
+ const download = await params.waiter.promise;
2548
+ if (params.state.armIdDownload !== params.armId) throw new Error("Download was superseded by another waiter");
2549
+ return await saveDownloadPayload(download, params.outPath);
2550
+ } catch (err) {
2551
+ params.waiter.cancel();
2552
+ throw err;
2553
+ }
2554
+ }
2413
2555
  async function downloadViaPlaywright(opts) {
2414
2556
  await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
2415
2557
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2416
2558
  const state = ensurePageState(page);
2417
2559
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
2418
2560
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2419
- const locator = refLocator(page, opts.ref);
2561
+ const outPath = String(opts.path ?? "").trim();
2562
+ if (!outPath) throw new Error("path is required");
2420
2563
  state.armIdDownload = bumpDownloadArmId();
2564
+ const armId = state.armIdDownload;
2565
+ const waiter = createPageDownloadWaiter(page, timeout);
2421
2566
  try {
2422
- const [download] = await Promise.all([
2423
- page.waitForEvent("download", { timeout }),
2424
- locator.click({ timeout })
2425
- ]);
2426
- const outPath = opts.path;
2427
- await writeViaSiblingTempPath({
2428
- rootDir: path.dirname(outPath),
2429
- targetPath: outPath,
2430
- writeTemp: async (tempPath) => {
2431
- await download.saveAs(tempPath);
2432
- }
2433
- });
2434
- return {
2435
- url: download.url(),
2436
- suggestedFilename: download.suggestedFilename(),
2437
- path: outPath
2438
- };
2567
+ const locator = refLocator(page, opts.ref);
2568
+ try {
2569
+ await locator.click({ timeout });
2570
+ } catch (err) {
2571
+ throw toAIFriendlyError(err, opts.ref);
2572
+ }
2573
+ return await awaitDownloadPayload({ waiter, state, armId, outPath });
2439
2574
  } catch (err) {
2440
- throw toAIFriendlyError(err, opts.ref);
2575
+ waiter.cancel();
2576
+ throw err;
2441
2577
  }
2442
2578
  }
2443
2579
  async function waitForDownloadViaPlaywright(opts) {
2444
2580
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2445
- ensurePageState(page);
2581
+ const state = ensurePageState(page);
2446
2582
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 12e4);
2447
- const download = await page.waitForEvent("download", { timeout });
2448
- const savePath = opts.path ?? download.suggestedFilename();
2449
- await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2450
- await writeViaSiblingTempPath({
2451
- rootDir: path.dirname(savePath),
2452
- targetPath: savePath,
2453
- writeTemp: async (tempPath) => {
2454
- await download.saveAs(tempPath);
2455
- }
2456
- });
2457
- return {
2458
- url: download.url(),
2459
- suggestedFilename: download.suggestedFilename(),
2460
- path: savePath
2461
- };
2583
+ state.armIdDownload = bumpDownloadArmId();
2584
+ const armId = state.armIdDownload;
2585
+ const waiter = createPageDownloadWaiter(page, timeout);
2586
+ try {
2587
+ const download = await waiter.promise;
2588
+ if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
2589
+ const savePath = opts.path ?? download.suggestedFilename();
2590
+ await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
2591
+ return await saveDownloadPayload(download, savePath);
2592
+ } catch (err) {
2593
+ waiter.cancel();
2594
+ throw err;
2595
+ }
2462
2596
  }
2463
2597
  async function emulateMediaViaPlaywright(opts) {
2464
2598
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
@@ -2573,7 +2707,7 @@ async function setLocaleViaPlaywright(opts) {
2573
2707
  async function setOfflineViaPlaywright(opts) {
2574
2708
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
2575
2709
  ensurePageState(page);
2576
- await page.context().setOffline(opts.offline);
2710
+ await page.context().setOffline(Boolean(opts.offline));
2577
2711
  }
2578
2712
  async function setTimezoneViaPlaywright(opts) {
2579
2713
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });