Start

Quickstart

Quickstart

Build a root() and use it to read, write, atomically replace, and unpack an archive under a trusted workspace. Confinement strength and concurrent-mutation limits depend on the operation and platform; see the security model.

If you have used Go's os.Root / OpenInRoot or Rust's cap-std, this is the same shape: a capability-style handle that carries the boundary across every operation. The first thing to internalize is that you stop reasoning about paths and start reasoning about the handle.

#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; create it before constructing the capability. fs.ensureRoot() is for generic code that needs to assert or recreate the root itself after a handle was successfully constructed. It does not accept a subpath and cannot bootstrap a root that prevented root() from returning.

#2. Read and write text

await fs.write("notes/today.txt", "hello\n");
const text = await fs.readText("notes/today.txt");

Replacement 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

Default create-only writes can expose incomplete content on the JavaScript fallback. Pass { atomic: true } to stage complete content before publication. When hardlinks are refused, staged publication can still expose an empty destination placeholder before replacing it with the completed file. See atomic creation and publication without hardlinks.

#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 and requires native support. Linux auto can fall back when no-replace rename is unsupported; its directory fallback can replace a concurrently created empty directory. Use native require when that must fail closed. See the move contract. Pass { overwrite: true } when replacement is intended.

remove() removes files and empty directories by default. For a non-empty directory, pass { recursive: true }; use maxEntries, maxDepth, and signal to bound the work. See root() for removal ordering, limits, and partial-removal semantics.

#5. Inspect

const here = await fs.exists("state/config.json"); // boolean
const stat = await fs.stat("state/config.json");   // { isFile, isDirectory, 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 operation-local identity checks, use read(), open(), write(), create(), copyIn(), move(), or remove(). Linux native beneath opens are kernel-atomic; other mechanisms remain best-effort as documented in the security model.

#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,
    maxMetaEntryBytes: 1024 * 1024,
    maxEntryPathComponents: 64,
  },
  entryModes: "clamp",
});

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 workspace.copyIn("input.bin", "/tmp/source.bin");
  // ...do work in workspace.dir; auto-cleaned on exit
});

The directory is mode 0700 under the caller-provided root, and cleanup runs when the callback returns or throws. Compatible cleanup remains available without native support; pass cleanupSafety: "require-bounded" to require native no-replace quarantine and descriptor-bounded tree removal before creating a child. Cleanup preserves raced or ambiguous entries under a private quarantine name. See the temp workspace compatibility and security contract.

#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.