@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 +6 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/extended/browser.mjs +316 -39
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/documents.mjs +316 -82
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/tools/files.mjs +245 -130
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.
|
|
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,
|
package/src/compact-catalog.mjs
CHANGED
|
@@ -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
|
-
...
|
|
130
|
+
...supportedCoreTools().map(tool => tool.name),
|
|
131
131
|
...extendedToolDefinitions.filter(tool => supportedExtended.has(tool.name)).map(tool => tool.name),
|
|
132
132
|
]);
|
|
133
133
|
}
|
package/src/extended/browser.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import process from 'node:process';
|
|
|
2
2
|
import os from 'node:os';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { isIP } from 'node:net';
|
|
5
|
+
import { lookup } from 'node:dns/promises';
|
|
5
6
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
6
7
|
import { resolveSafePath } from '../util.mjs';
|
|
7
8
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
@@ -11,7 +12,10 @@ const MAX_DISCOVERY_BYTES = 8 * 1024 * 1024;
|
|
|
11
12
|
const MAX_CDP_MESSAGE_BYTES = 16 * 1024 * 1024;
|
|
12
13
|
const MAX_EVALUATION_BYTES = 256 * 1024;
|
|
13
14
|
const BROWSER_PROTOCOLS = new Set(['http:', 'https:']);
|
|
14
|
-
const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob):/i;
|
|
15
|
+
const BLOCKED_BROWSER_SCHEME = /(?:^|[\s"'`(=])(?:file|chrome|devtools|view-source|filesystem|blob|wss?):/i;
|
|
16
|
+
const BLOCKED_BROWSER_NETWORK_API = /\b(?:fetch|WebSocket|EventSource|XMLHttpRequest|sendBeacon)\b/;
|
|
17
|
+
const PRODUCTION_SESSION_IDLE_MS = 10 * 60 * 1000;
|
|
18
|
+
const productionBrowserSessions = new Map();
|
|
15
19
|
|
|
16
20
|
let browserLaunchPromise = null;
|
|
17
21
|
|
|
@@ -38,6 +42,16 @@ export function browserAutoLaunchAvailable() {
|
|
|
38
42
|
return Boolean(browserExecutable());
|
|
39
43
|
}
|
|
40
44
|
|
|
45
|
+
export function browserRemoteEnabled() {
|
|
46
|
+
const configured = String(process.env.REMCP_BROWSER_REMOTE_ENABLED || '').trim().toLowerCase();
|
|
47
|
+
if (configured) return ['1', 'true', 'yes', 'on'].includes(configured);
|
|
48
|
+
return ['development', 'test'].includes(String(process.env.NODE_ENV || '').trim().toLowerCase());
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function assertBrowserRemoteEnabled() {
|
|
52
|
+
if (!browserRemoteEnabled()) throw new Error('Browser control is disabled for remote runtime; set REMCP_BROWSER_REMOTE_ENABLED=1 only on a trusted local runtime');
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
function browserDataDir() {
|
|
42
56
|
const configured = optionalString(process.env.REMCP_BROWSER_DATA_DIR);
|
|
43
57
|
if (configured) return configured;
|
|
@@ -56,6 +70,7 @@ async function waitForBrowserEndpoint(timeoutMs = 5000) {
|
|
|
56
70
|
}
|
|
57
71
|
|
|
58
72
|
async function ensureBrowserEndpoint(endpoint) {
|
|
73
|
+
assertBrowserRemoteEnabled();
|
|
59
74
|
if (endpoint || process.env.REMCP_CDP_URL) return;
|
|
60
75
|
if (await browserCapabilityAvailable(undefined, 250)) return;
|
|
61
76
|
if (!browserLaunchPromise) {
|
|
@@ -81,6 +96,8 @@ async function ensureBrowserEndpoint(endpoint) {
|
|
|
81
96
|
}
|
|
82
97
|
|
|
83
98
|
export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
99
|
+
if (!browserRemoteEnabled()) return false;
|
|
100
|
+
if (process.env.NODE_ENV === 'production' && !productionBrowserPolicyReady()) return false;
|
|
84
101
|
if (await browserCapabilityAvailable(endpoint, timeoutMs)) return true;
|
|
85
102
|
if (endpoint || process.env.REMCP_CDP_URL) return false;
|
|
86
103
|
return browserAutoLaunchAvailable();
|
|
@@ -101,6 +118,7 @@ function isLoopbackHost(hostname) {
|
|
|
101
118
|
}
|
|
102
119
|
|
|
103
120
|
function localBrowserNavigationAllowed() {
|
|
121
|
+
if (process.env.NODE_ENV === 'production') return false;
|
|
104
122
|
return ['REMCP_BROWSER_ALLOW_LOCAL_NAVIGATION', 'REMCP_BROWSER_ALLOW_LOCAL', 'REMCP_ALLOW_LOCAL_BROWSER_NAVIGATION']
|
|
105
123
|
.some(name => ['1', 'true', 'yes', 'on'].includes(String(process.env[name] || '').trim().toLowerCase()));
|
|
106
124
|
}
|
|
@@ -161,6 +179,11 @@ function endpointUrl(value) {
|
|
|
161
179
|
return url;
|
|
162
180
|
}
|
|
163
181
|
|
|
182
|
+
function endpointAuthority(url) {
|
|
183
|
+
const port = url.port || ((url.protocol === 'https:' || url.protocol === 'wss:') ? '443' : '80');
|
|
184
|
+
return `${normalizedHost(url.hostname)}:${port}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
164
187
|
function safeBrowserUrl(value, field = 'url') {
|
|
165
188
|
let url;
|
|
166
189
|
try { url = new URL(String(value || '')); }
|
|
@@ -178,8 +201,55 @@ function safePageUrl(value, field = 'browser page URL') {
|
|
|
178
201
|
return safeBrowserUrl(value, field);
|
|
179
202
|
}
|
|
180
203
|
|
|
204
|
+
function configuredBrowserHosts() {
|
|
205
|
+
return String(process.env.REMCP_BROWSER_ALLOWED_HOSTS || '')
|
|
206
|
+
.split(/[,\s]+/)
|
|
207
|
+
.map(normalizedHost)
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function browserHostAllowlisted(host) {
|
|
212
|
+
return configuredBrowserHosts().some(pattern => pattern === host
|
|
213
|
+
|| (pattern.startsWith('*.') && host.endsWith(pattern.slice(1)) && host.length > pattern.length - 1));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function browserDnsRebindingExplicitlyAllowed() {
|
|
217
|
+
return ['1', 'true', 'yes', 'on'].includes(String(process.env.REMCP_BROWSER_ALLOW_DNS_REBIND || '').trim().toLowerCase());
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function productionBrowserPolicyReady() {
|
|
221
|
+
const hosts = configuredBrowserHosts();
|
|
222
|
+
return (hosts.length > 0 && hosts.some(isIP))
|
|
223
|
+
|| (browserDnsRebindingExplicitlyAllowed() && hosts.length > 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function assertResolvedBrowserUrl(value, field = 'browser URL') {
|
|
227
|
+
const safe = safeBrowserUrl(value, field);
|
|
228
|
+
if (localBrowserNavigationAllowed() && process.env.NODE_ENV !== 'production') return safe;
|
|
229
|
+
const host = normalizedHost(new URL(safe).hostname);
|
|
230
|
+
if (process.env.NODE_ENV === 'production' && !browserHostAllowlisted(host)) {
|
|
231
|
+
throw new Error(`${field} host is not in REMCP_BROWSER_ALLOWED_HOSTS`);
|
|
232
|
+
}
|
|
233
|
+
if (isIP(host) || (host.endsWith('.test') && process.env.NODE_ENV !== 'production')) return safe;
|
|
234
|
+
if (process.env.NODE_ENV === 'production' && !isIP(host) && !browserDnsRebindingExplicitlyAllowed()) {
|
|
235
|
+
throw new Error(`${field} requires an IP-literal allowlist in production; set REMCP_BROWSER_ALLOW_DNS_REBIND=1 only with a DNS/egress boundary`);
|
|
236
|
+
}
|
|
237
|
+
let addresses;
|
|
238
|
+
try { addresses = await lookup(host, { all:true, verbatim:true }); }
|
|
239
|
+
catch (error) { throw new Error(`${field} host could not be resolved safely: ${error instanceof Error ? error.message : String(error)}`); }
|
|
240
|
+
if (addresses.some(address => isPrivateBrowserHost(address.address))) {
|
|
241
|
+
throw new Error(`${field} must not resolve to loopback, private, or link-local addresses`);
|
|
242
|
+
}
|
|
243
|
+
return safe;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function assertResolvedPageUrl(value, field = 'browser page URL') {
|
|
247
|
+
if (String(value || '') === 'about:blank') return value;
|
|
248
|
+
return assertResolvedBrowserUrl(value, field);
|
|
249
|
+
}
|
|
250
|
+
|
|
181
251
|
function expressionHasPrivateDestination(expression) {
|
|
182
|
-
const urls = expression.match(/https
|
|
252
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
183
253
|
return urls.some(value => {
|
|
184
254
|
try { return isPrivateBrowserHost(new URL(value).hostname); }
|
|
185
255
|
catch { return false; }
|
|
@@ -193,12 +263,19 @@ function safeEvaluationExpression(value) {
|
|
|
193
263
|
throw new Error(`expression must be at most ${MAX_EVALUATION_BYTES} bytes`);
|
|
194
264
|
}
|
|
195
265
|
if (BLOCKED_BROWSER_SCHEME.test(expression)) throw new Error('expression contains a blocked local browser scheme');
|
|
196
|
-
if (!localBrowserNavigationAllowed() && expressionHasPrivateDestination(expression)) {
|
|
197
|
-
throw new Error('expression contains a blocked private browser destination');
|
|
266
|
+
if (!localBrowserNavigationAllowed() && (expressionHasPrivateDestination(expression) || BLOCKED_BROWSER_NETWORK_API.test(expression))) {
|
|
267
|
+
throw new Error('expression contains a blocked private browser destination or network API');
|
|
198
268
|
}
|
|
199
269
|
return expression;
|
|
200
270
|
}
|
|
201
271
|
|
|
272
|
+
async function assertSafeEvaluationExpression(value) {
|
|
273
|
+
const expression = safeEvaluationExpression(value);
|
|
274
|
+
const urls = expression.match(/(?:https?|wss?):\/\/[^\s"'`<>]+/gi) || [];
|
|
275
|
+
await Promise.all(urls.map(url => assertResolvedBrowserUrl(url, 'expression destination')));
|
|
276
|
+
return expression;
|
|
277
|
+
}
|
|
278
|
+
|
|
202
279
|
async function requestJson(pathname, endpoint, options = {}) {
|
|
203
280
|
const base = endpointUrl(endpoint);
|
|
204
281
|
const url = new URL(pathname, base);
|
|
@@ -241,13 +318,33 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
|
|
|
241
318
|
}
|
|
242
319
|
|
|
243
320
|
export async function listBrowserTargets(endpoint) {
|
|
321
|
+
assertBrowserRemoteEnabled();
|
|
244
322
|
await ensureBrowserEndpoint(endpoint);
|
|
245
323
|
const list = await requestJson('/json/list', endpoint);
|
|
246
324
|
if (!Array.isArray(list)) return [];
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
325
|
+
const targets = [];
|
|
326
|
+
for (const item of list) {
|
|
327
|
+
if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
|
|
328
|
+
try {
|
|
329
|
+
await assertResolvedPageUrl(item.url);
|
|
330
|
+
targets.push(item);
|
|
331
|
+
} catch {}
|
|
332
|
+
}
|
|
333
|
+
return targets;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function validatedTargetWebSocketUrl(target, endpoint) {
|
|
337
|
+
let socketUrl;
|
|
338
|
+
try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }
|
|
339
|
+
catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
|
|
340
|
+
if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
|
|
341
|
+
throw new Error('Browser CDP WebSocket target must be loopback-only');
|
|
342
|
+
}
|
|
343
|
+
if (endpointAuthority(socketUrl) !== endpointAuthority(endpoint)
|
|
344
|
+
|| socketUrl.pathname !== `/devtools/page/${encodeURIComponent(String(target.id))}`) {
|
|
345
|
+
throw new Error('Browser CDP WebSocket target must match the configured loopback page endpoint');
|
|
346
|
+
}
|
|
347
|
+
return socketUrl.href;
|
|
251
348
|
}
|
|
252
349
|
|
|
253
350
|
async function chooseTarget(args = {}) {
|
|
@@ -266,13 +363,8 @@ async function chooseTarget(args = {}) {
|
|
|
266
363
|
if (hasSelector) throw new Error('No browser page target matched the requested selector');
|
|
267
364
|
unavailable('Browser control', 'no debuggable page target is available');
|
|
268
365
|
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
catch { throw new Error('Browser returned an invalid CDP WebSocket URL'); }
|
|
272
|
-
if (!['ws:', 'wss:'].includes(socketUrl.protocol) || !isLoopbackHost(socketUrl.hostname)) {
|
|
273
|
-
throw new Error('Browser CDP WebSocket target must be loopback-only');
|
|
274
|
-
}
|
|
275
|
-
return { ...target, webSocketDebuggerUrl: socketUrl.href };
|
|
366
|
+
const endpoint = endpointUrl(args.endpoint);
|
|
367
|
+
return { ...target, webSocketDebuggerUrl: validatedTargetWebSocketUrl(target, endpoint) };
|
|
276
368
|
}
|
|
277
369
|
|
|
278
370
|
class CdpSession {
|
|
@@ -284,15 +376,19 @@ class CdpSession {
|
|
|
284
376
|
this.pending = new Map();
|
|
285
377
|
this.waiters = new Map();
|
|
286
378
|
this.observers = new Map();
|
|
379
|
+
this.closed = true;
|
|
287
380
|
}
|
|
288
381
|
|
|
289
382
|
async open() {
|
|
290
383
|
if (typeof WebSocket !== 'function') throw new Error('This Node.js runtime does not provide WebSocket support');
|
|
291
384
|
await new Promise((resolve, reject) => {
|
|
292
385
|
const socket = new WebSocket(this.url);
|
|
293
|
-
const timer = setTimeout(() =>
|
|
294
|
-
|
|
295
|
-
|
|
386
|
+
const timer = setTimeout(() => {
|
|
387
|
+
try { socket.close(); } catch {}
|
|
388
|
+
reject(new Error('CDP websocket connection timed out'));
|
|
389
|
+
}, this.timeoutMs);
|
|
390
|
+
socket.addEventListener('open', () => { clearTimeout(timer); this.socket = socket; this.closed = false; resolve(); }, { once: true });
|
|
391
|
+
socket.addEventListener('error', event => { clearTimeout(timer); try { socket.close(); } catch {}; reject(new Error(event?.message || 'CDP websocket error')); }, { once: true });
|
|
296
392
|
socket.addEventListener('message', event => this.onMessage(event));
|
|
297
393
|
socket.addEventListener('close', () => this.onClose());
|
|
298
394
|
});
|
|
@@ -307,9 +403,11 @@ class CdpSession {
|
|
|
307
403
|
}
|
|
308
404
|
let message;
|
|
309
405
|
try { message = JSON.parse(raw); } catch { return; }
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
406
|
+
const sessionId = String(message.sessionId || '');
|
|
407
|
+
const pendingKey = message.id ? `${sessionId}:${message.id}` : '';
|
|
408
|
+
if (message.id && this.pending.has(pendingKey)) {
|
|
409
|
+
const pending = this.pending.get(pendingKey);
|
|
410
|
+
this.pending.delete(pendingKey);
|
|
313
411
|
clearTimeout(pending.timer);
|
|
314
412
|
if (message.error) pending.reject(new Error(message.error.message || JSON.stringify(message.error)));
|
|
315
413
|
else pending.resolve(message.result || {});
|
|
@@ -318,7 +416,7 @@ class CdpSession {
|
|
|
318
416
|
const observers = this.observers.get(message.method);
|
|
319
417
|
if (observers) {
|
|
320
418
|
for (const observer of [...observers]) {
|
|
321
|
-
try { observer(message.params || {}); } catch {}
|
|
419
|
+
try { observer(message.params || {}, sessionId); } catch {}
|
|
322
420
|
}
|
|
323
421
|
}
|
|
324
422
|
const listeners = this.waiters.get(message.method);
|
|
@@ -331,6 +429,7 @@ class CdpSession {
|
|
|
331
429
|
}
|
|
332
430
|
|
|
333
431
|
onClose() {
|
|
432
|
+
this.closed = true;
|
|
334
433
|
for (const pending of this.pending.values()) {
|
|
335
434
|
clearTimeout(pending.timer);
|
|
336
435
|
pending.reject(new Error('CDP websocket closed'));
|
|
@@ -358,15 +457,19 @@ class CdpSession {
|
|
|
358
457
|
};
|
|
359
458
|
}
|
|
360
459
|
|
|
361
|
-
send(method, params = {}) {
|
|
460
|
+
send(method, params = {}, sessionId = '', timeoutMs = this.timeoutMs) {
|
|
461
|
+
if (this.closed || !this.socket || this.socket.readyState !== 1) {
|
|
462
|
+
return Promise.reject(new Error('CDP websocket is not open'));
|
|
463
|
+
}
|
|
362
464
|
const id = this.nextId++;
|
|
465
|
+
const key = `${sessionId}:${id}`;
|
|
363
466
|
return new Promise((resolve, reject) => {
|
|
364
467
|
const timer = setTimeout(() => {
|
|
365
|
-
this.pending.delete(
|
|
468
|
+
this.pending.delete(key);
|
|
366
469
|
reject(new Error(`${method} timed out`));
|
|
367
|
-
},
|
|
368
|
-
this.pending.set(
|
|
369
|
-
this.socket.send(JSON.stringify({ id, method, params }));
|
|
470
|
+
}, timeoutMs);
|
|
471
|
+
this.pending.set(key, { resolve, reject, timer });
|
|
472
|
+
this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
|
|
370
473
|
});
|
|
371
474
|
}
|
|
372
475
|
|
|
@@ -385,18 +488,181 @@ class CdpSession {
|
|
|
385
488
|
});
|
|
386
489
|
}
|
|
387
490
|
|
|
388
|
-
close() {
|
|
491
|
+
close() {
|
|
492
|
+
if (this.closed) return;
|
|
493
|
+
try { this.socket?.close(); } catch {}
|
|
494
|
+
this.onClose();
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function installBrowserRequestGuard(session) {
|
|
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
|
-
|
|
394
|
-
const
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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) ?
|
|
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 ?
|
|
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 ?
|
|
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)
|
|
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')
|
|
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
|
-
|
|
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
|
|