@remcp/runtime 0.2.153 → 0.2.155

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.
@@ -1,1440 +1,4 @@
1
- import process from 'node:process';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { isIP } from 'node:net';
5
- import { lookup } from 'node:dns/promises';
6
- import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
7
- import { resolveSafePath } from '../util.mjs';
8
- import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs';
9
- import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
10
-
11
- const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
12
- const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
13
- const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
14
- const MAX_EVALUATION_BYTES = 256 * 1024;
15
- const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
16
- const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
17
- const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
18
- const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
19
- const productionBrowserSessions = new Map();
20
-
21
- let browserLaunchPromise = null;
22
-
23
- function browserExecutable() {
24
- const configured = optionalString(process.env.REMCP_BROWSER_BINARY);
25
- const candidates = configured ? [configured] : process.platform === 'darwin'
26
- ? [
27
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
28
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
29
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
30
- ]
31
- : process.platform === 'win32'
32
- ? [
33
- path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
34
- path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
35
- path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
36
- path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
37
- ]
38
- : ['/opt/google/chrome/chrome','/usr/bin/google-chrome','/usr/bin/google-chrome-stable','chromium','chromium-browser','microsoft-edge','microsoft-edge-stable'];
39
- return candidates.find(candidate => path.isAbsolute(candidate) ? existsSync(candidate) : commandExists(candidate)) || null;
40
- }
41
-
42
- export function browserAutoLaunchAvailable() {
43
- return Boolean(browserExecutable());
44
- }
45
-
46
- export function browserRemoteEnabled() {
47
- const configured = String(process.env.REMCP_BROWSER_REMOTE_ENABLED || '').trim().toLowerCase();
48
- if (configured) return ['1', 'true', 'yes', 'on'].includes(configured);
49
- return ['development', 'test'].includes(String(process.env.NODE_ENV || '').trim().toLowerCase());
50
- }
51
-
52
- function assertBrowserRemoteEnabled() {
53
- if (!browserRemoteEnabled()) throw new Error('Browser control is disabled for remote runtime; set REMCP_BROWSER_REMOTE_ENABLED=1 only on a trusted local runtime');
54
- }
55
-
56
- function browserDataDir() {
57
- const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
58
- if (configured) return configured;
59
- if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'ReMCP', 'browser-cdp');
60
- if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'ReMCP', 'browser-cdp');
61
- return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
62
- }
63
-
64
- // Browser sessions belong to the local user, not to the lifetime of the ReMCP
65
- // agent. The agent can be restarted by an npm update while Chrome is open.
66
- // If Chrome was interrupted and already persisted tabs, ask Chromium itself to
67
- // restore the session. Never parse, persist, or relay the sensitive tab URLs.
68
- // A clean user-initiated quit must not resurrect a deliberately closed session.
69
- export function shouldRestoreBrowserSession(profile) {
70
- let prefsFd;
71
- let sessionFd;
72
- try {
73
- const defaultProfile = path.join(profile, 'Default');
74
- // Open first, then validate the file descriptor: checking the path with
75
- // lstat before reading it creates a symlink-swap TOCTOU vulnerability.
76
- prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
77
- fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
78
- const prefsStat = fstatSync(prefsFd);
79
- if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
80
- const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
81
- if (prefs?.profile?.exit_type !== 'Crashed') return false;
82
-
83
- const sessionDir = path.join(defaultProfile, 'Sessions');
84
- let readableDir = sessionDir;
85
- if (process.platform !== 'win32') {
86
- // Use the opened directory even if another process renames the path.
87
- // O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
88
- sessionFd = openSync(sessionDir,
89
- fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
90
- if (!fstatSync(sessionFd).isDirectory()) return false;
91
- readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
92
- }
93
- return readdirSync(readableDir).some(name => {
94
- if (!/^Tabs_\d+$/.test(name)) return false;
95
- let tabFd;
96
- try {
97
- tabFd = openSync(path.join(readableDir, name),
98
- fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
99
- const stat = fstatSync(tabFd);
100
- return stat.isFile() && stat.size > 0;
101
- } catch { return false; }
102
- finally { if (tabFd !== undefined) closeSync(tabFd); }
103
- });
104
- } catch {
105
- // A new, malformed or inaccessible profile must start fresh, not fail CDP.
106
- return false;
107
- } finally {
108
- if (sessionFd !== undefined) closeSync(sessionFd);
109
- if (prefsFd !== undefined) closeSync(prefsFd);
110
- }
111
- }
112
-
113
- export function browserAutoLaunchArgs(profile, {
114
- platform = process.platform,
115
- display = process.env.DISPLAY,
116
- waylandDisplay = process.env.WAYLAND_DISPLAY,
117
- } = {}) {
118
- const restore = shouldRestoreBrowserSession(profile);
119
- const args = [
120
- '--remote-debugging-address=127.0.0.1',
121
- '--remote-debugging-port=9222',
122
- `--user-data-dir=${profile}`,
123
- '--no-first-run',
124
- '--no-default-browser-check',
125
- // --restore-last-session only for an interrupted, persisted local profile.
126
- // An explicit about:blank startup URL would suppress the normal restore.
127
- ...(restore ? ['--restore-last-session'] : ['about:blank']),
128
- ];
129
- if (platform === 'linux' && !display && !waylandDisplay) args.unshift('--headless=new');
130
- return args;
131
- }
132
-
133
- async function waitForBrowserEndpoint(timeoutMs = 5000) {
134
- const deadline = Date.now() + timeoutMs;
135
- do {
136
- if (await browserCapabilityAvailable(undefined, 250)) return true;
137
- await new Promise(resolve => setTimeout(resolve, 100));
138
- } while (Date.now() < deadline);
139
- return false;
140
- }
141
-
142
- async function ensureBrowserEndpoint(endpoint) {
143
- assertBrowserRemoteEnabled();
144
- if (endpoint || process.env.REMCP_CDP_URL) return;
145
- if (await browserCapabilityAvailable(undefined, 250)) return;
146
- if (!browserLaunchPromise) {
147
- browserLaunchPromise = (async () => {
148
- const executable = browserExecutable();
149
- if (!executable) throw new Error('Browser control is unavailable: no supported Chrome, Edge, or Chromium executable was found');
150
- const profile = browserDataDir();
151
- mkdirSync(profile, { recursive:true, mode:0o700 });
152
- // Chrome rotates its Sessions/ files on startup. Protect existing local
153
- // evidence before a restart, including profiles with only Tabs_* left.
154
- preserveInterruptedBrowserSession(profile);
155
- spawnDetached(executable, browserAutoLaunchArgs(profile));
156
- if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
157
- })().finally(() => { browserLaunchPromise = null; });
158
- }
159
- await browserLaunchPromise;
160
- }
161
-
162
- export async function browserControlAvailable(endpoint, timeoutMs = 500) {
163
- if (!browserRemoteEnabled()) return false;
164
- if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
165
- if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
166
- if (endpoint || process.env.REMCP_CDP_URL) return false;
167
- return browserAutoLaunchAvailable();
168
- }
169
-
170
- function boundedText(value, max = 1000) {
171
- const text = String(value ?? '');
172
- return text.length <= max ? text : `${text.slice(0, max)}…`;
173
- }
174
-
175
- function normalizedHost(hostname) {
176
- return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
177
- }
178
-
179
- function isLoopbackHost(hostname) {
180
- const host = normalizedHost(hostname);
181
- return ['localhost', '127.0.0.1', '::1'].includes(host);
182
- }
183
-
184
- function localBrowserNavigationAllowed() {
185
- if (process.env.NODE_ENV === 'production') return false;
186
- return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
187
- .some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
188
- }
189
-
190
- function isPrivateIpv4(host) {
191
- if (isIP(host) !== 4) return false;
192
- const parts = host.split('.').map(Number);
193
- const [a, b, c] = parts;
194
- return a === 0
195
- || a === 10
196
- || a === 127
197
- || (a === 100 && b >= 64 && b <= 127)
198
- || (a === 169 && b === 254)
199
- || (a === 172 && b >= 16 && b <= 31)
200
- || (a === 192 && b === 0 && c === 0)
201
- || (a === 192 && b === 0 && c === 2)
202
- || (a === 192 && b === 168)
203
- || (a === 198 && (b === 18 || b === 19))
204
- || (a === 198 && b === 51 && c === 100)
205
- || (a === 203 && b === 0 && c === 113)
206
- || a >= 224;
207
- }
208
-
209
- function isPrivateIpv6(host) {
210
- if (isIP(host) !== 6) return false;
211
- if (host === '::' || host === '::1') return true;
212
- if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
213
- const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
214
- if (mapped) {
215
- const high = Number.parseInt(mapped[1], 16);
216
- const low = Number.parseInt(mapped[2], 16);
217
- return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
218
- }
219
- return false;
220
- }
221
-
222
- function isPrivateBrowserHost(hostname) {
223
- const host = normalizedHost(hostname);
224
- if (!host) return true;
225
- if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
226
- return host === 'localhost'
227
- || host.endsWith('.localhost')
228
- || host.endsWith('.local')
229
- || host.endsWith('.internal')
230
- || host.endsWith('.home.arpa')
231
- || host.endsWith('.lan')
232
- || host === 'localhost.localdomain'
233
- || host === 'ip6-localhost'
234
- || host === 'ip6-loopback';
235
- }
236
-
237
- function endpointUrl(value) {
238
- let url;
239
- try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
240
- catch { throw new Error('endpoint must be a valid local http(s) URL'); }
241
- if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
242
- if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
243
- return url;
244
- }
245
-
246
- function endpointAuthority(url) {
247
- const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
248
- return `${normalizedHost(url.hostname)}:${port}`;
249
- }
250
-
251
- function safeBrowserUrl(value, field = 'url') {
252
- let url;
253
- try { url = new URL(String(value || '')); }
254
- catch { throw new Error(`${field} must be a valid absolute URL`); }
255
- if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
256
- if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
257
- if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
258
- throw new Error(`${field} must not target loopback, private, or link-local addresses`);
259
- }
260
- return url.href;
261
- }
262
-
263
- function safePageUrl(value, field = 'browser page URL') {
264
- if (String(value || '') === 'about:blank') return value;
265
- return safeBrowserUrl(value, field);
266
- }
267
-
268
- function configuredBrowserHosts() {
269
- return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
270
- .split(/[,\s]+/)
271
- .map(normalizedHost)
272
- .filter(Boolean);
273
- }
274
-
275
- function browserHostAllowlisted(host) {
276
- return configuredBrowserHosts().some(pattern => pattern === host
277
- || (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
278
- }
279
-
280
- function browserDnsRebindingExplicitlyAllowed() {
281
- return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
282
- }
283
-
284
- function productionBrowserPolicyReady() {
285
- const hosts = configuredBrowserHosts();
286
- return (hosts.length > 0 && hosts.some(isIP))
287
- || (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
288
- }
289
-
290
- async function assertResolvedBrowserUrl(value, field = 'browser URL') {
291
- const safe = safeBrowserUrl(value, field);
292
- if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
293
- const host = normalizedHost(new URL(safe).hostname);
294
- if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
295
- throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
296
- }
297
- if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
298
- if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
299
- throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
300
- }
301
- let addresses;
302
- try { addresses = await lookup(host, { all:true, verbatim:true }); }
303
- catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
304
- if (addresses.some(address => isPrivateBrowserHost(address.address))) {
305
- throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
306
- }
307
- return safe;
308
- }
309
-
310
- async function assertResolvedPageUrl(value, field = 'browser page URL') {
311
- if (String(value || '') === 'about:blank') return value;
312
- return assertResolvedBrowserUrl(value, field);
313
- }
314
-
315
- function expressionHasPrivateDestination(expression) {
316
- const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
317
- return urls.some(value => {
318
- try { return isPrivateBrowserHost(new URL(value).hostname); }
319
- catch { return false; }
320
- });
321
- }
322
-
323
- function safeEvaluationExpression(value) {
324
- const expression = String(value || '');
325
- if (!expression.trim()) throw new Error('expression is required');
326
- if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
327
- throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
328
- }
329
- if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
330
- if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
331
- throw new Error('expression contains a blocked private browser destination or network API');
332
- }
333
- return expression;
334
- }
335
-
336
- async function assertSafeEvaluationExpression(value) {
337
- const expression = safeEvaluationExpression(value);
338
- const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
339
- await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
340
- return expression;
341
- }
342
-
343
- async function requestJson(pathname, endpoint, options = {}) {
344
- const base = endpointUrl(endpoint);
345
- const url = new URL(pathname, base);
346
- const controller = new AbortController();
347
- const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
348
- try {
349
- const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal, redirect: 'error' });
350
- if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
351
- const declared = Number(response.headers.get('content-length') || 0);
352
- if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
353
- const reader = response.body?.getReader();
354
- if (!reader) return JSON.parse(await response.text());
355
- const chunks = [];
356
- let total = 0;
357
- for (;;) {
358
- const { value, done } = await reader.read();
359
- if (done) break;
360
- total += value.byteLength;
361
- if (total > MAX_DISCOVERY_BYTES) {
362
- await reader.cancel().catch(() => {});
363
- throw new Error(`CDP discovery response exceeded ${MAX_DISCOVERY_BYTES} bytes`);
364
- }
365
- chunks.push(Buffer.from(value));
366
- }
367
- return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
368
- } catch (error) {
369
- throw new Error(`Could not reach Chrome DevTools Protocol at ${base.origin}: ${error instanceof Error ? error.message : String(error)}. Start Chrome/Edge with --remote-debugging-port=9222.`);
370
- } finally {
371
- clearTimeout(timer);
372
- }
373
- }
374
-
375
- export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
376
- try {
377
- const version = await requestJson('/json/version', endpoint, { timeout_ms: timeoutMs });
378
- return Boolean(version && typeof version === 'object' && (version.Browser || version.webSocketDebuggerUrl));
379
- } catch {
380
- return false;
381
- }
382
- }
383
-
384
- // Validating every tab sequentially can require hundreds of serial DNS lookups,
385
- // making browser_tabs exceed the relay's deadline on machines with many tabs.
386
- // Keep DNS and security validation, but bound both the work and its concurrency.
387
- export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl } = {}) {
388
- if (!Array.isArray(list)) return [];
389
- const candidates = [];
390
- for (const item of list) {
391
- if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
392
- candidates.push(item);
393
- if (candidates.length >= 1000) break;
394
- }
395
- const safe = new Array(candidates.length).fill(false);
396
- let cursor = 0;
397
- await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, async () => {
398
- while (cursor < candidates.length) {
399
- const index = cursor++;
400
- try {
401
- await validate(candidates[index].url);
402
- safe[index] = true;
403
- } catch {
404
- // Exclude unsafe/unresolvable pages without exposing them to the caller.
405
- }
406
- }
407
- }));
408
- const targets = [];
409
- for (let i = 0; i < candidates.length && targets.length < 500; i++) {
410
- if (safe[i]) targets.push(candidates[i]);
411
- }
412
- return targets;
413
- }
414
-
415
- export async function listBrowserTargets(endpoint) {
416
- assertBrowserRemoteEnabled();
417
- // Reading tabs and page semantics must not launch a separate Chromium/profile.
418
- // Only an explicit browser_navigate(action=new_tab) can bootstrap an isolated browser.
419
- const base = endpointUrl(endpoint); // keeps the CDP address loopback-only
420
- let list;
421
- try {
422
- list = await requestJson('/json/list', endpoint, { timeout_ms: 1500 });
423
- } catch (error) {
424
- const message = String(error?.message || '');
425
- // Keep HTTP/policy/oversize/malformed-response errors distinct from an unavailable CDP.
426
- if (!/fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|timed out|network error/i.test(message)) throw error;
427
- const configured = Boolean(endpoint || process.env.REMCP_CDP_URL);
428
- const label = configured ? 'The configured CDP endpoint' : 'Browser CDP endpoint';
429
- throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser.`);
430
- }
431
- // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
432
- return filterBrowserTargets(list);
433
- }
434
-
435
- function validatedTargetWebSocketUrl(target, endpoint) {
436
- let socketUrl;
437
- try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
438
- catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
439
- if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
440
- throw new Error('Browser CDP WebSocket target must be loopback-only');
441
- }
442
- if (endpointAuthority(socketUrl) !== endpointAuthority(endpoint)
443
- || socketUrl.pathname !== `/devtools/page/${encodeURIComponent(String(target.id))}`) {
444
- throw new Error('Browser CDP WebSocket target must match the configured loopback page endpoint');
445
- }
446
- return socketUrl.href;
447
- }
448
-
449
- async function chooseTarget(args = {}) {
450
- const targets = (await listBrowserTargets(args.endpoint)).filter(item => item.type === 'page' && item.webSocketDebuggerUrl);
451
- const id = optionalString(args.target_id || args.targetId);
452
- const title = optionalString(args.title);
453
- const urlContains = optionalString(args.url_contains || args.urlContains);
454
- const matched = targets.find(item =>
455
- (!id || item.id === id) &&
456
- (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
457
- (!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
458
- );
459
- const hasSelector = Boolean(id || title || urlContains);
460
- const target = matched || (hasSelector ? null : targets[0]);
461
- if (!target) {
462
- if (hasSelector) throw new Error('No browser page target matched the requested selector');
463
- unavailable('Browser control', 'no debuggable page target is available');
464
- }
465
- const endpoint = endpointUrl(args.endpoint);
466
- return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
467
- }
468
-
469
- class CdpSession {
470
- constructor(url, timeoutMs) {
471
- this.url = url;
472
- this.timeoutMs = timeoutMs;
473
- this.socket = null;
474
- this.nextId = 1;
475
- this.pending = new Map();
476
- this.waiters = new Map();
477
- this.observers = new Map();
478
- this.closed = true;
479
- }
480
-
481
- async open() {
482
- if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
483
- await new Promise((resolve, reject) => {
484
- const socket = new WebSocket(this.url);
485
- const timer = setTimeout(() => {
486
- try { socket.close(); } catch {}
487
- reject(new Error('CDP websocket connection timed out'));
488
- }, this.timeoutMs);
489
- socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; this.closed = false; resolve(); }, { once: true });
490
- socket.addEventListener('error', event => { clearTimeout(timer); try { socket.close(); } catch {}; reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
491
- socket.addEventListener('message', event => this.onMessage(event));
492
- socket.addEventListener('close', () => this.onClose());
493
- });
494
- return this;
495
- }
496
-
497
- onMessage(event) {
498
- const raw = String(event.data);
499
- if (Buffer.byteLength(raw, 'utf8') > MAX_CDP_MESSAGE_BYTES) {
500
- this.close();
501
- return;
502
- }
503
- let message;
504
- try { message = JSON.parse(raw); } catch { return; }
505
- const sessionId = String(message.sessionId || '');
506
- const pendingKey = message.id ? `${sessionId}:${message.id}` : '';
507
- if (message.id && this.pending.has(pendingKey)) {
508
- const pending = this.pending.get(pendingKey);
509
- this.pending.delete(pendingKey);
510
- clearTimeout(pending.timer);
511
- if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
512
- else pending.resolve(message.result || {});
513
- return;
514
- }
515
- const observers = this.observers.get(message.method);
516
- if (observers) {
517
- for (const observer of [...observers]) {
518
- try { observer(message.params || {}, sessionId); } catch {}
519
- }
520
- }
521
- const listeners = this.waiters.get(message.method);
522
- if (!listeners) return;
523
- this.waiters.delete(message.method);
524
- for (const listener of listeners) {
525
- clearTimeout(listener.timer);
526
- listener.resolve(message.params || {});
527
- }
528
- }
529
-
530
- onClose() {
531
- this.closed = true;
532
- for (const pending of this.pending.values()) {
533
- clearTimeout(pending.timer);
534
- pending.reject(new Error('CDP websocket closed'));
535
- }
536
- this.pending.clear();
537
- for (const listeners of this.waiters.values()) {
538
- for (const listener of listeners) {
539
- clearTimeout(listener.timer);
540
- listener.reject(new Error('CDP websocket closed'));
541
- }
542
- }
543
- this.waiters.clear();
544
- this.observers.clear();
545
- }
546
-
547
- observe(method, callback) {
548
- const listeners = this.observers.get(method) || new Set();
549
- listeners.add(callback);
550
- this.observers.set(method, listeners);
551
- return () => {
552
- const current = this.observers.get(method);
553
- if (!current) return;
554
- current.delete(callback);
555
- if (!current.size) this.observers.delete(method);
556
- };
557
- }
558
-
559
- send(method, params = {}, sessionId = '', timeoutMs = this.timeoutMs) {
560
- if (this.closed || !this.socket || this.socket.readyState !== 1) {
561
- return Promise.reject(new Error('CDP websocket is not open'));
562
- }
563
- const id = this.nextId++;
564
- const key = `${sessionId}:${id}`;
565
- return new Promise((resolve, reject) => {
566
- const timer = setTimeout(() => {
567
- this.pending.delete(key);
568
- reject(new Error(`${method} timed out`));
569
- }, timeoutMs);
570
- this.pending.set(key, { resolve, reject, timer });
571
- this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
572
- });
573
- }
574
-
575
- waitFor(method, timeoutMs = this.timeoutMs) {
576
- return new Promise((resolve, reject) => {
577
- const listeners = this.waiters.get(method) || [];
578
- const listener = { resolve, reject, timer: null };
579
- listener.timer = setTimeout(() => {
580
- const current = this.waiters.get(method) || [];
581
- const next = current.filter(item => item !== listener);
582
- if (next.length) this.waiters.set(method, next); else this.waiters.delete(method);
583
- reject(new Error(`${method} timed out`));
584
- }, timeoutMs);
585
- listeners.push(listener);
586
- this.waiters.set(method, listeners);
587
- });
588
- }
589
-
590
- close() {
591
- if (this.closed) return;
592
- try { this.socket?.close(); } catch {}
593
- this.onClose();
594
- }
595
- }
596
-
597
- async function installBrowserRequestGuard(session, { closeRootTarget = async () => {} } = {}) {
598
- const guardedSessions = new Set();
599
- const childSetup = new Map();
600
- const enableSession = async (sessionId, { autoAttach = false } = {}) => {
601
- if (guardedSessions.has(sessionId)) return;
602
- try {
603
- await session.send('Network.enable', {}, sessionId, 2000);
604
- await session.send('Network.setBlockedURLs', { urls:['ws://*', 'wss://*'] }, sessionId, 2000);
605
- await session.send('Fetch.enable', { patterns:[{ urlPattern:'*' }] }, sessionId, 2000);
606
- if (autoAttach) await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, sessionId, 2000);
607
- guardedSessions.add(sessionId);
608
- } catch (error) {
609
- await session.send('Fetch.disable', {}, sessionId, 1000).catch(() => {});
610
- throw error;
611
- }
612
- };
613
- const guardChild = params => {
614
- const childSessionId = String(params?.sessionId || '');
615
- if (!childSessionId) return Promise.resolve();
616
- if (childSetup.has(childSessionId)) return childSetup.get(childSessionId);
617
- const setup = (async () => {
618
- try {
619
- await enableSession(childSessionId, { autoAttach:process.env.NODE_ENV === 'production' });
620
- await session.send('Runtime.runIfWaitingForDebugger', {}, childSessionId, 2000);
621
- } catch (error) {
622
- const targetId = params?.targetInfo?.targetId;
623
- let closed = false;
624
- if (targetId) {
625
- try {
626
- await session.send('Target.closeTarget', { targetId }, '', 2000);
627
- closed = true;
628
- } catch {}
629
- }
630
- if (!closed) throw new Error(`Browser child guard failed: ${error instanceof Error ? error.message : String(error)}`);
631
- }
632
- })();
633
- childSetup.set(childSessionId, setup);
634
- return setup;
635
- };
636
- const removePaused = session.observe('Fetch.requestPaused', (params, eventSessionId) => {
637
- const requestId = params?.requestId;
638
- const requestUrl = params?.request?.url || '';
639
- void (async () => {
640
- let allowed = requestUrl === 'about:blank';
641
- if (!allowed) {
642
- try { await assertResolvedBrowserUrl(requestUrl, 'browser request'); allowed = true; }
643
- catch {}
644
- }
645
- if (!requestId) return;
646
- try {
647
- if (allowed) await session.send('Fetch.continueRequest', { requestId }, eventSessionId);
648
- else await session.send('Fetch.failRequest', { requestId, errorReason:'BlockedByClient' }, eventSessionId);
649
- } catch {}
650
- })();
651
- });
652
- const removeAttached = session.observe('Target.attachedToTarget', params => {
653
- void guardChild(params).catch(async () => {
654
- await closeRootTarget().catch(() => {});
655
- session.close();
656
- });
657
- });
658
- await enableSession('');
659
- if (process.env.NODE_ENV === 'production') {
660
- await session.send('Target.setAutoAttach', { autoAttach:true, waitForDebuggerOnStart:true, flatten:true }, '', 2000);
661
- let existing;
662
- try {
663
- existing = await session.send('Target.getTargets', {}, '', 2000);
664
- } catch (error) {
665
- throw new Error(`Could not enumerate browser child targets safely: ${error instanceof Error ? error.message : String(error)}`);
666
- }
667
- for (const targetInfo of existing.targetInfos || []) {
668
- if (!['worker', 'service_worker', 'shared_worker', 'iframe', 'webview'].includes(targetInfo.type)) continue;
669
- const attached = await session.send('Target.attachToTarget', { targetId:targetInfo.targetId, flatten:true }, '', 2000).catch(async error => {
670
- await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
671
- throw new Error(`Could not attach browser child target safely: ${error instanceof Error ? error.message : String(error)}`);
672
- });
673
- if (attached?.sessionId) await guardChild({ sessionId:attached.sessionId, targetInfo });
674
- else {
675
- await session.send('Target.closeTarget', { targetId:targetInfo.targetId }, '', 2000).catch(() => {});
676
- throw new Error('Browser child target did not expose a guard session');
677
- }
678
- }
679
- }
680
- return () => {
681
- childSetup.clear();
682
- removePaused();
683
- removeAttached();
684
- };
685
- }
686
-
687
- async function closeBrowserTarget(endpoint, targetId) {
688
- if (!targetId) return false;
689
- try {
690
- const base = endpoint instanceof URL ? endpoint.href : endpoint;
691
- await requestJson(`/json/close/${encodeURIComponent(String(targetId))}`, base, { timeout_ms:2000 });
692
- return true;
693
- } catch {
694
- return false;
695
- }
696
- }
697
-
698
- function productionSessionKey(endpoint, target) {
699
- return `${endpoint.origin}|${String(target.id)}`;
700
- }
701
-
702
- function closeProductionSession(key, record) {
703
- if (!record) return;
704
- if (record.timer) clearTimeout(record.timer);
705
- record.removeGuard();
706
- if (record.ownedTarget && record.targetId) {
707
- record.session.send('Target.closeTarget', { targetId:record.targetId }, '', 2000).catch(() => {});
708
- if (record.endpoint) void closeBrowserTarget(record.endpoint, record.targetId);
709
- }
710
- record.session.send('Fetch.disable').catch(() => {});
711
- record.session.close();
712
- if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
713
- }
714
-
715
- function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
716
- const existing = productionBrowserSessions.get(key);
717
- if (existing) closeProductionSession(key, existing);
718
- const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
719
- productionBrowserSessions.set(key, record);
720
- const schedule = () => {
721
- if (record.timer) clearTimeout(record.timer);
722
- record.timer = setTimeout(() => closeProductionSession(key, record), PRODUCTION_SESSION_IDLE_MS);
723
- record.timer.unref?.();
724
- };
725
- record.schedule = schedule;
726
- schedule();
727
- return record;
728
- }
729
-
730
- async function createGuardedNewTab(url, args) {
731
- await ensureBrowserEndpoint(args.endpoint);
732
- const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
733
- if (!created?.id) throw new Error('Browser did not return a new page target');
734
- const endpoint = endpointUrl(args.endpoint);
735
- let target;
736
- try {
737
- target = { ...created, webSocketDebuggerUrl: validatedTargetWebSocketUrl(created, endpoint) };
738
- } catch (error) {
739
- await closeBrowserTarget(endpoint, created.id);
740
- throw error;
741
- }
742
- let session;
743
- try {
744
- session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
745
- } catch (error) {
746
- await closeBrowserTarget(endpoint, target.id);
747
- throw error;
748
- }
749
- let removeGuard = () => {};
750
- let productionRecord = null;
751
- let productionKey = '';
752
- let operationFailed = false;
753
- try {
754
- removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
755
- if (process.env.NODE_ENV === 'production') {
756
- productionKey = productionSessionKey(endpoint, target);
757
- productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
758
- }
759
- await session.send('Page.enable');
760
- const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
761
- const navigation = await session.send('Page.navigate', { url });
762
- if (loaded) await loaded;
763
- const finalUrl = await evaluate(session, 'location.href');
764
- await assertResolvedPageUrl(finalUrl);
765
- return { target, navigation, finalUrl };
766
- } catch (error) {
767
- operationFailed = true;
768
- if (!productionRecord) await closeBrowserTarget(endpoint, target.id);
769
- throw error;
770
- } finally {
771
- if (productionRecord) {
772
- if (operationFailed) closeProductionSession(productionKey, productionRecord);
773
- else productionRecord.schedule();
774
- }
775
- else {
776
- removeGuard();
777
- await session.send('Fetch.disable').catch(() => {});
778
- session.close();
779
- }
780
- }
781
- }
782
-
783
- async function withTarget(args, callback) {
784
- const target = await chooseTarget(args);
785
- await assertResolvedPageUrl(target.url);
786
- const endpoint = endpointUrl(args.endpoint);
787
- const production = process.env.NODE_ENV === 'production';
788
- const key = production ? productionSessionKey(endpoint, target) : '';
789
- let record = production ? productionBrowserSessions.get(key) : null;
790
- if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
791
- closeProductionSession(key, record);
792
- record = null;
793
- }
794
- let session = record?.session;
795
- let removeGuard = record?.removeGuard || (() => {});
796
- if (!session) {
797
- try {
798
- session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
799
- removeGuard = await installBrowserRequestGuard(session);
800
- if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
801
- } catch (error) {
802
- removeGuard();
803
- if (session) {
804
- await session.send('Fetch.disable').catch(() => {});
805
- session.close();
806
- }
807
- throw error;
808
- }
809
- }
810
- let operationFailed = false;
811
- try {
812
- const currentUrl = await evaluate(session, 'location.href');
813
- await assertResolvedPageUrl(currentUrl);
814
- const result = await callback(session, target);
815
- const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
816
- await assertResolvedPageUrl(finalUrl);
817
- return result;
818
- } catch (error) {
819
- operationFailed = true;
820
- throw error;
821
- } finally {
822
- if (production && record) {
823
- if (operationFailed) closeProductionSession(key, record);
824
- else record.schedule();
825
- }
826
- else {
827
- removeGuard();
828
- await session.send('Fetch.disable').catch(() => {});
829
- session.close();
830
- }
831
- }
832
- }
833
-
834
- async function evaluate(session, expression, awaitPromise = true) {
835
- const result = await session.send('Runtime.evaluate', {
836
- expression,
837
- returnByValue: true,
838
- awaitPromise,
839
- userGesture: true,
840
- });
841
- if (result.exceptionDetails) {
842
- throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript evaluation failed');
843
- }
844
- return result.result?.value;
845
- }
846
-
847
- async function callPageFunction(session, functionDeclaration, args = [], awaitPromise = true) {
848
- const global = await session.send('Runtime.evaluate', {
849
- expression: 'globalThis',
850
- returnByValue: false,
851
- awaitPromise: false,
852
- });
853
- if (global.exceptionDetails || !global.result?.objectId) {
854
- throw new Error(global.exceptionDetails?.exception?.description || global.exceptionDetails?.text || 'Could not access the page execution context');
855
- }
856
- const result = await session.send('Runtime.callFunctionOn', {
857
- objectId: global.result.objectId,
858
- functionDeclaration,
859
- arguments: args.map(value => ({ value })),
860
- returnByValue: true,
861
- awaitPromise,
862
- userGesture: true,
863
- });
864
- if (result.exceptionDetails) {
865
- throw new Error(result.exceptionDetails.exception?.description || result.exceptionDetails.text || 'JavaScript function call failed');
866
- }
867
- return result.result?.value;
868
- }
869
-
870
- export async function browserTabs(args = {}) {
871
- const targets = await listBrowserTargets(args.endpoint);
872
- const pages = targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
873
- id: boundedText(item.id, 256),
874
- title: boundedText(item.title, 1000),
875
- url: boundedText(item.url, 4096),
876
- description: boundedText(item.description, 1000),
877
- }));
878
- const result = jsonResult(pages);
879
- // Keep the typed empty-array contract while providing a native fallback hint.
880
- if (!pages.length) {
881
- result.content = [{ type: 'text', text: 'CDP is connected, but no debuggable Chromium tabs are available. Use ui_snapshot and ui_find to inspect existing browser windows without CDP; only open a new tab when explicitly requested.' }];
882
- }
883
- return result;
884
- }
885
-
886
- async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
887
- const deadline = Date.now() + timeoutMs;
888
- do {
889
- const state = await evaluate(session, '({url:location.href,ready:document.readyState})').catch(() => null);
890
- if (state?.ready === 'complete' && (!expectedUrl || state.url === expectedUrl)) return true;
891
- await new Promise(resolve => setTimeout(resolve, 75));
892
- } while (Date.now() < deadline);
893
- return false;
894
- }
895
-
896
- export async function browserNavigate(args = {}) {
897
- const requestedUrl = optionalString(args.url);
898
- const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
899
- const url = ['url','new_tab'].includes(action) ? await assertResolvedBrowserUrl(requestedUrl, 'url') : null;
900
- if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
901
- if (action === 'new_tab') {
902
- if (process.env.NODE_ENV === 'production') {
903
- const opened = await createGuardedNewTab(url, args);
904
- return jsonResult({
905
- target_id: opened.target.id,
906
- action,
907
- url: opened.finalUrl,
908
- title: opened.target.title || '',
909
- error_text: opened.navigation?.errorText || null,
910
- });
911
- }
912
- await ensureBrowserEndpoint(args.endpoint);
913
- const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
914
- return jsonResult({
915
- target_id:created?.id || null,
916
- action,
917
- url:created?.url ? await assertResolvedPageUrl(created.url) : url,
918
- title:created?.title || '',
919
- });
920
- }
921
- return withTarget(args, async (session, target) => {
922
- await session.send('Page.enable');
923
- const timeoutMs = clamp(args.timeout_ms, 15_000, 500, 120_000);
924
- const loaded = args.wait === false || ['back','forward'].includes(action)
925
- ? null
926
- : session.waitFor('Page.loadEventFired', timeoutMs).catch(() => null);
927
- let frameId = null;
928
- let errorText = null;
929
- let historyUrl = null;
930
- if (action === 'url') {
931
- const result = await session.send('Page.navigate', { url });
932
- frameId = result.frameId || null;
933
- errorText = result.errorText || null;
934
- } else if (action === 'reload') {
935
- await session.send('Page.reload', { ignoreCache: Boolean(args.ignore_cache) });
936
- } else {
937
- const history = await session.send('Page.getNavigationHistory');
938
- const current = Number(history.currentIndex || 0);
939
- const nextIndex = action === 'back' ? current - 1 : current + 1;
940
- const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
941
- if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
942
- historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
943
- await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
944
- }
945
- if (loaded) await loaded;
946
- if (args.wait !== false && ['back','forward'].includes(action)) {
947
- await waitForHistoryReady(session, historyUrl, timeoutMs);
948
- }
949
- const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
950
- if (currentUrl) await assertResolvedPageUrl(currentUrl);
951
- return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
952
- });
953
- }
954
-
955
- export async function browserSnapshot(args) {
956
- return withTarget(args, async (session, target) => {
957
- await session.send('Accessibility.enable');
958
- const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
959
- const limit = clamp(args.max_nodes, 1500, 1, 10_000);
960
- const simplified = nodes.slice(0, limit).map(node => ({
961
- node_id: boundedText(node.nodeId, 256),
962
- ignored: Boolean(node.ignored),
963
- role: boundedText(node.role?.value, 256),
964
- name: boundedText(node.name?.value, 1000),
965
- value: boundedText(node.value?.value, 1000),
966
- description: boundedText(node.description?.value, 1000),
967
- child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
968
- backend_dom_node_id: node.backendDOMNodeId || null,
969
- }));
970
- const payload = {
971
- target_id: target.id,
972
- title: target.title,
973
- url: target.url,
974
- count: simplified.length,
975
- truncated: nodes.length > limit,
976
- nodes: simplified,
977
- };
978
- if (!args.include_screenshot) return jsonResult(payload);
979
-
980
- await session.send('Page.enable');
981
- const selector = optionalString(args.selector);
982
- let restoreScroll = null;
983
- if (selector) {
984
- const bounds = await evaluate(session, `new Promise((resolve,reject) => {
985
- const el=document.querySelector(${JSON.stringify(selector)});
986
- if(!el){reject(new Error('Screenshot element not found'));return;}
987
- const originalScrollX=scrollX, originalScrollY=scrollY;
988
- const initial=el.getBoundingClientRect();
989
- const desiredTop=Math.max(0, scrollY + initial.top - Math.max(0,(innerHeight-initial.height)/2));
990
- window.scrollTo({top:desiredTop,left:scrollX,behavior:'instant'});
991
- requestAnimationFrame(() => requestAnimationFrame(() => {
992
- const r=el.getBoundingClientRect();
993
- resolve({
994
- x:r.x,
995
- y:r.y,
996
- width:r.width,
997
- height:r.height,
998
- viewport_width:innerWidth,
999
- viewport_height:innerHeight,
1000
- scroll_x:scrollX,
1001
- scroll_y:scrollY,
1002
- original_scroll_x:originalScrollX,
1003
- original_scroll_y:originalScrollY,
1004
- fully_visible:r.x>=0 && r.y>=0 && r.right<=innerWidth && r.bottom<=innerHeight
1005
- });
1006
- }));
1007
- })`);
1008
- if (!bounds || !Number.isFinite(Number(bounds.width)) || !Number.isFinite(Number(bounds.height)) || bounds.width <= 0 || bounds.height <= 0) {
1009
- throw new Error('Screenshot element has invalid bounds');
1010
- }
1011
- restoreScroll = { x:Number(bounds.original_scroll_x || 0), y:Number(bounds.original_scroll_y || 0) };
1012
- const { original_scroll_x: _originalX, original_scroll_y: _originalY, ...reportedBounds } = bounds;
1013
- payload.screenshot_target = { selector, ...reportedBounds };
1014
- }
1015
- payload.screenshot = {
1016
- scope: 'viewport',
1017
- width: await evaluate(session, 'innerWidth').catch(() => null),
1018
- height: await evaluate(session, 'innerHeight').catch(() => null),
1019
- };
1020
- const result = jsonResult(payload);
1021
- let captured;
1022
- try {
1023
- captured = await session.send('Page.captureScreenshot', {
1024
- format: 'png',
1025
- fromSurface: true,
1026
- captureBeyondViewport: false,
1027
- });
1028
- } finally {
1029
- if (restoreScroll) {
1030
- await evaluate(session, `window.scrollTo({left:${restoreScroll.x},top:${restoreScroll.y},behavior:'instant'})`).catch(() => {});
1031
- }
1032
- }
1033
- const data = String(captured?.data || '');
1034
- if (!data) throw new Error('CDP returned no screenshot data');
1035
- if (Buffer.byteLength(data, 'base64') > 8 * 1024 * 1024) throw new Error('Browser screenshot exceeds the 8 MiB inline image limit');
1036
- result.content.push({ type:'image', data, mimeType:'image/png' });
1037
- return result;
1038
- });
1039
- }
1040
-
1041
- export function findExpression(args) {
1042
- const selector = optionalString(args.selector);
1043
- const needle = optionalString(args.text);
1044
- const role = optionalString(args.role);
1045
- const limit = clamp(args.limit, 20, 1, 200);
1046
- return `(() => {
1047
- const selector = ${JSON.stringify(selector)};
1048
- const needle = ${JSON.stringify(needle)};
1049
- const role = ${JSON.stringify(role)};
1050
- const source = selector ? Array.from(document.querySelectorAll(selector)) : Array.from(document.querySelectorAll('*'));
1051
- function cssPath(el) {
1052
- if (el.id) return '#' + CSS.escape(el.id);
1053
- const parts = [];
1054
- while (el && el.nodeType === 1 && el !== document.documentElement) {
1055
- let part = el.tagName.toLowerCase();
1056
- if (el.parentElement) {
1057
- const same = Array.from(el.parentElement.children).filter(x => x.tagName === el.tagName);
1058
- if (same.length > 1) part += ':nth-of-type(' + (same.indexOf(el) + 1) + ')';
1059
- }
1060
- parts.unshift(part); el = el.parentElement;
1061
- }
1062
- return parts.join(' > ');
1063
- }
1064
- function semanticRole(el) {
1065
- const explicit = (el.getAttribute('role') || '').trim().toLowerCase();
1066
- if (explicit) return explicit;
1067
- const tag = el.tagName.toLowerCase();
1068
- if (/^h[1-6]$/.test(tag)) return 'heading';
1069
- if (tag === 'button') return 'button';
1070
- if (tag === 'a' && el.hasAttribute('href')) return 'link';
1071
- if (tag === 'textarea') return 'textbox';
1072
- if (tag === 'select') return el.multiple || el.size > 1 ? 'listbox' : 'combobox';
1073
- if (tag === 'option') return 'option';
1074
- if (tag === 'img') return 'img';
1075
- if (tag === 'progress') return 'progressbar';
1076
- if (tag === 'meter') return 'meter';
1077
- if (tag === 'summary') return 'button';
1078
- if (tag === 'input') {
1079
- const type = String(el.type || 'text').toLowerCase();
1080
- if (['button','submit','reset','image'].includes(type)) return 'button';
1081
- if (type === 'checkbox') return 'checkbox';
1082
- if (type === 'radio') return 'radio';
1083
- if (type === 'range') return 'slider';
1084
- if (type === 'number') return 'spinbutton';
1085
- if (type === 'search') return 'searchbox';
1086
- if (!['hidden','file','color'].includes(type)) return 'textbox';
1087
- }
1088
- return '';
1089
- }
1090
- function visibleText(el) {
1091
- return (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
1092
- }
1093
- function textQuality(value) {
1094
- if (!needle) return 0;
1095
- const hay = value.toLowerCase();
1096
- const wanted = needle.toLowerCase();
1097
- if (hay === wanted) return 0;
1098
- if (hay.startsWith(wanted)) return 1;
1099
- return 2;
1100
- }
1101
- function depthOf(el) {
1102
- let depth = 0;
1103
- for (let current = el.parentElement; current; current = current.parentElement) depth += 1;
1104
- return depth;
1105
- }
1106
- const candidates = source.filter(el => {
1107
- const style = getComputedStyle(el);
1108
- const visible = style.visibility !== 'hidden' && style.display !== 'none' && el.getClientRects().length > 0;
1109
- const hay = visibleText(el);
1110
- const r = semanticRole(el);
1111
- return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r === role.toLowerCase());
1112
- }).map((el, index) => ({
1113
- el,
1114
- index,
1115
- text: visibleText(el),
1116
- role: semanticRole(el),
1117
- depth: depthOf(el),
1118
- }));
1119
- let selected = candidates;
1120
- if (needle && !selector) {
1121
- selected = [...candidates].sort((a, b) =>
1122
- textQuality(a.text) - textQuality(b.text)
1123
- || Number(!a.role) - Number(!b.role)
1124
- || a.text.length - b.text.length
1125
- || b.depth - a.depth
1126
- || a.index - b.index
1127
- );
1128
- const deduped = [];
1129
- for (const candidate of selected) {
1130
- if (deduped.some(existing => existing.el.contains(candidate.el) || candidate.el.contains(existing.el))) continue;
1131
- deduped.push(candidate);
1132
- if (deduped.length >= ${limit}) break;
1133
- }
1134
- selected = deduped;
1135
- } else {
1136
- selected = selected.slice(0, ${limit});
1137
- }
1138
- return selected.map(({ el }) => {
1139
- const b = el.getBoundingClientRect();
1140
- return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: visibleText(el).slice(0, 500), value: 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
1141
- });
1142
- })()`;
1143
- }
1144
-
1145
- export async function browserFind(args) {
1146
- if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
1147
- return withTarget(args, async (session, target) => {
1148
- const matches = await evaluate(session, findExpression(args));
1149
- return jsonResult({ target_id: target.id, count: Array.isArray(matches) ? matches.length : 0, matches: Array.isArray(matches) ? matches : [] });
1150
- });
1151
- }
1152
-
1153
- const PAGE_SCROLL_ACTION = `function(selector, needle, deltaX, deltaY) {
1154
- let target = selector ? document.querySelector(selector) : null;
1155
- if (!target && needle) {
1156
- const lower = String(needle).toLowerCase();
1157
- target = Array.from(document.querySelectorAll('*')).find(node =>
1158
- String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
1159
- ) || null;
1160
- }
1161
- if ((selector || needle) && !target) throw new Error('Element not found');
1162
-
1163
- let scroller = document.scrollingElement || document.documentElement;
1164
- if (target) {
1165
- let current = target;
1166
- while (current && current !== document.documentElement) {
1167
- const style = getComputedStyle(current);
1168
- const scrollX = /(auto|scroll|overlay)/.test(String(style.overflowX || '')) && current.scrollWidth > current.clientWidth;
1169
- const scrollY = /(auto|scroll|overlay)/.test(String(style.overflowY || '')) && current.scrollHeight > current.clientHeight;
1170
- if (scrollX || scrollY) {
1171
- scroller = current;
1172
- break;
1173
- }
1174
- current = current.parentElement;
1175
- }
1176
- }
1177
-
1178
- const beforeX = Number(scroller.scrollLeft || 0);
1179
- const beforeY = Number(scroller.scrollTop || 0);
1180
- scroller.scrollLeft = beforeX + Number(deltaX || 0);
1181
- scroller.scrollTop = beforeY + Number(deltaY || 0);
1182
- return {
1183
- target: target ? target.tagName.toLowerCase() : 'page',
1184
- scroller: scroller === document.scrollingElement || scroller === document.documentElement ? 'page' : scroller.tagName.toLowerCase(),
1185
- before_x: beforeX,
1186
- before_y: beforeY,
1187
- after_x: Number(scroller.scrollLeft || 0),
1188
- after_y: Number(scroller.scrollTop || 0),
1189
- delta_x: Number(deltaX || 0),
1190
- delta_y: Number(deltaY || 0),
1191
- };
1192
- }`;
1193
-
1194
- const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
1195
- let el = selector ? document.querySelector(selector) : null;
1196
- if (!el && needle) {
1197
- const lower = String(needle).toLowerCase();
1198
- el = Array.from(document.querySelectorAll('*')).find(node =>
1199
- String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
1200
- ) || null;
1201
- }
1202
- if (!el) throw new Error('Element not found');
1203
- if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
1204
- el.scrollIntoView({block:'center', inline:'center'});
1205
- }
1206
- if (action === 'focus' || action === 'type') el.focus();
1207
- if (action === 'select') {
1208
- el.value = value;
1209
- el.dispatchEvent(new Event('input', {bubbles:true}));
1210
- el.dispatchEvent(new Event('change', {bubbles:true}));
1211
- } else if (action === 'set_value') {
1212
- const proto = Object.getPrototypeOf(el);
1213
- const own = Object.getOwnPropertyDescriptor(proto, 'value');
1214
- const parent = Object.getPrototypeOf(proto);
1215
- const inherited = parent ? Object.getOwnPropertyDescriptor(parent, 'value') : null;
1216
- const setter = own?.set || inherited?.set;
1217
- if (setter) setter.call(el, value); else el.value = value;
1218
- el.dispatchEvent(new Event('input', {bubbles:true}));
1219
- el.dispatchEvent(new Event('change', {bubbles:true}));
1220
- }
1221
- const b = el.getBoundingClientRect();
1222
- if (action === 'click' && (!b.width || !b.height)) throw new Error('Element has no clickable bounds');
1223
- return {
1224
- tag: el.tagName.toLowerCase(),
1225
- value: 'value' in el ? String(el.value) : '',
1226
- x: b.x, y: b.y, width: b.width, height: b.height,
1227
- };
1228
- }`;
1229
-
1230
- export function browserActionLocatorText(args = {}, action = '') {
1231
- const explicit = optionalString(args.browser_text || args.match_text);
1232
- if (explicit) return explicit;
1233
- if (action === 'type') {
1234
- // Legacy type calls used text as the locator and value/text_value as the payload.
1235
- if (!optionalString(args.selector) && (args.value != null || args.text_value != null)) return optionalString(args.text);
1236
- return null;
1237
- }
1238
- return optionalString(args.text);
1239
- }
1240
-
1241
- export function browserActionInputValue(args = {}, action = '') {
1242
- if (action === 'type') {
1243
- if (!optionalString(args.selector) && !optionalString(args.browser_text || args.match_text) && (args.value != null || args.text_value != null)) {
1244
- return String(args.text_value ?? args.value ?? '');
1245
- }
1246
- return String(args.text ?? args.text_value ?? args.value ?? '');
1247
- }
1248
- return String(args.value ?? args.text_value ?? args.text ?? '');
1249
- }
1250
-
1251
- export function browserScrollDeltas(args = {}) {
1252
- const direction = optionalString(args.direction);
1253
- const hasDelta = [args.delta_x,args.delta_y,args.delta].some(value => value != null && Number.isFinite(Number(value)));
1254
- if (!direction && !hasDelta) return null;
1255
- const times = clamp(args.wheel_times, 1, 1, 50);
1256
- let deltaX = Number(args.delta_x || 0);
1257
- let deltaY = Number(args.delta_y ?? args.delta ?? 0);
1258
- if (direction) {
1259
- const normalized = requireEnum(direction, 'direction', ['up','down','left','right']);
1260
- if (normalized === 'up') deltaY = -120 * times;
1261
- if (normalized === 'down') deltaY = 120 * times;
1262
- if (normalized === 'left') deltaX = -120 * times;
1263
- if (normalized === 'right') deltaX = 120 * times;
1264
- }
1265
- return { delta_x:deltaX, delta_y:deltaY };
1266
- }
1267
-
1268
- export async function browserAction(args) {
1269
- const action = requireEnum(args.action, 'action', ['click', 'focus', 'type', 'set_value', 'select', 'scroll', 'scroll_into_view', 'upload', 'press', 'set_viewport']);
1270
- return withTarget(args, async (session, target) => {
1271
- if (action === 'set_viewport') {
1272
- const width = clamp(args.width, 1280, 200, 8192);
1273
- const height = clamp(args.height, 720, 200, 8192);
1274
- const deviceScaleFactor = Math.max(0.1, Math.min(8, Number(args.device_scale_factor) || 1));
1275
- await session.send('Emulation.setDeviceMetricsOverride', {
1276
- width,
1277
- height,
1278
- deviceScaleFactor,
1279
- mobile: Boolean(args.mobile),
1280
- screenWidth: width,
1281
- screenHeight: height,
1282
- });
1283
- return jsonResult({ target_id:target.id, action, width, height, device_scale_factor:deviceScaleFactor, mobile:Boolean(args.mobile) });
1284
- }
1285
- if (action === 'upload') {
1286
- const selector = optionalString(args.selector);
1287
- if (!selector) throw new Error('selector is required for upload');
1288
- const requested = (Array.isArray(args.paths) ? args.paths : [args.path]).filter(Boolean);
1289
- if (!requested.length) throw new Error('path or paths is required for upload');
1290
- const files = [];
1291
- for (const value of requested) files.push(await resolveSafePath(value, 'path'));
1292
- await session.send('DOM.enable');
1293
- const doc = await session.send('DOM.getDocument', { depth: 1 });
1294
- const found = await session.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
1295
- if (!found.nodeId) throw new Error(`No element matches selector ${selector}`);
1296
- await session.send('DOM.setFileInputFiles', { nodeId: found.nodeId, files });
1297
- return jsonResult({ target_id: target.id, action, selector, files: files.length });
1298
- }
1299
- if (action === 'press') {
1300
- const key = optionalString(args.key);
1301
- if (!key) throw new Error('key is required for press');
1302
- await session.send('Input.dispatchKeyEvent', { type: 'keyDown', key });
1303
- await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key });
1304
- return jsonResult({ target_id: target.id, action, key });
1305
- }
1306
- const selector = optionalString(args.selector);
1307
- const needle = browserActionLocatorText(args, action);
1308
- const value = browserActionInputValue(args, action);
1309
- const option = String(args.option ?? value);
1310
-
1311
- if (action === 'scroll') {
1312
- const deltas = browserScrollDeltas(args);
1313
- if (!deltas) throw new Error('browser_action scroll requires direction or a delta');
1314
- return jsonResult({
1315
- target_id: target.id,
1316
- action,
1317
- result: await callPageFunction(session, PAGE_SCROLL_ACTION, [selector, needle, deltas.delta_x, deltas.delta_y]),
1318
- });
1319
- }
1320
-
1321
- if (!selector && !needle) throw new Error('selector or browser_text/text locator is required');
1322
-
1323
- if (action === 'click') {
1324
- const rect = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'click', '']);
1325
- const x = Number(rect.x) + Number(rect.width) / 2;
1326
- const y = Number(rect.y) + Number(rect.height) / 2;
1327
- await session.send('Input.dispatchMouseEvent', { type:'mouseMoved', x, y, button:'none' });
1328
- await session.send('Input.dispatchMouseEvent', { type:'mousePressed', x, y, button:'left', clickCount:1 });
1329
- await session.send('Input.dispatchMouseEvent', { type:'mouseReleased', x, y, button:'left', clickCount:1 });
1330
- return jsonResult({ target_id:target.id, action, result:{ ...rect, x, y } });
1331
- }
1332
-
1333
- if (action === 'type') {
1334
- const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'type', '']);
1335
- if (args.clear === true) await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'set_value', '']);
1336
- await session.send('Input.insertText', { text:value });
1337
- const current = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'read_value', '']);
1338
- return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current.value } });
1339
- }
1340
-
1341
- const pageValue = action === 'select' ? option : value;
1342
- return jsonResult({
1343
- target_id: target.id,
1344
- action,
1345
- result: await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, action, pageValue]),
1346
- });
1347
- });
1348
- }
1349
-
1350
- export async function browserWait(args = {}) {
1351
- const condition = requireEnum(args.condition || 'selector', 'condition', ['selector', 'text', 'url_contains', 'expression', 'load', 'navigation', 'network_idle']);
1352
- const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
1353
- const pollMs = clamp(args.poll_ms, 200, 50, 5000);
1354
- const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
1355
- const wanted = optionalString(args.selector || args.text || args.value || args.expression);
1356
- if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
1357
- if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
1358
- return withTarget(args, async (session, target) => {
1359
- const started = Date.now();
1360
- const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
1361
- const inflight = new Set();
1362
- let lastResourceCount = -1;
1363
- let stableSince = Date.now();
1364
- const unsubscribers = [];
1365
- if (condition === 'network_idle') {
1366
- await session.send('Network.enable');
1367
- const markBusy = params => {
1368
- if (params?.requestId) inflight.add(params.requestId);
1369
- stableSince = Date.now();
1370
- };
1371
- const markDone = params => {
1372
- if (params?.requestId) inflight.delete(params.requestId);
1373
- stableSince = Date.now();
1374
- };
1375
- unsubscribers.push(
1376
- session.observe('Network.requestWillBeSent', markBusy),
1377
- session.observe('Network.loadingFinished', markDone),
1378
- session.observe('Network.loadingFailed', markDone),
1379
- );
1380
- }
1381
- try {
1382
- while (Date.now() - started < timeoutMs) {
1383
- let matched = false;
1384
- if (condition === 'network_idle') {
1385
- const state = await evaluate(session, `({ready:document.readyState,resources:performance.getEntriesByType('resource').length})`).catch(() => null);
1386
- if (state) {
1387
- if (state.resources !== lastResourceCount) {
1388
- lastResourceCount = state.resources;
1389
- stableSince = Date.now();
1390
- }
1391
- matched = state.ready === 'complete' && inflight.size === 0 && Date.now() - stableSince >= idleMs;
1392
- }
1393
- } else {
1394
- const expression = condition === 'selector' ? `Boolean(document.querySelector(${JSON.stringify(wanted)}))`
1395
- : condition === 'text' ? `(document.body?.innerText||'').toLowerCase().includes(${JSON.stringify(wanted?.toLowerCase())})`
1396
- : condition === 'url_contains' ? `location.href.includes(${JSON.stringify(wanted)})`
1397
- : condition === 'load' ? `document.readyState === 'complete'`
1398
- : condition === 'navigation' ? `location.href !== ${JSON.stringify(initialUrl)}`
1399
- : `Boolean(${wanted})`;
1400
- matched = Boolean(await evaluate(session, expression));
1401
- }
1402
- if (matched) return jsonResult({
1403
- target_id: target.id,
1404
- condition,
1405
- matched: true,
1406
- elapsed_ms: Date.now() - started,
1407
- ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
1408
- url: await evaluate(session, 'location.href').catch(() => target.url || null),
1409
- });
1410
- await new Promise(resolve => setTimeout(resolve, pollMs));
1411
- }
1412
- return jsonResult({
1413
- target_id: target.id,
1414
- condition,
1415
- matched: false,
1416
- elapsed_ms: Date.now() - started,
1417
- ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
1418
- url: await evaluate(session, 'location.href').catch(() => target.url || null),
1419
- });
1420
- } finally {
1421
- for (const unsubscribe of unsubscribers) unsubscribe();
1422
- }
1423
- });
1424
- }
1425
-
1426
- export async function browserEvaluate(args) {
1427
- assertBrowserRemoteEnabled();
1428
- const expression = await assertSafeEvaluationExpression(args.expression);
1429
- return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
1430
- }
1431
-
1432
- export const browserHandlers = {
1433
- browser_tabs: browserTabs,
1434
- browser_navigate: browserNavigate,
1435
- browser_snapshot: browserSnapshot,
1436
- browser_find: browserFind,
1437
- browser_action: browserAction,
1438
- browser_wait: browserWait,
1439
- browser_evaluate: browserEvaluate,
1440
- };
1
+ // ReMCP browser adapter: stable exports, split by endpoint policy, session, read and action.
2
+ export { browserAutoLaunchAvailable, browserRemoteEnabled, shouldRestoreBrowserSession, browserAutoLaunchArgs, browserControlAvailable, browserCapabilityAvailable, filterBrowserTargets, listBrowserTargets } from './browser-endpoint.mjs';
3
+ export { browserTabs, browserNavigate, browserSnapshot, findExpression } from './browser-read.mjs';
4
+ export { browserFind, browserActionLocatorText, browserActionInputValue, browserScrollDeltas, browserAction, browserWait, browserEvaluate, browserHandlers } from './browser-actions.mjs';