Root API

root()

root()

root() is the primary entry point. It takes a trusted directory and returns a Root handle whose methods accept relative paths and refuse to escape the directory.

import { root } from "@openclaw/fs-safe";

const fs = await root("/srv/workspace", {
  hardlinks: "reject",
  symlinks: "reject",
  mkdir: true,
});

#Signature

function root(rootDir: string, defaults?: RootDefaults): Promise<Root>;

type RootDefaults = {
  hardlinks?: "reject" | "allow";  // refuse files with nlink > 1 on read; defaults to "reject"
  maxBytes?: number;               // refuse reads larger than this many bytes; defaults to 16 MiB
  mkdir?: boolean;                 // create missing parent dirs on write/openWritable/append
  mode?: number;                   // file mode applied to new writes; per-call override available
  nonBlockingRead?: boolean;       // schedule reads on a worker; useful for large files
  symlinks?: "reject" | "follow-within-root"; // policy when a path component is a symlink
};

root() resolves the directory through the real filesystem. A symlinked input becomes the canonical path; a non-existent root throws FsSafeError with code not-found.

defaults apply to every method on the returned handle. Per-call options on individual methods override the defaults for that call only.

#The Root interface

Every method on the returned handle accepts paths relative to the root and rejects anything that would escape it.

#Reads

fs.read(rel, options?)         // { buffer, realPath, stat }
fs.readBytes(rel, options?)    // Buffer
fs.readText(rel, options?)     // string
fs.readJson<T>(rel, options?)  // parsed T
fs.open(rel, options?)         // { handle, realPath, stat, [Symbol.asyncDispose] }
fs.readAbsolute(absPath, options?) // ReadResult; absPath must already be inside the root
fs.reader(options?)            // (path) => Promise<Buffer>; useful for loader APIs

open() returns a Node FileHandle for streaming. Prefer await using for cleanup:

await using opened = await fs.open("large.log");
{
  for await (const chunk of opened.handle.createReadStream()) {
    process.stdout.write(chunk);
  }
}

#Writes

fs.write(rel, data, options?)            // overwrite-ok atomic write
fs.create(rel, data, options?)           // throws "already-exists" if target exists
fs.writeJson(rel, value, options?)       // JSON.stringify + atomic write
fs.createJson(rel, value, options?)      // create() variant of writeJson
fs.append(rel, data, options?)           // append text/buffer; respects mkdir default
fs.copyIn(rel, sourceAbsPath, options?)  // copy from outside the root, atomically, with size cap
fs.openWritable(rel, options?)           // FileHandle for streaming writes; supports await using
fs.move(from, to, options?)              // rename within the root; defaults to no clobber
fs.remove(rel)                           // unlink file or rmdir empty directory
fs.mkdir(rel)                            // mkdir -p (creates missing parents)
fs.ensureRoot()                          // accepts "" / "." as the root itself

write, create, append, writeJson, and createJson accept mode?: number; use 0o600 for credentials and other private state. writeJson also accepts the same options as JSON.stringify plus trailingNewline?: boolean (defaults true so the file ends in \n).

copyIn is a one-shot ingest from a trusted absolute source path: it streams the source through the boundary, atomically renames into the root, and respects maxBytes.

openWritable opens a writable file with options mode?: number and writeMode?: "replace" | "append" | "update". replace truncates existing files and is the default; update keeps existing contents. Use it for streaming output. Prefer await using for cleanup.

#Inspection (advisory)

fs.exists(rel)                   // boolean
fs.stat(rel)                     // PathStat
fs.list(rel)                     // string[]
fs.list(rel, { withFileTypes })  // DirEntry[]
fs.resolve(rel)                  // absolute path inside the root, after canonicalization

These do not pin a later operation. They are safe to expose to UIs and decision points; for the actual read or write, use the verb methods so the operation pins identity at the point of use.

#Properties

fs.rootDir       // the directory you passed in
fs.rootReal      // its canonical real path (after symlink resolution)
fs.rootWithSep   // rootReal with a trailing separator, for prefix comparisons
fs.defaults      // the RootDefaults you passed

#Failure semantics

Every method throws FsSafeError with a code. Branch on err.code, not message text. Common codes:

CodeWhen it fires
outside-workspaceThe input resolves outside the root, or contains a .. segment that would escape it.
not-foundThe target does not exist (or its parent does not, with mkdir: false).
not-fileA read or copy targeted a non-regular file (directory, FIFO, socket, …).
already-existscreate() or move() without overwrite hit an existing target.
symlinkA path component is a symlink, and the call's symlinks policy is reject.
hardlinkThe target's nlink > 1 and hardlinks policy is reject.
path-mismatchPost-open identity check failed — the opened fd does not match the resolved path.
too-largeRead exceeded maxBytes.

Full list in the Errors reference.

#Defaults vs per-call options

Defaults reduce repetition; per-call options handle exceptions:

const fs = await root("/srv/workspace", {
  symlinks: "reject",
  hardlinks: "reject",
  mkdir: true,
});

// Default: symlinks rejected.
await fs.readText("config.toml");

// One specific path needs to follow a symlink that lands inside the root.
await fs.readText("links/current.log", { symlinks: "follow-within-root" });

Text helpers default to UTF-8. Pass encoding per call to readText, readJson, write, create, or append when you need another encoding.

#Common patterns

#Read-only loader

const fs = await root("/srv/workspace", { symlinks: "reject", hardlinks: "reject" });
const load = fs.reader();
const a = await load("notes/today.txt");        // relative
const b = await load("/srv/workspace/state.bin"); // absolute, but inside the root

fs.reader() returns a (path) => Promise<Buffer> callback. Useful when wiring fs-safe into APIs that accept a generic loader function. Absolute paths outside the root are rejected with outside-workspace.

#"Touch only if missing" seeding

try {
  await fs.create("config/seed.json", initialJson);
} catch (err) {
  if (err instanceof FsSafeError && err.code === "already-exists") {
    // existing config wins
  } else {
    throw err;
  }
}

#Replace + verify

await fs.write("state.json", JSON.stringify(state, null, 2));
const echoed = await fs.readJson<State>("state.json");
assertDeepEqual(echoed, state);

write is atomic, so the file is either old or new — never half-written. Re-reading lets you detect a parallel writer, if one exists.

#See also

  • Reading — read variants in depth, plus stream patterns.
  • Writing — write/create/move/remove in depth.
  • pathScope() — the same boundary semantics over an absolute path you already trust.
  • Atomic writes — the lower-level helpers used by fs.write.
  • Errors — the closed code union you'll be catching.