@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README.md +23 -3
  3. package/dist/archive-staging.d.ts.map +1 -1
  4. package/dist/archive-staging.js +51 -7
  5. package/dist/archive.js +2 -2
  6. package/dist/file-store-boundary.d.ts +27 -0
  7. package/dist/file-store-boundary.d.ts.map +1 -0
  8. package/dist/file-store-boundary.js +164 -0
  9. package/dist/file-store.d.ts.map +1 -1
  10. package/dist/file-store.js +62 -72
  11. package/dist/path.d.ts +1 -0
  12. package/dist/path.d.ts.map +1 -1
  13. package/dist/path.js +6 -3
  14. package/dist/private-file-store.d.ts +5 -7
  15. package/dist/private-file-store.d.ts.map +1 -1
  16. package/dist/private-file-store.js +21 -34
  17. package/dist/root-context.d.ts +23 -0
  18. package/dist/root-context.d.ts.map +1 -0
  19. package/dist/root-context.js +62 -0
  20. package/dist/root-errors.d.ts +4 -0
  21. package/dist/root-errors.d.ts.map +1 -0
  22. package/dist/root-errors.js +21 -0
  23. package/dist/root-impl.d.ts +108 -0
  24. package/dist/root-impl.d.ts.map +1 -0
  25. package/dist/{safe-root.js → root-impl.js} +464 -251
  26. package/dist/root.d.ts +1 -107
  27. package/dist/root.d.ts.map +1 -1
  28. package/dist/root.js +1 -1332
  29. package/docs/advanced.md +100 -0
  30. package/docs/archive.md +199 -0
  31. package/docs/atomic.md +163 -0
  32. package/docs/config.md +65 -0
  33. package/docs/contributing.md +80 -0
  34. package/docs/errors.md +154 -0
  35. package/docs/file-store.md +213 -0
  36. package/docs/filename.md +0 -0
  37. package/docs/index.md +76 -0
  38. package/docs/install-path.md +167 -0
  39. package/docs/install.md +150 -0
  40. package/docs/json-store.md +200 -0
  41. package/docs/json.md +158 -0
  42. package/docs/local-roots.md +164 -0
  43. package/docs/path-scope.md +130 -0
  44. package/docs/path.md +191 -0
  45. package/docs/permissions.md +95 -0
  46. package/docs/private-file-store.md +45 -0
  47. package/docs/python-helper.md +103 -0
  48. package/docs/quickstart.md +147 -0
  49. package/docs/reading.md +174 -0
  50. package/docs/regular-file.md +175 -0
  51. package/docs/root.md +205 -0
  52. package/docs/secret-file.md +154 -0
  53. package/docs/secure-file.md +82 -0
  54. package/docs/security-model.md +99 -0
  55. package/docs/sidecar-lock.md +229 -0
  56. package/docs/store.md +44 -0
  57. package/docs/temp.md +276 -0
  58. package/docs/test-hooks.md +74 -0
  59. package/docs/testing.md +168 -0
  60. package/docs/timing.md +121 -0
  61. package/docs/types.md +155 -0
  62. package/docs/walk.md +63 -0
  63. package/docs/writing.md +222 -0
  64. package/package.json +5 -2
  65. package/dist/archive-utils.d.ts +0 -3
  66. package/dist/archive-utils.d.ts.map +0 -1
  67. package/dist/archive-utils.js +0 -26
  68. package/dist/boundary-file-read.d.ts +0 -44
  69. package/dist/boundary-file-read.d.ts.map +0 -1
  70. package/dist/boundary-file-read.js +0 -129
  71. package/dist/boundary-path.d.ts +0 -39
  72. package/dist/boundary-path.d.ts.map +0 -1
  73. package/dist/boundary-path.js +0 -598
  74. package/dist/fs-pinned-path-helper.d.ts +0 -7
  75. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  76. package/dist/fs-pinned-path-helper.js +0 -182
  77. package/dist/fs-pinned-write-helper.d.ts +0 -21
  78. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  79. package/dist/fs-pinned-write-helper.js +0 -263
  80. package/dist/hardlink-guards.d.ts +0 -7
  81. package/dist/hardlink-guards.d.ts.map +0 -1
  82. package/dist/hardlink-guards.js +0 -30
  83. package/dist/install-safe-path.d.ts +0 -20
  84. package/dist/install-safe-path.d.ts.map +0 -1
  85. package/dist/install-safe-path.js +0 -94
  86. package/dist/json-file.d.ts +0 -3
  87. package/dist/json-file.d.ts.map +0 -1
  88. package/dist/json-file.js +0 -123
  89. package/dist/json-files.d.ts +0 -20
  90. package/dist/json-files.d.ts.map +0 -1
  91. package/dist/json-files.js +0 -153
  92. package/dist/path-alias-guards.d.ts +0 -19
  93. package/dist/path-alias-guards.d.ts.map +0 -1
  94. package/dist/path-alias-guards.js +0 -21
  95. package/dist/path-guards.d.ts +0 -7
  96. package/dist/path-guards.d.ts.map +0 -1
  97. package/dist/path-guards.js +0 -49
  98. package/dist/path-safety.d.ts +0 -12
  99. package/dist/path-safety.d.ts.map +0 -1
  100. package/dist/path-safety.js +0 -50
  101. package/dist/safe-open-sync.d.ts +0 -24
  102. package/dist/safe-open-sync.d.ts.map +0 -1
  103. package/dist/safe-open-sync.js +0 -71
  104. package/dist/safe-root.d.ts +0 -123
  105. package/dist/safe-root.d.ts.map +0 -1
  106. package/dist/secure-temp-workspace.d.ts +0 -25
  107. package/dist/secure-temp-workspace.d.ts.map +0 -1
  108. package/dist/secure-temp-workspace.js +0 -136
  109. package/dist/sibling-temp-file.d.ts +0 -16
  110. package/dist/sibling-temp-file.d.ts.map +0 -1
  111. package/dist/sibling-temp-file.js +0 -73
  112. package/dist/sibling-temp-write.d.ts +0 -8
  113. package/dist/sibling-temp-write.d.ts.map +0 -1
  114. 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.