From e2e2f01fe86c0618bd649adde9c176e3eeb85704 Mon Sep 17 00:00:00 2001 From: Shakker Date: Mon, 2 Feb 2026 23:45:58 +0000 Subject: [PATCH] Agents: harden session file repair --- src/agents/session-file-repair.test.ts | 59 +++++++++++++++++++++++++- src/agents/session-file-repair.ts | 23 +++++++--- 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/src/agents/session-file-repair.test.ts b/src/agents/session-file-repair.test.ts index 9606031199..325fc96a88 100644 --- a/src/agents/session-file-repair.test.ts +++ b/src/agents/session-file-repair.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { repairSessionFileIfNeeded } from "./session-file-repair.js"; describe("repairSessionFileIfNeeded", () => { @@ -39,4 +39,61 @@ describe("repairSessionFileIfNeeded", () => { expect(backup).toBe(content); } }); + + it("does not drop CRLF-terminated JSONL lines", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); + const file = path.join(dir, "session.jsonl"); + const header = { + type: "session", + version: 7, + id: "session-1", + timestamp: new Date().toISOString(), + cwd: "/tmp", + }; + const message = { + type: "message", + id: "msg-1", + parentId: null, + timestamp: new Date().toISOString(), + message: { role: "user", content: "hello" }, + }; + const content = `${JSON.stringify(header)}\r\n${JSON.stringify(message)}\r\n`; + await fs.writeFile(file, content, "utf-8"); + + const result = await repairSessionFileIfNeeded({ sessionFile: file }); + expect(result.repaired).toBe(false); + expect(result.droppedLines).toBe(0); + }); + + it("warns and skips repair when the session header is invalid", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); + const file = path.join(dir, "session.jsonl"); + const badHeader = { + type: "message", + id: "msg-1", + timestamp: new Date().toISOString(), + message: { role: "user", content: "hello" }, + }; + const content = `${JSON.stringify(badHeader)}\n{"type":"message"`; + await fs.writeFile(file, content, "utf-8"); + + const warn = vi.fn(); + const result = await repairSessionFileIfNeeded({ sessionFile: file, warn }); + + expect(result.repaired).toBe(false); + expect(result.reason).toBe("invalid session header"); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("invalid session header"); + }); + + it("returns a detailed reason when read errors are not ENOENT", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-session-repair-")); + const warn = vi.fn(); + + const result = await repairSessionFileIfNeeded({ sessionFile: dir, warn }); + + expect(result.repaired).toBe(false); + expect(result.reason).toContain("failed to read session file"); + expect(warn).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/agents/session-file-repair.ts b/src/agents/session-file-repair.ts index 568858c516..d073a50f50 100644 --- a/src/agents/session-file-repair.ts +++ b/src/agents/session-file-repair.ts @@ -28,11 +28,17 @@ export async function repairSessionFileIfNeeded(params: { let content: string; try { content = await fs.readFile(sessionFile, "utf-8"); - } catch { - return { repaired: false, droppedLines: 0, reason: "missing session file" }; + } catch (err) { + const code = (err as { code?: unknown } | undefined)?.code; + if (code === "ENOENT") { + return { repaired: false, droppedLines: 0, reason: "missing session file" }; + } + const reason = `failed to read session file: ${err instanceof Error ? err.message : "unknown error"}`; + params.warn?.(`session file repair skipped: ${reason} (${path.basename(sessionFile)})`); + return { repaired: false, droppedLines: 0, reason }; } - const lines = content.split("\n"); + const lines = content.split(/\r?\n/); const entries: unknown[] = []; let droppedLines = 0; @@ -53,6 +59,9 @@ export async function repairSessionFileIfNeeded(params: { } if (!isSessionHeader(entries[0])) { + params.warn?.( + `session file repair skipped: invalid session header (${path.basename(sessionFile)})`, + ); return { repaired: false, droppedLines, reason: "invalid session header" }; } @@ -77,8 +86,12 @@ export async function repairSessionFileIfNeeded(params: { } catch (err) { try { await fs.unlink(tmpPath); - } catch { - // ignore cleanup failures + } catch (cleanupErr) { + params.warn?.( + `session file repair cleanup failed: ${cleanupErr instanceof Error ? cleanupErr.message : "unknown error"} (${path.basename( + tmpPath, + )})`, + ); } return { repaired: false,