@openclaw/openshell-sandbox 2026.10.2 → 2026.10.5-beta.2

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 (91) hide show
  1. package/dist/index.js +69 -111
  2. package/node_modules/@openclaw/fs-safe/CHANGELOG.md +35 -0
  3. package/node_modules/@openclaw/fs-safe/README.md +1 -1
  4. package/node_modules/@openclaw/fs-safe/dist/advanced.d.ts +1 -1
  5. package/node_modules/@openclaw/fs-safe/dist/advanced.js +1 -1
  6. package/node_modules/@openclaw/fs-safe/dist/archive-durability.js +1 -1
  7. package/node_modules/@openclaw/fs-safe/dist/archive-merge.js +4 -4
  8. package/node_modules/@openclaw/fs-safe/dist/archive-parser.wasm +0 -0
  9. package/node_modules/@openclaw/fs-safe/dist/archive-read.js +5 -4
  10. package/node_modules/@openclaw/fs-safe/dist/archive-staging.d.ts +1 -1
  11. package/node_modules/@openclaw/fs-safe/dist/archive-staging.js +22 -38
  12. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.d.ts +17 -0
  13. package/node_modules/@openclaw/fs-safe/dist/copy-path-admission.js +117 -0
  14. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.d.ts +8 -5
  15. package/node_modules/@openclaw/fs-safe/dist/copy-root-file-sync.js +27 -50
  16. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.d.ts +3 -0
  17. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-acquire.js +57 -11
  18. package/node_modules/@openclaw/fs-safe/dist/guarded-mutation.js +5 -5
  19. package/node_modules/@openclaw/fs-safe/dist/guest-native-python.js +52 -5
  20. package/node_modules/@openclaw/fs-safe/dist/guest.js +1 -1
  21. package/node_modules/@openclaw/fs-safe/dist/native-binding.d.ts +1 -0
  22. package/node_modules/@openclaw/fs-safe/dist/native-operations.d.ts +0 -1
  23. package/node_modules/@openclaw/fs-safe/dist/native-operations.js +22 -26
  24. package/node_modules/@openclaw/fs-safe/dist/native-pinned-write-windows.js +5 -1
  25. package/node_modules/@openclaw/fs-safe/dist/native-staged-file.js +4 -1
  26. package/node_modules/@openclaw/fs-safe/dist/permission-exec.js +2 -1
  27. package/node_modules/@openclaw/fs-safe/dist/pinned-write-types.d.ts +6 -1
  28. package/node_modules/@openclaw/fs-safe/dist/private-temp-workspace.js +2 -2
  29. package/node_modules/@openclaw/fs-safe/dist/publish-file.js +34 -7
  30. package/node_modules/@openclaw/fs-safe/dist/replace-file-copy-fallback.js +1 -4
  31. package/node_modules/@openclaw/fs-safe/dist/replace-file-descriptor.js +6 -0
  32. package/node_modules/@openclaw/fs-safe/dist/root-create-input.d.ts +15 -1
  33. package/node_modules/@openclaw/fs-safe/dist/root-create-input.js +4 -0
  34. package/node_modules/@openclaw/fs-safe/dist/root-directory-entry.js +1 -1
  35. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.d.ts +0 -1
  36. package/node_modules/@openclaw/fs-safe/dist/root-directory-list.js +6 -12
  37. package/node_modules/@openclaw/fs-safe/dist/root-impl.js +15 -15
  38. package/node_modules/@openclaw/fs-safe/dist/root-paths.js +1 -1
  39. package/node_modules/@openclaw/fs-safe/dist/root-walk.js +4 -4
  40. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.d.ts +10 -1
  41. package/node_modules/@openclaw/fs-safe/dist/root-write-verification.js +20 -0
  42. package/node_modules/@openclaw/fs-safe/dist/secure-temp-dir.js +19 -7
  43. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-acquire.js +25 -3
  44. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-admission.d.ts +2 -0
  45. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.d.ts +14 -0
  46. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock-retained.js +187 -0
  47. package/node_modules/@openclaw/fs-safe/dist/sidecar-lock.js +15 -1
  48. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.d.ts +10 -0
  49. package/node_modules/@openclaw/fs-safe/dist/temp-directory-diagnostics.js +29 -0
  50. package/node_modules/@openclaw/fs-safe/dist/temp-target.js +1 -1
  51. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-admission.js +6 -6
  52. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.d.ts +4 -4
  53. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-child-admission.js +17 -16
  54. package/node_modules/@openclaw/fs-safe/dist/temp-workspace-descriptor.js +20 -28
  55. package/node_modules/@openclaw/fs-safe/dist/walk.js +2 -13
  56. package/node_modules/@openclaw/fs-safe/dist/watch-native.d.ts +2 -2
  57. package/node_modules/@openclaw/fs-safe/dist/watch-native.js +3 -1
  58. package/node_modules/@openclaw/fs-safe/dist/watch-rescan.js +3 -9
  59. package/node_modules/@openclaw/fs-safe/dist/watch.js +2 -3
  60. package/node_modules/@openclaw/fs-safe/docs/advanced.md +1 -1
  61. package/node_modules/@openclaw/fs-safe/docs/archive.md +3 -0
  62. package/node_modules/@openclaw/fs-safe/docs/atomic.md +11 -3
  63. package/node_modules/@openclaw/fs-safe/docs/contributing.md +1 -1
  64. package/node_modules/@openclaw/fs-safe/docs/copy.md +48 -0
  65. package/node_modules/@openclaw/fs-safe/docs/guest.md +21 -4
  66. package/node_modules/@openclaw/fs-safe/docs/path-scope.md +4 -0
  67. package/node_modules/@openclaw/fs-safe/docs/sidecar-lock.md +46 -8
  68. package/node_modules/@openclaw/fs-safe/docs/temp.md +12 -0
  69. package/node_modules/@openclaw/fs-safe/docs/watch.md +3 -2
  70. package/node_modules/@openclaw/fs-safe/package.json +15 -15
  71. package/node_modules/@openclaw/fs-safe-darwin-arm64/fs-safe-native.node +0 -0
  72. package/node_modules/@openclaw/fs-safe-darwin-arm64/package.json +1 -1
  73. package/node_modules/@openclaw/fs-safe-darwin-x64/fs-safe-native.node +0 -0
  74. package/node_modules/@openclaw/fs-safe-darwin-x64/package.json +1 -1
  75. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/fs-safe-native.node +0 -0
  76. package/node_modules/@openclaw/fs-safe-linux-arm64-gnu/package.json +1 -1
  77. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/fs-safe-native.node +0 -0
  78. package/node_modules/@openclaw/fs-safe-linux-arm64-musl/package.json +1 -1
  79. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/fs-safe-native.node +0 -0
  80. package/node_modules/@openclaw/fs-safe-linux-x64-gnu/package.json +1 -1
  81. package/node_modules/@openclaw/fs-safe-linux-x64-musl/fs-safe-native.node +0 -0
  82. package/node_modules/@openclaw/fs-safe-linux-x64-musl/package.json +1 -1
  83. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/fs-safe-native.node +0 -0
  84. package/node_modules/@openclaw/fs-safe-win32-x64-msvc/package.json +1 -1
  85. package/package.json +5 -5
  86. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.d.ts +0 -17
  87. package/node_modules/@openclaw/fs-safe/dist/file-lock-sync-root-arbitration.js +0 -66
  88. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.d.ts +0 -4
  89. package/node_modules/@openclaw/fs-safe/dist/publish-file-native.js +0 -35
  90. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.d.ts +0 -7
  91. package/node_modules/@openclaw/fs-safe/dist/root-move-preflight.js +0 -16
@@ -55,15 +55,9 @@ export function mergeWatchRescan(scopes, before, slice, requested) {
55
55
  for (const name of slice.childPaths?.get(directory)?.values() ?? [])
56
56
  selected.add(name);
57
57
  }
58
- for (const [name, previous] of before.entries) {
59
- if (!selected.has(name))
60
- continue;
61
- const current = slice.entries.get(name);
62
- if ((previous.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
63
- return;
64
- }
65
- for (const [name, current] of slice.entries) {
66
- if (selected.has(name) && current.startsWith("directory:") && identity(current) !== identity(before.entries.get(name)))
58
+ for (const name of selected) {
59
+ const previous = before.entries.get(name), current = slice.entries.get(name);
60
+ if ((previous?.startsWith("directory:") || current?.startsWith("directory:")) && identity(previous) !== identity(current))
67
61
  return;
68
62
  }
69
63
  // Exclusion changes can retire or admit a subtree, including callback-driven changes.
@@ -5,7 +5,6 @@ import { rootHandleContext } from "./root-handle-context.js";
5
5
  import { createSuppressedError } from "./suppressed-error.js";
6
6
  import { getFsSafeTestHooks } from "./test-hooks.js";
7
7
  import { mergeWatchRescan, watchRescanScopes } from "./watch-rescan.js";
8
- import { watchStreamPaths } from "./watch-stream.js";
9
8
  import { admittedNativeChanges, changedEntries, excludedWatchPath, guardedHintChanges, scopedChanges } from "./watch-hints.js";
10
9
  import path from "node:path";
11
10
  import { watchBinding, NativeWatchBackend } from "./watch-native.js";
@@ -338,7 +337,7 @@ export function watch(root, input) {
338
337
  }, maxPendingPaths, persistent);
339
338
  backend = candidate;
340
339
  if (snapshot)
341
- candidate.configure(watchStreamPaths(snapshot, g.scopes));
340
+ candidate.configure(snapshot, g.scopes);
342
341
  const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
343
342
  if (backend === candidate)
344
343
  onHint(g, batch);
@@ -401,7 +400,7 @@ export function watch(root, input) {
401
400
  }
402
401
  try {
403
402
  const entriesChanged = backend?.entries(next);
404
- const streamsChanged = backend?.configure(watchStreamPaths(next, g.scopes));
403
+ const streamsChanged = backend?.configure(next, g.scopes);
405
404
  if (entriesChanged || streamsChanged) {
406
405
  // Complete a guarded pass with the new transport before publishing readiness.
407
406
  // Bound handovers under churn; later passes still reconcile ongoing changes.
@@ -82,7 +82,7 @@ concurrent namespace changes.
82
82
  | `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
83
83
  | `retainEntryForPublication`, `RetainedEntryPublication`, `RetainEntryForPublicationOptions`, `EntryPublicationReceipt`, `EntryPublicationResult`, `EntryPublicationIssue`, `PublicationIdentity`, `PublicationParent` | [One-way entry publication](entry-publication.md) | Native no-replace directory/regular-file/symlink export, including Windows NTFS junctions, under caller-exclusive namespace; explicit transition receipts, close-only disposal, no source CAS. |
84
84
  | `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
85
- | `copyRootFileSync`, `CopyRootFileSyncOptions`, `CopiedRootFileSync` | [copy.md](copy.md#synchronous-guarded-file-copies) | Synchronous guarded exclusive file copy, optionally cloned and bound to an expected source identity, returning an owned destination descriptor and both identities. |
85
+ | `copyRootFileSync`, `createRootFileCopyBatchSync`, `RootFileCopyBatchSync`, `CopyRootFileSyncOptions`, `CopiedRootFileSync` | [copy.md](copy.md#synchronous-guarded-file-copies) | Synchronous guarded exclusive file copy, optionally cloned and bound to an expected source identity, returning an owned destination descriptor and both identities. |
86
86
  | `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
87
87
  | `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
88
88
  | `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
@@ -702,6 +702,9 @@ await extractArchive({
702
702
 
703
703
  ## `readArchiveEntry`
704
704
 
705
+ The byte limit is selected and validated once before reading the archive; changing
706
+ the options object during a read does not change the active limit.
707
+
705
708
  `readArchiveEntry(archivePath, entryPath, { maxBytes, kind? })` reads one
706
709
  regular-file entry into a bounded `Buffer` without extracting a tree. It reads
707
710
  the input through an identity-checked descriptor, rejects a requested link or
@@ -16,6 +16,11 @@ import {
16
16
 
17
17
  Write `content` to a sibling temp file in the destination directory, apply the parent-directory and final file modes through verified descriptors, optionally `fsync` the file descriptor, optionally `fsync` the parent directory after rename, then atomically rename over the destination. No permission change follows a caller-supplied pathname.
18
18
 
19
+ On POSIX, non-durable asynchronous writes using Node inspect the staged file's
20
+ complete permission bits with their final identity check and skip a redundant
21
+ mode change when they already match. A restrictive umask or differing special
22
+ bits still require descriptor mode correction before publication.
23
+
19
24
  On POSIX, the parent is opened with no-follow and directory-only flags, checked against its exact pre-open device/inode identity, and mode-adjusted through that descriptor. A replacement symlink is rejected rather than followed. If the directory cannot be opened for descriptor access, the operation fails closed instead of retrying by pathname. Windows does not enforce POSIX directory modes and Node cannot consistently open directory descriptors there, so `dirMode` is passed only to `mkdir`; no pathname `chmod` fallback is attempted.
20
25
 
21
26
  Async replacements to the same destination are serialized inside the current process, so two overlapping `replaceFileAtomic()` calls do not interleave their temp-write/rename phases. Use a sidecar lock when multiple processes may write the same target.
@@ -258,7 +263,9 @@ opened, removed, or replaced. A successor at that name is left untouched.
258
263
  `details.cleanup: "restored"` means the retained original file's bytes and mode
259
264
  were restored and synchronized, not that the pathname still names it. The existing
260
265
  `writing` receipt identifies that file; no `published` receipt is emitted for a
261
- failed replacement. Restoration I/O failures report `"restore-failed"`.
266
+ failed replacement. Restoration I/O failures report `"restore-failed"`, even if
267
+ the thrown error itself carries a `cleanup: "restored"` receipt from another
268
+ operation; the cause retains both the replacement and restoration failures.
262
269
 
263
270
  Callback refusals, detected identity/type/link changes, and other metadata errors
264
271
  remain terminal. Failed descriptor revalidation also stops restoration. These
@@ -539,8 +546,9 @@ remain a best-effort sequence, not atomic.
539
546
  `onDestinationPublished` runs exactly once after a successful rename resolves,
540
547
  before awaited post-rename directory checks or source cleanup. It receives a
541
548
  frozen receipt with the resolved absolute destination path and the exact bigint
542
- device/inode identity observed on the rename source before dispatch: the
543
- original source for a direct rename, or the staged copy for fallback. Failed
549
+ device/inode identity observed on the rename source after the synchronous
550
+ authority callbacks return and before dispatch: the source selected for a direct
551
+ rename, or the staged copy for fallback. Failed
544
552
  rename attempts never emit receipts. The return contract remains `Promise<void>`.
545
553
 
546
554
  The receipt records an observed identity, not authorization, durable storage,
@@ -175,7 +175,7 @@ temporary storage; session scratch trees may suppress macOS filesystem events.
175
175
  ### Optional Linux Testbox
176
176
 
177
177
  The manual `testbox-validation.yml` workflow prepares a 16-vCPU Ubuntu 24.04
178
- Blacksmith Testbox with Node 24.21.0, pnpm 12.4.2, dependencies, the Rust WASM
178
+ Blacksmith Testbox with Node 24.21.0, pnpm 12.10.1, dependencies, the Rust WASM
179
179
  target, and the pinned portable archive compiler. It leaves library builds and
180
180
  validation commands to the caller and does not replace required CI checks.
181
181
 
@@ -257,3 +257,51 @@ replacement entries. This helper creates directly at the destination name, so
257
257
  other processes may observe partial bytes until it returns. It does not promise
258
258
  crash durability or a coherent snapshot of concurrent in-place source edits.
259
259
  Use a private capture directory and keep sources unchanged while capturing.
260
+
261
+ ### Batches of synchronous guarded copies
262
+
263
+ `createRootFileCopyBatchSync()` on `advanced` returns a disposable
264
+ `RootFileCopyBatchSync`. Its `copyFile(options)` takes the same
265
+ `CopyRootFileSyncOptions` and returns the same owned `CopiedRootFileSync` as
266
+ `copyRootFileSync`:
267
+
268
+ ```ts
269
+ import { createRootFileCopyBatchSync } from "@openclaw/fs-safe/advanced";
270
+
271
+ using batch = createRootFileCopyBatchSync();
272
+ for (const name of ["plugin.js", "package.json"]) {
273
+ using copied = batch.copyFile({
274
+ source: { rootPath: "/srv/plugins", absolutePath: `/srv/plugins/${name}` },
275
+ destination: { rootPath: "/srv/capture", absolutePath: `/srv/capture/${name}` },
276
+ clone: "auto",
277
+ maxBytes: 16 * 1024 * 1024,
278
+ });
279
+ console.log(copied.bytes, copied.sourceIdentity);
280
+ }
281
+ ```
282
+
283
+ On canonical POSIX paths, the batch retains root and ancestor directory
284
+ identities across files. Every use still checks those identities without
285
+ following directory symlinks; root and parent canonical paths are rechecked.
286
+ Source opens, source and destination file identities, hardlink policy, exclusive
287
+ creation, bounds, permissions, and cleanup are checked separately for every file.
288
+ No source descriptor, file contents, or successful file-admission result is cached.
289
+ An ancestor replacement rejects even when the leaf parent has not changed.
290
+ Windows and noncanonical path spellings keep the full per-file resolver and its
291
+ alias handling. There is no option to bypass validation.
292
+
293
+ Keep the batch scoped to one capture or materialization, with unchanged source
294
+ and destination directory topology. `close()` and `Symbol.dispose` idempotently
295
+ release its retained metadata; later calls reject with `invalid-path`. Returned
296
+ file descriptors remain independently caller-owned. Successful earlier files
297
+ are not rolled back if a later copy fails. A failed copy keeps the ordinary
298
+ identity-checked cleanup contract; the batch is not an atomic tree transaction
299
+ or a coherent snapshot of concurrent edits. The single-file helper also reuses
300
+ directory admissions for the duration of its one copy.
301
+
302
+ Both APIs reuse the freshly checked canonical root during initial POSIX path
303
+ admission. This avoids repeatedly resolving every absolute root component while
304
+ retaining lexical traversal and the before/after root and parent identity checks.
305
+ Windows and root aliases retain the ordinary resolver, including its handling
306
+ of dot segments in symlink targets. No caller changes are needed for the
307
+ single-file improvement.
@@ -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
@@ -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
@@ -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
 
@@ -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.
@@ -152,8 +152,9 @@ FSEvents drops, including during recursive deletion. A shallow tree anchored abo
152
152
  a busy unselected subtree still receives recursive traffic; pending hints fold
153
153
  under pressure, while genuine FSEvents drops can still overflow.
154
154
  Absolute hints are reduced lexically against the admitted canonical Root;
155
- outside paths never become detail. Dropped/wrapped streams, RootChanged and
156
- 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
157
158
  after a swap, but the Root is never replaced and names require guarded admission.
158
159
  Removal synchronously stops, invalidates and releases the stream on its queue.
159
160
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.24.2",
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.24.2",
177
- "@openclaw/fs-safe-darwin-x64": "0.24.2",
178
- "@openclaw/fs-safe-linux-arm64-gnu": "0.24.2",
179
- "@openclaw/fs-safe-linux-arm64-musl": "0.24.2",
180
- "@openclaw/fs-safe-linux-x64-gnu": "0.24.2",
181
- "@openclaw/fs-safe-linux-x64-musl": "0.24.2",
182
- "@openclaw/fs-safe-win32-x64-msvc": "0.24.2",
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.6",
188
- "@types/node": "^26.6.1",
186
+ "@emnapi/runtime": "2.0.0-alpha.6",
187
+ "@napi-rs/cli": "3.10.8",
188
+ "@types/node": "^26.6.4",
189
189
  "@vitest/coverage-v8": "5.0.3",
190
- "fast-check": "^4.10.1",
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.2",
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.24.2",
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.24.2",
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.24.2",
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.24.2",
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>",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe-linux-x64-gnu",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Linux x64 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-x64-musl",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Linux x64 musl 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-win32-x64-msvc",
3
- "version": "0.24.2",
3
+ "version": "0.25.0",
4
4
  "description": "Windows x64 MSVC native binding for @openclaw/fs-safe.",
5
5
  "license": "MIT",
6
6
  "author": "OpenClaw Team <dev@openclaw.ai>",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/openshell-sandbox",
3
- "version": "2026.10.2",
3
+ "version": "2026.10.5-beta.2",
4
4
  "description": "OpenClaw sandbox backend for the NVIDIA OpenShell CLI with mirrored local workspaces and SSH command execution.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "type": "module",
10
10
  "dependencies": {
11
- "@openclaw/fs-safe": "0.24.2",
11
+ "@openclaw/fs-safe": "0.25.0",
12
12
  "p-limit": "7.3.3",
13
13
  "zod": "4.6.5"
14
14
  },
@@ -23,10 +23,10 @@
23
23
  "minHostVersion": ">=2026.5.12-beta.1"
24
24
  },
25
25
  "compat": {
26
- "pluginApi": ">=2026.10.2"
26
+ "pluginApi": ">=2026.10.5-beta.2"
27
27
  },
28
28
  "build": {
29
- "openclawVersion": "2026.10.2",
29
+ "openclawVersion": "2026.10.5-beta.2",
30
30
  "bundledDist": false
31
31
  },
32
32
  "release": {
@@ -45,7 +45,7 @@
45
45
  "assets/activity.svg"
46
46
  ],
47
47
  "peerDependencies": {
48
- "openclaw": ">=2026.10.2"
48
+ "openclaw": ">=2026.10.5-beta.2"
49
49
  },
50
50
  "peerDependenciesMeta": {
51
51
  "openclaw": {
@@ -1,17 +0,0 @@
1
- import { FsSafeError } from "./errors.js";
2
- import type { FileLockSyncHandle } from "./file-lock-sync.js";
3
- import { type FileLockSyncRootAuthority, type FileLockSyncRootPath } from "./file-lock-sync-root.js";
4
- import type { FileLockSyncRootFileReceipt } from "./file-lock-sync-root-io.js";
5
- import { type RootSyncHeldLock } from "./file-lock-sync-root-held.js";
6
- export declare class FileLockSyncRootArbitrationCollision extends FsSafeError {
7
- constructor();
8
- }
9
- export type FileLockSyncRootArbitration = Readonly<{
10
- authority: FileLockSyncRootAuthority;
11
- heldLocks: Map<string, RootSyncHeldLock>;
12
- lockRootPath: FileLockSyncRootPath;
13
- normalizedTargetPath: string;
14
- reentrantOwner?: string;
15
- }>;
16
- export declare function tryReuseCurrentRootSyncHeldLock(arbitration: FileLockSyncRootArbitration): FileLockSyncHandle | undefined;
17
- export declare function cleanupCreatedRootSyncLock(lockRootPath: FileLockSyncRootPath, fd: number, receipt: FileLockSyncRootFileReceipt, timer?: NodeJS.Timeout): void;
@@ -1,66 +0,0 @@
1
- import fs from "node:fs";
2
- import { foreignSyncHeldLock } from "./file-lock-sync-admission.js";
3
- import path from "node:path";
4
- import { FsSafeError } from "./errors.js";
5
- import {} from "./file-lock-sync-root.js";
6
- import { removeFileLockSyncRootFile } from "./file-lock-sync-root-mutation.js";
7
- import { createSuppressedError } from "./suppressed-error.js";
8
- import { createRootSyncHeldLockHandle, verifyRootSyncHeldLock, } from "./file-lock-sync-root-held.js";
9
- export class FileLockSyncRootArbitrationCollision extends FsSafeError {
10
- constructor() {
11
- super("path-mismatch", "file lock arbitration changed during local creation");
12
- }
13
- }
14
- function sameRootLockPath(left, right) {
15
- return left.relativePath === right.relativePath && path.relative(left.path, right.path) === "";
16
- }
17
- export function tryReuseCurrentRootSyncHeldLock(arbitration) {
18
- const held = arbitration.heldLocks.get(arbitration.normalizedTargetPath);
19
- const reusable = held && !foreignSyncHeldLock("root", arbitration.normalizedTargetPath) &&
20
- arbitration.reentrantOwner !== undefined &&
21
- held.reentrantOwner !== undefined &&
22
- arbitration.reentrantOwner === held.reentrantOwner &&
23
- arbitration.authority.adapter === held.rootAuthority.adapter &&
24
- sameRootLockPath(arbitration.lockRootPath, held.rootPath) &&
25
- held.releaseState !== "released";
26
- if (!reusable)
27
- return undefined;
28
- if (!verifyRootSyncHeldLock(held)) {
29
- throw new FsSafeError("path-mismatch", "held sidecar lock changed before reentrant reuse");
30
- }
31
- // Verification can invoke a custom parser. Re-fetch the exact entry before
32
- // granting a reference, while retaining supported releasing/exit states.
33
- if (arbitration.heldLocks.get(arbitration.normalizedTargetPath) !== held ||
34
- held.releaseState === "released" || foreignSyncHeldLock("root", arbitration.normalizedTargetPath)) {
35
- throw new FsSafeError("path-mismatch", "held sidecar lock changed during reentrant reuse");
36
- }
37
- held.refCount += 1;
38
- held.revision += 1;
39
- return createRootSyncHeldLockHandle(held);
40
- }
41
- export function cleanupCreatedRootSyncLock(lockRootPath, fd, receipt, timer) {
42
- let timerCleanupFailed = false;
43
- let timerCleanupError;
44
- try {
45
- if (timer)
46
- clearInterval(timer);
47
- }
48
- catch (error) {
49
- timerCleanupFailed = true;
50
- timerCleanupError = error;
51
- }
52
- try {
53
- fs.closeSync(fd);
54
- if (!removeFileLockSyncRootFile(lockRootPath, receipt)) {
55
- throw new FsSafeError("path-mismatch", "created sidecar lock changed before cleanup");
56
- }
57
- }
58
- catch (fileCleanupError) {
59
- if (timerCleanupFailed) {
60
- throw createSuppressedError(timerCleanupError, fileCleanupError, "unpublished lock timer and file cleanup both failed");
61
- }
62
- throw fileCleanupError;
63
- }
64
- if (timerCleanupFailed)
65
- throw timerCleanupError;
66
- }
@@ -1,4 +0,0 @@
1
- import type { BigIntStats } from "node:fs";
2
- import type { NativeBinding } from "./native-binding.js";
3
- import { type PublishFailureState } from "./publish-file-failure.js";
4
- export declare function publishFileNoReplaceNative(binding: NativeBinding, paths: Parameters<NativeBinding["renameNoReplace"]>, expected: BigIntStats, failure: PublishFailureState): "link-unlink" | undefined;