diff --git a/src/infra/heartbeat-runner.oversized-heartbeat-file.test.ts b/src/infra/heartbeat-runner.oversized-heartbeat-file.test.ts new file mode 100644 index 000000000000..6db7bd79b13d --- /dev/null +++ b/src/infra/heartbeat-runner.oversized-heartbeat-file.test.ts @@ -0,0 +1,112 @@ +// Regression test for bounded HEARTBEAT.md reads. +import fs from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resetLogger, setLoggerOverride } from "../logging/logger.js"; +import { loggingState } from "../logging/state.js"; +import { runHeartbeatOnce } from "./heartbeat-runner.js"; +import { installHeartbeatRunnerTestRuntime } from "./heartbeat-runner.test-harness.js"; +import { seedMainSessionStore, withTempHeartbeatSandbox } from "./heartbeat-runner.test-utils.js"; + +installHeartbeatRunnerTestRuntime({ includeSlack: true }); + +afterEach(() => { + loggingState.rawConsole = null; + setLoggerOverride(null); + resetLogger(); +}); + +describe("runHeartbeatOnce oversized HEARTBEAT.md", () => { + it("follows a symlinked HEARTBEAT.md to a regular file", async () => { + if (process.platform === "win32") { + // Symlink support in unit tests is not guaranteed on Windows CI runners. + return; + } + await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "slack", to: "channel:C123" }, + }, + }, + channels: { slack: { heartbeat: { showOk: false } } }, + session: { store: storePath }, + }; + await seedMainSessionStore(storePath, cfg, { + lastChannel: "slack", + lastProvider: "slack", + lastTo: "channel:C123", + }); + const heartbeatPath = path.join(tmpDir, "HEARTBEAT.md"); + const targetPath = path.join(tmpDir, "real-HEARTBEAT.md"); + await fs.writeFile(targetPath, "- Check status\n", "utf-8"); + await fs.rm(heartbeatPath, { force: true }); + await fs.symlink(targetPath, heartbeatPath); + + replySpy.mockResolvedValue({ text: "ok" }); + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m1", channelId: "C123" }); + + const res = await runHeartbeatOnce({ + cfg, + deps: { + getReplyFromConfig: replySpy, + slack: sendSlack, + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(res.status).toBe("ran"); + expect(sendSlack).toHaveBeenCalledTimes(1); + }); + }); + + it("treats an oversized HEARTBEAT.md like a missing file and continues the run", async () => { + await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + workspace: tmpDir, + heartbeat: { every: "5m", target: "slack", to: "channel:C123" }, + }, + }, + channels: { slack: { heartbeat: { showOk: false } } }, + session: { store: storePath }, + }; + await seedMainSessionStore(storePath, cfg, { + lastChannel: "slack", + lastProvider: "slack", + lastTo: "channel:C123", + }); + // Overwrite the default heartbeat file with content larger than the 16 MB cap. + const oversizedContent = Buffer.alloc(16 * 1024 * 1024 + 1, "x"); + await fs.writeFile(path.join(tmpDir, "HEARTBEAT.md"), oversizedContent); + + const warn = vi.fn(); + loggingState.rawConsole = { log: vi.fn(), info: vi.fn(), warn, error: vi.fn() }; + setLoggerOverride({ level: "silent", consoleLevel: "warn" }); + + replySpy.mockResolvedValue({ text: "needs attention" }); + const sendSlack = vi.fn().mockResolvedValue({ messageId: "m1", channelId: "C123" }); + + const res = await runHeartbeatOnce({ + cfg, + deps: { + getReplyFromConfig: replySpy, + slack: sendSlack, + getQueueSize: () => 0, + nowMs: () => 0, + }, + }); + + expect(res.status).toBe("ran"); + expect(sendSlack).toHaveBeenCalledTimes(1); + // Operators must see why their oversized heartbeat file no longer applies. + expect( + warn.mock.calls.some((call) => String(call[0]).includes("skipping oversized HEARTBEAT.md")), + ).toBe(true); + }); + }); +}); diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts index b0e0877d1bc7..f6ca81faa253 100644 --- a/src/infra/heartbeat-runner.ts +++ b/src/infra/heartbeat-runner.ts @@ -178,6 +178,7 @@ import { resolveHeartbeatDeliveryTargetWithSessionRoute, resolveHeartbeatSenderContext, } from "./outbound/targets.js"; +import { readRegularFile } from "./regular-file.js"; import { consumeSelectedSystemEventEntries, peekSystemEventEntries, @@ -985,9 +986,18 @@ async function resolveHeartbeatPreflight(params: { const workspaceDir = resolveAgentWorkspaceDir(params.cfg, params.agentId); const heartbeatFilePath = path.join(workspaceDir, DEFAULT_HEARTBEAT_FILENAME); + const MAX_HEARTBEAT_FILE_BYTES = 16 * 1024 * 1024; let heartbeatFileContent: string | undefined; try { - heartbeatFileContent = await fs.readFile(heartbeatFilePath, "utf-8"); + // Resolve symlinks so a HEARTBEAT.md pointing to a regular file keeps + // working; missing/broken symlinks still surface as ENOENT below. + const resolvedHeartbeatFilePath = await fs.realpath(heartbeatFilePath); + heartbeatFileContent = ( + await readRegularFile({ + filePath: resolvedHeartbeatFilePath, + maxBytes: MAX_HEARTBEAT_FILE_BYTES, + }) + ).buffer.toString("utf-8"); const tasks = parseHeartbeatTasks(heartbeatFileContent); if ( isHeartbeatContentEffectivelyEmpty(heartbeatFileContent) && @@ -1014,6 +1024,12 @@ async function resolveHeartbeatPreflight(params: { // The heartbeat prompt already says "if it exists". return basePreflight; } + // Oversized files loaded in full before the cap existed, so tell the + // operator why their heartbeat instructions no longer apply instead of + // dropping them silently. Other read errors keep proceeding as before. + if (err instanceof Error && err.message.startsWith("File exceeds")) { + log.warn(`heartbeat: skipping oversized ${DEFAULT_HEARTBEAT_FILENAME}: ${err.message}`); + } // For other read errors, proceed with heartbeat as before. }