@remcp/runtime 0.2.183 → 0.2.185

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,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.185 — 2026-10-26
4
+
5
+ **Make read-only browser discovery more focused and patch application fail safely.**
6
+
7
+ - Coalesce simultaneous read-only CDP readiness checks for the same validated endpoint, request implementation and timeout without sharing URL-policy decisions or mutating browser state.
8
+ - Honor browser tab ID, title and URL filters before expensive validation, while preserving per-target DNS, socket and SSRF checks.
9
+ - Reject incomplete multi-hunk patch writes to one file and out-of-range zero-context insertion positions instead of silently modifying the wrong text.
10
+ - Keep existing runtime tool schemas and authorization boundaries; validate across isolated Chromium, deterministic regressions and the full runtime suite.
11
+
12
+ ## 0.2.184 — 2026-10-26
13
+
14
+ - Return initial output from long-running terminal sessions with a 45 ms exit-status grace instead of 175 ms, while preserving final exit codes for fast commands, silent-process timeout bounds and tool output schemas.
15
+ - Add regression coverage for first-output latency, structured exit state, incremental output and command cancellation.
16
+
3
17
  ## 0.2.181 — 2026-10-25
4
18
 
5
19
  - 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.183",
3
+ "version": "0.2.185",
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",
@@ -173,12 +173,18 @@ export async function browserCapabilityAvailable(endpoint, timeoutMs = 500) {
173
173
  // Validating every tab sequentially can require hundreds of serial DNS lookups,
174
174
  // making browser_tabs exceed the relay's deadline on machines with many tabs.
175
175
  // Keep DNS and security validation, but bound both the work and its concurrency.
176
- export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl, targetId = null, endpoint = null } = {}) {
176
+ export async function filterBrowserTargets(list, { validate = assertResolvedPageUrl, targetId = null, titleContains = null, urlContains = null, endpoint = null } = {}) {
177
177
  if (!Array.isArray(list)) return [];
178
178
  const candidates = [];
179
+ const titleNeedle = String(titleContains || '').toLowerCase();
180
+ const urlNeedle = String(urlContains || '').toLowerCase();
179
181
  for (const item of list) {
180
182
  if (!item || item.type !== 'page' || !item.webSocketDebuggerUrl) continue;
181
183
  if (targetId && item.id !== targetId) continue;
184
+ // Filter untrusted CDP metadata before expensive URL/DNS validation, but
185
+ // still validate every matching URL before it can reach the caller.
186
+ if (titleNeedle && !String(item.title || '').toLowerCase().includes(titleNeedle)) continue;
187
+ if (urlNeedle && !String(item.url || '').toLowerCase().includes(urlNeedle)) continue;
182
188
  // Reject fabricated/stale CDP socket metadata *before* DNS validation.
183
189
  // browser_tabs must never advertise a target that cannot pass chooseTarget.
184
190
  if (endpoint) {
@@ -229,7 +235,7 @@ export async function filterBrowserTargets(list, { validate = assertResolvedPage
229
235
  return targets;
230
236
  }
231
237
 
232
- export async function listBrowserTargets(endpoint, { targetId = null, readOnlySingleFlight = false, validate = assertResolvedPageUrl } = {}) {
238
+ export async function listBrowserTargets(endpoint, { targetId = null, titleContains = null, urlContains = null, readOnlySingleFlight = false, validate = assertResolvedPageUrl } = {}) {
233
239
  assertBrowserRemoteEnabled();
234
240
  // Reading tabs and page semantics must not launch a separate Chromium/profile.
235
241
  // Only an explicit browser_navigate(action=new_tab) can bootstrap an isolated browser.
@@ -248,7 +254,7 @@ export async function listBrowserTargets(endpoint, { targetId = null, readOnlySi
248
254
  throw new Error(`${label} is unavailable at ${base.origin}. Read-only browser_tabs and browser_snapshot do not launch a browser. Use ui_snapshot and ui_find for the existing browser window; do not retry this CDP endpoint unchanged. Only browser_navigate(action=new_tab), when explicitly requested, may start a local isolated browser.${browserLaunchExitHint({ configured })}`);
249
255
  }
250
256
  // Retain the current bounded concurrent DNS/security checks for up to 1000 tabs.
251
- return filterBrowserTargets(list, { targetId, validate, endpoint:base });
257
+ return filterBrowserTargets(list, { targetId, titleContains, urlContains, validate, endpoint:base });
252
258
  }
253
259
 
254
260
  export function validatedTargetWebSocketUrl(target, endpoint) {
@@ -15,7 +15,12 @@ import { assertResolvedBrowserUrl, assertResolvedPageUrl, boundedText, endpointU
15
15
  import { createGuardedNewTab, evaluate, withTarget } from './browser-session.mjs';
16
16
 
17
17
  export async function browserTabs(args = {}) {
18
- const targets = await listBrowserTargets(args.endpoint, { readOnlySingleFlight:true });
18
+ const targetId = optionalString(args.target_id || args.targetId);
19
+ const titleContains = optionalString(args.title);
20
+ const urlContains = optionalString(args.url_contains || args.urlContains);
21
+ const targets = await listBrowserTargets(args.endpoint, {
22
+ targetId, titleContains, urlContains, readOnlySingleFlight:true,
23
+ });
19
24
  const pages = targets.filter(item => item.type === 'page').slice(0, 500).map(item => ({
20
25
  id: boundedText(item.id, 256),
21
26
  title: boundedText(item.title, 1000),
@@ -25,7 +30,10 @@ export async function browserTabs(args = {}) {
25
30
  const result = jsonResult(pages);
26
31
  // Keep the typed empty-array contract while providing a native fallback hint.
27
32
  if (!pages.length) {
28
- result.content = [{ type: 'text', text: 'CDP is connected, but no debuggable Chromium tabs are available. Use ui_snapshot and ui_find to inspect existing browser windows without CDP; only open a new tab when explicitly requested.' }];
33
+ const scoped = Boolean(targetId || titleContains || urlContains);
34
+ result.content = [{ type: 'text', text: scoped
35
+ ? 'CDP is connected, but no authorized browser page matches the requested selector. Check the selector or use ui_snapshot and ui_find for native browser accessibility.'
36
+ : 'CDP is connected, but no debuggable Chromium tabs are available. Use ui_snapshot and ui_find to inspect existing browser windows without CDP; only open a new tab when explicitly requested.' }];
29
37
  }
30
38
  return result;
31
39
  }
@@ -4,6 +4,9 @@
4
4
  import { browserRemoteEnabled, endpointUrl, requestJson, validatedTargetWebSocketUrl } from './browser-endpoint.mjs';
5
5
 
6
6
  const records = new Map();
7
+ // Avoid duplicate /json/version + /json/list reads when several agent calls
8
+ // request CDP readiness at once. Never coalesce different probe transports.
9
+ const pendingProbes = new Map();
7
10
  const CACHE_MS = 3000;
8
11
  const MAX_ENTRIES = 8;
9
12
  const transportError = message =>
@@ -39,30 +42,43 @@ export async function probeBrowserReadiness({
39
42
  let key;
40
43
  try { key = endpointUrl(endpoint).origin; }
41
44
  catch { return { status:'blocked_endpoint', ready:false, candidate_pages:0, next_step:'check_local_cdp_config' }; }
45
+ const timeout = Math.max(100, Math.min(800, Number(timeoutMs) || 350));
42
46
  if (cache) {
43
47
  const previous = records.get(key);
44
- if (previous && previous.expires > now) return { ...previous.value, cache_hit:true };
48
+ if (previous && previous.expires > now && previous.request === request &&
49
+ previous.timeout === timeout) return { ...previous.value, cache_hit:true };
45
50
  }
46
- const timeout = Math.max(100, Math.min(800, Number(timeoutMs) || 350));
47
- let result;
48
- try {
49
- const version = await request('/json/version', endpoint, { timeout_ms:timeout });
50
- // CDP version alone is not evidence a useful debuggable tab exists.
51
- if (!version || typeof version !== 'object' || !(version.Browser || version.webSocketDebuggerUrl)) {
52
- result = { status:'invalid_response', ready:false, candidate_pages:0 };
53
- } else {
54
- const targets = await request('/json/list', endpoint, { timeout_ms:timeout });
55
- result = classifyBrowserReadiness(version, targets, endpoint);
51
+ const load = async () => {
52
+ let result;
53
+ try {
54
+ const version = await request('/json/version', endpoint, { timeout_ms:timeout });
55
+ // CDP version alone is not evidence a useful debuggable tab exists.
56
+ if (!version || typeof version !== 'object' || !(version.Browser || version.webSocketDebuggerUrl)) {
57
+ result = { status:'invalid_response', ready:false, candidate_pages:0 };
58
+ } else {
59
+ const targets = await request('/json/list', endpoint, { timeout_ms:timeout });
60
+ result = classifyBrowserReadiness(version, targets, endpoint);
61
+ }
62
+ } catch (error) {
63
+ result = { status:transportError(String(error?.message || '')) ? 'unavailable' : 'invalid_response',
64
+ ready:false, candidate_pages:0 };
56
65
  }
57
- } catch (error) {
58
- result = { status:transportError(String(error?.message || '')) ? 'unavailable' : 'invalid_response',
59
- ready:false, candidate_pages:0 };
60
- }
61
- const next_step = result.ready ? 'use_browser_tabs' : 'use_native_accessibility';
62
- const output = { ...result, next_step };
63
- if (cache) {
64
- records.set(key, { expires:now + CACHE_MS, value:output });
65
- while (records.size > MAX_ENTRIES) records.delete(records.keys().next().value);
66
+ const output = { ...result, next_step:result.ready ? 'use_browser_tabs' : 'use_native_accessibility' };
67
+ if (cache) {
68
+ records.set(key, { expires:now + CACHE_MS, value:output, request, timeout });
69
+ while (records.size > MAX_ENTRIES) records.delete(records.keys().next().value);
70
+ }
71
+ return output;
72
+ };
73
+ if (!cache) return load();
74
+ const pending = pendingProbes.get(key);
75
+ if (pending && pending.request === request && pending.timeout === timeout) return pending.work;
76
+ // Bounded memory; mismatched transports must never share probe results.
77
+ if (pending || pendingProbes.size >= MAX_ENTRIES) return load();
78
+ const work = Promise.resolve().then(load);
79
+ pendingProbes.set(key, { request, timeout, work });
80
+ try { return await work; }
81
+ finally {
82
+ if (pendingProbes.get(key)?.work === work) pendingProbes.delete(key);
66
83
  }
67
- return output;
68
84
  }
package/src/patch.mjs CHANGED
@@ -87,7 +87,12 @@ function locate(lines, hunk) {
87
87
  // A hunk with no context and no removals is a pure insertion: `@@ -N,0 +M,K @@` inserts after
88
88
  // line N, so the zero-based insertion point is N (and the end of file when N is the last line).
89
89
  // Using N-1 here inserted every such hunk one line too early while still reporting success.
90
- if (!expected.length) return { index: Math.min(Math.max(hunk.oldStart, 0), lines.length), fuzz: 0 };
90
+ if (!expected.length) {
91
+ // A stale or malformed insertion offset must fail instead of silently
92
+ // appending to EOF (which can corrupt an unrelated section of a file).
93
+ if (!Number.isSafeInteger(hunk.oldStart) || hunk.oldStart < 0 || hunk.oldStart > lines.length) return null;
94
+ return { index: hunk.oldStart, fuzz: 0 };
95
+ }
91
96
  const candidates = [];
92
97
  const anchor = Math.max(0, hunk.oldStart - 1);
93
98
  for (let offset = 0; offset <= 200; offset += 1) {
@@ -234,7 +234,12 @@ export async function applyPatchTool(args) {
234
234
  else throw error;
235
235
  }
236
236
  const { updated, applied, failed } = applyHunks(original, file.hunks);
237
- if (failed.length && !applied.length) { results.push(`failed ${displayPath(absolute)}: none of the ${file.hunks.length} hunk(s) matched`); continue; }
237
+ // Never persist a partially applied file, even if one hunk did match.
238
+ // An MCP success response here would let an agent continue with broken code.
239
+ if (failed.length) {
240
+ results.push(`failed ${displayPath(absolute)}: ${failed.length}/${file.hunks.length} hunk(s) did not match; no changes written`);
241
+ continue;
242
+ }
238
243
  const stats = diffStats(original, updated);
239
244
  if (!dryRun) {
240
245
  assertWritableSize(updated);
@@ -69,9 +69,12 @@ function terminalStructured(value, fallbackText) {
69
69
  // useful output arrives, keeping a short grace window so quick commands can
70
70
  // still report their real exit status without requiring a follow-up tool call.
71
71
  // Explicit timeout_ms remains an upper bound, including for silent commands.
72
+ // Buffered output needs only a short exit-status grace: 175ms held long-running
73
+ // commands even after their first useful text was already ready. A 45ms window
74
+ // lets immediate exits report their final code without delaying live feedback.
72
75
  async function waitForInitialOutput(session, timeoutMs) {
73
76
  const deadline = Date.now() + timeoutMs;
74
- const graceUntil = Date.now() + Math.min(175, timeoutMs);
77
+ const graceUntil = Date.now() + Math.min(45, timeoutMs);
75
78
  while (!session.exited) {
76
79
  const now = Date.now();
77
80
  if (now >= deadline || (now >= graceUntil && hasNewOutput(session))) break;