diff --git a/CHANGELOG.md b/CHANGELOG.md index 45839520d..935167db3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ Docs: https://docs.clawd.bot ### Fixes - 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. - 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. diff --git a/src/config/sessions.test.ts b/src/config/sessions.test.ts index 6bce0ba03..725c660b7 100644 --- a/src/config/sessions.test.ts +++ b/src/config/sessions.test.ts @@ -256,6 +256,22 @@ describe("sessions", () => { 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 () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-sessions-")); const storePath = path.join(dir, "sessions.json"); diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index c41181623..f0e516476 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -29,6 +29,10 @@ type SessionStoreCacheEntry = { const SESSION_STORE_CACHE = new Map(); const DEFAULT_SESSION_STORE_TTL_MS = 45_000; // 45 seconds (between 30-60s) +function isSessionStoreRecord(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value); +} + function getSessionStoreTtl(): number { return resolveCacheTtlMs({ envValue: process.env.CLAWDBOT_SESSION_CACHE_TTL_MS, @@ -115,7 +119,7 @@ export function loadSessionStore( try { const raw = fs.readFileSync(storePath, "utf-8"); const parsed = JSON5.parse(raw); - if (parsed && typeof parsed === "object") { + if (isSessionStoreRecord(parsed)) { store = parsed as Record; } mtimeMs = getFileMtimeMs(storePath) ?? mtimeMs; diff --git a/src/infra/state-migrations.fs.test.ts b/src/infra/state-migrations.fs.test.ts new file mode 100644 index 000000000..861db04db --- /dev/null +++ b/src/infra/state-migrations.fs.test.ts @@ -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({}); + }); +}); diff --git a/src/infra/state-migrations.fs.ts b/src/infra/state-migrations.fs.ts index e77ddd9c0..298fed1bd 100644 --- a/src/infra/state-migrations.fs.ts +++ b/src/infra/state-migrations.fs.ts @@ -48,7 +48,7 @@ export function readSessionStoreJson5(storePath: string): { try { const raw = fs.readFileSync(storePath, "utf-8"); const parsed = JSON5.parse(raw); - if (parsed && typeof parsed === "object") { + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { return { store: parsed as Record, ok: true }; } } catch {