@remcp/runtime 0.2.165 → 0.2.167

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.165",
3
+ "version": "0.2.167",
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,9 +3,10 @@ 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 { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
6
+ import { existsSync, mkdirSync } from 'node:fs';
7
7
  import { resolveSafePath } from '../util.mjs';
8
8
  import { selectSafeBrowserLaunchProfile } from './browser-session-backup.mjs';
9
+ import { shouldRestoreBrowserSession } from './browser-session-directory.mjs';
9
10
  import { shareBrowserDiscovery } from './browser-discovery-flight.mjs';
10
11
  import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
11
12
 
@@ -64,54 +65,7 @@ export function browserDataDir() {
64
65
  return path.join(process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'), 'remcp', 'browser-cdp');
65
66
  }
66
67
 
67
- // Browser sessions belong to the local user, not to the lifetime of the ReMCP
68
- // agent. The agent can be restarted by an npm update while Chrome is open.
69
- // If Chrome was interrupted and already persisted tabs, ask Chromium itself to
70
- // restore the session. Never parse, persist, or relay the sensitive tab URLs.
71
- // A clean user-initiated quit must not resurrect a deliberately closed session.
72
- export function shouldRestoreBrowserSession(profile) {
73
- let prefsFd;
74
- let sessionFd;
75
- try {
76
- const defaultProfile = path.join(profile, 'Default');
77
- // Open first, then validate the file descriptor: checking the path with
78
- // lstat before reading it creates a symlink-swap TOCTOU vulnerability.
79
- prefsFd = openSync(path.join(defaultProfile, 'Preferences'),
80
- fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
81
- const prefsStat = fstatSync(prefsFd);
82
- if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
83
- const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
84
- if (prefs?.profile?.exit_type !== 'Crashed') return false;
85
-
86
- const sessionDir = path.join(defaultProfile, 'Sessions');
87
- let readableDir = sessionDir;
88
- if (process.platform !== 'win32') {
89
- // Use the opened directory even if another process renames the path.
90
- // O_NOFOLLOW refuses a linked Sessions directory on Unix platforms.
91
- sessionFd = openSync(sessionDir,
92
- fsConstants.O_RDONLY | (fsConstants.O_DIRECTORY || 0) | (fsConstants.O_NOFOLLOW || 0));
93
- if (!fstatSync(sessionFd).isDirectory()) return false;
94
- readableDir = `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${sessionFd}`;
95
- }
96
- return readdirSync(readableDir).some(name => {
97
- if (!/^Tabs_\d+$/.test(name)) return false;
98
- let tabFd;
99
- try {
100
- tabFd = openSync(path.join(readableDir, name),
101
- fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW || 0));
102
- const stat = fstatSync(tabFd);
103
- return stat.isFile() && stat.size > 0;
104
- } catch { return false; }
105
- finally { if (tabFd !== undefined) closeSync(tabFd); }
106
- });
107
- } catch {
108
- // A new, malformed or inaccessible profile must start fresh, not fail CDP.
109
- return false;
110
- } finally {
111
- if (sessionFd !== undefined) closeSync(sessionFd);
112
- if (prefsFd !== undefined) closeSync(prefsFd);
113
- }
114
- }
68
+ export { shouldRestoreBrowserSession };
115
69
 
116
70
  export function browserAutoLaunchArgs(profile, {
117
71
  platform = process.platform,
@@ -144,8 +98,8 @@ export async function waitForBrowserEndpoint(timeoutMs = 5000) {
144
98
 
145
99
  export async function ensureBrowserEndpoint(endpoint) {
146
100
  assertBrowserRemoteEnabled();
147
- if (endpoint || process.env.REMCP_CDP_URL) return;
148
- if (await browserCapabilityAvailable(undefined, 250)) return;
101
+ if (endpoint || process.env.REMCP_CDP_URL) return false;
102
+ if (await browserCapabilityAvailable(undefined, 250)) return false;
149
103
  if (!browserLaunchPromise) {
150
104
  browserLaunchPromise = (async () => {
151
105
  const executable = browserExecutable();
@@ -158,9 +112,10 @@ export async function ensureBrowserEndpoint(endpoint) {
158
112
  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
113
  spawnDetached(executable, browserAutoLaunchArgs(selected.profile));
160
114
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
115
+ return true;
161
116
  })().finally(() => { browserLaunchPromise = null; });
162
117
  }
163
- await browserLaunchPromise;
118
+ return browserLaunchPromise;
164
119
  }
165
120
 
166
121
  export async function browserControlAvailable(endpoint, timeoutMs = 500) {
@@ -239,11 +194,17 @@ export function isPrivateBrowserHost(hostname) {
239
194
  }
240
195
 
241
196
  export function endpointUrl(value) {
197
+ if (value != null && typeof value !== 'string' && !(value instanceof URL)) {
198
+ throw new Error('endpoint must be a valid local http(s) URL');
199
+ }
242
200
  let url;
243
- try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
244
- catch { throw new Error('endpoint must be a valid local http(s) URL'); }
201
+ try {
202
+ const provided = value instanceof URL ? value.href : optionalString(value);
203
+ url = new URL(provided || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT);
204
+ } catch { throw new Error('endpoint must be a valid local http(s) URL'); }
245
205
  if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
246
206
  if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
207
+ if (url.username || url.password) throw new Error('Browser CDP endpoint must not contain credentials');
247
208
  return url;
248
209
  }
249
210
 
@@ -98,18 +98,62 @@ export async function browserNavigate(args = {}) {
98
98
  });
99
99
  }
100
100
 
101
+ // CDP accessibility can include an input's current value even when it is
102
+ // masked on screen. The protected AX property must be checked before the
103
+ // node is mirrored to structuredContent or snapshot diff state.
104
+ export function safeAccessibilityValue(node) {
105
+ const props = Array.isArray(node?.properties) ? node.properties : [];
106
+ const protectedControl = props.some(item => item?.name === 'protected'
107
+ && (item?.value?.value === true || String(item?.value?.value) === 'true'));
108
+ const role = String(node?.role?.value || '').toLowerCase();
109
+ if (protectedControl || role === 'password' || role === 'passwordtext') return '';
110
+ return boundedText(node?.value?.value, 1000);
111
+ }
112
+
113
+ // Chromium's AX tree can repeat editable input values in descendant
114
+ // StaticText/InlineTextBox names. Blank the complete editable subtree before
115
+ // storing a snapshot diff or sending structured content to the MCP host.
116
+ export function sensitiveAccessibilityDescendants(nodes) {
117
+ const indexed = new Map(nodes.map(node => [node.nodeId, node]));
118
+ const secret = new Set();
119
+ const queue = [];
120
+ for (const node of nodes) {
121
+ const role = String(node.role?.value || '').toLowerCase();
122
+ const properties = Array.isArray(node.properties) ? node.properties : [];
123
+ const editable = properties.some(item => item?.name === 'editable'
124
+ && ![false,'false',null].includes(item?.value?.value));
125
+ const protectedNode = properties.some(item => item?.name === 'protected'
126
+ && [true,'true'].includes(item?.value?.value));
127
+ if (['textbox','searchbox','combobox','password','passwordtext','spinbutton'].includes(role)
128
+ || editable || protectedNode) {
129
+ secret.add(node.nodeId);
130
+ for (const id of node.childIds || []) queue.push(id);
131
+ }
132
+ }
133
+ while (queue.length) {
134
+ const id = queue.pop();
135
+ if (secret.has(id)) continue;
136
+ secret.add(id);
137
+ for (const child of indexed.get(id)?.childIds || []) queue.push(child);
138
+ }
139
+ return secret;
140
+ }
141
+
101
142
  export async function browserSnapshot(args) {
102
143
  return withTarget(args, async (session, target) => {
103
144
  await session.send('Accessibility.enable');
104
145
  const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
105
146
  const limit = clamp(args.max_nodes, 1500, 1, 10_000);
147
+ const redacted = sensitiveAccessibilityDescendants(nodes);
106
148
  const simplified = nodes.slice(0, limit).map(node => ({
107
149
  node_id: boundedText(node.nodeId, 256),
108
150
  ignored: Boolean(node.ignored),
109
151
  role: boundedText(node.role?.value, 256),
110
- name: boundedText(node.name?.value, 1000),
111
- value: boundedText(node.value?.value, 1000),
112
- description: boundedText(node.description?.value, 1000),
152
+ name: redacted.has(node.nodeId) && !['textbox','searchbox','combobox','spinbutton'].includes(
153
+ String(node.role?.value||'').toLowerCase()
154
+ ) ? '' : boundedText(node.name?.value, 1000),
155
+ value: redacted.has(node.nodeId) ? '' : safeAccessibilityValue(node),
156
+ description: redacted.has(node.nodeId) ? '' : boundedText(node.description?.value, 1000),
113
157
  child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
114
158
  backend_dom_node_id: node.backendDOMNodeId || null,
115
159
  }));
@@ -285,9 +329,19 @@ export function findExpression(args) {
285
329
  } else {
286
330
  selected = selected.slice(0, ${limit});
287
331
  }
332
+ // Credentials are never sent back in browser_find; even a normal
333
+ // text input can carry an OTP or API token via its name/autocomplete.
334
+ function isSensitiveControl(el) {
335
+ const tag = el.tagName.toLowerCase();
336
+ if (tag !== 'input' && tag !== 'textarea') return false;
337
+ if (tag === 'input' && String(el.type || '').toLowerCase() === 'password') return true;
338
+ const description = ['id','name','autocomplete'].map(key => el.getAttribute(key) || '').join(' ');
339
+ return /(?:password|passwd|passcode|one.time.code|otp|secret|api.?key|auth.?token|access.?token|recovery.?code)/i.test(description);
340
+ }
288
341
  return selected.map(({ el }) => {
289
342
  const b = el.getBoundingClientRect();
290
- return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: visibleText(el).slice(0, 500), value: 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
343
+ const sensitive = isSensitiveControl(el);
344
+ return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: sensitive ? '' : visibleText(el).slice(0, 500), value: !sensitive && 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
291
345
  });
292
346
  })()`;
293
347
  }
@@ -1,5 +1,6 @@
1
1
  import path from 'node:path';
2
2
  import process from 'node:process';
3
+ import { scanSessionDirectory, verifySessionDirectory } from './browser-session-directory.mjs';
3
4
  import { closeSync, fchmodSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, readdirSync, rmSync, writeFileSync, writeSync } from 'node:fs';
4
5
 
5
6
  const MAX_FILE_BYTES = 32 * 1024 * 1024;
@@ -95,12 +96,14 @@ export function preserveInterruptedBrowserSession(profile) {
95
96
  catch (error) { if (error?.code === 'ENOENT') return { saved: false, reason: 'no-sessions' }; throw error; }
96
97
  if (!fstatSync(dirFd).isDirectory()) throw new Error('Session directory is unsafe');
97
98
  }
98
- const readableDir = dirFd === undefined ? sessionDir : `${process.platform === 'darwin' ? '/dev/fd' : '/proc/self/fd'}/${dirFd}`;
99
+ const { readable: readableDir, names } = scanSessionDirectory(sessionDir, dirFd);
99
100
  const files = [];
100
101
  let total = 0;
101
- for (const name of readdirSync(readableDir).sort()) {
102
+ for (const name of names.sort()) {
103
+ verifySessionDirectory(sessionDir, dirFd);
102
104
  if (!/^(?:Session|Tabs)_\d+$/.test(name)) continue;
103
105
  const details = ownedRegularFile(path.join(readableDir, name), MAX_FILE_BYTES);
106
+ verifySessionDirectory(sessionDir, dirFd);
104
107
  if (!details) throw new Error('Session file is unsafe, empty or oversized');
105
108
  total += details.size;
106
109
  files.push({ name, ...details });
@@ -125,7 +128,11 @@ export function preserveInterruptedBrowserSession(profile) {
125
128
  if (snapshots.length >= MAX_SNAPSHOTS) throw new Error('Session recovery snapshot limit reached');
126
129
  const snapshotDir = mkdtempSync(path.join(recoveryDir, 'snapshot-'));
127
130
  try {
128
- for (const file of files) rawCopy(path.join(readableDir, file.name), path.join(snapshotDir, file.name), file);
131
+ for (const file of files) {
132
+ verifySessionDirectory(sessionDir, dirFd);
133
+ rawCopy(path.join(readableDir, file.name), path.join(snapshotDir, file.name), file);
134
+ verifySessionDirectory(sessionDir, dirFd);
135
+ }
129
136
  writeFileSync(path.join(snapshotDir, 'manifest.json'), JSON.stringify({ fingerprint, count: files.length, hasWindowSession: files.some(f => f.name.startsWith('Session_')) }), { flag: 'wx', mode: 0o600 });
130
137
  return { saved: true, reason: 'saved', count: files.length, bytes: total };
131
138
  } catch (error) {
@@ -0,0 +1,71 @@
1
+ // Keep Chrome recovery's directory-file-descriptor safety on Unix, while
2
+ // accommodating macOS: its /dev/fd/N aliases cannot be scanned as directories.
3
+ // This profile belongs to ReMCP, not to the user's normal Chrome profile.
4
+ import process from 'node:process';
5
+ import path from 'node:path';
6
+ import {
7
+ closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, readdirSync,
8
+ } from 'node:fs';
9
+
10
+ const NOFOLLOW = constants.O_NOFOLLOW || 0;
11
+ const DIRECTORY = constants.O_DIRECTORY || 0;
12
+
13
+ export function verifySessionDirectory(directory, fd, platform = process.platform) {
14
+ if (platform !== 'darwin') return;
15
+ const opened = fstatSync(fd);
16
+ const current = lstatSync(directory);
17
+ if (!opened.isDirectory() || !current.isDirectory() ||
18
+ opened.dev !== current.dev || opened.ino !== current.ino ||
19
+ (typeof process.getuid === 'function' && current.uid !== process.getuid())) {
20
+ throw new Error('Browser session directory changed or is unsafe');
21
+ }
22
+ }
23
+
24
+ export function scanSessionDirectory(directory, fd, platform = process.platform) {
25
+ const readable = platform === 'win32' ? directory
26
+ : platform === 'darwin' ? directory : `/proc/self/fd/${fd}`;
27
+ verifySessionDirectory(directory, fd, platform);
28
+ const names = readdirSync(readable);
29
+ verifySessionDirectory(directory, fd, platform);
30
+ return { readable, names };
31
+ }
32
+
33
+ export function shouldRestoreBrowserSession(profile) {
34
+ let prefsFd;
35
+ let sessionFd;
36
+ try {
37
+ const defaultProfile = path.join(profile, 'Default');
38
+ prefsFd = openSync(path.join(defaultProfile, 'Preferences'), constants.O_RDONLY | NOFOLLOW);
39
+ const prefsStat = fstatSync(prefsFd);
40
+ if (!prefsStat.isFile() || prefsStat.size > 4 * 1024 * 1024) return false;
41
+ const prefs = JSON.parse(readFileSync(prefsFd, 'utf8'));
42
+ if (prefs?.profile?.exit_type !== 'Crashed') return false;
43
+
44
+ const sessionDir = path.join(defaultProfile, 'Sessions');
45
+ if (process.platform !== 'win32') {
46
+ sessionFd = openSync(sessionDir, constants.O_RDONLY | DIRECTORY | NOFOLLOW);
47
+ if (!fstatSync(sessionFd).isDirectory()) return false;
48
+ }
49
+ const { readable, names } = scanSessionDirectory(sessionDir, sessionFd);
50
+ const found = names.some(name => {
51
+ if (!/^Tabs_\d+$/.test(name)) return false;
52
+ let tabFd;
53
+ try {
54
+ verifySessionDirectory(sessionDir, sessionFd);
55
+ tabFd = openSync(path.join(readable, name), constants.O_RDONLY | NOFOLLOW);
56
+ const stat = fstatSync(tabFd);
57
+ verifySessionDirectory(sessionDir, sessionFd);
58
+ return stat.isFile() && stat.size > 0;
59
+ } catch { return false; }
60
+ finally { if (tabFd !== undefined) closeSync(tabFd); }
61
+ });
62
+ verifySessionDirectory(sessionDir, sessionFd);
63
+ return found;
64
+ } catch {
65
+ // Never restore from missing, malformed, symlinked or inaccessible evidence.
66
+ return false;
67
+ } finally {
68
+ if (sessionFd !== undefined) closeSync(sessionFd);
69
+ if (prefsFd !== undefined) closeSync(prefsFd);
70
+ }
71
+ }
@@ -290,8 +290,35 @@ export function retainProductionSession(key, session, removeGuard, targetId, end
290
290
  return record;
291
291
  }
292
292
 
293
+ // New Chromium windows may report CDP readiness before their session restore
294
+ // finishes. Only cold-start navigation pays this bounded settling cost; all
295
+ // warm clicks, existing tabs and browser reads keep their current fast path.
296
+ export const COLD_BROWSER_SETTLE_MS = 1200;
297
+ export const COLD_TARGET_VERIFY_MS = 700;
298
+
299
+ export class BrowserTargetDisappearedError extends Error {
300
+ constructor() {
301
+ super('Browser CDP target disappeared during startup; the requested tab was not retained. Retry once after checking browser readiness.');
302
+ this.name = 'BrowserTargetDisappearedError';
303
+ this.code = 'BROWSER_TARGET_DISAPPEARED';
304
+ this.retryable = true;
305
+ }
306
+ }
307
+
308
+ export async function assertCreatedTargetAvailable(targetId, endpoint, {
309
+ request = requestJson, pause = ms => new Promise(resolve => setTimeout(resolve, ms)),
310
+ verifyAfterMs = 0,
311
+ } = {}) {
312
+ if (verifyAfterMs > 0) await pause(verifyAfterMs);
313
+ const targets = await request('/json/list', endpoint, { timeout_ms:2000 });
314
+ if (!Array.isArray(targets) || !targets.some(item => item?.id === targetId && item?.type === 'page')) {
315
+ throw new BrowserTargetDisappearedError();
316
+ }
317
+ }
318
+
293
319
  export async function createGuardedNewTab(url, args) {
294
- await ensureBrowserEndpoint(args.endpoint);
320
+ const autoLaunched = await ensureBrowserEndpoint(args.endpoint);
321
+ if (autoLaunched) await new Promise(resolve => setTimeout(resolve, COLD_BROWSER_SETTLE_MS));
295
322
  const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
296
323
  if (!created?.id) throw new Error('Browser did not return a new page target');
297
324
  const endpoint = endpointUrl(args.endpoint);
@@ -325,6 +352,12 @@ export async function createGuardedNewTab(url, args) {
325
352
  if (loaded) await loaded;
326
353
  const finalUrl = await evaluate(session, 'location.href');
327
354
  await assertResolvedPageUrl(finalUrl);
355
+ // A successful Page.navigate is not enough: Chrome's cold-start session
356
+ // restoration can asynchronously close its targets. Fail rather than
357
+ // returning a stale ID the next MCP tool call cannot use.
358
+ await assertCreatedTargetAvailable(target.id, endpoint, {
359
+ verifyAfterMs:autoLaunched ? COLD_TARGET_VERIFY_MS : 0,
360
+ });
328
361
  return { target, navigation, finalUrl };
329
362
  } catch (error) {
330
363
  operationFailed = true;