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:
-
+
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
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);
}