@remcp/runtime 0.2.106 → 0.2.108
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 +6 -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/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +431 -34
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/documents.mjs +389 -69
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +665 -85
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
package/src/extended/browser.mjs
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
import process from 'node:process';
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { lookup } from 'node:dns/promises';
|
|
4
6
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
5
7
|
import { resolveSafePath } from '../util.mjs';
|
|
6
8
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
7
9
|
|
|
8
10
|
const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
|
|
9
11
|
const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
|
|
12
|
+
const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
13
|
+
const MAX_EVALUATION_BYTES = 256 * 1024;
|
|
14
|
+
const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
|
|
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();
|
|
10
19
|
|
|
11
20
|
let browserLaunchPromise = null;
|
|
12
21
|
|
|
@@ -33,6 +42,16 @@ export function browserAutoLaunchAvailable() {
|
|
|
33
42
|
return Boolean(browserExecutable());
|
|
34
43
|
}
|
|
35
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
|
+
|
|
36
55
|
function browserDataDir() {
|
|
37
56
|
const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
|
|
38
57
|
if (configured) return configured;
|
|
@@ -51,6 +70,7 @@ async function waitForBrowserEndpoint(timeoutMs = 5000) {
|
|
|
51
70
|
}
|
|
52
71
|
|
|
53
72
|
async function ensureBrowserEndpoint(endpoint) {
|
|
73
|
+
assertBrowserRemoteEnabled();
|
|
54
74
|
if (endpoint || process.env.REMCP_CDP_URL) return;
|
|
55
75
|
if (await browserCapabilityAvailable(undefined, 250)) return;
|
|
56
76
|
if (!browserLaunchPromise) {
|
|
@@ -76,6 +96,8 @@ async function ensureBrowserEndpoint(endpoint) {
|
|
|
76
96
|
}
|
|
77
97
|
|
|
78
98
|
export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
99
|
+
if (!browserRemoteEnabled()) return false;
|
|
100
|
+
if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
|
|
79
101
|
if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
|
|
80
102
|
if (endpoint || process.env.REMCP_CDP_URL) return false;
|
|
81
103
|
return browserAutoLaunchAvailable();
|
|
@@ -86,11 +108,68 @@ function boundedText(value, max = 1000) {
|
|
|
86
108
|
return text.length <= max ? text : `${text.slice(0, max)}…`;
|
|
87
109
|
}
|
|
88
110
|
|
|
111
|
+
function normalizedHost(hostname) {
|
|
112
|
+
return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
|
|
113
|
+
}
|
|
114
|
+
|
|
89
115
|
function isLoopbackHost(hostname) {
|
|
90
|
-
const host =
|
|
116
|
+
const host = normalizedHost(hostname);
|
|
91
117
|
return ['localhost', '127.0.0.1', '::1'].includes(host);
|
|
92
118
|
}
|
|
93
119
|
|
|
120
|
+
function localBrowserNavigationAllowed() {
|
|
121
|
+
if (process.env.NODE_ENV === 'production') return false;
|
|
122
|
+
return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
|
|
123
|
+
.some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function isPrivateIpv4(host) {
|
|
127
|
+
if (isIP(host) !== 4) return false;
|
|
128
|
+
const parts = host.split('.').map(Number);
|
|
129
|
+
const [a, b, c] = parts;
|
|
130
|
+
return a === 0
|
|
131
|
+
|| a === 10
|
|
132
|
+
|| a === 127
|
|
133
|
+
|| (a === 100 && b >= 64 && b <= 127)
|
|
134
|
+
|| (a === 169 && b === 254)
|
|
135
|
+
|| (a === 172 && b >= 16 && b <= 31)
|
|
136
|
+
|| (a === 192 && b === 0 && c === 0)
|
|
137
|
+
|| (a === 192 && b === 0 && c === 2)
|
|
138
|
+
|| (a === 192 && b === 168)
|
|
139
|
+
|| (a === 198 && (b === 18 || b === 19))
|
|
140
|
+
|| (a === 198 && b === 51 && c === 100)
|
|
141
|
+
|| (a === 203 && b === 0 && c === 113)
|
|
142
|
+
|| a >= 224;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isPrivateIpv6(host) {
|
|
146
|
+
if (isIP(host) !== 6) return false;
|
|
147
|
+
if (host === '::' || host === '::1') return true;
|
|
148
|
+
if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
|
|
149
|
+
const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
|
|
150
|
+
if (mapped) {
|
|
151
|
+
const high = Number.parseInt(mapped[1], 16);
|
|
152
|
+
const low = Number.parseInt(mapped[2], 16);
|
|
153
|
+
return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function isPrivateBrowserHost(hostname) {
|
|
159
|
+
const host = normalizedHost(hostname);
|
|
160
|
+
if (!host) return true;
|
|
161
|
+
if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
|
|
162
|
+
return host === 'localhost'
|
|
163
|
+
|| host.endsWith('.localhost')
|
|
164
|
+
|| host.endsWith('.local')
|
|
165
|
+
|| host.endsWith('.internal')
|
|
166
|
+
|| host.endsWith('.home.arpa')
|
|
167
|
+
|| host.endsWith('.lan')
|
|
168
|
+
|| host === 'localhost.localdomain'
|
|
169
|
+
|| host === 'ip6-localhost'
|
|
170
|
+
|| host === 'ip6-loopback';
|
|
171
|
+
}
|
|
172
|
+
|
|
94
173
|
function endpointUrl(value) {
|
|
95
174
|
let url;
|
|
96
175
|
try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
|
|
@@ -100,13 +179,110 @@ function endpointUrl(value) {
|
|
|
100
179
|
return url;
|
|
101
180
|
}
|
|
102
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
|
+
|
|
187
|
+
function safeBrowserUrl(value, field = 'url') {
|
|
188
|
+
let url;
|
|
189
|
+
try { url = new URL(String(value || '')); }
|
|
190
|
+
catch { throw new Error(`${field} must be a valid absolute URL`); }
|
|
191
|
+
if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
|
|
192
|
+
if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
|
|
193
|
+
if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
|
|
194
|
+
throw new Error(`${field} must not target loopback, private, or link-local addresses`);
|
|
195
|
+
}
|
|
196
|
+
return url.href;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function safePageUrl(value, field = 'browser page URL') {
|
|
200
|
+
if (String(value || '') === 'about:blank') return value;
|
|
201
|
+
return safeBrowserUrl(value, field);
|
|
202
|
+
}
|
|
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
|
+
|
|
251
|
+
function expressionHasPrivateDestination(expression) {
|
|
252
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
253
|
+
return urls.some(value => {
|
|
254
|
+
try { return isPrivateBrowserHost(new URL(value).hostname); }
|
|
255
|
+
catch { return false; }
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function safeEvaluationExpression(value) {
|
|
260
|
+
const expression = String(value || '');
|
|
261
|
+
if (!expression.trim()) throw new Error('expression is required');
|
|
262
|
+
if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
|
|
263
|
+
throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
|
|
264
|
+
}
|
|
265
|
+
if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
|
|
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');
|
|
268
|
+
}
|
|
269
|
+
return expression;
|
|
270
|
+
}
|
|
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
|
+
|
|
103
279
|
async function requestJson(pathname, endpoint, options = {}) {
|
|
104
280
|
const base = endpointUrl(endpoint);
|
|
105
281
|
const url = new URL(pathname, base);
|
|
106
282
|
const controller = new AbortController();
|
|
107
283
|
const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
|
|
108
284
|
try {
|
|
109
|
-
const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal });
|
|
285
|
+
const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal, redirect: 'error' });
|
|
110
286
|
if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
|
|
111
287
|
const declared = Number(response.headers.get('content-length') || 0);
|
|
112
288
|
if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
|
|
@@ -142,9 +318,33 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
|
|
|
142
318
|
}
|
|
143
319
|
|
|
144
320
|
export async function listBrowserTargets(endpoint) {
|
|
321
|
+
assertBrowserRemoteEnabled();
|
|
145
322
|
await ensureBrowserEndpoint(endpoint);
|
|
146
323
|
const list = await requestJson('/json/list', endpoint);
|
|
147
|
-
|
|
324
|
+
if (!Array.isArray(list)) return [];
|
|
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;
|
|
148
348
|
}
|
|
149
349
|
|
|
150
350
|
async function chooseTarget(args = {}) {
|
|
@@ -152,19 +352,19 @@ async function chooseTarget(args = {}) {
|
|
|
152
352
|
const id = optionalString(args.target_id || args.targetId);
|
|
153
353
|
const title = optionalString(args.title);
|
|
154
354
|
const urlContains = optionalString(args.url_contains || args.urlContains);
|
|
155
|
-
const
|
|
355
|
+
const matched = targets.find(item =>
|
|
156
356
|
(!id || item.id === id) &&
|
|
157
357
|
(!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
|
|
158
358
|
(!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
|
|
159
|
-
)
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
throw new Error('Browser CDP WebSocket target must be loopback-only');
|
|
359
|
+
);
|
|
360
|
+
const hasSelector = Boolean(id || title || urlContains);
|
|
361
|
+
const target = matched || (hasSelector ? null : targets[0]);
|
|
362
|
+
if (!target) {
|
|
363
|
+
if (hasSelector) throw new Error('No browser page target matched the requested selector');
|
|
364
|
+
unavailable('Browser control', 'no debuggable page target is available');
|
|
166
365
|
}
|
|
167
|
-
|
|
366
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
367
|
+
return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
|
|
168
368
|
}
|
|
169
369
|
|
|
170
370
|
class CdpSession {
|
|
@@ -176,15 +376,19 @@ class CdpSession {
|
|
|
176
376
|
this.pending = new Map();
|
|
177
377
|
this.waiters = new Map();
|
|
178
378
|
this.observers = new Map();
|
|
379
|
+
this.closed = true;
|
|
179
380
|
}
|
|
180
381
|
|
|
181
382
|
async open() {
|
|
182
383
|
if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
|
|
183
384
|
await new Promise((resolve, reject) => {
|
|
184
385
|
const socket = new WebSocket(this.url);
|
|
185
|
-
const timer = setTimeout(() =>
|
|
186
|
-
|
|
187
|
-
|
|
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 });
|
|
188
392
|
socket.addEventListener('message', event => this.onMessage(event));
|
|
189
393
|
socket.addEventListener('close', () => this.onClose());
|
|
190
394
|
});
|
|
@@ -192,11 +396,18 @@ class CdpSession {
|
|
|
192
396
|
}
|
|
193
397
|
|
|
194
398
|
onMessage(event) {
|
|
399
|
+
const raw = String(event.data);
|
|
400
|
+
if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
|
|
401
|
+
this.close();
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
195
404
|
let message;
|
|
196
|
-
try { message = JSON.parse(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
405
|
+
try { message = JSON.parse(raw); } catch { return; }
|
|
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);
|
|
200
411
|
clearTimeout(pending.timer);
|
|
201
412
|
if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
202
413
|
else pending.resolve(message.result || {});
|
|
@@ -205,7 +416,7 @@ class CdpSession {
|
|
|
205
416
|
const observers = this.observers.get(message.method);
|
|
206
417
|
if (observers) {
|
|
207
418
|
for (const observer of [...observers]) {
|
|
208
|
-
try { observer(message.params || {}); } catch {}
|
|
419
|
+
try { observer(message.params || {}, sessionId); } catch {}
|
|
209
420
|
}
|
|
210
421
|
}
|
|
211
422
|
const listeners = this.waiters.get(message.method);
|
|
@@ -218,6 +429,7 @@ class CdpSession {
|
|
|
218
429
|
}
|
|
219
430
|
|
|
220
431
|
onClose() {
|
|
432
|
+
this.closed = true;
|
|
221
433
|
for (const pending of this.pending.values()) {
|
|
222
434
|
clearTimeout(pending.timer);
|
|
223
435
|
pending.reject(new Error('CDP websocket closed'));
|
|
@@ -245,15 +457,19 @@ class CdpSession {
|
|
|
245
457
|
};
|
|
246
458
|
}
|
|
247
459
|
|
|
248
|
-
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
|
+
}
|
|
249
464
|
const id = this.nextId++;
|
|
465
|
+
const key = `${sessionId}:${id}`;
|
|
250
466
|
return new Promise((resolve, reject) => {
|
|
251
467
|
const timer = setTimeout(() => {
|
|
252
|
-
this.pending.delete(
|
|
468
|
+
this.pending.delete(key);
|
|
253
469
|
reject(new Error(`${method} timed out`));
|
|
254
|
-
},
|
|
255
|
-
this.pending.set(
|
|
256
|
-
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 } : {}) }));
|
|
257
473
|
});
|
|
258
474
|
}
|
|
259
475
|
|
|
@@ -272,13 +488,181 @@ class CdpSession {
|
|
|
272
488
|
});
|
|
273
489
|
}
|
|
274
490
|
|
|
275
|
-
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) {
|
|
499
|
+
const guardedSessions = new Set();
|
|
500
|
+
const childSetup = new Map();
|
|
501
|
+
const enableSession = async sessionId => {
|
|
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
|
+
guardedSessions.add(sessionId);
|
|
508
|
+
} catch (error) {
|
|
509
|
+
await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
|
|
510
|
+
throw error;
|
|
511
|
+
}
|
|
512
|
+
};
|
|
513
|
+
const guardChild = params => {
|
|
514
|
+
const childSessionId = String(params?.sessionId || '');
|
|
515
|
+
if (!childSessionId) return Promise.resolve();
|
|
516
|
+
if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
|
|
517
|
+
const setup = (async () => {
|
|
518
|
+
try {
|
|
519
|
+
await enableSession(childSessionId);
|
|
520
|
+
await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
|
|
521
|
+
} catch {
|
|
522
|
+
const targetId = params?.targetInfo?.targetId;
|
|
523
|
+
if (targetId) await session.send('Target.closeTarget', { targetId }, '', 2000).catch(() => {});
|
|
524
|
+
}
|
|
525
|
+
})();
|
|
526
|
+
childSetup.set(childSessionId, setup);
|
|
527
|
+
return setup;
|
|
528
|
+
};
|
|
529
|
+
const removePaused = session.observe('Fetch.requestPaused', (params, eventSessionId) => {
|
|
530
|
+
const requestId = params?.requestId;
|
|
531
|
+
const requestUrl = params?.request?.url || '';
|
|
532
|
+
void (async () => {
|
|
533
|
+
let allowed = requestUrl === 'about:blank';
|
|
534
|
+
if (!allowed) {
|
|
535
|
+
try { await assertResolvedBrowserUrl(requestUrl, 'browser request'); allowed = true; }
|
|
536
|
+
catch {}
|
|
537
|
+
}
|
|
538
|
+
if (!requestId) return;
|
|
539
|
+
try {
|
|
540
|
+
if (allowed) await session.send('Fetch.continueRequest', { requestId }, eventSessionId);
|
|
541
|
+
else await session.send('Fetch.failRequest', { requestId, errorReason:'BlockedByClient' }, eventSessionId);
|
|
542
|
+
} catch {}
|
|
543
|
+
})();
|
|
544
|
+
});
|
|
545
|
+
const removeAttached = session.observe('Target.attachedToTarget', params => {
|
|
546
|
+
void guardChild(params);
|
|
547
|
+
});
|
|
548
|
+
await enableSession('');
|
|
549
|
+
if (process.env.NODE_ENV === 'production') {
|
|
550
|
+
await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
|
|
551
|
+
const existing = await session.send('Target.getTargets').catch(() => ({ targetInfos:[] }));
|
|
552
|
+
for (const targetInfo of existing.targetInfos || []) {
|
|
553
|
+
if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
|
|
554
|
+
const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(() => null);
|
|
555
|
+
if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return () => {
|
|
559
|
+
childSetup.clear();
|
|
560
|
+
removePaused();
|
|
561
|
+
removeAttached();
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function productionSessionKey(endpoint, target) {
|
|
566
|
+
return `${endpoint.origin}|${String(target.id)}`;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function closeProductionSession(key, record) {
|
|
570
|
+
if (!record) return;
|
|
571
|
+
if (record.timer) clearTimeout(record.timer);
|
|
572
|
+
record.removeGuard();
|
|
573
|
+
record.session.send('Fetch.disable').catch(() => {});
|
|
574
|
+
record.session.close();
|
|
575
|
+
if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function retainProductionSession(key, session, removeGuard) {
|
|
579
|
+
const existing = productionBrowserSessions.get(key);
|
|
580
|
+
if (existing) closeProductionSession(key, existing);
|
|
581
|
+
const record = { session, removeGuard, timer:null };
|
|
582
|
+
productionBrowserSessions.set(key, record);
|
|
583
|
+
const schedule = () => {
|
|
584
|
+
if (record.timer) clearTimeout(record.timer);
|
|
585
|
+
record.timer = setTimeout(() => closeProductionSession(key, record), PRODUCTION_SESSION_IDLE_MS);
|
|
586
|
+
record.timer.unref?.();
|
|
587
|
+
};
|
|
588
|
+
record.schedule = schedule;
|
|
589
|
+
schedule();
|
|
590
|
+
return record;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
async function createGuardedNewTab(url, args) {
|
|
594
|
+
await ensureBrowserEndpoint(args.endpoint);
|
|
595
|
+
const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
596
|
+
if (!created?.id) throw new Error('Browser did not return a new page target');
|
|
597
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
598
|
+
const target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
|
|
599
|
+
const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
600
|
+
let removeGuard = () => {};
|
|
601
|
+
let productionRecord = null;
|
|
602
|
+
let productionKey = '';
|
|
603
|
+
try {
|
|
604
|
+
removeGuard = await installBrowserRequestGuard(session);
|
|
605
|
+
if (process.env.NODE_ENV === 'production') {
|
|
606
|
+
productionKey = productionSessionKey(endpoint, target);
|
|
607
|
+
productionRecord = retainProductionSession(productionKey, session, removeGuard);
|
|
608
|
+
}
|
|
609
|
+
await session.send('Page.enable');
|
|
610
|
+
const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
|
|
611
|
+
const navigation = await session.send('Page.navigate', { url });
|
|
612
|
+
if (loaded) await loaded;
|
|
613
|
+
const finalUrl = await evaluate(session, 'location.href').catch(() => url);
|
|
614
|
+
await assertResolvedPageUrl(finalUrl);
|
|
615
|
+
return { target, navigation, finalUrl };
|
|
616
|
+
} finally {
|
|
617
|
+
if (productionRecord) productionRecord.schedule();
|
|
618
|
+
else {
|
|
619
|
+
removeGuard();
|
|
620
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
621
|
+
session.close();
|
|
622
|
+
}
|
|
623
|
+
}
|
|
276
624
|
}
|
|
277
625
|
|
|
278
626
|
async function withTarget(args, callback) {
|
|
279
627
|
const target = await chooseTarget(args);
|
|
280
|
-
|
|
281
|
-
|
|
628
|
+
await assertResolvedPageUrl(target.url);
|
|
629
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
630
|
+
const production = process.env.NODE_ENV === 'production';
|
|
631
|
+
const key = production ? productionSessionKey(endpoint, target) : '';
|
|
632
|
+
let record = production ? productionBrowserSessions.get(key) : null;
|
|
633
|
+
if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
|
|
634
|
+
closeProductionSession(key, record);
|
|
635
|
+
record = null;
|
|
636
|
+
}
|
|
637
|
+
let session = record?.session;
|
|
638
|
+
let removeGuard = record?.removeGuard || (() => {});
|
|
639
|
+
if (!session) {
|
|
640
|
+
session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
|
|
641
|
+
try {
|
|
642
|
+
removeGuard = await installBrowserRequestGuard(session);
|
|
643
|
+
if (production) record = retainProductionSession(key, session, removeGuard);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
removeGuard();
|
|
646
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
647
|
+
session.close();
|
|
648
|
+
throw error;
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
try {
|
|
652
|
+
const currentUrl = await evaluate(session, 'location.href').catch(() => target.url);
|
|
653
|
+
await assertResolvedPageUrl(currentUrl);
|
|
654
|
+
const result = await callback(session, target);
|
|
655
|
+
const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
|
|
656
|
+
await assertResolvedPageUrl(finalUrl);
|
|
657
|
+
return result;
|
|
658
|
+
} finally {
|
|
659
|
+
if (production && record) record.schedule();
|
|
660
|
+
else {
|
|
661
|
+
removeGuard();
|
|
662
|
+
await session.send('Fetch.disable').catch(() => {});
|
|
663
|
+
session.close();
|
|
664
|
+
}
|
|
665
|
+
}
|
|
282
666
|
}
|
|
283
667
|
|
|
284
668
|
async function evaluate(session, expression, awaitPromise = true) {
|
|
@@ -338,16 +722,27 @@ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
|
|
|
338
722
|
}
|
|
339
723
|
|
|
340
724
|
export async function browserNavigate(args = {}) {
|
|
341
|
-
const
|
|
342
|
-
const action = requireEnum(args.action || (
|
|
725
|
+
const requestedUrl = optionalString(args.url);
|
|
726
|
+
const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
|
|
727
|
+
const url = ['url','new_tab'].includes(action) ? await assertResolvedBrowserUrl(requestedUrl, 'url') : null;
|
|
343
728
|
if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
|
|
344
729
|
if (action === 'new_tab') {
|
|
730
|
+
if (process.env.NODE_ENV === 'production') {
|
|
731
|
+
const opened = await createGuardedNewTab(url, args);
|
|
732
|
+
return jsonResult({
|
|
733
|
+
target_id: opened.target.id,
|
|
734
|
+
action,
|
|
735
|
+
url: opened.finalUrl,
|
|
736
|
+
title: opened.target.title || '',
|
|
737
|
+
error_text: opened.navigation?.errorText || null,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
345
740
|
await ensureBrowserEndpoint(args.endpoint);
|
|
346
741
|
const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
347
742
|
return jsonResult({
|
|
348
743
|
target_id:created?.id || null,
|
|
349
744
|
action,
|
|
350
|
-
url:created?.url
|
|
745
|
+
url:created?.url ? await assertResolvedPageUrl(created.url) : url,
|
|
351
746
|
title:created?.title || '',
|
|
352
747
|
});
|
|
353
748
|
}
|
|
@@ -372,7 +767,7 @@ export async function browserNavigate(args = {}) {
|
|
|
372
767
|
const nextIndex = action === 'back' ? current - 1 : current + 1;
|
|
373
768
|
const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
|
|
374
769
|
if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
|
|
375
|
-
historyUrl = entry.url
|
|
770
|
+
historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
|
|
376
771
|
await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
|
|
377
772
|
}
|
|
378
773
|
if (loaded) await loaded;
|
|
@@ -380,6 +775,7 @@ export async function browserNavigate(args = {}) {
|
|
|
380
775
|
await waitForHistoryReady(session, historyUrl, timeoutMs);
|
|
381
776
|
}
|
|
382
777
|
const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
|
|
778
|
+
if (currentUrl) await assertResolvedPageUrl(currentUrl);
|
|
383
779
|
return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
|
|
384
780
|
});
|
|
385
781
|
}
|
|
@@ -701,6 +1097,7 @@ export async function browserWait(args = {}) {
|
|
|
701
1097
|
const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
|
|
702
1098
|
const wanted = optionalString(args.selector || args.text || args.value || args.expression);
|
|
703
1099
|
if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
|
|
1100
|
+
if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
|
|
704
1101
|
return withTarget(args, async (session, target) => {
|
|
705
1102
|
const started = Date.now();
|
|
706
1103
|
const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
|
|
@@ -770,8 +1167,8 @@ export async function browserWait(args = {}) {
|
|
|
770
1167
|
}
|
|
771
1168
|
|
|
772
1169
|
export async function browserEvaluate(args) {
|
|
773
|
-
|
|
774
|
-
|
|
1170
|
+
assertBrowserRemoteEnabled();
|
|
1171
|
+
const expression = await assertSafeEvaluationExpression(args.expression);
|
|
775
1172
|
return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
|
|
776
1173
|
}
|
|
777
1174
|
|