From f2e5cd1898efcfe763d00ea2eae63e9afbeb79e9 Mon Sep 17 00:00:00 2001 From: momothemage Date: Fri, 3 Jul 2026 11:59:22 +0800 Subject: [PATCH] fix: fail closed on capped transcript header scan --- .../session-manager-init.test.ts | 51 +++++++++++++++++++ .../session-manager-init.ts | 28 +++++++--- 2 files changed, 72 insertions(+), 7 deletions(-) diff --git a/src/agents/embedded-agent-runner/session-manager-init.test.ts b/src/agents/embedded-agent-runner/session-manager-init.test.ts index 18c82389f497..632f37e73f3a 100644 --- a/src/agents/embedded-agent-runner/session-manager-init.test.ts +++ b/src/agents/embedded-agent-runner/session-manager-init.test.ts @@ -404,6 +404,57 @@ describe("prepareSessionManagerForRun", () => { expect(sessionManager.flushed).toBe(true); }); + it("does not truncate when the first non-empty header is beyond the scan cap", async () => { + const sessionFile = await makeTempFile(); + const blankPrefix = " \n".repeat(33_000); + const originalTranscript = + blankPrefix + + [ + '{"type":"session","id":"broken"', + JSON.stringify({ + type: "message", + id: "user-1", + parentId: null, + timestamp: "2026-05-27T00:00:01.000Z", + message: { role: "user", content: "persisted prompt" }, + }), + ].join("\n") + + "\n"; + await fs.writeFile(sessionFile, originalTranscript, "utf-8"); + const sessionManager = { + sessionId: "fresh-session", + cwd: "/srv/openclaw/main", + flushed: true, + fileEntries: [ + { + type: "session", + id: "fresh-session", + cwd: "/srv/openclaw/main", + }, + { + type: "message", + message: { role: "user" }, + }, + ], + byId: new Map([["user-1", {}]]), + labelsById: new Map(), + leafId: "user-1", + }; + + await expect( + prepareSessionManagerForRun({ + sessionManager, + sessionFile, + hadSessionFile: true, + sessionId: "new-session", + cwd: "/tmp/task-repo", + }), + ).rejects.toThrow("Refusing to reset session transcript before finding a readable header"); + + expect(await fs.readFile(sessionFile, "utf-8")).toBe(originalTranscript); + expect(sessionManager.flushed).toBe(true); + }); + it("keeps recovered user-only transcripts through open and run preparation", async () => { const sessionFile = await makeTempFile(); const userEntry = { diff --git a/src/agents/embedded-agent-runner/session-manager-init.ts b/src/agents/embedded-agent-runner/session-manager-init.ts index 2419f74da239..82853ea78211 100644 --- a/src/agents/embedded-agent-runner/session-manager-init.ts +++ b/src/agents/embedded-agent-runner/session-manager-init.ts @@ -17,7 +17,12 @@ type SessionMessageEntry = { type: "message"; message?: { role?: string } }; const SESSION_HEADER_READ_CHUNK_BYTES = 4096; const MAX_SESSION_HEADER_BYTES = 64 * 1024; -async function readFirstSessionFileLine(sessionFile: string): Promise { +type SessionFileHeaderRead = { + firstLine?: string; + cappedBeforeFirstLine: boolean; +}; + +async function readFirstSessionFileLine(sessionFile: string): Promise { const handle = await fs.open(sessionFile, "r"); try { const chunks: string[] = []; @@ -31,8 +36,8 @@ async function readFirstSessionFileLine(sessionFile: string): Promise= MAX_SESSION_HEADER_BYTES; + const nextTotalBytes = totalBytes + bytesRead; + const readEnd = bytesRead < buffer.length || nextTotalBytes >= MAX_SESSION_HEADER_BYTES; const scannedText = chunks.join(""); const lines = scannedText.split(/\r?\n/u); const hasCompleteLine = lines.length > 1; @@ -40,23 +45,32 @@ async function readFirstSessionFileLine(sessionFile: string): Promise= MAX_SESSION_HEADER_BYTES) { + return { cappedBeforeFirstLine: true }; + } } - return chunks + const firstLine = chunks .join("") .split(/\r?\n/u) .map((line) => line.trim()) .find((line) => line.length > 0); + return { firstLine, cappedBeforeFirstLine: false }; } finally { await handle.close().catch(() => undefined); } } async function assertExistingHeaderIsReadable(sessionFile: string): Promise { - const firstLine = await readFirstSessionFileLine(sessionFile); + const { firstLine, cappedBeforeFirstLine } = await readFirstSessionFileLine(sessionFile); + if (cappedBeforeFirstLine) { + throw new Error( + `Refusing to reset session transcript before finding a readable header: ${sessionFile}`, + ); + } if (!firstLine) { return; }