@remcp/runtime 0.2.160 → 0.2.162

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.160",
3
+ "version": "0.2.162",
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",
@@ -244,6 +244,24 @@ export function productionSessionKey(endpoint, target) {
244
244
  return `${endpoint.origin}|${String(target.id)}`;
245
245
  }
246
246
 
247
+ // A target returned by browser_tabs is already bound to a validated CDP
248
+ // WebSocket. Reuse its guarded live session for subsequent exact-ID actions
249
+ // instead of rediscovering every unrelated Chrome tab on each keypress/click.
250
+ export function findGuardedCachedTarget(args, endpoint) {
251
+ if (process.env.NODE_ENV !== 'production') return null;
252
+ const id = optionalString(args?.target_id || args?.targetId);
253
+ if (!id || args?.title || args?.url_contains || args?.urlContains) return null;
254
+ const key = endpoint.origin + '|' + id;
255
+ const record = productionBrowserSessions.get(key);
256
+ if (!record?.guardInstalled || !record.target || record.targetId !== id
257
+ || record.target.id !== id || record.session?.closed
258
+ || record.session?.socket?.readyState !== 1) return null;
259
+ try {
260
+ if (validatedTargetWebSocketUrl(record.target, endpoint) !== record.session.url) return null;
261
+ } catch { return null; }
262
+ return { key, record, target:record.target };
263
+ }
264
+
247
265
  export function closeProductionSession(key, record) {
248
266
  if (!record) return;
249
267
  if (record.timer) clearTimeout(record.timer);
@@ -257,10 +275,10 @@ export function closeProductionSession(key, record) {
257
275
  if (productionBrowserSessions.get(key) === record) productionBrowserSessions.delete(key);
258
276
  }
259
277
 
260
- export function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false) {
278
+ export function retainProductionSession(key, session, removeGuard, targetId, endpoint, ownedTarget = false, target = null) {
261
279
  const existing = productionBrowserSessions.get(key);
262
280
  if (existing) closeProductionSession(key, existing);
263
- const record = { session, removeGuard, targetId, endpoint, ownedTarget, timer:null };
281
+ const record = { session, removeGuard, targetId, endpoint, ownedTarget, target, guardInstalled:true, timer:null };
264
282
  productionBrowserSessions.set(key, record);
265
283
  const schedule = () => {
266
284
  if (record.timer) clearTimeout(record.timer);
@@ -299,7 +317,7 @@ export async function createGuardedNewTab(url, args) {
299
317
  removeGuard = await installBrowserRequestGuard(session, { closeRootTarget:() => closeBrowserTarget(endpoint, target.id) });
300
318
  if (process.env.NODE_ENV === 'production') {
301
319
  productionKey = productionSessionKey(endpoint, target);
302
- productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true);
320
+ productionRecord = retainProductionSession(productionKey, session, removeGuard, target.id, endpoint, true, target);
303
321
  }
304
322
  await session.send('Page.enable');
305
323
  const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
@@ -325,15 +343,16 @@ export async function createGuardedNewTab(url, args) {
325
343
  }
326
344
  }
327
345
 
328
- export async function withTarget(args, callback) {
346
+ export async function withTarget(args = {}, callback) {
329
347
  // chooseTarget already validated this exact discovered URL during this call.
330
348
  // The actual page URL is independently checked before and after the action.
331
349
  // Avoid a redundant DNS check for the same target metadata.
332
- const target = await chooseTarget(args);
333
350
  const endpoint = endpointUrl(args.endpoint);
351
+ const cached = findGuardedCachedTarget(args, endpoint);
352
+ const target = cached?.target || await chooseTarget(args);
334
353
  const production = process.env.NODE_ENV === 'production';
335
354
  const key = production ? productionSessionKey(endpoint, target) : '';
336
- let record = production ? productionBrowserSessions.get(key) : null;
355
+ let record = cached?.record || (production ? productionBrowserSessions.get(key) : null);
337
356
  if (record?.session?.closed || (record?.session?.socket && record.session.socket.readyState !== 1)) {
338
357
  closeProductionSession(key, record);
339
358
  record = null;
@@ -344,7 +363,7 @@ export async function withTarget(args, callback) {
344
363
  try {
345
364
  session = await new CdpSession(target.webSocketDebuggerUrl, clamp(args.timeout_ms, 10_000, 100, 120_000)).open();
346
365
  removeGuard = await installBrowserRequestGuard(session);
347
- if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint);
366
+ if (production) record = retainProductionSession(key, session, removeGuard, target.id, endpoint, false, target);
348
367
  } catch (error) {
349
368
  removeGuard();
350
369
  if (session) {
@@ -358,7 +377,9 @@ export async function withTarget(args, callback) {
358
377
  try {
359
378
  const currentUrl = await evaluate(session, 'location.href');
360
379
  await assertResolvedPageUrl(currentUrl);
361
- const result = await callback(session, target);
380
+ // A reused target's discovery metadata can be old after navigation.
381
+ // Pass the just-verified live URL without an extra CDP round-trip.
382
+ const result = await callback(session, { ...target, url:currentUrl });
362
383
  const finalUrl = await evaluate(session, 'location.href').catch(() => { throw new Error('Browser page URL could not be verified after the operation'); });
363
384
  await assertResolvedPageUrl(finalUrl);
364
385
  return result;
@@ -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) {