@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.
@@ -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, writeFile } from 'node:fs/promises';
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 READ_NOFOLLOW = constants.O_RDONLY | (constants.O_NOFOLLOW || 0);
44
- const READWRITE_NOFOLLOW = constants.O_RDWR | (constants.O_NOFOLLOW || 0);
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 mkdir(path.dirname(absolute), { recursive: true });
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 mkdir(path.dirname(absolute), { recursive: true });
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 writeFile(absolute, updated, 'utf8');
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 writeFile(absolute, updated, 'utf8');
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 writeFile(absolute, updatedText, 'utf8');
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 mkdir(destination, { recursive: true });
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 access(target, constants.F_OK).then(() => true, () => false)) {
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 rename(source, target).catch(async error => {
580
- if (error?.code !== 'EXDEV') throw error;
581
- if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
582
- await copyFile(source, target);
583
- await unlink(source);
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 mkdir(path.dirname(absolute), { recursive: true });
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 mkdir(path.dirname(destination), { recursive: true });
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 mkdir(path.dirname(destination), { recursive: true });
751
- try {
752
- await rename(source, destination);
753
- } catch (error) {
754
- if (error?.code !== 'EXDEV') throw error;
755
- await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
756
- await rm(source, { recursive: true, force: true });
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 mkdir(path.dirname(absolute), { recursive: true });
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 mkdir(absolute, { recursive: true });
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 mkdir(path.dirname(destination), { recursive: true });
885
- try {
886
- await rename(source, destination);
887
- } catch (error) {
888
- if (error?.code !== 'EXDEV') throw error;
889
- const info = await stat(source);
890
- if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
891
- await copyFile(source, destination);
892
- await unlink(source);
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 mkdir(path.dirname(destination), { recursive: true });
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
- // An archive written inside the tree it packs makes tar abort with "file changed as we
933
- // read it" (the directory mtime moves while it is being read), so build it outside the
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 staging = selfInside ? path.join(os.tmpdir(), `remcp-archive-${Date.now()}-${process.pid}${suffix}`) : destination;
939
- const output = staging;
940
- if (format === 'zip') {
941
- if (!tools.zip) fail('zip is not installed on this device; use format "tar.gz"');
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
- if (selfInside) {
953
- await mkdir(path.dirname(destination), { recursive: true });
954
- await rename(staging, destination);
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
- const info = await stat(destination).catch(() => null);
957
- const note = selfInside ? ' (built outside the tree so it does not include itself)' : '';
958
- return text(`Created ${displayPath(destination)} (${format}, ${info?.size ?? 0} bytes) from ${resolved.length} path(s)${note}.`);
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
- await mkdir(destination, { recursive: true });
966
- if (/\.zip$/i.test(archive)) {
967
- if (!tools.unzip) fail('unzip is not installed on this device');
968
- const result = spawnSync(tools.unzip, ['-o', '-q', archive, '-d', destination], { encoding: 'utf8' });
969
- if (result.status !== 0) fail(`unzip failed: ${(result.stderr || result.stdout || '').trim() || `exit ${result.status}`}`);
970
- } else {
971
- if (!tools.tar) fail('tar is not installed on this device');
972
- const flags = /\.(tar\.gz|tgz)$/i.test(archive) ? '-xzf' : /\.(tar\.bz2|tbz2?)$/i.test(archive) ? '-xjf' : /\.tar\.xz$/i.test(archive) ? '-xJf' : '-xf';
973
- const result = spawnSync(tools.tar, [flags, archive, '-C', destination], { encoding: 'utf8' });
974
- if (result.status !== 0) fail(`tar failed: ${(result.stderr || '').trim() || `exit ${result.status}`}`);
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
- if (requestedDirectory) await mkdir(directory, { recursive: true });
1032
- const file = path.join(directory, `capture-${randomUUID()}.png`);
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(file)) : ''}` },
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(file)} 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.`);
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 = {