@openclaw/fs-safe 0.1.1 → 0.2.0
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 +58 -0
- package/README.md +58 -2
- package/dist/absolute-path.d.ts +14 -0
- package/dist/absolute-path.d.ts.map +1 -1
- package/dist/absolute-path.js +203 -2
- package/dist/advanced.d.ts +2 -1
- package/dist/advanced.d.ts.map +1 -1
- package/dist/advanced.js +2 -1
- package/dist/archive-staging.d.ts.map +1 -1
- package/dist/archive-staging.js +81 -7
- package/dist/archive.d.ts.map +1 -1
- package/dist/archive.js +25 -42
- package/dist/bounded-read-stream.d.ts +8 -0
- package/dist/bounded-read-stream.d.ts.map +1 -0
- package/dist/bounded-read-stream.js +20 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/directory-guard.d.ts +18 -0
- package/dist/directory-guard.d.ts.map +1 -0
- package/dist/directory-guard.js +70 -0
- package/dist/file-lock.d.ts +4 -2
- package/dist/file-lock.d.ts.map +1 -1
- package/dist/file-lock.js +13 -2
- package/dist/file-store-boundary.d.ts +25 -0
- package/dist/file-store-boundary.d.ts.map +1 -0
- package/dist/file-store-boundary.js +168 -0
- package/dist/file-store-prune.d.ts +12 -0
- package/dist/file-store-prune.d.ts.map +1 -0
- package/dist/file-store-prune.js +86 -0
- package/dist/file-store-source.d.ts +5 -0
- package/dist/file-store-source.d.ts.map +1 -0
- package/dist/file-store-source.js +30 -0
- package/dist/file-store.d.ts +2 -6
- package/dist/file-store.d.ts.map +1 -1
- package/dist/file-store.js +122 -125
- package/dist/guarded-mkdir.d.ts +6 -0
- package/dist/guarded-mkdir.d.ts.map +1 -0
- package/dist/guarded-mkdir.js +45 -0
- package/dist/guarded-mutation.d.ts +33 -0
- package/dist/guarded-mutation.d.ts.map +1 -0
- package/dist/guarded-mutation.js +76 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/json-document-store.d.ts +2 -0
- package/dist/json-document-store.d.ts.map +1 -1
- package/dist/json-document-store.js +7 -3
- package/dist/json-durable-queue.d.ts +45 -0
- package/dist/json-durable-queue.d.ts.map +1 -0
- package/dist/json-durable-queue.js +200 -0
- package/dist/json.d.ts +35 -5
- package/dist/json.d.ts.map +1 -1
- package/dist/json.js +69 -2
- package/dist/local-roots.d.ts.map +1 -1
- package/dist/local-roots.js +17 -3
- package/dist/lock-config.d.ts +10 -0
- package/dist/lock-config.d.ts.map +1 -0
- package/dist/lock-config.js +12 -0
- package/dist/move-path.d.ts +1 -0
- package/dist/move-path.d.ts.map +1 -1
- package/dist/move-path.js +195 -7
- package/dist/output.d.ts +13 -0
- package/dist/output.d.ts.map +1 -0
- package/dist/output.js +69 -0
- package/dist/path-stat.d.ts +4 -0
- package/dist/path-stat.d.ts.map +1 -0
- package/dist/path-stat.js +15 -0
- package/dist/path.d.ts +1 -0
- package/dist/path.d.ts.map +1 -1
- package/dist/path.js +6 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- 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/private-temp-workspace.d.ts.map +1 -1
- package/dist/private-temp-workspace.js +28 -15
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +52 -8
- package/dist/replace-directory.d.ts.map +1 -1
- package/dist/replace-directory.js +5 -4
- package/dist/replace-file.d.ts +1 -1
- package/dist/replace-file.d.ts.map +1 -1
- package/dist/replace-file.js +93 -31
- 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} +484 -265
- package/dist/root.d.ts +1 -107
- package/dist/root.d.ts.map +1 -1
- package/dist/root.js +1 -1332
- package/dist/safe-path-segment.d.ts +9 -0
- package/dist/safe-path-segment.d.ts.map +1 -0
- package/dist/safe-path-segment.js +51 -0
- package/dist/secret-file.d.ts.map +1 -1
- package/dist/secret-file.js +6 -1
- package/dist/sibling-temp.d.ts +1 -0
- package/dist/sibling-temp.d.ts.map +1 -1
- package/dist/sibling-temp.js +32 -7
- package/dist/sidecar-lock.d.ts +2 -0
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +93 -13
- package/dist/store.d.ts +1 -0
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +1 -0
- package/dist/temp-target.d.ts.map +1 -1
- package/dist/temp-target.js +7 -4
- package/dist/test-hooks.d.ts +6 -0
- package/dist/test-hooks.d.ts.map +1 -1
- package/dist/text-atomic.d.ts +7 -0
- package/dist/text-atomic.d.ts.map +1 -1
- package/dist/text-atomic.js +3 -2
- package/dist/trash.d.ts.map +1 -1
- package/dist/trash.js +48 -13
- package/docs/advanced.md +110 -0
- package/docs/archive.md +199 -0
- package/docs/atomic.md +191 -0
- package/docs/config.md +94 -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 +77 -0
- package/docs/install-path.md +167 -0
- package/docs/install.md +150 -0
- package/docs/json-store.md +204 -0
- package/docs/json.md +192 -0
- package/docs/local-roots.md +164 -0
- package/docs/output.md +92 -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 +230 -0
- package/docs/store.md +77 -0
- package/docs/temp.md +284 -0
- package/docs/test-hooks.md +74 -0
- package/docs/testing.md +178 -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 +10 -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
package/docs/temp.md
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
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
|
+
By default it preserves the historical private-helper behavior of chmodding
|
|
179
|
+
`dir` to `dirMode` (default `0o700`). Pass `chmodDir: false` when the directory
|
|
180
|
+
is a public staging/output path whose existing mode must be preserved.
|
|
181
|
+
|
|
182
|
+
### `writeViaSiblingTempPath`
|
|
183
|
+
|
|
184
|
+
A higher-level convenience for callback-based producers. The callback writes to
|
|
185
|
+
a private temp path, then the helper copies the result into `targetPath` through
|
|
186
|
+
the root boundary:
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced";
|
|
190
|
+
|
|
191
|
+
await writeViaSiblingTempPath({
|
|
192
|
+
rootDir: "/srv/workspace",
|
|
193
|
+
targetPath: "/srv/workspace/state.json",
|
|
194
|
+
writeTemp: async (tempPath) => {
|
|
195
|
+
await fs.writeFile(tempPath, JSON.stringify(state));
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
If `replaceFileAtomic` does what you need, prefer that. Use
|
|
201
|
+
`writeViaSiblingTempPath` when the producer needs a concrete temp pathname but
|
|
202
|
+
the final destination still needs root-boundary checks.
|
|
203
|
+
|
|
204
|
+
## Secure temp root
|
|
205
|
+
|
|
206
|
+
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.
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
import { resolveSecureTempRoot } from "@openclaw/fs-safe/temp";
|
|
210
|
+
|
|
211
|
+
const tempRoot = resolveSecureTempRoot({ fallbackPrefix: "my-app" });
|
|
212
|
+
// e.g. /tmp/my-app-501
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
### Options
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
type ResolveSecureTempRootOptions = {
|
|
219
|
+
fallbackPrefix: string; // base name for the per-user fallback dir
|
|
220
|
+
preferredDir?: string; // optional preferred secure temp root
|
|
221
|
+
tmpdir?: () => string; // override os.tmpdir()
|
|
222
|
+
};
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
## Common patterns
|
|
228
|
+
|
|
229
|
+
### Build something, atomically place it
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
import { replaceDirectoryAtomic } from "@openclaw/fs-safe/atomic";
|
|
233
|
+
|
|
234
|
+
await withTempWorkspace({ rootDir: "/srv/site/tmp", prefix: "build-" }, async (ws) => {
|
|
235
|
+
await runCompiler({ outDir: ws.dir });
|
|
236
|
+
await replaceDirectoryAtomic({
|
|
237
|
+
stagedDir: ws.dir,
|
|
238
|
+
targetDir: "/srv/site/public",
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
### Stream a download to a sibling temp, then commit
|
|
244
|
+
|
|
245
|
+
```ts
|
|
246
|
+
import { writeSiblingTempFile } from "@openclaw/fs-safe/advanced";
|
|
247
|
+
import fs from "node:fs/promises";
|
|
248
|
+
|
|
249
|
+
const r = await writeSiblingTempFile({
|
|
250
|
+
dir: "/srv/cache",
|
|
251
|
+
writeTemp: async (tempPath) => {
|
|
252
|
+
const handle = await fs.open(tempPath, "w");
|
|
253
|
+
try {
|
|
254
|
+
await pipeline(downloadStream, handle.createWriteStream());
|
|
255
|
+
} finally {
|
|
256
|
+
await handle.close();
|
|
257
|
+
}
|
|
258
|
+
return "blob.bin";
|
|
259
|
+
},
|
|
260
|
+
resolveFinalPath: (fileName) => path.join("/srv/cache", fileName),
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
console.log(`downloaded ${r.filePath}`);
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
### Per-call private scratch in a test
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
import { withTempWorkspace } from "@openclaw/fs-safe/temp";
|
|
270
|
+
|
|
271
|
+
it("processes a fixture", async () => {
|
|
272
|
+
await withTempWorkspace({ rootDir: "/tmp/my-tests", prefix: "test-" }, async (ws) => {
|
|
273
|
+
await fs.writeFile(path.join(ws.dir, "input.txt"), fixture);
|
|
274
|
+
const out = await processFile(path.join(ws.dir, "input.txt"));
|
|
275
|
+
expect(out).toEqual(expected);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
## See also
|
|
281
|
+
|
|
282
|
+
- [Atomic writes](atomic.md) — `replaceDirectoryAtomic` for whole-directory swaps.
|
|
283
|
+
- [`root()`](root.md) — `fs.copyIn(rel, sourceAbs)` for moving files from a temp into a `Root`.
|
|
284
|
+
- [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.
|
package/docs/testing.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Testing
|
|
2
|
+
|
|
3
|
+
`@openclaw/fs-safe/test-hooks` exposes a small set of test-only injection points. They are inert in production: the hooks only activate when `process.env.NODE_ENV === "test"`. Outside test mode, calls to set hooks are no-ops, so leaking a test setup line into production is safe but ineffective.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import {
|
|
7
|
+
__setFsSafeTestHooksForTest,
|
|
8
|
+
type FsSafeTestHooks,
|
|
9
|
+
} from "@openclaw/fs-safe/test-hooks";
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The double-underscore prefix is a deliberate "hands off" signal: production code should never import this module. ESLint or your equivalent linter should flag it.
|
|
13
|
+
|
|
14
|
+
## When to reach for hooks
|
|
15
|
+
|
|
16
|
+
- Reproduce a TOCTOU race deterministically: simulate a symlink swap between resolve and open, or between write and rename.
|
|
17
|
+
- Force Node-only behavior without uninstalling Python from your runners.
|
|
18
|
+
- Inject latency to test cancellation/timeout paths.
|
|
19
|
+
|
|
20
|
+
If you don't need to inject a race, you don't need hooks — most tests should drive the library through normal calls and assert on observable behavior.
|
|
21
|
+
|
|
22
|
+
## Hooks API
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
type FsSafeTestHooks = {
|
|
26
|
+
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
27
|
+
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
28
|
+
afterOpen?: (filePath: string, handle: import("node:fs/promises").FileHandle) => Promise<void> | void;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
|
|
32
|
+
function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Hooks are called at well-defined points in the library's hot paths:
|
|
36
|
+
|
|
37
|
+
- **`afterPreOpenLstat`** — runs after the pre-open `lstat`. A common use is to swap the path's target via `fs.symlink`/`fs.unlink` to drive a TOCTOU race.
|
|
38
|
+
- **`beforeOpen`** — runs before `fs.open` with the exact flags the root read path will use.
|
|
39
|
+
- **`afterOpen`** — runs after the file handle is opened. Useful to wrap handle methods or inject a size race before a stream is consumed.
|
|
40
|
+
|
|
41
|
+
`__setFsSafeTestHooksForTest(undefined)` clears all hooks. Always clean up between tests.
|
|
42
|
+
|
|
43
|
+
## Example: simulate a TOCTOU swap
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
import { describe, it, beforeEach, afterEach, expect } from "vitest";
|
|
47
|
+
import { mkdtemp, mkdir, rm, symlink, unlink, writeFile } from "node:fs/promises";
|
|
48
|
+
import os from "node:os";
|
|
49
|
+
import path from "node:path";
|
|
50
|
+
import { root, FsSafeError } from "@openclaw/fs-safe";
|
|
51
|
+
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
|
|
52
|
+
|
|
53
|
+
let dir: string;
|
|
54
|
+
beforeEach(async () => {
|
|
55
|
+
dir = await mkdtemp(path.join(os.tmpdir(), "fs-safe-toctou-"));
|
|
56
|
+
await writeFile(path.join(dir, "real.txt"), "secret");
|
|
57
|
+
await writeFile(path.join(dir, "decoy.txt"), "decoy");
|
|
58
|
+
});
|
|
59
|
+
afterEach(async () => {
|
|
60
|
+
__setFsSafeTestHooksForTest(undefined);
|
|
61
|
+
await rm(dir, { recursive: true, force: true });
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("rejects a swap between resolve and open", async () => {
|
|
65
|
+
const fs = await root(dir, { symlinks: "reject" });
|
|
66
|
+
|
|
67
|
+
__setFsSafeTestHooksForTest({
|
|
68
|
+
afterPreOpenLstat: async (absPath) => {
|
|
69
|
+
// swap real.txt for a symlink to decoy.txt right before the open
|
|
70
|
+
await unlink(absPath);
|
|
71
|
+
await symlink(path.join(dir, "decoy.txt"), absPath);
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
await expect(fs.read("real.txt")).rejects.toMatchObject({
|
|
76
|
+
name: "FsSafeError",
|
|
77
|
+
code: expect.stringMatching(/symlink|path-mismatch/),
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
The `code` may be `symlink` (caught at open by `O_NOFOLLOW`) or `path-mismatch` (caught by the post-open identity check) depending on platform — both are correct refusals.
|
|
83
|
+
|
|
84
|
+
## Example: force Node-only fallback behavior
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
import { configureFsSafePython } from "@openclaw/fs-safe/config";
|
|
88
|
+
|
|
89
|
+
beforeEach(() => {
|
|
90
|
+
configureFsSafePython({ mode: "off" });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
afterEach(() => {
|
|
94
|
+
configureFsSafePython({ mode: "auto", pythonPath: undefined });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("runs without the Python helper", async () => {
|
|
98
|
+
const fs = await root(dir);
|
|
99
|
+
await fs.write("file.txt", "ok");
|
|
100
|
+
await expect(fs.readText("file.txt")).resolves.toBe("ok");
|
|
101
|
+
});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
## Cleanup is mandatory
|
|
105
|
+
|
|
106
|
+
Hooks set by `__setFsSafeTestHooksForTest` persist across tests until explicitly cleared. Always clear in `afterEach` (or your test framework's equivalent) — leaked hooks will silently change behavior in unrelated tests and cause maddening intermittent failures.
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
import { afterEach } from "vitest";
|
|
110
|
+
import { __setFsSafeTestHooksForTest } from "@openclaw/fs-safe/test-hooks";
|
|
111
|
+
|
|
112
|
+
afterEach(() => {
|
|
113
|
+
__setFsSafeTestHooksForTest(undefined);
|
|
114
|
+
});
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
A global hook clear in your test setup file is a good safety net.
|
|
118
|
+
|
|
119
|
+
## Patterns for testing fs-safe-using code
|
|
120
|
+
|
|
121
|
+
You usually don't need hooks. Most tests follow this shape:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
125
|
+
import os from "node:os";
|
|
126
|
+
import path from "node:path";
|
|
127
|
+
import { root } from "@openclaw/fs-safe";
|
|
128
|
+
|
|
129
|
+
let dir: string;
|
|
130
|
+
let fs: Awaited<ReturnType<typeof root>>;
|
|
131
|
+
|
|
132
|
+
beforeEach(async () => {
|
|
133
|
+
dir = await mkdtemp(path.join(os.tmpdir(), "my-feature-"));
|
|
134
|
+
fs = await root(dir, { symlinks: "reject", hardlinks: "reject", mkdir: true });
|
|
135
|
+
});
|
|
136
|
+
afterEach(async () => {
|
|
137
|
+
await rm(dir, { recursive: true, force: true });
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("writes and reads through the boundary", async () => {
|
|
141
|
+
await fs.write("notes/today.txt", "hello");
|
|
142
|
+
expect(await fs.readText("notes/today.txt")).toBe("hello");
|
|
143
|
+
});
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
For tests that need a private temp workspace, [`withTempWorkspace`](temp.md) makes the setup-and-teardown story trivial.
|
|
147
|
+
|
|
148
|
+
## Repo test shards
|
|
149
|
+
|
|
150
|
+
Run the full local gate before handoff:
|
|
151
|
+
|
|
152
|
+
```sh
|
|
153
|
+
pnpm check
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Run only the security boundary corpus while iterating on root/path/archive/temp hardening:
|
|
157
|
+
|
|
158
|
+
```sh
|
|
159
|
+
pnpm test:security
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Run the static primitive guard after changing low-level filesystem helpers:
|
|
163
|
+
|
|
164
|
+
```sh
|
|
165
|
+
pnpm lint:fs-boundary
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
It catches the specific raw fallback patterns that previously led to
|
|
169
|
+
check-then-use bugs, such as direct copy-to-destination fallback and sync temp
|
|
170
|
+
workspace reads that bypass pinned file descriptors.
|
|
171
|
+
|
|
172
|
+
`pnpm check` also runs `pnpm lint:file-size`. New source and test files should stay under 500 lines. Existing larger files have explicit budgets in `scripts/check-file-size.mjs`; do not increase those budgets as part of unrelated work.
|
|
173
|
+
|
|
174
|
+
## See also
|
|
175
|
+
|
|
176
|
+
- [Security model](security-model.md) — what the boundary is supposed to defend; design tests around the same threats.
|
|
177
|
+
- [`root()`](root.md) — the surface most tests will exercise.
|
|
178
|
+
- [Temp workspaces](temp.md) — `withTempWorkspace` for cleanup-on-exit test directories.
|
package/docs/timing.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# Timing
|
|
2
|
+
|
|
3
|
+
`withTimeout(promise, timeoutMs, labelOrOptions?)` is a small helper for putting a wall-clock ceiling on an async operation. It rejects with a synthetic timeout error after `timeoutMs` and clears its internal timer when the wrapped promise settles first.
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { withTimeout } from "@openclaw/fs-safe/advanced";
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
## Signature
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
function withTimeout<T>(
|
|
13
|
+
promise: Promise<T>,
|
|
14
|
+
timeoutMs: number,
|
|
15
|
+
labelOrOptions?: string | {
|
|
16
|
+
label?: string;
|
|
17
|
+
message?: string;
|
|
18
|
+
createError?: () => Error;
|
|
19
|
+
},
|
|
20
|
+
): Promise<T>;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
If `timeoutMs` is `0`, negative, `Infinity`, or `NaN`, the helper is a no-op and simply awaits the original promise.
|
|
24
|
+
|
|
25
|
+
## Examples
|
|
26
|
+
|
|
27
|
+
### Simple ceiling
|
|
28
|
+
|
|
29
|
+
```ts
|
|
30
|
+
import { withTimeout } from "@openclaw/fs-safe/advanced";
|
|
31
|
+
|
|
32
|
+
const buf = await withTimeout(
|
|
33
|
+
fs.readFile("/srv/big.bin"),
|
|
34
|
+
5_000,
|
|
35
|
+
"read big.bin",
|
|
36
|
+
);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
If the read doesn't resolve within 5 seconds, the returned promise rejects with `Error: read big.bin timed out after 5000ms`. The underlying `fs.readFile` continues until Node finishes it — `withTimeout` does not cancel the wrapped work, only the wait.
|
|
40
|
+
|
|
41
|
+
### Custom message
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
await withTimeout(work(), 5_000, {
|
|
45
|
+
message: "build did not finish in time",
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Custom error factory
|
|
50
|
+
|
|
51
|
+
```ts
|
|
52
|
+
class BuildTimeout extends Error {}
|
|
53
|
+
|
|
54
|
+
await withTimeout(work(), 5_000, {
|
|
55
|
+
createError: () => new BuildTimeout("build timeout (5s)"),
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`createError` is called when the timer fires; the returned error is what the promise rejects with. Use this when callers branch on `instanceof` or want a custom `cause`.
|
|
60
|
+
|
|
61
|
+
## Cancellation
|
|
62
|
+
|
|
63
|
+
`withTimeout` does **not** abort the wrapped operation when the timer fires — it just stops waiting. If you need real cancellation, the wrapped operation must opt into an `AbortSignal` itself:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
const controller = new AbortController();
|
|
67
|
+
const timer = setTimeout(() => controller.abort(), 5_000);
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
71
|
+
// ...
|
|
72
|
+
} finally {
|
|
73
|
+
clearTimeout(timer);
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
For the common "I want a deadline AND cancellation" shape, use `AbortSignal.timeout(ms)` directly — it's the standard library's answer and handles both at once.
|
|
78
|
+
|
|
79
|
+
## Patterns
|
|
80
|
+
|
|
81
|
+
### Bound a credential refresh
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
const fresh = await withTimeout(
|
|
85
|
+
refreshToken(currentRefresh),
|
|
86
|
+
5_000,
|
|
87
|
+
"refresh oauth token",
|
|
88
|
+
);
|
|
89
|
+
await writeSecretFileAtomic({ rootDir, filePath, content: JSON.stringify(fresh) });
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Compose with archive extraction
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
import { extractArchive } from "@openclaw/fs-safe/archive";
|
|
96
|
+
|
|
97
|
+
await extractArchive({
|
|
98
|
+
archivePath,
|
|
99
|
+
destDir,
|
|
100
|
+
kind: "zip",
|
|
101
|
+
timeoutMs: 30_000,
|
|
102
|
+
});
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`extractArchive` already takes `timeoutMs` and uses `withTimeout` internally — you don't need to wrap it. Reach for `withTimeout` for operations that don't carry their own timeout knob.
|
|
106
|
+
|
|
107
|
+
### Disable in tests
|
|
108
|
+
|
|
109
|
+
When unit-testing flaky code, you might want to disable the timeout. Pass `0`:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
await withTimeout(work(), process.env.NODE_ENV === "test" ? 0 : 5_000, "work");
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Better, gate it from the caller — `withTimeout(p, 0, ...)` returns the promise as-is.
|
|
116
|
+
|
|
117
|
+
## See also
|
|
118
|
+
|
|
119
|
+
- [Archive extraction](archive.md) — `extractArchive` already takes `timeoutMs`.
|
|
120
|
+
- [File lock](sidecar-lock.md) — retry policy is a different form of bounded waiting.
|
|
121
|
+
- [`AbortSignal.timeout`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal/timeout_static) — standard-library cancellation when you need to *abort*, not just *give up*.
|