@openclaw/fs-safe 0.1.2 → 0.2.1
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 +51 -0
- package/README.md +39 -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 +2 -4
- package/dist/file-store-boundary.d.ts.map +1 -1
- package/dist/file-store-boundary.js +11 -7
- 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 +60 -53
- 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/home-dir.d.ts.map +1 -1
- package/dist/home-dir.js +38 -33
- 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.map +1 -1
- package/dist/path.js +4 -3
- package/dist/pinned-write.d.ts.map +1 -1
- package/dist/pinned-write.js +44 -19
- 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-impl.d.ts.map +1 -1
- package/dist/root-impl.js +62 -53
- 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 +28 -7
- 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 +10 -0
- package/docs/atomic.md +33 -5
- package/docs/config.md +32 -3
- package/docs/index.md +3 -2
- package/docs/json-store.md +4 -0
- package/docs/json.md +34 -0
- package/docs/output.md +92 -0
- package/docs/sidecar-lock.md +13 -12
- package/docs/store.md +33 -0
- package/docs/temp.md +10 -2
- package/docs/testing.md +10 -0
- package/package.json +7 -2
package/dist/sidecar-lock.d.ts
CHANGED
|
@@ -5,12 +5,14 @@ export type SidecarLockRetryOptions = {
|
|
|
5
5
|
maxTimeout?: number;
|
|
6
6
|
randomize?: boolean;
|
|
7
7
|
};
|
|
8
|
+
export type SidecarLockStaleRecovery = "fail-closed";
|
|
8
9
|
export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
9
10
|
targetPath: string;
|
|
10
11
|
lockPath?: string;
|
|
11
12
|
staleMs: number;
|
|
12
13
|
timeoutMs?: number;
|
|
13
14
|
retry?: SidecarLockRetryOptions;
|
|
15
|
+
staleRecovery?: SidecarLockStaleRecovery;
|
|
14
16
|
allowReentrant?: boolean;
|
|
15
17
|
payload: () => TPayload | Promise<TPayload>;
|
|
16
18
|
shouldReclaim?: (params: {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fsSync from "node:fs";
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
4
5
|
const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
|
|
5
6
|
function getGlobalManagers() {
|
|
6
7
|
const globalWithState = globalThis;
|
|
@@ -18,17 +19,63 @@ function resolveManagerState(key) {
|
|
|
18
19
|
}
|
|
19
20
|
return state;
|
|
20
21
|
}
|
|
21
|
-
async function
|
|
22
|
+
async function readLockSnapshot(lockPath) {
|
|
22
23
|
try {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
const stat = await fs.lstat(lockPath);
|
|
25
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
26
|
+
try {
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
29
|
+
? parsed
|
|
30
|
+
: null;
|
|
31
|
+
return { raw, payload, stat };
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return { raw, payload: null, stat };
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
catch {
|
|
29
38
|
return null;
|
|
30
39
|
}
|
|
31
40
|
}
|
|
41
|
+
function snapshotMatches(current, observed) {
|
|
42
|
+
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
if (observed.raw !== undefined) {
|
|
46
|
+
return current.raw === observed.raw;
|
|
47
|
+
}
|
|
48
|
+
return observed.stat !== undefined && current.stat !== undefined;
|
|
49
|
+
}
|
|
50
|
+
async function removeLockIfUnchanged(lockPath, observed) {
|
|
51
|
+
const current = await readLockSnapshot(lockPath);
|
|
52
|
+
if (!current || !observed) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
if (!snapshotMatches(current, observed)) {
|
|
56
|
+
// The lock changed after we decided it was stale. Leave the fresh holder's
|
|
57
|
+
// file alone; deleting by path here would break mutual exclusion.
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
async function lockSnapshotStillPresent(lockPath, observed) {
|
|
64
|
+
const current = await readLockSnapshot(lockPath);
|
|
65
|
+
return !!current && !!observed && snapshotMatches(current, observed);
|
|
66
|
+
}
|
|
67
|
+
function snapshotMatchesSync(lockPath, observed) {
|
|
68
|
+
try {
|
|
69
|
+
const stat = fsSync.lstatSync(lockPath);
|
|
70
|
+
if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
32
79
|
async function resolveNormalizedTargetPath(targetPath) {
|
|
33
80
|
const resolved = path.resolve(targetPath);
|
|
34
81
|
const dir = path.dirname(resolved);
|
|
@@ -66,7 +113,9 @@ function releaseAllLocksSync(state) {
|
|
|
66
113
|
for (const [normalizedTargetPath, held] of state.held) {
|
|
67
114
|
void held.handle.close().catch(() => undefined);
|
|
68
115
|
try {
|
|
69
|
-
|
|
116
|
+
if (snapshotMatchesSync(held.lockPath, held.snapshot)) {
|
|
117
|
+
fsSync.rmSync(held.lockPath, { force: true });
|
|
118
|
+
}
|
|
70
119
|
}
|
|
71
120
|
catch {
|
|
72
121
|
// Best-effort process-exit cleanup.
|
|
@@ -95,7 +144,7 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
|
|
|
95
144
|
state.held.delete(normalizedTargetPath);
|
|
96
145
|
held.releasePromise = (async () => {
|
|
97
146
|
await held.handle.close().catch(() => undefined);
|
|
98
|
-
await
|
|
147
|
+
await removeLockIfUnchanged(held.lockPath, held.snapshot);
|
|
99
148
|
})();
|
|
100
149
|
try {
|
|
101
150
|
await held.releasePromise;
|
|
@@ -137,15 +186,19 @@ export function createSidecarLockManager(key) {
|
|
|
137
186
|
let handle = null;
|
|
138
187
|
try {
|
|
139
188
|
handle = await fs.open(lockPath, "wx");
|
|
189
|
+
const payload = await options.payload();
|
|
190
|
+
const raw = `${JSON.stringify(payload, null, 2)}\n`;
|
|
191
|
+
await handle.writeFile(raw, "utf8");
|
|
192
|
+
const snapshot = { raw, payload, stat: await handle.stat() };
|
|
140
193
|
const createdHeld = {
|
|
141
194
|
count: 1,
|
|
142
195
|
handle,
|
|
143
196
|
lockPath,
|
|
197
|
+
snapshot,
|
|
144
198
|
acquiredAt: Date.now(),
|
|
145
199
|
metadata: options.metadata ?? {},
|
|
146
200
|
};
|
|
147
201
|
state.held.set(normalizedTargetPath, createdHeld);
|
|
148
|
-
await handle.writeFile(`${JSON.stringify(await options.payload(), null, 2)}\n`, "utf8");
|
|
149
202
|
const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
|
|
150
203
|
return {
|
|
151
204
|
lockPath,
|
|
@@ -156,29 +209,56 @@ export function createSidecarLockManager(key) {
|
|
|
156
209
|
}
|
|
157
210
|
catch (err) {
|
|
158
211
|
if (handle) {
|
|
212
|
+
const failedSnapshot = { payload: null };
|
|
213
|
+
try {
|
|
214
|
+
failedSnapshot.stat = await handle.stat();
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Best-effort cleanup of a failed exclusive create.
|
|
218
|
+
}
|
|
159
219
|
const current = state.held.get(normalizedTargetPath);
|
|
160
220
|
if (current?.handle === handle) {
|
|
161
221
|
state.held.delete(normalizedTargetPath);
|
|
162
222
|
}
|
|
163
|
-
|
|
223
|
+
// If payload serialization/write fails, the file may be empty or
|
|
224
|
+
// partial JSON, so remove while our exclusive handle is still open.
|
|
164
225
|
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
226
|
+
await handle.close().catch(() => undefined);
|
|
227
|
+
// Windows can refuse removing an open file; retry after close but
|
|
228
|
+
// only if the path still points at the file identity we created.
|
|
229
|
+
await removeLockIfUnchanged(lockPath, failedSnapshot);
|
|
165
230
|
}
|
|
166
231
|
if (err.code !== "EEXIST") {
|
|
167
232
|
throw err;
|
|
168
233
|
}
|
|
169
234
|
const nowMs = Date.now();
|
|
170
|
-
const
|
|
235
|
+
const snapshot = await readLockSnapshot(lockPath);
|
|
236
|
+
if (!snapshot) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
171
239
|
const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
|
|
172
240
|
if (await shouldReclaim({
|
|
173
241
|
lockPath,
|
|
174
242
|
normalizedTargetPath,
|
|
175
|
-
payload,
|
|
243
|
+
payload: snapshot?.payload ?? null,
|
|
176
244
|
staleMs: options.staleMs,
|
|
177
245
|
nowMs,
|
|
178
246
|
heldByThisProcess: state.held.has(normalizedTargetPath),
|
|
179
247
|
})) {
|
|
180
|
-
await
|
|
181
|
-
|
|
248
|
+
if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
// Node exposes only path-based unlink/rename here. A stale-lock
|
|
252
|
+
// reclaimer cannot bind the delete to the file it inspected, so a
|
|
253
|
+
// concurrent release+fresh-acquire could otherwise lose its lock.
|
|
254
|
+
// Fail closed and let callers choose a higher-level recovery path.
|
|
255
|
+
if ((options.staleRecovery ?? "fail-closed") === "fail-closed") {
|
|
256
|
+
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
257
|
+
code: "file_lock_stale",
|
|
258
|
+
lockPath,
|
|
259
|
+
normalizedTargetPath,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
182
262
|
}
|
|
183
263
|
const elapsed = Date.now() - startedAt;
|
|
184
264
|
if ((options.timeoutMs !== undefined &&
|
package/dist/store.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { fileStore, fileStoreSync, type FileStore, type FileStoreOptions, type FileStorePruneOptions, type FileStoreReadOptions, type FileStoreSync, type FileStoreWriteOptions, } from "./file-store.js";
|
|
2
2
|
export { jsonStore, type JsonStore, type JsonFileStoreOptions, type JsonStoreLockOptions, type JsonStoreOptions, } from "./json-store.js";
|
|
3
|
+
export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, type JsonDurableQueueEntryPaths, type JsonDurableQueueLoadOptions, type JsonDurableQueueReadResult, } from "./json-durable-queue.js";
|
|
3
4
|
//# sourceMappingURL=store.d.ts.map
|
package/dist/store.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC"}
|
|
1
|
+
{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../src/store.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,SAAS,EACT,aAAa,EACb,KAAK,SAAS,EACd,KAAK,gBAAgB,EACrB,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,aAAa,EAClB,KAAK,qBAAqB,GAC3B,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,SAAS,EACT,KAAK,SAAS,EACd,KAAK,oBAAoB,EACzB,KAAK,oBAAoB,EACzB,KAAK,gBAAgB,GACtB,MAAM,iBAAiB,CAAC;AACzB,OAAO,EACL,wBAAwB,EACxB,0BAA0B,EAC1B,2BAA2B,EAC3B,yBAAyB,EACzB,kCAAkC,EAClC,iCAAiC,EACjC,yBAAyB,EACzB,iCAAiC,EACjC,gBAAgB,EAChB,0BAA0B,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,2BAA2B,EAChC,KAAK,0BAA0B,GAChC,MAAM,yBAAyB,CAAC"}
|
package/dist/store.js
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { fileStore, fileStoreSync, } from "./file-store.js";
|
|
2
2
|
export { jsonStore, } from "./json-store.js";
|
|
3
|
+
export { ackJsonDurableQueueEntry, ensureJsonDurableQueueDirs, jsonDurableQueueEntryExists, loadJsonDurableQueueEntry, loadPendingJsonDurableQueueEntries, moveJsonDurableQueueEntryToFailed, readJsonDurableQueueEntry, resolveJsonDurableQueueEntryPaths, unlinkBestEffort, writeJsonDurableQueueEntry, } from "./json-durable-queue.js";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"temp-target.d.ts","sourceRoot":"","sources":["../src/temp-target.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,QAAQ,GAAG;IACrB,GAAG,EAAE,MAAM,CAAC;IACZ,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IAChC,OAAO,EAAE,MAAM,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B,CAAC,MAAM,CAAC,YAAY,CAAC,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CACxC,CAAC;AAiBF,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAI7D;AAED,wBAAgB,uBAAuB,CAAC,MAAM,EAAE;IAC9C,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;CACf,GAAG,MAAM,CAaT;AAyBD,wBAAsB,QAAQ,CAAC,MAAM,EAAE;IACrC,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,GAAG,OAAO,CAAC,QAAQ,CAAC,CAqBpB;AAED,wBAAsB,YAAY,CAAC,CAAC,EAClC,MAAM,EAAE;IACN,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,OAAO,KAAK,IAAI,CAAC;CAC3C,EACD,EAAE,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,GAClC,OAAO,CAAC,CAAC,CAAC,CAOZ"}
|
package/dist/temp-target.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { assertSafePathSegment, sanitizeSafePathSegment } from "./safe-path-segment.js";
|
|
4
5
|
import { resolveSecureTempRoot } from "./secure-temp-dir.js";
|
|
5
6
|
import { registerTempPathForExit } from "./temp-cleanup.js";
|
|
6
7
|
function sanitizePrefix(prefix) {
|
|
@@ -17,9 +18,9 @@ function sanitizeExtension(extension) {
|
|
|
17
18
|
return token ? `.${token}` : "";
|
|
18
19
|
}
|
|
19
20
|
export function sanitizeTempFileName(fileName) {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
return sanitizeSafePathSegment(path.basename(fileName), "download.bin", {
|
|
22
|
+
allowDotPrefix: true,
|
|
23
|
+
});
|
|
23
24
|
}
|
|
24
25
|
export function buildRandomTempFilePath(params) {
|
|
25
26
|
const rootDir = resolveTempRoot(params.rootDir);
|
|
@@ -29,7 +30,9 @@ export function buildRandomTempFilePath(params) {
|
|
|
29
30
|
const now = typeof nowCandidate === "number" && Number.isFinite(nowCandidate)
|
|
30
31
|
? Math.trunc(nowCandidate)
|
|
31
32
|
: Date.now();
|
|
32
|
-
const uuid = params.uuid
|
|
33
|
+
const uuid = params.uuid
|
|
34
|
+
? assertSafePathSegment(params.uuid.trim(), { label: "temp uuid" })
|
|
35
|
+
: crypto.randomUUID();
|
|
33
36
|
return path.join(rootDir, `${prefix}-${now}-${uuid}${extension}`);
|
|
34
37
|
}
|
|
35
38
|
function isNodeErrorWithCode(err, code) {
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -3,6 +3,12 @@ export type FsSafeTestHooks = {
|
|
|
3
3
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
4
4
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
|
5
5
|
afterOpen?: (filePath: string, handle: FileHandle) => Promise<void> | void;
|
|
6
|
+
beforeArchiveOutputMutation?: (operation: "mkdir" | "chmod", targetPath: string) => Promise<void> | void;
|
|
7
|
+
beforeFileStorePruneDescend?: (dirPath: string) => Promise<void> | void;
|
|
8
|
+
beforeFileStoreSyncPrivateWrite?: (filePath: string) => void;
|
|
9
|
+
beforeRootFallbackMutation?: (operation: "mkdir" | "move" | "remove", targetPath: string) => Promise<void> | void;
|
|
10
|
+
beforeSiblingTempWrite?: (tempPath: string) => Promise<void> | void;
|
|
11
|
+
beforeTrashMove?: (targetPath: string, destPath: string) => void;
|
|
6
12
|
};
|
|
7
13
|
export declare function getFsSafeTestHooks(): FsSafeTestHooks | undefined;
|
|
8
14
|
export declare function __setFsSafeTestHooksForTest(hooks?: FsSafeTestHooks): void;
|
package/dist/test-hooks.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;
|
|
1
|
+
{"version":3,"file":"test-hooks.d.ts","sourceRoot":"","sources":["../src/test-hooks.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAEnD,MAAM,MAAM,eAAe,GAAG;IAC5B,iBAAiB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC/D,UAAU,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACvE,SAAS,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,UAAU,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC3E,2BAA2B,CAAC,EAAE,CAC5B,SAAS,EAAE,OAAO,GAAG,OAAO,EAC5B,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,2BAA2B,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACxE,+BAA+B,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IAC7D,0BAA0B,CAAC,EAAE,CAC3B,SAAS,EAAE,OAAO,GAAG,MAAM,GAAG,QAAQ,EACtC,UAAU,EAAE,MAAM,KACf,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAC1B,sBAAsB,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IACpE,eAAe,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;CAClE,CAAC;AAQF,wBAAgB,kBAAkB,IAAI,eAAe,GAAG,SAAS,CAEhE;AAED,wBAAgB,2BAA2B,CAAC,KAAK,CAAC,EAAE,eAAe,GAAG,IAAI,CAKzE"}
|
package/dist/text-atomic.d.ts
CHANGED
|
@@ -2,6 +2,13 @@ export type WriteTextAtomicOptions = {
|
|
|
2
2
|
mode?: number;
|
|
3
3
|
dirMode?: number;
|
|
4
4
|
trailingNewline?: boolean;
|
|
5
|
+
/**
|
|
6
|
+
* When false, skip the temp-file and parent-directory fsync calls while
|
|
7
|
+
* preserving the temp-file replace/rename behavior.
|
|
8
|
+
*
|
|
9
|
+
* Defaults to true.
|
|
10
|
+
*/
|
|
11
|
+
durable?: boolean;
|
|
5
12
|
};
|
|
6
13
|
export declare function writeTextAtomic(filePath: string, content: string, options?: WriteTextAtomicOptions): Promise<void>;
|
|
7
14
|
//# sourceMappingURL=text-atomic.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;
|
|
1
|
+
{"version":3,"file":"text-atomic.d.ts","sourceRoot":"","sources":["../src/text-atomic.ts"],"names":[],"mappings":"AAEA,MAAM,MAAM,sBAAsB,GAAG;IACnC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,eAAe,CAAC,EAAE,OAAO,CAAC;IAC1B;;;;;OAKG;IACH,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB,CAAC;AAEF,wBAAsB,eAAe,CACnC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,OAAO,CAAC,EAAE,sBAAsB,GAC/B,OAAO,CAAC,IAAI,CAAC,CAYf"}
|
package/dist/text-atomic.js
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { replaceFileAtomic } from "./replace-file.js";
|
|
2
2
|
export async function writeTextAtomic(filePath, content, options) {
|
|
3
3
|
const payload = options?.trailingNewline && !content.endsWith("\n") ? `${content}\n` : content;
|
|
4
|
+
const durable = options?.durable ?? true;
|
|
4
5
|
await replaceFileAtomic({
|
|
5
6
|
filePath,
|
|
6
7
|
content: payload,
|
|
7
8
|
mode: options?.mode ?? 0o600,
|
|
8
9
|
dirMode: options?.dirMode ?? (0o777 & ~process.umask()),
|
|
9
10
|
copyFallbackOnPermissionError: true,
|
|
10
|
-
syncTempFile:
|
|
11
|
-
syncParentDir:
|
|
11
|
+
syncTempFile: durable,
|
|
12
|
+
syncParentDir: durable,
|
|
12
13
|
});
|
|
13
14
|
}
|
package/dist/trash.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"trash.d.ts","sourceRoot":"","sources":["../src/trash.ts"],"names":[],"mappings":"AAOA,MAAM,MAAM,sBAAsB,GAAG;IACnC,YAAY,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,CAAC;CACjC,CAAC;AAwKF,wBAAsB,eAAe,CACnC,UAAU,EAAE,MAAM,EAClB,OAAO,GAAE,sBAA2B,GACnC,OAAO,CAAC,MAAM,CAAC,CAcjB"}
|
package/dist/trash.js
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import os from "node:os";
|
|
3
3
|
import path from "node:path";
|
|
4
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
5
|
+
import { guardedRenameSync, guardedRmSync } from "./guarded-mutation.js";
|
|
6
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
4
7
|
const TRASH_DESTINATION_COLLISION_CODES = new Set(["EEXIST", "ENOTEMPTY", "ERR_FS_CP_EEXIST"]);
|
|
5
8
|
const TRASH_DESTINATION_RETRY_LIMIT = 4;
|
|
6
9
|
function getFsErrorCode(error) {
|
|
@@ -18,28 +21,56 @@ function isSameOrChildPath(candidate, parent) {
|
|
|
18
21
|
return candidate === parent || candidate.startsWith(`${parent}${path.sep}`);
|
|
19
22
|
}
|
|
20
23
|
function resolveAllowedTrashRoots(allowedRoots) {
|
|
21
|
-
const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].
|
|
24
|
+
const roots = [...(allowedRoots ?? [os.homedir(), os.tmpdir()])].flatMap((root) => {
|
|
25
|
+
const lexicalRoot = path.resolve(root);
|
|
22
26
|
try {
|
|
23
|
-
|
|
27
|
+
// Keep both spellings: broken symlink targets cannot be realpathed and
|
|
28
|
+
// may only compare equal to the caller's lexical allowed root.
|
|
29
|
+
return [path.resolve(fs.realpathSync.native(root)), lexicalRoot];
|
|
24
30
|
}
|
|
25
31
|
catch {
|
|
26
|
-
return
|
|
32
|
+
return [lexicalRoot];
|
|
27
33
|
}
|
|
28
34
|
});
|
|
29
35
|
return [...new Set(roots)];
|
|
30
36
|
}
|
|
31
|
-
function
|
|
32
|
-
let resolvedTargetPath = path.resolve(targetPath);
|
|
37
|
+
function resolveTrashTargetPath(targetPath) {
|
|
33
38
|
try {
|
|
34
|
-
|
|
39
|
+
return { path: path.resolve(fs.realpathSync.native(targetPath)), resolved: true };
|
|
35
40
|
}
|
|
36
41
|
catch {
|
|
37
|
-
//
|
|
42
|
+
// Broken symlinks are valid trash targets. Fall back to the lexical path,
|
|
43
|
+
// then rely on lstat identity so the move renames the symlink itself.
|
|
44
|
+
return { path: path.resolve(targetPath), resolved: false };
|
|
38
45
|
}
|
|
46
|
+
}
|
|
47
|
+
function assertAllowedTrashTarget(targetPath, allowedRoots) {
|
|
48
|
+
const stat = fs.lstatSync(path.resolve(targetPath));
|
|
49
|
+
const resolvedTarget = resolveTrashTargetPath(targetPath);
|
|
50
|
+
const resolvedTargetPath = resolvedTarget.path;
|
|
39
51
|
const isAllowed = resolveAllowedTrashRoots(allowedRoots).some((root) => resolvedTargetPath !== root && isSameOrChildPath(resolvedTargetPath, root));
|
|
40
52
|
if (!isAllowed) {
|
|
41
53
|
throw new Error(`Refusing to trash path outside allowed roots: ${targetPath}`);
|
|
42
54
|
}
|
|
55
|
+
return {
|
|
56
|
+
path: path.resolve(targetPath),
|
|
57
|
+
realPath: resolvedTargetPath,
|
|
58
|
+
realPathResolved: resolvedTarget.resolved,
|
|
59
|
+
stat,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
function assertTrashTargetGuard(guard) {
|
|
63
|
+
const stat = fs.lstatSync(guard.path);
|
|
64
|
+
if (!sameFileIdentity(stat, guard.stat)) {
|
|
65
|
+
throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
|
|
66
|
+
}
|
|
67
|
+
const current = resolveTrashTargetPath(guard.path);
|
|
68
|
+
if (guard.realPathResolved && (!current.resolved || current.path !== guard.realPath)) {
|
|
69
|
+
throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
|
|
70
|
+
}
|
|
71
|
+
if (!guard.realPathResolved && current.resolved) {
|
|
72
|
+
throw new Error(`Refusing to trash path after it changed: ${guard.path}`);
|
|
73
|
+
}
|
|
43
74
|
}
|
|
44
75
|
function resolveTrashDir() {
|
|
45
76
|
const homeDir = os.homedir();
|
|
@@ -86,9 +117,11 @@ function reserveTrashDestination(trashDir, base, timestamp) {
|
|
|
86
117
|
}
|
|
87
118
|
return resolveContainedPath(container, base);
|
|
88
119
|
}
|
|
89
|
-
function movePathToDestination(
|
|
120
|
+
function movePathToDestination(target, dest) {
|
|
121
|
+
getFsSafeTestHooks()?.beforeTrashMove?.(target.path, dest);
|
|
122
|
+
assertTrashTargetGuard(target);
|
|
90
123
|
try {
|
|
91
|
-
|
|
124
|
+
guardedRenameSync({ from: target.path, to: dest });
|
|
92
125
|
return true;
|
|
93
126
|
}
|
|
94
127
|
catch (error) {
|
|
@@ -100,8 +133,10 @@ function movePathToDestination(targetPath, dest) {
|
|
|
100
133
|
}
|
|
101
134
|
}
|
|
102
135
|
try {
|
|
103
|
-
|
|
104
|
-
fs.
|
|
136
|
+
assertTrashTargetGuard(target);
|
|
137
|
+
fs.cpSync(target.path, dest, { recursive: true, force: false, errorOnExist: true });
|
|
138
|
+
assertTrashTargetGuard(target);
|
|
139
|
+
guardedRmSync({ target: target.path, recursive: true, force: false, verifyAfter: false });
|
|
105
140
|
return true;
|
|
106
141
|
}
|
|
107
142
|
catch (error) {
|
|
@@ -114,12 +149,12 @@ function movePathToDestination(targetPath, dest) {
|
|
|
114
149
|
export async function movePathToTrash(targetPath, options = {}) {
|
|
115
150
|
// Avoid resolving external trash helpers through the service PATH during cleanup.
|
|
116
151
|
const base = trashBaseName(targetPath);
|
|
117
|
-
assertAllowedTrashTarget(targetPath, options.allowedRoots);
|
|
152
|
+
const target = assertAllowedTrashTarget(targetPath, options.allowedRoots);
|
|
118
153
|
const trashDir = resolveTrashDir();
|
|
119
154
|
const timestamp = Date.now();
|
|
120
155
|
for (let attempt = 0; attempt < TRASH_DESTINATION_RETRY_LIMIT; attempt += 1) {
|
|
121
156
|
const dest = reserveTrashDestination(trashDir, base, timestamp);
|
|
122
|
-
if (movePathToDestination(
|
|
157
|
+
if (movePathToDestination(target, dest)) {
|
|
123
158
|
return dest;
|
|
124
159
|
}
|
|
125
160
|
}
|
package/docs/advanced.md
CHANGED
|
@@ -38,9 +38,19 @@ The exports group into a handful of themes. Each documented helper has its own p
|
|
|
38
38
|
| Export | Page | Notes |
|
|
39
39
|
|---|---|---|
|
|
40
40
|
| `assertAbsolutePathInput` | – | Validate a caller-supplied absolute path string. |
|
|
41
|
+
| `ensureAbsoluteDirectory`, `EnsureAbsoluteDirectoryOptions`, `EnsureAbsoluteDirectoryResult` | – | Create a trusted absolute directory path one segment at a time, rejecting symlink or non-directory segments. |
|
|
41
42
|
| `canonicalPathFromExistingAncestor`, `findExistingAncestor` | – | Canonicalize without requiring the leaf to exist. |
|
|
42
43
|
| `resolveAbsolutePathForRead`, `resolveAbsolutePathForWrite`, `ResolvedAbsolutePath`, `ResolvedWritableAbsolutePath`, `AbsolutePathSymlinkPolicy` | – | Validate an absolute path against a symlink policy before opening. |
|
|
43
44
|
|
|
45
|
+
`ensureAbsoluteDirectory()` is for paths you already intend to trust as absolute
|
|
46
|
+
locations, such as a configured output root. It does not enforce a root boundary;
|
|
47
|
+
use `pathScope().ensureDir()` or `ensureDirectoryWithinRoot()` when the caller
|
|
48
|
+
supplies a path that must stay under a root.
|
|
49
|
+
|
|
50
|
+
The helper returns `{ ok: false, code, error }` for path-policy failures such as
|
|
51
|
+
relative paths, symlinks, non-directories, or directory swaps during creation.
|
|
52
|
+
Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
53
|
+
|
|
44
54
|
### Files and identity
|
|
45
55
|
|
|
46
56
|
| Export | Page | Notes |
|
package/docs/atomic.md
CHANGED
|
@@ -68,7 +68,12 @@ If `beforeRename` throws, the rename is skipped and the temp file is removed —
|
|
|
68
68
|
|
|
69
69
|
### `EPERM` and copy fallback
|
|
70
70
|
|
|
71
|
-
On systems where `rename` fails with `EPERM`/`EEXIST`, pass
|
|
71
|
+
On systems where `rename` fails with `EPERM`/`EEXIST`, pass
|
|
72
|
+
`copyFallbackOnPermissionError: true` to fall back to a non-atomic copy
|
|
73
|
+
replacement. The fallback removes the old destination, opens the replacement
|
|
74
|
+
with exclusive/no-follow flags where the platform supports them, and refuses
|
|
75
|
+
known symlink destinations so it does not write through a replaced destination
|
|
76
|
+
link.
|
|
72
77
|
|
|
73
78
|
### Sync variant
|
|
74
79
|
|
|
@@ -109,21 +114,44 @@ await writeTextAtomic("/srv/workspace/rendered.md", rendered, {
|
|
|
109
114
|
});
|
|
110
115
|
```
|
|
111
116
|
|
|
117
|
+
Options:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
type WriteTextAtomicOptions = {
|
|
121
|
+
mode?: number; // file mode (default 0o600)
|
|
122
|
+
dirMode?: number; // mode for parent dirs created on demand
|
|
123
|
+
trailingNewline?: boolean; // append "\n" if missing
|
|
124
|
+
durable?: boolean; // default true; false skips temp/parent fsync
|
|
125
|
+
};
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
`durable: false` keeps the sibling-temp replace/rename behavior but skips the
|
|
129
|
+
temp-file and parent-directory `fsync` calls. Use it only for reconstructible
|
|
130
|
+
metadata where lower latency matters more than crash-durability.
|
|
131
|
+
|
|
112
132
|
## `movePathWithCopyFallback`
|
|
113
133
|
|
|
114
|
-
Rename a path. If the rename fails with `EXDEV` (cross-device)
|
|
134
|
+
Rename a path. If the rename fails with `EXDEV` (cross-device), fall back to
|
|
135
|
+
copying into a staged sibling path, renaming that staged path into place, and
|
|
136
|
+
then removing only the source entries that were copied. The fallback avoids
|
|
137
|
+
buffering regular files into memory and does not tighten the destination parent
|
|
138
|
+
directory mode.
|
|
115
139
|
|
|
116
140
|
```ts
|
|
117
141
|
import { movePathWithCopyFallback } from "@openclaw/fs-safe/atomic";
|
|
118
142
|
|
|
119
143
|
await movePathWithCopyFallback({
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
from: "/srv/cache/blob.bin",
|
|
145
|
+
sourceHardlinks: "reject",
|
|
146
|
+
to: "/srv/persistent/blob.bin",
|
|
123
147
|
});
|
|
124
148
|
```
|
|
125
149
|
|
|
126
150
|
Use it when source and destination might live on different filesystems (containers, tmpfs, separate volumes).
|
|
151
|
+
If another writer changes source entries during the fallback, the staged copy
|
|
152
|
+
throws `ESTALE` before commit when possible. If the destination has already
|
|
153
|
+
been committed, cleanup still preserves the changed source entries and throws
|
|
154
|
+
`ESTALE`.
|
|
127
155
|
|
|
128
156
|
## Difference from `root()`
|
|
129
157
|
|
package/docs/config.md
CHANGED
|
@@ -1,22 +1,25 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Config
|
|
3
|
-
description: "Process-global
|
|
3
|
+
description: "Process-global defaults for optional fs-safe helpers."
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# `@openclaw/fs-safe/config`
|
|
7
7
|
|
|
8
|
-
Process-global configuration knobs for
|
|
8
|
+
Process-global configuration knobs for optional fs-safe helpers. The Python helper policy is described in the [Python helper policy](python-helper.md); this page is the API reference.
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
11
|
import {
|
|
12
12
|
configureFsSafePython,
|
|
13
|
+
configureFsSafeLocks,
|
|
13
14
|
getFsSafePythonConfig,
|
|
15
|
+
getFsSafeLockConfig,
|
|
16
|
+
type FsSafeLockConfig,
|
|
14
17
|
type FsSafePythonConfig,
|
|
15
18
|
type FsSafePythonMode,
|
|
16
19
|
} from "@openclaw/fs-safe/config";
|
|
17
20
|
```
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
These functions are also re-exported from the main entry point. Prefer the subpath when you only need helper configuration and want the smallest import surface.
|
|
20
23
|
|
|
21
24
|
## `configureFsSafePython(config)`
|
|
22
25
|
|
|
@@ -47,6 +50,31 @@ function getFsSafePythonConfig(): FsSafePythonConfig;
|
|
|
47
50
|
|
|
48
51
|
Return the effective configuration: programmatic overrides win, then env vars, then the package default (`auto`).
|
|
49
52
|
|
|
53
|
+
## `configureFsSafeLocks(config)`
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
57
|
+
|
|
58
|
+
type FsSafeLockConfig = {
|
|
59
|
+
staleRecovery: "fail-closed";
|
|
60
|
+
staleMs?: number;
|
|
61
|
+
timeoutMs?: number;
|
|
62
|
+
retry?: FileLockRetryOptions;
|
|
63
|
+
};
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
67
|
+
|
|
68
|
+
`staleRecovery` currently supports `"fail-closed"` only. Stale third-party sidecars are not deleted by path because Node cannot atomically bind that deletion to the file that was inspected.
|
|
69
|
+
|
|
70
|
+
## `getFsSafeLockConfig()`
|
|
71
|
+
|
|
72
|
+
```ts
|
|
73
|
+
function getFsSafeLockConfig(): FsSafeLockConfig;
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Return the current sidecar lock defaults.
|
|
77
|
+
|
|
50
78
|
## Environment variables
|
|
51
79
|
|
|
52
80
|
The same policy can be set without code:
|
|
@@ -61,5 +89,6 @@ OpenClaw compatibility aliases are accepted: `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OP
|
|
|
61
89
|
## Related pages
|
|
62
90
|
|
|
63
91
|
- [Python helper policy](python-helper.md) — when to pick `auto`, `off`, or `require`, and what each mode protects.
|
|
92
|
+
- [File lock](sidecar-lock.md) — the per-resource lock API that consumes lock defaults.
|
|
64
93
|
- [Root API](root.md) — the API whose POSIX hardening the helper backs.
|
|
65
94
|
- [Errors](errors.md) — `helper-unavailable` and `helper-failed`.
|
package/docs/index.md
CHANGED
|
@@ -49,9 +49,10 @@ await fs.remove("notes/archive/today.txt");
|
|
|
49
49
|
| Surface | Use it for |
|
|
50
50
|
|---|---|
|
|
51
51
|
| [`root()`](root.md) | One boundary for read/write/move/remove inside a trusted directory. |
|
|
52
|
-
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper
|
|
52
|
+
| [`@openclaw/fs-safe/config`](config.md) | Process-global Python helper and lock-option defaults. |
|
|
53
53
|
| [Python helper policy](python-helper.md) | Choose `auto`, `off`, or `require` for POSIX fd-relative hardening. |
|
|
54
54
|
| [`replaceFileAtomic`](atomic.md) | Sibling-temp + rename, fsync hooks, mode preservation, copy fallback. |
|
|
55
|
+
| [`writeExternalFileWithinRoot`](output.md) | Stage external-library file output in private temp storage, then finalize under a root. |
|
|
55
56
|
| [`writeJson` / `readJson*`](json.md) | JSON state files with strict and lenient read variants. |
|
|
56
57
|
| [`@openclaw/fs-safe/store`](store.md) | Overview of `fileStore`, `fileStoreSync`, and `jsonStore`. |
|
|
57
58
|
| [`jsonStore`](json-store.md) | Single JSON state file with explicit fallback, atomic writes, and optional locking. |
|
|
@@ -63,7 +64,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
63
64
|
| [`extractArchive`](archive.md) | ZIP/TAR extraction with size, count, link, and traversal limits. |
|
|
64
65
|
| [Secret files](secret-file.md) | Mode-0600 credentials with size and TOCTOU defense. |
|
|
65
66
|
| [Permissions](permissions.md) | POSIX mode and Windows ACL inspection/remediation helpers. |
|
|
66
|
-
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and stale-lock
|
|
67
|
+
| [`acquireFileLock`](sidecar-lock.md) | Cross-process file lock with retry and fail-closed stale-lock handling. |
|
|
67
68
|
| [`FsSafeError`](errors.md) | Closed code union (with `policy` / `operational` category) you can branch on. |
|
|
68
69
|
| [`pathScope()`](path-scope.md) | Lower-level absolute-path boundary helper; lives behind `@openclaw/fs-safe/advanced`. |
|
|
69
70
|
| [`@openclaw/fs-safe/advanced`](advanced.md) | Directory of lower-level composition helpers (path scopes, regular-file I/O, install paths, sibling-temp writes, …). |
|
package/docs/json-store.md
CHANGED
|
@@ -53,6 +53,7 @@ type JsonStoreLockOptions = {
|
|
|
53
53
|
staleMs?: number; // default 30_000
|
|
54
54
|
timeoutMs?: number; // default 30_000
|
|
55
55
|
retry?: FileLockRetryOptions;
|
|
56
|
+
staleRecovery?: "fail-closed";
|
|
56
57
|
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
|
57
58
|
};
|
|
58
59
|
|
|
@@ -130,6 +131,7 @@ const counter = jsonStore<{ count: number }>({
|
|
|
130
131
|
lock: {
|
|
131
132
|
staleMs: 60_000,
|
|
132
133
|
timeoutMs: 10_000,
|
|
134
|
+
staleRecovery: "fail-closed",
|
|
133
135
|
retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
|
|
134
136
|
},
|
|
135
137
|
});
|
|
@@ -137,6 +139,8 @@ const counter = jsonStore<{ count: number }>({
|
|
|
137
139
|
|
|
138
140
|
When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` shape is still useful — it gives you a single function for the read-modify-write pattern — but it offers no concurrency guarantees if other processes also write to the file.
|
|
139
141
|
|
|
142
|
+
Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
|
|
143
|
+
|
|
140
144
|
The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
|
|
141
145
|
|
|
142
146
|
## Common patterns
|