@openclaw/fs-safe 0.19.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +24 -6
  3. package/dist/advanced.d.ts +4 -0
  4. package/dist/advanced.js +2 -0
  5. package/dist/archive-plan.d.ts +2 -7
  6. package/dist/archive-read.js +9 -18
  7. package/dist/archive-zip-entry.d.ts +11 -11
  8. package/dist/archive-zip-entry.js +3 -35
  9. package/dist/archive-zip-integrity.d.ts +2 -2
  10. package/dist/archive-zip-integrity.js +2 -12
  11. package/dist/archive-zip-loader.d.ts +7 -3
  12. package/dist/archive-zip-loader.js +10 -9
  13. package/dist/archive-zip-preflight.d.ts +2 -1
  14. package/dist/archive-zip-preflight.js +16 -7
  15. package/dist/archive.js +17 -16
  16. package/dist/atomic.d.ts +1 -1
  17. package/dist/directory-receipt.js +5 -7
  18. package/dist/effective-uid.js +1 -4
  19. package/dist/errors.d.ts +3 -1
  20. package/dist/errors.js +3 -2
  21. package/dist/file-lock-sync-root-held.js +1 -4
  22. package/dist/file-store.d.ts +4 -7
  23. package/dist/json-document-store.d.ts +4 -9
  24. package/dist/local-file-access.js +2 -5
  25. package/dist/move-path-cleanup.js +4 -4
  26. package/dist/native-binding.d.ts +28 -1
  27. package/dist/native-staged-symlink.d.ts +13 -0
  28. package/dist/native-staged-symlink.js +303 -0
  29. package/dist/owner-dacl-batch-worker.d.ts +1 -0
  30. package/dist/owner-dacl-batch-worker.js +54 -0
  31. package/dist/owner-dacl-batch.d.ts +5 -0
  32. package/dist/owner-dacl-batch.js +64 -0
  33. package/dist/owner-dacl.d.ts +2 -0
  34. package/dist/owner-dacl.js +3 -0
  35. package/dist/path.js +17 -1
  36. package/dist/permission-exec.js +3 -6
  37. package/dist/permissions-public.d.ts +1 -0
  38. package/dist/permissions-public.js +1 -0
  39. package/dist/pinned-mutation-admission.d.ts +0 -1
  40. package/dist/pinned-open.d.ts +0 -1
  41. package/dist/pinned-open.js +1 -2
  42. package/dist/publish-copy-stage.js +4 -0
  43. package/dist/read-opened-file.d.ts +2 -5
  44. package/dist/regular-file.js +3 -3
  45. package/dist/replace-file-buffer.d.ts +4 -0
  46. package/dist/replace-file-buffer.js +36 -0
  47. package/dist/replace-file-copy-fallback.d.ts +3 -2
  48. package/dist/replace-file-copy-fallback.js +66 -38
  49. package/dist/replace-file-descriptor.d.ts +4 -0
  50. package/dist/replace-file-descriptor.js +9 -1
  51. package/dist/replace-file-destination.d.ts +17 -0
  52. package/dist/replace-file-destination.js +61 -0
  53. package/dist/replace-file-mutation.d.ts +26 -0
  54. package/dist/replace-file-mutation.js +47 -0
  55. package/dist/replace-file-temp-owner.d.ts +2 -2
  56. package/dist/replace-file-temp-owner.js +16 -4
  57. package/dist/replace-file-types.d.ts +55 -0
  58. package/dist/replace-file-types.js +1 -0
  59. package/dist/replace-file.d.ts +3 -55
  60. package/dist/replace-file.js +29 -10
  61. package/dist/retained-file-types.d.ts +61 -0
  62. package/dist/retained-file-types.js +1 -0
  63. package/dist/retained-file.d.ts +3 -0
  64. package/dist/retained-file.js +121 -0
  65. package/dist/root-directory-entry.d.ts +9 -0
  66. package/dist/root-directory-entry.js +28 -0
  67. package/dist/root-directory-list.d.ts +7 -1
  68. package/dist/root-directory-list.js +48 -23
  69. package/dist/root-handle-context.d.ts +4 -0
  70. package/dist/root-handle-context.js +12 -0
  71. package/dist/root-impl.d.ts +3 -3
  72. package/dist/root-impl.js +8 -5
  73. package/dist/root-observed-path.d.ts +0 -1
  74. package/dist/root-observed-path.js +0 -3
  75. package/dist/root-path-observation.d.ts +4 -11
  76. package/dist/root-path.js +7 -10
  77. package/dist/root-walk.d.ts +19 -12
  78. package/dist/root-walk.js +49 -18
  79. package/dist/root-write-admission.js +0 -2
  80. package/dist/safe-path-segment.d.ts +1 -0
  81. package/dist/safe-path-segment.js +8 -2
  82. package/dist/secure-file.js +3 -2
  83. package/dist/sidecar-lock.js +5 -3
  84. package/dist/staged-symlink-types.d.ts +49 -0
  85. package/dist/staged-symlink-types.js +1 -0
  86. package/dist/symlink-parents.js +58 -7
  87. package/dist/temp-target.js +5 -2
  88. package/dist/temp-workspace-admission.js +22 -21
  89. package/dist/temp-workspace-child-admission.d.ts +1 -1
  90. package/dist/temp-workspace-child-admission.js +14 -9
  91. package/dist/temp-workspace-owner.js +4 -9
  92. package/dist/temp-workspace-ownership.d.ts +8 -0
  93. package/dist/temp-workspace-ownership.js +52 -0
  94. package/dist/test-hooks.d.ts +3 -0
  95. package/dist/text-atomic.d.ts +2 -1
  96. package/dist/text-atomic.js +2 -0
  97. package/dist/trash.js +27 -1
  98. package/dist/walk.d.ts +2 -5
  99. package/dist/watch-alias.d.ts +6 -0
  100. package/dist/watch-alias.js +80 -0
  101. package/dist/watch-hints.d.ts +8 -0
  102. package/dist/watch-hints.js +77 -0
  103. package/dist/watch-native.d.ts +32 -0
  104. package/dist/watch-native.js +56 -0
  105. package/dist/watch-scan.d.ts +24 -0
  106. package/dist/watch-scan.js +269 -0
  107. package/dist/watch-types.d.ts +58 -0
  108. package/dist/watch-types.js +1 -0
  109. package/dist/watch.d.ts +5 -0
  110. package/dist/watch.js +502 -0
  111. package/dist/windows-owner.d.ts +0 -1
  112. package/dist/windows-owner.js +0 -1
  113. package/dist/windows-security-bridge.cs +6 -4
  114. package/dist/windows-security-bridge.ps1 +78 -3
  115. package/dist/windows-security-command.d.ts +8 -0
  116. package/dist/windows-security-command.js +66 -12
  117. package/dist/windows-security-facts.d.ts +3 -0
  118. package/dist/windows-security-facts.js +4 -0
  119. package/docs/advanced.md +4 -2
  120. package/docs/archive.md +8 -0
  121. package/docs/atomic.md +72 -3
  122. package/docs/contributing.md +35 -0
  123. package/docs/durability.md +7 -0
  124. package/docs/index.md +1 -0
  125. package/docs/install.md +28 -0
  126. package/docs/native-helper.md +14 -4
  127. package/docs/native.md +45 -1
  128. package/docs/permissions.md +66 -0
  129. package/docs/public-api.md +7 -1
  130. package/docs/retained-file.md +113 -0
  131. package/docs/root.md +6 -1
  132. package/docs/security-model.md +4 -1
  133. package/docs/sidecar-lock.md +2 -0
  134. package/docs/staged-symlink.md +123 -0
  135. package/docs/store.md +3 -1
  136. package/docs/temp.md +24 -4
  137. package/docs/testing.md +88 -0
  138. package/docs/types.md +6 -0
  139. package/docs/walk.md +22 -1
  140. package/docs/watch.md +184 -0
  141. package/docs/writing.md +10 -0
  142. package/package.json +13 -9
package/docs/watch.md ADDED
@@ -0,0 +1,184 @@
1
+ # Guarded filesystem observation
2
+
3
+ `@openclaw/fs-safe/watch` observes literal paths under an admitted `Root`.
4
+ Notifications are advisory invalidations, not a transaction log, stable content,
5
+ or authority to read a reported pathname. Guarded metadata scans through the
6
+ original Root determine what may be published. Use the Root again to read data.
7
+
8
+ ```ts
9
+ import { root } from "@openclaw/fs-safe/root";
10
+ import { watch } from "@openclaw/fs-safe/watch";
11
+
12
+ const workspace = await root("/trusted/workspace");
13
+ const subscription = watch(workspace, {
14
+ mode: "auto",
15
+ scopes: [
16
+ { path: "config.json", kind: "entry" },
17
+ { path: "skills", kind: "tree", depth: 8 },
18
+ ],
19
+ exclude: entry => entry.kind === "directory" && entry.path.endsWith("node_modules"),
20
+ onInvalidate(invalidation) {
21
+ // Schedule application-owned settling/reload work; this callback is synchronous.
22
+ console.log(invalidation.reason, invalidation.changes);
23
+ },
24
+ });
25
+ await subscription.ready;
26
+ await subscription.setScopes([{ path: "skills", kind: "tree" }]);
27
+ await subscription.reconcile();
28
+ await subscription.close();
29
+ ```
30
+
31
+ ## Contract
32
+
33
+ The subpath exports `watch` and the types `WatchScope`, `WatchEntry`,
34
+ `WatchChange`, `WatchInvalidation`, `WatchFailure`, `WatchHealth`, `WatchOptions`,
35
+ and `WatchSubscription`.
36
+
37
+ An `entry` scope observes only that entry, including its identity and metadata.
38
+ A `tree` also observes descendants to its depth (default 32, maximum 128).
39
+ Depth zero observes only the entry. The empty string and `.` select the Root;
40
+ trailing separators are accepted and normalized after validation. Scopes are
41
+ literal names, never globs or home expansion. Absolute paths, traversal, NULs,
42
+ and platform namespace aliases reject. Windows scopes also reject reserved
43
+ device components and trailing dots/spaces that Win32 would silently alias. Filesystem identity determines ordinary
44
+ case/Unicode aliases; names are not compared by lowercasing.
45
+
46
+ Symlinks are observed as entries and never followed. A symbolic parent fails
47
+ admission: separately admit a caller-trusted link target if needed. Missing
48
+ entries and ordinary blocking files can become directories in later scans.
49
+ Replacing, renaming, or losing the admitted Root fails observation; it never
50
+ silently adopts a new Root. Directory entry scopes ignore child-only mtime/size
51
+ changes, but include permission-mode changes to the directory itself.
52
+
53
+ Each invalidation has `reason: "event" | "reconcile" | "overflow"` and optional
54
+ bounded `changes: { path, type: "content" | "structural" }[]`. Missing detail
55
+ means invalidate **every configured scope**. Initial admission and every
56
+ successful `setScopes` publish one undetailed `reconcile` invalidation. A rename
57
+ or identity replacement is structural; metadata changes to the same ordinary
58
+ file may be content changes. Neither means the file is settled or readable.
59
+ Raw event names remain private: detail comes from guarded scans, prior guarded
60
+ snapshots, or explicitly configured targets. Unknown names and overflow lose
61
+ detail. Exclusion callbacks are synchronous; excluded directories are not
62
+ scanned. Exclusions are a scan policy, not a promise that overflow cannot wake
63
+ the application.
64
+
65
+ ## Transport and mode
66
+
67
+ | Platform/runtime | `auto` | Event transport / limitation |
68
+ | --- | --- | --- |
69
+ | Node.js on Linux with addon | `events` | One shared Rust thread and inotify instance; a nonrecursive watch per distinct directory inode. |
70
+ | Node.js on macOS with addon | `events` | One FSEvents stream per subscription on a shared serial dispatch queue. Pathname activity after a swap remains advisory. |
71
+ | Node.js on Windows with addon | `events` | One recursive ReadDirectoryChangesW Root handle per subscription on the shared IOCP hub; the open handle prevents ordinary renames of the Root's ancestors. |
72
+ | Bun / other unsupported runtimes | `poll` | TSFN lifetime and shutdown have not been qualified; `events` rejects. |
73
+ | Missing/disabled addon | `poll` | `events` rejects with `FsSafeError("helper-unavailable")`. |
74
+
75
+ The shared hub sleeps until a filesystem event, command, or callback acknowledgement:
76
+ Linux blocks on inotify plus eventfd, Windows on IOCP, and macOS on its command
77
+ channel (FSEvents wakes it from the serial dispatch queue). There is no native
78
+ polling timer; the independent JS reconciliation interval remains authoritative.
79
+
80
+ `mode` is required. `poll` never starts or loads the watch hub; guarded scans
81
+ may still use the existing addon. Existing `FS_SAFE_NATIVE_MODE=off` and
82
+ `require` policies apply: `require` plus an unavailable event backend rejects
83
+ `auto` too. Health reports the selected `events` or `poll` mode and failures
84
+ from selection have `operation: "watch"`.
85
+
86
+ Linux installs each watch **before** enumerating children. It opens directories
87
+ beneath the Root using the shared guarded native open (openat2, or its checked
88
+ openat fallback), checks exact identities, verifies the procfs namespace, and
89
+ registers through `/proc/self/fd/N/.`. The final `/.` makes `IN_DONT_FOLLOW` apply
90
+ to the directory instead of rejecting the procfs magic symlink. The descriptor
91
+ closes immediately: inotify retains the inode reference. Reconciliation replaces
92
+ registrations when the directory inventory changes. Queue overflow invalidates
93
+ all owners; exhausted watch capacity reports `failure.code: "watch-limit"`.
94
+ Nonblocking TSFN batches cannot block the hub on JavaScript, and per-owner
95
+ pending detail and queued batches are bounded. The last removal stops and joins
96
+ the native thread. No Worker threads, eval programs, or JS `fs.watch` are used.
97
+
98
+ macOS uses FileEvents, NoDefer and WatchRoot with a 30 ms FSEvents latency.
99
+ Absolute hints are reduced lexically against the admitted canonical Root;
100
+ outside paths never become detail. Dropped/wrapped streams, RootChanged and
101
+ Unmount trigger guarded reconciliation. Pathname hints can reflect activity
102
+ after a swap, but the Root is never replaced and names require guarded admission.
103
+ Removal synchronously stops, invalidates and releases the stream on its queue.
104
+
105
+ Windows opens one identity-checked Root handle per subscription with
106
+ READ/WRITE/DELETE sharing, backup semantics and overlapped I/O. Each handle
107
+ observes the entire subtree; guarded scans filter hints to configured scopes.
108
+ No descendant watch handles are retained, so directories inside the Root can be
109
+ renamed while watching, including directories containing selected scopes.
110
+ Root-wide noise can coalesce into whole-scope invalidation. Completed 64 KiB buffers are copied and the
111
+ read re-armed before names are examined. Zero-byte / enumeration-loss completions
112
+ invalidate every scope. Cancellation waits for IOCP completion before closing
113
+ the handle or freeing its buffer; there are no detached retirement waits.
114
+
115
+ Windows prevents ordinary renames of the Root's own ancestors while its directory
116
+ handle is open, even with DELETE sharing. Use `mode: "poll"` when callers must not
117
+ retain that handle. Renaming or replacing the Root still fails guarded observation;
118
+ the subscription never adopts another location. Subscriptions have independent
119
+ handles and delivery queues, and closing one does not retire another's observation.
120
+
121
+ ## Budgets and lifecycle
122
+
123
+ | Option | Default / bound |
124
+ | --- | --- |
125
+ | `scopes` | At most 128 literal scopes |
126
+ | `persistent` | `true`; `false` lets Node exit with the subscription still open |
127
+ | `intervalMs` | 30000 with events; 1000 with poll; minimum 20 ms |
128
+ | `maxDirectories` | 4096 observed directories, including scope ancestors |
129
+ | `maxEntries` | 100000 examined entries per pass, including excluded entries |
130
+ | `maxPendingPaths` | 256; maximum 4096 |
131
+ | Reconciliation | One active pass and one coalesced pending pass; no convergence/pass budget |
132
+
133
+ Periodic guarded reconciliation runs without needing an event. It catches
134
+ missed events and works on filesystems where native hints are incomplete.
135
+ Scans are metadata comparisons: content changes preserving all compared
136
+ metadata may be missed in polling mode. No mode promises transactional
137
+ snapshots, complete history, or hard real-time delivery.
138
+
139
+ `ready` resolves after the first complete guarded scan establishes the baseline,
140
+ even while writes continue. Events mode installs each directory registration
141
+ before listing it (FSEvents and recursive RDCW anchors cover the crawl). A changed
142
+ registration/listing identity is retried up to three times per directory; further
143
+ churn invalidates that subtree. Poll mode starts with its first scan and detects
144
+ changes during the crawl on the next comparison. Neither mode waits for two
145
+ agreeing scans.
146
+
147
+ Each later pass compares with the previous snapshot, publishes bounded differences,
148
+ and adopts its result as the next snapshot. Vanishing entries, kind changes, and
149
+ transient descendant scan errors produce structural invalidations, preserving the
150
+ Root identity checks. Events during a pass coalesce into one pending pass; detail
151
+ overflow or catching up beyond the 25 ms coalescing window emits `overflow` without
152
+ detail. Sustained writes cannot exhaust a pass budget or disable observation.
153
+
154
+ `reconcile()` resolves after a complete pass that **started after the call**. Calls
155
+ waiting for the same future pass coalesce; an earlier in-flight pass cannot satisfy
156
+ a new call. It rejects only when observation becomes unavailable or is closed.
157
+ `setScopes` fences the old generation immediately and resolves after the new
158
+ baseline scan; superseded scope calls reject `AbortError`.
159
+ By default, an open subscription keeps the Node event loop alive, matching
160
+ `fs.watch`. Set `persistent: false` for caches used by one-shot commands:
161
+ the subscription's timers and native delivery handle do not keep Node alive,
162
+ including during startup or reconciliation. Invalidations still arrive while
163
+ other work keeps the process alive. Persistent and non-persistent subscriptions
164
+ have independent lifetimes; closing the last persistent one lets Node exit.
165
+ Native environment cleanup retires any remaining event registrations and joins
166
+ the hub at exit. `signal` triggers close;
167
+ await `close()` or `[Symbol.asyncDispose]()` to join owned work.
168
+
169
+ `health()` returns `starting`, `ready`, `reconciling`, `unavailable`, or `closed`,
170
+ the actual mode, observed `directories`, and optional `{ operation, code, error }`
171
+ failure. A running reconciliation reports `reconciling`, then returns to `ready`.
172
+ Observation becomes `unavailable` on loss of Root authority (removed, replaced,
173
+ or inaccessible), fatal native backend/registration failures such as `watch-limit`,
174
+ deterministic size limits (`too-large`, operation `scan`), or callback contract
175
+ violations. Invalid scope admission, including symbolic parents, still rejects;
176
+ it never grants authority through a link. Transient descendant churn does not
177
+ make an admitted subscription unavailable. Callbacks may synchronously retire the owner; returning a thenable from
178
+ `onInvalidate`, `onHealth`, or `exclude` rejects observation. Application async
179
+ work remains application-owned and is not joined by the subscription.
180
+
181
+ `close()` is terminal, idempotent, and joined. Observation failures remain in
182
+ health but do not make successful retirement reject. Retirement failures do
183
+ reject, with a `SuppressedError` retaining an earlier observation failure when
184
+ both exist. No new generation or callback can be admitted after close.
package/docs/writing.md CHANGED
@@ -211,6 +211,11 @@ can establish them; native disposal can retain them inside a `SuppressedError`
211
211
  cause. Preserve those details when handling errors: a rejection can follow
212
212
  complete publication, and an indeterminate link or native rename must preserve names for
213
213
  recovery. A cleanup or close failure also retains the original operation failure.
214
+ The JavaScript fallback checks the destination again immediately before attempting
215
+ publication. A collision observed there leaves publication unattempted and cleans
216
+ the owned stage. A later collision or other error from the link call remains
217
+ indeterminate, including `EEXIST`; the error code alone does not prove that the
218
+ filesystem left both names unchanged.
214
219
  No later verification, mode, or synchronization failure authorizes deleting an
215
220
  already published complete destination. See [receipt meanings](staged-file.md).
216
221
 
@@ -354,6 +359,11 @@ replacing rename. After dispatch it rechecks both parent identities, so a
354
359
  post-operation rejection can mean the no-replace rename completed. Directory
355
360
  moves continue to require `overwrite: true`.
356
361
 
362
+ Linux without `openat2` uses the [guarded native parent walk](native.md#linux-without-openat2).
363
+ The move still uses `renameat2(RENAME_NOREPLACE)` and preserves collisions;
364
+ parent resolution reports the documented `best-effort` containment class.
365
+ Disabling the addon still makes no-clobber moves unavailable.
366
+
357
367
  Both selected canonical endpoints are admitted inside the retained Root after
358
368
  native parent admission. With `mutationSymlinks: "reject"`, both full operation
359
369
  paths are rechecked after the live mutation-authority callback and before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.19.0",
3
+ "version": "0.21.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "keywords": [
6
6
  "filesystem",
@@ -95,6 +95,10 @@
95
95
  "types": "./dist/file-lock.d.ts",
96
96
  "default": "./dist/file-lock.js"
97
97
  },
98
+ "./watch": {
99
+ "types": "./dist/watch.d.ts",
100
+ "default": "./dist/watch.js"
101
+ },
98
102
  "./walk": {
99
103
  "types": "./dist/walk.d.ts",
100
104
  "default": "./dist/walk.js"
@@ -169,18 +173,18 @@
169
173
  "archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
170
174
  },
171
175
  "optionalDependencies": {
172
- "@openclaw/fs-safe-darwin-arm64": "0.19.0",
173
- "@openclaw/fs-safe-darwin-x64": "0.19.0",
174
- "@openclaw/fs-safe-linux-arm64-gnu": "0.19.0",
175
- "@openclaw/fs-safe-linux-arm64-musl": "0.19.0",
176
- "@openclaw/fs-safe-linux-x64-gnu": "0.19.0",
177
- "@openclaw/fs-safe-linux-x64-musl": "0.19.0",
178
- "@openclaw/fs-safe-win32-x64-msvc": "0.19.0",
176
+ "@openclaw/fs-safe-darwin-arm64": "0.21.0",
177
+ "@openclaw/fs-safe-darwin-x64": "0.21.0",
178
+ "@openclaw/fs-safe-linux-arm64-gnu": "0.21.0",
179
+ "@openclaw/fs-safe-linux-arm64-musl": "0.21.0",
180
+ "@openclaw/fs-safe-linux-x64-gnu": "0.21.0",
181
+ "@openclaw/fs-safe-linux-x64-musl": "0.21.0",
182
+ "@openclaw/fs-safe-win32-x64-msvc": "0.21.0",
179
183
  "jszip": "^3.10.2"
180
184
  },
181
185
  "devDependencies": {
182
186
  "@emnapi/runtime": "2.0.0-alpha.5",
183
- "@napi-rs/cli": "3.10.3",
187
+ "@napi-rs/cli": "3.10.5",
184
188
  "@types/node": "^26.6.1",
185
189
  "@vitest/coverage-v8": "5.0.1",
186
190
  "fast-check": "^4.10.1",