@remcp/runtime 0.2.103 → 0.2.105

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.105 — 2026-09-22
4
+
5
+ - macOS top-level window IDs now include a snapshot generation and retain cached app/title/bounds identity, so a changed process-local window index cannot silently retarget an action.
6
+ - Older cached handles recover the same unambiguous window after reorder; stale, fabricated, expired, or ambiguous handles fail closed and require `list_windows` refresh.
7
+ - Real Mac proof on a multi-window Chrome process verified `g1 → g2` refresh, old-handle exact focus/capture resolution, fabricated-generation rejection, and Accessibility traversal.
8
+ - Runtime tool names and schemas are unchanged from 0.2.104.
9
+
10
+ ## 0.2.104 — 2026-09-22
11
+
12
+ - Fresh installations default the narrow catastrophic-command guardrail to `block`; ordinary development, file, Git, package-manager, Docker, database, browser, and document workflows remain available.
13
+ - Explicit `warn`, `allow`, and unrestricted/godmode remain operator-controlled local opt-ins.
14
+ - Added an isolated 73-scenario Desktop Commander compatibility smoke matrix; all 73 scenarios pass with zero guardrail false positives, while genuinely catastrophic host power/destructive commands remain blocked before execution.
15
+ - Wayland portal pointer clicks now keep a 60 ms button-down interval before release so a transport-level success is not collapsed into a zero-duration gesture; the fix passed three consecutive full 80/80 live ASUS smoke runs.
16
+ - Runtime tool names and schemas are unchanged from 0.2.103.
17
+
3
18
  ## 0.2.103 — 2026-09-22
4
19
 
5
20
  - Native Wayland `window_action` targets exact PID/title Accessibility windows for `close` and verifies that the requested window disappeared.
package/README.md CHANGED
@@ -147,10 +147,10 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
147
147
  against the resolved real path of the deepest existing ancestor rather than the lexical string, so
148
148
  `<allowed>/link -> /etc` cannot be used to read or write outside the allowed directories.
149
149
  `allowedRoots: ["/"]` means the whole filesystem and works as written.
150
- - **Command guardrail.** `dangerousCommands` defaults to `warn`: a catastrophic command still runs and
151
- the result carries a note. `allow` silences the note, `block` refuses before running. Rules match the
152
- *command word* of each shell segment, so `grep -n format README.md` is never affected. User
153
- `blockedCommands` entries are always enforced.
150
+ - **Command guardrail.** `dangerousCommands` defaults to `block`: a catastrophic command is refused
151
+ before execution. Operators can explicitly choose `warn` to run it with an advisory note or `allow`
152
+ to disable the built-in guardrail. Rules match the *command word* of each shell segment, so
153
+ `grep -n format README.md` is never affected. User `blockedCommands` entries are always enforced.
154
154
  - **A bad configuration is loud.** If `runtime.json` cannot be parsed, the device refuses to start and
155
155
  says why, instead of quietly dropping your `allowedRoots` and re-enabling usage metrics.
156
156
  - **Secret masking.** `list_processes` masks command arguments that look like tokens, passwords, or
@@ -182,7 +182,7 @@ Optional settings live in `~/.config/remcp/runtime.json` (override the directory
182
182
  "name": "workstation",
183
183
  "allowedRoots": ["~/projects", "/srv/data"],
184
184
  "blockedCommands": ["rm -rf /", "shutdown"],
185
- "dangerousCommands": "warn",
185
+ "dangerousCommands": "block",
186
186
  "telemetryEnabled": true,
187
187
  "maxOutputBytes": 1048576,
188
188
  "maxReadLines": 2000,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remcp/runtime",
3
- "version": "0.2.103",
3
+ "version": "0.2.105",
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",
package/src/config.mjs CHANGED
@@ -61,10 +61,9 @@ function booleanValue(value, fallback) {
61
61
 
62
62
  const DANGEROUS_MODES = ['block', 'warn', 'allow'];
63
63
 
64
- // `warn` runs the command and adds a note when it matches the destructive-command list:
65
- // nothing is ever blocked or delayed, but a catastrophic command is visible in the tool
66
- // result. Set `allow` for zero noise or `block` to refuse.
67
- function dangerousMode(value, fallback = 'warn') {
64
+ // `block` is the secure default: matching catastrophic commands are refused before execution.
65
+ // Operators can explicitly choose `warn` to run with an advisory note or `allow` for zero guardrail noise.
66
+ function dangerousMode(value, fallback = 'block') {
68
67
  const normalized = String(value ?? '').trim().toLowerCase();
69
68
  return DANGEROUS_MODES.includes(normalized) ? normalized : fallback;
70
69
  }
@@ -1147,6 +1147,10 @@ export async function pointer(args = {}) {
1147
1147
  else if (action === 'move') throw new Error('x and y are required for pointer move');
1148
1148
  const click = async () => {
1149
1149
  await portalPointerButton(buttonName, true, portalOptions);
1150
+ // A zero-duration press/release can be accepted by the portal transport but
1151
+ // coalesced before the target toolkit observes a real click. Keep a short,
1152
+ // human-scale hold so successful return means a deliverable button gesture.
1153
+ await new Promise(resolve => setTimeout(resolve, 60));
1150
1154
  await portalPointerButton(buttonName, false, portalOptions);
1151
1155
  await new Promise(resolve => setTimeout(resolve, 40));
1152
1156
  };
@@ -23,6 +23,72 @@ function parseTsv(value, fields) {
23
23
  });
24
24
  }
25
25
 
26
+ const macWindowTargets = new Map();
27
+ const MAC_WINDOW_TARGET_TTL_MS = 5 * 60 * 1000;
28
+ let macWindowGeneration = 0;
29
+
30
+ function rememberMacWindowTarget(row) {
31
+ macWindowTargets.set(row.id, {
32
+ pid:row.pid,
33
+ app:row.app,
34
+ title:row.title,
35
+ x:row.x,
36
+ y:row.y,
37
+ width:row.width,
38
+ height:row.height,
39
+ at:Date.now(),
40
+ });
41
+ }
42
+
43
+ function cachedMacWindowTarget(id) {
44
+ const cached = macWindowTargets.get(id);
45
+ if (!cached) return null;
46
+ if ((Date.now() - cached.at) > MAC_WINDOW_TARGET_TTL_MS) {
47
+ macWindowTargets.delete(id);
48
+ return null;
49
+ }
50
+ return cached;
51
+ }
52
+
53
+ export function macWindowId(pid, windowIndex, generation) {
54
+ const numericPid = Number(pid);
55
+ const numericIndex = Number(windowIndex);
56
+ const numericGeneration = Number(generation);
57
+ if (!Number.isInteger(numericPid) || numericPid <= 0
58
+ || !Number.isInteger(numericIndex) || numericIndex < 0
59
+ || !Number.isInteger(numericGeneration) || numericGeneration <= 0) {
60
+ throw new Error('Could not construct a macOS window id from invalid PID/index/generation');
61
+ }
62
+ return `mac:${numericPid}:w${numericIndex}:g${numericGeneration}`;
63
+ }
64
+
65
+ export function normalizeMacWindowTarget(args = {}, { useWindowId = false } = {}) {
66
+ const explicitPid = Number.isInteger(Number(args.pid)) && Number(args.pid) > 0 ? Number(args.pid) : 0;
67
+ let idPid = 0;
68
+ let windowIndex = null;
69
+ const rawId = useWindowId ? optionalString(args.id) : '';
70
+ let cached = null;
71
+ if (rawId) {
72
+ const match = /^mac:(\d+):w(\d+):g(\d+)$/.exec(rawId);
73
+ if (!match) throw new Error('macOS window id is stale or unsupported; call list_windows again');
74
+ idPid = Number(match[1]);
75
+ windowIndex = Number(match[2]);
76
+ if (explicitPid && explicitPid !== idPid) throw new Error('macOS window id and pid refer to different processes');
77
+ cached = cachedMacWindowTarget(rawId);
78
+ }
79
+ return {
80
+ id:rawId,
81
+ idCached:Boolean(cached),
82
+ pid:idPid || explicitPid,
83
+ windowIndex,
84
+ app:optionalString(args.app) || '',
85
+ title:optionalString(args.window_title || args.windowTitle || args.title) || '',
86
+ expectedApp:cached?.app || '',
87
+ expectedTitle:cached?.title || '',
88
+ expectedBounds:cached ? [cached.x,cached.y,cached.width,cached.height] : null,
89
+ };
90
+ }
91
+
26
92
  export async function listWindows() {
27
93
  const script = `
28
94
  set output to ""
@@ -31,22 +97,30 @@ tell application "System Events"
31
97
  try
32
98
  set appName to name of p
33
99
  set appPid to unix id of p
100
+ set windowIndex to 0
34
101
  repeat with w in windows of p
35
102
  try
36
103
  set pos to position of w
37
104
  set sz to size of w
38
- set output to output & appPid & tab & appName & tab & (name of w) & tab & (item 1 of pos) & tab & (item 2 of pos) & tab & (item 1 of sz) & tab & (item 2 of sz) & linefeed
105
+ set output to output & appPid & tab & windowIndex & tab & appName & tab & (name of w) & tab & (item 1 of pos) & tab & (item 2 of pos) & tab & (item 1 of sz) & tab & (item 2 of sz) & linefeed
39
106
  end try
107
+ set windowIndex to windowIndex + 1
40
108
  end repeat
41
109
  end try
42
110
  end repeat
43
111
  end tell
44
112
  return output`;
45
113
  const { stdout } = await runOsa(script, { label: 'list windows' });
46
- const rows = parseTsv(stdout, ['pid','app','title','x','y','width','height']).map((row,index)=>({
47
- id:`mac:${row.pid}:${index}`,pid:Number(row.pid),app:row.app,title:row.title,
114
+ macWindowGeneration += 1;
115
+ const generation = macWindowGeneration;
116
+ const rows = parseTsv(stdout, ['pid','window_index','app','title','x','y','width','height']).map(row=>({
117
+ id:macWindowId(row.pid,row.window_index,generation),pid:Number(row.pid),app:row.app,title:row.title,
48
118
  x:Number(row.x),y:Number(row.y),width:Number(row.width),height:Number(row.height),
49
119
  }));
120
+ for (const [id,cached] of macWindowTargets.entries()) {
121
+ if ((Date.now() - cached.at) > MAC_WINDOW_TARGET_TTL_MS) macWindowTargets.delete(id);
122
+ }
123
+ for (const row of rows) rememberMacWindowTarget(row);
50
124
  return jsonResult(rows);
51
125
  }
52
126
 
@@ -67,7 +141,7 @@ export async function windowAction(args = {}) {
67
141
  if((action==='move'||action==='move_resize')&&(!Number.isFinite(x)||!Number.isFinite(y)))throw new Error('x and y are required for window move');
68
142
  if((action==='resize'||action==='move_resize')&&(!Number.isFinite(width)||!Number.isFinite(height)||width<=0||height<=0))throw new Error('width and height must be positive for window resize');
69
143
  const script=`(function(){
70
- ${macJxaTargetPrelude(args)}
144
+ ${macJxaTargetPrelude(args,{useWindowId:true})}
71
145
  function perform(e,wanted){let acts=[];try{acts=e.actions()}catch(_){};for(let i=0;i<acts.length;i++){let n='';try{n=String(acts[i].name())}catch(_){};if(n===wanted){acts[i].perform();return true}}return false}
72
146
  function setAttr(e,n,v){try{e.attributes.byName(n).value=v;return true}catch(_){return false}}
73
147
  const action=${JSON.stringify(action)};
@@ -86,21 +160,58 @@ return JSON.stringify({action,pid:Number(proc.unixId()),app:safeName(proc),windo
86
160
  let parsed;try{parsed=JSON.parse(stdout.trim())}catch{parsed={action,backend:'macos-accessibility'}}
87
161
  return jsonResult(parsed);
88
162
  }
89
- function macJxaTargetPrelude(args = {}) {
90
- const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : 0;
91
- const app = JSON.stringify(optionalString(args.app) || '');
92
- const title = JSON.stringify(optionalString(args.window_title || args.windowTitle || args.title) || '');
163
+ export async function resolveWindowTarget(args = {}) {
164
+ const script=`(function(){
165
+ ${macJxaTargetPrelude(args,{useWindowId:true})}
166
+ let p={};try{p=win.properties()}catch(_){};
167
+ return JSON.stringify({
168
+ id:${JSON.stringify(optionalString(args.id || args.window_id || args.windowId) || '')},
169
+ pid:Number(proc.unixId()),app:safeName(proc),title:safeName(win),
170
+ x:Array.isArray(p.position)?Number(p.position[0]):null,
171
+ y:Array.isArray(p.position)?Number(p.position[1]):null,
172
+ width:Array.isArray(p.size)?Number(p.size[0]):null,
173
+ height:Array.isArray(p.size)?Number(p.size[1]):null,
174
+ backend:'macos-accessibility'
175
+ });
176
+ })()`;
177
+ const {stdout}=await runOsa(script,{javascript:true,label:'resolve window target',timeout:30_000});
178
+ let parsed;try{parsed=JSON.parse(stdout.trim())}catch{throw new Error('Could not resolve macOS window target')}
179
+ return jsonResult(parsed);
180
+ }
181
+
182
+ function macJxaTargetPrelude(args = {}, options = {}) {
183
+ const target = normalizeMacWindowTarget(args, options);
184
+ if (options.useWindowId && target.id && !target.idCached) throw new Error('macOS window id is stale or unknown; call list_windows again');
185
+ const app = JSON.stringify(target.app);
186
+ const title = JSON.stringify(target.title);
187
+ const expectedApp = JSON.stringify(target.expectedApp);
188
+ const expectedTitle = JSON.stringify(target.expectedTitle);
189
+ const expectedBounds = JSON.stringify(target.expectedBounds);
93
190
  return String.raw`
94
191
  const se=Application('System Events');
95
- const wantPid=${pid};
192
+ const wantPid=${target.pid};
193
+ const wantWindowIndex=${target.windowIndex == null ? 'null' : target.windowIndex};
96
194
  const wantApp=${app};
97
195
  const wantTitle=${title};
196
+ const expectedApp=${expectedApp};
197
+ const expectedTitle=${expectedTitle};
198
+ const expectedBounds=${expectedBounds};
98
199
  function safeWindows(p){try{return p.windows()}catch(e){return []}}
99
200
  function safeName(spec){try{return String(spec.name()||'')}catch(e){return ''}}
201
+ function safeProps(spec){try{return spec.properties()}catch(e){return {}}}
202
+ function sameBounds(spec,bounds){
203
+ if(!bounds)return false;
204
+ const p=safeProps(spec),pos=Array.isArray(p.position)?p.position:[],size=Array.isArray(p.size)?p.size:[];
205
+ return Number(pos[0])===Number(bounds[0])&&Number(pos[1])===Number(bounds[1])&&Number(size[0])===Number(bounds[2])&&Number(size[1])===Number(bounds[3]);
206
+ }
100
207
  function chooseProcess(){
101
208
  if(wantPid){
102
209
  const rows=se.applicationProcesses.whose({unixId:wantPid})();
103
- if(rows.length)return rows[0];
210
+ if(rows.length){
211
+ if(wantApp && !safeName(rows[0]).toLowerCase().includes(wantApp.toLowerCase()))throw new Error('macOS window target app does not match the requested id/pid');
212
+ if(expectedApp && safeName(rows[0])!==expectedApp)throw new Error('macOS window id no longer belongs to the same application; call list_windows again');
213
+ return rows[0];
214
+ }
104
215
  throw new Error('No matching UI process found');
105
216
  }
106
217
  if(wantApp){
@@ -130,6 +241,30 @@ function chooseProcess(){
130
241
  function chooseWindow(p){
131
242
  const rows=safeWindows(p);
132
243
  if(!rows.length)throw new Error('The selected application has no accessible windows');
244
+ if(wantWindowIndex!==null){
245
+ const indexValid=Number.isInteger(wantWindowIndex)&&wantWindowIndex>=0&&wantWindowIndex<rows.length;
246
+ const selected=indexValid?rows[wantWindowIndex]:null;
247
+ if(expectedTitle){
248
+ if(selected&&safeName(selected)===expectedTitle)return selected;
249
+ const matches=[];
250
+ for(let i=0;i<rows.length;i++)if(safeName(rows[i])===expectedTitle)matches.push(rows[i]);
251
+ if(matches.length===1)return matches[0];
252
+ if(matches.length>1&&expectedBounds){
253
+ const bounded=matches.filter(row=>sameBounds(row,expectedBounds));
254
+ if(bounded.length===1)return bounded[0];
255
+ }
256
+ throw new Error('macOS window id no longer identifies one unambiguous window; call list_windows again');
257
+ }
258
+ if(expectedBounds){
259
+ if(selected&&sameBounds(selected,expectedBounds))return selected;
260
+ const bounded=rows.filter(row=>sameBounds(row,expectedBounds));
261
+ if(bounded.length===1)return bounded[0];
262
+ throw new Error('macOS window id no longer identifies one unambiguous window; call list_windows again');
263
+ }
264
+ if(!indexValid)throw new Error('macOS window id is stale; call list_windows again');
265
+ if(wantTitle && !safeName(selected).toLowerCase().includes(wantTitle.toLowerCase()))throw new Error('macOS window id no longer matches the requested title; call list_windows again');
266
+ return selected;
267
+ }
133
268
  if(wantTitle){
134
269
  for(let i=0;i<rows.length;i++){
135
270
  if(safeName(rows[i]).toLowerCase().includes(wantTitle.toLowerCase()))return rows[i];
@@ -373,13 +373,23 @@ export async function screenshotRegion(args = {}) {
373
373
  const title = optionalString(args.title);
374
374
  const pid = Number.isInteger(Number(args.pid)) ? Number(args.pid) : null;
375
375
  if (windowId || app || title || pid != null) {
376
- const rows = resultValue(await listWindows({ include_monitor:false }));
377
- const row = Array.isArray(rows) ? rows.find(item =>
378
- (!windowId || item.id === windowId)
379
- && (pid == null || Number(item.pid) === pid)
380
- && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
381
- && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
382
- ) : null;
376
+ let row = null;
377
+ if (windowId && typeof adapter.resolveWindowTarget === 'function') {
378
+ row = resultValue(await adapter.resolveWindowTarget({
379
+ id:windowId,
380
+ ...(pid != null ? { pid } : {}),
381
+ ...(app ? { app } : {}),
382
+ ...(title ? { title } : {}),
383
+ }));
384
+ } else {
385
+ const rows = resultValue(await listWindows({ include_monitor:false }));
386
+ row = Array.isArray(rows) ? rows.find(item =>
387
+ (!windowId || item.id === windowId)
388
+ && (pid == null || Number(item.pid) === pid)
389
+ && (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
390
+ && (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
391
+ ) : null;
392
+ }
383
393
  if (!row) throw new Error('No matching window found for screenshot_region');
384
394
  x = Number(row.x); y = Number(row.y); width = Number(row.width); height = Number(row.height);
385
395
  } else if (args.monitor != null || args.monitor_index != null) {
package/src/policy.mjs CHANGED
@@ -2,10 +2,10 @@ import { runtimeConfig } from './config.mjs';
2
2
  import { recordEvent } from './telemetry.mjs';
3
3
  import { fail } from './util.mjs';
4
4
 
5
- // Optional hardening rules for catastrophic host-level commands. The default is `allow`:
6
- // ReMCP is a remote-control tool for computers you own, and the agent needs to be able to
7
- // do anything you could do at a shell. Deployments that want a safety net can set
8
- // `dangerousCommands` to `warn` (run and report) or `block` (refuse before running).
5
+ // Built-in hardening rules for catastrophic host-level commands. The default is `block`:
6
+ // fresh installations refuse these commands before execution. Operators can explicitly choose
7
+ // `warn` (run and report) or `allow` (disable this built-in guardrail); unrestricted/godmode
8
+ // also forces `allow` and remains a local-only opt-in.
9
9
  //
10
10
  // Rules are matched against the *command word* of each shell segment, so a read-only
11
11
  // command that merely mentions a dangerous word (`grep -n format README.md`,