@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
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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) {
|