@openclaw/fs-safe 0.4.3 → 0.4.5

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 CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.5 - 2026-07-20
4
+
5
+ ### Highlights
6
+
7
+ - Preserve sidecar-lock ownership across filesystem identity drift without weakening legacy stale-lock checks.
8
+
9
+ ### Security and Correctness
10
+
11
+ - Give new sidecar locks an internal random ownership token outside the parsed JSON payload so release remains ownership-checked when virtual filesystems report different descriptor and pathname identities, while legacy locks retain the stricter identity-plus-content check.
12
+
13
+ ## 0.4.4 - 2026-07-18
14
+
15
+ ### Security and Correctness
16
+
17
+ - Restore opt-in stale sidecar recovery with an exclusive reclaim guard that serializes snapshot verification and unlink, preserving dead-owner recovery without allowing a competing reclaimer to delete a fresh replacement lock.
18
+
3
19
  ## 0.4.3 - 2026-07-18
4
20
 
5
21
  ### Compatibility
package/README.md CHANGED
@@ -283,9 +283,10 @@ the common merge-into-defaults case. Standalone helpers use options bags
283
283
  because they do not carry a bound root and often need multiple authority, path,
284
284
  and policy knobs.
285
285
 
286
- Sidecar locks fail closed on stale holders. The legacy `remove-if-unchanged`
287
- option remains accepted for source compatibility, but it no longer unlinks a
288
- third-party stale lock during acquisition; see the [file lock docs](docs/sidecar-lock.md).
286
+ Sidecar locks fail closed on stale holders by default. Opt-in `remove-if-unchanged`
287
+ recovery requires caller approval and serializes snapshot verification and unlink
288
+ with an exclusive reclaim guard so a replacement lock cannot be deleted; see the
289
+ [file lock docs](docs/sidecar-lock.md).
289
290
 
290
291
  Use `fileStore()` for cache/blob/media-style directories where callers
291
292
  need safe relative paths, size limits, atomic replacement, stream writes, and
@@ -0,0 +1,32 @@
1
+ import type { Stats } from "node:fs";
2
+ export type SidecarLockStaleSnapshot = {
3
+ lockPath: string;
4
+ normalizedTargetPath: string;
5
+ raw: string;
6
+ payload: Record<string, unknown> | null;
7
+ };
8
+ export type SidecarLockSnapshot = {
9
+ raw?: string;
10
+ payload: Record<string, unknown> | null;
11
+ stat?: Stats;
12
+ ownershipToken?: string;
13
+ };
14
+ export declare function readSidecarLockOwnershipToken(raw: string): string | undefined;
15
+ export declare function serializeSidecarLockPayload(payload: Record<string, unknown>): {
16
+ raw: string;
17
+ ownershipToken: string;
18
+ };
19
+ export declare function readSidecarLockSnapshot(lockPath: string): Promise<SidecarLockSnapshot | null>;
20
+ export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
21
+ export declare function removeSidecarLockIfUnchanged(lockPath: string, observed: SidecarLockSnapshot | null): Promise<boolean>;
22
+ export declare function sidecarLockSnapshotStillPresent(lockPath: string, observed: SidecarLockSnapshot | null): Promise<boolean>;
23
+ export declare function sidecarReclaimGuardExists(pathname: string): Promise<boolean>;
24
+ export declare function tryAcquireSidecarReclaimGuard(reclaimGuards: Set<string>, reclaimGuardPath: string): Promise<boolean>;
25
+ export declare function releaseSidecarReclaimGuard(reclaimGuards: Set<string>, reclaimGuardPath: string): Promise<void>;
26
+ export declare function removeStaleSidecarLockIfAllowed(params: {
27
+ lockPath: string;
28
+ normalizedTargetPath: string;
29
+ snapshot: SidecarLockSnapshot;
30
+ shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
31
+ }): Promise<"removed" | "changed" | "not-approved">;
32
+ //# sourceMappingURL=sidecar-lock-reclaim.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAWrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;IACb,cAAc,CAAC,EAAE,MAAM,CAAC;CACzB,CAAC;AAYF,wBAAgB,6BAA6B,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAE7E;AAED,wBAAgB,2BAA2B,CAAC,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG;IAC7E,GAAG,EAAE,MAAM,CAAC;IACZ,cAAc,EAAE,MAAM,CAAC;CACxB,CAMA;AAED,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAkBT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
@@ -0,0 +1,135 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import { sameFileIdentity } from "./file-identity.js";
4
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES = 16;
5
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS = SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES * 8;
6
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX = "\t".repeat(8);
7
+ const SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN = new RegExp(`\\n(${SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX}[ \\t]{${SIDECAR_LOCK_OWNERSHIP_TOKEN_BITS}})\\n$`);
8
+ function createSidecarLockOwnershipToken() {
9
+ let token = SIDECAR_LOCK_OWNERSHIP_TOKEN_PREFIX;
10
+ for (const byte of randomBytes(SIDECAR_LOCK_OWNERSHIP_TOKEN_BYTES)) {
11
+ for (let bit = 7; bit >= 0; bit -= 1) {
12
+ token += byte & (1 << bit) ? "\t" : " ";
13
+ }
14
+ }
15
+ return token;
16
+ }
17
+ export function readSidecarLockOwnershipToken(raw) {
18
+ return SIDECAR_LOCK_OWNERSHIP_TOKEN_PATTERN.exec(raw)?.[1];
19
+ }
20
+ export function serializeSidecarLockPayload(payload) {
21
+ const ownershipToken = createSidecarLockOwnershipToken();
22
+ return {
23
+ raw: `${JSON.stringify(payload, null, 2)}\n${ownershipToken}\n`,
24
+ ownershipToken,
25
+ };
26
+ }
27
+ export async function readSidecarLockSnapshot(lockPath) {
28
+ try {
29
+ const stat = await fs.lstat(lockPath);
30
+ const raw = await fs.readFile(lockPath, "utf8");
31
+ try {
32
+ const parsed = JSON.parse(raw);
33
+ const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
34
+ ? parsed
35
+ : null;
36
+ return { raw, payload, stat };
37
+ }
38
+ catch {
39
+ return { raw, payload: null, stat };
40
+ }
41
+ }
42
+ catch (err) {
43
+ if (err.code === "ENOENT") {
44
+ return null;
45
+ }
46
+ throw err;
47
+ }
48
+ }
49
+ export function sidecarLockSnapshotMatches(current, observed) {
50
+ if (observed.ownershipToken !== undefined) {
51
+ return (current.stat?.isFile() === true &&
52
+ current.raw !== undefined &&
53
+ observed.raw !== undefined &&
54
+ readSidecarLockOwnershipToken(current.raw) === observed.ownershipToken &&
55
+ readSidecarLockOwnershipToken(observed.raw) === observed.ownershipToken &&
56
+ current.raw === observed.raw);
57
+ }
58
+ if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
59
+ return false;
60
+ }
61
+ if (observed.raw !== undefined) {
62
+ return current.raw === observed.raw;
63
+ }
64
+ return observed.stat !== undefined && current.stat !== undefined;
65
+ }
66
+ export async function removeSidecarLockIfUnchanged(lockPath, observed) {
67
+ const current = await readSidecarLockSnapshot(lockPath);
68
+ if (!current || !observed || !sidecarLockSnapshotMatches(current, observed)) {
69
+ return false;
70
+ }
71
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
72
+ return true;
73
+ }
74
+ export async function sidecarLockSnapshotStillPresent(lockPath, observed) {
75
+ const current = await readSidecarLockSnapshot(lockPath);
76
+ return !!current && !!observed && sidecarLockSnapshotMatches(current, observed);
77
+ }
78
+ export async function sidecarReclaimGuardExists(pathname) {
79
+ try {
80
+ await fs.lstat(pathname);
81
+ return true;
82
+ }
83
+ catch (err) {
84
+ if (err.code === "ENOENT") {
85
+ return false;
86
+ }
87
+ throw err;
88
+ }
89
+ }
90
+ export async function tryAcquireSidecarReclaimGuard(reclaimGuards, reclaimGuardPath) {
91
+ try {
92
+ await fs.mkdir(reclaimGuardPath);
93
+ reclaimGuards.add(reclaimGuardPath);
94
+ return true;
95
+ }
96
+ catch (err) {
97
+ if (err.code === "EEXIST") {
98
+ return false;
99
+ }
100
+ throw err;
101
+ }
102
+ }
103
+ export async function releaseSidecarReclaimGuard(reclaimGuards, reclaimGuardPath) {
104
+ await fs.rmdir(reclaimGuardPath);
105
+ reclaimGuards.delete(reclaimGuardPath);
106
+ }
107
+ export async function removeStaleSidecarLockIfAllowed(params) {
108
+ if (!params.shouldRemoveStaleLock || params.snapshot.raw === undefined) {
109
+ return "not-approved";
110
+ }
111
+ if (!(await sidecarLockSnapshotStillPresent(params.lockPath, params.snapshot))) {
112
+ return "changed";
113
+ }
114
+ if (!(await params.shouldRemoveStaleLock({
115
+ lockPath: params.lockPath,
116
+ normalizedTargetPath: params.normalizedTargetPath,
117
+ raw: params.snapshot.raw,
118
+ payload: params.snapshot.payload,
119
+ }))) {
120
+ return "not-approved";
121
+ }
122
+ if (!(await sidecarLockSnapshotStillPresent(params.lockPath, params.snapshot))) {
123
+ return "changed";
124
+ }
125
+ try {
126
+ await fs.rm(params.lockPath);
127
+ return "removed";
128
+ }
129
+ catch (err) {
130
+ if (err.code === "ENOENT") {
131
+ return "changed";
132
+ }
133
+ throw err;
134
+ }
135
+ }
@@ -1,3 +1,5 @@
1
+ import { type SidecarLockStaleSnapshot } from "./sidecar-lock-reclaim.js";
2
+ export type { SidecarLockStaleSnapshot } from "./sidecar-lock-reclaim.js";
1
3
  export type SidecarLockRetryOptions = {
2
4
  retries?: number;
3
5
  factor?: number;
@@ -5,16 +7,7 @@ export type SidecarLockRetryOptions = {
5
7
  maxTimeout?: number;
6
8
  randomize?: boolean;
7
9
  };
8
- export type SidecarLockStaleRecovery = "fail-closed"
9
- /** @deprecated Stale locks now always fail closed. */
10
- | "remove-if-unchanged";
11
- /** @deprecated Stale-removal callbacks are retained for source compatibility but are not invoked. */
12
- export type SidecarLockStaleSnapshot = {
13
- lockPath: string;
14
- normalizedTargetPath: string;
15
- raw: string;
16
- payload: Record<string, unknown> | null;
17
- };
10
+ export type SidecarLockStaleRecovery = "fail-closed" | "remove-if-unchanged";
18
11
  export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
19
12
  targetPath: string;
20
13
  lockPath?: string;
@@ -32,7 +25,6 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
32
25
  nowMs: number;
33
26
  heldByThisProcess: boolean;
34
27
  }) => boolean | Promise<boolean>;
35
- /** @deprecated Stale locks now always fail closed; this callback is not invoked. */
36
28
  shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
37
29
  metadata?: Record<string, unknown>;
38
30
  };
@@ -1 +1 @@
1
- {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
@@ -2,6 +2,7 @@ import fsSync from "node:fs";
2
2
  import fs from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { sameFileIdentity } from "./file-identity.js";
5
+ import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, serializeSidecarLockPayload, sidecarLockSnapshotMatches, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
5
6
  const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
6
7
  function getGlobalManagers() {
7
8
  const globalWithState = globalThis;
@@ -14,70 +15,62 @@ function resolveManagerState(key) {
14
15
  const managers = getGlobalManagers();
15
16
  let state = managers.get(key);
16
17
  if (!state) {
17
- state = { cleanupRegistered: false, held: new Map() };
18
+ state = {
19
+ cleanupRegistered: false,
20
+ held: new Map(),
21
+ reclaimCleanupRegistered: false,
22
+ reclaimGuards: new Set(),
23
+ };
18
24
  managers.set(key, state);
19
25
  }
26
+ else {
27
+ // The global manager symbol is shared across package copies and hot reloads.
28
+ // Backfill state created by fs-safe versions that predate reclaim guards.
29
+ state.reclaimCleanupRegistered ??= false;
30
+ state.reclaimGuards ??= new Set();
31
+ }
20
32
  return state;
21
33
  }
22
- async function readLockSnapshot(lockPath) {
34
+ function snapshotMatchesSync(lockPath, observed) {
35
+ let fd;
23
36
  try {
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 };
37
+ const beforeStat = fsSync.lstatSync(lockPath);
38
+ if (!beforeStat.isFile()) {
39
+ return false;
32
40
  }
33
- catch {
34
- return { raw, payload: null, stat };
41
+ const openFlags = fsSync.constants.O_RDONLY |
42
+ (process.platform !== "win32" && typeof fsSync.constants.O_NOFOLLOW === "number"
43
+ ? fsSync.constants.O_NOFOLLOW
44
+ : 0) |
45
+ (typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
46
+ fd = fsSync.openSync(lockPath, openFlags);
47
+ const openedStat = fsSync.fstatSync(fd);
48
+ if (!openedStat.isFile()) {
49
+ return false;
35
50
  }
36
- }
37
- catch (err) {
38
- if (err.code === "ENOENT") {
39
- return null;
51
+ if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
52
+ return false;
40
53
  }
41
- throw err;
42
- }
43
- }
44
- function snapshotMatches(current, observed) {
45
- if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
46
- return false;
47
- }
48
- if (observed.raw !== undefined) {
49
- return current.raw === observed.raw;
50
- }
51
- return observed.stat !== undefined && current.stat !== undefined;
52
- }
53
- async function removeLockIfUnchanged(lockPath, observed) {
54
- const current = await readLockSnapshot(lockPath);
55
- if (!current || !observed) {
56
- return false;
57
- }
58
- if (!snapshotMatches(current, observed)) {
59
- // The lock changed after we decided it was stale. Leave the fresh holder's
60
- // file alone; deleting by path here would break mutual exclusion.
61
- return false;
62
- }
63
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
64
- return true;
65
- }
66
- async function lockSnapshotStillPresent(lockPath, observed) {
67
- const current = await readLockSnapshot(lockPath);
68
- return !!current && !!observed && snapshotMatches(current, observed);
69
- }
70
- function snapshotMatchesSync(lockPath, observed) {
71
- try {
72
- const stat = fsSync.lstatSync(lockPath);
73
- if (observed.stat && !sameFileIdentity(observed.stat, stat)) {
54
+ const raw = fsSync.readFileSync(fd, "utf8");
55
+ const afterStat = fsSync.lstatSync(lockPath);
56
+ if (!afterStat.isFile() || !sameFileIdentity(beforeStat, afterStat)) {
74
57
  return false;
75
58
  }
76
- return observed.raw === undefined || fsSync.readFileSync(lockPath, "utf8") === observed.raw;
59
+ return sidecarLockSnapshotMatches({ raw, payload: null, stat: afterStat }, observed);
77
60
  }
78
61
  catch {
79
62
  return false;
80
63
  }
64
+ finally {
65
+ if (fd !== undefined) {
66
+ try {
67
+ fsSync.closeSync(fd);
68
+ }
69
+ catch {
70
+ // Best-effort process-exit cleanup.
71
+ }
72
+ }
73
+ }
81
74
  }
82
75
  async function resolveNormalizedTargetPath(targetPath) {
83
76
  const resolved = path.resolve(targetPath);
@@ -112,6 +105,17 @@ async function defaultShouldReclaim(params) {
112
105
  return true;
113
106
  }
114
107
  }
108
+ function releaseAllReclaimGuardsSync(state) {
109
+ for (const reclaimGuardPath of state.reclaimGuards) {
110
+ try {
111
+ fsSync.rmdirSync(reclaimGuardPath);
112
+ state.reclaimGuards.delete(reclaimGuardPath);
113
+ }
114
+ catch {
115
+ // Best-effort process-exit cleanup. A surviving guard fails closed.
116
+ }
117
+ }
118
+ }
115
119
  function releaseAllLocksSync(state) {
116
120
  for (const [normalizedTargetPath, held] of state.held) {
117
121
  void held.handle.close().catch(() => undefined);
@@ -125,6 +129,7 @@ function releaseAllLocksSync(state) {
125
129
  }
126
130
  state.held.delete(normalizedTargetPath);
127
131
  }
132
+ releaseAllReclaimGuardsSync(state);
128
133
  }
129
134
  async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
130
135
  const current = state.held.get(normalizedTargetPath);
@@ -147,7 +152,7 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
147
152
  state.held.delete(normalizedTargetPath);
148
153
  held.releasePromise = (async () => {
149
154
  await held.handle.close().catch(() => undefined);
150
- await removeLockIfUnchanged(held.lockPath, held.snapshot);
155
+ await removeSidecarLockIfUnchanged(held.lockPath, held.snapshot);
151
156
  })();
152
157
  try {
153
158
  await held.releasePromise;
@@ -160,11 +165,16 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
160
165
  export function createSidecarLockManager(key) {
161
166
  const state = resolveManagerState(key);
162
167
  function ensureExitCleanupRegistered() {
163
- if (state.cleanupRegistered) {
168
+ if (!state.cleanupRegistered) {
169
+ state.cleanupRegistered = true;
170
+ state.reclaimCleanupRegistered = true;
171
+ process.on("exit", () => releaseAllLocksSync(state));
164
172
  return;
165
173
  }
166
- state.cleanupRegistered = true;
167
- process.on("exit", () => releaseAllLocksSync(state));
174
+ if (!state.reclaimCleanupRegistered) {
175
+ state.reclaimCleanupRegistered = true;
176
+ process.on("exit", () => releaseAllReclaimGuardsSync(state));
177
+ }
168
178
  }
169
179
  async function acquire(options) {
170
180
  ensureExitCleanupRegistered();
@@ -184,99 +194,146 @@ export function createSidecarLockManager(key) {
184
194
  const startedAt = Date.now();
185
195
  const retry = options.retry ?? {};
186
196
  const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
197
+ const reclaimGuardPath = `${lockPath}.reclaim`;
198
+ let ownsReclaimGuard = false;
187
199
  let attempt = 0;
188
- while (true) {
189
- let handle = null;
190
- try {
191
- handle = await fs.open(lockPath, "wx");
192
- const payload = await options.payload();
193
- const raw = `${JSON.stringify(payload, null, 2)}\n`;
194
- await handle.writeFile(raw, "utf8");
195
- const snapshot = { raw, payload, stat: await handle.stat() };
196
- const createdHeld = {
197
- count: 1,
198
- handle,
199
- lockPath,
200
- snapshot,
201
- acquiredAt: Date.now(),
202
- metadata: options.metadata ?? {},
203
- };
204
- state.held.set(normalizedTargetPath, createdHeld);
205
- const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
206
- return {
200
+ const waitForRetry = async () => {
201
+ const elapsed = Date.now() - startedAt;
202
+ if ((options.timeoutMs !== undefined &&
203
+ options.timeoutMs !== Number.POSITIVE_INFINITY &&
204
+ elapsed >= options.timeoutMs) ||
205
+ (maxRetries !== undefined && attempt >= maxRetries)) {
206
+ throw Object.assign(new Error(`file lock timeout for ${normalizedTargetPath}`), {
207
+ code: "file_lock_timeout",
207
208
  lockPath,
208
209
  normalizedTargetPath,
209
- release,
210
- [Symbol.asyncDispose]: release,
211
- };
210
+ });
212
211
  }
213
- catch (err) {
214
- if (handle) {
215
- const failedSnapshot = { payload: null };
216
- try {
217
- failedSnapshot.stat = await handle.stat();
218
- }
219
- catch {
220
- // Best-effort cleanup of a failed exclusive create.
221
- }
222
- const current = state.held.get(normalizedTargetPath);
223
- if (current?.handle === handle) {
224
- state.held.delete(normalizedTargetPath);
225
- }
226
- // If payload serialization/write fails, the file may be empty or
227
- // partial JSON, so remove while our exclusive handle is still open.
228
- await fs.rm(lockPath, { force: true }).catch(() => undefined);
229
- await handle.close().catch(() => undefined);
230
- // Windows can refuse removing an open file; retry after close but
231
- // only if the path still points at the file identity we created.
232
- await removeLockIfUnchanged(lockPath, failedSnapshot);
233
- }
234
- if (err.code !== "EEXIST") {
235
- throw err;
236
- }
237
- const nowMs = Date.now();
238
- const snapshot = await readLockSnapshot(lockPath);
239
- if (!snapshot) {
212
+ const remaining = options.timeoutMs === undefined || options.timeoutMs === Number.POSITIVE_INFINITY
213
+ ? Number.POSITIVE_INFINITY
214
+ : Math.max(0, options.timeoutMs - elapsed);
215
+ const delay = Math.min(computeDelayMs(retry, attempt), remaining);
216
+ attempt += 1;
217
+ await new Promise((resolve) => setTimeout(resolve, delay));
218
+ };
219
+ try {
220
+ while (true) {
221
+ if (!ownsReclaimGuard && (await sidecarReclaimGuardExists(reclaimGuardPath))) {
222
+ await waitForRetry();
240
223
  continue;
241
224
  }
242
- const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
243
- if (await shouldReclaim({
244
- lockPath,
245
- normalizedTargetPath,
246
- payload: snapshot?.payload ?? null,
247
- staleMs: options.staleMs,
248
- nowMs,
249
- heldByThisProcess: state.held.has(normalizedTargetPath),
250
- })) {
251
- if (!(await lockSnapshotStillPresent(lockPath, snapshot))) {
252
- continue;
225
+ let handle = null;
226
+ try {
227
+ handle = await fs.open(lockPath, "wx");
228
+ const payload = await options.payload();
229
+ const { raw, ownershipToken } = serializeSidecarLockPayload(payload);
230
+ await handle.writeFile(raw, "utf8");
231
+ const snapshot = { raw, payload, stat: await handle.stat(), ownershipToken };
232
+ const createdHeld = {
233
+ count: 1,
234
+ handle,
235
+ lockPath,
236
+ snapshot,
237
+ acquiredAt: Date.now(),
238
+ metadata: options.metadata ?? {},
239
+ };
240
+ state.held.set(normalizedTargetPath, createdHeld);
241
+ if (ownsReclaimGuard) {
242
+ try {
243
+ await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
244
+ ownsReclaimGuard = false;
245
+ }
246
+ catch (err) {
247
+ await releaseHeldLock(state, normalizedTargetPath, createdHeld, { force: true });
248
+ throw err;
249
+ }
253
250
  }
254
- // A pathname recheck followed by unlink is not atomic: a fresh lock
255
- // can replace the observed file in between. Legacy recovery inputs
256
- // remain accepted, but third-party stale locks always fail closed.
257
- throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
258
- code: "file_lock_stale",
251
+ const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
252
+ return {
259
253
  lockPath,
260
254
  normalizedTargetPath,
261
- });
255
+ release,
256
+ [Symbol.asyncDispose]: release,
257
+ };
262
258
  }
263
- const elapsed = Date.now() - startedAt;
264
- if ((options.timeoutMs !== undefined &&
265
- options.timeoutMs !== Number.POSITIVE_INFINITY &&
266
- elapsed >= options.timeoutMs) ||
267
- (maxRetries !== undefined && attempt >= maxRetries)) {
268
- throw Object.assign(new Error(`file lock timeout for ${normalizedTargetPath}`), {
269
- code: "file_lock_timeout",
259
+ catch (err) {
260
+ if (handle) {
261
+ const failedSnapshot = { payload: null };
262
+ try {
263
+ failedSnapshot.stat = await handle.stat();
264
+ }
265
+ catch {
266
+ // Best-effort cleanup of a failed exclusive create.
267
+ }
268
+ const current = state.held.get(normalizedTargetPath);
269
+ if (current?.handle === handle) {
270
+ state.held.delete(normalizedTargetPath);
271
+ }
272
+ // If payload serialization/write fails, the file may be empty or
273
+ // partial JSON, so remove while our exclusive handle is still open.
274
+ await fs.rm(lockPath, { force: true }).catch(() => undefined);
275
+ await handle.close().catch(() => undefined);
276
+ // Windows can refuse removing an open file; retry after close but
277
+ // only if the path still points at the file identity we created.
278
+ await removeSidecarLockIfUnchanged(lockPath, failedSnapshot);
279
+ }
280
+ if (err.code !== "EEXIST") {
281
+ throw err;
282
+ }
283
+ if (ownsReclaimGuard) {
284
+ await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
285
+ ownsReclaimGuard = false;
286
+ continue;
287
+ }
288
+ const nowMs = Date.now();
289
+ const snapshot = await readSidecarLockSnapshot(lockPath);
290
+ if (!snapshot) {
291
+ continue;
292
+ }
293
+ const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
294
+ if (await shouldReclaim({
270
295
  lockPath,
271
296
  normalizedTargetPath,
272
- });
297
+ payload: snapshot?.payload ?? null,
298
+ staleMs: options.staleMs,
299
+ nowMs,
300
+ heldByThisProcess: state.held.has(normalizedTargetPath),
301
+ })) {
302
+ if (!(await sidecarLockSnapshotStillPresent(lockPath, snapshot))) {
303
+ continue;
304
+ }
305
+ const staleRecovery = options.staleRecovery ?? "fail-closed";
306
+ if (staleRecovery === "remove-if-unchanged") {
307
+ if (!(await tryAcquireSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath))) {
308
+ await waitForRetry();
309
+ continue;
310
+ }
311
+ ownsReclaimGuard = true;
312
+ const removal = await removeStaleSidecarLockIfAllowed({
313
+ lockPath,
314
+ normalizedTargetPath,
315
+ snapshot,
316
+ shouldRemoveStaleLock: options.shouldRemoveStaleLock,
317
+ });
318
+ if (removal === "removed" || removal === "changed") {
319
+ continue;
320
+ }
321
+ await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
322
+ ownsReclaimGuard = false;
323
+ }
324
+ throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
325
+ code: "file_lock_stale",
326
+ lockPath,
327
+ normalizedTargetPath,
328
+ });
329
+ }
330
+ await waitForRetry();
273
331
  }
274
- const remaining = options.timeoutMs === undefined || options.timeoutMs === Number.POSITIVE_INFINITY
275
- ? Number.POSITIVE_INFINITY
276
- : Math.max(0, options.timeoutMs - elapsed);
277
- const delay = Math.min(computeDelayMs(retry, attempt), remaining);
278
- attempt += 1;
279
- await new Promise((resolve) => setTimeout(resolve, delay));
332
+ }
333
+ }
334
+ finally {
335
+ if (ownsReclaimGuard) {
336
+ await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath).catch(() => undefined);
280
337
  }
281
338
  }
282
339
  }
package/docs/config.md CHANGED
@@ -56,7 +56,7 @@ Return the effective configuration: programmatic overrides win, then env vars, t
56
56
  function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
57
57
 
58
58
  type FsSafeLockConfig = {
59
- staleRecovery: "fail-closed" | "remove-if-unchanged"; // legacy value also fails closed
59
+ staleRecovery: "fail-closed" | "remove-if-unchanged";
60
60
  staleMs?: number;
61
61
  timeoutMs?: number;
62
62
  retry?: FileLockRetryOptions;
@@ -65,7 +65,7 @@ type FsSafeLockConfig = {
65
65
 
66
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
67
 
68
- `staleRecovery` defaults to `"fail-closed"`. The deprecated `"remove-if-unchanged"` value remains accepted for source and configuration compatibility, but behaves as `"fail-closed"`. fs-safe never removes a stale third-party lock during acquisition because a pathname recheck followed by unlink cannot prevent deleting a replacement lock.
68
+ `staleRecovery` defaults to `"fail-closed"`. The opt-in `"remove-if-unchanged"` mode requires caller approval and serializes the final snapshot check and unlink with an exclusive `.reclaim` guard. A reclaim guard left by a killed reclaimer fails closed and requires externally coordinated cleanup.
69
69
 
70
70
  ## `getFsSafeLockConfig()`
71
71
 
@@ -53,7 +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" | "remove-if-unchanged"; // legacy value also fails closed
56
+ staleRecovery?: "fail-closed" | "remove-if-unchanged";
57
57
  managerKey?: string; // default `fs-safe.json-store:<filePath>`
58
58
  };
59
59
 
@@ -141,7 +141,7 @@ When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` sha
141
141
 
142
142
  Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
143
143
 
144
- JSON store locks always fail closed on stale sidecars. The deprecated `staleRecovery: "remove-if-unchanged"` value remains accepted for compatibility, but it behaves as `"fail-closed"` and never removes the lock.
144
+ JSON store locks fail closed on stale sidecars by default. Opt-in `staleRecovery: "remove-if-unchanged"` requires caller approval and uses the same exclusive reclaim guard as the low-level sidecar-lock API.
145
145
 
146
146
  The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
147
147
 
@@ -21,7 +21,11 @@ try {
21
21
 
22
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 it should keep waiting or fail.
23
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. Crashed holders leave their sidecar behind; remove those through an application-owned recovery path after you have proved the holder cannot still be writing.
24
+ The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
25
+
26
+ Each new sidecar also carries an internal random ownership token encoded as JSON trailing whitespace. `JSON.parse()` and every payload callback still see exactly the caller-provided object. Only the process that successfully created the sidecar keeps that token as release authority; merely reading token-shaped bytes from disk does not enable this mode. Release compares the in-memory token and exact serialized bytes, and requires the pathname to remain a regular file, instead of requiring an opened descriptor and pathname lookup to report the same inode identity. This preserves ownership checks on filesystems such as Docker Desktop VirtioFS where those two views can legitimately differ. Sidecars created by older releases have no token and retain the legacy identity-plus-content check.
27
+
28
+ The raw sidecar bytes are not a canonical JSON representation: tools that trim or rewrite the trailing whitespace invalidate the ownership token, so release leaves the changed sidecar in place and fails closed. The token distinguishes cooperating acquisitions; it is not a secret and does not make pathname compare-and-remove atomic against a hostile process that can replace files outside the lock protocol.
25
29
 
26
30
  ## API
27
31
 
@@ -51,7 +55,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
51
55
  staleMs?: number; // default 30_000
52
56
  timeoutMs?: number; // overall acquire deadline; default unbounded
53
57
  retry?: FileLockRetryOptions;
54
- staleRecovery?: "fail-closed" | "remove-if-unchanged"; // legacy value also fails closed
58
+ staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
55
59
  allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
56
60
  payload: () => TPayload | Promise<TPayload>;
57
61
  shouldReclaim?: (params: {
@@ -62,7 +66,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
62
66
  nowMs: number;
63
67
  heldByThisProcess: boolean;
64
68
  }) => boolean | Promise<boolean>;
65
- shouldRemoveStaleLock?: (snapshot: { // deprecated; retained but not invoked
69
+ shouldRemoveStaleLock?: (snapshot: {
66
70
  lockPath: string;
67
71
  normalizedTargetPath: string;
68
72
  raw: string;
@@ -170,24 +174,28 @@ const handle = await acquireFileLock(targetPath, {
170
174
  });
171
175
  ```
172
176
 
173
- `heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale and acquisition throws an error with code `file_lock_stale`.
177
+ `heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or attempts caller-approved removal.
178
+
179
+ ## Stale recovery: guarded `remove-if-unchanged`
180
+
181
+ The default `staleRecovery: "fail-closed"` never removes third-party sidecars. Use `staleRecovery: "remove-if-unchanged"` only when your app has a reliable owner-liveness policy and can prove a stale owner cannot still be writing.
174
182
 
175
- ## Stale recovery is fail-closed
183
+ Opt-in recovery creates an exclusive `<lockPath>.reclaim` directory before the final snapshot check and unlink. Every compliant acquirer waits while that guard exists, so two reclaimers cannot perform the check/unlink race that could delete a fresh replacement lock. If another acquirer creates the replacement during the handoff, its exclusive create wins and the reclaimer leaves it untouched.
176
184
 
177
- fs-safe never removes a stale third-party sidecar during acquisition. Checking a pathname's content and identity before unlinking it is not atomic: another process can replace the lock between the final check and the unlink. Every stale result therefore fails closed with error code `file_lock_stale`.
185
+ `shouldRemoveStaleLock` receives the exact lock snapshot that fs-safe inspected. The callback must approve that owner as definitely stale. If the callback is missing, returns false, or the file changed, acquisition fails closed or keeps retrying according to the normal retry policy.
178
186
 
179
- The `"remove-if-unchanged"` value and `shouldRemoveStaleLock` callback remain accepted as deprecated compatibility inputs. They are ignored and the callback is not invoked. To recover, stop or otherwise exclude every process that can acquire the lock, remove the confirmed stale sidecar under that external authority, and retry acquisition.
187
+ A process killed during the short reclaim section can leave the empty `.reclaim` directory behind. That ambiguous state intentionally fails closed; remove the guard only under external authority that excludes every competing lock acquirer.
180
188
 
181
189
  ## What sidecar locks defend against
182
190
 
183
191
  - **Two processes writing the same file at once.** `acquire` serializes the critical section.
184
- - **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks always fail closed and are never unlinked during acquisition.
192
+ - **Accidentally deleting a fresh lock during stale recovery.** Opt-in removal is serialized by the reclaim guard and rechecks the approved snapshot before unlinking.
185
193
  - **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
186
194
 
187
195
  ## What they do **not** defend against
188
196
 
189
197
  - **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
190
- - **Automatic stale lock deletion.** If a process crashes, recover only under external authority that excludes every competing lock acquirer.
198
+ - **Unapproved stale lock deletion.** If a process crashes, use the payload and your own liveness policy before opting into guarded recovery.
191
199
  - **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
192
200
 
193
201
  ## Common patterns
package/docs/writing.md CHANGED
@@ -250,7 +250,7 @@ await fs.write("state.json", body); // succeeds on rclone FUSE
250
250
 
251
251
  **Security note.** `verify-content-with-lock` proves that the bytes observed after rename match the requested write and prevents *cooperating* writers from interleaving. It does **not** prove that the destination still names the temp-file object, retain the Python helper's fd-relative parent pinning, or stop a same-UID process that ignores the advisory lock. Do not use this option on directories writable by untrusted same-UID processes. Strict identity verification remains the default.
252
252
 
253
- Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-is-fail-closed).
253
+ Lock recovery is fail-closed. If a process crashes and leaves the root-level `.fs-safe-write-<sha256>.lock`, a later write reports the stale lock instead of deleting it based on a host-local PID. Recover only under external authority that excludes every competing writer; see [File lock](sidecar-lock.md#stale-recovery-guarded-remove-if-unchanged).
254
254
 
255
255
  ## See also
256
256
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {