Quickstart
Five minutes. By the end you will have a working root() and know how to read, write, atomically replace, and unpack an archive — without your code being able to escape the workspace.
#1. Build a root
import { root } from "@openclaw/fs-safe";
const fs = await root("/srv/jobs/incoming", {
hardlinks: "reject", // refuse files that are hardlinks of out-of-tree inodes
symlinks: "reject", // refuse to traverse a symlink during open
mkdir: true, // create missing parent dirs on write
});
root() resolves the directory through the real filesystem (so symlinked roots become canonical) and verifies it exists. The defaults you pass apply to every call below; per-call options override them.
If the root directory itself does not exist yet, root() throws FsSafeError with code not-found. Either create the directory before calling root(), or call await fs.ensureRoot() after a successful root() to create empty subpaths.
#2. Read and write text
await fs.write("notes/today.txt", "hello\n");
const text = await fs.readText("notes/today.txt");
Writes use a sibling temp file plus rename, so a partial write never appears at the destination. Reads open with O_NOFOLLOW where available and verify the opened fd matches the path identity before returning the buffer.
create() is the don't-clobber variant of write() and throws already-exists when the target is already there:
await fs.create("notes/README.md", "seed\n"); // throws if it already exists
#3. JSON, with parsing
type Config = { tokens: string[]; updatedAt: string };
await fs.writeJson("state/config.json", { tokens: [], updatedAt: new Date().toISOString() }, {
space: 2,
});
const config = await fs.readJson<Config>("state/config.json");
writeJson stringifies and writes atomically. readJson reads through the same boundary and parses; validate the shape at your application boundary if it came from a less-trusted source.
#4. Move and remove
await fs.move("notes/today.txt", "notes/archive/today.txt", { overwrite: true });
await fs.remove("notes/archive/today.txt");
move() defaults to no clobber. Pass { overwrite: true } when replacing the target is intentional. remove() works on files and empty directories. For non-empty directories, list and remove children first or use replaceDirectoryAtomic.
#5. Inspect
const here = await fs.exists("state/config.json"); // boolean
const stat = await fs.stat("state/config.json"); // { kind, size, mtimeMs, ... }
const names = await fs.list("state"); // string[]
const entries = await fs.list("state", { withFileTypes: true }); // DirEntry[]
exists, stat, and list are boundary-checked but do not pin a later operation to the same filesystem object. For race-resistant reads or writes, use read(), open(), write(), create(), copyIn(), move(), or remove() — they pin the path identity at the point of use.
#6. Catch escapes
import { FsSafeError } from "@openclaw/fs-safe";
try {
await fs.write("../escape.txt", "x");
} catch (err) {
if (err instanceof FsSafeError && err.code === "outside-workspace") {
// log, count, drop the request
} else {
throw err;
}
}
Error codes are a closed union — branch on err.code instead of matching message text. The full list lives in the Errors reference.
#7. Replace a config file atomically
import { replaceFileAtomic } from "@openclaw/fs-safe/atomic";
await replaceFileAtomic({
filePath: "/srv/jobs/incoming/state/config.json",
content: JSON.stringify(state, null, 2),
mode: 0o600,
syncTempFile: true,
syncParentDir: true,
});
Use replaceFileAtomic directly when you have an absolute path you trust and want sibling-temp + rename without going through root(). See Atomic writes.
#8. Unpack a ZIP
import { extractArchive, resolveArchiveKind } from "@openclaw/fs-safe/archive";
const kind = resolveArchiveKind("upload.zip");
if (!kind) throw new Error("unsupported archive");
await extractArchive({
archivePath: "/srv/jobs/incoming/uploads/upload.zip",
destDir: "/srv/jobs/incoming/extracted",
kind,
timeoutMs: 15_000,
limits: {
maxArchiveBytes: 256 * 1024 * 1024,
maxEntries: 50_000,
maxExtractedBytes: 512 * 1024 * 1024,
maxEntryBytes: 256 * 1024 * 1024,
},
});
Extraction stages into a private dir and merges through the same boundary used by direct writes, so a symlinked entry can't trick the merge into following an out-of-tree path. See Archive extraction.
#9. Get a private scratch directory
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
await withTempWorkspace({ rootDir: "/srv/jobs/tmp", prefix: "build-" }, async (workspace) => {
await fs.copyIn("input.bin", "/tmp/source.bin");
// ...do work in workspace.dir; auto-cleaned on exit
});
The directory is mode 0700, sits under a per-user secure temp root, and is removed when the callback returns or throws. See Temp workspaces.
#Where to next
- Security model — exactly what the boundary defends against, what it does not.
- Root API — every method on the
Roothandle, including streaming and reader callbacks. - Errors — the full code union and what each one means.