fix(core): avoid session export filename collisions (#77762)

This commit is contained in:
Vincent Koc
2026-05-05 02:11:48 -07:00
committed by GitHub
parent a732208d45
commit 3b1921b543
2 changed files with 60 additions and 3 deletions

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HandleCommandsParams } from "./commands-types.js";
const hoisted = await vi.hoisted(async () => {
@@ -119,6 +120,10 @@ function makeParams(): HandleCommandsParams {
}
describe("buildExportSessionReply", () => {
afterEach(() => {
vi.useRealTimers();
});
beforeEach(() => {
vi.clearAllMocks();
hoisted.resolveDefaultSessionStorePathMock.mockReturnValue("/tmp/target-store/sessions.json");
@@ -231,6 +236,32 @@ describe("buildExportSessionReply", () => {
expect(html).toContain('const base64 = document.getElementById("session-data").textContent;');
});
it("suffixes colliding default export filenames instead of overwriting", async () => {
const { buildExportSessionReply } = await import("./commands-export-session.js");
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z"));
const collision = Object.assign(new Error("exists"), { code: "EEXIST" });
hoisted.writeFileMock.mockRejectedValueOnce(collision).mockResolvedValueOnce(undefined);
const reply = await buildExportSessionReply(makeParams());
const expectedBase = path.join(
"/tmp/workspace",
"openclaw-session-session--2026-05-05T10-11-12.html",
);
const expectedSuffix = path.join(
"/tmp/workspace",
"openclaw-session-session--2026-05-05T10-11-12-2.html",
);
expect(hoisted.writeFileMock.mock.calls[0]?.[0]).toBe(expectedBase);
expect(hoisted.writeFileMock.mock.calls[0]?.[2]).toMatchObject({
encoding: "utf-8",
flag: "wx",
});
expect(hoisted.writeFileMock.mock.calls[1]?.[0]).toBe(expectedSuffix);
expect(reply.text).toContain("📄 File: openclaw-session-session--2026-05-05T10-11-12-2.html");
});
it("preserves replacement text with dollar sequences", async () => {
const { buildExportSessionReply } = await import("./commands-export-session.js");
hoisted.exportHtmlTemplateContents.set(

View File

@@ -131,6 +131,28 @@ async function fileExists(pathName: string): Promise<boolean> {
}
}
function addCollisionSuffix(filePath: string, suffix: number): string {
const ext = path.extname(filePath);
const baseName = path.basename(filePath, ext);
return path.join(path.dirname(filePath), `${baseName}-${suffix}${ext}`);
}
async function writeNewDefaultExportFile(filePath: string, html: string): Promise<string> {
for (let suffix = 1; suffix <= 100; suffix++) {
const candidate = suffix === 1 ? filePath : addCollisionSuffix(filePath, suffix);
try {
await fsp.writeFile(candidate, html, { encoding: "utf-8", flag: "wx" });
return candidate;
} catch (error) {
if (typeof error === "object" && error && "code" in error && error.code === "EEXIST") {
continue;
}
throw error;
}
}
throw new Error(`Could not find an unused export filename near ${filePath}`);
}
async function readSessionDataFromTranscript(sessionFile: string): Promise<{
header: SessionHeader | null;
entries: PiSessionEntry[];
@@ -193,7 +215,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
// 6. Determine output path
const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
const defaultFileName = `openclaw-session-${entry.sessionId.slice(0, 8)}-${timestamp}.html`;
const outputPath = args.outputPath
let outputPath = args.outputPath
? path.resolve(
args.outputPath.startsWith("~")
? args.outputPath.replace("~", process.env.HOME ?? "")
@@ -206,7 +228,11 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
await fsp.mkdir(outputDir, { recursive: true });
// 7. Write file
await fsp.writeFile(outputPath, html, "utf-8");
if (args.outputPath) {
await fsp.writeFile(outputPath, html, "utf-8");
} else {
outputPath = await writeNewDefaultExportFile(outputPath, html);
}
const relativePath = path.relative(params.workspaceDir, outputPath);
const displayPath = relativePath.startsWith("..") ? outputPath : relativePath;