@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.
- package/CHANGELOG.md +24 -0
- package/dist/archive-zip-directory.js +4 -0
- package/dist/archive-zip-loader.js +3 -1
- package/dist/archive-zip-manifest.js +3 -1
- package/dist/copy-publication.d.ts +1 -3
- package/dist/copy-publication.js +2 -6
- package/dist/deny-mutation-match.d.ts +2 -0
- package/dist/deny-mutation-match.js +71 -0
- package/dist/deny-mutations.js +4 -3
- package/dist/file-identity.js +10 -2
- package/dist/file-lock-sync-admission.js +5 -1
- package/dist/file-lock-sync-root-io.d.ts +2 -2
- package/dist/file-lock-sync-root-io.js +1 -1
- package/dist/file-lock-sync.js +2 -2
- package/dist/file-store-prune.js +4 -4
- package/dist/mutation-authority.js +5 -0
- package/dist/native-binding.d.ts +11 -0
- package/dist/native-pinned-write.js +8 -2
- package/dist/pinned-mutation-admission.js +4 -2
- package/dist/replace-file-copy-fallback.js +4 -4
- package/dist/replace-file-destination.d.ts +4 -0
- package/dist/replace-file-destination.js +67 -5
- package/dist/replace-file.js +2 -1
- package/dist/retained-file-types.d.ts +3 -14
- package/dist/root-directory-list.d.ts +2 -0
- package/dist/root-directory-list.js +46 -20
- package/dist/root-move-noreplace.js +3 -3
- package/dist/sidecar-lock-acquire.js +3 -3
- package/dist/sidecar-lock-reclaim.d.ts +2 -2
- package/dist/sidecar-lock-reclaim.js +6 -6
- package/dist/sidecar-lock.js +4 -4
- package/dist/staged-symlink-types.d.ts +4 -14
- package/dist/test-hooks.d.ts +1 -0
- package/dist/watch-alias.js +11 -3
- package/dist/watch-hints.d.ts +2 -1
- package/dist/watch-hints.js +17 -1
- package/dist/watch-native.d.ts +4 -0
- package/dist/watch-native.js +18 -1
- package/dist/watch-scan.d.ts +5 -1
- package/dist/watch-scan.js +37 -6
- package/dist/watch-stream.d.ts +8 -0
- package/dist/watch-stream.js +32 -0
- package/dist/watch-types.d.ts +2 -0
- package/dist/watch.js +35 -7
- package/docs/archive.md +6 -0
- package/docs/atomic.md +23 -2
- package/docs/contributing.md +69 -6
- package/docs/install.md +2 -0
- package/docs/native.md +24 -6
- package/docs/public-api.md +23 -2
- package/docs/retained-file.md +4 -2
- package/docs/root.md +19 -7
- package/docs/sidecar-lock.md +1 -1
- package/docs/staged-symlink.md +2 -1
- package/docs/testing.md +132 -10
- package/docs/watch.md +77 -10
- 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
|
+
}
|
package/dist/watch-types.d.ts
CHANGED
|
@@ -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 {
|
|
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 =
|
|
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
|
|
158
|
-
|
|
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
|
|
package/docs/contributing.md
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
package/docs/public-api.md
CHANGED
|
@@ -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`,
|
|
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
|
package/docs/retained-file.md
CHANGED
|
@@ -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.
|
|
79
|
-
|
|
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.
|
|
267
|
-
|
|
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;
|
|
328
|
-
or
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
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
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -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
|
|
package/docs/staged-symlink.md
CHANGED
|
@@ -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
|
|
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
|