@remcp/runtime 0.2.166 → 0.2.168
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
|
@@ -125,6 +125,9 @@ export const PAGE_FILL_FORM = `function(fields) {
|
|
|
125
125
|
}
|
|
126
126
|
const tag = el.tagName.toLowerCase();
|
|
127
127
|
if (!['input','textarea','select'].includes(tag)) throw new Error('Unsupported form field: ' + field.selector);
|
|
128
|
+
if (tag === 'input' && ['checkbox','radio'].includes(el.type) && typeof field.value !== 'boolean') {
|
|
129
|
+
throw new Error('Checkbox and radio fields require a boolean value: ' + field.selector);
|
|
130
|
+
}
|
|
128
131
|
if (tag === 'select' && !Array.from(el.options).some(option => option.value === String(field.value))) {
|
|
129
132
|
throw new Error('No matching select option: ' + field.selector);
|
|
130
133
|
}
|
|
@@ -98,8 +98,8 @@ export async function waitForBrowserEndpoint(timeoutMs = 5000) {
|
|
|
98
98
|
|
|
99
99
|
export async function ensureBrowserEndpoint(endpoint) {
|
|
100
100
|
assertBrowserRemoteEnabled();
|
|
101
|
-
if (endpoint || process.env.REMCP_CDP_URL) return;
|
|
102
|
-
if (await browserCapabilityAvailable(undefined, 250)) return;
|
|
101
|
+
if (endpoint || process.env.REMCP_CDP_URL) return false;
|
|
102
|
+
if (await browserCapabilityAvailable(undefined, 250)) return false;
|
|
103
103
|
if (!browserLaunchPromise) {
|
|
104
104
|
browserLaunchPromise = (async () => {
|
|
105
105
|
const executable = browserExecutable();
|
|
@@ -112,9 +112,10 @@ export async function ensureBrowserEndpoint(endpoint) {
|
|
|
112
112
|
if (selected.isolated) console.warn('ReMCP browser: using an isolated recovery profile; existing session and backups are preserved, and sign-in state is not copied.');
|
|
113
113
|
spawnDetached(executable, browserAutoLaunchArgs(selected.profile));
|
|
114
114
|
if (!await waitForBrowserEndpoint(5000)) throw new Error('ReMCP launched a browser but its local CDP endpoint did not become ready');
|
|
115
|
+
return true;
|
|
115
116
|
})().finally(() => { browserLaunchPromise = null; });
|
|
116
117
|
}
|
|
117
|
-
|
|
118
|
+
return browserLaunchPromise;
|
|
118
119
|
}
|
|
119
120
|
|
|
120
121
|
export async function browserControlAvailable(endpoint, timeoutMs = 500) {
|
|
@@ -193,11 +194,17 @@ export function isPrivateBrowserHost(hostname) {
|
|
|
193
194
|
}
|
|
194
195
|
|
|
195
196
|
export function endpointUrl(value) {
|
|
197
|
+
if (value != null && typeof value !== 'string' && !(value instanceof URL)) {
|
|
198
|
+
throw new Error('endpoint must be a valid local http(s) URL');
|
|
199
|
+
}
|
|
196
200
|
let url;
|
|
197
|
-
try {
|
|
198
|
-
|
|
201
|
+
try {
|
|
202
|
+
const provided = value instanceof URL ? value.href : optionalString(value);
|
|
203
|
+
url = new URL(provided || process.env.REMCP_CDP_URL || DEFAULT_ENDPOINT);
|
|
204
|
+
} catch { throw new Error('endpoint must be a valid local http(s) URL'); }
|
|
199
205
|
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('endpoint must use http or https');
|
|
200
206
|
if (!isLoopbackHost(url.hostname)) throw new Error('Browser CDP endpoint must be loopback-only');
|
|
207
|
+
if (url.username || url.password) throw new Error('Browser CDP endpoint must not contain credentials');
|
|
201
208
|
return url;
|
|
202
209
|
}
|
|
203
210
|
|
|
@@ -98,18 +98,62 @@ export async function browserNavigate(args = {}) {
|
|
|
98
98
|
});
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
// CDP accessibility can include an input's current value even when it is
|
|
102
|
+
// masked on screen. The protected AX property must be checked before the
|
|
103
|
+
// node is mirrored to structuredContent or snapshot diff state.
|
|
104
|
+
export function safeAccessibilityValue(node) {
|
|
105
|
+
const props = Array.isArray(node?.properties) ? node.properties : [];
|
|
106
|
+
const protectedControl = props.some(item => item?.name === 'protected'
|
|
107
|
+
&& (item?.value?.value === true || String(item?.value?.value) === 'true'));
|
|
108
|
+
const role = String(node?.role?.value || '').toLowerCase();
|
|
109
|
+
if (protectedControl || role === 'password' || role === 'passwordtext') return '';
|
|
110
|
+
return boundedText(node?.value?.value, 1000);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Chromium's AX tree can repeat editable input values in descendant
|
|
114
|
+
// StaticText/InlineTextBox names. Blank the complete editable subtree before
|
|
115
|
+
// storing a snapshot diff or sending structured content to the MCP host.
|
|
116
|
+
export function sensitiveAccessibilityDescendants(nodes) {
|
|
117
|
+
const indexed = new Map(nodes.map(node => [node.nodeId, node]));
|
|
118
|
+
const secret = new Set();
|
|
119
|
+
const queue = [];
|
|
120
|
+
for (const node of nodes) {
|
|
121
|
+
const role = String(node.role?.value || '').toLowerCase();
|
|
122
|
+
const properties = Array.isArray(node.properties) ? node.properties : [];
|
|
123
|
+
const editable = properties.some(item => item?.name === 'editable'
|
|
124
|
+
&& ![false,'false',null].includes(item?.value?.value));
|
|
125
|
+
const protectedNode = properties.some(item => item?.name === 'protected'
|
|
126
|
+
&& [true,'true'].includes(item?.value?.value));
|
|
127
|
+
if (['textbox','searchbox','combobox','password','passwordtext','spinbutton'].includes(role)
|
|
128
|
+
|| editable || protectedNode) {
|
|
129
|
+
secret.add(node.nodeId);
|
|
130
|
+
for (const id of node.childIds || []) queue.push(id);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
while (queue.length) {
|
|
134
|
+
const id = queue.pop();
|
|
135
|
+
if (secret.has(id)) continue;
|
|
136
|
+
secret.add(id);
|
|
137
|
+
for (const child of indexed.get(id)?.childIds || []) queue.push(child);
|
|
138
|
+
}
|
|
139
|
+
return secret;
|
|
140
|
+
}
|
|
141
|
+
|
|
101
142
|
export async function browserSnapshot(args) {
|
|
102
143
|
return withTarget(args, async (session, target) => {
|
|
103
144
|
await session.send('Accessibility.enable');
|
|
104
145
|
const { nodes = [] } = await session.send('Accessibility.getFullAXTree');
|
|
105
146
|
const limit = clamp(args.max_nodes, 1500, 1, 10_000);
|
|
147
|
+
const redacted = sensitiveAccessibilityDescendants(nodes);
|
|
106
148
|
const simplified = nodes.slice(0, limit).map(node => ({
|
|
107
149
|
node_id: boundedText(node.nodeId, 256),
|
|
108
150
|
ignored: Boolean(node.ignored),
|
|
109
151
|
role: boundedText(node.role?.value, 256),
|
|
110
|
-
name:
|
|
111
|
-
|
|
112
|
-
|
|
152
|
+
name: redacted.has(node.nodeId) && !['textbox','searchbox','combobox','spinbutton'].includes(
|
|
153
|
+
String(node.role?.value||'').toLowerCase()
|
|
154
|
+
) ? '' : boundedText(node.name?.value, 1000),
|
|
155
|
+
value: redacted.has(node.nodeId) ? '' : safeAccessibilityValue(node),
|
|
156
|
+
description: redacted.has(node.nodeId) ? '' : boundedText(node.description?.value, 1000),
|
|
113
157
|
child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
|
|
114
158
|
backend_dom_node_id: node.backendDOMNodeId || null,
|
|
115
159
|
}));
|
|
@@ -285,9 +329,19 @@ export function findExpression(args) {
|
|
|
285
329
|
} else {
|
|
286
330
|
selected = selected.slice(0, ${limit});
|
|
287
331
|
}
|
|
332
|
+
// Credentials are never sent back in browser_find; even a normal
|
|
333
|
+
// text input can carry an OTP or API token via its name/autocomplete.
|
|
334
|
+
function isSensitiveControl(el) {
|
|
335
|
+
const tag = el.tagName.toLowerCase();
|
|
336
|
+
if (tag !== 'input' && tag !== 'textarea') return false;
|
|
337
|
+
if (tag === 'input' && String(el.type || '').toLowerCase() === 'password') return true;
|
|
338
|
+
const description = ['id','name','autocomplete'].map(key => el.getAttribute(key) || '').join(' ');
|
|
339
|
+
return /(?:password|passwd|passcode|one.time.code|otp|secret|api.?key|auth.?token|access.?token|recovery.?code)/i.test(description);
|
|
340
|
+
}
|
|
288
341
|
return selected.map(({ el }) => {
|
|
289
342
|
const b = el.getBoundingClientRect();
|
|
290
|
-
|
|
343
|
+
const sensitive = isSensitiveControl(el);
|
|
344
|
+
return { selector: cssPath(el), tag: el.tagName.toLowerCase(), role: semanticRole(el), name: el.getAttribute('aria-label') || el.getAttribute('name') || '', text: sensitive ? '' : visibleText(el).slice(0, 500), value: !sensitive && 'value' in el ? String(el.value).slice(0, 500) : '', x: b.x, y: b.y, width: b.width, height: b.height, disabled: Boolean(el.disabled) };
|
|
291
345
|
});
|
|
292
346
|
})()`;
|
|
293
347
|
}
|
|
@@ -290,8 +290,35 @@ export function retainProductionSession(key, session, removeGuard, targetId, end
|
|
|
290
290
|
return record;
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
+
// New Chromium windows may report CDP readiness before their session restore
|
|
294
|
+
// finishes. Only cold-start navigation pays this bounded settling cost; all
|
|
295
|
+
// warm clicks, existing tabs and browser reads keep their current fast path.
|
|
296
|
+
export const COLD_BROWSER_SETTLE_MS = 1200;
|
|
297
|
+
export const COLD_TARGET_VERIFY_MS = 700;
|
|
298
|
+
|
|
299
|
+
export class BrowserTargetDisappearedError extends Error {
|
|
300
|
+
constructor() {
|
|
301
|
+
super('Browser CDP target disappeared during startup; the requested tab was not retained. Retry once after checking browser readiness.');
|
|
302
|
+
this.name = 'BrowserTargetDisappearedError';
|
|
303
|
+
this.code = 'BROWSER_TARGET_DISAPPEARED';
|
|
304
|
+
this.retryable = true;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function assertCreatedTargetAvailable(targetId, endpoint, {
|
|
309
|
+
request = requestJson, pause = ms => new Promise(resolve => setTimeout(resolve, ms)),
|
|
310
|
+
verifyAfterMs = 0,
|
|
311
|
+
} = {}) {
|
|
312
|
+
if (verifyAfterMs > 0) await pause(verifyAfterMs);
|
|
313
|
+
const targets = await request('/json/list', endpoint, { timeout_ms:2000 });
|
|
314
|
+
if (!Array.isArray(targets) || !targets.some(item => item?.id === targetId && item?.type === 'page')) {
|
|
315
|
+
throw new BrowserTargetDisappearedError();
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
293
319
|
export async function createGuardedNewTab(url, args) {
|
|
294
|
-
await ensureBrowserEndpoint(args.endpoint);
|
|
320
|
+
const autoLaunched = await ensureBrowserEndpoint(args.endpoint);
|
|
321
|
+
if (autoLaunched) await new Promise(resolve => setTimeout(resolve, COLD_BROWSER_SETTLE_MS));
|
|
295
322
|
const created = await requestJson('/json/new', args.endpoint, { method:'PUT', timeout_ms:args.timeout_ms });
|
|
296
323
|
if (!created?.id) throw new Error('Browser did not return a new page target');
|
|
297
324
|
const endpoint = endpointUrl(args.endpoint);
|
|
@@ -325,6 +352,12 @@ export async function createGuardedNewTab(url, args) {
|
|
|
325
352
|
if (loaded) await loaded;
|
|
326
353
|
const finalUrl = await evaluate(session, 'location.href');
|
|
327
354
|
await assertResolvedPageUrl(finalUrl);
|
|
355
|
+
// A successful Page.navigate is not enough: Chrome's cold-start session
|
|
356
|
+
// restoration can asynchronously close its targets. Fail rather than
|
|
357
|
+
// returning a stale ID the next MCP tool call cannot use.
|
|
358
|
+
await assertCreatedTargetAvailable(target.id, endpoint, {
|
|
359
|
+
verifyAfterMs:autoLaunched ? COLD_TARGET_VERIFY_MS : 0,
|
|
360
|
+
});
|
|
328
361
|
return { target, navigation, finalUrl };
|
|
329
362
|
} catch (error) {
|
|
330
363
|
operationFailed = true;
|