@remcp/runtime 0.2.154 → 0.2.155

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.154",
3
+ "version": "0.2.155",
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,311 @@
1
+ import process from 'node:process';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { isIP } from 'node:net';
5
+ import { lookup } from 'node:dns/promises';
6
+ import { closeSync, constants as fsConstants, existsSync, fstatSync, mkdirSync, openSync, readFileSync, readdirSync } from 'node:fs';
7
+ import { resolveSafePath } from '../util.mjs';
8
+ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs';
9
+ import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
10
+
11
+
12
+ import { assertBrowserRemoteEnabled, assertSafeEvaluationExpression } from './browser-endpoint.mjs';
13
+ import { callPageFunction, evaluate, withTarget } from './browser-session.mjs';
14
+ import { browserNavigate, browserSnapshot, browserTabs, findExpression } from './browser-read.mjs';
15
+
16
+ export async function browserFind(args) {
17
+ if (!(args.selector || args.text || args.role)) throw new Error('browser_find requires selector, text, or role; use browser_snapshot to enumerate page structure');
18
+ return withTarget(args, async (session, target) => {
19
+ const matches = await evaluate(session, findExpression(args));
20
+ return jsonResult({ target_id: target.id, count: Array.isArray(matches) ? matches.length : 0, matches: Array.isArray(matches) ? matches : [] });
21
+ });
22
+ }
23
+
24
+ export const PAGE_SCROLL_ACTION = `function(selector, needle, deltaX, deltaY) {
25
+ let target = selector ? document.querySelector(selector) : null;
26
+ if (!target && needle) {
27
+ const lower = String(needle).toLowerCase();
28
+ target = Array.from(document.querySelectorAll('*')).find(node =>
29
+ String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
30
+ ) || null;
31
+ }
32
+ if ((selector || needle) && !target) throw new Error('Element not found');
33
+
34
+ let scroller = document.scrollingElement || document.documentElement;
35
+ if (target) {
36
+ let current = target;
37
+ while (current && current !== document.documentElement) {
38
+ const style = getComputedStyle(current);
39
+ const scrollX = /(auto|scroll|overlay)/.test(String(style.overflowX || '')) && current.scrollWidth > current.clientWidth;
40
+ const scrollY = /(auto|scroll|overlay)/.test(String(style.overflowY || '')) && current.scrollHeight > current.clientHeight;
41
+ if (scrollX || scrollY) {
42
+ scroller = current;
43
+ break;
44
+ }
45
+ current = current.parentElement;
46
+ }
47
+ }
48
+
49
+ const beforeX = Number(scroller.scrollLeft || 0);
50
+ const beforeY = Number(scroller.scrollTop || 0);
51
+ scroller.scrollLeft = beforeX + Number(deltaX || 0);
52
+ scroller.scrollTop = beforeY + Number(deltaY || 0);
53
+ return {
54
+ target: target ? target.tagName.toLowerCase() : 'page',
55
+ scroller: scroller === document.scrollingElement || scroller === document.documentElement ? 'page' : scroller.tagName.toLowerCase(),
56
+ before_x: beforeX,
57
+ before_y: beforeY,
58
+ after_x: Number(scroller.scrollLeft || 0),
59
+ after_y: Number(scroller.scrollTop || 0),
60
+ delta_x: Number(deltaX || 0),
61
+ delta_y: Number(deltaY || 0),
62
+ };
63
+ }`;
64
+
65
+ export const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
66
+ let el = selector ? document.querySelector(selector) : null;
67
+ if (!el && needle) {
68
+ const lower = String(needle).toLowerCase();
69
+ el = Array.from(document.querySelectorAll('*')).find(node =>
70
+ String(node.innerText || node.textContent || '').trim().toLowerCase().includes(lower)
71
+ ) || null;
72
+ }
73
+ if (!el) throw new Error('Element not found');
74
+ if (action === 'click' || action === 'type' || action === 'scroll_into_view') {
75
+ el.scrollIntoView({block:'center', inline:'center'});
76
+ }
77
+ if (action === 'focus' || action === 'type') el.focus();
78
+ if (action === 'select') {
79
+ el.value = value;
80
+ el.dispatchEvent(new Event('input', {bubbles:true}));
81
+ el.dispatchEvent(new Event('change', {bubbles:true}));
82
+ } else if (action === 'set_value') {
83
+ const proto = Object.getPrototypeOf(el);
84
+ const own = Object.getOwnPropertyDescriptor(proto, 'value');
85
+ const parent = Object.getPrototypeOf(proto);
86
+ const inherited = parent ? Object.getOwnPropertyDescriptor(parent, 'value') : null;
87
+ const setter = own?.set || inherited?.set;
88
+ if (setter) setter.call(el, value); else el.value = value;
89
+ el.dispatchEvent(new Event('input', {bubbles:true}));
90
+ el.dispatchEvent(new Event('change', {bubbles:true}));
91
+ }
92
+ const b = el.getBoundingClientRect();
93
+ if (action === 'click' && (!b.width || !b.height)) throw new Error('Element has no clickable bounds');
94
+ return {
95
+ tag: el.tagName.toLowerCase(),
96
+ value: 'value' in el ? String(el.value) : '',
97
+ x: b.x, y: b.y, width: b.width, height: b.height,
98
+ };
99
+ }`;
100
+
101
+ export function browserActionLocatorText(args = {}, action = '') {
102
+ const explicit = optionalString(args.browser_text || args.match_text);
103
+ if (explicit) return explicit;
104
+ if (action === 'type') {
105
+ // Legacy type calls used text as the locator and value/text_value as the payload.
106
+ if (!optionalString(args.selector) && (args.value != null || args.text_value != null)) return optionalString(args.text);
107
+ return null;
108
+ }
109
+ return optionalString(args.text);
110
+ }
111
+
112
+ export function browserActionInputValue(args = {}, action = '') {
113
+ if (action === 'type') {
114
+ if (!optionalString(args.selector) && !optionalString(args.browser_text || args.match_text) && (args.value != null || args.text_value != null)) {
115
+ return String(args.text_value ?? args.value ?? '');
116
+ }
117
+ return String(args.text ?? args.text_value ?? args.value ?? '');
118
+ }
119
+ return String(args.value ?? args.text_value ?? args.text ?? '');
120
+ }
121
+
122
+ export function browserScrollDeltas(args = {}) {
123
+ const direction = optionalString(args.direction);
124
+ const hasDelta = [args.delta_x,args.delta_y,args.delta].some(value => value != null && Number.isFinite(Number(value)));
125
+ if (!direction && !hasDelta) return null;
126
+ const times = clamp(args.wheel_times, 1, 1, 50);
127
+ let deltaX = Number(args.delta_x || 0);
128
+ let deltaY = Number(args.delta_y ?? args.delta ?? 0);
129
+ if (direction) {
130
+ const normalized = requireEnum(direction, 'direction', ['up','down','left','right']);
131
+ if (normalized === 'up') deltaY = -120 * times;
132
+ if (normalized === 'down') deltaY = 120 * times;
133
+ if (normalized === 'left') deltaX = -120 * times;
134
+ if (normalized === 'right') deltaX = 120 * times;
135
+ }
136
+ return { delta_x:deltaX, delta_y:deltaY };
137
+ }
138
+
139
+ export async function browserAction(args) {
140
+ const action = requireEnum(args.action, 'action', ['click', 'focus', 'type', 'set_value', 'select', 'scroll', 'scroll_into_view', 'upload', 'press', 'set_viewport']);
141
+ return withTarget(args, async (session, target) => {
142
+ if (action === 'set_viewport') {
143
+ const width = clamp(args.width, 1280, 200, 8192);
144
+ const height = clamp(args.height, 720, 200, 8192);
145
+ const deviceScaleFactor = Math.max(0.1, Math.min(8, Number(args.device_scale_factor) || 1));
146
+ await session.send('Emulation.setDeviceMetricsOverride', {
147
+ width,
148
+ height,
149
+ deviceScaleFactor,
150
+ mobile: Boolean(args.mobile),
151
+ screenWidth: width,
152
+ screenHeight: height,
153
+ });
154
+ return jsonResult({ target_id:target.id, action, width, height, device_scale_factor:deviceScaleFactor, mobile:Boolean(args.mobile) });
155
+ }
156
+ if (action === 'upload') {
157
+ const selector = optionalString(args.selector);
158
+ if (!selector) throw new Error('selector is required for upload');
159
+ const requested = (Array.isArray(args.paths) ? args.paths : [args.path]).filter(Boolean);
160
+ if (!requested.length) throw new Error('path or paths is required for upload');
161
+ const files = [];
162
+ for (const value of requested) files.push(await resolveSafePath(value, 'path'));
163
+ await session.send('DOM.enable');
164
+ const doc = await session.send('DOM.getDocument', { depth: 1 });
165
+ const found = await session.send('DOM.querySelector', { nodeId: doc.root.nodeId, selector });
166
+ if (!found.nodeId) throw new Error(`No element matches selector ${selector}`);
167
+ await session.send('DOM.setFileInputFiles', { nodeId: found.nodeId, files });
168
+ return jsonResult({ target_id: target.id, action, selector, files: files.length });
169
+ }
170
+ if (action === 'press') {
171
+ const key = optionalString(args.key);
172
+ if (!key) throw new Error('key is required for press');
173
+ await session.send('Input.dispatchKeyEvent', { type: 'keyDown', key });
174
+ await session.send('Input.dispatchKeyEvent', { type: 'keyUp', key });
175
+ return jsonResult({ target_id: target.id, action, key });
176
+ }
177
+ const selector = optionalString(args.selector);
178
+ const needle = browserActionLocatorText(args, action);
179
+ const value = browserActionInputValue(args, action);
180
+ const option = String(args.option ?? value);
181
+
182
+ if (action === 'scroll') {
183
+ const deltas = browserScrollDeltas(args);
184
+ if (!deltas) throw new Error('browser_action scroll requires direction or a delta');
185
+ return jsonResult({
186
+ target_id: target.id,
187
+ action,
188
+ result: await callPageFunction(session, PAGE_SCROLL_ACTION, [selector, needle, deltas.delta_x, deltas.delta_y]),
189
+ });
190
+ }
191
+
192
+ if (!selector && !needle) throw new Error('selector or browser_text/text locator is required');
193
+
194
+ if (action === 'click') {
195
+ const rect = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'click', '']);
196
+ const x = Number(rect.x) + Number(rect.width) / 2;
197
+ const y = Number(rect.y) + Number(rect.height) / 2;
198
+ await session.send('Input.dispatchMouseEvent', { type:'mouseMoved', x, y, button:'none' });
199
+ await session.send('Input.dispatchMouseEvent', { type:'mousePressed', x, y, button:'left', clickCount:1 });
200
+ await session.send('Input.dispatchMouseEvent', { type:'mouseReleased', x, y, button:'left', clickCount:1 });
201
+ return jsonResult({ target_id:target.id, action, result:{ ...rect, x, y } });
202
+ }
203
+
204
+ if (action === 'type') {
205
+ const focused = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'type', '']);
206
+ if (args.clear === true) await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'set_value', '']);
207
+ await session.send('Input.insertText', { text:value });
208
+ const current = await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, 'read_value', '']);
209
+ return jsonResult({ target_id:target.id, action, result:{ ...focused, value:current.value } });
210
+ }
211
+
212
+ const pageValue = action === 'select' ? option : value;
213
+ return jsonResult({
214
+ target_id: target.id,
215
+ action,
216
+ result: await callPageFunction(session, PAGE_ELEMENT_ACTION, [selector, needle, action, pageValue]),
217
+ });
218
+ });
219
+ }
220
+
221
+ export async function browserWait(args = {}) {
222
+ const condition = requireEnum(args.condition || 'selector', 'condition', ['selector', 'text', 'url_contains', 'expression', 'load', 'navigation', 'network_idle']);
223
+ const timeoutMs = clamp(args.timeout_ms, 10_000, 100, 120_000);
224
+ const pollMs = clamp(args.poll_ms, 200, 50, 5000);
225
+ const idleMs = clamp(args.idle_ms, 500, 100, 10_000);
226
+ const wanted = optionalString(args.selector || args.text || args.value || args.expression);
227
+ if (['selector','text','url_contains','expression'].includes(condition) && !wanted) throw new Error('selector/text/value/expression is required for this condition');
228
+ if (condition === 'expression') await assertSafeEvaluationExpression(wanted);
229
+ return withTarget(args, async (session, target) => {
230
+ const started = Date.now();
231
+ const initialUrl = await evaluate(session, 'location.href').catch(() => target.url || '');
232
+ const inflight = new Set();
233
+ let lastResourceCount = -1;
234
+ let stableSince = Date.now();
235
+ const unsubscribers = [];
236
+ if (condition === 'network_idle') {
237
+ await session.send('Network.enable');
238
+ const markBusy = params => {
239
+ if (params?.requestId) inflight.add(params.requestId);
240
+ stableSince = Date.now();
241
+ };
242
+ const markDone = params => {
243
+ if (params?.requestId) inflight.delete(params.requestId);
244
+ stableSince = Date.now();
245
+ };
246
+ unsubscribers.push(
247
+ session.observe('Network.requestWillBeSent', markBusy),
248
+ session.observe('Network.loadingFinished', markDone),
249
+ session.observe('Network.loadingFailed', markDone),
250
+ );
251
+ }
252
+ try {
253
+ while (Date.now() - started < timeoutMs) {
254
+ let matched = false;
255
+ if (condition === 'network_idle') {
256
+ const state = await evaluate(session, `({ready:document.readyState,resources:performance.getEntriesByType('resource').length})`).catch(() => null);
257
+ if (state) {
258
+ if (state.resources !== lastResourceCount) {
259
+ lastResourceCount = state.resources;
260
+ stableSince = Date.now();
261
+ }
262
+ matched = state.ready === 'complete' && inflight.size === 0 && Date.now() - stableSince >= idleMs;
263
+ }
264
+ } else {
265
+ const expression = condition === 'selector' ? `Boolean(document.querySelector(${JSON.stringify(wanted)}))`
266
+ : condition === 'text' ? `(document.body?.innerText||'').toLowerCase().includes(${JSON.stringify(wanted?.toLowerCase())})`
267
+ : condition === 'url_contains' ? `location.href.includes(${JSON.stringify(wanted)})`
268
+ : condition === 'load' ? `document.readyState === 'complete'`
269
+ : condition === 'navigation' ? `location.href !== ${JSON.stringify(initialUrl)}`
270
+ : `Boolean(${wanted})`;
271
+ matched = Boolean(await evaluate(session, expression));
272
+ }
273
+ if (matched) return jsonResult({
274
+ target_id: target.id,
275
+ condition,
276
+ matched: true,
277
+ elapsed_ms: Date.now() - started,
278
+ ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
279
+ url: await evaluate(session, 'location.href').catch(() => target.url || null),
280
+ });
281
+ await new Promise(resolve => setTimeout(resolve, pollMs));
282
+ }
283
+ return jsonResult({
284
+ target_id: target.id,
285
+ condition,
286
+ matched: false,
287
+ elapsed_ms: Date.now() - started,
288
+ ...(condition === 'network_idle' ? { inflight: inflight.size, idle_ms: idleMs } : {}),
289
+ url: await evaluate(session, 'location.href').catch(() => target.url || null),
290
+ });
291
+ } finally {
292
+ for (const unsubscribe of unsubscribers) unsubscribe();
293
+ }
294
+ });
295
+ }
296
+
297
+ export async function browserEvaluate(args) {
298
+ assertBrowserRemoteEnabled();
299
+ const expression = await assertSafeEvaluationExpression(args.expression);
300
+ return withTarget(args, async (session, target) => jsonResult({ target_id: target.id, value: await evaluate(session, expression, args.await_promise !== false) }));
301
+ }
302
+
303
+ export const browserHandlers = {
304
+ browser_tabs: browserTabs,
305
+ browser_navigate: browserNavigate,
306
+ browser_snapshot: browserSnapshot,
307
+ browser_find: browserFind,
308
+ browser_action: browserAction,
309
+ browser_wait: browserWait,
310
+ browser_evaluate: browserEvaluate,
311
+ };