@totalreclaw/totalreclaw 3.3.0-rc.3 → 3.3.0-rc.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
@@ -4,6 +4,213 @@ All notable changes to `@totalreclaw/totalreclaw` (the OpenClaw plugin) are docu
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [3.3.0-rc.5] — 2026-04-20
8
+
9
+ Fifth release candidate for 3.3.0. Single ship-stopper fix for rc.4's
10
+ QR-pairing flow, root-caused by the auto-QA run against rc.4 artifacts
11
+ (report: `docs/notes/QA-plugin-3.3.0-rc.4-20260420-1517.md` in
12
+ `totalreclaw-internal`, thread at `totalreclaw-internal#21` comment
13
+ 4281568050). rc.2 (scanner), rc.3 (auth literal path), and rc.4 (auth
14
+ `'plugin'` literal + `ensureSessionsFileDir` mkdir before lock) fixes are
15
+ all preserved. No protocol / on-chain changes vs 3.3.0.
16
+
17
+ ### Fixed
18
+
19
+ - **`skill/plugin/index.ts` — register pair HTTP routes synchronously
20
+ (remove async IIFE)**. rc.2–rc.4 wrapped the 4 `api.registerHttpRoute`
21
+ calls in a fire-and-forget `(async () => { ... })()` block whose three
22
+ `await import(...)` calls (`./pair-http.js`, `@scure/bip39`, and
23
+ `@scure/bip39/wordlists/english.js`) settled one microtask AFTER the
24
+ SDK loader had already called `register()` and frozen the plugin's
25
+ HTTP-route registry. The 4 post-activation pushes landed on the
26
+ dispatcher's "inactive" copy and never reached the live router;
27
+ `openclaw plugins inspect totalreclaw --json | jq .httpRouteCount`
28
+ returned `0` on rc.4 despite both the `auth: 'plugin'` literal (rc.4)
29
+ and the `ensureSessionsFileDir` mkdir (rc.4) being correct. rc.5:
30
+
31
+ 1. `buildPairRoutes`, `validateMnemonic`, and `wordlist` are now
32
+ **static top-of-file imports** (alongside the existing
33
+ `onboarding-cli.ts` / `generate-mnemonic.ts` static imports of the
34
+ same modules — no new deps, no circular-dep risk).
35
+ 2. `writeOnboardingState` is added to the existing static
36
+ `./fs-helpers.js` import (it was the only dynamic import inside
37
+ the `completePairing` callback).
38
+ 3. The async IIFE is deleted. `buildPairRoutes(...)` and the 4
39
+ `api.registerHttpRoute({...})` calls are now in the synchronous
40
+ body of `register(api)`, inside the existing
41
+ `if (typeof api.registerHttpRoute === 'function')` guard. The
42
+ `else` branch and warning are unchanged. The post-registration
43
+ info log now reads `'registered 4 QR-pairing HTTP routes
44
+ synchronously'` for clearer debug output.
45
+ 4. `completePairing` remains `async` (it does disk I/O) — that is
46
+ fine because `registerHttpRoute` accepts async handlers. Only the
47
+ REGISTRATION had to be synchronous; the handler itself can
48
+ defer-to-microtask freely at runtime.
49
+
50
+ Scanner: static imports don't trigger any rule that dynamic imports
51
+ don't already trigger (verified via `node skill/scripts/check-scanner.mjs`,
52
+ 0 flags, 72 files scanned).
53
+
54
+ **Before (rc.4):**
55
+ ```ts
56
+ if (typeof api.registerHttpRoute === 'function') {
57
+ (async () => {
58
+ try {
59
+ const { buildPairRoutes } = await import('./pair-http.js');
60
+ const { validateMnemonic } = await import('@scure/bip39');
61
+ const { wordlist } = await import('@scure/bip39/wordlists/english.js');
62
+ const bundle = buildPairRoutes({ /* ... */ });
63
+ api.registerHttpRoute!({ path: bundle.finishPath, /*...*/, auth: 'plugin' });
64
+ api.registerHttpRoute!({ path: bundle.startPath, /*...*/, auth: 'plugin' });
65
+ api.registerHttpRoute!({ path: bundle.respondPath,/*...*/, auth: 'plugin' });
66
+ api.registerHttpRoute!({ path: bundle.statusPath, /*...*/, auth: 'plugin' });
67
+ // ^^ these 4 pushes happen AFTER register() has returned + the
68
+ // SDK loader has already activated the (empty) route registry.
69
+ } catch (err) { /* ... */ }
70
+ })();
71
+ }
72
+ ```
73
+
74
+ **After (rc.5):**
75
+ ```ts
76
+ // top of file
77
+ import { buildPairRoutes } from './pair-http.js';
78
+ import { validateMnemonic } from '@scure/bip39';
79
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
80
+ // ... fs-helpers import now also includes writeOnboardingState
81
+
82
+ // inside register(api)
83
+ if (typeof api.registerHttpRoute === 'function') {
84
+ const bundle = buildPairRoutes({ /* ... */ });
85
+ api.registerHttpRoute!({ path: bundle.finishPath, /*...*/, auth: 'plugin' });
86
+ api.registerHttpRoute!({ path: bundle.startPath, /*...*/, auth: 'plugin' });
87
+ api.registerHttpRoute!({ path: bundle.respondPath, /*...*/, auth: 'plugin' });
88
+ api.registerHttpRoute!({ path: bundle.statusPath, /*...*/, auth: 'plugin' });
89
+ // ^^ these 4 pushes happen synchronously BEFORE register() returns,
90
+ // i.e. BEFORE the SDK loader activates the registry.
91
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously');
92
+ }
93
+ ```
94
+
95
+ - **`skill/plugin/pair-http-route-registration.test.ts` — rc.5 regression
96
+ guard**. The existing SIMULATION suite (27 assertions covering the 4
97
+ routes' `auth` literal, path shape, handler type) is preserved. Added
98
+ a new SYNCHRONY suite (14 assertions) that invokes `plugin.register(mockApi)`
99
+ with a minimal mocked OpenClaw API and asserts `mockApi.registerHttpRoute`
100
+ has been called 4 times IMMEDIATELY after `register()` returns — no
101
+ `await`, no tick wait. This assertion would fail under the rc.4 async-IIFE
102
+ implementation and guards against any future refactor that re-introduces
103
+ an async boundary at the registration site. Total: 41/41 passing.
104
+
105
+ ## [3.3.0-rc.4] — 2026-04-20
106
+
107
+ Fourth release candidate for 3.3.0. Two independent ship-stopper fixes for
108
+ rc.3's QR-pairing flow, both surfaced by the auto-QA run against rc.3
109
+ artifacts (report: `docs/notes/QA-plugin-3.3.0-rc.3-20260420-1440.md` in
110
+ `totalreclaw-internal`, thread at `totalreclaw-internal#21`). No protocol /
111
+ on-chain changes vs 3.3.0. Bundled into a single RC because shipping them
112
+ separately would require two more QA loops for what are, individually,
113
+ one-line fixes.
114
+
115
+ ### Fixed
116
+
117
+ - **`skill/plugin/index.ts` — pair HTTP routes must use `auth: 'plugin'`, not
118
+ `'gateway'`** (lines 2750–2753, now 2760–2763 after added comment). rc.3
119
+ added `auth: 'gateway'` to the 4 `api.registerHttpRoute` calls, which the
120
+ SDK loader accepted as a legal value but whose runtime semantics are
121
+ "requires gateway bearer token" (see
122
+ `matchedPluginRoutesRequireGatewayAuth` at
123
+ `gateway-cli-CWpalJNJ.js:23186`). For the 4 pair routes — reached from a
124
+ phone/laptop browser with no bearer token — that means `/pair/*` is 401'd
125
+ at the plugin-auth stage before the handler ever runs. The second valid
126
+ literal, `auth: 'plugin'` (verified as the only other accepted value at
127
+ `loader-BkOjign1.js:662`), lets the plugin's handler run directly and
128
+ authenticate itself via the in-session sid + 6-digit secondaryCode +
129
+ single-use consumption + ECDH AEAD payload, which is the correct model
130
+ for QR-pair. QA observed `httpRouteCount: 0` in rc.3 via `plugins inspect`
131
+ and confirmed all 4 `/plugin/totalreclaw/pair/*` paths returned 404 / SPA
132
+ fallthrough. rc.4 switches all 4 to `auth: 'plugin'`.
133
+
134
+ **Before (rc.3):**
135
+ ```ts
136
+ api.registerHttpRoute!({ path: bundle.finishPath, handler: bundle.handlers.finish, auth: 'gateway' });
137
+ api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'gateway' });
138
+ api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'gateway' });
139
+ api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'gateway' });
140
+ ```
141
+
142
+ **After (rc.4):**
143
+ ```ts
144
+ api.registerHttpRoute!({ path: bundle.finishPath, handler: bundle.handlers.finish, auth: 'plugin' });
145
+ api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
146
+ api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
147
+ api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
148
+ ```
149
+
150
+ - **`skill/plugin/pair-session-store.ts::acquireSessionsFileLock` — mkdir
151
+ parent before `openSync(wx)`**. On a fresh install with no
152
+ `~/.totalreclaw/` directory, the lock's `openSync(path, 'wx')` returned
153
+ `ENOENT (No such file or directory)` and the retry loop misinterpreted
154
+ that as "lock already held", spinning at 50 ms intervals for the full
155
+ 10 s `LOCK_WAIT_MS` before throwing `could not acquire lock`. The CLI
156
+ surfaced this as a hung `openclaw totalreclaw pair generate` with no QR,
157
+ URL, or secondary code ever rendered. `writePairSessionsFileSync`
158
+ already had a mkdir, but it was never reached because the lock never
159
+ acquired. rc.4 extracts a shared `ensureSessionsFileDir(sessionsPath)`
160
+ helper (mkdir `-p` with mode 0700) and calls it at the TOP of both
161
+ `acquireSessionsFileLock` AND `writePairSessionsFileSync` so the two
162
+ code paths can't drift. QA strace evidence in
163
+ `totalreclaw-internal#21`.
164
+
165
+ **Before (rc.3):**
166
+ ```ts
167
+ async function acquireSessionsFileLock(sessionsPath) {
168
+ const lockPath = `${sessionsPath}.lock`;
169
+ // ...
170
+ while (true) {
171
+ try {
172
+ const fd = fs.openSync(lockPath, 'wx'); // ENOENT here on fresh install
173
+ // ...
174
+ ```
175
+
176
+ **After (rc.4):**
177
+ ```ts
178
+ function ensureSessionsFileDir(sessionsPath) {
179
+ const dir = path.dirname(sessionsPath);
180
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
181
+ }
182
+
183
+ async function acquireSessionsFileLock(sessionsPath) {
184
+ ensureSessionsFileDir(sessionsPath); // NEW — guarantees parent dir
185
+ const lockPath = `${sessionsPath}.lock`;
186
+ // ...
187
+ ```
188
+
189
+ ### Added
190
+
191
+ - `skill/plugin/pair-session-store.test.ts` — two new blocks (§17, §18)
192
+ covering the fresh-install regression: `createPairSession` against a
193
+ path whose parent directory does NOT exist completes in < 2 s (was
194
+ 10 s hang), materializes the missing dir with the correct mode, writes
195
+ the sessions file at 0600, and leaves no lock sentinel. Plus read-path
196
+ defensive tests: `getPairSession` / `listActivePairSessions` against
197
+ a missing dir return null / `[]` without throwing (previously would
198
+ have hit the same ENOENT hang).
199
+ - `skill/plugin/pair-http-route-registration.test.ts` — assertions
200
+ updated from `'gateway'` to `'plugin'`, plus a per-call regression
201
+ guard asserting `auth !== 'gateway'` so rc.3's value cannot sneak back
202
+ in. Test count: 23 → 27 assertions.
203
+
204
+ ### Unchanged
205
+
206
+ No changes to: scanner-sim rules (still 0 flags), tarball contents (same
207
+ 44 files; diff is content of 3 `.ts` files + `package.json` bump +
208
+ `CHANGELOG.md`), UX copy, terminology (`recovery phrase` throughout),
209
+ protobuf schema, Memory Taxonomy v1, on-chain contract surface, MCP
210
+ wiring, client integration, Hermes / NanoClaw / core (plugin-only RC).
211
+
212
+ ---
213
+
7
214
  ## [3.3.0-rc.3] — 2026-04-20
8
215
 
9
216
  Third release candidate for 3.3.0. Sole change vs rc.2: adds the mandatory
package/index.ts CHANGED
@@ -133,10 +133,14 @@ import {
133
133
  isRunningInDocker,
134
134
  deleteFileIfExists,
135
135
  resolveOnboardingState,
136
+ writeOnboardingState,
136
137
  type OnboardingState,
137
138
  } from './fs-helpers.js';
138
139
  import { decideToolGate, isGatedToolName } from './tool-gating.js';
139
140
  import { detectFirstRun, buildWelcomePrepend, type GatewayMode } from './first-run.js';
141
+ import { buildPairRoutes } from './pair-http.js';
142
+ import { validateMnemonic } from '@scure/bip39';
143
+ import { wordlist } from '@scure/bip39/wordlists/english.js';
140
144
  import crypto from 'node:crypto';
141
145
 
142
146
  // ---------------------------------------------------------------------------
@@ -2711,52 +2715,64 @@ const plugin = {
2711
2715
  // encrypted mnemonic payload, and expose a status polled by the
2712
2716
  // CLI. See pair-http.ts and the 2026-04-20 design doc.
2713
2717
  if (typeof api.registerHttpRoute === 'function') {
2714
- (async () => {
2715
- try {
2716
- const { buildPairRoutes } = await import('./pair-http.js');
2717
- const { validateMnemonic } = await import('@scure/bip39');
2718
- const { wordlist } = await import('@scure/bip39/wordlists/english.js');
2719
- const bundle = buildPairRoutes({
2720
- sessionsPath: CONFIG.pairSessionsPath,
2721
- apiBase: '/plugin/totalreclaw/pair',
2722
- logger: api.logger,
2723
- validateMnemonic: (p) => validateMnemonic(p, wordlist),
2724
- completePairing: async ({ mnemonic }) => {
2725
- // Write credentials.json + flip state to 'active' via
2726
- // fs-helpers. This centralizes disk I/O off the
2727
- // pair-http surface (scanner isolation).
2728
- const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
2729
- const next = { ...creds, mnemonic };
2730
- if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
2731
- return { state: 'error', error: 'credentials_write_failed' };
2732
- }
2733
- // Hot-reload: update the runtime override so existing
2734
- // in-memory state picks up the new phrase without a
2735
- // process restart.
2736
- setRecoveryPhraseOverride(mnemonic);
2737
- // Flip onboarding state. writeOnboardingState is in
2738
- // fs-helpers; dynamic import to keep it out of any
2739
- // potential scanner collision surface in this file.
2740
- const { writeOnboardingState } = await import('./fs-helpers.js');
2741
- writeOnboardingState(CONFIG.onboardingStatePath, {
2742
- onboardingState: 'active',
2743
- createdBy: 'generate',
2744
- credentialsCreatedAt: new Date().toISOString(),
2745
- version: '3.3.0',
2746
- });
2747
- return { state: 'active' };
2748
- },
2718
+ // rc.5 the 4 `registerHttpRoute` calls MUST happen synchronously inside
2719
+ // `register(api)` because the SDK loader freezes the plugin's HTTP-route
2720
+ // registry as soon as `register()` returns. In rc.2–rc.4 this block was
2721
+ // wrapped in a fire-and-forget async IIFE whose `await import(...)`
2722
+ // settled one microtask AFTER the loader had already activated the
2723
+ // (empty) route list — the post-activation pushes landed on the
2724
+ // dispatcher's "inactive" copy and `openclaw plugins inspect
2725
+ // totalreclaw --json | jq .httpRouteCount` returned 0. See
2726
+ // `docs/notes/QA-plugin-3.3.0-rc.4-20260420-1517.md` (internal#21).
2727
+ // Moving `buildPairRoutes`, `@scure/bip39`, and `fs-helpers`
2728
+ // `writeOnboardingState` to static top-of-file imports keeps the
2729
+ // registration site synchronous and makes the call order deterministic.
2730
+ // `completePairing` remains async (it does disk I/O) that is fine,
2731
+ // since `registerHttpRoute` accepts async handlers; only the
2732
+ // REGISTRATION must be synchronous.
2733
+ const bundle = buildPairRoutes({
2734
+ sessionsPath: CONFIG.pairSessionsPath,
2735
+ apiBase: '/plugin/totalreclaw/pair',
2736
+ logger: api.logger,
2737
+ validateMnemonic: (p) => validateMnemonic(p, wordlist),
2738
+ completePairing: async ({ mnemonic }) => {
2739
+ // Write credentials.json + flip state to 'active' via
2740
+ // fs-helpers. This centralizes disk I/O off the
2741
+ // pair-http surface (scanner isolation).
2742
+ const creds = loadCredentialsJson(CREDENTIALS_PATH) ?? {};
2743
+ const next = { ...creds, mnemonic };
2744
+ if (!writeCredentialsJson(CREDENTIALS_PATH, next)) {
2745
+ return { state: 'error', error: 'credentials_write_failed' };
2746
+ }
2747
+ // Hot-reload: update the runtime override so existing
2748
+ // in-memory state picks up the new phrase without a
2749
+ // process restart.
2750
+ setRecoveryPhraseOverride(mnemonic);
2751
+ // Flip onboarding state.
2752
+ writeOnboardingState(CONFIG.onboardingStatePath, {
2753
+ onboardingState: 'active',
2754
+ createdBy: 'generate',
2755
+ credentialsCreatedAt: new Date().toISOString(),
2756
+ version: '3.3.0',
2749
2757
  });
2750
- api.registerHttpRoute!({ path: bundle.finishPath, handler: bundle.handlers.finish, auth: 'gateway' });
2751
- api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'gateway' });
2752
- api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'gateway' });
2753
- api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'gateway' });
2754
- api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes');
2755
- } catch (err) {
2756
- const msg = err instanceof Error ? err.message : String(err);
2757
- api.logger.error(`TotalReclaw: failed to register pairing HTTP routes: ${msg}`);
2758
- }
2759
- })();
2758
+ return { state: 'active' };
2759
+ },
2760
+ });
2761
+ // auth: 'plugin' the 4 pair routes are reached from the operator's
2762
+ // phone/laptop browser, which has no gateway bearer token. The plugin
2763
+ // authenticates each request itself via (a) the in-memory pair session
2764
+ // (sid + secondaryCode + single-use consumption), (b) ECDH + AEAD for
2765
+ // the encrypted mnemonic payload. See gateway-cli dist
2766
+ // `matchedPluginRoutesRequireGatewayAuth` / `enforcePluginRouteGatewayAuth`
2767
+ // — routes with `auth: 'gateway'` require a bearer token and 401 any
2768
+ // browser caller, which is the wrong semantic for QR-pair. rc.3
2769
+ // shipped `auth: 'gateway'` and the QA agent confirmed the routes
2770
+ // were unreachable from a browser (QA-plugin-3.3.0-rc.3 report).
2771
+ api.registerHttpRoute!({ path: bundle.finishPath, handler: bundle.handlers.finish, auth: 'plugin' });
2772
+ api.registerHttpRoute!({ path: bundle.startPath, handler: bundle.handlers.start, auth: 'plugin' });
2773
+ api.registerHttpRoute!({ path: bundle.respondPath, handler: bundle.handlers.respond, auth: 'plugin' });
2774
+ api.registerHttpRoute!({ path: bundle.statusPath, handler: bundle.handlers.status, auth: 'plugin' });
2775
+ api.logger.info('TotalReclaw: registered 4 QR-pairing HTTP routes synchronously');
2760
2776
  } else {
2761
2777
  api.logger.warn(
2762
2778
  'api.registerHttpRoute is unavailable on this OpenClaw version — /totalreclaw pair will not work. ' +
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@totalreclaw/totalreclaw",
3
- "version": "3.3.0-rc.3",
3
+ "version": "3.3.0-rc.5",
4
4
  "description": "End-to-end encrypted, agent-portable memory for OpenClaw and any LLM-agent runtime. XChaCha20-Poly1305 with protobuf v4 + on-chain Memory Taxonomy v1 (claim / preference / directive / commitment / episode / summary).",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -238,6 +238,25 @@ export const LOCK_WAIT_MS = 10_000;
238
238
  /** Between-retry sleep. Short enough to feel responsive, long enough not to spin. */
239
239
  export const LOCK_RETRY_MS = 50;
240
240
 
241
+ /**
242
+ * Ensure the parent directory of `sessionsPath` exists, creating it
243
+ * (and any missing intermediates) with 0700 mode. This is called from
244
+ * BOTH the lock-acquisition and the write paths — if we only create
245
+ * the dir on write, a fresh install hits ENOENT on the lock's
246
+ * `openSync(path, 'wx')` and spins the retry loop until deadline (rc.3
247
+ * regression: QA-plugin-3.3.0-rc.3 report).
248
+ *
249
+ * Best-effort: a mkdir failure is re-thrown to the caller, which will
250
+ * surface it via the lock-acquisition error path (for lock) or the
251
+ * try/catch in `writePairSessionsFileSync` (for write). 0700 mode
252
+ * matches the privacy posture of the sessions file (0600) — if the
253
+ * user can read the directory they can already read the file.
254
+ */
255
+ function ensureSessionsFileDir(sessionsPath: string): void {
256
+ const dir = path.dirname(sessionsPath);
257
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
258
+ }
259
+
241
260
  /**
242
261
  * Acquire an exclusive lock on the given sessions-file path by
243
262
  * atomically creating `<path>.lock` with `wx` mode. Retries up to
@@ -251,6 +270,16 @@ export const LOCK_RETRY_MS = 50;
251
270
  * and self-contained is safer than fighting the scanner.
252
271
  */
253
272
  async function acquireSessionsFileLock(sessionsPath: string): Promise<() => void> {
273
+ // Guarantee the parent directory exists BEFORE the first openSync(wx).
274
+ // Without this, a fresh install where `~/.totalreclaw/` doesn't yet
275
+ // exist gets ENOENT on every attempt, tight-loops until deadline, and
276
+ // throws "could not acquire lock" — which the CLI surfaces as a hung
277
+ // pair command with no QR / code / URL ever rendered (rc.3 blocker;
278
+ // QA-plugin-3.3.0-rc.3 strace evidence in totalreclaw-internal#21).
279
+ // `writePairSessionsFileSync` already creates the dir, but that path
280
+ // is never reached because the lock never acquires.
281
+ ensureSessionsFileDir(sessionsPath);
282
+
254
283
  const lockPath = `${sessionsPath}.lock`;
255
284
  const deadline = Date.now() + LOCK_WAIT_MS;
256
285
 
@@ -349,8 +378,9 @@ export function writePairSessionsFileSync(
349
378
  file: PairSessionFile,
350
379
  ): boolean {
351
380
  try {
352
- const dir = path.dirname(sessionsPath);
353
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
381
+ // Same ensureSessionsFileDir used by acquireSessionsFileLock so the
382
+ // two paths can't drift.
383
+ ensureSessionsFileDir(sessionsPath);
354
384
  const tmp = `${sessionsPath}.tmp-${process.pid}-${Date.now()}`;
355
385
  fs.writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
356
386
  fs.renameSync(tmp, sessionsPath);