@remcp/runtime 0.2.107 → 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';
@@ -49,6 +49,66 @@ const WRITE_CREATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants
49
49
  const WRITE_TRUNCATE_NOFOLLOW = constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | NOFOLLOW | NONBLOCK;
50
50
  const DIRECTORY_READ_NOFOLLOW = constants.O_RDONLY | (constants.O_DIRECTORY || 0) | NOFOLLOW | NONBLOCK;
51
51
 
52
+ async function 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
+ }
111
+
52
112
  async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
53
113
  const handle = await open(absolute, flags);
54
114
  try {
@@ -60,6 +120,51 @@ async function openRegularFile(absolute, flags = READ_NOFOLLOW) {
60
120
  }
61
121
  }
62
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
+
63
168
  async function readRegularBuffer(absolute, maxBytes = Infinity) {
64
169
  const { handle, info } = await openRegularFile(absolute);
65
170
  try {
@@ -331,8 +436,7 @@ export async function writeFileTool(args) {
331
436
  fail(`Appending ${bytes} bytes would grow ${displayPath(absolute)} to ${existingSize + bytes} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
332
437
  }
333
438
  }
334
- await mkdir(path.dirname(absolute), { recursive: true });
335
- await writeFile(absolute, content, mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
439
+ await writeResolvedFile(absolute, content, { append: mode === 'append' });
336
440
  countEvent('bytesWritten', bytes);
337
441
  return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${bytes} bytes to ${displayPath(absolute)}.`);
338
442
  }
@@ -387,8 +491,7 @@ export async function writeBinaryTool(args) {
387
491
  recordEvent('write_denied', { reason: 'size_limit' });
388
492
  fail(`Decoded content is ${buffer.length} bytes, above the ${runtimeConfig.maxWriteBytes}-byte write limit for this device`);
389
493
  }
390
- await mkdir(path.dirname(absolute), { recursive: true });
391
- await writeFile(absolute, buffer, mode === 'append' ? { flag: 'a' } : undefined);
494
+ await writeResolvedFile(absolute, buffer, { append: mode === 'append' });
392
495
  countEvent('bytesWritten', buffer.length);
393
496
  return text(`${mode === 'append' ? 'Appended' : 'Wrote'} ${buffer.length} bytes to ${displayPath(absolute)}.`);
394
497
  }
@@ -435,7 +538,7 @@ export async function editBlockTool(args) {
435
538
  if (occurrences === expected) {
436
539
  const updated = content.split(oldString).join(newString);
437
540
  assertWritableSize(updated);
438
- if (!dryRun) await writeFile(absolute, updated, 'utf8');
541
+ if (!dryRun) await writeResolvedFile(absolute, updated);
439
542
  return text(present(updated, `Replaced ${occurrences} occurrence(s)`));
440
543
  }
441
544
  if (occurrences > 0) {
@@ -457,7 +560,7 @@ export async function editBlockTool(args) {
457
560
  // file to LF and turned a one-line change into a whole-file diff on Windows.
458
561
  const updated = `${lines.join(eol)}${endsWithNewline && lines.length ? eol : ''}`;
459
562
  assertWritableSize(updated);
460
- if (!dryRun) await writeFile(absolute, updated, 'utf8');
563
+ if (!dryRun) await writeResolvedFile(absolute, updated);
461
564
  return text(present(updated, `Replaced ${starts.length} occurrence(s) using whitespace-tolerant matching (line endings kept as ${eol === '\r\n' ? 'CRLF' : 'LF'})`));
462
565
  }
463
566
 
@@ -482,7 +585,7 @@ export async function replaceLinesTool(args) {
482
585
  if (dryRun) {
483
586
  return text(`${summary}\n(dry run: nothing was written)\n${unifiedDiff(original, updatedText, { oldLabel: displayPath(absolute), newLabel: 'after' })}`);
484
587
  }
485
- await writeFile(absolute, updatedText, 'utf8');
588
+ await writeResolvedFile(absolute, updatedText);
486
589
  return text(`${summary}.`);
487
590
  }
488
591
 
@@ -577,7 +680,7 @@ export async function moveToTrashTool(args) {
577
680
  const existingDestination = await lstat(destination).catch(() => null);
578
681
  if (existingDestination?.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
579
682
  if (existingDestination && !existingDestination.isDirectory()) fail('Trash destination is not a directory');
580
- await mkdir(destination, { recursive: true, mode: 0o700 });
683
+ await ensureDirectoryPath(destination);
581
684
  destination = await resolveSafePath(destination, 'trash');
582
685
  const destinationInfo = await lstat(destination);
583
686
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Refusing to use a symlink as the trash directory');
@@ -588,11 +691,13 @@ export async function moveToTrashTool(args) {
588
691
  target = path.join(destination, `${stamp}-${counter}-${path.basename(source)}`);
589
692
  counter += 1;
590
693
  }
591
- await rename(source, target).catch(async error => {
592
- if (error?.code !== 'EXDEV') throw error;
593
- if (info.isDirectory()) fail('Moving a directory to the trash across filesystems is not supported');
594
- await copyFile(source, target);
595
- 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
+ });
596
701
  });
597
702
  return text(`Moved ${displayPath(source)} to ${displayPath(target)}. Restore it with move_file if this was a mistake.`);
598
703
  }
@@ -661,8 +766,7 @@ export async function writeFilesTool(args) {
661
766
  const bytes = assertWritableSize(content);
662
767
  totalBytes += bytes;
663
768
  if (totalBytes > runtimeConfig.maxWriteBytes * 4) fail(`This call would write ${totalBytes} bytes, above the ${runtimeConfig.maxWriteBytes * 4}-byte batch limit`);
664
- await mkdir(path.dirname(absolute), { recursive: true });
665
- await writeFile(absolute, content, entry.mode === 'append' ? { encoding: 'utf8', flag: 'a' } : 'utf8');
769
+ await writeResolvedFile(absolute, content, { append: entry.mode === 'append' });
666
770
  results.push(`${entry.mode === 'append' ? 'appended' : 'wrote'} ${displayPath(absolute)} (${bytes} bytes)`);
667
771
  } catch (error) {
668
772
  results.push(`failed ${target}: ${describeFilesystemFailure(error, { path: error?.path })}`);
@@ -732,8 +836,7 @@ export async function copyPathsTool(args) {
732
836
  if (!info) throw new Error('source not found');
733
837
  const existing = await stat(destination).catch(() => null);
734
838
  if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
735
- await mkdir(path.dirname(destination), { recursive: true });
736
- await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
839
+ await withWritableParent(destination, anchoredDestination => cp(source, anchoredDestination, { recursive: true, force: overwrite, errorOnExist: !overwrite }));
737
840
  copied += 1;
738
841
  results.push(`copied ${displayPath(source)} → ${displayPath(destination)}`);
739
842
  } catch (error) {
@@ -759,14 +862,15 @@ export async function movePathsTool(args) {
759
862
  if (!info) throw new Error('source not found');
760
863
  const existing = await stat(destination).catch(() => null);
761
864
  if (existing && !overwrite) throw new Error('destination already exists (pass overwrite: true)');
762
- await mkdir(path.dirname(destination), { recursive: true });
763
- try {
764
- await rename(source, destination);
765
- } catch (error) {
766
- if (error?.code !== 'EXDEV') throw error;
767
- await cp(source, destination, { recursive: true, force: overwrite, errorOnExist: !overwrite });
768
- await rm(source, { recursive: true, force: true });
769
- }
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
+ });
770
874
  moved += 1;
771
875
  results.push(`moved ${displayPath(source)} → ${displayPath(destination)}`);
772
876
  } catch (error) {
@@ -801,8 +905,7 @@ export async function applyPatchTool(args) {
801
905
  const stats = diffStats(original, updated);
802
906
  if (!dryRun) {
803
907
  assertWritableSize(updated);
804
- await mkdir(path.dirname(absolute), { recursive: true });
805
- await writeFile(absolute, updated, 'utf8');
908
+ await writeResolvedFile(absolute, updated);
806
909
  }
807
910
  changed += 1;
808
911
  const fuzzy = applied.filter(entry => entry.fuzz > 0).length;
@@ -867,7 +970,7 @@ export async function createDirectoryTool(args) {
867
970
  for (const entry of list) {
868
971
  try {
869
972
  const absolute = await resolveSafePath(entry);
870
- await mkdir(absolute, { recursive: true });
973
+ await ensureDirectoryPath(absolute);
871
974
  created.push(displayPath(absolute));
872
975
  } catch (error) {
873
976
  failed.push(`${entry}: ${describeFilesystemFailure(error, { path: error?.path })}`);
@@ -893,16 +996,17 @@ export async function moveFileTool(args) {
893
996
  const entries = await readdir(destination).catch(() => []);
894
997
  if (entries.length) fail(`Destination is a non-empty directory: ${displayPath(destination)}. Move it aside or pick another name.`);
895
998
  }
896
- await mkdir(path.dirname(destination), { recursive: true });
897
- try {
898
- await rename(source, destination);
899
- } catch (error) {
900
- if (error?.code !== 'EXDEV') throw error;
901
- const info = await stat(source);
902
- if (info.isDirectory()) fail('Moving a directory across filesystems is not supported; copy it manually or move within one volume');
903
- await copyFile(source, destination);
904
- await unlink(source);
905
- }
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
+ });
906
1010
  return text(`Moved ${displayPath(source)} to ${displayPath(destination)}.`);
907
1011
  }
908
1012
 
@@ -914,27 +1018,11 @@ export async function copyFileTool(args) {
914
1018
  if (info.isDirectory()) fail('copy_file copies single files only; create the directory and copy its files individually');
915
1019
  const overwrite = args.overwrite !== false;
916
1020
  if (!overwrite && await pathExists(destination)) fail(`Destination already exists: ${displayPath(destination)}. Pass overwrite: true to replace it.`);
917
- await mkdir(path.dirname(destination), { recursive: true });
918
- await copyFile(source, destination, overwrite ? 0 : constants.COPYFILE_EXCL);
1021
+ await withWritableParent(destination, anchoredDestination => copyFile(source, anchoredDestination, overwrite ? 0 : constants.COPYFILE_EXCL));
919
1022
  return text(`Copied ${displayPath(source)} to ${displayPath(destination)} (${info.size} bytes).`);
920
1023
  }
921
1024
 
922
1025
  // --- archives -------------------------------------------------------------------------
923
- async function assertNoSymlinkTree(root, state = { entries: 0 }) {
924
- const info = await lstat(root);
925
- if (info.isSymbolicLink()) fail(`Archive sources cannot contain symbolic links: ${displayPath(root)}`);
926
- if (!info.isDirectory() && !info.isFile()) fail(`Archive sources cannot contain special files: ${displayPath(root)}`);
927
- state.entries += 1;
928
- if (state.entries > 100_000) fail('Archive source contains too many entries');
929
- if (state.maxBytes !== undefined && info.isFile()) {
930
- state.bytes = (state.bytes || 0) + info.size;
931
- if (state.bytes > state.maxBytes) fail('Archive source exceeds the safety limit');
932
- }
933
- if (!info.isDirectory()) return;
934
- const entries = await readdir(root, { withFileTypes: true });
935
- for (const entry of entries) await assertNoSymlinkTree(path.join(root, entry.name), state);
936
- }
937
-
938
1026
  const MAX_ARCHIVE_EXPANDED_BYTES = Math.min(
939
1027
  4 * 1024 * 1024 * 1024,
940
1028
  Math.max(64 * 1024 * 1024, Number(process.env.REMCP_MAX_ARCHIVE_EXPANDED_BYTES) || 512 * 1024 * 1024),
@@ -1007,7 +1095,7 @@ async function assertExtractedTree(root, state = { entries: 0, bytes: 0 }) {
1007
1095
  }
1008
1096
 
1009
1097
  async function openArchiveSource(absolute) {
1010
- if (process.platform !== 'linux') {
1098
+ if (process.platform !== 'linux' || !constants.O_NOFOLLOW || !constants.O_DIRECTORY) {
1011
1099
  const opened = await openRegularFile(absolute, READ_NOFOLLOW_NONBLOCK);
1012
1100
  return { ...opened, close: () => opened.handle.close() };
1013
1101
  }
@@ -1127,29 +1215,29 @@ async function copyOpenRegularFile(sourceHandle, sourceInfo, destinationPath, st
1127
1215
 
1128
1216
  async function copyTreeContents(source, destination, state = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES }) {
1129
1217
  const initial = await lstat(source);
1130
- if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains a link or special file; archive destination is unsafe');
1218
+ if (initial.isSymbolicLink() || (!initial.isDirectory() && !initial.isFile())) fail('Archive contains symbolic links or special files; archive destination is unsafe');
1131
1219
  state.entries += 1;
1132
1220
  if (state.entries > 100_000) fail('Archive contains too many entries');
1133
1221
  if (initial.isFile()) {
1134
- const opened = await openRegularFile(source, READ_NOFOLLOW_NONBLOCK);
1222
+ const opened = source.startsWith('/proc/self/fd/')
1223
+ ? await openRegularFile(source, READ_NOFOLLOW_NONBLOCK)
1224
+ : await openArchiveSource(source);
1135
1225
  try { await copyOpenRegularFile(opened.handle, opened.info, destination, state); }
1136
- finally { await opened.handle.close(); }
1226
+ finally { if (opened.close) await opened.close(); else await opened.handle.close(); }
1137
1227
  return;
1138
1228
  }
1139
1229
  let directoryHandle = null;
1140
1230
  let sourcePath = source;
1141
1231
  let info = initial;
1142
1232
  try {
1143
- if (process.platform === 'linux') {
1144
- try {
1145
- directoryHandle = await open(source, DIRECTORY_READ_NOFOLLOW);
1146
- info = await directoryHandle.stat();
1147
- if (!info.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1148
- sourcePath = `/proc/self/fd/${directoryHandle.fd}`;
1149
- } catch (error) {
1150
- if (['ELOOP', 'ENOTDIR'].includes(error?.code)) fail('Archive contains a link or special file; archive destination is unsafe');
1151
- throw error;
1152
- }
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;
1153
1241
  }
1154
1242
  const mode = info.mode & 0o777;
1155
1243
  const existing = await lstat(destination).catch(() => null);
@@ -1160,14 +1248,15 @@ async function copyTreeContents(source, destination, state = { entries: 0, bytes
1160
1248
  if (!destinationInfo.isDirectory() || destinationInfo.isSymbolicLink()) fail('Archive contains a link or special file; archive destination is unsafe');
1161
1249
  let destinationHandle;
1162
1250
  try {
1163
- destinationHandle = await open(destination, DIRECTORY_READ_NOFOLLOW);
1164
- const openedDestination = await destinationHandle.stat();
1251
+ destinationHandle = await openDirectoryPath(destination);
1252
+ const openedDestination = await destinationHandle.handle.stat();
1165
1253
  if (!openedDestination.isDirectory()) fail('Archive contains a link or special file; archive destination is unsafe');
1166
- await destinationHandle.chmod(mode | 0o700);
1254
+ await destinationHandle.handle.chmod(mode | 0o700);
1255
+ const destinationAnchor = destinationHandle.anchor;
1167
1256
  for (const name of await readdir(sourcePath)) {
1168
- await copyTreeContents(path.join(sourcePath, name), path.join(destination, name), state);
1257
+ await copyTreeContents(path.join(sourcePath, name), path.join(destinationAnchor, name), state);
1169
1258
  }
1170
- await destinationHandle.chmod(mode);
1259
+ await destinationHandle.handle.chmod(mode);
1171
1260
  } finally {
1172
1261
  if (destinationHandle) await destinationHandle.close().catch(() => {});
1173
1262
  }
@@ -1184,32 +1273,39 @@ async function removeExtractionArtifact(target) {
1184
1273
  }
1185
1274
 
1186
1275
  async function recoverExtractionArtifacts(parent, destination, prefix) {
1187
- const entries = await readdir(parent, { withFileTypes: true });
1188
- const staleBefore = Date.now() - 60 * 60 * 1000;
1189
- for (const entry of entries) {
1190
- if (!entry.name.startsWith('.remcp-extract-txn-')) continue;
1191
- const candidate = path.join(parent, entry.name);
1192
- const info = await lstat(candidate).catch(() => null);
1193
- if (info && info.mtimeMs < staleBefore) await removeExtractionArtifact(candidate);
1194
- }
1195
- const backups = entries.filter(entry => entry.name.startsWith(prefix)).map(entry => path.join(parent, entry.name));
1196
- for (const backup of backups) {
1197
- const destinationInfo = await lstat(destination).catch(() => null);
1198
- if (destinationInfo) {
1199
- await removeExtractionArtifact(backup);
1200
- continue;
1201
- }
1202
- const backupInfo = await lstat(backup).catch(() => null);
1203
- if (!backupInfo) continue;
1204
- if (backupInfo.isSymbolicLink() || !backupInfo.isDirectory()) {
1205
- await removeExtractionArtifact(backup);
1206
- continue;
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);
1207
1287
  }
1208
- try {
1209
- await rename(backup, destination);
1210
- } catch {
1211
- continue;
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
+ }
1212
1306
  }
1307
+ } finally {
1308
+ await parentDirectory.close().catch(() => {});
1213
1309
  }
1214
1310
  }
1215
1311
 
@@ -1232,20 +1328,23 @@ async function restoreExtractionBackup(backup, destination) {
1232
1328
  async function installExtractedTree(source, destination) {
1233
1329
  const parent = path.dirname(destination);
1234
1330
  if (destination === path.parse(destination).root) fail('Archive destination cannot be a filesystem root');
1235
- await mkdir(parent, { recursive: true, mode: 0o700 });
1236
1331
  const backupPrefix = `.remcp-extract-backup-${createHash('sha256').update(destination).digest('hex').slice(0, 16)}-`;
1237
1332
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1238
- const existing = await lstat(destination).catch(() => null);
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);
1239
1337
  const destinationMode = existing?.isDirectory() ? existing.mode & 0o777 : 0o700;
1240
- const transaction = await mkdtemp(path.join(parent, '.remcp-extract-txn-'));
1338
+ let transaction = '';
1241
1339
  let backup = '';
1242
1340
  let backupOwned = false;
1243
1341
  let preserveBackup = false;
1244
1342
  try {
1343
+ transaction = await mkdtemp(path.join(parentAnchor, '.remcp-extract-txn-'));
1245
1344
  if (existing) {
1246
- backup = path.join(parent, `${backupPrefix}${randomUUID()}`);
1345
+ backup = path.join(parentAnchor, `${backupPrefix}${randomUUID()}`);
1247
1346
  try {
1248
- await rename(destination, backup);
1347
+ await rename(destinationPath, backup);
1249
1348
  backupOwned = true;
1250
1349
  } catch (error) {
1251
1350
  if (error?.code !== 'ENOENT') throw error;
@@ -1259,7 +1358,7 @@ async function installExtractedTree(source, destination) {
1259
1358
  }
1260
1359
  await copyTreeContents(source, transaction);
1261
1360
  await chmod(transaction, destinationMode);
1262
- await rename(transaction, destination);
1361
+ await rename(transaction, destinationPath);
1263
1362
  if (backupOwned) {
1264
1363
  await removeExtractionArtifact(backup);
1265
1364
  backupOwned = false;
@@ -1267,7 +1366,7 @@ async function installExtractedTree(source, destination) {
1267
1366
  } catch (error) {
1268
1367
  if (backupOwned) {
1269
1368
  try {
1270
- await restoreExtractionBackup(backup, destination);
1369
+ await restoreExtractionBackup(backup, destinationPath);
1271
1370
  if (!await lstat(backup).catch(() => null)) backupOwned = false;
1272
1371
  else preserveBackup = true;
1273
1372
  } catch (restoreError) {
@@ -1277,24 +1376,32 @@ async function installExtractedTree(source, destination) {
1277
1376
  }
1278
1377
  throw error;
1279
1378
  } finally {
1280
- await rm(transaction, { recursive: true, force: true });
1379
+ if (transaction) await rm(transaction, { recursive: true, force: true });
1281
1380
  if (backupOwned && !preserveBackup) await removeExtractionArtifact(backup);
1381
+ await parentDirectory.close().catch(() => {});
1282
1382
  }
1283
1383
  }
1284
1384
 
1285
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));
1286
1389
  try {
1287
- await rename(source, destination);
1288
- } catch (error) {
1289
- if (!['EXDEV', 'EPERM'].includes(error?.code)) throw error;
1290
- const temporary = path.join(path.dirname(destination), `.${path.basename(destination)}.remcp-${randomUUID()}.tmp`);
1291
1390
  try {
1292
- await copyFile(source, temporary, constants.COPYFILE_EXCL);
1293
- await rename(temporary, destination);
1294
- } finally {
1295
- await unlink(temporary).catch(() => {});
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);
1296
1402
  }
1297
- await unlink(source);
1403
+ } finally {
1404
+ await parentDirectory.close().catch(() => {});
1298
1405
  }
1299
1406
  }
1300
1407
 
@@ -1314,13 +1421,10 @@ export async function createArchiveTool(args) {
1314
1421
  if (!sources.length) fail('paths must list at least one file or directory');
1315
1422
  const resolved = [];
1316
1423
  for (const entry of sources) resolved.push(await resolveSafePath(entry, 'paths[]'));
1317
- const sourceState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1318
- for (const entry of resolved) await assertNoSymlinkTree(entry, sourceState);
1319
1424
  const destination = await resolveSafePath(args.destination, 'destination');
1320
1425
  const existingDestination = await lstat(destination).catch(() => null);
1321
1426
  if (existingDestination?.isSymbolicLink()) fail('Archive destination cannot be a symbolic link');
1322
1427
  const format = String(args.format || (destination.endsWith('.zip') ? 'zip' : 'tar.gz')).toLowerCase();
1323
- await mkdir(path.dirname(destination), { recursive: true });
1324
1428
  const baseDir = path.dirname(resolved[0]);
1325
1429
  const names = resolved.map(entry => path.relative(baseDir, entry));
1326
1430
  if (names.some(name => !name || name.startsWith('..') || path.isAbsolute(name))) fail('Archive sources must share one parent directory');
@@ -1334,11 +1438,13 @@ export async function createArchiveTool(args) {
1334
1438
  const staging = path.join(stagingDirectory, `payload${suffix}`);
1335
1439
  const snapshotRoot = path.join(stagingDirectory, 'source');
1336
1440
  try {
1337
- await mkdir(snapshotRoot, { mode: 0o700 });
1441
+ await ensureDirectoryPath(snapshotRoot);
1442
+
1338
1443
  const snapshotState = { entries: 0, bytes: 0, maxBytes: MAX_ARCHIVE_EXPANDED_BYTES };
1339
1444
  for (let index = 0; index < resolved.length; index += 1) {
1340
1445
  const snapshotPath = path.join(snapshotRoot, names[index]);
1341
- await mkdir(path.dirname(snapshotPath), { recursive: true, mode: 0o700 });
1446
+ await ensureDirectoryPath(path.dirname(snapshotPath));
1447
+
1342
1448
  await copyTreeContents(resolved[index], snapshotPath, snapshotState);
1343
1449
  }
1344
1450
  if (format === 'zip') {
@@ -1353,11 +1459,11 @@ export async function createArchiveTool(args) {
1353
1459
  } else {
1354
1460
  fail('format must be tar, tar.gz, or zip');
1355
1461
  }
1356
- const stagedInfo = await stat(staging);
1357
- assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1358
- await mkdir(path.dirname(destination), { recursive: true });
1462
+ const stagedInfo = await stat(staging);
1463
+ assertArchiveExpandedSize(stagedInfo.size, 'archive output');
1464
+
1465
+ await moveArchiveFile(staging, destination);
1359
1466
 
1360
- await moveArchiveFile(staging, destination);
1361
1467
  const info = await stat(destination).catch(() => null);
1362
1468
  const note = ' (built outside the tree in temporary staging and committed atomically)';
1363
1469
 
@@ -1397,7 +1503,6 @@ export async function extractArchiveTool(args) {
1397
1503
  try {
1398
1504
  await mkdir(staging, { mode: 0o700 });
1399
1505
  await snapshotRegularFile(archive, archiveSnapshot, MAX_ARCHIVE_EXPANDED_BYTES);
1400
- await mkdir(parent, { recursive: true, mode: 0o700 });
1401
1506
  await recoverExtractionArtifacts(parent, destination, backupPrefix);
1402
1507
  const existingDestination = await lstat(destination).catch(() => null);
1403
1508
  if (existingDestination?.isSymbolicLink() || (existingDestination && !existingDestination.isDirectory())) fail('Archive destination is not a safe directory');
@@ -1493,8 +1598,17 @@ export async function takeScreenshotTool(args) {
1493
1598
  const requestedDirectory = args.directory ? await resolveSafePath(args.directory, 'directory') : '';
1494
1599
  const temporaryDirectory = requestedDirectory ? '' : await mkdtemp(path.join(os.tmpdir(), 'remcp-screenshot-'));
1495
1600
  const directory = requestedDirectory || temporaryDirectory;
1496
- if (requestedDirectory) await mkdir(directory, { recursive: true });
1497
- 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);
1498
1612
  const attempts = [];
1499
1613
  let preserveCapture = false;
1500
1614
  try {
@@ -1530,12 +1644,12 @@ export async function takeScreenshotTool(args) {
1530
1644
  const buffer = await handle.readFile();
1531
1645
  preserveCapture = args.keep === true;
1532
1646
  return multi([
1533
- { 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)) : ''}` },
1534
1648
  image(buffer.toString('base64'), 'image/png'),
1535
1649
  ]);
1536
1650
  }
1537
1651
  preserveCapture = true;
1538
- return text(`Screenshot of ${os.hostname()} captured: ${info.size} bytes, above the ${MAX_IMAGE_BYTES}-byte inline limit, so it is saved at ${displayPath(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.`);
1539
1653
  } finally {
1540
1654
  await handle.close();
1541
1655
  }
@@ -1544,6 +1658,7 @@ export async function takeScreenshotTool(args) {
1544
1658
  if (temporaryDirectory) await rm(temporaryDirectory, { recursive: true, force: true }).catch(() => {});
1545
1659
  else await rm(file, { force: true }).catch(() => {});
1546
1660
  }
1661
+ if (outputDirectory) await outputDirectory.close().catch(() => {});
1547
1662
  }
1548
1663
  }
1549
1664
  export const fileToolHandlers = {