@openclaw/fs-safe 0.14.0 → 0.15.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 (235) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +10 -4
  3. package/dist/advanced.d.ts +1 -1
  4. package/dist/advanced.d.ts.map +1 -1
  5. package/dist/advanced.js +1 -1
  6. package/dist/archive-merge.d.ts.map +1 -1
  7. package/dist/archive-merge.js +113 -46
  8. package/dist/archive-read.d.ts.map +1 -1
  9. package/dist/archive-read.js +4 -4
  10. package/dist/archive-zip-directory.d.ts +4 -0
  11. package/dist/archive-zip-directory.d.ts.map +1 -1
  12. package/dist/archive-zip-directory.js +2 -0
  13. package/dist/archive-zip-entry.d.ts +6 -2
  14. package/dist/archive-zip-entry.d.ts.map +1 -1
  15. package/dist/archive-zip-entry.js +23 -8
  16. package/dist/archive-zip-integrity.d.ts.map +1 -1
  17. package/dist/archive-zip-integrity.js +3 -4
  18. package/dist/archive-zip-loader.d.ts.map +1 -1
  19. package/dist/archive-zip-loader.js +107 -31
  20. package/dist/archive-zip-names.d.ts +1 -0
  21. package/dist/archive-zip-names.d.ts.map +1 -1
  22. package/dist/archive-zip-names.js +6 -0
  23. package/dist/archive.js +6 -5
  24. package/dist/bounded-read-stream.d.ts +0 -1
  25. package/dist/bounded-read-stream.d.ts.map +1 -1
  26. package/dist/bounded-read-stream.js +0 -6
  27. package/dist/copy-publication.d.ts +6 -0
  28. package/dist/copy-publication.d.ts.map +1 -1
  29. package/dist/copy-publication.js +3 -0
  30. package/dist/copy-tree-portable.d.ts.map +1 -1
  31. package/dist/copy-tree-portable.js +44 -24
  32. package/dist/copy.d.ts.map +1 -1
  33. package/dist/copy.js +29 -11
  34. package/dist/directory-mode-owner.js +5 -5
  35. package/dist/file-handle-transfer.d.ts +2 -0
  36. package/dist/file-handle-transfer.d.ts.map +1 -1
  37. package/dist/file-handle-transfer.js +57 -2
  38. package/dist/file-identity.d.ts.map +1 -1
  39. package/dist/file-identity.js +18 -4
  40. package/dist/file-lock-sync-admission.d.ts +19 -0
  41. package/dist/file-lock-sync-admission.d.ts.map +1 -0
  42. package/dist/file-lock-sync-admission.js +93 -0
  43. package/dist/file-lock-sync-root-acquire.d.ts +4 -0
  44. package/dist/file-lock-sync-root-acquire.d.ts.map +1 -0
  45. package/dist/file-lock-sync-root-acquire.js +370 -0
  46. package/dist/file-lock-sync-root-arbitration.d.ts +18 -0
  47. package/dist/file-lock-sync-root-arbitration.d.ts.map +1 -0
  48. package/dist/file-lock-sync-root-arbitration.js +66 -0
  49. package/dist/file-lock-sync-root-held.d.ts +34 -0
  50. package/dist/file-lock-sync-root-held.d.ts.map +1 -0
  51. package/dist/file-lock-sync-root-held.js +393 -0
  52. package/dist/file-lock-sync-root-io.d.ts +44 -0
  53. package/dist/file-lock-sync-root-io.d.ts.map +1 -0
  54. package/dist/file-lock-sync-root-io.js +209 -0
  55. package/dist/file-lock-sync-root-mutation.d.ts +17 -0
  56. package/dist/file-lock-sync-root-mutation.d.ts.map +1 -0
  57. package/dist/file-lock-sync-root-mutation.js +277 -0
  58. package/dist/file-lock-sync-root-options.d.ts +20 -0
  59. package/dist/file-lock-sync-root-options.d.ts.map +1 -0
  60. package/dist/file-lock-sync-root-options.js +58 -0
  61. package/dist/file-lock-sync-root-registration.d.ts +2 -0
  62. package/dist/file-lock-sync-root-registration.d.ts.map +1 -0
  63. package/dist/file-lock-sync-root-registration.js +90 -0
  64. package/dist/file-lock-sync-root.d.ts +36 -0
  65. package/dist/file-lock-sync-root.d.ts.map +1 -0
  66. package/dist/file-lock-sync-root.js +361 -0
  67. package/dist/file-lock-sync-stale-admission.d.ts +24 -0
  68. package/dist/file-lock-sync-stale-admission.d.ts.map +1 -0
  69. package/dist/file-lock-sync-stale-admission.js +205 -0
  70. package/dist/file-lock-sync.d.ts.map +1 -1
  71. package/dist/file-lock-sync.js +245 -205
  72. package/dist/file-store-prune.d.ts.map +1 -1
  73. package/dist/file-store-prune.js +5 -1
  74. package/dist/file-store-sync-write.js +3 -3
  75. package/dist/file-store.d.ts.map +1 -1
  76. package/dist/file-store.js +47 -12
  77. package/dist/json-document-store.d.ts.map +1 -1
  78. package/dist/json-document-store.js +22 -15
  79. package/dist/json-durable-queue-ownership.d.ts +0 -1
  80. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  81. package/dist/json-durable-queue-ownership.js +0 -6
  82. package/dist/native-binding.d.ts +2 -0
  83. package/dist/native-binding.d.ts.map +1 -1
  84. package/dist/native-parent-admission.d.ts +3 -2
  85. package/dist/native-parent-admission.d.ts.map +1 -1
  86. package/dist/native-parent-admission.js +24 -5
  87. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  88. package/dist/native-pinned-write-windows.js +7 -7
  89. package/dist/native-pinned-write.d.ts.map +1 -1
  90. package/dist/native-pinned-write.js +7 -11
  91. package/dist/native-policy-parent-windows.d.ts +14 -0
  92. package/dist/native-policy-parent-windows.d.ts.map +1 -0
  93. package/dist/native-policy-parent-windows.js +200 -0
  94. package/dist/native-rename-outcome.d.ts +4 -0
  95. package/dist/native-rename-outcome.d.ts.map +1 -0
  96. package/dist/native-rename-outcome.js +8 -0
  97. package/dist/native-staged-file.d.ts +2 -2
  98. package/dist/native-staged-file.d.ts.map +1 -1
  99. package/dist/native-staged-file.js +42 -40
  100. package/dist/output.d.ts.map +1 -1
  101. package/dist/output.js +12 -8
  102. package/dist/path-prefix.d.ts.map +1 -1
  103. package/dist/path-prefix.js +30 -8
  104. package/dist/path-suffix-aliases.d.ts +2 -0
  105. package/dist/path-suffix-aliases.d.ts.map +1 -1
  106. package/dist/path-suffix-aliases.js +25 -17
  107. package/dist/permission-exec.d.ts +2 -0
  108. package/dist/permission-exec.d.ts.map +1 -1
  109. package/dist/permission-exec.js +150 -21
  110. package/dist/permissions-windows.js +1 -1
  111. package/dist/pinned-mutation-admission.d.ts.map +1 -1
  112. package/dist/pinned-mutation-admission.js +10 -5
  113. package/dist/pinned-mutation-observation.d.ts +0 -1
  114. package/dist/pinned-mutation-observation.d.ts.map +1 -1
  115. package/dist/pinned-mutation-observation.js +0 -19
  116. package/dist/pinned-mutation-shared-route.d.ts +1 -0
  117. package/dist/pinned-mutation-shared-route.d.ts.map +1 -1
  118. package/dist/pinned-mutation-shared-route.js +1 -1
  119. package/dist/pinned-write-types.d.ts +2 -0
  120. package/dist/pinned-write-types.d.ts.map +1 -1
  121. package/dist/private-temp-workspace.d.ts.map +1 -1
  122. package/dist/private-temp-workspace.js +75 -121
  123. package/dist/regular-file.d.ts.map +1 -1
  124. package/dist/regular-file.js +1 -15
  125. package/dist/replace-directory.d.ts.map +1 -1
  126. package/dist/replace-directory.js +256 -18
  127. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  128. package/dist/replace-file-copy-fallback.js +62 -70
  129. package/dist/replace-file-copy-source.d.ts.map +1 -1
  130. package/dist/replace-file-copy-source.js +10 -12
  131. package/dist/replace-file-temp-owner.d.ts +5 -2
  132. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  133. package/dist/replace-file-temp-owner.js +65 -30
  134. package/dist/replace-file.js +6 -6
  135. package/dist/retained-directory-replacement.d.ts +26 -0
  136. package/dist/retained-directory-replacement.d.ts.map +1 -0
  137. package/dist/retained-directory-replacement.js +193 -0
  138. package/dist/root-boundary.d.ts +1 -0
  139. package/dist/root-boundary.d.ts.map +1 -1
  140. package/dist/root-boundary.js +4 -0
  141. package/dist/root-context.d.ts +0 -8
  142. package/dist/root-context.d.ts.map +1 -1
  143. package/dist/root-context.js +0 -3
  144. package/dist/root-create-input.d.ts +6 -0
  145. package/dist/root-create-input.d.ts.map +1 -1
  146. package/dist/root-create-input.js +5 -1
  147. package/dist/root-directory-list.d.ts +1 -0
  148. package/dist/root-directory-list.d.ts.map +1 -1
  149. package/dist/root-directory-list.js +1 -0
  150. package/dist/root-impl.d.ts.map +1 -1
  151. package/dist/root-impl.js +18 -10
  152. package/dist/root-move-noreplace.d.ts.map +1 -1
  153. package/dist/root-move-noreplace.js +2 -2
  154. package/dist/root-path-errors.d.ts +1 -0
  155. package/dist/root-path-errors.d.ts.map +1 -1
  156. package/dist/root-path-errors.js +11 -2
  157. package/dist/root-path-existing.d.ts.map +1 -1
  158. package/dist/root-path-existing.js +11 -35
  159. package/dist/root-path.js +1 -13
  160. package/dist/root-remove.d.ts +1 -0
  161. package/dist/root-remove.d.ts.map +1 -1
  162. package/dist/root-remove.js +4 -0
  163. package/dist/root-walk.d.ts +1 -1
  164. package/dist/root-walk.d.ts.map +1 -1
  165. package/dist/root-walk.js +17 -2
  166. package/dist/root-write-admission.d.ts +0 -2
  167. package/dist/root-write-admission.d.ts.map +1 -1
  168. package/dist/root-write-admission.js +1 -15
  169. package/dist/root-write-complete-parent.d.ts.map +1 -1
  170. package/dist/root-write-complete-parent.js +7 -23
  171. package/dist/root-write-verification.d.ts.map +1 -1
  172. package/dist/root-write-verification.js +29 -42
  173. package/dist/secret-file.d.ts.map +1 -1
  174. package/dist/secret-file.js +2 -24
  175. package/dist/secret-read-async.d.ts.map +1 -1
  176. package/dist/secret-read-async.js +3 -24
  177. package/dist/secret-read-policy.d.ts +6 -2
  178. package/dist/secret-read-policy.d.ts.map +1 -1
  179. package/dist/secret-read-policy.js +26 -2
  180. package/dist/sibling-temp.d.ts.map +1 -1
  181. package/dist/sibling-temp.js +23 -12
  182. package/dist/sidecar-lock-acquire.d.ts +2 -28
  183. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  184. package/dist/sidecar-lock-acquire.js +288 -199
  185. package/dist/sidecar-lock-admission-context.d.ts +19 -0
  186. package/dist/sidecar-lock-admission-context.d.ts.map +1 -0
  187. package/dist/sidecar-lock-admission-context.js +60 -0
  188. package/dist/sidecar-lock-admission-parser.d.ts +43 -0
  189. package/dist/sidecar-lock-admission-parser.d.ts.map +1 -0
  190. package/dist/sidecar-lock-admission-parser.js +113 -0
  191. package/dist/sidecar-lock-admission.d.ts +35 -0
  192. package/dist/sidecar-lock-admission.d.ts.map +1 -0
  193. package/dist/sidecar-lock-admission.js +7 -0
  194. package/dist/sidecar-lock-reclaim.d.ts +9 -4
  195. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  196. package/dist/sidecar-lock-reclaim.js +80 -25
  197. package/dist/sidecar-lock-stale-admission.d.ts +39 -0
  198. package/dist/sidecar-lock-stale-admission.d.ts.map +1 -0
  199. package/dist/sidecar-lock-stale-admission.js +232 -0
  200. package/dist/sidecar-lock-target.d.ts +8 -0
  201. package/dist/sidecar-lock-target.d.ts.map +1 -0
  202. package/dist/sidecar-lock-target.js +55 -0
  203. package/dist/sidecar-lock.d.ts.map +1 -1
  204. package/dist/sidecar-lock.js +100 -16
  205. package/dist/temp-workspace-descriptor.d.ts.map +1 -1
  206. package/dist/temp-workspace-descriptor.js +9 -27
  207. package/dist/temp-workspace-owner.d.ts.map +1 -1
  208. package/dist/temp-workspace-owner.js +8 -8
  209. package/dist/walk.d.ts +5 -1
  210. package/dist/walk.d.ts.map +1 -1
  211. package/dist/walk.js +19 -6
  212. package/dist/windows-owner.d.ts.map +1 -1
  213. package/dist/windows-owner.js +2 -2
  214. package/docs/advanced.md +1 -1
  215. package/docs/archive.md +36 -9
  216. package/docs/atomic.md +85 -8
  217. package/docs/copy.md +35 -0
  218. package/docs/file-store.md +19 -0
  219. package/docs/json-store.md +5 -0
  220. package/docs/native-helper.md +10 -3
  221. package/docs/output.md +6 -0
  222. package/docs/path-prefix.md +10 -0
  223. package/docs/path-suffix-aliases.md +51 -6
  224. package/docs/permissions.md +13 -0
  225. package/docs/public-api.md +3 -2
  226. package/docs/root.md +22 -3
  227. package/docs/sidecar-lock.md +109 -4
  228. package/docs/staged-file.md +7 -3
  229. package/docs/temp.md +20 -3
  230. package/docs/walk.md +67 -1
  231. package/docs/writing.md +5 -1
  232. package/package.json +10 -10
  233. package/dist/darwin-acl.d.ts +0 -4
  234. package/dist/darwin-acl.d.ts.map +0 -1
  235. package/dist/darwin-acl.js +0 -24
package/docs/root.md CHANGED
@@ -72,12 +72,22 @@ The default `order: "sorted"` enumerates and sorts each directory's names;
72
72
  wide directories. Budget exhaustion yields a `"truncated"` marker by
73
73
  default or throws `FsSafeError("too-large")` with `limitBehavior: "throw"`.
74
74
  Use `entryFilter(entry)` to return `"include"`, `"skip"`, or
75
- `"skip-subtree"`. `"skip"` omits the current entry but still descends into a
76
- directory; `"skip-subtree"` omits a directory and all of its descendants.
75
+ `"skip-subtree"`, directly or through a Promise. `"skip"` omits the current
76
+ entry but still descends into a directory; `"skip-subtree"` omits a directory
77
+ and all of its descendants.
78
+ Filters run serially outside metadata batches, with the options object as their
79
+ `this` receiver. After an awaited filter resolves, the walk checks cancellation
80
+ and revalidates the current listing directory and Root identities before using
81
+ the decision. Captured entry metadata retains its snapshot semantics.
82
+
83
+ Cancellation and iterator disposal wait for a pending filter to settle; they do
84
+ not race the callback or close its directory while it is running. Callback
85
+ throws and promise rejections reject the walk through normal cleanup.
77
86
  Directory reads remain fail-fast by default. With
78
87
  `onDirectoryError: "skip-and-report"`, the iterator instead yields
79
88
  `{ relativePath, kind: "directory-error", size: 0, error }` and continues with
80
- the remaining tree.
89
+ the remaining tree. That policy also covers identity-check failures after an
90
+ awaited filter, while callback failures always reject.
81
91
  See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
82
92
  with the standalone best-effort walkers.
83
93
 
@@ -290,6 +300,15 @@ the caller, which must check authority before its own later writes.
290
300
 
291
301
  All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
292
302
 
303
+ For writes, creates, streams, and copies, parent creation admits the prospective
304
+ file and each missing directory before creating that directory, including on the
305
+ Windows native route. An exact deny on an existing parent does not prevent using
306
+ that parent to write an allowed child. If a deeper missing parent is denied,
307
+ earlier admitted directories may remain; the denied directory and file are not
308
+ created. With `mkdir: false`, missing parents are never created. Native Windows
309
+ policy-aware creation requires the direct-child helper and fails with
310
+ `helper-unavailable` if it is absent.
311
+
293
312
  All mutation methods also accept `mutationSymlinks`. `"reject"` rejects symlink
294
313
  components; `"follow-parents-within-root"` resolves contained parent directory
295
314
  aliases but rejects the final component if it is a symlink, including a dangling
@@ -9,6 +9,13 @@ normal meaning, and ordinary colon-bearing POSIX paths remain valid.
9
9
 
10
10
  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.
11
11
 
12
+ Root-backed lock records and reclaim guards also claim their final name
13
+ exclusively, including with the native backend. The native path retains the
14
+ admitted parent descriptor and removes incomplete claims only while their exact
15
+ identity remains owned. Ordinary Root creates still stage privately; lock records
16
+ use this internal exclusive-create path so racing contenders can retry without
17
+ an ambiguous rename outcome.
18
+
12
19
  ```ts
13
20
  import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
14
21
 
@@ -28,13 +35,32 @@ try {
28
35
 
29
36
  The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
30
37
 
31
- On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first.
38
+ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and raw-path reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first.
39
+
40
+ Asynchronous Root-backed stale recovery uses a regular file at the
41
+ `.reclaim` name, created, verified, and removed through that Root. Its ownership
42
+ token and exact bytes are checked after awaited decisions and before stale
43
+ removal; Root mutation policies also apply to the guard. Raw and synchronous
44
+ Root reclaimers use directories and recognize these files as occupied guards.
45
+ Each asynchronous attempt owns its guard directly, outside process-exit cleanup,
46
+ so `beforeExit` cannot release an exclusion still needed by an unsettled attempt.
47
+ Normal completion removes it through the Root. Interrupted creation, revoked
48
+ cleanup authority, identity changes, or process exit can leave the guard in
49
+ place; recover it only after an application-owned liveness check proves the
50
+ attempt has ended. There is no raw-path cleanup fallback. These token/byte
51
+ checks retain the sidecar protocol's cooperative, non-atomic removal boundary.
52
+ The final guard check follows the last sidecar snapshot and parser call, before
53
+ removal. Parsers can run before guard ownership is established or verified;
54
+ invocation is not mutation authority. A failing final parser keeps its error
55
+ even if guard ownership has also changed.
56
+ `manager.reset()` invalidates admission bookkeeping but preserves a pending
57
+ Root guard; let its original attempt settle before retrying that guarded path.
32
58
 
33
59
  Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
34
60
 
35
- Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
61
+ Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. First registration is committed only after Node accepts the listener; synchronous `newListener` reentry fails closed and a thrown registration rolls back so a later acquisition can retry. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
36
62
 
37
- Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
63
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
38
64
 
39
65
  The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
40
66
 
@@ -62,6 +88,41 @@ function withFileLockSync<T, TPayload>(targetPath: string, options: FileLockSync
62
88
 
63
89
  `managerKey` is an optional identifier used to keep state isolated across multiple lock domains in the same process. Use distinct keys for distinct domains (`"snapshot"`, `"compact"`, `"build"`). If omitted, fs-safe derives one from the target path.
64
90
 
91
+ Within one manager domain, the canonical target path is the in-process
92
+ arbitration key even when callers supply different explicit `lockPath` values.
93
+ Admission remains pending while payload serialization and stale-policy callbacks
94
+ run, and becomes reentrant only after a matching owner is fully published.
95
+ Foreign owners wait or reach the configured timeout without opening their
96
+ alternate sidecar. Each unguarded attempt still invokes and serializes `payload`
97
+ before a completed foreign holder consumes retry budget, preserving callback and
98
+ retry compatibility; the holder is rechecked before delayed option accessors or
99
+ sidecar I/O. When the candidate resolves to the holder's actual sidecar (the
100
+ default path or an explicitly identical path), asynchronous retries also preserve
101
+ the existing parser observation: bytes are observed first, then its accessor is
102
+ read and the current holder bytes are parsed once per unguarded attempt.
103
+ Distinct alternate sidecars do not trigger that observation. Async acquisition releases
104
+ pending admission before retry backoff;
105
+ synchronous callback reentry cannot let the active stack progress, so it fails
106
+ closed with the normal `file_lock_timeout` fields. Async payload, serialization,
107
+ delayed-option, and stale-policy callbacks carry a process-shared ancestry scope:
108
+ a nested acquisition of the same canonical target in the same manager domain
109
+ fails before waiting on its ancestor, while independent tasks, different targets,
110
+ and different manager domains retain their normal retry behavior. The synchronous
111
+ API uses one process-wide domain. An ancestry snapshot keeps each ancestor that
112
+ is active when the child acquisition starts, even if the current callback's own
113
+ scope already became inactive; later deactivation cannot reclassify that child.
114
+ Detached work started only after every matching ancestor has finished is not
115
+ retained as a descendant. Promise-like callback results are assimilated inside
116
+ that ancestry scope, and the resolved payload crosses the internal return
117
+ boundary in a non-thenable envelope. The helper therefore does not observe a
118
+ stateful payload `then` accessor again outside the reservation.
119
+
120
+ The pending-admission registry coordinates package copies that implement this
121
+ protocol without placing incomplete state in the legacy held-lock map. An older
122
+ already-loaded executable copy does not consult that registry, so a mixed-version
123
+ process cannot rely on the new in-process arbitration until every copy is updated
124
+ and the process is restarted.
125
+
65
126
  ## Acquire options
66
127
 
67
128
  ```ts
@@ -105,7 +166,11 @@ type FileLockRetryOptions = {
105
166
  };
106
167
  ```
107
168
 
108
- `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
169
+ `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp,
170
+ PID). Callback-valued option accessors are otherwise captured once for an
171
+ acquisition; the asynchronous same-sidecar parser observation above reads
172
+ `parsePayload` once per unguarded attempt. Callback invocation keeps its
173
+ established receiver behavior.
109
174
 
110
175
  Asynchronous acquisition snapshots `targetPath`, an explicit `lockPath`, and
111
176
  `lockRoot` before its first asynchronous operation. Cwd-dependent path spellings
@@ -295,6 +360,46 @@ deadline budget. Errors from descriptor reads/stats or parsing are not treated
295
360
  as missing snapshots, even when their code is `ENOENT`. Held verification,
296
361
  release, and reclaim do not retry open denials.
297
362
 
363
+ Synchronous `lockRoot` is an authority boundary, not only a containment hint.
364
+ It requires a genuine `Root` returned by the same loaded package copy; a
365
+ structural/custom Root lookalike or a handle constructed by another installed
366
+ copy fails with `helper-unavailable` before any remaining acquisition option or
367
+ nested retry getter, payload evaluation, or filesystem effects. After reading
368
+ `lockRoot` once, the genuine Root and its policies are snapshotted before those
369
+ getters run. Construct `lockRoot` through the same import instance that provides
370
+ the synchronous lock function. The acquirer retains the original Root context,
371
+ exact root, parent, and file identities, and the Root's entry-time read, hardlink,
372
+ mutation-symlink, `denyMutations`, and `assertBeforeMutation` policies. Those
373
+ receipts remain authoritative through same-owner reuse, compromise checks,
374
+ reclaim, explicit release, and process-exit cleanup. If the Root, an admitted
375
+ parent, or the owned entry changes, cleanup leaves the ambiguous path in place.
376
+ Root-backed synchronous records and their exit handler use a separate versioned
377
+ global domain; legacy raw-lock handlers and legacy package copies cannot adopt or
378
+ pathname-delete those records. Root and raw acquisitions never share a
379
+ reentrant reference, even when their owner strings match.
380
+
381
+ As with asynchronous Root-backed acquisition, synchronous target normalization
382
+ does not create the target's parent. An explicit in-root `lockPath` can therefore
383
+ guard an external or not-yet-created target key without creating anything next
384
+ to that target. Missing directories for the sidecar itself are created one
385
+ component at a time through the retained Root policy; the returned `lockPath`
386
+ uses the admitted canonical spelling. This strengthens earlier synchronous
387
+ behavior that treated `lockRoot` as a one-time lexical/canonical bound and used
388
+ raw pathname operations afterward.
389
+
390
+ Windows Root-backed target keys use native existing-ancestor canonicalization,
391
+ so long and short spellings of the same target parent share an arbitration key.
392
+ Sidecar admission applies both the retained mutation policy and read/final-link
393
+ policy before payload evaluation; a dangling final sidecar link is rejected
394
+ without creating its target.
395
+
396
+ Exact Root, parent, and file receipts narrow replacement races but do not make a
397
+ pathname check and the following `open`, `mkdir`, `unlink`, or `rmdir` one atomic
398
+ filesystem operation. A hostile peer with direct write access can still race the
399
+ final syscall. An observed mismatch fails closed and ambiguous entries remain;
400
+ use OS-enforced directory permissions or a native descriptor-relative primitive
401
+ when that attacker model must be excluded.
402
+
298
403
  Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
299
404
  A synchronous retry sleep is clamped to the remaining finite deadline, so a long or jittered backoff cannot extend the configured timeout or block forever.
300
405
  Per-call options take precedence, including zero values; a per-call `retry`
@@ -105,9 +105,13 @@ touching descriptors.
105
105
  basename. Empty, dot, dotdot, separators, absolute paths, NUL, control characters,
106
106
  drive-relative spellings, and the stage's own name are rejected.
107
107
 
108
- With `overwrite: false`, publication is genuine kernel no-replace rename; a
109
- collision leaves both names unchanged and raises `FsSafeError("already-exists")`.
110
- The stage may then be cleaned or published under another name. With
108
+ With `overwrite: false`, publication is genuine kernel no-replace rename.
109
+ A native collision raises `FsSafeError("already-exists")`, but ordinary errno
110
+ does not prove that a remote rename never committed. Native rename failures
111
+ without explicit pre-dispatch provenance therefore report `indeterminate`:
112
+ cleanup preserves names and closes descriptors, and further publication rejects.
113
+ Only rejection before rename dispatch leaves the stage eligible for cleanup or
114
+ publication under another name. With
111
115
  `overwrite: true`, publication is plain atomic replacement. Neither route
112
116
  copies. Both source and destination resolve through the retained original
113
117
  parent, with checks immediately before rename and after publication.
package/docs/temp.md CHANGED
@@ -221,9 +221,15 @@ A missing workspace returns `"missing"`. A replacement observed at the public
221
221
  name before quarantine returns `"identity-mismatch"` when the parent is stable;
222
222
  an ambiguous parent returns `"indeterminate"`. After successful removal,
223
223
  repeated cleanup returns `"missing"` without touching a recreated public name.
224
- Other statuses remain stable. Operational removal errors propagate and later
225
- cleanup returns `"indeterminate"` without retrying. Disposal and scoped helpers
226
- ignore returned statuses, while manual cleanup exposes the result.
224
+ Other statuses remain stable. Compatible recursive-removal failures propagate
225
+ the exact thrown value, including `undefined`, `null`, `false`, positive or
226
+ negative numeric zero, bigint zero, an empty string, and `NaN`; they are never
227
+ inferred from value identity or truthiness. Uncertain quarantine and
228
+ retained-parent checks instead return
229
+ `"indeterminate"`. After a propagated removal failure, later cleanup returns
230
+ `"indeterminate"` without retrying. Disposal and scoped helpers ignore returned
231
+ statuses, while manual cleanup exposes the result. A terminal descriptor-close
232
+ failure retains its existing precedence if it also fails during settlement.
227
233
 
228
234
  When cleanup is part of a retention or audit decision, inspect the receipt
229
235
  instead of treating cleanup as fire-and-forget:
@@ -410,6 +416,12 @@ files, hardlinks, and changes between the pre-open pathname, opened descriptor,
410
416
  and current pathname are rejected. The callback must finish and close its
411
417
  writer before returning. Its return value is preserved as `result`.
412
418
 
419
+ Each option is read once before directory creation starts, including both
420
+ callbacks, the temp prefix, isolation, directory and file modes, and sync flags.
421
+ Later changes to the options object do not change the in-flight operation.
422
+ `writeTemp` and `resolveFinalPath` retain their shared internal staging object
423
+ as the callback receiver.
424
+
413
425
  Generated temp filenames suffix Windows reserved-device basenames on every
414
426
  platform. Before either an ordinary or isolated producer runs, the completed staging
415
427
  name must be a nonempty, non-dot path component with no POSIX or Windows
@@ -536,6 +548,11 @@ await writeViaSiblingTempPath({
536
548
  If `replaceFileAtomic` does what you need, prefer that. Use
537
549
  `writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
538
550
  the final destination still needs root-boundary checks.
551
+
552
+ The root, target, callback, fallback filename, and temp prefix are read once
553
+ before setup. Later changes to the parameters do not affect the in-flight
554
+ operation; `writeTemp` retains the original parameters object as its receiver.
555
+
539
556
  Its private workspace uses `tempFile()`'s compatible identity-aware cleanup.
540
557
  It preserves replacements observed before removal, but retains the final
541
558
  pathname-recursive-removal gap described above; this helper does not expose
package/docs/walk.md CHANGED
@@ -59,12 +59,43 @@ type WalkDirectoryOptions = {
59
59
  include?: (entry: WalkDirectoryEntry) => boolean;
60
60
  descend?: (entry: WalkDirectoryEntry) => boolean;
61
61
  };
62
+
63
+ type AsyncWalkDirectoryOptions = Omit<WalkDirectoryOptions, "include" | "descend"> & {
64
+ include?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
65
+ descend?: (entry: WalkDirectoryEntry) => boolean | Promise<boolean>;
66
+ };
62
67
  ```
63
68
 
64
69
  `symlinks` defaults to `"skip"`. `"include"` returns symlink entries without following them. `"follow"` resolves symlinks with `stat()` and may descend into linked directories, so use it only when that is intentional. Already-visited real directories are skipped so symlink cycles do not recurse forever.
65
70
 
71
+ Before descending into a child directory, `skip` and `include` recheck whether
72
+ that entry has become a symlink, including changes made while a filter waits.
73
+ The explicitly supplied walk root may still be a symlink. This best-effort
74
+ child check does not turn the standalone walker into a confinement boundary;
75
+ use `Root.walk()` when root confinement is required.
76
+
66
77
  `include` controls which entries are returned. `descend` controls which directory entries are traversed. A skipped directory can still be returned if `include` accepts it.
67
78
 
79
+ The asynchronous `walkDirectory()` accepts `AsyncWalkDirectoryOptions`. It resolves each `include` decision before calling `descend`, and resolves descent before reading the directory's children. Decisions run serially in the existing filesystem-order depth-first traversal. Both callbacks retain the supplied options object as their `this` receiver.
80
+
81
+ Absent callbacks and primitive results keep the synchronous selection path. Object and function results are awaited directly, including promises and thenables. For JavaScript callers, nullish results retain the default `true`; other resolved values use their existing truthiness. Return booleans or promises of booleans for the typed API.
82
+
83
+ ```ts
84
+ import fs from "node:fs/promises";
85
+ import path from "node:path";
86
+
87
+ const scan = await walkDirectory("/safe/workspace", {
88
+ include: (entry) => entry.kind === "file",
89
+ descend: async (entry) => {
90
+ const marked = await fs.access(path.join(entry.path, "SKILL.md"))
91
+ .then(() => true, () => false);
92
+ return !marked;
93
+ },
94
+ });
95
+ ```
96
+
97
+ This prunes a directory after finding its marker without listing that directory's children. Callback throws and promise rejections reject the walk; they are not directory failures in `failedDirs`. Filtering still consumes the examined-entry budget. `WalkDirectoryOptions` and `walkDirectorySync()` remain synchronous; the async options do not add confinement or cancellation to the standalone walker.
98
+
68
99
  Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
69
100
 
70
101
  ## Root-bounded async iteration
@@ -123,7 +154,9 @@ If a thrown walk failure and directory close both fail, disposal throws a
123
154
  `SuppressedError` with the close failure in `error` and the original failure in
124
155
  `suppressed`, preserving both causes.
125
156
 
126
- `entryFilter` is evaluated for each resolved file, directory, or other entry:
157
+ `entryFilter` is evaluated for each resolved file, directory, or other entry.
158
+ The `RootWalkEntryFilter` callback returns a `RootWalkEntryFilterResult`
159
+ or a `Promise<RootWalkEntryFilterResult>`:
127
160
 
128
161
  ```ts
129
162
  for await (const entry of capability.walk("", {
@@ -147,10 +180,43 @@ The result values are `"include"`, `"skip"`, and `"skip-subtree"`. Plain
147
180
  `"skip-subtree"` omits that directory and prunes its descendants. Returning
148
181
  `"skip-subtree"` for a non-directory is equivalent to `"skip"`.
149
182
 
183
+ An asynchronous filter can inspect a marker before deciding whether to prune:
184
+
185
+ ```ts
186
+ for await (const entry of capability.walk("", {
187
+ symlinkPolicy: "skip",
188
+ entryFilter: async (entry) => {
189
+ if (
190
+ entry.kind === "directory" &&
191
+ await capability.exists(`${entry.relativePath}/SKILL.md`)
192
+ ) {
193
+ return "skip-subtree";
194
+ }
195
+ return "include";
196
+ },
197
+ })) {
198
+ consume(entry);
199
+ }
200
+ ```
201
+
202
+ Filters run serially outside metadata batches and retain the supplied options
203
+ object as their `this` receiver. When an awaited filter resolves, the walk
204
+ checks cancellation and revalidates the current listing directory and Root
205
+ identities before using the decision. These checks do not refresh the entry's
206
+ captured metadata or pin a later operation.
207
+
208
+ Cancellation and iterator disposal wait for a pending filter to settle. The
209
+ walk does not race the callback against the abort signal or close its directory
210
+ while the callback is running; callbacks must settle their own work for
211
+ cancellation to finish. Callback throws and promise rejections reject the walk
212
+ through its normal cleanup path, even with `onDirectoryError: "skip-and-report"`.
213
+
150
214
  `onDirectoryError` defaults to `"throw"`, preserving the original fail-fast
151
215
  contract. `"skip-and-report"` yields a discriminated
152
216
  `{ kind: "directory-error", relativePath, size: 0, error }` marker for a
153
217
  directory that cannot be resolved or listed, then continues with its siblings.
218
+ This policy also applies when the directory or Root identity recheck after an
219
+ awaited filter fails; callback failures themselves are not directory errors.
154
220
  Every examined directory entry consumes `maxEntries` before filtering, so
155
221
  `"skip"` cannot turn the iterator into an unbounded traversal. Reporting and
156
222
  `"truncated"` markers describe already-reached state and do not authorize
package/docs/writing.md CHANGED
@@ -501,7 +501,11 @@ for (const file of files) await fs.write(`${stagingDir}/${file.name}`, file.body
501
501
  await fs.move(stagingDir, "snapshots/2026-05-05", { overwrite: true });
502
502
  ```
503
503
 
504
- For a true commit-or-rollback over a *directory*, use [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic).
504
+ For guarded whole-directory publication, use
505
+ [`replaceDirectoryAtomic`](atomic.md#replacedirectoryatomic). Replacing an
506
+ existing target is a two-rename protocol with a temporary target-absence
507
+ interval and conditional no-replace rollback, not a transactional
508
+ commit-or-rollback.
505
509
 
506
510
  ### Rotate logs
507
511
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -167,20 +167,20 @@
167
167
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
168
168
  },
169
169
  "optionalDependencies": {
170
- "@openclaw/fs-safe-darwin-arm64": "0.14.0",
171
- "@openclaw/fs-safe-darwin-x64": "0.14.0",
172
- "@openclaw/fs-safe-linux-arm64-gnu": "0.14.0",
173
- "@openclaw/fs-safe-linux-arm64-musl": "0.14.0",
174
- "@openclaw/fs-safe-linux-x64-gnu": "0.14.0",
175
- "@openclaw/fs-safe-linux-x64-musl": "0.14.0",
176
- "@openclaw/fs-safe-win32-x64-msvc": "0.14.0",
170
+ "@openclaw/fs-safe-darwin-arm64": "0.15.0",
171
+ "@openclaw/fs-safe-darwin-x64": "0.15.0",
172
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.15.0",
173
+ "@openclaw/fs-safe-linux-arm64-musl": "0.15.0",
174
+ "@openclaw/fs-safe-linux-x64-gnu": "0.15.0",
175
+ "@openclaw/fs-safe-linux-x64-musl": "0.15.0",
176
+ "@openclaw/fs-safe-win32-x64-msvc": "0.15.0",
177
177
  "jszip": "^3.10.2"
178
178
  },
179
179
  "devDependencies": {
180
180
  "@emnapi/runtime": "2.0.0-alpha.5",
181
- "@napi-rs/cli": "3.9.1",
181
+ "@napi-rs/cli": "3.10.0",
182
182
  "@types/node": "^26.5.1",
183
- "@vitest/coverage-v8": "5.0.0",
183
+ "@vitest/coverage-v8": "5.0.1",
184
184
  "fast-check": "^4.9.0",
185
185
  "istanbul-lib-coverage": "3.2.2",
186
186
  "istanbul-lib-report": "3.0.1",
@@ -1,4 +0,0 @@
1
- import type { NativeDarwinAclFacts } from "./native-binding.js";
2
- /** Internal descriptor facts only; callers own identity, lifetime, and ACL policy. */
3
- export declare function inspectDarwinAcl(fd: number): NativeDarwinAclFacts;
4
- //# sourceMappingURL=darwin-acl.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"darwin-acl.d.ts","sourceRoot":"","sources":["../src/darwin-acl.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAGhE,sFAAsF;AACtF,wBAAgB,gBAAgB,CAAC,EAAE,EAAE,MAAM,GAAG,oBAAoB,CAmBjE"}
@@ -1,24 +0,0 @@
1
- import { FsSafeError } from "./errors.js";
2
- import { getNativeBinding } from "./native.js";
3
- /** Internal descriptor facts only; callers own identity, lifetime, and ACL policy. */
4
- export function inspectDarwinAcl(fd) {
5
- if (!Number.isInteger(fd) || fd < 0 || fd > 0x7fff_ffff) {
6
- throw new FsSafeError("permission-unverified", "Darwin ACL inspection requires a valid descriptor");
7
- }
8
- const native = getNativeBinding();
9
- if (typeof native?.inspectDarwinAcl !== "function") {
10
- throw new FsSafeError("helper-unavailable", "Darwin ACL inspection requires the matching native capability");
11
- }
12
- let facts;
13
- try {
14
- facts = native.inspectDarwinAcl(fd);
15
- }
16
- catch (cause) {
17
- throw new FsSafeError("permission-unverified", "Darwin descriptor ACL could not be inspected", { cause });
18
- }
19
- const state = facts && typeof facts === "object" && "state" in facts ? facts.state : undefined;
20
- if (state !== "absent" && state !== "empty" && state !== "present") {
21
- throw new FsSafeError("permission-unverified", "Darwin ACL inspection returned invalid facts");
22
- }
23
- return { state };
24
- }