@remcp/runtime 0.2.148 → 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,11 @@
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
+
3
9
  ## 0.2.148 — 2026-10-14
4
10
 
5
11
  - Request Chromium-native `--restore-last-session` only when the dedicated ReMCP browser profile has a crashed exit marker and persisted tab session files.
package/README.md CHANGED
@@ -64,7 +64,7 @@ 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.
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
68
 
69
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.
70
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.148",
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
+ }
@@ -5,6 +5,7 @@ import { isIP } from 'node:net';
5
5
  import { lookup } from 'node:dns/promises';
6
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';
@@ -148,6 +149,9 @@ async function ensureBrowserEndpoint(endpoint) {
148
149
  if (!executable) throw new Error('Browser control is unavailable: no supported Chrome, Edge, or Chromium executable was found');
149
150
  const profile = browserDataDir();
150
151
  mkdirSync(profile, { recursive:true, mode:0o700 });
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);
151
155
  spawnDetached(executable, browserAutoLaunchArgs(profile));
152
156
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
153
157
  })().finally(() => { browserLaunchPromise = null; });