@remcp/runtime 0.2.109 → 0.2.111
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 +11 -0
- package/README.md +9 -4
- package/package.json +1 -1
- package/src/extended/desktop-linux.mjs +70 -12
- package/src/extended/documents.mjs +4 -3
- package/src/tools/files.mjs +52 -15
- package/src/util.mjs +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.111 — 2026-09-25
|
|
4
|
+
|
|
5
|
+
- `read_document` reads `.xlsx` files again while `allowedRoots` confinement is enabled: the reader no longer re-enters its own temp staging path through the allowlist.
|
|
6
|
+
- Linux window inventory refreshes X11 rows after the AT-SPI walk, window selectors resolve X11/XWayland targets without a full semantic rebuild, and `window_action focus` verifies direct XWayland activation before falling back to portal cycling. Runtime tool names and schemas are unchanged.
|
|
7
|
+
|
|
8
|
+
## 0.2.110 — 2026-09-25
|
|
9
|
+
|
|
10
|
+
- Bulk handlers (`read_multiple_files`, `read_files`, `replace_in_files`, `write_files`, `copy_paths`, `move_paths`, `delete_paths`, `create_archive`) and the directory walk now honour client cancellation between items, so an aborted call stops at the next item boundary instead of finishing on the device; single-file and external-tool (`tar`/`unzip`) operations remain atomic and are not interrupted mid-step.
|
|
11
|
+
- A file with more than one hard link now produces one warning per path: path-based confinement cannot see a shared inode, and refusing would break backup and dotfile layouts.
|
|
12
|
+
- Runtime tool names, input schemas and output schemas are unchanged.
|
|
13
|
+
|
|
3
14
|
## 0.2.109 — 2026-09-25
|
|
4
15
|
|
|
5
16
|
- Linux file reads and recursive permission changes retain descriptor-bound parent traversal for the full operation; proc-fd anchored captures remain readable without reopening the path lexically.
|
package/README.md
CHANGED
|
@@ -99,10 +99,10 @@ UI actions and the XDG Screenshot portal remain separate from that low-level inp
|
|
|
99
99
|
- file writes replace by default (`mode: "append"` to add), moves and copies replace the destination
|
|
100
100
|
(`overwrite: false` refuses instead), `replace_in_files` applies immediately (`dry_run: true`
|
|
101
101
|
previews), and `move_to_trash` is there when you want an undo;
|
|
102
|
-
- the destructive-command guardrail defaults to `
|
|
103
|
-
when it matches the
|
|
104
|
-
|
|
105
|
-
|
|
102
|
+
- the destructive-command guardrail defaults to `block`: a catastrophic command is refused before it
|
|
103
|
+
runs when it matches the list (`mkfs`, raw device writes, repartitioning, host power control, fork
|
|
104
|
+
bombs, recursive root deletion). `warn` downgrades that to a note on the result, `allow` removes
|
|
105
|
+
even the note, and `blockedCommands` adds your own deny list;
|
|
106
106
|
- `allowedRoots` is empty, so the device reaches everything the agent's account can reach. Set it to
|
|
107
107
|
confine a device to specific directories, enforced against the resolved real path.
|
|
108
108
|
|
|
@@ -169,6 +169,11 @@ in `~/.config/remcp/runtime.json`. `--describe` always reports the current state
|
|
|
169
169
|
descriptor-bound directory traversal on Linux. macOS and Windows preserve the same bounded snapshot,
|
|
170
170
|
link/special-file rejection, canonical root confinement and atomic staging semantics, while using their
|
|
171
171
|
portable path APIs where Node does not expose directory-fd-relative open/rename/unlink primitives.
|
|
172
|
+
Two consequences are worth knowing: on macOS and Windows `set_permissions` re-resolves each path
|
|
173
|
+
and then chmods it, so a component swap between those two steps is not descriptor-bound, and a file
|
|
174
|
+
with more than one hard link (`st_nlink > 1`) is reachable from outside the allowed roots no matter
|
|
175
|
+
which path was used. The runtime warns once per hard-linked path instead of refusing it, because
|
|
176
|
+
backups and dotfile managers create those layouts legitimately.
|
|
172
177
|
- **Crash-resistant sessions.** A bogus shell, a closed stdin, or a dead parent cannot take the
|
|
173
178
|
runtime down; sessions run in their own process group so `force_terminate` stops a whole pipeline;
|
|
174
179
|
the agent restarts the runtime if it ever exits, so a device recovers instead of going silently
|
package/package.json
CHANGED
|
@@ -55,7 +55,7 @@ async function x11PixelBounds(id) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
export async function listWindows() {
|
|
58
|
-
|
|
58
|
+
let wmRows = commandExists('wmctrl')
|
|
59
59
|
? parseWindows((await runFile('wmctrl', ['-lpGx'], { label: 'window inventory', allowFailure:true, timeout:3000 })).stdout)
|
|
60
60
|
: [];
|
|
61
61
|
if (!isWaylandSession()) {
|
|
@@ -85,6 +85,15 @@ export async function listWindows() {
|
|
|
85
85
|
});
|
|
86
86
|
} catch {}
|
|
87
87
|
|
|
88
|
+
// AT-SPI enumeration can take several seconds on a busy desktop. Refresh the cheap
|
|
89
|
+
// X11 inventory afterwards so an XWayland window that appeared while AT-SPI was
|
|
90
|
+
// walking the tree is merged with its semantic window instead of being misclassified
|
|
91
|
+
// as native Wayland for the rest of this call.
|
|
92
|
+
if (commandExists('wmctrl')) {
|
|
93
|
+
const refreshedWmRows = parseWindows((await runFile('wmctrl', ['-lpGx'], { label:'window inventory refresh', allowFailure:true, timeout:3000 })).stdout);
|
|
94
|
+
if (refreshedWmRows.length) wmRows = refreshedWmRows;
|
|
95
|
+
}
|
|
96
|
+
|
|
88
97
|
const pixelBounds = new Map();
|
|
89
98
|
if (commandExists('xwininfo')) {
|
|
90
99
|
// wmctrl geometry on Mutter/XWayland is decoration-shifted and cannot be used for pixel crops.
|
|
@@ -151,18 +160,50 @@ export async function listWindows() {
|
|
|
151
160
|
return jsonResult(rows);
|
|
152
161
|
}
|
|
153
162
|
|
|
163
|
+
function windowSelector(args = {}) {
|
|
164
|
+
return {
|
|
165
|
+
id: optionalString(args.id || args.window_id || args.windowId),
|
|
166
|
+
pid: Number.isInteger(Number(args.pid)) ? Number(args.pid) : null,
|
|
167
|
+
app: optionalString(args.app),
|
|
168
|
+
title: optionalString(args.title || args.window_title || args.windowTitle),
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function windowMatchesSelector(item, selector) {
|
|
173
|
+
return (!selector.id || item.id === selector.id || item.wm_id === selector.id || item.ui_id === selector.id)
|
|
174
|
+
&& (selector.pid == null || Number(item.pid) === selector.pid)
|
|
175
|
+
&& (!selector.app || String(item.app || '').toLowerCase().includes(selector.app.toLowerCase()))
|
|
176
|
+
&& (!selector.title || String(item.title || '').toLowerCase().includes(selector.title.toLowerCase()));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
async function fastX11WindowMatch(args) {
|
|
180
|
+
if (!commandExists('wmctrl')) return null;
|
|
181
|
+
const selector = windowSelector(args);
|
|
182
|
+
const rows = parseWindows((await runFile('wmctrl', ['-lpGx'], {
|
|
183
|
+
label:'window match',
|
|
184
|
+
allowFailure:true,
|
|
185
|
+
timeout:2000,
|
|
186
|
+
})).stdout);
|
|
187
|
+
const row = rows.find(item => windowMatchesSelector({ ...item, wm_id:item.id, ui_id:null }, selector));
|
|
188
|
+
if (!row) return null;
|
|
189
|
+
return {
|
|
190
|
+
...row,
|
|
191
|
+
wm_id:row.id,
|
|
192
|
+
ui_id:null,
|
|
193
|
+
backend:isWaylandSession() ? 'xwayland' : 'x11',
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
154
197
|
async function windowMatch(args) {
|
|
198
|
+
// Most desktop actions target an X11/XWayland window. Resolve that cheap native id
|
|
199
|
+
// first instead of rebuilding the entire AT-SPI inventory before every action.
|
|
200
|
+
// Native Wayland windows have no wmctrl row and fall through to the semantic inventory.
|
|
201
|
+
const fast = await fastX11WindowMatch(args);
|
|
202
|
+
if (fast) return fast;
|
|
203
|
+
|
|
155
204
|
const rows = JSON.parse((await listWindows()).content[0].text);
|
|
156
|
-
const
|
|
157
|
-
const
|
|
158
|
-
const app = optionalString(args.app);
|
|
159
|
-
const title = optionalString(args.title || args.window_title || args.windowTitle);
|
|
160
|
-
const row = rows.find(item =>
|
|
161
|
-
(!id || item.id === id || item.wm_id === id || item.ui_id === id)
|
|
162
|
-
&& (pid == null || item.pid === pid)
|
|
163
|
-
&& (!app || String(item.app || '').toLowerCase().includes(app.toLowerCase()))
|
|
164
|
-
&& (!title || String(item.title || '').toLowerCase().includes(title.toLowerCase()))
|
|
165
|
-
);
|
|
205
|
+
const selector = windowSelector(args);
|
|
206
|
+
const row = rows.find(item => windowMatchesSelector(item, selector));
|
|
166
207
|
if (!row) throw new Error('No matching window found');
|
|
167
208
|
return row;
|
|
168
209
|
}
|
|
@@ -502,7 +543,24 @@ export async function windowAction(args) {
|
|
|
502
543
|
if (focusState.exact) return text('Window action focus completed via verified AT-SPI focus.');
|
|
503
544
|
} catch {}
|
|
504
545
|
}
|
|
505
|
-
|
|
546
|
+
// XWayland windows have a real X11 wm_id even inside a Wayland session. Prefer
|
|
547
|
+
// direct activation for those windows, verify the result through accessibility,
|
|
548
|
+
// and only fall back to compositor-level cycling if direct activation did not take.
|
|
549
|
+
// This avoids dozens of Alt+Esc/AT-SPI round trips on busy desktops.
|
|
550
|
+
if (row.wm_id && backend !== 'portal' && commandExists('wmctrl')) {
|
|
551
|
+
const activated = await runFile('wmctrl', ['-ia', row.wm_id], { label:'XWayland window focus', allowFailure:true, timeout:2000 });
|
|
552
|
+
if (activated.code === 0) {
|
|
553
|
+
if (commandExists('xdotool')) {
|
|
554
|
+
await runFile('xdotool', ['windowactivate','--sync',row.wm_id], { label:'XWayland window focus sync', allowFailure:true, timeout:3000 });
|
|
555
|
+
}
|
|
556
|
+
await new Promise(resolve => setTimeout(resolve, 80));
|
|
557
|
+
const focusState = await targetAccessibilityFocus(row);
|
|
558
|
+
if (focusState.exact) return text('Window action focus completed via verified XWayland activation.');
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (backend === 'x11') unavailable('Wayland window focus', row.wm_id
|
|
562
|
+
? 'direct XWayland activation did not produce verifiable focus'
|
|
563
|
+
: 'the target is native Wayland and has no X11 window id; use backend=portal');
|
|
506
564
|
if (!waylandPortalCandidate()) unavailable('Wayland window focus', 'no XDG RemoteDesktop portal is available');
|
|
507
565
|
if (backend === 'auto' && !hasWaylandRemoteDesktopGrant()) portalPermissionHint('Wayland window focus');
|
|
508
566
|
const portalOptions = { timeoutMs: backend === 'portal' ? 120_000 : 2500 };
|
|
@@ -334,10 +334,12 @@ function readWorksheetCells(xml, sharedStrings, maxCells) {
|
|
|
334
334
|
}
|
|
335
335
|
|
|
336
336
|
async function readXlsx(filePath, args) {
|
|
337
|
-
const source = await snapshotDocumentFile(filePath);
|
|
338
337
|
const dir = await tempDir('remcp-xlsx-read-');
|
|
339
338
|
try {
|
|
340
|
-
|
|
339
|
+
// extractZip already snapshots the user-approved source into a private temp file
|
|
340
|
+
// before invoking unzip. A second snapshot here used to re-open ReMCP's own temp
|
|
341
|
+
// path through allowedRoots, so read_document failed whenever confinement was on.
|
|
342
|
+
await extractZip(filePath, dir);
|
|
341
343
|
const workbook = await readFile(path.join(dir,'xl','workbook.xml'),'utf8');
|
|
342
344
|
const rels = await readFile(path.join(dir,'xl','_rels','workbook.xml.rels'),'utf8');
|
|
343
345
|
const sheet = workbookSheetPath(workbook, rels, optionalString(args.sheet));
|
|
@@ -347,7 +349,6 @@ async function readXlsx(filePath, args) {
|
|
|
347
349
|
const cells = readWorksheetCells(worksheet, shared, clamp(args.max_cells,5000,1,50_000));
|
|
348
350
|
return { kind:'xlsx', sheet:sheet.name, count:cells.length, cells };
|
|
349
351
|
} finally {
|
|
350
|
-
await removeTemp(source.dir);
|
|
351
352
|
await removeTemp(dir);
|
|
352
353
|
}
|
|
353
354
|
}
|
package/src/tools/files.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
|
|
|
12
12
|
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
13
13
|
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
14
14
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
15
|
-
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
15
|
+
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
|
|
16
16
|
|
|
17
17
|
const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
|
|
18
18
|
// An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
|
|
@@ -283,10 +283,26 @@ async function openEntryAtPath(absolute, flags) {
|
|
|
283
283
|
return { handle, close: () => handle.close() };
|
|
284
284
|
}
|
|
285
285
|
|
|
286
|
+
// A hard link inside an allowed root is the one escape path-based confinement cannot see: the very
|
|
287
|
+
// same inode is reachable from outside the root, so a read or write here is visible there (round 2
|
|
288
|
+
// audit R2-14). The allowlist is a guardrail, not an inode boundary, so this warns instead of
|
|
289
|
+
// refusing — refusing would break legitimate layouts (backups, dotfile managers, deduplicated
|
|
290
|
+
// stores). One warning per path keeps a hot loop from flooding the log.
|
|
291
|
+
const hardlinkWarned = new Set();
|
|
292
|
+
|
|
293
|
+
function warnOnHardlink(label, info) {
|
|
294
|
+
if (!Number.isFinite(info?.nlink) || info.nlink <= 1 || hardlinkWarned.has(label)) return;
|
|
295
|
+
if (hardlinkWarned.size > 1000) hardlinkWarned.clear();
|
|
296
|
+
hardlinkWarned.add(label);
|
|
297
|
+
countEvent('hardlinkWarnings');
|
|
298
|
+
console.warn(`ReMCP runtime: ${label} has ${info.nlink} hard links, so the same file is reachable from outside the allowed roots`);
|
|
299
|
+
}
|
|
300
|
+
|
|
286
301
|
async function openRegularFileAtPath(absolute, flags, label = absolute) {
|
|
287
302
|
const opened = await openEntryAtPath(absolute, flags);
|
|
288
303
|
try {
|
|
289
304
|
const info = assertRegularFile(await opened.handle.stat(), label);
|
|
305
|
+
warnOnHardlink(label, info);
|
|
290
306
|
return { handle: opened.handle, info, close: opened.close };
|
|
291
307
|
} catch (error) {
|
|
292
308
|
await opened.close().catch(() => {});
|
|
@@ -388,7 +404,7 @@ async function overwriteOpenFile(handle, content) {
|
|
|
388
404
|
// anywhere, so links are never followed and each collected path is resolved through
|
|
389
405
|
// resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
|
|
390
406
|
// how a symlinked directory inside a root used to expose files outside it.
|
|
391
|
-
async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
407
|
+
async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
|
|
392
408
|
const found = [];
|
|
393
409
|
const denied = [];
|
|
394
410
|
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
@@ -401,6 +417,7 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
|
401
417
|
return;
|
|
402
418
|
}
|
|
403
419
|
for (const entry of entries) {
|
|
420
|
+
throwIfCancelled(signal);
|
|
404
421
|
if (found.length >= maxFiles) return;
|
|
405
422
|
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
406
423
|
const childLogical = path.join(logicalBase, entry.name);
|
|
@@ -437,6 +454,8 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
|
437
454
|
directory = await openDirectoryPath(root);
|
|
438
455
|
await visitDirectory(directory, root);
|
|
439
456
|
} catch {
|
|
457
|
+
// A cancelled walk must not degrade into the "root is a single file" fallback below.
|
|
458
|
+
throwIfCancelled(signal);
|
|
440
459
|
let opened;
|
|
441
460
|
try {
|
|
442
461
|
opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
|
|
@@ -509,11 +528,12 @@ ${page.slice.join('\n')}`);
|
|
|
509
528
|
return text(`${header}\n${slice.join('\n')}`);
|
|
510
529
|
}
|
|
511
530
|
|
|
512
|
-
export async function readMultipleFilesTool(args) {
|
|
531
|
+
export async function readMultipleFilesTool(args, extra = {}) {
|
|
513
532
|
if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
|
|
514
533
|
if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
|
|
515
534
|
const sections = [];
|
|
516
535
|
for (const entry of args.paths) {
|
|
536
|
+
throwIfCancelled(extra.signal);
|
|
517
537
|
let absolute;
|
|
518
538
|
try {
|
|
519
539
|
absolute = await resolveSafePath(entry, 'paths[]');
|
|
@@ -895,7 +915,7 @@ export async function replaceLinesTool(args) {
|
|
|
895
915
|
return text(`${summary}.`);
|
|
896
916
|
}
|
|
897
917
|
|
|
898
|
-
export async function replaceInFilesTool(args) {
|
|
918
|
+
export async function replaceInFilesTool(args, extra = {}) {
|
|
899
919
|
const root = await resolveSafePath(args.path);
|
|
900
920
|
const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
|
|
901
921
|
const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
|
|
@@ -912,12 +932,13 @@ export async function replaceInFilesTool(args) {
|
|
|
912
932
|
}
|
|
913
933
|
}
|
|
914
934
|
const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
|
|
915
|
-
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
|
|
935
|
+
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
|
|
916
936
|
const files = await confineAll(walk.files);
|
|
917
937
|
const glob = filePattern ? globToRegExp(filePattern) : null;
|
|
918
938
|
const changed = [];
|
|
919
939
|
let scanned = 0;
|
|
920
940
|
for (const file of files) {
|
|
941
|
+
throwIfCancelled(extra.signal);
|
|
921
942
|
if (changed.length >= maxFiles) break;
|
|
922
943
|
if (glob && !glob.test(path.basename(file))) continue;
|
|
923
944
|
let opened;
|
|
@@ -1011,7 +1032,7 @@ export async function moveToTrashTool(args) {
|
|
|
1011
1032
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
1012
1033
|
}
|
|
1013
1034
|
|
|
1014
|
-
export async function readFilesTool(args) {
|
|
1035
|
+
export async function readFilesTool(args, extra = {}) {
|
|
1015
1036
|
// Glob-first bulk read: one call fills the model's context with every file that matters
|
|
1016
1037
|
// instead of one round trip per path.
|
|
1017
1038
|
const root = await resolveSafePath(args.path || '.');
|
|
@@ -1027,6 +1048,7 @@ export async function readFilesTool(args) {
|
|
|
1027
1048
|
: await collectTree(root, {
|
|
1028
1049
|
maxFiles: maxFiles + 1,
|
|
1029
1050
|
skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
|
|
1051
|
+
signal: extra.signal,
|
|
1030
1052
|
});
|
|
1031
1053
|
const candidates = await confineAll(walk.files);
|
|
1032
1054
|
const matched = candidates.filter(target => {
|
|
@@ -1038,6 +1060,7 @@ export async function readFilesTool(args) {
|
|
|
1038
1060
|
const sections = [];
|
|
1039
1061
|
let skipped = 0;
|
|
1040
1062
|
for (const file of files.slice(0, maxFiles)) {
|
|
1063
|
+
throwIfCancelled(extra.signal);
|
|
1041
1064
|
try {
|
|
1042
1065
|
const { content, encoding } = await readTextFile(file);
|
|
1043
1066
|
const lines = splitLines(content);
|
|
@@ -1057,7 +1080,7 @@ export async function readFilesTool(args) {
|
|
|
1057
1080
|
return text(`${header}\n\n${sections.join('\n\n')}`);
|
|
1058
1081
|
}
|
|
1059
1082
|
|
|
1060
|
-
export async function writeFilesTool(args) {
|
|
1083
|
+
export async function writeFilesTool(args, extra = {}) {
|
|
1061
1084
|
// Bulk write for scaffolding: one call creates or replaces many files.
|
|
1062
1085
|
const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
|
|
1063
1086
|
if (!files.length) fail('files must not be empty');
|
|
@@ -1065,6 +1088,7 @@ export async function writeFilesTool(args) {
|
|
|
1065
1088
|
const results = [];
|
|
1066
1089
|
let totalBytes = 0;
|
|
1067
1090
|
for (const entry of files) {
|
|
1091
|
+
throwIfCancelled(extra.signal);
|
|
1068
1092
|
const target = typeof entry?.path === 'string' ? entry.path : null;
|
|
1069
1093
|
if (!target) { results.push('skipped: entry without a path'); continue; }
|
|
1070
1094
|
if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
|
|
@@ -1104,7 +1128,7 @@ export async function deletePathTool(args) {
|
|
|
1104
1128
|
return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
|
|
1105
1129
|
}
|
|
1106
1130
|
|
|
1107
|
-
export async function deletePathsTool(args) {
|
|
1131
|
+
export async function deletePathsTool(args, extra = {}) {
|
|
1108
1132
|
const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
|
|
1109
1133
|
if (!paths.length) fail('paths must not be empty');
|
|
1110
1134
|
if (paths.length > 500) fail('paths accepts at most 500 entries per call');
|
|
@@ -1112,6 +1136,7 @@ export async function deletePathsTool(args) {
|
|
|
1112
1136
|
const results = [];
|
|
1113
1137
|
let deleted = 0;
|
|
1114
1138
|
for (const entry of paths) {
|
|
1139
|
+
throwIfCancelled(extra.signal);
|
|
1115
1140
|
try {
|
|
1116
1141
|
const absolute = await resolveSafePath(entry, 'paths[]');
|
|
1117
1142
|
if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
|
|
@@ -1135,7 +1160,7 @@ export async function deletePathsTool(args) {
|
|
|
1135
1160
|
|
|
1136
1161
|
// Recursive copy for files and whole directories, so a project or a backup can be
|
|
1137
1162
|
// duplicated in one call.
|
|
1138
|
-
export async function copyPathsTool(args) {
|
|
1163
|
+
export async function copyPathsTool(args, extra = {}) {
|
|
1139
1164
|
const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
|
|
1140
1165
|
if (!pairs.length) fail('paths must not be empty');
|
|
1141
1166
|
if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
|
|
@@ -1143,6 +1168,7 @@ export async function copyPathsTool(args) {
|
|
|
1143
1168
|
const results = [];
|
|
1144
1169
|
let copied = 0;
|
|
1145
1170
|
for (const entry of pairs) {
|
|
1171
|
+
throwIfCancelled(extra.signal);
|
|
1146
1172
|
try {
|
|
1147
1173
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
1148
1174
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
@@ -1150,7 +1176,7 @@ export async function copyPathsTool(args) {
|
|
|
1150
1176
|
await withWritableParent(destination, async anchoredDestination => {
|
|
1151
1177
|
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1152
1178
|
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
1153
|
-
await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1179
|
+
await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
|
|
1154
1180
|
});
|
|
1155
1181
|
copied += 1;
|
|
1156
1182
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
@@ -1161,7 +1187,7 @@ export async function copyPathsTool(args) {
|
|
|
1161
1187
|
return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
|
|
1162
1188
|
}
|
|
1163
1189
|
|
|
1164
|
-
export async function movePathsTool(args) {
|
|
1190
|
+
export async function movePathsTool(args, extra = {}) {
|
|
1165
1191
|
const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
|
|
1166
1192
|
if (!pairs.length) fail('paths must not be empty');
|
|
1167
1193
|
if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
|
|
@@ -1169,6 +1195,7 @@ export async function movePathsTool(args) {
|
|
|
1169
1195
|
const results = [];
|
|
1170
1196
|
let moved = 0;
|
|
1171
1197
|
for (const entry of pairs) {
|
|
1198
|
+
throwIfCancelled(extra.signal);
|
|
1172
1199
|
try {
|
|
1173
1200
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
1174
1201
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
@@ -1184,7 +1211,7 @@ export async function movePathsTool(args) {
|
|
|
1184
1211
|
await rename(anchoredSource, anchoredDestination);
|
|
1185
1212
|
} catch (error) {
|
|
1186
1213
|
if (error?.code !== 'EXDEV') throw error;
|
|
1187
|
-
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1214
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
|
|
1188
1215
|
await rm(anchoredSource, { recursive:true, force:true });
|
|
1189
1216
|
}
|
|
1190
1217
|
});
|
|
@@ -1289,6 +1316,11 @@ export async function setPermissionsTool(args) {
|
|
|
1289
1316
|
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
1290
1317
|
changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
|
|
1291
1318
|
} else {
|
|
1319
|
+
// macOS/Windows fallback: Node exposes no directory-fd-relative chmod/chown there, so this
|
|
1320
|
+
// branch re-resolves each path lexically (confineAll) and then acts on the path. Between those
|
|
1321
|
+
// two steps another process can swap a component, so confinement is a guardrail on this
|
|
1322
|
+
// platform rather than a descriptor-bound guarantee — the same residual the archive/document
|
|
1323
|
+
// staging path documents. Linux uses the descriptor-bound path above.
|
|
1292
1324
|
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
1293
1325
|
const targets = [absolute];
|
|
1294
1326
|
if (recursive && info.isDirectory()) {
|
|
@@ -1548,6 +1580,7 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
|
|
|
1548
1580
|
}
|
|
1549
1581
|
|
|
1550
1582
|
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1583
|
+
throwIfCancelled(state.signal);
|
|
1551
1584
|
const initial = await lstat(source);
|
|
1552
1585
|
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
1553
1586
|
state.entries += 1;
|
|
@@ -1749,12 +1782,15 @@ function archiveTool() {
|
|
|
1749
1782
|
return { tar: probe('tar'), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
|
|
1750
1783
|
}
|
|
1751
1784
|
|
|
1752
|
-
export async function createArchiveTool(args) {
|
|
1785
|
+
export async function createArchiveTool(args, extra = {}) {
|
|
1753
1786
|
const tools = archiveTool();
|
|
1754
1787
|
const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
|
|
1755
1788
|
if (!sources.length) fail('paths must list at least one file or directory');
|
|
1756
1789
|
const resolved = [];
|
|
1757
|
-
for (const entry of sources)
|
|
1790
|
+
for (const entry of sources) {
|
|
1791
|
+
throwIfCancelled(extra.signal);
|
|
1792
|
+
resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
1793
|
+
}
|
|
1758
1794
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1759
1795
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1760
1796
|
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
@@ -1774,8 +1810,9 @@ export async function createArchiveTool(args) {
|
|
|
1774
1810
|
try {
|
|
1775
1811
|
await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
|
|
1776
1812
|
|
|
1777
|
-
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1813
|
+
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES, signal: extra.signal };
|
|
1778
1814
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1815
|
+
throwIfCancelled(extra.signal);
|
|
1779
1816
|
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1780
1817
|
await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
|
|
1781
1818
|
|
package/src/util.mjs
CHANGED
|
@@ -9,6 +9,14 @@ export function fail(message) {
|
|
|
9
9
|
throw new ToolError(String(message));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
// Cooperative cancellation for handlers that loop over many items. The MCP layer already rejects
|
|
13
|
+
// the call to the client on abort, so the job here is to stop the device-side side effects: a bulk
|
|
14
|
+
// read/write/archive that ignored the signal kept mutating files long after the client gave up.
|
|
15
|
+
// Long handlers call this between items, so a cancel lands at the next item boundary.
|
|
16
|
+
export function throwIfCancelled(signal) {
|
|
17
|
+
if (signal?.aborted) fail('Cancelled by the client; stopped before the next item.');
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
export function expandHome(value) {
|
|
13
21
|
const text = String(value ?? '');
|
|
14
22
|
if (text === '~') return os.homedir();
|