@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 +15 -0
- package/README.md +5 -5
- package/package.json +1 -1
- package/src/config.mjs +3 -4
- package/src/extended/desktop-linux.mjs +4 -0
- package/src/extended/desktop-macos.mjs +145 -10
- package/src/extended/desktop.mjs +17 -7
- package/src/policy.mjs +4 -4
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 `
|
|
151
|
-
|
|
152
|
-
*command word* of each shell segment, so
|
|
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": "
|
|
185
|
+
"dangerousCommands": "block",
|
|
186
186
|
"telemetryEnabled": true,
|
|
187
187
|
"maxOutputBytes": 1048576,
|
|
188
188
|
"maxReadLines": 2000,
|
package/package.json
CHANGED
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
|
-
// `
|
|
65
|
-
//
|
|
66
|
-
|
|
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
|
-
|
|
47
|
-
|
|
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
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
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)
|
|
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];
|
package/src/extended/desktop.mjs
CHANGED
|
@@ -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
|
-
|
|
377
|
-
|
|
378
|
-
(
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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`,
|