@remcp/runtime 0.2.106 → 0.2.108
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 +6 -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/config.mjs +12 -2
- package/src/documents.mjs +7 -0
- package/src/extended/browser.mjs +431 -34
- package/src/extended/catalog.mjs +11 -8
- package/src/extended/desktop.mjs +14 -10
- package/src/extended/documents.mjs +389 -69
- package/src/index.mjs +2 -2
- package/src/mcp-v2.mjs +7 -3
- package/src/policy.mjs +42 -9
- package/src/tools/files.mjs +665 -85
- package/src/tools/terminal.mjs +4 -2
- package/src/util.mjs +7 -1
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';
|
|
@@ -40,8 +40,74 @@ function assertRegularFile(info, absolute) {
|
|
|
40
40
|
return info;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
const
|
|
44
|
-
const
|
|
43
|
+
const NOFOLLOW = constants.O_NOFOLLOW || 0;
|
|
44
|
+
const NONBLOCK = constants.O_NONBLOCK || 0;
|
|
45
|
+
const READ_NOFOLLOW = constants.O_RDONLY | NOFOLLOW;
|
|
46
|
+
const READ_NOFOLLOW_NONBLOCK = READ_NOFOLLOW | NONBLOCK;
|
|
47
|
+
const READWRITE_NOFOLLOW = constants.O_RDWR | NOFOLLOW;
|
|
48
|
+
const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | NOFOLLOW | NONBLOCK;
|
|
49
|
+
const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
|
|
50
|
+
const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
|
|
51
|
+
|
|
52
|
+
async function openDirectoryPath(absolute, { create = false } = {}) {
|
|
53
|
+
const descriptorBound = process.platform === 'linux' && Boolean(constants.O_NOFOLLOW) && Boolean(constants.O_DIRECTORY);
|
|
54
|
+
if (!descriptorBound) {
|
|
55
|
+
if (create) await mkdir(absolute, { recursive:true, mode:0o700 });
|
|
56
|
+
const info = await lstat(absolute);
|
|
57
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a safe directory');
|
|
58
|
+
const handle = {
|
|
59
|
+
stat: () => stat(absolute),
|
|
60
|
+
chmod: mode => chmod(absolute, mode),
|
|
61
|
+
close: async () => {},
|
|
62
|
+
};
|
|
63
|
+
return { handle, anchor:absolute, descriptorBound:false, close: handle.close };
|
|
64
|
+
}
|
|
65
|
+
if (absolute.startsWith('/proc/self/fd/')) {
|
|
66
|
+
const handle = await open(absolute, DIRECTORY_READ_NOFOLLOW);
|
|
67
|
+
return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
|
|
68
|
+
}
|
|
69
|
+
const root = path.parse(absolute).root;
|
|
70
|
+
const parts = absolute.slice(root.length).split(path.sep).filter(Boolean);
|
|
71
|
+
if (!parts.length) {
|
|
72
|
+
const handle = await open(root, DIRECTORY_READ_NOFOLLOW);
|
|
73
|
+
return { handle, anchor: `/proc/self/fd/${handle.fd}`, close: () => handle.close() };
|
|
74
|
+
}
|
|
75
|
+
const directories = [];
|
|
76
|
+
let current = root;
|
|
77
|
+
const closeDirectories = async () => {
|
|
78
|
+
for (const handle of directories.reverse()) await handle.close().catch(() => {});
|
|
79
|
+
};
|
|
80
|
+
try {
|
|
81
|
+
for (const part of parts) {
|
|
82
|
+
const candidate = path.join(current, part);
|
|
83
|
+
if (create) {
|
|
84
|
+
await mkdir(candidate, { mode:0o700 }).catch(error => {
|
|
85
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
const handle = await open(candidate, DIRECTORY_READ_NOFOLLOW);
|
|
89
|
+
const info = await handle.stat();
|
|
90
|
+
if (!info.isDirectory()) {
|
|
91
|
+
await handle.close().catch(() => {});
|
|
92
|
+
fail('Filesystem path component is not a directory');
|
|
93
|
+
}
|
|
94
|
+
directories.push(handle);
|
|
95
|
+
current = `/proc/self/fd/${handle.fd}`;
|
|
96
|
+
}
|
|
97
|
+
const handle = directories.pop();
|
|
98
|
+
return {
|
|
99
|
+
handle,
|
|
100
|
+
anchor: current,
|
|
101
|
+
close: async () => {
|
|
102
|
+
try { await handle.close(); }
|
|
103
|
+
finally { await closeDirectories(); }
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
} catch (error) {
|
|
107
|
+
await closeDirectories();
|
|
108
|
+
throw error;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
45
111
|
|
|
46
112
|
async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
47
113
|
const handle = await open(absolute, flags);
|
|
@@ -54,6 +120,51 @@ async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
|
|
|
54
120
|
}
|
|
55
121
|
}
|
|
56
122
|
|
|
123
|
+
async function openWritableParent(absolute) {
|
|
124
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
125
|
+
return openDirectoryPath(absolute, { create:true });
|
|
126
|
+
}
|
|
127
|
+
await mkdir(absolute, { recursive:true });
|
|
128
|
+
const info = await lstat(absolute);
|
|
129
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
130
|
+
return { anchor:absolute, close:async() => {} };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function writeResolvedFile(absolute, content, { append = false } = {}) {
|
|
134
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
135
|
+
let handle;
|
|
136
|
+
try {
|
|
137
|
+
const flags = append
|
|
138
|
+
? constants.O_WRONLY | constants.O_CREAT | (constants.O_APPEND || 0) | NOFOLLOW | NONBLOCK
|
|
139
|
+
: WRITE_TRUNCATE_NOFOLLOW;
|
|
140
|
+
handle = await open(path.join(parent.anchor, path.basename(absolute)), flags, 0o600);
|
|
141
|
+
await handle.writeFile(content);
|
|
142
|
+
} finally {
|
|
143
|
+
if (handle) await handle.close().catch(() => {});
|
|
144
|
+
await parent.close().catch(() => {});
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function withWritableParent(absolute, callback) {
|
|
149
|
+
const parent = await openWritableParent(path.dirname(absolute));
|
|
150
|
+
try {
|
|
151
|
+
return await callback(path.join(parent.anchor, path.basename(absolute)));
|
|
152
|
+
} finally {
|
|
153
|
+
await parent.close().catch(() => {});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function ensureDirectoryPath(absolute) {
|
|
158
|
+
if (process.platform === 'linux' && constants.O_NOFOLLOW && constants.O_DIRECTORY) {
|
|
159
|
+
const directory = await openDirectoryPath(absolute, { create:true });
|
|
160
|
+
await directory.close();
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
await mkdir(absolute, { recursive:true });
|
|
164
|
+
const info = await lstat(absolute);
|
|
165
|
+
if (info.isSymbolicLink() || !info.isDirectory()) fail('Filesystem path component is not a directory');
|
|
166
|
+
}
|
|
167
|
+
|
|
57
168
|
async function readRegularBuffer(absolute, maxBytes = Infinity) {
|
|
58
169
|
const { handle, info } = await openRegularFile(absolute);
|
|
59
170
|
try {
|
|
@@ -325,8 +436,7 @@ export async function writeFileTool(args) {
|
|
|
325
436
|
fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
326
437
|
}
|
|
327
438
|
}
|
|
328
|
-
await
|
|
329
|
-
await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
439
|
+
await writeResolvedFile(absolute, content, { append: mode === 'append' });
|
|
330
440
|
countEvent('bytesWritten', bytes);
|
|
331
441
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
|
|
332
442
|
}
|
|
@@ -381,8 +491,7 @@ export async function writeBinaryTool(args) {
|
|
|
381
491
|
recordEvent('write_denied', { reason: 'size_limit' });
|
|
382
492
|
fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
|
|
383
493
|
}
|
|
384
|
-
await
|
|
385
|
-
await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
|
|
494
|
+
await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
|
|
386
495
|
countEvent('bytesWritten', buffer.length);
|
|
387
496
|
return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
|
|
388
497
|
}
|
|
@@ -429,7 +538,7 @@ export async function editBlockTool(args) {
|
|
|
429
538
|
if (occurrences === expected) {
|
|
430
539
|
const updated = content.split(oldString).join(newString);
|
|
431
540
|
assertWritableSize(updated);
|
|
432
|
-
if (!dryRun) await
|
|
541
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
433
542
|
return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
|
|
434
543
|
}
|
|
435
544
|
if (occurrences > 0) {
|
|
@@ -451,7 +560,7 @@ export async function editBlockTool(args) {
|
|
|
451
560
|
// file to LF and turned a one-line change into a whole-file diff on Windows.
|
|
452
561
|
const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
|
|
453
562
|
assertWritableSize(updated);
|
|
454
|
-
if (!dryRun) await
|
|
563
|
+
if (!dryRun) await writeResolvedFile(absolute, updated);
|
|
455
564
|
return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
|
|
456
565
|
}
|
|
457
566
|
|
|
@@ -476,7 +585,7 @@ export async function replaceLinesTool(args) {
|
|
|
476
585
|
if (dryRun) {
|
|
477
586
|
return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
|
|
478
587
|
}
|
|
479
|
-
await
|
|
588
|
+
await writeResolvedFile(absolute, updatedText);
|
|
480
589
|
return text(`${summary}.`);
|
|
481
590
|
}
|
|
482
591
|
|
|
@@ -568,19 +677,27 @@ export async function moveToTrashTool(args) {
|
|
|
568
677
|
} catch { destination = null; }
|
|
569
678
|
}
|
|
570
679
|
if (!destination) destination = path.join(path.dirname(source), '.remcp-trash');
|
|
571
|
-
await
|
|
680
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
681
|
+
if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
682
|
+
if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
|
|
683
|
+
await ensureDirectoryPath(destination);
|
|
684
|
+
destination = await resolveSafePath(destination, 'trash');
|
|
685
|
+
const destinationInfo = await lstat(destination);
|
|
686
|
+
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
|
|
572
687
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
573
688
|
let target = path.join(destination, `${stamp}-${path.basename(source)}`);
|
|
574
689
|
let counter = 1;
|
|
575
|
-
while (await
|
|
690
|
+
while (await lstat(target).then(value => Boolean(value), () => false)) {
|
|
576
691
|
target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
|
|
577
692
|
counter += 1;
|
|
578
693
|
}
|
|
579
|
-
await
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
694
|
+
await withWritableParent(target, async anchoredTarget => {
|
|
695
|
+
await rename(source, anchoredTarget).catch(async error => {
|
|
696
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
697
|
+
if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
|
|
698
|
+
await copyFile(source, anchoredTarget);
|
|
699
|
+
await unlink(source);
|
|
700
|
+
});
|
|
584
701
|
});
|
|
585
702
|
return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
|
|
586
703
|
}
|
|
@@ -649,8 +766,7 @@ export async function writeFilesTool(args) {
|
|
|
649
766
|
const bytes = assertWritableSize(content);
|
|
650
767
|
totalBytes += bytes;
|
|
651
768
|
if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
|
|
652
|
-
await
|
|
653
|
-
await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
|
|
769
|
+
await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
|
|
654
770
|
results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
|
|
655
771
|
} catch (error) {
|
|
656
772
|
results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -720,8 +836,7 @@ export async function copyPathsTool(args) {
|
|
|
720
836
|
if (!info) throw new Error('source not found');
|
|
721
837
|
const existing = await stat(destination).catch(() => null);
|
|
722
838
|
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
723
|
-
await
|
|
724
|
-
await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
|
|
839
|
+
await withWritableParent(destination, anchoredDestination => cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite }));
|
|
725
840
|
copied += 1;
|
|
726
841
|
results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
|
|
727
842
|
} catch (error) {
|
|
@@ -747,14 +862,15 @@ export async function movePathsTool(args) {
|
|
|
747
862
|
if (!info) throw new Error('source not found');
|
|
748
863
|
const existing = await stat(destination).catch(() => null);
|
|
749
864
|
if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
|
|
750
|
-
await
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
865
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
866
|
+
try {
|
|
867
|
+
await rename(source, anchoredDestination);
|
|
868
|
+
} catch (error) {
|
|
869
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
870
|
+
await cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
|
|
871
|
+
await rm(source, { recursive: true, force: true });
|
|
872
|
+
}
|
|
873
|
+
});
|
|
758
874
|
moved += 1;
|
|
759
875
|
results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
|
|
760
876
|
} catch (error) {
|
|
@@ -789,8 +905,7 @@ export async function applyPatchTool(args) {
|
|
|
789
905
|
const stats = diffStats(original, updated);
|
|
790
906
|
if (!dryRun) {
|
|
791
907
|
assertWritableSize(updated);
|
|
792
|
-
await
|
|
793
|
-
await writeFile(absolute, updated, 'utf8');
|
|
908
|
+
await writeResolvedFile(absolute, updated);
|
|
794
909
|
}
|
|
795
910
|
changed += 1;
|
|
796
911
|
const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
|
|
@@ -855,7 +970,7 @@ export async function createDirectoryTool(args) {
|
|
|
855
970
|
for (const entry of list) {
|
|
856
971
|
try {
|
|
857
972
|
const absolute = await resolveSafePath(entry);
|
|
858
|
-
await
|
|
973
|
+
await ensureDirectoryPath(absolute);
|
|
859
974
|
created.push(displayPath(absolute));
|
|
860
975
|
} catch (error) {
|
|
861
976
|
failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
|
|
@@ -881,16 +996,17 @@ export async function moveFileTool(args) {
|
|
|
881
996
|
const entries = await readdir(destination).catch(() => []);
|
|
882
997
|
if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
|
|
883
998
|
}
|
|
884
|
-
await
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
999
|
+
await withWritableParent(destination, async anchoredDestination => {
|
|
1000
|
+
try {
|
|
1001
|
+
await rename(source, anchoredDestination);
|
|
1002
|
+
} catch (error) {
|
|
1003
|
+
if (error?.code !== 'EXDEV') throw error;
|
|
1004
|
+
const info = await stat(source);
|
|
1005
|
+
if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
|
|
1006
|
+
await copyFile(source, anchoredDestination);
|
|
1007
|
+
await unlink(source);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
894
1010
|
return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
|
|
895
1011
|
}
|
|
896
1012
|
|
|
@@ -902,12 +1018,393 @@ export async function copyFileTool(args) {
|
|
|
902
1018
|
if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
|
|
903
1019
|
const overwrite = args.overwrite !== false;
|
|
904
1020
|
if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
|
|
905
|
-
await
|
|
906
|
-
await copyFile(source, destination, overwrite ? 0 : constants.COPYFILE_EXCL);
|
|
1021
|
+
await withWritableParent(destination, anchoredDestination => copyFile(source, anchoredDestination, overwrite ? 0 : constants.COPYFILE_EXCL));
|
|
907
1022
|
return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
|
|
908
1023
|
}
|
|
909
1024
|
|
|
910
1025
|
// --- archives -------------------------------------------------------------------------
|
|
1026
|
+
const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
|
|
1027
|
+
4 * 1024 * 1024 * 1024,
|
|
1028
|
+
Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
function safeArchiveEntry(name) {
|
|
1032
|
+
const normalized = String(name || '').replace(/\\/g, '/');
|
|
1033
|
+
return Boolean(normalized) && !normalized.startsWith('/') && !/^[A-Za-z]:\//.test(normalized) && !normalized.split('/').includes('..');
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
function archiveExpandedSize(lines, pattern, label, expectedEntries = null) {
|
|
1037
|
+
let total = 0;
|
|
1038
|
+
let matched = 0;
|
|
1039
|
+
for (const line of lines) {
|
|
1040
|
+
const match = line.match(pattern);
|
|
1041
|
+
if (!match) continue;
|
|
1042
|
+
total += Number(match[1]);
|
|
1043
|
+
matched += 1;
|
|
1044
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
1045
|
+
}
|
|
1046
|
+
if ((!matched && expectedEntries !== 0) || (expectedEntries !== null && matched < expectedEntries)) fail(`Could not determine ${label} size safely`);
|
|
1047
|
+
return total;
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
function tarEntrySize(line) {
|
|
1051
|
+
const tokens = String(line || '').trim().split(/\s+/);
|
|
1052
|
+
for (const index of [2, 3, 4]) {
|
|
1053
|
+
const size = Number(tokens[index]);
|
|
1054
|
+
if (!Number.isSafeInteger(size) || size < 0) continue;
|
|
1055
|
+
const tail = tokens.slice(index + 1);
|
|
1056
|
+
const gnuDate = /^\d{4}-\d{2}-\d{2}$/.test(tail[0] || '');
|
|
1057
|
+
const bsdDate = /^[A-Z][a-z]{2}$/.test(tail[0] || '')
|
|
1058
|
+
&& /^\d{1,2}$/.test(tail[1] || '')
|
|
1059
|
+
&& ((/^\d{2}:\d{2}(?::\d{2})?$/.test(tail[2] || '') && /^\d{4}$/.test(tail[3] || ''))
|
|
1060
|
+
|| /^\d{4}$/.test(tail[2] || ''));
|
|
1061
|
+
if (gnuDate || bsdDate) return size;
|
|
1062
|
+
}
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
function archiveTarExpandedSize(lines, label, expectedEntries) {
|
|
1067
|
+
let total = 0;
|
|
1068
|
+
let matched = 0;
|
|
1069
|
+
for (const line of lines) {
|
|
1070
|
+
const size = tarEntrySize(line);
|
|
1071
|
+
if (size === null) continue;
|
|
1072
|
+
total += size;
|
|
1073
|
+
matched += 1;
|
|
1074
|
+
if (!Number.isSafeInteger(total) || total > MAX_ARCHIVE_EXPANDED_BYTES) fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
1075
|
+
}
|
|
1076
|
+
if (matched < expectedEntries) fail(`Could not determine ${label} size safely`);
|
|
1077
|
+
return total;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
function assertArchiveExpandedSize(bytes, label) {
|
|
1081
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0 || bytes > MAX_ARCHIVE_EXPANDED_BYTES) {
|
|
1082
|
+
fail(`${label} exceeds the ${MAX_ARCHIVE_EXPANDED_BYTES}-byte extraction limit`);
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
|
|
1087
|
+
const info = await lstat(root);
|
|
1088
|
+
if (info.isSymbolicLink()) fail('Archive extraction produced a symbolic link');
|
|
1089
|
+
if (!info.isDirectory() && !info.isFile()) fail('Archive extraction produced a special file');
|
|
1090
|
+
state.entries += 1;
|
|
1091
|
+
state.bytes += info.isFile() ? info.size : 0;
|
|
1092
|
+
if (state.entries > 100_000 || state.bytes > MAX_ARCHIVE_EXPANDED_BYTES) fail('Archive extraction exceeded the safety limit');
|
|
1093
|
+
if (!info.isDirectory()) return;
|
|
1094
|
+
for (const entry of await readdir(root, { withFileTypes: true })) await assertExtractedTree(path.join(root, entry.name), state);
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function openArchiveSource(absolute) {
|
|
1098
|
+
if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
|
|
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
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function snapshotRegularFile(source, destination, maxBytes) {
|
|
1138
|
+
const sourceInfo = await lstat(source);
|
|
1139
|
+
if (sourceInfo.isSymbolicLink()) fail(`Archive source is a symbolic link: ${displayPath(source)}`);
|
|
1140
|
+
if (!sourceInfo.isFile()) fail(`Archive source is not a regular file: ${displayPath(source)}`);
|
|
1141
|
+
const opened = await openArchiveSource(source);
|
|
1142
|
+
const { handle, info } = opened;
|
|
1143
|
+
let output;
|
|
1144
|
+
try {
|
|
1145
|
+
if (info.size > maxBytes) fail(`Archive is ${info.size} bytes, above the ${maxBytes}-byte snapshot limit`);
|
|
1146
|
+
output = await open(destination, WRITE_CREATE_NOFOLLOW, 0o400);
|
|
1147
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
1148
|
+
let offset = 0;
|
|
1149
|
+
for (;;) {
|
|
1150
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, offset);
|
|
1151
|
+
if (!bytesRead) break;
|
|
1152
|
+
if (!Number.isSafeInteger(offset + bytesRead) || offset + bytesRead > maxBytes) {
|
|
1153
|
+
fail(`Archive grew beyond the ${maxBytes}-byte snapshot limit`);
|
|
1154
|
+
}
|
|
1155
|
+
let written = 0;
|
|
1156
|
+
while (written < bytesRead) {
|
|
1157
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
1158
|
+
if (!result.bytesWritten) throw new Error('Could not finish writing the archive snapshot');
|
|
1159
|
+
written += result.bytesWritten;
|
|
1160
|
+
}
|
|
1161
|
+
offset += bytesRead;
|
|
1162
|
+
}
|
|
1163
|
+
await output.truncate(offset);
|
|
1164
|
+
await output.sync();
|
|
1165
|
+
await output.chmod(0o400);
|
|
1166
|
+
return offset;
|
|
1167
|
+
} finally {
|
|
1168
|
+
if (output) await output.close().catch(() => {});
|
|
1169
|
+
await opened.close().catch(() => {});
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, state) {
|
|
1174
|
+
const mode = sourceInfo.mode & 0o777;
|
|
1175
|
+
const currentBytes = state.bytes || 0;
|
|
1176
|
+
if (state.maxBytes !== undefined && sourceInfo.size > state.maxBytes - currentBytes) {
|
|
1177
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
1178
|
+
}
|
|
1179
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
1180
|
+
if (existing && (!existing.isFile() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1181
|
+
let output;
|
|
1182
|
+
try {
|
|
1183
|
+
try {
|
|
1184
|
+
output = await open(destinationPath, WRITE_TRUNCATE_NOFOLLOW, mode);
|
|
1185
|
+
} catch (error) {
|
|
1186
|
+
if (['ELOOP', 'ENOTDIR', 'EISDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1187
|
+
throw error;
|
|
1188
|
+
}
|
|
1189
|
+
const outputInfo = await output.stat();
|
|
1190
|
+
if (!outputInfo.isFile()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1191
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
1192
|
+
let offset = 0;
|
|
1193
|
+
for (;;) {
|
|
1194
|
+
const { bytesRead } = await sourceHandle.read(buffer, 0, buffer.length, offset);
|
|
1195
|
+
if (!bytesRead) break;
|
|
1196
|
+
if (!Number.isSafeInteger(offset + bytesRead) || (state.maxBytes !== undefined && currentBytes + offset + bytesRead > state.maxBytes)) {
|
|
1197
|
+
fail(`Archive contents exceed the ${state.maxBytes}-byte extraction limit`);
|
|
1198
|
+
}
|
|
1199
|
+
let written = 0;
|
|
1200
|
+
while (written < bytesRead) {
|
|
1201
|
+
const result = await output.write(buffer, written, bytesRead - written, offset + written);
|
|
1202
|
+
if (!result.bytesWritten) throw new Error('Could not finish copying archive contents');
|
|
1203
|
+
written += result.bytesWritten;
|
|
1204
|
+
}
|
|
1205
|
+
offset += bytesRead;
|
|
1206
|
+
}
|
|
1207
|
+
await output.truncate(offset);
|
|
1208
|
+
await output.sync();
|
|
1209
|
+
await output.chmod(mode);
|
|
1210
|
+
state.bytes = currentBytes + offset;
|
|
1211
|
+
} finally {
|
|
1212
|
+
if (output) await output.close().catch(() => {});
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
|
|
1217
|
+
const initial = await lstat(source);
|
|
1218
|
+
if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
|
|
1219
|
+
state.entries += 1;
|
|
1220
|
+
if (state.entries > 100_000) fail('Archive contains too many entries');
|
|
1221
|
+
if (initial.isFile()) {
|
|
1222
|
+
const opened = source.startsWith('/proc/self/fd/')
|
|
1223
|
+
? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
|
|
1224
|
+
: await openArchiveSource(source);
|
|
1225
|
+
try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
|
|
1226
|
+
finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
|
|
1227
|
+
return;
|
|
1228
|
+
}
|
|
1229
|
+
let directoryHandle = null;
|
|
1230
|
+
let sourcePath = source;
|
|
1231
|
+
let info = initial;
|
|
1232
|
+
try {
|
|
1233
|
+
try {
|
|
1234
|
+
directoryHandle = await openDirectoryPath(source);
|
|
1235
|
+
info = await directoryHandle.handle.stat();
|
|
1236
|
+
if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1237
|
+
sourcePath = directoryHandle.anchor;
|
|
1238
|
+
} catch (error) {
|
|
1239
|
+
if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1240
|
+
throw error;
|
|
1241
|
+
}
|
|
1242
|
+
const mode = info.mode & 0o777;
|
|
1243
|
+
const existing = await lstat(destination).catch(() => null);
|
|
1244
|
+
if (existing && (!existing.isDirectory() || existing.isSymbolicLink())) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1245
|
+
try { await mkdir(destination, { mode: mode | 0o700 }); }
|
|
1246
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
1247
|
+
const destinationInfo = await lstat(destination);
|
|
1248
|
+
if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1249
|
+
let destinationHandle;
|
|
1250
|
+
try {
|
|
1251
|
+
destinationHandle = await openDirectoryPath(destination);
|
|
1252
|
+
const openedDestination = await destinationHandle.handle.stat();
|
|
1253
|
+
if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1254
|
+
await destinationHandle.handle.chmod(mode | 0o700);
|
|
1255
|
+
const destinationAnchor = destinationHandle.anchor;
|
|
1256
|
+
for (const name of await readdir(sourcePath)) {
|
|
1257
|
+
await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
|
|
1258
|
+
}
|
|
1259
|
+
await destinationHandle.handle.chmod(mode);
|
|
1260
|
+
} finally {
|
|
1261
|
+
if (destinationHandle) await destinationHandle.close().catch(() => {});
|
|
1262
|
+
}
|
|
1263
|
+
} finally {
|
|
1264
|
+
if (directoryHandle) await directoryHandle.close().catch(() => {});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
async function removeExtractionArtifact(target) {
|
|
1269
|
+
const info = await lstat(target).catch(() => null);
|
|
1270
|
+
if (!info) return;
|
|
1271
|
+
if (info.isDirectory() && !info.isSymbolicLink()) await rm(target, { recursive: true, force: true });
|
|
1272
|
+
else await unlink(target).catch(error => { if (error?.code !== 'ENOENT') throw error; });
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
async function recoverExtractionArtifacts(parent, destination, prefix) {
|
|
1276
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1277
|
+
const anchor = parentDirectory.anchor;
|
|
1278
|
+
const destinationPath = path.join(anchor, path.basename(destination));
|
|
1279
|
+
try {
|
|
1280
|
+
const entries = await readdir(anchor, { withFileTypes: true });
|
|
1281
|
+
const staleBefore = Date.now() - 60 * 60 * 1000;
|
|
1282
|
+
for (const entry of entries) {
|
|
1283
|
+
if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
|
|
1284
|
+
const candidate = path.join(anchor, entry.name);
|
|
1285
|
+
const info = await lstat(candidate).catch(() => null);
|
|
1286
|
+
if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
|
|
1287
|
+
}
|
|
1288
|
+
const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(anchor, entry.name));
|
|
1289
|
+
for (const backup of backups) {
|
|
1290
|
+
const destinationInfo = await lstat(destinationPath).catch(() => null);
|
|
1291
|
+
if (destinationInfo) {
|
|
1292
|
+
await removeExtractionArtifact(backup);
|
|
1293
|
+
continue;
|
|
1294
|
+
}
|
|
1295
|
+
const backupInfo = await lstat(backup).catch(() => null);
|
|
1296
|
+
if (!backupInfo) continue;
|
|
1297
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
|
|
1298
|
+
await removeExtractionArtifact(backup);
|
|
1299
|
+
continue;
|
|
1300
|
+
}
|
|
1301
|
+
try {
|
|
1302
|
+
await rename(backup, destinationPath);
|
|
1303
|
+
} catch {
|
|
1304
|
+
continue;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
} finally {
|
|
1308
|
+
await parentDirectory.close().catch(() => {});
|
|
1309
|
+
}
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
async function restoreExtractionBackup(backup, destination) {
|
|
1313
|
+
if (await lstat(destination).catch(() => null)) return false;
|
|
1314
|
+
const info = await lstat(backup).catch(() => null);
|
|
1315
|
+
if (!info) return false;
|
|
1316
|
+
if (info.isSymbolicLink() || !info.isDirectory()) {
|
|
1317
|
+
await removeExtractionArtifact(backup);
|
|
1318
|
+
return false;
|
|
1319
|
+
}
|
|
1320
|
+
try {
|
|
1321
|
+
await rename(backup, destination);
|
|
1322
|
+
return true;
|
|
1323
|
+
} catch {
|
|
1324
|
+
return false;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
async function installExtractedTree(source, destination) {
|
|
1329
|
+
const parent = path.dirname(destination);
|
|
1330
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1331
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1332
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1333
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1334
|
+
const parentAnchor = parentDirectory.anchor;
|
|
1335
|
+
const destinationPath = path.join(parentAnchor, path.basename(destination));
|
|
1336
|
+
const existing = await lstat(destinationPath).catch(() => null);
|
|
1337
|
+
const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
|
|
1338
|
+
let transaction = '';
|
|
1339
|
+
let backup = '';
|
|
1340
|
+
let backupOwned = false;
|
|
1341
|
+
let preserveBackup = false;
|
|
1342
|
+
try {
|
|
1343
|
+
transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
|
|
1344
|
+
if (existing) {
|
|
1345
|
+
backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
|
|
1346
|
+
try {
|
|
1347
|
+
await rename(destinationPath, backup);
|
|
1348
|
+
backupOwned = true;
|
|
1349
|
+
} catch (error) {
|
|
1350
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
1351
|
+
backup = '';
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
if (backupOwned) {
|
|
1355
|
+
const backupInfo = await lstat(backup);
|
|
1356
|
+
if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) fail('Archive destination is not a safe directory');
|
|
1357
|
+
await copyTreeContents(backup, transaction);
|
|
1358
|
+
}
|
|
1359
|
+
await copyTreeContents(source, transaction);
|
|
1360
|
+
await chmod(transaction, destinationMode);
|
|
1361
|
+
await rename(transaction, destinationPath);
|
|
1362
|
+
if (backupOwned) {
|
|
1363
|
+
await removeExtractionArtifact(backup);
|
|
1364
|
+
backupOwned = false;
|
|
1365
|
+
}
|
|
1366
|
+
} catch (error) {
|
|
1367
|
+
if (backupOwned) {
|
|
1368
|
+
try {
|
|
1369
|
+
await restoreExtractionBackup(backup, destinationPath);
|
|
1370
|
+
if (!await lstat(backup).catch(() => null)) backupOwned = false;
|
|
1371
|
+
else preserveBackup = true;
|
|
1372
|
+
} catch (restoreError) {
|
|
1373
|
+
preserveBackup = true;
|
|
1374
|
+
throw new AggregateError([error, restoreError], 'Could not restore archive destination after extraction failure');
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
throw error;
|
|
1378
|
+
} finally {
|
|
1379
|
+
if (transaction) await rm(transaction, { recursive: true, force: true });
|
|
1380
|
+
if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
|
|
1381
|
+
await parentDirectory.close().catch(() => {});
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
async function moveArchiveFile(source, destination) {
|
|
1386
|
+
const parent = path.dirname(destination);
|
|
1387
|
+
const parentDirectory = await openDirectoryPath(parent, { create:true });
|
|
1388
|
+
const destinationPath = path.join(parentDirectory.anchor, path.basename(destination));
|
|
1389
|
+
try {
|
|
1390
|
+
try {
|
|
1391
|
+
await rename(source, destinationPath);
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
|
|
1394
|
+
const temporary = path.join(parentDirectory.anchor, `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
|
|
1395
|
+
try {
|
|
1396
|
+
await copyFile(source, temporary, constants.COPYFILE_EXCL);
|
|
1397
|
+
await rename(temporary, destinationPath);
|
|
1398
|
+
} finally {
|
|
1399
|
+
await unlink(temporary).catch(() => {});
|
|
1400
|
+
}
|
|
1401
|
+
await unlink(source);
|
|
1402
|
+
}
|
|
1403
|
+
} finally {
|
|
1404
|
+
await parentDirectory.close().catch(() => {});
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
|
|
911
1408
|
function archiveTool() {
|
|
912
1409
|
const probe = (name, versionArgs = ['--version']) => {
|
|
913
1410
|
const result = spawnSync(name, versionArgs, { encoding: 'utf8' });
|
|
@@ -925,56 +1422,129 @@ export async function createArchiveTool(args) {
|
|
|
925
1422
|
const resolved = [];
|
|
926
1423
|
for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
|
|
927
1424
|
const destination = await resolveSafePath(args.destination, 'destination');
|
|
1425
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
1426
|
+
if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
|
|
928
1427
|
const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
|
|
929
|
-
await mkdir(path.dirname(destination), { recursive: true });
|
|
930
1428
|
const baseDir = path.dirname(resolved[0]);
|
|
931
1429
|
const names = resolved.map(entry => path.relative(baseDir, entry));
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
// tree first and move it into place afterwards.
|
|
935
|
-
const destinationRelative = path.relative(baseDir, destination);
|
|
936
|
-
const selfInside = !destinationRelative.startsWith('..') && !path.isAbsolute(destinationRelative);
|
|
1430
|
+
if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
|
|
1431
|
+
const archiveNames = names.map(name => name.startsWith('-') ? `./${name}` : name);
|
|
937
1432
|
const suffix = format === 'zip' ? '.zip' : format === 'tar' ? '.tar' : '.tar.gz';
|
|
938
|
-
const
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
const result = spawnSync(tools.zip, ['-r', '-q', output, ...names], { cwd: baseDir, encoding: 'utf8' });
|
|
943
|
-
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
944
|
-
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
945
|
-
if (!tools.tar) fail('tar is not installed on this device');
|
|
946
|
-
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
947
|
-
const result = spawnSync(tools.tar, [flags, output, ...names], { cwd: baseDir, encoding: 'utf8' });
|
|
948
|
-
if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
|
|
949
|
-
} else {
|
|
950
|
-
fail('format must be tar, tar.gz, or zip');
|
|
1433
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-archive-'));
|
|
1434
|
+
if (resolved.some(entry => !path.relative(entry, stagingDirectory).startsWith('..') && !path.isAbsolute(path.relative(entry, stagingDirectory)))) {
|
|
1435
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
1436
|
+
fail('Archive source must not contain the system temporary directory');
|
|
951
1437
|
}
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
1438
|
+
const staging = path.join(stagingDirectory, `payload${suffix}`);
|
|
1439
|
+
const snapshotRoot = path.join(stagingDirectory, 'source');
|
|
1440
|
+
try {
|
|
1441
|
+
await ensureDirectoryPath(snapshotRoot);
|
|
1442
|
+
|
|
1443
|
+
const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
|
|
1444
|
+
for (let index = 0; index < resolved.length; index += 1) {
|
|
1445
|
+
const snapshotPath = path.join(snapshotRoot, names[index]);
|
|
1446
|
+
await ensureDirectoryPath(path.dirname(snapshotPath));
|
|
1447
|
+
|
|
1448
|
+
await copyTreeContents(resolved[index], snapshotPath, snapshotState);
|
|
1449
|
+
}
|
|
1450
|
+
if (format === 'zip') {
|
|
1451
|
+
if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
|
|
1452
|
+
const result = spawnSync(tools.zip, ['-r', '-q', '-y', staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1453
|
+
if (result.status !== 0) fail(`zip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1454
|
+
} else if (format === 'tar' || format === 'tar.gz' || format === 'tgz') {
|
|
1455
|
+
if (!tools.tar) fail('tar is not installed on this device');
|
|
1456
|
+
const flags = format === 'tar' ? '-cf' : '-czf';
|
|
1457
|
+
const result = spawnSync(tools.tar, [flags, staging, '--', ...archiveNames], { cwd: snapshotRoot, encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1458
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1459
|
+
} else {
|
|
1460
|
+
fail('format must be tar, tar.gz, or zip');
|
|
1461
|
+
}
|
|
1462
|
+
const stagedInfo = await stat(staging);
|
|
1463
|
+
assertArchiveExpandedSize(stagedInfo.size, 'archive output');
|
|
1464
|
+
|
|
1465
|
+
await moveArchiveFile(staging, destination);
|
|
1466
|
+
|
|
1467
|
+
const info = await stat(destination).catch(() => null);
|
|
1468
|
+
const note = ' (built outside the tree in temporary staging and committed atomically)';
|
|
1469
|
+
|
|
1470
|
+
return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
|
|
1471
|
+
} finally {
|
|
1472
|
+
if (stagingDirectory) await rm(stagingDirectory, { recursive: true, force: true });
|
|
955
1473
|
}
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
function archiveFormatForPath(value) {
|
|
1477
|
+
if (/\.zip$/i.test(value)) return 'zip';
|
|
1478
|
+
if (/\.(tar\.gz|tgz)$/i.test(value)) return 'tar.gz';
|
|
1479
|
+
if (/\.(tar\.bz2|tbz2?)$/i.test(value)) return 'tar.bz2';
|
|
1480
|
+
if (/\.tar\.xz$/i.test(value)) return 'tar.xz';
|
|
1481
|
+
return 'tar';
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
function archiveSnapshotSuffix(format) {
|
|
1485
|
+
if (format === 'zip') return '.zip';
|
|
1486
|
+
if (format === 'tar.bz2') return '.tar.bz2';
|
|
1487
|
+
if (format === 'tar.xz') return '.tar.xz';
|
|
1488
|
+
if (format === 'tar.gz') return '.tar.gz';
|
|
1489
|
+
return '.tar';
|
|
959
1490
|
}
|
|
960
1491
|
|
|
961
1492
|
export async function extractArchiveTool(args) {
|
|
962
1493
|
const tools = archiveTool();
|
|
963
1494
|
const archive = await resolveSafePath(args.archive, 'archive');
|
|
964
1495
|
const destination = await resolveSafePath(args.destination || path.dirname(archive), 'destination');
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1496
|
+
if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
|
|
1497
|
+
const parent = path.dirname(destination);
|
|
1498
|
+
const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
|
|
1499
|
+
const format = archiveFormatForPath(archive);
|
|
1500
|
+
const stagingDirectory = await mkdtemp(path.join(os.tmpdir(), 'remcp-extract-'));
|
|
1501
|
+
const archiveSnapshot = path.join(stagingDirectory, `source${archiveSnapshotSuffix(format)}`);
|
|
1502
|
+
const staging = path.join(stagingDirectory, 'tree');
|
|
1503
|
+
try {
|
|
1504
|
+
await mkdir(staging, { mode: 0o700 });
|
|
1505
|
+
await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
|
|
1506
|
+
await recoverExtractionArtifacts(parent, destination, backupPrefix);
|
|
1507
|
+
const existingDestination = await lstat(destination).catch(() => null);
|
|
1508
|
+
if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
|
|
1509
|
+
if (format === 'zip') {
|
|
1510
|
+
if (!tools.unzip) fail('unzip is not installed on this device');
|
|
1511
|
+
const listing = spawnSync(tools.unzip, ['-Z', '-1', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1512
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect ZIP archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
1513
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
1514
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('ZIP archive contains an unsafe path or too many entries');
|
|
1515
|
+
const details = spawnSync(tools.unzip, ['-Z', '-v', archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1516
|
+
if (details.error || details.status !== 0) fail(`Could not inspect ZIP sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
1517
|
+
const detailText = String(details.stdout || '');
|
|
1518
|
+
if (/symbolic link|Unix file attributes \([^)]*\b12\d{4}/i.test(detailText)) fail('ZIP archive contains a symbolic link');
|
|
1519
|
+
const expandedBytes = archiveExpandedSize(detailText.split(/\r?\n/), /uncompressed size:\s*(\d+)\s*bytes/i, 'ZIP archive', names.length);
|
|
1520
|
+
assertArchiveExpandedSize(expandedBytes, 'ZIP archive');
|
|
1521
|
+
const result = spawnSync(tools.unzip, ['-o', '-q', archiveSnapshot, '-d', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1522
|
+
if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1523
|
+
} else {
|
|
1524
|
+
if (!tools.tar) fail('tar is not installed on this device');
|
|
1525
|
+
const listFlags = format === 'tar.gz' ? '-tzf' : format === 'tar.bz2' ? '-tjf' : format === 'tar.xz' ? '-tJf' : '-tf';
|
|
1526
|
+
const listing = spawnSync(tools.tar, [listFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1527
|
+
if (listing.error || listing.status !== 0) fail(`Could not inspect archive: ${(listing.stderr || listing.error?.message || `exit ${listing.status}`).trim()}`);
|
|
1528
|
+
const names = String(listing.stdout || '').split(/\r?\n/).filter(Boolean);
|
|
1529
|
+
if (names.length > 100_000 || names.some(name => !safeArchiveEntry(name))) fail('Archive contains an unsafe path or too many entries');
|
|
1530
|
+
const verboseFlags = format === 'tar.gz' ? '-tvzf' : format === 'tar.bz2' ? '-tvjf' : format === 'tar.xz' ? '-tvJf' : '-tvf';
|
|
1531
|
+
const details = spawnSync(tools.tar, [verboseFlags, archiveSnapshot], { encoding: 'utf8', timeout: 30_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1532
|
+
if (details.error || details.status !== 0) fail(`Could not inspect archive sizes: ${(details.stderr || details.error?.message || `exit ${details.status}`).trim()}`);
|
|
1533
|
+
const detailLines = String(details.stdout || '').split(/\r?\n/);
|
|
1534
|
+
if (detailLines.some(line => /^[bclph]/.test(line))) fail('Archive contains a link or special file; archive destination is unsafe');
|
|
1535
|
+
const expandedBytes = archiveTarExpandedSize(detailLines, 'archive', names.length);
|
|
1536
|
+
assertArchiveExpandedSize(expandedBytes, 'archive');
|
|
1537
|
+
const flags = format === 'tar.gz' ? '-xzf' : format === 'tar.bz2' ? '-xjf' : format === 'tar.xz' ? '-xJf' : '-xf';
|
|
1538
|
+
const result = spawnSync(tools.tar, [flags, archiveSnapshot, '-C', staging], { encoding: 'utf8', timeout: 120_000, maxBuffer: 16 * 1024 * 1024 });
|
|
1539
|
+
if (result.status !== 0) fail(`tar failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
|
|
1540
|
+
}
|
|
1541
|
+
await assertExtractedTree(staging);
|
|
1542
|
+
await installExtractedTree(staging, destination);
|
|
1543
|
+
const entries = await readdir(destination).catch(() => []);
|
|
1544
|
+
return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
|
|
1545
|
+
} finally {
|
|
1546
|
+
await rm(stagingDirectory, { recursive: true, force: true });
|
|
975
1547
|
}
|
|
976
|
-
const entries = await readdir(destination).catch(() => []);
|
|
977
|
-
return text(`Extracted ${displayPath(archive)} into ${displayPath(destination)} (${entries.length} top-level entries).`);
|
|
978
1548
|
}
|
|
979
1549
|
|
|
980
1550
|
// --- screenshots ----------------------------------------------------------------------
|
|
@@ -1028,8 +1598,17 @@ export async function takeScreenshotTool(args) {
|
|
|
1028
1598
|
const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
|
|
1029
1599
|
const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
|
|
1030
1600
|
const directory = requestedDirectory || temporaryDirectory;
|
|
1031
|
-
|
|
1032
|
-
|
|
1601
|
+
let outputDirectory = null;
|
|
1602
|
+
if (requestedDirectory) {
|
|
1603
|
+
await ensureDirectoryPath(directory);
|
|
1604
|
+
outputDirectory = await openWritableParent(directory);
|
|
1605
|
+
}
|
|
1606
|
+
const outputAnchor = outputDirectory
|
|
1607
|
+
? outputDirectory.anchor.replace('/proc/self/fd/', `/proc/${process.pid}/fd/`)
|
|
1608
|
+
: directory;
|
|
1609
|
+
const fileName = `capture-${randomUUID()}.png`;
|
|
1610
|
+
const file = path.join(outputAnchor, fileName);
|
|
1611
|
+
const displayFile = path.join(directory, fileName);
|
|
1033
1612
|
const attempts = [];
|
|
1034
1613
|
let preserveCapture = false;
|
|
1035
1614
|
try {
|
|
@@ -1065,12 +1644,12 @@ export async function takeScreenshotTool(args) {
|
|
|
1065
1644
|
const buffer = await handle.readFile();
|
|
1066
1645
|
preserveCapture = args.keep === true;
|
|
1067
1646
|
return multi([
|
|
1068
|
-
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(
|
|
1647
|
+
{ type: 'text', text: `Screenshot of ${os.hostname()} (${info.size} bytes)${preserveCapture ? (' saved at ' + displayPath(displayFile)) : ''}` },
|
|
1069
1648
|
image(buffer.toString('base64'), 'image/png'),
|
|
1070
1649
|
]);
|
|
1071
1650
|
}
|
|
1072
1651
|
preserveCapture = true;
|
|
1073
|
-
return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(
|
|
1652
|
+
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.`);
|
|
1074
1653
|
} finally {
|
|
1075
1654
|
await handle.close();
|
|
1076
1655
|
}
|
|
@@ -1079,6 +1658,7 @@ export async function takeScreenshotTool(args) {
|
|
|
1079
1658
|
if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
|
|
1080
1659
|
else await rm(file, { force: true }).catch(() => {});
|
|
1081
1660
|
}
|
|
1661
|
+
if (outputDirectory) await outputDirectory.close().catch(() => {});
|
|
1082
1662
|
}
|
|
1083
1663
|
}
|
|
1084
1664
|
export const fileToolHandlers = {
|