@openclaw/fs-safe 0.21.0 → 0.21.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 (57) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/archive-zip-directory.js +4 -0
  3. package/dist/archive-zip-loader.js +3 -1
  4. package/dist/archive-zip-manifest.js +3 -1
  5. package/dist/copy-publication.d.ts +1 -3
  6. package/dist/copy-publication.js +2 -6
  7. package/dist/deny-mutation-match.d.ts +2 -0
  8. package/dist/deny-mutation-match.js +71 -0
  9. package/dist/deny-mutations.js +4 -3
  10. package/dist/file-identity.js +10 -2
  11. package/dist/file-lock-sync-admission.js +5 -1
  12. package/dist/file-lock-sync-root-io.d.ts +2 -2
  13. package/dist/file-lock-sync-root-io.js +1 -1
  14. package/dist/file-lock-sync.js +2 -2
  15. package/dist/file-store-prune.js +4 -4
  16. package/dist/mutation-authority.js +5 -0
  17. package/dist/native-binding.d.ts +11 -0
  18. package/dist/native-pinned-write.js +8 -2
  19. package/dist/pinned-mutation-admission.js +4 -2
  20. package/dist/replace-file-copy-fallback.js +4 -4
  21. package/dist/replace-file-destination.d.ts +4 -0
  22. package/dist/replace-file-destination.js +67 -5
  23. package/dist/replace-file.js +2 -1
  24. package/dist/retained-file-types.d.ts +3 -14
  25. package/dist/root-directory-list.d.ts +2 -0
  26. package/dist/root-directory-list.js +46 -20
  27. package/dist/root-move-noreplace.js +3 -3
  28. package/dist/sidecar-lock-acquire.js +3 -3
  29. package/dist/sidecar-lock-reclaim.d.ts +2 -2
  30. package/dist/sidecar-lock-reclaim.js +6 -6
  31. package/dist/sidecar-lock.js +4 -4
  32. package/dist/staged-symlink-types.d.ts +4 -14
  33. package/dist/test-hooks.d.ts +1 -0
  34. package/dist/watch-alias.js +11 -3
  35. package/dist/watch-hints.d.ts +2 -1
  36. package/dist/watch-hints.js +17 -1
  37. package/dist/watch-native.d.ts +4 -0
  38. package/dist/watch-native.js +18 -1
  39. package/dist/watch-scan.d.ts +5 -1
  40. package/dist/watch-scan.js +37 -6
  41. package/dist/watch-stream.d.ts +8 -0
  42. package/dist/watch-stream.js +32 -0
  43. package/dist/watch-types.d.ts +2 -0
  44. package/dist/watch.js +35 -7
  45. package/docs/archive.md +6 -0
  46. package/docs/atomic.md +23 -2
  47. package/docs/contributing.md +69 -6
  48. package/docs/install.md +2 -0
  49. package/docs/native.md +24 -6
  50. package/docs/public-api.md +23 -2
  51. package/docs/retained-file.md +4 -2
  52. package/docs/root.md +19 -7
  53. package/docs/sidecar-lock.md +1 -1
  54. package/docs/staged-symlink.md +2 -1
  55. package/docs/testing.md +132 -10
  56. package/docs/watch.md +77 -10
  57. package/package.json +8 -8
@@ -0,0 +1,32 @@
1
+ import path from "node:path";
2
+ function shallowest(paths, limit) {
3
+ const result = [];
4
+ const sorted = [...new Set(paths)].sort((a, b) => a.split(path.sep).length - b.split(path.sep).length || a.localeCompare(b));
5
+ for (const name of sorted) {
6
+ if (result.some(parent => name.startsWith(parent.endsWith(path.sep) ? parent : parent + path.sep)))
7
+ continue;
8
+ result.push(name);
9
+ if (result.length === limit)
10
+ break;
11
+ }
12
+ return result;
13
+ }
14
+ /** Canonical names come only from guarded directory observations, never backend hints. */
15
+ export function watchStreamPaths(snapshot, scopes) {
16
+ const anchors = [];
17
+ for (const scope of scopes) {
18
+ let name = scope.kind === "tree" && scope.depth !== 0 ? scope.path : path.dirname(scope.path);
19
+ if (name === ".")
20
+ name = "";
21
+ while (!snapshot.directoryPaths?.has(name)) {
22
+ if (!name)
23
+ break;
24
+ const parent = path.dirname(name);
25
+ name = parent === "." ? "" : parent;
26
+ }
27
+ const canonical = snapshot.directoryPaths?.get(name);
28
+ if (canonical)
29
+ anchors.push(canonical);
30
+ }
31
+ return { anchors: shallowest(anchors, 128), exclusions: shallowest(snapshot.excludedDirectories?.values() ?? [], 8) };
32
+ }
@@ -37,6 +37,8 @@ export type WatchOptions = {
37
37
  persistent?: boolean;
38
38
  /** Guarded reconciliation interval: events 30000, poll 1000; minimum 20 ms. */
39
39
  intervalMs?: number;
40
+ /** Polling transport interval; overrides intervalMs in poll mode or auto fallback. Minimum 20 ms. */
41
+ pollIntervalMs?: number;
40
42
  exclude?: (entry: WatchEntry) => boolean;
41
43
  maxDirectories?: number;
42
44
  maxEntries?: number;
package/dist/watch.js CHANGED
@@ -5,7 +5,9 @@ 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 { admittedNativeChanges } from "./watch-alias.js";
8
- import { changedEntries, guardedHintChanges, scopedChanges } from "./watch-hints.js";
8
+ import { watchStreamPaths } from "./watch-stream.js";
9
+ import { changedEntries, excludedWatchPath, guardedHintChanges, scopedChanges } from "./watch-hints.js";
10
+ import path from "node:path";
9
11
  import { watchBinding, NativeWatchBackend } from "./watch-native.js";
10
12
  import { getFsSafeNativeConfig } from "./native-config.js";
11
13
  import { isWatchPathError, scanWatch, watchScopes } from "./watch-scan.js";
@@ -48,6 +50,11 @@ export function watch(root, input) {
48
50
  let intervalMs = budget(options.intervalMs, mode === "events" ? 30_000 : 1000, "intervalMs", 2_147_483_647);
49
51
  if (intervalMs < 20)
50
52
  throw new RangeError("watch intervalMs must be at least 20");
53
+ const pollIntervalMs = budget(options.pollIntervalMs, options.intervalMs ?? 1000, "pollIntervalMs", 2_147_483_647);
54
+ if (pollIntervalMs < 20)
55
+ throw new RangeError("watch pollIntervalMs must be at least 20");
56
+ if (mode === "poll")
57
+ intervalMs = pollIntervalMs;
51
58
  const maxDirectories = budget(options.maxDirectories, 4096, "maxDirectories");
52
59
  const maxEntries = budget(options.maxEntries, 100_000, "maxEntries");
53
60
  const maxPendingPaths = budget(options.maxPendingPaths, 256, "maxPendingPaths", 4096);
@@ -76,6 +83,7 @@ export function watch(root, input) {
76
83
  let pendingWaiter;
77
84
  let runningWaiter;
78
85
  let pendingHint = false;
86
+ let pendingBackendOverflow = false;
79
87
  let pendingChanges = new Map();
80
88
  let timer;
81
89
  let hintTimer;
@@ -117,6 +125,7 @@ export function watch(root, input) {
117
125
  pending = false;
118
126
  pendingHint = false;
119
127
  pendingChanges = new Map();
128
+ pendingBackendOverflow = false;
120
129
  };
121
130
  const notifyHealth = () => {
122
131
  try {
@@ -203,6 +212,16 @@ export function watch(root, input) {
203
212
  const onHint = (g, batch) => {
204
213
  if (terminal || current !== g || g.abort.signal.aborted || failure !== undefined)
205
214
  return;
215
+ if (batch.overflow) {
216
+ pendingBackendOverflow = true;
217
+ try {
218
+ assertSynchronousCallbackResult(getFsSafeTestHooks()?.afterWatchBackendOverflow?.(context.rootReal, "received"), "afterWatchBackendOverflow");
219
+ }
220
+ catch (error) {
221
+ lose(error, "callback");
222
+ return;
223
+ }
224
+ }
206
225
  if (batch.error === "ESTALE")
207
226
  refreshBackend = true;
208
227
  else if (batch.error) {
@@ -213,6 +232,8 @@ export function watch(root, input) {
213
232
  pendingChanges = undefined;
214
233
  else if (pendingChanges)
215
234
  for (const hint of batch.hints) {
235
+ if (typeof hint.name === "string" && excludedWatchPath(snapshot, hint.directory ? path.join(hint.directory, hint.name) : hint.name))
236
+ continue;
216
237
  const key = JSON.stringify([hint.directory, hint.name]);
217
238
  if (!pendingChanges.has(key) && pendingChanges.size >= maxPendingPaths) {
218
239
  pendingChanges = undefined;
@@ -241,7 +262,7 @@ export function watch(root, input) {
241
262
  return false;
242
263
  mode = "poll";
243
264
  binding = undefined;
244
- intervalMs = options.intervalMs ?? 1000;
265
+ intervalMs = pollIntervalMs;
245
266
  return true;
246
267
  };
247
268
  const observe = async (g) => {
@@ -262,11 +283,12 @@ export function watch(root, input) {
262
283
  state = snapshot ? "reconciling" : "starting";
263
284
  notifyHealth();
264
285
  check(g);
265
- const started = performance.now();
266
286
  const hadHints = pendingHint;
287
+ const hadBackendOverflow = pendingBackendOverflow;
267
288
  const hints = pendingChanges;
268
289
  pendingHint = false;
269
290
  pendingChanges = new Map();
291
+ pendingBackendOverflow = false;
270
292
  clearTimeout(hintTimer);
271
293
  hintTimer = undefined;
272
294
  if (mode === "events" && !backend && g.scopes.length) {
@@ -276,6 +298,8 @@ export function watch(root, input) {
276
298
  onHint(g, batch);
277
299
  }, maxPendingPaths, persistent);
278
300
  backend = candidate;
301
+ if (snapshot)
302
+ candidate.configure(watchStreamPaths(snapshot, g.scopes));
279
303
  const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
280
304
  if (backend === candidate)
281
305
  onHint(g, batch);
@@ -288,7 +312,7 @@ export function watch(root, input) {
288
312
  }
289
313
  check(g);
290
314
  }
291
- const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot }, g.abort.signal, async (name, identity, guard) => {
315
+ const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot, previous: snapshot }, g.abort.signal, async (name, identity, guard) => {
292
316
  check(g);
293
317
  const existing = registered.get(name);
294
318
  const acquire = !existing || existing.dev !== identity.dev || existing.ino !== identity.ino;
@@ -311,6 +335,10 @@ export function watch(root, input) {
311
335
  registered.set(name, identity);
312
336
  }, retainRetirement);
313
337
  check(g);
338
+ if (backend?.configure(watchStreamPaths(next, g.scopes))) {
339
+ // The replacement stream is live before the next guarded pass covers the handover.
340
+ pending = true;
341
+ }
314
342
  // Retire stale inventory before the next pass; that crawl installs fresh anchors first.
315
343
  if ([...registered.keys()].some(name => !next.directories.has(name))) {
316
344
  refreshBackend = true;
@@ -345,10 +373,10 @@ export function watch(root, input) {
345
373
  let details = hadHints
346
374
  ? guardedHintChanges(g.scopes, snapshot, next, admittedHints, observed, maxPendingPaths)
347
375
  : observed;
348
- const behind = (hadHints || pendingHint) && performance.now() - started > coalesceMs;
349
- if (behind)
350
- details = undefined;
351
376
  snapshot = next;
377
+ if (!initial && !details && hadBackendOverflow) {
378
+ assertSynchronousCallbackResult(getFsSafeTestHooks()?.afterWatchBackendOverflow?.(context.rootReal, "reconciled"), "afterWatchBackendOverflow");
379
+ }
352
380
  // Publish before readiness; callbacks may synchronously retire this generation.
353
381
  if (initial || !details || details.length)
354
382
  dirty(g, initial ? "reconcile" : !details ? "overflow" : hadHints ? "event" : "reconcile", initial ? undefined : details);
package/docs/archive.md CHANGED
@@ -179,6 +179,12 @@ between native and JavaScript paths rather than reimplementing it in Rust.
179
179
 
180
180
  ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless internal separator and dot-component equivalence is allowed only after validation; every raw and Unicode interpretation must also agree on whether its name ends in `/` or `\`. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
181
181
 
182
+ ZIP preflight and bounded reads apply extraction's case-insensitive NFC collision
183
+ policy to the complete archive, including entries that extraction would strip
184
+ or skip. Known UTF-8 and Unicode Path names are checked during physical admission;
185
+ legacy names retain backend-selected decoding and undergo the same collision
186
+ check before callbacks or selected bytes are returned.
187
+
182
188
  ZIP end-record admission searches the bounded comment window for signatures
183
189
  while retaining complete comment-length and ambiguity checks. Dense signature
184
190
  sequences fall back to the bounded byte scan.
package/docs/atomic.md CHANGED
@@ -154,8 +154,9 @@ can replace the pathname, so compare the recorded identity with the current
154
154
  entry and recheck application authority before compensation. Receipts do not
155
155
  promise durable storage or authorize rollback.
156
156
 
157
- Both callbacks must complete synchronously; Promise and thenable results are
158
- rejected, and ordinary return values are ignored. The first callback refusal
157
+ Both callbacks must complete synchronously; Promise, thenable, and synchronous
158
+ or asynchronous generator results are rejected. Returned generators are never
159
+ advanced; other ordinary return values are ignored. The first callback refusal
159
160
  is terminal, including falsy thrown values; an `EPERM`, `EEXIST`, or `EBUSY` code
160
161
  from a callback never starts fallback or retry. Refusal during an in-place
161
162
  fallback also stops new restoration writes. Final mode, synchronization, close,
@@ -245,6 +246,22 @@ that same descriptor, and synchronizes the result. Any write, mode, or sync
245
246
  failure triggers a byte-and-mode restore and another sync through the same
246
247
  descriptor.
247
248
 
249
+ With mutation callbacks enabled, an `EIO` from destination `stat`/`lstat` after
250
+ successful truncation also attempts restoration through that retained descriptor.
251
+ Each restore write still requires live application authority and fresh exact
252
+ descriptor identity, regular-file, and configured hardlink checks. The failed
253
+ pathname observation is not retried to authorize restoration; no pathname is
254
+ opened, removed, or replaced. A successor at that name is left untouched.
255
+ `details.cleanup: "restored"` means the retained original file's bytes and mode
256
+ were restored and synchronized, not that the pathname still names it. The existing
257
+ `writing` receipt identifies that file; no `published` receipt is emitted for a
258
+ failed replacement. Restoration I/O failures report `"restore-failed"`.
259
+
260
+ Callback refusals, detected identity/type/link changes, and other metadata errors
261
+ remain terminal. Failed descriptor revalidation also stops restoration. These
262
+ cases can leave the retained file empty or partial with only a `writing` receipt;
263
+ before the first successful truncation, verification failure leaves it untouched.
264
+
248
265
  With `syncTempFile: false`, an exclusive-create copy fallback does not report
249
266
  success until its new destination writer closes successfully. This includes
250
267
  `"restore-original"` when the destination did not exist. A close rejection or throw is
@@ -275,6 +292,10 @@ through short reads and EOF, grow only as data arrives, and enforce the same
275
292
  `beforeRename` callback, and `ReplaceFileAtomicSyncFileSystem`. Use it inside
276
293
  synchronous boot paths or test setup code. It returns the same
277
294
  `{ method: "rename" | "copy-fallback" }` receipt as the async variant.
295
+ Promise, thenable, and synchronous or asynchronous generator results from the
296
+ hook reject with `TypeError` before publication; rejected promises are consumed
297
+ and generators are never advanced. Other synchronous return values are ignored.
298
+ The replacement is not published; owned-temp cleanup follows the rules above.
278
299
 
279
300
  ## `replaceDirectoryAtomic`
280
301
 
@@ -94,11 +94,11 @@ GLIBC versions. To inspect an existing binding, use
94
94
  `pnpm native:build` remains a host-toolchain development build; it does not
95
95
  establish the GNU release ABI floor.
96
96
 
97
- On Linux x64 with Docker, run `pnpm build`, copy the GNU x64 artifact from
97
+ On Linux x64 or arm64 with Docker, run `pnpm build`, copy the matching GNU artifact from
98
98
  `artifacts/` to `native/`, run `node scripts/stage-host-native.mjs`, then run
99
99
  `bash scripts/test-linux-glibc-floor.sh`. CI uses this command to load the actual
100
100
  artifact and run native security and no-replace move tests in Rocky Linux 8
101
- (glibc 2.28). GNU arm64 is cross-built and symbol-checked in the same CI matrix.
101
+ (glibc 2.28). Both GNU architectures execute this load test on matching runners.
102
102
 
103
103
  ## Test
104
104
 
@@ -172,6 +172,47 @@ actual edit latency. Run `FS_SAFE_TEST_SERIAL=1 pnpm check` to isolate local tim
172
172
  checks from the other filesystem stress suites. Watch fixtures use normal OS
173
173
  temporary storage; session scratch trees may suppress macOS filesystem events.
174
174
 
175
+ ### Optional Linux Testbox
176
+
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
179
+ target, and the pinned portable archive compiler. It leaves library builds and
180
+ validation commands to the caller and does not replace required CI checks.
181
+
182
+ Use an authenticated Blacksmith CLI with access to the repository and its
183
+ Blacksmith organization. From a full repository checkout, warm one session
184
+ through Crabbox:
185
+
186
+ ```sh
187
+ CRABBOX_BLACKSMITH_IDLE_TIMEOUT=240m crabbox warmup --provider blacksmith-testbox \
188
+ --blacksmith-org openclaw \
189
+ --blacksmith-workflow .github/workflows/testbox-validation.yml \
190
+ --blacksmith-job validate --blacksmith-ref main \
191
+ --idle-timeout 240m --timing-json
192
+ ```
193
+
194
+ Use a branch or tag containing the workflow for `--blacksmith-ref`; GitHub must
195
+ first have registered the workflow on the default branch. The job has a fixed
196
+ 240-minute limit. Keep the idle timeout at that limit (240 minutes in the native
197
+ Blacksmith CLI) because the pinned Testbox action can miss active SSH sessions
198
+ behind a forwarded port. Bound commands by the remaining job time and leave time
199
+ to collect results and stop before the deadline.
200
+
201
+ Use the returned `tbx_...` ID for subsequent commands. The `fs-safe-testbox`
202
+ wrapper restores the prepared tool paths and WASM compiler settings in the SSH
203
+ shell. For example:
204
+
205
+ ```sh
206
+ crabbox run --provider blacksmith-testbox --id <tbx_id> --timing-json -- \
207
+ fs-safe-testbox pnpm check
208
+ crabbox stop --provider blacksmith-testbox <tbx_id>
209
+ ```
210
+
211
+ Stop the session when finished and verify its terminal status. Blacksmith owns
212
+ checkout synchronization; record the tested source revision or diff, Testbox ID,
213
+ and Actions run. This backend is Linux-only and does not accept Crabbox's direct
214
+ SSH `--script` or `--download` flags. The workflow provides no application secrets.
215
+
175
216
  ### Method benchmarks
176
217
 
177
218
  `pnpm benchmark:methods` measures the callable library surface against synthetic
@@ -236,10 +277,32 @@ require the pnpm lifecycle CLI path; JavaScript CLIs run through Node and standa
236
277
  direct `node` invocation without lifecycle metadata is unsupported. Archive
237
278
  codecs and their dependencies are packed from the installed dependency graph.
238
279
 
239
- PR CI builds and executes four host targets: Linux x64 glibc, Linux x64 musl
240
- (Alpine), macOS arm64, and Windows x64. The root-only smoke runs on each. The
241
- seven-target source build matrix runs on release tags; packaging all seven is
242
- not execution proof for every architecture. The smoke writes manager versions,
280
+ PR CI builds and executes all seven shipped bindings. The existing check names
281
+ stay stable; extra runner/runtime combinations add checks without changing the
282
+ repository ruleset. Native lanes run Node 24; the GNU host lanes also exercise
283
+ Bun 1.4.2 (as do macOS and Windows).
284
+
285
+ | Lane | Runner | Architecture / libc | Runtime |
286
+ | --- | --- | --- | --- |
287
+ | JavaScript check | `ubuntu-latest` | x64 / glibc | Node 22, 24, 26 |
288
+ | JavaScript check | `macos-15` | arm64 | Node 22, 24, 26 |
289
+ | JavaScript check | `fs-safe-windows-16core` (`windows-latest`) | x64 | Node 22, 24, 26 |
290
+ | Native check | `ubuntu-latest` | x64 / glibc | Node 24, Bun 1.4.2 |
291
+ | Native check (forced no-openat2) | `ubuntu-latest` | x64 / glibc | Node 24 |
292
+ | Native check | `ubuntu-24.04-arm` | arm64 / glibc | Node 24, Bun 1.4.2 |
293
+ | Native check | `macos-15` | arm64 | Node 24, Bun 1.4.2 |
294
+ | Native check | `macos-15-intel` | x64 | Node 24, Bun 1.4.2 |
295
+ | Native check | `fs-safe-windows-16core` (`windows-latest`) | x64 | Node 24, Bun 1.4.2 |
296
+ | Native check | `windows-2022` (standard hosted) | x64 | Node 24, Bun 1.4.2 |
297
+ | Native check (Alpine 3.24) | `ubuntu-latest` | x64 / musl | Node 24 |
298
+ | Native check (Alpine 3.24) | `ubuntu-24.04-arm` | arm64 / musl | Node 24 |
299
+ | GNU glibc 2.28 build + Rocky Linux 8 load | `ubuntu-latest` | x64 / glibc | Node 24 |
300
+ | GNU glibc 2.28 build + Rocky Linux 8 load | `ubuntu-24.04-arm` | arm64 / glibc | Node 24 |
301
+ | Bundled package smoke | `ubuntu-latest`, `macos-15`, `fs-safe-windows-16core` | host | Node 22, 24 |
302
+ | Coverage | `ubuntu-latest`, `macos-15`, `fs-safe-windows-16core` | host | Node 22 |
303
+
304
+ Both musl lanes also run root-only package smoke with the real host binding.
305
+ The seven-target source build matrix still runs on release tags. The smoke writes manager versions,
243
306
  cases, and synthetic-fixture scope to `release-artifacts/consumer-proof.json`.
244
307
 
245
308
  ## Docs
package/docs/install.md CHANGED
@@ -130,6 +130,8 @@ features, including strict owned-tree temp cleanup, retained-directory staging,
130
130
  and atomic `rename-noreplace` (including the default no-clobber `Root.move()`),
131
131
  remain unavailable. Operations without a safe fallback fail with `helper-unavailable`
132
132
  when the matching package is absent, incompatible, or disabled.
133
+ No-clobber `Root.move()` preserves a native loader failure in the error's
134
+ `cause`, including the original missing-library or incompatible-glibc diagnostic.
133
135
 
134
136
  Upgrading an existing 0.5 consumer? Follow [Migrating to 0.6](migrating-to-0.6.md)
135
137
  before deploying with native mode `require` or native-only features.
package/docs/native.md CHANGED
@@ -96,12 +96,30 @@ operation is still a permission error and never triggers a retry. Install
96
96
  syscall filters before the first native operation; a later `ENOSYS` fails with
97
97
  `ENOTSUP`, rather than changing the cached mechanism during a call.
98
98
 
99
- The fallback opens each directory relative to a retained descriptor with
100
- `O_PATH | O_DIRECTORY | O_NOFOLLOW`, compares exact device/inode/type identities,
101
- and rechecks the retained parent chain before and after the final no-follow
102
- open. It rejects all symlink components, including procfs magic links, even
103
- when the low-level caller requests symlink following. Public Root policy and
104
- canonical-path admission, hardlink rejection, pinned-file checks, and mutation
99
+ The fallback inspects components with `O_PATH | O_NOFOLLOW`, follows relative
100
+ symlink targets using a stack of retained directory descriptors, and rejects
101
+ absolute targets or `..` past the retained root with `EXDEV`. It permits at most
102
+ 40 link expansions (`ELOOP` beyond that limit). Final `O_NOFOLLOW` and exclusive
103
+ creation retain their syscall semantics, including opening the link itself with
104
+ `O_PATH | O_NOFOLLOW` and creating through a dangling in-root relative link.
105
+ Exact device/inode/type identities are checked before and after the final
106
+ no-follow open; followed links also retain their descriptors and have their
107
+ named identities and target strings rechecked. Detected replacements fail with
108
+ `EXDEV`, including changes to directories left behind by a link's `..` target.
109
+
110
+ The fallback also honors `nosymfollow` mount restrictions on retained links.
111
+ Two conservative restrictions remain: the fallback refuses to follow **any
112
+ procfs symlink** with `ELOOP`, including ordinary links such as `/proc/mounts`.
113
+ Userspace metadata cannot distinguish these from procfs magic links, which
114
+ `RESOLVE_NO_MAGICLINKS` must never follow. Opening a final link itself with
115
+ `O_PATH | O_NOFOLLOW` remains allowed. In a sticky, world-writable directory,
116
+ the fallback refuses all symlink following with `EACCES`, even when the calling
117
+ thread or directory owner owns the link, or `fs.protected_symlinks=0`. This
118
+ preserves Linux's protected-symlink restriction without assuming the thread's
119
+ filesystem UID or treating equal mapped `stat` UIDs as proof of equal kernel
120
+ owners (distinct unmapped owners can both appear as the overflow UID).
121
+
122
+ Public Root policy and canonical-path admission, hardlink rejection, pinned-file checks, and mutation
105
123
  identity fences remain in place. `openBeneath()` reports `best-effort`: these
106
124
  identity samples detect replacements but cannot make a multi-component walk
107
125
  atomic against a hostile process renaming directories between samples. This
@@ -79,6 +79,16 @@ missing relative suffixes beneath an existing directory using bounded temporary
79
79
  directory probes. The caller owns Unicode-pair policy, caching, and the fallback
80
80
  for `undefined`. See [path suffix alias probing](path-suffix-aliases.md).
81
81
 
82
+ `retainSymlinkInDirectory` holds an explicitly identified POSIX symlink through
83
+ exact-slot no-replace publication; its receipts use the `StagedSymlink*` and
84
+ `PublishedSymlinkReceipt` types. See [staged symlinks](staged-symlink.md).
85
+
86
+ `retainFileInDirectory` retains an existing Windows NTFS file through a native
87
+ handle for explicit identity-bound retirement. It is described by
88
+ `RetainFileInDirectoryOptions`, `RetainedFile`, `RetainedFileAdmission`,
89
+ `RetainedFileExpected`, `RetainedFileIssue`, `RetainedFileReceipt`, and
90
+ `RetainedFileResult`. See [retained Windows files](retained-file.md).
91
+
82
92
  ## Guest source
83
93
 
84
94
  `@openclaw/fs-safe/guest` exports `GUEST_FILESYSTEM_PYTHON`,
@@ -132,8 +142,11 @@ parent/workspace descriptors; see the
132
142
  Atomic helper option and receipt types include
133
143
  `MovePathWithCopyFallbackOptions`, `ReplaceDirectoryAtomicOptions`,
134
144
  `ReplaceFileAtomicSyncOptions`, `ReplaceFileAtomicResult`,
135
- `ReplaceFileAtomicRestoreCleanup`, `ReplaceFileCopyFallbackRestorePolicy`, and
136
- `ReplaceFileDestinationHardlinkPolicy`.
145
+ `ReplaceFileAtomicRestoreCleanup`, `ReplaceFileCopyFallbackRestorePolicy`,
146
+ `ReplaceFileDestinationHardlinkPolicy`, and `ReplaceFileAtomicDestinationState`.
147
+ The `assertBeforeMutation` option rechecks caller authority before new effects,
148
+ and `onDestinationState` reports retained destination identities as
149
+ `ReplaceFileAtomicDestinationState` values; see [atomic writes](atomic.md).
137
150
 
138
151
  The durability surface also exports the synchronous strict
139
152
  `syncDirectorySync`, plus `DirectoryReceipt`, `DurableDirectoryReceipt`,
@@ -160,6 +173,14 @@ Archive option and policy types are `ExtractArchiveOptions`,
160
173
  used by extractors. `resolvePackedRootDir` finds the single packed root when an
161
174
  archive layout permits it; neither helper weakens entry validation.
162
175
 
176
+ ## `watch`
177
+
178
+ `@openclaw/fs-safe/watch` exports `watch()` plus `WatchScope`, `WatchEntry`,
179
+ `WatchChange`, `WatchInvalidation`, `WatchFailure`, `WatchHealth`,
180
+ `WatchOptions`, and `WatchSubscription`. Invalidations are advisory; guarded
181
+ scans stay authoritative. See [filesystem observation](watch.md) for modes,
182
+ budgets, `persistent`, and lifecycle.
183
+
163
184
  ## Keeping this list honest
164
185
 
165
186
  Every runtime and type name in `test/public-api.json` must appear somewhere in
@@ -75,8 +75,10 @@ outside this capability's threat model.
75
75
  `remove()` admits authority once, revalidates the retained object, sets native
76
76
  handle disposition, closes the file, observes the name under the still-retained
77
77
  parent, then closes ancestry. No pathname is passed to unlink. Ordinary
78
- `dispose()` and `[Symbol.dispose]()` only close resources. Asynchronous authority
79
- callbacks and reentrancy are refused; even caught reentrancy poisons that attempt.
78
+ `dispose()` and `[Symbol.dispose]()` only close resources. Authority callbacks
79
+ returning Promises, thenables, or synchronous or asynchronous generator objects
80
+ are refused without deletion; generators are never advanced. Reentrancy is also
81
+ refused, and even caught reentrancy poisons that attempt.
80
82
  Repeated settlement returns the original receipt without another mutation or
81
83
  another authority call. A copied receipt cannot be used to remove a replacement.
82
84
  `[Symbol.dispose]()` throws `FsSafeError` with the complete result in
package/docs/root.md CHANGED
@@ -263,8 +263,10 @@ occurred, later cancellation or verification failure preserves the destination.
263
263
  The synchronous optional `onDestinationPublished` callback receives a frozen
264
264
  `RootCopyPublicationReceipt` containing `{ path, dev, ino }`, with exact bigint identity immediately after
265
265
  publication, before later checks can fail. Callback errors also preserve the
266
- published file. This receipt records an outcome; it does not authorize removing
267
- a file that another actor may have edited. Application recovery and cooperative
266
+ published file. Promise, thenable, and synchronous or asynchronous generator
267
+ results reject with `TypeError`; returned generators are never advanced. Other
268
+ synchronous return values are ignored. This receipt records an outcome; it does
269
+ not authorize removing a file that another actor may have edited. Application recovery and cooperative
268
270
  locking remain caller-owned.
269
271
 
270
272
  Existing `copyIn` callers must account for completed destinations retained after
@@ -324,10 +326,12 @@ truncation, append, move, and removal. Buffered writes use bounded chunks and
324
326
  recheck before every partial-write submission; file removal submits a direct
325
327
  unlink request. Native calls that perform multiple filesystem steps are one
326
328
  dispatch. No asynchronous wait separates the check
327
- from that dispatch. A thrown value rejects the operation unchanged; an async
328
- or thenable-returning callback rejects with `TypeError` before that mutation.
329
- Synchronous return values are ignored. Callbacks can run multiple times and
330
- must inspect current authority each time.
329
+ from that dispatch. A thrown value rejects the operation unchanged; a Promise,
330
+ thenable, or synchronous or asynchronous generator result rejects with `TypeError`
331
+ before that mutation. Returned generators are never advanced. Other synchronous
332
+ return values are ignored. Generator detection applies to generator objects
333
+ themselves, not proxy wrappers. Callbacks can run multiple times and must inspect
334
+ current authority during each call; return-value validation does not establish it.
331
335
  Directory creation rechecks the retained parent after the callback and before
332
336
  submitting mkdir, so a replacement is rejected before creating that component.
333
337
  Overwrite moves recheck the retained root, parents, source identity and both
@@ -354,7 +358,9 @@ For `openWritable()`, the callback covers the library's parent creation,
354
358
  exclusive creation, and truncation. The returned raw `FileHandle` belongs to
355
359
  the caller, which must check authority before its own later writes.
356
360
 
357
- All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
361
+ All mutation methods accept `denyMutations?: { paths?: string[]; prefixes?: string[] }`. Entries must be absolute paths. `paths` blocks those exact paths; `prefixes` blocks those paths and their descendants. fs-safe preserves path strings exactly and canonicalizes through existing ancestors before comparing, so a symlinked ancestor to a denied location is still denied. Missing suffixes also match prospective case and canonical Unicode normalization aliases: case-folding and Unicode NFC/NFD-equivalent spellings cannot bypass a denied path or prefix. A read-only observation of the existing parent can establish that ASCII case variants are distinct. Admission never creates temporary probe files or directories. If sensitivity cannot be established (including empty or unreadable parents, future directories, and Unicode normalization), equivalent suffixes are denied conservatively, even on a filesystem that would allow distinct names. Distinct existing canonical ancestors and unrelated names remain distinct. Observations are local to each synchronous policy check and are refreshed after callbacks or mutations. This conservative fallback does not model other filesystem-specific equivalences, such as HFS+ ignorable formatting characters.
362
+
363
+ Denied mutations throw `FsSafeError` with code `denied-path`. Use this for caller-specific sensitive paths, not as a replacement for the root boundary, symlink, or hardlink checks.
358
364
 
359
365
  `move()` snapshots its merged default and per-call mutation policy before
360
366
  asynchronous preparation. Later changes to the original policy objects or arrays
@@ -389,6 +395,12 @@ fs.entries(rel, options?) // nonrecursive AsyncIterable<DirEntry>, includ
389
395
  fs.resolve(rel) // absolute path inside the root, after canonicalization
390
396
  ```
391
397
 
398
+ Directory enumeration (`list`, `entries`, and `walk`) requires UTF-8 filenames.
399
+ A discovered name that cannot be represented losslessly as a JavaScript string
400
+ rejects with `invalid-path`, before looking up metadata through that name.
401
+ Streaming traversal may already have yielded earlier entries. Literal Unicode
402
+ replacement characters (`U+FFFD`) remain valid names.
403
+
392
404
  These do not pin a later operation. During `stat()`, the exact selected target and
393
405
  parent are checked around metadata collection; `list()` checks one exact selected
394
406
  directory around the complete name/metadata batch instead of repeating containment
@@ -61,7 +61,7 @@ Always release locks in a `finally` block. Application-managed graceful shutdown
61
61
 
62
62
  Exit cleanup tolerates shared managers created by older package copies that lack reclaim-guard state, and continues through later manager domains. Handler ownership remains first-registration-wins: loading an updated copy does not replace an older copy's registered handler. First registration is committed only after Node accepts the listener; synchronous `newListener` reentry fails closed and a thrown registration rolls back so a later acquisition can retry. Restart with the updated copy registering first to obtain the fix. After building, `node scripts/legacy-lock-exit-proof.mjs` checks clean exit, removal of legacy and modern raw locks, and preservation of a retained lock using synthetic temporary files.
63
63
 
64
- Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
64
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check. Raw acquisition, snapshot, and exit cleanup capture bigint device/inode values; unsafe numeric identities from older in-process receipts cannot authorize cleanup. If Windows reports a zero device or inode for either identity observation, that check is inconclusive and removal is skipped.
65
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
 
@@ -86,7 +86,8 @@ the original receipt is never replaced by a post-publication identity.
86
86
  touching descriptor numbers.
87
87
  - `await using` invokes cleanup and raises on a `preserved` outcome. Invocation
88
88
  order serializes descriptor work; reentrant calls from the authority callback
89
- reject. Callbacks must be synchronous; returned thenables are refused.
89
+ reject. Callbacks must be synchronous; returned thenables and synchronous or
90
+ asynchronous generator objects are refused. Generators are never advanced.
90
91
 
91
92
  Errors carry `StagedSymlinkFailureDetails` in `FsSafeError.details`: the phase,
92
93
  recorded `publication`, and a cleanup receipt when applicable. Publication is