@remcp/runtime 0.2.166 → 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.166",
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",
@@ -98,8 +98,8 @@ export async function waitForBrowserEndpoint(timeoutMs = 5000) {
98
98
 
99
99
  export async function ensureBrowserEndpoint(endpoint) {
100
100
  assertBrowserRemoteEnabled();
101
- if (endpoint || process.env.REMCP_CDP_URL) return;
102
- if (await browserCapabilityAvailable(undefined, 250)) return;
101
+ if (endpoint || process.env.REMCP_CDP_URL) return false;
102
+ if (await browserCapabilityAvailable(undefined, 250)) return false;
103
103
  if (!browserLaunchPromise) {
104
104
  browserLaunchPromise = (async () => {
105
105
  const executable = browserExecutable();
@@ -112,9 +112,10 @@ export async function ensureBrowserEndpoint(endpoint) {
112
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.');
113
113
  spawnDetached(executable, browserAutoLaunchArgs(selected.profile));
114
114
  if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
115
+ return true;
115
116
  })().finally(() => { browserLaunchPromise = null; });
116
117
  }
117
- await browserLaunchPromise;
118
+ return browserLaunchPromise;
118
119
  }
119
120
 
120
121
  export async function browserControlAvailable(endpoint, timeoutMs = 500) {
@@ -193,11 +194,17 @@ export function isPrivateBrowserHost(hostname) {
193
194
  }
194
195
 
195
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
+ }
196
200
  let url;
197
- try { url = new URL(optionalString(value) || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT); }
198
- 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'); }
199
205
  if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
200
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');
201
208
  return url;
202
209
  }
203
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
  }
@@ -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;