@remcp/runtime 0.2.161 → 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.161",
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
+ }
@@ -2,7 +2,7 @@
2
2
  // A workflow is explicitly requested by the caller after inspecting the page.
3
3
  // No arbitrary JavaScript, cross-target access, file uploads or implicit submits.
4
4
  import { assertResolvedPageUrl } from './browser-endpoint.mjs';
5
- import { callPageFunction, evaluate } from './browser-session.mjs';
5
+ import { evaluate } from './browser-session.mjs';
6
6
 
7
7
  const ACTIONS = new Set(['fill_form','set_value','select','focus','click','wait_for']);
8
8
  const CONDITIONS = new Set(['selector','text','url_contains','load']);
@@ -45,7 +45,28 @@ export function validateBrowserWorkflow(steps, { validateFormFields }) {
45
45
  });
46
46
  }
47
47
 
48
- async function waitForStep(session, step, started) {
48
+ // A workflow is a single-page plan. Redirects across origins require a new
49
+ // snapshot and a separate deliberate action, even if both URLs are public.
50
+ export function assertWorkflowOrigin(expectedOrigin, currentUrl) {
51
+ let currentOrigin;
52
+ try { currentOrigin = new URL(currentUrl).origin; }
53
+ catch { throw new Error('Browser workflow page origin could not be verified'); }
54
+ if (expectedOrigin !== null && currentOrigin !== expectedOrigin) {
55
+ throw new Error('Browser workflow origin changed');
56
+ }
57
+ return currentOrigin;
58
+ }
59
+
60
+ // Enforce the origin in the exact same page JS evaluation that writes a field.
61
+ // Caller-supplied selectors/values stay JSON data, not arbitrary JavaScript.
62
+ async function guardedPageFunction(session, trustedFunction, args, origin) {
63
+ const expression = '(() => { if (location.origin !== ' + JSON.stringify(origin) +
64
+ ') throw new Error("workflow_origin_changed"); return (' + trustedFunction +
65
+ ')(...' + JSON.stringify(args) + '); })()';
66
+ return evaluate(session, expression);
67
+ }
68
+
69
+ async function waitForStep(session, step, started, expectedOrigin) {
49
70
  const deadline = Math.min(started + 12000, Date.now() + step.timeout_ms);
50
71
  const expression = step.condition === 'selector' ?
51
72
  'Boolean(document.querySelector('+JSON.stringify(step.criterion)+'))'
@@ -67,7 +88,8 @@ async function waitForStep(session, step, started) {
67
88
  await new Promise(resolve=>setTimeout(resolve, 75));
68
89
  continue;
69
90
  }
70
- // Never accept a match on a private or unsafe page.
91
+ // Never accept a match on an unsafe page or an unexpected origin.
92
+ assertWorkflowOrigin(expectedOrigin, state?.url);
71
93
  await assertResolvedPageUrl(state?.url);
72
94
  if (state?.matched) return;
73
95
  await new Promise(resolve=>setTimeout(resolve, 75));
@@ -80,25 +102,28 @@ export async function executeBrowserWorkflow(session, steps, {
80
102
  }) {
81
103
  const started = Date.now();
82
104
  const completed = [];
105
+ let boundOrigin = null;
83
106
  for (let index = 0; index < steps.length; index++) {
84
107
  const step = steps[index];
85
108
  if (Date.now() - started > 12000) throw new Error('Workflow exceeded 12 second budget after '+completed.length+' steps');
86
109
  try {
87
110
  const currentUrl = await evaluate(session, 'location.href');
111
+ const currentOrigin = assertWorkflowOrigin(boundOrigin, currentUrl);
88
112
  await assertResolvedPageUrl(currentUrl);
113
+ if (boundOrigin === null) boundOrigin = currentOrigin;
89
114
  if (step.action === 'fill_form') {
90
- await callPageFunction(session,pageFillForm,[step.fields]);
115
+ await guardedPageFunction(session,pageFillForm,[step.fields],boundOrigin);
91
116
  } else if (step.action === 'wait_for') {
92
- await waitForStep(session,step,started);
117
+ await waitForStep(session,step,started,boundOrigin);
93
118
  } else if (step.action === 'click') {
94
- const bounds=await callPageFunction(session,pageElementAction,[step.selector,null,'click','']);
119
+ const bounds=await guardedPageFunction(session,pageElementAction,[step.selector,null,'click',''],boundOrigin);
95
120
  const x=Number(bounds.x)+Number(bounds.width)/2;
96
121
  const y=Number(bounds.y)+Number(bounds.height)/2;
97
122
  await session.send('Input.dispatchMouseEvent',{type:'mouseMoved',x,y,button:'none'});
98
123
  await session.send('Input.dispatchMouseEvent',{type:'mousePressed',x,y,button:'left',clickCount:1});
99
124
  await session.send('Input.dispatchMouseEvent',{type:'mouseReleased',x,y,button:'left',clickCount:1});
100
125
  } else {
101
- await callPageFunction(session,pageElementAction,[step.selector,null,step.action,step.value??'']);
126
+ await guardedPageFunction(session,pageElementAction,[step.selector,null,step.action,step.value??''],boundOrigin);
102
127
  }
103
128
  completed.push({index:index+1,action:step.action,ok:true});
104
129
  } catch (error) {
@@ -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 },