@openclaw/openshell-sandbox 2026.10.1-beta.2 → 2026.10.5-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 (126) hide show
  1. package/dist/index.js +69 -111
  2. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +91 -0
  3. package/node_modules/@openclaw/fs-safe/README.md +1 -1
  4. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -0
  5. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -0
  6. package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
  7. package/node_modules/@openclaw/fs-safe/dist/archive-entry.js +7 -3
  8. package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +4 -4
  9. package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
  10. package/node_modules/@openclaw/fs-safe/dist/archive-read.js +5 -4
  11. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
  12. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +22 -38
  13. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.d.ts +17 -0
  14. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.js +117 -0
  15. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +38 -0
  16. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +195 -0
  17. package/node_modules/@openclaw/fs-safe/dist/create.d.ts +1 -0
  18. package/node_modules/@openclaw/fs-safe/dist/create.js +4 -1
  19. package/node_modules/@openclaw/fs-safe/dist/entry-publication.js +3 -1
  20. package/node_modules/@openclaw/fs-safe/dist/errors.d.ts +2 -0
  21. package/node_modules/@openclaw/fs-safe/dist/errors.js +5 -0
  22. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.d.ts +3 -0
  23. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.js +57 -11
  24. package/node_modules/@openclaw/fs-safe/dist/guarded-mutation.js +5 -5
  25. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +52 -5
  26. package/node_modules/@openclaw/fs-safe/dist/guest.js +1 -1
  27. package/node_modules/@openclaw/fs-safe/dist/index.d.ts +1 -1
  28. package/node_modules/@openclaw/fs-safe/dist/index.js +1 -1
  29. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +4 -0
  30. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.d.ts +3 -0
  31. package/node_modules/@openclaw/fs-safe/dist/native-move-fallback.js +54 -0
  32. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.d.ts +8 -0
  33. package/node_modules/@openclaw/fs-safe/dist/native-noreplace.js +55 -0
  34. package/node_modules/@openclaw/fs-safe/dist/native-operations.d.ts +0 -1
  35. package/node_modules/@openclaw/fs-safe/dist/native-operations.js +22 -26
  36. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +5 -1
  37. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.d.ts +1 -1
  38. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write.js +12 -2
  39. package/node_modules/@openclaw/fs-safe/dist/native-rename-outcome.js +3 -1
  40. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.d.ts +1 -1
  41. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +52 -6
  42. package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +2 -1
  43. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.d.ts +2 -0
  44. package/node_modules/@openclaw/fs-safe/dist/pinned-write-input.js +17 -0
  45. package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +6 -1
  46. package/node_modules/@openclaw/fs-safe/dist/pinned-write.js +1 -1
  47. package/node_modules/@openclaw/fs-safe/dist/private-temp-workspace.js +2 -2
  48. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.d.ts +5 -0
  49. package/node_modules/@openclaw/fs-safe/dist/publish-file-failure.js +1 -0
  50. package/node_modules/@openclaw/fs-safe/dist/publish-file.d.ts +2 -0
  51. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +35 -2
  52. package/node_modules/@openclaw/fs-safe/dist/replace-directory.js +6 -5
  53. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +1 -4
  54. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +6 -0
  55. package/node_modules/@openclaw/fs-safe/dist/root-create-input.d.ts +15 -1
  56. package/node_modules/@openclaw/fs-safe/dist/root-create-input.js +4 -0
  57. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +1 -1
  58. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +0 -1
  59. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +6 -12
  60. package/node_modules/@openclaw/fs-safe/dist/root-errors.js +25 -4
  61. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +18 -18
  62. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.d.ts +4 -2
  63. package/node_modules/@openclaw/fs-safe/dist/root-move-noreplace.js +13 -17
  64. package/node_modules/@openclaw/fs-safe/dist/root-paths.js +1 -1
  65. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +4 -4
  66. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.d.ts +10 -1
  67. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.js +20 -0
  68. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +19 -7
  69. package/node_modules/@openclaw/fs-safe/dist/sibling-staged-file.js +18 -3
  70. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +25 -3
  71. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission.d.ts +2 -0
  72. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.d.ts +14 -0
  73. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +187 -0
  74. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +15 -1
  75. package/node_modules/@openclaw/fs-safe/dist/staged-file-settlement.js +4 -1
  76. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.d.ts +10 -0
  77. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.js +29 -0
  78. package/node_modules/@openclaw/fs-safe/dist/temp-target.js +1 -1
  79. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +6 -6
  80. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +4 -4
  81. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +17 -16
  82. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +20 -28
  83. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.d.ts +1 -0
  84. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-owner.js +27 -4
  85. package/node_modules/@openclaw/fs-safe/dist/walk.js +2 -13
  86. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +2 -2
  87. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +3 -1
  88. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +3 -9
  89. package/node_modules/@openclaw/fs-safe/dist/watch.js +2 -3
  90. package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -0
  91. package/node_modules/@openclaw/fs-safe/docs/archive.md +5 -1
  92. package/node_modules/@openclaw/fs-safe/docs/atomic.md +11 -3
  93. package/node_modules/@openclaw/fs-safe/docs/contributing.md +1 -1
  94. package/node_modules/@openclaw/fs-safe/docs/copy.md +109 -1
  95. package/node_modules/@openclaw/fs-safe/docs/durability.md +50 -5
  96. package/node_modules/@openclaw/fs-safe/docs/errors.md +37 -0
  97. package/node_modules/@openclaw/fs-safe/docs/guest.md +21 -4
  98. package/node_modules/@openclaw/fs-safe/docs/native-helper.md +5 -3
  99. package/node_modules/@openclaw/fs-safe/docs/native.md +39 -6
  100. package/node_modules/@openclaw/fs-safe/docs/path-scope.md +4 -0
  101. package/node_modules/@openclaw/fs-safe/docs/public-api.md +3 -1
  102. package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +46 -8
  103. package/node_modules/@openclaw/fs-safe/docs/staged-file.md +10 -3
  104. package/node_modules/@openclaw/fs-safe/docs/temp.md +12 -0
  105. package/node_modules/@openclaw/fs-safe/docs/watch.md +6 -3
  106. package/node_modules/@openclaw/fs-safe/docs/writing.md +40 -11
  107. package/node_modules/@openclaw/fs-safe/package.json +16 -16
  108. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  109. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  110. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  111. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  112. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  113. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  114. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  115. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  116. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  117. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  118. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  119. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  120. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  121. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  122. package/package.json +5 -5
  123. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.d.ts +0 -17
  124. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.js +0 -66
  125. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.d.ts +0 -7
  126. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.js +0 -16
@@ -191,7 +191,37 @@ try {
191
191
  |---|---|---|
192
192
  | `link-required` | Create a same-filesystem hardlink or propagate the failure. | No; guarded JS `link` fallback remains. |
193
193
  | `link-or-copy` | Try hardlink, then clone, Linux `copy_file_range`, then the JS byte loop for classified unsupported errors. | No; acceleration is optional. |
194
- | `rename-noreplace` | Atomically move the source without replacing an existing target. Success consumes `sourcePath`. | Yes. |
194
+ | `rename-noreplace` | Move the source without replacing an existing target; Linux `auto` can use identity-checked link/unlink when no-replace rename is unsupported. Success consumes `sourcePath`. | Yes. |
195
+
196
+ `rename-noreplace` normally uses atomic native rename. On Linux in native `auto`
197
+ mode, a definitively unsupported `RENAME_NOREPLACE` result (`EINVAL`, `ENOSYS`,
198
+ or unsupported-operation errno) permits the existing native file-move fallback.
199
+ It requires a regular source with one link, retains both parents, creates the
200
+ target with `linkat` without following symlinks, verifies both names against the
201
+ staged identity with exactly two links, then unlinks the source and verifies the
202
+ target has one link. Fallback collisions report `already-exists` without clobbering;
203
+ the atomic rename path retains its existing `EEXIST` error.
204
+ Success still returns `method: "rename-noreplace"`, with the additive
205
+ `fallback: "link-unlink"` field identifying this weaker mechanism. Atomic rename
206
+ and the other strategies omit `fallback`.
207
+
208
+ **Link/unlink is not crash-atomic.** A crash between those syscalls can leave both
209
+ names on the same complete inode. Callers must recognize that interrupted pair
210
+ as incomplete evidence or recover it under their own identity/ownership rules
211
+ before accepting a sealed artifact. The source parent is synchronized after
212
+ unlink, and the target parent is synchronized through the existing pinned
213
+ directory/`parentReceipt` path before success. These directory barriers do not
214
+ flush file contents: callers must finish and synchronize staged contents first.
215
+ Identity checks and syscalls remain separate, not source-name compare-and-swap;
216
+ detected substitutions refuse completion and preserve evidence.
217
+
218
+ Native `require` remains fail-closed with `helper-unavailable` and
219
+ `details.capability === "rename-noreplace"`. Missing helpers, native `off`, and
220
+ non-Linux platforms gain no fallback. If `linkat` returns `EPERM`, `EOPNOTSUPP`,
221
+ `EMLINK`, `EXDEV`, or `ENOSYS`, publication also fails closed, naming both
222
+ `rename-noreplace` and `details.fallbackCapability: "linkat"`. Permission or link
223
+ count limits need not mean the entire filesystem lacks hard-link support.
224
+ `link-required` and `link-or-copy` do not use `renameat2` and are unchanged.
195
225
 
196
226
  `"link-required"` propagates an unsupported hardlink failure.
197
227
  `"link-or-copy"` falls back only for `EPERM`, `EXDEV`, `ENOTSUP`,
@@ -339,6 +369,9 @@ type PublishFileExclusiveFailureDetails = {
339
369
  targetIdentity?: { dev: number | bigint; ino: number | bigint };
340
370
  cleanup: "removed" | "preserved" | "unknown";
341
371
  directorySync?: { status: "failed"; code?: string };
372
+ fallback?: "link-unlink";
373
+ publication?: "published";
374
+ sourceRemoval?: "not-attempted" | "still-linked" | "unverified" | "removed";
342
375
  };
343
376
  ```
344
377
 
@@ -352,6 +385,16 @@ this receipt instead of inferring ownership from path existence. The original
352
385
  failure remains available as `cause`. Failures before target creation retain
353
386
  their existing error shape and do not claim a cleanup result.
354
387
 
388
+ After a fallback link succeeds, failure always preserves the target, irrespective
389
+ of `onSyncFailure`. The receipt reports `fallback: "link-unlink"`,
390
+ `publication: "published"`, and the source-removal state. `still-linked` means
391
+ unlink failed and both names were reverified as the exact two-link pair;
392
+ `not-attempted` means pair verification failed before unlink; `unverified` means
393
+ unlink failed and the pair could no longer be verified; `removed` means unlink
394
+ completed but a later verification or sync failed. Publication describes the
395
+ completed link operation, not continuing authority over the target pathname.
396
+ Unverified targets do not receive a claimed `targetIdentity`.
397
+
355
398
  Source and target identities are checked again after successful or unsupported
356
399
  directory synchronization, while their descriptors remain owned. A late
357
400
  verification failure retains its strategy's verification phase and is not a
@@ -389,13 +432,15 @@ can make a failed directory sync succeed: rollback deletion is also not proven
389
432
  durable, and a preserved name may disappear after a crash. Always use the
390
433
  typed receipt rather than inferring ownership from `exists()`.
391
434
 
392
- `rename-noreplace` always preserves its target after a successful rename,
435
+ `rename-noreplace` always preserves its target after a successful rename or fallback link,
393
436
  because removing it would discard the source's only remaining name; its typed
394
437
  failure receipt makes that explicit regardless of `onSyncFailure`.
395
438
 
396
- `"rename-noreplace"` requires the native helper and atomically moves the
397
- source to the target without replacement. A collision is reported as
398
- `EEXIST`, both files remain unchanged, and a successful call returns
439
+ `"rename-noreplace"` requires the native helper and moves the
440
+ source to the target without replacement, with the Linux `auto` fallback limits
441
+ described above. A collision is reported as `EEXIST` for atomic rename or
442
+ `already-exists` for its Linux fallback, both files remain
443
+ unchanged, and a successful call returns
399
444
  `method: "rename-noreplace"` after synchronizing the source and target parent
400
445
  directories. Unlike the link/copy strategies, success consumes `sourcePath`.
401
446
 
@@ -45,6 +45,11 @@ failures with messages such as `permission denied (EACCES)` or
45
45
  code and `policy` category for compatibility, along with the original `cause`;
46
46
  they do not expose native message text or paths. Already-classified `FsSafeError`
47
47
  instances and missing-path errors keep their existing classification.
48
+ Disk-space diagnostics also survive combined publication and disposal failures,
49
+ including `SuppressedError` and `AggregateError`. They retain the original cause
50
+ graph and cleanup/publication receipts, without changing the compatibility code
51
+ or category. An uncertain native rename still reports indeterminate publication
52
+ and a preserved stage; `ENOSPC` does not prove that publication never happened.
48
53
 
49
54
  Descriptor exhaustion during writes uses `helper-failed` / `operational` and
50
55
  names `EMFILE` (process descriptor limit) or `ENFILE` (system descriptor limit)
@@ -254,3 +259,35 @@ Two reasons it isn't a richer hierarchy of subclasses:
254
259
  - [Reading](reading.md) — read-path codes.
255
260
  - [Writing](writing.md) — write-path codes.
256
261
  - [Archive extraction](archive.md) — `ArchiveLimitError` and `ArchiveSecurityError`.
262
+
263
+ ## Unsupported no-replace moves
264
+
265
+ `isNoReplaceUnsupported(error)` is exported from `@openclaw/fs-safe` and
266
+ `@openclaw/fs-safe/errors`. It recognizes a `helper-unavailable` `FsSafeError`
267
+ with `details.capability === "rename-noreplace"`. This includes strict native
268
+ publication refusals and a Root file move whose link fallback is also unavailable
269
+ (`details.fallback === "link-unlink"`, `details.fallbackCapability === "linkat"`).
270
+ It does not classify a missing native addon, unrelated permission errors, or
271
+ post-publication failures. Once no-replace rename has been rejected, a denied
272
+ or unsupported `linkat` fallback (`EPERM`, `EOPNOTSUPP`, `EMLINK`, `EXDEV`,
273
+ or `ENOSYS`) also fails with this classification. `EPERM` can reflect source
274
+ permissions or inode flags rather than a filesystem-wide lack of hard links;
275
+ the fallback is unavailable for this move. No link-capability cache is populated.
276
+ Consumers need not inspect internal native cause codes.
277
+ The classifier is diagnostic; it does not authorize a replacing retry.
278
+
279
+ When a Root file move's link/unlink fallback fails after creating the target,
280
+ `details.operation` is `"move"`, `details.fallback` is `"link-unlink"`, and
281
+ `details.publication` is `"published"` (the link was created, not a guarantee
282
+ that a concurrent actor has left it unchanged). `details.sourceRemoval` is:
283
+
284
+ | Value | Observation |
285
+ | --- | --- |
286
+ | `"not-attempted"` | Identity or link-pair verification failed; source unlink was refused (`path-mismatch`). |
287
+ | `"still-linked"` | Unlink failed and both original inode links were reverified (`helper-failed`). |
288
+ | `"unverified"` | Unlink failed and the current pair could not be verified (`helper-failed`). |
289
+ | `"removed"` | Source unlink succeeded but final target verification failed (`helper-failed`). |
290
+
291
+ The target is never deleted to roll back these failures. These are observations,
292
+ not locks; inspect the names and identities before recovery. Interrupted processes
293
+ can likewise leave both links, and default hardlink rejection remains enabled.
@@ -43,9 +43,26 @@ The fragment has no argument preflight of its own.
43
43
 
44
44
  On Linux it uses `renameat2(RENAME_NOREPLACE)`; on macOS it uses
45
45
  `renameatx_np(RENAME_EXCL)`. If Linux lacks that function or reports it as
46
- unsupported, it falls back to `link(..., follow_symlinks=False)`, leaving the
47
- source entry for caller cleanup. That fallback supports files, not directory
48
- publication. Existing destinations are never replaced by this fragment.
46
+ unsupported, it falls back to `link(..., follow_symlinks=False)`, verifying the
47
+ source and target against the admitted inode with exactly two links
48
+ before unlinking the source. Admission and final target verification require
49
+ one link. A successful file publication consumes the source in both paths.
50
+
51
+ For directories, a definitive Linux rejection (`EINVAL`, `ENOSYS`, `ENOTSUP`,
52
+ or `EOPNOTSUPP`) or missing `renameat2` rechecks the source identity and target
53
+ absence, then uses plain rename through the same directory descriptors. A
54
+ trailing slash enforces directory source type at the syscall. Directory rename
55
+ can replace only an **empty directory created concurrently**; non-empty
56
+ directories and non-directory targets are refused as `EEXIST`. An existing
57
+ empty directory observed by the absence check is also refused. No empty claim
58
+ directory is exposed before publishing the completed source.
59
+
60
+ Identity checks and mutation remain separate, not source-name compare-and-swap.
61
+ The fallback verifies the published identity afterward. A file fallback failure
62
+ can leave both names, and post-publication failures preserve the destination
63
+ for caller reconciliation. Exclusive-create cleanup preserves changed payloads
64
+ and incomplete link pairs. The guest has no strict/require mode; host native
65
+ policy does not apply. macOS and other errors retain their fail-closed behavior.
49
66
 
50
67
  ## Invocation protocol
51
68
 
@@ -107,7 +124,7 @@ entries, including symlinks, without following them. A force removal tolerates
107
124
  a missing leaf but does not suppress failure to open its parent.
108
125
 
109
126
  Write preserves an existing regular file's mode and otherwise creates private
110
- files. Copy preserves the source mode. Exclusive create publishes a mode-0600
127
+ files. Copy and cross-device file moves preserve the admitted source descriptor's mode. Exclusive create publishes a mode-0600
111
128
  file from a private staging directory. Staging names retain the `.openclaw-*`
112
129
  prefixes and use short random suffixes independent of the destination basename,
113
130
  so legal names near the filesystem's component limit also work for writes and
@@ -94,9 +94,11 @@ through the retained parents without another receipt, duplicate, reopen, or
94
94
  macOS `F_GETPATH`; the documented final source-name substitution window remains
95
95
  there. Deeper names retain guarded parent traversal.
96
96
 
97
- No-clobber `Root.move()` fails with `helper-unavailable` if descriptor-relative
98
- parent admission or atomic no-replace rename is unavailable; it never substitutes
99
- a check followed by a replacing rename. See the
97
+ No-clobber `Root.move()` requires descriptor-relative parent admission. On Linux,
98
+ `auto` can use identity-checked link/unlink for files or plain descriptor-relative
99
+ rename for directories when `RENAME_NOREPLACE` is unsupported. Directory fallback
100
+ can replace an empty directory created concurrently; `require` stays strict.
101
+ See the [move contract](writing.md#fs-move-from-to-options) and the
100
102
  [fallback contract](native.md#javascript-fallback-guarantees-and-delta).
101
103
 
102
104
  Guarded JavaScript mutations can have out-of-root effects before a post-check
@@ -127,11 +127,44 @@ is the same documented containment class as the macOS and JavaScript paths;
127
127
  applications requiring atomic beneath resolution must check the result or use
128
128
  OS isolation. Rejection after a mutating open does not promise rollback.
129
129
 
130
- Nested no-clobber `Root.move()` still admits both parents and uses
131
- `renameat2(RENAME_NOREPLACE)`. Existing destinations are never overwritten.
132
- That separate syscall/filesystem capability remains required; if unavailable,
133
- the operation fails with `helper-unavailable`. Turning native mode `off` still
134
- disables no-clobber moves because Node has no equivalent atomic rename API.
130
+ Nested no-clobber `Root.move()` admits both parents and first uses
131
+ `renameat2(RENAME_NOREPLACE)`. Linux `auto` can fall back through those same
132
+ descriptors to identity-checked file link/unlink or directory `renameat`;
133
+ the directory fallback can replace an empty directory created concurrently.
134
+ See the [move contract](writing.md#fs-move-from-to-options) for identity windows
135
+ and partial-state receipts. `require` rejects an unsupported capability.
136
+ Turning native mode `off` still disables no-clobber moves.
137
+
138
+ For regular-file writes with no-clobber publication (including `Root.create()`
139
+ and archive file extraction), `auto` removes its identity-checked unpublished
140
+ stage and retries through the same guarded JavaScript path as native mode `off`
141
+ when Linux rejects `RENAME_NOREPLACE` with `EINVAL`, `ENOSYS`, or unsupported-operation
142
+ errno. This is a definite non-publication, not an uncertain remote rename.
143
+ Completed streams are replayed from the retained stage descriptor, without
144
+ calling their producer again. Unsupported capability is cached by the retained
145
+ parent's exact device number in a process-local cache bounded to 128 devices.
146
+ Only distinct sibling publication/quarantine/move failures populate that cache; generic
147
+ cross-parent moves can also reject directory ancestry after a source swap.
148
+ Other devices and replacement writes retain their native route. `require`
149
+ cleans the stage and reports `helper-unavailable`, naming the operation and
150
+ `renameat2 RENAME_NOREPLACE`. Failed cleanup prevents retry.
151
+ Copies explicitly requiring `clone: "always"` also fail closed: the JavaScript
152
+ byte-copy path cannot satisfy that requirement.
153
+
154
+ Private producer workspace promotion likewise uses its existing guarded
155
+ JavaScript handoff in `auto`, preserving the completed producer output.
156
+ Explicit native-only staged, retained-entry, symlink and directory-replacement
157
+ contracts fail closed with the same capability
158
+ diagnostic. Compatible temp-workspace cleanup can use its existing guarded
159
+ JavaScript quarantine/removal path; `require-bounded` and native `require` refuse
160
+ that downgrade. These publication/cleanup owners keep their own recovery
161
+ contracts. Regular-file `publishFileExclusive` with `rename-noreplace` also opts
162
+ into the retained-parent file-move fallback in Linux `auto`, reporting
163
+ `fallback: "link-unlink"`; `require` still refuses. This fallback is not
164
+ crash-atomic: see [publication strategies](durability.md#strategies) and partial
165
+ failure receipts. The standalone
166
+ `movePathWithCopyFallback()` uses guarded replacing rename and its existing
167
+ cross-device copy policy, so unsupported no-replace flags do not affect it.
135
168
 
136
169
  Bounded owned-tree cleanup deliberately has no `openat` fallback:
137
170
  `RESOLVE_NO_XDEV` rejects bind mounts even when device numbers match, which
@@ -311,7 +344,7 @@ See [Root containment guarantees](security-model.md#containment-guarantees-by-pl
311
344
  | ZIP/TAR/gzip | Rust streaming decode and fd-relative output creation. | Optional JSZip or bundled WASM TAR into guarded private staging, then the same guarded merge policy. |
312
345
  | Zstd/bzip2 TAR | Rust streaming decode and fd-relative output creation. | Bundled WASM codecs feed the shared Rust TAR parser, then guarded private staging and the same merge policy; no optional codec dependency. |
313
346
  | Publication copy | Clone, Linux `copy_file_range`, async native SHA-256. | Exclusive `wx` byte loop and Node SHA-256 with the same content/identity fences. |
314
- | `rename-noreplace` | Atomic platform no-replace rename. | Unsupported; no emulation by check-then-rename. |
347
+ | `rename-noreplace` | Atomic platform no-replace rename; Linux `auto` file publication can use identity-checked, non-crash-atomic link/unlink when unsupported. | Unsupported; no emulation by check-then-rename. |
315
348
  | Windows DACL read | Direct `GetSecurityInfo`; the public facts API exposes ordered basic allow/deny ACE SIDs, masks, and decoded flags without trust policy. Secure-file reads query the borrowed open descriptor and compare its 32-bit volume serial and 64-bit file-index projection with Node's bigint receipt. | The packaged PowerShell/C# bridge preserves raw facts and inspects the borrowed descriptor for secure reads, with the same Node identity comparison; its command failures reject. Structured .NET pathname reporting retains its separate compatibility query. |
316
349
  | Windows private directory | Creation-time protected DACL. | The packaged PowerShell/C# bridge applies the protected DACL at creation and retains exact handles through identity validation and failure cleanup. Command failures reject. |
317
350
 
@@ -1,5 +1,9 @@
1
1
  # pathScope()
2
2
 
3
+ Paths supplied to a scope are literal filesystem names. In particular, `~/file`
4
+ selects a file inside a directory named `~` beneath the scope; it does not expand
5
+ the user's home directory.
6
+
3
7
  `pathScope()` prepares absolute paths and returns plain `{ ok, path }` results. `resolve()` and `resolveAll()` check lexical containment without touching the filesystem; `existing()`, `files()`, and `writable()` add the filesystem checks described below. Use it to prepare paths before handing them to another library, whose file-opening and mutation behavior still applies.
4
8
 
5
9
  ```ts
@@ -24,7 +24,9 @@ The handle resolver verifies exact descriptor and pathname identities, with one
24
24
  bounded retry for unknown Windows observations. It borrows the handle without
25
25
  reading, reopening, closing it, or changing its cursor.
26
26
 
27
- The error helpers are `categorizeFsSafeError` and `FsSafeErrorDetails`.
27
+ The error helpers are `categorizeFsSafeError`, `isNoReplaceUnsupported`, and
28
+ `FsSafeErrorDetails`. The no-replace classifier recognizes the documented
29
+ capability refusal without matching internal cause codes.
28
30
 
29
31
  ## `path` and `advanced`
30
32
 
@@ -35,7 +35,7 @@ try {
35
35
 
36
36
  The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
37
37
 
38
- On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their retained Root capability and ownership receipt. The synchronous `process.on("exit")` handler provides last-chance cleanup for raw locks and raw-path reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first.
38
+ On natural event-loop shutdown, a globally deduplicated `process.on("beforeExit")` handler attempts asynchronous cleanup of held Root-backed locks through their creation receipt (or the Root capability when native retention is unavailable). The synchronous `process.on("exit")` handler also cleans retained native sidecars, raw locks, and raw-path reclaim guards. Changed sidecars and failed Root cleanup remain in place; cleanup does not keep retrying during shutdown unless another acquisition re-arms it. Locks acquired with `retainOnExit: true` are exempt from both handlers: their sidecar stays in place after exit and is governed only by the caller's own stale policy. Because exit handlers are globally deduplicated across package copies, `retainOnExit` fails closed with `helper-unavailable` if an older copy that cannot honor it registered the handlers first.
39
39
 
40
40
  Asynchronous Root-backed stale recovery uses a regular file at the
41
41
  `.reclaim` name, created, verified, and removed through that Root. Its ownership
@@ -57,17 +57,17 @@ even if guard ownership has also changed.
57
57
  Root guard; let its original attempt settle before retrying that guarded path.
58
58
  It stops compromise monitoring for forgotten holders, including callbacks from checks already in flight.
59
59
 
60
- Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()`, uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) do not reliably run asynchronous Root cleanup and may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
60
+ Always release locks in a `finally` block. Application-managed graceful shutdown can await `release()` or `manager.drain()` before terminating. Explicit `process.exit()` runs synchronous cleanup for retained native sidecars, but cannot run asynchronous fallback Root cleanup. Uncaught failures, crashes, default signal handling, and fatal termination (including `SIGKILL`) may leave sidecars. Recover only after an application-owned liveness policy proves the holder cannot still be writing.
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. 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.
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. Raw and JavaScript fallback release compare the in-memory token and exact serialized bytes, and require the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. Retained native Root locks additionally require the exact created inode and a single link, as described below. 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
65
 
66
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.
67
67
 
68
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.
69
69
 
70
- `release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. The handle and manager retain the exact cleanup receipt after a failure, so the same handle can retry `release()` and `manager.drain()` can retry retained cleanup. A changed sidecar remains an ownership mismatch rather than a deletion failure and is left untouched. If both a `withFileLock()` or `withFileLockSync()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed asynchronous acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
70
+ `release()` propagates an I/O failure that prevents deletion of an unchanged, owned sidecar; it never reports successful cleanup while leaving that lock behind. Raw and JavaScript fallback handles and managers retain the cleanup receipt after an I/O failure, so the same handle can retry `release()` and `manager.drain()` can retry cleanup. Retained native Root locks settle once and close their retained handles on every outcome, including failure; a failed deletion requires application-owned recovery. A changed sidecar is left untouched. Retained native Root release reports `FsSafeError` code `path-mismatch`, distinguishing a changed identity from a changed owner record; this includes a replacement inode containing an exact copy of the original bytes. If both a `withFileLock()` or `withFileLockSync()` callback and release fail, the release error is the primary `SuppressedError.error` and the callback failure remains available as `SuppressedError.suppressed`. Failed asynchronous acquisition cleanup uses the same shape, with the cleanup error primary and the acquisition failure suppressed. On Node runtimes without the global `SuppressedError` constructor, fs-safe returns the equivalent `Error` shape with the same name and properties.
71
71
 
72
72
  ## API
73
73
 
@@ -265,10 +265,48 @@ its separate canonical-path mutation queue. The synchronous APIs implement the
265
265
  same owner/refcount rules; a mismatched synchronous acquisition blocks the
266
266
  calling thread according to its retry and timeout options.
267
267
 
268
- Pass `lockRoot` to place sidecar create, read, verification, and removal behind
269
- an existing `Root` capability. `lockPath` must resolve inside that root.
270
- Identity-conditioned removal remains the only release and reclaim deletion
271
- path.
268
+ Pass `lockRoot` to admit sidecar creation, reads, and verification through an
269
+ existing `Root` capability. `lockPath` must resolve inside that root.
270
+
271
+ With the maintained native helper, asynchronous acquisition retains cleanup
272
+ authority over only the sidecar it created: an identity-checked parent descriptor
273
+ and file descriptor on POSIX, or a shared-delete parent directory handle on Windows
274
+ (local NTFS). `release()` and process-exit cleanup verify the original named
275
+ entry's exact identity, regular-file type, single link, and complete owner record
276
+ through those retained handles. They then use guarded descriptor-relative removal
277
+ or Windows disposition-delete, without resolving the Root again. Cleanup therefore
278
+ works after the Root moves, is replaced, or its parent becomes a symlink. A foreign
279
+ entry at either the original Root path or the retained sidecar name is preserved.
280
+ The retained handles close on success, mismatch, failed acquisition, and exit
281
+ (including `retainOnExit`, which closes handles without deleting).
282
+ If retention itself fails after creation, rollback checks the original identity
283
+ and owner bytes through the creator's still-open descriptors before removing the
284
+ claim. A changed entry is preserved and both the retention and cleanup errors are
285
+ reported; cleanup never falls back to token-only deletion of a replacement.
286
+ Retention duplicates the already-admitted parent authority instead of walking
287
+ its pathname again or requiring extra access to its ancestors.
288
+ Windows opens the named file through the retained parent only for settlement,
289
+ with delete access and sharing; keeping a file handle open would prevent ordinary
290
+ directory relocation. Retained cleanup compares the owner bytes directly and does
291
+ not invoke `parsePayload`; verification and stale-policy reads still do.
292
+ Windows permits renaming that retained parent itself, but refuses renaming an
293
+ ancestor containing the open directory handle, even with delete sharing and no
294
+ data access. Such a refused move leaves the Root and lock in place; release or
295
+ exit cleanup closes the pin so the ancestor can then move. POSIX also supports
296
+ cleanup after a successful ancestor move or symlink swap.
297
+
298
+ `verifyStillHeld()` retains its Root-based semantics and can still reject a moved
299
+ Root even though `release()` can remove its own sidecar. This creation-specific
300
+ cleanup receipt does not authorize other Root operations or stale reclamation;
301
+ those continue to enforce current confinement and mutation policy. As with the
302
+ sidecar protocol generally, the POSIX final identity check and unlink are not an
303
+ atomic compare-and-delete against an uncooperative process racing the same name.
304
+
305
+ When native retention is unavailable or explicitly disabled, release keeps the
306
+ existing Root-based fail-closed behavior; a moved Root can leave a sidecar requiring
307
+ application-owned recovery. Use the matching maintained native package to obtain
308
+ relocation-safe cleanup. Synchronous Root locks keep their existing Root authority.
309
+ Identity-conditioned removal remains the only release and reclaim deletion path.
272
310
  Root mutation refusals during stale removal propagate unchanged, including
273
311
  `null`, `undefined`, and errors carrying `ENOENT`; they do not become missing-sidecar retries.
274
312
 
@@ -114,10 +114,17 @@ drive-relative spellings, and the stage's own name are rejected.
114
114
  With `overwrite: false`, publication is genuine kernel no-replace rename.
115
115
  A native collision raises `FsSafeError("already-exists")`, but ordinary errno
116
116
  does not prove that a remote rename never committed. Native rename failures
117
- without explicit pre-dispatch provenance therefore report `indeterminate`:
117
+ without explicit non-publication provenance therefore report `indeterminate`:
118
118
  cleanup preserves names and closes descriptors, and further publication rejects.
119
- Only rejection before rename dispatch leaves the stage eligible for cleanup or
120
- publication under another name. With
119
+ Rejection before rename dispatch, or an unsupported no-replace capability,
120
+ leaves the stage eligible for identity-checked cleanup. Linux `EINVAL` for the
121
+ fixed `RENAME_NOREPLACE` flag on a regular file renamed to a distinct sibling,
122
+ `ENOSYS`, and unsupported-operation errno prove that nothing was published
123
+ ([rename(2)](https://man7.org/linux/man-pages/man2/rename.2.html),
124
+ [VFS rename contract](https://www.kernel.org/doc/html/latest/filesystems/vfs.html)).
125
+ This native-only API reports `helper-unavailable` with the capability name;
126
+ it does not retry through JavaScript. `Root.create()` in native `auto` mode owns
127
+ that separate compatibility retry. With
121
128
  `overwrite: true`, publication is plain atomic replacement. Neither route
122
129
  copies. Both source and destination resolve through the retained original
123
130
  parent, with checks immediately before rename and after publication.
@@ -35,6 +35,18 @@ mode, correction uses a verified directory descriptor.
35
35
  An initial mode-descriptor admission error is preserved if closing that rejected
36
36
  descriptor also fails; close failures after successful admission remain reportable.
37
37
 
38
+ These rules apply equally to effective UID 0: root-owned `0700` or `0755` temp
39
+ roots beneath root-owned sticky `1777` `/tmp` are accepted. Passing `/tmp` itself
40
+ as `rootDir` is rejected because the workspace root must be private. Permission
41
+ and ownership errors name the rejected directory (including an unsafe ancestor),
42
+ the observed mode or owner UID, the expected property, and a recovery path.
43
+ Prefer `resolveSecureTempRoot()` or a dedicated directory owned by the effective
44
+ user with mode `0700`. For a non-sticky writable ancestor, have its administrator
45
+ restore appropriate permissions (`1777` for shared system temp) or move the root
46
+ under trusted ancestry. Do not make shared `/tmp` private or take ownership of
47
+ another user's directory. A caller's preferred root takes precedence over
48
+ `TMPDIR`; changing `TMPDIR` only changes the resolver's fallback location.
49
+
38
50
  On Linux, non-identity `/proc/self/uid_map` and `/proc/self/gid_map` evidence
39
51
  permits ancestors whose UID and GID equal unmapped kernel overflow IDs,
40
52
  provided the same ancestor mode checks pass.
@@ -131,7 +131,9 @@ and, when present, the entry itself. Parent activity requests a guarded scan
131
131
  without supplying filenames. The entry descriptor covers content and attribute
132
132
  changes and is replaced when a guarded scan admits a new identity. Missing paths
133
133
  use their nearest admitted ancestor. Descriptors are opened without following
134
- symlinks; a symlink entry uses `O_SYMLINK` to observe the link itself. Every
134
+ symlinks; a symlink entry uses `O_SYMLINK` to observe the link itself. All
135
+ non-directory entries are opened nonblocking so a FIFO cannot stall the shared
136
+ watch hub. Every
135
137
  descriptor's device/inode must match the guarded observation. There are at most
136
138
  two retained descriptors per entry scope (128 scopes maximum), counted in health
137
139
  `directories`; descriptor exhaustion fails registration with `EMFILE`. Removal
@@ -150,8 +152,9 @@ FSEvents drops, including during recursive deletion. A shallow tree anchored abo
150
152
  a busy unselected subtree still receives recursive traffic; pending hints fold
151
153
  under pressure, while genuine FSEvents drops can still overflow.
152
154
  Absolute hints are reduced lexically against the admitted canonical Root;
153
- outside paths never become detail. Dropped/wrapped streams, RootChanged and
154
- Unmount trigger guarded reconciliation. Pathname hints can reflect activity
155
+ outside paths never become detail. Dropped/wrapped streams, RootChanged, Unmount
156
+ and notifications naming the Root itself trigger guarded reconciliation without detail.
157
+ Pathname hints can reflect activity
155
158
  after a swap, but the Root is never replaced and names require guarded admission.
156
159
  Removal synchronously stops, invalidates and releases the stream on its queue.
157
160
 
@@ -367,6 +367,11 @@ For high-volume logging, consider [`openWritable`](#openwritable-for-streaming)
367
367
 
368
368
  ### `fs.copyIn(rel, sourceAbsPath, options?)`
369
369
 
370
+ Synchronous module hooks can use [`copyRootFileSync`](copy.md#synchronous-guarded-file-copies)
371
+ from `@openclaw/fs-safe/advanced` to create an absent destination and retain its
372
+ descriptor for hashing. Root itself remains asynchronous.
373
+
374
+
370
375
  `copyIn` accepts a `RootCopySource`: a trusted absolute source path or a file
371
376
  within another Root. The guarded form supplies `root` with only its `open` and
372
377
  `stat` read capabilities, plus `relativePath`:
@@ -416,8 +421,11 @@ default to `"auto"`:
416
421
  | `auto` | Try native file cloning, then copy offload or ordinary byte copying when cloning is unavailable. |
417
422
  | `always` | Require native cloning; fail when the binding or filesystem cannot provide it. |
418
423
 
419
- Native file cloning supports APFS and supported Linux filesystems. Windows
420
- currently uses byte copying for `never` and `auto`; `always` fails. Clone choice
424
+ Native file cloning supports APFS and supported Linux filesystems. On Linux, a
425
+ `FICLONE` request denied with `EPERM`, as container seccomp profiles commonly
426
+ do, counts as unavailable cloning: `auto` copies bytes and `always` fails with
427
+ `unsupported-platform`. Windows currently uses byte copying for `never` and
428
+ `auto`; `always` fails. Clone choice
421
429
  does not change modes, durability, root confinement, or source and publication
422
430
  identity checks. The shared strategy does not replace Root's guarded regular-file
423
431
  contract with `copyTree`'s caller-owned immutable-tree and metadata contract.
@@ -460,17 +468,38 @@ Mutation policy is captured at call start; changes to caller-owned denial arrays
460
468
  apply to later moves. For live cancellation or revocation, throw from
461
469
  `assertBeforeMutation` immediately before dispatch.
462
470
 
463
- The default no-clobber mode requires the native helper. It admits both parent
464
- directory descriptors and performs a descriptor-relative no-replace rename, so
465
- a competitor that creates the target first is preserved and the source remains
466
- in place. If the helper or safe parent admission is unavailable, the call fails
467
- with `helper-unavailable`; it never falls back to a check followed by a
468
- replacing rename. After dispatch it rechecks both parent identities, so a
469
- post-operation rejection can mean the no-replace rename completed. Directory
470
- moves continue to require `overwrite: true`.
471
+ The default no-clobber mode supports regular files and directories and requires
472
+ the native helper. It admits both parent directory descriptors and first uses
473
+ descriptor-relative no-replace rename. If the helper or safe parent admission
474
+ is unavailable, the call fails with `helper-unavailable`. After dispatch it
475
+ rechecks both parent identities; rejection does not imply rollback.
476
+
477
+ On Linux filesystems that reject `renameat2(RENAME_NOREPLACE)`, `auto` uses the
478
+ same retained parents and rechecks the source identity before falling back:
479
+
480
+ - Regular files use `linkat` without following symlinks, verify the exact
481
+ source/target inode pair with two links, then recheck the source before
482
+ `unlinkat`. Existing targets cannot be overwritten. Only this intermediate
483
+ pair permits two links; admission and completed publication require one.
484
+ Denied or unsupported hard links fail closed, naming both capabilities.
485
+ - Directories recheck that the target is absent and use plain `renameat`.
486
+ A concurrent non-empty directory or non-directory target rejects as
487
+ `already-exists`. **A concurrently created empty directory can be replaced.**
488
+ A trailing slash on the direct-child source requires a directory at syscall
489
+ time, even after a file or symlink substitution. Source identity checks and
490
+ rename/unlink are separate syscalls,
491
+ not source-name compare-and-swap; use exclusive namespace ownership or OS
492
+ isolation against hostile actors in the final check-to-syscall window.
493
+
494
+ `require` does not take these fallbacks. Use
495
+ [`isNoReplaceUnsupported(error)`](errors.md#unsupported-no-replace-moves) to
496
+ classify the capability refusal. File fallback failures after link publication
497
+ leave the target intact and report `details.publication: "published"` plus
498
+ `details.sourceRemoval`; callers must inspect that partial state before retrying.
499
+ There is no automatic rollback or crash-durability guarantee.
471
500
 
472
501
  Linux without `openat2` uses the [guarded native parent walk](native.md#linux-without-openat2).
473
- The move still uses `renameat2(RENAME_NOREPLACE)` and preserves collisions;
502
+ The move uses `renameat2(RENAME_NOREPLACE)` or the Linux auto fallbacks above;
474
503
  parent resolution reports the documented `best-effort` containment class.
475
504
  Disabling the addon still makes no-clobber moves unavailable.
476
505
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -173,32 +173,32 @@
173
173
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
174
174
  },
175
175
  "optionalDependencies": {
176
- "@openclaw/fs-safe-darwin-arm64": "0.23.0",
177
- "@openclaw/fs-safe-darwin-x64": "0.23.0",
178
- "@openclaw/fs-safe-linux-arm64-gnu": "0.23.0",
179
- "@openclaw/fs-safe-linux-arm64-musl": "0.23.0",
180
- "@openclaw/fs-safe-linux-x64-gnu": "0.23.0",
181
- "@openclaw/fs-safe-linux-x64-musl": "0.23.0",
182
- "@openclaw/fs-safe-win32-x64-msvc": "0.23.0",
176
+ "@openclaw/fs-safe-darwin-arm64": "0.25.0",
177
+ "@openclaw/fs-safe-darwin-x64": "0.25.0",
178
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.25.0",
179
+ "@openclaw/fs-safe-linux-arm64-musl": "0.25.0",
180
+ "@openclaw/fs-safe-linux-x64-gnu": "0.25.0",
181
+ "@openclaw/fs-safe-linux-x64-musl": "0.25.0",
182
+ "@openclaw/fs-safe-win32-x64-msvc": "0.25.0",
183
183
  "jszip": "^3.10.2"
184
184
  },
185
185
  "devDependencies": {
186
- "@emnapi/runtime": "2.0.0-alpha.5",
187
- "@napi-rs/cli": "3.10.5",
188
- "@types/node": "^26.6.1",
189
- "@vitest/coverage-v8": "5.0.2",
190
- "fast-check": "^4.10.1",
186
+ "@emnapi/runtime": "2.0.0-alpha.6",
187
+ "@napi-rs/cli": "3.10.8",
188
+ "@types/node": "^26.6.4",
189
+ "@vitest/coverage-v8": "5.0.3",
190
+ "fast-check": "^4.10.2",
191
191
  "istanbul-lib-coverage": "3.2.2",
192
192
  "istanbul-lib-report": "3.0.1",
193
193
  "istanbul-reports": "3.2.0",
194
194
  "sigstore": "5.0.0",
195
195
  "tar": "7.5.22",
196
196
  "typescript": "^7.0.2",
197
- "vite": "8.3.1",
198
- "vitest": "^5.0.1"
197
+ "vite": "8.3.3",
198
+ "vitest": "^5.0.3"
199
199
  },
200
200
  "engines": {
201
201
  "node": ">=22"
202
202
  },
203
- "packageManager": "pnpm@12.4.2"
203
+ "packageManager": "pnpm@12.10.1"
204
204
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe-darwin-arm64",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "macOS arm64 native binding for @openclaw/fs-safe.",
5
5
  "license": "MIT",
6
6
  "author": "OpenClaw Team <dev@openclaw.ai>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe-darwin-x64",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "macOS x64 native binding for @openclaw/fs-safe.",
5
5
  "license": "MIT",
6
6
  "author": "OpenClaw Team <dev@openclaw.ai>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe-linux-arm64-gnu",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Linux arm64 glibc native binding for @openclaw/fs-safe.",
5
5
  "license": "MIT",
6
6
  "author": "OpenClaw Team <dev@openclaw.ai>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe-linux-arm64-musl",
3
- "version": "0.23.0",
3
+ "version": "0.25.0",
4
4
  "description": "Linux arm64 musl native binding for @openclaw/fs-safe.",
5
5
  "license": "MIT",
6
6
  "author": "OpenClaw Team <dev@openclaw.ai>",