Reference

Errors

Errors

Every failure that's the library's job to surface lands as an FsSafeError with a closed code union you can branch on. Catch by code, not by message text — messages may change, codes will not.

Path and archive-entry details embedded in diagnostics escape control characters as \\uXXXX sequences. This keeps attacker-controlled names on one log line; the escaped message is for diagnosis, not for reconstructing the original path.

import { FsSafeError, type FsSafeErrorCode } from "@openclaw/fs-safe";

#Shape

class FsSafeError extends Error {
  readonly name: "FsSafeError";
  readonly code: FsSafeErrorCode;
  readonly category: "policy" | "operational";
  readonly details?: Readonly<Record<string, unknown>>;

  constructor(
    code: FsSafeErrorCode,
    message: string,
    options?: { cause?: unknown; details?: Readonly<Record<string, unknown>> },
  );
}

cause is available through the standard Error cause property when the failure was triggered by a NodeJS.ErrnoException (e.g. a wrapped EACCES). Inspect it for the original code / errno / syscall if you need finer-grained reporting.

details is an operation-specific receipt, not an alternate error code. For example, publishFileExclusive() uses it to report the failing phase, created target identity, cleanup decision, and failed directory-sync outcome. Narrow by code and a documented details field before consuming it; do not assume all FsSafeError instances carry the same keys.

replaceFileAtomic({ copyFallbackRestore: "restore-original" }) reports a failed copy fallback with the exported ReplaceFileAtomicRestoreFailureDetails shape:

type ReplaceFileAtomicRestoreFailureDetails = {
  cleanup: "restored" | "restore-failed";
};

Both outcomes use code: "helper-failed". "restored" means the original snapshot was written back and fsynced through the pinned destination handle. "restore-failed" means both the replacement and recovery failed; cause is an AggregateError containing both failures. A snapshot that exceeds maxRestoreBytes fails earlier with too-large and does not overwrite the destination.

category separates caller-policy failures from operational failures:

  • "policy" — unsafe input or target state rejected by a safety policy, such as outside-workspace, symlink, hardlink, or too-large.
  • "operational" — routine filesystem outcomes or environment/runtime failures, such as not-found, not-empty, not-removable, helper startup, platform support, timeout, or unverifiable permissions.

Routine absence or inability to remove a path does not by itself indicate a filesystem boundary violation. Branch on the specific code when the distinction between those operational outcomes matters.

The operational set is exactly helper-failed, helper-unavailable, not-empty, not-found, not-removable, permission-unverified, read-failed, timeout, and unsupported-platform. Every other current FsSafeErrorCode, including store-reentrant-update, is categorized as policy.

#Code union

type FsSafeErrorCode =
  | "already-exists"
  | "denied-path"
  | "device-path"
  | "hardlink"
  | "helper-failed"
  | "helper-unavailable"
  | "insecure-permissions"
  | "invalid-path"
  | "not-empty"
  | "not-file"
  | "not-found"
  | "not-owned"
  | "not-removable"
  | "outside-workspace"
  | "path-alias"
  | "path-mismatch"
  | "permission-unverified"
  | "read-failed"
  | "secret-exists"
  | "store-reentrant-update"
  | "symlink"
  | "timeout"
  | "too-large"
  | "unsupported-platform";

#Code reference

CodeWhen it firesCommon causes
already-existscreate(), createJson(), move({ overwrite: false }).Target file or directory already at the destination.
denied-pathA root mutation matched denyMutations.paths or denyMutations.prefixes.Caller configured application-sensitive paths that must not be written, removed, moved, or created.
device-pathA read/open target is a known unsafe device or process-fd path./dev/zero, /dev/random, /dev/stdin, /dev/fd/*, /proc/*/fd/*, or a Windows reserved device name.
hardlinkRead or copy with hardlinks: "reject" saw nlink > 1.File is hardlinked — possibly an alias of an out-of-tree inode.
helper-failedA native mechanism or multi-step operational helper failed.Inspect cause and any operation-specific details; retrying may be unsafe if the operation partially completed.
helper-unavailableRequired native binding could not be loaded.Unsupported platform, missing/incompatible bundled binary, or FS_SAFE_NATIVE_MODE=require. auto falls back where possible; require fails closed.
insecure-permissionsA secure file or path permission check found a mode/ACL that allows broader access than requested.File or directory is group/world writable/readable; Windows ACL grants broad read.
invalid-pathInput was empty, contained NUL, was an unparseable URL, or otherwise unusable.Caller didn't validate input; input was a network path on Windows, a drive-relative segment in a portable relative path or store key, or a leading drive-relative spelling such as C:name used as a Root destination. Existing-object Root lookups retain legal POSIX drive-like names.
not-emptyremove() on a non-empty directory.Use replaceDirectoryAtomic or remove children first.
not-fileRead or copy targeted a non-regular file, or a path walk found a non-directory ancestor.Target was a directory, FIFO, socket, device, or an existing file was followed by another segment.
not-foundThe target does not exist (or its parent does not, with mkdir: false).Typical missing-file case.
not-ownedA secure file owner check failed.File is owned by another UID.
not-removableremove() couldn't unlink/rmdir for a reason other than non-empty.Permissions, device busy, immutable bit.
outside-workspacePath resolves outside the configured root... traversal; absolute path outside the root; symlink resolved out.
path-aliasA path alias check failed (e.g. canonical-real-path moved out of the root).Symlink resolution lands outside the root.
path-mismatchPost-open identity check failed: the opened fd does not match the resolved path.TOCTOU — something else swapped the path between resolve and open.
permission-unverifiedA secure file check could not verify required permissions.Windows ACL inspection failed; POSIX ownership/mode was unavailable.
read-failedA validated file could not be read because of an operational filesystem or device failure.I/O error, media failure, or another runtime read failure; inspect cause.
secret-existscreateSecretFileAtomic() found an existing final path.First-writer-wins secret creation lost a race or the credential was already initialized.
store-reentrant-updateA JsonStore.update() callback called update() or updateOr() for the same canonical store before returning.Reentrant mutation would deadlock or lose an update; return the complete next value from the outer callback.
symlinkPath component is a symlink, policy is reject.Caller followed a symlink they shouldn't have, or symlinks: "reject" is set.
timeoutAn operation with a wall-clock budget overran.Secure file read or timed operation exceeded timeoutMs.
too-largeA read or bounded walk exceeded its configured budget.Caller gave a too-permissive file or traversal limit.
unsupported-platformReserved compatibility code for a platform-specific operation.No current public helper emits this FsSafeError code. Platform-specific APIs currently return a typed unsupported result or use helper-unavailable; keep the union member when exhaustively switching across supported package versions.

#Branching

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

try {
  await fs.write("../escape.txt", "x");
} catch (err) {
  if (!(err instanceof FsSafeError)) throw err;
  switch (err.code) {
    case "outside-workspace":
      return reply(400, "path escapes workspace");
    case "already-exists":
      return reply(409, "exists");
    case "too-large":
      return reply(413, "too large");
    case "not-found":
      return reply(404, "missing");
    case "symlink":
    case "device-path":
    case "hardlink":
    case "path-mismatch":
    case "path-alias":
      return reply(400, "unsafe path");
    default:
      throw err;
  }
}

The default above deliberately rethrows unknown codes, so it remains safe as the union grows. If you want the compiler to flag every newly added code, end an exhaustive switch with a never assertion instead of a general default.

#Distinguishing from NodeJS.ErrnoException

Some failures bubble up as native Node errors (e.g. EACCES, EPERM, EISDIR, EBUSY) when they don't map cleanly to a library code. Inspect both:

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

try {
  await op();
} catch (err) {
  if (err instanceof FsSafeError) {
    handleFsSafe(err);
    return;
  }
  const code = (err as NodeJS.ErrnoException).code;
  if (code === "EACCES" || code === "EPERM") {
    handleAccess();
    return;
  }
  throw err;
}

A common pattern is to wrap your domain code in a single try/catch that maps both shapes to your application's typed error format.

On Windows, access-denied failures from native root/open filesystem operations use EPERM to match Node/libuv and the JavaScript fallback. Security-descriptor inspection has its own failure surface. Older fs-safe versions could report EACCES for the same root/open condition, so consumers spanning versions should accept both codes.

#Specialty errors

A handful of helpers throw their own typed errors instead of FsSafeError:

  • JsonFileReadError — thrown by readJson. Carries cause so you can distinguish missing (ENOENT) from invalid (SyntaxError).
  • ArchiveLimitError — thrown by extractArchive when an archive size, entry count, path depth, metadata, or extracted-byte budget is exceeded. The code field uses the string values exposed by ARCHIVE_LIMIT_ERROR_CODE (for example archive-entry-path-components-exceeds-limit).
  • ArchiveSecurityError — thrown by extraction when entry policy or destination safety fails. Entry codes are entry-path, entry-link, and entry-filtered; destination codes cover non-directory, symlink, and symlink-traversal failures.

These are exported from their respective subpaths.

#Why FsSafeError?

Two reasons it isn't a richer hierarchy of subclasses:

  1. Switch on code, don't instanceof a tree. code is a closed string union the TypeScript compiler can exhaust-check. Subclasses make instanceof ladders that drift over time.
  2. One catch handler. Library callers often want a single "is this an fs-safe failure?" gate before deciding what to do — instanceof FsSafeError plus a switch is the cleanest expression of that.

#See also