fix: guard session store against array corruption

This commit is contained in:
Peter Steinberger
2026-01-24 04:51:34 +00:00
parent 63176ccb8a
commit 975f5a5284
5 changed files with 41 additions and 2 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot
### Fixes ### Fixes
- Docker: update gateway command in docker-compose and Hetzner guide. (#1514) - Docker: update gateway command in docker-compose and Hetzner guide. (#1514)
- Sessions: reject array-backed session stores to prevent silent wipes. (#1469)
- Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS. - Voice wake: auto-save wake words on blur/submit across iOS/Android and align limits with macOS.
- UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast. - UI: keep the Control UI sidebar visible while scrolling long pages. (#1515) Thanks @pookNast.
- UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank. - UI: cache Control UI markdown rendering + memoize chat text extraction to reduce Safari typing jank.

View File

@@ -256,6 +256,22 @@ describe("sessions", () => {
expect(store["agent:main:two"]?.sessionId).toBe("sess-2"); expect(store["agent:main:two"]?.sessionId).toBe("sess-2");
}); });
it("recovers from array-backed session stores", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "[]", "utf-8");
await updateSessionStore(storePath, (store) => {
store["agent:main:main"] = { sessionId: "sess-1", updatedAt: 1 };
});
const store = loadSessionStore(storePath);
expect(store["agent:main:main"]?.sessionId).toBe("sess-1");
const raw = await fs.readFile(storePath, "utf-8");
expect(raw.trim().startsWith("{")).toBe(true);
});
it("normalizes last route fields on write", async () => { it("normalizes last route fields on write", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-"));
const storePath = path.join(dir, "sessions.json"); const storePath = path.join(dir, "sessions.json");

View File

@@ -29,6 +29,10 @@ type SessionStoreCacheEntry = {
const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>(); const SESSION_STORE_CACHE = new Map<string, SessionStoreCacheEntry>();
const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s) const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s)
function isSessionStoreRecord(value: unknown): value is Record<string, SessionEntry> {
return !!value && typeof value === "object" && !Array.isArray(value);
}
function getSessionStoreTtl(): number { function getSessionStoreTtl(): number {
return resolveCacheTtlMs({ return resolveCacheTtlMs({
envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS, envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS,
@@ -115,7 +119,7 @@ export function loadSessionStore(
try { try {
const raw = fs.readFileSync(storePath, "utf-8"); const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") { if (isSessionStoreRecord(parsed)) {
store = parsed as Record<string, SessionEntry>; store = parsed as Record<string, SessionEntry>;
} }
mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs; mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs;

View File

@@ -0,0 +1,18 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { readSessionStoreJson5 } from "./state-migrations.fs.js";
describe("state migrations fs", () => {
it("treats array session stores as invalid", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-session-store-"));
const storePath = path.join(dir, "sessions.json");
await fs.writeFile(storePath, "[]", "utf-8");
const result = readSessionStoreJson5(storePath);
expect(result.ok).toBe(false);
expect(result.store).toEqual({});
});
});

View File

@@ -48,7 +48,7 @@ export function readSessionStoreJson5(storePath: string): {
try { try {
const raw = fs.readFileSync(storePath, "utf-8"); const raw = fs.readFileSync(storePath, "utf-8");
const parsed = JSON5.parse(raw); const parsed = JSON5.parse(raw);
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
return { store: parsed as Record<string, SessionEntryLike>, ok: true }; return { store: parsed as Record<string, SessionEntryLike>, ok: true };
} }
} catch { } catch {