feat(sessions): add directory-backed session store#51946
feat(sessions): add directory-backed session store#51946jalehman wants to merge 10 commits intoopenclaw:mainfrom
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
Vulnerabilities1. 🟠 TOCTOU symlink race in directory-backed session store allows arbitrary file write
Description
However, the safety check occurs after the write. An attacker who can modify the filesystem (e.g., same user, or any actor able to replace the
Because this function is used to write session entry files and Vulnerable code: const validatedDir = await ensureSafeDirectory(params.dirPath);
const filePath = path.join(params.dirPath, params.fileName);
await jsonFiles.writeTextAtomic(filePath, params.content, { mode: params.mode });
const actualPath = await fsPromises.realpath(filePath).catch(() => null);
...
if (relativePath === "" || relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
throw new Error(...);
}RecommendationMake the write itself symlink-safe, not merely detected after the fact. Options (pick one depending on Node/platform support):
Example hardening using the validated real path (reduces, but does not fully eliminate races if the validated directory itself can be swapped via parent symlinks; combine with re-validation): const validatedDir = await ensureSafeDirectory(dirPath);
const filePath = path.join(validatedDir, fileName); // use validatedDir
await jsonFiles.writeTextAtomic(filePath, content, { mode });Additionally, consider using platform-specific protections where available (e.g., 2. 🟠 Prototype pollution via legacy sessions.json keys during session store normalization
DescriptionThe legacy This can lead to prototype pollution / logic manipulation anywhere the polluted object is later used (e.g., property lookups, merges/spreads, or security decisions). Key points:
Vulnerable code: function normalizeSessionStore(store: Record<string, SessionEntry>): void {
for (const [key, entry] of Object.entries(store)) {
...
if (normalized !== entry) {
store[key] = normalized; // unsafe when key is "__proto__" etc.
}
}
}RecommendationFilter/deny blocked prototype keys in all legacy-store code paths (load, normalize, migrate, save), not only in the directory-store loader. Minimal safe fixes:
Example patch: import { isBlockedObjectKey } from "../../infra/prototype-keys.js";
function normalizeSessionStore(store: Record<string, SessionEntry>): void {
for (const [key, entry] of Object.entries(store)) {
if (isBlockedObjectKey(key)) {
delete (store as any)[key];
continue;
}
if (!entry) continue;
const normalized = normalizeSessionEntryDelivery(normalizeSessionRuntimeModelFields(entry));
if (normalized !== entry) {
// defineProperty avoids invoking __proto__ setter semantics
Object.defineProperty(store, key, {
value: normalized,
writable: true,
enumerable: true,
configurable: true,
});
}
}
}
export function loadSessionStore(...) {
...
// after JSON.parse
const safe: Record<string, SessionEntry> = Object.create(null);
for (const [k,v] of Object.entries(parsed)) {
if (!isBlockedObjectKey(k)) safe[k] = v as SessionEntry;
}
store = safe;
}Also apply similar filtering inside migration normalization ( 3. 🟡 Unbounded directory session-store loading can cause memory/CPU exhaustion (DoS)
DescriptionThe directory-backed session store loader reads and parses every file in
This enables a denial-of-service condition if an attacker (or any untrusted local process/plugin) can write to the Vulnerable code paths:
Vulnerable code: const entries = fs.readdirSync(paths.entriesDir, { withFileTypes: true });
for (const entry of entries) {
// ...
const sessionEntry = readSessionEntryFile(path.join(paths.entriesDir, entry.name), sessionKeyFromName);
// ...
}
// ...
const raw = fs.readFileSync(filePath, "utf-8");
const parsed = JSON.parse(raw);RecommendationAdd defensive limits and fail-safe behavior when loading entry files:
Example size cap (synchronous): const MAX_ENTRY_BYTES = 1024 * 1024; // 1 MiB, tune to expected entry size
const stat = fs.lstatSync(filePath);
if (!stat.isFile() || stat.isSymbolicLink()) return null;
if (stat.size > MAX_ENTRY_BYTES) return null; // or quarantine/log
const raw = fs.readFileSync(filePath, "utf-8");Example count cap: const MAX_FILES = 100_000; // tune
for (const [i, entry] of entries.entries()) {
if (i >= MAX_FILES) break;
// ...
}Analyzed PR: #51946 at commit Last updated on: 2026-03-26T22:30:39Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 113d7e97e8
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
Greptile SummaryThis PR introduces a well-designed directory-backed session store ( Key design choices are sound:
Two minor observations:
Test coverage is comprehensive: key encoding round-trips, crash-safe migration with injected fault, version-stamp cache invalidation, per-entry update without full-store scan, Confidence Score: 4/5
Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/config/sessions/store-directory.ts
Line: 360-376
Comment:
**`versionToken` return field appears unused by all callers**
`loadSessionStoreFromDirectory` returns a `versionToken` field, but every caller in `store.ts` destructures only `.store` and discards the token. The actual version used for cache validation comes from a separate `readDirectorySessionStoreVersion(storePath)` call in `loadSessionStore` (the pre-read `directoryVersion` variable), not from this return value.
This dead surface isn't a bug — `loadSessionStore` caches with `directoryVersion`, which correctly gets invalidated on the next read when the version has advanced — but the exported `versionToken` field creates a misleading impression that callers can use it for coherence checks. If it's kept for future use, a comment would help; if it's truly unused, removing it avoids confusion.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/config/sessions/store-directory.ts
Line: 398-409
Comment:
**Implicit lock requirement not enforced at the API boundary**
`writeSessionEntryToDirectory` and `deleteSessionEntryFromDirectory` both read the state version, perform async filesystem work, then write `state.version + 1`. This is safe because all call sites (`persistDirectorySessionEntry`, `syncDirectorySessionStore`) are invoked within `withSessionStoreLock`. However, nothing at the API level prevents a future caller from using these functions outside the lock, which would cause two concurrent callers that both observe version N to race and both write N+1 — silently losing one version bump and potentially confusing the cache invalidation logic.
Consider adding a `// CALLER MUST HOLD the session-store lock` JSDoc comment to `writeSessionEntryToDirectory` and `deleteSessionEntryFromDirectory` to make this invariant explicit, analogous to the `saveSessionStoreUnlocked` naming convention used elsewhere in the file.
How can I resolve this? If you propose a fix, please make it concise.Last reviewed commit: "feat(sessions): add ..." |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c0d0281f5a
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
c0d0281 to
81216d6
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 81216d613d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 851f91db9f
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| for (const sessionKey of diff.changed) { | ||
| await writeDirectorySessionEntryFile( | ||
| paths.entriesDir, |
There was a problem hiding this comment.
Make directory sync atomic when state update fails
This update writes entry files before bumping state.json, so if the final state-file write fails (for example ENOSPC/EPERM), the function throws even though part of the new store has already been persisted. Because the version token is unchanged, cached readers still treat the old snapshot as current, which can return stale data while disk already contains partial updates. This creates a correctness gap between on-disk state, cache coherence, and the reported write result.
Useful? React with 👍 / 👎.
| if (resolveMaintenanceConfig().mode === "enforce") { | ||
| const previousStore = loadSessionStoreFromDirectory({ storePath: params.storePath }).store; | ||
| const nextStore: Record<string, SessionEntry> = { |
There was a problem hiding this comment.
Preserve warn-mode maintenance on directory fast paths
Fast-path directory writes only route through saveSessionStoreUnlocked() when mode === "enforce"; in default warn mode they bypass maintenance entirely and write the entry directly. After migration, common paths (recordSessionMetaFromInbound/updateLastRoute/updateSessionStoreEntry) therefore stop emitting warn-mode prune/cap/disk-budget signals during normal traffic, which is a behavior regression from the legacy path where warn-mode checks ran on every write.
Useful? React with 👍 / 👎.
Address PR openclaw#51946 follow-up review feedback in the directory-backed session store. Clamp filesystem exposure from oversized session keys by switching oversized keys to fixed-length hashed filenames, store the original key inside each entry payload, reject unsafe legacy migration inputs, and harden load/write paths against blocked prototype keys plus writable directory layouts. Also remove the unused directory load versionToken surface, document the lock invariant on direct directory writes, preserve empty legacy-store saves instead of dropping them, and route directory fast-path writes through full maintenance enforcement when session maintenance is set to enforce. Regeneration-Prompt: | Follow up on OpenClaw PR openclaw#51946 review feedback for the new directory-backed session store. Fix the security issues from Aisle by making long session keys use fixed-length hashed filenames while preserving the original key inside the entry file, reject unsafe legacy migration inputs like symlinks or oversized files, and harden directory loading against __proto__/prototype style keys and writable directory layouts. Also clean up the misleading unused versionToken return, document that direct directory write helpers require the session-store lock, preserve empty legacy-store writes instead of returning early, and keep maintenance enforcement active on directory fast paths. Validate with focused session-store tests plus pnpm check and pnpm build.
Address the two remaining PR openclaw#51946 regressions after rebasing onto current origin/main. Keep resolveAllAgentSessionStoreTargets* returning stores after sessions.json is promoted to sessions.d, and make subagent depth checks read migrated directory-backed stores while preserving legacy JSON5 support. Add focused regressions for both cases. Regeneration-Prompt: | Update the rebased PR openclaw#51946 session-store branch so directory-backed migration does not break existing readers. Keep combined-store target discovery working after sessions.json is promoted away by treating an active sibling sessions.d store as a valid authoritative store, and make subagent-depth read migrated directory-backed stores instead of only the legacy JSON file while still supporting JSON5 in unmigrated stores. Add focused tests covering post-migration target discovery and preserved subagent depth, then verify with the targeted test files plus pnpm build and pnpm check.
Prevent unchanged legacy session-store updates from migrating into directory mode, so the existing no-write contract still holds for no-op saves. Also isolate the Discord exec-approval test store from stale directory-backed siblings on CI runners. Regeneration-Prompt: | Investigate CI failures introduced after adding directory-backed session-store discovery and subagent-depth support. Preserve the existing behavior that an unchanged legacy sessions.json update does not write anything, even if migration support exists. Trace the failing Discord exec-approvals test to shared temp-path state: the test rewrites sessions.json directly, so stale sessions.d state from another run or earlier migration must not remain authoritative. Keep the product fix minimal in the session-store save path, and harden the test fixture so it uses isolated temp storage and removes any directory-backed sibling before each write.
Make the session-store summary helper read through the directory-aware session reader so WhatsApp heartbeat recipient inference still sees migrated sessions.d data. Add a regression test that exercises a migrated store summary directly. Regeneration-Prompt: | A new Codex review comment flagged that WhatsApp heartbeat recipient inference still depended on a helper that only parsed legacy sessions.json. After this PR migrates session stores into sessions.d and renames the JSON file to a backup, existing users would lose session-derived heartbeat recipients unless they passed --to or allowFrom. Fix the summary helper itself rather than the caller so all shallow session-summary readers stay directory-aware, and add a focused regression test that migrates a legacy store and verifies the summary still exposes lastChannel, lastTo, and updatedAt from the directory backend.
Add an explicit rollback path for directory-backed session stores so local testing can restore legacy sessions.json snapshots without changing the one-way startup migration behavior in the PR. Keep the rollback logic in the session-store layer, add a focused regression test, and provide a local script that targets the same store set as startup migration. Regeneration-Prompt: | Keep the PR's automatic migration one-way on gateway startup, but add a manual way for a maintainer to roll a local instance back from sessions.d to legacy sessions.json. The rollback should read the authoritative directory-backed store, write a canonical sessions.json snapshot under the existing session-store lock, and then rename the sessions.d directory to a timestamped backup so the downgraded code can see the legacy file again. Add a targeted regression test for that rollback, and provide a local script that can scan the same session-store targets as startup migration or accept explicit --store paths for dry-run and restore workflows.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 49429c7c68
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| await writeDirectorySessionEntryFile(paths.entriesDir, params.sessionKey, params.entry); | ||
| await writeDirectoryStoreStateFile(paths.statePath, state.version + 1); |
There was a problem hiding this comment.
Make fast-path directory entry writes atomic
writeSessionEntryToDirectory persists the entry file before updating state.json; if the state-file write fails (for example ENOSPC/EPERM), this call throws after partially committing the update. persistDirectorySessionEntry uses this path for normal warn-mode hot paths (updateSessionStoreEntry, recordSessionMetaFromInbound, updateLastRoute), so a failed write can still change disk state while leaving the version token unchanged, allowing other processes to keep serving stale cached snapshots.
Useful? React with 👍 / 👎.
49429c7 to
a96d31f
Compare
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: a96d31f39e
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const migrated = await migrateLegacySessionStoreToDirectory({ | ||
| storePath, | ||
| normalizeKey: normalizeStoreSessionKey, | ||
| sourceStore: store, | ||
| }); |
There was a problem hiding this comment.
Re-run legacy path safety checks when sourceStore is provided
saveSessionStoreUnlocked now calls migrateLegacySessionStoreToDirectory with sourceStore, but that helper only performs the lstat/symlink rejection inside the if (!sourceStore) branch. In the normal write path this bypasses the new symlink hardening: a symlinked sessions.json can still be migrated (and renamed) even though direct migration is supposed to reject symlink stores. This is a correctness/security regression for installs where sessions.json is a symlink (accidental or malicious), because migration will proceed on a path class explicitly treated as unsafe elsewhere.
Useful? React with 👍 / 👎.
Use session-store loaders in tests that now exercise directory-backed stores, and harden the Discord /think autocomplete fixture to clear sibling sessions.d state before rewriting sessions.json. Regeneration-Prompt: | The rebased PR started failing CI in three test-only ways after the session-store migration work. Two tests were still reading sessions.json directly after code paths that now write through the session-store layer and may promote to sessions.d, so update those assertions to verify the logical store via loadSessionStore(...). The Discord native /think autocomplete test also needed the same temp-store hardening pattern used in exec-approvals: isolate the temp directory and remove any sibling sessions.d state before writing the legacy fixture, because CI can otherwise observe stale directory-backed state and fall back to the default model context.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 63eade6d8d
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| writeSessionStoreCache({ | ||
| storePath, | ||
| store, | ||
| versionToken: readDirectorySessionStoreVersion(storePath), |
There was a problem hiding this comment.
Cache migrated directory snapshot instead of pre-migration store
After migrateLegacySessionStoreToDirectory succeeds, this branch caches the original in-memory store object, but migration normalizes/deduplicates keys before persisting (for example mixed-case aliases collapse to one canonical key). That means loadSessionStore() can immediately return ghost entries that no longer exist on disk until cache expiry or a later version bump, which can make post-migration reads inconsistent with the authoritative sessions.d contents.
Useful? React with 👍 / 👎.
Clarify that session transcripts remain under the agent sessions directory while the metadata store may now be either legacy sessions.json or migrated sessions.d. Also note the backup file pattern left behind after migration. Regeneration-Prompt: | Update the session-logs skill after the session store migration work. Preserve the existing guidance for finding JSONL transcripts under ~/.openclaw/agents/<agentId>/sessions/, but stop implying that sessions.json is always authoritative. Document that migrated agents may use sessions.d instead, and mention the sessions.json.bak.<timestamp> backup artifact so operators know what they are seeing on disk.
Add a read-only migration inspection step for legacy session stores, switch directory migration to return structured outcomes, and move gateway startup onto an aggregated summary log. Empty or missing legacy stores now stay info-level, while invalid stores and execution failures are surfaced in the summary with their paths. Include focused tests for detection and startup reporting. Regeneration-Prompt: | Follow up on the sessions.json to sessions.d migration in PR openclaw#51946 without adding a feature flag. Keep the one-way migration behavior, but harden operator visibility based on patterns used in older state migrations. Add a read-only detection layer for legacy stores, return structured migration outcomes instead of a bare boolean, and have gateway startup log one compact summary with counts for migrated, already-directory, skipped-empty, skipped-invalid, missing, and failed stores. Empty or unused legacy stores should no longer emit scary warning noise by themselves. Preserve the existing crash-safe migration behavior and validate the new detection/result contract with focused tests plus build and check.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 9aba7a68f3
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| const changed = await syncDirectorySessionStore({ | ||
| storePath, | ||
| nextStore: store, | ||
| previousStore: directoryWrite?.previousStore, |
There was a problem hiding this comment.
Normalize keys before syncing directory session snapshots
saveSessionStoreUnlocked forwards nextStore to syncDirectorySessionStore without canonicalizing keys, so mixed-case keys can be persisted as distinct directory entries. The new directory fast paths (updateSessionStoreEntry/recordSessionMetaFromInbound/updateLastRoute) always lowercase the lookup key before readSessionEntryFromDirectory, so a previously written mixed-case entry is missed and a second lowercase record can be created instead of updating the original. Legacy mode tolerated these aliases via resolveSessionStoreEntry, so this is a regression after migration.
Useful? React with 👍 / 👎.
| const realStatePath = fsSync.realpathSync.native(statePath); | ||
| const realAgentsRoot = params.realAgentsRoot ?? fsSync.realpathSync.native(params.agentsRoot); | ||
| return isWithinRoot(realStatePath, realAgentsRoot) ? path.resolve(params.storePath) : undefined; |
There was a problem hiding this comment.
Return canonical path for directory-backed store discovery
This branch validates directory stores using realStatePath but returns path.resolve(storePath) rather than a canonical real path. resolveAllAgentSessionStoreTargets* deduplicates by literal storePath, so when the same store is reachable through two path aliases (for example, a symlinked state root plus a configured real-path store), both aliases survive dedupe and downstream combined-store workflows can process the same backing store twice. Returning a canonical path (as the legacy-file branch does) avoids duplicate targets.
Useful? React with 👍 / 👎.
Remove the accidental session migration detector re-export, update the stale plugin SDK baseline metadata, and rename the duplicate Anthropic config heading so the deterministic CI failures on this branch clear. Regeneration-Prompt: | The branch picked up new CI failures after the migration-hardening work. Investigate which failures are actually caused by this branch versus inherited base instability. Preserve the migration hardening behavior and tests. Remove any accidental public API surface expansion from the session-store changes instead of broadening exports. If the plugin SDK baseline is stale relative to the current source tree, refresh only the minimal generated baseline entries needed to match the existing public surface. Also fix deterministic docs lint on this branch without pulling in unrelated docs churn.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: bf53ecf75b
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } | ||
|
|
||
| function resolveDirectorySessionStorePaths(storePath: string): DirectorySessionStorePaths { | ||
| const rootDir = path.join(path.dirname(path.resolve(storePath)), DIRECTORY_STORE_NAME); |
There was a problem hiding this comment.
Namespace directory stores by source store file
The directory backend path is derived only from path.dirname(storePath), so different session store files in the same directory (for example a valid template like .../sessions-{agentId}.json) all collapse into the same sessions.d directory. After migration, reads/writes for those distinct stores alias each other, which can mix agent data and let maintenance on one logical store prune entries from another. Legacy mode kept these stores isolated by filename, so this introduces cross-store data corruption for filename-templated setups.
Useful? React with 👍 / 👎.
| if (isDirectorySessionStoreActive(params.storePath)) { | ||
| return readSessionEntryFromDirectory({ | ||
| storePath: params.storePath, | ||
| sessionKey: normalizeStoreSessionKey(params.sessionKey), |
There was a problem hiding this comment.
Handle blank keys on directory updatedAt fast path
In directory mode this branch bypasses the legacy try/catch path and forwards normalizeStoreSessionKey(params.sessionKey) directly. If the input key is empty/whitespace, the downstream directory key encoder throws (sessionKey must be a non-empty string) instead of returning undefined as before. That turns malformed or missing session-key inputs into runtime exceptions only after migration to sessions.d, which can break inbound handlers that currently rely on this helper being best-effort.
Useful? React with 👍 / 👎.
|
Re-verifying on v2026.4.24 (released 2026-04-25): the architecture our 2026-04-05 reproduction described on #42160 (issuecomment-4189423808) is unchanged in current bundles.
Reproduction context (carried over from #42160; still applies on v4.24): We have a pipeline where a parent agent uses This is exactly the scenario this PR's directory-backed store would resolve — per-session files would let the announcement delivery path proceed while the parent's Workaround in production: we dispatch subagents as independent We're carrying this workaround indefinitely while waiting for this PR. Happy to provide additional repro telemetry, or test against a draft branch / WIP commit if it would help unblock review. |
Summary
sessions.jsonstore forces whole-store reads/writes under a shared lock, which causes contention, scaling pain, and memory pressure.sessions.dstate, direct per-entry hot paths, explicit version-stamp cache coherence, crash-safe migration, a local rollback script, and aggregated startup migration reporting.updateSessionStoremutators still serialize under the existing store lock; this does not claim to eliminate every lock/contention source.Change Type (select all)
Scope (select all touched areas)
Linked Issue/PR
User-visible / Behavior Changes
sessions.jsoninto directory-backed storage automatically.scripts/rollback-session-stores.ts.Security Impact (required)
No)No)No)No)No)Yes, explain risk + mitigation:Repro + Verification
Environment
Steps
pnpm build.pnpm check.Expected
Actual
pnpm buildpassed.pnpm checkpassed.Evidence
Attach at least one:
Human Verification (required)
What you personally verified (not just CI), and how:
readSessionUpdatedAtfast pathsessions.dback to legacysessions.jsonReview Conversations
Compatibility / Migration
Yes, with an explicit local rollback path)No)Yes)node --import tsx scripts/rollback-session-stores.ts.Failure Recovery (if this breaks)
node --import tsx scripts/rollback-session-stores.tsif the instance has already migrated tosessions.d, then restart the gateway.sessions.jsonand backs up the directory store assessions.d.bak.<timestamp>.readSessionUpdatedAtregressionsRisks and Mitigations
state.jsonreplaces TTL-only invalidation.