@openclaw/fs-safe 0.1.2 → 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.
Files changed (116) hide show
  1. package/CHANGELOG.md +41 -0
  2. package/README.md +39 -2
  3. package/dist/absolute-path.d.ts +14 -0
  4. package/dist/absolute-path.d.ts.map +1 -1
  5. package/dist/absolute-path.js +203 -2
  6. package/dist/advanced.d.ts +2 -1
  7. package/dist/advanced.d.ts.map +1 -1
  8. package/dist/advanced.js +2 -1
  9. package/dist/archive-staging.d.ts.map +1 -1
  10. package/dist/archive-staging.js +81 -7
  11. package/dist/archive.d.ts.map +1 -1
  12. package/dist/archive.js +25 -42
  13. package/dist/bounded-read-stream.d.ts +8 -0
  14. package/dist/bounded-read-stream.d.ts.map +1 -0
  15. package/dist/bounded-read-stream.js +20 -0
  16. package/dist/config.d.ts +1 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/config.js +1 -0
  19. package/dist/directory-guard.d.ts +18 -0
  20. package/dist/directory-guard.d.ts.map +1 -0
  21. package/dist/directory-guard.js +70 -0
  22. package/dist/file-lock.d.ts +4 -2
  23. package/dist/file-lock.d.ts.map +1 -1
  24. package/dist/file-lock.js +13 -2
  25. package/dist/file-store-boundary.d.ts +2 -4
  26. package/dist/file-store-boundary.d.ts.map +1 -1
  27. package/dist/file-store-boundary.js +11 -7
  28. package/dist/file-store-prune.d.ts +12 -0
  29. package/dist/file-store-prune.d.ts.map +1 -0
  30. package/dist/file-store-prune.js +86 -0
  31. package/dist/file-store-source.d.ts +5 -0
  32. package/dist/file-store-source.d.ts.map +1 -0
  33. package/dist/file-store-source.js +30 -0
  34. package/dist/file-store.d.ts +2 -6
  35. package/dist/file-store.d.ts.map +1 -1
  36. package/dist/file-store.js +60 -53
  37. package/dist/guarded-mkdir.d.ts +6 -0
  38. package/dist/guarded-mkdir.d.ts.map +1 -0
  39. package/dist/guarded-mkdir.js +45 -0
  40. package/dist/guarded-mutation.d.ts +33 -0
  41. package/dist/guarded-mutation.d.ts.map +1 -0
  42. package/dist/guarded-mutation.js +76 -0
  43. package/dist/index.d.ts +2 -0
  44. package/dist/index.d.ts.map +1 -1
  45. package/dist/index.js +2 -0
  46. package/dist/json-document-store.d.ts +2 -0
  47. package/dist/json-document-store.d.ts.map +1 -1
  48. package/dist/json-document-store.js +7 -3
  49. package/dist/json-durable-queue.d.ts +45 -0
  50. package/dist/json-durable-queue.d.ts.map +1 -0
  51. package/dist/json-durable-queue.js +200 -0
  52. package/dist/json.d.ts +35 -5
  53. package/dist/json.d.ts.map +1 -1
  54. package/dist/json.js +69 -2
  55. package/dist/local-roots.d.ts.map +1 -1
  56. package/dist/local-roots.js +17 -3
  57. package/dist/lock-config.d.ts +10 -0
  58. package/dist/lock-config.d.ts.map +1 -0
  59. package/dist/lock-config.js +12 -0
  60. package/dist/move-path.d.ts +1 -0
  61. package/dist/move-path.d.ts.map +1 -1
  62. package/dist/move-path.js +195 -7
  63. package/dist/output.d.ts +13 -0
  64. package/dist/output.d.ts.map +1 -0
  65. package/dist/output.js +69 -0
  66. package/dist/path-stat.d.ts +4 -0
  67. package/dist/path-stat.d.ts.map +1 -0
  68. package/dist/path-stat.js +15 -0
  69. package/dist/pinned-write.d.ts.map +1 -1
  70. package/dist/pinned-write.js +44 -19
  71. package/dist/private-temp-workspace.d.ts.map +1 -1
  72. package/dist/private-temp-workspace.js +28 -15
  73. package/dist/regular-file.d.ts.map +1 -1
  74. package/dist/regular-file.js +52 -8
  75. package/dist/replace-directory.d.ts.map +1 -1
  76. package/dist/replace-directory.js +5 -4
  77. package/dist/replace-file.d.ts +1 -1
  78. package/dist/replace-file.d.ts.map +1 -1
  79. package/dist/replace-file.js +93 -31
  80. package/dist/root-impl.d.ts.map +1 -1
  81. package/dist/root-impl.js +57 -51
  82. package/dist/safe-path-segment.d.ts +9 -0
  83. package/dist/safe-path-segment.d.ts.map +1 -0
  84. package/dist/safe-path-segment.js +51 -0
  85. package/dist/secret-file.d.ts.map +1 -1
  86. package/dist/secret-file.js +6 -1
  87. package/dist/sibling-temp.d.ts +1 -0
  88. package/dist/sibling-temp.d.ts.map +1 -1
  89. package/dist/sibling-temp.js +32 -7
  90. package/dist/sidecar-lock.d.ts +2 -0
  91. package/dist/sidecar-lock.d.ts.map +1 -1
  92. package/dist/sidecar-lock.js +93 -13
  93. package/dist/store.d.ts +1 -0
  94. package/dist/store.d.ts.map +1 -1
  95. package/dist/store.js +1 -0
  96. package/dist/temp-target.d.ts.map +1 -1
  97. package/dist/temp-target.js +7 -4
  98. package/dist/test-hooks.d.ts +6 -0
  99. package/dist/test-hooks.d.ts.map +1 -1
  100. package/dist/text-atomic.d.ts +7 -0
  101. package/dist/text-atomic.d.ts.map +1 -1
  102. package/dist/text-atomic.js +3 -2
  103. package/dist/trash.d.ts.map +1 -1
  104. package/dist/trash.js +48 -13
  105. package/docs/advanced.md +10 -0
  106. package/docs/atomic.md +33 -5
  107. package/docs/config.md +32 -3
  108. package/docs/index.md +3 -2
  109. package/docs/json-store.md +4 -0
  110. package/docs/json.md +34 -0
  111. package/docs/output.md +92 -0
  112. package/docs/sidecar-lock.md +13 -12
  113. package/docs/store.md +33 -0
  114. package/docs/temp.md +10 -2
  115. package/docs/testing.md +10 -0
  116. package/package.json +7 -2
@@ -1,6 +1,6 @@
1
1
  # File lock
2
2
 
3
- `acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry, stale-lock reclaim, and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
3
+ `acquireFileLock()` and `withFileLock()` provide a cross-process file lock with retry and process-exit cleanup. The lock is implemented as a sidecar file (e.g. `state.json` ↔ `state.json.lock`) — only one acquirer can create the sidecar with `O_CREAT | O_EXCL` at a time.
4
4
 
5
5
  ```ts
6
6
  import { acquireFileLock } from "@openclaw/fs-safe/file-lock";
@@ -19,9 +19,9 @@ try {
19
19
 
20
20
  ## Why sidecar?
21
21
 
22
- The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether to take it over.
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. Crashes still need the reclaim path.
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.
25
25
 
26
26
  ## API
27
27
 
@@ -48,9 +48,10 @@ function createFileLockManager(key: string): FileLockManager;
48
48
  type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
49
49
  managerKey?: string; // optional in-process manager namespace
50
50
  lockPath?: string; // override; defaults to `${targetPath}.lock`
51
- staleMs: number; // how long until a held lock is considered stale
51
+ staleMs?: number; // default 30_000
52
52
  timeoutMs?: number; // overall acquire deadline; default unbounded
53
53
  retry?: FileLockRetryOptions;
54
+ staleRecovery?: "fail-closed"; // default
54
55
  allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
55
56
  payload: () => TPayload | Promise<TPayload>;
56
57
  shouldReclaim?: (params: {
@@ -100,7 +101,7 @@ try {
100
101
  }
101
102
  ```
102
103
 
103
- If your process dies before `release()` runs and skips the exit handler, the next acquirer reclaims the lock once `staleMs` elapses (or your `shouldReclaim` returns true).
104
+ If your process dies before `release()` runs and skips the exit handler, the sidecar remains. Once `staleMs` elapses (or your `shouldReclaim` returns true), acquisition fails closed instead of deleting by path, because Node cannot atomically bind that deletion to the file that was inspected.
104
105
 
105
106
  ## `withFileLock` — common shape made one-liner
106
107
 
@@ -139,9 +140,9 @@ await handle.release();
139
140
  await locks.drain();
140
141
  ```
141
142
 
142
- ## Reclaim policy: `shouldReclaim`
143
+ ## Stale policy: `shouldReclaim`
143
144
 
144
- The default policy reclaims locks whose `acquiredAt` is older than `staleMs`. Pass a custom callback when you want a richer notion of "is the holder still alive":
145
+ The default policy treats locks whose `createdAt` is older than `staleMs` as stale. Pass a custom callback when you want a richer notion of "is the holder still alive":
145
146
 
146
147
  ```ts
147
148
  import { kill } from "node:process";
@@ -155,26 +156,26 @@ const handle = await acquireFileLock(targetPath, {
155
156
  if (!Number.isFinite(pid)) return true;
156
157
  try {
157
158
  kill(pid, 0);
158
- return false; // process still alive — don't reclaim
159
+ return false; // process still alive — keep waiting
159
160
  } catch {
160
- return true; // process gone — reclaim
161
+ return true; // process gone — fail closed for recovery
161
162
  }
162
163
  },
163
164
  });
164
165
  ```
165
166
 
166
- `heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case).
167
+ `heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result does not delete the sidecar; it lets the acquire loop stop waiting once the retry/timeout policy says to give up.
167
168
 
168
169
  ## What sidecar locks defend against
169
170
 
170
171
  - **Two processes writing the same file at once.** `acquire` serializes the critical section.
171
- - **A crashed holder leaving a stale lock.** `staleMs` plus optional `shouldReclaim` recovers it.
172
+ - **Accidentally deleting a fresh lock during stale recovery.** Stale third-party locks fail closed because safe compare-and-unlink is not available through Node's path APIs.
172
173
  - **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
173
174
 
174
175
  ## What they do **not** defend against
175
176
 
176
177
  - **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
177
- - **Holders that never call `release` and have no liveness check.** Without a real `shouldReclaim`, the lock relies on `staleMs` alone pick a deadline that is comfortably longer than your real work but short enough to recover from crashes.
178
+ - **Automatic stale lock deletion.** If a process crashes, use the payload and your own supervisor/process table to decide when to remove the sidecar.
178
179
  - **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
179
180
 
180
181
  ## Common patterns
package/docs/store.md CHANGED
@@ -9,9 +9,13 @@ The `store` subpath bundles two managed wrappers around the same safe-write prim
9
9
 
10
10
  ```ts
11
11
  import {
12
+ ensureJsonDurableQueueDirs,
12
13
  fileStore,
13
14
  fileStoreSync,
14
15
  jsonStore,
16
+ loadPendingJsonDurableQueueEntries,
17
+ resolveJsonDurableQueueEntryPaths,
18
+ writeJsonDurableQueueEntry,
15
19
  type FileStore,
16
20
  type FileStoreOptions,
17
21
  type FileStoreSync,
@@ -25,6 +29,7 @@ import {
25
29
  | [`fileStore()`](file-store.md) | Multi-file directories with safe relative paths, size limits, atomic replacement, stream writes, copy-in, and TTL-based pruning. |
26
30
  | `fileStoreSync()` | Synchronous variant of `fileStore()` for places that genuinely cannot await. |
27
31
  | [`jsonStore()`](json-store.md) | A single keyed JSON state file with explicit fallback, atomic writes, and optional sidecar locking around read-modify-write updates. |
32
+ | Durable JSON queue helpers | Append/load/ack JSON entry files using atomic writes and delivered markers. |
28
33
  | [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for credentials, tokens, and per-agent state at `0600` files under `0700` directories. |
29
34
 
30
35
  `fileStore().json("rel.json")` and `jsonStore({ filePath })` are intentionally separate primitives. Use `fileStore().json(...)` when JSON state lives alongside other files in the same managed directory; use `jsonStore({ filePath })` when you have a single absolute path and want the keyed JSON shape directly.
@@ -34,6 +39,34 @@ import {
34
39
  - **Multi-file directory under one root** — reach for `fileStore()`. It exposes `write`, `writeJson`, `writeText`, `writeStream`, `read*`, `open`, `copyIn`, `remove`, and `pruneExpired` against safe relative paths.
35
40
  - **One JSON state file** — reach for `jsonStore({ filePath })`. Its `update()` and `updateOr()` methods cover the merge-into-defaults and read-modify-write cases.
36
41
  - **Credentials or tokens** — pass `private: true` to `fileStore()`. Same store shape; writes route through the secret-file atomic path with `0600`/`0700` permissions.
42
+ - **Durable work queues** — use the durable JSON queue helpers when each work item is a standalone JSON file and acknowledgement is represented by moving it through a short-lived `.delivered` marker.
43
+
44
+ ## Durable JSON queues
45
+
46
+ The durable queue helpers are intentionally low-level. They do not decide retry,
47
+ dedupe, or recovery policy; they just provide the filesystem mechanics that
48
+ several queue implementations otherwise rewrite by hand.
49
+
50
+ ```ts
51
+ await ensureJsonDurableQueueDirs({ queueDir, failedDir });
52
+
53
+ const paths = resolveJsonDurableQueueEntryPaths(queueDir, id);
54
+ await writeJsonDurableQueueEntry({
55
+ filePath: paths.jsonPath,
56
+ entry,
57
+ tempPrefix: "queue",
58
+ });
59
+
60
+ const pending = await loadPendingJsonDurableQueueEntries({ queueDir, tempPrefix: "queue" });
61
+ ```
62
+
63
+ `id` must be a single safe path segment: non-empty, not dot-prefixed, and made
64
+ from letters, numbers, `_`, `-`, and `.`. Slashes, backslashes, NUL bytes, `.`,
65
+ and `..` are rejected.
66
+
67
+ Use `ackJsonDurableQueueEntry()` after durable processing succeeds and
68
+ `moveJsonDurableQueueEntryToFailed()` when the caller wants to quarantine an
69
+ entry for inspection.
37
70
 
38
71
  ## Related pages
39
72
 
package/docs/temp.md CHANGED
@@ -175,9 +175,15 @@ const result = await writeSiblingTempFile<string>({
175
175
 
176
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
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
+
178
182
  ### `writeViaSiblingTempPath`
179
183
 
180
- A higher-level convenience write content + rename in one call:
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:
181
187
 
182
188
  ```ts
183
189
  import { writeViaSiblingTempPath } from "@openclaw/fs-safe/advanced";
@@ -191,7 +197,9 @@ await writeViaSiblingTempPath({
191
197
  });
192
198
  ```
193
199
 
194
- If `replaceFileAtomic` does what you need, prefer that — `writeViaSiblingTempPath` is the lower-level building block.
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.
195
203
 
196
204
  ## Secure temp root
197
205
 
package/docs/testing.md CHANGED
@@ -159,6 +159,16 @@ Run only the security boundary corpus while iterating on root/path/archive/temp
159
159
  pnpm test:security
160
160
  ```
161
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
+
162
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.
163
173
 
164
174
  ## See also
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/fs-safe",
3
- "version": "0.1.2",
3
+ "version": "0.2.0",
4
4
  "description": "Capability-style filesystem roots for Node.js apps that handle untrusted relative paths.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -36,6 +36,10 @@
36
36
  "types": "./dist/path.d.ts",
37
37
  "default": "./dist/path.js"
38
38
  },
39
+ "./output": {
40
+ "types": "./dist/output.d.ts",
41
+ "default": "./dist/output.js"
42
+ },
39
43
  "./advanced": {
40
44
  "types": "./dist/advanced.d.ts",
41
45
  "default": "./dist/advanced.js"
@@ -97,11 +101,12 @@
97
101
  "benchmark": "node scripts/benchmark.mjs",
98
102
  "build": "tsc -p tsconfig.json",
99
103
  "lint:file-size": "node scripts/check-file-size.mjs",
104
+ "lint:fs-boundary": "node scripts/check-fs-boundary-primitives.mjs",
100
105
  "prepack": "node scripts/prepack-build.mjs",
101
106
  "test": "vitest run",
102
107
  "test:coverage": "vitest run --coverage",
103
108
  "test:security": "vitest run test/fs-safe.test.ts test/openclaw-read-bypass-parity.test.ts test/openclaw-write-bypass-parity.test.ts test/additional-bypass-parity.test.ts test/adversarial-boundary-payloads.test.ts",
104
- "check": "pnpm lint:file-size && pnpm build && pnpm test",
109
+ "check": "pnpm lint:file-size && pnpm lint:fs-boundary && pnpm build && pnpm test",
105
110
  "docs:site": "node scripts/build-docs-site.mjs"
106
111
  },
107
112
  "optionalDependencies": {