mirror of
https://bitbucket.org/siakitem/my-pi.git
synced 2026-08-28 08:35:57 +00:00
feat: enable permission-aware subagents
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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<typeof import("node:os")>();
|
||||
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/<name>", () => {
|
||||
const dir = resolveMemoryDir("auditor", "project", "/workspace");
|
||||
expect(dir).toBe("/workspace/.pi/agent-memory/auditor");
|
||||
});
|
||||
|
||||
it("resolves local scope to .pi/agent-memory-local/<name>", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user