@remcp/runtime 0.2.146 → 0.2.148

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.148 — 2026-10-14
4
+
5
+ - Request Chromium-native `--restore-last-session` only when the dedicated ReMCP browser profile has a crashed exit marker and persisted tab session files.
6
+ - Preserve intentional clean browser shutdown and first-run behavior; avoid following symlinked session directories or files.
7
+ - Use atomic file-descriptor validation for local session metadata to avoid check-then-open file races (CodeQL TOCTOU).
8
+ - Add cross-platform regression coverage for interrupted, clean, missing, malformed, new, and symlinked browser session profiles.
9
+ - No new tools or MCP schema changes.
10
+
11
+ ## 0.2.147 — 2026-10-14
12
+
13
+ - Accelerated Chrome CDP tab discovery by validating existing page URLs with eight concurrent, bounded DNS/safety workers instead of sequential lookups.
14
+ - Limited enumeration to 1,000 candidate pages and 500 returned tabs while retaining ordering and the existing private-network/unsafe-scheme safety policy.
15
+ - Added regression coverage for concurrent discovery, ordered output, bounded work, and blocked pages; tool schemas and the 83-operation MCP surface remain unchanged.
16
+
3
17
  ## 0.2.146 — 2026-10-04
4
18
 
5
19
  - No local runtime tool behavior or schema changes. This patch keeps `@remcp/runtime` aligned with the ReMCP 0.2.146 OpenAI submission-metadata release.
package/README.md CHANGED
@@ -64,6 +64,8 @@ discovery for new clients.
64
64
 
65
65
  For browser typing, `browser_text` is the visible DOM locator while `text` is the Unicode payload. `browser_action(action="scroll")` accepts page/element deltas or direction/wheel steps and scrolls the page or nearest scrollable ancestor; `scroll_into_view` remains the separate semantic reveal operation.
66
66
 
67
+ When ReMCP auto-launches Chrome/Edge for local CDP, it uses a dedicated ReMCP browser profile. If that browser was interrupted (for example during an agent upgrade), and Chromium has saved tabs in that profile, the next launch requests Chromium's own last-session restoration instead of opening a new `about:blank` tab. A clean browser shutdown never triggers automatic reopening. This inspects only local session metadata, never reads or transmits tab URLs to ReMCP's relay. The browser itself controls which tabs can actually be recovered.
68
+
67
69
  For frontend QA, use semantic data to operate the page and rendered pixels to verify it: `browser_action(action="set_viewport")` sets an exact responsive-test viewport, `browser_wait` waits for the real application state, and `browser_snapshot(include_screenshot=true, selector=...)` recenters the component, returns its bounds, and attaches the real CDP-rendered viewport PNG. This intentionally treats screenshots as the visual oracle without making screenshot-driven coordinate guessing the primary control path.
68
70
 
69
71
  `set_config_value` remains deliberately narrow: it can change only `telemetryEnabled`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.146",
3
+ "version": "0.2.148",
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",
@@ -3,7 +3,7 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import { isIP } from 'node:net';
5
5
  import { lookup } from 'node:dns/promises';
6
- import { existsSync, mkdirSync } from 'node:fs';
6
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
7
7
  import { resolveSafePath } from '../util.mjs';
8
8
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
9
9
 
@@ -60,6 +60,75 @@ function browserDataDir() {
60
60
  return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
61
61
  }
62
62
 
63
+ // Browser sessions belong to the local user, not to the lifetime of the ReMCP
64
+ // agent. The agent can be restarted by an npm update while Chrome is open.
65
+ // If Chrome was interrupted and already persisted tabs, ask Chromium itself to
66
+ // restore the session. Never parse, persist, or relay the sensitive tab URLs.
67
+ // A clean user-initiated quit must not resurrect a deliberately closed session.
68
+ export function shouldRestoreBrowserSession(profile) {
69
+ let prefsFd;
70
+ let sessionFd;
71
+ try {
72
+ const defaultProfile = path.join(profile, 'Default');
73
+ // Open first, then validate the file descriptor: checking the path with
74
+ // lstat before reading it creates a symlink-swap TOCTOU vulnerability.
75
+ prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
76
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
77
+ const prefsStat = fstatSync(prefsFd);
78
+ if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
79
+ const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
80
+ if (prefs?.profile?.exit_type !== 'Crashed') return false;
81
+
82
+ const sessionDir = path.join(defaultProfile, 'Sessions');
83
+ let readableDir = sessionDir;
84
+ if (process.platform !== 'win32') {
85
+ // Use the opened directory even if another process renames the path.
86
+ // O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
87
+ sessionFd = openSync(sessionDir,
88
+ fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
89
+ if (!fstatSync(sessionFd).isDirectory()) return false;
90
+ readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
91
+ }
92
+ return readdirSync(readableDir).some(name => {
93
+ if (!/^Tabs_\d+$/.test(name)) return false;
94
+ let tabFd;
95
+ try {
96
+ tabFd = openSync(path.join(readableDir, name),
97
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
98
+ const stat = fstatSync(tabFd);
99
+ return stat.isFile() && stat.size > 0;
100
+ } catch { return false; }
101
+ finally { if (tabFd !== undefined) closeSync(tabFd); }
102
+ });
103
+ } catch {
104
+ // A new, malformed or inaccessible profile must start fresh, not fail CDP.
105
+ return false;
106
+ } finally {
107
+ if (sessionFd !== undefined) closeSync(sessionFd);
108
+ if (prefsFd !== undefined) closeSync(prefsFd);
109
+ }
110
+ }
111
+
112
+ export function browserAutoLaunchArgs(profile, {
113
+ platform = process.platform,
114
+ display = process.env.DISPLAY,
115
+ waylandDisplay = process.env.WAYLAND_DISPLAY,
116
+ } = {}) {
117
+ const restore = shouldRestoreBrowserSession(profile);
118
+ const args = [
119
+ '--remote-debugging-address=127.0.0.1',
120
+ '--remote-debugging-port=9222',
121
+ `--user-data-dir=${profile}`,
122
+ '--no-first-run',
123
+ '--no-default-browser-check',
124
+ // --restore-last-session only for an interrupted, persisted local profile.
125
+ // An explicit about:blank startup URL would suppress the normal restore.
126
+ ...(restore ? ['--restore-last-session'] : ['about:blank']),
127
+ ];
128
+ if (platform === 'linux' && !display && !waylandDisplay) args.unshift('--headless=new');
129
+ return args;
130
+ }
131
+
63
132
  async function waitForBrowserEndpoint(timeoutMs = 5000) {
64
133
  const deadline = Date.now() + timeoutMs;
65
134
  do {
@@ -79,16 +148,7 @@ async function ensureBrowserEndpoint(endpoint) {
79
148
  if (!executable) throw new Error('Browser control is unavailable: no supported Chrome, Edge, or Chromium executable was found');
80
149
  const profile = browserDataDir();
81
150
  mkdirSync(profile, { recursive:true, mode:0o700 });
82
- const args = [
83
- '--remote-debugging-address=127.0.0.1',
84
- '--remote-debugging-port=9222',
85
- `--user-data-dir=${profile}`,
86
- '--no-first-run',
87
- '--no-default-browser-check',
88
- 'about:blank',
89
- ];
90
- if (process.platform === 'linux' && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) args.unshift('--headless=new');
91
- spawnDetached(executable, args);
151
+ spawnDetached(executable, browserAutoLaunchArgs(profile));
92
152
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
93
153
  })().finally(() => { browserLaunchPromise = null; });
94
154
  }
@@ -317,22 +377,43 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
317
377
  }
318
378
  }
319
379
 
320
- export async function listBrowserTargets(endpoint) {
321
- assertBrowserRemoteEnabled();
322
- await ensureBrowserEndpoint(endpoint);
323
- const list = await requestJson('/json/list', endpoint);
380
+ // Validating every tab sequentially can require hundreds of serial DNS lookups,
381
+ // making browser_tabs exceed the relay's deadline on machines with many tabs.
382
+ // Keep DNS and security validation, but bound both the work and its concurrency.
383
+ export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl } = {}) {
324
384
  if (!Array.isArray(list)) return [];
325
- const targets = [];
385
+ const candidates = [];
326
386
  for (const item of list) {
327
387
  if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
328
- try {
329
- await assertResolvedPageUrl(item.url);
330
- targets.push(item);
331
- } catch {}
388
+ candidates.push(item);
389
+ if (candidates.length >= 1000) break;
390
+ }
391
+ const safe = new Array(candidates.length).fill(false);
392
+ let cursor = 0;
393
+ await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, async () => {
394
+ while (cursor < candidates.length) {
395
+ const index = cursor++;
396
+ try {
397
+ await validate(candidates[index].url);
398
+ safe[index] = true;
399
+ } catch {
400
+ // Exclude unsafe/unresolvable pages without exposing them to the caller.
401
+ }
402
+ }
403
+ }));
404
+ const targets = [];
405
+ for (let i = 0; i < candidates.length && targets.length < 500; i++) {
406
+ if (safe[i]) targets.push(candidates[i]);
332
407
  }
333
408
  return targets;
334
409
  }
335
410
 
411
+ export async function listBrowserTargets(endpoint) {
412
+ assertBrowserRemoteEnabled();
413
+ await ensureBrowserEndpoint(endpoint);
414
+ return filterBrowserTargets(await requestJson('/json/list', endpoint));
415
+ }
416
+
336
417
  function validatedTargetWebSocketUrl(target, endpoint) {
337
418
  let socketUrl;
338
419
  try { socketUrl = new URL(String(target?.webSocketDebuggerUrl)); }