@remcp/runtime 0.2.190 → 0.2.192

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.192 — 2026-10-28
4
+
5
+ - Accelerate `browser_find` role/text candidate filtering without omitting ARIA fallback semantics.
6
+ - Reduce redundant `browser_wait` Chrome reads and coalesce focus/clear for `browser_action` native text input.
7
+ - Reject CDP event waits and pending writes promptly when the debugging WebSocket disconnects.
8
+ - Preserve browser authorization, origin/DNS/SSRF checks, secure input events, credential redaction and the 84-tool contract.
9
+
10
+ ## 0.2.191 — 2026-10-28
11
+
12
+ - Correct the catastrophic-command heuristic for shell wrappers that consume option values, including sudo, nice, ionice, and env split-string.
13
+ - Add no-side-effect guardrail regression cases; no new MCP tool or expanded device permission.
14
+ - Make the short-process terminal assertion wait for the real exit event after buffered output without changing runtime I/O behavior.
15
+ - This matcher is not a shell sandbox and cannot protect computers running with unrestricted God Mode; deployment still requires OS least-privilege mitigation.
16
+
3
17
  ## 0.2.190 — 2026-10-27
4
18
 
5
19
  - Publish the runtime alongside the compatible ReMCP agent; the device HTTPS network probe is agent-owned and does not add runtime MCP tools.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.190",
3
+ "version": "0.2.192",
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",
@@ -74,15 +74,15 @@ export const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
74
74
  ) || null;
75
75
  }
76
76
  if (!el) throw new Error('Element not found');
77
- if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
77
+ if (action === 'click' || action === 'type' || action === 'type_clear' || action === 'scroll_into_view') {
78
78
  el.scrollIntoView({block:'center', inline:'center'});
79
79
  }
80
- if (action === 'focus' || action === 'type') el.focus();
80
+ if (action === 'focus' || action === 'type' || action === 'type_clear') el.focus();
81
81
  if (action === 'select') {
82
82
  el.value = value;
83
83
  el.dispatchEvent(new Event('input', {bubbles:true}));
84
84
  el.dispatchEvent(new Event('change', {bubbles:true}));
85
- } else if (action === 'set_value') {
85
+ } else if (action === 'set_value' || action === 'type_clear') {
86
86
  const proto = Object.getPrototypeOf(el);
87
87
  const own = Object.getOwnPropertyDescriptor(proto, 'value');
88
88
  const parent = Object.getPrototypeOf(proto);
@@ -307,8 +307,11 @@ export async function browserAction(args) {
307
307
  }
308
308
 
309
309
  if (action === 'type') {
310
- const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'type', '']);
311
- if (args.clear === true) await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'set_value', '']);
310
+ // Focus and clear must happen in that order, but they can share one
311
+ // guarded page evaluation. Keep native insertText and read-back CDP
312
+ // calls sequential so real keyboard input and verification are preserved.
313
+ const preparatoryAction = args.clear === true ? 'type_clear' : 'type';
314
+ const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, preparatoryAction, '']);
312
315
  await session.send('Input.insertText', { text:value });
313
316
  const current = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'read_value', '']);
314
317
  return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current.value } });
@@ -333,7 +336,10 @@ export async function browserWait(args = {}) {
333
336
  if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
334
337
  return withTarget(args, async (session, target) => {
335
338
  const started = Date.now();
336
- const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
339
+ // withTarget has already verified this live URL before calling us.
340
+ // Avoid reading it again just to establish the navigation baseline.
341
+ const initialUrl = target.url || '';
342
+ let observedUrl = initialUrl || null;
337
343
  const inflight = new Set();
338
344
  let lastResourceCount = -1;
339
345
  let stableSince = Date.now();
@@ -358,8 +364,9 @@ export async function browserWait(args = {}) {
358
364
  while (Date.now() - started < timeoutMs) {
359
365
  let matched = false;
360
366
  if (condition === 'network_idle') {
361
- const state = await evaluate(session, `({ready:document.readyState,resources:performance.getEntriesByType('resource').length})`).catch(() => null);
367
+ const state = await evaluate(session, `({url:location.href,ready:document.readyState,resources:performance.getEntriesByType('resource').length})`).catch(() => null);
362
368
  if (state) {
369
+ if (typeof state.url === 'string') observedUrl = state.url;
363
370
  if (state.resources !== lastResourceCount) {
364
371
  lastResourceCount = state.resources;
365
372
  stableSince = Date.now();
@@ -373,7 +380,11 @@ export async function browserWait(args = {}) {
373
380
  : condition === 'load' ? `document.readyState === 'complete'`
374
381
  : condition === 'navigation' ? `location.href !== ${JSON.stringify(initialUrl)}`
375
382
  : `Boolean(${wanted})`;
376
- matched = Boolean(await evaluate(session, expression));
383
+ // Capture the condition and its URL atomically in one CDP read.
384
+ // withTarget independently validates the final live URL afterward.
385
+ const state = await evaluate(session, `({url:location.href,matched:Boolean(${expression})})`);
386
+ if (typeof state?.url === 'string') observedUrl = state.url;
387
+ matched = Boolean(state?.matched);
377
388
  }
378
389
  if (matched) return jsonResult({
379
390
  target_id: target.id,
@@ -381,7 +392,7 @@ export async function browserWait(args = {}) {
381
392
  matched: true,
382
393
  elapsed_ms: Date.now() - started,
383
394
  ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
384
- url: await evaluate(session, 'location.href').catch(() => target.url || null),
395
+ url: observedUrl,
385
396
  });
386
397
  await new Promise(resolve => setTimeout(resolve, pollMs));
387
398
  }
@@ -391,7 +402,7 @@ export async function browserWait(args = {}) {
391
402
  matched: false,
392
403
  elapsed_ms: Date.now() - started,
393
404
  ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
394
- url: await evaluate(session, 'location.href').catch(() => target.url || null),
405
+ url: observedUrl,
395
406
  });
396
407
  } finally {
397
408
  for (const unsubscribe of unsubscribers) unsubscribe();
@@ -284,7 +284,22 @@ export function findExpression(args) {
284
284
  const selector = ${JSON.stringify(selector)};
285
285
  const needle = ${JSON.stringify(needle)};
286
286
  const role = ${JSON.stringify(role)};
287
- const source = selector ? Array.from(document.querySelectorAll(selector)) : Array.from(document.querySelectorAll('*'));
287
+ const wanted = needle ? needle.toLowerCase() : '';
288
+ const compactWanted = wanted.replace(/\\s+/g, '');
289
+ const wantedRole = role ? role.toLowerCase() : '';
290
+ // Native roles have a bounded set of possible tags. Include all explicit
291
+ // ARIA roles too, so role='button' on a custom element remains discoverable.
292
+ // Unknown roles deliberately retain the full-document fallback.
293
+ const nativeRoleSelectors = {
294
+ button:'button,summary,input', link:'a[href]',
295
+ heading:'h1,h2,h3,h4,h5,h6', textbox:'textarea,input',
296
+ searchbox:'input', combobox:'select,input', listbox:'select',
297
+ checkbox:'input', radio:'input', slider:'input', spinbutton:'input',
298
+ option:'option', img:'img', progressbar:'progress', meter:'meter',
299
+ };
300
+ const sourceSelector = selector || (Object.hasOwn(nativeRoleSelectors,wantedRole)
301
+ ? nativeRoleSelectors[wantedRole] + ',[role]' : '*');
302
+ const source = document.querySelectorAll(sourceSelector);
288
303
  function cssPath(el) {
289
304
  if (el.id) return '#' + CSS.escape(el.id);
290
305
  const parts = [];
@@ -328,9 +343,8 @@ export function findExpression(args) {
328
343
  return (el.innerText || el.textContent || el.getAttribute('aria-label') || '').trim();
329
344
  }
330
345
  function textQuality(value) {
331
- if (!needle) return 0;
346
+ if (!wanted) return 0;
332
347
  const hay = value.toLowerCase();
333
- const wanted = needle.toLowerCase();
334
348
  if (hay === wanted) return 0;
335
349
  if (hay.startsWith(wanted)) return 1;
336
350
  return 2;
@@ -340,19 +354,32 @@ export function findExpression(args) {
340
354
  for (let current = el.parentElement; current; current = current.parentElement) depth += 1;
341
355
  return depth;
342
356
  }
343
- const candidates = source.filter(el => {
344
- const style = getComputedStyle(el);
345
- const visible = style.visibility !== 'hidden' && style.display !== 'none' && el.getClientRects().length > 0;
346
- const hay = visibleText(el);
357
+ const rankAll = Boolean(wanted && !selector);
358
+ const candidates = [];
359
+ for (const el of source) {
347
360
  const r = semanticRole(el);
348
- return visible && (!needle || hay.toLowerCase().includes(needle.toLowerCase())) && (!role || r === role.toLowerCase());
349
- }).map((el, index) => ({
350
- el,
351
- index,
352
- text: visibleText(el),
353
- role: semanticRole(el),
354
- depth: depthOf(el),
355
- }));
361
+ if (wantedRole && r !== wantedRole) continue;
362
+ // Cheap raw text/label check before forcing layout via innerText,
363
+ // computed styles or bounding boxes. The final visibleText match is
364
+ // still required so hidden descendants do not become false positives.
365
+ if (wanted) {
366
+ const raw = String(el.textContent || '').toLowerCase();
367
+ const aria = String(el.getAttribute('aria-label') || '').toLowerCase();
368
+ // CSS layout can insert line breaks absent from textContent. Compare
369
+ // whitespace-free forms as a fallback to avoid missing those matches.
370
+ if (!raw.includes(wanted) && !aria.includes(wanted)
371
+ && !raw.replace(/\\s+/g, '').includes(compactWanted)
372
+ && !aria.replace(/\\s+/g, '').includes(compactWanted)) continue;
373
+ }
374
+ const style = getComputedStyle(el);
375
+ if (style.visibility === 'hidden' || style.display === 'none' || !el.getClientRects().length) continue;
376
+ const text = wanted ? visibleText(el) : '';
377
+ if (wanted && !text.toLowerCase().includes(wanted)) continue;
378
+ candidates.push({el,index:candidates.length,text,role:r,depth:rankAll ? depthOf(el) : 0});
379
+ // Text-only searches rank all matches to prefer exact interactive
380
+ // targets. Every other search uses DOM order and can stop at limit.
381
+ if (!rankAll && candidates.length >= ${limit}) break;
382
+ }
356
383
  let selected = candidates;
357
384
  if (needle && !selector) {
358
385
  selected = [...candidates].sort((a, b) =>
@@ -115,11 +115,25 @@ export class CdpSession {
115
115
  reject(new Error(`${method} timed out`));
116
116
  }, timeoutMs);
117
117
  this.pending.set(key, { resolve, reject, timer });
118
- this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
118
+ try {
119
+ this.socket.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
120
+ } catch (error) {
121
+ // A failed synchronous WebSocket write must not retain a pending
122
+ // request (and its timer) for the rest of the CDP deadline.
123
+ this.pending.delete(key);
124
+ clearTimeout(timer);
125
+ reject(error);
126
+ }
119
127
  });
120
128
  }
121
129
 
122
130
  waitFor(method, timeoutMs = this.timeoutMs, signal = null) {
131
+ if (signal?.aborted) return Promise.reject(new Error(`${method} aborted`));
132
+ // Chrome may disconnect between the preceding command and subscription.
133
+ // Do not turn an already-dead socket into a full navigation timeout.
134
+ if (this.closed || !this.socket || this.socket.readyState !== 1) {
135
+ return Promise.reject(new Error('CDP websocket is not open'));
136
+ }
123
137
  return new Promise((resolve, reject) => {
124
138
  if (signal?.aborted) { reject(new Error(`${method} aborted`)); return; }
125
139
  const listeners = this.waiters.get(method) || [];
package/src/policy.mjs CHANGED
@@ -60,19 +60,49 @@ function segments(command) {
60
60
  .filter(Boolean);
61
61
  }
62
62
 
63
+ // A wrapper option may consume the following token. Dropping the option but
64
+ // not its value makes the guardrail examine "root"/"10" instead of "rm".
65
+ const WRAPPER_VALUE_FLAGS = Object.freeze({
66
+ sudo: new Set(['-u', '--user', '-g', '--group', '-h', '--host', '-p', '--prompt', '-C', '--close-from', '-D', '--chdir', '-R', '--chroot', '-T', '--command-timeout']),
67
+ doas: new Set(['-u']),
68
+ env: new Set(['-u', '--unset', '-C', '--chdir', '-S', '--split-string']),
69
+ nice: new Set(['-n', '--adjustment']),
70
+ ionice: new Set(['-c', '--class', '-n', '--classdata', '-p', '--pid', '-P', '--pgid', '-u', '--uid']),
71
+ stdbuf: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
72
+ timeout: new Set(['-s', '--signal', '-k', '--kill-after']),
73
+ });
74
+
63
75
  function commandWord(segment) {
64
76
  const tokens = matchingText(segment).split(' ').filter(Boolean);
65
77
  while (tokens.length) {
66
78
  const token = tokens[0];
67
79
  if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(token)) { tokens.shift(); continue; }
68
80
  const tokenName = path.basename(token).replace(/\.(exe|cmd|bat)$/i, '').toLowerCase();
69
- if (WRAPPERS.has(tokenName)) {
81
+ if (!WRAPPERS.has(tokenName)) break;
82
+ tokens.shift();
83
+ const valueFlags = WRAPPER_VALUE_FLAGS[tokenName];
84
+ while (tokens.length) {
85
+ const option = tokens[0];
86
+ if (option === '--') { tokens.shift(); break; }
87
+ if (tokenName === 'env' && /^[A-Za-z_][A-Za-z0-9_]*=/.test(option)) {
88
+ tokens.shift();
89
+ continue;
90
+ }
91
+ // env -S/--split-string executes its argument as command words; never
92
+ // discard that argument as an ordinary wrapper option value.
93
+ if (tokenName === 'env' && (option === '-S' || option === '--split-string')) {
94
+ tokens.shift();
95
+ break;
96
+ }
97
+ if (tokenName === 'env' && (option.startsWith('--split-string=') || /^-S.+/.test(option))) {
98
+ tokens[0] = option.startsWith('--split-string=') ? option.slice('--split-string='.length) : option.slice(2);
99
+ break;
100
+ }
101
+ if (!option.startsWith('-') || option === '-') break;
70
102
  tokens.shift();
71
- while (tokens.length && tokens[0].startsWith('-')) tokens.shift();
72
- if (tokenName === 'timeout' && tokens.length && /^\d+[smhd]?$/.test(tokens[0])) tokens.shift();
73
- continue;
103
+ if (valueFlags?.has(option) && tokens.length) tokens.shift();
74
104
  }
75
- break;
105
+ if (tokenName === 'timeout' && tokens.length && /^\d+(?:\.\d+)?[smhd]?$/.test(tokens[0])) tokens.shift();
76
106
  }
77
107
  const word = tokens[0] || '';
78
108
  return word.replace(/^.*[\\/]/, '').replace(/\.(exe|cmd|bat)$/i, '');