@openclaw/fs-safe 0.20.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.
- package/CHANGELOG.md +23 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/atomic.d.ts +1 -1
- package/dist/native-binding.d.ts +22 -0
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +2 -0
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +17 -0
- package/dist/replace-file-destination.js +61 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +29 -10
- package/dist/retained-file-types.d.ts +61 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +7 -1
- package/dist/root-directory-list.js +48 -23
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -2
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/temp-target.js +3 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +3 -0
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/docs/advanced.md +1 -0
- package/docs/atomic.md +61 -0
- package/docs/contributing.md +5 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/native-helper.md +9 -0
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +60 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/package.json +12 -8
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Retained Windows files
|
|
3
|
+
description: "Explicit identity-bound retirement of an existing file; resource settlement is not persistence."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Retain an existing Windows file
|
|
7
|
+
|
|
8
|
+
`retainFileInDirectory` from `@openclaw/fs-safe/advanced` retains **one existing
|
|
9
|
+
regular direct child**, without creating or deleting anything at admission.
|
|
10
|
+
It requires the matching native package. There is no pathname-unlink fallback.
|
|
11
|
+
The initial implementation supports fixed local NTFS drives only; other systems
|
|
12
|
+
return `unsupported`.
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
import { retainFileInDirectory } from "@openclaw/fs-safe/advanced";
|
|
16
|
+
|
|
17
|
+
const admission = retainFileInDirectory({
|
|
18
|
+
directory: producer.directory, // canonical C:\... spelling
|
|
19
|
+
parent: producer.parentIdentity, // exact bigint dev/ino
|
|
20
|
+
basename: producer.basename,
|
|
21
|
+
expected: producer.expected, // bigint dev/ino/size/mtimeNs/ctimeNs and SHA-256
|
|
22
|
+
assertBeforeMutation: () => producer.assertCurrentExclusiveAuthority(),
|
|
23
|
+
});
|
|
24
|
+
if (admission.status === "retained") {
|
|
25
|
+
using file = admission.file;
|
|
26
|
+
const outcome = file.remove(); // explicit; using alone does NOT delete
|
|
27
|
+
// outcome.persistence is always "not-proven".
|
|
28
|
+
producer.recordRetirementObservation(outcome);
|
|
29
|
+
}
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
The producer must capture its expected identity, generation and bytes during its
|
|
33
|
+
own creation/ownership protocol. Reading an arbitrary current pathname immediately
|
|
34
|
+
before admission does **not** authenticate the producer. `Number` identities,
|
|
35
|
+
zero/unknown identities, negative/overflowing identities, and malformed digests
|
|
36
|
+
are rejected. A full opaque native volume/file ID is included in the receipt;
|
|
37
|
+
no native handle or numeric descriptor is exposed. Receipts are immutable facts,
|
|
38
|
+
not transferable authority.
|
|
39
|
+
|
|
40
|
+
## Public types
|
|
41
|
+
|
|
42
|
+
- `RetainFileInDirectoryOptions`: immutable admission inputs and synchronous authority.
|
|
43
|
+
- `RetainedFileExpected`: exact producer identity, write generation and expected digest.
|
|
44
|
+
- `RetainedFileAdmission`: a retained `RetainedFile` or a refusal/settlement result.
|
|
45
|
+
- `RetainedFileReceipt`: immutable admitted facts, never reusable authority.
|
|
46
|
+
- `RetainedFileResult`: separate disposition, namespace, resources and persistence facts.
|
|
47
|
+
- `RetainedFileIssue`: phase, native code/message and optional original authority cause.
|
|
48
|
+
|
|
49
|
+
## Admission and mutation custody
|
|
50
|
+
|
|
51
|
+
The directory's ancestry is opened component by component without following
|
|
52
|
+
reparse points, with delete sharing denied. Its exact expected parent identity
|
|
53
|
+
and canonical path are checked. The file is opened relative to that retained
|
|
54
|
+
parent, with write/delete sharing denied. Existing writer handles refuse
|
|
55
|
+
admission; a read-oplock grant also excludes preexisting writable mapped
|
|
56
|
+
sections. That oplock request is cancelled and joined before admission returns,
|
|
57
|
+
while the no-write-sharing file handle remains open. No detached request remains.
|
|
58
|
+
|
|
59
|
+
The opened file must match the expected exact NTFS identity, single-link regular
|
|
60
|
+
type, write/change generation, size and SHA-256. Names with stream/device syntax,
|
|
61
|
+
trailing-dot/space aliases and observed named data streams are unsupported.
|
|
62
|
+
Readonly attributes and ACL denials are not repaired or overridden. Verification
|
|
63
|
+
is synchronous and bounded by `maxBytes` (default 16 MiB, maximum 64 MiB).
|
|
64
|
+
|
|
65
|
+
The original producer must retain **exclusive mutation custody for the entire
|
|
66
|
+
file**, including alternate streams, attributes, security and hardlink creation.
|
|
67
|
+
Windows read/write sharing is per-stream; it is not an application lock over
|
|
68
|
+
all possible aliases. The helper rejects observed alternate streams and changed
|
|
69
|
+
generations, but does not turn a point-in-time check into exclusion of concurrent
|
|
70
|
+
alias/attribute operations. The synchronous `assertBeforeMutation` must check
|
|
71
|
+
that this original authority is still current. Callers unable to establish that
|
|
72
|
+
custody must not call `remove`. Privileged/raw-volume/kernel modifications are
|
|
73
|
+
outside this capability's threat model.
|
|
74
|
+
|
|
75
|
+
`remove()` admits authority once, revalidates the retained object, sets native
|
|
76
|
+
handle disposition, closes the file, observes the name under the still-retained
|
|
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.
|
|
80
|
+
Repeated settlement returns the original receipt without another mutation or
|
|
81
|
+
another authority call. A copied receipt cannot be used to remove a replacement.
|
|
82
|
+
`[Symbol.dispose]()` throws `FsSafeError` with the complete result in
|
|
83
|
+
`error.details.result` if resource settlement is uncertain; `dispose()` returns
|
|
84
|
+
that result directly. GC closes only and produces no settlement receipt; use
|
|
85
|
+
explicit disposal.
|
|
86
|
+
|
|
87
|
+
## Read the facts separately
|
|
88
|
+
|
|
89
|
+
| Field | Meaning |
|
|
90
|
+
| --- | --- |
|
|
91
|
+
| `status` | `unsupported`, `not-attempted`, `preserved-mismatch`, `disposition-accepted`, `name-absent-after-settlement`, `failed`, or `indeterminate`. |
|
|
92
|
+
| `disposition` | Whether native deletion was unattempted, accepted, rejected, or indeterminate. |
|
|
93
|
+
| `namespace` | A post-file-close observation: absent, original, foreign, unknown, or not observed. Absence alone never authenticates prior deletion. |
|
|
94
|
+
| `resources` | `closed` or `close-failed`; operation and close errors are retained together. An uncertain close is never retried against a possibly recycled handle. |
|
|
95
|
+
| `persistence` | Always `not-proven`; no namespace persistence barrier was performed. |
|
|
96
|
+
|
|
97
|
+
An accepted disposition can still have an unknown/foreign/original namespace
|
|
98
|
+
observation or failed close. A later replacement may exist even after observed
|
|
99
|
+
absence. Existing read handles can retain access to old bytes after the name is
|
|
100
|
+
absent. Namespace observation does not certify all foreign handles are closed.
|
|
101
|
+
An unexpected native binding failure is indeterminate, not success.
|
|
102
|
+
|
|
103
|
+
## No persistence or service-transaction guarantee
|
|
104
|
+
|
|
105
|
+
This API does not flush a volume, implement a journal, certify power-loss-safe
|
|
106
|
+
unlink, or issue an application dependency release. Process-termination tests
|
|
107
|
+
establish process-owned handle lifetime only, not crash/storage durability.
|
|
108
|
+
|
|
109
|
+
An update transaction must independently qualify either a real namespace
|
|
110
|
+
persistence barrier or original-owned durable recovery with correct ordering,
|
|
111
|
+
generation binding and restart consumption. Until then it must retain its
|
|
112
|
+
uncertain recovery/dependency state. Successful removal of one payload does not
|
|
113
|
+
establish a committed multi-file transaction or justify deleting its receipt.
|
package/docs/root.md
CHANGED
|
@@ -68,7 +68,10 @@ fs.walk(rel, options) // root-bounded AsyncIterable<{ relativePath, kin
|
|
|
68
68
|
|
|
69
69
|
`walk()` is the incremental, root-bounded recursive scan. It supports entry and
|
|
70
70
|
depth budgets, cancellation, and `symlinkPolicy: "skip" |
|
|
71
|
-
"follow-within-root"
|
|
71
|
+
"follow-within-root" | "include"`. Include mode returns links as
|
|
72
|
+
`{ relativePath, kind: "symlink", size }` without resolving or entering their
|
|
73
|
+
targets, including dangling and outside-root links. `size` describes the link,
|
|
74
|
+
not its target. With an entry budget, sorted walks prepare small metadata
|
|
72
75
|
batches within the remaining budget; unbounded sorted walks reuse the full
|
|
73
76
|
directory snapshot.
|
|
74
77
|
The default `order: "sorted"` enumerates and sorts each directory's names;
|
|
@@ -92,6 +95,8 @@ Directory reads remain fail-fast by default. With
|
|
|
92
95
|
`{ relativePath, kind: "directory-error", size: 0, error }` and continues with
|
|
93
96
|
the remaining tree. That policy also covers identity-check failures after an
|
|
94
97
|
awaited filter, while callback failures always reject.
|
|
98
|
+
In include mode, a directory that becomes a symlink before descent is a
|
|
99
|
+
`path-mismatch` directory error; it is never silently omitted or followed.
|
|
95
100
|
See [Directory walking](walk.md) for the pure-Node guarantees and the contrast
|
|
96
101
|
with the standalone best-effort walkers.
|
|
97
102
|
|
package/docs/temp.md
CHANGED
|
@@ -22,10 +22,11 @@ substitutions; the compatible JavaScript fallback has the narrower race
|
|
|
22
22
|
contract documented below.
|
|
23
23
|
|
|
24
24
|
On POSIX, workspace creation verifies the supplied root and its canonical
|
|
25
|
-
ancestors before creating a child.
|
|
26
|
-
effective user
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
ancestors before creating a child. The supplied root must be owned by the
|
|
26
|
+
effective user and must not be group/world writable, even with the sticky bit.
|
|
27
|
+
Use a private per-user directory rather than supplying a shared `/tmp` directly.
|
|
28
|
+
Ancestors must be owned by the effective user or root; group/world-writable
|
|
29
|
+
ancestors must have the sticky bit. Foreign-owned directories and non-sticky writable ancestors reject
|
|
29
30
|
with `not-owned` or `insecure-permissions`; unavailable effective-user identity
|
|
30
31
|
rejects with `permission-unverified`. Existing supplied directories keep their
|
|
31
32
|
permissions. Missing root components are created at `0o700` and initialized
|
|
@@ -34,6 +35,25 @@ mode, correction uses a verified directory descriptor.
|
|
|
34
35
|
An initial mode-descriptor admission error is preserved if closing that rejected
|
|
35
36
|
descriptor also fails; close failures after successful admission remain reportable.
|
|
36
37
|
|
|
38
|
+
On Linux, non-identity `/proc/self/uid_map` and `/proc/self/gid_map` evidence
|
|
39
|
+
permits ancestors whose UID and GID equal unmapped kernel overflow IDs,
|
|
40
|
+
provided the same ancestor mode checks pass.
|
|
41
|
+
These owners are classified as **unmapped**, not verified root owners:
|
|
42
|
+
[Linux maps all unmapped owners to overflow IDs](https://man7.org/linux/man-pages/man7/user_namespaces.7.html).
|
|
43
|
+
Supporting systemd user services with `PrivateUsers=true` therefore trusts the
|
|
44
|
+
host directory hierarchy against unmapped host peers who own an ancestor and
|
|
45
|
+
can rename it. Sticky world-writable ancestors remain admitted because host
|
|
46
|
+
`/tmp` and `PrivateTmp` appear unmapped under `PrivateUsers`; refusing them would
|
|
47
|
+
disable the default system-temp layout even with a private per-user leaf root.
|
|
48
|
+
An unmapped host owner of such a sticky ancestor could rename its children,
|
|
49
|
+
but a normal host's `/tmp` is root-owned by construction. Mapped foreign owners
|
|
50
|
+
still reject, and this exception never applies to the supplied root or newly
|
|
51
|
+
created workspace. Both maps and mapped-owner exclusions are checked afresh
|
|
52
|
+
whenever admission relies on unmapped ownership, including identity replay;
|
|
53
|
+
unavailable namespace evidence leaves admission unchanged.
|
|
54
|
+
The first admitted unmapped ancestor emits `FS_SAFE_UNMAPPED_TEMP_ANCESTOR`
|
|
55
|
+
through Node's warning event. The warning contains no caller paths.
|
|
56
|
+
|
|
37
57
|
For an already existing canonical root, discovery retains only its immutable
|
|
38
58
|
exact identity. Cleanup-parent retention is provisional: after any native
|
|
39
59
|
capability probe, creation captures and validates the complete ancestry,
|
package/docs/testing.md
CHANGED
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
# Testing
|
|
2
2
|
|
|
3
|
+
## Manual watch stress campaign
|
|
4
|
+
|
|
5
|
+
Build from the exact revision being qualified with `pnpm install --frozen-lockfile`,
|
|
6
|
+
`pnpm native:build`, and `pnpm build`, then run on a disposable machine:
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
node scripts/watch-stress.mjs --scenario all
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Individual scenario names are `scale`, `fanout`, `churn`, `lifecycle`,
|
|
13
|
+
`adversarial`, `limits`, `idle`, and `soak`. The runner uses plain Node and no
|
|
14
|
+
additional dependencies. Each scenario prints one JSON result line; progress
|
|
15
|
+
goes to stderr. `all` isolates scenarios in child processes and stops at the
|
|
16
|
+
first failure. This suite is manual and is not part of per-PR CI.
|
|
17
|
+
|
|
18
|
+
Run `node scripts/watch-stress.mjs --scenario oracle-selftest` first to check
|
|
19
|
+
that a poisoned cache fails comparison and can recover only after invalidation.
|
|
20
|
+
All fixture Roots live in `os.tmpdir()`. The consumer cache refreshes only from
|
|
21
|
+
`onInvalidate`, using guarded Root reads of invalidated paths/scopes. After
|
|
22
|
+
quiescence and a fresh `reconcile()`, checkpoints compare it with an independent
|
|
23
|
+
filesystem walk, including file-content hashes. A mismatch is a failure, with
|
|
24
|
+
no comparison retry or checkpoint-triggered cache refresh. Transient guarded
|
|
25
|
+
read failures retain already-invalidated consumer work and settle at 25 ms
|
|
26
|
+
intervals, with a 120-second flush deadline; these errors are counted in results.
|
|
27
|
+
|
|
28
|
+
Scale uses 50,000 files in 2,000 child directories. Fan-out checks 64 and 256
|
|
29
|
+
distinct Roots, one shared hub thread, and return to the warmed handle baseline.
|
|
30
|
+
Churn first creates 1,024 entries while JavaScript is blocked to exceed the
|
|
31
|
+
default 256-path detail budget, then runs at least five minutes with persistent
|
|
32
|
+
differences across 10,000-mutation batches and isolated edit latency measurements.
|
|
33
|
+
Lifecycle performs 10,000 ready/close cycles plus admission
|
|
34
|
+
cancellation, close-during-ready, 1,000 scope replacements, and callback-close.
|
|
35
|
+
Adversarial cases exercise Root swaps, outside symlinks, recursive deletion and
|
|
36
|
+
10,000 same-name create/delete pairs. Soak runs 30 minutes, checking the oracle
|
|
37
|
+
each minute. Linux needs passwordless `sudo` for the limits scenario; it lowers
|
|
38
|
+
`fs.inotify.max_user_watches` to 1 in a child shell with a restoration trap and
|
|
39
|
+
verifies restoration. Never run that scenario on a shared production host.
|
|
40
|
+
|
|
41
|
+
RSS limits are fixed before execution: 512 MiB peak for churn/soak, at most
|
|
42
|
+
64 MiB growth after warmup, and at most 32 MiB lifecycle growth after 3,000
|
|
43
|
+
cycles. Reports include samples and fitted slopes. Idle runs for ten minutes
|
|
44
|
+
with 16 subscriptions and a one-hour reconciliation interval to isolate native
|
|
45
|
+
hub wakeups, requiring less than 1% of one CPU and, on Linux, at most 30 hub
|
|
46
|
+
context switches. macOS captures `ps -M`; Windows captures PowerShell thread
|
|
47
|
+
CPU time and handle counts. macOS descriptor counts use `lsof`.
|
|
48
|
+
|
|
49
|
+
The macOS limits case also exercises injected UserDropped/KernelDropped flags
|
|
50
|
+
through the native decoder and labels these as synthetic. Natural FSEvents drop
|
|
51
|
+
flags are not independently observable through the current public batch. Windows
|
|
52
|
+
records native overflow and recovery, but the shared batch does not distinguish
|
|
53
|
+
RDCW kernel-buffer loss from bounded native queue loss; a Windows qualification
|
|
54
|
+
must retain that limitation rather than call it proved kernel overflow.
|
|
55
|
+
|
|
3
56
|
## Linux openat2 fallback
|
|
4
57
|
|
|
5
58
|
Build the host addon and package first. The test hook is cached with the native
|
|
@@ -186,6 +239,13 @@ For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) mak
|
|
|
186
239
|
|
|
187
240
|
## Repo test shards
|
|
188
241
|
|
|
242
|
+
On macOS, after building the native addon, run the native watch cleanup allocation
|
|
243
|
+
regression with `MallocStackLogging=1 node --expose-gc scripts/watch-cleanup-leak-proof.mjs`.
|
|
244
|
+
It compares `leaks` results before and after 100 and 1,000 subscription cycles,
|
|
245
|
+
requiring zero growth in leaked allocations. Allocation stacks are saved under
|
|
246
|
+
`.artifacts/watch-cleanup-leaks`. The native macOS CI lane runs this short proof;
|
|
247
|
+
the full stress campaign remains manual.
|
|
248
|
+
|
|
189
249
|
Run the full local gate before handoff:
|
|
190
250
|
|
|
191
251
|
```sh
|
package/docs/types.md
CHANGED
|
@@ -4,6 +4,12 @@ The types most callers reach for. Shared data shapes are exported from `@opencla
|
|
|
4
4
|
|
|
5
5
|
For atomic replacement, `ReplaceFileAtomicFileSystem` and `ReplaceFileAtomicSyncFileSystem` are exported from `@openclaw/fs-safe/atomic`. Async adapters use `chmod()` on the `FileHandle` returned by their required `open()` operation. The synchronous type adds optional `fchmodSync(fd, mode)`; custom sync adapters that explicitly request `mode` or `preserveExistingMode` must implement it. See [Atomic writes](atomic.md#test-injection).
|
|
6
6
|
|
|
7
|
+
`ReplaceFileAtomicDestinationState` is also exported from that subpath. It is a
|
|
8
|
+
readonly union of `{ state: "removed", path }` and
|
|
9
|
+
`{ state: "writing" | "published", path, dev: bigint, ino: bigint }`. Both atomic
|
|
10
|
+
replacement option types accept synchronous `assertBeforeMutation` and
|
|
11
|
+
`onDestinationState` callbacks. See [authority and destination state](atomic.md#atomic-write-authority-and-destination-state).
|
|
12
|
+
|
|
7
13
|
```ts
|
|
8
14
|
import type {
|
|
9
15
|
BasePathOptions,
|
package/docs/walk.md
CHANGED
|
@@ -107,11 +107,32 @@ Unreadable directories are skipped rather than throwing, but every skipped direc
|
|
|
107
107
|
`Root.walk(rel, options)` is the root-bounded counterpart to these standalone
|
|
108
108
|
inventory helpers. It yields `{ relativePath, kind, size }` incrementally and
|
|
109
109
|
accepts `maxDepth`, `maxEntries`, `symlinkPolicy: "skip" |
|
|
110
|
-
"follow-within-root"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
|
|
110
|
+
"follow-within-root" | "include"`, `order: "sorted" | "filesystem"`, and an `AbortSignal`. The default budget behavior yields
|
|
111
111
|
one `kind: "truncated"` marker and ends; pass `limitBehavior: "throw"` for a
|
|
112
112
|
typed `FsSafeError("too-large")` instead.
|
|
113
113
|
|
|
114
114
|
For followed symlinks, both `kind` and `size` describe the resolved target.
|
|
115
|
+
With `symlinkPolicy: "include"`, links retain `kind: "symlink"` and their own
|
|
116
|
+
size. Targets are neither resolved nor visited; dangling and outside-root links
|
|
117
|
+
are included. Filters receive these entries, and links consume the same entry
|
|
118
|
+
budget as other names. A directory replaced by a symlink after observation
|
|
119
|
+
fails with `path-mismatch` before descent, or produces a `directory-error`
|
|
120
|
+
entry when `onDirectoryError` is `"skip-and-report"`.
|
|
121
|
+
The starting directory retains existing Root path resolution; include mode
|
|
122
|
+
controls the entries beneath that directory.
|
|
123
|
+
|
|
124
|
+
```ts
|
|
125
|
+
for await (const entry of capability.walk("", { symlinkPolicy: "include" })) {
|
|
126
|
+
if (entry.kind === "symlink") reportLink(entry.relativePath);
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Existing skip/follow calls keep their result types without a symlink variant.
|
|
131
|
+
For explicitly annotated include-mode values, use `RootWalkOptions<"include">`
|
|
132
|
+
and `RootWalkEntry<"include">`. `RootWalkSymlinkPolicy` describes all
|
|
133
|
+
three policies when the policy is selected dynamically; the unparameterized
|
|
134
|
+
entry and options types retain their previous shapes. Use
|
|
135
|
+
`RootWalkOptions<RootWalkSymlinkPolicy>` for a dynamically selected policy.
|
|
115
136
|
|
|
116
137
|
The caller's starting path retains Root home shorthand: `~` and `~/dir` expand
|
|
117
138
|
the home directory when iteration starts and must resolve inside the Root.
|
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/fs-safe",
|
|
3
|
-
"version": "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,13 +173,13 @@
|
|
|
169
173
|
"archive:producer-smoke": "node scripts/archive-producer-smoke.mjs"
|
|
170
174
|
},
|
|
171
175
|
"optionalDependencies": {
|
|
172
|
-
"@openclaw/fs-safe-darwin-arm64": "0.
|
|
173
|
-
"@openclaw/fs-safe-darwin-x64": "0.
|
|
174
|
-
"@openclaw/fs-safe-linux-arm64-gnu": "0.
|
|
175
|
-
"@openclaw/fs-safe-linux-arm64-musl": "0.
|
|
176
|
-
"@openclaw/fs-safe-linux-x64-gnu": "0.
|
|
177
|
-
"@openclaw/fs-safe-linux-x64-musl": "0.
|
|
178
|
-
"@openclaw/fs-safe-win32-x64-msvc": "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": {
|