Migrating from 0.4 to 0.5
Use this checklist from top to bottom. Version 0.5 replaces the Python worker, changes the default archive mode policy, and adds explicit contracts for publication, walking, locks, secrets, and native-only features. Nothing in this guide requires a Rust toolchain: all supported native binaries are prebuilt and bundled in @openclaw/fs-safe.
#1. Update the package and runtime
- Run on Node.js 22 or newer.
- Update
@openclaw/fs-safeand regenerate every lock or shrinkwrap file your - Keep optional dependencies enabled if you want JavaScript ZIP/TAR support.
deployment consumes.
Native loading no longer depends on optional packages because all seven binaries ship in @openclaw/fs-safe. An install that omits optional packages can still import fs-safe, but missing JS archive decoders fail with actionable errors.
If you call resolveRootPath() or resolveRootPathSync() directly, upgrade to 0.5: versions through 0.4.7 could approve an in-root symlink traversal that resolved outside the root. root() handles were not affected. See the affected versions and exposure.
#2. Replace Python helper configuration
Change startup configuration before the first filesystem operation:
import { configureFsSafeNative } from "@openclaw/fs-safe/config";
configureFsSafeNative({ mode: "auto" });
| Remove from 0.4 | Use in 0.5 |
|---|---|
configureFsSafePython({ mode }) | configureFsSafeNative({ mode }) |
FS_SAFE_PYTHON_MODE | FS_SAFE_NATIVE_MODE |
OPENCLAW_FS_SAFE_PYTHON_MODE | OPENCLAW_FS_SAFE_NATIVE_MODE |
pythonPath, FS_SAFE_PYTHON, pinned-Python aliases | Nothing; bundled native binaries do not use an interpreter |
The old names warn once and map auto, off, or require so a shipped 0.4 deployment does not silently change policy. Interpreter paths are ignored and Python is never spawned. Treat that warning as an upgrade diagnostic, not as a second supported helper path.
Choose the production mode deliberately:
autokeeps guarded JavaScript fallbacks when a binding is unavailable.offmakes fallback testing deterministic.requirefails withhelper-unavailableinstead of weakening an operation
that expected native support.
See Native helper policy and Native architecture.
#3. Audit every archive call
The 0.5 default is entryModes: "clamp". Directories become 0o755; files become 0o644 or 0o755 when owner-execute was archived. Set entryModes: "preserve" explicitly only if your 0.4 consumer intentionally relied on archived rwx bits. Setuid, setgid, sticky bits, and archived ownership are never restored.
await extractArchive({
archivePath: uploadPath,
destDir: restoreRoot,
timeoutMs: 30_000,
entryModes: "clamp",
entryFilter: (entry) =>
entry.path.startsWith("snapshot/cache/") ? "skip" : "extract",
onFiltered: "skip-entry",
limits: {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 50_000,
maxExtractedBytes: 512 * 1024 * 1024,
maxEntryBytes: 256 * 1024 * 1024,
maxMetaEntryBytes: 1024 * 1024,
maxEntryPathComponents: 64,
},
});
Returning "skip" rejects the archive unless onFiltered: "skip-entry" is explicit. Zstd and bzip2 TAR are native-only; ZIP, TAR, and gzip retain guarded JavaScript implementations. Catch ArchiveLimitError by its code, including archive-entry-path-components-exceeds-limit for deep implicit-directory attacks. See Archive extraction.
#4. Pick a publication failure policy
publishFileExclusive() never replaces an existing target. Choose a strategy and decide what a post-create directory-sync failure means to your application:
await publishFileExclusive({
sourcePath: stagedArchive,
targetPath: finalArchive,
strategy: "link-or-copy",
onSyncFailure: "preserve",
});
rollback is the default: an unchanged target created by this call is removed when directory sync throws. preserve keeps a complete but possibly non-durable target and reports cleanup: "preserved" plus directorySync: { status: "failed", code? } in the typed error. Backup archives commonly need preserve; transactional protocols that expose only durably committed names usually want rollback. See Directory durability.
#5. Replace recursive scans with an explicit walk policy
Use Root.walk() for caller-controlled relative paths. Every examined entry consumes the budget even when filtered:
for await (const entry of workspace.walk("memory", {
maxDepth: 12,
maxEntries: 50_000,
symlinkPolicy: "skip",
entryFilter: (entry) =>
entry.kind === "directory" && entry.relativePath.endsWith("/.git")
? "skip-subtree"
: "include",
onDirectoryError: "skip-and-report",
})) {
if (entry.kind === "directory-error") {
reportIncompleteSubtree(entry.relativePath, entry.error);
continue;
}
indexEntry(entry);
}
The default directory-error policy remains throw. See Directory walking.
#6. Adopt the focused concurrency and secret APIs
- Use
acquireFileLockSync()only in synchronous boot or migration code; retry - Remove the
allowReentrantboolean from async file-lock options. If a logical - Use
createSecretFileAtomic()for first-writer-wins credentials and catch - Async
readSecretFile()is strict;tryReadSecretFile()returnsundefined - Check
tempWorkspace.cleanup()results when ownership matters;
waits block the thread. Request-serving paths should use withFileLock().
holder intentionally nests acquisition, pass the same operation-scoped reentrantOwner string to each acquisition; different or missing owners contend normally. Never replace the boolean with a process-wide constant. Locked and unlocked jsonStore mutations serialize by canonical file path and do not opt into lock reentrancy; nested same-file mutations from an update callback fail with store-reentrant-update, so return the complete value from the outer callback instead.
secret-exists; use writeSecretFileAtomic() only when replacement is the intended protocol.
only for missing or blank content and still rejects suspicious files.
identity-mismatch deliberately preserves a replacement path.
See File locks, Secret files, and Temp workspaces.
#7. Gate native-only features
createPrivateDirectory() is Windows-only and native-only because a pathname fallback cannot promise the same creation-time DACL. Zstd/bzip2 extraction and strategy: "rename-noreplace" are also native-only. Test the unavailable path instead of assuming installation always succeeds.
#8. Run both behavior families in CI
For each consumer workflow that matters:
- Run once with
FS_SAFE_NATIVE_MODE=autoon every supported OS. - Run once with
FS_SAFE_NATIVE_MODE=offto prove the JavaScript fallback. - Run native-required or native-only cases with
FS_SAFE_NATIVE_MODE=require. - Exercise archive traversal/link/depth limits, publication sync failure, and
partial-walk reporting with production-shaped fixtures.
For downstream staging and backup consumers:
- [ ] Replace private whole-file hashing with
sha256File(path | FileHandle) - [ ] If Windows trust policy depends on exact principals, consume
from durability; native mode keeps digest work off the event loop and the JavaScript fallback remains streaming.
readOwnerAndDacl() from permissions, reject incomplete/null/remote descriptors as your policy requires, skip inherit-only ACEs where appropriate, and apply the application's own SID allowlist.
The Testing page documents the test hooks and mode setup used by fs-safe itself.