@remcp/runtime 0.2.153 → 0.2.155

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.
@@ -1,2111 +1,8 @@
1
- import path from 'node:path';
2
- import os from 'node:os';
3
- import { spawnSync } from 'node:child_process';
4
- import { createHash, randomUUID } from 'node:crypto';
5
- import { constants, createReadStream } from 'node:fs';
6
- import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
7
- import { pipeline } from 'node:stream/promises';
8
- import { liveConfig, runtimeConfig } from '../config.mjs';
9
- import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
10
- import { diffStats, unifiedDiff } from '../diff.mjs';
11
- import { describeFilesystemFailure } from '../permissions.mjs';
12
- import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
13
- import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
14
- import { countEvent, recordEvent } from '../telemetry.mjs';
15
- import { clampInteger, decodeText, displayPath, fail, fitsOutput, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text, throwIfCancelled } from '../util.mjs';
16
-
17
- const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
18
- // An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
19
- // 24 MiB and the relay's WebSocket frames hold 32 MiB, so 8 MiB of image is ~11 MiB on the wire with
20
- // room to spare; anything larger goes through read_binary in chunks instead. Binary chunks are 4 MiB
21
- // for the same reason: four times fewer round trips for the same file.
22
- const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
23
- const MAX_BINARY_CHUNK_BYTES = 1024 * 1024;
24
- const IMAGE_TYPES = new Map([
25
- ['.png', 'image/png'], ['.jpg', 'image/jpeg'], ['.jpeg', 'image/jpeg'], ['.gif', 'image/gif'],
26
- ['.webp', 'image/webp'], ['.bmp', 'image/bmp'], ['.svg', 'image/svg+xml'], ['.avif', 'image/avif'],
27
- ]);
28
-
29
- function detectEol(content) {
30
- const crlf = (content.match(/\r\n/g) || []).length;
31
- const lf = (content.match(/(?<!\r)\n/g) || []).length;
32
- return crlf > lf ? '\r\n' : '\n';
33
- }
34
-
35
- // Only regular files can be read: a FIFO blocks until a writer appears, a device node can be
36
- // endless, and both would hang or flood a tool call instead of returning an answer.
37
- function assertRegularFile(info, absolute) {
38
- if (info.isDirectory()) fail(`${displayPath(absolute)} is a directory, not a file`);
39
- if (!info.isFile()) fail(`${displayPath(absolute)} is not a regular file`);
40
- return info;
41
- }
42
-
43
- const NOFOLLOW = constants.O_NOFOLLOW || 0;
44
- const NONBLOCK = constants.O_NONBLOCK || 0;
45
- const READ_NOFOLLOW = constants.O_RDONLY | NOFOLLOW;
46
- const READ_NOFOLLOW_NONBLOCK = READ_NOFOLLOW | NONBLOCK;
47
- const READWRITE_NOFOLLOW = constants.O_RDWR | NOFOLLOW;
48
- const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK;
49
- const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
50
- const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
51
-
52
- async function openDirectoryFromFilesystemRoot(absolute) {
53
- const root = path.parse(absolute).root;
54
- const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
55
- const directories = [];
56
- let current = root;
57
- const closeDirectories = async () => {
58
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
59
- };
60
- try {
61
- const rootHandle = await open(root, DIRECTORY_READ_NOFOLLOW);
62
- directories.push(rootHandle);
63
- current = `/proc/self/fd/${rootHandle.fd}`;
64
- for (const part of parts) {
65
- const candidate = path.join(current, part);
66
- const next = await open(candidate, DIRECTORY_READ_NOFOLLOW);
67
- try {
68
- const info = await next.stat();
69
- if (!info.isDirectory()) fail('Filesystem path component is not a directory');
70
- directories.push(next);
71
- current = `/proc/self/fd/${next.fd}`;
72
- } catch (error) {
73
- await next.close().catch(() => {});
74
- throw error;
75
- }
76
- }
77
- const handle = directories.pop();
78
- return {
79
- handle,
80
- anchor: current,
81
- close: async () => {
82
- try { await handle.close(); }
83
- finally { await closeDirectories(); }
84
- },
85
- };
86
- } catch (error) {
87
- await closeDirectories();
88
- throw error;
89
- }
90
- }
91
-
92
- const secureRootPromises = new Map();
93
-
94
- async function secureAllowedRoot(root) {
95
- if (!secureRootPromises.has(root)) {
96
- secureRootPromises.set(root, openDirectoryFromFilesystemRoot(root));
97
- }
98
- try {
99
- return await secureRootPromises.get(root);
100
- } catch (error) {
101
- secureRootPromises.delete(root);
102
- throw error;
103
- }
104
- }
105
-
106
- async function cloneDirectory(directory) {
107
- const handle = await open(`${directory.anchor}/.`, DIRECTORY_READ_NOFOLLOW);
108
- return {
109
- handle,
110
- anchor: `/proc/self/fd/${handle.fd}`,
111
- close: () => handle.close(),
112
- };
113
- }
114
-
115
- async function openConfinedDirectoryPath(absolute, create) {
116
- if (!runtimeConfig.allowedRoots.length || absolute.startsWith('/proc/')) return null;
117
- const root = runtimeConfig.allowedRoots.find(candidate => isInsideRoot(absolute, candidate));
118
- if (!root) return null;
119
- const relative = path.relative(root, absolute);
120
- if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
121
- const rootDirectory = await secureAllowedRoot(root);
122
- const directories = [];
123
- let current = rootDirectory.anchor;
124
- const closeDirectories = async () => {
125
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
126
- };
127
- try {
128
- for (const part of relative.split(path.sep).filter(Boolean)) {
129
- const candidate = path.join(current, part);
130
- if (create) {
131
- await mkdir(candidate, { mode:0o700 }).catch(error => {
132
- if (error?.code !== 'EEXIST') throw error;
133
- });
134
- }
135
- const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
136
- const info = await handle.stat();
137
- if (!info.isDirectory()) {
138
- await handle.close().catch(() => {});
139
- fail('Filesystem path component is not a directory');
140
- }
141
- directories.push(handle);
142
- current = `/proc/self/fd/${handle.fd}`;
143
- }
144
- if (!directories.length) return cloneDirectory(rootDirectory);
145
- const handle = directories.pop();
146
- return {
147
- handle,
148
- anchor: current,
149
- close: async () => {
150
- try { await handle.close(); }
151
- finally { await closeDirectories(); }
152
- },
153
- };
154
- } catch (error) {
155
- await closeDirectories();
156
- throw error;
157
- }
158
- }
159
-
160
- async function openDescriptorDirectoryPath(absolute, create) {
161
- const match = absolute.match(/^(\/proc\/(?:self|\d+)\/fd\/\d+)(?:\/(.*))?$/);
162
- if (!match) return null;
163
- // The proc-fd entry itself is a symlink. Make it an intermediate component so
164
- // O_NOFOLLOW protects the referenced directory entry rather than rejecting procfs.
165
- const base = await open(`${match[1]}/.`, DIRECTORY_READ_NOFOLLOW);
166
- const directories = [base];
167
- let current = `/proc/self/fd/${base.fd}`;
168
- const closeDirectories = async () => {
169
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
170
- };
171
- try {
172
- for (const part of (match[2] || '').split(path.sep).filter(Boolean)) {
173
- const candidate = path.join(current, part);
174
- if (create) {
175
- await mkdir(candidate, { mode:0o700 }).catch(error => {
176
- if (error?.code !== 'EEXIST') throw error;
177
- });
178
- }
179
- const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
180
- const info = await handle.stat();
181
- if (!info.isDirectory()) {
182
- await handle.close().catch(() => {});
183
- fail('Filesystem path component is not a directory');
184
- }
185
- directories.push(handle);
186
- current = `/proc/self/fd/${handle.fd}`;
187
- }
188
- const handle = directories.pop();
189
- return {
190
- handle,
191
- anchor: current,
192
- close: async () => {
193
- try { await handle.close(); }
194
- finally { await closeDirectories(); }
195
- },
196
- };
197
- } catch (error) {
198
- await closeDirectories();
199
- throw error;
200
- }
201
- }
202
-
203
- export async function openDirectoryPath(absolute, { create = false, allowOutside = false } = {}) {
204
- const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
205
- if (!descriptorBound) {
206
- if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
207
- const info = await lstat(absolute);
208
- if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a safe directory');
209
- const handle = {
210
- stat: () => stat(absolute),
211
- chmod: mode => chmod(absolute, mode),
212
- close: async () => {},
213
- };
214
- return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
215
- }
216
- const confinedPath = await openConfinedDirectoryPath(absolute, create);
217
- if (confinedPath) return confinedPath;
218
- const descriptorPath = await openDescriptorDirectoryPath(absolute, create);
219
- if (descriptorPath) return descriptorPath;
220
- if (runtimeConfig.allowedRoots.length && !allowOutside) fail('Path is outside the directories this device allows');
221
- const root = path.parse(absolute).root;
222
- const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
223
- if (!parts.length) {
224
- const handle = await open(root, DIRECTORY_READ_NOFOLLOW);
225
- return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
226
- }
227
- const directories = [];
228
- let current = root;
229
- const closeDirectories = async () => {
230
- for (const handle of directories.reverse()) await handle.close().catch(() => {});
231
- };
232
- try {
233
- for (const part of parts) {
234
- const candidate = path.join(current, part);
235
- if (create) {
236
- await mkdir(candidate, { mode:0o700 }).catch(error => {
237
- if (error?.code !== 'EEXIST') throw error;
238
- });
239
- }
240
- const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
241
- const info = await handle.stat();
242
- if (!info.isDirectory()) {
243
- await handle.close().catch(() => {});
244
- fail('Filesystem path component is not a directory');
245
- }
246
- directories.push(handle);
247
- current = `/proc/self/fd/${handle.fd}`;
248
- }
249
- const handle = directories.pop();
250
- return {
251
- handle,
252
- anchor: current,
253
- close: async () => {
254
- try { await handle.close(); }
255
- finally { await closeDirectories(); }
256
- },
257
- };
258
- } catch (error) {
259
- await closeDirectories();
260
- throw error;
261
- }
262
- }
263
-
264
- async function openEntryAtPath(absolute, flags) {
265
- const descriptorFile = /^\/proc\/(?:self|\d+)\/fd\/\d+$/.test(absolute);
266
- if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY && !descriptorFile) {
267
- const parent = await openDirectoryPath(path.dirname(absolute));
268
- try {
269
- const handle = await open(path.join(parent.anchor, path.basename(absolute)), flags);
270
- return {
271
- handle,
272
- close: async () => {
273
- try { await handle.close(); }
274
- finally { await parent.close().catch(() => {}); }
275
- },
276
- };
277
- } catch (error) {
278
- await parent.close().catch(() => {});
279
- throw error;
280
- }
281
- }
282
- const handle = await open(absolute, flags);
283
- return { handle, close: () => handle.close() };
284
- }
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
-
301
- async function openRegularFileAtPath(absolute, flags, label = absolute) {
302
- const opened = await openEntryAtPath(absolute, flags);
303
- try {
304
- const info = assertRegularFile(await opened.handle.stat(), label);
305
- warnOnHardlink(label, info);
306
- return { handle: opened.handle, info, close: opened.close };
307
- } catch (error) {
308
- await opened.close().catch(() => {});
309
- throw error;
310
- }
311
- }
312
-
313
- export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
314
- return openRegularFileAtPath(absolute, flags, absolute);
315
- }
316
-
317
- async function openWritableParent(absolute, options = {}) {
318
- if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
319
- return openDirectoryPath(absolute, { create:true, ...options });
320
- }
321
- await mkdir(absolute, { recursive:true });
322
- const info = await lstat(absolute);
323
- if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
324
- return { anchor:absolute, close:async() => {} };
325
- }
326
-
327
- async function writeResolvedFile(absolute, content, { append = false } = {}) {
328
- const parent = await openWritableParent(path.dirname(absolute));
329
- let handle;
330
- try {
331
- const flags = append
332
- ? constants.O_WRONLY | constants.O_CREAT | (constants.O_APPEND || 0) | NOFOLLOW | NONBLOCK
333
- : WRITE_TRUNCATE_NOFOLLOW;
334
- handle = await open(path.join(parent.anchor, path.basename(absolute)), flags, 0o600);
335
- await handle.writeFile(content);
336
- } finally {
337
- if (handle) await handle.close().catch(() => {});
338
- await parent.close().catch(() => {});
339
- }
340
- }
341
-
342
- async function withWritableParent(absolute, callback) {
343
- const parent = await openWritableParent(path.dirname(absolute));
344
- try {
345
- return await callback(path.join(parent.anchor, path.basename(absolute)));
346
- } finally {
347
- await parent.close().catch(() => {});
348
- }
349
- }
350
-
351
- async function openExistingParent(absolute) {
352
- if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
353
- return openDirectoryPath(absolute);
354
- }
355
- const info = await lstat(absolute);
356
- if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
357
- return { anchor:absolute, close:async() => {} };
358
- }
359
-
360
- async function withExistingParent(absolute, callback) {
361
- const parent = await openExistingParent(path.dirname(absolute));
362
- try {
363
- return await callback(path.join(parent.anchor, path.basename(absolute)));
364
- } finally {
365
- await parent.close().catch(() => {});
366
- }
367
- }
368
-
369
- async function ensureDirectoryPath(absolute, options = {}) {
370
- if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
371
- const directory = await openDirectoryPath(absolute, { create:true, ...options });
372
- await directory.close();
373
- return;
374
- }
375
- await mkdir(absolute, { recursive:true });
376
- const info = await lstat(absolute);
377
- if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
378
- }
379
-
380
- async function readRegularBuffer(absolute, maxBytes = Infinity) {
381
- const opened = await openRegularFile(absolute);
382
- const { handle, info } = opened;
383
- try {
384
- if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
385
- return { info, buffer: await handle.readFile() };
386
- } finally {
387
- await opened.close();
388
- }
389
- }
390
-
391
- async function overwriteOpenFile(handle, content) {
392
- const buffer = Buffer.from(content, 'utf8');
393
- await handle.truncate(0);
394
- let offset = 0;
395
- while (offset < buffer.length) {
396
- const { bytesWritten } = await handle.write(buffer, offset, buffer.length - offset, offset);
397
- if (!bytesWritten) throw new Error('Could not finish writing the open file');
398
- offset += bytesWritten;
399
- }
400
- await handle.truncate(buffer.length);
401
- }
402
-
403
- // Traversal helper for every multi-file tool. A symbolic link inside an allowed root can point
404
- // anywhere, so links are never followed and each collected path is resolved through
405
- // resolveSafePath again before a tool reads or writes it. `stat` follows links, which is exactly
406
- // how a symlinked directory inside a root used to expose files outside it.
407
- async function collectTree(root, { maxFiles = 500, skip = [], signal = null } = {}) {
408
- const found = [];
409
- const denied = [];
410
- const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
411
- const visitDirectory = async (directory, logicalBase) => {
412
- let entries;
413
- try {
414
- entries = await readdir(directory.anchor, { withFileTypes: true });
415
- } catch {
416
- denied.push(logicalBase);
417
- return;
418
- }
419
- for (const entry of entries) {
420
- throwIfCancelled(signal);
421
- if (found.length >= maxFiles) return;
422
- if (entry.isSymbolicLink() || skipName(entry.name)) continue;
423
- const childLogical = path.join(logicalBase, entry.name);
424
- const childAnchor = path.join(directory.anchor, entry.name);
425
- if (entry.isDirectory()) {
426
- let child;
427
- try {
428
- child = await openDirectoryPath(childAnchor);
429
- const info = await child.handle.stat();
430
- if (!info.isDirectory()) fail('Filesystem path component is not a directory');
431
- await visitDirectory(child, childLogical);
432
- } catch {
433
- denied.push(childLogical);
434
- } finally {
435
- if (child) await child.close().catch(() => {});
436
- }
437
- } else if (entry.isFile()) {
438
- let opened;
439
- try {
440
- opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
441
- const info = await opened.handle.stat();
442
- if (info.isFile()) found.push(childLogical);
443
- } catch {
444
- denied.push(childLogical);
445
- } finally {
446
- if (opened) await opened.close().catch(() => {});
447
- }
448
- }
449
- if (found.length >= maxFiles) return;
450
- }
451
- };
452
- let directory;
453
- try {
454
- directory = await openDirectoryPath(root);
455
- await visitDirectory(directory, root);
456
- } catch {
457
- // A cancelled walk must not degrade into the "root is a single file" fallback below.
458
- throwIfCancelled(signal);
459
- let opened;
460
- try {
461
- opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
462
- const info = await opened.handle.stat();
463
- if (info.isFile()) found.push(root);
464
- else denied.push(root);
465
- } catch {
466
- denied.push(root);
467
- } finally {
468
- if (opened) await opened.close().catch(() => {});
469
- }
470
- } finally {
471
- if (directory) await directory.close().catch(() => {});
472
- }
473
- return { files: found, denied };
474
- }
475
-
476
- // Every path a multi-file tool is about to touch passes through the same confinement check as a
477
- // single-file call, so dropping the traversal shortcut cannot widen what is reachable.
478
- async function confineAll(paths) {
479
- const safe = [];
480
- for (const target of paths) {
481
- try { safe.push(await resolveSafePath(target)); } catch { /* outside the allowed roots: skip it */ }
482
- }
483
- return safe;
484
- }
485
-
486
- async function readTextFile(absolute) {
487
- let opened;
488
- try { opened = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES); }
489
- catch (error) {
490
- if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
491
- throw error;
492
- }
493
- const { info, buffer } = opened;
494
- const decoded = decodeText(buffer);
495
- if (decoded.encoding === 'utf8' && looksBinary(buffer)) {
496
- fail(`${displayPath(absolute)} looks like a binary file and cannot be read as text. Use read_image for images, or get_file_info and hash_file for other binaries.`);
497
- }
498
- return { info, content: decoded.text, encoding: decoded.encoding, eol: detectEol(decoded.text) };
499
- }
500
-
501
- export async function readFileTool(args) {
502
- const absolute = await resolveSafePath(args.path);
503
- // Documents first: a .docx or .pdf is not text, and the binary guard below would refuse it.
504
- const kind = documentKind(absolute);
505
- if (kind) {
506
- const { buffer } = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES);
507
- const extracted = kind === 'docx' ? readDocxText(buffer) : readPdfText(buffer);
508
- const documentLines = splitLines(extracted);
509
- const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
510
- const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
511
- const page = pageLines(documentLines, offset, length);
512
- const label = kind === 'docx' ? 'Word document' : 'PDF text';
513
- const header = documentLines.length
514
- ? `${displayPath(absolute)} (${label}, lines ${page.start + 1}-${page.end} of ${documentLines.length})`
515
- : `${displayPath(absolute)} (${label}, no text)`;
516
- return text(`${header}
517
- ${page.slice.join('\n')}`);
518
- }
519
- const { content, encoding, eol } = await readTextFile(absolute);
520
- const lines = splitLines(content);
521
- const offset = Number.isFinite(Number(args.offset)) ? Math.trunc(Number(args.offset)) : 0;
522
- const length = clampInteger(args.length, liveConfig('maxReadLines'), 1, 10000);
523
- const { start, end, slice } = pageLines(lines, offset, length);
524
- const notes = `${encoding === 'utf8' ? '' : ` ${encoding}`}${eol === '\r\n' ? ' CRLF' : ''}`;
525
- const header = lines.length
526
- ? `${displayPath(absolute)} (lines ${start + 1}-${end} of ${lines.length}${notes})`
527
- : `${displayPath(absolute)} (empty file)`;
528
- return text(`${header}\n${slice.join('\n')}`);
529
- }
530
-
531
- export async function readMultipleFilesTool(args, extra = {}) {
532
- if (!Array.isArray(args.paths) || !args.paths.length) fail('paths must be a non-empty array');
533
- if (args.paths.length > 50) fail('paths accepts at most 50 entries per call');
534
- const sections = [];
535
- for (const entry of args.paths) {
536
- throwIfCancelled(extra.signal);
537
- let absolute;
538
- try {
539
- absolute = await resolveSafePath(entry, 'paths[]');
540
- } catch (error) {
541
- sections.push(`${String(entry)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
542
- continue;
543
- }
544
- try {
545
- const { content } = await readTextFile(absolute);
546
- const lines = splitLines(content);
547
- const limit = liveConfig('maxReadLines');
548
- const slice = lines.slice(0, limit);
549
- const suffix = lines.length > limit ? `\n… ${lines.length - limit} more lines truncated` : '';
550
- sections.push(`${displayPath(absolute)}:\n${slice.join('\n')}${suffix}`);
551
- } catch (error) {
552
- sections.push(`${displayPath(absolute)}: error - ${describeFilesystemFailure(error, { path: error?.path })}`);
553
- }
554
- }
555
- return text(sections.join('\n\n'));
556
- }
557
-
558
- export async function readImageTool(args) {
559
- const absolute = await resolveSafePath(args.path);
560
- let opened;
561
- try { opened = await readRegularBuffer(absolute, MAX_IMAGE_BYTES); }
562
- catch (error) {
563
- if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
564
- throw error;
565
- }
566
- const { info, buffer } = opened;
567
- const mimeType = IMAGE_TYPES.get(path.extname(absolute).toLowerCase());
568
- if (!mimeType) fail(`${displayPath(absolute)} is not a supported image type (${[...IMAGE_TYPES.keys()].join(', ')})`);
569
- if (mimeType === 'image/svg+xml') {
570
- const decoded = decodeText(buffer);
571
- if (decoded.encoding === 'utf8' && looksBinary(buffer)) fail(`${displayPath(absolute)} looks like a binary file`);
572
- return text(`SVG image ${displayPath(absolute)} (${info.size} bytes):\n${decoded.text}`);
573
- }
574
- return multi([
575
- { type: 'text', text: `${displayPath(absolute)} — ${mimeType}, ${info.size} bytes` },
576
- image(buffer.toString('base64'), mimeType),
577
- ]);
578
- }
579
-
580
- export async function hashFileTool(args) {
581
- const absolute = await resolveSafePath(args.path);
582
- let opened;
583
- try { opened = await openRegularFile(absolute); }
584
- catch (error) {
585
- if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
586
- throw error;
587
- }
588
- const { handle, info } = opened;
589
- const algorithm = String(args.algorithm || 'sha256').toLowerCase();
590
- if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
591
- const hash = createHash(algorithm);
592
- try {
593
- await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
594
- return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
595
- } finally {
596
- await opened.close();
597
- }
598
- }
599
-
600
- async function openListEntry(base, name) {
601
- const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
602
- try {
603
- return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
604
- } catch (error) {
605
- await opened.close().catch(() => {});
606
- throw error;
607
- }
608
- }
609
-
610
- async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
611
- let entries;
612
- try {
613
- entries = await readdir(directory.anchor, { withFileTypes: true });
614
- } catch (error) {
615
- return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
616
- }
617
- entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
618
- const rows = [];
619
- for (const entry of entries) {
620
- const childDisplay = path.join(displayBase, entry.name);
621
- if (entry.isDirectory()) {
622
- rows.push(`${prefix}[DIR] ${entry.name}`);
623
- if (depth >= maxDepth) continue;
624
- let child;
625
- try {
626
- child = await openDirectoryPath(path.join(directory.anchor, entry.name));
627
- const childInfo = await child.handle.stat();
628
- if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
629
- rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
630
- } catch (error) {
631
- rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
632
- } finally {
633
- if (child) await child.close().catch(() => {});
634
- }
635
- } else if (entry.isSymbolicLink()) {
636
- rows.push(`${prefix}[LINK] ${entry.name}`);
637
- } else {
638
- if (pattern && !pattern.test(entry.name)) continue;
639
- let opened;
640
- try {
641
- opened = await openListEntry(directory.anchor, entry.name);
642
- rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
643
- } catch (error) {
644
- rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
645
- } finally {
646
- if (opened) await opened.close().catch(() => {});
647
- }
648
- }
649
- }
650
- return rows;
651
- }
652
-
653
- export async function listDirectoryTool(args) {
654
- const absolute = await resolveSafePath(args.path);
655
- const depth = clampInteger(args.depth, 1, 1, 5);
656
- const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
657
- let directory;
658
- try {
659
- directory = await openDirectoryPath(absolute);
660
- const info = await directory.handle.stat();
661
- if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
662
- const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
663
- return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
664
- } catch (error) {
665
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
666
- throw error;
667
- } finally {
668
- if (directory) await directory.close().catch(() => {});
669
- }
670
- }
671
-
672
- async function openPathForInfo(absolute) {
673
- let directory;
674
- try {
675
- directory = await openDirectoryPath(absolute);
676
- return { info: await directory.handle.stat(), close: directory.close };
677
- } catch (directoryError) {
678
- const canTryFile = directoryError?.code === 'ENOTDIR'
679
- || directoryError?.code === 'EISDIR'
680
- || (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
681
- if (!canTryFile) throw directoryError;
682
- }
683
- const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
684
- try {
685
- return { info: await opened.handle.stat(), close: opened.close };
686
- } catch (error) {
687
- await opened.close().catch(() => {});
688
- throw error;
689
- }
690
- }
691
-
692
- async function getPathInfo(absolute) {
693
- const opened = await openPathForInfo(absolute);
694
- try { return opened.info; }
695
- finally { await opened.close().catch(() => {}); }
696
- }
697
-
698
- export async function getFileInfoTool(args) {
699
- const absolute = await resolveSafePath(args.path);
700
- let opened;
701
- try {
702
- opened = await openPathForInfo(absolute);
703
- } catch (error) {
704
- if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
705
- throw error;
706
- }
707
- const { info } = opened;
708
- try {
709
- const payload = {
710
- path: displayPath(absolute),
711
- type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
712
- size: info.size,
713
- createdAt: info.birthtime.toISOString(),
714
- modifiedAt: info.mtime.toISOString(),
715
- permissions: `0${(info.mode & 0o777).toString(8)}`,
716
- };
717
- if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
718
- const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
719
- if (regular) {
720
- const decoded = decodeText(regular.buffer);
721
- if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
722
- if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
723
- const lines = splitLines(decoded.text);
724
- payload.lineCount = lines.length;
725
- payload.lastLine = Math.max(0, lines.length - 1);
726
- payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
727
- }
728
- }
729
- }
730
- return text(JSON.stringify(payload, null, 2));
731
- } finally {
732
- await opened.close().catch(() => {});
733
- }
734
- }
735
-
736
- function assertWritableSize(content) {
737
- const bytes = Buffer.byteLength(content, 'utf8');
738
- if (bytes > runtimeConfig.maxWriteBytes) {
739
- countEvent('writeDenials');
740
- recordEvent('write_denied', { reason: 'size_limit' });
741
- fail(`Content is ${bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
742
- }
743
- return bytes;
744
- }
745
-
746
- export async function writeFileTool(args) {
747
- const absolute = await resolveSafePath(args.path);
748
- const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
749
- const providedMode = typeof args.mode === 'string' && args.mode.trim() ? args.mode.trim().toLowerCase() : '';
750
- if (providedMode && !['rewrite', 'append'].includes(providedMode)) fail('mode must be rewrite or append');
751
- const mode = providedMode || 'rewrite';
752
- const bytes = Buffer.byteLength(content, 'utf8');
753
- if (mode === 'rewrite') {
754
- assertWritableSize(content);
755
- if (content.includes('\0')) fail('content contains NUL bytes. For binary data pass encoding: "base64" (or use write_binary) so the file is written byte for byte.');
756
- } else {
757
- const existing = await stat(absolute).catch(() => null);
758
- const existingSize = existing?.isFile() ? existing.size : 0;
759
- if (existingSize + bytes > runtimeConfig.maxWriteBytes) {
760
- countEvent('writeDenials');
761
- recordEvent('write_denied', { reason: 'size_limit' });
762
- fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
763
- }
764
- }
765
- await writeResolvedFile(absolute, content, { append: mode === 'append' });
766
- countEvent('bytesWritten', bytes);
767
- return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
768
- }
769
-
770
- // Binary transfer in both directions, in chunks: the relay carries MCP results, so a
771
- // large file is read as a sequence of base64 slices and written back the same way.
772
- export async function readBinaryTool(args) {
773
- const absolute = await resolveSafePath(args.path);
774
- let opened;
775
- try { opened = await openRegularFile(absolute); }
776
- catch (error) {
777
- if (error?.code === 'ENOENT') fail(`File not found: ${displayPath(absolute)}`);
778
- throw error;
779
- }
780
- const { handle, info } = opened;
781
- const offset = Math.max(0, Number.isFinite(Number(args.offset_bytes)) ? Math.trunc(Number(args.offset_bytes)) : 0);
782
- const length = clampInteger(args.length_bytes, MAX_BINARY_CHUNK_BYTES, 1, MAX_BINARY_CHUNK_BYTES);
783
- const start = Math.min(offset, info.size);
784
- const end = Math.min(info.size, start + length);
785
- try {
786
- const buffer = Buffer.alloc(end - start);
787
- if (buffer.length) await handle.read(buffer, 0, buffer.length, start);
788
- const renderPayload = lengthBytes => {
789
- const actualEnd = start + lengthBytes;
790
- return JSON.stringify({
791
- path: displayPath(absolute),
792
- size: info.size,
793
- offsetBytes: start,
794
- lengthBytes,
795
- nextOffsetBytes: actualEnd < info.size ? actualEnd : null,
796
- complete: actualEnd >= info.size,
797
- encoding: 'base64',
798
- data: buffer.subarray(0, lengthBytes).toString('base64'),
799
- });
800
- };
801
- let returnedLength = buffer.length;
802
- let payload = renderPayload(returnedLength);
803
- if (!fitsOutput(payload)) {
804
- let low = 0;
805
- let high = returnedLength;
806
- while (low < high) {
807
- const candidate = Math.ceil((low + high) / 2);
808
- if (fitsOutput(renderPayload(candidate))) low = candidate;
809
- else high = candidate - 1;
810
- }
811
- returnedLength = low;
812
- payload = renderPayload(returnedLength);
813
- if (!fitsOutput(payload)) fail('Runtime output limit is too small to return read_binary metadata');
814
- }
815
- return text(payload);
816
- } finally {
817
- await opened.close();
818
- }
819
- }
820
-
821
- export async function writeBinaryTool(args) {
822
- const absolute = await resolveSafePath(args.path);
823
- const data = typeof args.data === 'string' ? args.data : fail('data must be a base64 string');
824
- const mode = String(args.mode || 'rewrite').toLowerCase();
825
- if (!['rewrite', 'append'].includes(mode)) fail('mode must be rewrite or append');
826
- let buffer;
827
- try {
828
- buffer = Buffer.from(data.replace(/\s+/g, ''), 'base64');
829
- } catch {
830
- fail('data must be valid base64');
831
- }
832
- if (buffer.length > runtimeConfig.maxWriteBytes) {
833
- countEvent('writeDenials');
834
- recordEvent('write_denied', { reason: 'size_limit' });
835
- fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
836
- }
837
- await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
838
- countEvent('bytesWritten', buffer.length);
839
- return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
840
- }
841
-
842
- function normalizeForFuzzy(value) {
843
- return splitLines(value).map(line => line.replace(/[ \t]+/g, ' ').trim());
844
- }
845
-
846
- // Whitespace-tolerant fallback: models frequently re-indent an exact block they just
847
- // read. Every candidate window is compared with collapsed whitespace, and the edit is
848
- // applied only when the number of candidate windows matches expected_replacements.
849
- function fuzzyMatchStarts(lines, target) {
850
- const normalized = lines.map(line => line.replace(/[ \t]+/g, ' ').trim());
851
- const starts = [];
852
- for (let start = 0; start + target.length <= normalized.length; start += 1) {
853
- let equal = true;
854
- for (let index = 0; index < target.length; index += 1) {
855
- if (normalized[start + index] !== target[index]) { equal = false; break; }
856
- }
857
- if (equal) starts.push(start);
858
- }
859
- return starts;
860
- }
861
-
862
- export async function editBlockTool(args) {
863
- const absolute = await resolveSafePath(args.file_path, 'file_path');
864
- const oldString = typeof args.old_string === 'string' ? args.old_string : fail('old_string must be a string');
865
- const newString = typeof args.new_string === 'string' ? args.new_string : fail('new_string must be a string');
866
- if (!oldString) fail('old_string must not be empty');
867
- if (oldString === newString) fail('old_string and new_string are identical');
868
- const allowFuzzy = args.allow_fuzzy !== false;
869
- const dryRun = args.dry_run === true;
870
- const expected = Number.isInteger(Number(args.expected_replacements)) ? Math.max(1, Math.trunc(Number(args.expected_replacements))) : 1;
871
- const { content, eol } = await readTextFile(absolute);
872
- const occurrences = content.split(oldString).length - 1;
873
-
874
- const present = (updated, how) => {
875
- const stats = diffStats(content, updated);
876
- const summary = `${how} in ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
877
- if (!dryRun) return `${summary}.`;
878
- return `${summary}\n(dry run: nothing was written)\n${unifiedDiff(content, updated, { oldLabel: displayPath(absolute), newLabel: 'after' })}`;
879
- };
880
-
881
- if (occurrences === expected) {
882
- const updated = content.split(oldString).join(newString);
883
- assertWritableSize(updated);
884
- if (!dryRun) await writeResolvedFile(absolute, updated);
885
- return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
886
- }
887
- if (occurrences > 0) {
888
- fail(`Expected ${expected} occurrence(s) of old_string but found ${occurrences}. Add more surrounding context.`);
889
- }
890
- if (!allowFuzzy) fail('old_string was not found in the file');
891
- const target = normalizeForFuzzy(oldString);
892
- const lines = splitLines(content);
893
- const starts = target.length ? fuzzyMatchStarts(lines, target) : [];
894
- if (starts.length !== expected) {
895
- fail(starts.length
896
- ? `old_string matched ${starts.length} block(s) after whitespace normalization, expected ${expected}. Add more surrounding context.`
897
- : 'old_string was not found in the file, even after whitespace normalization');
898
- }
899
- const replacement = splitLines(newString);
900
- const endsWithNewline = /\n$/.test(content);
901
- for (const start of [...starts].reverse()) lines.splice(start, target.length, ...replacement);
902
- // Rebuild with the file's own line ending: hard-coding \n silently rewrote every CRLF
903
- // file to LF and turned a one-line change into a whole-file diff on Windows.
904
- const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
905
- assertWritableSize(updated);
906
- if (!dryRun) await writeResolvedFile(absolute, updated);
907
- return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
908
- }
909
-
910
- export async function replaceLinesTool(args) {
911
- const absolute = await resolveSafePath(args.path);
912
- const startLine = Number(args.start_line);
913
- const endLine = Number(args.end_line);
914
- if (!Number.isInteger(startLine) || startLine < 1) fail('start_line must be a positive integer (1-based)');
915
- if (!Number.isInteger(endLine) || endLine < startLine) fail('end_line must be an integer greater than or equal to start_line');
916
- const content = typeof args.content === 'string' ? args.content : fail('content must be a string');
917
- const dryRun = args.dry_run === true;
918
- const { content: original, eol } = await readTextFile(absolute);
919
- const lines = splitLines(original);
920
- if (startLine > lines.length) fail(`${displayPath(absolute)} has ${lines.length} lines; start_line ${startLine} is past the end`);
921
- const endsWithNewline = /\n$/.test(original);
922
- const replacement = splitLines(content);
923
- const updated = [...lines.slice(0, startLine - 1), ...replacement, ...lines.slice(Math.min(endLine, lines.length))];
924
- const updatedText = `${updated.join(eol)}${endsWithNewline && updated.length ? eol : ''}`;
925
- assertWritableSize(updatedText);
926
- const stats = diffStats(original, updatedText);
927
- const summary = `Replaced lines ${startLine}-${Math.min(endLine, lines.length)} of ${displayPath(absolute)} (+${stats.added}/-${stats.removed} lines)`;
928
- if (dryRun) {
929
- return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
930
- }
931
- await writeResolvedFile(absolute, updatedText);
932
- return text(`${summary}.`);
933
- }
934
-
935
- export async function replaceInFilesTool(args, extra = {}) {
936
- const root = await resolveSafePath(args.path);
937
- const pattern = typeof args.pattern === 'string' && args.pattern ? args.pattern : fail('pattern is required');
938
- const replacement = typeof args.replacement === 'string' ? args.replacement : fail('replacement must be a string');
939
- const filePattern = typeof args.filePattern === 'string' && args.filePattern.trim() ? args.filePattern.trim() : null;
940
- const isRegex = args.regex === true;
941
- // Applying is the default: the agent is expected to act, and a dry run is available
942
- // when a caller explicitly wants a preview.
943
- const dryRun = args.dry_run === true;
944
- const maxFiles = clampInteger(args.maxFiles, 100, 1, 500);
945
- let matcher = null;
946
- if (isRegex) {
947
- try { matcher = new RegExp(pattern, 'g'); } catch (error) {
948
- fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
949
- }
950
- }
951
- const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
952
- const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'], signal: extra.signal });
953
- const files = await confineAll(walk.files);
954
- const glob = filePattern ? globToRegExp(filePattern) : null;
955
- const changed = [];
956
- let scanned = 0;
957
- for (const file of files) {
958
- throwIfCancelled(extra.signal);
959
- if (changed.length >= maxFiles) break;
960
- if (glob && !glob.test(path.basename(file))) continue;
961
- let opened;
962
- try { opened = await openRegularFile(file, dryRun ? READ_NOFOLLOW : READWRITE_NOFOLLOW); }
963
- catch { continue; }
964
- const { handle, info: fileInfo } = opened;
965
- try {
966
- if (fileInfo.size > MAX_INLINE_FILE_BYTES) continue;
967
- const buffer = await handle.readFile();
968
- const decoded = decodeText(buffer);
969
- if (decoded.encoding === 'utf8' && looksBinary(buffer)) continue;
970
- scanned += 1;
971
- const original = decoded.text;
972
- const count = isRegex ? (original.match(matcher) || []).length : original.split(pattern).length - 1;
973
- if (!count) continue;
974
- if (isRegex) matcher.lastIndex = 0;
975
- const updated = isRegex ? original.replace(matcher, replacement) : original.split(pattern).join(replacement);
976
- if (updated === original) continue;
977
- assertWritableSize(updated);
978
- if (!dryRun) await overwriteOpenFile(handle, updated);
979
- const stats = diffStats(original, updated);
980
- changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
981
- } finally {
982
- await opened.close();
983
- }
984
- }
985
- if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
986
- const rows = changed.map(entry => `${dryRun ? 'would change' : 'changed'} ${entry.file} · ${entry.replacements} replacement(s) · +${entry.added}/-${entry.removed} lines`);
987
- const header = `${dryRun ? 'Dry run' : 'Applied'}: ${changed.length} file(s), ${changed.reduce((sum, entry) => sum + entry.replacements, 0)} replacement(s)`;
988
- const hint = dryRun ? '\nNothing was written. Call again with dry_run: false to apply.' : '';
989
- return text(`${header}\n${rows.join('\n')}${hint}`);
990
- }
991
-
992
- export async function diffFilesTool(args) {
993
- const left = await resolveSafePath(args.left, 'left');
994
- const right = await resolveSafePath(args.right, 'right');
995
- const context = clampInteger(args.context_lines, 3, 0, 20);
996
- const a = await readTextFile(left);
997
- const b = await readTextFile(right);
998
- const diff = unifiedDiff(a.content, b.content, { oldLabel: displayPath(left), newLabel: displayPath(right), context });
999
- if (!diff) return text(`${displayPath(left)} and ${displayPath(right)} are identical (${a.content.length} bytes).`);
1000
- const stats = diffStats(a.content, b.content);
1001
- return text(`${displayPath(left)} → ${displayPath(right)} (+${stats.added}/-${stats.removed} lines)\n${diff}`);
1002
- }
1003
-
1004
- function trashDirectoryFor() {
1005
- if (process.platform === 'darwin') return path.join(os.homedir(), '.Trash');
1006
- if (process.platform === 'win32') return null;
1007
- return path.join(os.homedir(), '.local', 'share', 'Trash', 'files');
1008
- }
1009
-
1010
- export async function moveToTrashTool(args) {
1011
- const source = await resolveSafePath(args.source, 'source');
1012
- const trash = trashDirectoryFor();
1013
- let destination = null;
1014
- if (trash) {
1015
- // The trash lives outside the allowed roots, so only use it when confinement permits
1016
- // it; otherwise fall back to a trash folder beside the file.
1017
- try {
1018
- await resolveSafePath(trash, 'trash');
1019
- destination = trash;
1020
- } catch { destination = null; }
1021
- }
1022
- if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
1023
- const existingDestination = await lstat(destination).catch(() => null);
1024
- if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
1025
- if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
1026
- await ensureDirectoryPath(destination);
1027
- destination = await resolveSafePath(destination, 'trash');
1028
- const destinationInfo = await lstat(destination);
1029
- if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
1030
- const stamp = new Date().toISOString().replace(/[:.]/g, '-');
1031
- let target = path.join(destination, `${stamp}-${path.basename(source)}`);
1032
- let counter = 1;
1033
- while (await lstat(target).then(value => Boolean(value), () => false)) {
1034
- target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
1035
- counter += 1;
1036
- }
1037
- await withExistingParent(source, async anchoredSource => {
1038
- const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
1039
- if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1040
- await withWritableParent(target, async anchoredTarget => {
1041
- await rename(anchoredSource, anchoredTarget).catch(async error => {
1042
- if (error?.code !== 'EXDEV') throw error;
1043
- if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
1044
- await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1045
- await rm(anchoredSource, { force:true });
1046
- });
1047
- });
1048
- });
1049
- return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
1050
- }
1051
-
1052
- export async function readFilesTool(args, extra = {}) {
1053
- // Glob-first bulk read: one call fills the model's context with every file that matters
1054
- // instead of one round trip per path.
1055
- const root = await resolveSafePath(args.path || '.');
1056
- const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? args.pattern.trim() : '**/*';
1057
- const maxFiles = clampInteger(args.max_files, 100, 1, 500);
1058
- const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
1059
- const includeIgnored = args.include_ignored === true;
1060
- const matcher = globToRegExp(pattern);
1061
- const rootInfo = await getPathInfo(root).catch(() => null);
1062
- // A file path is matched directly; a directory is walked without following links.
1063
- const walk = rootInfo?.isFile()
1064
- ? { files: [root], denied: [] }
1065
- : await collectTree(root, {
1066
- maxFiles: maxFiles + 1,
1067
- skip: includeIgnored ? ['.remcp-trash*'] : ['node_modules', '.git', '.remcp-trash*'],
1068
- signal: extra.signal,
1069
- });
1070
- const candidates = await confineAll(walk.files);
1071
- const matched = candidates.filter(target => {
1072
- const relative = path.relative(root, target) || path.basename(target);
1073
- return matcher.test(relative.split(path.sep).join('/')) || matcher.test(path.basename(target));
1074
- });
1075
- const files = matched.slice(0, maxFiles);
1076
- if (!files.length) return text(`No files matched ${pattern} under ${displayPath(root)}.`);
1077
- const sections = [];
1078
- let skipped = 0;
1079
- for (const file of files.slice(0, maxFiles)) {
1080
- throwIfCancelled(extra.signal);
1081
- try {
1082
- const { content, encoding } = await readTextFile(file);
1083
- const lines = splitLines(content);
1084
- const slice = lines.slice(0, maxLinesPerFile);
1085
- const suffix = lines.length > maxLinesPerFile ? `\n… ${lines.length - maxLinesPerFile} more lines (use read_file with offset)` : '';
1086
- sections.push(`===== ${displayPath(file)} (${lines.length} lines${encoding === 'utf8' ? '' : `, ${encoding}`}) =====\n${slice.join('\n')}${suffix}`);
1087
- } catch (error) {
1088
- skipped += 1;
1089
- sections.push(`===== ${displayPath(file)} =====\n(skipped: ${describeFilesystemFailure(error, { path: error?.path })})`);
1090
- }
1091
- }
1092
- const notes = [];
1093
- if (matched.length > files.length) notes.push(`showing the first ${files.length}`);
1094
- if (skipped) notes.push(`${skipped} unreadable`);
1095
- if (walk.denied.length) notes.push(`${walk.denied.length} unreadable director${walk.denied.length === 1 ? 'y' : 'ies'} skipped`);
1096
- const header = `${matched.length} file(s) matched ${pattern} under ${displayPath(root)}${notes.length ? ` (${notes.join(', ')})` : ''}`;
1097
- return text(`${header}\n\n${sections.join('\n\n')}`);
1098
- }
1099
-
1100
- export async function writeFilesTool(args, extra = {}) {
1101
- // Bulk write for scaffolding: one call creates or replaces many files.
1102
- const files = Array.isArray(args.files) ? args.files : fail('files must be an array of { path, content } objects');
1103
- if (!files.length) fail('files must not be empty');
1104
- if (files.length > 200) fail('files accepts at most 200 entries per call');
1105
- const results = [];
1106
- let totalBytes = 0;
1107
- for (const entry of files) {
1108
- throwIfCancelled(extra.signal);
1109
- const target = typeof entry?.path === 'string' ? entry.path : null;
1110
- if (!target) { results.push('skipped: entry without a path'); continue; }
1111
- if (typeof entry.content !== 'string') { results.push(`skipped ${target}: content must be a string`); continue; }
1112
- try {
1113
- const absolute = await resolveSafePath(target);
1114
- const content = entry.content;
1115
- if (content.includes('\0')) throw new Error('content contains NUL bytes; use write_binary for binary data');
1116
- const bytes = assertWritableSize(content);
1117
- totalBytes += bytes;
1118
- if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
1119
- await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
1120
- results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
1121
- } catch (error) {
1122
- results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1123
- }
1124
- }
1125
- countEvent('bytesWritten', totalBytes);
1126
- const failed = results.filter(line => line.startsWith('failed') || line.startsWith('skipped')).length;
1127
- return text(`${results.length - failed}/${results.length} file(s) written, ${totalBytes} bytes total\n${results.join('\n')}`, failed > 0);
1128
- }
1129
-
1130
- export async function deletePathTool(args) {
1131
- const absolute = await resolveSafePath(args.path);
1132
- if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
1133
- const recursive = args.recursive !== false;
1134
- let info;
1135
- let entries = [];
1136
- await withExistingParent(absolute, async target => {
1137
- info = await lstat(target).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1138
- if (info.isDirectory() && !recursive) {
1139
- entries = await readdir(target).catch(() => []);
1140
- if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
1141
- }
1142
- if (info.isDirectory()) entries = await readdir(target).catch(() => []);
1143
- await rm(target, { recursive:true, force:false });
1144
- });
1145
- return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
1146
- }
1147
-
1148
- export async function deletePathsTool(args, extra = {}) {
1149
- const paths = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of absolute paths');
1150
- if (!paths.length) fail('paths must not be empty');
1151
- if (paths.length > 500) fail('paths accepts at most 500 entries per call');
1152
- const recursive = args.recursive !== false;
1153
- const results = [];
1154
- let deleted = 0;
1155
- for (const entry of paths) {
1156
- throwIfCancelled(extra.signal);
1157
- try {
1158
- const absolute = await resolveSafePath(entry, 'paths[]');
1159
- if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
1160
- await withExistingParent(absolute, async target => {
1161
- const info = await lstat(target).catch(() => null);
1162
- if (!info) throw new Error('not found');
1163
- if (info.isDirectory() && !recursive) {
1164
- const children = await readdir(target).catch(() => []);
1165
- if (children.length) throw new Error('directory is not empty (pass recursive: true)');
1166
- }
1167
- await rm(target, { recursive:true, force:false });
1168
- });
1169
- deleted += 1;
1170
- results.push(`deleted ${displayPath(absolute)}`);
1171
- } catch (error) {
1172
- results.push(`failed ${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1173
- }
1174
- }
1175
- return text(`${deleted}/${paths.length} path(s) deleted\n${results.join('\n')}`, deleted !== paths.length);
1176
- }
1177
-
1178
- // Recursive copy for files and whole directories, so a project or a backup can be
1179
- // duplicated in one call.
1180
- export async function copyPathsTool(args, extra = {}) {
1181
- const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
1182
- if (!pairs.length) fail('paths must not be empty');
1183
- if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
1184
- const overwrite = args.overwrite !== false;
1185
- const results = [];
1186
- let copied = 0;
1187
- for (const entry of pairs) {
1188
- throwIfCancelled(extra.signal);
1189
- try {
1190
- const source = await resolveSafePath(entry?.source, 'paths[].source');
1191
- const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
1192
- if (source === destination) throw new Error('source and destination are the same path');
1193
- await withWritableParent(destination, async anchoredDestination => {
1194
- const existing = await lstat(anchoredDestination).catch(() => null);
1195
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1196
- await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1197
- });
1198
- copied += 1;
1199
- results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
1200
- } catch (error) {
1201
- results.push(`failed ${entry?.source ?? '?'}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1202
- }
1203
- }
1204
- return text(`${copied}/${pairs.length} path(s) copied\n${results.join('\n')}`, copied !== pairs.length);
1205
- }
1206
-
1207
- export async function movePathsTool(args, extra = {}) {
1208
- const pairs = Array.isArray(args.paths) ? args.paths : fail('paths must be an array of { source, destination } objects');
1209
- if (!pairs.length) fail('paths must not be empty');
1210
- if (pairs.length > 200) fail('paths accepts at most 200 entries per call');
1211
- const overwrite = args.overwrite !== false;
1212
- const results = [];
1213
- let moved = 0;
1214
- for (const entry of pairs) {
1215
- throwIfCancelled(extra.signal);
1216
- try {
1217
- const source = await resolveSafePath(entry?.source, 'paths[].source');
1218
- const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
1219
- if (source === destination) throw new Error('source and destination are the same path');
1220
- await withExistingParent(source, async anchoredSource => {
1221
- const sourceInfo = await lstat(anchoredSource).catch(() => null);
1222
- if (!sourceInfo) throw new Error('source not found');
1223
- if (sourceInfo.isSymbolicLink()) throw new Error('source cannot be a symbolic link');
1224
- await withWritableParent(destination, async anchoredDestination => {
1225
- const existing = await lstat(anchoredDestination).catch(() => null);
1226
- if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
1227
- try {
1228
- await rename(anchoredSource, anchoredDestination);
1229
- } catch (error) {
1230
- if (error?.code !== 'EXDEV') throw error;
1231
- await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER, signal: extra.signal });
1232
- await rm(anchoredSource, { recursive:true, force:true });
1233
- }
1234
- });
1235
- });
1236
- moved += 1;
1237
- results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
1238
- } catch (error) {
1239
- results.push(`failed ${entry?.source ?? '?'}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1240
- }
1241
- }
1242
- return text(`${moved}/${pairs.length} path(s) moved\n${results.join('\n')}`, moved !== pairs.length);
1243
- }
1244
-
1245
- // Applying a unified diff is the fastest path from "the model knows the change" to "the
1246
- // change is on disk": no exact-block matching, no re-sending whole files.
1247
- export async function applyPatchTool(args) {
1248
- const patch = typeof args.patch === 'string' && args.patch.trim() ? args.patch : fail('patch must be a unified diff');
1249
- const dryRun = args.dry_run === true;
1250
- const forcePath = typeof args.path === 'string' && args.path.trim() ? args.path : null;
1251
- const files = parseUnifiedDiff(patch);
1252
- if (!files.length) fail('patch does not contain any @@ hunks');
1253
- const results = [];
1254
- let changed = 0;
1255
- for (const file of files) {
1256
- const target = forcePath || (file.newPath && file.newPath !== '/dev/null' ? file.newPath : file.oldPath);
1257
- if (!target || target === '/dev/null') { results.push('failed: a hunk has no target path; pass path explicitly'); continue; }
1258
- try {
1259
- const absolute = await resolveSafePath(target);
1260
- let original = '';
1261
- try { original = (await readTextFile(absolute)).content; } catch (error) {
1262
- if (file.oldPath === '/dev/null' || /not found/i.test(error?.message || '')) original = '';
1263
- else throw error;
1264
- }
1265
- const { updated, applied, failed } = applyHunks(original, file.hunks);
1266
- if (failed.length && !applied.length) { results.push(`failed ${displayPath(absolute)}: none of the ${file.hunks.length} hunk(s) matched`); continue; }
1267
- const stats = diffStats(original, updated);
1268
- if (!dryRun) {
1269
- assertWritableSize(updated);
1270
- await writeResolvedFile(absolute, updated);
1271
- }
1272
- changed += 1;
1273
- const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
1274
- results.push(`${dryRun ? 'would patch' : 'patched'} ${displayPath(absolute)} · ${applied.length}/${file.hunks.length} hunk(s), +${stats.added}/-${stats.removed} lines${fuzzy ? `, ${fuzzy} with fuzz` : ''}${failed.length ? `, ${failed.length} hunk(s) did not match` : ''}`);
1275
- if (dryRun) results.push(unifiedDiff(original, updated, { oldLabel: displayPath(absolute), newLabel: 'after' }));
1276
- } catch (error) {
1277
- results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1278
- }
1279
- }
1280
- const failedCount = results.filter(line => line.startsWith('failed')).length;
1281
- return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
1282
- }
1283
-
1284
- async function setLinuxPermissions(target, mode, uid, gid, recursive, displayTarget, failures) {
1285
- let directory = null;
1286
- let file = null;
1287
- try {
1288
- const info = await lstat(target);
1289
- if (info.isSymbolicLink()) {
1290
- failures.push(`${displayTarget}: symbolic links are not changed`);
1291
- return 0;
1292
- }
1293
- if (info.isDirectory()) {
1294
- directory = await openDirectoryPath(target);
1295
- let changed = 0;
1296
- if (recursive) {
1297
- for (const entry of await readdir(directory.anchor, { withFileTypes:true })) {
1298
- if (entry.isSymbolicLink()) continue;
1299
- changed += await setLinuxPermissions(path.join(directory.anchor, entry.name), mode, uid, gid, true, path.join(displayTarget, entry.name), failures);
1300
- }
1301
- }
1302
- await directory.handle.chmod(mode);
1303
- if (uid !== null || gid !== null) await directory.handle.chown(uid ?? -1, gid ?? -1);
1304
- return changed + 1;
1305
- }
1306
- if (!info.isFile()) {
1307
- failures.push(`${displayTarget}: special files are not changed`);
1308
- return 0;
1309
- }
1310
- file = await openRegularFile(target, READ_NOFOLLOW);
1311
- await file.handle.chmod(mode);
1312
- if (uid !== null || gid !== null) await file.handle.chown(uid ?? -1, gid ?? -1);
1313
- return 1;
1314
- } catch (error) {
1315
- failures.push(`${displayTarget}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1316
- return 0;
1317
- } finally {
1318
- if (file) await file.close().catch(() => {});
1319
- if (directory) await directory.close().catch(() => {});
1320
- }
1321
- }
1322
-
1323
- export async function setPermissionsTool(args) {
1324
- const absolute = await resolveSafePath(args.path);
1325
- const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
1326
- if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
1327
- const mode = Number.parseInt(raw, 8);
1328
- const recursive = args.recursive === true;
1329
- const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
1330
- const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
1331
- const failures = [];
1332
- let changed = 0;
1333
- if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
1334
- changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
1335
- } else {
1336
- // macOS/Windows fallback: Node exposes no directory-fd-relative chmod/chown there, so this
1337
- // branch re-resolves each path lexically (confineAll) and then acts on the path. Between those
1338
- // two steps another process can swap a component, so confinement is a guardrail on this
1339
- // platform rather than a descriptor-bound guarantee — the same residual the archive/document
1340
- // staging path documents. Linux uses the descriptor-bound path above.
1341
- const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
1342
- const targets = [absolute];
1343
- if (recursive && info.isDirectory()) {
1344
- const walk = async target => {
1345
- const entries = await readdir(target, { withFileTypes:true }).catch(() => []);
1346
- for (const entry of entries) {
1347
- if (entry.isSymbolicLink()) continue;
1348
- const child = path.join(target, entry.name);
1349
- targets.push(child);
1350
- if (entry.isDirectory()) await walk(child);
1351
- }
1352
- };
1353
- await walk(absolute);
1354
- }
1355
- const confined = await confineAll(targets);
1356
- for (const target of confined) {
1357
- try {
1358
- await chmod(target, mode);
1359
- if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
1360
- changed += 1;
1361
- } catch (error) {
1362
- failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path:error?.path })}`);
1363
- }
1364
- }
1365
- }
1366
- const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
1367
- if (!failures.length) return text(summary);
1368
- return text(`${summary}\n${failures.length} path(s) could not be changed:\n${failures.slice(0, 20).join('\n')}`, true);
1369
- }
1370
-
1371
- export async function createDirectoryTool(args) {
1372
- const list = Array.isArray(args.paths) ? args.paths : [args.path];
1373
- if (!list.filter(Boolean).length) fail('path (or paths) is required');
1374
- if (list.length > 200) fail('paths accepts at most 200 entries per call');
1375
- const created = [];
1376
- const failed = [];
1377
- for (const entry of list) {
1378
- try {
1379
- const absolute = await resolveSafePath(entry);
1380
- await ensureDirectoryPath(absolute);
1381
- created.push(displayPath(absolute));
1382
- } catch (error) {
1383
- failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
1384
- }
1385
- }
1386
- const header = `${created.length} director${created.length === 1 ? 'y' : 'ies'} ready`;
1387
- return text([header, ...created, ...failed.map(line => `failed ${line}`)].join('\n'), failed.length > 0);
1388
- }
1389
-
1390
- async function pathExists(target) {
1391
- try { await access(target, constants.F_OK); return true; } catch { return false; }
1392
- }
1393
-
1394
- export async function moveFileTool(args) {
1395
- const source = await resolveSafePath(args.source, 'source');
1396
- const destination = await resolveSafePath(args.destination, 'destination');
1397
- if (source === destination) fail('source and destination are the same path');
1398
- const overwrite = args.overwrite !== false;
1399
- await withExistingParent(source, async anchoredSource => {
1400
- const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1401
- if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1402
- await withWritableParent(destination, async anchoredDestination => {
1403
- const existing = await lstat(anchoredDestination).catch(() => null);
1404
- if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
1405
- if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1406
- if (existing?.isDirectory()) {
1407
- const entries = await readdir(anchoredDestination).catch(() => []);
1408
- if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
1409
- }
1410
- try {
1411
- await rename(anchoredSource, anchoredDestination);
1412
- } catch (error) {
1413
- if (error?.code !== 'EXDEV') throw error;
1414
- if (sourceInfo.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
1415
- await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1416
- await rm(anchoredSource, { force:true });
1417
- }
1418
- });
1419
- });
1420
- return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
1421
- }
1422
-
1423
- export async function copyFileTool(args) {
1424
- const source = await resolveSafePath(args.source, 'source');
1425
- const destination = await resolveSafePath(args.destination, 'destination');
1426
- if (source === destination) fail('source and destination are the same path');
1427
- const overwrite = args.overwrite !== false;
1428
- let info;
1429
- await withExistingParent(source, async anchoredSource => {
1430
- const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
1431
- if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
1432
- if (sourceInfo.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
1433
- info = sourceInfo;
1434
- await withWritableParent(destination, async anchoredDestination => {
1435
- const existing = await lstat(anchoredDestination).catch(() => null);
1436
- if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
1437
- if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
1438
- await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
1439
- });
1440
- });
1441
- return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
1442
- }
1443
-
1444
- // --- archives -------------------------------------------------------------------------
1445
- const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
1446
- 4 * 1024 * 1024 * 1024,
1447
- Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
1448
- );
1449
-
1450
- function safeArchiveEntry(name) {
1451
- const normalized = String(name || '').replace(/\\/g, '/');
1452
- return Boolean(normalized) && !normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized) && !normalized.split('/').includes('..');
1453
- }
1454
-
1455
- function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
1456
- let total = 0;
1457
- let matched = 0;
1458
- for (const line of lines) {
1459
- const match = line.match(pattern);
1460
- if (!match) continue;
1461
- total += Number(match[1]);
1462
- matched += 1;
1463
- if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
1464
- }
1465
- if ((!matched && expectedEntries !== 0) || (expectedEntries !== null && matched < expectedEntries)) fail(`Could not determine ${label} size safely`);
1466
- return total;
1467
- }
1468
-
1469
- export function tarEntrySize(line) {
1470
- const tokens = String(line || '').trim().split(/\s+/);
1471
- for (const index of [2, 3, 4]) {
1472
- const size = Number(tokens[index]);
1473
- if (!Number.isSafeInteger(size) || size < 0) continue;
1474
- const tail = tokens.slice(index + 1);
1475
- const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
1476
- const bsdMonthDay = /^[A-Z][a-z]{2}$/.test(tail[0] || '') && /^\d{1,2}$/.test(tail[1] || '');
1477
- const bsdTime = /^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '');
1478
- const bsdDate = bsdMonthDay && (bsdTime || /^\d{4}$/.test(tail[2] || ''));
1479
- if (gnuDate || bsdDate) return size;
1480
- }
1481
- return null;
1482
- }
1483
-
1484
- function archiveTarExpandedSize(lines, label, expectedEntries) {
1485
- let total = 0;
1486
- let matched = 0;
1487
- for (const line of lines) {
1488
- const size = tarEntrySize(line);
1489
- if (size === null) continue;
1490
- total += size;
1491
- matched += 1;
1492
- if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
1493
- }
1494
- if (matched < expectedEntries) fail(`Could not determine ${label} size safely`);
1495
- return total;
1496
- }
1497
-
1498
- function assertArchiveExpandedSize(bytes, label) {
1499
- if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_ARCHIVE_EXPANDED_BYTES) {
1500
- fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
1501
- }
1502
- }
1503
-
1504
- async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
1505
- const info = await lstat(root);
1506
- if (info.isSymbolicLink()) fail('Archive extraction produced a symbolic link');
1507
- if (!info.isDirectory() && !info.isFile()) fail('Archive extraction produced a special file');
1508
- state.entries += 1;
1509
- state.bytes += info.isFile() ? info.size : 0;
1510
- if (state.entries > 100_000 || state.bytes > MAX_ARCHIVE_EXPANDED_BYTES) fail('Archive extraction exceeded the safety limit');
1511
- if (!info.isDirectory()) return;
1512
- for (const entry of await readdir(root, { withFileTypes: true })) await assertExtractedTree(path.join(root, entry.name), state);
1513
- }
1514
-
1515
- async function openArchiveSource(absolute) {
1516
- return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1517
- }
1518
-
1519
- async function snapshotRegularFile(source, destination, maxBytes) {
1520
- const sourceInfo = await lstat(source);
1521
- if (sourceInfo.isSymbolicLink()) fail(`Archive source is a symbolic link: ${displayPath(source)}`);
1522
- if (!sourceInfo.isFile()) fail(`Archive source is not a regular file: ${displayPath(source)}`);
1523
- const opened = await openArchiveSource(source);
1524
- const { handle, info } = opened;
1525
- let output;
1526
- try {
1527
- if (info.size > maxBytes) fail(`Archive is ${info.size} bytes, above the ${maxBytes}-byte snapshot limit`);
1528
- output = await open(destination, WRITE_CREATE_NOFOLLOW, 0o400);
1529
- const buffer = Buffer.allocUnsafe(1024 * 1024);
1530
- let offset = 0;
1531
- for (;;) {
1532
- const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
1533
- if (!bytesRead) break;
1534
- if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maxBytes) {
1535
- fail(`Archive grew beyond the ${maxBytes}-byte snapshot limit`);
1536
- }
1537
- let written = 0;
1538
- while (written < bytesRead) {
1539
- const result = await output.write(buffer, written, bytesRead - written, offset + written);
1540
- if (!result.bytesWritten) throw new Error('Could not finish writing the archive snapshot');
1541
- written += result.bytesWritten;
1542
- }
1543
- offset += bytesRead;
1544
- }
1545
- await output.truncate(offset);
1546
- await output.sync();
1547
- await output.chmod(0o400);
1548
- return offset;
1549
- } finally {
1550
- if (output) await output.close().catch(() => {});
1551
- await opened.close().catch(() => {});
1552
- }
1553
- }
1554
-
1555
- async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, state) {
1556
- const mode = sourceInfo.mode & 0o777;
1557
- const currentBytes = state.bytes || 0;
1558
- if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
1559
- fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
1560
- }
1561
- // Never validate a pathname and then truncate it: another local process could replace that entry
1562
- // between the check and open. Build a brand-new inode beside the destination and commit it with
1563
- // rename(), which replaces the directory entry atomically without following a raced symlink or
1564
- // truncating a hard-linked file.
1565
- const temporary = path.join(
1566
- path.dirname(destinationPath),
1567
- `.${path.basename(destinationPath)}.remcp-${randomUUID()}.tmp`,
1568
- );
1569
- let output;
1570
- try {
1571
- output = await open(temporary, WRITE_CREATE_NOFOLLOW, mode);
1572
- const outputInfo = await output.stat();
1573
- if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
1574
- const buffer = Buffer.allocUnsafe(1024 * 1024);
1575
- let offset = 0;
1576
- for (;;) {
1577
- const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, offset);
1578
- if (!bytesRead) break;
1579
- if (!Number.isSafeInteger(offset + bytesRead) || (state.maxBytes !== undefined && currentBytes + offset + bytesRead > state.maxBytes)) {
1580
- fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
1581
- }
1582
- let written = 0;
1583
- while (written < bytesRead) {
1584
- const result = await output.write(buffer, written, bytesRead - written, offset + written);
1585
- if (!result.bytesWritten) throw new Error('Could not finish copying archive contents');
1586
- written += result.bytesWritten;
1587
- }
1588
- offset += bytesRead;
1589
- }
1590
- await output.sync();
1591
- await output.chmod(mode);
1592
- await output.close();
1593
- output = null;
1594
- await rename(temporary, destinationPath);
1595
- state.bytes = currentBytes + offset;
1596
- } finally {
1597
- if (output) await output.close().catch(() => {});
1598
- await unlink(temporary).catch(() => {});
1599
- }
1600
- }
1601
-
1602
- async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1603
- throwIfCancelled(state.signal);
1604
- const initial = await lstat(source);
1605
- if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
1606
- state.entries += 1;
1607
- if (state.entries > 100_000) fail('Archive contains too many entries');
1608
- if (initial.isFile()) {
1609
- const opened = source.startsWith('/proc/self/fd/')
1610
- ? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
1611
- : await openArchiveSource(source);
1612
- try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
1613
- finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
1614
- return;
1615
- }
1616
- let directoryHandle = null;
1617
- let sourcePath = source;
1618
- let info = initial;
1619
- try {
1620
- try {
1621
- directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
1622
- info = await directoryHandle.handle.stat();
1623
- if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1624
- sourcePath = directoryHandle.anchor;
1625
- } catch (error) {
1626
- if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1627
- throw error;
1628
- }
1629
- const mode = info.mode & 0o777;
1630
- const existing = await lstat(destination).catch(() => null);
1631
- if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
1632
- try { await ensureDirectoryPath(destination, { allowOutside:true }); }
1633
- catch (error) { if (error?.code !== 'EEXIST') throw error; }
1634
- const destinationInfo = await lstat(destination);
1635
- if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1636
- let destinationHandle;
1637
- try {
1638
- destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
1639
- const openedDestination = await destinationHandle.handle.stat();
1640
- if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1641
- await destinationHandle.handle.chmod(mode | 0o700);
1642
- const destinationAnchor = destinationHandle.anchor;
1643
- for (const name of await readdir(sourcePath)) {
1644
- await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
1645
- }
1646
- await destinationHandle.handle.chmod(mode);
1647
- } finally {
1648
- if (destinationHandle) await destinationHandle.close().catch(() => {});
1649
- }
1650
- } finally {
1651
- if (directoryHandle) await directoryHandle.close().catch(() => {});
1652
- }
1653
- }
1654
-
1655
- async function removeExtractionArtifact(target) {
1656
- const info = await lstat(target).catch(() => null);
1657
- if (!info) return;
1658
- if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true, force: true });
1659
- else await unlink(target).catch(error => { if (error?.code !== 'ENOENT') throw error; });
1660
- }
1661
-
1662
- async function recoverExtractionArtifacts(parent, destination, prefix) {
1663
- const parentDirectory = await openDirectoryPath(parent, { create:true });
1664
- const anchor = parentDirectory.anchor;
1665
- const destinationPath = path.join(anchor, path.basename(destination));
1666
- try {
1667
- const entries = await readdir(anchor, { withFileTypes: true });
1668
- const staleBefore = Date.now() - 60 * 60 * 1000;
1669
- for (const entry of entries) {
1670
- if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
1671
- const candidate = path.join(anchor, entry.name);
1672
- const info = await lstat(candidate).catch(() => null);
1673
- if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
1674
- }
1675
- const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
1676
- for (const backup of backups) {
1677
- const destinationInfo = await lstat(destinationPath).catch(() => null);
1678
- if (destinationInfo) {
1679
- await removeExtractionArtifact(backup);
1680
- continue;
1681
- }
1682
- const backupInfo = await lstat(backup).catch(() => null);
1683
- if (!backupInfo) continue;
1684
- if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
1685
- await removeExtractionArtifact(backup);
1686
- continue;
1687
- }
1688
- try {
1689
- await rename(backup, destinationPath);
1690
- } catch {
1691
- continue;
1692
- }
1693
- }
1694
- } finally {
1695
- await parentDirectory.close().catch(() => {});
1696
- }
1697
- }
1698
-
1699
- async function restoreExtractionBackup(backup, destination) {
1700
- if (await lstat(destination).catch(() => null)) return false;
1701
- const info = await lstat(backup).catch(() => null);
1702
- if (!info) return false;
1703
- if (info.isSymbolicLink() || !info.isDirectory()) {
1704
- await removeExtractionArtifact(backup);
1705
- return false;
1706
- }
1707
- try {
1708
- await rename(backup, destination);
1709
- return true;
1710
- } catch {
1711
- return false;
1712
- }
1713
- }
1714
-
1715
- async function installExtractedTree(source, destination) {
1716
- const parent = path.dirname(destination);
1717
- if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1718
- const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1719
- await recoverExtractionArtifacts(parent, destination, backupPrefix);
1720
- const parentDirectory = await openDirectoryPath(parent, { create:true });
1721
- const parentAnchor = parentDirectory.anchor;
1722
- const destinationPath = path.join(parentAnchor, path.basename(destination));
1723
- const existing = await lstat(destinationPath).catch(() => null);
1724
- const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
1725
- let transaction = '';
1726
- let backup = '';
1727
- let backupOwned = false;
1728
- let preserveBackup = false;
1729
- try {
1730
- transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
1731
- if (existing) {
1732
- backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
1733
- try {
1734
- await rename(destinationPath, backup);
1735
- backupOwned = true;
1736
- } catch (error) {
1737
- if (error?.code !== 'ENOENT') throw error;
1738
- backup = '';
1739
- }
1740
- }
1741
- if (backupOwned) {
1742
- const backupInfo = await lstat(backup);
1743
- if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
1744
- await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1745
- }
1746
- await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
1747
- await chmod(transaction, destinationMode);
1748
- await rename(transaction, destinationPath);
1749
- if (backupOwned) {
1750
- await removeExtractionArtifact(backup);
1751
- backupOwned = false;
1752
- }
1753
- } catch (error) {
1754
- if (backupOwned) {
1755
- try {
1756
- await restoreExtractionBackup(backup, destinationPath);
1757
- if (!await lstat(backup).catch(() => null)) backupOwned = false;
1758
- else preserveBackup = true;
1759
- } catch (restoreError) {
1760
- preserveBackup = true;
1761
- throw new AggregateError([error, restoreError], 'Could not restore archive destination after extraction failure');
1762
- }
1763
- }
1764
- throw error;
1765
- } finally {
1766
- if (transaction) await rm(transaction, { recursive: true, force: true });
1767
- if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
1768
- await parentDirectory.close().catch(() => {});
1769
- }
1770
- }
1771
-
1772
- async function moveArchiveFile(source, destination) {
1773
- const parent = path.dirname(destination);
1774
- const parentDirectory = await openDirectoryPath(parent, { create:true });
1775
- const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
1776
- try {
1777
- try {
1778
- await rename(source, destinationPath);
1779
- } catch (error) {
1780
- if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
1781
- const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
1782
- try {
1783
- await copyFile(source, temporary, constants.COPYFILE_EXCL);
1784
- await rename(temporary, destinationPath);
1785
- } finally {
1786
- await unlink(temporary).catch(() => {});
1787
- }
1788
- await unlink(source);
1789
- }
1790
- } finally {
1791
- await parentDirectory.close().catch(() => {});
1792
- }
1793
- }
1794
-
1795
- function archiveCommandProbe(name, versionArgs = ['--version']) {
1796
- const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
1797
- return !result.error && result.status === 0 ? name : null;
1798
- }
1799
-
1800
- function archiveTool({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1801
- const tarName = platform === 'win32' ? 'tar.exe' : 'tar';
1802
- // Windows ships libarchive/bsdtar as tar.exe. It can create/read ZIP as well as tar-family
1803
- // archives, so keeping one backend avoids introducing a second extraction/safety path.
1804
- if (platform === 'win32') return { tar: probe(tarName), zip:null, unzip:null };
1805
- // Info-ZIP unzip (the default on Debian/Ubuntu) treats --version as an invalid combination and
1806
- // exits 10 even though the binary is healthy. Its portable version probe is -v.
1807
- return { tar: probe(tarName), zip: probe('zip'), unzip: probe('unzip', ['-v']) };
1808
- }
1809
-
1810
- export function archiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1811
- if (platform === 'win32') return Boolean(archiveTool({ platform, probe }).tar);
1812
- return platform === 'linux' || platform === 'darwin';
1813
- }
1814
-
1815
- export function zipArchiveBackendAvailable({ platform = process.platform, probe = archiveCommandProbe } = {}) {
1816
- const tools = archiveTool({ platform, probe });
1817
- if (platform === 'win32') return Boolean(tools.tar);
1818
- if (platform === 'linux' || platform === 'darwin') return Boolean(tools.zip && tools.unzip);
1819
- return false;
1820
- }
1821
-
1822
- export async function createArchiveTool(args, extra = {}) {
1823
- const tools = archiveTool();
1824
- const sources = Array.isArray(args.paths) ? args.paths : [args.paths].filter(Boolean);
1825
- if (!sources.length) fail('paths must list at least one file or directory');
1826
- const resolved = [];
1827
- for (const entry of sources) {
1828
- throwIfCancelled(extra.signal);
1829
- resolved.push(await resolveSafePath(entry, 'paths[]'));
1830
- }
1831
- const destination = await resolveSafePath(args.destination, 'destination');
1832
- const existingDestination = await lstat(destination).catch(() => null);
1833
- if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
1834
- const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
1835
- const baseDir = path.dirname(resolved[0]);
1836
- const names = resolved.map(entry => path.relative(baseDir, entry));
1837
- if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
1838
- const archiveNames = names.map(name => {
1839
- const portable = name.split(path.sep).join('/');
1840
- return portable.startsWith('-') ? `./${portable}` : portable;
1841
- });
1842
- const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
1843
- const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
1844
- if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
1845
- await rm(stagingDirectory, { recursive: true, force: true });
1846
- fail('Archive source must not contain the system temporary directory');
1847
- }
1848
- const staging = path.join(stagingDirectory, `payload${suffix}`);
1849
- const snapshotRoot = path.join(stagingDirectory, 'source');
1850
- try {
1851
- await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
1852
-
1853
- const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES, signal: extra.signal };
1854
- for (let index = 0; index < resolved.length; index += 1) {
1855
- throwIfCancelled(extra.signal);
1856
- const snapshotPath = path.join(snapshotRoot, names[index]);
1857
- await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
1858
-
1859
- await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1860
- }
1861
- if (format === 'zip') {
1862
- if (process.platform === 'win32') {
1863
- if (!tools.tar) fail('Windows archive backend is unavailable: tar.exe is required');
1864
- const result = spawnSync(tools.tar, ['-a', '-c', '-f', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1865
- if (result.status !== 0) fail(`tar.exe ZIP creation failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1866
- } else {
1867
- if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
1868
- const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1869
- if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1870
- }
1871
- } else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
1872
- if (!tools.tar) fail('tar is not installed on this device');
1873
- const flags = format === 'tar' ? '-cf' : '-czf';
1874
- const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1875
- if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1876
- } else {
1877
- fail('format must be tar, tar.gz, or zip');
1878
- }
1879
- const stagedInfo = await stat(staging);
1880
- assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1881
-
1882
- await moveArchiveFile(staging, destination);
1883
-
1884
- const info = await stat(destination).catch(() => null);
1885
- const note = ' (built outside the tree in temporary staging and committed atomically)';
1886
-
1887
- return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
1888
- } finally {
1889
- if (stagingDirectory) await rm(stagingDirectory, { recursive: true, force: true });
1890
- }
1891
- }
1892
-
1893
- function archiveFormatForPath(value) {
1894
- if (/\.zip$/i.test(value)) return 'zip';
1895
- if (/\.(tar\.gz|tgz)$/i.test(value)) return 'tar.gz';
1896
- if (/\.(tar\.bz2|tbz2?)$/i.test(value)) return 'tar.bz2';
1897
- if (/\.tar\.xz$/i.test(value)) return 'tar.xz';
1898
- return 'tar';
1899
- }
1900
-
1901
- function archiveSnapshotSuffix(format) {
1902
- if (format === 'zip') return '.zip';
1903
- if (format === 'tar.bz2') return '.tar.bz2';
1904
- if (format === 'tar.xz') return '.tar.xz';
1905
- if (format === 'tar.gz') return '.tar.gz';
1906
- return '.tar';
1907
- }
1908
-
1909
- export async function extractArchiveTool(args) {
1910
- const tools = archiveTool();
1911
- const archive = await resolveSafePath(args.archive, 'archive');
1912
- const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
1913
- if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1914
- const parent = path.dirname(destination);
1915
- const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1916
- const format = archiveFormatForPath(archive);
1917
- const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-extract-'));
1918
- const archiveSnapshot = path.join(stagingDirectory, `source${archiveSnapshotSuffix(format)}`);
1919
- const staging = path.join(stagingDirectory, 'tree');
1920
- try {
1921
- await mkdir(staging, { mode: 0o700 });
1922
- await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
1923
- await recoverExtractionArtifacts(parent, destination, backupPrefix);
1924
- const existingDestination = await lstat(destination).catch(() => null);
1925
- if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
1926
- if (format === 'zip' && process.platform !== 'win32') {
1927
- if (!tools.unzip) fail('unzip is not installed on this device');
1928
- const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1929
- if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
1930
- const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
1931
- if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
1932
- const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1933
- if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
1934
- const detailText = String(details.stdout || '');
1935
- if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
1936
- const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
1937
- assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
1938
- const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1939
- if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1940
- } else {
1941
- if (!tools.tar) fail(process.platform === 'win32' ? 'Windows archive backend is unavailable: tar.exe is required' : 'tar is not installed on this device');
1942
- const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
1943
- const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1944
- if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
1945
- const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
1946
- if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
1947
- const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
1948
- const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
1949
- if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
1950
- const detailLines = String(details.stdout || '').split(/\r?\n/);
1951
- if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
1952
- const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
1953
- assertArchiveExpandedSize(expandedBytes, 'archive');
1954
- const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
1955
- const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
1956
- if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
1957
- }
1958
- await assertExtractedTree(staging);
1959
- await installExtractedTree(staging, destination);
1960
- const entries = await readdir(destination).catch(() => []);
1961
- return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
1962
- } finally {
1963
- await rm(stagingDirectory, { recursive: true, force: true });
1964
- }
1965
- }
1966
-
1967
- // --- screenshots ----------------------------------------------------------------------
1968
- const SCREENSHOT_COMMANDS = [
1969
- { command: 'grim', args: file => [file] },
1970
- { command: 'gnome-screenshot', args: file => ['-f', file] },
1971
- { command: 'spectacle', args: file => ['-b', '-n', '-o', file] },
1972
- { command: 'scrot', args: file => ['-o', file] },
1973
- { command: 'import', args: file => ['-window', 'root', file] },
1974
- { command: 'screencapture', args: file => ['-x', file] },
1975
- ];
1976
-
1977
- // What to tell the person when no capture worked. The general "install a screenshot tool" line was
1978
- // wrong on a Mac, where `screencapture` ships with the system and fails only because TCC has not
1979
- // granted Screen Recording — the exact failure behind "the screenshot returned an error" reports.
1980
- function screenshotAdvice(attempts, { platform = process.platform, env = process.env, execPath = process.execPath } = {}) {
1981
- if (platform === 'darwin') {
1982
- return `macOS refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Grant Screen Recording to the binary that runs the tools — System Settings → Privacy & Security → Screen Recording → + → ${execPath} — then restart the agent with \`remcp start\`. macOS requires it for screencapture even when the file itself is writable.`;
1983
- }
1984
- if (platform === 'win32') {
1985
- return `Windows refused the screen capture (${attempts.join('; ') || 'no capture command ran'}). Screen capture needs an interactive desktop session: a machine where nobody is signed in, or a locked session, cannot be captured. Sign in on that computer and try again.`;
1986
- }
1987
- const wayland = /wayland/i.test(String(env.XDG_SESSION_TYPE || '')) || Boolean(env.WAYLAND_DISPLAY);
1988
- if (!env.DISPLAY && !wayland) {
1989
- return `This computer has no graphical session (no DISPLAY and no Wayland display), so there is nothing to capture — servers and containers usually have none.`;
1990
- }
1991
- // GNOME on Wayland is its own case: Mutter does not expose wlr-screencopy, so grim cannot
1992
- // capture it. ReMCP tries the compositor-supported XDG Desktop Portal first; local screenshot
1993
- // commands remain fallbacks for environments where the portal is unavailable.
1994
- if (wayland && /gnome/i.test(String(env.XDG_CURRENT_DESKTOP || ''))) {
1995
- return `Could not capture the screen on GNOME Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first, which is GNOME's supported screenshot API. Make sure xdg-desktop-portal and xdg-desktop-portal-gnome are installed and the agent is running inside the signed-in user's graphical session; command-line capture helpers are only fallbacks.`;
1996
- }
1997
- if (wayland) {
1998
- return `Could not capture the screen on Wayland (${attempts.join('; ') || 'no capture backend ran'}). ReMCP tried the XDG Desktop Portal first. Verify xdg-desktop-portal is running; on wlroots compositors, \`grim\` is also supported as a fallback.`;
1999
- }
2000
- return `Could not capture the screen. Install one of grim, gnome-screenshot, spectacle, scrot, or ImageMagick import (tried: ${attempts.join('; ') || 'none available'}).`;
2001
- }
2002
-
2003
- function windowsScreenshotScript(file) {
2004
- return [
2005
- 'Add-Type -AssemblyName System.Windows.Forms,System.Drawing',
2006
- '$b = [System.Windows.Forms.SystemInformation]::VirtualScreen',
2007
- '$bmp = New-Object System.Drawing.Bitmap $b.Width, $b.Height',
2008
- '$g = [System.Drawing.Graphics]::FromImage($bmp)',
2009
- '$g.CopyFromScreen($b.Left, $b.Top, 0, 0, $bmp.Size)',
2010
- `$bmp.Save('${file.replace(/'/g, "''")}', [System.Drawing.Imaging.ImageFormat]::Png)`,
2011
- ].join('; ');
2012
- }
2013
-
2014
- export async function takeScreenshotTool(args) {
2015
- const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
2016
- const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
2017
- const directory = requestedDirectory || temporaryDirectory;
2018
- let outputDirectory = null;
2019
- if (requestedDirectory) {
2020
- await ensureDirectoryPath(directory);
2021
- outputDirectory = await openWritableParent(directory);
2022
- }
2023
- const outputAnchor = outputDirectory
2024
- ? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
2025
- : directory;
2026
- const fileName = `capture-${randomUUID()}.png`;
2027
- const file = path.join(outputAnchor, fileName);
2028
- const displayFile = path.join(directory, fileName);
2029
- const attempts = [];
2030
- let preserveCapture = false;
2031
- try {
2032
- if (process.platform === 'win32') {
2033
- const result = spawnSync('powershell.exe', ['-NoProfile', '-NonInteractive', '-Command', windowsScreenshotScript(file)], { encoding: 'utf8', timeout: 30000 });
2034
- attempts.push(`powershell: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
2035
- } else {
2036
- if (process.platform === 'linux' && isWaylandSession()) {
2037
- try {
2038
- await capturePortalScreenshot(file);
2039
- } catch (error) {
2040
- const message = error instanceof Error ? error.message : `xdg-desktop-portal: ${String(error)}`;
2041
- attempts.push(message);
2042
- if (error && typeof error === 'object' && error.code === 'PORTAL_CANCELLED') {
2043
- fail(`Screen capture was cancelled in the desktop permission dialog (${message}).`);
2044
- }
2045
- }
2046
- }
2047
- if (!await pathExists(file)) {
2048
- for (const candidate of SCREENSHOT_COMMANDS) {
2049
- if (spawnSync('which', [candidate.command], { encoding: 'utf8' }).status !== 0) continue;
2050
- const result = spawnSync(candidate.command, candidate.args(file), { encoding: 'utf8', timeout: 30000 });
2051
- if (result.status === 0 && await pathExists(file)) break;
2052
- attempts.push(`${candidate.command}: ${(result.stderr || '').trim() || ('exit ' + result.status)}`);
2053
- }
2054
- }
2055
- }
2056
- if (!await pathExists(file)) fail(screenshotAdvice(attempts));
2057
-
2058
- const opened = await openRegularFile(file);
2059
- const { handle, info } = opened;
2060
- try {
2061
- if (info.size <= MAX_IMAGE_BYTES) {
2062
- const buffer = await handle.readFile();
2063
- preserveCapture = args.keep === true;
2064
- return multi([
2065
- { type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
2066
- image(buffer.toString('base64'), 'image/png'),
2067
- ]);
2068
- }
2069
- preserveCapture = true;
2070
- return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(displayFile)} instead of being returned as an image. Fetch it with read_binary using chunks of up to ${MAX_BINARY_CHUNK_BYTES} bytes (offset_bytes and length_bytes), or ask for a smaller region.`);
2071
- } finally {
2072
- await opened.close();
2073
- }
2074
- } finally {
2075
- if (!preserveCapture) {
2076
- if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
2077
- else await rm(file, { force: true }).catch(() => {});
2078
- }
2079
- if (outputDirectory) await outputDirectory.close().catch(() => {});
2080
- }
2081
- }
2082
- export const fileToolHandlers = {
2083
- read_file: readFileTool,
2084
- read_files: readFilesTool,
2085
- read_multiple_files: readMultipleFilesTool,
2086
- read_image: readImageTool,
2087
- read_binary: readBinaryTool,
2088
- hash_file: hashFileTool,
2089
- list_directory: listDirectoryTool,
2090
- get_file_info: getFileInfoTool,
2091
- write_file: writeFileTool,
2092
- write_files: writeFilesTool,
2093
- write_binary: writeBinaryTool,
2094
- edit_block: editBlockTool,
2095
- replace_lines: replaceLinesTool,
2096
- replace_in_files: replaceInFilesTool,
2097
- diff_files: diffFilesTool,
2098
- create_directory: createDirectoryTool,
2099
- apply_patch: applyPatchTool,
2100
- set_permissions: setPermissionsTool,
2101
- delete_path: deletePathTool,
2102
- delete_paths: deletePathsTool,
2103
- move_file: moveFileTool,
2104
- move_paths: movePathsTool,
2105
- copy_file: copyFileTool,
2106
- copy_paths: copyPathsTool,
2107
- move_to_trash: moveToTrashTool,
2108
- create_archive: createArchiveTool,
2109
- extract_archive: extractArchiveTool,
2110
- take_screenshot: takeScreenshotTool,
2111
- };
1
+ // First-party filesystem handlers: safe path, read, write, batch, archive and visual modules.
2
+ export { openDirectoryPath, openRegularFile } from './files-secure-paths.mjs';
3
+ export { readFileTool, readMultipleFilesTool, readImageTool, hashFileTool, listDirectoryTool, getFileInfoTool } from './files-read-traversal.mjs';
4
+ export { writeFileTool, readBinaryTool, writeBinaryTool, editBlockTool, replaceLinesTool, replaceInFilesTool, diffFilesTool, moveToTrashTool } from './files-write-edit.mjs';
5
+ export { readFilesTool, writeFilesTool, deletePathTool, deletePathsTool, copyPathsTool, movePathsTool, applyPatchTool, setPermissionsTool, createDirectoryTool, moveFileTool, copyFileTool } from './files-bulk-operations.mjs';
6
+ export { tarEntrySize, archiveBackendAvailable, zipArchiveBackendAvailable } from './files-archive-safety.mjs';
7
+ export { createArchiveTool, extractArchiveTool } from './files-archive-actions.mjs';
8
+ export { takeScreenshotTool, fileToolHandlers } from './files-screenshots.mjs';