@openclaw/openshell-sandbox 2026.9.6 → 2026.9.7

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 (244) hide show
  1. package/assets/icon.png +0 -0
  2. package/dist/index.js +75 -229
  3. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +102 -0
  4. package/node_modules/@openclaw/fs-safe/README.md +24 -6
  5. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +6 -1
  6. package/node_modules/@openclaw/fs-safe/dist/advanced.js +3 -1
  7. package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
  8. package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +1 -2
  9. package/node_modules/@openclaw/fs-safe/dist/archive-plan.d.ts +2 -7
  10. package/node_modules/@openclaw/fs-safe/dist/archive-read.js +9 -18
  11. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -2
  12. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +3 -3
  13. package/node_modules/@openclaw/fs-safe/dist/archive-zip-directory.js +4 -0
  14. package/node_modules/@openclaw/fs-safe/dist/archive-zip-entry.d.ts +11 -11
  15. package/node_modules/@openclaw/fs-safe/dist/archive-zip-entry.js +3 -35
  16. package/node_modules/@openclaw/fs-safe/dist/archive-zip-integrity.d.ts +2 -2
  17. package/node_modules/@openclaw/fs-safe/dist/archive-zip-integrity.js +2 -12
  18. package/node_modules/@openclaw/fs-safe/dist/archive-zip-loader.d.ts +7 -3
  19. package/node_modules/@openclaw/fs-safe/dist/archive-zip-loader.js +13 -10
  20. package/node_modules/@openclaw/fs-safe/dist/archive-zip-manifest.js +3 -1
  21. package/node_modules/@openclaw/fs-safe/dist/archive-zip-preflight.d.ts +2 -3
  22. package/node_modules/@openclaw/fs-safe/dist/archive-zip-preflight.js +16 -8
  23. package/node_modules/@openclaw/fs-safe/dist/archive.d.ts +6 -3
  24. package/node_modules/@openclaw/fs-safe/dist/archive.js +22 -19
  25. package/node_modules/@openclaw/fs-safe/dist/atomic.d.ts +1 -1
  26. package/node_modules/@openclaw/fs-safe/dist/copy-publication.d.ts +1 -3
  27. package/node_modules/@openclaw/fs-safe/dist/copy-publication.js +2 -6
  28. package/node_modules/@openclaw/fs-safe/dist/deny-mutation-match.d.ts +2 -0
  29. package/node_modules/@openclaw/fs-safe/dist/deny-mutation-match.js +71 -0
  30. package/node_modules/@openclaw/fs-safe/dist/deny-mutations.js +4 -3
  31. package/node_modules/@openclaw/fs-safe/dist/directory-guard.d.ts +0 -1
  32. package/node_modules/@openclaw/fs-safe/dist/directory-guard.js +0 -3
  33. package/node_modules/@openclaw/fs-safe/dist/directory-mode-node.d.ts +20 -2
  34. package/node_modules/@openclaw/fs-safe/dist/directory-mode-node.js +77 -1
  35. package/node_modules/@openclaw/fs-safe/dist/directory-receipt.js +5 -7
  36. package/node_modules/@openclaw/fs-safe/dist/effective-uid.js +1 -4
  37. package/node_modules/@openclaw/fs-safe/dist/errors.d.ts +3 -1
  38. package/node_modules/@openclaw/fs-safe/dist/errors.js +3 -2
  39. package/node_modules/@openclaw/fs-safe/dist/file-identity.js +10 -2
  40. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-acquisition.js +1 -1
  41. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-admission.js +5 -1
  42. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-held.d.ts +4 -11
  43. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-held.js +1 -4
  44. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-io.d.ts +3 -6
  45. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-io.js +1 -1
  46. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-options.d.ts +2 -10
  47. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root.d.ts +2 -4
  48. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync.js +2 -2
  49. package/node_modules/@openclaw/fs-safe/dist/file-store-boundary.d.ts +3 -7
  50. package/node_modules/@openclaw/fs-safe/dist/file-store-boundary.js +7 -10
  51. package/node_modules/@openclaw/fs-safe/dist/file-store-prune.js +7 -6
  52. package/node_modules/@openclaw/fs-safe/dist/file-store.d.ts +4 -7
  53. package/node_modules/@openclaw/fs-safe/dist/file-store.js +19 -21
  54. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +23 -31
  55. package/node_modules/@openclaw/fs-safe/dist/guest.js +21 -12
  56. package/node_modules/@openclaw/fs-safe/dist/json-document-store.d.ts +4 -9
  57. package/node_modules/@openclaw/fs-safe/dist/json-durable-queue-ownership.d.ts +1 -5
  58. package/node_modules/@openclaw/fs-safe/dist/json-durable-queue.d.ts +0 -1
  59. package/node_modules/@openclaw/fs-safe/dist/json-durable-queue.js +2 -7
  60. package/node_modules/@openclaw/fs-safe/dist/local-file-access.js +2 -5
  61. package/node_modules/@openclaw/fs-safe/dist/local-file-descriptor.d.ts +2 -5
  62. package/node_modules/@openclaw/fs-safe/dist/local-roots.d.ts +2 -7
  63. package/node_modules/@openclaw/fs-safe/dist/move-path-cleanup.js +4 -4
  64. package/node_modules/@openclaw/fs-safe/dist/mutation-authority.js +5 -0
  65. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +51 -14
  66. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +8 -2
  67. package/node_modules/@openclaw/fs-safe/dist/native-staged-symlink.d.ts +13 -0
  68. package/node_modules/@openclaw/fs-safe/dist/native-staged-symlink.js +303 -0
  69. package/node_modules/@openclaw/fs-safe/dist/owner-dacl-batch-worker.d.ts +1 -0
  70. package/node_modules/@openclaw/fs-safe/dist/owner-dacl-batch-worker.js +54 -0
  71. package/node_modules/@openclaw/fs-safe/dist/owner-dacl-batch.d.ts +5 -0
  72. package/node_modules/@openclaw/fs-safe/dist/owner-dacl-batch.js +64 -0
  73. package/node_modules/@openclaw/fs-safe/dist/owner-dacl.d.ts +2 -0
  74. package/node_modules/@openclaw/fs-safe/dist/owner-dacl.js +3 -0
  75. package/node_modules/@openclaw/fs-safe/dist/path.js +17 -1
  76. package/node_modules/@openclaw/fs-safe/dist/permission-exec.d.ts +6 -0
  77. package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +3 -6
  78. package/node_modules/@openclaw/fs-safe/dist/permissions-public.d.ts +3 -1
  79. package/node_modules/@openclaw/fs-safe/dist/permissions-public.js +1 -0
  80. package/node_modules/@openclaw/fs-safe/dist/permissions-windows.d.ts +3 -6
  81. package/node_modules/@openclaw/fs-safe/dist/permissions.d.ts +3 -10
  82. package/node_modules/@openclaw/fs-safe/dist/permissions.js +0 -1
  83. package/node_modules/@openclaw/fs-safe/dist/pinned-mutation-admission.d.ts +0 -1
  84. package/node_modules/@openclaw/fs-safe/dist/pinned-mutation-admission.js +4 -2
  85. package/node_modules/@openclaw/fs-safe/dist/pinned-mutation-shared-route.d.ts +2 -8
  86. package/node_modules/@openclaw/fs-safe/dist/pinned-open.d.ts +1 -1
  87. package/node_modules/@openclaw/fs-safe/dist/pinned-open.js +2 -3
  88. package/node_modules/@openclaw/fs-safe/dist/publish-copy-stage.js +4 -0
  89. package/node_modules/@openclaw/fs-safe/dist/read-opened-file.d.ts +2 -5
  90. package/node_modules/@openclaw/fs-safe/dist/regular-file.js +3 -3
  91. package/node_modules/@openclaw/fs-safe/dist/replace-directory.js +5 -17
  92. package/node_modules/@openclaw/fs-safe/dist/replace-file-buffer.d.ts +4 -0
  93. package/node_modules/@openclaw/fs-safe/dist/replace-file-buffer.js +36 -0
  94. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.d.ts +4 -10
  95. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +66 -38
  96. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.d.ts +7 -11
  97. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +10 -2
  98. package/node_modules/@openclaw/fs-safe/dist/replace-file-destination.d.ts +21 -0
  99. package/node_modules/@openclaw/fs-safe/dist/replace-file-destination.js +123 -0
  100. package/node_modules/@openclaw/fs-safe/dist/replace-file-mutation.d.ts +26 -0
  101. package/node_modules/@openclaw/fs-safe/dist/replace-file-mutation.js +47 -0
  102. package/node_modules/@openclaw/fs-safe/dist/replace-file-temp-owner.d.ts +2 -2
  103. package/node_modules/@openclaw/fs-safe/dist/replace-file-temp-owner.js +16 -4
  104. package/node_modules/@openclaw/fs-safe/dist/replace-file-types.d.ts +55 -0
  105. package/node_modules/@openclaw/fs-safe/dist/replace-file-types.js +1 -0
  106. package/node_modules/@openclaw/fs-safe/dist/replace-file.d.ts +3 -55
  107. package/node_modules/@openclaw/fs-safe/dist/replace-file.js +31 -11
  108. package/node_modules/@openclaw/fs-safe/dist/retained-directory-replacement.d.ts +1 -0
  109. package/node_modules/@openclaw/fs-safe/dist/retained-directory-replacement.js +1 -1
  110. package/node_modules/@openclaw/fs-safe/dist/retained-file-types.d.ts +50 -0
  111. package/node_modules/@openclaw/fs-safe/dist/retained-file-types.js +1 -0
  112. package/node_modules/@openclaw/fs-safe/dist/retained-file.d.ts +3 -0
  113. package/node_modules/@openclaw/fs-safe/dist/retained-file.js +121 -0
  114. package/node_modules/@openclaw/fs-safe/dist/root-context.js +3 -2
  115. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.d.ts +9 -0
  116. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +28 -0
  117. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +9 -1
  118. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +88 -37
  119. package/node_modules/@openclaw/fs-safe/dist/root-file-final-admission.d.ts +1 -1
  120. package/node_modules/@openclaw/fs-safe/dist/root-file-final-admission.js +1 -6
  121. package/node_modules/@openclaw/fs-safe/dist/root-file.js +2 -5
  122. package/node_modules/@openclaw/fs-safe/dist/root-handle-context.d.ts +4 -0
  123. package/node_modules/@openclaw/fs-safe/dist/root-handle-context.js +12 -0
  124. package/node_modules/@openclaw/fs-safe/dist/root-impl.d.ts +3 -3
  125. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +8 -5
  126. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +2 -7
  127. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +3 -3
  128. package/node_modules/@openclaw/fs-safe/dist/root-observed-path.d.ts +0 -1
  129. package/node_modules/@openclaw/fs-safe/dist/root-observed-path.js +0 -3
  130. package/node_modules/@openclaw/fs-safe/dist/root-path-observation.d.ts +4 -11
  131. package/node_modules/@openclaw/fs-safe/dist/root-path.js +7 -10
  132. package/node_modules/@openclaw/fs-safe/dist/root-paths.d.ts +2 -6
  133. package/node_modules/@openclaw/fs-safe/dist/root-remove-identity.d.ts +1 -3
  134. package/node_modules/@openclaw/fs-safe/dist/root-walk.d.ts +19 -12
  135. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +56 -20
  136. package/node_modules/@openclaw/fs-safe/dist/root-write-admission.js +1 -6
  137. package/node_modules/@openclaw/fs-safe/dist/root-write-complete-parent.d.ts +2 -0
  138. package/node_modules/@openclaw/fs-safe/dist/root-write-complete-parent.js +1 -1
  139. package/node_modules/@openclaw/fs-safe/dist/safe-path-segment.d.ts +1 -0
  140. package/node_modules/@openclaw/fs-safe/dist/safe-path-segment.js +8 -2
  141. package/node_modules/@openclaw/fs-safe/dist/secret-file.d.ts +6 -2
  142. package/node_modules/@openclaw/fs-safe/dist/secret-file.js +12 -16
  143. package/node_modules/@openclaw/fs-safe/dist/secret-read-policy.js +1 -1
  144. package/node_modules/@openclaw/fs-safe/dist/secure-file-windows.js +5 -14
  145. package/node_modules/@openclaw/fs-safe/dist/secure-file.js +3 -2
  146. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +2 -7
  147. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +4 -4
  148. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission-parser.d.ts +1 -2
  149. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-handle.d.ts +2 -8
  150. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-policy.d.ts +2 -7
  151. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-reclaim.d.ts +2 -2
  152. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-reclaim.js +11 -12
  153. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-stale-admission.d.ts +1 -5
  154. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +9 -7
  155. package/node_modules/@openclaw/fs-safe/dist/staged-symlink-types.d.ts +39 -0
  156. package/node_modules/@openclaw/fs-safe/dist/staged-symlink-types.js +1 -0
  157. package/node_modules/@openclaw/fs-safe/dist/stat-observation.js +3 -10
  158. package/node_modules/@openclaw/fs-safe/dist/strict-file-identity.d.ts +2 -0
  159. package/node_modules/@openclaw/fs-safe/dist/strict-file-identity.js +6 -6
  160. package/node_modules/@openclaw/fs-safe/dist/symlink-parents.js +58 -7
  161. package/node_modules/@openclaw/fs-safe/dist/temp-target.js +8 -10
  162. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +28 -33
  163. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +1 -1
  164. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +16 -17
  165. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +1 -2
  166. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.js +4 -9
  167. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-ownership.d.ts +8 -0
  168. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-ownership.js +52 -0
  169. package/node_modules/@openclaw/fs-safe/dist/test-hooks.d.ts +5 -1
  170. package/node_modules/@openclaw/fs-safe/dist/text-atomic.d.ts +2 -1
  171. package/node_modules/@openclaw/fs-safe/dist/text-atomic.js +2 -0
  172. package/node_modules/@openclaw/fs-safe/dist/trash.js +27 -1
  173. package/node_modules/@openclaw/fs-safe/dist/walk.d.ts +2 -5
  174. package/node_modules/@openclaw/fs-safe/dist/watch-alias.d.ts +6 -0
  175. package/node_modules/@openclaw/fs-safe/dist/watch-alias.js +88 -0
  176. package/node_modules/@openclaw/fs-safe/dist/watch-hints.d.ts +9 -0
  177. package/node_modules/@openclaw/fs-safe/dist/watch-hints.js +93 -0
  178. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +36 -0
  179. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +73 -0
  180. package/node_modules/@openclaw/fs-safe/dist/watch-scan.d.ts +28 -0
  181. package/node_modules/@openclaw/fs-safe/dist/watch-scan.js +300 -0
  182. package/node_modules/@openclaw/fs-safe/dist/watch-stream.d.ts +8 -0
  183. package/node_modules/@openclaw/fs-safe/dist/watch-stream.js +32 -0
  184. package/node_modules/@openclaw/fs-safe/dist/watch-types.d.ts +60 -0
  185. package/node_modules/@openclaw/fs-safe/dist/watch-types.js +1 -0
  186. package/node_modules/@openclaw/fs-safe/dist/watch.d.ts +5 -0
  187. package/node_modules/@openclaw/fs-safe/dist/watch.js +530 -0
  188. package/node_modules/@openclaw/fs-safe/dist/windows-owner.d.ts +3 -7
  189. package/node_modules/@openclaw/fs-safe/dist/windows-owner.js +0 -1
  190. package/node_modules/@openclaw/fs-safe/dist/windows-security-bridge.cs +6 -6
  191. package/node_modules/@openclaw/fs-safe/dist/windows-security-bridge.ps1 +78 -3
  192. package/node_modules/@openclaw/fs-safe/dist/windows-security-command.d.ts +8 -0
  193. package/node_modules/@openclaw/fs-safe/dist/windows-security-command.js +66 -15
  194. package/node_modules/@openclaw/fs-safe/dist/windows-security-facts.d.ts +4 -0
  195. package/node_modules/@openclaw/fs-safe/dist/windows-security-facts.js +6 -2
  196. package/node_modules/@openclaw/fs-safe/docs/advanced.md +4 -2
  197. package/node_modules/@openclaw/fs-safe/docs/archive.md +16 -0
  198. package/node_modules/@openclaw/fs-safe/docs/atomic.md +93 -3
  199. package/node_modules/@openclaw/fs-safe/docs/contributing.md +102 -4
  200. package/node_modules/@openclaw/fs-safe/docs/copy.md +2 -0
  201. package/node_modules/@openclaw/fs-safe/docs/durability.md +7 -0
  202. package/node_modules/@openclaw/fs-safe/docs/file-store.md +5 -0
  203. package/node_modules/@openclaw/fs-safe/docs/guest.md +7 -1
  204. package/node_modules/@openclaw/fs-safe/docs/index.md +1 -0
  205. package/node_modules/@openclaw/fs-safe/docs/install.md +30 -0
  206. package/node_modules/@openclaw/fs-safe/docs/native-helper.md +20 -4
  207. package/node_modules/@openclaw/fs-safe/docs/native.md +63 -1
  208. package/node_modules/@openclaw/fs-safe/docs/permissions.md +66 -0
  209. package/node_modules/@openclaw/fs-safe/docs/public-api.md +30 -3
  210. package/node_modules/@openclaw/fs-safe/docs/retained-file.md +115 -0
  211. package/node_modules/@openclaw/fs-safe/docs/root.md +35 -9
  212. package/node_modules/@openclaw/fs-safe/docs/secret-file.md +19 -0
  213. package/node_modules/@openclaw/fs-safe/docs/security-model.md +4 -1
  214. package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +5 -1
  215. package/node_modules/@openclaw/fs-safe/docs/staged-symlink.md +124 -0
  216. package/node_modules/@openclaw/fs-safe/docs/store.md +3 -1
  217. package/node_modules/@openclaw/fs-safe/docs/temp.md +24 -4
  218. package/node_modules/@openclaw/fs-safe/docs/testing.md +210 -0
  219. package/node_modules/@openclaw/fs-safe/docs/types.md +6 -0
  220. package/node_modules/@openclaw/fs-safe/docs/walk.md +34 -1
  221. package/node_modules/@openclaw/fs-safe/docs/watch.md +251 -0
  222. package/node_modules/@openclaw/fs-safe/docs/writing.md +21 -0
  223. package/node_modules/@openclaw/fs-safe/package.json +13 -9
  224. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  225. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  226. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  227. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  228. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  229. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  230. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  231. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  232. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  233. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  234. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  235. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  236. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  237. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  238. package/node_modules/p-limit/index.d.ts +2 -0
  239. package/node_modules/p-limit/index.js +17 -1
  240. package/node_modules/p-limit/package.json +1 -1
  241. package/node_modules/p-limit/readme.md +2 -0
  242. package/package.json +6 -6
  243. package/node_modules/@openclaw/fs-safe/dist/directory-mode-owner.d.ts +0 -20
  244. package/node_modules/@openclaw/fs-safe/dist/directory-mode-owner.js +0 -78
@@ -61,7 +61,9 @@ Always release locks in a `finally` block. Application-managed graceful shutdown
61
61
 
62
62
  Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. First registration is committed only after Node accepts the listener; synchronous `newListener` reentry fails closed and a thrown registration rolls back so a later acquisition can retry. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
63
63
 
64
- Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
64
+ 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. Raw acquisition, snapshot, and exit cleanup capture bigint device/inode values; unsafe numeric identities from older in-process receipts cannot authorize cleanup. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
65
+
66
+ Last-chance raw-lock exit cleanup requires known Windows device and inode values from both pathname observations and the opened descriptor. A zero value leaves the sidecar in place, including for token-owned locks. Known descriptor/path identity differences remain supported; a pathname identity change during the read still prevents cleanup.
65
67
 
66
68
  The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
67
69
 
@@ -267,6 +269,8 @@ Pass `lockRoot` to place sidecar create, read, verification, and removal behind
267
269
  an existing `Root` capability. `lockPath` must resolve inside that root.
268
270
  Identity-conditioned removal remains the only release and reclaim deletion
269
271
  path.
272
+ Root mutation refusals during stale removal propagate unchanged, including
273
+ `null`, `undefined`, and errors carrying `ENOENT`; they do not become missing-sidecar retries.
270
274
 
271
275
  Async Root-backed acquisition normalizes the target's parent without creating
272
276
  it, checking the retained Root before and after normalization. A deleted or
@@ -0,0 +1,124 @@
1
+ ---
2
+ title: Retained symlink publication
3
+ description: "Admit and retain an identified staged symlink for no-replace publication and explicit recovery."
4
+ ---
5
+
6
+ # Retained symlink publication
7
+
8
+ `retainSymlinkInDirectory()` from `@openclaw/fs-safe/advanced` admits an
9
+ **existing** direct-child symlink against caller-captured identity, ownership,
10
+ change time and target. It holds a no-follow descriptor to that inode and its
11
+ parent until cleanup. It does not create a symlink or infer ownership from a
12
+ same-target pathname observation. This dedicated lifecycle does not relax
13
+ `Root.move()` or regular-file staging's symlink rejection.
14
+
15
+ The caller owns staging and admission evidence. Create and inspect the stage
16
+ under the application's cooperative writer lock, and keep that lock through
17
+ admission. A historical stat is not a lifetime handle: if peers can recycle
18
+ inodes between capture and admission, identity numbers alone cannot establish
19
+ historical ownership. Once admitted, the retained descriptor prevents inode
20
+ reuse until this owner closes. Failed admission closes descriptors without
21
+ unlinking anything; all staging cleanup remains with the caller.
22
+
23
+ Linux uses `O_PATH | O_NOFOLLOW`; macOS uses metadata-only `O_EVTONLY | O_SYMLINK`
24
+ with nonblocking/no-controlling-terminal flags. A non-following preflight rejects
25
+ observed special files before open; the opened descriptor must still match.
26
+ Windows, native mode off, and missing or older bindings reject before namespace
27
+ mutation. No fallback interpreter, copy, following open, or public descriptor
28
+ is used. Symlinks must have one link and a UTF-8 target; their targets are never
29
+ opened or confined by this API. The application must authorize the target.
30
+
31
+ ## Example
32
+
33
+ ```ts
34
+ import {
35
+ retainSymlinkInDirectory,
36
+ type StagedSymlinkExpected,
37
+ type PublishedSymlinkReceipt,
38
+ } from "@openclaw/fs-safe/advanced";
39
+ import type { DirectoryReceipt } from "@openclaw/fs-safe/durability";
40
+
41
+ // Application owns stage creation, capture, locking and transaction policy.
42
+ export async function publishInstallerLink(options: {
43
+ directory: DirectoryReceipt;
44
+ stagedBasename: string;
45
+ expected: StagedSymlinkExpected;
46
+ finalBasename: string;
47
+ assertAuthorizedAndCurrent(): void;
48
+ }): Promise<PublishedSymlinkReceipt> {
49
+ await using staged = await retainSymlinkInDirectory({
50
+ directory: options.directory,
51
+ basename: options.stagedBasename,
52
+ expected: options.expected,
53
+ assertBeforeMutation: options.assertAuthorizedAndCurrent,
54
+ });
55
+ return await staged.publish(options.finalBasename);
56
+ }
57
+ ```
58
+
59
+ `expected` requires exact bigint `dev`, `ino`, `ctimeNs`, integer `uid`
60
+ and `gid`, and `target`. Admission snapshots these values and the parent
61
+ receipt. The frozen returned receipt contains `directory`,
62
+ `temporaryBasename`, `target`, and preparation-time `identity` metadata,
63
+ like [regular-file staging](staged-file.md). Rename may change timestamps;
64
+ the original receipt is never replaced by a post-publication identity.
65
+
66
+ ## Lifetime and outcomes
67
+
68
+ - `assertCurrent()` checks the admitted parent pathname, original name, retained
69
+ inode, single-link count, ownership, mode and target before publication.
70
+ - `publish(basename)` accepts a distinct portable direct-child name. It performs
71
+ the synchronous authority assertion, rechecks source and parent, and dispatches
72
+ guarded native no-replace rename. An existing file, symlink or directory is
73
+ never overwritten, and the link is never nested inside a destination directory.
74
+ - Successful dispatch is recorded as `published` **before** fallible postchecks.
75
+ A foreign same-target replacement fails verification without becoming owned.
76
+ `assertPublished()` checks against the still-retained inode.
77
+ - `removePublished()` is an explicit recovery action, not automatic rollback.
78
+ It reasserts authority and removes only an observed matching published link
79
+ through the retained original parent. It returns `removed`, `name-absent`,
80
+ or `preserved`. Its outcome or error is cached: it never retries an uncertain
81
+ unlink or touches a later replacement. It does not restore an old destination.
82
+ - `cleanup()` removes only an unattempted, still-owned stage, then closes both
83
+ handles. Published names are preserved. Indeterminate publication preserves
84
+ both names. Authority rejection during cleanup preserves the stage and still
85
+ closes handles. Repeated cleanup replays the cached result/error without
86
+ touching descriptor numbers.
87
+ - `await using` invokes cleanup and raises on a `preserved` outcome. Invocation
88
+ order serializes descriptor work; reentrant calls from the authority callback
89
+ reject. Callbacks must be synchronous; returned thenables and synchronous or
90
+ asynchronous generator objects are refused. Generators are never advanced.
91
+
92
+ Errors carry `StagedSymlinkFailureDetails` in `FsSafeError.details`: the phase,
93
+ recorded `publication`, and a cleanup receipt when applicable. Publication is
94
+ `not-published`, `published` (with the precaptured receipt), or
95
+ `indeterminate` (with the attempted name). Native errno, including a collision,
96
+ does not rule out a committed remote rename whose response was lost. Only
97
+ pre-dispatch rejection leaves publication `not-published`. After an indeterminate
98
+ result, this owner cannot publish again or remove the possibly published name.
99
+ If inspecting a native rename error itself fails, publication is likewise
100
+ `indeterminate`; the original thrown value remains the reported cause.
101
+
102
+ Cleanup records `temporaryBasename`, `publication`, `resources`
103
+ (`closed` or `close-failed`), and `status` (`removed`, `name-absent`,
104
+ `preserved`, `failed`, or `not-needed`). Failure closes every retained handle
105
+ once and aggregates cleanup/close errors. A failed explicit recovery unlink is
106
+ reported to its caller and is not converted into successful recovery by cleanup.
107
+ Thrown values whose error metadata cannot be inspected are reported as
108
+ `helper-failed` with the original value as `cause`; cleanup and recovery still
109
+ cache their terminal error without retrying callbacks or descriptor closes.
110
+
111
+ ## Limits
112
+
113
+ This is directory-relative targeting, **not CAS**. Identity checks followed by
114
+ rename or unlink are not atomic conditional mutations. An uncooperative writer
115
+ can replace a child in the final syscall gap; observed substitutes are preserved,
116
+ but callers must coordinate writers to exclude that gap. The retained parent
117
+ prevents redirection into a replacement parent; a parent move can still cause
118
+ publication in the moved original and a `published` postcheck failure.
119
+
120
+ No receipt promises crash durability or recovery after process death, arbitrary
121
+ renames, permission revocation or I/O failure. This API does not sync directories
122
+ or persist descriptors. Caller journals, state capture, conditional restoration,
123
+ durability, and transaction settlement remain separate requirements. Keep the
124
+ owner alive until the application's publication or recovery decision settles.
@@ -62,7 +62,9 @@ const pending = await loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix:
62
62
 
63
63
  `id` must be a single safe path segment: non-empty, not dot-prefixed, and made
64
64
  from letters, numbers, `_`, `-`, and `.`. Slashes, backslashes, NUL bytes, `.`,
65
- and `..` are rejected.
65
+ `..`, and Windows reserved device names such as `CON`, `NUL`, and `COM1` are
66
+ rejected on every platform so queue IDs remain portable. Temporary filenames
67
+ continue to suffix reserved stems (for example, `CON.txt` becomes `CON_.txt`).
66
68
 
67
69
  Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
68
70
  `moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
@@ -22,10 +22,11 @@ substitutions; the compatible JavaScript fallback has the narrower race
22
22
  contract documented below.
23
23
 
24
24
  On POSIX, workspace creation verifies the supplied root and its canonical
25
- ancestors before creating a child. Each existing directory must be owned by the
26
- effective user or root. Group/world-writable directories must also have the
27
- sticky bit, so ordinary system temp directories remain usable without changing
28
- their modes. Foreign-owned directories and non-sticky writable ancestors reject
25
+ ancestors before creating a child. The supplied root must be owned by the
26
+ effective user and must not be group/world writable, even with the sticky bit.
27
+ Use a private per-user directory rather than supplying a shared `/tmp` directly.
28
+ Ancestors must be owned by the effective user or root; group/world-writable
29
+ ancestors must have the sticky bit. Foreign-owned directories and non-sticky writable ancestors reject
29
30
  with `not-owned` or `insecure-permissions`; unavailable effective-user identity
30
31
  rejects with `permission-unverified`. Existing supplied directories keep their
31
32
  permissions. Missing root components are created at `0o700` and initialized
@@ -34,6 +35,25 @@ mode, correction uses a verified directory descriptor.
34
35
  An initial mode-descriptor admission error is preserved if closing that rejected
35
36
  descriptor also fails; close failures after successful admission remain reportable.
36
37
 
38
+ On Linux, non-identity `/proc/self/uid_map` and `/proc/self/gid_map` evidence
39
+ permits ancestors whose UID and GID equal unmapped kernel overflow IDs,
40
+ provided the same ancestor mode checks pass.
41
+ These owners are classified as **unmapped**, not verified root owners:
42
+ [Linux maps all unmapped owners to overflow IDs](https://man7.org/linux/man-pages/man7/user_namespaces.7.html).
43
+ Supporting systemd user services with `PrivateUsers=true` therefore trusts the
44
+ host directory hierarchy against unmapped host peers who own an ancestor and
45
+ can rename it. Sticky world-writable ancestors remain admitted because host
46
+ `/tmp` and `PrivateTmp` appear unmapped under `PrivateUsers`; refusing them would
47
+ disable the default system-temp layout even with a private per-user leaf root.
48
+ An unmapped host owner of such a sticky ancestor could rename its children,
49
+ but a normal host's `/tmp` is root-owned by construction. Mapped foreign owners
50
+ still reject, and this exception never applies to the supplied root or newly
51
+ created workspace. Both maps and mapped-owner exclusions are checked afresh
52
+ whenever admission relies on unmapped ownership, including identity replay;
53
+ unavailable namespace evidence leaves admission unchanged.
54
+ The first admitted unmapped ancestor emits `FS_SAFE_UNMAPPED_TEMP_ANCESTOR`
55
+ through Node's warning event. The warning contains no caller paths.
56
+
37
57
  For an already existing canonical root, discovery retains only its immutable
38
58
  exact identity. Cleanup-parent retention is provisional: after any native
39
59
  capability probe, creation captures and validates the complete ancestry,
@@ -1,5 +1,154 @@
1
1
  # Testing
2
2
 
3
+ ## Coverage gates
4
+
5
+ The coverage workflow measures `src/**/*.ts` with V8 on Linux, macOS, and
6
+ Windows, then merges their counters before enforcing the thresholds in
7
+ `vitest.config.ts`: lines 94%, statements 92%, functions 95%, and branches 89%.
8
+ `pnpm test:coverage:collect` disables per-OS thresholds so platform-only paths
9
+ are credited by their own OS; `pnpm test:coverage:merge` requires all three
10
+ reports and enforces the combined gate. The DACL batch child entrypoint also
11
+ runs in-process in a unit test so its protocol and budget handling are measured.
12
+
13
+ The Linux Rust job uses pinned `cargo-llvm-cov` and a pinned nightly compiler
14
+ for line and branch instrumentation. It combines native crate unit tests with
15
+ TS native suites running the instrumented addon, and exports LCOV plus separate
16
+ unit-only and combined summaries. The Rust line threshold is 71%; addon execution
17
+ must also increase covered Rust lines beyond the unit tests. Only native crate sources compiled on Linux are measured;
18
+ macOS/Windows Rust implementations and the archive WASM crate are outside this
19
+ report. Run `pnpm build` followed by `bash scripts/native-coverage.sh` on Linux
20
+ after installing the toolchain versions specified in `coverage.yml`.
21
+
22
+ Percentages complement behavioral gates: mutation-policy proof, nightly watch
23
+ stress, and platform lanes are equally important. High coverage cannot establish
24
+ root confinement, race safety, event delivery, or bounded resource retirement.
25
+
26
+ ## Watch stress campaign
27
+
28
+ Build from the exact revision being qualified with `pnpm install --frozen-lockfile`,
29
+ `pnpm native:build`, and `pnpm build`, then run on a disposable machine:
30
+
31
+ ```sh
32
+ node scripts/watch-stress.mjs --scenario all
33
+ ```
34
+
35
+ Individual scenario names are `scale`, `fanout`, `churn`, `lifecycle`,
36
+ `adversarial`, `limits`, `idle`, and `soak`. The runner uses plain Node and no
37
+ additional dependencies. Each scenario prints one JSON result line; progress
38
+ goes to stderr. `all` isolates scenarios in child processes and stops at the
39
+ first failure. The full qualification remains manual. A smaller nightly campaign runs outside
40
+ per-PR CI; it can also be dispatched with `watch-stress.yml`.
41
+
42
+ Run `node scripts/watch-stress.mjs --scenario oracle-selftest` first to check
43
+ that a poisoned cache fails comparison and can recover only after invalidation.
44
+ All fixture Roots live in `os.tmpdir()`. The consumer cache refreshes only from
45
+ `onInvalidate`, using guarded Root reads of invalidated paths/scopes. After
46
+ quiescence and a fresh `reconcile()`, checkpoints compare it with an independent
47
+ filesystem walk, including file-content hashes. A mismatch is a failure, with
48
+ no comparison retry or checkpoint-triggered cache refresh. Transient guarded
49
+ read failures retain already-invalidated consumer work and settle at 25 ms
50
+ intervals, with a 120-second flush deadline; these errors are counted in results.
51
+
52
+ Scale uses 50,000 files in 2,000 child directories. Fan-out checks 64 and 256
53
+ distinct Roots, one shared hub thread, and return to the warmed handle baseline.
54
+ Churn first creates 1,024 entries while JavaScript is blocked to exceed the
55
+ default 256-path detail budget, then runs at least five minutes with persistent
56
+ differences across 10,000-mutation batches and isolated edit latency measurements.
57
+ Lifecycle performs 10,000 ready/close cycles plus admission
58
+ cancellation, close-during-ready, 1,000 scope replacements, and callback-close.
59
+ Adversarial cases exercise Root swaps, outside symlinks, recursive deletion and
60
+ 10,000 same-name create/delete pairs. Soak runs 60 minutes, checking the oracle
61
+ each minute. Linux needs passwordless `sudo` for the limits scenario; it lowers
62
+ `fs.inotify.max_user_watches` to 1 in a child shell with a restoration trap and
63
+ verifies restoration. Never run that scenario on a shared production host.
64
+
65
+ RSS limits are fixed before execution: 512 MiB peak for churn/soak, at most
66
+ 64 MiB churn growth after warmup, and at most 32 MiB lifecycle growth after 3,000
67
+ cycles. Soak collects garbage twice at every checkpoint, limits collected-heap
68
+ and external-memory growth to 8 MiB after minute five, and requires the fitted
69
+ RSS slope over minutes 31–60 to stay at or below 1 MiB/minute. The runner launches
70
+ soak with `--expose-gc` automatically, including through `--scenario all`.
71
+ Reports include samples and fitted slopes. Idle runs for ten minutes
72
+ with 16 subscriptions and a one-hour reconciliation interval to isolate native
73
+ hub wakeups, requiring less than 1% of one CPU and, on Linux, at most 30 hub
74
+ context switches. macOS captures `ps -M`; Windows captures PowerShell thread
75
+ CPU time and handle counts. macOS descriptor counts use `lsof`.
76
+
77
+ The macOS limits case also exercises injected UserDropped/KernelDropped flags
78
+ through the native decoder and labels these as synthetic. Natural FSEvents drop
79
+ flags are not independently observable through the current public batch. Windows
80
+ records native overflow and recovery, but the shared batch does not distinguish
81
+ RDCW kernel-buffer loss from bounded native queue loss; a Windows qualification
82
+ must retain that limitation rather than call it proved kernel overflow.
83
+
84
+ ### Nightly workload
85
+
86
+ The nightly workflow runs on `ubuntu-latest` (x64), `ubuntu-24.04-arm`,
87
+ `macos-15` (arm64), `macos-15-intel`, and the Windows latest 16-core runner,
88
+ using Node 24 and freshly built native bindings. Each job has a 25-minute
89
+ budget, uploads one JSON result per scenario (including failures), and fails
90
+ if any scenario fails. Remaining scenarios still run after a failure. Only
91
+ Linux runs the kernel-limits scenario automatically.
92
+
93
+ The same harness accepts these environment overrides; defaults remain the
94
+ full qualification workload. Child processes inherit the settings, which are
95
+ recorded in every JSON result.
96
+
97
+ | Environment variable | Default | Nightly |
98
+ | --- | ---: | ---: |
99
+ | `FS_SAFE_STRESS_SCALE_DIRECTORIES` (25 files each) | 2,000 | 400 (10,000 files) |
100
+ | `FS_SAFE_STRESS_FANOUT` (maximum subscriptions) | 256 | 64 |
101
+ | `FS_SAFE_STRESS_CHURN_SECONDS` | 300 | 60 |
102
+ | `FS_SAFE_STRESS_LIFECYCLE_CYCLES` | 10,000 | 1,000 |
103
+ | `FS_SAFE_STRESS_IDLE_SECONDS` | 600 | 120 |
104
+ | `FS_SAFE_STRESS_SOAK_MINUTES` (minimum 10) | 60 | 10 |
105
+
106
+ Soaks shorter than 30 minutes keep the hard peak-RSS, collected-heap, and external-memory
107
+ growth gates, but report the second-half RSS slope and its limit without using it
108
+ to pass or fail (`memory.rssSlopeGated: false`): [#701](https://github.com/openclaw/fs-safe/pull/701)
109
+ found that V8 capacity expansion and allocator retention can raise RSS while live memory stays flat.
110
+ Runs of 30 minutes or longer enforce the same second-half slope limit; only runs of
111
+ at least 60 minutes report `memory.qualification: true`.
112
+ Lifecycle memory is sampled across ten intervals, with
113
+ the first two excluded as warm-up; idle's Linux wakeup bound scales with the
114
+ requested duration (three per minute). Adversarial cases remain unchanged.
115
+
116
+ ## Linux openat2 fallback
117
+
118
+ Build the host addon and package first. The test hook is cached with the native
119
+ capability probe; set it before starting the process, rather than changing it
120
+ between tests in one process:
121
+
122
+ ```bash
123
+ pnpm native:build
124
+ pnpm build
125
+ FS_SAFE_TEST_NO_OPENAT2=1 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-parity.test.ts test/linux-openat2-fallback.test.ts test/root-move-noreplace.test.ts test/root-move-native-integration.test.ts test/native-write-containment.test.ts
126
+ ```
127
+
128
+ On Linux, the seccomp harness also exercises the real syscall failure without
129
+ the environment hook. It needs a C compiler and permission to install an
130
+ unprivileged seccomp filter; it affects only its child process:
131
+
132
+ ```bash
133
+ cc test/fixtures/deny-openat2.c -o /tmp/fs-safe-deny-openat2
134
+ /tmp/fs-safe-deny-openat2 ENOSYS node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
135
+ /tmp/fs-safe-deny-openat2 EPERM node test/fixtures/linux-openat2-fallback.mjs "$PWD/native/fs-safe-native.linux-x64-gnu.node"
136
+ FS_SAFE_TEST_OPENAT2_FILTER=/tmp/fs-safe-deny-openat2 FS_SAFE_NATIVE_MODE=require pnpm test test/linux-openat2-parity.test.ts
137
+ ```
138
+
139
+ Use the matching native artifact filename on other Linux architectures/libcs.
140
+ The fixtures prove in-root alias operations and policy parity, nested moves,
141
+ collisions, read/write, traversal and escaping-link rejection, hardlink rejection,
142
+ cached selection, and `helper-unavailable` for strict
143
+ bounded cleanup. Bounded-cleanup success tests require real `openat2`; run the
144
+ full suite with the environment hook unset. PR CI's `Native check
145
+ (linux-x64-no-openat2)` runs the native Node suites and watch proofs with the
146
+ hook set. It replaces the four bounded-cleanup success suites and quarantine
147
+ success proof with the explicit refusal fixture above. XFS tree-clone proof
148
+ requires the same openat2/NO_XDEV primitive and runs in the ordinary Linux
149
+ lanes. Bun's full compatibility
150
+ suite remains in the normal native lanes, because it includes bounded cleanup.
151
+
3
152
  `@openclaw/fs-safe/test-hooks` exposes test-only injection points. Registration
4
153
  is allowed only when `process.env.NODE_ENV === "test"` or
5
154
  `process.env.VITEST === "true"`; registering a non-empty hook set elsewhere
@@ -158,6 +307,14 @@ For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) mak
158
307
 
159
308
  ## Repo test shards
160
309
 
310
+ On macOS, after building the native addon, run the native watch cleanup allocation
311
+ regression with `MallocStackLogging=1 node --expose-gc scripts/watch-cleanup-leak-proof.mjs`.
312
+ It compares `leaks` results before and after 100 and 1,000 subscription cycles,
313
+ requiring zero growth in leaked allocations. Allocation stacks are saved under
314
+ `.artifacts/watch-cleanup-leaks`. The native macOS CI lane runs this short proof;
315
+ the full stress qualification remains manual, with the smaller nightly
316
+ campaign above.
317
+
161
318
  Run the full local gate before handoff:
162
319
 
163
320
  ```sh
@@ -182,6 +339,59 @@ workspace reads that bypass pinned file descriptors.
182
339
 
183
340
  `pnpm check` also runs `pnpm lint:file-size`. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in `scripts/check-file-size.mjs`; do not increase those budgets as part of unrelated work.
184
341
 
342
+ ## Watch memory diagnosis
343
+
344
+ The original soak rule rejected more than 64 MiB RSS growth after minute five.
345
+ 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.
360
+
361
+ Build the native addon and package from the same revision, then run each control
362
+ in a fresh Node process on a disposable machine:
363
+
364
+ ```sh
365
+ node --expose-gc scripts/watch-memory.mjs --arm events --minutes 60 --output .artifacts/events
366
+ node --expose-gc scripts/watch-memory.mjs --arm none --minutes 30 --output .artifacts/none
367
+ node --expose-gc scripts/watch-memory.mjs --arm poll --minutes 30 --output .artifacts/poll
368
+ node --expose-gc scripts/watch-memory.mjs --arm lifecycle --minutes 30 --output .artifacts/lifecycle
369
+ node --expose-gc scripts/watch-memory.mjs --arm steady --minutes 30 --output .artifacts/steady
370
+ ```
371
+
372
+ `events` and `poll` combine low-rate edits, a 10,000-operation burst every fifth
373
+ minute, and subscription cycling. `steady` omits cycling; `lifecycle` omits
374
+ writes. `none` drives the same writer and consumer cache using explicit synthetic
375
+ invalidations, without constructing subscriptions. That arm is an allocation
376
+ control, not evidence of watcher correctness. Every arm checks its consumer
377
+ cache against an independent filesystem walk at each checkpoint.
378
+
379
+ The diagnostic runner emits JSONL to stdout and `memory.jsonl` in its output
380
+ directory. Each minute includes all five `process.memoryUsage()` fields before
381
+ and after collection, V8 heap-space capacity, Linux `smaps_rollup`, operation and
382
+ guarded-read counts, and live/created/destroyed native watch allocations.
383
+ `--gc none` measures the same workload without forced collection. `--snapshots`
384
+ writes V8 snapshots at minutes 5 and 30; use a separate run because snapshots
385
+ perturb memory usage. On macOS, `--tools` saves `vmmap --summary` and `leaks`
386
+ reports at minutes 5, 30, and 60. `--smoke` is a short calibration, explicitly
387
+ marked in output; it is not a duration-qualified soak.
388
+
389
+ The native allocation getter is internal and requires `NODE_ENV=test` or
390
+ `VITEST=true`; the runner sets the former. After close, it requires no live
391
+ registrations, pending sets, callback payloads, or thread-safe functions.
392
+ Diagnostic success establishes correctness and retirement; it does not classify
393
+ an RSS curve as a leak or automatically approve a memory budget.
394
+
185
395
  ## See also
186
396
 
187
397
  - [Security model](security-model.md) — what the boundary is supposed to defend; design tests around the same threats.
@@ -4,6 +4,12 @@ The types most callers reach for. Shared data shapes are exported from `@opencla
4
4
 
5
5
  For atomic replacement, `ReplaceFileAtomicFileSystem` and `ReplaceFileAtomicSyncFileSystem` are exported from `@openclaw/fs-safe/atomic`. Async adapters use `chmod()` on the `FileHandle` returned by their required `open()` operation. The synchronous type adds optional `fchmodSync(fd, mode)`; custom sync adapters that explicitly request `mode` or `preserveExistingMode` must implement it. See [Atomic writes](atomic.md#test-injection).
6
6
 
7
+ `ReplaceFileAtomicDestinationState` is also exported from that subpath. It is a
8
+ readonly union of `{ state: "removed", path }` and
9
+ `{ state: "writing" | "published", path, dev: bigint, ino: bigint }`. Both atomic
10
+ replacement option types accept synchronous `assertBeforeMutation` and
11
+ `onDestinationState` callbacks. See [authority and destination state](atomic.md#atomic-write-authority-and-destination-state).
12
+
7
13
  ```ts
8
14
  import type {
9
15
  BasePathOptions,
@@ -107,11 +107,44 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
107
107
  `Root.walk(rel, options)` is the root-bounded counterpart to these standalone
108
108
  inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
109
109
  accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
110
- "follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
110
+ "follow-within-root" | "include"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
111
111
  one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
112
112
  typed `FsSafeError("too-large")` instead.
113
113
 
114
114
  For followed symlinks, both `kind` and `size` describe the resolved target.
115
+ With `symlinkPolicy: "include"`, links retain `kind: "symlink"` and their own
116
+ size. Targets are neither resolved nor visited; dangling and outside-root links
117
+ are included. Filters receive these entries, and links consume the same entry
118
+ budget as other names. A directory replaced by a symlink after observation
119
+ fails with `path-mismatch` before descent, or produces a `directory-error`
120
+ entry when `onDirectoryError` is `"skip-and-report"`.
121
+ The starting directory retains existing Root path resolution; include mode
122
+ controls the entries beneath that directory.
123
+
124
+ ```ts
125
+ for await (const entry of capability.walk("", { symlinkPolicy: "include" })) {
126
+ if (entry.kind === "symlink") reportLink(entry.relativePath);
127
+ }
128
+ ```
129
+
130
+ Existing skip/follow calls keep their result types without a symlink variant.
131
+ For explicitly annotated include-mode values, use `RootWalkOptions<"include">`
132
+ and `RootWalkEntry<"include">`. `RootWalkSymlinkPolicy` describes all
133
+ three policies when the policy is selected dynamically; the unparameterized
134
+ entry and options types retain their previous shapes. Use
135
+ `RootWalkOptions<RootWalkSymlinkPolicy>` for a dynamically selected policy.
136
+
137
+ The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
138
+ the home directory when iteration starts and must resolve inside the Root.
139
+ Home-started walks report actual Root-relative paths, such as `home/dir/file`,
140
+ rather than `~/dir/file`. Use `./~/dir` to start at a literal `~` directory.
141
+ An alias within a home-started path is reported under its admitted canonical
142
+ target; ordinary non-home starting aliases retain their caller-supplied spelling.
143
+
144
+ Entry names remain literal filesystem data, including a directory named `~`
145
+ and its descendants. To reuse an entry path in another Root method without
146
+ home-directory expansion, prefix it with `./`, as in
147
+ `capability.open("./" + entry.relativePath)`.
115
148
 
116
149
  The default `order: "sorted"` visits each directory's names in lexicographic
117
150
  order before descending depth first. It reads and sorts all names in each