@openclaw/fs-safe 0.6.0 → 0.7.1

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 (202) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/README.md +7 -0
  3. package/dist/archive-deadline.d.ts +3 -0
  4. package/dist/archive-deadline.d.ts.map +1 -1
  5. package/dist/archive-deadline.js +44 -8
  6. package/dist/archive-entry.d.ts.map +1 -1
  7. package/dist/archive-entry.js +1 -0
  8. package/dist/archive-errors.d.ts +1 -0
  9. package/dist/archive-errors.d.ts.map +1 -1
  10. package/dist/archive-errors.js +3 -0
  11. package/dist/archive-input.d.ts.map +1 -1
  12. package/dist/archive-input.js +26 -16
  13. package/dist/archive-limits.d.ts +11 -3
  14. package/dist/archive-limits.d.ts.map +1 -1
  15. package/dist/archive-limits.js +24 -0
  16. package/dist/archive-native.d.ts +3 -2
  17. package/dist/archive-native.d.ts.map +1 -1
  18. package/dist/archive-native.js +24 -9
  19. package/dist/archive-policy.d.ts +2 -0
  20. package/dist/archive-policy.d.ts.map +1 -1
  21. package/dist/archive-policy.js +9 -1
  22. package/dist/archive-read.d.ts.map +1 -1
  23. package/dist/archive-read.js +69 -41
  24. package/dist/archive-staging.d.ts +3 -0
  25. package/dist/archive-staging.d.ts.map +1 -1
  26. package/dist/archive-staging.js +91 -43
  27. package/dist/archive-tar-admission.d.ts +7 -0
  28. package/dist/archive-tar-admission.d.ts.map +1 -0
  29. package/dist/archive-tar-admission.js +43 -0
  30. package/dist/archive-tar-gnu.d.ts +2 -0
  31. package/dist/archive-tar-gnu.d.ts.map +1 -0
  32. package/dist/archive-tar-gnu.js +20 -0
  33. package/dist/archive-tar-header.d.ts +8 -0
  34. package/dist/archive-tar-header.d.ts.map +1 -0
  35. package/dist/archive-tar-header.js +47 -0
  36. package/dist/archive-tar-meta.d.ts +29 -1
  37. package/dist/archive-tar-meta.d.ts.map +1 -1
  38. package/dist/archive-tar-meta.js +104 -35
  39. package/dist/archive-tar-runtime.d.ts +3 -0
  40. package/dist/archive-tar-runtime.d.ts.map +1 -1
  41. package/dist/archive-tar-runtime.js +3 -0
  42. package/dist/archive-tar.d.ts.map +1 -1
  43. package/dist/archive-tar.js +8 -2
  44. package/dist/archive-zip-names.js +2 -2
  45. package/dist/archive.d.ts.map +1 -1
  46. package/dist/archive.js +38 -27
  47. package/dist/atomic.d.ts +1 -1
  48. package/dist/atomic.d.ts.map +1 -1
  49. package/dist/bounded-read-stream.d.ts.map +1 -1
  50. package/dist/bounded-read-stream.js +2 -4
  51. package/dist/bounded-read.d.ts.map +1 -1
  52. package/dist/bounded-read.js +3 -10
  53. package/dist/byte-budget.d.ts +5 -0
  54. package/dist/byte-budget.d.ts.map +1 -0
  55. package/dist/byte-budget.js +9 -0
  56. package/dist/directory-guard.d.ts +2 -1
  57. package/dist/directory-guard.d.ts.map +1 -1
  58. package/dist/directory-guard.js +10 -0
  59. package/dist/file-lock-sync.d.ts.map +1 -1
  60. package/dist/file-lock-sync.js +81 -30
  61. package/dist/file-observation.d.ts +9 -0
  62. package/dist/file-observation.d.ts.map +1 -0
  63. package/dist/file-observation.js +22 -0
  64. package/dist/file-store-boundary.d.ts.map +1 -1
  65. package/dist/file-store-boundary.js +9 -5
  66. package/dist/file-store-limit.d.ts +2 -0
  67. package/dist/file-store-limit.d.ts.map +1 -0
  68. package/dist/file-store-limit.js +8 -0
  69. package/dist/file-store-sync-write.d.ts.map +1 -1
  70. package/dist/file-store-sync-write.js +38 -8
  71. package/dist/file-store.d.ts.map +1 -1
  72. package/dist/file-store.js +26 -26
  73. package/dist/json-durable-queue-directory.d.ts +2 -0
  74. package/dist/json-durable-queue-directory.d.ts.map +1 -0
  75. package/dist/json-durable-queue-directory.js +20 -0
  76. package/dist/json-durable-queue-ownership.d.ts +14 -0
  77. package/dist/json-durable-queue-ownership.d.ts.map +1 -0
  78. package/dist/json-durable-queue-ownership.js +168 -0
  79. package/dist/json-durable-queue-retirement.d.ts +9 -0
  80. package/dist/json-durable-queue-retirement.d.ts.map +1 -0
  81. package/dist/json-durable-queue-retirement.js +126 -0
  82. package/dist/json-durable-queue-transfer-lock.d.ts +2 -0
  83. package/dist/json-durable-queue-transfer-lock.d.ts.map +1 -0
  84. package/dist/json-durable-queue-transfer-lock.js +19 -0
  85. package/dist/json-durable-queue.d.ts +1 -0
  86. package/dist/json-durable-queue.d.ts.map +1 -1
  87. package/dist/json-durable-queue.js +90 -57
  88. package/dist/json.d.ts.map +1 -1
  89. package/dist/json.js +32 -13
  90. package/dist/local-roots.d.ts.map +1 -1
  91. package/dist/local-roots.js +4 -2
  92. package/dist/native-binding.d.ts +12 -3
  93. package/dist/native-binding.d.ts.map +1 -1
  94. package/dist/native-pinned-write-windows.js +1 -1
  95. package/dist/opened-file-failure.d.ts +7 -0
  96. package/dist/opened-file-failure.d.ts.map +1 -0
  97. package/dist/opened-file-failure.js +41 -0
  98. package/dist/opened-realpath.d.ts.map +1 -1
  99. package/dist/opened-realpath.js +8 -2
  100. package/dist/output-sibling.d.ts.map +1 -1
  101. package/dist/output-sibling.js +11 -110
  102. package/dist/output.d.ts.map +1 -1
  103. package/dist/output.js +4 -2
  104. package/dist/pinned-open.d.ts.map +1 -1
  105. package/dist/pinned-open.js +27 -48
  106. package/dist/pinned-write.d.ts.map +1 -1
  107. package/dist/pinned-write.js +10 -10
  108. package/dist/private-temp-workspace.d.ts +3 -1
  109. package/dist/private-temp-workspace.d.ts.map +1 -1
  110. package/dist/private-temp-workspace.js +81 -56
  111. package/dist/publish-file.d.ts.map +1 -1
  112. package/dist/publish-file.js +2 -4
  113. package/dist/read-opened-file.d.ts.map +1 -1
  114. package/dist/read-opened-file.js +6 -4
  115. package/dist/regular-file.d.ts.map +1 -1
  116. package/dist/regular-file.js +136 -82
  117. package/dist/replace-file-copy-fallback.d.ts +3 -1
  118. package/dist/replace-file-copy-fallback.d.ts.map +1 -1
  119. package/dist/replace-file-copy-fallback.js +21 -36
  120. package/dist/replace-file-copy-source.d.ts +21 -0
  121. package/dist/replace-file-copy-source.d.ts.map +1 -0
  122. package/dist/replace-file-copy-source.js +112 -0
  123. package/dist/replace-file-descriptor.d.ts +13 -3
  124. package/dist/replace-file-descriptor.d.ts.map +1 -1
  125. package/dist/replace-file-descriptor.js +32 -7
  126. package/dist/replace-file-rename-policy.d.ts +7 -0
  127. package/dist/replace-file-rename-policy.d.ts.map +1 -0
  128. package/dist/replace-file-rename-policy.js +30 -0
  129. package/dist/replace-file-temp-owner.d.ts +46 -0
  130. package/dist/replace-file-temp-owner.d.ts.map +1 -0
  131. package/dist/replace-file-temp-owner.js +346 -0
  132. package/dist/replace-file.d.ts +6 -1
  133. package/dist/replace-file.d.ts.map +1 -1
  134. package/dist/replace-file.js +72 -58
  135. package/dist/root-impl.d.ts.map +1 -1
  136. package/dist/root-impl.js +113 -82
  137. package/dist/secret-file.d.ts.map +1 -1
  138. package/dist/secret-file.js +6 -3
  139. package/dist/secret-read-async.d.ts.map +1 -1
  140. package/dist/secret-read-async.js +6 -5
  141. package/dist/secure-file.d.ts.map +1 -1
  142. package/dist/secure-file.js +17 -13
  143. package/dist/sibling-staged-file.d.ts +15 -0
  144. package/dist/sibling-staged-file.d.ts.map +1 -0
  145. package/dist/sibling-staged-file.js +148 -0
  146. package/dist/sibling-temp.d.ts +3 -0
  147. package/dist/sibling-temp.d.ts.map +1 -1
  148. package/dist/sibling-temp.js +30 -74
  149. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  150. package/dist/sidecar-lock-acquire.js +91 -37
  151. package/dist/sidecar-lock-handle.d.ts +6 -2
  152. package/dist/sidecar-lock-handle.d.ts.map +1 -1
  153. package/dist/sidecar-lock-handle.js +17 -3
  154. package/dist/sidecar-lock-policy.d.ts +2 -0
  155. package/dist/sidecar-lock-policy.d.ts.map +1 -1
  156. package/dist/sidecar-lock-policy.js +29 -0
  157. package/dist/sidecar-lock-reclaim.d.ts +16 -5
  158. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  159. package/dist/sidecar-lock-reclaim.js +60 -28
  160. package/dist/sidecar-lock-root.d.ts +3 -0
  161. package/dist/sidecar-lock-root.d.ts.map +1 -0
  162. package/dist/sidecar-lock-root.js +76 -0
  163. package/dist/sidecar-lock.d.ts.map +1 -1
  164. package/dist/sidecar-lock.js +28 -16
  165. package/dist/strict-file-identity.d.ts.map +1 -1
  166. package/dist/strict-file-identity.js +4 -1
  167. package/dist/suppressed-error.d.ts +6 -0
  168. package/dist/suppressed-error.d.ts.map +1 -0
  169. package/dist/suppressed-error.js +15 -0
  170. package/dist/temp-cleanup.d.ts +2 -0
  171. package/dist/temp-cleanup.d.ts.map +1 -1
  172. package/dist/temp-cleanup.js +25 -10
  173. package/dist/temp-workspace-owner.d.ts +23 -0
  174. package/dist/temp-workspace-owner.d.ts.map +1 -0
  175. package/dist/temp-workspace-owner.js +320 -0
  176. package/dist/temp.d.ts +1 -1
  177. package/dist/temp.d.ts.map +1 -1
  178. package/dist/test-hooks.d.ts +5 -0
  179. package/dist/test-hooks.d.ts.map +1 -1
  180. package/docs/advanced.md +8 -1
  181. package/docs/archive.md +202 -26
  182. package/docs/atomic.md +11 -2
  183. package/docs/durability.md +1 -1
  184. package/docs/file-store.md +2 -0
  185. package/docs/install.md +8 -4
  186. package/docs/json.md +11 -4
  187. package/docs/native-helper.md +15 -3
  188. package/docs/native.md +33 -6
  189. package/docs/output.md +16 -3
  190. package/docs/public-api.md +6 -3
  191. package/docs/quickstart.md +1 -1
  192. package/docs/reading.md +1 -1
  193. package/docs/regular-file.md +9 -2
  194. package/docs/root.md +2 -0
  195. package/docs/secret-file.md +5 -1
  196. package/docs/secure-file.md +4 -2
  197. package/docs/security-model.md +18 -0
  198. package/docs/sidecar-lock.md +60 -4
  199. package/docs/store.md +13 -0
  200. package/docs/temp.md +128 -14
  201. package/docs/writing.md +6 -0
  202. package/package.json +10 -10
package/docs/root.md CHANGED
@@ -38,6 +38,8 @@ type DenyMutationPolicy = {
38
38
 
39
39
  `defaults` apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only, except `denyMutations`: root and per-call deny entries are merged so a call cannot clear a root-level deny.
40
40
 
41
+ Every `maxBytes` value must be a non-negative safe integer or positive `Infinity`. Zero is an active zero-byte cap; `Infinity` explicitly disables the cap. An omitted or explicitly `undefined` per-call value preserves the configured Root default instead of clearing it.
42
+
41
43
  ## The `Root` interface
42
44
 
43
45
  Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.
@@ -73,6 +73,8 @@ type SecretFileReadOptions = {
73
73
  };
74
74
  ```
75
75
 
76
+ `maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap, `Infinity` disables it, and omitted or explicitly `undefined` values preserve the 16 KiB default.
77
+
76
78
  The reader trims the file content and rejects empty results. Symlink paths are
77
79
  followed and pinned by default; set `rejectSymlink: true` when the pathname
78
80
  itself must not be an alias. Hardlinks are rejected by default so another
@@ -90,7 +92,9 @@ the same pinned-handle validation, byte cap, trimming, error codes, and strict
90
92
  versus missing-is-undefined naming semantics.
91
93
 
92
94
  Both sync and async readers compare lossless bigint identities from the preview,
93
- opened descriptor, resolved target, and current input path before reading. An
95
+ opened descriptor, resolved target, and current input path before reading. POSIX
96
+ opens are nonblocking, so a raced FIFO is rejected by descriptor type instead of
97
+ waiting for a writer. An
94
98
  allowed symlink must still point to the opened file. On Windows, a zero device
95
99
  or inode is unverified: that inspection is retried once without reopening the
96
100
  file, preserving known identity components and link checks. Definite mismatches
@@ -18,8 +18,8 @@ const { buffer, realPath, permissions } = await readSecureFile({
18
18
  The helper:
19
19
 
20
20
  - requires a local absolute path and rejects UNC/network paths by default
21
- - rejects directories and, by default, symlink paths
22
- - opens the file before reading and verifies the opened fd still matches the path and realpath
21
+ - rejects every non-regular preview and, by default, symlink paths
22
+ - opens POSIX paths no-follow and nonblocking before reading, then verifies the opened fd still matches the path and realpath; a FIFO swap cannot block before `timeoutMs` owns the byte read
23
23
  - optionally requires the real path to live under one of `trust.trustedDirs`
24
24
  - rejects hard-to-verify or unsafe permissions unless `permissions.allowInsecure` is set
25
25
  - rejects files owned by another POSIX uid
@@ -57,6 +57,8 @@ type SecureFileReadOptions = {
57
57
  };
58
58
  ```
59
59
 
60
+ `io.maxBytes` must be a non-negative safe integer or positive `Infinity`; zero is an active cap and `Infinity` disables the cap. Invalid limits reject before filesystem admission.
61
+
60
62
  `permissions.allowInsecure` is a migration escape hatch. Prefer fixing permissions and using [`formatPermissionRemediation`](permissions.md) to show the user what to run. `trust.allowNetworkPath` is off by default because UNC paths are remote authority, not local filesystem input. `inject` is for tests and platform adapters; production callers usually leave it unset.
61
63
 
62
64
  `permissions.allowInsecure` bypasses only permission checks. Neither it nor `inject.platform` changes filesystem identity verification, which always uses the actual process platform. `trust.allowSymlink` permits an alias but still requires its target and realpath to match the opened descriptor.
@@ -58,6 +58,9 @@ descriptor, the input path, and the canonical target; numeric public `Stats`
58
58
  receipts are not used as identity evidence. Unknown Windows device/inode values
59
59
  receive one re-inspection without reopening the file. A definite mismatch or
60
60
  persistent unknown identity rejects with `path-mismatch` before reading bytes.
61
+ Regular-file readers, root-file adapters, and archive input staging use the same
62
+ exact admission policy. `copyIn()` retains the admitted source identity for its
63
+ checks before and after copying, independently of its numeric metadata receipt.
61
64
 
62
65
  ### Symlinks (write side)
63
66
 
@@ -121,6 +124,21 @@ durability guarantee.
121
124
 
122
125
  `extractArchive` first stages into a private temp directory (mode 0700) outside the destination, validates each entry path against `..` and absolute prefixes, refuses link-type entries by default, enforces entry count and byte budgets, and only then merges the staged tree into the destination through the same boundary checks used by direct writes.
123
126
 
127
+ TAR extraction and bounded reads validate the complete decompressed raw framing
128
+ before parser normalization: directory/link sizes must be zero, EOF must contain
129
+ two consecutive zero blocks, and only zero padding may follow EOF. This rejects
130
+ hidden headers and body/header smuggling consistently across the JavaScript and
131
+ native backends. The meter checks logical member count before consuming bodies,
132
+ even for unrequested or filtered members. Per-entry/cumulative payload budgets
133
+ apply only to entries accepted after strip/filter policy, outside the raw meter.
134
+ A separate absolute decoded cap includes every member body, headers, metadata,
135
+ all padding, and EOF, closing cumulative-metadata and zero-tail bypasses. Native
136
+ extraction/read passes drain through physical EOF before publication or
137
+ returning selected bytes, even when the TAR parser stops earlier. Bounded
138
+ reads use default archive admission limits and keep `maxBytes` scoped to the
139
+ requested output. Metadata retains its per-record bound. See [archive framing](archive.md#raw-tar-framing) for supported
140
+ metadata and compression formats.
141
+
124
142
  ## What "library-level" means
125
143
 
126
144
  A library cannot revoke its own caller's authority. If your code chooses to bypass `fs-safe` and call `fs.writeFile` directly with the same path, you bypass the defenses too. The contract `fs-safe` enforces is: *every filesystem operation that touches caller-controlled input goes through the boundary*. That contract is yours to keep.
@@ -27,6 +27,8 @@ Each new sidecar also carries an internal random ownership token encoded as JSON
27
27
 
28
28
  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.
29
29
 
30
+ `release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
31
+
30
32
  ## API
31
33
 
32
34
  ```ts
@@ -92,15 +94,21 @@ type FileLockRetryOptions = {
92
94
  ```
93
95
 
94
96
  `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
97
+ Errors thrown by `payload`, its JSON serialization (including `toJSON`), or
98
+ `parsePayload` propagate unchanged without retrying the callback. Rethrowing an
99
+ error saved from an earlier filesystem operation does not grant retry authority.
100
+ Retry counts must be non-negative safe integers. Retry factors and delays must be finite and non-negative, and when both delay bounds are provided `minTimeout` cannot exceed `maxTimeout`. `timeoutMs` accepts a finite non-negative deadline or positive infinity for an unbounded wait; invalid numeric values reject before filesystem acquisition starts.
95
101
  `parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
96
102
  result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
97
103
  process-start, argv, or role schemas to remain application-owned.
98
104
 
99
105
  On Windows, a pathed `EPERM` from creating or opening the lock file can be a
100
- short teardown race after another holder unlinks it. The async lock retries that
101
- specific denial at most eight times. A parent-directory denial, a denial from a
102
- callback, or a ninth consecutive lock-file denial surfaces as the original
103
- `EPERM`; it is not converted to `file_lock_timeout`.
106
+ short teardown race after another holder unlinks it. Both async and sync locks
107
+ retry that specific open denial at most eight times per acquisition, within the
108
+ caller's retry/deadline budget. A parent-directory denial, a callback/read/stat
109
+ failure, or exhaustion of either budget surfaces the original error; a denied
110
+ open is not converted to `file_lock_timeout`. Retrying always requires fresh
111
+ exclusive creation and grants no ownership or removal authority.
104
112
 
105
113
  ## Owner-scoped reentrancy
106
114
 
@@ -152,6 +160,45 @@ an existing `Root` capability. `lockPath` must resolve inside that root.
152
160
  Identity-conditioned removal remains the only release and reclaim deletion
153
161
  path.
154
162
 
163
+ An owner can finish releasing while another async acquirer inspects its record.
164
+ Create-only Root writes do not open an existing record merely to inherit its
165
+ mode. Once a pathname sample and opened descriptor agree, a failed acquisition
166
+ snapshot can be discarded only when the original descriptor has exact identity,
167
+ was not observed with multiple links, and proves it was unlinked (`nlink === 0`).
168
+ This includes Windows resolver
169
+ `EPERM`/`EBADF` failures, with evidence captured at the failing operation before
170
+ closing the descriptor. The canonical in-root ancestor chain and Root are
171
+ rechecked; permitted in-root parent symlinks are resolved before those checks.
172
+
173
+ A contending waiter may also encounter a new holder between its pre-open
174
+ pathname inspection and opening the file. It may discard that stale observation
175
+ only when the old sample and opened descriptor have different, strictly known
176
+ regular-file identities, neither was observed with multiple links, and the
177
+ opened descriptor and complete canonical ancestry pass reinspection. This does
178
+ not prove the old pathname sample was unlinked rather than moved. The new
179
+ holder's payload is not read or adopted. Post-create admission never opts into
180
+ this pre-open-change policy.
181
+
182
+ Discarding an acquisition observation is not proof that the pathname is absent:
183
+ another owner may already have created the next record. Every discarded
184
+ observation consumes the normal retry/deadline budget and requires fresh
185
+ exclusive creation. It supplies no release, reclaim, or held-lock authority.
186
+ Generic `Root.open()` and held-owner/reclaim reads still reject failed opens.
187
+ Moving an already-matched pinned descriptor without unlinking it, unknown or
188
+ inexact identities, retargeted ancestors, and unrelated filesystem or caller
189
+ errors fail closed. Failure receipts belong only to the current Root observation,
190
+ including during nested or concurrent acquisitions; historical error identity
191
+ is not changed-file, unlink, or open-denial evidence.
192
+
193
+ After creating a record, the async Root-backed acquirer checks the reopened
194
+ bytes against its exact serialized payload and ownership token. A replacement
195
+ is never adopted; a descriptor observed unlinked at the end of admission is
196
+ never registered as held. Failed admission cleanup retains the original creator
197
+ receipt, so it cannot remove a replacement using a later stat alone. Native
198
+ mode changes the create mechanism, not these Root-backed admission checks.
199
+ Non-Root and synchronous snapshots retain their descriptor/read/path checks
200
+ and do not use the Root opened-path resolver.
201
+
155
202
  ## Release handle
156
203
 
157
204
  ```ts
@@ -181,7 +228,16 @@ compromise interval treats a thrown verification I/O error as a lost lock and
181
228
  invokes `onCompromised` once, matching the asynchronous `.catch(() => false)`
182
229
  contract. An explicit `verifyStillHeld()` call still propagates that I/O error.
183
230
 
231
+ Windows synchronous lock parents use the same canonical path spelling as
232
+ `root()`, including short-name expansion. With `lockRoot`, a failed parent
233
+ canonicalization or an out-of-root parent still rejects. Missing-path observations from snapshot
234
+ `lstat`/`open`, and identity-mismatched snapshots, consume the normal retry and
235
+ deadline budget. Errors from descriptor reads/stats or parsing are not treated
236
+ as missing snapshots, even when their code is `ENOENT`. Held verification,
237
+ release, and reclaim do not retry open denials.
238
+
184
239
  Both synchronous helpers consume the [process-wide lock defaults](config.md#configurefssafelocks-config).
240
+ 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.
185
241
  Per-call options take precedence, including zero values; a per-call `retry`
186
242
  object replaces the entire configured retry object. A configured
187
243
  `staleRecovery: "remove-if-unchanged"` still needs per-call
package/docs/store.md CHANGED
@@ -68,6 +68,19 @@ Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
68
68
  `moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
69
69
  entry for inspection.
70
70
 
71
+ Loading serializes consumers for one ID through a sidecar lock, then creates `processingPath` with a no-replace hardlink and retires the source through a recoverable sibling record. Whichever producer generation occupies `.json` at the hardlink operation becomes the claim; a replacement published afterward remains pending. After a crash, `.processing` is retried before that pending replacement. Transfer locks are fail-closed and are never reclaimed automatically, so an operator must remove a proven-stale lock after a hard process crash. Acknowledgement atomically moves `.processing` through the short-lived `.delivered` marker, and existing delivered markers are cleaned during batch loading. `ackJsonDurableQueueEntry()` rejects while `.json` exists without a processing claim: callers that previously paired direct `readJsonDurableQueueEntry()` with acknowledgement must load through `loadJsonDurableQueueEntry()` before processing so acknowledgement is generation-bound.
72
+
73
+ Queue and failed directory creation fsyncs every newly-created parent edge from the leaf toward the trusted root. Enqueue and migration writes fsync the temp file and parent; claim, acknowledgement, quarantine, delivered-marker cleanup, and retirement transitions fsync every affected directory and propagate real sync failures. A transition may already be visible when a post-mutation sync fails, so retry the same operation to complete its crash-recovery state; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
74
+
75
+ Failed destinations are create-only. Quarantine publishes the claimed file by hardlink, so the queue and failed directories must share a filesystem with hardlink support. If `failed/<id>.json` already exists, quarantine rejects while preserving both that earlier evidence and the current claimed entry instead of overwriting either file. The `read` callback continues to receive the logical `.json` path even though bytes are read and migrations are written through the claimed path.
76
+
77
+ Queue entry reads verify lossless file identities before opening, on the opened
78
+ descriptor, and at the current pathname before reading bytes. POSIX opens are
79
+ nonblocking, so a raced FIFO is rejected rather than stalling a consumer. On Windows, an
80
+ unknown identity gets one bounded reinspection; persistent ambiguity or a
81
+ mismatch rejects with `queue entry changed during read`. Each inspection still
82
+ rejects non-files, symlinks, hardlinks, and entries over the byte limit.
83
+
71
84
  ## Related pages
72
85
 
73
86
  - [`fileStore`](file-store.md) — full API for the multi-file store.
package/docs/temp.md CHANGED
@@ -14,7 +14,7 @@ import {
14
14
 
15
15
  ## Private temp workspaces
16
16
 
17
- A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix) and cleaned up when you call `cleanup()` or leave an `await using` scope.
17
+ A private workspace is a directory created at mode `0o700` under a caller-provided temp root. It is unique per call (random suffix). Calling `cleanup()` or leaving an `await using` scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
18
18
 
19
19
  ### `tempWorkspace`
20
20
 
@@ -31,7 +31,7 @@ type TempWorkspace = {
31
31
  writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
32
32
  copyIn(fileName: string, sourcePath: string): Promise<string>;
33
33
  read(fileName: string): Promise<Buffer>;
34
- cleanup(): Promise<"removed" | "missing" | "identity-mismatch">;
34
+ cleanup(): Promise<"removed" | "missing" | "identity-mismatch" | "indeterminate">;
35
35
  [Symbol.asyncDispose](): Promise<void>;
36
36
  };
37
37
  ```
@@ -64,12 +64,66 @@ await state.write({ ready: true });
64
64
  The workspace owns cleanup; the store is only a view over the workspace
65
65
  directory.
66
66
 
67
- The identity receipt is captured when the workspace is created. Manual,
68
- disposal, and process-exit cleanup remove the path only while `lstat` still
69
- matches that receipt. If another actor renames the workspace away and places a
70
- new directory at the old name, cleanup returns `"identity-mismatch"` and leaves
71
- the replacement untouched. Disposal hooks perform the same check and ignore
72
- the returned status.
67
+ **Compatibility and security:** workspace creation remains available in native
68
+ `auto`, `off`, and unavailable-native environments. The default
69
+ `cleanupSafety: "compatible"` preserves the JavaScript cleanup behavior from
70
+ 0.6: it verifies the workspace identity, moves the public name to a fresh
71
+ `.fs-safe-workspace-cleanup-<uuid>` sibling, verifies that quarantine, and then
72
+ uses guarded pathname-recursive removal. This fallback never recursively
73
+ removes the public workspace name, but it is not atomic conditional deletion: a
74
+ same-privilege peer that discovers and replaces the private quarantine after
75
+ verification can still redirect the final pathname removal.
76
+
77
+ Set `cleanupSafety: "require-bounded"` when that concurrent attacker is in scope.
78
+ Creation then requires native no-replace directory rename, native owned-tree
79
+ removal, and a retained parent descriptor **before** `mkdtemp` creates
80
+ a child. If any capability is unavailable, creation throws
81
+ `FsSafeError("helper-unavailable")`; no child is created and a scoped callback is
82
+ not called. The compatible default retains its fallback even if process-global
83
+ native mode is `require`; select `require-bounded` to make cleanup capability
84
+ mandatory for this API.
85
+
86
+ On Linux, bounded cleanup requires a successful runtime probe of the exact
87
+ `openat2` child-directory flags, including `RESOLVE_NO_XDEV`, against the retained
88
+ parent descriptor. If the kernel or seccomp policy denies that capability,
89
+ compatible mode uses the guarded JavaScript fallback; `require-bounded` rejects
90
+ before child creation. The probe runs once at creation, without filesystem mutation.
91
+
92
+ Bounded cleanup checks the parent and public workspace identity, quarantines
93
+ the direct child without replacement, and verifies the quarantine against the
94
+ retained workspace descriptor. It binds every enumerated child to its native
95
+ identity before opening it, rejects mount crossings, and traverses descendants
96
+ only through opened directory handles; symlinks/reparse entries are removed as
97
+ leaves and never traversed. Windows marks the exact opened objects for deletion by handle.
98
+
99
+ POSIX has no unlink-by-fd or expected-inode unlink for directory entries. After
100
+ the final identity check, each `unlinkat` can still be raced; the possible side
101
+ effect is bounded to one substituted non-directory leaf or one empty directory
102
+ entry per raced syscall. A substituted nonempty directory is never recursively
103
+ traversed and is preserved as `"indeterminate"`, but a leaf replacement removed
104
+ in that irreducible final gap cannot be distinguished after the syscall.
105
+
106
+ The workspace captures its identity, binding, and descriptors until cleanup.
107
+ Later process-global mode changes or loader resets do not revoke that authority.
108
+ Manual, disposal, and process-exit cleanup share one serialized owner,
109
+ registered before store construction; a construction failure after registration
110
+ remains exit-cleanable. Earlier creation failures close retained descriptors
111
+ without deleting an unverified child.
112
+
113
+ If the quarantine does not match the creation descriptor, cleanup leaves it in
114
+ place without restoring the public name or recursively deleting it and returns
115
+ `"indeterminate"`. A collision, uncertain rename outcome, changed parent,
116
+ mount/device crossing, changed reparse state, or detected concurrent mutation also
117
+ preserves the remaining artifact. Recover `.fs-safe-workspace-cleanup-<uuid>` entries only
118
+ after excluding competing mutators and re-establishing ownership.
119
+
120
+ A missing workspace returns `"missing"`. A replacement observed at the public
121
+ name before quarantine returns `"identity-mismatch"` when the parent is stable;
122
+ an ambiguous parent returns `"indeterminate"`. After successful removal,
123
+ repeated cleanup returns `"missing"` without touching a recreated public name.
124
+ Other statuses remain stable. Operational removal errors propagate and later
125
+ cleanup returns `"indeterminate"` without retrying. Disposal and scoped helpers
126
+ ignore returned statuses, while manual cleanup exposes the result.
73
127
 
74
128
  When cleanup is part of a retention or audit decision, inspect the receipt
75
129
  instead of treating cleanup as fire-and-forget:
@@ -82,6 +136,8 @@ try {
82
136
  const cleanup = await workspace.cleanup();
83
137
  if (cleanup === "identity-mismatch") {
84
138
  alertOperator("restore workspace path was replaced; replacement preserved");
139
+ } else if (cleanup === "indeterminate") {
140
+ alertOperator("restore workspace cleanup could not establish safe completion; inspect retained entries");
85
141
  }
86
142
  }
87
143
  ```
@@ -91,7 +147,7 @@ types and a `FileStoreSync` at `workspace.store`.
91
147
 
92
148
  ### `withTempWorkspace`
93
149
 
94
- The recommended shape. Auto-cleanup on every exit path:
150
+ The recommended shape. Attempts cleanup on every exit path:
95
151
 
96
152
  ```ts
97
153
  import { withTempWorkspace } from "@openclaw/fs-safe/temp";
@@ -129,6 +185,7 @@ type TempWorkspaceOptions = {
129
185
  prefix: string; // dir prefix (sanitized)
130
186
  dirMode?: number; // dir mode; default 0o700
131
187
  mode?: number; // file write mode; default 0o600
188
+ cleanupSafety?: "compatible" | "require-bounded"; // default compatible
132
189
  };
133
190
  ```
134
191
 
@@ -205,11 +262,65 @@ const result = await writeSiblingTempFile<string>({
205
262
  // result.filePath, result.result (returned by writeTemp)
206
263
  ```
207
264
 
208
- `writeSiblingTempFile` chooses a random sibling name in `dir`, calls your `writeTemp()` callback, validates that `resolveFinalPath(result)` is still inside that same directory, and renames the temp file there.
209
-
210
- By default it preserves the historical private-helper behavior of chmodding
211
- `dir` to `dirMode` (default `0o700`). Pass `chmodDir: false` when the directory
212
- is a public staging/output path whose existing mode must be preserved.
265
+ `writeSiblingTempFile` chooses a random, initially absent sibling name in `dir`
266
+ and calls `writeTemp()`. After the callback succeeds, it validates the produced
267
+ regular file before taking ownership: symlinks, directories, other non-regular
268
+ files, hardlinks, and changes between the pre-open pathname, opened descriptor,
269
+ and current pathname are rejected. The callback must finish and close its
270
+ writer before returning. Its return value is preserved as `result`.
271
+
272
+ The helper retains one descriptor through requested mode application, opt-in
273
+ file synchronization, rename, and publication verification. It opens read-only
274
+ unless file synchronization is requested, so closed read-only producer output
275
+ remains publishable under the historical default. Omitting
276
+ `mode` preserves the callback-produced mode without chmod; explicit modes,
277
+ including `0`, are applied through that descriptor. File-mode errors are
278
+ tolerated for compatibility with the helper's historical best-effort behavior.
279
+ No chmod, content read, or reopen follows
280
+ the staged or published pathname. `resolveFinalPath(result)` must resolve to a
281
+ distinct direct child of the same directory. Final-path writes are serialized
282
+ within the process, and the retained descriptor and current name must still
283
+ have the admitted exact bigint identity and exactly one link before rename and
284
+ after publication. A verification failure after rename does not roll back or
285
+ delete the final name.
286
+
287
+ `syncTempFile` and `syncParentDir` retain their historical `false` defaults.
288
+ Explicit `syncTempFile: true` synchronizes the descriptor before rename;
289
+ file-sync errors propagate except for the existing `EPERM` compatibility case.
290
+ Explicit `syncParentDir: true` requests best-effort parent sync after rename.
291
+ Omitting either option or passing `false` skips that sync, never the identity
292
+ checks. Parent synchronization can be unsupported or fail without rejecting
293
+ the write, so success is not a strict crash-durability receipt.
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,
297
+ including during process-exit cleanup. Operational cleanup failures retain an
298
+ identity-bound exit retry. If the callback throws or admission fails, no file
299
+ has been adopted: even a regular partial file is left for caller-directed
300
+ recovery. The helper never recursively removes a sibling temp.
301
+
302
+ On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap
303
+ does not block the helper. Windows retains Node's guarded pathname-open behavior
304
+ because Node has no portable no-follow flag there; metadata is checked before
305
+ and after opening, and unknown Windows identities fail closed after one bounded
306
+ re-inspection without reopening. These helpers remain available with native
307
+ mode `off`; they do not acquire the native-required retained-directory contract
308
+ of [`stageFileInDirectory`](staged-file.md).
309
+
310
+ Identity checks and pathname rename/unlink are separate syscalls, not atomic
311
+ conditional mutations. A hostile process can still replace a leaf or parent in
312
+ the final syscall gap or mutate an open file's contents. Use an approved writable
313
+ directory and cooperative locking or OS isolation; a moved parent can leave an
314
+ unpublished original temp behind. Observed replacements are preserved, but
315
+ arbitrary concurrent namespace changes cannot be prevented by these helpers.
316
+
317
+ By default the helper attempts to set `dir` to `dirMode` (default `0o700`)
318
+ through the shared verified POSIX directory-descriptor helper. Only the actual
319
+ descriptor chmod error is tolerated, preserving the historical best-effort
320
+ directory-mode behavior. Directory lstat, open, type, identity, and close errors
321
+ still propagate; there is no pathname chmod fallback. Windows only passes the
322
+ directory mode to `mkdir`. Pass
323
+ `chmodDir: false` when an existing staging/output directory mode must be preserved.
213
324
 
214
325
  ### `writeViaSiblingTempPath`
215
326
 
@@ -232,6 +343,9 @@ await writeViaSiblingTempPath({
232
343
  If `replaceFileAtomic` does what you need, prefer that. Use
233
344
  `writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
234
345
  the final destination still needs root-boundary checks.
346
+ Its private workspace uses the same identity-aware directory cleanup as
347
+ `tempFile()`: moving and replacing the workspace preserves the replacement.
348
+ This workspace owns its contents, unlike the unadmitted sibling pathname above.
235
349
 
236
350
  ## Secure temp root
237
351
 
package/docs/writing.md CHANGED
@@ -27,6 +27,9 @@ await fs.mkdir("snapshots/2026/05");
27
27
  5. Atomically rename the temp file over the destination.
28
28
  6. Stat the resulting fd and verify identity.
29
29
 
30
+ Private sibling temporary names are independent of the destination basename,
31
+ so staging does not add a suffix to an otherwise valid long filename.
32
+
30
33
  A failure before the final rename leaves the destination at its previous
31
34
  contents. A successful rename publishes the complete replacement. This
32
35
  old-or-new guarantee does not apply to `append()` or `openWritable()`, which
@@ -86,6 +89,9 @@ alone is never proof that the name still refers to the expected file.
86
89
  ### `fs.create(rel, data, options?)`
87
90
 
88
91
  Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
92
+ Create-only preflight preserves boundary, alias, hardlink, and type checks without
93
+ opening an existing target to inherit its mode; a fresh file uses the requested
94
+ mode or the normal new-file default.
89
95
 
90
96
  ```ts
91
97
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -128,7 +128,7 @@
128
128
  "prepack": "node scripts/prepack-build.mjs",
129
129
  "test": "vitest run",
130
130
  "test:coverage": "vitest run --coverage",
131
- "test:coverage:collect": "vitest run --coverage --coverage.reporter=json --coverage.thresholds.lines=0 --coverage.thresholds.functions=0 --coverage.thresholds.statements=0 --coverage.thresholds.branches=0",
131
+ "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",
132
132
  "test:coverage:merge": "node scripts/merge-coverage.mjs",
133
133
  "test:security": "vitest run test/fs-safe.test.ts test/read-boundary-bypass.test.ts test/write-boundary-bypass.test.ts test/additional-boundary-bypass.test.ts test/adversarial-boundary-payloads.test.ts",
134
134
  "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm docs:check && pnpm test && node scripts/check-pack.mjs",
@@ -149,20 +149,20 @@
149
149
  "crabbox:warmup": "crabbox warmup"
150
150
  },
151
151
  "optionalDependencies": {
152
- "@openclaw/fs-safe-darwin-arm64": "0.6.0",
153
- "@openclaw/fs-safe-darwin-x64": "0.6.0",
154
- "@openclaw/fs-safe-linux-arm64-gnu": "0.6.0",
155
- "@openclaw/fs-safe-linux-arm64-musl": "0.6.0",
156
- "@openclaw/fs-safe-linux-x64-gnu": "0.6.0",
157
- "@openclaw/fs-safe-linux-x64-musl": "0.6.0",
158
- "@openclaw/fs-safe-win32-x64-msvc": "0.6.0",
152
+ "@openclaw/fs-safe-darwin-arm64": "0.7.1",
153
+ "@openclaw/fs-safe-darwin-x64": "0.7.1",
154
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.7.1",
155
+ "@openclaw/fs-safe-linux-arm64-musl": "0.7.1",
156
+ "@openclaw/fs-safe-linux-x64-gnu": "0.7.1",
157
+ "@openclaw/fs-safe-linux-x64-musl": "0.7.1",
158
+ "@openclaw/fs-safe-win32-x64-msvc": "0.7.1",
159
159
  "jszip": "^3.10.1",
160
160
  "tar": "7.5.22"
161
161
  },
162
162
  "devDependencies": {
163
163
  "@emnapi/runtime": "2.0.0-alpha.4",
164
164
  "@napi-rs/cli": "3.8.6",
165
- "@types/node": "^26.3.0",
165
+ "@types/node": "^26.4.0",
166
166
  "@vitest/coverage-v8": "4.1.11",
167
167
  "fast-check": "^4.9.0",
168
168
  "istanbul-lib-coverage": "3.2.2",