@remcp/runtime 0.2.162 → 0.2.163

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.162",
3
+ "version": "0.2.163",
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,68 @@
1
+ // Optional bounded local CDP readiness probe. This reports availability only;
2
+ // it never exposes tab titles/URLs, starts Chrome, changes CDP flags or grants
3
+ // permission to access targets. Actual actions always re-check browser policy.
4
+ import { browserRemoteEnabled, endpointUrl, requestJson, validatedTargetWebSocketUrl } from './browser-endpoint.mjs';
5
+
6
+ const records = new Map();
7
+ const CACHE_MS = 3000;
8
+ const MAX_ENTRIES = 8;
9
+ const transportError = message =>
10
+ /fetch failed|ECONNREFUSED|ECONNRESET|ETIMEDOUT|abort|timed out|network error/i.test(message);
11
+
12
+ export function classifyBrowserReadiness(version, targets, endpoint) {
13
+ if (!version || typeof version !== 'object' || !(version.Browser || version.webSocketDebuggerUrl)
14
+ || !Array.isArray(targets)) return { status:'invalid_response', ready:false, candidate_pages:0 };
15
+ let pages = 0;
16
+ for (const target of targets.slice(0, 1000)) {
17
+ if (target?.type !== 'page' || !target?.id || !target?.webSocketDebuggerUrl) continue;
18
+ try {
19
+ validatedTargetWebSocketUrl(target, endpointUrl(endpoint));
20
+ pages += 1;
21
+ } catch {
22
+ // The CDP server's untrusted page/socket metadata is not permission.
23
+ }
24
+ }
25
+ return { status:pages ? 'ready' : 'no_tabs', ready:pages > 0, candidate_pages:pages };
26
+ }
27
+
28
+ export async function probeBrowserReadiness({
29
+ endpoint,
30
+ timeoutMs = 350,
31
+ request = requestJson,
32
+ enabled = browserRemoteEnabled(),
33
+ now = Date.now(),
34
+ cache = request === requestJson,
35
+ } = {}) {
36
+ if (!enabled) return {
37
+ status:'disabled', ready:false, candidate_pages:0, next_step:'use_native_accessibility',
38
+ };
39
+ let key;
40
+ try { key = endpointUrl(endpoint).origin; }
41
+ catch { return { status:'blocked_endpoint', ready:false, candidate_pages:0, next_step:'check_local_cdp_config' }; }
42
+ if (cache) {
43
+ const previous = records.get(key);
44
+ if (previous && previous.expires > now) return { ...previous.value, cache_hit:true };
45
+ }
46
+ const timeout = Math.max(100, Math.min(800, Number(timeoutMs) || 350));
47
+ let result;
48
+ try {
49
+ const version = await request('/json/version', endpoint, { timeout_ms:timeout });
50
+ // CDP version alone is not evidence a useful debuggable tab exists.
51
+ if (!version || typeof version !== 'object' || !(version.Browser || version.webSocketDebuggerUrl)) {
52
+ result = { status:'invalid_response', ready:false, candidate_pages:0 };
53
+ } else {
54
+ const targets = await request('/json/list', endpoint, { timeout_ms:timeout });
55
+ result = classifyBrowserReadiness(version, targets, endpoint);
56
+ }
57
+ } catch (error) {
58
+ result = { status:transportError(String(error?.message || '')) ? 'unavailable' : 'invalid_response',
59
+ ready:false, candidate_pages:0 };
60
+ }
61
+ const next_step = result.ready ? 'use_browser_tabs' : 'use_native_accessibility';
62
+ const output = { ...result, next_step };
63
+ if (cache) {
64
+ records.set(key, { expires:now + CACHE_MS, value:output });
65
+ while (records.size > MAX_ENTRIES) records.delete(records.keys().next().value);
66
+ }
67
+ return output;
68
+ }
@@ -164,6 +164,7 @@ export const OUTPUT_FIELDS = Object.freeze({
164
164
  cursor:outputObject('Pointer position when available.'),
165
165
  clipboard:outputObject('Clipboard metadata only unless clipboard content was explicitly requested.'),
166
166
  browser:outputObject('Optional browser DOM/CDP snapshot.'),
167
+ browser_readiness:outputObject('Optional bounded local CDP readiness status and fallback hint; no tab titles or URLs.'),
167
168
  ocr:outputObject('Optional OCR words/metadata.'),
168
169
  fallback_chain:outputObject('Which semantic/DOM/OCR/coordinate layers were requested and available.'),
169
170
  errors:outputArray(outputObject(), 'Non-fatal sub-capture errors.'),
@@ -63,7 +63,7 @@ const browserTarget = {
63
63
  };
64
64
 
65
65
  export const extendedToolDefinitions = [
66
- define('computer_snapshot', 'Computer snapshot', 'Capture one compact computer-use state bundle: displays, visible windows, labeled semantic accessibility state, clipboard metadata, optional native browser accessibility/CDP/OCR, and real screenshot pixels. Use include_ui=false for a fast screenshot-first state; screenshot_window_id/monitor/region scope pixels without adding another tool.', o({ max_ui_nodes:n('Maximum native accessibility nodes; default 800.'), max_ui_depth:n('Maximum native accessibility depth; default 12.'), include_ui:b('Include native accessibility tree; default true. False is the fast screenshot-only path.'), ui_scope:e(['active','desktop'],'Accessibility scope for computer_snapshot. Default active keeps the compact snapshot fast; desktop requests all accessible applications/windows.'), semantic_tree:b('Include compact semantic hierarchy text alongside nodes; default true.'), ui_browser_dom:b('Prefer the native browser accessibility document subtree when the OS exposes one; on Windows this also enables the Firefox MSAA/IAccessible fallback.'), include_screenshot:b('Include real rendered desktop pixels; default true.'), screenshot_region:{type:'array',minItems:4,maxItems:4,items:{type:'number'},description:'Virtual-desktop [left,top,right,bottom] region; takes precedence over full desktop.'}, screenshot_window_id:s('Capture the window returned by list_windows instead of the whole desktop.'), screenshot_monitor:s('Monitor name substring or "primary".'), screenshot_monitor_index:n('Zero-based monitor index.'), screenshot_padding:n('Pixels of context around a targeted screenshot; 0..200.'), grid_columns:n('Optional reference-grid column count returned as visual metadata; 0..20.'), grid_rows:n('Optional reference-grid row count returned as visual metadata; 0..20.'), include_browser:b('Include CDP browser accessibility when available. Default auto: only when the active app looks Chromium-based.'), browser_max_nodes:n('Maximum browser accessibility nodes; default 500, maximum 2000.'), include_ocr:b('Run optional local OCR. Default auto: only when native accessibility and browser DOM are unavailable. Requires tesseract on PATH.'), ocr_language:s('Tesseract language code, default eng; multiple languages may use eng+deu.'), ocr_max_words:n('Maximum OCR word boxes returned; default 2000, maximum 10000.'), ocr_psm:n('Tesseract page segmentation mode, 3..13; default 11.'), ocr_timeout_ms:n('OCR timeout, default 30000 ms, maximum 120000 ms.') }), readOnlyLive, desktopHandlers.computer_snapshot),
66
+ define('computer_snapshot', 'Computer snapshot', 'Capture one compact computer-use state bundle: displays, visible windows, labeled semantic accessibility state, clipboard metadata, optional native browser accessibility/CDP/OCR, and real screenshot pixels. For local browser availability without tab content or launching Chrome, request browser_readiness=true; otherwise no extra CDP probe is made. Use include_ui=false for a fast screenshot-first state; screenshot_window_id/monitor/region scope pixels without adding another tool.', o({ max_ui_nodes:n('Maximum native accessibility nodes; default 800.'), max_ui_depth:n('Maximum native accessibility depth; default 12.'), include_ui:b('Include native accessibility tree; default true. False is the fast screenshot-only path.'), ui_scope:e(['active','desktop'],'Accessibility scope for computer_snapshot. Default active keeps the compact snapshot fast; desktop requests all accessible applications/windows.'), semantic_tree:b('Include compact semantic hierarchy text alongside nodes; default true.'), ui_browser_dom:b('Prefer the native browser accessibility document subtree when the OS exposes one; on Windows this also enables the Firefox MSAA/IAccessible fallback.'), include_screenshot:b('Include real rendered desktop pixels; default true.'), screenshot_region:{type:'array',minItems:4,maxItems:4,items:{type:'number'},description:'Virtual-desktop [left,top,right,bottom] region; takes precedence over full desktop.'}, screenshot_window_id:s('Capture the window returned by list_windows instead of the whole desktop.'), screenshot_monitor:s('Monitor name substring or "primary".'), screenshot_monitor_index:n('Zero-based monitor index.'), screenshot_padding:n('Pixels of context around a targeted screenshot; 0..200.'), grid_columns:n('Optional reference-grid column count returned as visual metadata; 0..20.'), grid_rows:n('Optional reference-grid row count returned as visual metadata; 0..20.'), include_browser:b('Include CDP browser accessibility when available. Default auto: only when the active app looks Chromium-based.'), browser_readiness:b('Opt-in bounded CDP readiness status: disabled, blocked endpoint, unavailable, invalid response, no tabs, or ready. No tab URLs or titles; never launches Chrome. Default false.'), browser_max_nodes:n('Maximum browser accessibility nodes; default 500, maximum 2000.'), include_ocr:b('Run optional local OCR. Default auto: only when native accessibility and browser DOM are unavailable. Requires tesseract on PATH.'), ocr_language:s('Tesseract language code, default eng; multiple languages may use eng+deu.'), ocr_max_words:n('Maximum OCR word boxes returned; default 2000, maximum 10000.'), ocr_psm:n('Tesseract page segmentation mode, 3..13; default 11.'), ocr_timeout_ms:n('OCR timeout, default 30000 ms, maximum 120000 ms.') }), readOnlyLive, desktopHandlers.computer_snapshot),
67
67
  define(
68
68
  'computer_action',
69
69
  'Computer action',
@@ -72,6 +72,17 @@ export function computerActionWindowTextArgs(args = {}) {
72
72
  };
73
73
  }
74
74
 
75
+ // A UI element ID is scoped to the native accessibility tree. If it becomes
76
+ // stale, never reinterpret a remaining name, selector, OCR hint or x/y as a
77
+ // different target: refind that element in the same window first.
78
+ export function hasPinnedUiElement(args = {}) {
79
+ return Boolean(args.id || args.automation_id || args.automationId);
80
+ }
81
+
82
+ function stalePinnedUiElement() {
83
+ return new Error('Pinned UI element could not be resolved; use ui_snapshot and ui_find to refresh its ID. No browser, OCR, window or coordinate fallback was attempted.');
84
+ }
85
+
75
86
  export async function computerAction(args = {}) {
76
87
  const legacy = ['ui','pointer','window','type','keyboard','scroll','drag','clipboard','launch_app','open_path','reveal_path','notification'];
77
88
  const universal = [
@@ -89,6 +100,7 @@ export async function computerAction(args = {}) {
89
100
  const hasOcr = Boolean(ocrNeedle);
90
101
  const hasCoordinates = Number.isFinite(Number(args.x)) && Number.isFinite(Number(args.y));
91
102
  const automatic = target === 'auto';
103
+ const pinnedUi = automatic && hasPinnedUiElement(args);
92
104
 
93
105
  if (action === 'batch') {
94
106
  const steps = Array.isArray(args.steps) ? args.steps : [];
@@ -170,7 +182,10 @@ export async function computerAction(args = {}) {
170
182
  const failures = [];
171
183
  if (hasUi) {
172
184
  try { return await typeText({ ...args, method:'accessibility' }); }
173
- catch (error) { failures.push(`accessibility: ${String(error?.message || error).slice(0, 300)}`); }
185
+ catch (error) {
186
+ if (pinnedUi) throw stalePinnedUiElement();
187
+ failures.push(`accessibility: ${String(error?.message || error).slice(0, 300)}`);
188
+ }
174
189
  }
175
190
  if (hasBrowser) {
176
191
  if (await browserCapabilityAvailable(args.endpoint, 300)) {
@@ -203,7 +218,10 @@ export async function computerAction(args = {}) {
203
218
  let uiError = null;
204
219
  if (hasUi) {
205
220
  try { return await scroll(args); }
206
- catch (error) { uiError = error; }
221
+ catch (error) {
222
+ if (pinnedUi) throw stalePinnedUiElement();
223
+ uiError = error;
224
+ }
207
225
  }
208
226
  if (hasBrowser) {
209
227
  if (await browserCapabilityAvailable(args.endpoint, 300)) return browserComputerAction(args, 'scroll');
@@ -244,7 +262,10 @@ export async function computerAction(args = {}) {
244
262
  let uiError = null;
245
263
  if (hasUi) {
246
264
  try { return await uiAction({ ...args, action:uiActionName }); }
247
- catch (error) { uiError = error; }
265
+ catch (error) {
266
+ if (pinnedUi) throw stalePinnedUiElement();
267
+ uiError = error;
268
+ }
248
269
  }
249
270
  if (hasBrowser) return browserComputerAction(args, uiActionName);
250
271
  if (uiError) throw uiError;
@@ -288,7 +309,10 @@ export async function computerAction(args = {}) {
288
309
  const failures = [];
289
310
  if (hasUi) {
290
311
  try { return await uiAction({ ...args, action:'click' }); }
291
- catch (error) { failures.push(`accessibility: ${String(error?.message || error).slice(0, 300)}`); }
312
+ catch (error) {
313
+ if (pinnedUi) throw stalePinnedUiElement();
314
+ failures.push(`accessibility: ${String(error?.message || error).slice(0, 300)}`);
315
+ }
292
316
  }
293
317
  if (hasBrowser) {
294
318
  if (await browserCapabilityAvailable(args.endpoint, 300)) {
@@ -9,6 +9,7 @@ import * as linux from './desktop-linux.mjs';
9
9
  import * as macos from './desktop-macos.mjs';
10
10
  import * as windows from './desktop-windows.mjs';
11
11
  import { browserAction, browserCapabilityAvailable, browserSnapshot } from './browser.mjs';
12
+ import { probeBrowserReadiness } from './browser-readiness.mjs';
12
13
  import { findOcrText, ocrImage } from './ocr.mjs';
13
14
 
14
15
 
@@ -20,7 +21,7 @@ export async function computerSnapshot(args = {}) {
20
21
  const screenshotRequested = args.include_screenshot !== false || args.include_ocr === true;
21
22
  const captureScreenshot = desktopAvailable && screenshotRequested;
22
23
  const includeUi = args.include_ui !== false;
23
- const [windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult] = await Promise.allSettled([
24
+ const [windowsResult, displaysResult, uiResult, clipboardResult, cursorResult, shotResult, readinessResult] = await Promise.allSettled([
24
25
  desktopAvailable ? listWindows() : Promise.resolve(null),
25
26
  desktopAvailable ? displayInventory() : Promise.resolve(null),
26
27
  includeUi && desktopAvailable
@@ -35,6 +36,7 @@ export async function computerSnapshot(args = {}) {
35
36
  desktopAvailable ? clipboard({ action:'read' }) : Promise.resolve(null),
36
37
  desktopAvailable ? cursorPosition() : Promise.resolve(null),
37
38
  captureScreenshot ? computerScreenshotTask(args) : Promise.resolve(null),
39
+ args.browser_readiness === true ? probeBrowserReadiness() : Promise.resolve(null),
38
40
  ]);
39
41
  const parse = (result, fallback = null) => {
40
42
  if (result.status !== 'fulfilled' || !result.value || result.value.isError === true) return fallback;
@@ -112,9 +114,11 @@ export async function computerSnapshot(args = {}) {
112
114
  const activeLabel = [activeApp?.app, activeApp?.window, activeWindow?.app, activeWindow?.title].filter(Boolean).join(' ');
113
115
  const browserRequested = args.include_browser === true
114
116
  || (args.include_browser !== false && /chrome|chromium|edge|brave/i.test(activeLabel));
117
+ const readiness = args.browser_readiness === true && readinessResult.status === 'fulfilled'
118
+ ? objectRecord(readinessResult.value) : null;
115
119
  let browser = null;
116
120
  let browserError = null;
117
- if (browserRequested && await browserCapabilityAvailable(undefined, 400)) {
121
+ if (browserRequested && (readiness ? readiness.ready : await browserCapabilityAvailable(undefined, 400))) {
118
122
  const hint = browserTitleHint(activeWindow);
119
123
  try {
120
124
  const browserResult = await browserSnapshot({
@@ -184,6 +188,7 @@ export async function computerSnapshot(args = {}) {
184
188
  cursor,
185
189
  ui,
186
190
  ...(browser ? { browser } : {}),
191
+ ...(args.browser_readiness === true ? { browser_readiness:readiness || { status:'invalid_response', ready:false, candidate_pages:0, next_step:'use_native_accessibility' } } : {}),
187
192
  ...(ocr ? { ocr } : {}),
188
193
  fallback_chain: {
189
194
  accessibility: { requested:includeUi, available:includeUi && desktopAvailable && uiResult.status === 'fulfilled' && Boolean(uiResult.value), nodes:uiNodes.length },