@openclaw/fs-safe 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (207) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +58 -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 +25 -0
  26. package/dist/file-store-boundary.d.ts.map +1 -0
  27. package/dist/file-store-boundary.js +168 -0
  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 +122 -125
  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/path.d.ts +1 -0
  70. package/dist/path.d.ts.map +1 -1
  71. package/dist/path.js +6 -3
  72. package/dist/pinned-write.d.ts.map +1 -1
  73. package/dist/pinned-write.js +44 -19
  74. package/dist/private-file-store.d.ts +5 -7
  75. package/dist/private-file-store.d.ts.map +1 -1
  76. package/dist/private-file-store.js +21 -34
  77. package/dist/private-temp-workspace.d.ts.map +1 -1
  78. package/dist/private-temp-workspace.js +28 -15
  79. package/dist/regular-file.d.ts.map +1 -1
  80. package/dist/regular-file.js +52 -8
  81. package/dist/replace-directory.d.ts.map +1 -1
  82. package/dist/replace-directory.js +5 -4
  83. package/dist/replace-file.d.ts +1 -1
  84. package/dist/replace-file.d.ts.map +1 -1
  85. package/dist/replace-file.js +93 -31
  86. package/dist/root-context.d.ts +23 -0
  87. package/dist/root-context.d.ts.map +1 -0
  88. package/dist/root-context.js +62 -0
  89. package/dist/root-errors.d.ts +4 -0
  90. package/dist/root-errors.d.ts.map +1 -0
  91. package/dist/root-errors.js +21 -0
  92. package/dist/root-impl.d.ts +108 -0
  93. package/dist/root-impl.d.ts.map +1 -0
  94. package/dist/{safe-root.js → root-impl.js} +484 -265
  95. package/dist/root.d.ts +1 -107
  96. package/dist/root.d.ts.map +1 -1
  97. package/dist/root.js +1 -1332
  98. package/dist/safe-path-segment.d.ts +9 -0
  99. package/dist/safe-path-segment.d.ts.map +1 -0
  100. package/dist/safe-path-segment.js +51 -0
  101. package/dist/secret-file.d.ts.map +1 -1
  102. package/dist/secret-file.js +6 -1
  103. package/dist/sibling-temp.d.ts +1 -0
  104. package/dist/sibling-temp.d.ts.map +1 -1
  105. package/dist/sibling-temp.js +32 -7
  106. package/dist/sidecar-lock.d.ts +2 -0
  107. package/dist/sidecar-lock.d.ts.map +1 -1
  108. package/dist/sidecar-lock.js +93 -13
  109. package/dist/store.d.ts +1 -0
  110. package/dist/store.d.ts.map +1 -1
  111. package/dist/store.js +1 -0
  112. package/dist/temp-target.d.ts.map +1 -1
  113. package/dist/temp-target.js +7 -4
  114. package/dist/test-hooks.d.ts +6 -0
  115. package/dist/test-hooks.d.ts.map +1 -1
  116. package/dist/text-atomic.d.ts +7 -0
  117. package/dist/text-atomic.d.ts.map +1 -1
  118. package/dist/text-atomic.js +3 -2
  119. package/dist/trash.d.ts.map +1 -1
  120. package/dist/trash.js +48 -13
  121. package/docs/advanced.md +110 -0
  122. package/docs/archive.md +199 -0
  123. package/docs/atomic.md +191 -0
  124. package/docs/config.md +94 -0
  125. package/docs/contributing.md +80 -0
  126. package/docs/errors.md +154 -0
  127. package/docs/file-store.md +213 -0
  128. package/docs/filename.md +0 -0
  129. package/docs/index.md +77 -0
  130. package/docs/install-path.md +167 -0
  131. package/docs/install.md +150 -0
  132. package/docs/json-store.md +204 -0
  133. package/docs/json.md +192 -0
  134. package/docs/local-roots.md +164 -0
  135. package/docs/output.md +92 -0
  136. package/docs/path-scope.md +130 -0
  137. package/docs/path.md +191 -0
  138. package/docs/permissions.md +95 -0
  139. package/docs/private-file-store.md +45 -0
  140. package/docs/python-helper.md +103 -0
  141. package/docs/quickstart.md +147 -0
  142. package/docs/reading.md +174 -0
  143. package/docs/regular-file.md +175 -0
  144. package/docs/root.md +205 -0
  145. package/docs/secret-file.md +154 -0
  146. package/docs/secure-file.md +82 -0
  147. package/docs/security-model.md +99 -0
  148. package/docs/sidecar-lock.md +230 -0
  149. package/docs/store.md +77 -0
  150. package/docs/temp.md +284 -0
  151. package/docs/test-hooks.md +74 -0
  152. package/docs/testing.md +178 -0
  153. package/docs/timing.md +121 -0
  154. package/docs/types.md +155 -0
  155. package/docs/walk.md +63 -0
  156. package/docs/writing.md +222 -0
  157. package/package.json +10 -2
  158. package/dist/archive-utils.d.ts +0 -3
  159. package/dist/archive-utils.d.ts.map +0 -1
  160. package/dist/archive-utils.js +0 -26
  161. package/dist/boundary-file-read.d.ts +0 -44
  162. package/dist/boundary-file-read.d.ts.map +0 -1
  163. package/dist/boundary-file-read.js +0 -129
  164. package/dist/boundary-path.d.ts +0 -39
  165. package/dist/boundary-path.d.ts.map +0 -1
  166. package/dist/boundary-path.js +0 -598
  167. package/dist/fs-pinned-path-helper.d.ts +0 -7
  168. package/dist/fs-pinned-path-helper.d.ts.map +0 -1
  169. package/dist/fs-pinned-path-helper.js +0 -182
  170. package/dist/fs-pinned-write-helper.d.ts +0 -21
  171. package/dist/fs-pinned-write-helper.d.ts.map +0 -1
  172. package/dist/fs-pinned-write-helper.js +0 -263
  173. package/dist/hardlink-guards.d.ts +0 -7
  174. package/dist/hardlink-guards.d.ts.map +0 -1
  175. package/dist/hardlink-guards.js +0 -30
  176. package/dist/install-safe-path.d.ts +0 -20
  177. package/dist/install-safe-path.d.ts.map +0 -1
  178. package/dist/install-safe-path.js +0 -94
  179. package/dist/json-file.d.ts +0 -3
  180. package/dist/json-file.d.ts.map +0 -1
  181. package/dist/json-file.js +0 -123
  182. package/dist/json-files.d.ts +0 -20
  183. package/dist/json-files.d.ts.map +0 -1
  184. package/dist/json-files.js +0 -153
  185. package/dist/path-alias-guards.d.ts +0 -19
  186. package/dist/path-alias-guards.d.ts.map +0 -1
  187. package/dist/path-alias-guards.js +0 -21
  188. package/dist/path-guards.d.ts +0 -7
  189. package/dist/path-guards.d.ts.map +0 -1
  190. package/dist/path-guards.js +0 -49
  191. package/dist/path-safety.d.ts +0 -12
  192. package/dist/path-safety.d.ts.map +0 -1
  193. package/dist/path-safety.js +0 -50
  194. package/dist/safe-open-sync.d.ts +0 -24
  195. package/dist/safe-open-sync.d.ts.map +0 -1
  196. package/dist/safe-open-sync.js +0 -71
  197. package/dist/safe-root.d.ts +0 -123
  198. package/dist/safe-root.d.ts.map +0 -1
  199. package/dist/secure-temp-workspace.d.ts +0 -25
  200. package/dist/secure-temp-workspace.d.ts.map +0 -1
  201. package/dist/secure-temp-workspace.js +0 -136
  202. package/dist/sibling-temp-file.d.ts +0 -16
  203. package/dist/sibling-temp-file.d.ts.map +0 -1
  204. package/dist/sibling-temp-file.js +0 -73
  205. package/dist/sibling-temp-write.d.ts +0 -8
  206. package/dist/sibling-temp-write.d.ts.map +0 -1
  207. package/dist/sibling-temp-write.js +0 -40
@@ -0,0 +1,167 @@
1
+ # Install paths
2
+
3
+ Helpers for code that creates per-name install directories under a trusted base — typical for plugins, packages, snapshots, anywhere you want `<base>/<safe-name>/`. The combination of [`resolveSafeInstallDir`](#resolvesafeinstalldir) and [`assertCanonicalPathWithinBase`](#assertcanonicalpathwithinbase) gives you "compute the install path safely, then re-verify after creation."
4
+
5
+ ```ts
6
+ import {
7
+ assertCanonicalPathWithinBase,
8
+ resolveSafeInstallDir,
9
+ safeDirName,
10
+ safePathSegmentHashed,
11
+ } from "@openclaw/fs-safe/advanced";
12
+ ```
13
+
14
+ ## `resolveSafeInstallDir`
15
+
16
+ ```ts
17
+ function resolveSafeInstallDir(params: {
18
+ baseDir: string;
19
+ id: string;
20
+ invalidNameMessage: string;
21
+ nameEncoder?: (id: string) => string; // default safeDirName
22
+ }): { ok: true; path: string } | { ok: false; error: string };
23
+ ```
24
+
25
+ Computes the absolute install directory for `id` under `baseDir`, after running `id` through `nameEncoder` (`safeDirName` by default). Verifies the result stays inside `baseDir` — anything that would escape returns `{ ok: false, error: invalidNameMessage }`.
26
+
27
+ ```ts
28
+ const r = resolveSafeInstallDir({
29
+ baseDir: "/srv/plugins",
30
+ id: "@scope/my-plugin",
31
+ invalidNameMessage: "invalid plugin name",
32
+ });
33
+ if (!r.ok) return reply(400, r.error);
34
+
35
+ await fs.mkdir(r.path, { recursive: true });
36
+ ```
37
+
38
+ For ids whose default-sanitized form might collide (e.g. `"foo/bar"` and `"foo\\bar"` both map to `"foo__bar"`), pass `nameEncoder: safePathSegmentHashed` to append a content hash:
39
+
40
+ ```ts
41
+ const r = resolveSafeInstallDir({
42
+ baseDir: "/srv/plugins",
43
+ id: untrustedId,
44
+ invalidNameMessage: "invalid plugin name",
45
+ nameEncoder: safePathSegmentHashed,
46
+ });
47
+ ```
48
+
49
+ The helper does **not** create the directory — it returns the path. Pair with `fs.mkdir`, [`Root.mkdir`](root.md), or `assertCanonicalPathWithinBase` before/after creation as needed.
50
+
51
+ ## `assertCanonicalPathWithinBase`
52
+
53
+ Async. Verifies that a candidate absolute path's canonical real path stays inside the base. Useful as a post-`mkdir` check, or when you have an existing path you didn't compute yourself.
54
+
55
+ ```ts
56
+ function assertCanonicalPathWithinBase(params: {
57
+ baseDir: string;
58
+ candidatePath: string;
59
+ boundaryLabel: string;
60
+ }): Promise<void>;
61
+ ```
62
+
63
+ Throws if the candidate resolves outside `baseDir` after `realpath`. The `boundaryLabel` is included in the error message ("Invalid path: must stay within {boundaryLabel}").
64
+
65
+ ```ts
66
+ await assertCanonicalPathWithinBase({
67
+ baseDir: "/srv/plugins",
68
+ candidatePath: "/srv/plugins/my-plugin",
69
+ boundaryLabel: "plugin install dir",
70
+ });
71
+ ```
72
+
73
+ If the candidate does not exist, the helper validates the parent directory instead — useful for "the directory I'm about to create" semantics.
74
+
75
+ ## Segment sanitizers
76
+
77
+ ### `safeDirName`
78
+
79
+ Returns a directory-safe segment derived from `input` by replacing `/` and `\` with `__`. Trims whitespace; returns an empty string if the input was only whitespace.
80
+
81
+ ```ts
82
+ safeDirName("@scope/my-plugin"); // "@scope__my-plugin"
83
+ safeDirName("../../etc"); // "..__..__etc"
84
+ safeDirName("plugin-v1"); // "plugin-v1"
85
+ safeDirName(""); // ""
86
+ ```
87
+
88
+ `safeDirName` does *not* try to be exhaustive about Windows-reserved names or special characters. It is purely a separator-stripping pass — `resolveSafeInstallDir` adds the boundary check on top so an `"../../etc"` input cannot escape `baseDir`.
89
+
90
+ For stricter sanitization, use `safePathSegmentHashed`.
91
+
92
+ ### `safePathSegmentHashed`
93
+
94
+ Returns a directory-safe segment **plus** a short content hash when sanitization changed the input or when the safe form is too long. Use this when input collisions matter:
95
+
96
+ ```ts
97
+ safePathSegmentHashed("plugin-v1"); // "plugin-v1" (unchanged short input)
98
+ safePathSegmentHashed("plugin/v1"); // "plugin-v1-3f2a..."
99
+ safePathSegmentHashed("plugin\\v1"); // "plugin-v1-91c4..." (different hash; same safe form)
100
+ safePathSegmentHashed("Über@"); // "ber-9aae..."
101
+ safePathSegmentHashed(""); // "skill" (empty fallback)
102
+ safePathSegmentHashed("."); // "skill"
103
+ ```
104
+
105
+ The sanitization is more aggressive than `safeDirName`: any character not in `[A-Za-z0-9._-]` becomes `-`, runs of `-` collapse, leading and trailing `-` are stripped, the empty/`.`/`..` fallback is `"skill"`. Long results are truncated to 50 chars before the hash is appended.
106
+
107
+ The hash is the first 10 hex chars of `sha256(originalInput)`. It guarantees that two distinct inputs which sanitize to the same string yield distinct outputs.
108
+
109
+ ## Common patterns
110
+
111
+ ### Install a plugin
112
+
113
+ ```ts
114
+ import { resolveSafeInstallDir, assertCanonicalPathWithinBase, safePathSegmentHashed } from "@openclaw/fs-safe/advanced";
115
+ import { extractArchive } from "@openclaw/fs-safe/archive";
116
+ import fs from "node:fs/promises";
117
+
118
+ const r = resolveSafeInstallDir({
119
+ baseDir: "/srv/plugins",
120
+ id: untrustedName,
121
+ invalidNameMessage: "invalid plugin name",
122
+ nameEncoder: safePathSegmentHashed,
123
+ });
124
+ if (!r.ok) return reply(400, r.error);
125
+
126
+ await fs.mkdir(r.path, { recursive: true, mode: 0o755 });
127
+ await assertCanonicalPathWithinBase({
128
+ baseDir: "/srv/plugins",
129
+ candidatePath: r.path,
130
+ boundaryLabel: "plugin install dir",
131
+ });
132
+
133
+ await extractArchive({
134
+ archivePath: pluginZip,
135
+ destDir: r.path,
136
+ kind: "zip",
137
+ timeoutMs: 30_000,
138
+ });
139
+ ```
140
+
141
+ ### Per-version snapshot directories
142
+
143
+ ```ts
144
+ const snap = resolveSafeInstallDir({
145
+ baseDir: "/srv/snapshots",
146
+ id: `${runId}-${version}`,
147
+ invalidNameMessage: "invalid snapshot id",
148
+ });
149
+ if (!snap.ok) throw new Error(snap.error);
150
+ await fs.mkdir(snap.path, { recursive: true });
151
+ ```
152
+
153
+ ### Reject and log on bad input
154
+
155
+ ```ts
156
+ const r = resolveSafeInstallDir({ baseDir, id, invalidNameMessage: "bad name" });
157
+ if (!r.ok) {
158
+ logger.warn({ id, base: baseDir, error: r.error }, "rejected install attempt");
159
+ return reply(400, r.error);
160
+ }
161
+ ```
162
+
163
+ ## See also
164
+
165
+ - [`root()`](root.md) — when the install dir becomes a root for further writes.
166
+ - [Filenames](filename.md) — `sanitizeUntrustedFileName` for file-name (not directory-name) sanitization.
167
+ - [Archive extraction](archive.md) — extract into the install dir computed by these helpers.
@@ -0,0 +1,150 @@
1
+ # Install
2
+
3
+ `fs-safe` is published to npm as `@openclaw/fs-safe`. It targets Node 20.11 or newer, ships ESM only, and works on macOS, Linux, and Windows.
4
+
5
+ ## Package managers
6
+
7
+ ```bash
8
+ pnpm add @openclaw/fs-safe
9
+ ```
10
+
11
+ ```bash
12
+ npm install @openclaw/fs-safe
13
+ ```
14
+
15
+ ```bash
16
+ yarn add @openclaw/fs-safe
17
+ ```
18
+
19
+ ```bash
20
+ bun add @openclaw/fs-safe
21
+ ```
22
+
23
+ ## Node version
24
+
25
+ Minimum **Node 20.11**. The package uses `fs.promises`, `fs.constants.O_NOFOLLOW` where available, and `node:stream/promises`. Earlier Node releases will fail at import time.
26
+
27
+ Verify the runtime:
28
+
29
+ ```bash
30
+ node --version
31
+ # v20.11.0 or newer
32
+ ```
33
+
34
+ ## TypeScript
35
+
36
+ Types ship with the package — no `@types/openclaw__fs-safe` needed. The `exports` map in `package.json` provides typed entries for every subpath:
37
+
38
+ ```ts
39
+ import { root, FsSafeError } from "@openclaw/fs-safe";
40
+ import { writeJson } from "@openclaw/fs-safe/json";
41
+ import { extractArchive } from "@openclaw/fs-safe/archive";
42
+ ```
43
+
44
+ A working `tsconfig.json` for consumers:
45
+
46
+ ```jsonc
47
+ {
48
+ "compilerOptions": {
49
+ "target": "es2022",
50
+ "module": "node18",
51
+ "moduleResolution": "node16",
52
+ "strict": true,
53
+ "esModuleInterop": true,
54
+ "skipLibCheck": true
55
+ }
56
+ }
57
+ ```
58
+
59
+ ## Subpath exports
60
+
61
+ Use the main entry for the common surface, or the focused subpaths when you want a leaner import or to depend on a narrower contract:
62
+
63
+ | Subpath | Contents |
64
+ |---|---|
65
+ | `@openclaw/fs-safe` | Small common surface: `root`, root types, and errors. |
66
+ | `@openclaw/fs-safe/root` | `root()`, `Root`, `RootDefaults`, related types. |
67
+ | `@openclaw/fs-safe/config` | Process-global Python helper configuration. |
68
+ | `@openclaw/fs-safe/path` | `isPathInside`, `safeRealpathSync`, `isWithinDir`, error helpers. |
69
+ | `@openclaw/fs-safe/json` | `tryReadJson`, `readJson`, `readJsonIfExists`, `writeJson`, sync variants. |
70
+ | `@openclaw/fs-safe/store` | `fileStore()`, `fileStoreSync()`, and `jsonStore<T>()`. |
71
+ | `@openclaw/fs-safe/secret` | Secret file read/write helpers. |
72
+ | `@openclaw/fs-safe/atomic` | `replaceFileAtomic`, `writeTextAtomic`, `replaceDirectoryAtomic`, `movePathWithCopyFallback`. |
73
+ | `@openclaw/fs-safe/temp` | `tempWorkspace`, `withTempWorkspace`, sync variants, `resolveSecureTempRoot`. |
74
+ | `@openclaw/fs-safe/secure-file` | `readSecureFile` for pinned absolute file reads with permissions checks. |
75
+ | `@openclaw/fs-safe/file-lock` | `acquireFileLock`, `withFileLock`, `createFileLockManager`, and related lock types. |
76
+ | `@openclaw/fs-safe/permissions` | POSIX mode and Windows ACL inspection/remediation helpers. |
77
+ | `@openclaw/fs-safe/walk` | `walkDirectory`, `walkDirectorySync`, related types. Budget-bounded, not root-bounded. |
78
+ | `@openclaw/fs-safe/archive` | `extractArchive`, `resolveArchiveKind`, limits, preflight helpers. |
79
+ | `@openclaw/fs-safe/advanced` | Lower-level composition helpers: path scopes, root-file open, install paths, local-root readers, temp-file targets, sibling-temp writes, regular-file helpers, `pathExists`, `withTimeout`, and related advanced types. This surface is less stable than the focused public subpaths. |
80
+ | `@openclaw/fs-safe/errors` | `FsSafeError`, `FsSafeErrorCode`. |
81
+ | `@openclaw/fs-safe/types` | Shared types: `DirEntry`, `PathStat`, `BasePathOptions`, … |
82
+ | `@openclaw/fs-safe/test-hooks` | Test-only hooks for injecting races. Active under `NODE_ENV=test`. |
83
+
84
+ ## Runtime dependencies
85
+
86
+ `@openclaw/fs-safe` lists `jszip` and `tar` as optional dependencies for [archive extraction](archive.md). They are loaded lazily and only required when ZIP/TAR helpers run. Installs that omit optional dependencies can still import and use every non-archive subpath; archive calls fail with a clear missing-optional-dependency message.
87
+
88
+ There are no peer dependencies and no native build step.
89
+
90
+ ## Python helper policy
91
+
92
+ On POSIX, `root()` uses one persistent Python helper process for the
93
+ fd-relative operations Node does not expose cleanly. The default is `auto`: use
94
+ the helper when it starts, fall back to Node-only behavior when it is disabled
95
+ or unavailable.
96
+
97
+ ```ts
98
+ import { configureFsSafePython } from "@openclaw/fs-safe/config";
99
+
100
+ configureFsSafePython({ mode: "auto" }); // default
101
+ configureFsSafePython({ mode: "off" }); // never spawn Python
102
+ configureFsSafePython({ mode: "require" }); // fail closed if unavailable
103
+ ```
104
+
105
+ Environment variables are read at runtime:
106
+
107
+ ```bash
108
+ FS_SAFE_PYTHON_MODE=off # auto | off | require
109
+ FS_SAFE_PYTHON=/usr/bin/python3
110
+ ```
111
+
112
+ OpenClaw compatibility aliases are also accepted:
113
+ `OPENCLAW_FS_SAFE_PYTHON_MODE`, `OPENCLAW_FS_SAFE_PYTHON`,
114
+ `OPENCLAW_PINNED_PYTHON`, and `OPENCLAW_PINNED_WRITE_PYTHON`.
115
+
116
+ Disabling Python keeps the public API working, but downgrades POSIX mutation
117
+ hardening from fd-relative syscalls to Node path operations guarded by lexical
118
+ and canonical checks plus identity verification. Use `require` for
119
+ security-sensitive deployments where that downgrade should be a startup/runtime
120
+ failure instead of a fallback. The full tradeoff is documented in
121
+ [Python helper policy](python-helper.md).
122
+
123
+ ## Verify the install
124
+
125
+ ```ts
126
+ import { root, FsSafeError } from "@openclaw/fs-safe";
127
+ import os from "node:os";
128
+ import path from "node:path";
129
+
130
+ const dir = path.join(os.tmpdir(), "fs-safe-smoke");
131
+ await import("node:fs/promises").then((fs) => fs.mkdir(dir, { recursive: true }));
132
+
133
+ const fs = await root(dir);
134
+ await fs.write("hello.txt", "ok\n");
135
+ console.log(await fs.readText("hello.txt"));
136
+
137
+ try {
138
+ await fs.write("../escape.txt", "x");
139
+ } catch (err) {
140
+ if (err instanceof FsSafeError) console.log("blocked:", err.code);
141
+ }
142
+ ```
143
+
144
+ If the script prints `ok` followed by `blocked: outside-workspace`, your install is healthy.
145
+
146
+ ## Next
147
+
148
+ - [Quickstart](quickstart.md) — write, read, atomic, temp.
149
+ - [Security model](security-model.md) — what the boundary defends against.
150
+ - [Errors](errors.md) — the closed code union you'll be catching.
@@ -0,0 +1,204 @@
1
+ # JSON store
2
+
3
+ `jsonStore` is exported from `@openclaw/fs-safe/store`. It is the absolute-path
4
+ convenience wrapper for `fileStore(...).json(...)`: a small read-modify-write
5
+ handle around a single JSON file. It bakes in atomic writes, explicit fallback
6
+ reads, and optional cross-process locking via
7
+ [`acquireFileLock`](sidecar-lock.md).
8
+
9
+ ```ts
10
+ import { jsonStore } from "@openclaw/fs-safe/store";
11
+
12
+ const settings = jsonStore<{ theme: "light" | "dark"; volume: number }>({
13
+ filePath: "/var/lib/app/settings.json",
14
+ });
15
+
16
+ const current = await settings.readOr({ theme: "dark", volume: 0.7 });
17
+ await settings.write({ ...current, volume: 1 });
18
+ await settings.updateOr({ theme: "dark", volume: 0.7 }, (prev) => ({ ...prev, theme: "light" }));
19
+ ```
20
+
21
+ If you already have a store/root context, prefer binding the JSON file from that
22
+ store:
23
+
24
+ ```ts
25
+ import { fileStore } from "@openclaw/fs-safe/store";
26
+
27
+ const files = fileStore({ rootDir: "/var/lib/app", private: true });
28
+ const settings = files.json<Settings>("settings.json", { lock: true });
29
+ ```
30
+
31
+ ## When to reach for it
32
+
33
+ - You have a single JSON state file and want `read / readOr / readRequired / write / update` semantics.
34
+ - You want every write atomic at file mode `0o600` and parents at `0o700` by default.
35
+ - You want optional cross-process locking with one boolean.
36
+
37
+ For ad-hoc read/write of multiple JSON files, use the standalone helpers in
38
+ [`json`](json.md). For object-style storage of many files at known modes, use
39
+ [`fileStore`](file-store.md) and bind JSON files with `store.json(rel)`.
40
+
41
+ ## Factory: `jsonStore<T>(options)`
42
+
43
+ ```ts
44
+ type JsonStoreOptions<T> = {
45
+ filePath: string;
46
+ dirMode?: number; // default 0o700
47
+ mode?: number; // default 0o600
48
+ trailingNewline?: boolean; // default true
49
+ lock?: boolean | JsonStoreLockOptions; // false / undefined = no lock
50
+ };
51
+
52
+ type JsonStoreLockOptions = {
53
+ staleMs?: number; // default 30_000
54
+ timeoutMs?: number; // default 30_000
55
+ retry?: FileLockRetryOptions;
56
+ staleRecovery?: "fail-closed";
57
+ managerKey?: string; // default `fs-safe.json-store:<filePath>`
58
+ };
59
+
60
+ type JsonStore<T> = {
61
+ readonly filePath: string;
62
+ read(): Promise<T | undefined>;
63
+ readOr(fallback: T): Promise<T>;
64
+ readRequired(): Promise<T>;
65
+ write(value: T): Promise<void>;
66
+ update(run: (current: T | undefined) => T | Promise<T>): Promise<T>;
67
+ updateOr(fallback: T, run: (current: T) => T | Promise<T>): Promise<T>;
68
+ };
69
+ ```
70
+
71
+ `jsonStore({ filePath })` resolves `rootDir = dirname(filePath)` and calls
72
+ `fileStore({ rootDir, private: true }).json(basename(filePath), options)`.
73
+
74
+ The store does **not** validate the parsed value against `T` at runtime — the cast is unchecked. Wrap with a schema (zod/valibot) if the file might be hand-edited or written by another process you don't control.
75
+
76
+ ## `read()`
77
+
78
+ Returns the parsed contents, or `undefined` if the file does not exist. Invalid JSON throws (via [`readJsonIfExists`](json.md)).
79
+
80
+ ```ts
81
+ const state = await store.read();
82
+ ```
83
+
84
+ ## `readOr(fallback)`
85
+
86
+ Returns the parsed contents or the per-call fallback. Object fallbacks are cloned so callers can safely mutate the returned value:
87
+
88
+ ```ts
89
+ const state = await store.readOr(defaultState);
90
+ ```
91
+
92
+ ## `readRequired()`
93
+
94
+ Strict disk read. Throws when the file is missing or invalid:
95
+
96
+ ```ts
97
+ const state = await store.readRequired();
98
+ ```
99
+
100
+ ## `write(value)`
101
+
102
+ Atomic JSON write at `mode` (default `0o600`), creating parent dirs at `dirMode` (default `0o700`) if needed. When `lock: true` is set, takes the sidecar lock for the duration of the write.
103
+
104
+ ```ts
105
+ await store.write({ ...state, lastSeen: Date.now() });
106
+ ```
107
+
108
+ ## `update(run)`
109
+
110
+ Read, transform, write — under the lock if locking is enabled. Returns the new value:
111
+
112
+ ```ts
113
+ const next = await store.update((prev) => ({ count: (prev?.count ?? 0) + 1 }));
114
+ ```
115
+
116
+ `run` is async-friendly. The whole `read → run → write` sequence runs inside one `withLock` call, so concurrent updaters from different processes serialize cleanly.
117
+
118
+ Use `update(run)` when missing state is part of your model. Use `updateOr(fallback, run)` when the missing-file case should start from a concrete value and you want to merge into defaults:
119
+
120
+ ```ts
121
+ const next = await store.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));
122
+ ```
123
+
124
+ ## Locking
125
+
126
+ Set `lock: true` for default behavior, or pass an options object to tune:
127
+
128
+ ```ts
129
+ const counter = jsonStore<{ count: number }>({
130
+ filePath: "/var/lib/app/counter.json",
131
+ lock: {
132
+ staleMs: 60_000,
133
+ timeoutMs: 10_000,
134
+ staleRecovery: "fail-closed",
135
+ retry: { retries: 30, minTimeout: 100, maxTimeout: 5_000, randomize: true },
136
+ },
137
+ });
138
+ ```
139
+
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.
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
+
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.
145
+
146
+ ## Common patterns
147
+
148
+ ### Per-feature settings file
149
+
150
+ ```ts
151
+ type Settings = { theme: "light" | "dark"; muted: boolean };
152
+
153
+ const settings = jsonStore<Settings>({
154
+ filePath: path.join(homedir(), ".myapp/settings.json"),
155
+ });
156
+
157
+ // Read on boot
158
+ applySettings(await settings.readOr({ theme: "dark", muted: false }));
159
+
160
+ // Toggle on UI action
161
+ await settings.update((prev) => {
162
+ const current = prev ?? { theme: "dark", muted: false };
163
+ return { ...current, muted: !current.muted };
164
+ });
165
+ ```
166
+
167
+ ### Cross-process counter
168
+
169
+ ```ts
170
+ const counter = jsonStore<{ count: number }>({
171
+ filePath: "/var/lib/app/counter.json",
172
+ lock: true,
173
+ });
174
+
175
+ const { count } = await counter.updateOr({ count: 0 }, (prev) => ({ count: prev.count + 1 }));
176
+ console.log("now at", count);
177
+ ```
178
+
179
+ ### Migration on boot
180
+
181
+ ```ts
182
+ const config = jsonStore<Config>({ filePath });
183
+ const current = await config.readOr(defaultConfig);
184
+ if (current.version !== CURRENT_VERSION) {
185
+ await config.write(migrate(current));
186
+ }
187
+ ```
188
+
189
+ ## Difference from raw `writeJson` / `readJsonIfExists`
190
+
191
+ | `jsonStore` | Raw helpers |
192
+ |---|---|
193
+ | Read-modify-write in one call (`update`). | Compose `readJsonIfExists` + `writeJson` yourself. |
194
+ | Optional cross-process lock with one flag. | Manage `withFileLock` yourself. |
195
+ | Explicit `readOr` / `updateOr` fallbacks. | Caller handles `null` and clones. |
196
+ | Mode/dirMode locked per store. | Per-call. |
197
+
198
+ `jsonStore` is the right shape when one file owns one piece of state and many call sites read or update it. For one-off writes, the raw helpers are leaner.
199
+
200
+ ## See also
201
+
202
+ - [JSON files](json.md) — the standalone helpers `jsonStore` is built on.
203
+ - [File lock](sidecar-lock.md) — the cross-process lock used when `lock: true`.
204
+ - [File store](file-store.md) — the multi-file equivalent of this surface.