Infrastructure/store/ — Unified Persistence Layer

Atomic file I/O, sandboxed access, and multi-backend data storage

The store sub-module provides all data persistence for OpenClaw. It abstracts whether data lives in synchronous JSON files, async atomic JSON, or SQLite—exposing a unified API surface. Every write is atomic (UUID-temp-file → rename) to prevent corruption on crash, and sandboxed file operations prevent directory traversal and symlink attacks.

~40
Files
~12K
Lines of Code
15+
Exported Types
3
Storage Backends

Atomic Writes Multi-Backend Sandboxed I/O Crash-Safe

📚 What is this & why does it exist?

The store sub-module is OpenClaw’s unified persistence layer. It abstracts three storage backends (synchronous JSON, asynchronous atomic JSON, and SQLite) behind a consistent API so that callers never worry about file-level atomicity, locking, or path safety. Every write is staged to a temporary file first, then atomically renamed into place—meaning a crash mid-write can never leave corrupted data on disk. The sandboxed file operations (fs-safe.ts) add a security boundary that blocks directory traversal and symlink-following attacks.

💡 Real-world analogy

Think of the store like a bank vault with safety deposit boxes. Each box (JSON file) is opened with a key that’s been verified against a list of authorized paths. When you update the contents, a new box is prepared in a separate room (temp file), and only when it’s ready is it swapped into the main vault atomically—so no one ever sees a half-written box. The vault guard (fs-safe.ts) checks every access against a whitelist and refuses to follow redirects (symlinks) to unauthorized areas.

Contents

Architecture

The store follows an atomic write-ahead pattern: validate the target path → write to a PID-stamped temp file → rename into place. All file operations pass through boundary checks, symlink rejection, size limits, and finally the atomic commit.

flowchart TD
  subgraph Callers["Consumer Modules"]
    DEV["device-auth-store.ts"]
    DQ["delivery-queue-storage.ts"]
    EA["exec-approvals-store.ts"]
    SM["state-migrations.ts"]
  end
  subgraph Sync["Sync JSON Backend"]
    JF["json-file.ts\nloadJsonFile / saveJsonFile"]
  end
  subgraph Async["Async Atomic Backend"]
    JFS["json-files.ts\nreadJsonFile / writeJsonAtomic"]
    LOCK["createAsyncLock()\nwithLock()"]
  end
  subgraph SQLite["SQLite Backend"]
    NS["node-sqlite.ts\nrequireNodeSqlite()"]
  end
  subgraph Safety["Safety Layer"]
    FS["fs-safe.ts\nopenFileWithinRoot()"]
    FL["file-lock.ts"]
    FI["file-identity.ts"]
  end
  subgraph Archive["Archive Extraction"]
    AR["archive.ts\nArchiveExtractLimits"]
  end
  Callers --> Sync
  Callers --> Async
  Callers --> SQLite
  Sync --> Safety
  Async --> Safety
  Async --> LOCK
  SQLite --> Safety
  Archive --> Safety
  FS --> FL
  FS --> FI

Data Flow — Atomic Write Lifecycle

This sequence shows how an atomic write flows through the store, from the initial call to the final rename that makes the data visible.

sequenceDiagram
    participant Caller as Consumer Module
    participant Store as writeJsonAtomic()
    participant Lock as AsyncLock
    participant FS as fs-safe.ts
    participant Disk as Filesystem

    Caller->>Store: writeJsonAtomic(filePath, value)
    Store->>Lock: withLock(filePath)
    Lock-->>Store: Lock acquired
    Store->>FS: openFileWithinRoot(dir)
    FS->>FS: Validate path (no traversal)
    FS->>FS: Reject symlinks (O_NOFOLLOW)
    FS-->>Store: SafeOpenResult
    Store->>Disk: write ${filePath}.${pid}.tmp
    Disk-->>Store: Write complete
    Store->>Disk: fsync(fd)
    Disk-->>Store: Synced
    Store->>Disk: rename(tmp, filePath)
    Disk-->>Store: Atomic commit
    Store->>Lock: Release lock
    Lock-->>Caller: Resolved

Delivery Queue Flow

flowchart LR
  ENQ["enqueueDelivery()"] --> DIR["${stateDir}/delivery-queue/"]
  DIR --> LOAD["loadPendingDeliveries()"]
  LOAD --> ATTEMPT["Delivery Attempt"]
  ATTEMPT -->|Success| ACK["ackDelivery()\n(delete file)"]
  ATTEMPT -->|Failure| FAIL["failDelivery()\n(increment retryCount)"]
  FAIL -->|Max retries| MOVE["moveToFailed()\n(archive to failed/)"]
  FAIL -->|Retry| LOAD

Key Classes & Functions

Function / ExportFilePurpose
loadJsonFile(pathname)json-file.tsSynchronous JSON read from disk; returns parsed object or undefined
saveJsonFile(pathname, data)json-file.tsSynchronous JSON write with mode 0o600 (owner-only permissions)
readJsonFile<T>(filePath)json-files.tsAsync generic JSON read with type inference
writeJsonAtomic(filePath, value, options?)json-files.tsAsync atomic write: serialize → temp file → fsync → rename
writeTextAtomic()json-files.tsAsync atomic text-file write (same temp-rename pattern)
createAsyncLock()json-files.tsReturns withLock<T>() for serializing concurrent writes to the same path
requireNodeSqlite()node-sqlite.tsDynamic import of node:sqlite with warning filter for experimental API
openFileWithinRoot()fs-safe.tsSandboxed file open: validates path is within root, rejects symlinks
readFileWithinRoot()fs-safe.tsConvenience wrapper: open + read + close within root boundary
loadDeviceAuthToken()device-auth-store.tsReads device auth JSON from ${stateDir}/identity/device-auth.json
storeDeviceAuthToken()device-auth-store.tsPersists device auth token with version field and device ID
clearDeviceAuthToken()device-auth-store.tsRemoves device auth JSON from disk
enqueueDelivery()delivery-queue-storage.tsWrites a QueuedDelivery to the queue directory atomically
loadPendingDeliveries()delivery-queue-storage.tsScans queue directory and returns all pending delivery files
ackDelivery()delivery-queue-storage.tsAcknowledges successful delivery by deleting the queue file
failDelivery()delivery-queue-storage.tsIncrements retry count and records last error on a queued delivery
moveToFailed()delivery-queue-storage.tsArchives a delivery to the failed/ subdirectory after max retries
readSessionStoreJson5()state-migrations.tsReads legacy JSON5 session files for migration

Data Structures

KindNameDescription
interfaceDeviceAuthStore{ version: 1, deviceId: string, tokens: TokenSet } — persisted device identity
interfaceQueuedDelivery{ id, channel, to, payloads[], enqueuedAt, retryCount, lastAttemptAt?, lastError? }
interfaceSafeOpenResult{ handle: FileHandle, realPath: string, stat: Stats } — returned by sandboxed open
enumSafeOpenErrorCodeError codes for sandboxed file operations: PATH_TRAVERSAL, SYMLINK, NOT_FOUND, etc.
typeArchiveKind"tar" | "zip" — supported archive formats
interfaceArchiveExtractLimits{ maxArchiveBytes?, maxEntries?, maxExtractedBytes?, maxEntryBytes? }
typeLegacyStateDetectionFlags for detecting WhatsApp auth, agent dirs, and pairing allowlist in legacy state

Interactions

DirectionPartnerInterfaceDescription
→FilesystemNode fs / fs/promisesAll reads and writes go through Node’s file APIs with O_RDONLY | O_NOFOLLOW flags
→SQLitenode:sqliteDynamic import for structured query storage via requireNodeSqlite()
←Agent Systemdevice-auth-store.tsAgents read/write device authentication tokens through the store
←Delivery Pipelinedelivery-queue-storage.tsDelivery system enqueues, retries, and acknowledges messages via queue files
←Extension Hostexec-approvals-store.tsExtension approval decisions are persisted and recalled through the store
←Migration Systemstate-migrations.tsLegacy state files are read and migrated to the current schema
→Archive Extractorarchive.tsExtracted archives are validated against size/entry limits before writing
↔File Lockfile-lock.tsCoordinates concurrent access to shared files across processes

Configuration

Knob / ConstantDefaultDescription
saveJsonFile mode0o600Owner-only read/write permissions on persisted JSON files
O_RDONLY | O_NOFOLLOW—Flags used by fs-safe.ts to prevent symlink following
maxArchiveBytes256 MBMaximum zip archive size allowed for extraction
maxEntries50,000Maximum number of entries allowed in a single archive
maxExtractedBytes512 MBTotal bytes limit for all extracted files combined
maxEntryBytes256 MBMaximum size for a single extracted file
Temp file pattern${filePath}.${pid}.tmpPID-stamped temporary file for atomic writes
Device auth path${stateDir}/identity/device-auth.jsonLocation of device authentication token on disk
Queue directory${stateDir}/delivery-queue/Directory where pending delivery files are stored
DeviceAuthStore.version1Schema version for device auth store; enables future migrations

Directory Map

Source tree for store/
store/
├── json-file.ts                   ← Synchronous JSON load/save (mode 0o600)
├── json-files.ts                  ← Async atomic JSON: readJsonFile, writeJsonAtomic, createAsyncLock
├── node-sqlite.ts                 ← Dynamic node:sqlite import with warning filter
├── device-auth-store.ts           ← DeviceAuthStore schema, load/store/clear tokens
├── state-migrations.ts            ← Legacy state detection & migration (WhatsApp, agents, pairing)
├── delivery-queue-storage.ts      ← QueuedDelivery: enqueue, load, ack, fail, moveToFailed
├── fs-safe.ts                     ← SafeOpenResult, openFileWithinRoot, readFileWithinRoot
├── archive.ts                     ← ArchiveKind, ArchiveExtractLimits, extraction logic
├── exec-approvals-store.ts        ← Execution approval decision persistence
├── file-lock.ts                   ← File locking primitives for cross-process coordination
├── file-identity.ts               ← Inode/path-based file identity tracking
├── data_models/                   ← Schema definitions and type-safe data model helpers
│   └── ...
├── migrations/                    ← Versioned migration scripts and rollback logic
│   └── ...
└── query_builder/                 ← Composable query construction for SQLite backend
    └── ...

Deep-Dive Highlights

Core persistence mechanisms have been extracted into dedicated focus pages for in-depth exploration. Each page covers one aspect of the store with diagrams, code walks, and configuration details.

Deep-Dive Pages

Data Models Focus

Schema definitions, type-safe model helpers, and the versioned data contract that every store consumer relies on.

Extracted topic page

Migrations Focus

Versioned migration scripts, rollback strategies, and the legacy state detection pipeline for WhatsApp auth and agent directories.

Extracted topic page

Query Builder Focus

Composable, type-safe query construction for the SQLite backend—parameterized queries, joins, and safe interpolation.

Extracted topic page

Code Examples

Atomic JSON Write with Locking

import { writeJsonAtomic, createAsyncLock } from "./json-files";

const lock = createAsyncLock();

async function persistConfig(filePath: string, config: AppConfig): Promise<void> {
  await lock.withLock(async () => {
    await writeJsonAtomic(filePath, config, {
      mode: 0o600,
      mkdir: true,
    });
  });
}

Sandboxed File Read

import { readFileWithinRoot, SafeOpenErrorCode } from "./fs-safe";

async function loadUserFile(root: string, userPath: string): Promise<Buffer | null> {
  const result = await readFileWithinRoot(root, userPath);
  if (!result.ok) {
    switch (result.error) {
      case SafeOpenErrorCode.PATH_TRAVERSAL:
        throw new Error(`Blocked directory traversal: ${userPath}`);
      case SafeOpenErrorCode.SYMLINK:
        throw new Error(`Symlink not allowed: ${userPath}`);
      default:
        return null;
    }
  }
  return result.data;
}

Delivery Queue Lifecycle

import {
  enqueueDelivery,
  loadPendingDeliveries,
  ackDelivery,
  failDelivery,
  moveToFailed,
} from "./delivery-queue-storage";

const delivery: QueuedDelivery = {
  id: crypto.randomUUID(),
  channel: "whatsapp",
  to: "+1234567890",
  payloads: [{ type: "text", body: "Hello from OpenClaw" }],
  enqueuedAt: Date.now(),
  retryCount: 0,
};

await enqueueDelivery(stateDir, delivery);

const pending = await loadPendingDeliveries(stateDir);
for (const item of pending) {
  try {
    await sendMessage(item);
    await ackDelivery(stateDir, item.id);
  } catch (err) {
    await failDelivery(stateDir, item.id, err.message);
    if (item.retryCount >= MAX_RETRIES) {
      await moveToFailed(stateDir, item.id);
    }
  }
}

Device Auth Token Management

import {
  loadDeviceAuthToken,
  storeDeviceAuthToken,
  clearDeviceAuthToken,
} from "./device-auth-store";

// Load existing token (returns undefined if not found)
const auth = await loadDeviceAuthToken(stateDir);

if (!auth || isExpired(auth.tokens)) {
  const freshTokens = await refreshDeviceAuth(auth?.deviceId);
  await storeDeviceAuthToken(stateDir, {
    version: 1,
    deviceId: freshTokens.deviceId,
    tokens: freshTokens.tokens,
  });
}

// On logout, remove persisted credentials
await clearDeviceAuthToken(stateDir);