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.
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 / Export | File | Purpose |
|---|---|---|
loadJsonFile(pathname) | json-file.ts | Synchronous JSON read from disk; returns parsed object or undefined |
saveJsonFile(pathname, data) | json-file.ts | Synchronous JSON write with mode 0o600 (owner-only permissions) |
readJsonFile<T>(filePath) | json-files.ts | Async generic JSON read with type inference |
writeJsonAtomic(filePath, value, options?) | json-files.ts | Async atomic write: serialize → temp file → fsync → rename |
writeTextAtomic() | json-files.ts | Async atomic text-file write (same temp-rename pattern) |
createAsyncLock() | json-files.ts | Returns withLock<T>() for serializing concurrent writes to the same path |
requireNodeSqlite() | node-sqlite.ts | Dynamic import of node:sqlite with warning filter for experimental API |
openFileWithinRoot() | fs-safe.ts | Sandboxed file open: validates path is within root, rejects symlinks |
readFileWithinRoot() | fs-safe.ts | Convenience wrapper: open + read + close within root boundary |
loadDeviceAuthToken() | device-auth-store.ts | Reads device auth JSON from ${stateDir}/identity/device-auth.json |
storeDeviceAuthToken() | device-auth-store.ts | Persists device auth token with version field and device ID |
clearDeviceAuthToken() | device-auth-store.ts | Removes device auth JSON from disk |
enqueueDelivery() | delivery-queue-storage.ts | Writes a QueuedDelivery to the queue directory atomically |
loadPendingDeliveries() | delivery-queue-storage.ts | Scans queue directory and returns all pending delivery files |
ackDelivery() | delivery-queue-storage.ts | Acknowledges successful delivery by deleting the queue file |
failDelivery() | delivery-queue-storage.ts | Increments retry count and records last error on a queued delivery |
moveToFailed() | delivery-queue-storage.ts | Archives a delivery to the failed/ subdirectory after max retries |
readSessionStoreJson5() | state-migrations.ts | Reads legacy JSON5 session files for migration |
Data Structures
| Kind | Name | Description |
|---|---|---|
| interface | DeviceAuthStore | { version: 1, deviceId: string, tokens: TokenSet } — persisted device identity |
| interface | QueuedDelivery | { id, channel, to, payloads[], enqueuedAt, retryCount, lastAttemptAt?, lastError? } |
| interface | SafeOpenResult | { handle: FileHandle, realPath: string, stat: Stats } — returned by sandboxed open |
| enum | SafeOpenErrorCode | Error codes for sandboxed file operations: PATH_TRAVERSAL, SYMLINK, NOT_FOUND, etc. |
| type | ArchiveKind | "tar" | "zip" — supported archive formats |
| interface | ArchiveExtractLimits | { maxArchiveBytes?, maxEntries?, maxExtractedBytes?, maxEntryBytes? } |
| type | LegacyStateDetection | Flags for detecting WhatsApp auth, agent dirs, and pairing allowlist in legacy state |
Interactions
| Direction | Partner | Interface | Description |
|---|---|---|---|
| → | Filesystem | Node fs / fs/promises | All reads and writes go through Node’s file APIs with O_RDONLY | O_NOFOLLOW flags |
| → | SQLite | node:sqlite | Dynamic import for structured query storage via requireNodeSqlite() |
| ← | Agent System | device-auth-store.ts | Agents read/write device authentication tokens through the store |
| ← | Delivery Pipeline | delivery-queue-storage.ts | Delivery system enqueues, retries, and acknowledges messages via queue files |
| ← | Extension Host | exec-approvals-store.ts | Extension approval decisions are persisted and recalled through the store |
| ← | Migration System | state-migrations.ts | Legacy state files are read and migrated to the current schema |
| → | Archive Extractor | archive.ts | Extracted archives are validated against size/entry limits before writing |
| ↔ | File Lock | file-lock.ts | Coordinates concurrent access to shared files across processes |
Configuration
| Knob / Constant | Default | Description |
|---|---|---|
saveJsonFile mode | 0o600 | Owner-only read/write permissions on persisted JSON files |
O_RDONLY | O_NOFOLLOW | — | Flags used by fs-safe.ts to prevent symlink following |
maxArchiveBytes | 256 MB | Maximum zip archive size allowed for extraction |
maxEntries | 50,000 | Maximum number of entries allowed in a single archive |
maxExtractedBytes | 512 MB | Total bytes limit for all extracted files combined |
maxEntryBytes | 256 MB | Maximum size for a single extracted file |
| Temp file pattern | ${filePath}.${pid}.tmp | PID-stamped temporary file for atomic writes |
| Device auth path | ${stateDir}/identity/device-auth.json | Location of device authentication token on disk |
| Queue directory | ${stateDir}/delivery-queue/ | Directory where pending delivery files are stored |
DeviceAuthStore.version | 1 | Schema 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.
Migrations Focus
Versioned migration scripts, rollback strategies, and the legacy state detection pipeline for WhatsApp auth and agent directories.
Query Builder Focus
Composable, type-safe query construction for the SQLite backend—parameterized queries, joins, and safe interpolation.
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);