@remcp/runtime 0.2.178 → 0.2.180

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,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.180 — 2026-10-25
4
+
5
+ - Categorize lost CDP sessions, stale targets, navigation failures and browser guard failures without transmitting page content, URLs, target IDs or raw exception messages as telemetry categories.
6
+ - Treat nonempty `Page.navigate.errorText` as a failed tool call for both selected tabs and guarded new tabs.
7
+ - Preserve existing browser policies and existing runtime operation contracts; regression tests cover redaction and failure semantics.
8
+
9
+
3
10
  ## 0.2.176 — 2026-10-23
4
11
 
5
12
  - Reduce screenshot CDP round-trips by batching viewport resolution with capture and reusing measured element dimensions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.178",
3
+ "version": "0.2.180",
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",
@@ -14,11 +14,28 @@ export function classifyRuntimeToolError(error, toolName = '') {
14
14
  if (MISSING_CODES.has(code)) return 'not_found';
15
15
  if (TIMEOUT_CODES.has(code)) return 'timeout';
16
16
  if (browserTool && CONNECTION_CODES.has(code)) return 'browser_unavailable';
17
+ if (browserTool && code === 'BROWSER_TARGET_DISAPPEARED') return 'browser_target_stale';
17
18
  if (/\bcancelled\b|\bcanceled\b|\baborted by the client\b/.test(message)) return 'cancelled';
18
19
  if (/blocked (?:by|local|private)|outside the directories this device allows|private browser destination|blocked local browser scheme/.test(message)) return 'blocked';
19
20
  if (browserTool && /browser cdp endpoint is unavailable|browser control is unavailable|cdp websocket (?:error|connection timed out)|local cdp endpoint did not become ready/.test(message)) {
20
21
  return 'browser_unavailable';
21
22
  }
23
+ if (browserTool) {
24
+ // Match only fixed CDP failure signatures; never emit the error text, tab ID,
25
+ // destination URL or selector as a telemetry category.
26
+ if (/browser child guard failed|browser request guard failed|browser safety guard failed/.test(message)) {
27
+ return 'browser_guard_failed';
28
+ }
29
+ if (/browser cdp target disappeared|target (?:was )?closed|no target with given id|target with given id not found|inspected target navigated or closed/.test(message)) {
30
+ return 'browser_target_stale';
31
+ }
32
+ if (/cdp websocket (?:closed|is not open)|session with given id not found|cdp session (?:closed|disconnected)/.test(message)) {
33
+ return 'browser_session_lost';
34
+ }
35
+ if (/browser navigation failed|cannot navigate (?:back|forward)|net::err_[a-z_]+/.test(message)) {
36
+ return 'browser_navigation_failed';
37
+ }
38
+ }
22
39
  if (/permission denied|access is denied|not authorized|not authorised|accessibility permission/.test(message)) return 'permission';
23
40
  if (/timed out|timeout after|time limit exceeded/.test(message)) return 'timeout';
24
41
  if (/element not found|no element matches|no matching ui window|target not found|not found in the current/.test(message)) return 'not_found';
@@ -4,6 +4,7 @@ import { jsonResult, clamp, requireEnum } from './common.mjs';
4
4
  import { withTarget } from './browser-session.mjs';
5
5
  import { inspectNativeBrowserAudit } from './browser-native-audit.mjs';
6
6
  import { inspectDomGeometry } from './browser-geometry.mjs';
7
+ import { inspectElementListeners } from './browser-listeners.mjs';
7
8
 
8
9
  const METRIC_NAMES = new Set([
9
10
  'Timestamp','Documents','Frames','JSEventListeners','Nodes','LayoutCount',
@@ -32,7 +33,7 @@ export function normalizeDevtoolsMetrics(entries) {
32
33
  .filter(item => METRIC_NAMES.has(item?.name) && Number.isFinite(item?.value))
33
34
  .map(item => [item.name, item.value]));
34
35
  }
35
- export async function sampleBrowserDevtools(session, action, durationMs, maxEvents) {
36
+ export async function sampleBrowserDevtools(session, action, durationMs, maxEvents, selector) {
36
37
  const events = [];
37
38
  const counts = {};
38
39
  const requests = new Map();
@@ -43,6 +44,7 @@ export async function sampleBrowserDevtools(session, action, durationMs, maxEven
43
44
  }
44
45
  if (action === 'layout' || action === 'accessibility') return inspectNativeBrowserAudit(session, action);
45
46
  if (action === 'geometry') return inspectDomGeometry(session);
47
+ if (action === 'listeners') return inspectElementListeners(session,selector);
46
48
  if (action === 'performance') {
47
49
  await session.send('Performance.enable');
48
50
  try {
@@ -103,14 +105,16 @@ export async function sampleBrowserDevtools(session, action, durationMs, maxEven
103
105
  }
104
106
  export function buildDevtoolsReport(target, action, result) {
105
107
  return { target_id:target.id,
106
- ...(action === 'geometry' ? {} : {url:safeDevtoolsUrl(target.url)}),
108
+ ...(['geometry','listeners'].includes(action) ? {} : {url:safeDevtoolsUrl(target.url)}),
107
109
  ...result };
108
110
  }
109
111
  export async function browserDevtools(args = {}) {
110
- const action = requireEnum(args.action, 'action', ['console','network','performance','layout','accessibility','geometry']);
112
+ const action = requireEnum(args.action, 'action', ['console','network','performance','layout','accessibility','geometry','listeners']);
111
113
  const durationMs = clamp(args.sample_ms, 1500, 250, 5000);
112
114
  const maxEvents = clamp(args.max_events, 50, 1, 100);
115
+ if (action === 'listeners' && (typeof args.target_id !== 'string' || !args.target_id.trim()))
116
+ throw new TypeError('Listener diagnostics require an explicitly selected target_id');
113
117
  return withTarget(args, async (session,target) =>
114
118
  jsonResult(buildDevtoolsReport(target, action,
115
- await sampleBrowserDevtools(session,action,durationMs,maxEvents))));
119
+ await sampleBrowserDevtools(session,action,durationMs,maxEvents,args.selector))));
116
120
  }
@@ -0,0 +1,65 @@
1
+ // Native CDP event-listener audit for one explicitly selected DOM element.
2
+ // CDP returns handler references, source positions and custom listener names:
3
+ // NONE of that may leave the paired device. Never evaluate page JavaScript.
4
+ const TYPES = new Set([
5
+ 'click','dblclick','auxclick','contextmenu','input','change','beforeinput',
6
+ 'submit','reset','keydown','keyup','keypress','focus','blur','focusin','focusout',
7
+ 'mousedown','mouseup','mousemove','mouseenter','mouseleave',
8
+ 'pointerdown','pointerup','pointermove','pointerenter','pointerleave',
9
+ 'touchstart','touchend','touchmove','wheel','scroll','resize','load','error',
10
+ 'drag','dragstart','dragend','dragover','drop','animationend','transitionend',
11
+ ]);
12
+ const MAX_LISTENERS = 200;
13
+ const MAX_TYPES = 24;
14
+ function validSelector(value) {
15
+ if (typeof value !== 'string') throw new TypeError('A CSS selector is required for the event listener audit');
16
+ const selector = value.trim();
17
+ if (!selector || selector.length > 120 || /[\u0000-\u001f\u007f<>]/.test(selector)) {
18
+ throw new TypeError('A CSS selector between 1 and 120 safe characters is required');
19
+ }
20
+ return selector;
21
+ }
22
+ export function summarizeElementListeners(raw) {
23
+ const listeners = Array.isArray(raw) ? raw : [];
24
+ const sample = listeners.slice(0,MAX_LISTENERS);
25
+ const types = Object.create(null);
26
+ let passive = 0, once = 0, capturing = 0;
27
+ for (const listener of sample) {
28
+ if (!listener || typeof listener !== 'object') continue;
29
+ const eventType = TYPES.has(listener.type) ? listener.type : 'other';
30
+ types[eventType] = (types[eventType] || 0) + 1;
31
+ if (listener.passive === true) passive++;
32
+ if (listener.once === true) once++;
33
+ if (listener.useCapture === true) capturing++;
34
+ }
35
+ const byType = Object.entries(types).sort((a,b)=>b[1]-a[1] || a[0].localeCompare(b[0])).slice(0,MAX_TYPES);
36
+ return {
37
+ sampled_listeners:sample.length,
38
+ listener_count:Math.min(listeners.length,100000),
39
+ truncated:listeners.length>MAX_LISTENERS,
40
+ by_type:Object.fromEntries(byType),
41
+ passive_count:passive, once_count:once, capture_count:capturing,
42
+ repeated_event_types:byType.filter(([,count])=>count>1).length,
43
+ note:'Native CDP listener counts on the single selected node. Repeated event types are not proof of duplicate handlers. Delegated ancestor listeners are not included; handler code, locations, custom event names and page contents are never returned.',
44
+ };
45
+ }
46
+ export async function inspectElementListeners(session,selectorValue) {
47
+ const selector=validSelector(selectorValue);
48
+ // getDocument(depth:0) is enough to get the root without enumerating contents.
49
+ const {root} = await session.send('DOM.getDocument',{depth:0,pierce:false});
50
+ if (!Number.isSafeInteger(root?.nodeId) || root.nodeId<=0) throw new Error('CDP document root unavailable');
51
+ let objectId;
52
+ try {
53
+ const selected = await session.send('DOM.querySelector',{nodeId:root.nodeId,selector});
54
+ if (!Number.isSafeInteger(selected?.nodeId) || selected.nodeId<=0)
55
+ return {action:'listeners',found:false,listeners:summarizeElementListeners([])};
56
+ const resolved=await session.send('DOM.resolveNode',{nodeId:selected.nodeId});
57
+ objectId=resolved?.object?.objectId;
58
+ if (typeof objectId!=='string' || !objectId || objectId.length>512) throw new Error('Selected DOM node cannot be resolved');
59
+ const result=await session.send('DOMDebugger.getEventListeners',{objectId,depth:0,pierce:false});
60
+ return {action:'listeners',found:true,listeners:summarizeElementListeners(result.listeners)};
61
+ } finally {
62
+ if (objectId) await session.send('Runtime.releaseObject',{objectId}).catch(()=>{});
63
+ await session.send('DOM.disable').catch(()=>{});
64
+ }
65
+ }
@@ -0,0 +1,8 @@
1
+ // Page.navigate may resolve successfully while Chrome explicitly reports a
2
+ // navigation error. Treat that as a failed tool call, without echoing CDP's
3
+ // errorText (which may contain user data) into diagnostics.
4
+ export function requireSuccessfulBrowserNavigation(result) {
5
+ if (typeof result?.errorText === 'string' && result.errorText.trim()) {
6
+ throw new Error('Browser navigation failed: Chrome reported an unsuccessful navigation');
7
+ }
8
+ }
@@ -1,4 +1,5 @@
1
1
  import { snapshotDelta } from './browser-snapshot-diff.mjs';
2
+ import { requireSuccessfulBrowserNavigation } from './browser-navigation-result.mjs';
2
3
  import process from 'node:process';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -75,6 +76,7 @@ export async function browserNavigate(args = {}) {
75
76
  let historyUrl = null;
76
77
  if (action === 'url') {
77
78
  const result = await session.send('Page.navigate', { url });
79
+ requireSuccessfulBrowserNavigation(result);
78
80
  frameId = result.frameId || null;
79
81
  errorText = result.errorText || null;
80
82
  } else if (action === 'reload') {
@@ -1,3 +1,4 @@
1
+ import { requireSuccessfulBrowserNavigation } from './browser-navigation-result.mjs';
1
2
  import process from 'node:process';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -350,6 +351,7 @@ export async function createGuardedNewTab(url, args) {
350
351
  await session.send('Page.enable');
351
352
  const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
352
353
  const navigation = await session.send('Page.navigate', { url });
354
+ requireSuccessfulBrowserNavigation(navigation);
353
355
  if (loaded) await loaded;
354
356
  const finalUrl = await evaluate(session, 'location.href');
355
357
  await assertResolvedPageUrl(finalUrl);
@@ -167,7 +167,7 @@ export const extendedToolDefinitions = [
167
167
  define('browser_find', 'Find browser element', 'Find visible DOM elements by CSS selector, text, or ARIA role and return reusable selectors plus text, value, and bounding boxes.', o({ ...browserTarget, selector:s('CSS selector.'), text:s('Visible text substring.'), role:s('ARIA role.'), limit:n() }), readOnlyOpen, browserHandlers.browser_find, ['browser_cdp']),
168
168
  define('browser_action', 'Browser element action', 'Click, focus, type/set a value, fill up to 20 form fields in one guarded call, select, scroll a page or scrollable element, scroll an element into view, upload files, press a key, or set an exact responsive-test viewport in a debuggable browser page using DOM/CDP semantics.', o({ ...browserTarget, action:e(['click','focus','type','set_value','select','scroll','scroll_into_view','upload','press','set_viewport','fill_form','workflow']), selector:s('CSS selector for the target element; prefer the reusable selector returned by browser_find.'), steps:{type:'array',minItems:1,maxItems:12,description:'Workflow on one page: explicit CSS actions and bounded waits, max 12 seconds. Never silently submits; click must be explicit.',items:{type:'object',additionalProperties:false,properties:{action:e(['fill_form','set_value','select','focus','click','wait_for']),selector:s('CSS selector for action or wait_for.'),value:{type:['string','number','boolean']},fields:{type:'array',description:'CSS-selected field edits within the workflow step; no implicit form submission.',minItems:1,maxItems:20,items:{type:'object',additionalProperties:false,properties:{selector:s(),value:{type:['string','number','boolean']}},required:['selector','value']}},condition:e(['selector','text','url_contains','load']),text:s('Text or URL fragment for wait condition.'),timeout_ms:n('Wait timeout 100..4000 ms; default 1500.')},required:['action']}}, fields:{type:'array',minItems:1,maxItems:20,items:{type:'object',additionalProperties:false,properties:{selector:s('CSS selector.'),value:{type:['string','boolean','number'],description:'New field value; not returned in results.'}},required:['selector','value']},description:'fill_form: 1–20 CSS form fields in one CDP call. Does not submit.'}, browser_text:s('Visible text used only to locate a DOM element; use this instead of overloading text for action=type.'), text:s('For action=type, Unicode text to insert. For non-type element actions without selector/browser_text, visible text may identify the target.'), clear:b('For action=type, clear the current value before inserting text.'), value:s('Value for action=set_value; also accepted as a legacy type payload when text is used as the locator.'), text_value:s('Compatibility alias for the type/set_value text value.'), option:s(), delta_x:n('Horizontal scroll delta; positive means right.'), delta_y:n('Vertical scroll delta; positive means down.'), delta:n('Alias for delta_y.'), direction:e(['up','down','left','right']), wheel_times:n('Number of 120-unit wheel steps for direction; 1..50.'), path:s(), paths:{type:'array',items:{type:'string'}}, key:s(), width:n('CSS viewport width for set_viewport.'), height:n('CSS viewport height for set_viewport.'), device_scale_factor:n('Device scale factor for set_viewport; default 1.'), mobile:b('Enable mobile emulation for set_viewport.') }, ['action']), openMutating, browserHandlers.browser_action, ['browser_cdp']),
169
169
  define('browser_wait', 'Wait for browser', 'Wait for DOM state, page text, URL, completed loading, a navigation away from the current URL, or a short network-idle period in a debuggable browser page. Use browser_evaluate separately for JavaScript predicates or page-specific inspection.', o({ ...browserTarget, condition:e(['selector','text','url_contains','load','navigation','network_idle']), selector:s(), text:s(), value:s(), poll_ms:n(), idle_ms:n('Required zero-in-flight network quiet window for network_idle; default 500 ms.') }), readOnlyOpen, browserHandlers.browser_wait, ['browser_cdp']),
170
- define('browser_devtools', 'Browser DevTools diagnostics', 'Use this to inspect Chrome Console/Network live events, Performance, Layout, Accessibility or screenshot-free DOMSnapshot Geometry via native CDP. Network headers, bodies and cookies are excluded; geometry returns bounded numeric summaries without page text.', o({ ...browserTarget, action:e(['console','network','performance','layout','accessibility','geometry'],'Diagnostics mode. Layout measures CSS viewport, accessibility returns anonymized roles and unnamed controls, geometry audits DOMSnapshot bounds without page contents.'), sample_ms:n('Live event capture period 250–5000 ms; default 1500. Ignored for performance, layout, accessibility and geometry.'), max_events:n('Maximum 1–100 events retained; default 50.') }, ['action']), readOnlyOpen, browserHandlers.browser_devtools, ['browser_cdp']),
170
+ define('browser_devtools', 'Browser DevTools diagnostics', 'Use this to inspect Chrome Console/Network live events, Performance, Layout, Accessibility or screenshot-free DOMSnapshot Geometry via native CDP. Network headers, bodies and cookies are excluded; geometry returns bounded numeric summaries without page text.', o({ ...browserTarget, action:e(['console','network','performance','layout','accessibility','geometry','listeners'],'Diagnostics mode. Layout measures CSS viewport, accessibility returns anonymized roles and unnamed controls, geometry audits DOMSnapshot bounds without page contents.'), sample_ms:n('Live event capture period 250–5000 ms; default 1500. Ignored for performance, layout, accessibility, geometry and listeners.'), selector:s('Required CSS selector for action=listeners; only the first matching element is inspected, with no script source or DOM text returned.'), max_events:n('Maximum 1–100 events retained; default 50.') }, ['action']), readOnlyOpen, browserHandlers.browser_devtools, ['browser_cdp']),
171
171
  define('browser_evaluate', 'Evaluate browser JavaScript', 'Evaluate JavaScript in a selected debuggable browser page and return its serializable value. This is a powerful escape hatch for page-specific automation.', o({ ...browserTarget, expression:s('JavaScript expression.'), await_promise:b('Await a returned Promise; default true.') }, ['expression']), openMutating, browserHandlers.browser_evaluate, ['browser_evaluate']),
172
172
 
173
173
  define('service', 'Service control', 'List, inspect, start, stop, or restart operating-system services using Service Control Manager, launchctl, or systemd. Mutating actions pass through ReMCP command policy.', o({ action:e(['list','status','start','stop','restart']), name:s('Service/unit/launchd label.'), scope:e(['user','system']) }, ['action']), mutating, diagnosticHandlers.service, ['services']),