@remcp/runtime 0.2.159 → 0.2.160
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 +1 -1
- package/src/error-classes.mjs +27 -0
- package/src/extended/browser-endpoint.mjs +22 -1
- package/src/extended/browser-read.mjs +7 -2
- package/src/extended/browser-session.mjs +3 -1
- package/src/extended/browser-snapshot-diff.mjs +56 -0
- package/src/extended/catalog-schemas.mjs +1 -1
- package/src/extended/catalog.mjs +1 -1
- package/src/invoke.mjs +3 -8
package/package.json
CHANGED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { ToolError } from './util.mjs';
|
|
2
|
+
// Error telemetry is a small closed vocabulary. Never return an error message,
|
|
3
|
+
// path, URL, selector, command, or arbitrary error.code to account telemetry.
|
|
4
|
+
const PERMISSION_CODES = new Set(['EACCES', 'EPERM']);
|
|
5
|
+
const MISSING_CODES = new Set(['ENOENT', 'ENOTDIR']);
|
|
6
|
+
const TIMEOUT_CODES = new Set(['ETIMEDOUT', 'ETIME']);
|
|
7
|
+
const CONNECTION_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'EHOSTUNREACH']);
|
|
8
|
+
|
|
9
|
+
export function classifyRuntimeToolError(error, toolName = '') {
|
|
10
|
+
const code = typeof error?.code === 'string' ? error.code.toUpperCase() : '';
|
|
11
|
+
const message = typeof error?.message === 'string' ? error.message.slice(0, 2048).toLowerCase() : '';
|
|
12
|
+
const browserTool = typeof toolName === 'string' && toolName.startsWith('browser_');
|
|
13
|
+
if (PERMISSION_CODES.has(code)) return 'permission';
|
|
14
|
+
if (MISSING_CODES.has(code)) return 'not_found';
|
|
15
|
+
if (TIMEOUT_CODES.has(code)) return 'timeout';
|
|
16
|
+
if (browserTool && CONNECTION_CODES.has(code)) return 'browser_unavailable';
|
|
17
|
+
if (/\bcancelled\b|\bcanceled\b|\baborted by the client\b/.test(message)) return 'cancelled';
|
|
18
|
+
if (/blocked (?:by|local|private)|outside the directories this device allows|private browser destination|blocked local browser scheme/.test(message)) return 'blocked';
|
|
19
|
+
if (browserTool && /browser cdp endpoint is unavailable|browser control is unavailable|cdp websocket (?:error|connection timed out)|local cdp endpoint did not become ready/.test(message)) {
|
|
20
|
+
return 'browser_unavailable';
|
|
21
|
+
}
|
|
22
|
+
if (/permission denied|access is denied|not authorized|not authorised|accessibility permission/.test(message)) return 'permission';
|
|
23
|
+
if (/timed out|timeout after|time limit exceeded/.test(message)) return 'timeout';
|
|
24
|
+
if (/element not found|no element matches|no matching ui window|target not found|not found in the current/.test(message)) return 'not_found';
|
|
25
|
+
if (/requires .*|must be (?:a |an |positive |between )|invalid (?:argument|selector|field|parameter)/.test(message)) return 'invalid_input';
|
|
26
|
+
return error instanceof ToolError ? 'tool_error' : 'runtime_error';
|
|
27
|
+
}
|
|
@@ -396,12 +396,33 @@ export async function filterBrowserTargets(list, { validate = assertResolvedPage
|
|
|
396
396
|
if (candidates.length >= 1000) break;
|
|
397
397
|
}
|
|
398
398
|
const safe = new Array(candidates.length).fill(false);
|
|
399
|
+
// A single tab-list request may contain hundreds of pages on the same host.
|
|
400
|
+
// Every URL is still checked independently for scheme, credentials and private
|
|
401
|
+
// targets; the bounded DNS/allowlist decision is shared *only within this call*.
|
|
402
|
+
// Never cache across requests: every new call must revalidate DNS rebinding.
|
|
403
|
+
const sharedHostChecks = validate === assertResolvedPageUrl ? new Map() : null;
|
|
399
404
|
let cursor = 0;
|
|
400
405
|
await Promise.all(Array.from({ length: Math.min(8, candidates.length) }, async () => {
|
|
401
406
|
while (cursor < candidates.length) {
|
|
402
407
|
const index = cursor++;
|
|
403
408
|
try {
|
|
404
|
-
|
|
409
|
+
const rawUrl = candidates[index].url;
|
|
410
|
+
if (sharedHostChecks) {
|
|
411
|
+
const parsedUrl = safePageUrl(rawUrl);
|
|
412
|
+
// about:blank has no DNS and should not alias a real host.
|
|
413
|
+
const host = parsedUrl === 'about:blank' ? null : normalizedHost(new URL(parsedUrl).hostname);
|
|
414
|
+
if (host) {
|
|
415
|
+
if (!sharedHostChecks.has(host)) {
|
|
416
|
+
sharedHostChecks.set(host, Promise.resolve().then(() => validate(parsedUrl)));
|
|
417
|
+
}
|
|
418
|
+
await sharedHostChecks.get(host);
|
|
419
|
+
} else {
|
|
420
|
+
await validate(parsedUrl);
|
|
421
|
+
}
|
|
422
|
+
} else {
|
|
423
|
+
// Custom validators can have URL/path-specific policy: do not coalesce.
|
|
424
|
+
await validate(rawUrl);
|
|
425
|
+
}
|
|
405
426
|
safe[index] = true;
|
|
406
427
|
} catch {
|
|
407
428
|
// Exclude unsafe/unresolvable pages without exposing them to the caller.
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { snapshotDelta } from './browser-snapshot-diff.mjs';
|
|
1
2
|
import process from 'node:process';
|
|
2
3
|
import os from 'node:os';
|
|
3
4
|
import path from 'node:path';
|
|
@@ -9,7 +10,7 @@ import { preserveInterruptedBrowserSession } from './browser-session-backup.mjs'
|
|
|
9
10
|
import { clamp, commandExists, jsonResult, optionalString, requireEnum, spawnDetached, unavailable } from './common.mjs';
|
|
10
11
|
|
|
11
12
|
|
|
12
|
-
import { assertResolvedBrowserUrl, assertResolvedPageUrl, boundedText, ensureBrowserEndpoint, listBrowserTargets, requestJson } from './browser-endpoint.mjs';
|
|
13
|
+
import { assertResolvedBrowserUrl, assertResolvedPageUrl, boundedText, endpointUrl, ensureBrowserEndpoint, listBrowserTargets, requestJson } from './browser-endpoint.mjs';
|
|
13
14
|
import { createGuardedNewTab, evaluate, withTarget } from './browser-session.mjs';
|
|
14
15
|
|
|
15
16
|
export async function browserTabs(args = {}) {
|
|
@@ -112,13 +113,17 @@ export async function browserSnapshot(args) {
|
|
|
112
113
|
child_ids: Array.isArray(node.childIds) ? node.childIds.slice(0, 200).map(id => boundedText(id, 256)) : [],
|
|
113
114
|
backend_dom_node_id: node.backendDOMNodeId || null,
|
|
114
115
|
}));
|
|
116
|
+
const delta = snapshotDelta(simplified,target,limit,{
|
|
117
|
+
from:args.diff_from,
|
|
118
|
+
endpoint:endpointUrl(args.endpoint).origin,
|
|
119
|
+
});
|
|
115
120
|
const payload = {
|
|
116
121
|
target_id: target.id,
|
|
117
122
|
title: target.title,
|
|
118
123
|
url: target.url,
|
|
119
124
|
count: simplified.length,
|
|
120
125
|
truncated: nodes.length > limit,
|
|
121
|
-
|
|
126
|
+
...delta,
|
|
122
127
|
};
|
|
123
128
|
if (!args.include_screenshot) return jsonResult(payload);
|
|
124
129
|
|
|
@@ -326,8 +326,10 @@ export async function createGuardedNewTab(url, args) {
|
|
|
326
326
|
}
|
|
327
327
|
|
|
328
328
|
export async function withTarget(args, callback) {
|
|
329
|
+
// chooseTarget already validated this exact discovered URL during this call.
|
|
330
|
+
// The actual page URL is independently checked before and after the action.
|
|
331
|
+
// Avoid a redundant DNS check for the same target metadata.
|
|
329
332
|
const target = await chooseTarget(args);
|
|
330
|
-
await assertResolvedPageUrl(target.url);
|
|
331
333
|
const endpoint = endpointUrl(args.endpoint);
|
|
332
334
|
const production = process.env.NODE_ENV === 'production';
|
|
333
335
|
const key = production ? productionSessionKey(endpoint, target) : '';
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Incremental CDP AX snapshots with bounded, opaque, privacy-preserving state.
|
|
2
|
+
// Only keyed HMAC fingerprints are cached; page text/values are never retained.
|
|
3
|
+
import { createHmac, randomBytes, randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const secret = randomBytes(32);
|
|
6
|
+
const snapshots = new Map();
|
|
7
|
+
const MAX_SNAPSHOTS = 16;
|
|
8
|
+
const MAX_AGE_MS = 120_000;
|
|
9
|
+
|
|
10
|
+
function fingerprint(node) {
|
|
11
|
+
return createHmac('sha256', secret).update(JSON.stringify(node)).digest('hex');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function scopeFor(target, limit, endpoint) {
|
|
15
|
+
return JSON.stringify([String(endpoint ?? ''),String(target?.id || ''),String(target?.url || ''),limit]);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function snapshotDelta(nodes, target, limit, {
|
|
19
|
+
from = null,
|
|
20
|
+
endpoint = '',
|
|
21
|
+
now = Date.now(),
|
|
22
|
+
} = {}) {
|
|
23
|
+
// Pure-data input may contain arbitrary page text. Store only HMACs and IDs,
|
|
24
|
+
// never node text. Fingerprints cannot be reverse-looked up without the key.
|
|
25
|
+
for (const [key, record] of snapshots) {
|
|
26
|
+
if (now - record.created > MAX_AGE_MS) snapshots.delete(key);
|
|
27
|
+
}
|
|
28
|
+
const scope = scopeFor(target,limit,endpoint);
|
|
29
|
+
const digest = new Map();
|
|
30
|
+
for(let i=0;i<nodes.length;i++){
|
|
31
|
+
const id = String(nodes[i].node_id || 'index:'+i);
|
|
32
|
+
digest.set(id, fingerprint(nodes[i]));
|
|
33
|
+
}
|
|
34
|
+
const previous = typeof from === 'string' && from.length === 36 && /^[0-9a-f-]{36}$/.test(from)
|
|
35
|
+
? snapshots.get(from) : null;
|
|
36
|
+
const matched = previous && previous.scope === scope && now - previous.created <= MAX_AGE_MS;
|
|
37
|
+
const updated = [];
|
|
38
|
+
const removed = [];
|
|
39
|
+
if (matched) {
|
|
40
|
+
for(let i=0;i<nodes.length;i++){
|
|
41
|
+
const id=String(nodes[i].node_id || 'index:'+i);
|
|
42
|
+
if(previous.digests.get(id)!==digest.get(id))updated.push(nodes[i]);
|
|
43
|
+
}
|
|
44
|
+
for(const id of previous.digests.keys())if(!digest.has(id))removed.push(id);
|
|
45
|
+
}
|
|
46
|
+
const id = randomUUID();
|
|
47
|
+
snapshots.set(id, {scope,digests:digest,created:now});
|
|
48
|
+
while(snapshots.size>MAX_SNAPSHOTS)snapshots.delete(snapshots.keys().next().value);
|
|
49
|
+
return {
|
|
50
|
+
snapshot_id:id,
|
|
51
|
+
diff_status:matched?'delta':from?'unavailable':'full',
|
|
52
|
+
nodes:matched?updated:nodes,
|
|
53
|
+
removed_node_ids:matched?removed:[],
|
|
54
|
+
total_nodes:nodes.length,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
@@ -36,7 +36,7 @@ export const AI_TOOL_DESCRIPTIONS = Object.freeze({
|
|
|
36
36
|
notification: 'Use this only when the user wants a visible native desktop notification on the paired computer. Do not use it as a substitute for replying in chat.',
|
|
37
37
|
browser_tabs: 'Use this first for CDP browser automation when the target page/tab is not already unambiguous. It lists debuggable Chromium pages from a loopback-only endpoint; use the returned target_id/title/url to scope later browser tools.',
|
|
38
38
|
browser_navigate: 'Use this to open a URL in a new debuggable tab or navigate a known Chromium page by URL, back, forward or reload. Prefer it over typing into the address bar; new_tab also bootstraps browser automation when no page target exists yet.',
|
|
39
|
-
browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed. A selector may be temporarily centered for capture, but the original page scroll position is restored before the tool returns.',
|
|
39
|
+
browser_snapshot: 'Use this to inspect a web page structurally through CDP accessibility. Reuse the last snapshot_id as diff_from to return changed/removed semantic nodes rather than retransmitting the whole page after each step; refresh with a full snapshot when unavailable. Prefer it over desktop ui_snapshot for page content; add include_screenshot=true only when final pixel/layout verification is needed. A selector may be temporarily centered for capture, but the original page scroll position is restored before the tool returns.',
|
|
40
40
|
browser_find: 'Use this to locate visible web elements by CSS selector, text or ARIA role and obtain reusable selectors/bounds. It requires an element criterion; use browser_snapshot, not an empty browser_find, to enumerate page structure. Prefer this before browser_action rather than guessing selectors or coordinates.',
|
|
41
41
|
browser_action: 'Use this for deterministic DOM/CDP interaction with a known web element: click/focus/type/value/select/upload/key/scroll or viewport emulation. Prefer it over desktop pointer/ui_action for page content; use action=fill_form with CSS selectors to edit up to 20 visible fields in one tool call without submitting. Use browser_evaluate only when supported actions cannot express the task.',
|
|
42
42
|
browser_wait: 'Use this after browser navigation/action to wait for selector/text/URL/load/navigation/network-idle instead of fixed sleeps or screenshot polling. Use browser_evaluate separately for JavaScript predicates.',
|
package/src/extended/catalog.mjs
CHANGED
|
@@ -163,7 +163,7 @@ export const extendedToolDefinitions = [
|
|
|
163
163
|
|
|
164
164
|
define('browser_tabs', 'Browser tabs', 'List debuggable Chrome, Edge, or Chromium page targets from a loopback-only Chrome DevTools Protocol endpoint.', o({ ...browserTarget }), readOnlyOpen, browserHandlers.browser_tabs, ['browser_cdp']),
|
|
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
|
-
define('browser_snapshot', 'Browser snapshot', 'Return
|
|
166
|
+
define('browser_snapshot', 'Browser snapshot', 'Return a full or incremental accessibility tree (diff_from snapshot_id) 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.'), diff_from:s('Optional previous opaque snapshot_id for this exact target/page and node limit. Return changed/removed AX nodes only; if stale or mismatched, safely return a full snapshot.'), 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
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']),
|
package/src/invoke.mjs
CHANGED
|
@@ -2,18 +2,13 @@ import { toolHandlers } from './catalog.mjs';
|
|
|
2
2
|
import { extendedToolHandlers } from './extended/catalog.mjs';
|
|
3
3
|
import { describeFilesystemFailure } from './permissions.mjs';
|
|
4
4
|
import { recordEvent } from './telemetry.mjs';
|
|
5
|
+
import { classifyRuntimeToolError } from './error-classes.mjs';
|
|
5
6
|
import { ToolError, text } from './util.mjs';
|
|
6
7
|
|
|
7
8
|
export function hasTool(name) {
|
|
8
9
|
return toolHandlers.has(name) || extendedToolHandlers.has(name);
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
function errorKind(error) {
|
|
12
|
-
if (error instanceof ToolError) return 'tool_error';
|
|
13
|
-
const code = error?.code;
|
|
14
|
-
return typeof code === 'string' && code ? code.slice(0, 32) : 'runtime_error';
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
export async function invokeTool(name, args = {}, extra = {}) {
|
|
18
13
|
const started = performance.now();
|
|
19
14
|
const definition = toolHandlers.get(name) || extendedToolHandlers.get(name);
|
|
@@ -26,10 +21,10 @@ export async function invokeTool(name, args = {}, extra = {}) {
|
|
|
26
21
|
}
|
|
27
22
|
try {
|
|
28
23
|
const result = await definition.handler(args || {}, extra || {});
|
|
29
|
-
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: result?.isError !== true });
|
|
24
|
+
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: result?.isError !== true, ...(result?.isError === true ? { errorKind:'tool_error' } : {}) });
|
|
30
25
|
return result;
|
|
31
26
|
} catch (error) {
|
|
32
|
-
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: false, errorKind:
|
|
27
|
+
recordEvent('tool_call', { tool: definition.name, durationMs: performance.now() - started, success: false, errorKind: classifyRuntimeToolError(error, definition.name) });
|
|
33
28
|
if (error instanceof ToolError) return text(error.message, true);
|
|
34
29
|
return text(`Tool ${name} failed: ${describeFilesystemFailure(error, { path: error?.path })}`, true);
|
|
35
30
|
}
|