@openclaw/fs-safe 0.10.0 → 0.11.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 (259) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +1 -0
  3. package/README.md +36 -5
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +3 -2
  6. package/dist/advanced.d.ts +4 -0
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +4 -0
  9. package/dist/archive-durability.d.ts +6 -6
  10. package/dist/archive-durability.d.ts.map +1 -1
  11. package/dist/archive-durability.js +1 -1
  12. package/dist/archive-entry.d.ts.map +1 -1
  13. package/dist/archive-entry.js +4 -5
  14. package/dist/archive-gzip-tail.d.ts +1 -0
  15. package/dist/archive-gzip-tail.d.ts.map +1 -1
  16. package/dist/archive-gzip-tail.js +3 -0
  17. package/dist/archive-input.d.ts.map +1 -1
  18. package/dist/archive-input.js +4 -2
  19. package/dist/archive-merge.d.ts +5 -1
  20. package/dist/archive-merge.d.ts.map +1 -1
  21. package/dist/archive-merge.js +15 -12
  22. package/dist/archive-native.js +4 -4
  23. package/dist/archive-parser.wasm +0 -0
  24. package/dist/archive-read.d.ts.map +1 -1
  25. package/dist/archive-read.js +17 -9
  26. package/dist/archive-staging.d.ts +6 -3
  27. package/dist/archive-staging.d.ts.map +1 -1
  28. package/dist/archive-staging.js +42 -22
  29. package/dist/archive-tar-stream.d.ts.map +1 -1
  30. package/dist/archive-tar-stream.js +7 -6
  31. package/dist/archive-tar-wasm.d.ts.map +1 -1
  32. package/dist/archive-tar-wasm.js +16 -13
  33. package/dist/archive-zip-admission.d.ts +1 -1
  34. package/dist/archive-zip-admission.d.ts.map +1 -1
  35. package/dist/archive-zip-admission.js +48 -12
  36. package/dist/archive-zip-loader.d.ts +6 -0
  37. package/dist/archive-zip-loader.d.ts.map +1 -0
  38. package/dist/archive-zip-loader.js +38 -0
  39. package/dist/archive-zip-names.d.ts.map +1 -1
  40. package/dist/archive-zip-names.js +13 -8
  41. package/dist/archive-zip-preflight.d.ts +2 -3
  42. package/dist/archive-zip-preflight.d.ts.map +1 -1
  43. package/dist/archive-zip-preflight.js +2 -34
  44. package/dist/archive.d.ts.map +1 -1
  45. package/dist/archive.js +12 -10
  46. package/dist/bounded-read.d.ts +5 -0
  47. package/dist/bounded-read.d.ts.map +1 -1
  48. package/dist/bounded-read.js +16 -9
  49. package/dist/copy-file-input.d.ts +0 -1
  50. package/dist/copy-file-input.d.ts.map +1 -1
  51. package/dist/copy-file-input.js +4 -26
  52. package/dist/copy-tree-portable.d.ts.map +1 -1
  53. package/dist/copy-tree-portable.js +57 -26
  54. package/dist/copy.d.ts +1 -1
  55. package/dist/copy.d.ts.map +1 -1
  56. package/dist/copy.js +3 -1
  57. package/dist/directory-durability.d.ts.map +1 -1
  58. package/dist/directory-durability.js +5 -4
  59. package/dist/directory-guard.d.ts +11 -1
  60. package/dist/directory-guard.d.ts.map +1 -1
  61. package/dist/directory-guard.js +53 -11
  62. package/dist/durability.d.ts +1 -1
  63. package/dist/durability.d.ts.map +1 -1
  64. package/dist/durability.js +1 -1
  65. package/dist/file-handle-transfer.d.ts +14 -0
  66. package/dist/file-handle-transfer.d.ts.map +1 -0
  67. package/dist/file-handle-transfer.js +64 -0
  68. package/dist/file-hash.d.ts +3 -0
  69. package/dist/file-hash.d.ts.map +1 -1
  70. package/dist/file-hash.js +91 -31
  71. package/dist/file-lock-sync.d.ts.map +1 -1
  72. package/dist/file-lock-sync.js +8 -4
  73. package/dist/file-store-boundary.d.ts.map +1 -1
  74. package/dist/file-store-boundary.js +7 -5
  75. package/dist/file-store-path.d.ts +3 -0
  76. package/dist/file-store-path.d.ts.map +1 -0
  77. package/dist/file-store-path.js +27 -0
  78. package/dist/file-store-prune.d.ts.map +1 -1
  79. package/dist/file-store-prune.js +6 -4
  80. package/dist/file-store-sync-write.d.ts.map +1 -1
  81. package/dist/file-store-sync-write.js +56 -44
  82. package/dist/file-store.d.ts.map +1 -1
  83. package/dist/file-store.js +2 -18
  84. package/dist/filename.d.ts.map +1 -1
  85. package/dist/filename.js +2 -1
  86. package/dist/guarded-mkdir.d.ts.map +1 -1
  87. package/dist/guarded-mkdir.js +3 -2
  88. package/dist/guest-dispatch-python.d.ts +2 -0
  89. package/dist/guest-dispatch-python.d.ts.map +1 -0
  90. package/dist/guest-dispatch-python.js +117 -0
  91. package/dist/guest-native-python.d.ts +4 -0
  92. package/dist/guest-native-python.d.ts.map +1 -0
  93. package/dist/guest-native-python.js +135 -0
  94. package/dist/guest.d.ts +9 -0
  95. package/dist/guest.d.ts.map +1 -0
  96. package/dist/guest.js +413 -0
  97. package/dist/index.d.ts +1 -1
  98. package/dist/index.d.ts.map +1 -1
  99. package/dist/install-path.d.ts.map +1 -1
  100. package/dist/install-path.js +3 -2
  101. package/dist/json-durable-queue-directory.js +3 -3
  102. package/dist/json-durable-queue.d.ts.map +1 -1
  103. package/dist/json-durable-queue.js +6 -4
  104. package/dist/json.d.ts.map +1 -1
  105. package/dist/json.js +2 -1
  106. package/dist/local-roots.d.ts.map +1 -1
  107. package/dist/local-roots.js +2 -1
  108. package/dist/move-path-stage.d.ts.map +1 -1
  109. package/dist/move-path-stage.js +2 -1
  110. package/dist/move-path.d.ts.map +1 -1
  111. package/dist/move-path.js +4 -3
  112. package/dist/mutation-authority.d.ts +1 -0
  113. package/dist/mutation-authority.d.ts.map +1 -1
  114. package/dist/mutation-authority.js +4 -4
  115. package/dist/native-binding.d.ts +7 -2
  116. package/dist/native-binding.d.ts.map +1 -1
  117. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  118. package/dist/native-pinned-write-windows.js +3 -2
  119. package/dist/native-pinned-write.d.ts.map +1 -1
  120. package/dist/native-pinned-write.js +2 -1
  121. package/dist/opened-realpath.d.ts.map +1 -1
  122. package/dist/opened-realpath.js +5 -4
  123. package/dist/output.d.ts +2 -0
  124. package/dist/output.d.ts.map +1 -1
  125. package/dist/output.js +2 -0
  126. package/dist/overwrite-file-handle.d.ts +8 -0
  127. package/dist/overwrite-file-handle.d.ts.map +1 -0
  128. package/dist/overwrite-file-handle.js +42 -0
  129. package/dist/path-case.d.ts +7 -0
  130. package/dist/path-case.d.ts.map +1 -0
  131. package/dist/path-case.js +136 -0
  132. package/dist/path.d.ts.map +1 -1
  133. package/dist/path.js +2 -1
  134. package/dist/permissions-windows.d.ts +1 -1
  135. package/dist/permissions-windows.d.ts.map +1 -1
  136. package/dist/permissions-windows.js +48 -6
  137. package/dist/pinned-open.d.ts.map +1 -1
  138. package/dist/pinned-open.js +3 -1
  139. package/dist/pinned-write.d.ts +2 -2
  140. package/dist/pinned-write.d.ts.map +1 -1
  141. package/dist/pinned-write.js +2 -1
  142. package/dist/private-temp-workspace.d.ts.map +1 -1
  143. package/dist/private-temp-workspace.js +6 -4
  144. package/dist/realpath.d.ts +4 -0
  145. package/dist/realpath.d.ts.map +1 -0
  146. package/dist/realpath.js +43 -0
  147. package/dist/recursive-mkdir-path.d.ts +3 -0
  148. package/dist/recursive-mkdir-path.d.ts.map +1 -0
  149. package/dist/recursive-mkdir-path.js +8 -0
  150. package/dist/replace-directory.d.ts.map +1 -1
  151. package/dist/replace-directory.js +2 -1
  152. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  153. package/dist/replace-file-copy-fallback.js +23 -31
  154. package/dist/replace-file-copy-source.d.ts.map +1 -1
  155. package/dist/replace-file-copy-source.js +7 -12
  156. package/dist/replace-file-mode.d.ts +3 -0
  157. package/dist/replace-file-mode.d.ts.map +1 -0
  158. package/dist/replace-file-mode.js +10 -0
  159. package/dist/replace-file.d.ts +1 -0
  160. package/dist/replace-file.d.ts.map +1 -1
  161. package/dist/replace-file.js +14 -8
  162. package/dist/root-context.d.ts.map +1 -1
  163. package/dist/root-context.js +8 -8
  164. package/dist/root-create-input.d.ts +10 -0
  165. package/dist/root-create-input.d.ts.map +1 -0
  166. package/dist/root-create-input.js +80 -0
  167. package/dist/root-directory-list.d.ts +3 -1
  168. package/dist/root-directory-list.d.ts.map +1 -1
  169. package/dist/root-directory-list.js +21 -3
  170. package/dist/root-entries.d.ts +11 -0
  171. package/dist/root-entries.d.ts.map +1 -0
  172. package/dist/root-entries.js +61 -0
  173. package/dist/root-errors.d.ts +5 -5
  174. package/dist/root-errors.d.ts.map +1 -1
  175. package/dist/root-errors.js +13 -12
  176. package/dist/root-impl.d.ts +13 -3
  177. package/dist/root-impl.d.ts.map +1 -1
  178. package/dist/root-impl.js +40 -36
  179. package/dist/root-options.d.ts +12 -1
  180. package/dist/root-options.d.ts.map +1 -1
  181. package/dist/root-path-existing.d.ts.map +1 -1
  182. package/dist/root-path-existing.js +4 -3
  183. package/dist/root-path-symlink.d.ts.map +1 -1
  184. package/dist/root-path-symlink.js +3 -2
  185. package/dist/root-paths.d.ts.map +1 -1
  186. package/dist/root-paths.js +13 -9
  187. package/dist/root-remove.d.ts +5 -0
  188. package/dist/root-remove.d.ts.map +1 -0
  189. package/dist/root-remove.js +286 -0
  190. package/dist/root-symlink-policy.d.ts +2 -1
  191. package/dist/root-symlink-policy.d.ts.map +1 -1
  192. package/dist/root-symlink-policy.js +2 -2
  193. package/dist/root-write-mode.d.ts.map +1 -1
  194. package/dist/root-write-mode.js +2 -1
  195. package/dist/root.d.ts +2 -1
  196. package/dist/root.d.ts.map +1 -1
  197. package/dist/secret-file.d.ts.map +1 -1
  198. package/dist/secret-file.js +2 -1
  199. package/dist/secret-read-async.d.ts.map +1 -1
  200. package/dist/secret-read-async.js +2 -1
  201. package/dist/secure-file.d.ts.map +1 -1
  202. package/dist/secure-file.js +21 -4
  203. package/dist/secure-temp-dir.d.ts.map +1 -1
  204. package/dist/secure-temp-dir.js +2 -1
  205. package/dist/sibling-staged-file.d.ts +1 -0
  206. package/dist/sibling-staged-file.d.ts.map +1 -1
  207. package/dist/sibling-staged-file.js +42 -8
  208. package/dist/sibling-temp.d.ts +2 -0
  209. package/dist/sibling-temp.d.ts.map +1 -1
  210. package/dist/sibling-temp.js +6 -4
  211. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  212. package/dist/sidecar-lock-acquire.js +15 -4
  213. package/dist/sidecar-lock-policy.d.ts +2 -0
  214. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  215. package/dist/sidecar-lock-policy.js +17 -0
  216. package/dist/staged-directory.d.ts.map +1 -1
  217. package/dist/staged-directory.js +4 -3
  218. package/dist/temp-target.d.ts +14 -12
  219. package/dist/temp-target.d.ts.map +1 -1
  220. package/dist/temp-target.js +12 -6
  221. package/dist/trash.d.ts.map +1 -1
  222. package/dist/trash.js +7 -5
  223. package/dist/unicode-path.d.ts +3 -0
  224. package/dist/unicode-path.d.ts.map +1 -0
  225. package/dist/unicode-path.js +13 -0
  226. package/dist/walk.d.ts.map +1 -1
  227. package/dist/walk.js +3 -2
  228. package/dist/write-file-handle.d.ts +1 -0
  229. package/dist/write-file-handle.d.ts.map +1 -1
  230. package/dist/write-file-handle.js +3 -2
  231. package/docs/advanced.md +4 -0
  232. package/docs/archive.md +25 -5
  233. package/docs/atomic.md +17 -1
  234. package/docs/config.md +1 -0
  235. package/docs/contributing.md +29 -1
  236. package/docs/copy.md +75 -6
  237. package/docs/directory-identity.md +85 -0
  238. package/docs/durability.md +36 -1
  239. package/docs/entries.md +109 -0
  240. package/docs/errors.md +3 -3
  241. package/docs/file-store.md +15 -0
  242. package/docs/guest.md +141 -0
  243. package/docs/in-place-write.md +81 -0
  244. package/docs/index.md +2 -0
  245. package/docs/install.md +31 -0
  246. package/docs/native-helper.md +10 -3
  247. package/docs/native.md +4 -0
  248. package/docs/output.md +32 -6
  249. package/docs/path-case.md +64 -0
  250. package/docs/path-scope.md +1 -1
  251. package/docs/permissions.md +11 -2
  252. package/docs/public-api.md +31 -2
  253. package/docs/root.md +30 -2
  254. package/docs/secure-file.md +2 -0
  255. package/docs/sidecar-lock.md +12 -3
  256. package/docs/temp.md +35 -6
  257. package/docs/types.md +1 -1
  258. package/docs/writing.md +153 -3
  259. package/package.json +14 -8
@@ -2,6 +2,8 @@
2
2
 
3
3
  `acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
4
4
 
5
+ JavaScript raw-sidecar creation passes mode `0o600` to that exclusive open. On POSIX, the process umask may further restrict the new file but cannot add group or other access. No pathname `chmod` fallback is used.
6
+
5
7
  ```ts
6
8
  import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
7
9
 
@@ -61,7 +63,7 @@ function withFileLockSync<T, TPayload>(targetPath: string, options: FileLockSync
61
63
  type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
62
64
  managerKey?: string; // optional in-process manager namespace
63
65
  lockPath?: string; // override; defaults to `${targetPath}.lock`
64
- staleMs?: number; // default 30_000
66
+ staleMs?: number; // non-negative or Infinity; default 30_000
65
67
  timeoutMs?: number; // overall acquire deadline; default unbounded
66
68
  retry?: FileLockRetryOptions;
67
69
  staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
@@ -86,7 +88,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
86
88
  lockRoot?: Root;
87
89
  retainOnExit?: boolean; // keep the sidecar across process exit (default false)
88
90
  onCompromised?: (info: { lockPath: string; normalizedTargetPath: string }) => void;
89
- compromiseCheckIntervalMs?: number;
91
+ compromiseCheckIntervalMs?: number; // 0/omitted disables; otherwise 1..2_147_483_647
90
92
  };
91
93
 
92
94
  type FileLockRetryOptions = {
@@ -245,7 +247,14 @@ type FileLockHandle = {
245
247
  captured at acquisition. Set `compromiseCheckIntervalMs` together with
246
248
  `onCompromised` for a cheap periodic check; the callback fires once after the
247
249
  sidecar no longer matches or after a verification I/O failure. This is
248
- detection, not revocation of work already in progress.
250
+ detection, not revocation of work already in progress. Asynchronous checks are
251
+ serialized, so a slow verification never overlaps the next timer tick.
252
+
253
+ The compromise-check interval is validated before payload evaluation or
254
+ filesystem acquisition. Omit it or pass `0` to disable monitoring; enabled
255
+ intervals must be finite and between 1 and 2,147,483,647 milliseconds. Values
256
+ outside that range are rejected instead of being clamped by Node.js to an
257
+ unexpectedly tight polling loop.
249
258
 
250
259
  ## Synchronous locks
251
260
 
package/docs/temp.md CHANGED
@@ -262,8 +262,8 @@ const result = await writeSiblingTempFile<string>({
262
262
  // result.filePath, result.result (returned by writeTemp)
263
263
  ```
264
264
 
265
- `writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
266
- and calls `writeTemp()`. After the callback succeeds, it validates the produced
265
+ By default, `writeSiblingTempFile` chooses a random, initially absent sibling
266
+ name in `dir` and calls `writeTemp()`. After the callback succeeds, it validates the produced
267
267
  regular file before taking ownership: symlinks, directories, other non-regular
268
268
  files, hardlinks, and changes between the pre-open pathname, opened descriptor,
269
269
  and current pathname are rejected. The callback must finish and close its
@@ -292,12 +292,40 @@ Omitting either option or passing `false` skips that sync, never the identity
292
292
  checks. Parent synchronization can be unsupported or fail without rejecting
293
293
  the write, so success is not a strict crash-durability receipt.
294
294
 
295
- Cleanup only unlinks an admitted file while the parent, pathname identity, and
296
- single-link regular-file checks still agree. Observed substitutes are preserved,
295
+ Without producer isolation, cleanup only unlinks an admitted file while the
296
+ parent, pathname identity, and single-link regular-file checks still agree.
297
+ Observed substitutes are preserved,
297
298
  including during process-exit cleanup. Operational cleanup failures retain an
298
299
  identity-bound exit retry. If the callback throws or admission fails, no file
299
300
  has been adopted: even a regular partial file is left for caller-directed
300
- recovery. The helper never recursively removes a sibling temp.
301
+ recovery. The helper never recursively removes a sibling temp file path.
302
+
303
+ Set `producerIsolation: "private-directory"` in `WriteSiblingTempFileOptions`
304
+ when the producer can leave partial output before throwing. The callback then
305
+ receives an initially absent file path inside a private child workspace under
306
+ `dir`, on the same filesystem as the final target. fs-safe captures directory
307
+ cleanup ownership before invoking the callback. A callback exception triggers
308
+ owned workspace cleanup, including partial output, subject to directory
309
+ identity checks and I/O failures. The callback must still finish and close its
310
+ writer before returning.
311
+
312
+ After the callback succeeds, `Root.move` checks source aliases and moves the
313
+ output to the ordinary sibling path before file admission. An escaping symlink
314
+ can fail with `path-alias` at this step. Rejected output still inside the owned
315
+ workspace follows its cleanup contract. Once output moves to the sibling path,
316
+ failures before file adoption retain it for caller-directed recovery, as above.
317
+ File admission, requested modes, sync options, and final rename keep their
318
+ existing contracts; `resolveFinalPath(result)` still names a direct child of `dir`.
319
+
320
+ The isolated path retains exact bigint identities for both the parent and the
321
+ workspace and rechecks them before moving output to the sibling path. An
322
+ observed replacement is rejected. Cleanup uses the existing
323
+ [`withTempFile` ownership contract](#withtempfile), backed by [`tempFile`](#tempfile):
324
+ moving or replacing the parent or workspace can leave the original or
325
+ replacement paths in place. This option does not promise cleanup through a
326
+ retained directory after a rename, stronger permissions, or additional crash
327
+ durability. Omitting it preserves the direct sibling callback path and
328
+ unadmitted partial-file retention.
301
329
 
302
330
  On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
303
331
  does not block the helper. Windows retains Node's guarded pathname-open behavior
@@ -345,7 +373,7 @@ If `replaceFileAtomic` does what you need, prefer that. Use
345
373
  the final destination still needs root-boundary checks.
346
374
  Its private workspace uses the same identity-aware directory cleanup as
347
375
  `tempFile()`: moving and replacing the workspace preserves the replacement.
348
- The callback staging component is capped at 255 bytes under NFC and NFD by
376
+ The callback staging component is capped at 255 bytes as written and under NFC and NFD by
349
377
  shortening only an overlong embedded destination tail, while preserving an
350
378
  extension when possible. Short callback paths and the final target stay
351
379
  unchanged. This workspace owns its contents, unlike the unadmitted sibling
@@ -440,6 +468,7 @@ import fs from "node:fs/promises";
440
468
 
441
469
  const r = await writeSiblingTempFile({
442
470
  dir: "/srv/cache",
471
+ producerIsolation: "private-directory",
443
472
  writeTemp: async (tempPath) => {
444
473
  const handle = await fs.open(tempPath, "w");
445
474
  try {
package/docs/types.md CHANGED
@@ -45,7 +45,7 @@ type DirEntry = PathStat & {
45
45
  };
46
46
  ```
47
47
 
48
- Returned by `Root.list(rel, { withFileTypes: true })`. Includes every
48
+ Returned by `Root.list(rel, { withFileTypes: true })` and [`Root.entries()`](entries.md). Includes every
49
49
  `PathStat` field plus the entry's `name`.
50
50
 
51
51
  ## `BasePathOptions`
package/docs/writing.md CHANGED
@@ -154,6 +154,61 @@ try {
154
154
  }
155
155
  ```
156
156
 
157
+ ### Streamed creation
158
+
159
+ Pass an `AsyncIterable<Uint8Array>` to `create()` when bytes come from a database,
160
+ network response, or another incremental producer. Buffers are accepted chunks.
161
+ The writer consumes each chunk completely before requesting the next one; it
162
+ does not collect the full input in memory or expose a writable descriptor.
163
+
164
+ ```ts
165
+ async function* snapshotChunks(): AsyncGenerator<Uint8Array> {
166
+ yield Buffer.from("first stored chunk\n");
167
+ yield Buffer.from("second stored chunk\n");
168
+ }
169
+
170
+ await fs.create("restored/config.txt", snapshotChunks(), {
171
+ mode: 0o600,
172
+ maxBytes: 8 * 1024 * 1024,
173
+ durable: false,
174
+ signal: AbortSignal.timeout(30_000),
175
+ });
176
+ ```
177
+
178
+ `RootCreateStreamOptions` keeps `mkdir`, `mode`, `durable`,
179
+ `assertBeforeMutation`, `denyMutations`, and `mutationSymlinks` from buffered
180
+ creation and adds `maxBytes` and `signal`. Byte chunks have no encoding option;
181
+ streamed creation uses strict publication identity and does not support
182
+ `renameIdentity: "verify-content-with-lock"`. Existing Root defaults apply,
183
+ including explicit `maxBytes`; with no byte cap at either level, input size is
184
+ unlimited. Zero permits an empty input only. Invalid limits reject before I/O.
185
+
186
+ Unlike buffered creation's JavaScript fallback, streamed creation stages all
187
+ chunks before publishing the final name. Native mode uses no-replace rename;
188
+ the JavaScript fallback hardlinks the completed stage and removes its temporary
189
+ name in the same JavaScript turn. That fallback requires a filesystem supporting
190
+ hardlinks; other processes may briefly observe both names. An existing or
191
+ concurrently created destination is preserved. Existing-target preflight does
192
+ not consume the input. The final mode and durability policy use the same guarded
193
+ writer as other Root operations.
194
+
195
+ Cancellation checks run before and after producer pulls, before content writes,
196
+ and before publication. `assertBeforeMutation` also rechecks current application
197
+ authority after producer waits and before each partial write. The operation
198
+ waits for any pending producer pull or filesystem write, then awaits the
199
+ producer's `return()` and cleans only the owned unpublished stage. Pass the same
200
+ signal into a producer that may stall: an arbitrary async iterator cannot be
201
+ forcibly interrupted, so cancellation waits for its pending work and cleanup to
202
+ settle. Do not mutate a yielded chunk until the next pull. Producer errors retain
203
+ their original value when cleanup succeeds.
204
+
205
+ An aborted or failed operation can leave created parent directories. If a
206
+ stage's identity or parent cannot be verified during cleanup, the existing
207
+ guarded cleanup preserves it. After publication, later verification or cleanup
208
+ failures preserve the destination; rejection does not prove that no file was
209
+ created. Cancellation arriving after publication does not undo the completed
210
+ file. Application recovery remains caller-owned.
211
+
157
212
  ### `fs.writeJson(rel, value, options?)`
158
213
 
159
214
  `JSON.stringify(value, replacer, space)` + atomic write. Adds a trailing newline by default.
@@ -230,6 +285,101 @@ await fs.remove("snapshots/empty-dir"); // ok
230
285
  await fs.remove("snapshots/full-dir"); // throws not-empty
231
286
  ```
232
287
 
288
+ For a tree, opt into bounded recursive removal:
289
+
290
+ ```ts
291
+ await fs.remove("scratch/finished-job", {
292
+ recursive: true,
293
+ force: true,
294
+ maxEntries: 20_000,
295
+ maxDepth: 32,
296
+ mutationSymlinks: "reject",
297
+ signal: AbortSignal.timeout(30_000),
298
+ assertBeforeMutation: () => assertJobLeaseCurrent(),
299
+ });
300
+ ```
301
+
302
+ | Option | Default / behavior |
303
+ | --- | --- |
304
+ | `recursive` | `false`; opt in to removing non-empty directories. |
305
+ | `force` | `false`; `true` tolerates missing targets and vanished child entries. Other errors still reject. |
306
+ | `order` | `"filesystem"`; `"sorted"` collects and sorts child names lexicographically before descending. Requires `recursive: true`. |
307
+ | `maxEntries` | `100_000` in recursive mode; counts the requested target and every encountered child, including directories and symlinks. |
308
+ | `maxDepth` | `64` in recursive mode; the requested target has depth 0 and each child adds one. An empty directory at the limit can be removed. |
309
+ | `signal` | Stops traversal and new mutations when aborted. Already dispatched work settles and directory handles close before rejection. |
310
+
311
+ Budgets must be non-negative safe integers or explicit `Infinity`, and require
312
+ `recursive: true`. Omitted budgets retain their finite defaults. An entry or
313
+ depth limit throws `too-large` before processing the over-budget entry.
314
+
315
+ The default filesystem order streams each directory once, using memory and open
316
+ handles proportional to depth rather than directory width. Sorted order also
317
+ enumerates each directory once, but collects its names before descending and
318
+ deletes directories after their children. It uses JavaScript's default string
319
+ sort, not locale collation. Collected names consume the shared entry budget,
320
+ including sibling names still pending while an earlier directory is traversed.
321
+ With a finite budget, collection overflow rejects before processing that
322
+ directory's children; one extra name distinguishes an exact limit from overflow.
323
+ An `Infinity` entry budget uses a bulk name read while retaining the opened
324
+ directory handle and identity checks. Sorted mode retains collected names, so
325
+ unlimited budgets also permit unlimited name storage.
326
+
327
+ Sorted traversal preserves lexicographic processing, not the incidental syscall
328
+ timing of a caller that repeatedly rescans parent directories. Each child is
329
+ inspected when visited. Newly added entries can make the final `rmdir` fail with
330
+ `not-empty`; the operation does not retry indefinitely.
331
+
332
+ Recursive removal never follows a discovered symlink or junction. With an
333
+ omitted `mutationSymlinks` policy it unlinks that entry, preserving the existing
334
+ nonrecursive behavior. Both explicit mutation policies reject discovered links.
335
+ `follow-parents-within-root` permits aliases only in the requested target's
336
+ parents, and still rejects a final link. Root and per-call `denyMutations` policies
337
+ remain additive; a denied descendant prevents removal of the enclosing requested
338
+ tree before any entry is removed.
339
+
340
+ The operation retains exact identities for the traversal directories and each
341
+ observed target. Swapped or missing ancestors reject even with `force: true`;
342
+ an abort reason or authority refusal carrying `ENOENT` is not treated as absence.
343
+ Authority is rechecked immediately before each direct `unlink` or `rmdir`
344
+ dispatch. Directory handles close before their directories are removed, including
345
+ on Windows. If both traversal and close fail, `SuppressedError` retains both
346
+ failures. Directory-stream filesystem errors use the same removal codes as
347
+ `unlink` and `rmdir`, with the original error in `cause`; caller abort and
348
+ authority refusals retain their original values.
349
+
350
+ When `force: true` encounters a missing directory during an `opendir` or read,
351
+ it closes any open stream and reaches the ordinary final target check. The
352
+ surviving ancestors and original target identity are checked again; a replacement
353
+ is never accepted as a missing directory. An actually vanished child does not
354
+ prevent processing later siblings.
355
+
356
+ Filesystem failures normalized by the recursive removal owner and its direct
357
+ symlink rejections carry additional `FsSafeError.details` context:
358
+
359
+ ```ts
360
+ type RemoveFailureDetails = {
361
+ operation: "remove";
362
+ phase: "enumerate" | "inspect" | "remove";
363
+ relativePath: string;
364
+ };
365
+ ```
366
+
367
+ `relativePath` is relative to the requested removal target, using host path
368
+ separators: `""` identifies that target and `"nested/link"` identifies a child
369
+ on POSIX. It does not replace caller spelling with the canonical Root path.
370
+ `enumerate` covers directory stream operations, `inspect` covers initial child
371
+ observations, and `remove` covers final identity checks and unlink/rmdir failures.
372
+ The existing error codes, messages, and causes remain unchanged. Errors that
373
+ already have their own identity, including caller authority and cancellation
374
+ reasons, are propagated without adding or changing their details. Context is
375
+ diagnostic; it is not permission to retry or mutate an entry.
376
+
377
+ Removal is incremental, not atomic. A later budget, cancellation, identity, or
378
+ filesystem failure does not restore already removed entries. As with existing
379
+ `remove`, this is a guarded JavaScript operation in every native mode: pathname
380
+ checks are best-effort against a hostile concurrent process and do not create
381
+ an atomic check-and-delete syscall. Use OS isolation for that threat model.
382
+
233
383
  ### `fs.mkdir(rel)`
234
384
 
235
385
  `mkdir -p`. Creates missing parents.
@@ -265,9 +415,9 @@ try {
265
415
  Options are `{ denyMutations?, mkdir?, mode?, writeMode? }`, where `writeMode`
266
416
  is `"replace"` (default), `"append"`, or `"update"`. `replace` truncates existing
267
417
  files; `update` keeps existing contents. Streaming writes go directly to the
268
- destination — there is no atomic-rename step. If you need both streaming and
269
- atomicity, write to a sibling temp yourself and rename when done; the
270
- [`atomic`](atomic.md) helpers can do this for you.
418
+ destination — there is no atomic-rename step. For exclusive publication of a
419
+ complete stream, use [`create()`](#streamed-creation). For streamed replacement,
420
+ the [`atomic`](atomic.md) helpers provide a staged writer.
271
421
 
272
422
  On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
273
423
  a no-reader FIFO cannot stall regular-file validation. This does not change
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -50,6 +50,10 @@
50
50
  "types": "./dist/copy.d.ts",
51
51
  "default": "./dist/copy.js"
52
52
  },
53
+ "./guest": {
54
+ "types": "./dist/guest.d.ts",
55
+ "default": "./dist/guest.js"
56
+ },
53
57
  "./config": {
54
58
  "types": "./dist/config.d.ts",
55
59
  "default": "./dist/config.js"
@@ -137,6 +141,8 @@
137
141
  "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
138
142
  "prepack": "node scripts/prepack-build.mjs",
139
143
  "test": "vitest run",
144
+ "test:bun": "bun node_modules/vitest/vitest.mjs run --config scripts/bun-vitest.config.ts",
145
+ "test:bun:native": "bun scripts/bun-native-proof.mjs && bun node_modules/vitest/vitest.mjs run --config scripts/bun-native-vitest.config.ts",
140
146
  "test:coverage": "vitest run --coverage",
141
147
  "test:coverage:collect": "pnpm build && vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0",
142
148
  "test:coverage:merge": "node scripts/merge-coverage.mjs",
@@ -161,13 +167,13 @@
161
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
162
168
  },
163
169
  "optionalDependencies": {
164
- "@openclaw/fs-safe-darwin-arm64": "0.10.0",
165
- "@openclaw/fs-safe-darwin-x64": "0.10.0",
166
- "@openclaw/fs-safe-linux-arm64-gnu": "0.10.0",
167
- "@openclaw/fs-safe-linux-arm64-musl": "0.10.0",
168
- "@openclaw/fs-safe-linux-x64-gnu": "0.10.0",
169
- "@openclaw/fs-safe-linux-x64-musl": "0.10.0",
170
- "@openclaw/fs-safe-win32-x64-msvc": "0.10.0",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.11.0",
171
+ "@openclaw/fs-safe-darwin-x64": "0.11.0",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.11.0",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.11.0",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.11.0",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.11.0",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.11.0",
171
177
  "jszip": "^3.10.2"
172
178
  },
173
179
  "devDependencies": {