Temp workspaces
@openclaw/fs-safe/temp is the stable temp surface: private temp workspaces with auto-cleanup plus the secure per-user temp root the helpers default to.
import {
tempWorkspace,
withTempWorkspace,
tempWorkspaceSync,
withTempWorkspaceSync,
resolveSecureTempRoot,
} from "@openclaw/fs-safe/temp";
#Private temp workspaces
A private workspace is a uniquely named directory under a caller-provided temp root. The default requested mode is 0o700. Calling cleanup() or leaving an await using scope moves an unchanged workspace through a private quarantine before removal. Descriptor-bounded cleanup prevents recursive traversal of substitutions; the compatible JavaScript fallback has the narrower race contract documented below.
On POSIX, workspace creation verifies the supplied root and its canonical ancestors before creating a child. The supplied root must be owned by the effective user and must not be group/world writable, even with the sticky bit. Use a private per-user directory rather than supplying a shared /tmp directly. Ancestors must be owned by the effective user or root; group/world-writable ancestors must have the sticky bit. Foreign-owned directories and non-sticky writable ancestors reject with not-owned or insecure-permissions; unavailable effective-user identity rejects with permission-unverified. Existing supplied directories keep their permissions. Missing root components are created at 0o700 and initialized from their first exact security snapshot; if a restrictive umask changes that mode, correction uses a verified directory descriptor. An initial mode-descriptor admission error is preserved if closing that rejected descriptor also fails; close failures after successful admission remain reportable.
These rules apply equally to effective UID 0: root-owned 0700 or 0755 temp roots beneath root-owned sticky 1777 /tmp are accepted. Passing /tmp itself as rootDir is rejected because the workspace root must be private. Permission and ownership errors name the rejected directory (including an unsafe ancestor), the observed mode or owner UID, the expected property, and a recovery path. Prefer resolveSecureTempRoot() or a dedicated directory owned by the effective user with mode 0700. For a non-sticky writable ancestor, have its administrator restore appropriate permissions (1777 for shared system temp) or move the root under trusted ancestry. Do not make shared /tmp private or take ownership of another user's directory. A caller's preferred root takes precedence over TMPDIR; changing TMPDIR only changes the resolver's fallback location.
On Linux, non-identity /proc/self/uid_map and /proc/self/gid_map evidence permits ancestors whose UID and GID equal unmapped kernel overflow IDs, provided the same ancestor mode checks pass. These owners are classified as unmapped, not verified root owners: Linux maps all unmapped owners to overflow IDs. Supporting systemd user services with PrivateUsers=true therefore trusts the host directory hierarchy against unmapped host peers who own an ancestor and can rename it. Sticky world-writable ancestors remain admitted because host /tmp and PrivateTmp appear unmapped under PrivateUsers; refusing them would disable the default system-temp layout even with a private per-user leaf root. An unmapped host owner of such a sticky ancestor could rename its children, but a normal host's /tmp is root-owned by construction. Mapped foreign owners still reject, and this exception never applies to the supplied root or newly created workspace. Both maps and mapped-owner exclusions are checked afresh whenever admission relies on unmapped ownership, including identity replay; unavailable namespace evidence leaves admission unchanged. The first admitted unmapped ancestor emits FS_SAFE_UNMAPPED_TEMP_ANCESTOR through Node's warning event. The warning contains no caller paths.
Existing canonical-root discovery retains immutable exact identity; parent retention is provisional until, after any native probe, creation validates complete ancestry, rechecks the root, and binds the parent descriptor. Existing aliases and missing-component roots keep guarded admission. Async creation, default 0o700, and ineligible sync modes dispatch mkdtemp (initial mode 0o700) immediately after admission, without another yield or probe.
Linux/macOS sync creation can use exclusive mkdir with a six-character random suffix when an explicit mode other than 0o700 grants owner rwx, no special bits, and no group/world write. Each of at most 64 attempts generates a candidate, replays admitted ancestry and descriptor receipts, then creates immediately. Colliding entries are never inspected, adopted, corrected, registered, or deleted. Observed complete mode bits are authoritative: umask, inherited ACLs, or special bits can change the requested mode. POSIX modes do not establish ACL privacy, and this optimization does not promise identical Linux/macOS syscalls or ACL behavior.
The direct sync path opens the child without following its final component and captures one exact descriptor observation after parent replay. Creation checks identity, type, owner, private bits, and complete 0o7777 mode. Matching dirMode avoids an extra mode descriptor and chmod; mismatches use descriptor-bound correction. Immediate sync correction consumes the initial observation, checks the fresh child name, and replays the parent; later admission uses fresh descriptor/name checks. Permission failures propagate. POSIX dirMode cannot grant group/world write and affects only the new workspace, never existing supplied directories.
Before registering cleanup, final adoption retains a no-follow child descriptor, rechecks complete ancestry and retained cleanup-parent authority, then verifies the original child's descriptor and current name for exact identity, owner, private bits, and requested mode. Linux/macOS replay retained identities through round-trip-safe nonnegative numeric dev/ino projections when exact (otherwise BigInt); malformed or mismatched numeric observations fail closed without an exact retry. Observed parent/child replacements reject before ownership registration; unverifiable artifacts remain for caller-directed recovery.
On Windows, POSIX mode/UID metadata does not establish ACL privacy, and these factories neither claim nor initialize a POSIX dirMode; every requested value uses the identity-only path without opening a mode descriptor or applying chmod. Callers must supply a root with trusted ACLs that protect its children and ancestors; exact pathname identity checks still apply. cleanupSafety controls removal capability, not Windows ACL admission.
Root aliases already present at entry retain their historical support and are canonicalized. Callers remain responsible for choosing trusted root paths and excluding hostile peers with the same filesystem authority. Node's pathname mkdir/mkdtemp calls do not atomically return a creation descriptor: identity checks detect observed substitutions but cannot prove provenance against every same-privilege replacement before the first observation. Descriptor chmod cannot be redirected to a subsequently substituted pathname. Native bounded cleanup does not upgrade the creation operation to an atomic namespace boundary.
#tempWorkspace
The compact factory. Returns:
type TempWorkspace = {
dir: string;
identity: { dev: number | bigint; ino: number | bigint };
store: FileStore;
path(fileName: string): string;
write(fileName: string, data: string | Uint8Array): Promise<string>;
writeText(fileName: string, data: string): Promise<string>;
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
copyIn(fileName: string, sourcePath: string): Promise<string>;
read(fileName: string): Promise<Buffer>;
cleanup(): Promise<"removed" | "missing" | "identity-mismatch" | "indeterminate">;
[Symbol.asyncDispose](): Promise<void>;
};
import { tempWorkspace } from "@openclaw/fs-safe/temp";
await using workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" });
const inputPath = await workspace.write("input.txt", "data");
await runBuild(workspace.dir, inputPath);
write writes at mode (default 0o600); writeText and writeJson are convenience wrappers for the common scratch-file shapes; copyIn ingests an absolute source path through the same atomic-rename machinery as Root.copyIn. read is a small accessor that reads back any file you wrote into the workspace. Both the async and sync read methods throw FsSafeError("not-file") when the named leaf is a directory or another non-regular target, and preserve the not-found, hardlink, or symlink code for those stable target states. Operational filesystem read failures use read-failed with the Node error in cause.
store is a fileStore({ rootDir: workspace.dir, private: true }) handle. Use it when you want the richer store surface, including writeStream, exists, remove, readJsonIfExists, or store.json<T>(rel):
await using workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" });
const state = workspace.store.json<State>("state.json");
await state.write({ ready: true });
The workspace owns cleanup; the store is only a view over the workspace directory.
Compatibility and security: workspace creation remains available in native auto, off, and unavailable-native environments. The default cleanupSafety: "compatible" preserves the JavaScript cleanup behavior from 0.6: it verifies the workspace identity, moves the public name to a fresh .fs-safe-workspace-cleanup-<uuid> sibling, verifies that quarantine, and then uses guarded pathname-recursive removal. This fallback never recursively removes the public workspace name, but it is not atomic conditional deletion: a same-privilege peer that discovers and replaces the private quarantine after verification can still redirect the final pathname removal. If admitting a cleanup parent fails and closing its descriptor also fails, creation rejects with both failures in an AggregateError. This does not select compatible fallback or retry the indeterminate descriptor close.
Set cleanupSafety: "require-bounded" when that concurrent attacker is in scope. Creation then requires native no-replace directory rename, native owned-tree removal, and a readable retained parent descriptor before child creation. On POSIX, the final requested dirMode must also include owner read and search ((dirMode & 0o500) === 0o500). An unavailable capability throws FsSafeError("helper-unavailable") without creating a child or calling a scoped callback. The child descriptor is opened while the new directory still has its private creation mode, before an explicit dirMode can lower access. Retaining a read descriptor does not bypass the POSIX final-mode requirement: enumeration reopens the directory relative to that descriptor. Compatible mode accepts these restrictive modes but selects the JavaScript fallback and does not retain native traversal authority for the child, even if the caller later restores its permissions. Windows cleanup does not use this POSIX mode gate. A search-only descriptor remains valid identity evidence but is never native traversal authority: compatible cleanup selects the JavaScript fallback, while require-bounded rejects and leaves the unregistered child in place for caller-directed recovery. The compatible default retains its fallback even if process-global native mode is require; select require-bounded to make cleanup capability mandatory for this API.
On Linux, bounded cleanup requires a successful runtime probe of the exact openat2 child-directory flags, including RESOLVE_NO_XDEV, against the retained parent descriptor. If the kernel or seccomp policy denies that capability, compatible mode uses the guarded JavaScript fallback; require-bounded rejects before child creation. For eligible modes, the probe runs once at creation, without filesystem mutation.
Cleanup does not repair POSIX workspace or descendant permissions. In compatible mode, a restrictive workspace mode or caller-created unreadable descendants can prevent recursive removal; native bounded cleanup can also encounter later permission changes or inaccessible descendants. These remain operational cleanup failures, with the propagation and recovery behavior described below.
Bounded cleanup checks the parent and public workspace identity, quarantines the direct child without replacement, and verifies the quarantine against the retained workspace descriptor. It binds every enumerated child to its native identity before opening it, rejects mount crossings, and traverses descendants only through opened directory handles; symlinks/reparse entries are removed as leaves and never traversed. Windows marks the exact opened objects for deletion by handle.
Empty quarantines can be removed without directory enumeration: the native helper can attempt a descriptor-relative empty-directory removal, and the compatible POSIX JavaScript path can attempt rmdir. Unchanged creation timestamps select this attempt as a performance hint, never as emptiness or ownership proof; all fresh identity checks still run. Changed timestamps and nonempty directories continue through the same guarded recursive removal. The native empty attempt is bounded and synchronous, including for async workspaces; recursive native cleanup stays asynchronous. Older bindings and Windows compatible cleanup retain their existing recursive route; Windows can defer directory timestamp changes, so the empty-attempt hint does not select a faster compatible path there.
POSIX has no unlink-by-fd or expected-inode unlink for directory entries. After the final identity check, each unlinkat can still be raced; the possible side effect is bounded to one substituted non-directory leaf or one empty directory entry per raced syscall. A substituted nonempty directory is never recursively traversed and is preserved as "indeterminate", but a leaf replacement removed in that irreducible final gap cannot be distinguished after the syscall.
The workspace captures its identity, binding, and descriptors until cleanup. Later process-global mode changes or loader resets do not revoke that authority. Manual, disposal, and process-exit cleanup share one serialized owner, registered before store construction; a construction failure after registration remains exit-cleanable. Earlier creation failures close retained descriptors without deleting an unverified child.
If the quarantine does not match the creation descriptor, cleanup leaves it in place without restoring the public name or recursively deleting it and returns "indeterminate". A collision, uncertain rename outcome, changed parent, mount/device crossing, changed reparse state, or detected concurrent mutation also preserves the remaining artifact. Recover .fs-safe-workspace-cleanup-<uuid> entries only after excluding competing mutators and re-establishing ownership.
A missing workspace returns "missing". A replacement observed at the public name before quarantine returns "identity-mismatch" when the parent is stable; an ambiguous parent returns "indeterminate". After successful removal, repeated cleanup returns "missing" without touching a recreated public name. Other statuses remain stable. Compatible recursive-removal failures propagate the original thrown value, including falsy values. Uncertain quarantine and retained-parent checks instead return "indeterminate". After a propagated removal failure, later cleanup returns "indeterminate" without retrying. Disposal and scoped helpers ignore returned statuses, while manual cleanup exposes the result. A terminal descriptor-close failure retains its existing precedence if it also fails during settlement.
When cleanup is part of a retention or audit decision, inspect the receipt instead of treating cleanup as fire-and-forget:
const workspace = await tempWorkspace({ rootDir: "/var/lib/app/tmp", prefix: "restore-" });
try {
await restoreInto(workspace.dir);
} finally {
const cleanup = await workspace.cleanup();
if (cleanup === "identity-mismatch") {
alertOperator("restore workspace path was replaced; replacement preserved");
} else if (cleanup === "indeterminate") {
alertOperator("restore workspace cleanup could not establish safe completion; inspect retained entries");
}
}
The sync variant tempWorkspaceSync exposes the same surface with sync return types and a FileStoreSync at workspace.store.
#withTempWorkspace
The recommended shape. Attempts cleanup on every exit path:
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
const result = await withTempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" }, async (workspace) => {
await workspace.write("input.txt", "data");
return await runBuild(workspace.dir);
});
The callback receives the same workspace shape as tempWorkspace(). Cleanup is wired to run after the callback resolves or rejects.
#Manual lifetime
Manage the lifetime with try/finally and inspect the cleanup result, as in the receipt-inspecting example above.
#Sync variants
tempWorkspaceSync and withTempWorkspaceSync are the synchronous siblings. Useful for setup code in tests or boot paths that have not entered async land yet.
#Options
type TempWorkspaceOptions = {
rootDir: string; // parent directory for workspaces
prefix: string; // dir prefix (sanitized)
dirMode?: number; // new workspace mode; default 0o700; no POSIX group/world write
mode?: number; // file write mode; default 0o600
cleanupSafety?: "compatible" | "require-bounded"; // default compatible
};
On Windows, caller-provided workspace roots and workspace leaf names reject NTFS alternate-stream and directory-index namespace spellings before directory creation or file access. The lower-level temp and sibling helpers apply the same admission to supplied roots, sibling directories, and callback-selected final paths. Prefixes and tempFile() filenames keep their documented sanitization behavior; ordinary colon-bearing POSIX roots and leaf names remain valid.
#Advanced temp primitives
When you don't need the stable workspace abstraction, the lower-level temp-file and sibling-temp helpers live behind @openclaw/fs-safe/advanced. They are composition primitives for stores and atomic writers, not the primary API. tempWorkspace() carries the stable lifetime contract for application code; tempFile() is a one-shot building block whose options may move as store and archive internals evolve.
#tempFile
import { tempFile } from "@openclaw/fs-safe/advanced";
const target = await tempFile({ fileName: "report.pdf", prefix: "render-" });
try {
await render(target.path);
await fs.copyFile(target.path, "/srv/workspace/reports/today.pdf");
} finally {
await target.cleanup();
}
Options:
type TempFileOptions = {
rootDir?: string;
prefix: string;
fileName?: string;
onCleanupError?: (error: unknown) => void;
cleanupSafety?: "compatible" | "require-bounded"; // default compatible
};
Returns:
type TempFile = {
path: string; // absolute path; safe to write to
dir: string; // the enclosing private workspace dir
file(fileName?: string): string; // resolve another file in the same dir
cleanup(): Promise<void>; // removes the original private workspace dir
[Symbol.asyncDispose](): Promise<void>; // alias of cleanup()
};
The default cleanupSafety: "compatible" retains the historical temp-file behavior without loading or probing the native helper. Cleanup captures the directory identity at creation time and preserves a replacement observed by its pre-removal identity check. That check and pathname-recursive removal are separate operations, however: a same-privilege peer can substitute a directory in the final gap and redirect recursive traversal. Process-exit cleanup has the same compatible contract.
The bounded mode requires an existing supplied root and applies the same trusted-ancestry admission as private temp workspaces. On POSIX it finalizes the new directory to 0o700 through its retained descriptor; Windows keeps identity checks without treating POSIX modes as ACL privacy. Unverified creation artifacts are preserved for caller-directed recovery.
Set cleanupSafety: "require-bounded" to require the retained-parent, no-replace quarantine, and descriptor-bounded owned-tree removal described for private temp workspaces. Admission, including the runtime probe, completes before mkdtemp; unavailable support throws FsSafeError("helper-unavailable") before a child is created. Manual, disposal, scoped, and process-exit cleanup then share one owner, and no pathname-recursive fallback is used. cleanup() still resolves Promise<void>: operational cleanup errors are passed to onCleanupError when supplied and otherwise suppressed for compatibility. The bounded POSIX final-entry unlink limit still applies.
#withTempFile
Same shape with auto-cleanup:
import { withTempFile } from "@openclaw/fs-safe/advanced";
await withTempFile({ fileName: "out.zip", prefix: "pack-" }, async (filePath) => {
await pack(filePath);
await uploadAndForget(filePath);
});
#Sibling temp writes
When you want to write to a temp file in the same directory as a future destination — useful when you need atomic placement but don't want to use replaceFileAtomic's full machinery.
#writeSiblingTempFile
import { writeSiblingTempFile } from "@openclaw/fs-safe/advanced";
const result = await writeSiblingTempFile<string>({
dir: "/srv/workspace",
mode: 0o600,
writeTemp: async (tempPath) => {
await fs.writeFile(tempPath, JSON.stringify(state));
return "state.json";
},
resolveFinalPath: (fileName) => path.join("/srv/workspace", fileName),
});
// result.filePath, result.result (returned by writeTemp)
By default, writeSiblingTempFile chooses a random, initially absent sibling name in dir and calls writeTemp(). After the callback succeeds, it validates the produced regular file before taking ownership: symlinks, directories, other non-regular files, hardlinks, and changes between the pre-open pathname, opened descriptor, and current pathname are rejected. The callback must finish and close its writer before returning. Its return value is preserved as result.
Each option is read once before directory creation starts, including both callbacks, the temp prefix, isolation, directory and file modes, and sync flags. Later changes to the options object do not change the in-flight operation. writeTemp and resolveFinalPath retain their shared internal staging object as the callback receiver.
Generated temp filenames suffix Windows reserved-device basenames on every platform. Before either an ordinary or isolated producer runs, the completed staging name must be a nonempty, non-dot path component with no POSIX or Windows separator, C0/C1 control, Windows-invalid punctuation or stream colon. Windows reserved devices and trailing-dot/space aliases are rejected on every host. The helper joins that validated component to the guarded or owned directory and verifies that the result is a direct child. An invalid completion rejects with invalid-path without calling the producer or its pre-write hook.
The helper retains one descriptor through requested mode application, opt-in file synchronization, rename, and publication verification. It opens read-only unless file synchronization is requested, so closed read-only producer output remains publishable under the historical default. Omitting mode preserves the callback-produced mode without chmod; explicit modes, including 0, are applied through that descriptor. File-mode errors are tolerated for compatibility with the helper's historical best-effort behavior. No chmod, content read, or reopen follows the staged or published pathname. resolveFinalPath(result) must resolve to a distinct direct child of the same directory. Final-path writes are serialized within the process, and the retained descriptor and current name must still have the admitted exact bigint identity and exactly one link before rename and after publication. A verification failure after rename does not roll back or delete the final name.
syncTempFile and syncParentDir retain their historical false defaults. Explicit syncTempFile: true synchronizes the descriptor before rename; file-sync errors propagate except for the existing EPERM compatibility case. Explicit syncParentDir: true requests best-effort parent sync after rename. Omitting either option or passing false skips that sync, never the identity checks. Parent synchronization can be unsupported or fail without rejecting the write, so success is not a strict crash-durability receipt.
Without producer isolation, cleanup only unlinks an admitted file while the parent, pathname identity, and single-link regular-file checks still agree. Observed substitutes are preserved, including during process-exit cleanup. Operational cleanup failures retain an identity-bound exit retry. If the callback throws or admission fails, no file has been adopted: even a regular partial file is left for caller-directed recovery. The helper never recursively removes a sibling temp file path.
Set producerIsolation: "private-directory" in WriteSiblingTempFileOptions when the producer can leave partial output before throwing. The callback then receives an initially absent file path inside a private child workspace under dir, on the same filesystem as the final target. fs-safe captures directory cleanup ownership before invoking the callback. A callback exception triggers owned workspace cleanup, including partial output, subject to directory identity checks and I/O failures. The callback must still finish and close its writer before returning.
After the callback succeeds, an available native helper uses guarded no-replace Root.move. Native-off operation admits the completed regular file with a retained descriptor. On Windows, that branch first requests write-only access so admission does not request completed-file data reads; access or provider rejections fall back to the historical read-only or read/write open before admission. It then creates the randomized sibling with an atomic no-clobber hard link and verifies the expected two-link transition. On Windows, it opens and verifies a sibling descriptor before closing the source descriptor, keeping the file pinned while allowing the private name to disappear on runtimes with legacy deletion behavior. It removes the private name before continuing. If legacy deletion clears the completed file's read-only attribute, the helper restores it through the retained sibling descriptor and verifies its mode and identity before publication. Restoration failures reject the operation. An escaping symlink fails with path-alias, and a filesystem without either handoff reports helper-unavailable. The retained descriptor carries file admission, requested modes, sync options, and final rename with their existing contracts; resolveFinalPath(result) still names a direct child of dir.
The isolated path retains exact bigint identities for both the parent and the workspace and rechecks them before moving output to the sibling path. An observed replacement is rejected. This internal use of withTempFile does not expose cleanupSafety and uses compatible cleanup: moving or replacing the parent or workspace can leave artifacts, and a workspace substituted in the final check-to-recursive-removal gap can redirect traversal. It does not promise cleanup through a retained directory after a rename, bounded cleanup, stronger permissions, or additional crash durability. Omitting producer isolation preserves the direct sibling callback path and unadmitted partial-file retention.
On POSIX, admission uses no-follow and nonblocking open flags, so a FIFO swap does not block the helper. Windows retains Node's guarded pathname-open behavior because Node has no portable no-follow flag there; metadata is checked before and after opening, and unknown Windows identities fail closed after one bounded re-inspection without reopening. These helpers remain available with native mode off; they do not acquire the native-required retained-directory contract of stageFileInDirectory.
Identity checks and pathname rename/unlink are separate syscalls, not atomic conditional mutations. A hostile process can still replace a leaf or parent in the final syscall gap or mutate an open file's contents. Use an approved writable directory and cooperative locking or OS isolation; a moved parent can leave an unpublished original temp behind. Replacements observed before the final namespace mutation are preserved, but that observation is not an atomic condition on the later unlink or rename. Private producer isolation also has the compatible recursive-cleanup gap described above. Arbitrary concurrent namespace changes cannot be prevented by these helpers.
By default the helper attempts to set dir to dirMode (default 0o700) through the shared verified POSIX directory-descriptor helper. Only the actual descriptor chmod error is tolerated, preserving the historical best-effort directory-mode behavior. Directory lstat, open, type, identity, and close errors still propagate; there is no pathname chmod fallback. Windows only passes the directory mode to mkdir. Pass chmodDir: false when an existing staging/output directory mode must be preserved.
#writeViaSiblingTempPath
A higher-level convenience for callback-based producers. The callback writes to a private temp path, then the helper copies the result into targetPath through the root boundary:
import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced";
await writeViaSiblingTempPath({
rootDir: "/srv/workspace",
targetPath: "/srv/workspace/state.json",
writeTemp: async (tempPath) => {
await fs.writeFile(tempPath, JSON.stringify(state));
},
});
If replaceFileAtomic does what you need, prefer that. Use writeViaSiblingTempPath when the producer needs a concrete temp pathname but the final destination still needs root-boundary checks.
The root, target, callback, fallback filename, and temp prefix are read once before setup. Later changes to the parameters do not affect the in-flight operation; writeTemp retains the original parameters object as its receiver.
Its private workspace uses tempFile()'s compatible identity-aware cleanup. It preserves replacements observed before removal, but retains the final pathname-recursive-removal gap described above; this helper does not expose cleanupSafety: "require-bounded". The callback staging component is capped at 255 bytes as written and under NFC and NFD by shortening only an overlong embedded destination tail, while preserving an extension when possible. Short callback paths and the final target stay unchanged. An unusable target basename causes fallbackFileName to pass through the same basename, character, reserved-device, and length sanitization before it is embedded; if neither candidate is usable, the fixed tail file is used. The completed component is then checked as a direct child before test hooks or the producer run. This workspace owns its contents, unlike the unadmitted sibling pathname above.
#Secure temp root
The resolveSecureTempRoot() helper picks a per-user directory under the system temp dir, creates it at mode 0o700 if missing, and returns the absolute path. The other helpers in this module call it by default; you can call it directly if you need to materialize the root yourself.
import { resolveSecureTempRoot } from "@openclaw/fs-safe/temp";
const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
// e.g. /tmp/my-app-501
Consumers that only need this resolver can use the narrow package subpath:
import {
resolveSecureTempRoot,
type ResolveSecureTempRootOptions,
type SecureTempRootDescriptorAdapter,
} from "@openclaw/fs-safe/secure-temp-root";
This entry excludes the temp workspace and store implementations from the module graph, keeping the import closure small for browser-aware builds that shim or exclude Node built-ins. The resolver remains a Node filesystem API; the narrow entry does not make it runnable in a browser.
#Options
type ResolveSecureTempRootOptions = {
fallbackPrefix: string; // one portable path segment; invalid values throw
preferredDir?: string; // preferred secure temp root
skipPreferredOnWindows?: boolean;
unsafeFallbackLabel?: string; // text used in thrown errors
warningPrefix?: string; // default "[fs-safe]"
warn?: (message: string) => void; // default console.warn
// Platform/test adapters; production callers normally omit these.
platform?: NodeJS.Platform;
getuid?: () => number | undefined;
tmpdir?: () => string;
accessSync?: typeof import("node:fs").accessSync;
descriptor?: SecureTempRootDescriptorAdapter; // complete bundle; see below
lstatSync?: (path: string) => {
isDirectory(): boolean;
isSymbolicLink(): boolean;
mode?: number | bigint;
uid?: number | bigint;
};
mkdirSync?: (
path: string,
options: { recursive: boolean; mode?: number },
) => void;
};
When process.getuid() is available, the fallback is <tmpdir>/<fallbackPrefix>-<uid>. Without a UID (including Windows), it is <tmpdir>/<fallbackPrefix>; no username is appended. The helper never returns the shared os.tmpdir() directory itself. It requires the selected path to be a writable, non-symlink directory and, when UID/mode facts are available, owned by the current user without group/world write bits. It creates or repairs the fallback to mode 0o700 where mode bits apply. If it cannot establish that state, it throws an ordinary Error; there is no native mode or helper-unavailable branch on this API.
Existing secure directories retain the one-lstat/access fast path, with no descriptor open or chmod. On POSIX, a directory created by this call is instead finalized through a pinned descriptor and must finish at exactly 0o700, even when a privileged caller can access its initial restrictive mode. A concurrent recursive-mkdir winner is inspected as an untrusted existing directory. Broad-mode repair also uses a pinned descriptor; there is no pathname chmod.
Repair and finalization require a known nonnegative safe-integer UID and exact bigint device, inode, owner, mode, and directory-type facts. Device and inode identities accept Node's signed 64-bit stat representation, including negative values down to -(1n << 63n); inode zero remains invalid. Previously supported nonnegative device and positive inode adapter values remain supported without an upper cap. Values are compared exactly as received: signed and unsigned encodings of the same bits are not treated as equal. They open with O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_NONBLOCK, verify the descriptor and current directory entry against the initial receipt before fchmod, then verify identity, permissions, and write/search access again before closing. Trailing separators are stripped only for entry inspection, preserving filesystem roots and symlink-sensitive .. components. Unknown, numeric, or malformed identity facts cannot authorize a mutation. A failed chmod is tolerated only for the existing EPERM/EACCES/ENOENT cases when the same exact pinned directory has concurrently become safe and accessible; it emits no repair warning.
There is no search-only descriptor or /proc fallback. A newly created 000 directory that cannot be opened read-only is left in place; the resolver tries the secure fallback or throws. Actual Windows uses directory-type and access checks and performs no POSIX chmod, regardless of an injected platform value. Failures keep the ordinary Error contract; available underlying repair and close failures are retained in cause, including paired errors.
The optional descriptor adapter is a complete authority bundle:
type SecureTempRootDescriptorAdapter = {
lstatSync(path: string, options: { bigint: true }): Pick<import("node:fs").BigIntStats,
"dev" | "ino" | "uid" | "mode" | "isDirectory" | "isSymbolicLink">;
fstatSync(fd: number, options: { bigint: true }): ReturnType<SecureTempRootDescriptorAdapter["lstatSync"]>;
openSync(path: string, flags: number): number;
fchmodSync(fd: number, mode: number): void;
closeSync(fd: number): void;
constants: { O_RDONLY: number; O_DIRECTORY: number; O_NOFOLLOW: number; O_NONBLOCK: number };
};
Options, adapter functions, and flags are captured synchronously before callbacks. On POSIX a complete bundle supplies exact admission and repair observations, taking precedence over the legacy lstatSync hook. Partial bundles or unavailable flags make repair/finalization unavailable. Injecting lstatSync, accessSync, or mkdirSync disables the default host descriptor bundle. Injected observations also require an explicit mkdirSync for creation; supplying a descriptor bundle likewise never implicitly authorizes host mkdir. A custom mkdir requires the complete descriptor bundle for POSIX finalization.
These checks bind chmod to the admitted object and reject observed replacements; the returned path is not a retained capability. Pathname access and identity checks remain separate syscalls, and another process can replace the path after the final check. Applications still need a trusted namespace or OS isolation when other processes can mutate it.
#Common patterns
#Build something, atomically place it
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "build-" }, async (ws) => {
await runCompiler({ outDir: ws.dir });
await replaceDirectoryAtomic({
stagedDir: ws.dir,
targetDir: "/srv/site/public",
});
});
#Stream a download to a sibling temp, then commit
import { writeSiblingTempFile } from "@openclaw/fs-safe/advanced";
import fs from "node:fs/promises";
const r = await writeSiblingTempFile({
dir: "/srv/cache",
producerIsolation: "private-directory",
writeTemp: async (tempPath) => {
const handle = await fs.open(tempPath, "w");
try {
await pipeline(downloadStream, handle.createWriteStream());
} finally {
await handle.close();
}
return "blob.bin";
},
resolveFinalPath: (fileName) => path.join("/srv/cache", fileName),
});
console.log(`downloaded ${r.filePath}`);
#Per-call private scratch in a test
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
it("processes a fixture", async () => {
await withTempWorkspace({ rootDir: "/tmp/my-tests", prefix: "test-" }, async (ws) => {
await fs.writeFile(path.join(ws.dir, "input.txt"), fixture);
const out = await processFile(path.join(ws.dir, "input.txt"));
expect(out).toEqual(expected);
});
});
#See also
- Atomic writes —
replaceDirectoryAtomicfor whole-directory swaps. root()—fs.copyIn(rel, sourceAbs)for moving files from a temp into aRoot.- File lock — when many processes share a temp tree.
#Exact workspace receipts
tempWorkspace() and tempWorkspaceSync() return exactIdentity alongside their unchanged legacy identity. Preserve the bigint companion for identity comparisons. tempFile().identity already uses bigint dev and ino and needs no conversion. Cleanup retains its own exact authority; editing or discarding a public receipt does not rebind cleanup to another file or directory.