Skip to content

Commit 89638b5

Browse files
committed
fix(heartbeat): bound HEARTBEAT.md file read size
1 parent ef9383d commit 89638b5

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
// Regression test for bounded HEARTBEAT.md reads.
2+
import fs from "node:fs/promises";
3+
import path from "node:path";
4+
import { describe, expect, it, vi } from "vitest";
5+
import type { OpenClawConfig } from "../config/types.openclaw.js";
6+
import { runHeartbeatOnce } from "./heartbeat-runner.js";
7+
import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js";
8+
import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js";
9+
10+
installHeartbeatRunnerTestRuntime({ includeSlack: true });
11+
12+
describe("runHeartbeatOnce oversized HEARTBEAT.md", () => {
13+
it("treats an oversized HEARTBEAT.md like a missing file and continues the run", async () => {
14+
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
15+
const cfg: OpenClawConfig = {
16+
agents: {
17+
defaults: {
18+
workspace: tmpDir,
19+
heartbeat: { every: "5m", target: "slack", to: "channel:C123" },
20+
},
21+
},
22+
channels: { slack: { heartbeat: { showOk: false } } },
23+
session: { store: storePath },
24+
};
25+
await seedMainSessionStore(storePath, cfg, {
26+
lastChannel: "slack",
27+
lastProvider: "slack",
28+
lastTo: "channel:C123",
29+
});
30+
// Overwrite the default heartbeat file with content larger than the 16 MB cap.
31+
const oversizedContent = Buffer.alloc(16 * 1024 * 1024 + 1, "x");
32+
await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), oversizedContent);
33+
34+
replySpy.mockResolvedValue({ text: "needs attention" });
35+
const sendSlack = vi.fn().mockResolvedValue({ messageId: "m1", channelId: "C123" });
36+
37+
const res = await runHeartbeatOnce({
38+
cfg,
39+
deps: {
40+
getReplyFromConfig: replySpy,
41+
slack: sendSlack,
42+
getQueueSize: () => 0,
43+
nowMs: () => 0,
44+
},
45+
});
46+
47+
expect(res.status).toBe("ran");
48+
expect(sendSlack).toHaveBeenCalledTimes(1);
49+
});
50+
});
51+
});

src/infra/heartbeat-runner.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
// Runs heartbeat checks and emits status updates for configured agents.
22
import { createHash } from "node:crypto";
3-
import fs from "node:fs/promises";
43
import path from "node:path";
54
import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion";
65
import {
@@ -158,6 +157,7 @@ import {
158157
resolveHeartbeatDeliveryTargetWithSessionRoute,
159158
resolveHeartbeatSenderContext,
160159
} from "./outbound/targets.js";
160+
import { readRegularFile } from "./regular-file.js";
161161
import {
162162
consumeSelectedSystemEventEntries,
163163
peekSystemEventEntries,
@@ -1117,9 +1117,12 @@ async function resolveHeartbeatPreflight(params: {
11171117

11181118
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId);
11191119
const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME);
1120+
const MAX_HEARTBEAT_FILE_BYTES = 16 * 1024 * 1024;
11201121
let heartbeatFileContent: string | undefined;
11211122
try {
1122-
heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8");
1123+
heartbeatFileContent = (
1124+
await readRegularFile({ filePath: heartbeatFilePath, maxBytes: MAX_HEARTBEAT_FILE_BYTES })
1125+
).buffer.toString("utf-8");
11231126
const tasks = parseHeartbeatTasks(heartbeatFileContent);
11241127
if (
11251128
isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) &&

0 commit comments

Comments
 (0)