browserclaw 0.3.6 → 0.3.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <h2 align="center">🦞 BrowserClaw — Standalone OpenClaw browser module</h1>
1
+ <h2 align="center">🦞 BrowserClaw — Standalone OpenClaw browser module</h2>
2
2
 
3
3
  <p align="center">
4
4
  <a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/v/browserclaw.svg" alt="npm version" /></a>
@@ -217,13 +217,13 @@ await page.press('Meta+Shift+p');
217
217
 
218
218
  // Fill multiple form fields at once
219
219
  await page.fill([
220
- { ref: 'e2', type: 'text', value: 'Jane Doe' },
221
- { ref: 'e4', type: 'text', value: 'jane@example.com' },
220
+ { ref: 'e2', value: 'Jane Doe' },
221
+ { ref: 'e4', value: 'jane@example.com' },
222
222
  { ref: 'e6', type: 'checkbox', value: true },
223
223
  ]);
224
224
  ```
225
225
 
226
- `fill()` field types: `'text'` calls Playwright `fill()` with the string value. `'checkbox'` and `'radio'` call `setChecked()` — truthy values are `true`, `1`, `'1'`, `'true'`. Empty ref or type throws.
226
+ `fill()` field types: `'text'` (default) calls Playwright `fill()` with the string value. `'checkbox'` and `'radio'` call `setChecked()` — truthy values are `true`, `1`, `'1'`, `'true'`. Type can be omitted and defaults to `'text'`. Empty ref throws.
227
227
 
228
228
  #### Highlight
229
229
 
package/dist/index.cjs CHANGED
@@ -7,6 +7,7 @@ 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 promises$1 = require('fs/promises');
10
11
 
11
12
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
12
13
 
@@ -1198,6 +1199,222 @@ function formatAriaNodes(nodes, limit) {
1198
1199
  }
1199
1200
  return out;
1200
1201
  }
1202
+ var InvalidBrowserNavigationUrlError = class extends Error {
1203
+ constructor(message) {
1204
+ super(message);
1205
+ this.name = "InvalidBrowserNavigationUrlError";
1206
+ }
1207
+ };
1208
+ function withBrowserNavigationPolicy(ssrfPolicy) {
1209
+ return { ssrfPolicy };
1210
+ }
1211
+ var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1212
+ var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
1213
+ async function assertBrowserNavigationAllowed(opts) {
1214
+ const rawUrl = String(opts.url ?? "").trim();
1215
+ let parsed;
1216
+ try {
1217
+ parsed = new URL(rawUrl);
1218
+ } catch {
1219
+ throw new InvalidBrowserNavigationUrlError(`Invalid URL: "${rawUrl}"`);
1220
+ }
1221
+ if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
1222
+ if (SAFE_NON_NETWORK_URLS.has(parsed.href)) return;
1223
+ throw new InvalidBrowserNavigationUrlError(`Navigation blocked: unsupported protocol "${parsed.protocol}"`);
1224
+ }
1225
+ const policy = opts.ssrfPolicy;
1226
+ if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1227
+ const allowedHostnames = [
1228
+ ...policy?.allowedHostnames ?? [],
1229
+ ...policy?.hostnameAllowlist ?? []
1230
+ ];
1231
+ if (allowedHostnames.length) {
1232
+ const hostname = parsed.hostname.toLowerCase();
1233
+ if (allowedHostnames.some((h) => h.toLowerCase() === hostname)) return;
1234
+ }
1235
+ if (await isInternalUrlResolved(rawUrl, opts.lookupFn)) {
1236
+ throw new InvalidBrowserNavigationUrlError(
1237
+ `Navigation to internal/loopback address blocked: "${rawUrl}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1238
+ );
1239
+ }
1240
+ }
1241
+ async function assertSafeOutputPath(path2, allowedRoots) {
1242
+ if (!path2 || typeof path2 !== "string") {
1243
+ throw new Error("Output path is required.");
1244
+ }
1245
+ const normalized = path.normalize(path2);
1246
+ if (normalized.includes("..")) {
1247
+ throw new Error(`Unsafe output path: directory traversal detected in "${path2}".`);
1248
+ }
1249
+ if (allowedRoots?.length) {
1250
+ const resolved = path.resolve(normalized);
1251
+ let parentReal;
1252
+ try {
1253
+ parentReal = await promises$1.realpath(path.dirname(resolved));
1254
+ } catch {
1255
+ throw new Error(`Unsafe output path: parent directory is inaccessible for "${path2}".`);
1256
+ }
1257
+ try {
1258
+ const targetStat = await promises$1.lstat(resolved);
1259
+ if (targetStat.isSymbolicLink()) {
1260
+ throw new Error(`Unsafe output path: "${path2}" is a symbolic link.`);
1261
+ }
1262
+ } catch (e) {
1263
+ if (e.code !== "ENOENT") throw e;
1264
+ }
1265
+ const results = await Promise.all(
1266
+ allowedRoots.map(async (root) => {
1267
+ try {
1268
+ const rootStat = await promises$1.lstat(path.resolve(root));
1269
+ if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) return false;
1270
+ const rootReal = await promises$1.realpath(path.resolve(root));
1271
+ return parentReal === rootReal || parentReal.startsWith(rootReal + path.sep);
1272
+ } catch {
1273
+ return false;
1274
+ }
1275
+ })
1276
+ );
1277
+ if (!results.some(Boolean)) {
1278
+ throw new Error(`Unsafe output path: "${path2}" is outside allowed directories.`);
1279
+ }
1280
+ }
1281
+ }
1282
+ function expandIPv6(ip) {
1283
+ let normalized = ip;
1284
+ const v4Match = normalized.match(/^(.+:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1285
+ if (v4Match) {
1286
+ const octets = v4Match[2].split(".").map(Number);
1287
+ if (octets.some((o) => o > 255)) return null;
1288
+ const hexHi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
1289
+ const hexLo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
1290
+ normalized = v4Match[1] + hexHi + ":" + hexLo;
1291
+ }
1292
+ const halves = normalized.split("::");
1293
+ if (halves.length > 2) return null;
1294
+ if (halves.length === 2) {
1295
+ const left = halves[0] !== "" ? halves[0].split(":") : [];
1296
+ const right = halves[1] !== "" ? halves[1].split(":") : [];
1297
+ const needed = 8 - left.length - right.length;
1298
+ if (needed < 0) return null;
1299
+ const groups2 = [...left, ...Array(needed).fill("0"), ...right];
1300
+ if (groups2.length !== 8) return null;
1301
+ return groups2.map((g) => g.padStart(4, "0")).join(":");
1302
+ }
1303
+ const groups = normalized.split(":");
1304
+ if (groups.length !== 8) return null;
1305
+ return groups.map((g) => g.padStart(4, "0")).join(":");
1306
+ }
1307
+ function hexToIPv4(hiHex, loHex) {
1308
+ const hi = parseInt(hiHex, 16);
1309
+ const lo = parseInt(loHex, 16);
1310
+ return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1311
+ }
1312
+ function extractEmbeddedIPv4(lower) {
1313
+ if (lower.startsWith("::ffff:")) {
1314
+ return lower.slice(7);
1315
+ }
1316
+ const expanded = expandIPv6(lower);
1317
+ if (expanded === null) return "";
1318
+ const groups = expanded.split(":");
1319
+ if (groups.length !== 8) return "";
1320
+ if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "0000") {
1321
+ return hexToIPv4(groups[6], groups[7]);
1322
+ }
1323
+ if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0001") {
1324
+ return hexToIPv4(groups[6], groups[7]);
1325
+ }
1326
+ if (groups[0] === "2002") {
1327
+ return hexToIPv4(groups[1], groups[2]);
1328
+ }
1329
+ if (groups[0] === "2001" && groups[1] === "0000") {
1330
+ const hiXored = (parseInt(groups[6], 16) ^ 65535).toString(16).padStart(4, "0");
1331
+ const loXored = (parseInt(groups[7], 16) ^ 65535).toString(16).padStart(4, "0");
1332
+ return hexToIPv4(hiXored, loXored);
1333
+ }
1334
+ return null;
1335
+ }
1336
+ function isStrictDecimalOctet(part) {
1337
+ if (!/^[0-9]+$/.test(part)) return false;
1338
+ const n = parseInt(part, 10);
1339
+ if (n < 0 || n > 255) return false;
1340
+ if (String(n) !== part) return false;
1341
+ return true;
1342
+ }
1343
+ function isUnsupportedIPv4Literal(ip) {
1344
+ if (/^[0-9]+$/.test(ip)) return true;
1345
+ const parts = ip.split(".");
1346
+ if (parts.length !== 4) return true;
1347
+ if (!parts.every(isStrictDecimalOctet)) return true;
1348
+ return false;
1349
+ }
1350
+ function isInternalIP(ip) {
1351
+ if (!ip.includes(":") && isUnsupportedIPv4Literal(ip)) return true;
1352
+ if (/^127\./.test(ip)) return true;
1353
+ if (/^10\./.test(ip)) return true;
1354
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
1355
+ if (/^192\.168\./.test(ip)) return true;
1356
+ if (/^169\.254\./.test(ip)) return true;
1357
+ if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true;
1358
+ if (ip === "0.0.0.0") return true;
1359
+ const lower = ip.toLowerCase();
1360
+ if (lower === "::1") return true;
1361
+ if (lower.startsWith("fe80:")) return true;
1362
+ if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
1363
+ if (lower.startsWith("ff")) return true;
1364
+ const embedded = extractEmbeddedIPv4(lower);
1365
+ if (embedded !== null) {
1366
+ if (embedded === "") return true;
1367
+ return isInternalIP(embedded);
1368
+ }
1369
+ return false;
1370
+ }
1371
+ function isInternalUrl(url) {
1372
+ let parsed;
1373
+ try {
1374
+ parsed = new URL(url);
1375
+ } catch {
1376
+ return true;
1377
+ }
1378
+ const hostname = parsed.hostname.toLowerCase();
1379
+ if (hostname === "localhost") return true;
1380
+ if (isInternalIP(hostname)) return true;
1381
+ if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) {
1382
+ return true;
1383
+ }
1384
+ return false;
1385
+ }
1386
+ async function assertSafeUploadPaths(paths) {
1387
+ for (const filePath of paths) {
1388
+ let stat;
1389
+ try {
1390
+ stat = await promises$1.lstat(filePath);
1391
+ } catch {
1392
+ throw new Error(`Upload path does not exist or is inaccessible: "${filePath}".`);
1393
+ }
1394
+ if (stat.isSymbolicLink()) {
1395
+ throw new Error(`Upload path is a symbolic link: "${filePath}".`);
1396
+ }
1397
+ if (!stat.isFile()) {
1398
+ throw new Error(`Upload path is not a regular file: "${filePath}".`);
1399
+ }
1400
+ }
1401
+ }
1402
+ async function isInternalUrlResolved(url, lookupFn = promises.lookup) {
1403
+ if (isInternalUrl(url)) return true;
1404
+ let parsed;
1405
+ try {
1406
+ parsed = new URL(url);
1407
+ } catch {
1408
+ return true;
1409
+ }
1410
+ try {
1411
+ const { address } = await lookupFn(parsed.hostname);
1412
+ if (isInternalIP(address)) return true;
1413
+ } catch {
1414
+ return true;
1415
+ }
1416
+ return false;
1417
+ }
1201
1418
 
1202
1419
  // src/actions/interaction.ts
1203
1420
  async function clickViaPlaywright(opts) {
@@ -1280,11 +1497,10 @@ async function fillFormViaPlaywright(opts) {
1280
1497
  for (let i = 0; i < opts.fields.length; i++) {
1281
1498
  const field = opts.fields[i];
1282
1499
  const ref = field.ref.trim();
1283
- const type = field.type.trim();
1500
+ const type = (typeof field.type === "string" ? field.type.trim() : "") || "text";
1284
1501
  const rawValue = field.value;
1285
1502
  const value = rawValue == null ? "" : String(rawValue);
1286
1503
  if (!ref) throw new Error(`fill(): field at index ${i} has empty ref`);
1287
- if (!type) throw new Error(`fill(): field "${ref}" has empty type`);
1288
1504
  const locator = refLocator(page, ref);
1289
1505
  if (type === "checkbox" || type === "radio") {
1290
1506
  const checked = rawValue === true || rawValue === 1 || rawValue === "1" || rawValue === "true";
@@ -1330,6 +1546,7 @@ async function setInputFilesViaPlaywright(opts) {
1330
1546
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
1331
1547
  const locator = opts.ref ? refLocator(page, opts.ref) : opts.element ? page.locator(opts.element).first() : null;
1332
1548
  if (!locator) throw new Error("Either ref or element is required for setInputFiles");
1549
+ await assertSafeUploadPaths(opts.paths);
1333
1550
  try {
1334
1551
  await locator.setInputFiles(opts.paths);
1335
1552
  } catch (err) {
@@ -1373,7 +1590,9 @@ async function armFileUploadViaPlaywright(opts) {
1373
1590
  const handler = async (fc) => {
1374
1591
  clearTimeout(timer);
1375
1592
  try {
1376
- await fc.setFiles(opts.paths ?? []);
1593
+ const paths = opts.paths ?? [];
1594
+ if (paths.length > 0) await assertSafeUploadPaths(paths);
1595
+ await fc.setFiles(paths);
1377
1596
  resolve2();
1378
1597
  } catch (err) {
1379
1598
  reject(err);
@@ -1391,183 +1610,6 @@ async function pressKeyViaPlaywright(opts) {
1391
1610
  ensurePageState(page);
1392
1611
  await page.keyboard.press(key, { delay: Math.max(0, Math.floor(opts.delayMs ?? 0)) });
1393
1612
  }
1394
- var InvalidBrowserNavigationUrlError = class extends Error {
1395
- constructor(message) {
1396
- super(message);
1397
- this.name = "InvalidBrowserNavigationUrlError";
1398
- }
1399
- };
1400
- function withBrowserNavigationPolicy(ssrfPolicy) {
1401
- return { ssrfPolicy };
1402
- }
1403
- var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
1404
- var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
1405
- async function assertBrowserNavigationAllowed(opts) {
1406
- const rawUrl = String(opts.url ?? "").trim();
1407
- let parsed;
1408
- try {
1409
- parsed = new URL(rawUrl);
1410
- } catch {
1411
- throw new InvalidBrowserNavigationUrlError(`Invalid URL: "${rawUrl}"`);
1412
- }
1413
- if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
1414
- if (SAFE_NON_NETWORK_URLS.has(parsed.href)) return;
1415
- throw new InvalidBrowserNavigationUrlError(`Navigation blocked: unsupported protocol "${parsed.protocol}"`);
1416
- }
1417
- const policy = opts.ssrfPolicy;
1418
- if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
1419
- const allowedHostnames = [
1420
- ...policy?.allowedHostnames ?? [],
1421
- ...policy?.hostnameAllowlist ?? []
1422
- ];
1423
- if (allowedHostnames.length) {
1424
- const hostname = parsed.hostname.toLowerCase();
1425
- if (allowedHostnames.some((h) => h.toLowerCase() === hostname)) return;
1426
- }
1427
- if (await isInternalUrlResolved(rawUrl, opts.lookupFn)) {
1428
- throw new InvalidBrowserNavigationUrlError(
1429
- `Navigation to internal/loopback address blocked: "${rawUrl}". ssrfPolicy.dangerouslyAllowPrivateNetwork is false (strict mode).`
1430
- );
1431
- }
1432
- }
1433
- function assertSafeOutputPath(path2, allowedRoots) {
1434
- if (!path2 || typeof path2 !== "string") {
1435
- throw new Error("Output path is required.");
1436
- }
1437
- const normalized = path.normalize(path2);
1438
- if (normalized.includes("..")) {
1439
- throw new Error(`Unsafe output path: directory traversal detected in "${path2}".`);
1440
- }
1441
- if (allowedRoots?.length) {
1442
- const resolved = path.resolve(normalized);
1443
- const withinRoot = allowedRoots.some((root) => {
1444
- const normalizedRoot = path.resolve(root);
1445
- return resolved === normalizedRoot || resolved.startsWith(normalizedRoot + path.sep);
1446
- });
1447
- if (!withinRoot) {
1448
- throw new Error(`Unsafe output path: "${path2}" is outside allowed directories.`);
1449
- }
1450
- }
1451
- }
1452
- function expandIPv6(ip) {
1453
- let normalized = ip;
1454
- const v4Match = normalized.match(/^(.+:)(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/);
1455
- if (v4Match) {
1456
- const octets = v4Match[2].split(".").map(Number);
1457
- if (octets.some((o) => o > 255)) return null;
1458
- const hexHi = (octets[0] << 8 | octets[1]).toString(16).padStart(4, "0");
1459
- const hexLo = (octets[2] << 8 | octets[3]).toString(16).padStart(4, "0");
1460
- normalized = v4Match[1] + hexHi + ":" + hexLo;
1461
- }
1462
- const halves = normalized.split("::");
1463
- if (halves.length > 2) return null;
1464
- if (halves.length === 2) {
1465
- const left = halves[0] !== "" ? halves[0].split(":") : [];
1466
- const right = halves[1] !== "" ? halves[1].split(":") : [];
1467
- const needed = 8 - left.length - right.length;
1468
- if (needed < 0) return null;
1469
- const groups2 = [...left, ...Array(needed).fill("0"), ...right];
1470
- if (groups2.length !== 8) return null;
1471
- return groups2.map((g) => g.padStart(4, "0")).join(":");
1472
- }
1473
- const groups = normalized.split(":");
1474
- if (groups.length !== 8) return null;
1475
- return groups.map((g) => g.padStart(4, "0")).join(":");
1476
- }
1477
- function hexToIPv4(hiHex, loHex) {
1478
- const hi = parseInt(hiHex, 16);
1479
- const lo = parseInt(loHex, 16);
1480
- return `${hi >> 8 & 255}.${hi & 255}.${lo >> 8 & 255}.${lo & 255}`;
1481
- }
1482
- function extractEmbeddedIPv4(lower) {
1483
- if (lower.startsWith("::ffff:")) {
1484
- return lower.slice(7);
1485
- }
1486
- const expanded = expandIPv6(lower);
1487
- if (expanded === null) return "";
1488
- const groups = expanded.split(":");
1489
- if (groups.length !== 8) return "";
1490
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0000" && groups[3] === "0000" && groups[4] === "0000" && groups[5] === "0000") {
1491
- return hexToIPv4(groups[6], groups[7]);
1492
- }
1493
- if (groups[0] === "0064" && groups[1] === "ff9b" && groups[2] === "0001") {
1494
- return hexToIPv4(groups[6], groups[7]);
1495
- }
1496
- if (groups[0] === "2002") {
1497
- return hexToIPv4(groups[1], groups[2]);
1498
- }
1499
- if (groups[0] === "2001" && groups[1] === "0000") {
1500
- const hiXored = (parseInt(groups[6], 16) ^ 65535).toString(16).padStart(4, "0");
1501
- const loXored = (parseInt(groups[7], 16) ^ 65535).toString(16).padStart(4, "0");
1502
- return hexToIPv4(hiXored, loXored);
1503
- }
1504
- return null;
1505
- }
1506
- function isStrictDecimalOctet(part) {
1507
- if (!/^[0-9]+$/.test(part)) return false;
1508
- const n = parseInt(part, 10);
1509
- if (n < 0 || n > 255) return false;
1510
- if (String(n) !== part) return false;
1511
- return true;
1512
- }
1513
- function isUnsupportedIPv4Literal(ip) {
1514
- if (/^[0-9]+$/.test(ip)) return true;
1515
- const parts = ip.split(".");
1516
- if (parts.length !== 4) return true;
1517
- if (!parts.every(isStrictDecimalOctet)) return true;
1518
- return false;
1519
- }
1520
- function isInternalIP(ip) {
1521
- if (!ip.includes(":") && isUnsupportedIPv4Literal(ip)) return true;
1522
- if (/^127\./.test(ip)) return true;
1523
- if (/^10\./.test(ip)) return true;
1524
- if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
1525
- if (/^192\.168\./.test(ip)) return true;
1526
- if (/^169\.254\./.test(ip)) return true;
1527
- if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true;
1528
- if (ip === "0.0.0.0") return true;
1529
- const lower = ip.toLowerCase();
1530
- if (lower === "::1") return true;
1531
- if (lower.startsWith("fe80:")) return true;
1532
- if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
1533
- const embedded = extractEmbeddedIPv4(lower);
1534
- if (embedded !== null) {
1535
- if (embedded === "") return true;
1536
- return isInternalIP(embedded);
1537
- }
1538
- return false;
1539
- }
1540
- function isInternalUrl(url) {
1541
- let parsed;
1542
- try {
1543
- parsed = new URL(url);
1544
- } catch {
1545
- return true;
1546
- }
1547
- const hostname = parsed.hostname.toLowerCase();
1548
- if (hostname === "localhost") return true;
1549
- if (isInternalIP(hostname)) return true;
1550
- if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) {
1551
- return true;
1552
- }
1553
- return false;
1554
- }
1555
- async function isInternalUrlResolved(url, lookupFn = promises.lookup) {
1556
- if (isInternalUrl(url)) return true;
1557
- let parsed;
1558
- try {
1559
- parsed = new URL(url);
1560
- } catch {
1561
- return true;
1562
- }
1563
- try {
1564
- const { address } = await lookupFn(parsed.hostname);
1565
- if (isInternalIP(address)) return true;
1566
- } catch {
1567
- return true;
1568
- }
1569
- return false;
1570
- }
1571
1613
 
1572
1614
  // src/actions/navigation.ts
1573
1615
  async function navigateViaPlaywright(opts) {
@@ -1759,7 +1801,7 @@ async function evaluateViaPlaywright(opts) {
1759
1801
 
1760
1802
  // src/actions/download.ts
1761
1803
  async function downloadViaPlaywright(opts) {
1762
- assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
1804
+ await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
1763
1805
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1764
1806
  ensurePageState(page);
1765
1807
  restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
@@ -1786,7 +1828,7 @@ async function waitForDownloadViaPlaywright(opts) {
1786
1828
  const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
1787
1829
  const download = await page.waitForEvent("download", { timeout });
1788
1830
  const savePath = opts.path ?? download.suggestedFilename();
1789
- assertSafeOutputPath(savePath, opts.allowedOutputRoots);
1831
+ await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
1790
1832
  await download.saveAs(savePath);
1791
1833
  return {
1792
1834
  url: download.url(),
@@ -1970,7 +2012,7 @@ async function traceStartViaPlaywright(opts) {
1970
2012
  });
1971
2013
  }
1972
2014
  async function traceStopViaPlaywright(opts) {
1973
- assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
2015
+ await assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
1974
2016
  const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
1975
2017
  ensurePageState(page);
1976
2018
  const context = page.context();