@remcp/runtime 0.2.101 → 0.2.103

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.103 — 2026-09-22
4
+
5
+ - Native Wayland `window_action` targets exact PID/title Accessibility windows for `close` and verifies that the requested window disappeared.
6
+ - Shortcut-backed Wayland window actions require verified focus on the exact requested window before minimize, maximize, move, resize, or close proceeds; ambiguous targeting fails closed.
7
+ - Live smoke coverage isolates XDG open/reveal handlers and stabilizes browser input focus without changing the runtime tool surface.
8
+ - Verified on GNOME Wayland with 80 passing computer-use checks and a two-window isolation proof: target A closed while sibling B stayed open.
9
+
10
+ ## 0.2.102 — 2026-09-22
11
+
12
+ - `runWithInput` installs a stdin error handler before writing input, so an early child pipe close is returned as a `ToolError` instead of an uncaught `EPIPE` that can terminate the runtime.
13
+ - A failed input pipe now terminates the child before rejecting, preventing a rejected call from leaving an orphan process behind.
14
+ - Empty input closes stdin without a zero-byte write, reducing races with short-lived subprocesses.
15
+ - Added a deterministic closed-stdin regression and stress coverage; runtime tool names and schemas are unchanged.
16
+
3
17
  ## 0.2.101 — 2026-09-22
4
18
 
5
19
  - macOS `record_screen` resolves FFmpeg from standard Homebrew locations when launchd's minimal `PATH` cannot see it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.101",
3
+ "version": "0.2.103",
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",
@@ -98,7 +98,14 @@ export async function runWithInput(file, args = [], input = '', options = {}) {
98
98
  if (code === 0 || options.allowFailure) finish(resolve, { stdout, stderr, code });
99
99
  else finish(reject, new ToolError(`${options.label || file} failed: ${stderr.trim() || `exit ${code}`}`));
100
100
  });
101
- child.stdin.end(String(input));
101
+ child.stdin.on('error', error => {
102
+ if (settled) return;
103
+ child.kill('SIGKILL');
104
+ finish(reject, new ToolError(`${options.label || file} input failed: ${error.message}`));
105
+ });
106
+ const inputText = String(input);
107
+ if (inputText.length) child.stdin.end(inputText);
108
+ else child.stdin.end();
102
109
  });
103
110
  }
104
111
 
@@ -312,19 +312,100 @@ async function verifyNativeWaylandGeometry(row, expected, label) {
312
312
  throw new Error(`Could not verify native Wayland window ${label} for ${row.title || row.app || row.id}: wanted ${wanted}; observed ${actual}`);
313
313
  }
314
314
 
315
+ async function nativeWaylandAccessibilityAction(row, actionNames) {
316
+ const pid = Number(row?.pid);
317
+ if (!Number.isInteger(pid) || pid <= 0 || !Array.isArray(actionNames) || !actionNames.length) {
318
+ return { applied:false, available:[] };
319
+ }
320
+ const script = pyAtSpiPrelude() + `
321
+ want_pid=${pid};want_window=${JSON.stringify(String(row.title || ''))};wanted_actions=${JSON.stringify(actionNames)}
322
+ target=None
323
+ for root,app_name,pid,active in q:
324
+ if pid!=want_pid: continue
325
+ try:
326
+ for child in root:
327
+ try:
328
+ role=(child.getRoleName() or "").lower();name=child.name or ""
329
+ if role not in ("frame","window","dialog"): continue
330
+ if want_window and want_window.strip().lower()!=name.strip().lower(): continue
331
+ target=child;break
332
+ except: pass
333
+ except: pass
334
+ if target is not None: break
335
+ if target is None:
336
+ print(json.dumps({"applied":False,"available":[],"reason":"target window not found"}))
337
+ else:
338
+ try:
339
+ actions=target.queryAction();available=[];chosen=-1;chosen_name=""
340
+ for i in range(actions.nActions):
341
+ try: action_name=actions.getName(i) or ""
342
+ except: action_name=""
343
+ available.append(action_name)
344
+ if chosen<0 and action_name in wanted_actions:
345
+ chosen=i;chosen_name=action_name
346
+ if chosen<0:
347
+ print(json.dumps({"applied":False,"available":available,"reason":"requested accessibility action unavailable"}))
348
+ else:
349
+ applied=actions.doAction(chosen)
350
+ if applied is False: raise Exception("accessibility window action returned false")
351
+ print(json.dumps({"applied":True,"action":chosen_name,"available":available}))
352
+ except Exception as exc:
353
+ print(json.dumps({"applied":False,"available":[],"reason":str(exc)}))
354
+ `;
355
+ const result = await runFile('python3', ['-c', script], { label:'AT-SPI window action', timeout:2000, allowFailure:true });
356
+ try {
357
+ const payload = JSON.parse(result.stdout || '{}');
358
+ return payload && typeof payload === 'object' ? payload : { applied:false, available:[] };
359
+ } catch {
360
+ return { applied:false, available:[], reason:String(result.stderr || result.stdout || 'AT-SPI window action failed').trim() };
361
+ }
362
+ }
363
+
364
+ async function verifyNativeWaylandWindowClosed(row, timeoutMs = 2500) {
365
+ const deadline = Date.now() + timeoutMs;
366
+ while (Date.now() < deadline) {
367
+ try { await windowMatch({ id:row.id }); }
368
+ catch { return; }
369
+ await new Promise(resolve => setTimeout(resolve, 100));
370
+ }
371
+ throw new Error(`Could not verify native Wayland window close for ${row.title || row.app || row.id}`);
372
+ }
373
+
374
+ async function focusNativeWaylandForAction(row, backend) {
375
+ await windowAction({ action:'focus', id:row.id, backend });
376
+ const focusState = await targetAccessibilityFocus(row);
377
+ if (!focusState.exact) throw new Error(`Could not verify native Wayland focus for ${row.title || row.app || row.id}`);
378
+ }
379
+
315
380
  async function nativeWaylandWindowAction(row, action, args) {
316
381
  const backend = inputBackend(args);
317
382
  if (backend === 'x11') unavailable('Native Wayland window action', 'use backend=portal for compositor-managed windows');
318
383
  if (!waylandPortalCandidate()) unavailable('Native Wayland window action', 'no XDG RemoteDesktop portal is available');
319
384
  if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Native Wayland window action');
320
- if (row.ui_id) {
321
- try { await uiAction({ action:'focus', id:row.ui_id }); } catch {}
322
- }
323
385
 
324
386
  const portalBackend = backend === 'portal' ? 'portal' : 'auto';
325
387
  const timeoutMs = backend === 'portal' ? 120_000 : 2500;
326
- if (action === 'close') await portalShortcut('ALT+F4', { timeoutMs });
327
- else if (action === 'minimize') await portalShortcut('ALT+F9', { timeoutMs });
388
+ if (action === 'close') {
389
+ const semantic = await nativeWaylandAccessibilityAction(row, ['window.close']);
390
+ if (semantic.applied) {
391
+ await verifyNativeWaylandWindowClosed(row);
392
+ return jsonResult({ action, backend:'atspi-window-action', id:row.id, accessibility_action:semantic.action });
393
+ }
394
+ // Some GTK apps disconnect from AT-SPI while handling window.close, before the action
395
+ // call can return a success payload. Trust the observed target state, not that race.
396
+ try {
397
+ await verifyNativeWaylandWindowClosed(row, 400);
398
+ return jsonResult({ action, backend:'atspi-window-action', id:row.id, accessibility_action:'window.close', verified_after_disconnect:true });
399
+ } catch {}
400
+ await focusNativeWaylandForAction(row, portalBackend);
401
+ await portalShortcut('ALT+F4', { timeoutMs });
402
+ await verifyNativeWaylandWindowClosed(row);
403
+ return jsonResult({ action, backend:'xdg-desktop-portal', id:row.id });
404
+ }
405
+ if (action === 'minimize') {
406
+ await focusNativeWaylandForAction(row, portalBackend);
407
+ await portalShortcut('ALT+F9', { timeoutMs });
408
+ }
328
409
  else if (action === 'maximize' || action === 'restore') {
329
410
  let maximized = false;
330
411
  try {
@@ -346,9 +427,11 @@ async function nativeWaylandWindowAction(row, action, args) {
346
427
  }
347
428
  } catch {}
348
429
  if ((action === 'maximize' && !maximized) || (action === 'restore' && maximized)) {
430
+ await focusNativeWaylandForAction(row, portalBackend);
349
431
  await portalShortcut('ALT+F10', { timeoutMs });
350
432
  }
351
433
  } else {
434
+ await focusNativeWaylandForAction(row, portalBackend);
352
435
  let currentX = Number(row.x), currentY = Number(row.y), currentWidth = Number(row.width), currentHeight = Number(row.height);
353
436
  if (![currentX,currentY,currentWidth,currentHeight].every(Number.isFinite)) unavailable('Native Wayland window geometry', 'accessibility bounds are unavailable');
354
437
 
@@ -495,6 +578,9 @@ except Exception:
495
578
  def currentValue(self,v): self.a.set_current_value(float(v))
496
579
  class _Action:
497
580
  def __init__(self,a): self.a=a
581
+ @property
582
+ def nActions(self): return self.a.get_n_actions()
583
+ def getName(self,i): return self.a.get_action_name(i)
498
584
  def doAction(self,i): return self.a.do_action(i)
499
585
  class _Editable:
500
586
  def __init__(self,a): self.a=a