Start

Quickstart

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 Root handle, including streaming and reader callbacks.
  • Errors — the full code union and what each one means.