@openclaw/fs-safe 0.4.6 → 0.5.0

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.
Files changed (246) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/README.md +110 -41
  3. package/dist/archive-entry.d.ts.map +1 -1
  4. package/dist/archive-entry.js +5 -4
  5. package/dist/archive-errors.d.ts +11 -0
  6. package/dist/archive-errors.d.ts.map +1 -0
  7. package/dist/archive-errors.js +16 -0
  8. package/dist/archive-input.d.ts +12 -0
  9. package/dist/archive-input.d.ts.map +1 -0
  10. package/dist/archive-input.js +72 -0
  11. package/dist/archive-kind.d.ts +1 -1
  12. package/dist/archive-kind.d.ts.map +1 -1
  13. package/dist/archive-kind.js +18 -0
  14. package/dist/archive-limits.d.ts +10 -0
  15. package/dist/archive-limits.d.ts.map +1 -1
  16. package/dist/archive-limits.js +18 -0
  17. package/dist/archive-native.d.ts +18 -0
  18. package/dist/archive-native.d.ts.map +1 -0
  19. package/dist/archive-native.js +115 -0
  20. package/dist/archive-options.d.ts +21 -0
  21. package/dist/archive-options.d.ts.map +1 -0
  22. package/dist/archive-options.js +1 -0
  23. package/dist/archive-policy.d.ts +20 -0
  24. package/dist/archive-policy.d.ts.map +1 -0
  25. package/dist/archive-policy.js +29 -0
  26. package/dist/archive-read.d.ts +6 -0
  27. package/dist/archive-read.d.ts.map +1 -0
  28. package/dist/archive-read.js +200 -0
  29. package/dist/archive-staging.d.ts +2 -5
  30. package/dist/archive-staging.d.ts.map +1 -1
  31. package/dist/archive-staging.js +19 -10
  32. package/dist/archive-tar-meta.d.ts +6 -0
  33. package/dist/archive-tar-meta.d.ts.map +1 -0
  34. package/dist/archive-tar-meta.js +143 -0
  35. package/dist/archive-tar-runtime.d.ts +43 -0
  36. package/dist/archive-tar-runtime.d.ts.map +1 -0
  37. package/dist/archive-tar-runtime.js +8 -0
  38. package/dist/archive-tar.d.ts +5 -1
  39. package/dist/archive-tar.d.ts.map +1 -1
  40. package/dist/archive-tar.js +24 -6
  41. package/dist/archive-zip-entry.d.ts +15 -0
  42. package/dist/archive-zip-entry.d.ts.map +1 -0
  43. package/dist/archive-zip-entry.js +17 -0
  44. package/dist/archive.d.ts +7 -17
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +111 -108
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/config.d.ts +1 -1
  50. package/dist/config.d.ts.map +1 -1
  51. package/dist/config.js +1 -1
  52. package/dist/containment.d.ts +2 -0
  53. package/dist/containment.d.ts.map +1 -0
  54. package/dist/containment.js +1 -0
  55. package/dist/directory-durability.d.ts +44 -0
  56. package/dist/directory-durability.d.ts.map +1 -0
  57. package/dist/directory-durability.js +299 -0
  58. package/dist/durability.d.ts +4 -0
  59. package/dist/durability.d.ts.map +1 -0
  60. package/dist/durability.js +3 -0
  61. package/dist/errors.d.ts +4 -1
  62. package/dist/errors.d.ts.map +1 -1
  63. package/dist/errors.js +2 -0
  64. package/dist/file-hash.d.ts +10 -0
  65. package/dist/file-hash.d.ts.map +1 -0
  66. package/dist/file-hash.js +73 -0
  67. package/dist/file-lock-sync.d.ts +35 -0
  68. package/dist/file-lock-sync.d.ts.map +1 -0
  69. package/dist/file-lock-sync.js +233 -0
  70. package/dist/file-lock.d.ts +2 -0
  71. package/dist/file-lock.d.ts.map +1 -1
  72. package/dist/file-lock.js +1 -0
  73. package/dist/filename.d.ts.map +1 -1
  74. package/dist/filename.js +4 -1
  75. package/dist/fsync.d.ts +1 -1
  76. package/dist/fsync.d.ts.map +1 -1
  77. package/dist/fsync.js +1 -21
  78. package/dist/guarded-mkdir.d.ts +7 -1
  79. package/dist/guarded-mkdir.d.ts.map +1 -1
  80. package/dist/guarded-mkdir.js +45 -3
  81. package/dist/index.d.ts +4 -3
  82. package/dist/index.d.ts.map +1 -1
  83. package/dist/index.js +1 -1
  84. package/dist/json-document-store.d.ts.map +1 -1
  85. package/dist/json-document-store.js +30 -15
  86. package/dist/move-path.d.ts.map +1 -1
  87. package/dist/move-path.js +101 -17
  88. package/dist/native/darwin-arm64/fs-safe-native.node +0 -0
  89. package/dist/native/darwin-x64/fs-safe-native.node +0 -0
  90. package/dist/native/linux-arm64-gnu/fs-safe-native.node +0 -0
  91. package/dist/native/linux-arm64-musl/fs-safe-native.node +0 -0
  92. package/dist/native/linux-x64-gnu/fs-safe-native.node +0 -0
  93. package/dist/native/linux-x64-musl/fs-safe-native.node +0 -0
  94. package/dist/native/win32-x64-msvc/fs-safe-native.node +0 -0
  95. package/dist/native-binding.d.ts +79 -0
  96. package/dist/native-binding.d.ts.map +1 -0
  97. package/dist/native-binding.js +1 -0
  98. package/dist/native-config.d.ts +17 -0
  99. package/dist/native-config.d.ts.map +1 -0
  100. package/dist/native-config.js +71 -0
  101. package/dist/native-operations.d.ts +21 -0
  102. package/dist/native-operations.d.ts.map +1 -0
  103. package/dist/native-operations.js +115 -0
  104. package/dist/native-pinned-write.d.ts +5 -0
  105. package/dist/native-pinned-write.d.ts.map +1 -0
  106. package/dist/native-pinned-write.js +115 -0
  107. package/dist/native.d.ts +14 -0
  108. package/dist/native.d.ts.map +1 -0
  109. package/dist/native.js +204 -0
  110. package/dist/output-sibling.d.ts +8 -0
  111. package/dist/output-sibling.d.ts.map +1 -0
  112. package/dist/output-sibling.js +122 -0
  113. package/dist/output.d.ts +2 -0
  114. package/dist/output.d.ts.map +1 -1
  115. package/dist/output.js +32 -5
  116. package/dist/owner-dacl.d.ts +31 -0
  117. package/dist/owner-dacl.d.ts.map +1 -0
  118. package/dist/owner-dacl.js +27 -0
  119. package/dist/path-policy.js +1 -1
  120. package/dist/permissions-public.d.ts +2 -0
  121. package/dist/permissions-public.d.ts.map +1 -1
  122. package/dist/permissions-public.js +2 -0
  123. package/dist/permissions.d.ts.map +1 -1
  124. package/dist/permissions.js +6 -0
  125. package/dist/pinned-operation.d.ts +2 -0
  126. package/dist/pinned-operation.d.ts.map +1 -0
  127. package/dist/pinned-operation.js +35 -0
  128. package/dist/pinned-write.d.ts +3 -16
  129. package/dist/pinned-write.d.ts.map +1 -1
  130. package/dist/pinned-write.js +20 -85
  131. package/dist/private-directory.d.ts +5 -0
  132. package/dist/private-directory.d.ts.map +1 -0
  133. package/dist/private-directory.js +13 -0
  134. package/dist/private-temp-workspace.d.ts +6 -2
  135. package/dist/private-temp-workspace.d.ts.map +1 -1
  136. package/dist/private-temp-workspace.js +40 -13
  137. package/dist/publish-file-failure.d.ts +27 -0
  138. package/dist/publish-file-failure.d.ts.map +1 -0
  139. package/dist/publish-file-failure.js +21 -0
  140. package/dist/publish-file.d.ts +21 -0
  141. package/dist/publish-file.d.ts.map +1 -0
  142. package/dist/publish-file.js +351 -0
  143. package/dist/read-opened-file.d.ts +3 -0
  144. package/dist/read-opened-file.d.ts.map +1 -1
  145. package/dist/read-opened-file.js +1 -0
  146. package/dist/replace-file-copy-fallback.d.ts +34 -0
  147. package/dist/replace-file-copy-fallback.d.ts.map +1 -0
  148. package/dist/replace-file-copy-fallback.js +339 -0
  149. package/dist/replace-file.d.ts +6 -2
  150. package/dist/replace-file.d.ts.map +1 -1
  151. package/dist/replace-file.js +39 -90
  152. package/dist/root-context.d.ts +4 -1
  153. package/dist/root-context.d.ts.map +1 -1
  154. package/dist/root-context.js +20 -1
  155. package/dist/root-impl.d.ts +5 -0
  156. package/dist/root-impl.d.ts.map +1 -1
  157. package/dist/root-impl.js +128 -225
  158. package/dist/root-path-existing.d.ts +3 -0
  159. package/dist/root-path-existing.d.ts.map +1 -0
  160. package/dist/root-path-existing.js +69 -0
  161. package/dist/root-path.d.ts +1 -2
  162. package/dist/root-path.d.ts.map +1 -1
  163. package/dist/root-path.js +67 -81
  164. package/dist/root-walk.d.ts +41 -0
  165. package/dist/root-walk.d.ts.map +1 -0
  166. package/dist/root-walk.js +114 -0
  167. package/dist/root.d.ts +2 -0
  168. package/dist/root.d.ts.map +1 -1
  169. package/dist/secret-file.d.ts +5 -2
  170. package/dist/secret-file.d.ts.map +1 -1
  171. package/dist/secret-file.js +20 -2
  172. package/dist/secret-read-async.d.ts +4 -0
  173. package/dist/secret-read-async.d.ts.map +1 -0
  174. package/dist/secret-read-async.js +95 -0
  175. package/dist/secret.d.ts +2 -1
  176. package/dist/secret.d.ts.map +1 -1
  177. package/dist/secret.js +2 -1
  178. package/dist/sibling-temp.d.ts.map +1 -1
  179. package/dist/sibling-temp.js +2 -13
  180. package/dist/sidecar-lock-handle.d.ts +20 -0
  181. package/dist/sidecar-lock-handle.d.ts.map +1 -0
  182. package/dist/sidecar-lock-handle.js +28 -0
  183. package/dist/sidecar-lock-policy.d.ts +10 -0
  184. package/dist/sidecar-lock-policy.d.ts.map +1 -0
  185. package/dist/sidecar-lock-policy.js +29 -0
  186. package/dist/sidecar-lock-reclaim.d.ts +22 -6
  187. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  188. package/dist/sidecar-lock-reclaim.js +106 -20
  189. package/dist/sidecar-lock-types.d.ts +56 -0
  190. package/dist/sidecar-lock-types.d.ts.map +1 -0
  191. package/dist/sidecar-lock-types.js +1 -0
  192. package/dist/sidecar-lock.d.ts +2 -45
  193. package/dist/sidecar-lock.d.ts.map +1 -1
  194. package/dist/sidecar-lock.js +94 -55
  195. package/dist/temp-cleanup.d.ts +7 -1
  196. package/dist/temp-cleanup.d.ts.map +1 -1
  197. package/dist/temp-cleanup.js +35 -5
  198. package/dist/temp.d.ts +2 -1
  199. package/dist/temp.d.ts.map +1 -1
  200. package/dist/test-hooks.d.ts +3 -0
  201. package/dist/test-hooks.d.ts.map +1 -1
  202. package/dist/windows-permissions-native.d.ts +9 -0
  203. package/dist/windows-permissions-native.d.ts.map +1 -0
  204. package/dist/windows-permissions-native.js +31 -0
  205. package/docs/advanced.md +1 -1
  206. package/docs/archive.md +131 -13
  207. package/docs/atomic.md +30 -1
  208. package/docs/config.md +51 -21
  209. package/docs/durability.md +299 -0
  210. package/docs/errors.md +36 -6
  211. package/docs/filename.md +0 -0
  212. package/docs/index.md +11 -7
  213. package/docs/install.md +27 -26
  214. package/docs/json-store.md +18 -3
  215. package/docs/migrating-to-0.5.md +196 -0
  216. package/docs/native-helper.md +85 -0
  217. package/docs/native.md +134 -0
  218. package/docs/output.md +37 -11
  219. package/docs/path.md +1 -1
  220. package/docs/permissions.md +84 -1
  221. package/docs/quickstart.md +5 -2
  222. package/docs/reading.md +4 -4
  223. package/docs/root.md +31 -15
  224. package/docs/secret-file.md +50 -1
  225. package/docs/security-model.md +42 -8
  226. package/docs/sidecar-lock.md +92 -7
  227. package/docs/temp.md +24 -1
  228. package/docs/test-hooks.md +22 -2
  229. package/docs/testing.md +18 -7
  230. package/docs/types.md +3 -1
  231. package/docs/walk.md +55 -0
  232. package/docs/writing.md +3 -3
  233. package/package.json +12 -2
  234. package/dist/pinned-helper.d.ts +0 -9
  235. package/dist/pinned-helper.d.ts.map +0 -1
  236. package/dist/pinned-helper.js +0 -19
  237. package/dist/pinned-path.d.ts +0 -7
  238. package/dist/pinned-path.d.ts.map +0 -1
  239. package/dist/pinned-path.js +0 -21
  240. package/dist/pinned-python-config.d.ts +0 -9
  241. package/dist/pinned-python-config.d.ts.map +0 -1
  242. package/dist/pinned-python-config.js +0 -38
  243. package/dist/pinned-python.d.ts +0 -12
  244. package/dist/pinned-python.d.ts.map +0 -1
  245. package/dist/pinned-python.js +0 -688
  246. package/docs/python-helper.md +0 -103
package/dist/move-path.js CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { constants as fsConstants } from "node:fs";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
+ import { FsSafeError } from "./errors.js";
5
6
  import { guardedRename } from "./guarded-mutation.js";
6
7
  export function moveCopyFallbackReasonForRenameError(error, platform = process.platform) {
7
8
  const code = error?.code;
@@ -13,6 +14,49 @@ export function moveCopyFallbackReasonForRenameError(error, platform = process.p
13
14
  }
14
15
  return undefined;
15
16
  }
17
+ const MAX_HARDLINK_PREFLIGHT_ENTRIES = 50_000;
18
+ function hardlinkedSourceError(sourcePath) {
19
+ return new FsSafeError("hardlink", `Refusing to move hardlinked file: ${sourcePath}`);
20
+ }
21
+ function hardlinkWalkTooLargeError() {
22
+ return new FsSafeError("too-large", `Source hardlink preflight exceeds ${MAX_HARDLINK_PREFLIGHT_ENTRIES} entries`);
23
+ }
24
+ async function preflightSourceHardlinks(sourcePath) {
25
+ const pending = [sourcePath];
26
+ let discovered = 1;
27
+ while (pending.length > 0) {
28
+ const current = pending.pop();
29
+ const stat = await fs.lstat(current);
30
+ if (stat.isFile() && stat.nlink > 1) {
31
+ throw hardlinkedSourceError(current);
32
+ }
33
+ if (!stat.isDirectory()) {
34
+ continue;
35
+ }
36
+ const directory = await fs.opendir(current);
37
+ for await (const entry of directory) {
38
+ discovered += 1;
39
+ if (discovered > MAX_HARDLINK_PREFLIGHT_ENTRIES) {
40
+ throw hardlinkWalkTooLargeError();
41
+ }
42
+ pending.push(path.join(current, entry.name));
43
+ }
44
+ }
45
+ }
46
+ function isSameOrDescendant(parentPath, candidatePath) {
47
+ const relative = path.relative(parentPath, candidatePath);
48
+ return (relative === "" ||
49
+ (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== ".."));
50
+ }
51
+ async function assertCopyDestinationOutsideSource(sourcePath, targetPath) {
52
+ const sourceReal = await fs.realpath(sourcePath);
53
+ const normalizedTarget = path.resolve(targetPath);
54
+ const targetParentReal = await fs.realpath(path.dirname(normalizedTarget));
55
+ const targetCandidate = path.join(targetParentReal, path.basename(normalizedTarget));
56
+ if (isSameOrDescendant(sourceReal, targetCandidate)) {
57
+ throw new FsSafeError("invalid-path", "Move destination must not be inside the source");
58
+ }
59
+ }
16
60
  function entryIdentity(stat) {
17
61
  return {
18
62
  ctimeMs: stat.ctimeMs,
@@ -20,6 +64,7 @@ function entryIdentity(stat) {
20
64
  ino: stat.ino,
21
65
  mode: stat.mode,
22
66
  mtimeMs: stat.mtimeMs,
67
+ nlink: stat.nlink,
23
68
  size: stat.size,
24
69
  };
25
70
  }
@@ -27,6 +72,7 @@ function sameIdentity(a, b) {
27
72
  return (a.dev === b.dev &&
28
73
  a.ino === b.ino &&
29
74
  a.mode === b.mode &&
75
+ a.nlink === b.nlink &&
30
76
  a.size === b.size &&
31
77
  a.mtimeMs === b.mtimeMs &&
32
78
  a.ctimeMs === b.ctimeMs);
@@ -51,6 +97,9 @@ function regularReadFlags() {
51
97
  return (fsConstants.O_RDONLY |
52
98
  (typeof fsConstants.O_NOFOLLOW === "number" && process.platform !== "win32"
53
99
  ? fsConstants.O_NOFOLLOW
100
+ : 0) |
101
+ (typeof fsConstants.O_NONBLOCK === "number" && process.platform !== "win32"
102
+ ? fsConstants.O_NONBLOCK
54
103
  : 0));
55
104
  }
56
105
  async function writeAll(handle, buffer, bytesRead) {
@@ -75,6 +124,9 @@ async function copyRegularFilePinned(params) {
75
124
  }
76
125
  try {
77
126
  const openedStat = await sourceHandle.stat();
127
+ if (params.rejectHardlinks && openedStat.nlink > 1) {
128
+ throw hardlinkedSourceError(params.from);
129
+ }
78
130
  if (!openedStat.isFile() || !sameIdentity(params.identity, entryIdentity(openedStat))) {
79
131
  throw sourceChangedError(params.from);
80
132
  }
@@ -95,7 +147,11 @@ async function copyRegularFilePinned(params) {
95
147
  }
96
148
  // Re-check the opened handle before the staged tree can be committed. If
97
149
  // the source changed while we copied, the caller should retry the move.
98
- if (!sameIdentity(params.identity, entryIdentity(await sourceHandle.stat()))) {
150
+ const finalSourceStat = await sourceHandle.stat();
151
+ if (params.rejectHardlinks && finalSourceStat.nlink > 1) {
152
+ throw hardlinkedSourceError(params.from);
153
+ }
154
+ if (!sameIdentity(params.identity, entryIdentity(finalSourceStat))) {
99
155
  throw sourceChangedError(params.from);
100
156
  }
101
157
  await fs.chmod(params.to, modeBits(params.mode)).catch(() => undefined);
@@ -123,7 +179,15 @@ async function copyEntryWithManifest(from, to, options) {
123
179
  if (sourceStat.isDirectory()) {
124
180
  await fs.mkdir(to, { mode: modeBits(sourceStat.mode) || 0o755 });
125
181
  const children = [];
126
- for (const child of await fs.readdir(from)) {
182
+ const childNames = [];
183
+ const directory = await fs.opendir(from);
184
+ for await (const entry of directory) {
185
+ if (options.budget && ++options.budget.discovered > MAX_HARDLINK_PREFLIGHT_ENTRIES) {
186
+ throw hardlinkWalkTooLargeError();
187
+ }
188
+ childNames.push(entry.name);
189
+ }
190
+ for (const child of childNames) {
127
191
  children.push({
128
192
  name: child,
129
193
  manifest: await copyEntryWithManifest(path.join(from, child), path.join(to, child), options),
@@ -141,9 +205,15 @@ async function copyEntryWithManifest(from, to, options) {
141
205
  throw new Error(`Refusing to move non-file path with copy fallback: ${from}`);
142
206
  }
143
207
  if (options.sourceHardlinks === "reject" && sourceStat.nlink > 1) {
144
- throw new Error(`Refusing to move hardlinked file with copy fallback: ${from}`);
208
+ throw hardlinkedSourceError(from);
145
209
  }
146
- await copyRegularFilePinned({ from, identity, mode: sourceStat.mode, to });
210
+ await copyRegularFilePinned({
211
+ from,
212
+ identity,
213
+ mode: sourceStat.mode,
214
+ rejectHardlinks: options.sourceHardlinks === "reject",
215
+ to,
216
+ });
147
217
  return { ...identity, kind: "leaf" };
148
218
  }
149
219
  function mergeCleanupResults(a, b) {
@@ -189,27 +259,41 @@ async function cleanupCopiedEntry(sourcePath, manifest) {
189
259
  return "removed";
190
260
  }
191
261
  export async function movePathWithCopyFallback(options) {
192
- let fallbackReason;
193
- try {
194
- await guardedRename({ from: options.from, to: options.to });
195
- return;
262
+ const sourcePath = path.resolve(options.from);
263
+ const targetPath = path.resolve(options.to);
264
+ const rejectHardlinks = options.sourceHardlinks === "reject";
265
+ if (rejectHardlinks) {
266
+ await preflightSourceHardlinks(sourcePath);
196
267
  }
197
- catch (error) {
198
- fallbackReason = moveCopyFallbackReasonForRenameError(error);
199
- if (!fallbackReason) {
200
- throw error;
268
+ if (!rejectHardlinks) {
269
+ try {
270
+ await guardedRename({ from: sourcePath, to: targetPath });
271
+ return;
272
+ }
273
+ catch (error) {
274
+ if (!moveCopyFallbackReasonForRenameError(error)) {
275
+ throw error;
276
+ }
201
277
  }
202
278
  }
203
- const targetDir = path.dirname(path.resolve(options.to));
279
+ else {
280
+ // A pathname preflight cannot make nlink and rename one atomic operation.
281
+ // Commit a fresh inode through the copy path so a post-scan hardlink can
282
+ // never become the published target; the copy loop fences nlink again.
283
+ }
284
+ await assertCopyDestinationOutsideSource(sourcePath, targetPath);
285
+ const targetDir = path.dirname(targetPath);
204
286
  const staged = path.join(targetDir, `.fs-safe-move-${process.pid}-${randomUUID()}.tmp`);
205
287
  try {
206
- const manifest = await copyEntryWithManifest(options.from, staged, {
288
+ const manifest = await copyEntryWithManifest(sourcePath, staged, {
207
289
  sourceHardlinks: options.sourceHardlinks ?? "allow",
290
+ ...(rejectHardlinks ? { budget: { discovered: 1 } } : {}),
208
291
  });
209
- await guardedRename({ from: staged, to: options.to });
210
- const cleanupResult = await cleanupCopiedEntry(options.from, manifest);
292
+ await assertCopyDestinationOutsideSource(sourcePath, targetPath);
293
+ await guardedRename({ from: staged, to: targetPath });
294
+ const cleanupResult = await cleanupCopiedEntry(sourcePath, manifest);
211
295
  if (cleanupResult === "stale") {
212
- throw sourceChangedError(options.from);
296
+ throw sourceChangedError(sourcePath);
213
297
  }
214
298
  }
215
299
  finally {
@@ -0,0 +1,79 @@
1
+ export interface NativeFileHash {
2
+ bytes: number;
3
+ digest: string;
4
+ }
5
+ export interface NativeFileIdentity {
6
+ dev: number;
7
+ ino: number;
8
+ mode: number;
9
+ nlink: number;
10
+ size: number;
11
+ isFile: boolean;
12
+ isDirectory: boolean;
13
+ isSymbolicLink: boolean;
14
+ }
15
+ export interface NativeArchiveEntry {
16
+ index: number;
17
+ path: string;
18
+ kind: string;
19
+ size: number;
20
+ mode: number;
21
+ }
22
+ export interface NativeArchivePlanEntry extends NativeArchiveEntry {
23
+ }
24
+ export interface NativeCopyResult {
25
+ fd: number;
26
+ bytes: number;
27
+ errorCode?: string;
28
+ errorMessage?: string;
29
+ }
30
+ export interface NativeOpenBeneathResult {
31
+ fd: number;
32
+ containment: "kernel-atomic" | "best-effort";
33
+ }
34
+ export interface NativeWindowsAccessControlEntry {
35
+ sid: string;
36
+ mask: number;
37
+ aceType: string;
38
+ flags: {
39
+ raw: number;
40
+ objectInherit: boolean;
41
+ containerInherit: boolean;
42
+ noPropagateInherit: boolean;
43
+ inheritOnly: boolean;
44
+ inherited: boolean;
45
+ successfulAccess: boolean;
46
+ failedAccess: boolean;
47
+ };
48
+ }
49
+ export interface NativeWindowsSecurityFacts {
50
+ ownerSid: string;
51
+ currentUserSid: string;
52
+ ownerClass: string;
53
+ worldWritable: boolean;
54
+ groupWritable: boolean;
55
+ worldReadable: boolean;
56
+ groupReadable: boolean;
57
+ fallbackRequired: boolean;
58
+ daclPresent: boolean;
59
+ isLocal: boolean;
60
+ aceListComplete: boolean;
61
+ unsupportedAceTypes: number[];
62
+ aces: NativeWindowsAccessControlEntry[];
63
+ }
64
+ export interface NativeBinding {
65
+ cloneFileExclusive(sourceFd: number, targetRootFd: number, targetRelPath: string): number;
66
+ copyFileRangeExclusive(sourceFd: number, targetRootFd: number, targetRelPath: string): Promise<NativeCopyResult>;
67
+ createPrivateDirectory(path: string): void;
68
+ extractArchiveNative(path: string, kind: string, rootFd: number, plan: NativeArchivePlanEntry[], maxMetaEntryBytes: number, signal: AbortSignal): Promise<void>;
69
+ fstatIdentity(fd: number): NativeFileIdentity;
70
+ inspectArchiveNative(path: string, kind: string, maxEntries: number, maxMetaEntryBytes: number, maxManifestBytes: number, signal: AbortSignal): Promise<NativeArchiveEntry[]>;
71
+ linkBeneath(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string): void;
72
+ mkdirBeneath(rootFd: number, relPath: string, mode: number): void;
73
+ openBeneath(rootFd: number, relPath: string, flags: number): NativeOpenBeneathResult;
74
+ readArchiveEntryNative(path: string, kind: string, requested: string, maxBytes: number, maxEntries: number, maxMetaEntryBytes: number, signal: AbortSignal): Promise<Buffer>;
75
+ readOwnerAndDacl(path: string): NativeWindowsSecurityFacts;
76
+ renameNoReplace(sourceRootFd: number, sourceRelPath: string, targetRootFd: number, targetRelPath: string): void;
77
+ sha256File(fd: number): Promise<NativeFileHash>;
78
+ }
79
+ //# sourceMappingURL=native-binding.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-binding.d.ts","sourceRoot":"","sources":["../src/native-binding.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,17 @@
1
+ export type FsSafeNativeMode = "auto" | "off" | "require";
2
+ export type FsSafeNativeConfig = {
3
+ mode: FsSafeNativeMode;
4
+ };
5
+ /** @deprecated Compatibility bridge for 0.4 upgrades. Use {@link FsSafeNativeConfig}. */
6
+ export type FsSafePythonConfig = {
7
+ mode: FsSafeNativeMode;
8
+ pythonPath?: string;
9
+ };
10
+ export declare function configureFsSafeNative(config: Partial<FsSafeNativeConfig>): void;
11
+ /**
12
+ * @deprecated Compatibility bridge for 0.4 upgrades. Use configureFsSafeNative.
13
+ */
14
+ export declare function configureFsSafePython(config: Partial<FsSafePythonConfig>): void;
15
+ export declare function getFsSafeNativeConfig(): FsSafeNativeConfig;
16
+ export declare function __resetFsSafeNativeConfigForTest(): void;
17
+ //# sourceMappingURL=native-config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-config.d.ts","sourceRoot":"","sources":["../src/native-config.ts"],"names":[],"mappings":"AAAA,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,KAAK,GAAG,SAAS,CAAC;AAE1D,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,gBAAgB,CAAC;CACxB,CAAC;AAEF,yFAAyF;AACzF,MAAM,MAAM,kBAAkB,GAAG;IAC/B,IAAI,EAAE,gBAAgB,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB,CAAC;AA8BF,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CAE/E;AA4BD;;GAEG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,OAAO,CAAC,kBAAkB,CAAC,GAAG,IAAI,CAM/E;AAED,wBAAgB,qBAAqB,IAAI,kBAAkB,CAU1D;AAED,wBAAgB,gCAAgC,IAAI,IAAI,CAGvD"}
@@ -0,0 +1,71 @@
1
+ let overrideConfig = {};
2
+ let legacyWarningEmitted = false;
3
+ const legacyModeEnvKeys = ["FS_SAFE_PYTHON_MODE", "OPENCLAW_FS_SAFE_PYTHON_MODE"];
4
+ const legacyPathEnvKeys = [
5
+ "FS_SAFE_PYTHON",
6
+ "OPENCLAW_FS_SAFE_PYTHON",
7
+ "OPENCLAW_PINNED_PYTHON",
8
+ "OPENCLAW_PINNED_WRITE_PYTHON",
9
+ ];
10
+ function parseMode(value) {
11
+ if (!value) {
12
+ return undefined;
13
+ }
14
+ const normalized = value.trim().toLowerCase();
15
+ if (normalized === "0" || normalized === "false" || normalized === "off" || normalized === "never") {
16
+ return "off";
17
+ }
18
+ if (normalized === "1" || normalized === "true" || normalized === "on" || normalized === "auto") {
19
+ return "auto";
20
+ }
21
+ if (normalized === "required" || normalized === "require") {
22
+ return "require";
23
+ }
24
+ return undefined;
25
+ }
26
+ export function configureFsSafeNative(config) {
27
+ overrideConfig = { ...overrideConfig, ...config };
28
+ }
29
+ function warnLegacyPythonConfiguration(source, mappedMode) {
30
+ if (legacyWarningEmitted) {
31
+ return;
32
+ }
33
+ legacyWarningEmitted = true;
34
+ process.emitWarning(`${source} is a deprecated 0.4 compatibility bridge; mapped to native mode ` +
35
+ `"${mappedMode}". Use configureFsSafeNative({ mode: "${mappedMode}" }) or ` +
36
+ `FS_SAFE_NATIVE_MODE=${mappedMode}. Python interpreter paths are no longer used.`, { code: "FS_SAFE_PYTHON_DEPRECATED", type: "DeprecationWarning" });
37
+ }
38
+ function readLegacyPythonMode() {
39
+ const configuredKeys = [...legacyModeEnvKeys, ...legacyPathEnvKeys].filter((key) => process.env[key] !== undefined);
40
+ if (configuredKeys.length === 0) {
41
+ return undefined;
42
+ }
43
+ const rawMode = legacyModeEnvKeys.map((key) => process.env[key]).find((value) => value !== undefined);
44
+ const mappedMode = parseMode(rawMode) ?? "auto";
45
+ warnLegacyPythonConfiguration(`Legacy ${configuredKeys.join(", ")}`, mappedMode);
46
+ return mappedMode;
47
+ }
48
+ /**
49
+ * @deprecated Compatibility bridge for 0.4 upgrades. Use configureFsSafeNative.
50
+ */
51
+ export function configureFsSafePython(config) {
52
+ const mappedMode = config.mode ?? "auto";
53
+ warnLegacyPythonConfiguration("configureFsSafePython()", mappedMode);
54
+ if (config.mode !== undefined) {
55
+ configureFsSafeNative({ mode: config.mode });
56
+ }
57
+ }
58
+ export function getFsSafeNativeConfig() {
59
+ const legacyMode = readLegacyPythonMode();
60
+ return {
61
+ mode: overrideConfig.mode ??
62
+ parseMode(process.env.FS_SAFE_NATIVE_MODE) ??
63
+ parseMode(process.env.OPENCLAW_FS_SAFE_NATIVE_MODE) ??
64
+ legacyMode ??
65
+ "auto",
66
+ };
67
+ }
68
+ export function __resetFsSafeNativeConfigForTest() {
69
+ overrideConfig = {};
70
+ legacyWarningEmitted = false;
71
+ }
@@ -0,0 +1,21 @@
1
+ import { type Stats } from "node:fs";
2
+ import type { ContainmentGuarantee } from "./containment.js";
3
+ import { type NativeBinding } from "./native.js";
4
+ export type NativeFileHandle = {
5
+ readonly fd: number;
6
+ readonly containment: ContainmentGuarantee;
7
+ close(): Promise<void>;
8
+ stat(): Promise<Stats>;
9
+ writeFile(data: string | Buffer, encoding?: BufferEncoding): Promise<void>;
10
+ };
11
+ export declare function removeNativeCreatedFileIfStillPinned(params: {
12
+ binding: NativeBinding;
13
+ parentPath: string;
14
+ parentFd: number;
15
+ basename: string;
16
+ created?: Stats;
17
+ }): void;
18
+ export declare function createNativeExclusiveFile(targetPath: string, mode: number): Promise<NativeFileHandle | undefined>;
19
+ export declare function syncNativeFileBestEffort(fd: number): void;
20
+ export declare function writeNativeFd(fd: number, data: Buffer): void;
21
+ //# sourceMappingURL=native-operations.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-operations.d.ts","sourceRoot":"","sources":["../src/native-operations.ts"],"names":[],"mappings":"AAAA,OAAe,EAAE,KAAK,KAAK,EAAE,MAAM,SAAS,CAAC;AAG7C,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,kBAAkB,CAAC;AAE7D,OAAO,EAAoB,KAAK,aAAa,EAAE,MAAM,aAAa,CAAC;AAEnE,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,WAAW,EAAE,oBAAoB,CAAC;IAC3C,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,IAAI,OAAO,CAAC,KAAK,CAAC,CAAC;IACvB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,QAAQ,CAAC,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;CAC5E,CAAC;AA0CF,wBAAgB,oCAAoC,CAAC,MAAM,EAAE;IAC3D,OAAO,EAAE,aAAa,CAAC;IACvB,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,KAAK,CAAC;CACjB,GAAG,IAAI,CAsBP;AAED,wBAAsB,yBAAyB,CAC7C,UAAU,EAAE,MAAM,EAClB,IAAI,EAAE,MAAM,GACX,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CA6CvC;AAED,wBAAgB,wBAAwB,CAAC,EAAE,EAAE,MAAM,GAAG,IAAI,CAQzD;AAED,wBAAgB,aAAa,CAAC,EAAE,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,GAAG,IAAI,CAE5D"}
@@ -0,0 +1,115 @@
1
+ import fsSync, {} from "node:fs";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { sameFileIdentity } from "./file-identity.js";
5
+ import { getNativeBinding } from "./native.js";
6
+ function nativeOpenFlags(flags) {
7
+ const closeOnExec = fsSync.constants.O_CLOEXEC;
8
+ return (flags |
9
+ (closeOnExec ?? 0) |
10
+ (typeof fsSync.constants.O_NOFOLLOW === "number" ? fsSync.constants.O_NOFOLLOW : 0));
11
+ }
12
+ function writeAll(fd, data) {
13
+ let offset = 0;
14
+ while (offset < data.byteLength) {
15
+ const written = fsSync.writeSync(fd, data, offset, data.byteLength - offset);
16
+ if (written <= 0) {
17
+ throw Object.assign(new Error("native file write made no progress"), { code: "EIO" });
18
+ }
19
+ offset += written;
20
+ }
21
+ }
22
+ function wrapNativeFd(fd, containment) {
23
+ let open = true;
24
+ return {
25
+ fd,
26
+ containment,
27
+ async close() {
28
+ if (open) {
29
+ open = false;
30
+ fsSync.closeSync(fd);
31
+ }
32
+ },
33
+ async stat() {
34
+ return fsSync.fstatSync(fd);
35
+ },
36
+ async writeFile(data, encoding) {
37
+ writeAll(fd, Buffer.isBuffer(data) ? data : Buffer.from(data, encoding ?? "utf8"));
38
+ },
39
+ };
40
+ }
41
+ export function removeNativeCreatedFileIfStillPinned(params) {
42
+ if (!params.created) {
43
+ return;
44
+ }
45
+ try {
46
+ const parentPathStat = fsSync.lstatSync(params.parentPath);
47
+ const parentFdStat = params.binding.fstatIdentity(params.parentFd);
48
+ const targetPath = path.join(params.parentPath, params.basename);
49
+ const target = fsSync.lstatSync(targetPath);
50
+ if (!parentPathStat.isSymbolicLink() &&
51
+ parentPathStat.dev === parentFdStat.dev &&
52
+ parentPathStat.ino === parentFdStat.ino &&
53
+ !target.isSymbolicLink() &&
54
+ sameFileIdentity(target, params.created)) {
55
+ fsSync.rmSync(targetPath);
56
+ }
57
+ }
58
+ catch {
59
+ // Failed cleanup leaves a fail-closed artifact instead of deleting an
60
+ // unverified pathname.
61
+ }
62
+ }
63
+ export async function createNativeExclusiveFile(targetPath, mode) {
64
+ const binding = getNativeBinding();
65
+ if (!binding) {
66
+ return undefined;
67
+ }
68
+ const parentPath = path.dirname(targetPath);
69
+ const basename = path.basename(targetPath);
70
+ const parent = await fs.open(parentPath, fsSync.constants.O_RDONLY |
71
+ (typeof fsSync.constants.O_DIRECTORY === "number" ? fsSync.constants.O_DIRECTORY : 0));
72
+ let fd;
73
+ let created;
74
+ try {
75
+ const opened = binding.openBeneath(parent.fd, basename, nativeOpenFlags(fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL));
76
+ fd = opened.fd;
77
+ fsSync.fchmodSync(fd, mode);
78
+ created = fsSync.fstatSync(fd);
79
+ return wrapNativeFd(fd, opened.containment);
80
+ }
81
+ catch (error) {
82
+ if (fd !== undefined) {
83
+ try {
84
+ fsSync.closeSync(fd);
85
+ }
86
+ catch {
87
+ // Preserve the original error.
88
+ }
89
+ removeNativeCreatedFileIfStillPinned({
90
+ binding,
91
+ parentPath,
92
+ parentFd: parent.fd,
93
+ basename,
94
+ created,
95
+ });
96
+ }
97
+ throw error;
98
+ }
99
+ finally {
100
+ await parent.close().catch(() => undefined);
101
+ }
102
+ }
103
+ export function syncNativeFileBestEffort(fd) {
104
+ try {
105
+ fsSync.fsyncSync(fd);
106
+ }
107
+ catch (error) {
108
+ if (error.code !== "EPERM") {
109
+ throw error;
110
+ }
111
+ }
112
+ }
113
+ export function writeNativeFd(fd, data) {
114
+ writeAll(fd, data);
115
+ }
@@ -0,0 +1,5 @@
1
+ import type { FileIdentityStat } from "./file-identity.js";
2
+ import type { NativeBinding } from "./native.js";
3
+ import type { PinnedWriteParams } from "./pinned-write.js";
4
+ export declare function runPinnedWriteNative(binding: NativeBinding, params: PinnedWriteParams): Promise<FileIdentityStat>;
5
+ //# sourceMappingURL=native-pinned-write.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"native-pinned-write.d.ts","sourceRoot":"","sources":["../src/native-pinned-write.ts"],"names":[],"mappings":"AAKA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,oBAAoB,CAAC;AAM3D,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AACjD,OAAO,KAAK,EAAoB,iBAAiB,EAAE,MAAM,mBAAmB,CAAC;AAiD7E,wBAAsB,oBAAoB,CACxC,OAAO,EAAE,aAAa,EACtB,MAAM,EAAE,iBAAiB,GACxB,OAAO,CAAC,gBAAgB,CAAC,CA4F3B"}
@@ -0,0 +1,115 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import fsSync, {} from "node:fs";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { FsSafeError } from "./errors.js";
6
+ import { removeNativeCreatedFileIfStillPinned, syncNativeFileBestEffort, writeNativeFd, } from "./native-operations.js";
7
+ function assertWithinMaxBytes(bytes, maxBytes) {
8
+ if (maxBytes !== undefined && bytes > maxBytes) {
9
+ throw new FsSafeError("too-large", `file exceeds limit of ${maxBytes} bytes (got at least ${bytes})`);
10
+ }
11
+ }
12
+ async function inputToBuffer(input, maxBytes) {
13
+ if (input.kind === "buffer") {
14
+ const data = typeof input.data === "string"
15
+ ? Buffer.from(input.data, input.encoding ?? "utf8")
16
+ : Buffer.from(input.data);
17
+ assertWithinMaxBytes(data.byteLength, maxBytes);
18
+ return data;
19
+ }
20
+ const chunks = [];
21
+ let bytes = 0;
22
+ for await (const chunk of input.stream) {
23
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
24
+ bytes += buffer.byteLength;
25
+ assertWithinMaxBytes(bytes, maxBytes);
26
+ chunks.push(buffer);
27
+ }
28
+ return Buffer.concat(chunks, bytes);
29
+ }
30
+ function nativeOpenFlags(flags) {
31
+ const closeOnExec = fsSync.constants.O_CLOEXEC;
32
+ return (flags |
33
+ (closeOnExec ?? 0) |
34
+ (typeof fsSync.constants.O_NOFOLLOW === "number" ? fsSync.constants.O_NOFOLLOW : 0));
35
+ }
36
+ function sameNativeIdentity(left, right) {
37
+ return left.dev === right.dev && left.ino === right.ino;
38
+ }
39
+ export async function runPinnedWriteNative(binding, params) {
40
+ const data = await inputToBuffer(params.input, params.maxBytes);
41
+ const root = await fs.open(params.rootPath, fsSync.constants.O_RDONLY |
42
+ (typeof fsSync.constants.O_DIRECTORY === "number" ? fsSync.constants.O_DIRECTORY : 0));
43
+ let parentFd;
44
+ let tempFd;
45
+ let targetFd;
46
+ let tempIdentity;
47
+ let parentPath = params.rootPath;
48
+ const tempName = `.${params.basename}.${randomUUID()}.native.tmp`;
49
+ let renamed = false;
50
+ try {
51
+ const rootIdentity = binding.fstatIdentity(root.fd);
52
+ if (params.rootIdentity && !sameNativeIdentity(params.rootIdentity, rootIdentity)) {
53
+ throw new FsSafeError("path-mismatch", "root path changed during native write");
54
+ }
55
+ if (params.mkdir) {
56
+ binding.mkdirBeneath(root.fd, params.relativeParentPath, 0o777);
57
+ }
58
+ const parentFlags = fsSync.constants.O_RDONLY |
59
+ (typeof fsSync.constants.O_DIRECTORY === "number" ? fsSync.constants.O_DIRECTORY : 0);
60
+ parentFd = binding.openBeneath(root.fd, params.relativeParentPath, parentFlags).fd;
61
+ parentPath = await fs.realpath(params.relativeParentPath
62
+ ? path.join(params.rootPath, ...params.relativeParentPath.split("/"))
63
+ : params.rootPath);
64
+ const parentPathStat = await fs.lstat(parentPath);
65
+ const parentIdentity = binding.fstatIdentity(parentFd);
66
+ if (parentPathStat.isSymbolicLink() ||
67
+ !sameNativeIdentity(parentPathStat, parentIdentity)) {
68
+ throw new FsSafeError("path-mismatch", "native write parent changed during resolution");
69
+ }
70
+ try {
71
+ await fs.lstat(path.join(parentPath, params.basename));
72
+ throw Object.assign(new Error("destination already exists"), { code: "EEXIST" });
73
+ }
74
+ catch (error) {
75
+ if (error.code !== "ENOENT") {
76
+ throw error;
77
+ }
78
+ }
79
+ tempFd = binding.openBeneath(parentFd, tempName, nativeOpenFlags(fsSync.constants.O_WRONLY | fsSync.constants.O_CREAT | fsSync.constants.O_EXCL)).fd;
80
+ fsSync.fchmodSync(tempFd, params.mode || 0o600);
81
+ writeNativeFd(tempFd, data);
82
+ syncNativeFileBestEffort(tempFd);
83
+ tempIdentity = fsSync.fstatSync(tempFd);
84
+ binding.renameNoReplace(parentFd, tempName, parentFd, params.basename);
85
+ renamed = true;
86
+ targetFd = binding.openBeneath(parentFd, params.basename, nativeOpenFlags(fsSync.constants.O_RDONLY)).fd;
87
+ const targetIdentity = binding.fstatIdentity(targetFd);
88
+ if (!targetIdentity.isFile || !sameNativeIdentity(tempIdentity, targetIdentity)) {
89
+ throw new FsSafeError("path-mismatch", "native write target changed after rename");
90
+ }
91
+ syncNativeFileBestEffort(parentFd);
92
+ return { dev: targetIdentity.dev, ino: targetIdentity.ino };
93
+ }
94
+ finally {
95
+ if (targetFd !== undefined) {
96
+ fsSync.closeSync(targetFd);
97
+ }
98
+ if (tempFd !== undefined) {
99
+ fsSync.closeSync(tempFd);
100
+ }
101
+ if (!renamed && parentFd !== undefined) {
102
+ removeNativeCreatedFileIfStillPinned({
103
+ binding,
104
+ parentPath,
105
+ parentFd,
106
+ basename: tempName,
107
+ created: tempIdentity,
108
+ });
109
+ }
110
+ if (parentFd !== undefined) {
111
+ fsSync.closeSync(parentFd);
112
+ }
113
+ await root.close().catch(() => undefined);
114
+ }
115
+ }