From f92ed16e605ed8984ee2e07cb2ff557398b6f5cd Mon Sep 17 00:00:00 2001 From: cxbAsDev Date: Wed, 8 Jul 2026 00:49:27 +0800 Subject: [PATCH] fix(sessions): bound trajectory snapshot and pointer reads (#101450) * fix(sessions): bound trajectory snapshot and pointer reads * test(sessions): align oversized snapshot assertion with bounded reader error message * fix(sessions): cap follow-mode trajectory deltas ClawSweeper review noted that the trajectory snapshot read was bounded but the follow-mode delta read (Buffer.alloc(fileState.size - offset)) was not. A runaway trajectory append could still OOM the tail loop. Reject deltas above TRAJECTORY_RUNTIME_FILE_MAX_BYTES and add regression coverage for the oversized-delta path. * test(sessions): exercise trajectory limits through command path --------- Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com> --- src/commands/sessions-tail.test.ts | 33 ++++++++++++++++++++++++++++++ src/commands/sessions-tail.ts | 25 ++++++++++++++++++---- 2 files changed, 54 insertions(+), 4 deletions(-) diff --git a/src/commands/sessions-tail.test.ts b/src/commands/sessions-tail.test.ts index e238aa991268..480dce2854cb 100644 --- a/src/commands/sessions-tail.test.ts +++ b/src/commands/sessions-tail.test.ts @@ -7,6 +7,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { resolveTrajectoryFilePath, resolveTrajectoryPointerFilePath, + TRAJECTORY_RUNTIME_FILE_MAX_BYTES, } from "../trajectory/paths.js"; import type { TrajectoryEvent } from "../trajectory/types.js"; import { sessionsTailCommand, setSessionsTailFollowIntervalMsForTests } from "./sessions-tail.js"; @@ -492,4 +493,36 @@ describe("sessionsTailCommand", () => { expect(output).toContain("trajectory-dir ok"); expect(output).not.toContain("No sessions found"); }); + + it("rejects oversized trajectory snapshots", async () => { + const runtime = makeRuntime(); + fs.writeFileSync(trajectoryPath, ""); + fs.truncateSync(trajectoryPath, TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); + + await expect(sessionsTailCommand({ store: storePath, sessionKey }, runtime)).rejects.toThrow( + /File exceeds 52428800 bytes/, + ); + }); + + it("rejects oversized follow-mode trajectory deltas", async () => { + const runtime = makeRuntime(); + writeJsonl(trajectoryPath, [ + makeEvent({ type: "session.started", ts: "2026-05-18T12:04:17.000Z" }), + ]); + + const run = sessionsTailCommand({ store: storePath, sessionKey, follow: true }, runtime); + try { + await waitForRuntimeOutput(runtime, "session.started"); + const initialSize = fs.statSync(trajectoryPath).size; + fs.truncateSync(trajectoryPath, initialSize + TRAJECTORY_RUNTIME_FILE_MAX_BYTES + 1); + await vi.waitFor(() => { + expect(runtime.error).toHaveBeenCalledWith( + expect.stringContaining("Trajectory delta exceeds 52428800 bytes"), + ); + }); + } finally { + process.emit("SIGTERM", "SIGTERM"); + await run; + } + }); }); diff --git a/src/commands/sessions-tail.ts b/src/commands/sessions-tail.ts index 9fa909a55ba9..9336c3874e52 100644 --- a/src/commands/sessions-tail.ts +++ b/src/commands/sessions-tail.ts @@ -15,11 +15,13 @@ import type { SessionEntry } from "../config/sessions/types.js"; import { resolveStoredSessionKeyForAgentStore } from "../gateway/session-store-key.js"; import { formatErrorMessage } from "../infra/errors.js"; import { parseStrictNonNegativeInteger } from "../infra/parse-finite-number.js"; +import { readRegularFileSync } from "../infra/regular-file.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; import type { RuntimeEnv } from "../runtime.js"; import { resolveTrajectoryFilePath, resolveTrajectoryPointerFilePath, + TRAJECTORY_RUNTIME_FILE_MAX_BYTES, } from "../trajectory/paths.js"; import { isRegularNonSymlinkFile, @@ -279,8 +281,13 @@ function formatProgressLine(event: TrajectoryEvent): string { function readTrajectorySnapshot(filePath: string): TrajectorySnapshot { try { - const stat = fs.statSync(filePath); - const text = fs.readFileSync(filePath, "utf8"); + // Use the runtime trajectory limit so tail accepts any file the runtime + // would have written and rejects anything larger. + const { buffer, stat } = readRegularFileSync({ + filePath, + maxBytes: TRAJECTORY_RUNTIME_FILE_MAX_BYTES, + }); + const text = buffer.toString("utf8"); return { events: parseTrajectoryEventLines(text.split(/\r?\n/u)), fileState: fileStateFromStat(stat), @@ -361,7 +368,11 @@ async function readTrajectoryPointerSessionId(sessionFile: string): Promise TRAJECTORY_RUNTIME_FILE_MAX_BYTES) { + throw new Error( + `Trajectory delta exceeds ${TRAJECTORY_RUNTIME_FILE_MAX_BYTES} bytes: ${deltaBytes}`, + ); + } + const buffer = Buffer.alloc(deltaBytes); fs.readSync(fd, buffer, 0, buffer.length, state.offset); state.offset = fileState.size; state.fileState = fileState;