@remcp/runtime 0.2.157 → 0.2.159

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.157",
3
+ "version": "0.2.159",
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",
@@ -12,6 +12,7 @@ import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDet
12
12
  import { assertBrowserRemoteEnabled, assertSafeEvaluationExpression } from './browser-endpoint.mjs';
13
13
  import { callPageFunction, evaluate, withTarget } from './browser-session.mjs';
14
14
  import { browserNavigate, browserSnapshot, browserTabs, findExpression } from './browser-read.mjs';
15
+ import { executeBrowserWorkflow, validateBrowserWorkflow } from './browser-workflow.mjs';
15
16
 
16
17
  export async function browserFind(args) {
17
18
  if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
@@ -89,11 +90,26 @@ export const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
89
90
  el.dispatchEvent(new Event('input', {bubbles:true}));
90
91
  el.dispatchEvent(new Event('change', {bubbles:true}));
91
92
  }
92
- const b = el.getBoundingClientRect();
93
- if (action === 'click' && (!b.width || !b.height)) throw new Error('Element has no clickable bounds');
93
+ let b = el.getBoundingClientRect();
94
+ if (action === 'click') {
95
+ // An inline element can span several text fragments. Its bounding box center
96
+ // can point to a completely unrelated input (or even a destructive button).
97
+ // Only return a point whose actual hit target belongs to this element.
98
+ const rectangles = Array.from(el.getClientRects());
99
+ const safe = rectangles.find(rect => {
100
+ if (!rect.width || !rect.height) return false;
101
+ const x = rect.x + rect.width / 2, y = rect.y + rect.height / 2;
102
+ const target = document.elementFromPoint(x, y);
103
+ return target && (target === el || el.contains(target));
104
+ });
105
+ if (!safe) throw new Error('Element has no unobstructed clickable fragment');
106
+ b = safe;
107
+ }
94
108
  return {
95
109
  tag: el.tagName.toLowerCase(),
96
- value: 'value' in el ? String(el.value) : '',
110
+ // Never expose password or credential field contents in tool responses.
111
+ value: 'value' in el && !el.matches('input[type="password"],input[autocomplete="current-password"],input[autocomplete="new-password"],input[autocomplete="one-time-code"]') ?
112
+ String(el.value) : '',
97
113
  x: b.x, y: b.y, width: b.width, height: b.height,
98
114
  };
99
115
  }`;
@@ -188,9 +204,17 @@ export function browserScrollDeltas(args = {}) {
188
204
  }
189
205
 
190
206
  export async function browserAction(args) {
191
- const action = requireEnum(args.action, 'action', ['click', 'focus', 'type', 'set_value', 'select', 'scroll', 'scroll_into_view', 'upload', 'press', 'set_viewport', 'fill_form']);
207
+ const action = requireEnum(args.action, 'action', ['click', 'focus', 'type', 'set_value', 'select', 'scroll', 'scroll_into_view', 'upload', 'press', 'set_viewport', 'fill_form', 'workflow']);
192
208
  const formFields = action === 'fill_form' ? validateFormFields(args.fields) : null;
209
+ const workflowSteps = action === 'workflow' ?
210
+ validateBrowserWorkflow(args.steps, {validateFormFields}) : null;
193
211
  return withTarget(args, async (session, target) => {
212
+ if (action === 'workflow') {
213
+ const result = await executeBrowserWorkflow(session, workflowSteps, {
214
+ pageElementAction:PAGE_ELEMENT_ACTION, pageFillForm:PAGE_FILL_FORM,
215
+ });
216
+ return jsonResult({target_id:target.id, action, result});
217
+ }
194
218
  if (action === 'fill_form') {
195
219
  const result = await callPageFunction(session, PAGE_FILL_FORM, [formFields]);
196
220
  return jsonResult({target_id:target.id, action, result});
@@ -0,0 +1,111 @@
1
+ // Execute deterministic bounded UI steps on one guarded CDP target/session.
2
+ // A workflow is explicitly requested by the caller after inspecting the page.
3
+ // No arbitrary JavaScript, cross-target access, file uploads or implicit submits.
4
+ import { assertResolvedPageUrl } from './browser-endpoint.mjs';
5
+ import { callPageFunction, evaluate } from './browser-session.mjs';
6
+
7
+ const ACTIONS = new Set(['fill_form','set_value','select','focus','click','wait_for']);
8
+ const CONDITIONS = new Set(['selector','text','url_contains','load']);
9
+
10
+ export function validateBrowserWorkflow(steps, { validateFormFields }) {
11
+ if (!Array.isArray(steps) || steps.length < 1 || steps.length > 12) {
12
+ throw new Error('browser workflow requires 1 to 12 steps');
13
+ }
14
+ return steps.map((raw, index) => {
15
+ const action = typeof raw?.action === 'string' ? raw.action : '';
16
+ if (!ACTIONS.has(action)) throw new Error('Unsupported browser workflow action at step ' + (index + 1));
17
+ if (action === 'fill_form') {
18
+ return { action, fields:validateFormFields(raw.fields) };
19
+ }
20
+ if (action === 'wait_for') {
21
+ const condition = String(raw.condition || 'selector');
22
+ if (!CONDITIONS.has(condition)) throw new Error('Unsupported browser wait condition at step ' + (index + 1));
23
+ const criterion = condition === 'selector' ? raw.selector : raw.text;
24
+ if (condition !== 'load' && (typeof criterion !== 'string' || !criterion.trim() || criterion.length > 512)) {
25
+ throw new Error('Browser workflow wait requires a bounded criterion at step ' + (index + 1));
26
+ }
27
+ const timeout_ms = raw.timeout_ms == null ? 1500 : Number(raw.timeout_ms);
28
+ if (!Number.isInteger(timeout_ms) || timeout_ms < 100 || timeout_ms > 4000) {
29
+ throw new Error('Browser workflow wait timeout must be 100-4000 ms');
30
+ }
31
+ return {action, condition, criterion:String(criterion || ''), timeout_ms};
32
+ }
33
+ if (typeof raw.selector !== 'string' || !raw.selector.trim() || raw.selector.length > 512) {
34
+ throw new Error('Browser workflow action requires a CSS selector at step ' + (index + 1));
35
+ }
36
+ if (['set_value','select'].includes(action) &&
37
+ (typeof raw.value !== 'string' && typeof raw.value !== 'number' && typeof raw.value !== 'boolean')) {
38
+ throw new Error('Browser workflow input requires a string/number/boolean value at step ' + (index + 1));
39
+ }
40
+ if (['set_value','select'].includes(action) && String(raw.value).length > 16384) {
41
+ throw new Error('Browser workflow input exceeds 16384 characters');
42
+ }
43
+ return {action, selector:raw.selector.trim(),
44
+ ...(['set_value','select'].includes(action) ? {value:String(raw.value)} : {})};
45
+ });
46
+ }
47
+
48
+ async function waitForStep(session, step, started) {
49
+ const deadline = Math.min(started + 12000, Date.now() + step.timeout_ms);
50
+ const expression = step.condition === 'selector' ?
51
+ 'Boolean(document.querySelector('+JSON.stringify(step.criterion)+'))'
52
+ : step.condition === 'text' ?
53
+ '(document.body?.innerText||"").includes('+JSON.stringify(step.criterion)+')'
54
+ : step.condition === 'url_contains' ?
55
+ 'location.href.includes('+JSON.stringify(step.criterion)+')'
56
+ : 'document.readyState === "complete"';
57
+ while (Date.now() < deadline) {
58
+ let state;
59
+ try {
60
+ // One CDP roundtrip validates the navigated page URL and the condition.
61
+ state = await evaluate(session, '({url:location.href,matched:Boolean(' + expression + ')})');
62
+ } catch (error) {
63
+ // Clicking a link can invalidate the JS execution context briefly.
64
+ if (!/execution context (was destroyed|not found)|cannot find context/i.test(String(error?.message || ''))) {
65
+ throw error;
66
+ }
67
+ await new Promise(resolve=>setTimeout(resolve, 75));
68
+ continue;
69
+ }
70
+ // Never accept a match on a private or unsafe page.
71
+ await assertResolvedPageUrl(state?.url);
72
+ if (state?.matched) return;
73
+ await new Promise(resolve=>setTimeout(resolve, 75));
74
+ }
75
+ throw new Error('Workflow wait timed out');
76
+ }
77
+
78
+ export async function executeBrowserWorkflow(session, steps, {
79
+ pageElementAction, pageFillForm,
80
+ }) {
81
+ const started = Date.now();
82
+ const completed = [];
83
+ for (let index = 0; index < steps.length; index++) {
84
+ const step = steps[index];
85
+ if (Date.now() - started > 12000) throw new Error('Workflow exceeded 12 second budget after '+completed.length+' steps');
86
+ try {
87
+ const currentUrl = await evaluate(session, 'location.href');
88
+ await assertResolvedPageUrl(currentUrl);
89
+ if (step.action === 'fill_form') {
90
+ await callPageFunction(session,pageFillForm,[step.fields]);
91
+ } else if (step.action === 'wait_for') {
92
+ await waitForStep(session,step,started);
93
+ } else if (step.action === 'click') {
94
+ const bounds=await callPageFunction(session,pageElementAction,[step.selector,null,'click','']);
95
+ const x=Number(bounds.x)+Number(bounds.width)/2;
96
+ const y=Number(bounds.y)+Number(bounds.height)/2;
97
+ await session.send('Input.dispatchMouseEvent',{type:'mouseMoved',x,y,button:'none'});
98
+ await session.send('Input.dispatchMouseEvent',{type:'mousePressed',x,y,button:'left',clickCount:1});
99
+ await session.send('Input.dispatchMouseEvent',{type:'mouseReleased',x,y,button:'left',clickCount:1});
100
+ } else {
101
+ await callPageFunction(session,pageElementAction,[step.selector,null,step.action,step.value??'']);
102
+ }
103
+ completed.push({index:index+1,action:step.action,ok:true});
104
+ } catch (error) {
105
+ // Don't echo form values or page text in an error.
106
+ throw new Error('Browser workflow stopped at step '+(index+1)+' ('+step.action+'); '+
107
+ completed.length+' steps completed. '+(step.action==='wait_for'?'Condition not satisfied.':'Inspect the page before retrying.'));
108
+ }
109
+ }
110
+ return {completed:completed.length,steps:completed,elapsed_ms:Date.now()-started};
111
+ }
@@ -165,7 +165,7 @@ export const extendedToolDefinitions = [
165
165
  define('browser_navigate', 'Browser navigate', 'Open a new debuggable tab or navigate a selected browser page by URL, history back/forward, or reload through Chrome DevTools Protocol.', o({ ...browserTarget, action:e(['url','new_tab','back','forward','reload'], 'Default url when url is provided, otherwise reload. new_tab creates the first page target when needed.'), url:s('Destination URL for action=url or action=new_tab.'), wait:b('Wait for the page load event; default true.'), ignore_cache:b('Reload without cache when action=reload.') }), openMutatingNonDestructive, browserHandlers.browser_navigate, ['browser_cdp']),
166
166
  define('browser_snapshot', 'Browser snapshot', 'Return the selected browser page accessibility tree through Chrome DevTools Protocol and, when requested, attach a real rendered viewport PNG for visual verification. A selector is temporarily recentered for capture, its exact viewport bounds are returned, and the prior scroll position is restored before return.', o({ ...browserTarget, max_nodes:n('Maximum AX nodes; default 1500.'), include_screenshot:b('Attach a real CDP-rendered viewport PNG screenshot; default false.'), selector:s('When include_screenshot=true, center this CSS-selected element before capture and report its viewport bounds.') }), readOnlyOpen, browserHandlers.browser_snapshot, ['browser_cdp']),
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
- 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']), selector:s('CSS selector for the target element; prefer the reusable selector returned by browser_find.'), 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']),
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
170
  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']),
171
171