@remcp/runtime 0.2.163 → 0.2.165

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,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.165 — 2026-10-18
4
+
5
+ **Crash-session recovery without losing browser automation when the local snapshot budget fills.**
6
+
7
+ - When the dedicated Chromium profile already has 12 interrupted-session backups, preserve its tabs and all evidence and start a single, owner-only isolated browser profile instead of failing to launch.
8
+ - Reuse the isolated profile across subsequent starts; never copy credentials or account state. Emit a local warning that sign-in state is not shared.
9
+ - Still fail closed on unsafe symlinks, oversized evidence, unowned recovery directories or exhausted isolated-profile recovery capacity. No automatic deletion of backups.
10
+ - Keep 83 runtime operations, the ten hosted MCP definitions, URL/DNS/SSRF checks and authorization boundaries unchanged. Add regression tests for full-capacity and unsafe-profile cases.
11
+
3
12
  ## 0.2.154 — 2026-10-17
4
13
 
5
14
  **ReMCP Gray v2 brand parity release.** The runtime tool contract remains at 83 operations with unchanged permissions and I/O semantics. The coordinated public client and OpenAI submission assets receive the owner-provided gray identity.
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. 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.
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 the primary profile reaches the 12-snapshot limit, ReMCP switches to one separate owner-only isolated browser profile rather than overwriting prior recovery evidence. The original profile, its tabs and backups remain on disk; credentials and sign-in state are **not** copied into the isolated profile, and a warning is logged. Subsequent launches reuse that isolated profile. If either profile has unsafe evidence (for example symlinks or oversized files) or the isolated profile also reaches its recovery limit, browser auto-launch fails closed. 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.163",
3
+ "version": "0.2.165",
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,17 @@
1
+ // Coalesce *only overlapping* read-only CDP /json/list transport requests.
2
+ // Raw tab metadata is held only until request completion; each caller must
3
+ // re-validate all target URLs/DNS independently before returning any tabs.
4
+ const active = new Map();
5
+ const MAX_ENDPOINTS = 8;
6
+
7
+ export async function shareBrowserDiscovery(origin, load) {
8
+ const pending = active.get(origin);
9
+ if (pending) return pending;
10
+ if (active.size >= MAX_ENDPOINTS) return load();
11
+ const work = Promise.resolve().then(load);
12
+ active.set(origin, work);
13
+ try { return await work; }
14
+ finally {
15
+ if (active.get(origin) === work) active.delete(origin);
16
+ }
17
+ }
@@ -5,7 +5,8 @@ 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
+ import { selectSafeBrowserLaunchProfile } from './browser-session-backup.mjs';
9
+ import { shareBrowserDiscovery } from './browser-discovery-flight.mjs';
9
10
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
10
11
 
11
12
 
@@ -153,8 +154,9 @@ export async function ensureBrowserEndpoint(endpoint) {
153
154
  mkdirSync(profile, { recursive:true, mode:0o700 });
154
155
  // Chrome rotates its Sessions/ files on startup. Protect existing local
155
156
  // evidence before a restart, including profiles with only Tabs_* left.
156
- preserveInterruptedBrowserSession(profile);
157
- spawnDetached(executable, browserAutoLaunchArgs(profile));
157
+ const selected = selectSafeBrowserLaunchProfile(profile);
158
+ if (selected.isolated) console.warn('ReMCP browser: using an isolated recovery profile; existing session and backups are preserved, and sign-in state is not copied.');
159
+ spawnDetached(executable, browserAutoLaunchArgs(selected.profile));
158
160
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
159
161
  })().finally(() => { browserLaunchPromise = null; });
160
162
  }
@@ -436,14 +438,16 @@ export async function filterBrowserTargets(list, { validate = assertResolvedPage
436
438
  return targets;
437
439
  }
438
440
 
439
- export async function listBrowserTargets(endpoint, { targetId = null } = {}) {
441
+ export async function listBrowserTargets(endpoint, { targetId = null, readOnlySingleFlight = false, validate = assertResolvedPageUrl } = {}) {
440
442
  assertBrowserRemoteEnabled();
441
443
  // Reading tabs and page semantics must not launch a separate Chromium/profile.
442
444
  // Only an explicit browser_navigate(action=new_tab) can bootstrap an isolated browser.
443
445
  const base = endpointUrl(endpoint); // keeps the CDP address loopback-only
444
446
  let list;
445
447
  try {
446
- list = await requestJson('/json/list', endpoint, { timeout_ms: 1500 });
448
+ const load = () => requestJson('/json/list', endpoint, { timeout_ms: 1500 });
449
+ list = readOnlySingleFlight && !base.username && !base.password
450
+ ? await shareBrowserDiscovery(base.origin, load) : await load();
447
451
  } catch (error) {
448
452
  const message = String(error?.message || '');
449
453
  // Keep HTTP/policy/oversize/malformed-response errors distinct from an unavailable CDP.
@@ -453,7 +457,7 @@ export async function listBrowserTargets(endpoint, { targetId = null } = {}) {
453
457
  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.`);
454
458
  }
455
459
  // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
456
- return filterBrowserTargets(list, { targetId });
460
+ return filterBrowserTargets(list, { targetId, validate });
457
461
  }
458
462
 
459
463
  export function validatedTargetWebSocketUrl(target, endpoint) {
@@ -14,7 +14,7 @@ import { assertResolvedBrowserUrl, assertResolvedPageUrl, boundedText, endpointU
14
14
  import { createGuardedNewTab, evaluate, withTarget } from './browser-session.mjs';
15
15
 
16
16
  export async function browserTabs(args = {}) {
17
- const targets = await listBrowserTargets(args.endpoint);
17
+ const targets = await listBrowserTargets(args.endpoint, { readOnlySingleFlight:true });
18
18
  const pages = targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
19
19
  id: boundedText(item.id, 256),
20
20
  title: boundedText(item.title, 1000),
@@ -42,7 +42,8 @@ function safeDirectory(dir) {
42
42
  if (process.platform === 'win32' && lstatSync(dir).isSymbolicLink()) throw new Error('Unsafe recovery directory');
43
43
  const fd = openSync(dir, constants.O_RDONLY | DIRECTORY | NOFOLLOW);
44
44
  try {
45
- if (!fstatSync(fd).isDirectory()) throw new Error('Unsafe recovery directory');
45
+ const stat = fstatSync(fd);
46
+ if (!stat.isDirectory() || (process.platform !== 'win32' && typeof process.getuid === 'function' && stat.uid !== process.getuid())) throw new Error('Unsafe recovery directory');
46
47
  if (process.platform !== 'win32') fchmodSync(fd, 0o700);
47
48
  } finally { closeSync(fd); }
48
49
  }
@@ -133,3 +134,28 @@ export function preserveInterruptedBrowserSession(profile) {
133
134
  }
134
135
  } finally { if (dirFd !== undefined) closeSync(dirFd); }
135
136
  }
137
+
138
+ // Never overwrite an interrupted Chromium profile when its recovery evidence
139
+ // is at capacity. One sibling profile keeps automation available while
140
+ // retaining the original profile and all snapshot files untouched.
141
+ export function selectSafeBrowserLaunchProfile(profile) {
142
+ const isolated = profile + '-isolated';
143
+ let prior = false;
144
+ try { lstatSync(isolated); prior = true; }
145
+ catch (error) { if (error?.code !== 'ENOENT') throw error; }
146
+ if (prior) {
147
+ safeDirectory(isolated);
148
+ preserveInterruptedBrowserSession(isolated);
149
+ return { profile: isolated, isolated: true };
150
+ }
151
+ try {
152
+ preserveInterruptedBrowserSession(profile);
153
+ return { profile, isolated: false };
154
+ } catch (error) {
155
+ if (error?.message !== 'Session recovery snapshot limit reached') throw error;
156
+ // Never prune user-owned session recovery evidence.
157
+ safeDirectory(isolated);
158
+ preserveInterruptedBrowserSession(isolated);
159
+ return { profile: isolated, isolated: true };
160
+ }
161
+ }