feat(hooks): add blockSessionSave and sessionSaveContent to session memory handler

Two new context fields for upstream hooks (e.g. security plugins) to
control session memory persistence:

- blockSessionSave (boolean): prevent session from being saved to memory
- sessionSaveContent (string): override saved content with custom text
  (empty string is valid — persists a blank marker without transcript)

When sessionSaveContent is set, LLM slug generation and session content
loading are skipped (unnecessary when content is overridden).

Split from #35567 — sessionSaveRedirectPath follows separately as it
requires path canonicalization, symlink resolution, and filesystem
write policy review.
This commit is contained in:
zeroaltitude 2026-03-06 09:43:52 -07:00
parent 91d37ccfc3
commit 65fae19fc8
No known key found for this signature in database
GPG Key ID: 77592FB1C703882E
2 changed files with 91 additions and 2 deletions

View File

@ -572,4 +572,81 @@ describe("session-memory hook", () => {
expect(memoryContent).toContain("user: Only message 1");
expect(memoryContent).toContain("assistant: Only message 2");
});
it("blockSessionSave prevents memory file creation", async () => {
const tempDir = await createCaseWorkspace("block-save");
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;
await handler(event);
const memoryDir = path.join(tempDir, "memory");
const memoryFiles = await fs.readdir(memoryDir).catch(() => [] as string[]);
expect(memoryFiles.filter((f) => f.endsWith(".md"))).toHaveLength(0);
});
it("sessionSaveContent overrides saved content", async () => {
const tempDir = await createCaseWorkspace("custom-content");
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: "original" }]),
});
const event = createHookEvent("command", "new", "agent:main:main", {
cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig,
previousSessionEntry: { sessionId: "s1", sessionFile },
});
event.context.sessionSaveContent = "Custom summary from upstream hook";
await handler(event);
const memoryDir = path.join(tempDir, "memory");
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).toContain("Custom summary from upstream hook");
expect(content).not.toContain("original");
});
it("sessionSaveContent empty string writes blank marker file", async () => {
const tempDir = await createCaseWorkspace("empty-content");
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: "sensitive data" }]),
});
const event = createHookEvent("command", "new", "agent:main:main", {
cfg: { agents: { defaults: { workspace: tempDir } } } satisfies OpenClawConfig,
previousSessionEntry: { sessionId: "s1", sessionFile },
});
// Empty string is a valid override — persists a blank marker without
// loading the transcript or generating an LLM slug.
event.context.sessionSaveContent = "";
await handler(event);
const memoryDir = path.join(tempDir, "memory");
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");
// Should be truly empty — blank marker file
expect(content).toBe("");
});
});

View File

@ -207,6 +207,13 @@ const saveSessionToMemory: HookHandler = async (event) => {
log.debug("Hook triggered for reset/new command", { action: event.action });
const context = event.context || {};
// Check if another hook (e.g., security plugin) blocked the save.
if (context.blockSessionSave === true) {
log.debug("Session save blocked by upstream hook");
return;
}
const cfg = context.cfg as OpenClawConfig | undefined;
const contextWorkspaceDir =
typeof context.workspaceDir === "string" && context.workspaceDir.trim().length > 0
@ -279,8 +286,9 @@ const saveSessionToMemory: HookHandler = async (event) => {
let slug: string | null = null;
let sessionContent: string | null = null;
const hasCustomContent = typeof context.sessionSaveContent === "string";
if (sessionFile) {
if (sessionFile && !hasCustomContent) {
// Get recent conversation content, with fallback to rotated reset transcript.
sessionContent = await getRecentSessionContentWithResetFallback(sessionFile, messageCount);
log.debug("Session content loaded", {
@ -341,7 +349,11 @@ const saveSessionToMemory: HookHandler = async (event) => {
entryParts.push("## Conversation Summary", "", sessionContent, "");
}
const entry = entryParts.join("\n");
// Use custom content from upstream hook if available, otherwise use built entry.
// An empty string is a valid redaction signal — hooks may intentionally
// set it to persist a blank marker while avoiding transcript retention.
const customContent = context.sessionSaveContent;
const entry = typeof customContent === "string" ? customContent : entryParts.join("\n");
// Write under memory root with alias-safe file validation.
await writeFileWithinRoot({