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>
This commit is contained in:
cxbAsDev
2026-07-08 00:49:27 +08:00
committed by GitHub
parent 453f5968bb
commit f92ed16e60
2 changed files with 54 additions and 4 deletions

View File

@@ -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;
}
});
});

View File

@@ -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<stri
return undefined;
}
try {
const parsed = JSON.parse(fs.readFileSync(pointerPath, "utf8")) as unknown;
const { buffer } = readRegularFileSync({
filePath: pointerPath,
maxBytes: 64 * 1024,
});
const parsed = JSON.parse(buffer.toString("utf8")) as unknown;
if (!isRecord(parsed) || typeof parsed.sessionId !== "string") {
return undefined;
}
@@ -501,7 +512,13 @@ function readNewFollowEvents(state: FollowState): TrajectoryEvent[] {
const fd = fs.openSync(state.selection.trajectoryPath, "r");
try {
const buffer = Buffer.alloc(fileState.size - state.offset);
const deltaBytes = fileState.size - state.offset;
if (deltaBytes > 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;