@remcp/runtime 0.2.107 → 0.2.109
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +14 -2
- package/package.json +2 -2
- package/src/catalog.mjs +8 -0
- package/src/compact-catalog.mjs +2 -2
- package/src/extended/browser.mjs +384 -40
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +252 -82
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +738 -288
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +4 -0
package/src/tools/files.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import os from 'node:os';
|
|
|
3
3
|
import { spawnSync } from 'node:child_process';
|
|
4
4
|
import { createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
import { constants, createReadStream } from 'node:fs';
|
|
6
|
-
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink
|
|
6
|
+
import { access, chmod, chown, copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink } from 'node:fs/promises';
|
|
7
7
|
import { pipeline } from 'node:stream/promises';
|
|
8
8
|
import { liveConfig, runtimeConfig } from '../config.mjs';
|
|
9
9
|
import { documentKind, readDocxText, readPdfText } from '../documents.mjs';
|
|
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
|
|
|
12
12
|
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
13
13
|
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
14
14
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
15
|
-
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
15
|
+
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, isInsideRoot, looksBinary, multi, pageLines, resolveSafePath, splitLines, text } from '../util.mjs';
|
|
16
16
|
|
|
17
17
|
const MAX_INLINE_FILE_BYTES = 20 * 1024 * 1024;
|
|
18
18
|
// An image travels base64-encoded, which costs a third more bytes. The agent's stdio transport holds
|
|
@@ -49,24 +49,326 @@ const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants
|
|
|
49
49
|
const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
|
|
50
50
|
const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
|
|
51
51
|
|
|
52
|
-
async function
|
|
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
|
+
}
|
|
53
282
|
const handle = await open(absolute, flags);
|
|
283
|
+
return { handle, close: () => handle.close() };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function openRegularFileAtPath(absolute, flags, label = absolute) {
|
|
287
|
+
const opened = await openEntryAtPath(absolute, flags);
|
|
54
288
|
try {
|
|
55
|
-
const info = assertRegularFile(await handle.stat(),
|
|
56
|
-
return { handle, info };
|
|
289
|
+
const info = assertRegularFile(await opened.handle.stat(), label);
|
|
290
|
+
return { handle: opened.handle, info, close: opened.close };
|
|
57
291
|
} catch (error) {
|
|
58
|
-
await
|
|
292
|
+
await opened.close().catch(() => {});
|
|
59
293
|
throw error;
|
|
60
294
|
}
|
|
61
295
|
}
|
|
62
296
|
|
|
297
|
+
export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
298
|
+
return openRegularFileAtPath(absolute, flags, absolute);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function openWritableParent(absolute, options = {}) {
|
|
302
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
303
|
+
return openDirectoryPath(absolute, { create:true, ...options });
|
|
304
|
+
}
|
|
305
|
+
await mkdir(absolute, { recursive:true });
|
|
306
|
+
const info = await lstat(absolute);
|
|
307
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
308
|
+
return { anchor:absolute, close:async() => {} };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
async function writeResolvedFile(absolute, content, { append = false } = {}) {
|
|
312
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
313
|
+
let handle;
|
|
314
|
+
try {
|
|
315
|
+
const flags = append
|
|
316
|
+
? constants.O_WRONLY | constants.O_CREAT | (constants.O_APPEND || 0) | NOFOLLOW | NONBLOCK
|
|
317
|
+
: WRITE_TRUNCATE_NOFOLLOW;
|
|
318
|
+
handle = await open(path.join(parent.anchor, path.basename(absolute)), flags, 0o600);
|
|
319
|
+
await handle.writeFile(content);
|
|
320
|
+
} finally {
|
|
321
|
+
if (handle) await handle.close().catch(() => {});
|
|
322
|
+
await parent.close().catch(() => {});
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
async function withWritableParent(absolute, callback) {
|
|
327
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
328
|
+
try {
|
|
329
|
+
return await callback(path.join(parent.anchor, path.basename(absolute)));
|
|
330
|
+
} finally {
|
|
331
|
+
await parent.close().catch(() => {});
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
async function openExistingParent(absolute) {
|
|
336
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
337
|
+
return openDirectoryPath(absolute);
|
|
338
|
+
}
|
|
339
|
+
const info = await lstat(absolute);
|
|
340
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
341
|
+
return { anchor:absolute, close:async() => {} };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function withExistingParent(absolute, callback) {
|
|
345
|
+
const parent = await openExistingParent(path.dirname(absolute));
|
|
346
|
+
try {
|
|
347
|
+
return await callback(path.join(parent.anchor, path.basename(absolute)));
|
|
348
|
+
} finally {
|
|
349
|
+
await parent.close().catch(() => {});
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async function ensureDirectoryPath(absolute, options = {}) {
|
|
354
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
355
|
+
const directory = await openDirectoryPath(absolute, { create:true, ...options });
|
|
356
|
+
await directory.close();
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
await mkdir(absolute, { recursive:true });
|
|
360
|
+
const info = await lstat(absolute);
|
|
361
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
362
|
+
}
|
|
363
|
+
|
|
63
364
|
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
64
|
-
const
|
|
365
|
+
const opened = await openRegularFile(absolute);
|
|
366
|
+
const { handle, info } = opened;
|
|
65
367
|
try {
|
|
66
368
|
if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
|
|
67
369
|
return { info, buffer: await handle.readFile() };
|
|
68
370
|
} finally {
|
|
69
|
-
await
|
|
371
|
+
await opened.close();
|
|
70
372
|
}
|
|
71
373
|
}
|
|
72
374
|
|
|
@@ -90,29 +392,65 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
|
90
392
|
const found = [];
|
|
91
393
|
const denied = [];
|
|
92
394
|
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
93
|
-
async
|
|
94
|
-
if (found.length >= maxFiles) return;
|
|
95
|
-
const info = await lstat(target).catch(() => null);
|
|
96
|
-
// A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
|
|
97
|
-
// and must not be reported as one.
|
|
98
|
-
if (!info || info.isSymbolicLink()) return;
|
|
99
|
-
if (info.isFile()) { found.push(target); return; }
|
|
100
|
-
if (!info.isDirectory()) return;
|
|
395
|
+
const visitDirectory = async (directory, logicalBase) => {
|
|
101
396
|
let entries;
|
|
102
397
|
try {
|
|
103
|
-
entries = await readdir(
|
|
398
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
104
399
|
} catch {
|
|
105
|
-
|
|
106
|
-
denied.push(target);
|
|
400
|
+
denied.push(logicalBase);
|
|
107
401
|
return;
|
|
108
402
|
}
|
|
109
403
|
for (const entry of entries) {
|
|
110
404
|
if (found.length >= maxFiles) return;
|
|
111
405
|
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
112
|
-
|
|
406
|
+
const childLogical = path.join(logicalBase, entry.name);
|
|
407
|
+
const childAnchor = path.join(directory.anchor, entry.name);
|
|
408
|
+
if (entry.isDirectory()) {
|
|
409
|
+
let child;
|
|
410
|
+
try {
|
|
411
|
+
child = await openDirectoryPath(childAnchor);
|
|
412
|
+
const info = await child.handle.stat();
|
|
413
|
+
if (!info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
414
|
+
await visitDirectory(child, childLogical);
|
|
415
|
+
} catch {
|
|
416
|
+
denied.push(childLogical);
|
|
417
|
+
} finally {
|
|
418
|
+
if (child) await child.close().catch(() => {});
|
|
419
|
+
}
|
|
420
|
+
} else if (entry.isFile()) {
|
|
421
|
+
let opened;
|
|
422
|
+
try {
|
|
423
|
+
opened = await openEntryAtPath(childAnchor, READ_NOFOLLOW_NONBLOCK);
|
|
424
|
+
const info = await opened.handle.stat();
|
|
425
|
+
if (info.isFile()) found.push(childLogical);
|
|
426
|
+
} catch {
|
|
427
|
+
denied.push(childLogical);
|
|
428
|
+
} finally {
|
|
429
|
+
if (opened) await opened.close().catch(() => {});
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (found.length >= maxFiles) return;
|
|
113
433
|
}
|
|
434
|
+
};
|
|
435
|
+
let directory;
|
|
436
|
+
try {
|
|
437
|
+
directory = await openDirectoryPath(root);
|
|
438
|
+
await visitDirectory(directory, root);
|
|
439
|
+
} catch {
|
|
440
|
+
let opened;
|
|
441
|
+
try {
|
|
442
|
+
opened = await openEntryAtPath(root, READ_NOFOLLOW_NONBLOCK);
|
|
443
|
+
const info = await opened.handle.stat();
|
|
444
|
+
if (info.isFile()) found.push(root);
|
|
445
|
+
else denied.push(root);
|
|
446
|
+
} catch {
|
|
447
|
+
denied.push(root);
|
|
448
|
+
} finally {
|
|
449
|
+
if (opened) await opened.close().catch(() => {});
|
|
450
|
+
}
|
|
451
|
+
} finally {
|
|
452
|
+
if (directory) await directory.close().catch(() => {});
|
|
114
453
|
}
|
|
115
|
-
await visit(root);
|
|
116
454
|
return { files: found, denied };
|
|
117
455
|
}
|
|
118
456
|
|
|
@@ -229,37 +567,64 @@ export async function hashFileTool(args) {
|
|
|
229
567
|
}
|
|
230
568
|
const { handle, info } = opened;
|
|
231
569
|
const algorithm = String(args.algorithm || 'sha256').toLowerCase();
|
|
232
|
-
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await
|
|
570
|
+
if (!['sha256', 'sha1', 'md5'].includes(algorithm)) { await opened.close(); fail('algorithm must be sha256, sha1, or md5'); }
|
|
233
571
|
const hash = createHash(algorithm);
|
|
234
572
|
try {
|
|
235
573
|
await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
|
|
236
574
|
return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
|
|
237
575
|
} finally {
|
|
238
|
-
await
|
|
576
|
+
await opened.close();
|
|
239
577
|
}
|
|
240
578
|
}
|
|
241
579
|
|
|
242
|
-
async function
|
|
580
|
+
async function openListEntry(base, name) {
|
|
581
|
+
const opened = await openEntryAtPath(path.join(base, name), READ_NOFOLLOW_NONBLOCK);
|
|
582
|
+
try {
|
|
583
|
+
return { handle: opened.handle, info: await opened.handle.stat(), close: opened.close };
|
|
584
|
+
} catch (error) {
|
|
585
|
+
await opened.close().catch(() => {});
|
|
586
|
+
throw error;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function listEntry(directory, depth, maxDepth, prefix, pattern, displayBase) {
|
|
243
591
|
let entries;
|
|
244
592
|
try {
|
|
245
|
-
entries = await readdir(
|
|
593
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
246
594
|
} catch (error) {
|
|
247
|
-
|
|
248
|
-
return [`${prefix}[DENIED] ${path.basename(base)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
|
|
595
|
+
return [`${prefix}[DENIED] ${path.basename(displayBase)} (${error instanceof Error ? error.code || error.message : 'unreadable'})`];
|
|
249
596
|
}
|
|
250
597
|
entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
251
598
|
const rows = [];
|
|
252
599
|
for (const entry of entries) {
|
|
253
|
-
const
|
|
600
|
+
const childDisplay = path.join(displayBase, entry.name);
|
|
254
601
|
if (entry.isDirectory()) {
|
|
255
602
|
rows.push(`${prefix}[DIR] ${entry.name}`);
|
|
256
|
-
if (depth
|
|
603
|
+
if (depth >= maxDepth) continue;
|
|
604
|
+
let child;
|
|
605
|
+
try {
|
|
606
|
+
child = await openDirectoryPath(path.join(directory.anchor, entry.name));
|
|
607
|
+
const childInfo = await child.handle.stat();
|
|
608
|
+
if (!childInfo.isDirectory()) fail('Filesystem path component is not a directory');
|
|
609
|
+
rows.push(...await listEntry(child, depth + 1, maxDepth, `${prefix} `.replace(/ {2}$/, '') + ' ', pattern, childDisplay));
|
|
610
|
+
} catch (error) {
|
|
611
|
+
rows.push(`${prefix} [DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
612
|
+
} finally {
|
|
613
|
+
if (child) await child.close().catch(() => {});
|
|
614
|
+
}
|
|
257
615
|
} else if (entry.isSymbolicLink()) {
|
|
258
616
|
rows.push(`${prefix}[LINK] ${entry.name}`);
|
|
259
617
|
} else {
|
|
260
618
|
if (pattern && !pattern.test(entry.name)) continue;
|
|
261
|
-
|
|
262
|
-
|
|
619
|
+
let opened;
|
|
620
|
+
try {
|
|
621
|
+
opened = await openListEntry(directory.anchor, entry.name);
|
|
622
|
+
rows.push(`${prefix}[${opened.info.isFile() ? 'FILE' : 'SPECIAL'}] ${entry.name}${opened.info.isFile() ? ` (${opened.info.size} bytes)` : ''}`);
|
|
623
|
+
} catch (error) {
|
|
624
|
+
rows.push(`${prefix}[DENIED] ${entry.name} (${error instanceof Error ? error.code || error.message : 'unreadable'})`);
|
|
625
|
+
} finally {
|
|
626
|
+
if (opened) await opened.close().catch(() => {});
|
|
627
|
+
}
|
|
263
628
|
}
|
|
264
629
|
}
|
|
265
630
|
return rows;
|
|
@@ -267,39 +632,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
|
|
|
267
632
|
|
|
268
633
|
export async function listDirectoryTool(args) {
|
|
269
634
|
const absolute = await resolveSafePath(args.path);
|
|
270
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
271
|
-
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
272
635
|
const depth = clampInteger(args.depth, 1, 1, 5);
|
|
273
636
|
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
|
|
274
|
-
|
|
275
|
-
|
|
637
|
+
let directory;
|
|
638
|
+
try {
|
|
639
|
+
directory = await openDirectoryPath(absolute);
|
|
640
|
+
const info = await directory.handle.stat();
|
|
641
|
+
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
642
|
+
const rows = await listEntry(directory, 1, depth, '', pattern, absolute);
|
|
643
|
+
return text(`${displayPath(absolute)}${pattern ? ` · matching ${args.pattern}` : ''}\n${rows.join('\n') || '(empty)'}`);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
646
|
+
throw error;
|
|
647
|
+
} finally {
|
|
648
|
+
if (directory) await directory.close().catch(() => {});
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function openPathForInfo(absolute) {
|
|
653
|
+
let directory;
|
|
654
|
+
try {
|
|
655
|
+
directory = await openDirectoryPath(absolute);
|
|
656
|
+
return { info: await directory.handle.stat(), close: directory.close };
|
|
657
|
+
} catch (directoryError) {
|
|
658
|
+
const canTryFile = directoryError?.code === 'ENOTDIR'
|
|
659
|
+
|| directoryError?.code === 'EISDIR'
|
|
660
|
+
|| (!directoryError?.code && /not a directory/i.test(directoryError?.message || ''));
|
|
661
|
+
if (!canTryFile) throw directoryError;
|
|
662
|
+
}
|
|
663
|
+
const opened = await openEntryAtPath(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
664
|
+
try {
|
|
665
|
+
return { info: await opened.handle.stat(), close: opened.close };
|
|
666
|
+
} catch (error) {
|
|
667
|
+
await opened.close().catch(() => {});
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
async function getPathInfo(absolute) {
|
|
673
|
+
const opened = await openPathForInfo(absolute);
|
|
674
|
+
try { return opened.info; }
|
|
675
|
+
finally { await opened.close().catch(() => {}); }
|
|
276
676
|
}
|
|
277
677
|
|
|
278
678
|
export async function getFileInfoTool(args) {
|
|
279
679
|
const absolute = await resolveSafePath(args.path);
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
680
|
+
let opened;
|
|
681
|
+
try {
|
|
682
|
+
opened = await openPathForInfo(absolute);
|
|
683
|
+
} catch (error) {
|
|
684
|
+
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') fail(`Path not found: ${displayPath(absolute)}`);
|
|
685
|
+
throw error;
|
|
686
|
+
}
|
|
687
|
+
const { info } = opened;
|
|
688
|
+
try {
|
|
689
|
+
const payload = {
|
|
690
|
+
path: displayPath(absolute),
|
|
691
|
+
type: info.isDirectory() ? 'directory' : info.isSymbolicLink() ? 'symlink' : info.isFile() ? 'file' : 'special',
|
|
692
|
+
size: info.size,
|
|
693
|
+
createdAt: info.birthtime.toISOString(),
|
|
694
|
+
modifiedAt: info.mtime.toISOString(),
|
|
695
|
+
permissions: `0${(info.mode & 0o777).toString(8)}`,
|
|
696
|
+
};
|
|
697
|
+
if (info.isFile() && info.size <= MAX_INLINE_FILE_BYTES) {
|
|
698
|
+
const regular = await readRegularBuffer(absolute, MAX_INLINE_FILE_BYTES).catch(() => null);
|
|
699
|
+
if (regular) {
|
|
700
|
+
const decoded = decodeText(regular.buffer);
|
|
701
|
+
if (decoded.encoding !== 'utf8') payload.encoding = decoded.encoding;
|
|
702
|
+
if (decoded.encoding !== 'utf8' || !looksBinary(regular.buffer)) {
|
|
703
|
+
const lines = splitLines(decoded.text);
|
|
704
|
+
payload.lineCount = lines.length;
|
|
705
|
+
payload.lastLine = Math.max(0, lines.length - 1);
|
|
706
|
+
payload.eol = detectEol(decoded.text) === '\r\n' ? 'CRLF' : 'LF';
|
|
707
|
+
}
|
|
299
708
|
}
|
|
300
709
|
}
|
|
710
|
+
return text(JSON.stringify(payload, null, 2));
|
|
711
|
+
} finally {
|
|
712
|
+
await opened.close().catch(() => {});
|
|
301
713
|
}
|
|
302
|
-
return text(JSON.stringify(payload, null, 2));
|
|
303
714
|
}
|
|
304
715
|
|
|
305
716
|
function assertWritableSize(content) {
|
|
@@ -331,8 +742,7 @@ export async function writeFileTool(args) {
|
|
|
331
742
|
fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
332
743
|
}
|
|
333
744
|
}
|
|
334
|
-
await
|
|
335
|
-
await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
745
|
+
await writeResolvedFile(absolute, content, { append: mode === 'append' });
|
|
336
746
|
countEvent('bytesWritten', bytes);
|
|
337
747
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
|
|
338
748
|
}
|
|
@@ -367,7 +777,7 @@ export async function readBinaryTool(args) {
|
|
|
367
777
|
});
|
|
368
778
|
return text(payload);
|
|
369
779
|
} finally {
|
|
370
|
-
await
|
|
780
|
+
await opened.close();
|
|
371
781
|
}
|
|
372
782
|
}
|
|
373
783
|
|
|
@@ -387,8 +797,7 @@ export async function writeBinaryTool(args) {
|
|
|
387
797
|
recordEvent('write_denied', { reason: 'size_limit' });
|
|
388
798
|
fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
389
799
|
}
|
|
390
|
-
await
|
|
391
|
-
await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
|
|
800
|
+
await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
|
|
392
801
|
countEvent('bytesWritten', buffer.length);
|
|
393
802
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
|
|
394
803
|
}
|
|
@@ -435,7 +844,7 @@ export async function editBlockTool(args) {
|
|
|
435
844
|
if (occurrences === expected) {
|
|
436
845
|
const updated = content.split(oldString).join(newString);
|
|
437
846
|
assertWritableSize(updated);
|
|
438
|
-
if (!dryRun) await
|
|
847
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
439
848
|
return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
|
|
440
849
|
}
|
|
441
850
|
if (occurrences > 0) {
|
|
@@ -457,7 +866,7 @@ export async function editBlockTool(args) {
|
|
|
457
866
|
// file to LF and turned a one-line change into a whole-file diff on Windows.
|
|
458
867
|
const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
|
|
459
868
|
assertWritableSize(updated);
|
|
460
|
-
if (!dryRun) await
|
|
869
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
461
870
|
return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
|
|
462
871
|
}
|
|
463
872
|
|
|
@@ -482,7 +891,7 @@ export async function replaceLinesTool(args) {
|
|
|
482
891
|
if (dryRun) {
|
|
483
892
|
return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
|
|
484
893
|
}
|
|
485
|
-
await
|
|
894
|
+
await writeResolvedFile(absolute, updatedText);
|
|
486
895
|
return text(`${summary}.`);
|
|
487
896
|
}
|
|
488
897
|
|
|
@@ -502,7 +911,7 @@ export async function replaceInFilesTool(args) {
|
|
|
502
911
|
fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
|
|
503
912
|
}
|
|
504
913
|
}
|
|
505
|
-
const info = await
|
|
914
|
+
const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
|
|
506
915
|
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
|
|
507
916
|
const files = await confineAll(walk.files);
|
|
508
917
|
const glob = filePattern ? globToRegExp(filePattern) : null;
|
|
@@ -532,7 +941,7 @@ export async function replaceInFilesTool(args) {
|
|
|
532
941
|
const stats = diffStats(original, updated);
|
|
533
942
|
changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
|
|
534
943
|
} finally {
|
|
535
|
-
await
|
|
944
|
+
await opened.close();
|
|
536
945
|
}
|
|
537
946
|
}
|
|
538
947
|
if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
|
|
@@ -562,7 +971,6 @@ function trashDirectoryFor() {
|
|
|
562
971
|
|
|
563
972
|
export async function moveToTrashTool(args) {
|
|
564
973
|
const source = await resolveSafePath(args.source, 'source');
|
|
565
|
-
const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
|
|
566
974
|
const trash = trashDirectoryFor();
|
|
567
975
|
let destination = null;
|
|
568
976
|
if (trash) {
|
|
@@ -577,7 +985,7 @@ export async function moveToTrashTool(args) {
|
|
|
577
985
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
578
986
|
if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
579
987
|
if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
|
|
580
|
-
await
|
|
988
|
+
await ensureDirectoryPath(destination);
|
|
581
989
|
destination = await resolveSafePath(destination, 'trash');
|
|
582
990
|
const destinationInfo = await lstat(destination);
|
|
583
991
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
@@ -588,11 +996,17 @@ export async function moveToTrashTool(args) {
|
|
|
588
996
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
589
997
|
counter += 1;
|
|
590
998
|
}
|
|
591
|
-
await
|
|
592
|
-
|
|
593
|
-
if (
|
|
594
|
-
await
|
|
595
|
-
|
|
999
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1000
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Path not found: ${displayPath(source)}`));
|
|
1001
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1002
|
+
await withWritableParent(target, async anchoredTarget => {
|
|
1003
|
+
await rename(anchoredSource, anchoredTarget).catch(async error => {
|
|
1004
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1005
|
+
if (sourceInfo.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
|
|
1006
|
+
await copyTreeContents(anchoredSource, anchoredTarget, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1007
|
+
await rm(anchoredSource, { force:true });
|
|
1008
|
+
});
|
|
1009
|
+
});
|
|
596
1010
|
});
|
|
597
1011
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
598
1012
|
}
|
|
@@ -606,7 +1020,7 @@ export async function readFilesTool(args) {
|
|
|
606
1020
|
const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
|
|
607
1021
|
const includeIgnored = args.include_ignored === true;
|
|
608
1022
|
const matcher = globToRegExp(pattern);
|
|
609
|
-
const rootInfo = await
|
|
1023
|
+
const rootInfo = await getPathInfo(root).catch(() => null);
|
|
610
1024
|
// A file path is matched directly; a directory is walked without following links.
|
|
611
1025
|
const walk = rootInfo?.isFile()
|
|
612
1026
|
? { files: [root], denied: [] }
|
|
@@ -661,8 +1075,7 @@ export async function writeFilesTool(args) {
|
|
|
661
1075
|
const bytes = assertWritableSize(content);
|
|
662
1076
|
totalBytes += bytes;
|
|
663
1077
|
if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
|
|
664
|
-
await
|
|
665
|
-
await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
1078
|
+
await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
|
|
666
1079
|
results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
|
|
667
1080
|
} catch (error) {
|
|
668
1081
|
results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -675,15 +1088,19 @@ export async function writeFilesTool(args) {
|
|
|
675
1088
|
|
|
676
1089
|
export async function deletePathTool(args) {
|
|
677
1090
|
const absolute = await resolveSafePath(args.path);
|
|
678
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
679
1091
|
if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
|
|
680
1092
|
const recursive = args.recursive !== false;
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
1093
|
+
let info;
|
|
1094
|
+
let entries = [];
|
|
1095
|
+
await withExistingParent(absolute, async target => {
|
|
1096
|
+
info = await lstat(target).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
1097
|
+
if (info.isDirectory() && !recursive) {
|
|
1098
|
+
entries = await readdir(target).catch(() => []);
|
|
1099
|
+
if (entries.length) fail(`Directory is not empty: ${displayPath(absolute)}. Pass recursive: true to delete it with its contents.`);
|
|
1100
|
+
}
|
|
1101
|
+
if (info.isDirectory()) entries = await readdir(target).catch(() => []);
|
|
1102
|
+
await rm(target, { recursive:true, force:false });
|
|
1103
|
+
});
|
|
687
1104
|
return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
|
|
688
1105
|
}
|
|
689
1106
|
|
|
@@ -698,13 +1115,15 @@ export async function deletePathsTool(args) {
|
|
|
698
1115
|
try {
|
|
699
1116
|
const absolute = await resolveSafePath(entry, 'paths[]');
|
|
700
1117
|
if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
1118
|
+
await withExistingParent(absolute, async target => {
|
|
1119
|
+
const info = await lstat(target).catch(() => null);
|
|
1120
|
+
if (!info) throw new Error('not found');
|
|
1121
|
+
if (info.isDirectory() && !recursive) {
|
|
1122
|
+
const children = await readdir(target).catch(() => []);
|
|
1123
|
+
if (children.length) throw new Error('directory is not empty (pass recursive: true)');
|
|
1124
|
+
}
|
|
1125
|
+
await rm(target, { recursive:true, force:false });
|
|
1126
|
+
});
|
|
708
1127
|
deleted += 1;
|
|
709
1128
|
results.push(`deleted ${displayPath(absolute)}`);
|
|
710
1129
|
} catch (error) {
|
|
@@ -728,12 +1147,11 @@ export async function copyPathsTool(args) {
|
|
|
728
1147
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
729
1148
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
730
1149
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
|
|
1150
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1151
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1152
|
+
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
1153
|
+
await copyTreeContents(source, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1154
|
+
});
|
|
737
1155
|
copied += 1;
|
|
738
1156
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
739
1157
|
} catch (error) {
|
|
@@ -755,18 +1173,22 @@ export async function movePathsTool(args) {
|
|
|
755
1173
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
756
1174
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
757
1175
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
1176
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1177
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => null);
|
|
1178
|
+
if (!sourceInfo) throw new Error('source not found');
|
|
1179
|
+
if (sourceInfo.isSymbolicLink()) throw new Error('source cannot be a symbolic link');
|
|
1180
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1181
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1182
|
+
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
1183
|
+
try {
|
|
1184
|
+
await rename(anchoredSource, anchoredDestination);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1187
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1188
|
+
await rm(anchoredSource, { recursive:true, force:true });
|
|
1189
|
+
}
|
|
1190
|
+
});
|
|
1191
|
+
});
|
|
770
1192
|
moved += 1;
|
|
771
1193
|
results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
|
|
772
1194
|
} catch (error) {
|
|
@@ -801,8 +1223,7 @@ export async function applyPatchTool(args) {
|
|
|
801
1223
|
const stats = diffStats(original, updated);
|
|
802
1224
|
if (!dryRun) {
|
|
803
1225
|
assertWritableSize(updated);
|
|
804
|
-
await
|
|
805
|
-
await writeFile(absolute, updated, 'utf8');
|
|
1226
|
+
await writeResolvedFile(absolute, updated);
|
|
806
1227
|
}
|
|
807
1228
|
changed += 1;
|
|
808
1229
|
const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
|
|
@@ -816,41 +1237,81 @@ export async function applyPatchTool(args) {
|
|
|
816
1237
|
return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
|
|
817
1238
|
}
|
|
818
1239
|
|
|
1240
|
+
async function setLinuxPermissions(target, mode, uid, gid, recursive, displayTarget, failures) {
|
|
1241
|
+
let directory = null;
|
|
1242
|
+
let file = null;
|
|
1243
|
+
try {
|
|
1244
|
+
const info = await lstat(target);
|
|
1245
|
+
if (info.isSymbolicLink()) {
|
|
1246
|
+
failures.push(`${displayTarget}: symbolic links are not changed`);
|
|
1247
|
+
return 0;
|
|
1248
|
+
}
|
|
1249
|
+
if (info.isDirectory()) {
|
|
1250
|
+
directory = await openDirectoryPath(target);
|
|
1251
|
+
let changed = 0;
|
|
1252
|
+
if (recursive) {
|
|
1253
|
+
for (const entry of await readdir(directory.anchor, { withFileTypes:true })) {
|
|
1254
|
+
if (entry.isSymbolicLink()) continue;
|
|
1255
|
+
changed += await setLinuxPermissions(path.join(directory.anchor, entry.name), mode, uid, gid, true, path.join(displayTarget, entry.name), failures);
|
|
1256
|
+
}
|
|
1257
|
+
}
|
|
1258
|
+
await directory.handle.chmod(mode);
|
|
1259
|
+
if (uid !== null || gid !== null) await directory.handle.chown(uid ?? -1, gid ?? -1);
|
|
1260
|
+
return changed + 1;
|
|
1261
|
+
}
|
|
1262
|
+
if (!info.isFile()) {
|
|
1263
|
+
failures.push(`${displayTarget}: special files are not changed`);
|
|
1264
|
+
return 0;
|
|
1265
|
+
}
|
|
1266
|
+
file = await openRegularFile(target, READ_NOFOLLOW);
|
|
1267
|
+
await file.handle.chmod(mode);
|
|
1268
|
+
if (uid !== null || gid !== null) await file.handle.chown(uid ?? -1, gid ?? -1);
|
|
1269
|
+
return 1;
|
|
1270
|
+
} catch (error) {
|
|
1271
|
+
failures.push(`${displayTarget}: ${describeFilesystemFailure(error, { path:error?.path })}`);
|
|
1272
|
+
return 0;
|
|
1273
|
+
} finally {
|
|
1274
|
+
if (file) await file.close().catch(() => {});
|
|
1275
|
+
if (directory) await directory.close().catch(() => {});
|
|
1276
|
+
}
|
|
1277
|
+
}
|
|
1278
|
+
|
|
819
1279
|
export async function setPermissionsTool(args) {
|
|
820
1280
|
const absolute = await resolveSafePath(args.path);
|
|
821
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
822
1281
|
const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
|
|
823
1282
|
if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
|
|
824
1283
|
const mode = Number.parseInt(raw, 8);
|
|
825
1284
|
const recursive = args.recursive === true;
|
|
826
1285
|
const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
|
|
827
1286
|
const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
|
|
828
|
-
const targets = [absolute];
|
|
829
|
-
if (recursive) {
|
|
830
|
-
// Directories are included, links are not: chmod follows a link and would change a target
|
|
831
|
-
// outside the allowed roots.
|
|
832
|
-
const walk = async target => {
|
|
833
|
-
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
834
|
-
for (const entry of entries) {
|
|
835
|
-
if (entry.isSymbolicLink()) continue;
|
|
836
|
-
const child = path.join(target, entry.name);
|
|
837
|
-
targets.push(child);
|
|
838
|
-
if (entry.isDirectory()) await walk(child);
|
|
839
|
-
}
|
|
840
|
-
};
|
|
841
|
-
await walk(absolute);
|
|
842
|
-
}
|
|
843
|
-
const confined = await confineAll(targets);
|
|
844
|
-
let changed = 0;
|
|
845
1287
|
const failures = [];
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
1288
|
+
let changed = 0;
|
|
1289
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
1290
|
+
changed = await setLinuxPermissions(absolute, mode, uid, gid, recursive, displayPath(absolute), failures);
|
|
1291
|
+
} else {
|
|
1292
|
+
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
1293
|
+
const targets = [absolute];
|
|
1294
|
+
if (recursive && info.isDirectory()) {
|
|
1295
|
+
const walk = async target => {
|
|
1296
|
+
const entries = await readdir(target, { withFileTypes:true }).catch(() => []);
|
|
1297
|
+
for (const entry of entries) {
|
|
1298
|
+
if (entry.isSymbolicLink()) continue;
|
|
1299
|
+
const child = path.join(target, entry.name);
|
|
1300
|
+
targets.push(child);
|
|
1301
|
+
if (entry.isDirectory()) await walk(child);
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
await walk(absolute);
|
|
1305
|
+
}
|
|
1306
|
+
const confined = await confineAll(targets);
|
|
1307
|
+
for (const target of confined) {
|
|
1308
|
+
try {
|
|
1309
|
+
await chmod(target, mode);
|
|
1310
|
+
if (uid !== null || gid !== null) await chown(target, uid ?? -1, gid ?? -1);
|
|
1311
|
+
changed += 1;
|
|
1312
|
+
} catch (error) {
|
|
1313
|
+
failures.push(`${displayPath(target)}: ${describeFilesystemFailure(error, { path:error?.path })}`);
|
|
1314
|
+
}
|
|
854
1315
|
}
|
|
855
1316
|
}
|
|
856
1317
|
const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
|
|
@@ -867,7 +1328,7 @@ export async function createDirectoryTool(args) {
|
|
|
867
1328
|
for (const entry of list) {
|
|
868
1329
|
try {
|
|
869
1330
|
const absolute = await resolveSafePath(entry);
|
|
870
|
-
await
|
|
1331
|
+
await ensureDirectoryPath(absolute);
|
|
871
1332
|
created.push(displayPath(absolute));
|
|
872
1333
|
} catch (error) {
|
|
873
1334
|
failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -885,24 +1346,28 @@ export async function moveFileTool(args) {
|
|
|
885
1346
|
const source = await resolveSafePath(args.source, 'source');
|
|
886
1347
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
887
1348
|
if (source === destination) fail('source and destination are the same path');
|
|
888
|
-
await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
889
1349
|
const overwrite = args.overwrite !== false;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
1350
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1351
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1352
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1353
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1354
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1355
|
+
if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
|
|
1356
|
+
if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
1357
|
+
if (existing?.isDirectory()) {
|
|
1358
|
+
const entries = await readdir(anchoredDestination).catch(() => []);
|
|
1359
|
+
if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
|
|
1360
|
+
}
|
|
1361
|
+
try {
|
|
1362
|
+
await rename(anchoredSource, anchoredDestination);
|
|
1363
|
+
} catch (error) {
|
|
1364
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1365
|
+
if (sourceInfo.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
|
|
1366
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1367
|
+
await rm(anchoredSource, { force:true });
|
|
1368
|
+
}
|
|
1369
|
+
});
|
|
1370
|
+
});
|
|
906
1371
|
return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
|
|
907
1372
|
}
|
|
908
1373
|
|
|
@@ -910,31 +1375,24 @@ export async function copyFileTool(args) {
|
|
|
910
1375
|
const source = await resolveSafePath(args.source, 'source');
|
|
911
1376
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
912
1377
|
if (source === destination) fail('source and destination are the same path');
|
|
913
|
-
const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
914
|
-
if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
915
1378
|
const overwrite = args.overwrite !== false;
|
|
916
|
-
|
|
917
|
-
await
|
|
918
|
-
|
|
1379
|
+
let info;
|
|
1380
|
+
await withExistingParent(source, async anchoredSource => {
|
|
1381
|
+
const sourceInfo = await lstat(anchoredSource).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1382
|
+
if (sourceInfo.isSymbolicLink()) fail('source cannot be a symbolic link');
|
|
1383
|
+
if (sourceInfo.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
1384
|
+
info = sourceInfo;
|
|
1385
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1386
|
+
const existing = await lstat(anchoredDestination).catch(() => null);
|
|
1387
|
+
if (existing?.isSymbolicLink()) fail('destination cannot be a symbolic link');
|
|
1388
|
+
if (existing && !overwrite) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
1389
|
+
await copyTreeContents(anchoredSource, anchoredDestination, { entries:0, bytes:0, maxBytes:Number.MAX_SAFE_INTEGER });
|
|
1390
|
+
});
|
|
1391
|
+
});
|
|
919
1392
|
return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
|
|
920
1393
|
}
|
|
921
1394
|
|
|
922
1395
|
// --- archives -------------------------------------------------------------------------
|
|
923
|
-
async function assertNoSymlinkTree(root, state = { entries: 0 }) {
|
|
924
|
-
const info = await lstat(root);
|
|
925
|
-
if (info.isSymbolicLink()) fail(`Archive sources cannot contain symbolic links: ${displayPath(root)}`);
|
|
926
|
-
if (!info.isDirectory() && !info.isFile()) fail(`Archive sources cannot contain special files: ${displayPath(root)}`);
|
|
927
|
-
state.entries += 1;
|
|
928
|
-
if (state.entries > 100_000) fail('Archive source contains too many entries');
|
|
929
|
-
if (state.maxBytes !== undefined && info.isFile()) {
|
|
930
|
-
state.bytes = (state.bytes || 0) + info.size;
|
|
931
|
-
if (state.bytes > state.maxBytes) fail('Archive source exceeds the safety limit');
|
|
932
|
-
}
|
|
933
|
-
if (!info.isDirectory()) return;
|
|
934
|
-
const entries = await readdir(root, { withFileTypes: true });
|
|
935
|
-
for (const entry of entries) await assertNoSymlinkTree(path.join(root, entry.name), state);
|
|
936
|
-
}
|
|
937
|
-
|
|
938
1396
|
const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
|
|
939
1397
|
4 * 1024 * 1024 * 1024,
|
|
940
1398
|
Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
|
|
@@ -1007,43 +1465,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
|
1007
1465
|
}
|
|
1008
1466
|
|
|
1009
1467
|
async function openArchiveSource(absolute) {
|
|
1010
|
-
|
|
1011
|
-
const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1012
|
-
return { ...opened, close: () => opened.handle.close() };
|
|
1013
|
-
}
|
|
1014
|
-
const root = path.parse(absolute).root;
|
|
1015
|
-
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
1016
|
-
if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1017
|
-
const directories = [];
|
|
1018
|
-
let current = root;
|
|
1019
|
-
const closeDirectories = async () => {
|
|
1020
|
-
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
1021
|
-
};
|
|
1022
|
-
try {
|
|
1023
|
-
for (const part of parts.slice(0, -1)) {
|
|
1024
|
-
const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
|
|
1025
|
-
try {
|
|
1026
|
-
const info = await handle.stat();
|
|
1027
|
-
if (!info.isDirectory()) fail('Archive source is not a safe regular file');
|
|
1028
|
-
} catch (error) {
|
|
1029
|
-
await handle.close().catch(() => {});
|
|
1030
|
-
throw error;
|
|
1031
|
-
}
|
|
1032
|
-
directories.push(handle);
|
|
1033
|
-
current = `/proc/self/fd/${handle.fd}`;
|
|
1034
|
-
}
|
|
1035
|
-
const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
|
|
1036
|
-
return {
|
|
1037
|
-
...opened,
|
|
1038
|
-
close: async () => {
|
|
1039
|
-
try { await opened.handle.close(); }
|
|
1040
|
-
finally { await closeDirectories(); }
|
|
1041
|
-
},
|
|
1042
|
-
};
|
|
1043
|
-
} catch (error) {
|
|
1044
|
-
await closeDirectories();
|
|
1045
|
-
throw error;
|
|
1046
|
-
}
|
|
1468
|
+
return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1047
1469
|
}
|
|
1048
1470
|
|
|
1049
1471
|
async function snapshotRegularFile(source, destination, maxBytes) {
|
|
@@ -1127,47 +1549,48 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
|
|
|
1127
1549
|
|
|
1128
1550
|
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1129
1551
|
const initial = await lstat(source);
|
|
1130
|
-
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains
|
|
1552
|
+
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
1131
1553
|
state.entries += 1;
|
|
1132
1554
|
if (state.entries > 100_000) fail('Archive contains too many entries');
|
|
1133
1555
|
if (initial.isFile()) {
|
|
1134
|
-
const opened =
|
|
1556
|
+
const opened = source.startsWith('/proc/self/fd/')
|
|
1557
|
+
? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
|
|
1558
|
+
: await openArchiveSource(source);
|
|
1135
1559
|
try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
|
|
1136
|
-
finally { await opened.handle.close(); }
|
|
1560
|
+
finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
|
|
1137
1561
|
return;
|
|
1138
1562
|
}
|
|
1139
1563
|
let directoryHandle = null;
|
|
1140
1564
|
let sourcePath = source;
|
|
1141
1565
|
let info = initial;
|
|
1142
1566
|
try {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
throw error;
|
|
1152
|
-
}
|
|
1567
|
+
try {
|
|
1568
|
+
directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
|
|
1569
|
+
info = await directoryHandle.handle.stat();
|
|
1570
|
+
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1571
|
+
sourcePath = directoryHandle.anchor;
|
|
1572
|
+
} catch (error) {
|
|
1573
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1574
|
+
throw error;
|
|
1153
1575
|
}
|
|
1154
1576
|
const mode = info.mode & 0o777;
|
|
1155
1577
|
const existing = await lstat(destination).catch(() => null);
|
|
1156
1578
|
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1157
|
-
try { await
|
|
1579
|
+
try { await ensureDirectoryPath(destination, { allowOutside:true }); }
|
|
1158
1580
|
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
1159
1581
|
const destinationInfo = await lstat(destination);
|
|
1160
1582
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1161
1583
|
let destinationHandle;
|
|
1162
1584
|
try {
|
|
1163
|
-
destinationHandle = await
|
|
1164
|
-
const openedDestination = await destinationHandle.stat();
|
|
1585
|
+
destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
|
|
1586
|
+
const openedDestination = await destinationHandle.handle.stat();
|
|
1165
1587
|
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1166
|
-
await destinationHandle.chmod(mode | 0o700);
|
|
1588
|
+
await destinationHandle.handle.chmod(mode | 0o700);
|
|
1589
|
+
const destinationAnchor = destinationHandle.anchor;
|
|
1167
1590
|
for (const name of await readdir(sourcePath)) {
|
|
1168
|
-
await copyTreeContents(path.join(sourcePath, name), path.join(
|
|
1591
|
+
await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
|
|
1169
1592
|
}
|
|
1170
|
-
await destinationHandle.chmod(mode);
|
|
1593
|
+
await destinationHandle.handle.chmod(mode);
|
|
1171
1594
|
} finally {
|
|
1172
1595
|
if (destinationHandle) await destinationHandle.close().catch(() => {});
|
|
1173
1596
|
}
|
|
@@ -1184,32 +1607,39 @@ async function removeExtractionArtifact(target) {
|
|
|
1184
1607
|
}
|
|
1185
1608
|
|
|
1186
1609
|
async function recoverExtractionArtifacts(parent, destination, prefix) {
|
|
1187
|
-
const
|
|
1188
|
-
const
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
const
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
if (destinationInfo) {
|
|
1199
|
-
await removeExtractionArtifact(backup);
|
|
1200
|
-
continue;
|
|
1201
|
-
}
|
|
1202
|
-
const backupInfo = await lstat(backup).catch(() => null);
|
|
1203
|
-
if (!backupInfo) continue;
|
|
1204
|
-
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
1205
|
-
await removeExtractionArtifact(backup);
|
|
1206
|
-
continue;
|
|
1610
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1611
|
+
const anchor = parentDirectory.anchor;
|
|
1612
|
+
const destinationPath = path.join(anchor, path.basename(destination));
|
|
1613
|
+
try {
|
|
1614
|
+
const entries = await readdir(anchor, { withFileTypes: true });
|
|
1615
|
+
const staleBefore = Date.now() - 60 * 60 * 1000;
|
|
1616
|
+
for (const entry of entries) {
|
|
1617
|
+
if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
|
|
1618
|
+
const candidate = path.join(anchor, entry.name);
|
|
1619
|
+
const info = await lstat(candidate).catch(() => null);
|
|
1620
|
+
if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
|
|
1207
1621
|
}
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1622
|
+
const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
|
|
1623
|
+
for (const backup of backups) {
|
|
1624
|
+
const destinationInfo = await lstat(destinationPath).catch(() => null);
|
|
1625
|
+
if (destinationInfo) {
|
|
1626
|
+
await removeExtractionArtifact(backup);
|
|
1627
|
+
continue;
|
|
1628
|
+
}
|
|
1629
|
+
const backupInfo = await lstat(backup).catch(() => null);
|
|
1630
|
+
if (!backupInfo) continue;
|
|
1631
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
1632
|
+
await removeExtractionArtifact(backup);
|
|
1633
|
+
continue;
|
|
1634
|
+
}
|
|
1635
|
+
try {
|
|
1636
|
+
await rename(backup, destinationPath);
|
|
1637
|
+
} catch {
|
|
1638
|
+
continue;
|
|
1639
|
+
}
|
|
1212
1640
|
}
|
|
1641
|
+
} finally {
|
|
1642
|
+
await parentDirectory.close().catch(() => {});
|
|
1213
1643
|
}
|
|
1214
1644
|
}
|
|
1215
1645
|
|
|
@@ -1232,20 +1662,23 @@ async function restoreExtractionBackup(backup, destination) {
|
|
|
1232
1662
|
async function installExtractedTree(source, destination) {
|
|
1233
1663
|
const parent = path.dirname(destination);
|
|
1234
1664
|
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1235
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1236
1665
|
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1237
1666
|
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1238
|
-
const
|
|
1667
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1668
|
+
const parentAnchor = parentDirectory.anchor;
|
|
1669
|
+
const destinationPath = path.join(parentAnchor, path.basename(destination));
|
|
1670
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
1239
1671
|
const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
|
|
1240
|
-
|
|
1672
|
+
let transaction = '';
|
|
1241
1673
|
let backup = '';
|
|
1242
1674
|
let backupOwned = false;
|
|
1243
1675
|
let preserveBackup = false;
|
|
1244
1676
|
try {
|
|
1677
|
+
transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
|
|
1245
1678
|
if (existing) {
|
|
1246
|
-
backup = path.join(
|
|
1679
|
+
backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
|
|
1247
1680
|
try {
|
|
1248
|
-
await rename(
|
|
1681
|
+
await rename(destinationPath, backup);
|
|
1249
1682
|
backupOwned = true;
|
|
1250
1683
|
} catch (error) {
|
|
1251
1684
|
if (error?.code !== 'ENOENT') throw error;
|
|
@@ -1255,11 +1688,11 @@ async function installExtractedTree(source, destination) {
|
|
|
1255
1688
|
if (backupOwned) {
|
|
1256
1689
|
const backupInfo = await lstat(backup);
|
|
1257
1690
|
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
1258
|
-
await copyTreeContents(backup, transaction);
|
|
1691
|
+
await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1259
1692
|
}
|
|
1260
|
-
await copyTreeContents(source, transaction);
|
|
1693
|
+
await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1261
1694
|
await chmod(transaction, destinationMode);
|
|
1262
|
-
await rename(transaction,
|
|
1695
|
+
await rename(transaction, destinationPath);
|
|
1263
1696
|
if (backupOwned) {
|
|
1264
1697
|
await removeExtractionArtifact(backup);
|
|
1265
1698
|
backupOwned = false;
|
|
@@ -1267,7 +1700,7 @@ async function installExtractedTree(source, destination) {
|
|
|
1267
1700
|
} catch (error) {
|
|
1268
1701
|
if (backupOwned) {
|
|
1269
1702
|
try {
|
|
1270
|
-
await restoreExtractionBackup(backup,
|
|
1703
|
+
await restoreExtractionBackup(backup, destinationPath);
|
|
1271
1704
|
if (!await lstat(backup).catch(() => null)) backupOwned = false;
|
|
1272
1705
|
else preserveBackup = true;
|
|
1273
1706
|
} catch (restoreError) {
|
|
@@ -1277,24 +1710,32 @@ async function installExtractedTree(source, destination) {
|
|
|
1277
1710
|
}
|
|
1278
1711
|
throw error;
|
|
1279
1712
|
} finally {
|
|
1280
|
-
await rm(transaction, { recursive: true, force: true });
|
|
1713
|
+
if (transaction) await rm(transaction, { recursive: true, force: true });
|
|
1281
1714
|
if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
|
|
1715
|
+
await parentDirectory.close().catch(() => {});
|
|
1282
1716
|
}
|
|
1283
1717
|
}
|
|
1284
1718
|
|
|
1285
1719
|
async function moveArchiveFile(source, destination) {
|
|
1720
|
+
const parent = path.dirname(destination);
|
|
1721
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1722
|
+
const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
|
|
1286
1723
|
try {
|
|
1287
|
-
await rename(source, destination);
|
|
1288
|
-
} catch (error) {
|
|
1289
|
-
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
1290
|
-
const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
1291
1724
|
try {
|
|
1292
|
-
await
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1725
|
+
await rename(source, destinationPath);
|
|
1726
|
+
} catch (error) {
|
|
1727
|
+
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
1728
|
+
const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
1729
|
+
try {
|
|
1730
|
+
await copyFile(source, temporary, constants.COPYFILE_EXCL);
|
|
1731
|
+
await rename(temporary, destinationPath);
|
|
1732
|
+
} finally {
|
|
1733
|
+
await unlink(temporary).catch(() => {});
|
|
1734
|
+
}
|
|
1735
|
+
await unlink(source);
|
|
1296
1736
|
}
|
|
1297
|
-
|
|
1737
|
+
} finally {
|
|
1738
|
+
await parentDirectory.close().catch(() => {});
|
|
1298
1739
|
}
|
|
1299
1740
|
}
|
|
1300
1741
|
|
|
@@ -1314,13 +1755,10 @@ export async function createArchiveTool(args) {
|
|
|
1314
1755
|
if (!sources.length) fail('paths must list at least one file or directory');
|
|
1315
1756
|
const resolved = [];
|
|
1316
1757
|
for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
1317
|
-
const sourceState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1318
|
-
for (const entry of resolved) await assertNoSymlinkTree(entry, sourceState);
|
|
1319
1758
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1320
1759
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1321
1760
|
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
1322
1761
|
const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
|
|
1323
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
1324
1762
|
const baseDir = path.dirname(resolved[0]);
|
|
1325
1763
|
const names = resolved.map(entry => path.relative(baseDir, entry));
|
|
1326
1764
|
if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
|
|
@@ -1334,11 +1772,13 @@ export async function createArchiveTool(args) {
|
|
|
1334
1772
|
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1335
1773
|
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1336
1774
|
try {
|
|
1337
|
-
|
|
1775
|
+
await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
|
|
1776
|
+
|
|
1338
1777
|
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1339
1778
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1340
1779
|
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1341
|
-
|
|
1780
|
+
await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
|
|
1781
|
+
|
|
1342
1782
|
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1343
1783
|
}
|
|
1344
1784
|
if (format === 'zip') {
|
|
@@ -1353,11 +1793,11 @@ export async function createArchiveTool(args) {
|
|
|
1353
1793
|
} else {
|
|
1354
1794
|
fail('format must be tar, tar.gz, or zip');
|
|
1355
1795
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1796
|
+
const stagedInfo = await stat(staging);
|
|
1797
|
+
assertArchiveExpandedSize(stagedInfo.size, 'archive output');
|
|
1798
|
+
|
|
1799
|
+
await moveArchiveFile(staging, destination);
|
|
1359
1800
|
|
|
1360
|
-
await moveArchiveFile(staging, destination);
|
|
1361
1801
|
const info = await stat(destination).catch(() => null);
|
|
1362
1802
|
const note = ' (built outside the tree in temporary staging and committed atomically)';
|
|
1363
1803
|
|
|
@@ -1397,7 +1837,6 @@ export async function extractArchiveTool(args) {
|
|
|
1397
1837
|
try {
|
|
1398
1838
|
await mkdir(staging, { mode: 0o700 });
|
|
1399
1839
|
await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
|
|
1400
|
-
await mkdir(parent, { recursive: true, mode: 0o700 });
|
|
1401
1840
|
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1402
1841
|
const existingDestination = await lstat(destination).catch(() => null);
|
|
1403
1842
|
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
@@ -1493,8 +1932,17 @@ export async function takeScreenshotTool(args) {
|
|
|
1493
1932
|
const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
|
|
1494
1933
|
const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
|
|
1495
1934
|
const directory = requestedDirectory || temporaryDirectory;
|
|
1496
|
-
|
|
1497
|
-
|
|
1935
|
+
let outputDirectory = null;
|
|
1936
|
+
if (requestedDirectory) {
|
|
1937
|
+
await ensureDirectoryPath(directory);
|
|
1938
|
+
outputDirectory = await openWritableParent(directory);
|
|
1939
|
+
}
|
|
1940
|
+
const outputAnchor = outputDirectory
|
|
1941
|
+
? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
|
|
1942
|
+
: directory;
|
|
1943
|
+
const fileName = `capture-${randomUUID()}.png`;
|
|
1944
|
+
const file = path.join(outputAnchor, fileName);
|
|
1945
|
+
const displayFile = path.join(directory, fileName);
|
|
1498
1946
|
const attempts = [];
|
|
1499
1947
|
let preserveCapture = false;
|
|
1500
1948
|
try {
|
|
@@ -1524,26 +1972,28 @@ export async function takeScreenshotTool(args) {
|
|
|
1524
1972
|
}
|
|
1525
1973
|
if (!await pathExists(file)) fail(screenshotAdvice(attempts));
|
|
1526
1974
|
|
|
1527
|
-
const
|
|
1975
|
+
const opened = await openRegularFile(file);
|
|
1976
|
+
const { handle, info } = opened;
|
|
1528
1977
|
try {
|
|
1529
1978
|
if (info.size <= MAX_IMAGE_BYTES) {
|
|
1530
1979
|
const buffer = await handle.readFile();
|
|
1531
1980
|
preserveCapture = args.keep === true;
|
|
1532
1981
|
return multi([
|
|
1533
|
-
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(
|
|
1982
|
+
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
|
|
1534
1983
|
image(buffer.toString('base64'), 'image/png'),
|
|
1535
1984
|
]);
|
|
1536
1985
|
}
|
|
1537
1986
|
preserveCapture = true;
|
|
1538
|
-
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(
|
|
1987
|
+
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.`);
|
|
1539
1988
|
} finally {
|
|
1540
|
-
await
|
|
1989
|
+
await opened.close();
|
|
1541
1990
|
}
|
|
1542
1991
|
} finally {
|
|
1543
1992
|
if (!preserveCapture) {
|
|
1544
1993
|
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1545
1994
|
else await rm(file, { force: true }).catch(() => {});
|
|
1546
1995
|
}
|
|
1996
|
+
if (outputDirectory) await outputDirectory.close().catch(() => {});
|
|
1547
1997
|
}
|
|
1548
1998
|
}
|
|
1549
1999
|
export const fileToolHandlers = {
|