@remcp/runtime 0.2.147 → 0.2.149

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.149 — 2026-10-14
4
+
5
+ - Automatically snapshot raw, local-only Chromium session files on crashed ReMCP CDP profiles before browser auto-launch; preserve orphaned tabs and prior snapshots without leaking URLs.
6
+ - Fail closed if session evidence cannot be backed up safely, and cap file sizes, total data and snapshot count.
7
+ - Add seven unit tests and document restoration limitations. No new runtime operations or MCP schema changes.
8
+
9
+ ## 0.2.148 — 2026-10-14
10
+
11
+ - Request Chromium-native `--restore-last-session` only when the dedicated ReMCP browser profile has a crashed exit marker and persisted tab session files.
12
+ - Preserve intentional clean browser shutdown and first-run behavior; avoid following symlinked session directories or files.
13
+ - Use atomic file-descriptor validation for local session metadata to avoid check-then-open file races (CodeQL TOCTOU).
14
+ - Add cross-platform regression coverage for interrupted, clean, missing, malformed, new, and symlinked browser session profiles.
15
+ - No new tools or MCP schema changes.
16
+
3
17
  ## 0.2.147 — 2026-10-14
4
18
 
5
19
  - Accelerated Chrome CDP tab discovery by validating existing page URLs with eight concurrent, bounded DNS/safety workers instead of sequential lookups.
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. On an interrupted profile, ReMCP now saves a private, local-only snapshot of `Session_*` and `Tabs_*` files before Chromium can rotate them on startup, even when only tab files remain. Recovery snapshots are adjacent to the dedicated browser profile in a `browser-cdp-recovery/` directory (owner-only on Unix); existing snapshots are never deleted automatically. Copy limits are 16 regular files, 32 MiB per file, 64 MiB per snapshot and 12 distinct snapshots. If evidence cannot be protected safely (for example due to symlinks, storage exhaustion or the snapshot limit), browser auto-launch fails rather than silently overwriting its only recoverable copy. No session URLs are parsed, returned through tools, or uploaded. You can review and manage those local snapshots yourself.
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.147",
3
+ "version": "0.2.149",
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",
@@ -0,0 +1,135 @@
1
+ import path from 'node:path';
2
+ import process from 'node:process';
3
+ import { closeSync, fchmodSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync, writeSync } from 'node:fs';
4
+
5
+ const MAX_FILE_BYTES = 32 * 1024 * 1024;
6
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
7
+ const MAX_FILES = 16;
8
+ const MAX_SNAPSHOTS = 12;
9
+ const NOFOLLOW = constants.O_NOFOLLOW || 0;
10
+ const DIRECTORY = constants.O_DIRECTORY || 0;
11
+
12
+ function ownedRegularFile(file, maxBytes) {
13
+ let fd;
14
+ try {
15
+ if (process.platform === 'win32' && lstatSync(file).isSymbolicLink()) return null;
16
+ fd = openSync(file, constants.O_RDONLY | NOFOLLOW);
17
+ const stat = fstatSync(fd);
18
+ if (!stat.isFile() || stat.size <= 0 || stat.size > maxBytes) return null;
19
+ return { size: stat.size, mtimeMs: stat.mtimeMs };
20
+ } catch { return null; }
21
+ finally { if (fd !== undefined) closeSync(fd); }
22
+ }
23
+
24
+ function sessionMetadata(profile) {
25
+ const file = path.join(profile, 'Default', 'Preferences');
26
+ let fd;
27
+ try {
28
+ if (process.platform === 'win32' && lstatSync(file).isSymbolicLink()) return null;
29
+ fd = openSync(file, constants.O_RDONLY | NOFOLLOW);
30
+ const stat = fstatSync(fd);
31
+ if (!stat.isFile() || stat.size > 4 * 1024 * 1024) return null;
32
+ return JSON.parse(readFileSync(fd, 'utf8'))?.profile?.exit_type || null;
33
+ } catch { return null; }
34
+ finally { if (fd !== undefined) closeSync(fd); }
35
+ }
36
+
37
+ function safeDirectory(dir) {
38
+ try { mkdirSync(dir, { recursive: false, mode: 0o700 }); }
39
+ catch (error) { if (error?.code !== 'EEXIST') throw error; }
40
+ // Open first and validate the handle, not the path: an lstat-then-open
41
+ // check would leave a filesystem race at the recovery destination.
42
+ if (process.platform === 'win32' && lstatSync(dir).isSymbolicLink()) throw new Error('Unsafe recovery directory');
43
+ const fd = openSync(dir, constants.O_RDONLY | DIRECTORY | NOFOLLOW);
44
+ try {
45
+ if (!fstatSync(fd).isDirectory()) throw new Error('Unsafe recovery directory');
46
+ if (process.platform !== 'win32') fchmodSync(fd, 0o700);
47
+ } finally { closeSync(fd); }
48
+ }
49
+
50
+ function rawCopy(from, to, expected) {
51
+ let source;
52
+ let destination;
53
+ try {
54
+ source = openSync(from, constants.O_RDONLY | NOFOLLOW);
55
+ const before = fstatSync(source);
56
+ if (!before.isFile() || before.size !== expected.size || before.mtimeMs !== expected.mtimeMs) {
57
+ throw new Error('Browser session changed while preparing the snapshot');
58
+ }
59
+ destination = openSync(to, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | NOFOLLOW, 0o600);
60
+ const buffer = Buffer.alloc(128 * 1024);
61
+ let total = 0;
62
+ while (total < expected.size) {
63
+ const count = readSync(source, buffer, 0, Math.min(buffer.length, expected.size - total), null);
64
+ if (count === 0) throw new Error('Browser session was truncated during snapshot');
65
+ let written = 0;
66
+ while (written < count) {
67
+ const next = writeSync(destination, buffer, written, count - written);
68
+ if (!next) throw new Error('Could not finish session backup write');
69
+ written += next;
70
+ }
71
+ total += count;
72
+ }
73
+ const after = fstatSync(source);
74
+ if (after.size !== before.size || after.mtimeMs !== before.mtimeMs) {
75
+ throw new Error('Browser session changed during snapshot');
76
+ }
77
+ fsyncSync(destination);
78
+ } finally {
79
+ if (source !== undefined) closeSync(source);
80
+ if (destination !== undefined) closeSync(destination);
81
+ }
82
+ }
83
+
84
+ // Called only before auto-launch of a *crashed* dedicated ReMCP CDP profile.
85
+ // The source data stays on the user's computer; no tab URLs are parsed or sent
86
+ // across the MCP connection. Never delete prior snapshots automatically.
87
+ export function preserveInterruptedBrowserSession(profile) {
88
+ if (sessionMetadata(profile) !== 'Crashed') return { saved: false, reason: 'clean-or-missing' };
89
+ const sessionDir = path.join(profile, 'Default', 'Sessions');
90
+ let dirFd;
91
+ try {
92
+ if (process.platform !== 'win32') {
93
+ try { dirFd = openSync(sessionDir, constants.O_RDONLY | DIRECTORY | NOFOLLOW); }
94
+ catch (error) { if (error?.code === 'ENOENT') return { saved: false, reason: 'no-sessions' }; throw error; }
95
+ if (!fstatSync(dirFd).isDirectory()) throw new Error('Session directory is unsafe');
96
+ }
97
+ const readableDir = dirFd === undefined ? sessionDir : `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${dirFd}`;
98
+ const files = [];
99
+ let total = 0;
100
+ for (const name of readdirSync(readableDir).sort()) {
101
+ if (!/^(?:Session|Tabs)_\d+$/.test(name)) continue;
102
+ const details = ownedRegularFile(path.join(readableDir, name), MAX_FILE_BYTES);
103
+ if (!details) throw new Error('Session file is unsafe, empty or oversized');
104
+ total += details.size;
105
+ files.push({ name, ...details });
106
+ if (files.length > MAX_FILES || total > MAX_TOTAL_BYTES) throw new Error('Session data exceeds safe snapshot limits');
107
+ }
108
+ if (!files.some(file => file.name.startsWith('Tabs_'))) return { saved: false, reason: 'no-tabs' };
109
+
110
+ const recoveryDir = path.join(path.dirname(profile), `${path.basename(profile)}-recovery`);
111
+ safeDirectory(recoveryDir);
112
+ const fingerprint = JSON.stringify(files);
113
+ const snapshots = readdirSync(recoveryDir).filter(name => /^snapshot-[A-Za-z0-9]+$/.test(name));
114
+ for (const name of snapshots) {
115
+ const manifest = path.join(recoveryDir, name, 'manifest.json');
116
+ try {
117
+ const fd = openSync(manifest, constants.O_RDONLY | NOFOLLOW);
118
+ let content;
119
+ try { if (fstatSync(fd).size > 8192) continue; content = JSON.parse(readFileSync(fd, 'utf8')); }
120
+ finally { closeSync(fd); }
121
+ if (content?.fingerprint === fingerprint) return { saved: false, reason: 'already-saved' };
122
+ } catch { /* Ignore incomplete previous attempts; never overwrite them. */ }
123
+ }
124
+ if (snapshots.length >= MAX_SNAPSHOTS) throw new Error('Session recovery snapshot limit reached');
125
+ const snapshotDir = mkdtempSync(path.join(recoveryDir, 'snapshot-'));
126
+ try {
127
+ for (const file of files) rawCopy(path.join(readableDir, file.name), path.join(snapshotDir, file.name), file);
128
+ writeFileSync(path.join(snapshotDir, 'manifest.json'), JSON.stringify({ fingerprint, count: files.length, hasWindowSession: files.some(f => f.name.startsWith('Session_')) }), { flag: 'wx', mode: 0o600 });
129
+ return { saved: true, reason: 'saved', count: files.length, bytes: total };
130
+ } catch (error) {
131
+ rmSync(snapshotDir, { recursive: true, force: true });
132
+ throw error;
133
+ }
134
+ } finally { if (dirFd !== undefined) closeSync(dirFd); }
135
+ }
@@ -3,8 +3,9 @@ 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
+ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs';
8
9
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
9
10
 
10
11
  const DEFAULT_ENDPOINT = 'http://127.0.0.1:9222';
@@ -60,6 +61,75 @@ function browserDataDir() {
60
61
  return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
61
62
  }
62
63
 
64
+ // Browser sessions belong to the local user, not to the lifetime of the ReMCP
65
+ // agent. The agent can be restarted by an npm update while Chrome is open.
66
+ // If Chrome was interrupted and already persisted tabs, ask Chromium itself to
67
+ // restore the session. Never parse, persist, or relay the sensitive tab URLs.
68
+ // A clean user-initiated quit must not resurrect a deliberately closed session.
69
+ export function shouldRestoreBrowserSession(profile) {
70
+ let prefsFd;
71
+ let sessionFd;
72
+ try {
73
+ const defaultProfile = path.join(profile, 'Default');
74
+ // Open first, then validate the file descriptor: checking the path with
75
+ // lstat before reading it creates a symlink-swap TOCTOU vulnerability.
76
+ prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
77
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
78
+ const prefsStat = fstatSync(prefsFd);
79
+ if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
80
+ const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
81
+ if (prefs?.profile?.exit_type !== 'Crashed') return false;
82
+
83
+ const sessionDir = path.join(defaultProfile, 'Sessions');
84
+ let readableDir = sessionDir;
85
+ if (process.platform !== 'win32') {
86
+ // Use the opened directory even if another process renames the path.
87
+ // O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
88
+ sessionFd = openSync(sessionDir,
89
+ fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
90
+ if (!fstatSync(sessionFd).isDirectory()) return false;
91
+ readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
92
+ }
93
+ return readdirSync(readableDir).some(name => {
94
+ if (!/^Tabs_\d+$/.test(name)) return false;
95
+ let tabFd;
96
+ try {
97
+ tabFd = openSync(path.join(readableDir, name),
98
+ fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
99
+ const stat = fstatSync(tabFd);
100
+ return stat.isFile() && stat.size > 0;
101
+ } catch { return false; }
102
+ finally { if (tabFd !== undefined) closeSync(tabFd); }
103
+ });
104
+ } catch {
105
+ // A new, malformed or inaccessible profile must start fresh, not fail CDP.
106
+ return false;
107
+ } finally {
108
+ if (sessionFd !== undefined) closeSync(sessionFd);
109
+ if (prefsFd !== undefined) closeSync(prefsFd);
110
+ }
111
+ }
112
+
113
+ export function browserAutoLaunchArgs(profile, {
114
+ platform = process.platform,
115
+ display = process.env.DISPLAY,
116
+ waylandDisplay = process.env.WAYLAND_DISPLAY,
117
+ } = {}) {
118
+ const restore = shouldRestoreBrowserSession(profile);
119
+ const args = [
120
+ '--remote-debugging-address=127.0.0.1',
121
+ '--remote-debugging-port=9222',
122
+ `--user-data-dir=${profile}`,
123
+ '--no-first-run',
124
+ '--no-default-browser-check',
125
+ // --restore-last-session only for an interrupted, persisted local profile.
126
+ // An explicit about:blank startup URL would suppress the normal restore.
127
+ ...(restore ? ['--restore-last-session'] : ['about:blank']),
128
+ ];
129
+ if (platform === 'linux' && !display && !waylandDisplay) args.unshift('--headless=new');
130
+ return args;
131
+ }
132
+
63
133
  async function waitForBrowserEndpoint(timeoutMs = 5000) {
64
134
  const deadline = Date.now() + timeoutMs;
65
135
  do {
@@ -79,16 +149,10 @@ async function ensureBrowserEndpoint(endpoint) {
79
149
  if (!executable) throw new Error('Browser control is unavailable: no supported Chrome, Edge, or Chromium executable was found');
80
150
  const profile = browserDataDir();
81
151
  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);
152
+ // Chrome rotates its Sessions/ files on startup. Protect existing local
153
+ // evidence before a restart, including profiles with only Tabs_* left.
154
+ preserveInterruptedBrowserSession(profile);
155
+ spawnDetached(executable, browserAutoLaunchArgs(profile));
92
156
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
93
157
  })().finally(() => { browserLaunchPromise = null; });
94
158
  }