Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion src/agent/docs-only-backend.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import path from "node:path";
import {
LocalShellBackend,
type EditResult,
type LocalShellBackendOptions,
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 & {
Expand All @@ -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(
Expand Down Expand Up @@ -52,7 +83,8 @@ export class OpenWikiLocalShellBackend extends LocalShellBackend {
if (
!this.docsOnly ||
this.outputMode === "local-wiki" ||
isOpenWikiDocsPath(filePath)
isOpenWikiDocsPath(filePath) ||
isConversationHistoryPath(filePath)
) {
return null;
}
Expand All @@ -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/")
);
}
2 changes: 2 additions & 0 deletions src/openwiki-home.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -33,6 +34,7 @@ export async function ensureOpenWikiHome(): Promise<void> {
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<void> {
Expand Down
37 changes: 37 additions & 0 deletions test/docs-only-backend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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({
Expand Down Expand Up @@ -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 });
});
});