@remcp/runtime 0.2.107 → 0.2.109
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/CHANGELOG.md +13 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/extended/browser.mjs +384 -40
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +252 -82
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +738 -288
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +4 -0
package/src/extended/browser.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import process from 'node:process';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
|
+
import { lookup } from 'node:dns/promises';
|
|
5
6
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
6
7
|
import { resolveSafePath } from '../util.mjs';
|
|
7
8
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
@@ -11,7 +12,10 @@ const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
|
|
|
11
12
|
const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
12
13
|
const MAX_EVALUATION_BYTES = 256 * 1024;
|
|
13
14
|
const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
|
|
14
|
-
const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob):/i;
|
|
15
|
+
const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
|
|
16
|
+
const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
|
|
17
|
+
const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
|
|
18
|
+
const productionBrowserSessions = new Map();
|
|
15
19
|
|
|
16
20
|
let browserLaunchPromise = null;
|
|
17
21
|
|
|
@@ -38,6 +42,16 @@ export function browserAutoLaunchAvailable() {
|
|
|
38
42
|
return Boolean(browserExecutable());
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
export function browserRemoteEnabled() {
|
|
46
|
+
const configured = String(process.env.REMCP_BROWSER_REMOTE_ENABLED || '').trim().toLowerCase();
|
|
47
|
+
if (configured) return ['1', 'true', 'yes', 'on'].includes(configured);
|
|
48
|
+
return ['development', 'test'].includes(String(process.env.NODE_ENV || '').trim().toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertBrowserRemoteEnabled() {
|
|
52
|
+
if (!browserRemoteEnabled()) throw new Error('Browser control is disabled for remote runtime; set REMCP_BROWSER_REMOTE_ENABLED=1 only on a trusted local runtime');
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
function browserDataDir() {
|
|
42
56
|
const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
|
|
43
57
|
if (configured) return configured;
|
|
@@ -56,6 +70,7 @@ async function waitForBrowserEndpoint(timeoutMs = 5000) {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
async function ensureBrowserEndpoint(endpoint) {
|
|
73
|
+
assertBrowserRemoteEnabled();
|
|
59
74
|
if (endpoint || process.env.REMCP_CDP_URL) return;
|
|
60
75
|
if (await browserCapabilityAvailable(undefined, 250)) return;
|
|
61
76
|
if (!browserLaunchPromise) {
|
|
@@ -81,6 +96,8 @@ async function ensureBrowserEndpoint(endpoint) {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
99
|
+
if (!browserRemoteEnabled()) return false;
|
|
100
|
+
if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
|
|
84
101
|
if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
|
|
85
102
|
if (endpoint || process.env.REMCP_CDP_URL) return false;
|
|
86
103
|
return browserAutoLaunchAvailable();
|
|
@@ -101,6 +118,7 @@ function isLoopbackHost(hostname) {
|
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
function localBrowserNavigationAllowed() {
|
|
121
|
+
if (process.env.NODE_ENV === 'production') return false;
|
|
104
122
|
return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
|
|
105
123
|
.some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
|
|
106
124
|
}
|
|
@@ -161,6 +179,11 @@ function endpointUrl(value) {
|
|
|
161
179
|
return url;
|
|
162
180
|
}
|
|
163
181
|
|
|
182
|
+
function endpointAuthority(url) {
|
|
183
|
+
const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
|
|
184
|
+
return `${normalizedHost(url.hostname)}:${port}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
function safeBrowserUrl(value, field = 'url') {
|
|
165
188
|
let url;
|
|
166
189
|
try { url = new URL(String(value || '')); }
|
|
@@ -178,8 +201,55 @@ function safePageUrl(value, field = 'browser page URL') {
|
|
|
178
201
|
return safeBrowserUrl(value, field);
|
|
179
202
|
}
|
|
180
203
|
|
|
204
|
+
function configuredBrowserHosts() {
|
|
205
|
+
return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
|
|
206
|
+
.split(/[,\s]+/)
|
|
207
|
+
.map(normalizedHost)
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function browserHostAllowlisted(host) {
|
|
212
|
+
return configuredBrowserHosts().some(pattern => pattern === host
|
|
213
|
+
|| (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function browserDnsRebindingExplicitlyAllowed() {
|
|
217
|
+
return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function productionBrowserPolicyReady() {
|
|
221
|
+
const hosts = configuredBrowserHosts();
|
|
222
|
+
return (hosts.length > 0 && hosts.some(isIP))
|
|
223
|
+
|| (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function assertResolvedBrowserUrl(value, field = 'browser URL') {
|
|
227
|
+
const safe = safeBrowserUrl(value, field);
|
|
228
|
+
if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
|
|
229
|
+
const host = normalizedHost(new URL(safe).hostname);
|
|
230
|
+
if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
|
|
231
|
+
throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
|
|
232
|
+
}
|
|
233
|
+
if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
|
|
234
|
+
if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
|
|
235
|
+
throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
|
|
236
|
+
}
|
|
237
|
+
let addresses;
|
|
238
|
+
try { addresses = await lookup(host, { all:true, verbatim:true }); }
|
|
239
|
+
catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
240
|
+
if (addresses.some(address => isPrivateBrowserHost(address.address))) {
|
|
241
|
+
throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
|
|
242
|
+
}
|
|
243
|
+
return safe;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function assertResolvedPageUrl(value, field = 'browser page URL') {
|
|
247
|
+
if (String(value || '') === 'about:blank') return value;
|
|
248
|
+
return assertResolvedBrowserUrl(value, field);
|
|
249
|
+
}
|
|
250
|
+
|
|
181
251
|
function expressionHasPrivateDestination(expression) {
|
|
182
|
-
const urls = expression.match(/https
|
|
252
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
183
253
|
return urls.some(value => {
|
|
184
254
|
try { return isPrivateBrowserHost(new URL(value).hostname); }
|
|
185
255
|
catch { return false; }
|
|
@@ -193,12 +263,19 @@ function safeEvaluationExpression(value) {
|
|
|
193
263
|
throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
|
|
194
264
|
}
|
|
195
265
|
if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
|
|
196
|
-
if (!localBrowserNavigationAllowed() && expressionHasPrivateDestination(expression)) {
|
|
197
|
-
throw new Error('expression contains a blocked private browser destination');
|
|
266
|
+
if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
|
|
267
|
+
throw new Error('expression contains a blocked private browser destination or network API');
|
|
198
268
|
}
|
|
199
269
|
return expression;
|
|
200
270
|
}
|
|
201
271
|
|
|
272
|
+
async function assertSafeEvaluationExpression(value) {
|
|
273
|
+
const expression = safeEvaluationExpression(value);
|
|
274
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
275
|
+
await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
|
|
276
|
+
return expression;
|
|
277
|
+
}
|
|
278
|
+
|
|
202
279
|
async function requestJson(pathname, endpoint, options = {}) {
|
|
203
280
|
const base = endpointUrl(endpoint);
|
|
204
281
|
const url = new URL(pathname, base);
|
|
@@ -241,13 +318,33 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
|
|
|
241
318
|
}
|
|
242
319
|
|
|
243
320
|
export async function listBrowserTargets(endpoint) {
|
|
321
|
+
assertBrowserRemoteEnabled();
|
|
244
322
|
await ensureBrowserEndpoint(endpoint);
|
|
245
323
|
const list = await requestJson('/json/list', endpoint);
|
|
246
324
|
if (!Array.isArray(list)) return [];
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
325
|
+
const targets = [];
|
|
326
|
+
for (const item of list) {
|
|
327
|
+
if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
|
|
328
|
+
try {
|
|
329
|
+
await assertResolvedPageUrl(item.url);
|
|
330
|
+
targets.push(item);
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
return targets;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validatedTargetWebSocketUrl(target, endpoint) {
|
|
337
|
+
let socketUrl;
|
|
338
|
+
try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
|
|
339
|
+
catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
|
|
340
|
+
if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
|
|
341
|
+
throw new Error('Browser CDP WebSocket target must be loopback-only');
|
|
342
|
+
}
|
|
343
|
+
if (endpointAuthority(socketUrl) !== endpointAuthority(endpoint)
|
|
344
|
+
|| socketUrl.pathname !== `/devtools/page/${encodeURIComponent(String(target.id))}`) {
|
|
345
|
+
throw new Error('Browser CDP WebSocket target must match the configured loopback page endpoint');
|
|
346
|
+
}
|
|
347
|
+
return socketUrl.href;
|
|
251
348
|
}
|
|
252
349
|
|
|
253
350
|
async function chooseTarget(args = {}) {
|
|
@@ -266,13 +363,8 @@ async function chooseTarget(args = {}) {
|
|
|
266
363
|
if (hasSelector) throw new Error('No browser page target matched the requested selector');
|
|
267
364
|
unavailable('Browser control', 'no debuggable page target is available');
|
|
268
365
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
|
|
272
|
-
if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
|
|
273
|
-
throw new Error('Browser CDP WebSocket target must be loopback-only');
|
|
274
|
-
}
|
|
275
|
-
return { ...target, webSocketDebuggerUrl: socketUrl.href };
|
|
366
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
367
|
+
return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
|
|
276
368
|
}
|
|
277
369
|
|
|
278
370
|
class CdpSession {
|
|
@@ -284,15 +376,19 @@ class CdpSession {
|
|
|
284
376
|
this.pending = new Map();
|
|
285
377
|
this.waiters = new Map();
|
|
286
378
|
this.observers = new Map();
|
|
379
|
+
this.closed = true;
|
|
287
380
|
}
|
|
288
381
|
|
|
289
382
|
async open() {
|
|
290
383
|
if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
|
|
291
384
|
await new Promise((resolve, reject) => {
|
|
292
385
|
const socket = new WebSocket(this.url);
|
|
293
|
-
const timer = setTimeout(() =>
|
|
294
|
-
|
|
295
|
-
|
|
386
|
+
const timer = setTimeout(() => {
|
|
387
|
+
try { socket.close(); } catch {}
|
|
388
|
+
reject(new Error('CDP websocket connection timed out'));
|
|
389
|
+
}, this.timeoutMs);
|
|
390
|
+
socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; this.closed = false; resolve(); }, { once: true });
|
|
391
|
+
socket.addEventListener('error', event => { clearTimeout(timer); try { socket.close(); } catch {}; reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
|
|
296
392
|
socket.addEventListener('message', event => this.onMessage(event));
|
|
297
393
|
socket.addEventListener('close', () => this.onClose());
|
|
298
394
|
});
|
|
@@ -307,9 +403,11 @@ class CdpSession {
|
|
|
307
403
|
}
|
|
308
404
|
let message;
|
|
309
405
|
try { message = JSON.parse(raw); } catch { return; }
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
406
|
+
const sessionId = String(message.sessionId || '');
|
|
407
|
+
const pendingKey = message.id ? `${sessionId}:${message.id}` : '';
|
|
408
|
+
if (message.id && this.pending.has(pendingKey)) {
|
|
409
|
+
const pending = this.pending.get(pendingKey);
|
|
410
|
+
this.pending.delete(pendingKey);
|
|
313
411
|
clearTimeout(pending.timer);
|
|
314
412
|
if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
315
413
|
else pending.resolve(message.result || {});
|
|
@@ -318,7 +416,7 @@ class CdpSession {
|
|
|
318
416
|
const observers = this.observers.get(message.method);
|
|
319
417
|
if (observers) {
|
|
320
418
|
for (const observer of [...observers]) {
|
|
321
|
-
try { observer(message.params || {}); } catch {}
|
|
419
|
+
try { observer(message.params || {}, sessionId); } catch {}
|
|
322
420
|
}
|
|
323
421
|
}
|
|
324
422
|
const listeners = this.waiters.get(message.method);
|
|
@@ -331,6 +429,7 @@ class CdpSession {
|
|
|
331
429
|
}
|
|
332
430
|
|
|
333
431
|
onClose() {
|
|
432
|
+
this.closed = true;
|
|
334
433
|
for (const pending of this.pending.values()) {
|
|
335
434
|
clearTimeout(pending.timer);
|
|
336
435
|
pending.reject(new Error('CDP websocket closed'));
|
|
@@ -358,15 +457,19 @@ class CdpSession {
|
|
|
358
457
|
};
|
|
359
458
|
}
|
|
360
459
|
|
|
361
|
-
send(method, params = {}) {
|
|
460
|
+
send(method, params = {}, sessionId = '', timeoutMs = this.timeoutMs) {
|
|
461
|
+
if (this.closed || !this.socket || this.socket.readyState !== 1) {
|
|
462
|
+
return Promise.reject(new Error('CDP websocket is not open'));
|
|
463
|
+
}
|
|
362
464
|
const id = this.nextId++;
|
|
465
|
+
const key = `${sessionId}:${id}`;
|
|
363
466
|
return new Promise((resolve, reject) => {
|
|
364
467
|
const timer = setTimeout(() => {
|
|
365
|
-
this.pending.delete(
|
|
468
|
+
this.pending.delete(key);
|
|
366
469
|
reject(new Error(`${method} timed out`));
|
|
367
|
-
},
|
|
368
|
-
this.pending.set(
|
|
369
|
-
this.socket.send(JSON.stringify({ id, method, params }));
|
|
470
|
+
}, timeoutMs);
|
|
471
|
+
this.pending.set(key, { resolve, reject, timer });
|
|
472
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
370
473
|
});
|
|
371
474
|
}
|
|
372
475
|
|
|
@@ -385,18 +488,248 @@ class CdpSession {
|
|
|
385
488
|
});
|
|
386
489
|
}
|
|
387
490
|
|
|
388
|
-
close() {
|
|
491
|
+
close() {
|
|
492
|
+
if (this.closed) return;
|
|
493
|
+
try { this.socket?.close(); } catch {}
|
|
494
|
+
this.onClose();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function installBrowserRequestGuard(session, { closeRootTarget = async () => {} } = {}) {
|
|
499
|
+
const guardedSessions = new Set();
|
|
500
|
+
const childSetup = new Map();
|
|
501
|
+
const enableSession = async (sessionId, { autoAttach = false } = {}) => {
|
|
502
|
+
if (guardedSessions.has(sessionId)) return;
|
|
503
|
+
try {
|
|
504
|
+
await session.send('Network.enable', {}, sessionId, 2000);
|
|
505
|
+
await session.send('Network.setBlockedURLs', { urls:['ws://*', 'wss://*'] }, sessionId, 2000);
|
|
506
|
+
await session.send('Fetch.enable', { patterns:[{ urlPattern:'*' }] }, sessionId, 2000);
|
|
507
|
+
if (autoAttach) await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, sessionId, 2000);
|
|
508
|
+
guardedSessions.add(sessionId);
|
|
509
|
+
} catch (error) {
|
|
510
|
+
await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
|
|
511
|
+
throw error;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
const guardChild = params => {
|
|
515
|
+
const childSessionId = String(params?.sessionId || '');
|
|
516
|
+
if (!childSessionId) return Promise.resolve();
|
|
517
|
+
if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
|
|
518
|
+
const setup = (async () => {
|
|
519
|
+
try {
|
|
520
|
+
await enableSession(childSessionId, { autoAttach:process.env.NODE_ENV === 'production' });
|
|
521
|
+
await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
|
|
522
|
+
} catch (error) {
|
|
523
|
+
const targetId = params?.targetInfo?.targetId;
|
|
524
|
+
let closed = false;
|
|
525
|
+
if (targetId) {
|
|
526
|
+
try {
|
|
527
|
+
await session.send('Target.closeTarget', { targetId }, '', 2000);
|
|
528
|
+
closed = true;
|
|
529
|
+
} catch {}
|
|
530
|
+
}
|
|
531
|
+
if (!closed) throw new Error(`Browser child guard failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
532
|
+
}
|
|
533
|
+
})();
|
|
534
|
+
childSetup.set(childSessionId, setup);
|
|
535
|
+
return setup;
|
|
536
|
+
};
|
|
537
|
+
const removePaused = session.observe('Fetch.requestPaused', (params, eventSessionId) => {
|
|
538
|
+
const requestId = params?.requestId;
|
|
539
|
+
const requestUrl = params?.request?.url || '';
|
|
540
|
+
void (async () => {
|
|
541
|
+
let allowed = requestUrl === 'about:blank';
|
|
542
|
+
if (!allowed) {
|
|
543
|
+
try { await assertResolvedBrowserUrl(requestUrl, 'browser request'); allowed = true; }
|
|
544
|
+
catch {}
|
|
545
|
+
}
|
|
546
|
+
if (!requestId) return;
|
|
547
|
+
try {
|
|
548
|
+
if (allowed) await session.send('Fetch.continueRequest', { requestId }, eventSessionId);
|
|
549
|
+
else await session.send('Fetch.failRequest', { requestId, errorReason:'BlockedByClient' }, eventSessionId);
|
|
550
|
+
} catch {}
|
|
551
|
+
})();
|
|
552
|
+
});
|
|
553
|
+
const removeAttached = session.observe('Target.attachedToTarget', params => {
|
|
554
|
+
void guardChild(params).catch(async () => {
|
|
555
|
+
await closeRootTarget().catch(() => {});
|
|
556
|
+
session.close();
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
await enableSession('');
|
|
560
|
+
if (process.env.NODE_ENV === 'production') {
|
|
561
|
+
await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
|
|
562
|
+
let existing;
|
|
563
|
+
try {
|
|
564
|
+
existing = await session.send('Target.getTargets', {}, '', 2000);
|
|
565
|
+
} catch (error) {
|
|
566
|
+
throw new Error(`Could not enumerate browser child targets safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
567
|
+
}
|
|
568
|
+
for (const targetInfo of existing.targetInfos || []) {
|
|
569
|
+
if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
|
|
570
|
+
const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(async error => {
|
|
571
|
+
await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
|
|
572
|
+
throw new Error(`Could not attach browser child target safely: ${error instanceof Error ? error.message : String(error)}`);
|
|
573
|
+
});
|
|
574
|
+
if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
|
|
575
|
+
else {
|
|
576
|
+
await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
|
|
577
|
+
throw new Error('Browser child target did not expose a guard session');
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
return () => {
|
|
582
|
+
childSetup.clear();
|
|
583
|
+
removePaused();
|
|
584
|
+
removeAttached();
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function closeBrowserTarget(endpoint, targetId) {
|
|
589
|
+
if (!targetId) return false;
|
|
590
|
+
try {
|
|
591
|
+
const base = endpoint instanceof URL ? endpoint.href : endpoint;
|
|
592
|
+
await requestJson(`/json/close/${encodeURIComponent(String(targetId))}`, base, { timeout_ms:2000 });
|
|
593
|
+
return true;
|
|
594
|
+
} catch {
|
|
595
|
+
return false;
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function productionSessionKey(endpoint, target) {
|
|
600
|
+
return `${endpoint.origin}|${String(target.id)}`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function closeProductionSession(key, record) {
|
|
604
|
+
if (!record) return;
|
|
605
|
+
if (record.timer) clearTimeout(record.timer);
|
|
606
|
+
record.removeGuard();
|
|
607
|
+
if (record.ownedTarget && record.targetId) {
|
|
608
|
+
record.session.send('Target.closeTarget', { targetId:record.targetId }, '', 2000).catch(() => {});
|
|
609
|
+
if (record.endpoint) void closeBrowserTarget(record.endpoint, record.targetId);
|
|
610
|
+
}
|
|
611
|
+
record.session.send('Fetch.disable').catch(() => {});
|
|
612
|
+
record.session.close();
|
|
613
|
+
if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
|
|
617
|
+
const existing = productionBrowserSessions.get(key);
|
|
618
|
+
if (existing) closeProductionSession(key, existing);
|
|
619
|
+
const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
|
|
620
|
+
productionBrowserSessions.set(key, record);
|
|
621
|
+
const schedule = () => {
|
|
622
|
+
if (record.timer) clearTimeout(record.timer);
|
|
623
|
+
record.timer = setTimeout(() => closeProductionSession(key, record), PRODUCTION_SESSION_IDLE_MS);
|
|
624
|
+
record.timer.unref?.();
|
|
625
|
+
};
|
|
626
|
+
record.schedule = schedule;
|
|
627
|
+
schedule();
|
|
628
|
+
return record;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
async function createGuardedNewTab(url, args) {
|
|
632
|
+
await ensureBrowserEndpoint(args.endpoint);
|
|
633
|
+
const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
634
|
+
if (!created?.id) throw new Error('Browser did not return a new page target');
|
|
635
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
636
|
+
let target;
|
|
637
|
+
try {
|
|
638
|
+
target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
|
|
639
|
+
} catch (error) {
|
|
640
|
+
await closeBrowserTarget(endpoint, created.id);
|
|
641
|
+
throw error;
|
|
642
|
+
}
|
|
643
|
+
let session;
|
|
644
|
+
try {
|
|
645
|
+
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
646
|
+
} catch (error) {
|
|
647
|
+
await closeBrowserTarget(endpoint, target.id);
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
let removeGuard = () => {};
|
|
651
|
+
let productionRecord = null;
|
|
652
|
+
let productionKey = '';
|
|
653
|
+
let operationFailed = false;
|
|
654
|
+
try {
|
|
655
|
+
removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
|
|
656
|
+
if (process.env.NODE_ENV === 'production') {
|
|
657
|
+
productionKey = productionSessionKey(endpoint, target);
|
|
658
|
+
productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
|
|
659
|
+
}
|
|
660
|
+
await session.send('Page.enable');
|
|
661
|
+
const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
|
|
662
|
+
const navigation = await session.send('Page.navigate', { url });
|
|
663
|
+
if (loaded) await loaded;
|
|
664
|
+
const finalUrl = await evaluate(session, 'location.href');
|
|
665
|
+
await assertResolvedPageUrl(finalUrl);
|
|
666
|
+
return { target, navigation, finalUrl };
|
|
667
|
+
} catch (error) {
|
|
668
|
+
operationFailed = true;
|
|
669
|
+
if (!productionRecord) await closeBrowserTarget(endpoint, target.id);
|
|
670
|
+
throw error;
|
|
671
|
+
} finally {
|
|
672
|
+
if (productionRecord) {
|
|
673
|
+
if (operationFailed) closeProductionSession(productionKey, productionRecord);
|
|
674
|
+
else productionRecord.schedule();
|
|
675
|
+
}
|
|
676
|
+
else {
|
|
677
|
+
removeGuard();
|
|
678
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
679
|
+
session.close();
|
|
680
|
+
}
|
|
681
|
+
}
|
|
389
682
|
}
|
|
390
683
|
|
|
391
684
|
async function withTarget(args, callback) {
|
|
392
685
|
const target = await chooseTarget(args);
|
|
393
|
-
|
|
394
|
-
const
|
|
686
|
+
await assertResolvedPageUrl(target.url);
|
|
687
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
688
|
+
const production = process.env.NODE_ENV === 'production';
|
|
689
|
+
const key = production ? productionSessionKey(endpoint, target) : '';
|
|
690
|
+
let record = production ? productionBrowserSessions.get(key) : null;
|
|
691
|
+
if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
|
|
692
|
+
closeProductionSession(key, record);
|
|
693
|
+
record = null;
|
|
694
|
+
}
|
|
695
|
+
let session = record?.session;
|
|
696
|
+
let removeGuard = record?.removeGuard || (() => {});
|
|
697
|
+
if (!session) {
|
|
698
|
+
try {
|
|
699
|
+
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
700
|
+
removeGuard = await installBrowserRequestGuard(session);
|
|
701
|
+
if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
|
|
702
|
+
} catch (error) {
|
|
703
|
+
removeGuard();
|
|
704
|
+
if (session) {
|
|
705
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
706
|
+
session.close();
|
|
707
|
+
}
|
|
708
|
+
throw error;
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
let operationFailed = false;
|
|
395
712
|
try {
|
|
396
|
-
const currentUrl = await evaluate(session, 'location.href')
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
713
|
+
const currentUrl = await evaluate(session, 'location.href');
|
|
714
|
+
await assertResolvedPageUrl(currentUrl);
|
|
715
|
+
const result = await callback(session, target);
|
|
716
|
+
const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
|
|
717
|
+
await assertResolvedPageUrl(finalUrl);
|
|
718
|
+
return result;
|
|
719
|
+
} catch (error) {
|
|
720
|
+
operationFailed = true;
|
|
721
|
+
throw error;
|
|
722
|
+
} finally {
|
|
723
|
+
if (production && record) {
|
|
724
|
+
if (operationFailed) closeProductionSession(key, record);
|
|
725
|
+
else record.schedule();
|
|
726
|
+
}
|
|
727
|
+
else {
|
|
728
|
+
removeGuard();
|
|
729
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
730
|
+
session.close();
|
|
731
|
+
}
|
|
732
|
+
}
|
|
400
733
|
}
|
|
401
734
|
|
|
402
735
|
async function evaluate(session, expression, awaitPromise = true) {
|
|
@@ -458,15 +791,25 @@ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
|
|
|
458
791
|
export async function browserNavigate(args = {}) {
|
|
459
792
|
const requestedUrl = optionalString(args.url);
|
|
460
793
|
const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
|
|
461
|
-
const url = ['url','new_tab'].includes(action) ?
|
|
794
|
+
const url = ['url','new_tab'].includes(action) ? await assertResolvedBrowserUrl(requestedUrl, 'url') : null;
|
|
462
795
|
if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
|
|
463
796
|
if (action === 'new_tab') {
|
|
797
|
+
if (process.env.NODE_ENV === 'production') {
|
|
798
|
+
const opened = await createGuardedNewTab(url, args);
|
|
799
|
+
return jsonResult({
|
|
800
|
+
target_id: opened.target.id,
|
|
801
|
+
action,
|
|
802
|
+
url: opened.finalUrl,
|
|
803
|
+
title: opened.target.title || '',
|
|
804
|
+
error_text: opened.navigation?.errorText || null,
|
|
805
|
+
});
|
|
806
|
+
}
|
|
464
807
|
await ensureBrowserEndpoint(args.endpoint);
|
|
465
808
|
const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
466
809
|
return jsonResult({
|
|
467
810
|
target_id:created?.id || null,
|
|
468
811
|
action,
|
|
469
|
-
url:created?.url ?
|
|
812
|
+
url:created?.url ? await assertResolvedPageUrl(created.url) : url,
|
|
470
813
|
title:created?.title || '',
|
|
471
814
|
});
|
|
472
815
|
}
|
|
@@ -491,7 +834,7 @@ export async function browserNavigate(args = {}) {
|
|
|
491
834
|
const nextIndex = action === 'back' ? current - 1 : current + 1;
|
|
492
835
|
const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
|
|
493
836
|
if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
|
|
494
|
-
historyUrl = entry.url ?
|
|
837
|
+
historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
|
|
495
838
|
await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
|
|
496
839
|
}
|
|
497
840
|
if (loaded) await loaded;
|
|
@@ -499,7 +842,7 @@ export async function browserNavigate(args = {}) {
|
|
|
499
842
|
await waitForHistoryReady(session, historyUrl, timeoutMs);
|
|
500
843
|
}
|
|
501
844
|
const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
|
|
502
|
-
if (currentUrl)
|
|
845
|
+
if (currentUrl) await assertResolvedPageUrl(currentUrl);
|
|
503
846
|
return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
|
|
504
847
|
});
|
|
505
848
|
}
|
|
@@ -821,7 +1164,7 @@ export async function browserWait(args = {}) {
|
|
|
821
1164
|
const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
|
|
822
1165
|
const wanted = optionalString(args.selector || args.text || args.value || args.expression);
|
|
823
1166
|
if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
|
|
824
|
-
if (condition === 'expression')
|
|
1167
|
+
if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
|
|
825
1168
|
return withTarget(args, async (session, target) => {
|
|
826
1169
|
const started = Date.now();
|
|
827
1170
|
const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
|
|
@@ -891,7 +1234,8 @@ export async function browserWait(args = {}) {
|
|
|
891
1234
|
}
|
|
892
1235
|
|
|
893
1236
|
export async function browserEvaluate(args) {
|
|
894
|
-
|
|
1237
|
+
assertBrowserRemoteEnabled();
|
|
1238
|
+
const expression = await assertSafeEvaluationExpression(args.expression);
|
|
895
1239
|
return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
|
|
896
1240
|
}
|
|
897
1241
|
|