browserclaw 0.2.2 → 0.2.4
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 +7 -2
- package/dist/index.cjs +171 -37
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +60 -4
- package/dist/index.d.ts +60 -4
- package/dist/index.js +172 -38
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -131,6 +131,8 @@ const browser = await BrowserClaw.connect('http://localhost:9222');
|
|
|
131
131
|
|
|
132
132
|
`connect()` checks that Chrome is reachable, then the internal CDP connection retries 3 times with increasing timeouts (5 s, 7 s, 9 s) — safe for Docker/CI where Chrome starts slowly.
|
|
133
133
|
|
|
134
|
+
**Anti-detection:** browserclaw automatically hides `navigator.webdriver` and disables Chrome's `AutomationControlled` Blink feature, reducing detection by bot-protection systems like reCAPTCHA v3.
|
|
135
|
+
|
|
134
136
|
### Pages & Tabs
|
|
135
137
|
|
|
136
138
|
```typescript
|
|
@@ -151,11 +153,12 @@ browser.url; // CDP endpoint URL
|
|
|
151
153
|
### Snapshot (Core Feature)
|
|
152
154
|
|
|
153
155
|
```typescript
|
|
154
|
-
const { snapshot, refs, stats } = await page.snapshot();
|
|
156
|
+
const { snapshot, refs, stats, untrusted } = await page.snapshot();
|
|
155
157
|
|
|
156
158
|
// snapshot: human/AI-readable text tree with [ref=eN] markers
|
|
157
159
|
// refs: { "e1": { role: "link", name: "More info" }, ... }
|
|
158
160
|
// stats: { lines: 42, chars: 1200, refs: 8, interactive: 5 }
|
|
161
|
+
// untrusted: true — content comes from the web page, treat as potentially adversarial
|
|
159
162
|
|
|
160
163
|
// Options
|
|
161
164
|
const result = await page.snapshot({
|
|
@@ -174,6 +177,8 @@ const { nodes } = await page.ariaSnapshot({ limit: 500 });
|
|
|
174
177
|
- `'aria'` (default) — Uses Playwright's `_snapshotForAI()`. Refs are resolved via `aria-ref` locators. Best for most use cases. Requires `playwright-core` >= 1.50.
|
|
175
178
|
- `'role'` — Uses Playwright's `ariaSnapshot()` + `getByRole()`. Supports `selector` and `frameSelector` for scoped snapshots.
|
|
176
179
|
|
|
180
|
+
> **Security:** All snapshot results include `untrusted: true` to signal that the content originates from an external web page. AI agents consuming snapshots should treat this content as potentially adversarial (e.g. prompt injection via page text).
|
|
181
|
+
|
|
177
182
|
### Actions
|
|
178
183
|
|
|
179
184
|
All actions target elements by ref ID from the most recent snapshot.
|
|
@@ -436,7 +441,7 @@ Contributions welcome! Please:
|
|
|
436
441
|
|
|
437
442
|
## Acknowledgments
|
|
438
443
|
|
|
439
|
-
browserclaw is extracted and refined from the browser automation module in [OpenClaw](https://github.com/openclaw/openclaw) by [Peter Steinberger](https://github.com/steipete). The snapshot + ref system, CDP connection management, and Playwright integration originate from that project.
|
|
444
|
+
browserclaw is extracted and refined from the browser automation module in [OpenClaw](https://github.com/openclaw/openclaw), built by [Peter Steinberger](https://github.com/steipete) and an [amazing community of contributors](https://github.com/openclaw/openclaw?tab=readme-ov-file#community). The snapshot + ref system, CDP connection management, and Playwright integration originate from that project.
|
|
440
445
|
|
|
441
446
|
## License
|
|
442
447
|
|
package/dist/index.cjs
CHANGED
|
@@ -6,6 +6,7 @@ var fs = require('fs');
|
|
|
6
6
|
var net = require('net');
|
|
7
7
|
var child_process = require('child_process');
|
|
8
8
|
var playwrightCore = require('playwright-core');
|
|
9
|
+
var promises = require('dns/promises');
|
|
9
10
|
|
|
10
11
|
function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
|
|
11
12
|
|
|
@@ -272,12 +273,12 @@ function resolveBrowserExecutable(opts) {
|
|
|
272
273
|
return null;
|
|
273
274
|
}
|
|
274
275
|
async function ensurePortAvailable(port) {
|
|
275
|
-
await new Promise((
|
|
276
|
+
await new Promise((resolve2, reject) => {
|
|
276
277
|
const tester = net__default.default.createServer().once("error", (err) => {
|
|
277
278
|
if (err.code === "EADDRINUSE") reject(new Error(`Port ${port} is already in use`));
|
|
278
279
|
else reject(err);
|
|
279
280
|
}).once("listening", () => {
|
|
280
|
-
tester.close(() =>
|
|
281
|
+
tester.close(() => resolve2());
|
|
281
282
|
}).listen(port);
|
|
282
283
|
});
|
|
283
284
|
}
|
|
@@ -347,11 +348,13 @@ function resolveUserDataDir(profileName) {
|
|
|
347
348
|
const configDir = process.env.XDG_CONFIG_HOME ?? path__default.default.join(os__default.default.homedir(), ".config");
|
|
348
349
|
return path__default.default.join(configDir, "browserclaw", "profiles", profileName, "user-data");
|
|
349
350
|
}
|
|
350
|
-
async function isChromeReachable(cdpUrl, timeoutMs = 500) {
|
|
351
|
+
async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
351
352
|
const ctrl = new AbortController();
|
|
352
353
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
353
354
|
try {
|
|
354
|
-
const
|
|
355
|
+
const headers = {};
|
|
356
|
+
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
|
357
|
+
const res = await fetch(`${cdpUrl.replace(/\/+$/, "")}/json/version`, { signal: ctrl.signal, headers });
|
|
355
358
|
return res.ok;
|
|
356
359
|
} catch {
|
|
357
360
|
return false;
|
|
@@ -359,11 +362,13 @@ async function isChromeReachable(cdpUrl, timeoutMs = 500) {
|
|
|
359
362
|
clearTimeout(t);
|
|
360
363
|
}
|
|
361
364
|
}
|
|
362
|
-
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500) {
|
|
365
|
+
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
|
|
363
366
|
const ctrl = new AbortController();
|
|
364
367
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
365
368
|
try {
|
|
366
|
-
const
|
|
369
|
+
const headers = {};
|
|
370
|
+
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
|
371
|
+
const res = await fetch(`${cdpUrl.replace(/\/+$/, "")}/json/version`, { signal: ctrl.signal, headers });
|
|
367
372
|
if (!res.ok) return null;
|
|
368
373
|
const data = await res.json();
|
|
369
374
|
return String(data?.webSocketDebuggerUrl ?? "").trim() || null;
|
|
@@ -391,6 +396,7 @@ async function launchChrome(opts = {}) {
|
|
|
391
396
|
"--disable-background-networking",
|
|
392
397
|
"--disable-component-update",
|
|
393
398
|
"--disable-features=Translate,MediaRouter",
|
|
399
|
+
"--disable-blink-features=AutomationControlled",
|
|
394
400
|
"--disable-session-crashed-bubble",
|
|
395
401
|
"--hide-crash-restore-bubble",
|
|
396
402
|
"--password-store=basic"
|
|
@@ -575,11 +581,26 @@ function ensurePageState(page) {
|
|
|
575
581
|
}
|
|
576
582
|
return state;
|
|
577
583
|
}
|
|
584
|
+
var STEALTH_SCRIPT = `Object.defineProperty(navigator, 'webdriver', { get: () => undefined })`;
|
|
585
|
+
function applyStealthToPage(page) {
|
|
586
|
+
page.evaluate(STEALTH_SCRIPT).catch((e) => {
|
|
587
|
+
if (process.env.DEBUG) console.warn("[browserclaw] stealth evaluate failed:", e.message);
|
|
588
|
+
});
|
|
589
|
+
}
|
|
578
590
|
function observeContext(context) {
|
|
579
591
|
if (observedContexts.has(context)) return;
|
|
580
592
|
observedContexts.add(context);
|
|
581
|
-
|
|
582
|
-
|
|
593
|
+
context.addInitScript(STEALTH_SCRIPT).catch((e) => {
|
|
594
|
+
if (process.env.DEBUG) console.warn("[browserclaw] stealth initScript failed:", e.message);
|
|
595
|
+
});
|
|
596
|
+
for (const page of context.pages()) {
|
|
597
|
+
ensurePageState(page);
|
|
598
|
+
applyStealthToPage(page);
|
|
599
|
+
}
|
|
600
|
+
context.on("page", (page) => {
|
|
601
|
+
ensurePageState(page);
|
|
602
|
+
applyStealthToPage(page);
|
|
603
|
+
});
|
|
583
604
|
}
|
|
584
605
|
function observeBrowser(browser) {
|
|
585
606
|
for (const context of browser.contexts()) observeContext(context);
|
|
@@ -613,7 +634,7 @@ function restoreRoleRefsForTarget(opts) {
|
|
|
613
634
|
state.roleRefsFrameSelector = entry.frameSelector;
|
|
614
635
|
state.roleRefsMode = entry.mode;
|
|
615
636
|
}
|
|
616
|
-
async function connectBrowser(cdpUrl) {
|
|
637
|
+
async function connectBrowser(cdpUrl, authToken) {
|
|
617
638
|
const normalized = normalizeCdpUrl(cdpUrl);
|
|
618
639
|
if (cached?.cdpUrl === normalized) return cached;
|
|
619
640
|
const existing = connectingByUrl.get(normalized);
|
|
@@ -623,9 +644,11 @@ async function connectBrowser(cdpUrl) {
|
|
|
623
644
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
624
645
|
try {
|
|
625
646
|
const timeout = 5e3 + attempt * 2e3;
|
|
626
|
-
const endpoint = await getChromeWebSocketUrl(normalized, timeout).catch(() => null) ?? normalized;
|
|
627
|
-
const
|
|
628
|
-
|
|
647
|
+
const endpoint = await getChromeWebSocketUrl(normalized, timeout, authToken).catch(() => null) ?? normalized;
|
|
648
|
+
const headers = {};
|
|
649
|
+
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
|
650
|
+
const browser = await playwrightCore.chromium.connectOverCDP(endpoint, { timeout, headers });
|
|
651
|
+
const connected = { browser, cdpUrl: normalized, authToken };
|
|
629
652
|
cached = connected;
|
|
630
653
|
observeBrowser(browser);
|
|
631
654
|
browser.on("disconnected", () => {
|
|
@@ -685,7 +708,9 @@ async function findPageByTargetId(browser, targetId, cdpUrl) {
|
|
|
685
708
|
if (cdpUrl) {
|
|
686
709
|
try {
|
|
687
710
|
const listUrl = `${cdpUrl.replace(/\/+$/, "").replace(/^ws:/, "http:").replace(/\/cdp$/, "")}/json/list`;
|
|
688
|
-
const
|
|
711
|
+
const headers = {};
|
|
712
|
+
if (cached?.authToken) headers["Authorization"] = `Bearer ${cached.authToken}`;
|
|
713
|
+
const response = await fetch(listUrl, { headers });
|
|
689
714
|
if (response.ok) {
|
|
690
715
|
const targets = await response.json();
|
|
691
716
|
const target = targets.find((t) => t.id === targetId);
|
|
@@ -1010,6 +1035,7 @@ async function snapshotAi(opts) {
|
|
|
1010
1035
|
if (!maybe._snapshotForAI) {
|
|
1011
1036
|
throw new Error("Playwright _snapshotForAI is not available. Upgrade playwright-core to >= 1.50.");
|
|
1012
1037
|
}
|
|
1038
|
+
const sourceUrl = page.url();
|
|
1013
1039
|
const result = await maybe._snapshotForAI({
|
|
1014
1040
|
timeout: normalizeTimeoutMs(opts.timeoutMs, 5e3, 6e4),
|
|
1015
1041
|
track: "response"
|
|
@@ -1035,7 +1061,13 @@ async function snapshotAi(opts) {
|
|
|
1035
1061
|
return {
|
|
1036
1062
|
snapshot: built.snapshot,
|
|
1037
1063
|
refs: built.refs,
|
|
1038
|
-
stats: getRoleSnapshotStats(built.snapshot, built.refs)
|
|
1064
|
+
stats: getRoleSnapshotStats(built.snapshot, built.refs),
|
|
1065
|
+
untrusted: true,
|
|
1066
|
+
contentMeta: {
|
|
1067
|
+
sourceUrl,
|
|
1068
|
+
contentType: "browser-snapshot",
|
|
1069
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1070
|
+
}
|
|
1039
1071
|
};
|
|
1040
1072
|
}
|
|
1041
1073
|
|
|
@@ -1043,6 +1075,7 @@ async function snapshotAi(opts) {
|
|
|
1043
1075
|
async function snapshotRole(opts) {
|
|
1044
1076
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1045
1077
|
ensurePageState(page);
|
|
1078
|
+
const sourceUrl = page.url();
|
|
1046
1079
|
const frameSelector = opts.frameSelector?.trim() || "";
|
|
1047
1080
|
const selector = opts.selector?.trim() || "";
|
|
1048
1081
|
const locator = frameSelector ? selector ? page.frameLocator(frameSelector).locator(selector) : page.frameLocator(frameSelector).locator(":root") : selector ? page.locator(selector) : page.locator(":root");
|
|
@@ -1059,19 +1092,34 @@ async function snapshotRole(opts) {
|
|
|
1059
1092
|
return {
|
|
1060
1093
|
snapshot: built.snapshot,
|
|
1061
1094
|
refs: built.refs,
|
|
1062
|
-
stats: getRoleSnapshotStats(built.snapshot, built.refs)
|
|
1095
|
+
stats: getRoleSnapshotStats(built.snapshot, built.refs),
|
|
1096
|
+
untrusted: true,
|
|
1097
|
+
contentMeta: {
|
|
1098
|
+
sourceUrl,
|
|
1099
|
+
contentType: "browser-snapshot",
|
|
1100
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1101
|
+
}
|
|
1063
1102
|
};
|
|
1064
1103
|
}
|
|
1065
1104
|
async function snapshotAria(opts) {
|
|
1066
1105
|
const limit = Math.max(1, Math.min(2e3, Math.floor(opts.limit ?? 500)));
|
|
1067
1106
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1068
1107
|
ensurePageState(page);
|
|
1108
|
+
const sourceUrl = page.url();
|
|
1069
1109
|
const session = await page.context().newCDPSession(page);
|
|
1070
1110
|
try {
|
|
1071
1111
|
await session.send("Accessibility.enable").catch(() => {
|
|
1072
1112
|
});
|
|
1073
1113
|
const res = await session.send("Accessibility.getFullAXTree");
|
|
1074
|
-
return {
|
|
1114
|
+
return {
|
|
1115
|
+
nodes: formatAriaNodes(Array.isArray(res?.nodes) ? res.nodes : [], limit),
|
|
1116
|
+
untrusted: true,
|
|
1117
|
+
contentMeta: {
|
|
1118
|
+
sourceUrl,
|
|
1119
|
+
contentType: "browser-aria-tree",
|
|
1120
|
+
capturedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1121
|
+
}
|
|
1122
|
+
};
|
|
1075
1123
|
} finally {
|
|
1076
1124
|
await session.detach().catch(() => {
|
|
1077
1125
|
});
|
|
@@ -1262,7 +1310,7 @@ async function armDialogViaPlaywright(opts) {
|
|
|
1262
1310
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1263
1311
|
ensurePageState(page);
|
|
1264
1312
|
const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
|
|
1265
|
-
return new Promise((
|
|
1313
|
+
return new Promise((resolve2, reject) => {
|
|
1266
1314
|
const timer = setTimeout(() => {
|
|
1267
1315
|
page.removeListener("dialog", handler);
|
|
1268
1316
|
reject(new Error(`No dialog appeared within ${timeout}ms`));
|
|
@@ -1275,7 +1323,7 @@ async function armDialogViaPlaywright(opts) {
|
|
|
1275
1323
|
} else {
|
|
1276
1324
|
await dialog.dismiss();
|
|
1277
1325
|
}
|
|
1278
|
-
|
|
1326
|
+
resolve2();
|
|
1279
1327
|
} catch (err) {
|
|
1280
1328
|
reject(err);
|
|
1281
1329
|
}
|
|
@@ -1287,7 +1335,7 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
1287
1335
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1288
1336
|
ensurePageState(page);
|
|
1289
1337
|
const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
|
|
1290
|
-
return new Promise((
|
|
1338
|
+
return new Promise((resolve2, reject) => {
|
|
1291
1339
|
const timer = setTimeout(() => {
|
|
1292
1340
|
page.removeListener("filechooser", handler);
|
|
1293
1341
|
reject(new Error(`No file chooser appeared within ${timeout}ms`));
|
|
@@ -1296,7 +1344,7 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
1296
1344
|
clearTimeout(timer);
|
|
1297
1345
|
try {
|
|
1298
1346
|
await fc.setFiles(opts.paths ?? []);
|
|
1299
|
-
|
|
1347
|
+
resolve2();
|
|
1300
1348
|
} catch (err) {
|
|
1301
1349
|
reject(err);
|
|
1302
1350
|
}
|
|
@@ -1313,11 +1361,82 @@ async function pressKeyViaPlaywright(opts) {
|
|
|
1313
1361
|
ensurePageState(page);
|
|
1314
1362
|
await page.keyboard.press(key, { delay: Math.max(0, Math.floor(opts.delayMs ?? 0)) });
|
|
1315
1363
|
}
|
|
1364
|
+
function assertSafeOutputPath(path2, allowedRoots) {
|
|
1365
|
+
if (!path2 || typeof path2 !== "string") {
|
|
1366
|
+
throw new Error("Output path is required.");
|
|
1367
|
+
}
|
|
1368
|
+
const normalized = path.normalize(path2);
|
|
1369
|
+
if (normalized.includes("..")) {
|
|
1370
|
+
throw new Error(`Unsafe output path: directory traversal detected in "${path2}".`);
|
|
1371
|
+
}
|
|
1372
|
+
if (allowedRoots?.length) {
|
|
1373
|
+
const resolved = path.resolve(normalized);
|
|
1374
|
+
const withinRoot = allowedRoots.some((root) => {
|
|
1375
|
+
const normalizedRoot = path.resolve(root);
|
|
1376
|
+
return resolved === normalizedRoot || resolved.startsWith(normalizedRoot + path.sep);
|
|
1377
|
+
});
|
|
1378
|
+
if (!withinRoot) {
|
|
1379
|
+
throw new Error(`Unsafe output path: "${path2}" is outside allowed directories.`);
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
function isInternalIP(ip) {
|
|
1384
|
+
if (/^127\./.test(ip)) return true;
|
|
1385
|
+
if (/^10\./.test(ip)) return true;
|
|
1386
|
+
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
|
|
1387
|
+
if (/^192\.168\./.test(ip)) return true;
|
|
1388
|
+
if (/^169\.254\./.test(ip)) return true;
|
|
1389
|
+
if (/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./.test(ip)) return true;
|
|
1390
|
+
if (ip === "0.0.0.0") return true;
|
|
1391
|
+
const lower = ip.toLowerCase();
|
|
1392
|
+
if (lower === "::1") return true;
|
|
1393
|
+
if (lower.startsWith("fe80:")) return true;
|
|
1394
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true;
|
|
1395
|
+
if (lower.startsWith("::ffff:")) {
|
|
1396
|
+
const v4 = lower.replace(/^::ffff:/, "");
|
|
1397
|
+
return isInternalIP(v4);
|
|
1398
|
+
}
|
|
1399
|
+
return false;
|
|
1400
|
+
}
|
|
1401
|
+
function isInternalUrl(url) {
|
|
1402
|
+
let parsed;
|
|
1403
|
+
try {
|
|
1404
|
+
parsed = new URL(url);
|
|
1405
|
+
} catch {
|
|
1406
|
+
return true;
|
|
1407
|
+
}
|
|
1408
|
+
const hostname = parsed.hostname.toLowerCase();
|
|
1409
|
+
if (hostname === "localhost") return true;
|
|
1410
|
+
if (isInternalIP(hostname)) return true;
|
|
1411
|
+
if (hostname.endsWith(".local") || hostname.endsWith(".internal") || hostname.endsWith(".localhost")) {
|
|
1412
|
+
return true;
|
|
1413
|
+
}
|
|
1414
|
+
return false;
|
|
1415
|
+
}
|
|
1416
|
+
async function isInternalUrlResolved(url) {
|
|
1417
|
+
if (isInternalUrl(url)) return true;
|
|
1418
|
+
let parsed;
|
|
1419
|
+
try {
|
|
1420
|
+
parsed = new URL(url);
|
|
1421
|
+
} catch {
|
|
1422
|
+
return true;
|
|
1423
|
+
}
|
|
1424
|
+
try {
|
|
1425
|
+
const { address } = await promises.lookup(parsed.hostname);
|
|
1426
|
+
if (isInternalIP(address)) return true;
|
|
1427
|
+
} catch {
|
|
1428
|
+
return true;
|
|
1429
|
+
}
|
|
1430
|
+
return false;
|
|
1431
|
+
}
|
|
1316
1432
|
|
|
1317
1433
|
// src/actions/navigation.ts
|
|
1318
1434
|
async function navigateViaPlaywright(opts) {
|
|
1319
1435
|
const url = String(opts.url ?? "").trim();
|
|
1320
1436
|
if (!url) throw new Error("url is required");
|
|
1437
|
+
if (!opts.allowInternal && await isInternalUrlResolved(url)) {
|
|
1438
|
+
throw new Error(`Navigation to internal/loopback address blocked: "${url}". Set allowInternal: true if this is intentional.`);
|
|
1439
|
+
}
|
|
1321
1440
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1322
1441
|
ensurePageState(page);
|
|
1323
1442
|
await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
|
|
@@ -1339,11 +1458,14 @@ async function listPagesViaPlaywright(opts) {
|
|
|
1339
1458
|
return results;
|
|
1340
1459
|
}
|
|
1341
1460
|
async function createPageViaPlaywright(opts) {
|
|
1461
|
+
const targetUrl = (opts.url ?? "").trim() || "about:blank";
|
|
1462
|
+
if (targetUrl !== "about:blank" && !opts.allowInternal && await isInternalUrlResolved(targetUrl)) {
|
|
1463
|
+
throw new Error(`Navigation to internal/loopback address blocked: "${targetUrl}". Set allowInternal: true if this is intentional.`);
|
|
1464
|
+
}
|
|
1342
1465
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
1343
1466
|
const context = browser.contexts()[0] ?? await browser.newContext();
|
|
1344
1467
|
const page = await context.newPage();
|
|
1345
1468
|
ensurePageState(page);
|
|
1346
|
-
const targetUrl = (opts.url ?? "").trim() || "about:blank";
|
|
1347
1469
|
if (targetUrl !== "about:blank") {
|
|
1348
1470
|
await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) });
|
|
1349
1471
|
}
|
|
@@ -1489,6 +1611,7 @@ async function evaluateViaPlaywright(opts) {
|
|
|
1489
1611
|
|
|
1490
1612
|
// src/actions/download.ts
|
|
1491
1613
|
async function downloadViaPlaywright(opts) {
|
|
1614
|
+
assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
|
|
1492
1615
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1493
1616
|
ensurePageState(page);
|
|
1494
1617
|
restoreRoleRefsForTarget({ cdpUrl: opts.cdpUrl, targetId: opts.targetId, page });
|
|
@@ -1515,6 +1638,7 @@ async function waitForDownloadViaPlaywright(opts) {
|
|
|
1515
1638
|
const timeout = normalizeTimeoutMs(opts.timeoutMs, 3e4, 12e4);
|
|
1516
1639
|
const download = await page.waitForEvent("download", { timeout });
|
|
1517
1640
|
const savePath = opts.path ?? download.suggestedFilename();
|
|
1641
|
+
assertSafeOutputPath(savePath, opts.allowedOutputRoots);
|
|
1518
1642
|
await download.saveAs(savePath);
|
|
1519
1643
|
return {
|
|
1520
1644
|
url: download.url(),
|
|
@@ -1698,6 +1822,7 @@ async function traceStartViaPlaywright(opts) {
|
|
|
1698
1822
|
});
|
|
1699
1823
|
}
|
|
1700
1824
|
async function traceStopViaPlaywright(opts) {
|
|
1825
|
+
assertSafeOutputPath(opts.path, opts.allowedOutputRoots);
|
|
1701
1826
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1702
1827
|
ensurePageState(page);
|
|
1703
1828
|
const context = page.context();
|
|
@@ -1843,10 +1968,12 @@ async function storageClearViaPlaywright(opts) {
|
|
|
1843
1968
|
var CrawlPage = class {
|
|
1844
1969
|
cdpUrl;
|
|
1845
1970
|
targetId;
|
|
1971
|
+
allowInternal;
|
|
1846
1972
|
/** @internal */
|
|
1847
|
-
constructor(cdpUrl, targetId) {
|
|
1973
|
+
constructor(cdpUrl, targetId, allowInternal = false) {
|
|
1848
1974
|
this.cdpUrl = cdpUrl;
|
|
1849
1975
|
this.targetId = targetId;
|
|
1976
|
+
this.allowInternal = allowInternal;
|
|
1850
1977
|
}
|
|
1851
1978
|
/** The CDP target ID for this page. Use this to identify the page in multi-tab scenarios. */
|
|
1852
1979
|
get id() {
|
|
@@ -2178,7 +2305,8 @@ var CrawlPage = class {
|
|
|
2178
2305
|
cdpUrl: this.cdpUrl,
|
|
2179
2306
|
targetId: this.targetId,
|
|
2180
2307
|
url,
|
|
2181
|
-
timeoutMs: opts?.timeoutMs
|
|
2308
|
+
timeoutMs: opts?.timeoutMs,
|
|
2309
|
+
allowInternal: this.allowInternal
|
|
2182
2310
|
});
|
|
2183
2311
|
}
|
|
2184
2312
|
/**
|
|
@@ -2365,12 +2493,14 @@ var CrawlPage = class {
|
|
|
2365
2493
|
* Stop recording a trace and save it to a file.
|
|
2366
2494
|
*
|
|
2367
2495
|
* @param path - File path to save the trace (e.g. `'trace.zip'`)
|
|
2496
|
+
* @param opts - Options (allowedOutputRoots: constrain output to specific directories)
|
|
2368
2497
|
*/
|
|
2369
|
-
async traceStop(path2) {
|
|
2498
|
+
async traceStop(path2, opts) {
|
|
2370
2499
|
return traceStopViaPlaywright({
|
|
2371
2500
|
cdpUrl: this.cdpUrl,
|
|
2372
2501
|
targetId: this.targetId,
|
|
2373
|
-
path: path2
|
|
2502
|
+
path: path2,
|
|
2503
|
+
allowedOutputRoots: opts?.allowedOutputRoots
|
|
2374
2504
|
});
|
|
2375
2505
|
}
|
|
2376
2506
|
/**
|
|
@@ -2558,7 +2688,8 @@ var CrawlPage = class {
|
|
|
2558
2688
|
targetId: this.targetId,
|
|
2559
2689
|
ref,
|
|
2560
2690
|
path: path2,
|
|
2561
|
-
timeoutMs: opts?.timeoutMs
|
|
2691
|
+
timeoutMs: opts?.timeoutMs,
|
|
2692
|
+
allowedOutputRoots: opts?.allowedOutputRoots
|
|
2562
2693
|
});
|
|
2563
2694
|
}
|
|
2564
2695
|
/**
|
|
@@ -2574,7 +2705,8 @@ var CrawlPage = class {
|
|
|
2574
2705
|
cdpUrl: this.cdpUrl,
|
|
2575
2706
|
targetId: this.targetId,
|
|
2576
2707
|
path: opts?.path,
|
|
2577
|
-
timeoutMs: opts?.timeoutMs
|
|
2708
|
+
timeoutMs: opts?.timeoutMs,
|
|
2709
|
+
allowedOutputRoots: opts?.allowedOutputRoots
|
|
2578
2710
|
});
|
|
2579
2711
|
}
|
|
2580
2712
|
// ── Emulation ───────────────────────────────────────────────
|
|
@@ -2704,10 +2836,12 @@ var CrawlPage = class {
|
|
|
2704
2836
|
};
|
|
2705
2837
|
var BrowserClaw = class _BrowserClaw {
|
|
2706
2838
|
cdpUrl;
|
|
2839
|
+
allowInternal;
|
|
2707
2840
|
chrome;
|
|
2708
|
-
constructor(cdpUrl, chrome) {
|
|
2841
|
+
constructor(cdpUrl, chrome, allowInternal = false) {
|
|
2709
2842
|
this.cdpUrl = cdpUrl;
|
|
2710
2843
|
this.chrome = chrome;
|
|
2844
|
+
this.allowInternal = allowInternal;
|
|
2711
2845
|
}
|
|
2712
2846
|
/**
|
|
2713
2847
|
* Launch a new Chrome instance and connect to it.
|
|
@@ -2735,7 +2869,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
2735
2869
|
static async launch(opts = {}) {
|
|
2736
2870
|
const chrome = await launchChrome(opts);
|
|
2737
2871
|
const cdpUrl = `http://127.0.0.1:${chrome.cdpPort}`;
|
|
2738
|
-
return new _BrowserClaw(cdpUrl, chrome);
|
|
2872
|
+
return new _BrowserClaw(cdpUrl, chrome, opts.allowInternal);
|
|
2739
2873
|
}
|
|
2740
2874
|
/**
|
|
2741
2875
|
* Connect to an already-running Chrome instance via its CDP endpoint.
|
|
@@ -2751,12 +2885,12 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
2751
2885
|
* const browser = await BrowserClaw.connect('http://localhost:9222');
|
|
2752
2886
|
* ```
|
|
2753
2887
|
*/
|
|
2754
|
-
static async connect(cdpUrl) {
|
|
2755
|
-
if (!await isChromeReachable(cdpUrl, 3e3)) {
|
|
2888
|
+
static async connect(cdpUrl, opts) {
|
|
2889
|
+
if (!await isChromeReachable(cdpUrl, 3e3, opts?.authToken)) {
|
|
2756
2890
|
throw new Error(`Cannot connect to Chrome at ${cdpUrl}. Is Chrome running with --remote-debugging-port?`);
|
|
2757
2891
|
}
|
|
2758
|
-
await connectBrowser(cdpUrl);
|
|
2759
|
-
return new _BrowserClaw(cdpUrl, null);
|
|
2892
|
+
await connectBrowser(cdpUrl, opts?.authToken);
|
|
2893
|
+
return new _BrowserClaw(cdpUrl, null, opts?.allowInternal);
|
|
2760
2894
|
}
|
|
2761
2895
|
/**
|
|
2762
2896
|
* Open a URL in a new tab and return the page handle.
|
|
@@ -2771,8 +2905,8 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
2771
2905
|
* ```
|
|
2772
2906
|
*/
|
|
2773
2907
|
async open(url) {
|
|
2774
|
-
const tab = await createPageViaPlaywright({ cdpUrl: this.cdpUrl, url });
|
|
2775
|
-
return new CrawlPage(this.cdpUrl, tab.targetId);
|
|
2908
|
+
const tab = await createPageViaPlaywright({ cdpUrl: this.cdpUrl, url, allowInternal: this.allowInternal });
|
|
2909
|
+
return new CrawlPage(this.cdpUrl, tab.targetId, this.allowInternal);
|
|
2776
2910
|
}
|
|
2777
2911
|
/**
|
|
2778
2912
|
* Get a CrawlPage handle for the currently active tab.
|
|
@@ -2785,7 +2919,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
2785
2919
|
if (!pages.length) throw new Error("No pages available. Use browser.open(url) to create a tab.");
|
|
2786
2920
|
const tid = await pageTargetId(pages[0]).catch(() => null);
|
|
2787
2921
|
if (!tid) throw new Error("Failed to get targetId for the current page.");
|
|
2788
|
-
return new CrawlPage(this.cdpUrl, tid);
|
|
2922
|
+
return new CrawlPage(this.cdpUrl, tid, this.allowInternal);
|
|
2789
2923
|
}
|
|
2790
2924
|
/**
|
|
2791
2925
|
* List all open tabs.
|
|
@@ -2820,7 +2954,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
2820
2954
|
* @returns CrawlPage for the specified tab
|
|
2821
2955
|
*/
|
|
2822
2956
|
page(targetId) {
|
|
2823
|
-
return new CrawlPage(this.cdpUrl, targetId);
|
|
2957
|
+
return new CrawlPage(this.cdpUrl, targetId, this.allowInternal);
|
|
2824
2958
|
}
|
|
2825
2959
|
/** The CDP endpoint URL for this browser connection. */
|
|
2826
2960
|
get url() {
|