@remcp/runtime 0.2.108 → 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 +7 -0
- package/package.json +1 -1
- package/src/extended/browser.mjs +89 -22
- package/src/extended/diagnostics.mjs +88 -27
- package/src/extended/documents.mjs +11 -75
- package/src/screenshot-portal.mjs +115 -6
- package/src/sessions.mjs +4 -0
- package/src/tools/files.mjs +533 -198
- package/src/tools/search.mjs +84 -41
- package/src/util.mjs +4 -0
package/src/tools/files.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { describeFilesystemFailure } from '../permissions.mjs';
|
|
|
12
12
|
import { capturePortalScreenshot, isWaylandSession } from '../screenshot-portal.mjs';
|
|
13
13
|
import { applyHunks, parseUnifiedDiff } from '../patch.mjs';
|
|
14
14
|
import { countEvent, recordEvent } from '../telemetry.mjs';
|
|
15
|
-
import { clampInteger, decodeText, displayPath, fail, globToRegExp, image, 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,7 +49,158 @@ 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 } = {}) {
|
|
53
204
|
const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
54
205
|
if (!descriptorBound) {
|
|
55
206
|
if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
|
|
@@ -62,10 +213,11 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
|
|
|
62
213
|
};
|
|
63
214
|
return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
|
|
64
215
|
}
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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');
|
|
69
221
|
const root = path.parse(absolute).root;
|
|
70
222
|
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
71
223
|
if (!parts.length) {
|
|
@@ -109,20 +261,46 @@ async function openDirectoryPath(absolute, { create = false } = {}) {
|
|
|
109
261
|
}
|
|
110
262
|
}
|
|
111
263
|
|
|
112
|
-
async function
|
|
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
|
+
}
|
|
113
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);
|
|
114
288
|
try {
|
|
115
|
-
const info = assertRegularFile(await handle.stat(),
|
|
116
|
-
return { handle, info };
|
|
289
|
+
const info = assertRegularFile(await opened.handle.stat(), label);
|
|
290
|
+
return { handle: opened.handle, info, close: opened.close };
|
|
117
291
|
} catch (error) {
|
|
118
|
-
await
|
|
292
|
+
await opened.close().catch(() => {});
|
|
119
293
|
throw error;
|
|
120
294
|
}
|
|
121
295
|
}
|
|
122
296
|
|
|
123
|
-
async function
|
|
297
|
+
export async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
298
|
+
return openRegularFileAtPath(absolute, flags, absolute);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
async function openWritableParent(absolute, options = {}) {
|
|
124
302
|
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
125
|
-
return openDirectoryPath(absolute, { create:true });
|
|
303
|
+
return openDirectoryPath(absolute, { create:true, ...options });
|
|
126
304
|
}
|
|
127
305
|
await mkdir(absolute, { recursive:true });
|
|
128
306
|
const info = await lstat(absolute);
|
|
@@ -154,9 +332,27 @@ async function withWritableParent(absolute, callback) {
|
|
|
154
332
|
}
|
|
155
333
|
}
|
|
156
334
|
|
|
157
|
-
async function
|
|
335
|
+
async function openExistingParent(absolute) {
|
|
158
336
|
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
159
|
-
|
|
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 });
|
|
160
356
|
await directory.close();
|
|
161
357
|
return;
|
|
162
358
|
}
|
|
@@ -166,12 +362,13 @@ async function ensureDirectoryPath(absolute) {
|
|
|
166
362
|
}
|
|
167
363
|
|
|
168
364
|
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
169
|
-
const
|
|
365
|
+
const opened = await openRegularFile(absolute);
|
|
366
|
+
const { handle, info } = opened;
|
|
170
367
|
try {
|
|
171
368
|
if (info.size > maxBytes) fail(`File is too large to read inline (${info.size} bytes)`);
|
|
172
369
|
return { info, buffer: await handle.readFile() };
|
|
173
370
|
} finally {
|
|
174
|
-
await
|
|
371
|
+
await opened.close();
|
|
175
372
|
}
|
|
176
373
|
}
|
|
177
374
|
|
|
@@ -195,29 +392,65 @@ async function collectTree(root, { maxFiles = 500, skip = [] } = {}) {
|
|
|
195
392
|
const found = [];
|
|
196
393
|
const denied = [];
|
|
197
394
|
const skipName = name => skip.some(entry => (entry.endsWith('*') ? name.startsWith(entry.slice(0, -1)) : name === entry));
|
|
198
|
-
async
|
|
199
|
-
if (found.length >= maxFiles) return;
|
|
200
|
-
const info = await lstat(target).catch(() => null);
|
|
201
|
-
// A symlink is skipped on purpose (it can point outside the allowed roots); that is not a denial
|
|
202
|
-
// and must not be reported as one.
|
|
203
|
-
if (!info || info.isSymbolicLink()) return;
|
|
204
|
-
if (info.isFile()) { found.push(target); return; }
|
|
205
|
-
if (!info.isDirectory()) return;
|
|
395
|
+
const visitDirectory = async (directory, logicalBase) => {
|
|
206
396
|
let entries;
|
|
207
397
|
try {
|
|
208
|
-
entries = await readdir(
|
|
398
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
209
399
|
} catch {
|
|
210
|
-
|
|
211
|
-
denied.push(target);
|
|
400
|
+
denied.push(logicalBase);
|
|
212
401
|
return;
|
|
213
402
|
}
|
|
214
403
|
for (const entry of entries) {
|
|
215
404
|
if (found.length >= maxFiles) return;
|
|
216
405
|
if (entry.isSymbolicLink() || skipName(entry.name)) continue;
|
|
217
|
-
|
|
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;
|
|
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(() => {});
|
|
218
450
|
}
|
|
451
|
+
} finally {
|
|
452
|
+
if (directory) await directory.close().catch(() => {});
|
|
219
453
|
}
|
|
220
|
-
await visit(root);
|
|
221
454
|
return { files: found, denied };
|
|
222
455
|
}
|
|
223
456
|
|
|
@@ -334,37 +567,64 @@ export async function hashFileTool(args) {
|
|
|
334
567
|
}
|
|
335
568
|
const { handle, info } = opened;
|
|
336
569
|
const algorithm = String(args.algorithm || 'sha256').toLowerCase();
|
|
337
|
-
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'); }
|
|
338
571
|
const hash = createHash(algorithm);
|
|
339
572
|
try {
|
|
340
573
|
await pipeline(createReadStream(absolute, { fd: handle.fd, autoClose: false }), hash);
|
|
341
574
|
return text(`${algorithm} ${hash.digest('hex')} ${displayPath(absolute)} (${info.size} bytes)`);
|
|
342
575
|
} finally {
|
|
343
|
-
await
|
|
576
|
+
await opened.close();
|
|
344
577
|
}
|
|
345
578
|
}
|
|
346
579
|
|
|
347
|
-
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) {
|
|
348
591
|
let entries;
|
|
349
592
|
try {
|
|
350
|
-
entries = await readdir(
|
|
593
|
+
entries = await readdir(directory.anchor, { withFileTypes: true });
|
|
351
594
|
} catch (error) {
|
|
352
|
-
|
|
353
|
-
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'})`];
|
|
354
596
|
}
|
|
355
597
|
entries.sort((a, b) => Number(b.isDirectory()) - Number(a.isDirectory()) || a.name.localeCompare(b.name));
|
|
356
598
|
const rows = [];
|
|
357
599
|
for (const entry of entries) {
|
|
358
|
-
const
|
|
600
|
+
const childDisplay = path.join(displayBase, entry.name);
|
|
359
601
|
if (entry.isDirectory()) {
|
|
360
602
|
rows.push(`${prefix}[DIR] ${entry.name}`);
|
|
361
|
-
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
|
+
}
|
|
362
615
|
} else if (entry.isSymbolicLink()) {
|
|
363
616
|
rows.push(`${prefix}[LINK] ${entry.name}`);
|
|
364
617
|
} else {
|
|
365
618
|
if (pattern && !pattern.test(entry.name)) continue;
|
|
366
|
-
|
|
367
|
-
|
|
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
|
+
}
|
|
368
628
|
}
|
|
369
629
|
}
|
|
370
630
|
return rows;
|
|
@@ -372,39 +632,85 @@ async function listEntry(base, depth, maxDepth, prefix, pattern) {
|
|
|
372
632
|
|
|
373
633
|
export async function listDirectoryTool(args) {
|
|
374
634
|
const absolute = await resolveSafePath(args.path);
|
|
375
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
376
|
-
if (!info.isDirectory()) fail(`${displayPath(absolute)} is not a directory`);
|
|
377
635
|
const depth = clampInteger(args.depth, 1, 1, 5);
|
|
378
636
|
const pattern = typeof args.pattern === 'string' && args.pattern.trim() ? globToRegExp(args.pattern.trim()) : null;
|
|
379
|
-
|
|
380
|
-
|
|
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(() => {}); }
|
|
381
676
|
}
|
|
382
677
|
|
|
383
678
|
export async function getFileInfoTool(args) {
|
|
384
679
|
const absolute = await resolveSafePath(args.path);
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
|
+
}
|
|
404
708
|
}
|
|
405
709
|
}
|
|
710
|
+
return text(JSON.stringify(payload, null, 2));
|
|
711
|
+
} finally {
|
|
712
|
+
await opened.close().catch(() => {});
|
|
406
713
|
}
|
|
407
|
-
return text(JSON.stringify(payload, null, 2));
|
|
408
714
|
}
|
|
409
715
|
|
|
410
716
|
function assertWritableSize(content) {
|
|
@@ -471,7 +777,7 @@ export async function readBinaryTool(args) {
|
|
|
471
777
|
});
|
|
472
778
|
return text(payload);
|
|
473
779
|
} finally {
|
|
474
|
-
await
|
|
780
|
+
await opened.close();
|
|
475
781
|
}
|
|
476
782
|
}
|
|
477
783
|
|
|
@@ -605,7 +911,7 @@ export async function replaceInFilesTool(args) {
|
|
|
605
911
|
fail(`pattern is not a valid regular expression (${error instanceof Error ? error.message : String(error)})`);
|
|
606
912
|
}
|
|
607
913
|
}
|
|
608
|
-
const info = await
|
|
914
|
+
const info = await getPathInfo(root).catch(() => fail(`Path not found: ${displayPath(root)}`));
|
|
609
915
|
const walk = info.isFile() ? { files: [root], denied: [] } : await collectTree(root, { maxFiles, skip: ['.git', 'node_modules', '.remcp-trash*'] });
|
|
610
916
|
const files = await confineAll(walk.files);
|
|
611
917
|
const glob = filePattern ? globToRegExp(filePattern) : null;
|
|
@@ -635,7 +941,7 @@ export async function replaceInFilesTool(args) {
|
|
|
635
941
|
const stats = diffStats(original, updated);
|
|
636
942
|
changed.push({ file: displayPath(file), replacements: count, added: stats.added, removed: stats.removed });
|
|
637
943
|
} finally {
|
|
638
|
-
await
|
|
944
|
+
await opened.close();
|
|
639
945
|
}
|
|
640
946
|
}
|
|
641
947
|
if (!changed.length) return text(`No matches for ${JSON.stringify(pattern)} in ${displayPath(root)} (${scanned} text files scanned).`);
|
|
@@ -665,7 +971,6 @@ function trashDirectoryFor() {
|
|
|
665
971
|
|
|
666
972
|
export async function moveToTrashTool(args) {
|
|
667
973
|
const source = await resolveSafePath(args.source, 'source');
|
|
668
|
-
const info = await stat(source).catch(() => fail(`Path not found: ${displayPath(source)}`));
|
|
669
974
|
const trash = trashDirectoryFor();
|
|
670
975
|
let destination = null;
|
|
671
976
|
if (trash) {
|
|
@@ -691,12 +996,16 @@ export async function moveToTrashTool(args) {
|
|
|
691
996
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
692
997
|
counter += 1;
|
|
693
998
|
}
|
|
694
|
-
await
|
|
695
|
-
await
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
await
|
|
699
|
-
|
|
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
|
+
});
|
|
700
1009
|
});
|
|
701
1010
|
});
|
|
702
1011
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
@@ -711,7 +1020,7 @@ export async function readFilesTool(args) {
|
|
|
711
1020
|
const maxLinesPerFile = clampInteger(args.max_lines_per_file, liveConfig('maxReadLines'), 1, 20000);
|
|
712
1021
|
const includeIgnored = args.include_ignored === true;
|
|
713
1022
|
const matcher = globToRegExp(pattern);
|
|
714
|
-
const rootInfo = await
|
|
1023
|
+
const rootInfo = await getPathInfo(root).catch(() => null);
|
|
715
1024
|
// A file path is matched directly; a directory is walked without following links.
|
|
716
1025
|
const walk = rootInfo?.isFile()
|
|
717
1026
|
? { files: [root], denied: [] }
|
|
@@ -779,15 +1088,19 @@ export async function writeFilesTool(args) {
|
|
|
779
1088
|
|
|
780
1089
|
export async function deletePathTool(args) {
|
|
781
1090
|
const absolute = await resolveSafePath(args.path);
|
|
782
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
783
1091
|
if (path.dirname(absolute) === absolute) fail(`Refusing to delete the filesystem root ${displayPath(absolute)}`);
|
|
784
1092
|
const recursive = args.recursive !== false;
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
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
|
+
});
|
|
791
1104
|
return text(`Deleted ${info.isDirectory() ? 'directory' : 'file'} ${displayPath(absolute)}${info.isDirectory() ? ` and its ${entries.length} top-level entr${entries.length === 1 ? 'y' : 'ies'}` : ''}.`);
|
|
792
1105
|
}
|
|
793
1106
|
|
|
@@ -802,13 +1115,15 @@ export async function deletePathsTool(args) {
|
|
|
802
1115
|
try {
|
|
803
1116
|
const absolute = await resolveSafePath(entry, 'paths[]');
|
|
804
1117
|
if (path.dirname(absolute) === absolute) throw new Error('refusing to delete the filesystem root');
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
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
|
+
});
|
|
812
1127
|
deleted += 1;
|
|
813
1128
|
results.push(`deleted ${displayPath(absolute)}`);
|
|
814
1129
|
} catch (error) {
|
|
@@ -832,11 +1147,11 @@ export async function copyPathsTool(args) {
|
|
|
832
1147
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
833
1148
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
834
1149
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
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
|
+
});
|
|
840
1155
|
copied += 1;
|
|
841
1156
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
842
1157
|
} catch (error) {
|
|
@@ -858,18 +1173,21 @@ export async function movePathsTool(args) {
|
|
|
858
1173
|
const source = await resolveSafePath(entry?.source, 'paths[].source');
|
|
859
1174
|
const destination = await resolveSafePath(entry?.destination, 'paths[].destination');
|
|
860
1175
|
if (source === destination) throw new Error('source and destination are the same path');
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
+
});
|
|
873
1191
|
});
|
|
874
1192
|
moved += 1;
|
|
875
1193
|
results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
|
|
@@ -919,41 +1237,81 @@ export async function applyPatchTool(args) {
|
|
|
919
1237
|
return text([`${changed}/${files.length} file(s) ${dryRun ? 'would be patched' : 'patched'}`, ...results].join('\n'), failedCount > 0);
|
|
920
1238
|
}
|
|
921
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
|
+
|
|
922
1279
|
export async function setPermissionsTool(args) {
|
|
923
1280
|
const absolute = await resolveSafePath(args.path);
|
|
924
|
-
const info = await stat(absolute).catch(() => fail(`Path not found: ${displayPath(absolute)}`));
|
|
925
1281
|
const raw = typeof args.mode === 'string' ? args.mode.trim() : String(args.mode ?? '');
|
|
926
1282
|
if (!/^[0-7]{3,4}$/.test(raw)) fail('mode must be an octal string such as "755" or "0644"');
|
|
927
1283
|
const mode = Number.parseInt(raw, 8);
|
|
928
1284
|
const recursive = args.recursive === true;
|
|
929
1285
|
const uid = Number.isInteger(Number(args.uid)) ? Number(args.uid) : null;
|
|
930
1286
|
const gid = Number.isInteger(Number(args.gid)) ? Number(args.gid) : null;
|
|
931
|
-
const targets = [absolute];
|
|
932
|
-
if (recursive) {
|
|
933
|
-
// Directories are included, links are not: chmod follows a link and would change a target
|
|
934
|
-
// outside the allowed roots.
|
|
935
|
-
const walk = async target => {
|
|
936
|
-
const entries = await readdir(target, { withFileTypes: true }).catch(() => []);
|
|
937
|
-
for (const entry of entries) {
|
|
938
|
-
if (entry.isSymbolicLink()) continue;
|
|
939
|
-
const child = path.join(target, entry.name);
|
|
940
|
-
targets.push(child);
|
|
941
|
-
if (entry.isDirectory()) await walk(child);
|
|
942
|
-
}
|
|
943
|
-
};
|
|
944
|
-
await walk(absolute);
|
|
945
|
-
}
|
|
946
|
-
const confined = await confineAll(targets);
|
|
947
|
-
let changed = 0;
|
|
948
1287
|
const failures = [];
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
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
|
+
}
|
|
957
1315
|
}
|
|
958
1316
|
}
|
|
959
1317
|
const summary = `Set mode ${raw}${uid !== null || gid !== null ? ` (uid ${uid ?? '-'} gid ${gid ?? '-'})` : ''} on ${changed} path(s) starting at ${displayPath(absolute)}.`;
|
|
@@ -988,24 +1346,27 @@ export async function moveFileTool(args) {
|
|
|
988
1346
|
const source = await resolveSafePath(args.source, 'source');
|
|
989
1347
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
990
1348
|
if (source === destination) fail('source and destination are the same path');
|
|
991
|
-
await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
992
1349
|
const overwrite = args.overwrite !== false;
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
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
|
+
});
|
|
1009
1370
|
});
|
|
1010
1371
|
return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
|
|
1011
1372
|
}
|
|
@@ -1014,11 +1375,20 @@ export async function copyFileTool(args) {
|
|
|
1014
1375
|
const source = await resolveSafePath(args.source, 'source');
|
|
1015
1376
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1016
1377
|
if (source === destination) fail('source and destination are the same path');
|
|
1017
|
-
const info = await stat(source).catch(() => fail(`Source not found: ${displayPath(source)}`));
|
|
1018
|
-
if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
1019
1378
|
const overwrite = args.overwrite !== false;
|
|
1020
|
-
|
|
1021
|
-
await
|
|
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
|
+
});
|
|
1022
1392
|
return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
|
|
1023
1393
|
}
|
|
1024
1394
|
|
|
@@ -1095,43 +1465,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
|
1095
1465
|
}
|
|
1096
1466
|
|
|
1097
1467
|
async function openArchiveSource(absolute) {
|
|
1098
|
-
|
|
1099
|
-
const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1100
|
-
return { ...opened, close: () => opened.handle.close() };
|
|
1101
|
-
}
|
|
1102
|
-
const root = path.parse(absolute).root;
|
|
1103
|
-
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
1104
|
-
if (!parts.length) return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1105
|
-
const directories = [];
|
|
1106
|
-
let current = root;
|
|
1107
|
-
const closeDirectories = async () => {
|
|
1108
|
-
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
1109
|
-
};
|
|
1110
|
-
try {
|
|
1111
|
-
for (const part of parts.slice(0, -1)) {
|
|
1112
|
-
const handle = await open(path.join(current, part), DIRECTORY_READ_NOFOLLOW);
|
|
1113
|
-
try {
|
|
1114
|
-
const info = await handle.stat();
|
|
1115
|
-
if (!info.isDirectory()) fail('Archive source is not a safe regular file');
|
|
1116
|
-
} catch (error) {
|
|
1117
|
-
await handle.close().catch(() => {});
|
|
1118
|
-
throw error;
|
|
1119
|
-
}
|
|
1120
|
-
directories.push(handle);
|
|
1121
|
-
current = `/proc/self/fd/${handle.fd}`;
|
|
1122
|
-
}
|
|
1123
|
-
const opened = await openRegularFile(path.join(current, parts.at(-1)), READ_NOFOLLOW_NONBLOCK);
|
|
1124
|
-
return {
|
|
1125
|
-
...opened,
|
|
1126
|
-
close: async () => {
|
|
1127
|
-
try { await opened.handle.close(); }
|
|
1128
|
-
finally { await closeDirectories(); }
|
|
1129
|
-
},
|
|
1130
|
-
};
|
|
1131
|
-
} catch (error) {
|
|
1132
|
-
await closeDirectories();
|
|
1133
|
-
throw error;
|
|
1134
|
-
}
|
|
1468
|
+
return openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
|
|
1135
1469
|
}
|
|
1136
1470
|
|
|
1137
1471
|
async function snapshotRegularFile(source, destination, maxBytes) {
|
|
@@ -1231,7 +1565,7 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
|
|
|
1231
1565
|
let info = initial;
|
|
1232
1566
|
try {
|
|
1233
1567
|
try {
|
|
1234
|
-
directoryHandle = await openDirectoryPath(source);
|
|
1568
|
+
directoryHandle = await openDirectoryPath(source, { allowOutside:state.allowOutsideSource === true });
|
|
1235
1569
|
info = await directoryHandle.handle.stat();
|
|
1236
1570
|
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1237
1571
|
sourcePath = directoryHandle.anchor;
|
|
@@ -1242,13 +1576,13 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
|
|
|
1242
1576
|
const mode = info.mode & 0o777;
|
|
1243
1577
|
const existing = await lstat(destination).catch(() => null);
|
|
1244
1578
|
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1245
|
-
try { await
|
|
1579
|
+
try { await ensureDirectoryPath(destination, { allowOutside:true }); }
|
|
1246
1580
|
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
1247
1581
|
const destinationInfo = await lstat(destination);
|
|
1248
1582
|
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1249
1583
|
let destinationHandle;
|
|
1250
1584
|
try {
|
|
1251
|
-
destinationHandle = await openDirectoryPath(destination);
|
|
1585
|
+
destinationHandle = await openDirectoryPath(destination, { allowOutside:true });
|
|
1252
1586
|
const openedDestination = await destinationHandle.handle.stat();
|
|
1253
1587
|
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1254
1588
|
await destinationHandle.handle.chmod(mode | 0o700);
|
|
@@ -1354,9 +1688,9 @@ async function installExtractedTree(source, destination) {
|
|
|
1354
1688
|
if (backupOwned) {
|
|
1355
1689
|
const backupInfo = await lstat(backup);
|
|
1356
1690
|
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
1357
|
-
await copyTreeContents(backup, transaction);
|
|
1691
|
+
await copyTreeContents(backup, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1358
1692
|
}
|
|
1359
|
-
await copyTreeContents(source, transaction);
|
|
1693
|
+
await copyTreeContents(source, transaction, { entries:0, bytes:0, maxBytes:MAX_ARCHIVE_EXPANDED_BYTES, allowOutsideSource:true });
|
|
1360
1694
|
await chmod(transaction, destinationMode);
|
|
1361
1695
|
await rename(transaction, destinationPath);
|
|
1362
1696
|
if (backupOwned) {
|
|
@@ -1438,12 +1772,12 @@ export async function createArchiveTool(args) {
|
|
|
1438
1772
|
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1439
1773
|
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1440
1774
|
try {
|
|
1441
|
-
await ensureDirectoryPath(snapshotRoot);
|
|
1775
|
+
await ensureDirectoryPath(snapshotRoot, { allowOutside:true });
|
|
1442
1776
|
|
|
1443
1777
|
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1444
1778
|
for (let index = 0; index < resolved.length; index += 1) {
|
|
1445
1779
|
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1446
|
-
await ensureDirectoryPath(path.dirname(snapshotPath));
|
|
1780
|
+
await ensureDirectoryPath(path.dirname(snapshotPath), { allowOutside:true });
|
|
1447
1781
|
|
|
1448
1782
|
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1449
1783
|
}
|
|
@@ -1638,7 +1972,8 @@ export async function takeScreenshotTool(args) {
|
|
|
1638
1972
|
}
|
|
1639
1973
|
if (!await pathExists(file)) fail(screenshotAdvice(attempts));
|
|
1640
1974
|
|
|
1641
|
-
const
|
|
1975
|
+
const opened = await openRegularFile(file);
|
|
1976
|
+
const { handle, info } = opened;
|
|
1642
1977
|
try {
|
|
1643
1978
|
if (info.size <= MAX_IMAGE_BYTES) {
|
|
1644
1979
|
const buffer = await handle.readFile();
|
|
@@ -1651,7 +1986,7 @@ export async function takeScreenshotTool(args) {
|
|
|
1651
1986
|
preserveCapture = true;
|
|
1652
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.`);
|
|
1653
1988
|
} finally {
|
|
1654
|
-
await
|
|
1989
|
+
await opened.close();
|
|
1655
1990
|
}
|
|
1656
1991
|
} finally {
|
|
1657
1992
|
if (!preserveCapture) {
|