@remcp/runtime 0.2.148 → 0.2.150

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.150 — 2026-10-15
4
+
5
+ - Read-only browser tab discovery and semantic snapshots no longer auto-launch a separate Chromium browser when CDP is unavailable.
6
+ - Give a clear Accessibility fallback (`ui_snapshot` / `ui_find`) without changing existing typed `structuredContent` for empty and non-empty lists.
7
+ - Keep explicit navigation as the sole browser auto-launch entry point, with existing CDP loopback and target safety controls.
8
+ - Add five regression tests. No new MCP tools or schemas; the 83-operation runtime catalog is preserved.
9
+
10
+
11
+ ## 0.2.149 — 2026-10-14
12
+
13
+ - 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.
14
+ - Fail closed if session evidence cannot be backed up safely, and cap file sizes, total data and snapshot count.
15
+ - Add seven unit tests and document restoration limitations. No new runtime operations or MCP schema changes.
16
+
3
17
  ## 0.2.148 — 2026-10-14
4
18
 
5
19
  - 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.150",
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; });
@@ -410,8 +414,22 @@ export async function filterBrowserTargets(list, { validate = assertResolvedPage
410
414
 
411
415
  export async function listBrowserTargets(endpoint) {
412
416
  assertBrowserRemoteEnabled();
413
- await ensureBrowserEndpoint(endpoint);
414
- return filterBrowserTargets(await requestJson('/json/list', endpoint));
417
+ // Reading tabs and page semantics must not launch a separate Chromium/profile.
418
+ // Only an explicit browser_navigate(action=new_tab) can bootstrap an isolated browser.
419
+ const base = endpointUrl(endpoint); // keeps the CDP address loopback-only
420
+ let list;
421
+ try {
422
+ list = await requestJson('/json/list', endpoint, { timeout_ms: 1500 });
423
+ } catch (error) {
424
+ const message = String(error?.message || '');
425
+ // Keep HTTP/policy/oversize/malformed-response errors distinct from an unavailable CDP.
426
+ if (!/fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|timed out|network error/i.test(message)) throw error;
427
+ const configured = Boolean(endpoint || process.env.REMCP_CDP_URL);
428
+ const label = configured ? 'The configured CDP endpoint' : 'Browser CDP endpoint';
429
+ throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser.`);
430
+ }
431
+ // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
432
+ return filterBrowserTargets(list);
415
433
  }
416
434
 
417
435
  function validatedTargetWebSocketUrl(target, endpoint) {
@@ -851,12 +869,18 @@ async function callPageFunction(session, functionDeclaration, args = [], awaitPr
851
869
 
852
870
  export async function browserTabs(args = {}) {
853
871
  const targets = await listBrowserTargets(args.endpoint);
854
- return jsonResult(targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
872
+ const pages = targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
855
873
  id: boundedText(item.id, 256),
856
874
  title: boundedText(item.title, 1000),
857
875
  url: boundedText(item.url, 4096),
858
876
  description: boundedText(item.description, 1000),
859
- })));
877
+ }));
878
+ const result = jsonResult(pages);
879
+ // Keep the typed empty-array contract while providing a native fallback hint.
880
+ if (!pages.length) {
881
+ result.content = [{ type: 'text', text: 'CDP is connected, but no debuggable Chromium tabs are available. Use ui_snapshot and ui_find to inspect existing browser windows without CDP; only open a new tab when explicitly requested.' }];
882
+ }
883
+ return result;
860
884
  }
861
885
 
862
886
  async function waitForHistoryReady(session, expectedUrl, timeoutMs) {