@remcp/runtime 0.2.94 → 0.2.97

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,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.97 — 2026-09-22
4
+
5
+ - Native GNOME Wayland `move`, `resize`, and `move_resize` no longer trust a portal drag as proof of success.
6
+ - Window geometry is re-read after each gesture and must match the requested coordinates/size within a bounded tolerance; otherwise the action fails closed.
7
+ - `move_resize` confirms the move before computing the resize gesture. Tool names and schemas remain unchanged.
8
+
9
+ ## 0.2.96 — 2026-09-22
10
+
11
+ - Keeps readable search matches when an unreadable descendant is encountered while surfacing a warning.
12
+ - Makes the regression backend-neutral so both ripgrep and the built-in scanner are validated consistently.
13
+ - Supersedes the failed unpublished 0.2.95 tag; runtime behavior is otherwise unchanged from that candidate.
14
+
15
+ ## 0.2.95 — 2026-09-22
16
+
17
+ - Search sessions preserve readable matches when ripgrep encounters unreadable descendant paths, reporting the partial traversal problem as a warning instead of a fatal session error.
18
+ - The dependency-free fallback scanner now skips unreadable descendant directories/files with the same warning semantics.
19
+ - Requested-root failures and non-traversal ripgrep errors still fail closed.
20
+ - Added ripgrep and fallback regressions; runtime tool names and schemas remain unchanged.
21
+
3
22
  ## 0.2.94 — 2026-09-22
4
23
 
5
24
  - Replaced DOCX/PDF regex-heavy extraction with bounded scanners and added adversarial parser regressions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.94",
3
+ "version": "0.2.97",
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",
@@ -283,6 +283,34 @@ async function focusExplicitWaylandInputTarget(args = {}) {
283
283
  return row;
284
284
  }
285
285
 
286
+ function nativeWaylandGeometryMatches(row, expected = {}, tolerance = 24) {
287
+ for (const key of ['x','y','width','height']) {
288
+ if (expected[key] == null) continue;
289
+ const actual = Number(row?.[key]);
290
+ const wanted = Number(expected[key]);
291
+ if (!Number.isFinite(actual) || !Number.isFinite(wanted) || Math.abs(actual - wanted) > tolerance) return false;
292
+ }
293
+ return true;
294
+ }
295
+
296
+ async function verifyNativeWaylandGeometry(row, expected, label) {
297
+ let latest = row;
298
+ for (let attempt = 0; attempt < 6; attempt += 1) {
299
+ if (attempt > 0) await new Promise(resolve => setTimeout(resolve, 120));
300
+ try {
301
+ latest = await windowMatch({ id:row.id });
302
+ if (nativeWaylandGeometryMatches(latest, expected)) return latest;
303
+ } catch {}
304
+ }
305
+ const actual = ['x','y','width','height']
306
+ .map(key => `${key}=${Number.isFinite(Number(latest?.[key])) ? Number(latest[key]) : 'unknown'}`)
307
+ .join(' ');
308
+ const wanted = Object.entries(expected)
309
+ .map(([key,value]) => `${key}=${value}`)
310
+ .join(' ');
311
+ throw new Error(`Could not verify native Wayland window ${label} for ${row.title || row.app || row.id}: wanted ${wanted}; observed ${actual}`);
312
+ }
313
+
286
314
  async function nativeWaylandWindowAction(row, action, args) {
287
315
  const backend = inputBackend(args);
288
316
  if (backend === 'x11') unavailable('Native Wayland window action', 'use backend=portal for compositor-managed windows');
@@ -336,8 +364,11 @@ async function nativeWaylandWindowAction(row, action, args) {
336
364
  hold_ms:80,
337
365
  duration_ms:120,
338
366
  });
339
- currentX = x;
340
- currentY = y;
367
+ const verified = await verifyNativeWaylandGeometry(row, { x, y }, 'move');
368
+ currentX = Number(verified.x);
369
+ currentY = Number(verified.y);
370
+ currentWidth = Number(verified.width);
371
+ currentHeight = Number(verified.height);
341
372
  }
342
373
 
343
374
  if (action === 'resize' || action === 'move_resize') {
@@ -352,7 +383,21 @@ async function nativeWaylandWindowAction(row, action, args) {
352
383
  hold_ms:80,
353
384
  duration_ms:120,
354
385
  });
386
+ const verified = await verifyNativeWaylandGeometry(row, { width, height }, 'resize');
387
+ currentX = Number(verified.x);
388
+ currentY = Number(verified.y);
389
+ currentWidth = Number(verified.width);
390
+ currentHeight = Number(verified.height);
355
391
  }
392
+ return jsonResult({
393
+ action,
394
+ backend:'xdg-desktop-portal',
395
+ id:row.id,
396
+ x:currentX,
397
+ y:currentY,
398
+ width:currentWidth,
399
+ height:currentHeight,
400
+ });
356
401
  }
357
402
  return jsonResult({ action, backend:'xdg-desktop-portal', id:row.id });
358
403
  }
package/src/sessions.mjs CHANGED
@@ -188,6 +188,7 @@ export function createSearchSession({ type, pattern, path, filePattern }) {
188
188
  results: [],
189
189
  status: 'running',
190
190
  error: null,
191
+ warning: null,
191
192
  cancel: null,
192
193
  waiters: [],
193
194
  lastActivityAt: Date.now(),
@@ -71,17 +71,50 @@ function formatContentResult(file, lineNumber, line, contextLines) {
71
71
  return rows.join('\n');
72
72
  }
73
73
 
74
- async function walk(target, options, onFile) {
75
- const info = await stat(target).catch(() => fail(`Search path not found: ${displayPath(target)}`));
74
+ function rememberSearchWarning(session, warning) {
75
+ if (session.warning) return;
76
+ session.warning = warning instanceof Error ? warning.message : String(warning);
77
+ }
78
+
79
+ function ripgrepTraversalWarnings(stderr, target) {
80
+ const lines = String(stderr || '').split(/\r?\n/).map(line => line.trim()).filter(Boolean);
81
+ if (!lines.length) return null;
82
+ const root = path.resolve(target);
83
+ for (const line of lines) {
84
+ const match = line.match(/^rg: (.*): .* \(os error \d+\)$/);
85
+ if (!match) return null;
86
+ const reported = path.resolve(match[1]);
87
+ const relative = path.relative(root, reported);
88
+ if (!relative || relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) return null;
89
+ }
90
+ return lines;
91
+ }
92
+
93
+ async function walk(target, options, onFile, isRoot = true) {
94
+ let info;
95
+ try {
96
+ info = await stat(target);
97
+ } catch (error) {
98
+ if (isRoot) fail(`Search path not found: ${displayPath(target)}`);
99
+ options.onWarning?.(error);
100
+ return;
101
+ }
76
102
  if (info.isFile()) { await onFile(target); return; }
77
- const entries = await readdir(target, { withFileTypes: true });
103
+ let entries;
104
+ try {
105
+ entries = await readdir(target, { withFileTypes: true });
106
+ } catch (error) {
107
+ if (isRoot) throw error;
108
+ options.onWarning?.(error);
109
+ return;
110
+ }
78
111
  for (const entry of entries) {
79
112
  if (options.stopped()) return;
80
113
  if (!options.includeHidden && entry.name.startsWith('.')) continue;
81
114
  const child = path.join(target, entry.name);
82
115
  if (entry.isDirectory()) {
83
116
  if (options.skipDirectories.has(entry.name)) continue;
84
- await walk(child, options, onFile);
117
+ await walk(child, options, onFile, false);
85
118
  } else if (entry.isFile()) {
86
119
  await onFile(child);
87
120
  }
@@ -131,8 +164,13 @@ function runRipgrep(session, { path: target, pattern, searchType, filePattern, i
131
164
  child.on('error', error => finishSearchSession(session, 'failed', error.message));
132
165
  child.on('close', code => {
133
166
  if (session.status !== 'running') { finishSearchSession(session, session.status); return; }
134
- // ripgrep exits 2 for a real error (bad pattern, unreadable path) and 0/1 otherwise.
135
- if (code === 2 && stderr.trim()) { finishSearchSession(session, 'failed', stderr.trim().split('\n')[0]); return; }
167
+ // ripgrep exits 2 both for fatal invocation errors and for partial traversal errors.
168
+ // Descendant OS errors are recoverable: preserve readable matches and surface a warning.
169
+ if (code === 2 && stderr.trim()) {
170
+ const warnings = ripgrepTraversalWarnings(stderr, target);
171
+ if (!warnings) { finishSearchSession(session, 'failed', stderr.trim().split(/\r?\n/)[0]); return; }
172
+ rememberSearchWarning(session, warnings.length === 1 ? warnings[0] : `${warnings[0]} (+${warnings.length - 1} more)`);
173
+ }
136
174
  finishSearchSession(session, capped ? 'capped' : 'completed');
137
175
  });
138
176
  }
@@ -142,7 +180,12 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
142
180
  const nameGlob = searchType === 'files' ? globToRegExp(fileNameGlob(session.pattern)) : null;
143
181
  const nameLiteral = searchType === 'files' && !/[*?[\]{}]/.test(session.pattern) ? session.pattern : null;
144
182
  let collected = 0;
145
- await walk(target, { includeHidden, skipDirectories: includeIgnored ? new Set() : SKIP_DIRECTORIES, stopped: () => session.status !== 'running' || collected >= maxResults }, async file => {
183
+ await walk(target, {
184
+ includeHidden,
185
+ skipDirectories: includeIgnored ? new Set() : SKIP_DIRECTORIES,
186
+ stopped: () => session.status !== 'running' || collected >= maxResults,
187
+ onWarning: warning => rememberSearchWarning(session, warning),
188
+ }, async file => {
146
189
  if (session.status !== 'running' || collected >= maxResults) return;
147
190
  if (fileGlobs.length && !fileGlobs.some(glob => glob.test(path.basename(file)))) return;
148
191
  if (searchType === 'files') {
@@ -153,8 +196,13 @@ async function runFallback(session, { path: target, matcher, searchType, filePat
153
196
  appendSearchResults(session, [displayPath(file)]);
154
197
  return;
155
198
  }
156
- const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)).catch(() => null);
157
- if (!handle) return;
199
+ let handle;
200
+ try {
201
+ handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
202
+ } catch (error) {
203
+ rememberSearchWarning(session, error);
204
+ return;
205
+ }
158
206
  let buffer;
159
207
  try {
160
208
  const info = await handle.stat();
@@ -210,6 +258,7 @@ export async function startSearchTool(args) {
210
258
  return text([
211
259
  `searchId: ${session.id} · type: ${searchType} · status: ${status} · results so far: ${session.results.length}`,
212
260
  `pattern: ${pattern} · path: ${displayPath(target)}`,
261
+ session.warning ? `warning: ${session.warning}` : '',
213
262
  initial.join('\n'),
214
263
  session.results.length > initial.length ? `… ${session.results.length - initial.length} more results buffered; use get_more_search_results with searchId ${session.id}` : '',
215
264
  ].filter(Boolean).join('\n'));
@@ -234,6 +283,7 @@ export async function getMoreSearchResultsTool(args) {
234
283
  const status = session.error ? `failed: ${session.error}` : session.status;
235
284
  return text([
236
285
  `searchId: ${session.id} · status: ${status} · results ${start}-${end} of ${total}`,
286
+ session.warning ? `warning: ${session.warning}` : '',
237
287
  session.results.slice(start, end).join('\n'),
238
288
  ].filter(Boolean).join('\n'));
239
289
  }