@remcp/runtime 0.2.154 → 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.
@@ -0,0 +1,469 @@
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
+
12
+
13
+ export const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
14
+ export const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
15
+ export const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
16
+ export const MAX_EVALUATION_BYTES = 256 * 1024;
17
+ export const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
18
+ export const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
19
+ export const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
20
+ export const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
21
+ export const productionBrowserSessions = new Map();
22
+
23
+ export let browserLaunchPromise = null;
24
+
25
+ export function browserExecutable() {
26
+ const configured = optionalString(process.env.REMCP_BROWSER_BINARY);
27
+ const candidates = configured ? [configured] : process.platform === 'darwin'
28
+ ? [
29
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
30
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
31
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
32
+ ]
33
+ : process.platform === 'win32'
34
+ ? [
35
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
36
+ path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
37
+ path.join(process.env.LOCALAPPDATA || '', 'Google', 'Chrome', 'Application', 'chrome.exe'),
38
+ path.join(process.env.PROGRAMFILES || 'C:\\Program Files', 'Microsoft', 'Edge', 'Application', 'msedge.exe'),
39
+ ]
40
+ : ['/opt/google/chrome/chrome','/usr/bin/google-chrome','/usr/bin/google-chrome-stable','chromium','chromium-browser','microsoft-edge','microsoft-edge-stable'];
41
+ return candidates.find(candidate => path.isAbsolute(candidate) ? existsSync(candidate) : commandExists(candidate)) || null;
42
+ }
43
+
44
+ export function browserAutoLaunchAvailable() {
45
+ return Boolean(browserExecutable());
46
+ }
47
+
48
+ export function browserRemoteEnabled() {
49
+ const configured = String(process.env.REMCP_BROWSER_REMOTE_ENABLED || '').trim().toLowerCase();
50
+ if (configured) return ['1', 'true', 'yes', 'on'].includes(configured);
51
+ return ['development', 'test'].includes(String(process.env.NODE_ENV || '').trim().toLowerCase());
52
+ }
53
+
54
+ export function assertBrowserRemoteEnabled() {
55
+ if (!browserRemoteEnabled()) throw new Error('Browser control is disabled for remote runtime; set REMCP_BROWSER_REMOTE_ENABLED=1 only on a trusted local runtime');
56
+ }
57
+
58
+ export function browserDataDir() {
59
+ const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
60
+ if (configured) return configured;
61
+ if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'ReMCP', 'browser-cdp');
62
+ if (process.platform === 'win32') return path.join(process.env.LOCALAPPDATA || os.homedir(), 'ReMCP', 'browser-cdp');
63
+ return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
64
+ }
65
+
66
+ // Browser sessions belong to the local user, not to the lifetime of the ReMCP
67
+ // agent. The agent can be restarted by an npm update while Chrome is open.
68
+ // If Chrome was interrupted and already persisted tabs, ask Chromium itself to
69
+ // restore the session. Never parse, persist, or relay the sensitive tab URLs.
70
+ // A clean user-initiated quit must not resurrect a deliberately closed session.
71
+ export function shouldRestoreBrowserSession(profile) {
72
+ let prefsFd;
73
+ let sessionFd;
74
+ try {
75
+ const defaultProfile = path.join(profile, 'Default');
76
+ // Open first, then validate the file descriptor: checking the path with
77
+ // lstat before reading it creates a symlink-swap TOCTOU vulnerability.
78
+ prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
79
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
80
+ const prefsStat = fstatSync(prefsFd);
81
+ if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
82
+ const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
83
+ if (prefs?.profile?.exit_type !== 'Crashed') return false;
84
+
85
+ const sessionDir = path.join(defaultProfile, 'Sessions');
86
+ let readableDir = sessionDir;
87
+ if (process.platform !== 'win32') {
88
+ // Use the opened directory even if another process renames the path.
89
+ // O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
90
+ sessionFd = openSync(sessionDir,
91
+ fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
92
+ if (!fstatSync(sessionFd).isDirectory()) return false;
93
+ readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
94
+ }
95
+ return readdirSync(readableDir).some(name => {
96
+ if (!/^Tabs_\d+$/.test(name)) return false;
97
+ let tabFd;
98
+ try {
99
+ tabFd = openSync(path.join(readableDir, name),
100
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
101
+ const stat = fstatSync(tabFd);
102
+ return stat.isFile() && stat.size > 0;
103
+ } catch { return false; }
104
+ finally { if (tabFd !== undefined) closeSync(tabFd); }
105
+ });
106
+ } catch {
107
+ // A new, malformed or inaccessible profile must start fresh, not fail CDP.
108
+ return false;
109
+ } finally {
110
+ if (sessionFd !== undefined) closeSync(sessionFd);
111
+ if (prefsFd !== undefined) closeSync(prefsFd);
112
+ }
113
+ }
114
+
115
+ export function browserAutoLaunchArgs(profile, {
116
+ platform = process.platform,
117
+ display = process.env.DISPLAY,
118
+ waylandDisplay = process.env.WAYLAND_DISPLAY,
119
+ } = {}) {
120
+ const restore = shouldRestoreBrowserSession(profile);
121
+ const args = [
122
+ '--remote-debugging-address=127.0.0.1',
123
+ '--remote-debugging-port=9222',
124
+ `--user-data-dir=${profile}`,
125
+ '--no-first-run',
126
+ '--no-default-browser-check',
127
+ // --restore-last-session only for an interrupted, persisted local profile.
128
+ // An explicit about:blank startup URL would suppress the normal restore.
129
+ ...(restore ? ['--restore-last-session'] : ['about:blank']),
130
+ ];
131
+ if (platform === 'linux' && !display && !waylandDisplay) args.unshift('--headless=new');
132
+ return args;
133
+ }
134
+
135
+ export async function waitForBrowserEndpoint(timeoutMs = 5000) {
136
+ const deadline = Date.now() + timeoutMs;
137
+ do {
138
+ if (await browserCapabilityAvailable(undefined, 250)) return true;
139
+ await new Promise(resolve => setTimeout(resolve, 100));
140
+ } while (Date.now() < deadline);
141
+ return false;
142
+ }
143
+
144
+ export async function ensureBrowserEndpoint(endpoint) {
145
+ assertBrowserRemoteEnabled();
146
+ if (endpoint || process.env.REMCP_CDP_URL) return;
147
+ if (await browserCapabilityAvailable(undefined, 250)) return;
148
+ if (!browserLaunchPromise) {
149
+ browserLaunchPromise = (async () => {
150
+ const executable = browserExecutable();
151
+ if (!executable) throw new Error('Browser control is unavailable: no supported Chrome, Edge, or Chromium executable was found');
152
+ const profile = browserDataDir();
153
+ mkdirSync(profile, { recursive:true, mode:0o700 });
154
+ // Chrome rotates its Sessions/ files on startup. Protect existing local
155
+ // evidence before a restart, including profiles with only Tabs_* left.
156
+ preserveInterruptedBrowserSession(profile);
157
+ spawnDetached(executable, browserAutoLaunchArgs(profile));
158
+ if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
159
+ })().finally(() => { browserLaunchPromise = null; });
160
+ }
161
+ await browserLaunchPromise;
162
+ }
163
+
164
+ export async function browserControlAvailable(endpoint, timeoutMs = 500) {
165
+ if (!browserRemoteEnabled()) return false;
166
+ if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
167
+ if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
168
+ if (endpoint || process.env.REMCP_CDP_URL) return false;
169
+ return browserAutoLaunchAvailable();
170
+ }
171
+
172
+ export function boundedText(value, max = 1000) {
173
+ const text = String(value ?? '');
174
+ return text.length <= max ? text : `${text.slice(0, max)}…`;
175
+ }
176
+
177
+ export function normalizedHost(hostname) {
178
+ return String(hostname || '').replace(/^\[|\]$/g, '').replace(/\.$/, '').toLowerCase();
179
+ }
180
+
181
+ export function isLoopbackHost(hostname) {
182
+ const host = normalizedHost(hostname);
183
+ return ['localhost', '127.0.0.1', '::1'].includes(host);
184
+ }
185
+
186
+ export function localBrowserNavigationAllowed() {
187
+ if (process.env.NODE_ENV === 'production') return false;
188
+ return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
189
+ .some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
190
+ }
191
+
192
+ export function isPrivateIpv4(host) {
193
+ if (isIP(host) !== 4) return false;
194
+ const parts = host.split('.').map(Number);
195
+ const [a, b, c] = parts;
196
+ return a === 0
197
+ || a === 10
198
+ || a === 127
199
+ || (a === 100 && b >= 64 && b <= 127)
200
+ || (a === 169 && b === 254)
201
+ || (a === 172 && b >= 16 && b <= 31)
202
+ || (a === 192 && b === 0 && c === 0)
203
+ || (a === 192 && b === 0 && c === 2)
204
+ || (a === 192 && b === 168)
205
+ || (a === 198 && (b === 18 || b === 19))
206
+ || (a === 198 && b === 51 && c === 100)
207
+ || (a === 203 && b === 0 && c === 113)
208
+ || a >= 224;
209
+ }
210
+
211
+ export function isPrivateIpv6(host) {
212
+ if (isIP(host) !== 6) return false;
213
+ if (host === '::' || host === '::1') return true;
214
+ if (/^f[cd]/.test(host) || /^fe[89ab]/.test(host) || host.startsWith('ff')) return true;
215
+ const mapped = host.match(/^::(?:ffff:)?([0-9a-f]{1,4}):([0-9a-f]{1,4})$/);
216
+ if (mapped) {
217
+ const high = Number.parseInt(mapped[1], 16);
218
+ const low = Number.parseInt(mapped[2], 16);
219
+ return isPrivateIpv4([high >> 8, high & 255, low >> 8, low & 255].join('.'));
220
+ }
221
+ return false;
222
+ }
223
+
224
+ export function isPrivateBrowserHost(hostname) {
225
+ const host = normalizedHost(hostname);
226
+ if (!host) return true;
227
+ if (isPrivateIpv4(host) || isPrivateIpv6(host)) return true;
228
+ return host === 'localhost'
229
+ || host.endsWith('.localhost')
230
+ || host.endsWith('.local')
231
+ || host.endsWith('.internal')
232
+ || host.endsWith('.home.arpa')
233
+ || host.endsWith('.lan')
234
+ || host === 'localhost.localdomain'
235
+ || host === 'ip6-localhost'
236
+ || host === 'ip6-loopback';
237
+ }
238
+
239
+ export function endpointUrl(value) {
240
+ let url;
241
+ try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
242
+ catch { throw new Error('endpoint must be a valid local http(s) URL'); }
243
+ if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
244
+ if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
245
+ return url;
246
+ }
247
+
248
+ export function endpointAuthority(url) {
249
+ const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
250
+ return `${normalizedHost(url.hostname)}:${port}`;
251
+ }
252
+
253
+ export function safeBrowserUrl(value, field = 'url') {
254
+ let url;
255
+ try { url = new URL(String(value || '')); }
256
+ catch { throw new Error(`${field} must be a valid absolute URL`); }
257
+ if (!BROWSER_PROTOCOLS.has(url.protocol)) throw new Error(`${field} must use http or https`);
258
+ if (url.username || url.password) throw new Error(`${field} must not embed credentials`);
259
+ if (!localBrowserNavigationAllowed() && isPrivateBrowserHost(url.hostname)) {
260
+ throw new Error(`${field} must not target loopback, private, or link-local addresses`);
261
+ }
262
+ return url.href;
263
+ }
264
+
265
+ export function safePageUrl(value, field = 'browser page URL') {
266
+ if (String(value || '') === 'about:blank') return value;
267
+ return safeBrowserUrl(value, field);
268
+ }
269
+
270
+ export function configuredBrowserHosts() {
271
+ return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
272
+ .split(/[,\s]+/)
273
+ .map(normalizedHost)
274
+ .filter(Boolean);
275
+ }
276
+
277
+ export function browserHostAllowlisted(host) {
278
+ return configuredBrowserHosts().some(pattern => pattern === host
279
+ || (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
280
+ }
281
+
282
+ export function browserDnsRebindingExplicitlyAllowed() {
283
+ return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
284
+ }
285
+
286
+ export function productionBrowserPolicyReady() {
287
+ const hosts = configuredBrowserHosts();
288
+ return (hosts.length > 0 && hosts.some(isIP))
289
+ || (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
290
+ }
291
+
292
+ export async function assertResolvedBrowserUrl(value, field = 'browser URL') {
293
+ const safe = safeBrowserUrl(value, field);
294
+ if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
295
+ const host = normalizedHost(new URL(safe).hostname);
296
+ if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
297
+ throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
298
+ }
299
+ if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
300
+ if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
301
+ throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
302
+ }
303
+ let addresses;
304
+ try { addresses = await lookup(host, { all:true, verbatim:true }); }
305
+ catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
306
+ if (addresses.some(address => isPrivateBrowserHost(address.address))) {
307
+ throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
308
+ }
309
+ return safe;
310
+ }
311
+
312
+ export async function assertResolvedPageUrl(value, field = 'browser page URL') {
313
+ if (String(value || '') === 'about:blank') return value;
314
+ return assertResolvedBrowserUrl(value, field);
315
+ }
316
+
317
+ export function expressionHasPrivateDestination(expression) {
318
+ const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
319
+ return urls.some(value => {
320
+ try { return isPrivateBrowserHost(new URL(value).hostname); }
321
+ catch { return false; }
322
+ });
323
+ }
324
+
325
+ export function safeEvaluationExpression(value) {
326
+ const expression = String(value || '');
327
+ if (!expression.trim()) throw new Error('expression is required');
328
+ if (expression.includes('\0') || Buffer.byteLength(expression, 'utf8') > MAX_EVALUATION_BYTES) {
329
+ throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
330
+ }
331
+ if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
332
+ if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
333
+ throw new Error('expression contains a blocked private browser destination or network API');
334
+ }
335
+ return expression;
336
+ }
337
+
338
+ export async function assertSafeEvaluationExpression(value) {
339
+ const expression = safeEvaluationExpression(value);
340
+ const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
341
+ await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
342
+ return expression;
343
+ }
344
+
345
+ export async function requestJson(pathname, endpoint, options = {}) {
346
+ const base = endpointUrl(endpoint);
347
+ const url = new URL(pathname, base);
348
+ const controller = new AbortController();
349
+ const timer = setTimeout(() => controller.abort(), clamp(options.timeout_ms, 5000, 100, 30_000));
350
+ try {
351
+ const response = await fetch(url, { method: options.method || 'GET', signal: controller.signal, redirect: 'error' });
352
+ if (!response.ok) throw new Error(`HTTP ${response.status} ${response.statusText}`);
353
+ const declared = Number(response.headers.get('content-length') || 0);
354
+ if (declared > MAX_DISCOVERY_BYTES) throw new Error(`CDP discovery response is too large (${declared} bytes)`);
355
+ const reader = response.body?.getReader();
356
+ if (!reader) return JSON.parse(await response.text());
357
+ const chunks = [];
358
+ let total = 0;
359
+ for (;;) {
360
+ const { value, done } = await reader.read();
361
+ if (done) break;
362
+ total += value.byteLength;
363
+ if (total > MAX_DISCOVERY_BYTES) {
364
+ await reader.cancel().catch(() => {});
365
+ throw new Error(`CDP discovery response exceeded ${MAX_DISCOVERY_BYTES} bytes`);
366
+ }
367
+ chunks.push(Buffer.from(value));
368
+ }
369
+ return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
370
+ } catch (error) {
371
+ 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.`);
372
+ } finally {
373
+ clearTimeout(timer);
374
+ }
375
+ }
376
+
377
+ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
378
+ try {
379
+ const version = await requestJson('/json/version', endpoint, { timeout_ms: timeoutMs });
380
+ return Boolean(version && typeof version === 'object' && (version.Browser || version.webSocketDebuggerUrl));
381
+ } catch {
382
+ return false;
383
+ }
384
+ }
385
+
386
+ // Validating every tab sequentially can require hundreds of serial DNS lookups,
387
+ // making browser_tabs exceed the relay's deadline on machines with many tabs.
388
+ // Keep DNS and security validation, but bound both the work and its concurrency.
389
+ export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl } = {}) {
390
+ if (!Array.isArray(list)) return [];
391
+ const candidates = [];
392
+ for (const item of list) {
393
+ if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
394
+ candidates.push(item);
395
+ if (candidates.length >= 1000) break;
396
+ }
397
+ const safe = new Array(candidates.length).fill(false);
398
+ let cursor = 0;
399
+ await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, async () => {
400
+ while (cursor < candidates.length) {
401
+ const index = cursor++;
402
+ try {
403
+ await validate(candidates[index].url);
404
+ safe[index] = true;
405
+ } catch {
406
+ // Exclude unsafe/unresolvable pages without exposing them to the caller.
407
+ }
408
+ }
409
+ }));
410
+ const targets = [];
411
+ for (let i = 0; i < candidates.length && targets.length < 500; i++) {
412
+ if (safe[i]) targets.push(candidates[i]);
413
+ }
414
+ return targets;
415
+ }
416
+
417
+ export async function listBrowserTargets(endpoint) {
418
+ assertBrowserRemoteEnabled();
419
+ // Reading tabs and page semantics must not launch a separate Chromium/profile.
420
+ // Only an explicit browser_navigate(action=new_tab) can bootstrap an isolated browser.
421
+ const base = endpointUrl(endpoint); // keeps the CDP address loopback-only
422
+ let list;
423
+ try {
424
+ list = await requestJson('/json/list', endpoint, { timeout_ms: 1500 });
425
+ } catch (error) {
426
+ const message = String(error?.message || '');
427
+ // Keep HTTP/policy/oversize/malformed-response errors distinct from an unavailable CDP.
428
+ if (!/fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|timed out|network error/i.test(message)) throw error;
429
+ const configured = Boolean(endpoint || process.env.REMCP_CDP_URL);
430
+ const label = configured ? 'The configured CDP endpoint' : 'Browser CDP endpoint';
431
+ 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.`);
432
+ }
433
+ // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
434
+ return filterBrowserTargets(list);
435
+ }
436
+
437
+ export function validatedTargetWebSocketUrl(target, endpoint) {
438
+ let socketUrl;
439
+ try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
440
+ catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
441
+ if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
442
+ throw new Error('Browser CDP WebSocket target must be loopback-only');
443
+ }
444
+ if (endpointAuthority(socketUrl) !== endpointAuthority(endpoint)
445
+ || socketUrl.pathname !== `/devtools/page/${encodeURIComponent(String(target.id))}`) {
446
+ throw new Error('Browser CDP WebSocket target must match the configured loopback page endpoint');
447
+ }
448
+ return socketUrl.href;
449
+ }
450
+
451
+ export async function chooseTarget(args = {}) {
452
+ const targets = (await listBrowserTargets(args.endpoint)).filter(item => item.type === 'page' && item.webSocketDebuggerUrl);
453
+ const id = optionalString(args.target_id || args.targetId);
454
+ const title = optionalString(args.title);
455
+ const urlContains = optionalString(args.url_contains || args.urlContains);
456
+ const matched = targets.find(item =>
457
+ (!id || item.id === id) &&
458
+ (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase())) &&
459
+ (!urlContains || String(item.url || '').toLowerCase().includes(urlContains.toLowerCase()))
460
+ );
461
+ const hasSelector = Boolean(id || title || urlContains);
462
+ const target = matched || (hasSelector ? null : targets[0]);
463
+ if (!target) {
464
+ if (hasSelector) throw new Error('No browser page target matched the requested selector');
465
+ unavailable('Browser control', 'no debuggable page target is available');
466
+ }
467
+ const endpoint = endpointUrl(args.endpoint);
468
+ return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
469
+ }