@openclaw/feishu 2026.9.8 → 2026.10.1-beta.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 (225) hide show
  1. package/dist/.setup/{channel-DV4hM2Uf.mjs → channel-CX62T71S.mjs} +146 -245
  2. package/dist/.setup/{channel.runtime-CXQcH5zZ.mjs → channel.runtime-COiaq-Rv.mjs} +6 -12
  3. package/dist/.setup/{chat-D8MlFAZi.mjs → chat-BdyvImsY.mjs} +6 -9
  4. package/dist/.setup/{client-BYG-_IZl.mjs → client-BsBVWJzN.mjs} +1 -1
  5. package/dist/.setup/{doctor-contract-3XC0vbIw.mjs → doctor-contract-B1B9ogdd.mjs} +212 -27
  6. package/dist/.setup/{monitor-D_rsHBZ6.mjs → monitor-7DvwUs4Y.mjs} +5 -6
  7. package/dist/.setup/{monitor.account-DvvY7i6k.mjs → monitor.account-7xVu7XWY.mjs} +190 -393
  8. package/dist/.setup/{probe-_ViqSi0j.mjs → probe-B9o7iey6.mjs} +13 -39
  9. package/dist/.setup/{reply-delivery-result-COAjaukc.mjs → reply-delivery-result-Bt-BWBEA.mjs} +151 -297
  10. package/dist/.setup/{setup-api-C4S5I3ac.mjs → setup-api-DmzX9S01.mjs} +1 -1
  11. package/dist/.setup/{subagent-hooks-BaFoMu8W.mjs → subagent-hooks-CUXg0pC-.mjs} +5 -11
  12. package/dist/.setup/{thread-bindings-EdLXPXzw.mjs → thread-bindings-jW2x5VuN.mjs} +23 -41
  13. package/dist/api.js +234 -297
  14. package/dist/channel-plugin-api.js +1 -1
  15. package/dist/doctor-contract-api.js +2 -2
  16. package/dist/session-binding-contract-api.js +1 -1
  17. package/dist/setup-api.js +1 -1
  18. package/dist/setup-entry.js +1 -1
  19. package/dist/subagent-hooks-api.js +1 -1
  20. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +95 -0
  21. package/node_modules/@openclaw/fs-safe/README.md +70 -379
  22. package/node_modules/@openclaw/fs-safe/dist/absolute-path.js +35 -51
  23. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +2 -0
  24. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -0
  25. package/node_modules/@openclaw/fs-safe/dist/archive-input.js +2 -0
  26. package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
  27. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
  28. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +9 -10
  29. package/node_modules/@openclaw/fs-safe/dist/archive.js +2 -5
  30. package/node_modules/@openclaw/fs-safe/dist/atomic-io.d.ts +51 -0
  31. package/node_modules/@openclaw/fs-safe/dist/atomic-io.js +242 -0
  32. package/node_modules/@openclaw/fs-safe/dist/config.d.ts +1 -1
  33. package/node_modules/@openclaw/fs-safe/dist/config.js +1 -1
  34. package/node_modules/@openclaw/fs-safe/dist/copy-file-input.d.ts +1 -1
  35. package/node_modules/@openclaw/fs-safe/dist/copy-file-input.js +2 -2
  36. package/node_modules/@openclaw/fs-safe/dist/copy-tree-portable.js +2 -0
  37. package/node_modules/@openclaw/fs-safe/dist/create.js +12 -5
  38. package/node_modules/@openclaw/fs-safe/dist/directory-guard.d.ts +11 -0
  39. package/node_modules/@openclaw/fs-safe/dist/directory-guard.js +4 -7
  40. package/node_modules/@openclaw/fs-safe/dist/entry-publication-types.d.ts +55 -0
  41. package/node_modules/@openclaw/fs-safe/dist/entry-publication-types.js +1 -0
  42. package/node_modules/@openclaw/fs-safe/dist/entry-publication.d.ts +8 -0
  43. package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +262 -0
  44. package/node_modules/@openclaw/fs-safe/dist/exclusive-create.d.ts +3 -0
  45. package/node_modules/@openclaw/fs-safe/dist/exclusive-create.js +22 -0
  46. package/node_modules/@openclaw/fs-safe/dist/file-cleanup.d.ts +1 -6
  47. package/node_modules/@openclaw/fs-safe/dist/file-cleanup.js +3 -6
  48. package/node_modules/@openclaw/fs-safe/dist/file-identity.js +3 -6
  49. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-io.js +2 -2
  50. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-mutation.js +9 -0
  51. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root.js +4 -6
  52. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-stale-admission.js +1 -1
  53. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync.js +8 -0
  54. package/node_modules/@openclaw/fs-safe/dist/file-store-boundary.js +2 -0
  55. package/node_modules/@openclaw/fs-safe/dist/file-store-sync-write.js +20 -18
  56. package/node_modules/@openclaw/fs-safe/dist/file-store.js +4 -10
  57. package/node_modules/@openclaw/fs-safe/dist/fs.d.ts +2 -10
  58. package/node_modules/@openclaw/fs-safe/dist/fs.js +2 -10
  59. package/node_modules/@openclaw/fs-safe/dist/guarded-mkdir.d.ts +3 -4
  60. package/node_modules/@openclaw/fs-safe/dist/guarded-mkdir.js +6 -18
  61. package/node_modules/@openclaw/fs-safe/dist/guest-dispatch-python.js +1 -1
  62. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +0 -2
  63. package/node_modules/@openclaw/fs-safe/dist/guest.js +3 -9
  64. package/node_modules/@openclaw/fs-safe/dist/index.d.ts +1 -1
  65. package/node_modules/@openclaw/fs-safe/dist/index.js +1 -1
  66. package/node_modules/@openclaw/fs-safe/dist/json-durable-queue-directory.js +1 -5
  67. package/node_modules/@openclaw/fs-safe/dist/json.js +19 -37
  68. package/node_modules/@openclaw/fs-safe/dist/move-path.js +2 -0
  69. package/node_modules/@openclaw/fs-safe/dist/mutation-authority.d.ts +1 -0
  70. package/node_modules/@openclaw/fs-safe/dist/mutation-authority.js +9 -1
  71. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +47 -2
  72. package/node_modules/@openclaw/fs-safe/dist/native-config.d.ts +1 -9
  73. package/node_modules/@openclaw/fs-safe/dist/native-config.js +6 -40
  74. package/node_modules/@openclaw/fs-safe/dist/native-parent-admission.d.ts +2 -0
  75. package/node_modules/@openclaw/fs-safe/dist/native-parent-admission.js +5 -2
  76. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +11 -316
  77. package/node_modules/@openclaw/fs-safe/dist/native-policy-parent-windows.d.ts +7 -7
  78. package/node_modules/@openclaw/fs-safe/dist/native-policy-parent-windows.js +28 -175
  79. package/node_modules/@openclaw/fs-safe/dist/native-policy-parent.d.ts +15 -0
  80. package/node_modules/@openclaw/fs-safe/dist/native-policy-parent.js +418 -0
  81. package/node_modules/@openclaw/fs-safe/dist/native-rename-outcome.js +9 -4
  82. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +17 -13
  83. package/node_modules/@openclaw/fs-safe/dist/native-staged-symlink.js +6 -24
  84. package/node_modules/@openclaw/fs-safe/dist/native.js +5 -1
  85. package/node_modules/@openclaw/fs-safe/dist/path-case.js +10 -9
  86. package/node_modules/@openclaw/fs-safe/dist/path-scope-lexical.js +4 -1
  87. package/node_modules/@openclaw/fs-safe/dist/path-segment-route.d.ts +1 -0
  88. package/node_modules/@openclaw/fs-safe/dist/path-segment-route.js +3 -0
  89. package/node_modules/@openclaw/fs-safe/dist/path.js +4 -1
  90. package/node_modules/@openclaw/fs-safe/dist/permissions-windows.d.ts +0 -1
  91. package/node_modules/@openclaw/fs-safe/dist/pinned-mutation-admission.js +1 -3
  92. package/node_modules/@openclaw/fs-safe/dist/pinned-write-staged.js +2 -0
  93. package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +9 -10
  94. package/node_modules/@openclaw/fs-safe/dist/private-producer-handoff.js +3 -6
  95. package/node_modules/@openclaw/fs-safe/dist/publish-copy-stage.js +2 -2
  96. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +4 -3
  97. package/node_modules/@openclaw/fs-safe/dist/replace-file-buffer.d.ts +3 -4
  98. package/node_modules/@openclaw/fs-safe/dist/replace-file-buffer.js +18 -32
  99. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.d.ts +5 -17
  100. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +92 -230
  101. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-source.d.ts +4 -15
  102. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-source.js +22 -63
  103. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.d.ts +9 -34
  104. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +78 -83
  105. package/node_modules/@openclaw/fs-safe/dist/replace-file-destination.d.ts +18 -20
  106. package/node_modules/@openclaw/fs-safe/dist/replace-file-destination.js +64 -98
  107. package/node_modules/@openclaw/fs-safe/dist/replace-file-temp-owner.d.ts +16 -33
  108. package/node_modules/@openclaw/fs-safe/dist/replace-file-temp-owner.js +61 -200
  109. package/node_modules/@openclaw/fs-safe/dist/replace-file-types.d.ts +1 -6
  110. package/node_modules/@openclaw/fs-safe/dist/replace-file.js +76 -216
  111. package/node_modules/@openclaw/fs-safe/dist/root-boundary.js +14 -24
  112. package/node_modules/@openclaw/fs-safe/dist/root-context.d.ts +1 -4
  113. package/node_modules/@openclaw/fs-safe/dist/root-context.js +4 -19
  114. package/node_modules/@openclaw/fs-safe/dist/root-create-native.d.ts +52 -0
  115. package/node_modules/@openclaw/fs-safe/dist/root-create-native.js +350 -0
  116. package/node_modules/@openclaw/fs-safe/dist/root-directory-list-types.d.ts +35 -0
  117. package/node_modules/@openclaw/fs-safe/dist/root-directory-list-types.js +1 -0
  118. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +2 -27
  119. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +31 -28
  120. package/node_modules/@openclaw/fs-safe/dist/root-directory.js +1 -4
  121. package/node_modules/@openclaw/fs-safe/dist/root-errors.js +34 -0
  122. package/node_modules/@openclaw/fs-safe/dist/root-file.js +9 -21
  123. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +105 -37
  124. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +7 -2
  125. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +67 -38
  126. package/node_modules/@openclaw/fs-safe/dist/root-observed-path.d.ts +1 -1
  127. package/node_modules/@openclaw/fs-safe/dist/root-observed-path.js +0 -2
  128. package/node_modules/@openclaw/fs-safe/dist/root-options.d.ts +2 -3
  129. package/node_modules/@openclaw/fs-safe/dist/root-path-existing.js +3 -1
  130. package/node_modules/@openclaw/fs-safe/dist/root-path-stat.js +68 -85
  131. package/node_modules/@openclaw/fs-safe/dist/root-path.js +6 -1
  132. package/node_modules/@openclaw/fs-safe/dist/root-paths-lexical.d.ts +4 -0
  133. package/node_modules/@openclaw/fs-safe/dist/root-paths-lexical.js +1 -1
  134. package/node_modules/@openclaw/fs-safe/dist/root-paths.js +4 -9
  135. package/node_modules/@openclaw/fs-safe/dist/root-remove-native.d.ts +4 -0
  136. package/node_modules/@openclaw/fs-safe/dist/root-remove-native.js +311 -0
  137. package/node_modules/@openclaw/fs-safe/dist/root-remove.d.ts +11 -0
  138. package/node_modules/@openclaw/fs-safe/dist/root-remove.js +3 -1
  139. package/node_modules/@openclaw/fs-safe/dist/root-walk.d.ts +2 -2
  140. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +1 -3
  141. package/node_modules/@openclaw/fs-safe/dist/root-write-admission.d.ts +3 -3
  142. package/node_modules/@openclaw/fs-safe/dist/root-write-admission.js +8 -10
  143. package/node_modules/@openclaw/fs-safe/dist/safe-path-segment.d.ts +0 -1
  144. package/node_modules/@openclaw/fs-safe/dist/safe-path-segment.js +0 -4
  145. package/node_modules/@openclaw/fs-safe/dist/secret-file.js +12 -9
  146. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.d.ts +0 -2
  147. package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +2 -0
  148. package/node_modules/@openclaw/fs-safe/dist/sibling-temp.js +7 -4
  149. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +13 -6
  150. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-reclaim.d.ts +0 -3
  151. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-reclaim.js +5 -23
  152. package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.d.ts +2 -1
  153. package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.js +11 -6
  154. package/node_modules/@openclaw/fs-safe/dist/temp-cleanup.js +1 -8
  155. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +1 -1
  156. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +2 -9
  157. package/node_modules/@openclaw/fs-safe/dist/text-atomic.d.ts +1 -6
  158. package/node_modules/@openclaw/fs-safe/dist/trash.js +6 -2
  159. package/node_modules/@openclaw/fs-safe/dist/walk.js +100 -46
  160. package/node_modules/@openclaw/fs-safe/dist/watch-hints.d.ts +7 -0
  161. package/node_modules/@openclaw/fs-safe/dist/watch-hints.js +235 -19
  162. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +21 -2
  163. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +21 -3
  164. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.d.ts +6 -0
  165. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +111 -0
  166. package/node_modules/@openclaw/fs-safe/dist/watch-scan.d.ts +13 -0
  167. package/node_modules/@openclaw/fs-safe/dist/watch-scan.js +27 -4
  168. package/node_modules/@openclaw/fs-safe/dist/watch-stream.js +2 -0
  169. package/node_modules/@openclaw/fs-safe/dist/watch.js +131 -40
  170. package/node_modules/@openclaw/fs-safe/dist/windows-path-alias.d.ts +8 -0
  171. package/node_modules/@openclaw/fs-safe/dist/windows-path-alias.js +24 -6
  172. package/node_modules/@openclaw/fs-safe/dist/windows-path-syntax.d.ts +13 -0
  173. package/node_modules/@openclaw/fs-safe/dist/windows-path-syntax.js +51 -0
  174. package/node_modules/@openclaw/fs-safe/docs/advanced.md +4 -0
  175. package/node_modules/@openclaw/fs-safe/docs/atomic.md +4 -1
  176. package/node_modules/@openclaw/fs-safe/docs/config.md +6 -14
  177. package/node_modules/@openclaw/fs-safe/docs/contributing.md +4 -14
  178. package/node_modules/@openclaw/fs-safe/docs/durability.md +6 -29
  179. package/node_modules/@openclaw/fs-safe/docs/entry-publication.md +146 -0
  180. package/node_modules/@openclaw/fs-safe/docs/errors.md +16 -0
  181. package/node_modules/@openclaw/fs-safe/docs/file-store.md +28 -5
  182. package/node_modules/@openclaw/fs-safe/docs/index.md +2 -31
  183. package/node_modules/@openclaw/fs-safe/docs/install.md +10 -31
  184. package/node_modules/@openclaw/fs-safe/docs/json.md +1 -1
  185. package/node_modules/@openclaw/fs-safe/docs/local-roots.md +0 -1
  186. package/node_modules/@openclaw/fs-safe/docs/migrating-to-0.5.md +12 -12
  187. package/node_modules/@openclaw/fs-safe/docs/native-helper.md +35 -96
  188. package/node_modules/@openclaw/fs-safe/docs/native.md +22 -20
  189. package/node_modules/@openclaw/fs-safe/docs/path.md +1 -1
  190. package/node_modules/@openclaw/fs-safe/docs/permissions.md +1 -2
  191. package/node_modules/@openclaw/fs-safe/docs/public-api.md +1 -2
  192. package/node_modules/@openclaw/fs-safe/docs/reading.md +1 -2
  193. package/node_modules/@openclaw/fs-safe/docs/root.md +26 -184
  194. package/node_modules/@openclaw/fs-safe/docs/secret-file.md +1 -1
  195. package/node_modules/@openclaw/fs-safe/docs/security-model.md +115 -3
  196. package/node_modules/@openclaw/fs-safe/docs/store.md +2 -2
  197. package/node_modules/@openclaw/fs-safe/docs/temp.md +29 -65
  198. package/node_modules/@openclaw/fs-safe/docs/testing.md +90 -58
  199. package/node_modules/@openclaw/fs-safe/docs/types.md +3 -16
  200. package/node_modules/@openclaw/fs-safe/docs/walk.md +33 -5
  201. package/node_modules/@openclaw/fs-safe/docs/watch.md +96 -14
  202. package/node_modules/@openclaw/fs-safe/docs/writing.md +172 -16
  203. package/node_modules/@openclaw/fs-safe/package.json +10 -10
  204. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  205. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  206. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  207. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  208. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  209. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  210. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  211. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  212. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  213. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  214. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  215. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  216. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  217. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  218. package/package.json +5 -5
  219. package/skills/feishu-wiki/SKILL.md +1 -1
  220. package/dist/.setup/accounts-wRqItHug.mjs +0 -206
  221. package/node_modules/@openclaw/fs-safe/dist/watch-alias.d.ts +0 -6
  222. package/node_modules/@openclaw/fs-safe/dist/watch-alias.js +0 -88
  223. package/node_modules/@openclaw/fs-safe/docs/mutation-policy-proof.md +0 -69
  224. package/node_modules/@openclaw/fs-safe/docs/private-file-store.md +0 -68
  225. package/node_modules/@openclaw/fs-safe/docs/test-hooks.md +0 -110
@@ -49,6 +49,24 @@ If you need full sandboxing, run the worker under reduced privileges (uid, conta
49
49
 
50
50
  Every path is resolved against the canonicalized real path of the root, then checked at that boundary. On Windows, an exact-case structural Root prefix stays on the lexical fast path; a prefix accepted only by case folding must have the Root's exact directory identity and is rebased onto the trusted Root spelling before use. Alias resolution walks components before applying a later `..`, so a symlink cannot change what that parent segment means after validation. Parent traversal, an absolute spelling, or any alias whose canonical result is outside the root throws `outside-workspace`; absolute spellings that remain inside the root are accepted.
51
51
 
52
+ On Windows, Root, root-file readers, `pathScope`, `isPathInside`, secret-file
53
+ writers, sibling-temp output, trash admission, and archive output preparation
54
+ reject foreign share or device roots before filesystem access can contact an
55
+ attacker-chosen host. Shares are compared by exact host and share name with
56
+ ASCII-only case folding, so Unicode case folding (for example the Kelvin sign
57
+ for `k`) cannot make another host look trusted. Namespaced drive roots keep
58
+ their existing handling, and explicitly selected shares, such as a Root,
59
+ allowed trash root, or destination on `\\server\share`, remain supported.
60
+ `\\?\` and `\\.\` spellings under `GLOBALROOT`/`Global`, or whose components
61
+ Windows would rewrite (empty or trailing-dot/space authority components, or
62
+ dot segments climbing above the drive, share, or device), are foreign unless
63
+ spelled exactly under the trusted boundary, because their target cannot be
64
+ identified from the spelling.
65
+
66
+ An absolute path on a different share or device is rejected even when a
67
+ filesystem alias would resolve it back inside the boundary. Pass a path
68
+ relative to the boundary, or spell it on the boundary's own share.
69
+
52
70
  Guarded pathname APIs reject Windows `:` namespace aliases before normalization
53
71
  or filesystem access. The only colon admitted in a Windows filesystem path is
54
72
  the rooted ASCII drive designator (including extended-drive syntax); relative
@@ -73,6 +91,19 @@ publication, and cleanup. File writers preserve the raw suffix. Root-relative
73
91
  APIs and caller-constructed relative directory receipts continue to reject
74
92
  drive designators.
75
93
 
94
+ ### Exclusive creation on Windows
95
+
96
+ Windows Node exclusive creation can follow a dangling file symlink before an
97
+ opened-descriptor check can reject it. Fallback creators therefore inspect the
98
+ final leaf before opening it, including create-only Root writes, standalone
99
+ creators, exclusive copy publication, lock records, and internal staging files. This preserves an unchanged
100
+ link and its missing target, including a target outside the intended directory.
101
+ Random staging names and freshly created workspaces reduce the opportunity to
102
+ preplace such a link, but are not a replacement for this check. The preflight is
103
+ best-effort: a concurrent replacement between inspection and open can still
104
+ redirect a pathname operation in native `auto`/`off` mode. Use the documented
105
+ native `require` creation paths and OS isolation when hostile concurrency is in scope.
106
+
76
107
  ### Symlinks (read side)
77
108
 
78
109
  `open()` and `read()` use `fs.open` with `O_NOFOLLOW` on POSIX where available. The library then `fstat`s the open fd and `realpath`s the original input, asserting the two refer to the same inode. A symlink swap that happens between resolve and open will fail at the identity check rather than silently following the new target.
@@ -149,7 +180,24 @@ final component again after awaited staging and parent fences, immediately befor
149
180
  the rename or exclusive open. These are best-effort symlink checks, not an atomic
150
181
  expected-entry/CAS replacement: a concurrent process can still replace the final
151
182
  entry between its check and rename. Existing parent containment guarantees remain
152
- as described below. Omitting `mutationSymlinks` preserves existing mutation behavior.
183
+ as described below. Omitting `mutationSymlinks` preserves each implementation's
184
+ existing mutation behavior; it does not currently provide uniform Windows link
185
+ handling.
186
+
187
+ For Windows buffered replacement `write()` and `writeJson()` calls (`overwrite`
188
+ omitted or `true`), the native pinned path rejects
189
+ a final file symlink with `path-alias`. The legacy JavaScript path can follow an
190
+ unchanged contained final link, keep the link itself, and replace the admitted
191
+ target. Its configured mutation policy reauthorizes the original target before
192
+ staging and publication, and its file/parent identity checks remain in force.
193
+ Likewise, native Windows parent admission refuses junction/reparse traversal
194
+ and can report `invalid-path`, while the legacy path can write through an
195
+ admitted contained parent-junction target.
196
+
197
+ The legacy writer is selected by native `off`, missing-binding `auto`, or
198
+ `renameIdentity: "verify-content-with-lock"`. Callers requiring the same link
199
+ rejection across implementations must set `mutationSymlinks: "reject"`
200
+ explicitly. See [Windows link modes](writing.md#windows-link-modes).
153
201
 
154
202
  The JavaScript fallback used by `off`, by `auto` when no binding loads, and by
155
203
  the explicit `renameIdentity: "verify-content-with-lock"` compatibility policy
@@ -158,7 +206,9 @@ It asserts directory identity around a pathname mutation and detects many
158
206
  swaps, but detection occurs after the kernel may already have followed a new
159
207
  parent symlink. A same-privilege peer with write access to the parent can
160
208
  therefore cause an out-of-root side effect before the operation throws. Use
161
- native `require` mode when concurrent hostile mutation is in scope.
209
+ native `require` mode to refuse implicit pathname mutation fallbacks, then check
210
+ the operation capabilities below. Loading an addon alone does not establish
211
+ confinement for every method.
162
212
 
163
213
  The separate [retained-directory staging lifecycle](staged-file.md) keeps abort
164
214
  cleanup anchored to the original directory after a parent or ancestor move.
@@ -233,7 +283,7 @@ The library does not modify or constrain the global Node.js `fs` namespace, and
233
283
  | Mechanism | Reported containment | Boundary |
234
284
  |---|---|---|
235
285
  | Linux native with `openat2` | `kernel-atomic` | `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` resolves and opens under the root in one kernel operation. |
236
- | Linux native without `openat2` | `best-effort` | A no-follow `openat` component walk retains each parent and verifies exact identity associations before and after the final open; all symlink components are rejected. |
286
+ | Linux native without `openat2` | `best-effort` | A no-follow `openat` component walk retains each parent, follows admitted in-root relative symlinks, and rechecks exact directory/link identities and link targets before and after the final open. |
237
287
  | macOS native | `best-effort` | macOS 15.4 and newer use `O_RESOLVE_BENEATH` first; older kernels use the guarded `openat(O_NOFOLLOW)` component walk. Both verify the opened descriptor with `F_GETPATH`. |
238
288
  | Windows native | `best-effort` | Handle-relative `NtCreateFile` rejects reparse points, but this package does not claim a Linux-style atomic beneath guarantee. |
239
289
  | JavaScript fallback | `best-effort` | Canonical checks, no-follow opens where Node exposes them, and post-open identity checks form a check-then-use sequence. |
@@ -244,6 +294,68 @@ The Linux fallback's identity checks are also detection-based: a directory can b
244
294
 
245
295
  The public `OpenResult`, `ReadResult`, and `WritableOpenResult` expose `containment`. Those root APIs currently report `best-effort`; direct native `openBeneath()` reports the platform value above. No-replace publication uses `renameat2(RENAME_NOREPLACE)` on Linux, `renameatx_np(RENAME_EXCL)` on macOS, and `FileRenameInfoEx` with replacement disabled on Windows, but those separate mutation semantics do not upgrade an open result's containment label.
246
296
 
297
+ ### Native Root mutation capabilities
298
+
299
+ For removal, recursive removal, mkdir, overwrite move, and writable-open creation,
300
+ `require` selects the hardened native capability or rejects with
301
+ `helper-unavailable`; it does not silently perform the corresponding Node
302
+ pathname mutation. Default `auto` keeps its previous best-effort paths for these
303
+ operations, even with a native addon loaded. **`auto` does not confine these
304
+ operations under hostile concurrency.** The matrix below describes `require`.
305
+ The existing native publication paths for `write`, `create`, `writeJson`, and
306
+ `copyIn` are unchanged, and no-clobber move still requires native support in every mode.
307
+
308
+ | Root operation | Linux with `openat2` | Linux guarded fallback | macOS native | Windows native |
309
+ |---|---|---|---|---|
310
+ | `write`, `create`, `writeJson`, `copyIn` publication | Retained parent descriptors | Retained parents; best-effort admission | Retained parents; best-effort admission | Handle-relative publication; best-effort admission |
311
+ | `remove` file, symlink, or empty directory | Identity check and `unlinkat` at retained parent | Same unlink; best-effort parent admission | Same unlink; best-effort parent admission | Handle-relative open and identity-checked `FileDispositionInfoEx`; best-effort parent admission |
312
+ | Recursive `remove` | Descriptor-relative enumeration and deletion; `RESOLVE_NO_XDEV` on descent | `require` rejects; `auto` uses JavaScript | Descriptor-relative traversal with mount-identity checks; best-effort admission | `require` rejects; `auto` uses JavaScript |
313
+ | `mkdir` | `mkdirat` and retained child identity checks | Same creation; best-effort admission | Same creation plus guarded admission | Handle-relative directory creation, including the existing protected private creator; best-effort admission |
314
+ | `move` with overwrite | Retained parents, source identity check, `renameat` | Same rename; best-effort admission | Retained parents and rename; best-effort admission | Source handle identity check and handle-relative rename; best-effort admission |
315
+ | `append` / `openWritable` creating a file | Beneath exclusive open, no-follow final component, identity-checked FileHandle handoff | Same creation through guarded native open; best-effort admission | Guarded native creation and identity-checked FileHandle handoff; best-effort admission | Handle-relative exclusive creation, identity-checked FileHandle handoff and handle-bound cleanup; best-effort admission |
316
+
317
+ These guarantees have a per-call cost in `require`: removal pays for retained
318
+ parent admission and entry checks, recursive removal additionally fences each
319
+ visited directory, mkdir admits and verifies each created parent, overwrite move
320
+ retains both parents, and open-create verifies the FileHandle handoff. Adjacent
321
+ native steps are combined where no authorization callback must intervene, but
322
+ the identity and policy fences remain. `auto` keeps its previous operation paths
323
+ and avoids this added cost.
324
+
325
+ On Linux, `openat2(RESOLVE_BENEATH | RESOLVE_NO_MAGICLINKS)` admits the parent
326
+ under the Root atomically. Later mutations use that retained parent, so replacing
327
+ its pathname with a symlink cannot redirect the syscall to the link's target.
328
+ Recursive removal never follows an enumerated symlink; it unlinks the link itself
329
+ unless an explicit mutation policy rejects it. The fallback Linux walk rejects
330
+ symlinks and keeps its `best-effort` label. macOS uses the existing guarded
331
+ `O_RESOLVE_BENEATH`/`F_GETPATH` admission and also remains `best-effort`.
332
+
333
+ Directory pinning is not an atomic check of the entire namespace at mutation
334
+ time. A peer can move an admitted directory, and POSIX has no expected-inode
335
+ conditional unlink or rename: a final name can change after its identity check.
336
+ Post-operation rejection does not roll back a completed mutation. Policy and
337
+ identity checks remain defense in depth; OS isolation is needed for stronger
338
+ same-privilege namespace or authorization guarantees.
339
+
340
+ Writable-open creation retains a native descriptor while reopening the same
341
+ inode as a Node `FileHandle`, without create or truncate flags. If the requested
342
+ mode or umask cannot be preserved through initial creation and that handoff,
343
+ `require` rejects rather than widening permissions. `auto` keeps its existing
344
+ JavaScript creation path. The native
345
+ creation syscall receives the requested mode, preserving inherited ACLs.
346
+ If the resulting kernel permissions prevent handoff, the call fails closed and
347
+ attempts identity-bound cleanup of its empty created file; it never broadens
348
+ those permissions with `chmod`.
349
+ Existing-file writable opens, reads, advisory methods, and caller operations on
350
+ returned handles retain their existing `best-effort` contracts. Explicit
351
+ `renameIdentity: "verify-content-with-lock"` also retains its documented
352
+ JavaScript compatibility path, including in `require` mode.
353
+
354
+ Compatibility: an older or incomplete addon, unavailable mount-bounded descent,
355
+ or another missing operation capability now causes `helper-unavailable` in
356
+ `require`, including cases that previously fell through to JavaScript. The
357
+ public result types and containment labels are unchanged.
358
+
247
359
  ## Limitations to keep in mind
248
360
 
249
361
  | Limitation | What it means |
@@ -30,7 +30,7 @@ import {
30
30
  | `fileStoreSync()` | Synchronous variant of `fileStore()` for places that genuinely cannot await. |
31
31
  | [`jsonStore()`](json-store.md) | A single keyed JSON state file with explicit fallback, atomic writes, and optional sidecar locking around read-modify-write updates. |
32
32
  | Durable JSON queue helpers | Append/load/ack JSON entry files using atomic writes and delivered markers. |
33
- | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for credentials, tokens, and per-agent state at `0600` files under `0700` directories. |
33
+ | [Private file-store mode](file-store.md#private-mode) | `fileStore({ private: true })` for credentials, tokens, and per-agent state at `0600` files under `0700` directories. |
34
34
 
35
35
  `fileStore().json("rel.json")` and `jsonStore({ filePath })` are intentionally separate primitives. Use `fileStore().json(...)` when JSON state lives alongside other files in the same managed directory; use `jsonStore({ filePath })` when you have one trusted path, resolved to an absolute path at construction, and want the keyed JSON shape directly.
36
36
 
@@ -124,6 +124,6 @@ rejects non-files, symlinks, hardlinks, and entries over the byte limit.
124
124
 
125
125
  - [`fileStore`](file-store.md) — full API for the multi-file store.
126
126
  - [`jsonStore`](json-store.md) — single-file JSON store with locking.
127
- - [Private file-store mode](private-file-store.md) — credential-shaped variant.
127
+ - [Private file-store mode](file-store.md#private-mode) — credential-shaped variant.
128
128
  - [JSON files](json.md) — lower-level `readJson` / `writeJson` helpers.
129
129
  - [Atomic writes](atomic.md) — what `fileStore` and `jsonStore` use under the hood.
@@ -54,55 +54,31 @@ unavailable namespace evidence leaves admission unchanged.
54
54
  The first admitted unmapped ancestor emits `FS_SAFE_UNMAPPED_TEMP_ANCESTOR`
55
55
  through Node's warning event. The warning contains no caller paths.
56
56
 
57
- For an already existing canonical root, discovery retains only its immutable
58
- exact identity. Cleanup-parent retention is provisional: after any native
59
- capability probe, creation captures and validates the complete ancestry,
60
- re-observes the root against discovery, and associates the retained parent
61
- descriptor. Async creation and sync creation outside the Linux/macOS
62
- direct-mode case dispatch `mkdtemp` immediately after that synchronous boundary
63
- without another yield or native probe. Existing aliases and missing-component
64
- roots keep the guarded admission route.
65
-
66
- On Linux and macOS, synchronous creation can instead use an exclusive six-character
67
- random child name when an explicit requested mode other than `0o700` has owner
68
- `rwx`, no special bits, and no group/world write bits. The requested mode is
69
- passed directly to `mkdir` and the observed complete permission bits, rather
70
- than the requested bits, are authoritative. Umask, inherited ACL state, or
71
- inherited special bits can make that observation differ, in which case creation
72
- corrects the mode through the retained descriptor. This mode-based optimization
73
- does not claim that Linux and macOS have identical syscall or ACL behavior, and
74
- POSIX mode bits do not establish ACL privacy. Creation makes at most 64 attempts;
75
- after a name collision, each retry generates its candidate first, replays the
76
- already admitted immutable ancestry and descriptor receipts, and then
77
- immediately attempts exclusive creation. A colliding entry is never inspected,
78
- adopted, corrected, registered, or deleted. The default `0o700`, async creation, and
79
- other sync modes retain the `mkdtemp` path. That path requests initial mode
80
- `0o700`; a result different from `dirMode` is initialized through the same
81
- descriptor-bound correction.
82
-
83
- The direct sync path opens the new child without following its final component
84
- and captures one exact descriptor observation after the parent replay. The new
85
- child's exact identity, type, owner, private bits, and complete `0o7777` mode are
86
- checked before mode initialization. When its creation mode already
87
- matches `dirMode` (including the default `0o700`), creation avoids an extra mode
88
- descriptor and chmod. If the observed creation mode differs from `dirMode`, the
89
- immediate synchronous correction consumes that one-shot observation, checks the
90
- fresh child name, replays the parent, and applies correction through the retained
91
- descriptor. Later admission always performs fresh descriptor and name checks.
92
- Permission failures propagate. POSIX `dirMode`
93
- must not grant group/world write access; it only controls the new workspace,
94
- not existing supplied directories. After the
95
- first exact child observation, final adoption retains a no-follow child
96
- descriptor, rechecks complete ancestry and retained cleanup-parent authority,
97
- and then validates the original child's descriptor and current name for exact
98
- identity, owner, private bits, and requested mode before cleanup is registered.
99
- Linux and macOS may replay exact identities through round-trip-safe nonnegative
100
- numeric `dev`/`ino` projections. Initial receipts that cannot be represented
101
- exactly stay on the BigInt path; a malformed or mismatched numeric replay fails
102
- closed without an exact retry.
103
- Parent or child replacements observed during creation reject before cleanup
104
- ownership is registered. Unverified artifacts are left in place for
105
- caller-directed recovery.
57
+ Existing canonical-root discovery retains immutable exact identity; parent retention is provisional until,
58
+ after any native probe, creation validates complete ancestry, rechecks the root, and binds the parent descriptor.
59
+ Existing aliases and missing-component roots keep guarded admission. Async creation, default `0o700`, and
60
+ ineligible sync modes dispatch `mkdtemp` (initial mode `0o700`) immediately after admission, without another yield or probe.
61
+
62
+ Linux/macOS sync creation can use exclusive `mkdir` with a six-character random suffix when an explicit
63
+ mode other than `0o700` grants owner `rwx`, no special bits, and no group/world write. Each of at most
64
+ 64 attempts generates a candidate, replays admitted ancestry and descriptor receipts, then creates immediately.
65
+ Colliding entries are never inspected, adopted, corrected, registered, or deleted. Observed complete mode bits
66
+ are authoritative: umask, inherited ACLs, or special bits can change the requested mode. POSIX modes do not
67
+ establish ACL privacy, and this optimization does not promise identical Linux/macOS syscalls or ACL behavior.
68
+
69
+ The direct sync path opens the child without following its final component and captures one exact descriptor
70
+ observation after parent replay. Creation checks identity, type, owner, private bits, and complete `0o7777` mode.
71
+ Matching `dirMode` avoids an extra mode descriptor and chmod; mismatches use descriptor-bound correction.
72
+ Immediate sync correction consumes the initial observation, checks the fresh child name, and replays the parent;
73
+ later admission uses fresh descriptor/name checks. Permission failures propagate. POSIX `dirMode` cannot grant
74
+ group/world write and affects only the new workspace, never existing supplied directories.
75
+
76
+ Before registering cleanup, final adoption retains a no-follow child descriptor, rechecks complete ancestry
77
+ and retained cleanup-parent authority, then verifies the original child's descriptor and current name for exact
78
+ identity, owner, private bits, and requested mode. Linux/macOS replay retained identities through round-trip-safe
79
+ nonnegative numeric `dev`/`ino` projections when exact (otherwise BigInt); malformed or mismatched numeric
80
+ observations fail closed without an exact retry. Observed parent/child replacements reject before ownership
81
+ registration; unverifiable artifacts remain for caller-directed recovery.
106
82
 
107
83
  On Windows, POSIX mode/UID metadata does not establish ACL privacy, and these
108
84
  factories neither claim nor initialize a POSIX `dirMode`; every requested value
@@ -247,9 +223,7 @@ name before quarantine returns `"identity-mismatch"` when the parent is stable;
247
223
  an ambiguous parent returns `"indeterminate"`. After successful removal,
248
224
  repeated cleanup returns `"missing"` without touching a recreated public name.
249
225
  Other statuses remain stable. Compatible recursive-removal failures propagate
250
- the exact thrown value, including `undefined`, `null`, `false`, positive or
251
- negative numeric zero, bigint zero, an empty string, and `NaN`; they are never
252
- inferred from value identity or truthiness. Uncertain quarantine and
226
+ the original thrown value, including falsy values. Uncertain quarantine and
253
227
  retained-parent checks instead return
254
228
  `"indeterminate"`. After a propagated removal failure, later cleanup returns
255
229
  `"indeterminate"` without retrying. Disposal and scoped helpers ignore returned
@@ -293,16 +267,8 @@ The callback receives the same workspace shape as `tempWorkspace()`. Cleanup is
293
267
 
294
268
  ### Manual lifetime
295
269
 
296
- Lower-level. You manage the lifetime:
297
-
298
- ```ts
299
- const workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "scan-" });
300
- try {
301
- // …work in workspace.dir…
302
- } finally {
303
- await workspace.cleanup();
304
- }
305
- ```
270
+ Manage the lifetime with `try/finally` and inspect the cleanup result, as in the
271
+ [receipt-inspecting example above](#tempworkspace).
306
272
 
307
273
  ### Sync variants
308
274
 
@@ -634,7 +600,6 @@ type ResolveSecureTempRootOptions = {
634
600
  getuid?: () => number | undefined;
635
601
  tmpdir?: () => string;
636
602
  accessSync?: typeof import("node:fs").accessSync;
637
- chmodSync?: typeof import("node:fs").chmodSync; // deprecated, never read or called
638
603
  descriptor?: SecureTempRootDescriptorAdapter; // complete bundle; see below
639
604
  lstatSync?: (path: string) => {
640
605
  isDirectory(): boolean;
@@ -710,8 +675,7 @@ flags make repair/finalization unavailable. Injecting `lstatSync`, `accessSync`,
710
675
  or `mkdirSync` disables the default host descriptor bundle. Injected observations
711
676
  also require an explicit `mkdirSync` for creation; supplying a descriptor bundle
712
677
  likewise never implicitly authorizes host mkdir. A custom mkdir requires the
713
- complete descriptor bundle for POSIX finalization. The deprecated `chmodSync`
714
- option is inert and alone does not disable normal host behavior.
678
+ complete descriptor bundle for POSIX finalization.
715
679
 
716
680
  These checks bind chmod to the admitted object and reject observed replacements;
717
681
  the returned path is not a retained capability. Pathname access and identity
@@ -1,5 +1,10 @@
1
1
  # Testing
2
2
 
3
+ The [seeded differential harness](https://github.com/openclaw/fs-safe/blob/main/scripts/differential-root.md) compares
4
+ isolated Node/Bun, native/fallback and sync/async public API runs, retaining
5
+ replayable return/error/tree receipts and bounded reduced repros. Its small
6
+ `node scripts/differential-root.mjs --ci` corpus runs in native CI lanes.
7
+
3
8
  ## Coverage gates
4
9
 
5
10
  The coverage workflow measures `src/**/*.ts` with V8 on Linux, macOS, and
@@ -23,6 +28,23 @@ Percentages complement behavioral gates: mutation-policy proof, nightly watch
23
28
  stress, and platform lanes are equally important. High coverage cannot establish
24
29
  root confinement, race safety, event delivery, or bounded resource retirement.
25
30
 
31
+ ## Hosted mutation-policy proof
32
+
33
+ The [hosted workflow](https://github.com/openclaw/fs-safe/blob/main/.github/workflows/mutation-policy-proof.yml)
34
+ builds the event-head package and host addon on Node 24 Linux, macOS, and Windows.
35
+ The [harness](https://github.com/openclaw/fs-safe/blob/main/scripts/mutation-policy-proof.mjs)
36
+ runs isolated temporary fixtures and binds sources, built modules, and the addon
37
+ to the tested revision. Its [receipt contract](https://github.com/openclaw/fs-safe/blob/main/test/mutation-policy-proof-contract.test.ts)
38
+ and [case contract](https://github.com/openclaw/fs-safe/blob/main/test/mutation-policy-proof-cases-contract.test.ts)
39
+ define the executable inventory and bounds.
40
+
41
+ Receipts describe representative observations: final listings and sentinels
42
+ neither count native syscalls nor exclude transient effects. Windows compatibility
43
+ payload writes remain JavaScript even when native-required sidecar publication
44
+ uses `Root.create`. Hosted cases complement internal interleaving tests; they
45
+ are neither exhaustive race proof nor performance clearance. Inspect exact
46
+ hosted artifacts before relying on a receipt's claims.
47
+
26
48
  ## Watch stress campaign
27
49
 
28
50
  Build from the exact revision being qualified with `pnpm install --frozen-lockfile`,
@@ -149,20 +171,6 @@ requires the same openat2/NO_XDEV primitive and runs in the ordinary Linux
149
171
  lanes. Bun's full compatibility
150
172
  suite remains in the normal native lanes, because it includes bounded cleanup.
151
173
 
152
- `@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
153
- is allowed only when `process.env.NODE_ENV === "test"` or
154
- `process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
155
- throws. Production code must not import this subpath.
156
-
157
- ```ts
158
- import {
159
- __setFsSafeTestHooksForTest,
160
- type FsSafeTestHooks,
161
- } from "@openclaw/fs-safe/test-hooks";
162
- ```
163
-
164
- The double-underscore prefix is a deliberate "hands off" signal: production code should never import this module. ESLint or your equivalent linter should flag it.
165
-
166
174
  ## When to reach for hooks
167
175
 
168
176
  - Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
@@ -173,30 +181,71 @@ If you don't need to inject a race, you don't need hooks — most tests should d
173
181
 
174
182
  ## Hooks API
175
183
 
184
+ `@openclaw/fs-safe/test-hooks` exposes injection points for downstream tests,
185
+ not a supported runtime API. Production code must not import it; enforce that
186
+ with your linter. New optional fields may appear between minor versions.
187
+
176
188
  ```ts
177
- type FsSafeTestHooks = {
178
- afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
179
- beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
180
- afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise<void> | void;
181
- afterPublishTargetCreated?: (method, targetPath, identity) => Promise<void> | void;
182
- beforePublishDirectorySync?: (method, targetPath, identity) => Promise<void> | void;
183
- // Additional archive, store, root-fallback, temp, and trash race hooks are
184
- // documented on the focused Test hooks reference page.
185
- };
189
+ import {
190
+ getFsSafeTestHooks,
191
+ __setFsSafeTestHooksForTest,
192
+ type FsSafeTestHooks,
193
+ } from "@openclaw/fs-safe/test-hooks";
194
+ ```
186
195
 
196
+ ```ts
187
197
  function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
188
198
  function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
189
199
  ```
190
200
 
191
- Hooks are called at well-defined points in the library's hot paths:
192
-
193
- - **`afterPreOpenLstat`** — runs after the pre-open `lstat`. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race.
194
- - **`beforeOpen`** — runs before `fs.open` with the exact flags the root read path will use.
195
- - **`afterOpen`** — runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed.
196
- - **`afterPublishTargetCreated`** — runs after exclusive publication created a target but before its final fences.
197
- - **`beforePublishDirectorySync`** — runs after target verification and immediately before strict parent sync; useful for exercising `onSyncFailure`.
198
-
199
- `__setFsSafeTestHooksForTest(undefined)` clears all hooks. Always clean up between tests.
201
+ Registering any truthy hook set (including `{}`) requires
202
+ `process.env.NODE_ENV === "test"` or `process.env.VITEST === "true"`; otherwise
203
+ the setter throws. Clearing with `undefined` is allowed in any environment.
204
+ The getter returns the registered set, or `undefined` when none is registered;
205
+ changing the environment does not erase registered hooks.
206
+
207
+ All fields are optional. Callbacks return `Promise<void> | void` and are awaited
208
+ unless marked **sync**, which requires `void` and must not return a promise.
209
+ Path arguments are strings, `flags` is a number, `withFileTypes` is a boolean,
210
+ and `handle` is a Node `FileHandle`. Publication `method` is `"hardlink"`,
211
+ `"exclusive-copy"`, or `"rename-noreplace"`; `identity` carries `dev` and `ino`
212
+ as numbers or bigints.
213
+
214
+ | Hook | Callback arguments | Timing |
215
+ |---|---|---|
216
+ | `beforeWatchRegistration` | `path` | Before a scanned directory's registration step, including when its existing registration is reused. |
217
+ | `afterWatchRegistration` | `path` | After that registration step, before recording a newly acquired registration. |
218
+ | `afterWatchBackendOverflow` (**sync**) | `root, phase` (`"received"` or `"reconciled"`) | On an overflow hint, and after a noninitial reconciliation produces an overflow invalidation. |
219
+ | `afterWatchBackendCreated` (**sync**) | `root, emit, nativeEvent?` | After creating/configuring the backend, before scanning. `emit(batch)` injects a native watch batch; `nativeEvent(path, flags)` injects a decoder event. |
220
+ | `afterPreOpenLstat` | `filePath` | After pre-open `lstat`, before opening the file. |
221
+ | `beforeOpen` | `filePath, flags` | Immediately before the guarded file open. |
222
+ | `afterOpen` | `filePath, handle` | After open, before the post-open identity check. |
223
+ | `afterOpenedPathIdentityCheck` | `filePath, handle` | A standalone local-file or absolute copy-source descriptor matches its pathname, before the generic opened-path resolver. Root reads use final admission hooks instead. |
224
+ | `afterRootReadPathResolution` | `filePath` | After Root read path resolution, before local-file open admission. |
225
+ | `beforeRootReadFinalFence` | `filePath, handle` | After descriptor identity and hardlink checks, before the final root/path/canonical-path/root admission fence. |
226
+ | `afterRootReadFinalPathIdentityCheck` (**sync**) | `filePath, handle` | After the final pathname-to-descriptor comparison, before the second root check. |
227
+ | `beforeArchiveOutputMutation` | `operation` (`"mkdir"` or `"chmod"`), `targetPath` | Before archive staging creates a directory or applies a mode. |
228
+ | `beforeFileStorePruneDescend` | `dirPath` | Before file-store pruning descends into a directory. |
229
+ | `beforeFileStoreSyncPrivateWrite` (**sync**) | `filePath` | Before a synchronous private-store write mutates its target. |
230
+ | `beforeRootFallbackMutation` | `operation` (`"mkdir"`, `"move"`, or `"remove"`), `targetPath` | Before a guarded JavaScript Root fallback mutation. |
231
+ | `beforePinnedWriteParentAdmission` | `targetPath` | After pinned-write policy preflight, before parent admission; also before refreshing retained JavaScript write authority and parent checks. |
232
+ | `beforeRootStatObservation` | `targetPath` | After `Root.stat()` admits the target and parent, before collecting returned metadata. |
233
+ | `beforeRootStatInitialObservation` | `targetPath` | After `Root.stat()` admits the parent, before its first target inspection. |
234
+ | `beforeRootListObservation` | `directoryPath, withFileTypes` | After `Root.list()` admits the selected directory, before collecting names and optional metadata. |
235
+ | `afterPinnedWriteFallbackRename` | `targetPath` | After fallback rename commits, before post-commit identity checks. |
236
+ | `beforeSiblingTempWrite` | `tempPath` | Before the `writeViaSiblingTempPath` producer runs, with its selected output pathname still absent. |
237
+ | `beforeSidecarLockSnapshotOpen` | `lockPath` | After sidecar inspection, before opening it for a bounded snapshot read. |
238
+ | `beforeRegularFileAppendOpen` | `filePath` | After append preflight and the initial size-budget check, before async open. |
239
+ | `beforeRegularFileAppendOpenSync` (**sync**) | `filePath` | The corresponding sync append point, before `openSync`. |
240
+ | `beforeTempWorkspaceNativeRemoval` | `quarantinePath` | After workspace quarantine admission, immediately before native owned-tree removal. |
241
+ | `beforeTempWorkspaceNativeRemovalSync` (**sync**) | `quarantinePath` | The corresponding sync native-removal point. |
242
+ | `beforeTrashMove` (**sync**) | `targetPath, destPath` | Before trash handling moves the target. |
243
+ | `afterPublishTargetCreated` | `method, targetPath, identity` | After exclusive publication creates the target, before final fences. |
244
+ | `beforePublishDirectorySync` | `method, targetPath, identity` | After publication verifies the target, immediately before strict parent sync. |
245
+
246
+ The watch `emit` callback accepts `{ hints, overflow, error? }`: `overflow` is
247
+ boolean, `error` is a string, and each hint has string `directory` and `name`
248
+ fields plus an `event` of `"rename"`, `"change"`, or `"children"`.
200
249
 
201
250
  ## Example: simulate a TOCTOU swap
202
251
 
@@ -263,18 +312,9 @@ it("runs without the native helper", async () => {
263
312
 
264
313
  Hooks set by `__setFsSafeTestHooksForTest` persist across tests until explicitly cleared. Always clear in `afterEach` (or your test framework's equivalent) — leaked hooks will silently change behavior in unrelated tests and cause maddening intermittent failures.
265
314
 
266
- ```ts
267
- import { afterEach } from "vitest";
268
- import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
269
-
270
- afterEach(() => {
271
- __setFsSafeTestHooksForTest(undefined);
272
- });
273
- ```
274
-
275
- A global hook clear in your test setup file is a good safety net.
276
-
277
- See the [complete Test hooks reference](test-hooks.md) for every optional hook.
315
+ Use `__setFsSafeTestHooksForTest(undefined)` as in the TOCTOU example above.
316
+ A global hook clear in your test setup file is a good safety net; the
317
+ [Hooks API](#hooks-api) lists every optional hook.
278
318
 
279
319
  ## Patterns for testing fs-safe-using code
280
320
 
@@ -343,20 +383,12 @@ workspace reads that bypass pinned file descriptors.
343
383
 
344
384
  The original soak rule rejected more than 64 MiB RSS growth after minute five.
345
385
  That outcome remains in `memory.legacyRss`, with its original limit and pass/fail
346
- value; it is no longer the soak pass criterion. Static guarded poll scans alone
347
- reproduced growth from 72 to 181 MiB RSS while collected heap stayed near 7–8 MiB:
348
- V8 expanded its almost-empty young generation to 128 MiB, with 103 MiB physically
349
- committed. A diagnostic run limiting that space ended at 85 MiB RSS with the
350
- same live heap. The normal harness keeps Node's default nursery sizing.
351
-
352
- The 60-minute qualification separates this capacity warm-up from the later RSS
353
- trend. The measured Linux event run had under 1 MiB collected-heap drift and a
354
- 0.55 MiB/minute second-half RSS slope; its final ten-minute RSS range was 1.60 MiB.
355
- The 8 MiB live-memory allowance and 1 MiB/minute RSS slope leave measurement
356
- margin while rejecting retained growth and continued rapid RSS growth. The
357
- 512 MiB peak ceiling is unchanged. Native allocation leaks need independent
358
- accounting/profiling too: the investigation found a much smaller cleanup-hook
359
- context leak even when registrations, pending sets, and TSFN counters retired.
386
+ value; it is no longer the soak pass criterion. V8 capacity expansion and
387
+ allocator retention can raise RSS while collected live memory stays flat.
388
+ The normal harness keeps Node's default nursery sizing and separates warm-up
389
+ from later RSS growth with the [current stress gates](#watch-stress-campaign).
390
+ Native allocation leaks still need independent accounting/profiling, even when
391
+ registrations, pending sets, and thread-safe function counters retire.
360
392
 
361
393
  Build the native addon and package from the same revision, then run each control
362
394
  in a fresh Node process on a disposable machine:
@@ -112,7 +112,6 @@ type RootDefaults = {
112
112
  maxBytes?: number;
113
113
  mkdir?: boolean; // default true for mutation methods
114
114
  mode?: number;
115
- nonBlockingRead?: boolean;
116
115
  renameIdentity?: RenameIdentityPolicy;
117
116
  symlinks?: "reject" | "follow-within-root" | "follow-parents-within-root";
118
117
  mutationSymlinks?: MutationSymlinkPolicy;
@@ -136,7 +135,7 @@ type RootOptions = {
136
135
  ```ts
137
136
  import type { CopyCloneMode, RootCopyPublicationReceipt } from "@openclaw/fs-safe";
138
137
 
139
- type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "nonBlockingRead" | "symlinks">;
138
+ type RootReadOptions = Pick<RootDefaults, "hardlinks" | "maxBytes" | "symlinks">;
140
139
  type RootWriteOptions = Pick<RootDefaults, "assertBeforeMutation" | "denyMutations" | "durable" | "mkdir" | "mode" | "renameIdentity" | "mutationSymlinks"> & {
141
140
  encoding?: BufferEncoding;
142
141
  overwrite?: boolean;
@@ -194,21 +193,9 @@ policy; omission preserves each mutation method's existing behavior.
194
193
 
195
194
  ## `FsSafeErrorCode` / `FsSafeErrorCategory`
196
195
 
197
- ```ts
198
- type FsSafeErrorCode =
199
- | "already-exists" | "denied-path" | "device-path" | "hardlink"
200
- | "helper-failed"
201
- | "helper-unavailable" | "insecure-permissions" | "invalid-path"
202
- | "not-empty" | "not-file" | "not-found" | "not-owned"
203
- | "not-removable" | "outside-workspace" | "path-alias"
204
- | "path-mismatch" | "permission-unverified" | "read-failed" | "secret-exists"
205
- | "store-reentrant-update" | "symlink"
206
- | "timeout" | "too-large" | "unsupported-platform";
207
- ```
208
-
209
- Closed union you switch on. See the [Errors](errors.md) reference for what each one means.
196
+ `FsSafeErrorCode` is a closed union you switch on; the [code union](errors.md#code-union) lists every member and the [code reference](errors.md#code-reference) explains each one.
210
197
 
211
- `FsSafeError.category` is `"policy"` for unsafe input or target state rejected by a safety policy and `"operational"` for routine filesystem outcomes or environment/runtime failures. `not-found`, `not-empty`, `not-removable`, and `read-failed` are operational.
198
+ `FsSafeError.category` is `"policy"` for unsafe input or target state rejected by a safety policy and `"operational"` for routine filesystem outcomes or environment/runtime failures. [Errors](errors.md#shape) lists the exact operational set.
212
199
 
213
200
  ## See also
214
201
 
@@ -51,7 +51,7 @@ Each entry's `path` is absolute and retains the normalized spelling of the
51
51
  supplied root, including followed directory aliases. Paths do not switch to
52
52
  the canonical symlink target during descent.
53
53
 
54
- `walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose `realpath`/`readdir` threw, so its contents are absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
54
+ `walkDirectory()` and `walkDirectorySync()` always return `failedDirs`; the property remains optional on the exported `WalkDirectoryResult` type so existing callers that manually construct the legacy result shape remain source-compatible. It lists every directory whose directory resolution, opening, reading, or closing threw, so some or all of its contents may be absent from `entries`. `error` is the thrown value (a `NodeJS.ErrnoException` at runtime), so callers can distinguish a benign missing-directory race (`ENOENT`) from a real read failure (`EACCES`, `EIO`, `ESTALE`, …). The walk-root failure has an empty `relativePath` and `depth: 0`. Failures resolving a symlink's target kind are not reported here.
55
55
 
56
56
  ## Options
57
57
 
@@ -102,6 +102,14 @@ This prunes a directory after finding its marker without listing that directory'
102
102
 
103
103
  Unreadable directories are skipped rather than throwing, but every skipped directory is recorded in `failedDirs`. This keeps the helper suitable for best-effort inventories while letting pruning jobs tell an incomplete scan from an empty one: a destructive reconcile that deletes state for paths missing from `entries` must first confirm `failedDirs` holds no real read failures, or a transient `EIO`/`EACCES` blip would be mistaken for mass deletion. Use a stricter root-bounded operation when every entry must be accounted for.
104
104
 
105
+ With `maxEntries`, both standalone walkers stream in filesystem order with a
106
+ Node.js's default 32-entry directory buffer. They examine at most the remaining
107
+ budget plus one lookahead per visited directory; Node may prefetch the rest of
108
+ the current 32-entry batch without invoking filters for those names. Memory is
109
+ bounded independently of directory width. Streams close on completion,
110
+ truncation, and callback failure. Filtering consumes the budget. Without an entry
111
+ budget, they retain eager directory snapshots for complete scans. Neither standalone API sorts its output.
112
+
105
113
  ## Root-bounded async iteration
106
114
 
107
115
  `Root.walk(rel, options)` is the root-bounded counterpart to these standalone
@@ -148,9 +156,13 @@ home-directory expansion, prefix it with `./`, as in
148
156
 
149
157
  The default `order: "sorted"` visits each directory's names in lexicographic
150
158
  order before descending depth first. It reads and sorts all names in each
151
- visited directory. With `maxEntries`, it prepares small metadata batches capped
152
- by the remaining global entry budget. Every batch stops at the first directory
153
- or symlink, so recursive descent cannot spend a budget already used by later
159
+ visited directory, even with `maxEntries`, so truncated walks select the globally
160
+ smallest names within each directory rather than a filesystem-order-dependent
161
+ subset. For an unchanged tree this preserves deterministic results. The entry
162
+ budget bounds metadata and filtering work, but does not bound sorted name
163
+ enumeration memory or time. With `maxEntries`, it prepares small metadata batches
164
+ capped by the remaining global entry budget. Every batch stops at the first
165
+ directory or symlink, so recursive descent cannot spend a budget already used by later
154
166
  siblings. An early `break` may leave metadata from the current batch unused;
155
167
  the total still stays within `maxEntries`. Filtering requires metadata and
156
168
  consumes the entry budget, including entries skipped by the filter.
@@ -237,7 +249,9 @@ for await (const entry of capability.walk("", {
237
249
  ```
238
250
 
239
251
  Filters run serially outside metadata batches and retain the supplied options
240
- object as their `this` receiver. When an awaited filter resolves, the walk
252
+ object as their `this` receiver. Cancellation is checked after every filter
253
+ decision, including synchronous callbacks, before yielding or descending.
254
+ When an awaited filter resolves, the walk
241
255
  checks cancellation and revalidates the current listing directory and Root
242
256
  identities before using the decision. These checks do not refresh the entry's
243
257
  captured metadata or pin a later operation.
@@ -276,6 +290,20 @@ root-bounded and reports failures inline because an async iterator has no final
276
290
  result summary. Its default remains to throw on unreadable or invalid
277
291
  directories.
278
292
 
293
+ `Root.list()` always returns a complete sorted array and has no entry budget.
294
+ `Root.entries()` defaults to streaming filesystem order; its sorted mode buffers
295
+ names, and `maxEntries` caps that buffer with one lookahead before throwing
296
+ `too-large`. Watch scans use the same guarded filesystem-order stream and enforce
297
+ their examined-entry budget before metadata lookup. Every Root listing mode
298
+ rejects invalid UTF-8 names before application metadata lookup, including the
299
+ lookahead; unexamined suffixes are not validated.
300
+
301
+ Bun 1.4.2 implements `Dir` with an internal eager `readdir`, including when
302
+ `bufferSize` is one. On that runtime, these budgets bound fs-safe's admitted
303
+ names, metadata, and results, but cannot bound Bun's internal enumeration memory.
304
+ Async scans retain async directory reads there; use Node.js when the directory
305
+ width itself must not determine enumeration allocation.
306
+
279
307
  ## See also
280
308
 
281
309
  - [`fileStore`](file-store.md) — managed stores use bounded walking for pruning.