From 1c96477686475daadbf6682279a7428b73facc82 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 15 Jan 2026 07:05:37 +0000 Subject: [PATCH] fix: harden session cache + heartbeat restore Co-authored-by: Ronak Guliani --- CHANGELOG.md | 3 + README.md | 22 +++--- scripts/clawtributors-map.json | 1 + src/commands/agent.ts | 30 +++++---- src/config/sessions.cache.test.ts | 26 +++++++ src/config/sessions/store.ts | 8 +-- ...espects-ackmaxchars-heartbeat-acks.test.ts | 67 +++++++++++++++++++ src/infra/heartbeat-runner.ts | 5 +- 8 files changed, 132 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc51c3ab1d..19fca623bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,9 @@ - Config: add `channels..configWrites` gating for channel-initiated config writes; migrate Slack channel IDs. ### Fixes +- Sessions: return deep clones (`structuredClone`) so cached session entries can't be mutated. (#934) — thanks @ronak-guliani. +- Heartbeat: keep `updatedAt` monotonic when restoring heartbeat sessions. (#934) — thanks @ronak-guliani. +- Agent: clear run context after CLI runs (`clearAgentRunContext`) to avoid runaway contexts. (#934) — thanks @ronak-guliani. - Mac: pass auth token/password to dashboard URL for authenticated access. (#918) — thanks @rahthakor. - UI: use application-defined WebSocket close code (browser compatibility). (#918) — thanks @rahthakor. - TUI: render picker overlays via the overlay stack so /models and /settings display. (#921) — thanks @grizzdank. diff --git a/README.md b/README.md index b328a82963..aedd0fe5de 100644 --- a/README.md +++ b/README.md @@ -480,16 +480,16 @@ Thanks to all clawtributors: nachoiacovino Vasanth Rao Naik Sabavat lc0rp scald andranik-sahakyan nachx639 davidguttman sleontenko sircrumpet peschee rafaelreis-r ratulsarna lutr0 thewilloftheshadow emanuelst KristijanJovanovski CashWilliams osolmaz kiranjd sebslight sheeek onutc manuelhettich minghinmatthewlam myfunc buddyh mcinteerj timkrase obviyus azade-c - danielz1z Josh Phillips bjesuiter superman32432432 Yurii Chukhlib roshanasingh4 antons austinm911 blacksmith-sh[bot] grp06 + danielz1z Josh Phillips bjesuiter roshanasingh4 superman32432432 Yurii Chukhlib antons austinm911 blacksmith-sh[bot] grp06 HeimdallStrategy imfing jarvis-medmatic mahmoudashraf93 petter-b pkrmf RandyVentures dan-dr erikpr1994 jalehman - jonasjancarik Keith the Silly Goose L36 Server mitschabaude-bot neist Friederike Seiler gabriel-trigo iamadig Kit koala73 - manmal ngutman ogulcancelik pasogott petradonka VACInc zats Chris Taylor Django Navarro pcty-nextgen-service-account - rubyrunsstuff Syhids Aaron Konyer erik-agens evalexpr fcatuhe gumadeiras henrino3 jayhickey jeffersonwarrior - jeffersonwarrior Jonathan D. Rhyne (DJ-D) juanpablodlc jverdi mickahouan mjrussell oswalpalash p6l-richard philipp-spiess robaxelsen - Sash Catanzarite VAC zknicker adam91holt alejandro maza andrewting19 Asleep123 bolismauro cash-echo-bot Clawd - conhecendocontato Drake Thomsen gtsifrikas HazAT hrdwdmrbl hugobarauna Jarvis Jefferson Nunn kitze kkarimi - levifig Lloyd loukotal Marc martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen ndraiman - nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke The Admiral - wes-davis wstock YuriNachos Zach Knickerbocker Azade carlulsoe cpojer ddyo Erik latitudeki5223 - longmaba Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres thesash William Stock + jonasjancarik Keith the Silly Goose L36 Server mitschabaude-bot neist chrisrodz Friederike Seiler gabriel-trigo iamadig Kit + koala73 manmal ngutman ogulcancelik pasogott petradonka VACInc zats Chris Taylor Django Navarro + pcty-nextgen-service-account rubyrunsstuff Syhids Aaron Konyer erik-agens evalexpr fcatuhe gumadeiras henrino3 jayhickey + jeffersonwarrior jeffersonwarrior Jonathan D. Rhyne (DJ-D) juanpablodlc jverdi mickahouan mjrussell oswalpalash p6l-richard philipp-spiess + robaxelsen Sash Catanzarite VAC zknicker adam91holt alejandro maza andrewting19 Asleep123 bolismauro cash-echo-bot + Clawd conhecendocontato Drake Thomsen gtsifrikas HazAT hrdwdmrbl hugobarauna Jarvis Jefferson Nunn kitze + kkarimi levifig Lloyd loukotal Marc martinpucik Miles mrdbstn MSch Mustafa Tag Eldeen + ndraiman nexty5870 prathamdby reeltimeapps RLTCmpe Rolf Fredheim Rony Kelner Samrat Jha siraht snopoke + The Admiral wes-davis wstock YuriNachos Zach Knickerbocker Azade carlulsoe cpojer ddyo Erik + latitudeki5223 longmaba Manuel Maly Mourad Boustani pcty-nextgen-ios-builder Quentin Randy Torres ronak-guliani thesash William Stock

diff --git a/scripts/clawtributors-map.json b/scripts/clawtributors-map.json index 131a424954..160b7e03f7 100644 --- a/scripts/clawtributors-map.json +++ b/scripts/clawtributors-map.json @@ -1,5 +1,6 @@ { "ensureLogins": [ + "ronak-guliani", "cpojer", "carlulsoe", "jdrhyne", diff --git a/src/commands/agent.ts b/src/commands/agent.ts index e3a6052089..42cf3a6c8e 100644 --- a/src/commands/agent.ts +++ b/src/commands/agent.ts @@ -38,7 +38,7 @@ import { type SessionEntry, saveSessionStore, } from "../config/sessions.js"; -import { emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; +import { clearAgentRunContext, emitAgentEvent, registerAgentRunContext } from "../infra/agent-events.js"; import { defaultRuntime, type RuntimeEnv } from "../runtime.js"; import { applyVerboseOverride } from "../sessions/level-overrides.js"; import { resolveSendPolicy } from "../sessions/send-policy.js"; @@ -123,18 +123,19 @@ export async function agentCommand( let sessionEntry = resolvedSessionEntry; const runId = opts.runId?.trim() || sessionId; - if (opts.deliver === true) { - const sendPolicy = resolveSendPolicy({ - cfg, - entry: sessionEntry, - sessionKey, - channel: sessionEntry?.channel, - chatType: sessionEntry?.chatType, - }); - if (sendPolicy === "deny") { - throw new Error("send blocked by session policy"); + try { + if (opts.deliver === true) { + const sendPolicy = resolveSendPolicy({ + cfg, + entry: sessionEntry, + sessionKey, + channel: sessionEntry?.channel, + chatType: sessionEntry?.chatType, + }); + if (sendPolicy === "deny") { + throw new Error("send blocked by session policy"); + } } - } let resolvedThinkLevel = thinkOnce ?? @@ -368,7 +369,7 @@ export async function agentCommand( ) { lifecycleEnded = true; } - emitAgentEvent({ + emitAgentEvent({ runId, stream: evt.stream, data: evt.data, @@ -435,4 +436,7 @@ export async function agentCommand( result, payloads, }); + } finally { + clearAgentRunContext(runId); + } } diff --git a/src/config/sessions.cache.test.ts b/src/config/sessions.cache.test.ts index 697a605b8d..74b0f7ae7a 100644 --- a/src/config/sessions.cache.test.ts +++ b/src/config/sessions.cache.test.ts @@ -76,6 +76,32 @@ describe("Session Store Cache", () => { readSpy.mockRestore(); }); + it("should not allow cached session mutations to leak across loads", async () => { + const testStore: Record = { + "session:1": { + sessionId: "id-1", + updatedAt: Date.now(), + cliSessionIds: { openai: "sess-1" }, + skillsSnapshot: { + prompt: "skills", + skills: [{ name: "alpha" }], + }, + }, + }; + + await saveSessionStore(storePath, testStore); + + const loaded1 = loadSessionStore(storePath); + loaded1["session:1"].cliSessionIds = { openai: "mutated" }; + if (loaded1["session:1"].skillsSnapshot?.skills?.length) { + loaded1["session:1"].skillsSnapshot!.skills[0].name = "mutated"; + } + + const loaded2 = loadSessionStore(storePath); + expect(loaded2["session:1"].cliSessionIds?.openai).toBe("sess-1"); + expect(loaded2["session:1"].skillsSnapshot?.skills?.[0]?.name).toBe("alpha"); + }); + it("should refresh cache when store file changes on disk", async () => { const testStore: Record = { "session:1": { diff --git a/src/config/sessions/store.ts b/src/config/sessions/store.ts index d50d94d9c8..6f993b6aa7 100644 --- a/src/config/sessions/store.ts +++ b/src/config/sessions/store.ts @@ -52,8 +52,8 @@ export function loadSessionStore(storePath: string): Record { } }); + it("does not regress updatedAt when restoring heartbeat sessions", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); + const storePath = path.join(tmpDir, "sessions.json"); + const replySpy = vi.spyOn(replyModule, "getReplyFromConfig"); + try { + const originalUpdatedAt = 1000; + const bumpedUpdatedAt = 2000; + await fs.writeFile( + storePath, + JSON.stringify( + { + main: { + sessionId: "sid", + updatedAt: originalUpdatedAt, + lastProvider: "whatsapp", + lastTo: "+1555", + }, + }, + null, + 2, + ), + ); + + const cfg: ClawdbotConfig = { + agents: { + defaults: { + heartbeat: { + every: "5m", + target: "whatsapp", + to: "+1555", + }, + }, + }, + channels: { whatsapp: { allowFrom: ["*"] } }, + session: { store: storePath }, + }; + + replySpy.mockImplementationOnce(async () => { + const raw = await fs.readFile(storePath, "utf-8"); + const parsed = JSON.parse(raw) as { main?: { updatedAt?: number } }; + if (parsed.main) { + parsed.main.updatedAt = bumpedUpdatedAt; + } + await fs.writeFile(storePath, JSON.stringify(parsed, null, 2)); + return { text: "" }; + }); + + await runHeartbeatOnce({ + cfg, + deps: { + getQueueSize: () => 0, + nowMs: () => 0, + webAuthExists: async () => true, + hasActiveWebListener: () => true, + }, + }); + + const finalStore = JSON.parse(await fs.readFile(storePath, "utf-8")) as { + main?: { updatedAt?: number }; + }; + expect(finalStore.main?.updatedAt).toBe(bumpedUpdatedAt); + } finally { + replySpy.mockRestore(); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + it("skips WhatsApp delivery when not linked or running", async () => { const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-hb-")); const storePath = path.join(tmpDir, "sessions.json"); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index 5decaa124c..60ee7d6adc 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -148,8 +148,9 @@ async function restoreHeartbeatUpdatedAt(params: { const store = loadSessionStore(storePath); const entry = store[sessionKey]; if (!entry) return; - if (entry.updatedAt === updatedAt) return; - store[sessionKey] = { ...entry, updatedAt }; + const nextUpdatedAt = Math.max(entry.updatedAt ?? 0, updatedAt); + if (entry.updatedAt === nextUpdatedAt) return; + store[sessionKey] = { ...entry, updatedAt: nextUpdatedAt }; await saveSessionStore(storePath, store); }