import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; // Mock homedir so the user-scope legacy fallback check (~/.pi/agent-memory) // resolves against a controlled temp home rather than the developer's real // ~/.pi state. The default return must be a valid string: pi-coding-agent // evaluates getAgentDir() at module load, so an undefined homedir throws at import. const mockHomedir = vi.hoisted(() => vi.fn(() => "/tmp")); vi.mock("node:os", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, homedir: mockHomedir }; }); import { buildMemoryBlock, buildReadOnlyMemoryBlock, ensureMemoryDir, isSymlink, isUnsafeName, readMemoryIndex, resolveMemoryDir, safeReadFile } from "../src/memory.js"; describe("memory", () => { let tmpDir: string; beforeEach(() => { tmpDir = mkdtempSync(join(tmpdir(), "pi-mem-test-")); // Point homedir at a clean temp home with no legacy agent-memory dirs, so // user-scope resolution deterministically returns the agent-dir location. const fakeHome = join(tmpDir, "home"); mkdirSync(fakeHome, { recursive: true }); mockHomedir.mockReturnValue(fakeHome); }); afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); describe("resolveMemoryDir", () => { it("resolves project scope to .pi/agent-memory/", () => { const dir = resolveMemoryDir("auditor", "project", "/workspace"); expect(dir).toBe("/workspace/.pi/agent-memory/auditor"); }); it("resolves local scope to .pi/agent-memory-local/", () => { const dir = resolveMemoryDir("auditor", "local", "/workspace"); expect(dir).toBe("/workspace/.pi/agent-memory-local/auditor"); }); it("resolves user scope under the agent dir (honors PI_CODING_AGENT_DIR)", () => { const originalEnv = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = join(tmpDir, "custom-agent-dir"); try { const dir = resolveMemoryDir("auditor", "user", "/workspace"); expect(dir).toBe(join(tmpDir, "custom-agent-dir", "agent-memory", "auditor")); expect(dir).not.toContain("/workspace"); } finally { if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalEnv; } }); it("throws on names with path traversal (..)", () => { expect(() => resolveMemoryDir("../../etc/evil", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on names with forward slash", () => { expect(() => resolveMemoryDir("foo/bar", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on names with backslash", () => { expect(() => resolveMemoryDir("foo\\bar", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on names with null byte", () => { expect(() => resolveMemoryDir("foo\0bar", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on empty name", () => { expect(() => resolveMemoryDir("", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on names starting with dot", () => { expect(() => resolveMemoryDir(".hidden", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("throws on names with spaces", () => { expect(() => resolveMemoryDir("foo bar", "project", "/workspace")).toThrow("Unsafe agent name"); }); it("allows hyphens, underscores, and dots in names", () => { expect(() => resolveMemoryDir("my-agent_v2.1", "project", "/workspace")).not.toThrow(); }); }); describe("isUnsafeName (whitelist validation)", () => { it("rejects empty string", () => { expect(isUnsafeName("")).toBe(true); }); it("rejects names longer than 128 chars", () => { expect(isUnsafeName("a".repeat(129))).toBe(true); }); it("rejects path traversal", () => { expect(isUnsafeName("../../etc")).toBe(true); }); it("rejects names starting with dot", () => { expect(isUnsafeName(".hidden")).toBe(true); }); it("rejects names with spaces", () => { expect(isUnsafeName("foo bar")).toBe(true); }); it("rejects names with special characters", () => { expect(isUnsafeName("foo;bar")).toBe(true); expect(isUnsafeName("foo|bar")).toBe(true); expect(isUnsafeName("foo`bar")).toBe(true); }); it("allows valid names", () => { expect(isUnsafeName("my-agent")).toBe(false); expect(isUnsafeName("agent_v2")).toBe(false); expect(isUnsafeName("Agent123")).toBe(false); expect(isUnsafeName("my-agent.v2")).toBe(false); }); }); describe("ensureMemoryDir", () => { it("creates directory if it doesn't exist", () => { const dir = join(tmpDir, "agent-memory", "test"); expect(existsSync(dir)).toBe(false); ensureMemoryDir(dir); expect(existsSync(dir)).toBe(true); }); it("no-ops if directory already exists", () => { const dir = join(tmpDir, "agent-memory", "test"); mkdirSync(dir, { recursive: true }); ensureMemoryDir(dir); // should not throw expect(existsSync(dir)).toBe(true); }); it("throws on symlinked directory", () => { const realDir = join(tmpDir, "real-dir"); const linkDir = join(tmpDir, "symlink-dir"); mkdirSync(realDir, { recursive: true }); symlinkSync(realDir, linkDir); expect(() => ensureMemoryDir(linkDir)).toThrow("symlinked memory directory"); }); }); describe("isSymlink", () => { it("returns false for regular file", () => { const file = join(tmpDir, "regular.txt"); writeFileSync(file, "content"); expect(isSymlink(file)).toBe(false); }); it("returns true for symlink", () => { const file = join(tmpDir, "real.txt"); const link = join(tmpDir, "link.txt"); writeFileSync(file, "content"); symlinkSync(file, link); expect(isSymlink(link)).toBe(true); }); it("returns false for nonexistent path", () => { expect(isSymlink(join(tmpDir, "nope"))).toBe(false); }); }); describe("safeReadFile", () => { it("reads regular files", () => { const file = join(tmpDir, "regular.txt"); writeFileSync(file, "hello"); expect(safeReadFile(file)).toBe("hello"); }); it("rejects symlinked files", () => { const file = join(tmpDir, "real.txt"); const link = join(tmpDir, "link.txt"); writeFileSync(file, "secret"); symlinkSync(file, link); expect(safeReadFile(link)).toBeUndefined(); }); it("returns undefined for nonexistent files", () => { expect(safeReadFile(join(tmpDir, "nope.txt"))).toBeUndefined(); }); }); describe("readMemoryIndex", () => { it("returns undefined when MEMORY.md doesn't exist", () => { const result = readMemoryIndex(tmpDir); expect(result).toBeUndefined(); }); it("reads MEMORY.md content", () => { writeFileSync(join(tmpDir, "MEMORY.md"), "# Memories\n- Item 1\n- Item 2"); const result = readMemoryIndex(tmpDir); expect(result).toBe("# Memories\n- Item 1\n- Item 2"); }); it("rejects symlinked memory directory", () => { const realDir = join(tmpDir, "real-mem"); const linkDir = join(tmpDir, "link-mem"); mkdirSync(realDir, { recursive: true }); writeFileSync(join(realDir, "MEMORY.md"), "# Secret"); symlinkSync(realDir, linkDir); expect(readMemoryIndex(linkDir)).toBeUndefined(); }); it("rejects symlinked MEMORY.md file", () => { const realFile = join(tmpDir, "secret.md"); writeFileSync(realFile, "# Secret"); const memDir = join(tmpDir, "mem-dir"); mkdirSync(memDir); symlinkSync(realFile, join(memDir, "MEMORY.md")); expect(readMemoryIndex(memDir)).toBeUndefined(); }); it("truncates content beyond 200 lines", () => { const lines = Array.from({ length: 250 }, (_, i) => `Line ${i + 1}`); writeFileSync(join(tmpDir, "MEMORY.md"), lines.join("\n")); const result = readMemoryIndex(tmpDir)!; expect(result).toContain("Line 200"); expect(result).not.toContain("Line 201"); expect(result).toContain("truncated at 200 lines"); }); }); describe("buildMemoryBlock", () => { it("builds memory block with no existing MEMORY.md", () => { const block = buildMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("Agent Memory"); expect(block).toContain("agent-memory/test-agent"); expect(block).toContain("No MEMORY.md exists yet"); expect(block).toContain("Memory Instructions"); }); it("builds memory block with existing MEMORY.md", () => { const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); mkdirSync(memDir, { recursive: true }); writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this"); const block = buildMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("Existing"); expect(block).toContain("recall this"); expect(block).not.toContain("No MEMORY.md exists yet"); }); it("creates memory directory if it doesn't exist", () => { const memDir = join(tmpDir, ".pi", "agent-memory", "new-agent"); expect(existsSync(memDir)).toBe(false); buildMemoryBlock("new-agent", "project", tmpDir); expect(existsSync(memDir)).toBe(true); }); it("includes Read/Write/Edit instructions", () => { const block = buildMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("Read, Write, and Edit tools"); }); it("uses correct directory for local scope", () => { const block = buildMemoryBlock("test-agent", "local", tmpDir); expect(block).toContain("agent-memory-local/test-agent"); }); it("uses correct directory for user scope", () => { // Pin the agent dir to a temp location so the test doesn't create // directories in the real home / agent dir (buildMemoryBlock mkdirs). const originalEnv = process.env.PI_CODING_AGENT_DIR; process.env.PI_CODING_AGENT_DIR = join(tmpDir, "agent-dir"); try { const block = buildMemoryBlock("test-agent", "user", join(tmpDir, "workspace")); expect(block).toContain(join(tmpDir, "agent-dir", "agent-memory", "test-agent")); expect(block).not.toContain(join(tmpDir, "workspace")); } finally { if (originalEnv == null) delete process.env.PI_CODING_AGENT_DIR; else process.env.PI_CODING_AGENT_DIR = originalEnv; } }); it("includes scope label in header", () => { expect(buildMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project"); expect(buildMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local"); expect(buildMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user"); }); }); describe("buildReadOnlyMemoryBlock", () => { it("returns read-only instructions without write/edit mention", () => { const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("read-only"); expect(block).not.toContain("Write"); expect(block).not.toContain("Edit"); expect(block).not.toContain("Memory Instructions"); }); it("does NOT create the memory directory", () => { const memDir = join(tmpDir, ".pi", "agent-memory", "ro-agent"); expect(existsSync(memDir)).toBe(false); buildReadOnlyMemoryBlock("ro-agent", "project", tmpDir); expect(existsSync(memDir)).toBe(false); }); it("includes existing MEMORY.md content", () => { const memDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); mkdirSync(memDir, { recursive: true }); writeFileSync(join(memDir, "MEMORY.md"), "# Existing\n- recall this"); const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("Existing"); expect(block).toContain("recall this"); }); it("returns 'no memory available' when no MEMORY.md exists", () => { const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); expect(block).toContain("No memory is available yet"); expect(block).not.toContain("Create one"); }); it("includes scope label in header", () => { expect(buildReadOnlyMemoryBlock("a", "project", tmpDir)).toContain("Memory scope: project"); expect(buildReadOnlyMemoryBlock("a", "local", tmpDir)).toContain("Memory scope: local"); expect(buildReadOnlyMemoryBlock("a", "user", tmpDir)).toContain("Memory scope: user"); }); it("does not mention memory directory path for write access", () => { const block = buildReadOnlyMemoryBlock("test-agent", "project", tmpDir); expect(block).not.toContain("persistent memory directory at:"); expect(block).not.toContain("Create one at"); }); it("rejects symlinked memory directory in read-only mode", () => { const realDir = join(tmpDir, ".pi", "agent-memory", "test-agent"); mkdirSync(realDir, { recursive: true }); writeFileSync(join(realDir, "MEMORY.md"), "# Secret"); const linkDir = join(tmpDir, ".pi", "agent-memory", "linked-agent"); mkdirSync(join(tmpDir, ".pi", "agent-memory"), { recursive: true }); symlinkSync(realDir, linkDir); // Should not read through the symlink const block = buildReadOnlyMemoryBlock("linked-agent", "project", tmpDir); expect(block).toContain("No memory is available yet"); }); }); });