From 4040a25bb19d3cd4783a59503a675b0ed74f779e Mon Sep 17 00:00:00 2001 From: zeroaltitude Date: Sat, 7 Mar 2026 10:09:43 -0700 Subject: [PATCH] fix(session-memory): ensure memoryDir exists in post-hook write path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When blockSessionSave is true initially, the inline write is skipped — including the fs.mkdir that creates memoryDir. If a later hook clears blockSessionSave and sets sessionSaveContent, the post-hook writeFileWithinRoot call would fail with ENOENT because the directory was never created. The error was silently swallowed, causing the content override to be lost. Add fs.mkdir(memoryDir, { recursive: true }) before the post-hook write. Add regression test for the block-then-clear-with-content scenario. --- .../bundled/session-memory/handler.test.ts | 43 +++++++++++++++++++ src/hooks/bundled/session-memory/handler.ts | 4 ++ 2 files changed, 47 insertions(+) diff --git a/src/hooks/bundled/session-memory/handler.test.ts b/src/hooks/bundled/session-memory/handler.test.ts index 85bd757727a..fbee8e9eba6 100644 --- a/src/hooks/bundled/session-memory/handler.test.ts +++ b/src/hooks/bundled/session-memory/handler.test.ts @@ -747,6 +747,49 @@ describe("session-memory hook", () => { expect(content).toContain("important data"); }); + it("blockSessionSave pre-set then cleared with sessionSaveContent creates file (mkdir edge case)", async () => { + // Regression: when blockSessionSave is true initially, the inline write + // is skipped — including the fs.mkdir. If a later hook clears the flag + // and sets sessionSaveContent, the post-hook write must create the + // directory itself or it fails with ENOENT. + const tempDir = await createCaseWorkspace("block-then-clear"); + const sessionsDir = path.join(tempDir, "sessions"); + await fs.mkdir(sessionsDir, { recursive: true }); + const sessionFile = await writeWorkspaceFile({ + dir: sessionsDir, + name: "test-session.jsonl", + content: createMockSessionContent([{ role: "user", content: "secret" }]), + }); + + const event = createHookEvent("command", "new", "agent:main:main", { + cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig, + previousSessionEntry: { sessionId: "s1", sessionFile }, + }); + event.context.blockSessionSave = true; + + // Handler runs — inline write is skipped, memoryDir never created + await handler(event); + + const memoryDir = path.join(tempDir, "memory"); + const existsBefore = await fs + .stat(memoryDir) + .then(() => true) + .catch(() => false); + expect(existsBefore).toBe(false); + + // A later hook clears blockSessionSave and sets custom content + event.context.blockSessionSave = false; + event.context.sessionSaveContent = "Replacement content from policy hook"; + + // Post-hook should create the directory and write the file + await drainPostHookActions(event); + + const files = (await fs.readdir(memoryDir)).filter((f) => f.endsWith(".md")); + expect(files.length).toBeGreaterThan(0); + const content = await fs.readFile(path.join(memoryDir, files[0]), "utf-8"); + expect(content).toBe("Replacement content from policy hook"); + }); + it("blockSessionSave takes precedence over sessionSaveContent (both pre-set)", async () => { const tempDir = await createCaseWorkspace("block-beats-content-pre"); const sessionsDir = path.join(tempDir, "sessions"); diff --git a/src/hooks/bundled/session-memory/handler.ts b/src/hooks/bundled/session-memory/handler.ts index fa038880051..91ca2ad698b 100644 --- a/src/hooks/bundled/session-memory/handler.ts +++ b/src/hooks/bundled/session-memory/handler.ts @@ -414,6 +414,10 @@ const saveSessionToMemory: HookHandler = async (event) => { typeof postContent === "string" && postContent !== writtenEntry ) { + // Ensure memoryDir exists — the inline write may have been + // skipped (e.g. blockSessionSave was true initially) so mkdir + // might never have run. + await fs.mkdir(memoryDir, { recursive: true }); await writeFileWithinRoot({ rootDir: memoryDir, relativePath: filename,