@remcp/runtime 0.2.179 → 0.2.181

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/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.181 — 2026-10-25
4
+
5
+ - Add bounded read-only `browser_devtools` Form Map and selector-scoped computed Styles/box-model inspection with no user-entered values, raw CSS, URL content or page text in diagnostic reports.
6
+ - Add optional `expected_tag` and `expected_type` safeguards for `fill_form` and workflow form batches; reject duplicates and detached DOM controls to reduce stale-selector writes.
7
+ - Keep explicit per-tab target_id, loopback Chrome CDP, URL/DNS safety checks, authenticated tool permissions, and explicit submit boundaries. Add real Chromium QA scripts and regression tests.
8
+
9
+ ## 0.2.180 — 2026-10-25
10
+
11
+ - Add redacted `browser_navigation_failed`, `browser_target_stale`, `browser_session_lost` and `browser_guard_failed` classifications for Chrome CDP failures, without disclosing error details, tab IDs or URLs.
12
+ - Fail `Page.navigate` when Chrome provides a nonempty errorText instead of returning false success; preserve explicit target, origin and security checks.
13
+ - Add focused regression coverage without changing the public 84-tool runtime interface.
14
+
15
+ ## 0.2.179 — 2026-10-25
16
+
17
+ - Add one-element native CDP listener diagnostics without exposing script source, page text or untrusted raw event handler content.
18
+ - Maintain the same 84 runtime tool names and the existing browser origin and permission boundaries.
19
+
20
+ ## 0.2.178 — 2026-10-25
21
+
22
+ - Add bounded DOMSnapshot geometry diagnostics to detect potential viewport overflows and layout issues without screenshots.
23
+ - Preserve privacy of raw DOM content and do not misclassify suspected geometry issues as confirmed CSS defects.
24
+
25
+ ## 0.2.177 — 2026-10-24
26
+
27
+ - Add native read-only Chrome DevTools diagnostics for console, network, performance, layout and accessibility, plus selected-tab DOM inspection in the web Devices workspace.
28
+ - Do not auto-start a user browser during read-only probes or expose CDP outside loopback.
29
+
3
30
  ## 0.2.176 — 2026-10-23
4
31
 
5
32
  - Reduce screenshot CDP round-trips by batching viewport resolution with capture and reusing measured element dimensions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.179",
3
+ "version": "0.2.181",
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",
@@ -14,11 +14,28 @@ export function classifyRuntimeToolError(error, toolName = '') {
14
14
  if (MISSING_CODES.has(code)) return 'not_found';
15
15
  if (TIMEOUT_CODES.has(code)) return 'timeout';
16
16
  if (browserTool && CONNECTION_CODES.has(code)) return 'browser_unavailable';
17
+ if (browserTool && code === 'BROWSER_TARGET_DISAPPEARED') return 'browser_target_stale';
17
18
  if (/\bcancelled\b|\bcanceled\b|\baborted by the client\b/.test(message)) return 'cancelled';
18
19
  if (/blocked (?:by|local|private)|outside the directories this device allows|private browser destination|blocked local browser scheme/.test(message)) return 'blocked';
19
20
  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
21
  return 'browser_unavailable';
21
22
  }
23
+ if (browserTool) {
24
+ // Match only fixed CDP failure signatures; never emit the error text, tab ID,
25
+ // destination URL or selector as a telemetry category.
26
+ if (/browser child guard failed|browser request guard failed|browser safety guard failed/.test(message)) {
27
+ return 'browser_guard_failed';
28
+ }
29
+ if (/browser cdp target disappeared|target (?:was )?closed|no target with given id|target with given id not found|inspected target navigated or closed/.test(message)) {
30
+ return 'browser_target_stale';
31
+ }
32
+ if (/cdp websocket (?:closed|is not open)|session with given id not found|cdp session (?:closed|disconnected)/.test(message)) {
33
+ return 'browser_session_lost';
34
+ }
35
+ if (/browser navigation failed|cannot navigate (?:back|forward)|net::err_[a-z_]+/.test(message)) {
36
+ return 'browser_navigation_failed';
37
+ }
38
+ }
22
39
  if (/permission denied|access is denied|not authorized|not authorised|accessibility permission/.test(message)) return 'permission';
23
40
  if (/timed out|timeout after|time limit exceeded/.test(message)) return 'timeout';
24
41
  if (/element not found|no element matches|no matching ui window|target not found|not found in the current/.test(message)) return 'not_found';
@@ -119,6 +119,7 @@ export const PAGE_ELEMENT_ACTION = `function(selector, needle, action, value) {
119
119
  // Bounded form mutation: validate every target before changing any field.
120
120
  // Values are intentionally excluded from the return object and tool logs.
121
121
  export const PAGE_FILL_FORM = `function(fields) {
122
+ const used = new Set();
122
123
  const targets = fields.map(field => {
123
124
  const el = document.querySelector(field.selector);
124
125
  if (!el || !el.getClientRects().length || el.disabled || el.readOnly ||
@@ -127,6 +128,14 @@ export const PAGE_FILL_FORM = `function(fields) {
127
128
  }
128
129
  const tag = el.tagName.toLowerCase();
129
130
  if (!['input','textarea','select'].includes(tag)) throw new Error('Unsupported form field: ' + field.selector);
131
+ if (used.has(el)) throw new Error('Duplicate form field target');
132
+ used.add(el);
133
+ // Form Map selectors are structural; verify the observed kind again after any rerender.
134
+ const actualType = tag === 'input' ? (el.type || 'text').toLowerCase() : tag;
135
+ if ((field.expected_tag && field.expected_tag !== tag) ||
136
+ (field.expected_type && field.expected_type !== actualType)) {
137
+ throw new Error('Form field kind changed; refresh the form map');
138
+ }
130
139
  if (tag === 'input' && ['checkbox','radio'].includes(el.type) && typeof field.value !== 'boolean') {
131
140
  throw new Error('Checkbox and radio fields require a boolean value: ' + field.selector);
132
141
  }
@@ -137,6 +146,8 @@ export const PAGE_FILL_FORM = `function(fields) {
137
146
  });
138
147
  const summary = [];
139
148
  for (const {el,tag,value} of targets) {
149
+ // A change handler from an earlier field can detach a later field mid-batch.
150
+ if (el.isConnected === false) throw new Error('Form rerendered during the batch; refresh the form map');
140
151
  const checkbox = tag === 'input' && ['checkbox','radio'].includes(el.type);
141
152
  const prop = checkbox ? 'checked' : 'value';
142
153
  let prototype = Object.getPrototypeOf(el);
@@ -158,6 +169,7 @@ export function validateFormFields(fields) {
158
169
  if (!Array.isArray(fields) || fields.length < 1 || fields.length > 20) {
159
170
  throw new Error('fill_form requires 1 to 20 fields');
160
171
  }
172
+ const seen = new Set();
161
173
  return fields.map(field => {
162
174
  const selector = typeof field?.selector === 'string' ? field.selector.trim() : '';
163
175
  if (!selector || selector.length > 512 || !Object.hasOwn(field,'value') ||
@@ -166,7 +178,20 @@ export function validateFormFields(fields) {
166
178
  throw new Error('Each form field requires a CSS selector and a string/boolean/number value');
167
179
  }
168
180
  if (String(field.value).length > 16384) throw new Error('Form field value exceeds 16384 characters');
169
- return {selector, value:field.value};
181
+ if (seen.has(selector)) throw new Error('Duplicate CSS selector in form batch');
182
+ seen.add(selector);
183
+ const expected_tag = field.expected_tag;
184
+ const expected_type = field.expected_type;
185
+ if (expected_tag != null && !['input','select','textarea'].includes(expected_tag))
186
+ throw new TypeError('Unsupported expected_tag');
187
+ if (expected_type != null && ![
188
+ 'text','email','password','tel','url','search','number','date','time','datetime-local',
189
+ 'month','week','color','range','checkbox','radio','file','hidden','submit','reset','button',
190
+ 'select','textarea'
191
+ ].includes(expected_type)) throw new TypeError('Unsupported expected_type');
192
+ return {selector, value:field.value,
193
+ ...(expected_tag == null ? {} : {expected_tag}),
194
+ ...(expected_type == null ? {} : {expected_type})};
170
195
  });
171
196
  }
172
197
 
@@ -5,6 +5,8 @@ import { withTarget } from './browser-session.mjs';
5
5
  import { inspectNativeBrowserAudit } from './browser-native-audit.mjs';
6
6
  import { inspectDomGeometry } from './browser-geometry.mjs';
7
7
  import { inspectElementListeners } from './browser-listeners.mjs';
8
+ import { inspectFormMap } from './browser-form-map.mjs';
9
+ import { inspectScopedStyles } from './browser-styles.mjs';
8
10
 
9
11
  const METRIC_NAMES = new Set([
10
12
  'Timestamp','Documents','Frames','JSEventListeners','Nodes','LayoutCount',
@@ -45,6 +47,8 @@ export async function sampleBrowserDevtools(session, action, durationMs, maxEven
45
47
  if (action === 'layout' || action === 'accessibility') return inspectNativeBrowserAudit(session, action);
46
48
  if (action === 'geometry') return inspectDomGeometry(session);
47
49
  if (action === 'listeners') return inspectElementListeners(session,selector);
50
+ if (action === 'form_map') return inspectFormMap(session);
51
+ if (action === 'styles') return inspectScopedStyles(session,selector);
48
52
  if (action === 'performance') {
49
53
  await session.send('Performance.enable');
50
54
  try {
@@ -105,15 +109,17 @@ export async function sampleBrowserDevtools(session, action, durationMs, maxEven
105
109
  }
106
110
  export function buildDevtoolsReport(target, action, result) {
107
111
  return { target_id:target.id,
108
- ...(['geometry','listeners'].includes(action) ? {} : {url:safeDevtoolsUrl(target.url)}),
112
+ ...(['geometry','listeners','form_map','styles'].includes(action) ? {} : {url:safeDevtoolsUrl(target.url)}),
109
113
  ...result };
110
114
  }
111
115
  export async function browserDevtools(args = {}) {
112
- const action = requireEnum(args.action, 'action', ['console','network','performance','layout','accessibility','geometry','listeners']);
116
+ const action = requireEnum(args.action, 'action', ['console','network','performance','layout','accessibility','geometry','listeners','form_map','styles']);
113
117
  const durationMs = clamp(args.sample_ms, 1500, 250, 5000);
114
118
  const maxEvents = clamp(args.max_events, 50, 1, 100);
115
- if (action === 'listeners' && (typeof args.target_id !== 'string' || !args.target_id.trim()))
116
- throw new TypeError('Listener diagnostics require an explicitly selected target_id');
119
+ if (['listeners','form_map','styles'].includes(action) && (typeof args.target_id !== 'string' || !args.target_id.trim()))
120
+ throw new TypeError('Scoped DOM diagnostics require an explicitly selected target_id');
121
+ if (action==='styles' && (typeof args.selector!=='string'||!args.selector.trim()))
122
+ throw new TypeError('Scoped style diagnostics require a CSS selector');
117
123
  return withTarget(args, async (session,target) =>
118
124
  jsonResult(buildDevtoolsReport(target, action,
119
125
  await sampleBrowserDevtools(session,action,durationMs,maxEvents,args.selector))));
@@ -0,0 +1,134 @@
1
+ // Read-only Chrome DOMSnapshot form map. Page strings/attributes never leave this module.
2
+ // Structural selectors contain only allowlisted HTML tag names and sibling indexes.
3
+ const TAGS = new Set(['HTML','HEAD','BODY','DIV','MAIN','SECTION','ARTICLE','ASIDE','FORM','FIELDSET',
4
+ 'LABEL','INPUT','SELECT','TEXTAREA','BUTTON','SPAN','P','UL','LI','DIALOG','TABLE','TR','TD']);
5
+ const FIELDS = new Set(['INPUT','SELECT','TEXTAREA']);
6
+ const TYPES = new Set(['text','email','password','tel','url','search','number','date','time',
7
+ 'datetime-local','month','week','color','range','checkbox','radio','file','hidden','submit',
8
+ 'reset','button','image']);
9
+ const AUTOCOMPLETE = new Set(['on','off','name','given-name','family-name','email','username',
10
+ 'new-password','current-password','one-time-code','tel','street-address','postal-code',
11
+ 'country','country-name','organization','address-line1','address-line2','address-level1',
12
+ 'address-level2','cc-name','cc-number','cc-exp','cc-exp-month','cc-exp-year','cc-csc']);
13
+ const MAX_NODES=12000, MAX_FIELDS=40, MAX_FORMS=8, MAX_DEPTH=16;
14
+ const safeNum = n => Number.isSafeInteger(n) && n>=0 ? n : -1;
15
+ function nodeTag(nodes,strings,i) {
16
+ const name=strings?.[nodes?.nodeName?.[i]];
17
+ if (typeof name !== 'string') return '';
18
+ const tag=name.toUpperCase();
19
+ // Chrome also reports a lowercase 'html' doctype, which is not an element.
20
+ return name===tag && TAGS.has(tag) ? tag : '';
21
+ }
22
+ function attrs(nodes,strings,i) {
23
+ const list=nodes?.attributes?.[i];
24
+ const found=Object.create(null);
25
+ if (!Array.isArray(list)) return found;
26
+ for(let j=0;j<Math.min(list.length,80)-1;j+=2) {
27
+ const name=strings?.[list[j]], value=strings?.[list[j+1]];
28
+ // Chrome encodes valueless HTML attributes (for example required) with -1.
29
+ if(typeof name==='string' && name.length<=40)
30
+ found[name.toLowerCase()]=typeof value==='string'?value:'';
31
+ }
32
+ return found;
33
+ }
34
+ function ancestorWithTag(i,parents,tags,tag) {
35
+ let current=i;
36
+ for(let depth=0;depth<MAX_DEPTH;depth++) {
37
+ current=parents[current];
38
+ if(!Number.isSafeInteger(current)||current<0||current>=tags.length) return -1;
39
+ if(tags[current]===tag)return current;
40
+ }
41
+ return -1;
42
+ }
43
+ function structuralSelector(i,parents,tags,siblingPositions) {
44
+ const parts=[];
45
+ let current=i;
46
+ for(let depth=0;depth<MAX_DEPTH;depth++) {
47
+ const tag=tags[current], pos=siblingPositions[current];
48
+ if(!tag||!pos) return null;
49
+ parts.push(tag.toLowerCase()+':nth-of-type('+pos+')');
50
+ if(tag==='HTML')break;
51
+ const parent=parents[current];
52
+ if(!Number.isSafeInteger(parent)||parent<0) break;
53
+ if(parent>=tags.length||parent===current) return null;
54
+ current=parent;
55
+ }
56
+ if(parts.at(-1)!=='html:nth-of-type(1)') return null;
57
+ const css=parts.reverse().join(' > ');
58
+ return css.length<=350 ? css : null;
59
+ }
60
+ export function summarizeFormMap(snapshot) {
61
+ const docs=Array.isArray(snapshot?.documents)?snapshot.documents:[];
62
+ const nodes=docs[0]?.nodes||{}, strings=Array.isArray(snapshot?.strings)?snapshot.strings:[];
63
+ const parents=Array.isArray(nodes.parentIndex)?nodes.parentIndex:[];
64
+ const total=Math.min(MAX_NODES,Array.isArray(nodes.nodeName)?nodes.nodeName.length:0);
65
+ const tags=Array.from({length:total},(_,i)=>nodeTag(nodes,strings,i));
66
+ const siblingPositions=new Array(total).fill(0), counts=new Map();
67
+ for(let i=0;i<total;i++) {
68
+ if(!tags[i])continue;
69
+ const parent=safeNum(parents[i]);
70
+ const key=parent+':'+tags[i], ordinal=(counts.get(key)||0)+1;
71
+ counts.set(key,ordinal);
72
+ siblingPositions[i]=ordinal;
73
+ }
74
+ const forms=[],formByIndex=new Map(), labelsFor=new Set();
75
+ for(let i=0;i<total;i++) {
76
+ if(tags[i]==='LABEL') {
77
+ const associated=attrs(nodes,strings,i).for;
78
+ if(associated) labelsFor.add(associated);
79
+ }
80
+ if(tags[i]==='FORM') {
81
+ const record={index:i,field_count:0,required_count:0,unlabeled_count:0,submit_count:0};
82
+ forms.push(record);formByIndex.set(i,record);
83
+ }
84
+ }
85
+ let fieldCount=0,requiredCount=0,unlabeledCount=0,disabledCount=0,unownedCount=0,submitCount=0;
86
+ const types=Object.create(null), fields=[];
87
+ for(let i=0;i<total;i++) {
88
+ const tag=tags[i];
89
+ if(!FIELDS.has(tag) && tag!=='BUTTON')continue;
90
+ const a=attrs(nodes,strings,i);
91
+ const rawType=tag==='INPUT' ? (a.type||'text').toLowerCase() : tag==='BUTTON'
92
+ ? (a.type||'submit').toLowerCase() : tag.toLowerCase();
93
+ const type=TYPES.has(rawType)?rawType:tag==='BUTTON'?'button':tag.toLowerCase()==='input'?'other':tag.toLowerCase();
94
+ const formIndex=ancestorWithTag(i,parents,tags,'FORM');
95
+ const form=formByIndex.get(formIndex);
96
+ const isSubmit=type==='submit';
97
+ if(isSubmit) {submitCount++; if(form) form.submit_count++;}
98
+ if(!FIELDS.has(tag))continue;
99
+ fieldCount++;if(form)form.field_count++;else unownedCount++;
100
+ types[type]=(types[type]||0)+1;
101
+ const required=Object.hasOwn(a,'required')||a['aria-required']==='true';
102
+ const disabled=Object.hasOwn(a,'disabled');
103
+ const labeled=Boolean(a['aria-label']?.trim()||a['aria-labelledby']?.trim()
104
+ ||(a.id && labelsFor.has(a.id))||ancestorWithTag(i,parents,tags,'LABEL')>=0);
105
+ if(required) {requiredCount++;if(form)form.required_count++;}
106
+ if(disabled)disabledCount++;
107
+ if(!labeled && type!=='hidden' && type!=='submit' && type!=='reset' && type!=='button') {
108
+ unlabeledCount++;if(form)form.unlabeled_count++;
109
+ }
110
+ if(fields.length<MAX_FIELDS) {
111
+ const autocomplete=typeof a.autocomplete==='string' ? a.autocomplete.trim().toLowerCase() : '';
112
+ fields.push({selector:structuralSelector(i,parents,tags,siblingPositions),tag:tag.toLowerCase(),
113
+ type,required,disabled,has_label:labeled,
114
+ autocomplete:AUTOCOMPLETE.has(autocomplete)?autocomplete:null,
115
+ form_index:formIndex>=0?forms.findIndex(f=>f.index===formIndex):null});
116
+ }
117
+ }
118
+ return {form_count:forms.length,field_count:fieldCount,required_count:requiredCount,
119
+ disabled_count:disabledCount,unlabeled_candidate_count:unlabeledCount,
120
+ unowned_field_count:unownedCount,submit_count:submitCount,
121
+ by_type:Object.fromEntries(Object.entries(types).sort((a,b)=>b[1]-a[1])),
122
+ forms:forms.slice(0,MAX_FORMS).map(f=>({field_count:f.field_count,
123
+ required_count:f.required_count,unlabeled_candidate_count:f.unlabeled_count,
124
+ submit_count:f.submit_count})), fields,
125
+ frames_omitted:Math.max(0,docs.length-1),
126
+ truncated:total<(nodes.nodeName?.length||0)||fieldCount>MAX_FIELDS||forms.length>MAX_FORMS,
127
+ note:'Native read-only DOMSnapshot; fields have structural selectors, type and label-presence flags only. No input values, raw attributes, names, label text, page text or URLs are returned. Selectors may become stale after re-render. Label candidates are heuristic; only the main frame is inspected.'};
128
+ }
129
+ export async function inspectFormMap(session) {
130
+ const snapshot=await session.send('DOMSnapshot.captureSnapshot',{
131
+ computedStyles:[],includePaintOrder:false,includeDOMRects:false,
132
+ });
133
+ return {action:'form_map',form_map:summarizeFormMap(snapshot)};
134
+ }
@@ -11,11 +11,11 @@ const TYPES = new Set([
11
11
  ]);
12
12
  const MAX_LISTENERS = 200;
13
13
  const MAX_TYPES = 24;
14
- function validSelector(value) {
14
+ export function validSelector(value,maxLength=120,allowChildCombinator=false) {
15
15
  if (typeof value !== 'string') throw new TypeError('A CSS selector is required for the event listener audit');
16
16
  const selector = value.trim();
17
- if (!selector || selector.length > 120 || /[\u0000-\u001f\u007f<>]/.test(selector)) {
18
- throw new TypeError('A CSS selector between 1 and 120 safe characters is required');
17
+ if (!selector || selector.length > maxLength || /[\u0000-\u001f\u007f<]/.test(selector) || (!allowChildCombinator && selector.includes('>'))) {
18
+ throw new TypeError('A CSS selector between 1 and '+maxLength+' safe characters is required');
19
19
  }
20
20
  return selector;
21
21
  }
@@ -0,0 +1,8 @@
1
+ // Page.navigate may resolve successfully while Chrome explicitly reports a
2
+ // navigation error. Treat that as a failed tool call, without echoing CDP's
3
+ // errorText (which may contain user data) into diagnostics.
4
+ export function requireSuccessfulBrowserNavigation(result) {
5
+ if (typeof result?.errorText === 'string' && result.errorText.trim()) {
6
+ throw new Error('Browser navigation failed: Chrome reported an unsuccessful navigation');
7
+ }
8
+ }
@@ -1,4 +1,5 @@
1
1
  import { snapshotDelta } from './browser-snapshot-diff.mjs';
2
+ import { requireSuccessfulBrowserNavigation } from './browser-navigation-result.mjs';
2
3
  import process from 'node:process';
3
4
  import os from 'node:os';
4
5
  import path from 'node:path';
@@ -75,6 +76,7 @@ export async function browserNavigate(args = {}) {
75
76
  let historyUrl = null;
76
77
  if (action === 'url') {
77
78
  const result = await session.send('Page.navigate', { url });
79
+ requireSuccessfulBrowserNavigation(result);
78
80
  frameId = result.frameId || null;
79
81
  errorText = result.errorText || null;
80
82
  } else if (action === 'reload') {
@@ -1,3 +1,4 @@
1
+ import { requireSuccessfulBrowserNavigation } from './browser-navigation-result.mjs';
1
2
  import process from 'node:process';
2
3
  import os from 'node:os';
3
4
  import path from 'node:path';
@@ -350,6 +351,7 @@ export async function createGuardedNewTab(url, args) {
350
351
  await session.send('Page.enable');
351
352
  const loaded = session.waitFor('Page.loadEventFired', clamp(args.timeout_ms, 15_000, 500, 120_000)).catch(() => null);
352
353
  const navigation = await session.send('Page.navigate', { url });
354
+ requireSuccessfulBrowserNavigation(navigation);
353
355
  if (loaded) await loaded;
354
356
  const finalUrl = await evaluate(session, 'location.href');
355
357
  await assertResolvedPageUrl(finalUrl);
@@ -0,0 +1,83 @@
1
+ // Scoped, read-only Chrome CSS/box model summary. Filter raw styles locally:
2
+ // Never return selector, custom properties, images, text or background URLs.
3
+ import { validSelector } from './browser-listeners.mjs';
4
+
5
+ const ENUMS = {
6
+ display:new Set(['none','block','inline','inline-block','flex','inline-flex','grid','inline-grid','contents','table','table-cell','flow-root','list-item']),
7
+ position:new Set(['static','relative','absolute','fixed','sticky']),
8
+ visibility:new Set(['visible','hidden','collapse']),
9
+ overflowX:new Set(['visible','hidden','clip','scroll','auto']),
10
+ overflowY:new Set(['visible','hidden','clip','scroll','auto']),
11
+ pointerEvents:new Set(['auto','none']),
12
+ boxSizing:new Set(['content-box','border-box']),
13
+ whiteSpace:new Set(['normal','nowrap','pre','pre-wrap','pre-line','break-spaces']),
14
+ };
15
+ const STYLE_FIELDS = {
16
+ display:'display',position:'position',visibility:'visibility',
17
+ 'overflow-x':'overflowX','overflow-y':'overflowY',
18
+ 'pointer-events':'pointerEvents','box-sizing':'boxSizing','white-space':'whiteSpace'
19
+ };
20
+ const LENGTHS=['font-size','line-height','padding-top','padding-right','padding-bottom','padding-left',
21
+ 'margin-top','margin-right','margin-bottom','margin-left','width','height'];
22
+ function rounded(value) {return Number.isFinite(value) ? Math.round(Math.max(-100000,Math.min(100000,value))*100)/100 : null;}
23
+ function lengthPx(value) {
24
+ if(typeof value!=='string')return null;
25
+ const m=/^(-?\d+(?:\.\d+)?)px$/.exec(value.trim());
26
+ return m?rounded(Number(m[1])):null;
27
+ }
28
+ export function summarizeComputedStyle(raw) {
29
+ const all=new Map((Array.isArray(raw)?raw:[])
30
+ .filter(item=>typeof item?.name==='string'&&typeof item?.value==='string')
31
+ .map(item=>[item.name,item.value]));
32
+ const result={};
33
+ for(const [css,key] of Object.entries(STYLE_FIELDS)) {
34
+ const value=all.get(css);
35
+ if(ENUMS[key].has(value))result[key]=value;
36
+ }
37
+ for(const css of LENGTHS) {
38
+ const value=lengthPx(all.get(css));
39
+ if(value!=null)result[css.replaceAll('-','_')+'_px']=value;
40
+ }
41
+ const opacity=Number(all.get('opacity'));
42
+ if(all.has('opacity')&&Number.isFinite(opacity)&&opacity>=0&&opacity<=1)result.opacity=rounded(opacity);
43
+ const zi=all.get('z-index');
44
+ if(zi==='auto')result.z_index='auto';
45
+ else if(typeof zi==='string'&&/^-?\d+$/.test(zi)) {
46
+ const num=rounded(Number(zi));
47
+ if(num!=null)result.z_index=num;
48
+ }
49
+ return result;
50
+ }
51
+ function rect(quad) {
52
+ if(!Array.isArray(quad)||quad.length!==8||quad.some(v=>typeof v!=='number'||!Number.isFinite(v)))return null;
53
+ const xs=[quad[0],quad[2],quad[4],quad[6]];
54
+ const ys=[quad[1],quad[3],quad[5],quad[7]];
55
+ const minX=Math.min(...xs),maxX=Math.max(...xs),minY=Math.min(...ys),maxY=Math.max(...ys);
56
+ return {x:rounded(minX),y:rounded(minY),width:rounded(maxX-minX),height:rounded(maxY-minY)};
57
+ }
58
+ export function summarizeBoxModel(model) {
59
+ if(!model||typeof model!=='object')return null;
60
+ const border=rect(model.border),content=rect(model.content);
61
+ if(!border)return null;
62
+ return {border,content,content_width_px:rounded(model.width),content_height_px:rounded(model.height)};
63
+ }
64
+ export async function inspectScopedStyles(session,selectorValue) {
65
+ const selector=validSelector(selectorValue,350,true);
66
+ await session.send('DOM.enable');
67
+ try {
68
+ await session.send('CSS.enable');
69
+ const {root}=await session.send('DOM.getDocument',{depth:0,pierce:false});
70
+ if(!Number.isSafeInteger(root?.nodeId)||root.nodeId<=0)throw new Error('CDP document root unavailable');
71
+ const {nodeId}=await session.send('DOM.querySelector',{nodeId:root.nodeId,selector});
72
+ if(!Number.isSafeInteger(nodeId)||nodeId<=0)return {action:'styles',found:false};
73
+ const [styles,box]=await Promise.all([
74
+ session.send('CSS.getComputedStyleForNode',{nodeId}),
75
+ session.send('DOM.getBoxModel',{nodeId}).catch(()=>null)
76
+ ]);
77
+ return {action:'styles',found:true,style:summarizeComputedStyle(styles.computedStyle),
78
+ box:summarizeBoxModel(box?.model),
79
+ note:'Scoped native CSS/box-model audit. Values are limited to allowlisted enums, pixel dimensions and numbers. It excludes raw CSS, selector, background URLs and page text. Border geometry is axis-aligned after transforms; inspect a screenshot only for visual details.'};
80
+ } finally {
81
+ await Promise.allSettled([session.send('CSS.disable'),session.send('DOM.disable')]);
82
+ }
83
+ }
@@ -165,9 +165,9 @@ export const extendedToolDefinitions = [
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
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
- 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']),
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']},expected_tag:e(['input','select','textarea'],'Optional HTML tag from Forms; rejects stale selector mismatches.'),expected_type:e(['text','email','password','tel','url','search','number','date','time','datetime-local','month','week','color','range','checkbox','radio','file','hidden','submit','reset','button','select','textarea'],'Optional field type from Forms; checked immediately before batch writes.')},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.'},expected_tag:e(['input','select','textarea'],'Expected tag from the native form map; stale selectors fail without writes.'),expected_type:e(['text','email','password','tel','url','search','number','date','time','datetime-local','month','week','color','range','checkbox','radio','file','hidden','submit','reset','button','select','textarea'],'Expected field type from the native form map; stale selectors fail without writes.')},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']),
170
- define('browser_devtools', 'Browser DevTools diagnostics', 'Use this to inspect Chrome Console/Network live events, Performance, Layout, Accessibility or screenshot-free DOMSnapshot Geometry via native CDP. Network headers, bodies and cookies are excluded; geometry returns bounded numeric summaries without page text.', o({ ...browserTarget, action:e(['console','network','performance','layout','accessibility','geometry','listeners'],'Diagnostics mode. Layout measures CSS viewport, accessibility returns anonymized roles and unnamed controls, geometry audits DOMSnapshot bounds without page contents.'), sample_ms:n('Live event capture period 250–5000 ms; default 1500. Ignored for performance, layout, accessibility, geometry and listeners.'), selector:s('Required CSS selector for action=listeners; only the first matching element is inspected, with no script source or DOM text returned.'), max_events:n('Maximum 1–100 events retained; default 50.') }, ['action']), readOnlyOpen, browserHandlers.browser_devtools, ['browser_cdp']),
170
+ define('browser_devtools', 'Browser DevTools diagnostics', 'Use this to inspect Chrome Console/Network live events, Performance, Layout, Accessibility, Geometry, scoped Listeners or a native screenshot-free Form Map, or selector-scoped CSS/box-model Styles. No form values, page text, raw CSS or network credentials leave the device in these bounded modes.', o({ ...browserTarget, action:e(['console','network','performance','layout','accessibility','geometry','listeners','form_map','styles'],'Diagnostics mode. Layout measures CSS viewport, accessibility returns anonymized roles and unnamed controls, geometry audits DOMSnapshot bounds without page contents.'), sample_ms:n('Live event capture period 250–5000 ms; default 1500. Ignored for performance, layout, accessibility, geometry and listeners.'), selector:s('Required CSS selector for action=listeners and action=styles. Only the first matching element is inspected; no script source, raw CSS or DOM text is returned.'), max_events:n('Maximum 1–100 events retained; default 50.') }, ['action']), readOnlyOpen, browserHandlers.browser_devtools, ['browser_cdp']),
171
171
  define('browser_evaluate', 'Evaluate browser JavaScript', 'Evaluate JavaScript in a selected debuggable browser page and return its serializable value. This is a powerful escape hatch for page-specific automation.', o({ ...browserTarget, expression:s('JavaScript expression.'), await_promise:b('Await a returned Promise; default true.') }, ['expression']), openMutating, browserHandlers.browser_evaluate, ['browser_evaluate']),
172
172
 
173
173
  define('service', 'Service control', 'List, inspect, start, stop, or restart operating-system services using Service Control Manager, launchctl, or systemd. Mutating actions pass through ReMCP command policy.', o({ action:e(['list','status','start','stop','restart']), name:s('Service/unit/launchd label.'), scope:e(['user','system']) }, ['action']), mutating, diagnosticHandlers.service, ['services']),