Atomic & temp

Temp workspaces

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 directory created at mode 0o700 under a caller-provided temp root. It is unique per call (random suffix) and cleaned up when you call cleanup() or leave an await using scope.

#tempWorkspace

The compact factory. Returns:

type TempWorkspace = {
  dir: string;
  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<void>;
  [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.

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.

The sync variant tempWorkspaceSync exposes the same surface with sync return types and a FileStoreSync at workspace.store.

#withTempWorkspace

The recommended shape. Auto-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

Lower-level. You manage the lifetime:

const workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "scan-" });
try {
  // …work in workspace.dir…
} finally {
  await workspace.cleanup();
}

#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;         // dir mode; default 0o700
  mode?: number;            // file write mode; default 0o600
};

#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();
}

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 private workspace dir
  [Symbol.asyncDispose](): Promise<void>;   // alias of cleanup()
};

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

writeSiblingTempFile chooses a random sibling name in dir, calls your writeTemp() callback, validates that resolveFinalPath(result) is still inside that same directory, and renames the temp file there.

By default it preserves the historical private-helper behavior of chmodding dir to dirMode (default 0o700). Pass chmodDir: false when the directory is a public staging/output path whose existing 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.

#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

#Options

type ResolveSecureTempRootOptions = {
  fallbackPrefix: string;   // base name for the per-user fallback dir
  preferredDir?: string;    // optional preferred secure temp root
  tmpdir?: () => string;    // override os.tmpdir()
};

The directory name embeds the user's UID (POSIX) or username so multi-user systems don't collide. On unsupported platforms, falls back to os.tmpdir() directly with a helper-unavailable error code surfaced to callers that explicitly required the secure root.

#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",
  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 writesreplaceDirectoryAtomic for whole-directory swaps.
  • root()fs.copyIn(rel, sourceAbs) for moving files from a temp into a Root.
  • File lock — when many processes share a temp tree.