From 1e54e33204a418b0b8b86c4083e42d2e2989183d Mon Sep 17 00:00:00 2001 From: blue-az Date: Tue, 14 Jul 2026 16:20:06 +0200 Subject: [PATCH] fix: redirect conversation_history offloads out of target repo Summarization middleware offloads write to /conversation_history, which the OpenWikiLocalShellBackend resolved under the target repo's cwd, polluting the workspace being documented. Redirect those virtual paths to ~/.openwiki/conversation_history instead. Fixes #49 --- src/agent/docs-only-backend.ts | 44 +++++++++++++++++++++++++++++++++- src/openwiki-home.ts | 2 ++ test/docs-only-backend.test.ts | 37 ++++++++++++++++++++++++++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/src/agent/docs-only-backend.ts b/src/agent/docs-only-backend.ts index d6c0ec12..4f565c12 100644 --- a/src/agent/docs-only-backend.ts +++ b/src/agent/docs-only-backend.ts @@ -1,3 +1,4 @@ +import path from "node:path"; import { LocalShellBackend, type EditResult, @@ -5,6 +6,7 @@ import { type WriteResult, } from "deepagents"; import { OPEN_WIKI_DIR } from "../constants.js"; +import { openWikiHistoryDir } from "../openwiki-home.js"; import type { OpenWikiOutputMode } from "./types.js"; type OpenWikiBackendOptions = LocalShellBackendOptions & { @@ -20,6 +22,35 @@ export class OpenWikiLocalShellBackend extends LocalShellBackend { super(options); this.docsOnly = options.docsOnly === true; this.outputMode = options.outputMode ?? "repository"; + + const self = this as unknown as { + resolvePath?(key: string): string; + }; + if (typeof self.resolvePath === "function") { + const originalResolvePath = self.resolvePath.bind(this); + self.resolvePath = (key: string): string => { + if (isConversationHistoryPath(key)) { + const normalizedPath = key.trim().replace(/\\/gu, "/"); + if (normalizedPath.includes("..") || normalizedPath.startsWith("~")) { + throw new Error("Path traversal not allowed"); + } + const virtualPath = normalizedPath.replace(/^\/+/u, ""); + const relativePart = virtualPath + .slice("conversation_history".length) + .replace(/^\/+/u, ""); + const full = path.resolve(openWikiHistoryDir, relativePart); + const relative = path.relative(openWikiHistoryDir, full); + if (relative.startsWith("..") || path.isAbsolute(relative)) { + throw new Error( + `Path: ${full} outside history directory: ${openWikiHistoryDir}`, + ); + } + return full; + } + + return originalResolvePath(key); + }; + } } override async write( @@ -52,7 +83,8 @@ export class OpenWikiLocalShellBackend extends LocalShellBackend { if ( !this.docsOnly || this.outputMode === "local-wiki" || - isOpenWikiDocsPath(filePath) + isOpenWikiDocsPath(filePath) || + isConversationHistoryPath(filePath) ) { return null; } @@ -69,3 +101,13 @@ export function isOpenWikiDocsPath(filePath: string): boolean { virtualPath === OPEN_WIKI_DIR || virtualPath.startsWith(`${OPEN_WIKI_DIR}/`) ); } + +export function isConversationHistoryPath(filePath: string): boolean { + const normalizedPath = filePath.trim().replace(/\\/gu, "/"); + const virtualPath = normalizedPath.replace(/^\/+/u, ""); + + return ( + virtualPath === "conversation_history" || + virtualPath.startsWith("conversation_history/") + ); +} diff --git a/src/openwiki-home.ts b/src/openwiki-home.ts index 8c4420dc..03c31223 100644 --- a/src/openwiki-home.ts +++ b/src/openwiki-home.ts @@ -6,6 +6,7 @@ export const openWikiHomeDir = path.join(os.homedir(), ".openwiki"); export const openWikiConnectorsDir = path.join(openWikiHomeDir, "connectors"); export const openWikiLocalWikiDir = path.join(openWikiHomeDir, "wiki"); export const openWikiSkillsDir = path.join(openWikiHomeDir, "skills"); +export const openWikiHistoryDir = path.join(openWikiHomeDir, "conversation_history"); export function getConnectorDir(connectorId: string): string { return path.join(openWikiConnectorsDir, connectorId); @@ -33,6 +34,7 @@ export async function ensureOpenWikiHome(): Promise { await mkdir(openWikiConnectorsDir, { recursive: true, mode: 0o700 }); await mkdir(openWikiLocalWikiDir, { recursive: true, mode: 0o700 }); await mkdir(openWikiSkillsDir, { recursive: true, mode: 0o700 }); + await mkdir(openWikiHistoryDir, { recursive: true, mode: 0o700 }); } export async function ensureConnectorHome(connectorId: string): Promise { diff --git a/test/docs-only-backend.test.ts b/test/docs-only-backend.test.ts index 9a5e3908..a997d631 100644 --- a/test/docs-only-backend.test.ts +++ b/test/docs-only-backend.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, test } from "vitest"; import { + isConversationHistoryPath, isOpenWikiDocsPath, OpenWikiLocalShellBackend, } from "../src/agent/docs-only-backend.ts"; @@ -19,6 +20,14 @@ describe("OpenWikiLocalShellBackend", () => { ); }); + test("recognizes conversation history virtual paths", () => { + expect(isConversationHistoryPath("/conversation_history/session_1.md")).toBe(true); + expect(isConversationHistoryPath("conversation_history/session_1.md")).toBe(true); + expect(isConversationHistoryPath("\\conversation_history\\session_1.md")).toBe(true); + expect(isConversationHistoryPath("/conversation_history")).toBe(true); + expect(isConversationHistoryPath("/not_conversation_history/session_1.md")).toBe(false); + }); + test("refuses init/update writes outside openwiki", async () => { const rootDir = await mkdtemp(path.join(os.tmpdir(), "openwiki-backend-")); const backend = new OpenWikiLocalShellBackend({ @@ -77,4 +86,32 @@ describe("OpenWikiLocalShellBackend", () => { readFile(path.join(rootDir, "notes.md"), "utf8"), ).resolves.toBe("ok"); }); + + test("redirects conversation history writes and resolves them to openWikiHistoryDir", async () => { + const rootDir = await mkdtemp(path.join(os.tmpdir(), "openwiki-backend-")); + const backend = new OpenWikiLocalShellBackend({ + docsOnly: true, + rootDir, + virtualMode: true, + }); + + const virtualPath = "/conversation_history/session_test.md"; + const expectedRealPath = ( + backend as unknown as { resolvePath(key: string): string } + ).resolvePath(virtualPath); + + // Verify it resolves under openWikiHistoryDir (outside rootDir) + expect(expectedRealPath).not.toContain(rootDir); + expect(expectedRealPath).toContain(".openwiki"); + expect(expectedRealPath).toContain("conversation_history"); + + // Verify that writing is allowed even when docsOnly is true + const writeResult = await backend.write(virtualPath, "history content"); + expect(writeResult.error).toBeUndefined(); + expect(writeResult.path).toBe(virtualPath); + + // Clean up if the file was written + const { rm } = await import("node:fs/promises"); + await rm(expectedRealPath, { force: true }); + }); });