@openclaw/fs-safe 0.7.1 → 0.8.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 (157) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/README.md +5 -1
  3. package/dist/absolute-path.d.ts.map +1 -1
  4. package/dist/absolute-path.js +9 -10
  5. package/dist/archive-merge.d.ts +18 -0
  6. package/dist/archive-merge.d.ts.map +1 -0
  7. package/dist/archive-merge.js +157 -0
  8. package/dist/archive-native.d.ts.map +1 -1
  9. package/dist/archive-native.js +25 -5
  10. package/dist/archive-policy.d.ts +1 -1
  11. package/dist/archive-policy.d.ts.map +1 -1
  12. package/dist/archive-policy.js +1 -1
  13. package/dist/archive-read.d.ts.map +1 -1
  14. package/dist/archive-read.js +3 -2
  15. package/dist/archive-staging.d.ts +13 -8
  16. package/dist/archive-staging.d.ts.map +1 -1
  17. package/dist/archive-staging.js +44 -192
  18. package/dist/archive-tar-meta.d.ts.map +1 -1
  19. package/dist/archive-tar-meta.js +9 -5
  20. package/dist/archive-tar-runtime.d.ts +1 -0
  21. package/dist/archive-tar-runtime.d.ts.map +1 -1
  22. package/dist/archive-zip-admission.d.ts +2 -1
  23. package/dist/archive-zip-admission.d.ts.map +1 -1
  24. package/dist/archive-zip-admission.js +2 -2
  25. package/dist/archive-zip-directory.d.ts +8 -1
  26. package/dist/archive-zip-directory.d.ts.map +1 -1
  27. package/dist/archive-zip-directory.js +6 -2
  28. package/dist/archive-zip-entry.d.ts +1 -1
  29. package/dist/archive-zip-entry.d.ts.map +1 -1
  30. package/dist/archive-zip-names.d.ts +1 -1
  31. package/dist/archive-zip-names.d.ts.map +1 -1
  32. package/dist/archive-zip-names.js +1 -0
  33. package/dist/archive.d.ts.map +1 -1
  34. package/dist/archive.js +17 -25
  35. package/dist/directory-guard.d.ts +17 -5
  36. package/dist/directory-guard.d.ts.map +1 -1
  37. package/dist/directory-guard.js +8 -6
  38. package/dist/directory-mode-node.d.ts +8 -0
  39. package/dist/directory-mode-node.d.ts.map +1 -0
  40. package/dist/directory-mode-node.js +93 -0
  41. package/dist/directory-mode-owner.d.ts +21 -0
  42. package/dist/directory-mode-owner.d.ts.map +1 -0
  43. package/dist/directory-mode-owner.js +73 -0
  44. package/dist/file-observation.d.ts +1 -1
  45. package/dist/file-observation.d.ts.map +1 -1
  46. package/dist/file-store-boundary.d.ts +2 -0
  47. package/dist/file-store-boundary.d.ts.map +1 -1
  48. package/dist/file-store-boundary.js +13 -0
  49. package/dist/file-store.d.ts.map +1 -1
  50. package/dist/file-store.js +8 -11
  51. package/dist/filename.d.ts +6 -0
  52. package/dist/filename.d.ts.map +1 -1
  53. package/dist/filename.js +36 -0
  54. package/dist/guarded-mkdir.d.ts +2 -0
  55. package/dist/guarded-mkdir.d.ts.map +1 -1
  56. package/dist/guarded-mkdir.js +2 -2
  57. package/dist/guarded-mutation.d.ts +3 -2
  58. package/dist/guarded-mutation.d.ts.map +1 -1
  59. package/dist/guarded-mutation.js +2 -0
  60. package/dist/json-document-store.d.ts +3 -0
  61. package/dist/json-document-store.d.ts.map +1 -1
  62. package/dist/json-document-store.js +2 -0
  63. package/dist/json-durable-queue-ownership.d.ts +3 -1
  64. package/dist/json-durable-queue-ownership.d.ts.map +1 -1
  65. package/dist/json-durable-queue-ownership.js +7 -4
  66. package/dist/json-durable-queue.d.ts.map +1 -1
  67. package/dist/json-durable-queue.js +25 -28
  68. package/dist/local-roots.d.ts.map +1 -1
  69. package/dist/local-roots.js +18 -56
  70. package/dist/move-path-cleanup.d.ts +43 -0
  71. package/dist/move-path-cleanup.d.ts.map +1 -0
  72. package/dist/move-path-cleanup.js +159 -0
  73. package/dist/move-path.d.ts +2 -0
  74. package/dist/move-path.d.ts.map +1 -1
  75. package/dist/move-path.js +47 -83
  76. package/dist/native-operations.d.ts +2 -4
  77. package/dist/native-operations.d.ts.map +1 -1
  78. package/dist/native-operations.js +7 -9
  79. package/dist/native-pinned-write-windows.d.ts +2 -2
  80. package/dist/native-pinned-write-windows.d.ts.map +1 -1
  81. package/dist/native-pinned-write-windows.js +5 -5
  82. package/dist/native-pinned-write.d.ts.map +1 -1
  83. package/dist/native-pinned-write.js +14 -4
  84. package/dist/native-staged-file.d.ts +2 -2
  85. package/dist/native-staged-file.d.ts.map +1 -1
  86. package/dist/opened-file-failure.d.ts +1 -0
  87. package/dist/opened-file-failure.d.ts.map +1 -1
  88. package/dist/opened-file-failure.js +4 -0
  89. package/dist/output-sibling.d.ts.map +1 -1
  90. package/dist/output-sibling.js +9 -3
  91. package/dist/pinned-write.d.ts +2 -2
  92. package/dist/pinned-write.d.ts.map +1 -1
  93. package/dist/pinned-write.js +32 -13
  94. package/dist/publish-file.d.ts.map +1 -1
  95. package/dist/publish-file.js +7 -3
  96. package/dist/regular-file.d.ts +1 -1
  97. package/dist/regular-file.d.ts.map +1 -1
  98. package/dist/regular-file.js +35 -6
  99. package/dist/replace-file-descriptor.d.ts +9 -1
  100. package/dist/replace-file-descriptor.d.ts.map +1 -1
  101. package/dist/replace-file-descriptor.js +58 -10
  102. package/dist/replace-file-temp-owner.d.ts +7 -0
  103. package/dist/replace-file-temp-owner.d.ts.map +1 -1
  104. package/dist/replace-file-temp-owner.js +29 -0
  105. package/dist/replace-file.d.ts +1 -0
  106. package/dist/replace-file.d.ts.map +1 -1
  107. package/dist/replace-file.js +12 -38
  108. package/dist/root-context.d.ts +4 -0
  109. package/dist/root-context.d.ts.map +1 -1
  110. package/dist/root-context.js +19 -0
  111. package/dist/root-impl.d.ts +43 -0
  112. package/dist/root-impl.d.ts.map +1 -1
  113. package/dist/root-impl.js +13 -11
  114. package/dist/root-path-symlink.d.ts +6 -2
  115. package/dist/root-path-symlink.d.ts.map +1 -1
  116. package/dist/root-path-symlink.js +7 -6
  117. package/dist/root-path.d.ts +1 -0
  118. package/dist/root-path.d.ts.map +1 -1
  119. package/dist/root-path.js +6 -2
  120. package/dist/root-walk.d.ts.map +1 -1
  121. package/dist/root-walk.js +4 -0
  122. package/dist/root-write-verification.d.ts +3 -2
  123. package/dist/root-write-verification.d.ts.map +1 -1
  124. package/dist/root-write-verification.js +6 -0
  125. package/dist/secret-file.d.ts +9 -0
  126. package/dist/secret-file.d.ts.map +1 -1
  127. package/dist/secret-file.js +136 -74
  128. package/dist/sibling-temp.d.ts.map +1 -1
  129. package/dist/sibling-temp.js +9 -3
  130. package/dist/sidecar-lock-acquire.d.ts.map +1 -1
  131. package/dist/sidecar-lock-acquire.js +18 -5
  132. package/dist/sidecar-lock-reclaim.d.ts.map +1 -1
  133. package/dist/sidecar-lock-reclaim.js +6 -4
  134. package/dist/sidecar-lock.d.ts.map +1 -1
  135. package/dist/sidecar-lock.js +26 -0
  136. package/dist/write-open-flags.d.ts +5 -0
  137. package/dist/write-open-flags.d.ts.map +1 -0
  138. package/dist/write-open-flags.js +35 -0
  139. package/docs/advanced.md +1 -1
  140. package/docs/archive.md +56 -7
  141. package/docs/atomic.md +42 -2
  142. package/docs/contributing.md +13 -0
  143. package/docs/durability.md +3 -2
  144. package/docs/errors.md +3 -1
  145. package/docs/file-store.md +38 -8
  146. package/docs/local-roots.md +4 -3
  147. package/docs/output.md +4 -1
  148. package/docs/private-file-store.md +11 -2
  149. package/docs/reading.md +3 -1
  150. package/docs/regular-file.md +16 -2
  151. package/docs/root.md +4 -2
  152. package/docs/secret-file.md +37 -2
  153. package/docs/sidecar-lock.md +32 -5
  154. package/docs/store.md +5 -1
  155. package/docs/temp.md +19 -1
  156. package/docs/writing.md +24 -3
  157. package/package.json +14 -10
package/docs/archive.md CHANGED
@@ -61,9 +61,42 @@ type ExtractArchiveOptions = {
61
61
  `entryModes` defaults to `"clamp"`: directories become `0o755`; files become
62
62
  `0o644`, or `0o755` when the archived owner-execute bit is set. `"preserve"`
63
63
  keeps archived read/write/execute bits. Both policies strip setuid, setgid, and
64
- sticky bits, and neither applies archived ownership. TAR extraction disables
65
- `tar`'s ownership and mode restoration and applies the selected modes in the
66
- private staging tree; ZIP applies the same policy to `unixPermissions`.
64
+ sticky bits, and neither applies archived ownership. An explicit zero mode,
65
+ including a mode containing only stripped special bits, stays zero under
66
+ `"preserve"`. Absent metadata defaults to `0o644` for files and `0o755` for
67
+ directories; ZIP UNIX creator records with zero attributes are explicit zero,
68
+ while non-UNIX ZIP records use the absent-metadata defaults.
69
+
70
+ TAR mode fields containing only NUL/ASCII-space padding use absent defaults.
71
+ Native extraction also recognizes GNU binary modes, including signed values,
72
+ within node-tar's JavaScript safe-integer range before masking permission bits.
73
+ Malformed or unsupported mode representations retain existing decoder behavior:
74
+ native falls back to zero, while JavaScript may default, parse an octal prefix,
75
+ or reject. These representations are not newly admitted or standardized by the
76
+ mode repair; raw framing and other numeric-field checks remain unchanged.
77
+
78
+ Final modes remain separate from private working staging permissions: files
79
+ stay `0o600` and directories `0o700` until publication. Files receive their final
80
+ mode through the guarded copy's owned writer descriptor. Directories are pinned
81
+ before descending and finalized after their children, including empty and
82
+ restrictive directories. Explicit accepted directory modes win regardless of
83
+ archive order; implicit parents receive `0o755`. Existing destination directories
84
+ also receive the requested final mode. They are never temporarily widened to
85
+ allow child writes; insufficient write/search access still rejects.
86
+
87
+ Directory mode changes use a retained no-follow read descriptor when possible.
88
+ On macOS x64/arm64, read-denied directories can use a retained search descriptor.
89
+ On Linux x64/arm64, the Node-only search route retains an `O_PATH` descriptor and
90
+ changes modes through its exact `/proc/self/fd/N` reference after verifying the
91
+ procfs namespace and followed identity. The descriptor stays open through the
92
+ operation and verification; original root, ancestor and named-directory checks
93
+ still apply. This route trusts host mount-namespace integrity and does not claim
94
+ atomic ancestry checks or protection against privileged mount replacement.
95
+ Readable directories do not depend on procfs. A Linux search-only directory
96
+ needing a mode change requires accessible, genuine procfs; unavailable or
97
+ untrusted authority rejects explicitly instead of silently accepting a wrong
98
+ mode. Other unsupported search-only routes also fail closed. Windows retains
99
+ its existing bounded lack of POSIX mode enforcement.
67
100
 
68
101
  Native extraction is deliberately split into two phases. Rust first reports an
69
102
  entry manifest without creating paths. TypeScript validates paths, applies
@@ -157,6 +190,22 @@ record and are then cleared; local PAX on unsupported types and GNU sparse
157
190
 
158
191
  If `kind` is omitted, the helper calls `resolveArchiveKind(archivePath)` and throws if the extension is not recognized. Pass `kind` explicitly when the archive name doesn't carry the type (e.g. content-addressed names). Archive inputs must remain regular files from preview through descriptor admission; POSIX opens are no-follow and nonblocking, so a FIFO swap cannot stall before deadline checks resume. A positive finite `timeoutMs` is a wall-clock budget; zero, negative, `NaN`, and infinity disable the deadline. Non-mutating work rejects promptly when the budget expires. If a live destination mutation is already in flight, rejection waits only for that mutation and any rollback to finish; no later destination mutation can begin.
159
192
 
193
+ The destination merge is nontransactional: each file is published atomically,
194
+ but completed files and directories can remain when a later copy, post-copy
195
+ check, mode application, or deadline fails. This also applies to
196
+ `mergeExtractedTreeIntoDestination()`. Guarded `Root.copyIn()` owns cleanup for
197
+ its operation; the archive merge does not unlink the current destination name
198
+ on error because it has no publication receipt proving ownership. A failure
199
+ before publication preserves a pre-existing file, and rejection does not grant
200
+ authority to delete a substituted file or alias. Failed extraction does not
201
+ restore overwritten contents. Active destination mutations and their guarded
202
+ cleanup still finish before rejection; no later destination mutation begins.
203
+ New directories whose postorder finalization was never reached can retain their
204
+ private working mode after failure. Failure cleanup closes retained descriptors;
205
+ it does not run a final chmod sweep or roll back the archive. The public merge
206
+ helper still derives modes from its external source tree and must be able to
207
+ read that source; it never chmods an unreadable external source to admit it.
208
+
160
209
  ### Limits
161
210
 
162
211
  ```ts
@@ -453,9 +502,9 @@ The archive subpath also exports the helpers `extractArchive` is built on. Most
453
502
  | `withStagedArchiveDestination(opts)` | Creates a private staging dir outside the destination, calls your `run(stagingDir)`, then cleans it up. |
454
503
  | `mergeExtractedTreeIntoDestination(opts)` | The merge step alone — staged tree → destination through boundary checks. |
455
504
  | `prepareArchiveDestinationDir(destDir)` | Canonicalizes and asserts the destination directory. |
456
- | `prepareArchiveOutputPath(opts)` | Resolves a single entry's output path against the staging dir. |
457
- | `loadZipArchiveWithPreflight(opts)` | Loads a JSZip with size/entry-count preflight before unzipping. |
458
- | `readZipCentralDirectoryEntryCount(path)` | Returns the entry count from a ZIP's central directory without reading any payloads. |
505
+ | `prepareArchiveOutputPath({ destinationDir, destinationRealDir, relPath, outPath, originalPath, isDirectory, deadline? })` | Validates and prepares parents for an already-resolved entry output path. |
506
+ | `loadZipArchiveWithPreflight(buffer, limits?)` | Loads a JSZip from a `Buffer` or `Uint8Array` with size/entry-count preflight before unzipping. |
507
+ | `readZipCentralDirectoryEntryCount(buffer)` | Returns the entry count from an already-loaded ZIP `Buffer` or `Uint8Array` without decoding payloads. |
459
508
  | `createTarEntryPreflightChecker(opts)` | Returns a per-entry checker for use as a `tar.x` `onReadEntry` hook. |
460
509
 
461
510
  These let you build custom extractors that share the same safety machinery — for example, a streaming uploader that wants to refuse archives with too many entries before reading any payloads.
@@ -477,7 +526,7 @@ import {
477
526
  - `validateArchiveEntryPath(raw, opts)` — throws `ArchiveSecurityError` for `..`, absolute, NUL-containing, drive-relative, or otherwise unsafe entry paths, including alternate data stream names on Windows.
478
527
  - `normalizeArchiveEntryPath(raw)` — converts backslashes in the entry path to forward slashes.
479
528
  - `stripArchivePath(entryPath, n)` — normalize separators, drop empty and `.` components, then strip the leading N components, returning `null` if none remain.
480
- - `resolveArchiveOutputPath({ destDir, entryPath })` — combines the entry path with the destination, after validation.
529
+ - `resolveArchiveOutputPath({ rootDir, relPath, originalPath, escapeLabel? })` — combines the validated relative path with the root and rejects escapes using the original archive path for diagnostics.
481
530
  - `isWindowsDrivePath(value)` — detects drive-relative segments such as `C:secret` or `nested/C:secret` that should be rejected.
482
531
 
483
532
  Validate attacker-controlled paths before calling normalization or stripping
package/docs/atomic.md CHANGED
@@ -184,7 +184,9 @@ an identity-bound process-exit cleanup retry.
184
184
  On POSIX, staged directory modes are applied through no-follow directory
185
185
  descriptors; on Windows, Node cannot portably open those descriptors and no
186
186
  pathname `chmod` fallback is attempted, so directory modes remain subject to
187
- Windows' `mkdir(mode)` behavior.
187
+ Windows' `mkdir(mode)` behavior. Symlink sources are copied as links rather than
188
+ followed, including when the referent is absent; only a source proven to be a
189
+ directory is dereferenced for the destination-descendant guard.
188
190
 
189
191
  ```ts
190
192
  import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
@@ -207,7 +209,45 @@ the preflight cap fails with `FsSafeError("too-large")`.
207
209
  If another writer changes source entries during the fallback, the staged copy
208
210
  throws `ESTALE` before commit when possible. If the destination has already
209
211
  been committed, cleanup still preserves the changed source entries and throws
210
- `ESTALE`.
212
+ `ESTALE`. When allowed source names are hardlinks to the same inode, each owned
213
+ unlink is verified through a remaining manifested alias and its exact resulting
214
+ identity becomes the next cleanup receipt. This accounts for the operation's
215
+ own link-count and ctime changes without suppressing unexpected external
216
+ mutations.
217
+
218
+ ### Final rename authorization
219
+
220
+ Pass `assertBeforeRename` when a move depends on a revocable lease or another
221
+ caller-owned authorization. The helper captures this callback when called and
222
+ runs it synchronously after asynchronous preparation and directory checks,
223
+ immediately before each rename is dispatched:
224
+
225
+ ```ts
226
+ type MovePathWithCopyFallbackOptions = {
227
+ from: string;
228
+ sourceHardlinks?: "allow" | "reject";
229
+ to: string;
230
+ assertBeforeRename?: () => void;
231
+ };
232
+ ```
233
+
234
+ Throw to refuse publication. The original error is propagated, including errors
235
+ with `EXDEV` or `EPERM` codes; an authorization failure never starts a copy
236
+ fallback. A genuine rename failure may still require a second authorization
237
+ check before publishing the staged copy.
238
+
239
+ The callback must return `undefined` synchronously. Returning a Promise, thenable,
240
+ or any other value refuses the rename with a `TypeError`; rejected asynchronous
241
+ results are consumed without authorizing the operation. Perform asynchronous
242
+ policy checks before calling the helper and use this callback to recheck the
243
+ current owner at the mutation boundary.
244
+
245
+ A refused rename leaves the source and destination unchanged; any private
246
+ staged copy follows the helper's normal cleanup. The check does not cancel an
247
+ already-dispatched rename or make an external lease store atomic with the
248
+ filesystem. Cleanup after a successful move retains the existing source-identity
249
+ checks. Omitting the
250
+ callback preserves the usual move behavior.
211
251
 
212
252
  ## Difference from `root()`
213
253
 
@@ -35,6 +35,19 @@ pnpm test test/archive.test.ts
35
35
 
36
36
  Use `vi.mock` sparingly. Most tests should drive real disk operations in a `mkdtemp`-created scratch directory, asserting on observable behavior. The library has [test hooks](testing.md) for the rare cases where you need to inject a TOCTOU race deterministically.
37
37
 
38
+ Vitest timeouts do not cancel filesystem promises. Shared fixtures with expensive
39
+ setup use `useSuiteFixture` from `test/helpers/suite-fixture.ts`: setup has a separate
40
+ 30-second hook budget, and teardown waits for tracked setup and test work before
41
+ removing directories. Run shared-state corpora sequentially with a deadline per
42
+ payload. Keep child-process liveness limits separate from fixture preparation.
43
+ The Windows CI slow-copy proof runs the real package-copy process-exit test with a
44
+ six-second copy delay, retaining its four-second child deadline:
45
+
46
+ ```bash
47
+ pnpm build
48
+ pnpm test --config scripts/slow-package-copy.config.ts
49
+ ```
50
+
38
51
  ## Checks
39
52
 
40
53
  Run the complete repository gate before handoff:
@@ -142,8 +142,9 @@ success. `parentReceipt`, when supplied, must name the target's direct parent.
142
142
  With a native binding, the copy fallback first attempts a copy-on-write clone
143
143
  (`fclonefileat` on macOS, `FICLONE` on Linux), then Linux
144
144
  `copy_file_range`, and finally the existing JavaScript byte loop. Every route
145
- creates the target exclusively, normalizes its mode to `0o600`, and goes
146
- through the same post-copy identity and SHA-256 fencing. Hashing uses an async
145
+ creates the target exclusively, applies mode `0o600` through its owned
146
+ descriptor independently of the process umask, and goes through the same
147
+ post-copy identity and SHA-256 fencing. Hashing uses an async
147
148
  native task when available, so large verification reads do not occupy the
148
149
  JavaScript event loop.
149
150
 
package/docs/errors.md CHANGED
@@ -116,7 +116,7 @@ type FsSafeErrorCode =
116
116
  | `helper-failed` | A native mechanism or multi-step operational helper failed. | Inspect `cause` and any operation-specific `details`; retrying may be unsafe if the operation partially completed. |
117
117
  | `helper-unavailable` | Required native binding could not be loaded. | Unsupported platform, omitted/missing/incompatible platform package, or `FS_SAFE_NATIVE_MODE=require`. `auto` falls back where possible; `require` fails closed. |
118
118
  | `insecure-permissions` | A secure file or path permission check found a mode/ACL that allows broader access than requested. | File or directory is group/world writable/readable; Windows ACL grants broad read. |
119
- | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable. | Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names. |
119
+ | `invalid-path` | Input was empty, contained NUL, was an unparseable URL, or otherwise unusable; a FileStore key used a noncanonical spelling. | Noncanonical FileStore aliases, backslashes, or complete parent segments; a network path on Windows; a drive-relative segment in a portable relative path or store key; or a leading drive-relative spelling such as `C:name` used as a Root destination. Existing-object Root lookups retain broader confined path compatibility, including legal POSIX drive-like names. |
120
120
  | `not-empty` | `remove()` on a non-empty directory. | Use `replaceDirectoryAtomic` or remove children first. |
121
121
  | `not-file` | Read or copy targeted a non-regular file, or a path walk found a non-directory ancestor. | Target was a directory, FIFO, socket, device, or an existing file was followed by another segment. |
122
122
  | `not-found` | The target does not exist (or its parent does not, with `mkdir: false`). | Typical missing-file case. |
@@ -134,6 +134,8 @@ type FsSafeErrorCode =
134
134
  | `too-large` | A read or bounded walk exceeded its configured budget. | Caller gave a too-permissive file or traversal limit. |
135
135
  | `unsupported-platform` | Reserved compatibility code for a platform-specific operation. | No current public helper emits this `FsSafeError` code. Platform-specific APIs currently return a typed unsupported result or use `helper-unavailable`; keep the union member when exhaustively switching across supported package versions. |
136
136
 
137
+ Secret writes reject invalid `mode` / `dirMode` values with `invalid-path` before directory creation. Existing secret directories with a mode different from the requested `dirMode` report `insecure-permissions` without chmod; a created directory whose descriptor ownership no longer matches its initializing effective user reports `not-owned`.
138
+
137
139
  Pathname `sha256File()` also reports `path-mismatch` when pre-open, descriptor,
138
140
  or current-path identity remains unknown after one bounded Windows retry, even
139
141
  if the file is benign. It never reopens to recover identity. Preview symlinks
@@ -66,14 +66,36 @@ type FileStore = {
66
66
 
67
67
  `path()` returns the absolute path the store would use, after asserting it stays inside `rootDir`. Useful for logging or for handing to other libraries.
68
68
 
69
- Every `relativePath` is a portable store key, including keys passed to reads,
70
- `exists`, and `remove`. A segment with a Windows drive-relative spelling such
71
- as `C:name` (including an embedded segment such as `a/C:name`) throws
72
- `invalid-path` on every platform. This prevents a key created on POSIX from
73
- aliasing a different file when the store is moved to Windows. Colons elsewhere,
74
- such as the timestamp in `logs/2026-08-02T10:30:00Z.log`, remain valid.
75
- Keys with surrounding whitespace also throw `invalid-path`; the store never
76
- silently trims one caller-supplied key onto another key.
69
+ Every `relativePath` is a portable store key. The same lexical policy applies
70
+ to every keyed async and sync method, including reads, `exists`, `remove`,
71
+ `path()`, and `json()` construction, with either `private: false` or `true`.
72
+ Keys must use their exact canonical spelling; the store never trims, normalizes,
73
+ or converts one caller-supplied key onto another:
74
+
75
+ - Keys are nonempty NFC Unicode strings without surrounding whitespace or NUL.
76
+ - Segments are separated by a single forward slash. Empty segments, repeated or
77
+ trailing slashes, and complete `.` or `..` segments are rejected, including
78
+ `./b`, `a/./b`, and `a/../b`.
79
+ - Every backslash is rejected on every platform, including a literal POSIX
80
+ `a\b` filename. POSIX and Windows absolute, rooted, UNC, and extended paths
81
+ are rejected.
82
+ - Windows drive-relative segments such as `C:name` or `C:` are rejected
83
+ anywhere in a key, including `a/C:name`.
84
+ - No segment may end in an ASCII dot or space.
85
+
86
+ Violations report `invalid-path` when key validation is reached. Ordinary nested
87
+ keys, NFC Unicode such as `café/日本語.txt`, `.hidden`, `a..b`, and internal spaces
88
+ such as `internal space/a b.txt` are accepted. Colons elsewhere, such as the
89
+ timestamp in `logs/2026-08-02T10:30:00Z.log`, remain lexically valid; filesystem
90
+ success still depends on the platform and the underlying Root policies.
91
+
92
+ Validation retains each method's operation order. Async reads, `exists`, and
93
+ `remove` open the root first: if the root is missing, strict methods report
94
+ `not-found` and `readTextIfExists` / `readJsonIfExists` return `null`, even for an
95
+ invalid key. With an existing root, those same invalid keys report `invalid-path`.
96
+ Private `copyIn` checks and reads its source before validating the destination
97
+ key, and `writeJson` serializes its value before validating the key. Key rejection
98
+ therefore does not imply that no filesystem access or serialization occurred.
77
99
 
78
100
  `root()` returns a [`Root`](root.md) handle for the same directory when you need the full surface (move, list, mkdir). It's a fresh handle per call and is safe to call frequently.
79
101
 
@@ -187,6 +209,14 @@ Symlinks are skipped. The walk is best-effort — failures on individual entries
187
209
 
188
210
  If you need richer ops (move, list, append, mkdir), call `store.root()` to get a `Root` and use that.
189
211
 
212
+ `FileStore` owns portable key identity; `Root` owns filesystem confinement.
213
+ Existing-object Root lookups intentionally retain broader confined path
214
+ compatibility, including in-root absolute paths and parent-segment spellings,
215
+ and literal backslashes on POSIX. FileStore rejects those spellings even when
216
+ Root could resolve them safely inside the directory. This key policy does not
217
+ change Root's containment checks or the absolute-path `jsonStore({ filePath })`
218
+ contract.
219
+
190
220
  ## Common patterns
191
221
 
192
222
  ### Cache with TTL prune
@@ -66,9 +66,10 @@ console.log(r.root); // canonical /srv/uploads
66
66
  By default the candidate must exist. `allowMissing: true` instead canonicalizes
67
67
  the nearest existing ancestor and validates the missing tail, which is useful
68
68
  when selecting a future output location. `requireFile: true` rejects existing
69
- directories and other non-file leaves. Dangling symlinks and candidates whose
70
- ancestors cannot be canonicalized are rejected rather than treated as safe
71
- missing paths.
69
+ directories and other non-file leaves. A missing suffix begins only at a
70
+ component that does not exist: dangling symlinks, descendants of dangling
71
+ symlinks, and candidates whose existing ancestors cannot be canonicalized are
72
+ rejected rather than treated as safe missing paths.
72
73
 
73
74
  ## `readLocalFileFromRoots(options)`
74
75
 
package/docs/output.md CHANGED
@@ -60,7 +60,10 @@ hazards but does not trim Windows-normalized trailing dots or spaces; reject or
60
60
  rewrite those when cross-platform filename uniqueness matters.
61
61
  `staging: "workspace"` passes the sanitized basename to the producer.
62
62
  `staging: "sibling"` embeds that basename in its randomized temporary name.
63
- The final target and returned `path` use the destination basename, sanitized
63
+ When the complete temporary component would exceed 255 bytes under NFC or NFD,
64
+ only the embedded tail is shortened, preserving its extension when possible;
65
+ short callback paths remain unchanged. The final target and returned `path` use
66
+ the destination basename, sanitized
64
67
  when needed as described above. Guarded temporary files used only inside
65
68
  fs-safe have independent names so their length does not grow with the
66
69
  destination basename.
@@ -17,8 +17,17 @@ const loaded = await store.readJsonIfExists<State>("state.json");
17
17
 
18
18
  - Writes create parent directories at `0o700` and files at `0o600` unless you
19
19
  pass stricter `dirMode` / `mode` options.
20
- - Private-mode writes route through the secret-file atomic path, which refuses
21
- symlink parent components and re-asserts mode after rename.
20
+ - Async private-mode writes route through the secret-file atomic path, which refuses
21
+ symlink parent components and re-asserts mode after rename. Existing directories
22
+ must already have the requested mode; writes do not repair their permissions.
23
+ New-directory initialization requires guarded descriptor authority and may
24
+ fail closed under restrictive platform/umask combinations; see the
25
+ [secret-directory policy](secret-file.md#parameters).
26
+ - Locked JSON mutations prepare private directories before acquiring their
27
+ sidecar and bind the lock to the admitted parent identity. Lock normalization
28
+ is read-only: a deleted or replaced admitted parent is rejected, not recreated.
29
+ The writer still revalidates directory admission afterward; reads do not create
30
+ directories.
22
31
  - `readText()` and `readJson()` are strict and throw on missing files.
23
32
  - `readTextIfExists()` and `readJsonIfExists()` return `null` on missing files.
24
33
  - `write()`, `writeText()`, `writeJson()`, `writeStream()`, and `copyIn()` all
package/docs/reading.md CHANGED
@@ -102,7 +102,9 @@ fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the
102
102
  fs.reader(options?) // (path) => Promise<Buffer>
103
103
  ```
104
104
 
105
- `readAbsolute` accepts absolute paths. Anything outside the root throws
105
+ `readAbsolute` accepts absolute paths using either the configured root spelling
106
+ or its canonical real path. This includes roots configured through a directory
107
+ symlink or Windows junction. Anything outside the root throws
106
108
  `outside-workspace`. It also accepts relative paths for compatibility, but use
107
109
  `read()`/`readBytes()` when the input contract is explicitly relative.
108
110
 
@@ -111,14 +111,28 @@ append boundary; rounded-equal replacements and persistent unknown Windows
111
111
  identities reject before chmod or writing bytes. With
112
112
  `rejectSymlinkParents: true`, it also rejects symlinked ancestor directories.
113
113
 
114
+ On POSIX, `O_NONBLOCK` prevents a no-reader FIFO substituted before open from
115
+ stalling admission. A confirmed non-regular target is refused before chmod or
116
+ append; other open errors propagate unchanged. This safeguard does not change
117
+ ordinary regular-file append semantics or require read permission.
118
+
119
+ The requested mode is applied through the admitted descriptor **before** any
120
+ content is appended, so an existing file is tightened first. On successful
121
+ completion, explicitly requested POSIX special bits are reapplied after the
122
+ content write, which can otherwise clear set-ID bits. An initial chmod failure
123
+ leaves the content untouched; a write or final chmod failure can leave appended
124
+ content and is not rolled back. Windows does not enforce POSIX mode semantics.
125
+
114
126
  ### `appendRegularFileSync(options)`
115
127
 
116
- Synchronous. Same options.
128
+ Synchronous. Same options and mode ordering. Writes the complete input through
129
+ the already-open descriptor, including when an individual write is short.
117
130
 
118
131
  ### `resolveRegularFileAppendFlags()`
119
132
 
120
133
  Helper that returns the append helpers' `O_WRONLY | O_APPEND | O_CREAT` flags,
121
- plus `O_NOFOLLOW` where the platform provides it:
134
+ plus `O_NOFOLLOW` where the platform provides it and `O_NONBLOCK` on POSIX
135
+ where available. Windows flags are unchanged:
122
136
 
123
137
  ```ts
124
138
  import { resolveRegularFileAppendFlags } from "@openclaw/fs-safe/advanced";
package/docs/root.md CHANGED
@@ -87,8 +87,10 @@ await using opened = await fs.open("large.log");
87
87
  [security model](security-model.md#containment-guarantees-by-platform).
88
88
 
89
89
  The read methods also accept an absolute spelling that already resolves inside
90
- the root. `readAbsolute()` and `reader()` make that intent explicit; an absolute
91
- path outside the root is still rejected.
90
+ the root. `readAbsolute()` and `reader()` make that intent explicit and accept
91
+ both the configured root spelling and its canonical real path when the Root was
92
+ created through a directory symlink or Windows junction. An absolute path
93
+ outside the root is still rejected.
92
94
 
93
95
  ### Writes
94
96
 
@@ -123,7 +123,30 @@ startWebhookVerifier(signingKey);
123
123
 
124
124
  ### `writeSecretFileAtomic(params)`
125
125
 
126
- Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file at `mode` (default `0o600`), atomically renames over the destination, and re-asserts the file mode after rename.
126
+ Async. Creates the parent directory at `dirMode` (default `0o700`) if missing, writes content to a sibling temp file, finalizes `mode` (default `0o600`) through an owned descriptor after content writes, and atomically renames over the destination. Publication verification checks the final file identity and mode.
127
+
128
+ Concurrent writes to distinct leaves may share creation of a missing parent.
129
+ After a parent-creation race, the helper re-inspects the entry and requires a
130
+ non-symlink directory, then revalidates root/parent guards, containment, and
131
+ the requested directory mode before writing either leaf.
132
+
133
+ Publication verification borrows the writer's still-open descriptor to check
134
+ the exact file identity, regular-file and link policy, requested POSIX mode,
135
+ and root/parent ancestry before the writer closes it. All `0o7777` mode bits
136
+ must match, including explicitly requested special bits; unexpected special
137
+ bits are rejected. POSIX mode overrides such as `0o000` and `0o200` do not
138
+ require read permission or a readonly reopen, and verification does not widen
139
+ the final mode. Windows retains pathname-identity verification without enforcing
140
+ POSIX mode bits; its native writer checks the reopened descriptor against the
141
+ original lossless file identity before changing the final mode.
142
+
143
+ Failed JavaScript fallback writes attempt cleanup while retaining the original
144
+ descriptor and only after checking parent and file identities. Native cleanup
145
+ also compares lossless parent and file identities. Unverifiable paths are left
146
+ for caller-managed cleanup, and cleanup failures do not replace the original
147
+ write error. These are best-effort identity checks followed by name-based
148
+ removal, not atomic conditional unlink. A publication-verification failure
149
+ after a completed write does not authorize deleting the published file.
127
150
 
128
151
  ```ts
129
152
  import { writeSecretFileAtomic } from "@openclaw/fs-safe/secret";
@@ -147,7 +170,15 @@ type WriteSecretFileParams = {
147
170
  };
148
171
  ```
149
172
 
150
- The directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. The helper enforces that every component matches `dirMode` wider permissions on an existing directory cause the write to fail. Audit and tighten existing secret directories yourself.
173
+ The full POSIX directory mode is asserted on each component along the path: `rootDir`, then any intermediate dirs, then the parent. Existing directories, including another creator's `EEXIST` winner, must already match `dirMode` exactly or the write fails with `insecure-permissions`; they are never chmod-repaired. An explicitly requested directory mode such as `0o2750` preserves its setgid bit. Audit and adjust existing secret directories yourself. The admitted directory guards are retained through traversal and the final writer/lock handoff; a fresh pathname lookup cannot silently authorize a replacement. The caller must still trust the selected root and its owners; matching permission bits alone do not establish that trust.
174
+
175
+ Directory admission and its retained guards use lossless bigint identities, including through private locks and native writes. On Windows, an unknown zero device or inode gets one reinspection that retains known components; a definite mismatch or persistent ambiguity fails with `path-mismatch` rather than authorizing a replacement.
176
+
177
+ Both mode options must resolve to integers between `0o0000` and `0o7777`; invalid values fail with `invalid-path` before directory creation or file publication. Windows validates the options but does not enforce POSIX permission bits.
178
+
179
+ After this operation wins directory creation, initialization uses a pinned descriptor bound to the admitted identity and effective user, with ancestor checks before chmod. It does not chmod the caller's pathname. Creation and descriptor admission are separate operations, not an atomic create-and-pin guarantee. A raced directory that has not reached its requested mode yet is rejected rather than repaired; callers may retry after its creator finishes initialization.
180
+
181
+ Initialization fails closed if the platform cannot safely pin a created directory. In particular, a non-root macOS process cannot pin a new `000` directory produced by `umask(0o777)`; the write fails without repairing that directory or writing a secret. Restrictive masks retaining owner search permission remain usable. Linux x64/arm64 can use the guarded `O_PATH`/procfs descriptor route where available. There is no unguarded pathname-chmod fallback, and a failure may leave a created directory for caller-managed cleanup.
151
182
 
152
183
  ### `createSecretFileAtomic(params)`
153
184
 
@@ -157,6 +188,10 @@ if anything already occupies the target path it throws
157
188
  `FsSafeError("secret-exists")` without modifying that entry. Use the distinct
158
189
  name when first-writer-wins is part of the credential protocol.
159
190
 
191
+ Distinct leaves can share missing-parent creation without a `secret-exists`
192
+ error. Concurrent creates at the same leaf still have exactly one winner;
193
+ the loser receives `secret-exists` and leaves the winner's bytes intact.
194
+
160
195
  For example, two onboarding requests may race to install the first refresh
161
196
  token. Exactly one should win, and the loser must not overwrite it:
162
197
 
@@ -21,7 +21,9 @@ try {
21
21
 
22
22
  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.
23
23
 
24
- The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
24
+ 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.
25
+
26
+ 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.
25
27
 
26
28
  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.
27
29
 
@@ -94,10 +96,23 @@ type FileLockRetryOptions = {
94
96
  ```
95
97
 
96
98
  `payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
99
+
100
+ The complete serialized sidecar must fit within 1 MiB (1,048,576 UTF-8 bytes),
101
+ including pretty-printed JSON, newlines, and the internal ownership token's
102
+ trailing whitespace. The limit counts bytes, not string characters. Oversized
103
+ payloads reject with `FsSafeError` code `too-large` before sidecar creation or
104
+ acquisition, without retrying serialization or reclaiming an existing sidecar.
105
+ This bound applies to raw and Root-backed locks, both async and sync.
106
+
97
107
  Errors thrown by `payload`, its JSON serialization (including `toJSON`), or
98
108
  `parsePayload` propagate unchanged without retrying the callback. Rethrowing an
99
109
  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.
110
+ 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 no deadline; invalid numeric values reject before filesystem acquisition starts.
111
+ Both async and sync locks enforce retry counts and deadlines independently: an
112
+ explicit `retry.retries` still applies with `timeoutMs: Infinity`, and zero allows
113
+ only the initial attempt. After process defaults are applied, an omitted retry
114
+ count means unlimited retries, and an omitted or infinite timeout means no
115
+ deadline. With neither budget bounded, contention can wait indefinitely.
101
116
  `parsePayload` replaces JSON parsing for legacy or custom sidecars. Its `unknown`
102
117
  result is passed to `shouldReclaim` and `shouldRemoveStaleLock`, allowing PID,
103
118
  process-start, argv, or role schemas to remain application-owned.
@@ -107,8 +122,11 @@ short teardown race after another holder unlinks it. Both async and sync locks
107
122
  retry that specific open denial at most eight times per acquisition, within the
108
123
  caller's retry/deadline budget. A parent-directory denial, a callback/read/stat
109
124
  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.
125
+ open is not converted to `file_lock_timeout`. Root-backed async creation uses
126
+ this same policy for the Windows fallback's exclusive-open denial, captured
127
+ within that individual create call. A generic `Root.create()` error or an error
128
+ replayed from an earlier call is not exclusive-open evidence. Retrying always
129
+ requires fresh exclusive creation and grants no ownership or removal authority.
112
130
 
113
131
  ## Owner-scoped reentrancy
114
132
 
@@ -160,6 +178,15 @@ an existing `Root` capability. `lockPath` must resolve inside that root.
160
178
  Identity-conditioned removal remains the only release and reclaim deletion
161
179
  path.
162
180
 
181
+ Async Root-backed acquisition normalizes the target's parent without creating
182
+ it, checking the retained Root before and after normalization. A deleted or
183
+ replaced Root fails before payload execution or held-entry reuse. Missing lock
184
+ subdirectories are still created through `Root.create`, never by target-key
185
+ normalization. The target is an arbitration key and may be outside the Root
186
+ when an explicit in-root `lockPath` is supplied; normalization does not follow a
187
+ target-leaf symlink. Non-Root acquisition retains its existing parent-creation
188
+ behavior.
189
+
163
190
  An owner can finish releasing while another async acquirer inspects its record.
164
191
  Create-only Root writes do not open an existing record merely to inherit its
165
192
  mode. Once a pathname sample and opened descriptor agree, a failed acquisition
@@ -267,7 +294,7 @@ The sync payload, reclaim, and parsing callbacks must also be synchronous. This
267
294
  shape is appropriate for a short boot migration; it is a poor fit for a server
268
295
  request because retry backoff uses a blocking wait.
269
296
 
270
- If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
297
+ If termination skips the relevant cleanup handler or cleanup fails, the sidecar remains. In particular, `process.exit()` skips asynchronous Root cleanup; await explicit release or drain during application-managed graceful shutdown. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed by default instead of deleting by path.
271
298
 
272
299
  ## `withFileLock` — common shape made one-liner
273
300
 
package/docs/store.md CHANGED
@@ -70,7 +70,11 @@ entry for inspection.
70
70
 
71
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
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.
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. Acknowledgement retries resync the queue directory even when both `.processing` and `.delivered` marker names are already absent, before reporting completion or rejecting a newer pending generation; quarantine retries with only failed evidence resync that destination before repairing the vanished queue source.
74
+
75
+ `writeJsonDurableQueueEntry()` and migrations share strict parent synchronization inside the atomic writer's retained descriptor and per-path serialization lifetime, followed by published-file identity verification. If sync fails after publication, the write rejects without rolling back the published JSON; retrying writes the entry again and must complete its own sync. This is not a rollback, deduplication, or exactly-once guarantee. The generic `replaceFileAtomic({ syncParentDir: true })` option remains best-effort.
76
+
77
+ Batch loading skips invalid entry names, malformed, oversized, or unreadable entry content, and caller `read` callback failures. Initially unowned pending entries (hardlinks or unverifiable identities), symlinks, non-files, and absent pending entries are also skipped. Claim, transfer-lock, retirement, and migration write/publication/durability failures reject the batch with the original error, even if earlier entries succeeded. Migration in both loaders strictly syncs the parent directory after successful publication. Visible transitions and earlier processing claims remain for retry; a rejected batch does not acknowledge or roll them back.
74
78
 
75
79
  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
80
 
package/docs/temp.md CHANGED
@@ -345,7 +345,11 @@ If `replaceFileAtomic` does what you need, prefer that. Use
345
345
  the final destination still needs root-boundary checks.
346
346
  Its private workspace uses the same identity-aware directory cleanup as
347
347
  `tempFile()`: moving and replacing the workspace preserves the replacement.
348
- This workspace owns its contents, unlike the unadmitted sibling pathname above.
348
+ The callback staging component is capped at 255 bytes under NFC and NFD by
349
+ shortening only an overlong embedded destination tail, while preserving an
350
+ extension when possible. Short callback paths and the final target stay
351
+ unchanged. This workspace owns its contents, unlike the unadmitted sibling
352
+ pathname above.
349
353
 
350
354
  ## Secure temp root
351
355
 
@@ -358,6 +362,20 @@ const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
358
362
  // e.g. /tmp/my-app-501
359
363
  ```
360
364
 
365
+ Consumers that only need this resolver can use the narrow package subpath:
366
+
367
+ ```ts
368
+ import {
369
+ resolveSecureTempRoot,
370
+ type ResolveSecureTempRootOptions,
371
+ } from "@openclaw/fs-safe/secure-temp-root";
372
+ ```
373
+
374
+ This entry excludes the temp workspace and store implementations from the
375
+ module graph, keeping the import closure small for browser-aware builds that
376
+ shim or exclude Node built-ins. The resolver remains a Node filesystem API; the
377
+ narrow entry does not make it runnable in a browser.
378
+
361
379
  ### Options
362
380
 
363
381
  ```ts
package/docs/writing.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # Writing
2
2
 
3
3
  The `Root` handle exposes a tight set of mutation verbs. Replacement writes
4
- (`write`, `create`, `writeJson`, `createJson`, and `copyIn`) publish with a
5
- sibling-temp commit so no half-written replacement appears at the destination.
4
+ (`write`, `writeJson`, and `copyIn`) publish with a sibling-temp commit so no
5
+ half-written replacement appears at the destination. Create-only writes
6
+ (`create`, `createJson`, and `write` with `overwrite: false`) use sibling-temp
7
+ staging with an atomic no-replace rename only on backends that provide one —
8
+ the native binding, which `require` mode guarantees and `auto` mode uses when
9
+ the binding loads. The pure-JavaScript fallback has no atomic no-clobber
10
+ rename and does not stage: it claims the final name exclusively with `O_EXCL`
11
+ and writes content in place, so a concurrent observer can see the new file
12
+ before its content is complete. Use `require` mode when that visibility window
13
+ matters.
6
14
  `append` and `openWritable` intentionally modify an opened file in place;
7
15
  `move`, `remove`, and `mkdir` mutate directory entries rather than file bytes.
8
16
  Each verb applies the boundary checks appropriate to its operation.
@@ -91,7 +99,13 @@ alone is never proof that the name still refers to the expected file.
91
99
  Don't-clobber variant of `write()`. Throws `already-exists` if the target is there.
92
100
  Create-only preflight preserves boundary, alias, hardlink, and type checks without
93
101
  opening an existing target to inherit its mode; a fresh file uses the requested
94
- mode or the normal new-file default.
102
+ mode or the normal new-file default. When the native binding is in use
103
+ (`require` mode, or `auto` mode with a successfully loaded binding), content is
104
+ staged privately and published with an atomic no-replace rename, so the name
105
+ never appears before its bytes. In the pure-JavaScript fallback the name is
106
+ claimed exclusively first and content is written afterward, so observers can
107
+ briefly see an empty file; failure cleanup removes a claimed file only when its
108
+ identity is unchanged.
95
109
 
96
110
  ```ts
97
111
  try {
@@ -207,6 +221,13 @@ destination — there is no atomic-rename step. If you need both streaming and
207
221
  atomicity, write to a sibling temp yourself and rename when done; the
208
222
  [`atomic`](atomic.md) helpers can do this for you.
209
223
 
224
+ On POSIX, existing-target opens use `O_NONBLOCK` as an admission safeguard so
225
+ a no-reader FIFO cannot stall regular-file validation. This does not change
226
+ ordinary regular-file write semantics. `replace` and `update` remain write-only
227
+ opens, including for mode `0o200` files; replacement truncation happens only
228
+ after type, identity, and boundary checks pass. Rejected existing paths are
229
+ never cleanup-owned or unlinked.
230
+
210
231
  ## Write defaults vs per-call options
211
232
 
212
233
  Set `mkdir: true` once on `root()`; pass text encodings per call when needed: