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:
parent
91d37ccfc3
commit
65fae19fc8
@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
@ -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({
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user