@remcp/runtime 0.2.107 → 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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.108 — 2026-09-25
4
+
5
+ - Standalone production runtimes keep browser control disabled unless explicitly enabled; the authenticated paired agent enables its owned local runtime. IP-literal production allowlisting and persistent CDP guards cover page and worker sessions, delayed navigation, redirects, and subresources.
6
+ - Archive and document operations use descriptor-bound Linux traversal where available; macOS/Windows preserve bounded source snapshots, canonical confinement, link/special-file rejection, and atomic staging through portable filesystem APIs instead of losing document/archive tools.
7
+ - Linux semantic scroll/drag targets refresh accessibility geometry, and empty AT-SPI snapshots retry once before using the conservative depth fallback. Runtime tool names and schemas are unchanged.
8
+
3
9
  ## 0.2.106 — 2026-09-22
4
10
 
5
11
  - Wayland EIS records the emulated device used for each button press and sends the matching release through that same device, preventing cross-region/device drags from losing `pointerup`.
package/README.md CHANGED
@@ -143,6 +143,12 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
143
143
  agent. Browser control accepts only loopback CDP endpoints. The explicit `network` diagnostic can
144
144
  open a bounded TCP connection to a host/port, and shell/browser actions can reach the network when
145
145
  the requested operation itself requires it.
146
+ - **Remote browser safety.** A standalone production runtime keeps browser control disabled unless
147
+ `REMCP_BROWSER_REMOTE_ENABLED=1`; the authenticated ReMCP device agent explicitly enables it for the local
148
+ runtime it owns. Production navigation requires an explicit `REMCP_BROWSER_ALLOWED_HOSTS` allowlist of IP
149
+ literals. Hostname navigation is refused unless `REMCP_BROWSER_ALLOW_DNS_REBIND=1` is set behind an external
150
+ DNS/egress boundary. Local navigation requires a separate `REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION=1` opt-in,
151
+ and CDP request guards reject private, file, and WebSocket destinations.
146
152
  - **Optional confinement.** `allowedRoots` is empty by default. When you set it, it is enforced
147
153
  against the resolved real path of the deepest existing ancestor rather than the lexical string, so
148
154
  `<allowed>/link -> /etc` cannot be used to read or write outside the allowed directories.
@@ -159,6 +165,10 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
159
165
  transport limit), writes are capped (`maxWriteBytes`), buffered session output is capped by both
160
166
  line count and total characters - a stream with no newlines cannot grow without limit - and reads
161
167
  are paged or chunked.
168
+ - **Filesystem structural safety.** Archive and document snapshot/publication operations use
169
+ descriptor-bound directory traversal on Linux. macOS and Windows preserve the same bounded snapshot,
170
+ link/special-file rejection, canonical root confinement and atomic staging semantics, while using their
171
+ portable path APIs where Node does not expose directory-fd-relative open/rename/unlink primitives.
162
172
  - **Crash-resistant sessions.** A bogus shell, a closed stdin, or a dead parent cannot take the
163
173
  runtime down; sessions run in their own process group so `force_terminate` stops a whole pipeline;
164
174
  the agent restarts the runtime if it ever exits, so a device recovers instead of going silently
@@ -195,8 +205,10 @@ Optional settings live in `~/.config/remcp/runtime.json` (override the directory
195
205
  Every value can also be set with an environment variable: `REMCP_RUNTIME_ALLOWED_ROOTS`,
196
206
  `REMCP_RUNTIME_BLOCKED_COMMANDS`, `REMCP_RUNTIME_DANGEROUS_COMMANDS`, `REMCP_RUNTIME_TELEMETRY`,
197
207
  `REMCP_RUNTIME_DISABLE_TELEMETRY`, `REMCP_RUNTIME_MAX_OUTPUT_BYTES`, `REMCP_RUNTIME_MAX_READ_LINES`,
198
- `REMCP_RUNTIME_MAX_BUFFERED_LINES`, `REMCP_RUNTIME_MAX_WRITE_BYTES`, `REMCP_RUNTIME_SHELL`,
199
- `REMCP_RUNTIME_NAME`.
208
+ `REMCP_RUNTIME_MAX_BUFFERED_LINES`, `REMCP_RUNTIME_MAX_WRITE_BYTES`, `REMCP_RUNTIME_SHELL`,
209
+ `REMCP_RUNTIME_NAME`, `REMCP_BROWSER_REMOTE_ENABLED`, `REMCP_BROWSER_ALLOWED_HOSTS`,
210
+ `REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION`, `REMCP_BROWSER_ALLOW_DNS_REBIND`.
211
+
200
212
 
201
213
  `allowedRoots` is empty by default, which means the paired device can reach anything the operating
202
214
  system user running the agent can reach. Set it when you want the device to be scoped to specific
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.107",
3
+ "version": "0.2.108",
4
4
  "description": "First-party ReMCP local device runtime: filesystem, terminal, computer-use, browser, diagnostics and document tools over MCP.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "scripts": {
22
22
  "start": "node src/index.mjs",
23
23
  "check": "node scripts/check.mjs",
24
- "test": "node --test test/*.test.mjs"
24
+ "test": "node --import ./test/setup.mjs --test test/*.test.mjs"
25
25
  },
26
26
  "dependencies": {
27
27
  "@jellybrick/dbus-next": "^0.11.3",
package/src/catalog.mjs CHANGED
@@ -830,6 +830,14 @@ export const toolDefinitions = [
830
830
  },
831
831
  ];
832
832
 
833
+ export function coreToolSupported(definition) {
834
+ return !['create_archive', 'extract_archive'].includes(definition.name) || process.platform === 'linux';
835
+ }
836
+
837
+ export function supportedCoreTools() {
838
+ return toolDefinitions.filter(coreToolSupported);
839
+ }
840
+
833
841
  export function advertisedTools() {
834
842
  return toolDefinitions.map(({ name, title, description, inputSchema, annotations, outputSchema }) => ({
835
843
  name,
@@ -1,4 +1,4 @@
1
- import { TEXT_OUTPUT_SCHEMA, toolDefinitions } from './catalog.mjs';
1
+ import { TEXT_OUTPUT_SCHEMA, supportedCoreTools, toolDefinitions } from './catalog.mjs';
2
2
  import { advertisedExtendedTools, extendedToolDefinitions } from './extended/catalog.mjs';
3
3
 
4
4
  export const compactRuntimeGroups = Object.freeze({
@@ -127,7 +127,7 @@ function groupedDefinition(name, members) {
127
127
  export async function supportedRuntimeOperationNames() {
128
128
  const supportedExtended = new Set((await advertisedExtendedTools()).map(tool => tool.name));
129
129
  return new Set([
130
- ...toolDefinitions.map(tool => tool.name),
130
+ ...supportedCoreTools().map(tool => tool.name),
131
131
  ...extendedToolDefinitions.filter(tool => supportedExtended.has(tool.name)).map(tool => tool.name),
132
132
  ]);
133
133
  }
@@ -2,6 +2,7 @@ import process from 'node:process';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { isIP } from 'node:net';
5
+ import { lookup } from 'node:dns/promises';
5
6
  import { existsSync, mkdirSync } from 'node:fs';
6
7
  import { resolveSafePath } from '../util.mjs';
7
8
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
@@ -11,7 +12,10 @@ const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
11
12
  const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
12
13
  const MAX_EVALUATION_BYTES = 256 * 1024;
13
14
  const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
14
- const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob):/i;
15
+ const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
16
+ const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
17
+ const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
18
+ const productionBrowserSessions = new Map();
15
19
 
16
20
  let browserLaunchPromise = null;
17
21
 
@@ -38,6 +42,16 @@ export function browserAutoLaunchAvailable() {
38
42
  return Boolean(browserExecutable());
39
43
  }
40
44
 
45
+ export function browserRemoteEnabled() {
46
+ const configured = String(process.env.REMCP_BROWSER_REMOTE_ENABLED || '').trim().toLowerCase();
47
+ if (configured) return ['1', 'true', 'yes', 'on'].includes(configured);
48
+ return ['development', 'test'].includes(String(process.env.NODE_ENV || '').trim().toLowerCase());
49
+ }
50
+
51
+ function assertBrowserRemoteEnabled() {
52
+ if (!browserRemoteEnabled()) throw new Error('Browser control is disabled for remote runtime; set REMCP_BROWSER_REMOTE_ENABLED=1 only on a trusted local runtime');
53
+ }
54
+
41
55
  function browserDataDir() {
42
56
  const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
43
57
  if (configured) return configured;
@@ -56,6 +70,7 @@ async function waitForBrowserEndpoint(timeoutMs = 5000) {
56
70
  }
57
71
 
58
72
  async function ensureBrowserEndpoint(endpoint) {
73
+ assertBrowserRemoteEnabled();
59
74
  if (endpoint || process.env.REMCP_CDP_URL) return;
60
75
  if (await browserCapabilityAvailable(undefined, 250)) return;
61
76
  if (!browserLaunchPromise) {
@@ -81,6 +96,8 @@ async function ensureBrowserEndpoint(endpoint) {
81
96
  }
82
97
 
83
98
  export async function browserControlAvailable(endpoint, timeoutMs = 500) {
99
+ if (!browserRemoteEnabled()) return false;
100
+ if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
84
101
  if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
85
102
  if (endpoint || process.env.REMCP_CDP_URL) return false;
86
103
  return browserAutoLaunchAvailable();
@@ -101,6 +118,7 @@ function isLoopbackHost(hostname) {
101
118
  }
102
119
 
103
120
  function localBrowserNavigationAllowed() {
121
+ if (process.env.NODE_ENV === 'production') return false;
104
122
  return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
105
123
  .some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
106
124
  }
@@ -161,6 +179,11 @@ function endpointUrl(value) {
161
179
  return url;
162
180
  }
163
181
 
182
+ function endpointAuthority(url) {
183
+ const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
184
+ return `${normalizedHost(url.hostname)}:${port}`;
185
+ }
186
+
164
187
  function safeBrowserUrl(value, field = 'url') {
165
188
  let url;
166
189
  try { url = new URL(String(value || '')); }
@@ -178,8 +201,55 @@ function safePageUrl(value, field = 'browser page URL') {
178
201
  return safeBrowserUrl(value, field);
179
202
  }
180
203
 
204
+ function configuredBrowserHosts() {
205
+ return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
206
+ .split(/[,\s]+/)
207
+ .map(normalizedHost)
208
+ .filter(Boolean);
209
+ }
210
+
211
+ function browserHostAllowlisted(host) {
212
+ return configuredBrowserHosts().some(pattern => pattern === host
213
+ || (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
214
+ }
215
+
216
+ function browserDnsRebindingExplicitlyAllowed() {
217
+ return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
218
+ }
219
+
220
+ function productionBrowserPolicyReady() {
221
+ const hosts = configuredBrowserHosts();
222
+ return (hosts.length > 0 && hosts.some(isIP))
223
+ || (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
224
+ }
225
+
226
+ async function assertResolvedBrowserUrl(value, field = 'browser URL') {
227
+ const safe = safeBrowserUrl(value, field);
228
+ if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
229
+ const host = normalizedHost(new URL(safe).hostname);
230
+ if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
231
+ throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
232
+ }
233
+ if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
234
+ if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
235
+ throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
236
+ }
237
+ let addresses;
238
+ try { addresses = await lookup(host, { all:true, verbatim:true }); }
239
+ catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
240
+ if (addresses.some(address => isPrivateBrowserHost(address.address))) {
241
+ throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
242
+ }
243
+ return safe;
244
+ }
245
+
246
+ async function assertResolvedPageUrl(value, field = 'browser page URL') {
247
+ if (String(value || '') === 'about:blank') return value;
248
+ return assertResolvedBrowserUrl(value, field);
249
+ }
250
+
181
251
  function expressionHasPrivateDestination(expression) {
182
- const urls = expression.match(/https?:\/\/[^\s"'`<>]+/gi) || [];
252
+ const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
183
253
  return urls.some(value => {
184
254
  try { return isPrivateBrowserHost(new URL(value).hostname); }
185
255
  catch { return false; }
@@ -193,12 +263,19 @@ function safeEvaluationExpression(value) {
193
263
  throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
194
264
  }
195
265
  if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
196
- if (!localBrowserNavigationAllowed() && expressionHasPrivateDestination(expression)) {
197
- throw new Error('expression contains a blocked private browser destination');
266
+ if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
267
+ throw new Error('expression contains a blocked private browser destination or network API');
198
268
  }
199
269
  return expression;
200
270
  }
201
271
 
272
+ async function assertSafeEvaluationExpression(value) {
273
+ const expression = safeEvaluationExpression(value);
274
+ const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
275
+ await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
276
+ return expression;
277
+ }
278
+
202
279
  async function requestJson(pathname, endpoint, options = {}) {
203
280
  const base = endpointUrl(endpoint);
204
281
  const url = new URL(pathname, base);
@@ -241,13 +318,33 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
241
318
  }
242
319
 
243
320
  export async function listBrowserTargets(endpoint) {
321
+ assertBrowserRemoteEnabled();
244
322
  await ensureBrowserEndpoint(endpoint);
245
323
  const list = await requestJson('/json/list', endpoint);
246
324
  if (!Array.isArray(list)) return [];
247
- return list.filter(item => {
248
- if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) return false;
249
- try { safePageUrl(item.url); return true; } catch { return false; }
250
- });
325
+ const targets = [];
326
+ for (const item of list) {
327
+ if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
328
+ try {
329
+ await assertResolvedPageUrl(item.url);
330
+ targets.push(item);
331
+ } catch {}
332
+ }
333
+ return targets;
334
+ }
335
+
336
+ function validatedTargetWebSocketUrl(target, endpoint) {
337
+ let socketUrl;
338
+ try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
339
+ catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
340
+ if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
341
+ throw new Error('Browser CDP WebSocket target must be loopback-only');
342
+ }
343
+ if (endpointAuthority(socketUrl) !== endpointAuthority(endpoint)
344
+ || socketUrl.pathname !== `/devtools/page/${encodeURIComponent(String(target.id))}`) {
345
+ throw new Error('Browser CDP WebSocket target must match the configured loopback page endpoint');
346
+ }
347
+ return socketUrl.href;
251
348
  }
252
349
 
253
350
  async function chooseTarget(args = {}) {
@@ -266,13 +363,8 @@ async function chooseTarget(args = {}) {
266
363
  if (hasSelector) throw new Error('No browser page target matched the requested selector');
267
364
  unavailable('Browser control', 'no debuggable page target is available');
268
365
  }
269
- let socketUrl;
270
- try { socketUrl = new URL(String(target.webSocketDebuggerUrl)); }
271
- catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
272
- if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
273
- throw new Error('Browser CDP WebSocket target must be loopback-only');
274
- }
275
- return { ...target, webSocketDebuggerUrl: socketUrl.href };
366
+ const endpoint = endpointUrl(args.endpoint);
367
+ return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
276
368
  }
277
369
 
278
370
  class CdpSession {
@@ -284,15 +376,19 @@ class CdpSession {
284
376
  this.pending = new Map();
285
377
  this.waiters = new Map();
286
378
  this.observers = new Map();
379
+ this.closed = true;
287
380
  }
288
381
 
289
382
  async open() {
290
383
  if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
291
384
  await new Promise((resolve, reject) => {
292
385
  const socket = new WebSocket(this.url);
293
- const timer = setTimeout(() => reject(new Error('CDP websocket connection timed out')), this.timeoutMs);
294
- socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; resolve(); }, { once: true });
295
- socket.addEventListener('error', event => { clearTimeout(timer); reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
386
+ const timer = setTimeout(() => {
387
+ try { socket.close(); } catch {}
388
+ reject(new Error('CDP websocket connection timed out'));
389
+ }, this.timeoutMs);
390
+ socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; this.closed = false; resolve(); }, { once: true });
391
+ socket.addEventListener('error', event => { clearTimeout(timer); try { socket.close(); } catch {}; reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
296
392
  socket.addEventListener('message', event => this.onMessage(event));
297
393
  socket.addEventListener('close', () => this.onClose());
298
394
  });
@@ -307,9 +403,11 @@ class CdpSession {
307
403
  }
308
404
  let message;
309
405
  try { message = JSON.parse(raw); } catch { return; }
310
- if (message.id && this.pending.has(message.id)) {
311
- const pending = this.pending.get(message.id);
312
- this.pending.delete(message.id);
406
+ const sessionId = String(message.sessionId || '');
407
+ const pendingKey = message.id ? `${sessionId}:${message.id}` : '';
408
+ if (message.id && this.pending.has(pendingKey)) {
409
+ const pending = this.pending.get(pendingKey);
410
+ this.pending.delete(pendingKey);
313
411
  clearTimeout(pending.timer);
314
412
  if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
315
413
  else pending.resolve(message.result || {});
@@ -318,7 +416,7 @@ class CdpSession {
318
416
  const observers = this.observers.get(message.method);
319
417
  if (observers) {
320
418
  for (const observer of [...observers]) {
321
- try { observer(message.params || {}); } catch {}
419
+ try { observer(message.params || {}, sessionId); } catch {}
322
420
  }
323
421
  }
324
422
  const listeners = this.waiters.get(message.method);
@@ -331,6 +429,7 @@ class CdpSession {
331
429
  }
332
430
 
333
431
  onClose() {
432
+ this.closed = true;
334
433
  for (const pending of this.pending.values()) {
335
434
  clearTimeout(pending.timer);
336
435
  pending.reject(new Error('CDP websocket closed'));
@@ -358,15 +457,19 @@ class CdpSession {
358
457
  };
359
458
  }
360
459
 
361
- send(method, params = {}) {
460
+ send(method, params = {}, sessionId = '', timeoutMs = this.timeoutMs) {
461
+ if (this.closed || !this.socket || this.socket.readyState !== 1) {
462
+ return Promise.reject(new Error('CDP websocket is not open'));
463
+ }
362
464
  const id = this.nextId++;
465
+ const key = `${sessionId}:${id}`;
363
466
  return new Promise((resolve, reject) => {
364
467
  const timer = setTimeout(() => {
365
- this.pending.delete(id);
468
+ this.pending.delete(key);
366
469
  reject(new Error(`${method} timed out`));
367
- }, this.timeoutMs);
368
- this.pending.set(id, { resolve, reject, timer });
369
- this.socket.send(JSON.stringify({ id, method, params }));
470
+ }, timeoutMs);
471
+ this.pending.set(key, { resolve, reject, timer });
472
+ this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
370
473
  });
371
474
  }
372
475
 
@@ -385,18 +488,181 @@ class CdpSession {
385
488
  });
386
489
  }
387
490
 
388
- close() { try { this.socket?.close(); } catch {} }
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
+ }
389
624
  }
390
625
 
391
626
  async function withTarget(args, callback) {
392
627
  const target = await chooseTarget(args);
393
- safePageUrl(target.url);
394
- const session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
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
+ }
395
651
  try {
396
652
  const currentUrl = await evaluate(session, 'location.href').catch(() => target.url);
397
- safePageUrl(currentUrl);
398
- return await callback(session, target);
399
- } finally { session.close(); }
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
+ }
400
666
  }
401
667
 
402
668
  async function evaluate(session, expression, awaitPromise = true) {
@@ -458,15 +724,25 @@ async function waitForHistoryReady(session, expectedUrl, timeoutMs) {
458
724
  export async function browserNavigate(args = {}) {
459
725
  const requestedUrl = optionalString(args.url);
460
726
  const action = requireEnum(args.action || (requestedUrl ? 'url' : 'reload'), 'action', ['url','new_tab','back','forward','reload']);
461
- const url = ['url','new_tab'].includes(action) ? safeBrowserUrl(requestedUrl) : null;
727
+ const url = ['url','new_tab'].includes(action) ? await assertResolvedBrowserUrl(requestedUrl, 'url') : null;
462
728
  if (['url','new_tab'].includes(action) && !url) throw new Error('url is required when action=url or new_tab');
463
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
+ }
464
740
  await ensureBrowserEndpoint(args.endpoint);
465
741
  const created = await requestJson(`/json/new?${encodeURIComponent(url)}`, args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
466
742
  return jsonResult({
467
743
  target_id:created?.id || null,
468
744
  action,
469
- url:created?.url ? safePageUrl(created.url) : url,
745
+ url:created?.url ? await assertResolvedPageUrl(created.url) : url,
470
746
  title:created?.title || '',
471
747
  });
472
748
  }
@@ -491,7 +767,7 @@ export async function browserNavigate(args = {}) {
491
767
  const nextIndex = action === 'back' ? current - 1 : current + 1;
492
768
  const entry = Array.isArray(history.entries) ? history.entries[nextIndex] : null;
493
769
  if (!entry) throw new Error(`Cannot navigate ${action}: no history entry is available`);
494
- historyUrl = entry.url ? safePageUrl(entry.url, 'history URL') : null;
770
+ historyUrl = entry.url ? await assertResolvedPageUrl(entry.url, 'history URL') : null;
495
771
  await session.send('Page.navigateToHistoryEntry', { entryId: entry.id });
496
772
  }
497
773
  if (loaded) await loaded;
@@ -499,7 +775,7 @@ export async function browserNavigate(args = {}) {
499
775
  await waitForHistoryReady(session, historyUrl, timeoutMs);
500
776
  }
501
777
  const currentUrl = await evaluate(session, 'location.href').catch(() => url || target.url || null);
502
- if (currentUrl) safePageUrl(currentUrl);
778
+ if (currentUrl) await assertResolvedPageUrl(currentUrl);
503
779
  return jsonResult({ target_id: target.id, action, url: currentUrl, frame_id: frameId, error_text: errorText });
504
780
  });
505
781
  }
@@ -821,7 +1097,7 @@ export async function browserWait(args = {}) {
821
1097
  const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
822
1098
  const wanted = optionalString(args.selector || args.text || args.value || args.expression);
823
1099
  if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
824
- if (condition === 'expression') safeEvaluationExpression(wanted);
1100
+ if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
825
1101
  return withTarget(args, async (session, target) => {
826
1102
  const started = Date.now();
827
1103
  const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
@@ -891,7 +1167,8 @@ export async function browserWait(args = {}) {
891
1167
  }
892
1168
 
893
1169
  export async function browserEvaluate(args) {
894
- const expression = safeEvaluationExpression(args.expression);
1170
+ assertBrowserRemoteEnabled();
1171
+ const expression = await assertSafeEvaluationExpression(args.expression);
895
1172
  return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
896
1173
  }
897
1174