Reading
The Root handle exposes five read shapes. Pick the narrowest one that gives you what you need — narrower shapes do less work and surface fewer footguns.
const result = await fs.read("notes/today.txt"); // { buffer, realPath, stat }
const text = await fs.readText("notes/today.txt"); // string
const bytes = await fs.readBytes("image.png"); // Buffer
const json = await fs.readJson<Config>("config.json"); // T
const opened = await fs.open("large.log"); // FileHandle for streaming
#What every read does
Regardless of shape, every read goes through the same boundary checks:
- Resolve the relative path against the canonical real root.
- Reject anything that escapes the root (
outside-workspace). - Reject
..segments and absolute inputs (unless viareadAbsolutewith an in-root absolute path). - Open with
O_NOFOLLOWwhere available. A symlink in the path triggerssymlinkunless the call'ssymlinkspolicy isfollow-within-root. - Stat the open fd and compare to the resolved path's identity (
sameFileIdentity). A swap mid-call triggerspath-mismatch. - If
hardlinks: "reject", refuse files withnlink > 1(hardlink). - If
maxBytesis set, refuse reads larger than the cap (too-large).
#Read shapes
#fs.read(rel, options?)
The full result. Use it when you need both the bytes and the verified realPath or stat:
const { buffer, realPath, stat } = await fs.read("notes/today.txt");
console.log(`${stat.size} bytes at ${realPath}`);
#fs.readText(rel, options?)
buffer.toString(encoding). Defaults to defaults.encoding ?? "utf8". Pass encoding per call to override:
const utf16 = await fs.readText("doc.txt", { encoding: "utf16le" });
#fs.readBytes(rel, options?)
The buffer alone. Useful when you don't care about the realPath or stat:
const png = await fs.readBytes("image.png");
#fs.readJson<T>(rel, options?)
readText + JSON.parse. The generic is a cast, not a validator — validate the parsed value at your application boundary if it came from a less-trusted source.
type Config = { tokens: string[] };
const config = await fs.readJson<Config>("config.json");
For tighter control over malformed-or-missing JSON, use the standalone helpers in @openclaw/fs-safe/json: tryReadJson (returns null on missing/invalid) vs readJson (throws).
#fs.open(rel, options?)
Returns a FileHandle plus the verified realPath and stat. Use this for streaming or partial reads, and always close the handle:
const opened = await fs.open("large.log");
try {
const stream = opened.handle.createReadStream();
for await (const chunk of stream) {
process.stdout.write(chunk);
}
} finally {
await opened.handle.close();
}
#Read options
type RootReadOptions = {
hardlinks?: "reject" | "allow"; // override defaults.hardlinks
maxBytes?: number; // refuse reads larger than this many bytes
nonBlockingRead?: boolean; // schedule the read off the main loop
symlinks?: "reject" | "follow-within-root"; // override defaults.symlinks
};
maxBytes is enforced eagerly: the library reads up to maxBytes + 1 and throws too-large if there is more, so a hostile target cannot silently exhaust memory.
nonBlockingRead is a scheduling hint. It does not affect safety — it lets you keep the event loop responsive when reading large files.
#readAbsolute() and reader()
Some APIs hand you an absolute path that the caller has already produced. Going back to a relative form just to call read() is awkward, so the library exposes:
fs.readAbsolute(absPath, options?) // ReadResult, abs path must be inside the root
fs.reader(options?) // (path) => Promise<Buffer>
readAbsolute accepts absolute paths. Anything outside the root throws outside-workspace.
reader() returns a closure that takes either a relative or an absolute path and returns a Buffer. Useful for plugging fs-safe into framework loader hooks:
const load = fs.reader({ maxBytes: 4 * 1024 * 1024 });
await someLibrary.parseTemplate({ load });
#Inspection vs reading
fs.exists, fs.stat, and fs.list are advisory. They are safe to call to drive UI or decisions, but they do not pin the file:
if (await fs.exists("notes/today.txt")) {
// the file existed when stat() ran — it may not now
const text = await fs.readText("notes/today.txt"); // this is the call that pins
}
A symlink swap between exists and readText is caught by the read; the boundary is per-call.
#Streaming patterns
#Read into a writable stream
import { pipeline } from "node:stream/promises";
const opened = await fs.open("large.log");
try {
await pipeline(opened.handle.createReadStream(), process.stdout);
} finally {
await opened.handle.close();
}
#Read in chunks
const opened = await fs.open("large.bin");
try {
const buf = Buffer.alloc(64 * 1024);
let off = 0;
while (true) {
const { bytesRead } = await opened.handle.read(buf, 0, buf.length, off);
if (bytesRead === 0) break;
consume(buf.subarray(0, bytesRead));
off += bytesRead;
}
} finally {
await opened.handle.close();
}
#Common errors
outside-workspace— relative path escaped the root, orreadAbsolutegot an absolute path outside.not-found— the file is gone.not-file— you read a directory or a non-regular file (FIFO, socket, …).symlink— a path component is a symlink and the policy isreject.path-mismatch— opened fd identity did not match the resolved path. Almost always a TOCTOU swap by something else.hardlink—hardlinks: "reject"sawnlink > 1.too-large— read exceededmaxBytes.
See Errors for the full list.
#See also
- Writing — companion verbs for produce-side I/O.
- JSON files — standalone strict/lenient JSON helpers.
- Pinned open — low-level synchronous pinned file open.