@openclaw/fs-safe 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -0
- package/README.md +23 -3
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +51 -7
- package/dist/archive.js +2 -2
- package/dist/file-store-boundary.d.ts +27 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +164 -0
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +62 -72
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/private-file-store.d.ts +5 -7
- package/dist/private-file-store.d.ts.map +1 -1
- package/dist/private-file-store.js +21 -34
- package/dist/root-context.d.ts +23 -0
- package/dist/root-context.d.ts.map +1 -0
- package/dist/root-context.js +62 -0
- package/dist/root-errors.d.ts +4 -0
- package/dist/root-errors.d.ts.map +1 -0
- package/dist/root-errors.js +21 -0
- package/dist/root-impl.d.ts +108 -0
- package/dist/root-impl.d.ts.map +1 -0
- package/dist/{safe-root.js → root-impl.js} +464 -251
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/docs/advanced.md +100 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +163 -0
- package/docs/config.md +65 -0
- package/docs/contributing.md +80 -0
- package/docs/errors.md +154 -0
- package/docs/file-store.md +213 -0
- package/docs/filename.md +0 -0
- package/docs/index.md +76 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +200 -0
- package/docs/json.md +158 -0
- package/docs/local-roots.md +164 -0
- package/docs/path-scope.md +130 -0
- package/docs/path.md +191 -0
- package/docs/permissions.md +95 -0
- package/docs/private-file-store.md +45 -0
- package/docs/python-helper.md +103 -0
- package/docs/quickstart.md +147 -0
- package/docs/reading.md +174 -0
- package/docs/regular-file.md +175 -0
- package/docs/root.md +205 -0
- package/docs/secret-file.md +154 -0
- package/docs/secure-file.md +82 -0
- package/docs/security-model.md +99 -0
- package/docs/sidecar-lock.md +229 -0
- package/docs/store.md +44 -0
- package/docs/temp.md +276 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +168 -0
- package/docs/timing.md +121 -0
- package/docs/types.md +155 -0
- package/docs/walk.md +63 -0
- package/docs/writing.md +222 -0
- package/package.json +5 -2
- package/dist/archive-utils.d.ts +0 -3
- package/dist/archive-utils.d.ts.map +0 -1
- package/dist/archive-utils.js +0 -26
- package/dist/boundary-file-read.d.ts +0 -44
- package/dist/boundary-file-read.d.ts.map +0 -1
- package/dist/boundary-file-read.js +0 -129
- package/dist/boundary-path.d.ts +0 -39
- package/dist/boundary-path.d.ts.map +0 -1
- package/dist/boundary-path.js +0 -598
- package/dist/fs-pinned-path-helper.d.ts +0 -7
- package/dist/fs-pinned-path-helper.d.ts.map +0 -1
- package/dist/fs-pinned-path-helper.js +0 -182
- package/dist/fs-pinned-write-helper.d.ts +0 -21
- package/dist/fs-pinned-write-helper.d.ts.map +0 -1
- package/dist/fs-pinned-write-helper.js +0 -263
- package/dist/hardlink-guards.d.ts +0 -7
- package/dist/hardlink-guards.d.ts.map +0 -1
- package/dist/hardlink-guards.js +0 -30
- package/dist/install-safe-path.d.ts +0 -20
- package/dist/install-safe-path.d.ts.map +0 -1
- package/dist/install-safe-path.js +0 -94
- package/dist/json-file.d.ts +0 -3
- package/dist/json-file.d.ts.map +0 -1
- package/dist/json-file.js +0 -123
- package/dist/json-files.d.ts +0 -20
- package/dist/json-files.d.ts.map +0 -1
- package/dist/json-files.js +0 -153
- package/dist/path-alias-guards.d.ts +0 -19
- package/dist/path-alias-guards.d.ts.map +0 -1
- package/dist/path-alias-guards.js +0 -21
- package/dist/path-guards.d.ts +0 -7
- package/dist/path-guards.d.ts.map +0 -1
- package/dist/path-guards.js +0 -49
- package/dist/path-safety.d.ts +0 -12
- package/dist/path-safety.d.ts.map +0 -1
- package/dist/path-safety.js +0 -50
- package/dist/safe-open-sync.d.ts +0 -24
- package/dist/safe-open-sync.d.ts.map +0 -1
- package/dist/safe-open-sync.js +0 -71
- package/dist/safe-root.d.ts +0 -123
- package/dist/safe-root.d.ts.map +0 -1
- package/dist/secure-temp-workspace.d.ts +0 -25
- package/dist/secure-temp-workspace.d.ts.map +0 -1
- package/dist/secure-temp-workspace.js +0 -136
- package/dist/sibling-temp-file.d.ts +0 -16
- package/dist/sibling-temp-file.d.ts.map +0 -1
- package/dist/sibling-temp-file.js +0 -73
- package/dist/sibling-temp-write.d.ts +0 -8
- package/dist/sibling-temp-write.d.ts.map +0 -1
- package/dist/sibling-temp-write.js +0 -40
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
# File lock
|
|
2
|
+
|
|
3
|
+
`acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry, stale-lock reclaim, and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
|
|
7
|
+
|
|
8
|
+
const handle = await acquireFileLock("/var/lib/app/state.json", {
|
|
9
|
+
managerKey: "snapshot",
|
|
10
|
+
staleMs: 5 * 60_000,
|
|
11
|
+
payload: async () => ({ pid: process.pid, host: os.hostname() }),
|
|
12
|
+
});
|
|
13
|
+
try {
|
|
14
|
+
// ...exclusive work on /var/lib/app/state.json...
|
|
15
|
+
} finally {
|
|
16
|
+
await handle.release();
|
|
17
|
+
}
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Why sidecar?
|
|
21
|
+
|
|
22
|
+
The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether to take it over.
|
|
23
|
+
|
|
24
|
+
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashes still need the reclaim path.
|
|
25
|
+
|
|
26
|
+
## API
|
|
27
|
+
|
|
28
|
+
```ts
|
|
29
|
+
function acquireFileLock<TPayload>(
|
|
30
|
+
targetPath: string,
|
|
31
|
+
options: FileLockAcquireOptions<TPayload>,
|
|
32
|
+
): Promise<FileLockHandle>;
|
|
33
|
+
|
|
34
|
+
function withFileLock<T, TPayload>(
|
|
35
|
+
targetPath: string,
|
|
36
|
+
options: FileLockAcquireOptions<TPayload>,
|
|
37
|
+
fn: () => Promise<T>,
|
|
38
|
+
): Promise<T>;
|
|
39
|
+
|
|
40
|
+
function createFileLockManager(key: string): FileLockManager;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`managerKey` is an optional identifier used to keep state isolated across multiple lock domains in the same process. Use distinct keys for distinct domains (`"snapshot"`, `"compact"`, `"build"`). If omitted, fs-safe derives one from the target path.
|
|
44
|
+
|
|
45
|
+
## Acquire options
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
49
|
+
managerKey?: string; // optional in-process manager namespace
|
|
50
|
+
lockPath?: string; // override; defaults to `${targetPath}.lock`
|
|
51
|
+
staleMs: number; // how long until a held lock is considered stale
|
|
52
|
+
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
53
|
+
retry?: FileLockRetryOptions;
|
|
54
|
+
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
|
55
|
+
payload: () => TPayload | Promise<TPayload>;
|
|
56
|
+
shouldReclaim?: (params: {
|
|
57
|
+
lockPath: string;
|
|
58
|
+
normalizedTargetPath: string;
|
|
59
|
+
payload: Record<string, unknown> | null;
|
|
60
|
+
staleMs: number;
|
|
61
|
+
nowMs: number;
|
|
62
|
+
heldByThisProcess: boolean;
|
|
63
|
+
}) => boolean | Promise<boolean>;
|
|
64
|
+
metadata?: Record<string, unknown>; // attached to heldEntries() output for diagnostics
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type FileLockRetryOptions = {
|
|
68
|
+
retries?: number; // number of retry attempts after the first failure
|
|
69
|
+
factor?: number; // exponential backoff factor (default 2)
|
|
70
|
+
minTimeout?: number; // initial delay (ms)
|
|
71
|
+
maxTimeout?: number; // delay cap (ms)
|
|
72
|
+
randomize?: boolean; // jitter
|
|
73
|
+
};
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
`payload` is a function so you can re-evaluate it on each retry (e.g. timestamp, PID).
|
|
77
|
+
|
|
78
|
+
## Release handle
|
|
79
|
+
|
|
80
|
+
```ts
|
|
81
|
+
type FileLockHandle = {
|
|
82
|
+
lockPath: string;
|
|
83
|
+
normalizedTargetPath: string;
|
|
84
|
+
release: () => Promise<void>;
|
|
85
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
86
|
+
};
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Always release in a `finally`:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const handle = await acquireFileLock(targetPath, {
|
|
93
|
+
staleMs: 60_000,
|
|
94
|
+
payload: () => ({ pid: process.pid }),
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
await doExclusiveWork();
|
|
98
|
+
} finally {
|
|
99
|
+
await handle.release();
|
|
100
|
+
}
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
If your process dies before `release()` runs and skips the exit handler, the next acquirer reclaims the lock once `staleMs` elapses (or your `shouldReclaim` returns true).
|
|
104
|
+
|
|
105
|
+
## `withFileLock` — common shape made one-liner
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
const result = await withFileLock(
|
|
109
|
+
"/var/lib/app/state.json",
|
|
110
|
+
{
|
|
111
|
+
managerKey: "compact",
|
|
112
|
+
staleMs: 30_000,
|
|
113
|
+
payload: () => ({ pid: process.pid, what: "compact" }),
|
|
114
|
+
},
|
|
115
|
+
async () => {
|
|
116
|
+
return await runCompaction();
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Acquires, runs `fn`, releases regardless of success/failure. Returns the result of `fn`.
|
|
122
|
+
|
|
123
|
+
## Long-lived managers
|
|
124
|
+
|
|
125
|
+
Most callers should use `acquireFileLock()` or `withFileLock()`. Use `createFileLockManager(key)` only when a long-lived service needs diagnostics or lifecycle control over locks it currently holds:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
const locks = createFileLockManager("session-writes");
|
|
129
|
+
const handle = await locks.acquire(sessionPath, {
|
|
130
|
+
staleMs: 60_000,
|
|
131
|
+
payload: () => ({ pid: process.pid }),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
for (const held of locks.heldEntries()) {
|
|
135
|
+
console.log(held.lockPath, held.acquiredAt);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await handle.release();
|
|
139
|
+
await locks.drain();
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Reclaim policy: `shouldReclaim`
|
|
143
|
+
|
|
144
|
+
The default policy reclaims locks whose `acquiredAt` is older than `staleMs`. Pass a custom callback when you want a richer notion of "is the holder still alive":
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { kill } from "node:process";
|
|
148
|
+
|
|
149
|
+
const handle = await acquireFileLock(targetPath, {
|
|
150
|
+
staleMs: 60_000,
|
|
151
|
+
payload: () => ({ pid: process.pid }),
|
|
152
|
+
shouldReclaim: ({ payload, nowMs, staleMs }) => {
|
|
153
|
+
if (!payload) return true;
|
|
154
|
+
const pid = Number(payload.pid);
|
|
155
|
+
if (!Number.isFinite(pid)) return true;
|
|
156
|
+
try {
|
|
157
|
+
kill(pid, 0);
|
|
158
|
+
return false; // process still alive — don't reclaim
|
|
159
|
+
} catch {
|
|
160
|
+
return true; // process gone — reclaim
|
|
161
|
+
}
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case).
|
|
167
|
+
|
|
168
|
+
## What sidecar locks defend against
|
|
169
|
+
|
|
170
|
+
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
|
171
|
+
- **A crashed holder leaving a stale lock.** `staleMs` plus optional `shouldReclaim` recovers it.
|
|
172
|
+
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
|
173
|
+
|
|
174
|
+
## What they do **not** defend against
|
|
175
|
+
|
|
176
|
+
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
|
177
|
+
- **Holders that never call `release` and have no liveness check.** Without a real `shouldReclaim`, the lock relies on `staleMs` alone — pick a deadline that is comfortably longer than your real work but short enough to recover from crashes.
|
|
178
|
+
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
|
179
|
+
|
|
180
|
+
## Common patterns
|
|
181
|
+
|
|
182
|
+
### Compact under lock
|
|
183
|
+
|
|
184
|
+
```ts
|
|
185
|
+
await withFileLock(
|
|
186
|
+
"/var/lib/app/db.sqlite",
|
|
187
|
+
{
|
|
188
|
+
staleMs: 30_000,
|
|
189
|
+
payload: () => ({ pid: process.pid, what: "compact" }),
|
|
190
|
+
},
|
|
191
|
+
async () => {
|
|
192
|
+
await runCompaction();
|
|
193
|
+
},
|
|
194
|
+
);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### Try once, give up if held
|
|
198
|
+
|
|
199
|
+
```ts
|
|
200
|
+
try {
|
|
201
|
+
await withFileLock(
|
|
202
|
+
targetPath,
|
|
203
|
+
{ staleMs: 30_000, retry: { retries: 0 }, payload: () => ({ pid: process.pid }) },
|
|
204
|
+
async () => await work(),
|
|
205
|
+
);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
console.log("another process is doing this; skipping");
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Wait politely with backoff
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
await withFileLock(
|
|
215
|
+
targetPath,
|
|
216
|
+
{
|
|
217
|
+
staleMs: 60_000,
|
|
218
|
+
timeoutMs: 30_000,
|
|
219
|
+
retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, factor: 1.7, randomize: true },
|
|
220
|
+
payload: () => ({ pid: process.pid }),
|
|
221
|
+
},
|
|
222
|
+
async () => await work(),
|
|
223
|
+
);
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
## See also
|
|
227
|
+
|
|
228
|
+
- [Atomic writes](atomic.md) — single-writer atomicity that often replaces the need for a lock entirely.
|
|
229
|
+
- `createAsyncLock` from `@openclaw/fs-safe/advanced` — in-process serialization for a single Node process.
|
package/docs/store.md
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Store
|
|
3
|
+
description: "Overview of @openclaw/fs-safe/store: fileStore, fileStoreSync, and jsonStore."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `@openclaw/fs-safe/store`
|
|
7
|
+
|
|
8
|
+
The `store` subpath bundles two managed wrappers around the same safe-write primitives `root()` uses:
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
fileStore,
|
|
13
|
+
fileStoreSync,
|
|
14
|
+
jsonStore,
|
|
15
|
+
type FileStore,
|
|
16
|
+
type FileStoreOptions,
|
|
17
|
+
type FileStoreSync,
|
|
18
|
+
type JsonStore,
|
|
19
|
+
type JsonStoreOptions,
|
|
20
|
+
} from "@openclaw/fs-safe/store";
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
| Helper | Use it for |
|
|
24
|
+
|---|---|
|
|
25
|
+
| [`fileStore()`](file-store.md) | Multi-file directories with safe relative paths, size limits, atomic replacement, stream writes, copy-in, and TTL-based pruning. |
|
|
26
|
+
| `fileStoreSync()` | Synchronous variant of `fileStore()` for places that genuinely cannot await. |
|
|
27
|
+
| [`jsonStore()`](json-store.md) | A single keyed JSON state file with explicit fallback, atomic writes, and optional sidecar locking around read-modify-write updates. |
|
|
28
|
+
| [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for credentials, tokens, and per-agent state at `0600` files under `0700` directories. |
|
|
29
|
+
|
|
30
|
+
`fileStore().json("rel.json")` and `jsonStore({ filePath })` are intentionally separate primitives. Use `fileStore().json(...)` when JSON state lives alongside other files in the same managed directory; use `jsonStore({ filePath })` when you have a single absolute path and want the keyed JSON shape directly.
|
|
31
|
+
|
|
32
|
+
## Picking a shape
|
|
33
|
+
|
|
34
|
+
- **Multi-file directory under one root** — reach for `fileStore()`. It exposes `write`, `writeJson`, `writeText`, `writeStream`, `read*`, `open`, `copyIn`, `remove`, and `pruneExpired` against safe relative paths.
|
|
35
|
+
- **One JSON state file** — reach for `jsonStore({ filePath })`. Its `update()` and `updateOr()` methods cover the merge-into-defaults and read-modify-write cases.
|
|
36
|
+
- **Credentials or tokens** — pass `private: true` to `fileStore()`. Same store shape; writes route through the secret-file atomic path with `0600`/`0700` permissions.
|
|
37
|
+
|
|
38
|
+
## Related pages
|
|
39
|
+
|
|
40
|
+
- [`fileStore`](file-store.md) — full API for the multi-file store.
|
|
41
|
+
- [`jsonStore`](json-store.md) — single-file JSON store with locking.
|
|
42
|
+
- [Private file-store mode](private-file-store.md) — credential-shaped variant.
|
|
43
|
+
- [JSON files](json.md) — lower-level `readJson` / `writeJson` helpers.
|
|
44
|
+
- [Atomic writes](atomic.md) — what `fileStore` and `jsonStore` use under the hood.
|
package/docs/temp.md
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
# Temp workspaces
|
|
2
|
+
|
|
3
|
+
`@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.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
tempWorkspace,
|
|
8
|
+
withTempWorkspace,
|
|
9
|
+
tempWorkspaceSync,
|
|
10
|
+
withTempWorkspaceSync,
|
|
11
|
+
resolveSecureTempRoot,
|
|
12
|
+
} from "@openclaw/fs-safe/temp";
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Private temp workspaces
|
|
16
|
+
|
|
17
|
+
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.
|
|
18
|
+
|
|
19
|
+
### `tempWorkspace`
|
|
20
|
+
|
|
21
|
+
The compact factory. Returns:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
type TempWorkspace = {
|
|
25
|
+
dir: string;
|
|
26
|
+
store: FileStore;
|
|
27
|
+
path(fileName: string): string;
|
|
28
|
+
write(fileName: string, data: string | Uint8Array): Promise<string>;
|
|
29
|
+
writeText(fileName: string, data: string): Promise<string>;
|
|
30
|
+
writeJson(fileName: string, data: unknown, options?: { trailingNewline?: boolean }): Promise<string>;
|
|
31
|
+
copyIn(fileName: string, sourcePath: string): Promise<string>;
|
|
32
|
+
read(fileName: string): Promise<Buffer>;
|
|
33
|
+
cleanup(): Promise<void>;
|
|
34
|
+
[Symbol.asyncDispose](): Promise<void>;
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import { tempWorkspace } from "@openclaw/fs-safe/temp";
|
|
40
|
+
|
|
41
|
+
await using workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" });
|
|
42
|
+
const inputPath = await workspace.write("input.txt", "data");
|
|
43
|
+
await runBuild(workspace.dir, inputPath);
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`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.
|
|
47
|
+
|
|
48
|
+
`store` is a `fileStore({ rootDir: workspace.dir, private: true })` handle. Use
|
|
49
|
+
it when you want the richer store surface, including `writeStream`, `exists`,
|
|
50
|
+
`remove`, `readJsonIfExists`, or `store.json<T>(rel)`:
|
|
51
|
+
|
|
52
|
+
```ts
|
|
53
|
+
await using workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" });
|
|
54
|
+
const state = workspace.store.json<State>("state.json");
|
|
55
|
+
await state.write({ ready: true });
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
The workspace owns cleanup; the store is only a view over the workspace
|
|
59
|
+
directory.
|
|
60
|
+
|
|
61
|
+
The sync variant `tempWorkspaceSync` exposes the same surface with sync return
|
|
62
|
+
types and a `FileStoreSync` at `workspace.store`.
|
|
63
|
+
|
|
64
|
+
### `withTempWorkspace`
|
|
65
|
+
|
|
66
|
+
The recommended shape. Auto-cleanup on every exit path:
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
70
|
+
|
|
71
|
+
const result = await withTempWorkspace({ rootDir: "/tmp/my-app", prefix: "build-" }, async (workspace) => {
|
|
72
|
+
await workspace.write("input.txt", "data");
|
|
73
|
+
return await runBuild(workspace.dir);
|
|
74
|
+
});
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The callback receives the same workspace shape as `tempWorkspace()`. Cleanup is wired to run after the callback resolves or rejects.
|
|
78
|
+
|
|
79
|
+
### Manual lifetime
|
|
80
|
+
|
|
81
|
+
Lower-level. You manage the lifetime:
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const workspace = await tempWorkspace({ rootDir: "/tmp/my-app", prefix: "scan-" });
|
|
85
|
+
try {
|
|
86
|
+
// …work in workspace.dir…
|
|
87
|
+
} finally {
|
|
88
|
+
await workspace.cleanup();
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Sync variants
|
|
93
|
+
|
|
94
|
+
`tempWorkspaceSync` and `withTempWorkspaceSync` are the synchronous siblings. Useful for setup code in tests or boot paths that have not entered async land yet.
|
|
95
|
+
|
|
96
|
+
### Options
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
type TempWorkspaceOptions = {
|
|
100
|
+
rootDir: string; // parent directory for workspaces
|
|
101
|
+
prefix: string; // dir prefix (sanitized)
|
|
102
|
+
dirMode?: number; // dir mode; default 0o700
|
|
103
|
+
mode?: number; // file write mode; default 0o600
|
|
104
|
+
};
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
## Advanced temp primitives
|
|
108
|
+
|
|
109
|
+
When you don't need the stable workspace abstraction, the lower-level temp-file
|
|
110
|
+
and sibling-temp helpers live behind `@openclaw/fs-safe/advanced`. They are
|
|
111
|
+
composition primitives for stores and atomic writers, not the primary API.
|
|
112
|
+
`tempWorkspace()` carries the stable lifetime contract for application code;
|
|
113
|
+
`tempFile()` is a one-shot building block whose options may move as store and
|
|
114
|
+
archive internals evolve.
|
|
115
|
+
|
|
116
|
+
### `tempFile`
|
|
117
|
+
|
|
118
|
+
```ts
|
|
119
|
+
import { tempFile } from "@openclaw/fs-safe/advanced";
|
|
120
|
+
|
|
121
|
+
const target = await tempFile({ fileName: "report.pdf", prefix: "render-" });
|
|
122
|
+
try {
|
|
123
|
+
await render(target.path);
|
|
124
|
+
await fs.copyFile(target.path, "/srv/workspace/reports/today.pdf");
|
|
125
|
+
} finally {
|
|
126
|
+
await target.cleanup();
|
|
127
|
+
}
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
type TempFile = {
|
|
134
|
+
path: string; // absolute path; safe to write to
|
|
135
|
+
dir: string; // the enclosing private workspace dir
|
|
136
|
+
file(fileName?: string): string; // resolve another file in the same dir
|
|
137
|
+
cleanup(): Promise<void>; // removes the private workspace dir
|
|
138
|
+
[Symbol.asyncDispose](): Promise<void>; // alias of cleanup()
|
|
139
|
+
};
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### `withTempFile`
|
|
143
|
+
|
|
144
|
+
Same shape with auto-cleanup:
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { withTempFile } from "@openclaw/fs-safe/advanced";
|
|
148
|
+
|
|
149
|
+
await withTempFile({ fileName: "out.zip", prefix: "pack-" }, async (filePath) => {
|
|
150
|
+
await pack(filePath);
|
|
151
|
+
await uploadAndForget(filePath);
|
|
152
|
+
});
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Sibling temp writes
|
|
156
|
+
|
|
157
|
+
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.
|
|
158
|
+
|
|
159
|
+
### `writeSiblingTempFile`
|
|
160
|
+
|
|
161
|
+
```ts
|
|
162
|
+
import { writeSiblingTempFile } from "@openclaw/fs-safe/advanced";
|
|
163
|
+
|
|
164
|
+
const result = await writeSiblingTempFile<string>({
|
|
165
|
+
dir: "/srv/workspace",
|
|
166
|
+
mode: 0o600,
|
|
167
|
+
writeTemp: async (tempPath) => {
|
|
168
|
+
await fs.writeFile(tempPath, JSON.stringify(state));
|
|
169
|
+
return "state.json";
|
|
170
|
+
},
|
|
171
|
+
resolveFinalPath: (fileName) => path.join("/srv/workspace", fileName),
|
|
172
|
+
});
|
|
173
|
+
// result.filePath, result.result (returned by writeTemp)
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`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.
|
|
177
|
+
|
|
178
|
+
### `writeViaSiblingTempPath`
|
|
179
|
+
|
|
180
|
+
A higher-level convenience — write content + rename in one call:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced";
|
|
184
|
+
|
|
185
|
+
await writeViaSiblingTempPath({
|
|
186
|
+
rootDir: "/srv/workspace",
|
|
187
|
+
targetPath: "/srv/workspace/state.json",
|
|
188
|
+
writeTemp: async (tempPath) => {
|
|
189
|
+
await fs.writeFile(tempPath, JSON.stringify(state));
|
|
190
|
+
},
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
If `replaceFileAtomic` does what you need, prefer that — `writeViaSiblingTempPath` is the lower-level building block.
|
|
195
|
+
|
|
196
|
+
## Secure temp root
|
|
197
|
+
|
|
198
|
+
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.
|
|
199
|
+
|
|
200
|
+
```ts
|
|
201
|
+
import { resolveSecureTempRoot } from "@openclaw/fs-safe/temp";
|
|
202
|
+
|
|
203
|
+
const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
|
|
204
|
+
// e.g. /tmp/my-app-501
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
### Options
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
type ResolveSecureTempRootOptions = {
|
|
211
|
+
fallbackPrefix: string; // base name for the per-user fallback dir
|
|
212
|
+
preferredDir?: string; // optional preferred secure temp root
|
|
213
|
+
tmpdir?: () => string; // override os.tmpdir()
|
|
214
|
+
};
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
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.
|
|
218
|
+
|
|
219
|
+
## Common patterns
|
|
220
|
+
|
|
221
|
+
### Build something, atomically place it
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
|
|
225
|
+
|
|
226
|
+
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "build-" }, async (ws) => {
|
|
227
|
+
await runCompiler({ outDir: ws.dir });
|
|
228
|
+
await replaceDirectoryAtomic({
|
|
229
|
+
stagedDir: ws.dir,
|
|
230
|
+
targetDir: "/srv/site/public",
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Stream a download to a sibling temp, then commit
|
|
236
|
+
|
|
237
|
+
```ts
|
|
238
|
+
import { writeSiblingTempFile } from "@openclaw/fs-safe/advanced";
|
|
239
|
+
import fs from "node:fs/promises";
|
|
240
|
+
|
|
241
|
+
const r = await writeSiblingTempFile({
|
|
242
|
+
dir: "/srv/cache",
|
|
243
|
+
writeTemp: async (tempPath) => {
|
|
244
|
+
const handle = await fs.open(tempPath, "w");
|
|
245
|
+
try {
|
|
246
|
+
await pipeline(downloadStream, handle.createWriteStream());
|
|
247
|
+
} finally {
|
|
248
|
+
await handle.close();
|
|
249
|
+
}
|
|
250
|
+
return "blob.bin";
|
|
251
|
+
},
|
|
252
|
+
resolveFinalPath: (fileName) => path.join("/srv/cache", fileName),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
console.log(`downloaded ${r.filePath}`);
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Per-call private scratch in a test
|
|
259
|
+
|
|
260
|
+
```ts
|
|
261
|
+
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
262
|
+
|
|
263
|
+
it("processes a fixture", async () => {
|
|
264
|
+
await withTempWorkspace({ rootDir: "/tmp/my-tests", prefix: "test-" }, async (ws) => {
|
|
265
|
+
await fs.writeFile(path.join(ws.dir, "input.txt"), fixture);
|
|
266
|
+
const out = await processFile(path.join(ws.dir, "input.txt"));
|
|
267
|
+
expect(out).toEqual(expected);
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## See also
|
|
273
|
+
|
|
274
|
+
- [Atomic writes](atomic.md) — `replaceDirectoryAtomic` for whole-directory swaps.
|
|
275
|
+
- [`root()`](root.md) — `fs.copyIn(rel, sourceAbs)` for moving files from a temp into a `Root`.
|
|
276
|
+
- [File lock](sidecar-lock.md) — when many processes share a temp tree.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Test hooks
|
|
3
|
+
description: "Internal-only injection hooks used by the fs-safe test suite. Active only when NODE_ENV=test or VITEST=true."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# `@openclaw/fs-safe/test-hooks`
|
|
7
|
+
|
|
8
|
+
Internal injection points the `fs-safe` test suite uses to deterministically reproduce open/lstat races. They are exposed as a public subpath so downstream test suites can reuse the same harness, but they are **not** part of the supported runtime API.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import {
|
|
12
|
+
getFsSafeTestHooks,
|
|
13
|
+
__setFsSafeTestHooksForTest,
|
|
14
|
+
type FsSafeTestHooks,
|
|
15
|
+
} from "@openclaw/fs-safe/test-hooks";
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## When the hooks are active
|
|
19
|
+
|
|
20
|
+
Hooks are only honored when one of the following is true:
|
|
21
|
+
|
|
22
|
+
- `process.env.NODE_ENV === "test"`
|
|
23
|
+
- `process.env.VITEST === "true"`
|
|
24
|
+
|
|
25
|
+
Calling `__setFsSafeTestHooksForTest(hooks)` outside of those environments throws. `getFsSafeTestHooks()` returns `undefined` when no hooks are registered, regardless of the environment.
|
|
26
|
+
|
|
27
|
+
## Shape
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
type FsSafeTestHooks = {
|
|
31
|
+
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
32
|
+
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
33
|
+
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
| Hook | Fires when |
|
|
38
|
+
|---|---|
|
|
39
|
+
| `afterPreOpenLstat` | A pre-open `lstat` has just resolved. Use this to swap a path between validation and open. |
|
|
40
|
+
| `beforeOpen` | The library is about to call `open(path, flags)`. Use this to inject a TOCTOU window. |
|
|
41
|
+
| `afterOpen` | An open just succeeded. Use this to mutate state before the post-open identity check runs. |
|
|
42
|
+
|
|
43
|
+
Each hook may be sync or async; async hooks are awaited.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { afterEach, beforeEach } from "vitest";
|
|
49
|
+
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
__setFsSafeTestHooksForTest({
|
|
53
|
+
beforeOpen: async (filePath) => {
|
|
54
|
+
// swap a victim file with a symlink right before fs-safe opens it
|
|
55
|
+
await replaceWithSymlink(filePath);
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
afterEach(() => {
|
|
61
|
+
__setFsSafeTestHooksForTest(undefined);
|
|
62
|
+
});
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Always clear the hooks in `afterEach` so a stuck hook does not leak across tests.
|
|
66
|
+
|
|
67
|
+
## Stability
|
|
68
|
+
|
|
69
|
+
The shape can grow new optional fields between minor versions. Treat the surface as test-only and do not rely on it from production code.
|
|
70
|
+
|
|
71
|
+
## Related pages
|
|
72
|
+
|
|
73
|
+
- [Testing](testing.md) — broader notes on testing against `fs-safe`.
|
|
74
|
+
- [Security model](security-model.md) — the races these hooks help reproduce.
|