repositories / pi-ext
pi-ext
bugabingas pi extensions
owned by admin
extensions/strata/__tests__/filesystem.test.ts
Rawimport {
access,
mkdir,
mkdtemp,
rename,
rm,
symlink,
writeFile,
} from "node:fs/promises";
import { createServer, type Server } from "node:net";
import { tmpdir } from "node:os";
import path from "node:path";
import { fauxToolCall } from "@earendil-works/pi-ai";
import { afterEach, describe, expect, it, vi } from "vitest";
const filesystemControl = vi.hoisted(() => ({
afterRealpath: undefined as
| ((requested: string, resolved: string) => Promise<void>)
| undefined,
beforeRead: undefined as ((openedPath: string) => Promise<void>) | undefined,
readRequests: [] as Array<{ path: string; bytes: number }>,
}));
const protectionControl = vi.hoisted(() => ({ parts: new Set<string>() }));
vi.mock("node:fs/promises", async (importOriginal) => {
const original = await importOriginal<typeof import("node:fs/promises")>();
return {
...original,
realpath: async (value: string) => {
const resolved = await original.realpath(value);
await filesystemControl.afterRealpath?.(value, resolved);
return resolved;
},
open: async (filePath: string, flags: number) => {
const handle = await original.open(filePath, flags);
return {
fd: handle.fd,
stat: handle.stat.bind(handle),
close: handle.close.bind(handle),
read: async (
buffer: Buffer,
offset: number,
length: number,
position: number | null,
) => {
const beforeRead = filesystemControl.beforeRead;
filesystemControl.beforeRead = undefined;
await beforeRead?.(filePath);
filesystemControl.readRequests.push({
path: filePath,
bytes: length,
});
return handle.read(buffer, offset, length, position);
},
};
},
};
});
vi.mock("../git.js", async (importOriginal) => {
const original = await importOriginal<typeof import("../git.js")>();
return {
...original,
isProtectedPath: (value: string, nativePath = false) =>
original.isProtectedPath(value, nativePath) ||
value.split(/[\\/]+/u).some((part) => protectionControl.parts.has(part)),
};
});
import { executeFilesystemTool, resolveAuthorizedPath } from "../filesystem.js";
const MAX_FILE_BYTES = 2 * 1024 * 1024;
const roots: string[] = [];
async function fixture(): Promise<{ base: string; repo: string }> {
const base = await mkdtemp(path.join(tmpdir(), "strata-filesystem-"));
roots.push(base);
const repo = path.join(base, "repo");
await mkdir(repo);
return { base, repo };
}
function execute(
cwd: string,
name: "read" | "grep" | "ls",
input: Record<string, unknown>,
signal = new AbortController().signal,
) {
return executeFilesystemTool(
cwd,
fauxToolCall(name, input),
signal,
) as Promise<Record<string, unknown>>;
}
afterEach(async () => {
filesystemControl.afterRealpath = undefined;
filesystemControl.beforeRead = undefined;
filesystemControl.readRequests.length = 0;
protectionControl.parts.clear();
await Promise.all(
roots.splice(0).map((root) => rm(root, { recursive: true, force: true })),
);
});
function swapAfterPreOpenResolution(
filePath: string,
swap: () => Promise<void>,
): void {
let resolutions = 0;
filesystemControl.afterRealpath = async (requested) => {
if (requested !== filePath || ++resolutions !== 2) return;
filesystemControl.afterRealpath = undefined;
await swap();
};
}
describe("authorized Ask filesystem tools", () => {
it("reads ordinary untracked text with bounded line pagination", async () => {
const { repo } = await fixture();
await writeFile(path.join(repo, "scratch.txt"), "one\ntwo\nthree\nfour\n");
const first = await execute(repo, "read", {
path: "scratch.txt",
offset: 2,
limit: 2,
});
expect(first).toMatchObject({
evidence: "CURRENT FILESYSTEM",
offset: 2,
returnedLines: 2,
content: "two\nthree",
truncated: true,
nextOffset: 4,
});
const second = await execute(repo, "read", {
path: "scratch.txt",
offset: 4,
limit: 2,
});
expect(second.content).toBe("four\n");
await expect(
execute(repo, "read", { path: "scratch.txt", offset: 99 }),
).rejects.toThrow(/beyond end of file/);
await writeFile(path.join(repo, "long-line.txt"), "x".repeat(30 * 1024));
const bounded = await execute(repo, "read", { path: "long-line.txt" });
expect(bounded).toMatchObject({
returnedLines: 1,
truncated: true,
lineTruncated: true,
});
expect(Buffer.byteLength(bounded.content as string)).toBeLessThanOrEqual(
24 * 1024,
);
});
it("accepts every nextOffset emitted at the maximum text-file boundary", async () => {
const { repo } = await fixture();
await writeFile(
path.join(repo, "line-boundary.txt"),
"\n".repeat(MAX_FILE_BYTES),
);
const penultimate = await execute(repo, "read", {
path: "line-boundary.txt",
offset: MAX_FILE_BYTES,
limit: 1,
});
expect(penultimate).toMatchObject({
totalLines: MAX_FILE_BYTES + 1,
nextOffset: MAX_FILE_BYTES + 1,
truncated: true,
});
const final = await execute(repo, "read", {
path: "line-boundary.txt",
offset: penultimate.nextOffset,
limit: 1,
});
expect(final).toMatchObject({
offset: MAX_FILE_BYTES + 1,
returnedLines: 1,
truncated: false,
});
});
it("searches an explicit ignored node_modules directory and bounds matches", async () => {
const { repo } = await fixture();
const dependency = path.join(repo, "node_modules", "example-package");
await mkdir(dependency, { recursive: true });
await writeFile(path.join(repo, ".gitignore"), "node_modules/\n");
await writeFile(
path.join(dependency, "README.md"),
"Package marker\npackage MARKER\nmarker again\n",
);
const result = await execute(repo, "grep", {
path: "node_modules/example-package",
pattern: "marker",
ignoreCase: true,
context: 1,
limit: 1,
});
expect(result).toMatchObject({
evidence: "CURRENT FILESYSTEM",
returnedMatches: 1,
totalMatches: 3,
truncated: true,
});
expect(JSON.stringify(result)).toContain("README.md");
});
it("marks clipped match context as aggregate truncation", async () => {
const { repo } = await fixture();
await writeFile(
path.join(repo, "context.txt"),
`${"b".repeat(700)}\nneedle\n${"a".repeat(700)}\n`,
);
const result = await execute(repo, "grep", {
path: "context.txt",
pattern: "needle",
context: 1,
});
expect(result).toMatchObject({
returnedMatches: 1,
linesTruncated: true,
truncated: true,
});
const [match] = result.matches as Array<{
before: string[];
after: string[];
}>;
expect(match.before[0]).toHaveLength(501);
expect(match.after[0]).toHaveLength(501);
expect(match.before[0]).toMatch(/…$/u);
expect(match.after[0]).toMatch(/…$/u);
});
it("reads absolute documentation outside the repository", async () => {
const { base, repo } = await fixture();
const docs = path.join(base, "external-docs");
await mkdir(docs);
const reference = path.join(docs, "reference.md");
await writeFile(reference, "# External reference\nfilesystem evidence\n");
const result = await execute(repo, "read", { path: reference });
expect(result.content).toContain("filesystem evidence");
expect(result.canonicalPath).toBe(
await resolveAuthorizedPath(repo, reference).then(
(value) => value.canonical,
),
);
});
it("follows safe package symlinks and terminates directory cycles", async () => {
const { repo } = await fixture();
const target = path.join(repo, "packages", "linked-package");
await mkdir(target, { recursive: true });
await writeFile(path.join(target, "README.md"), "safe symlink marker\n");
const link = path.join(repo, "node_modules", "linked-package");
await mkdir(path.dirname(link), { recursive: true });
await symlink(
target,
link,
process.platform === "win32" ? "junction" : "dir",
);
await symlink(
target,
path.join(target, "loop"),
process.platform === "win32" ? "junction" : "dir",
);
const listed = await execute(repo, "ls", {
path: "node_modules/linked-package",
});
expect(listed.entries).toEqual(
expect.arrayContaining(["loop/", "README.md"]),
);
const searched = await execute(repo, "grep", {
path: "node_modules/linked-package",
pattern: "symlink marker",
});
expect(searched).toMatchObject({
totalMatches: 1,
directoriesVisited: 1,
});
});
it.each(["read", "grep"] as const)(
"rejects a final-file swap after resolution through %s before reading bytes",
async (tool) => {
const { repo } = await fixture();
const visible = path.join(repo, "visible.txt");
const blocked = path.join(repo, "blocked-target.txt");
await writeFile(visible, "safe content\n");
await writeFile(blocked, "blocked marker\n");
protectionControl.parts.add(path.basename(blocked));
swapAfterPreOpenResolution(visible, async () => {
await rm(visible);
await symlink(
blocked,
visible,
process.platform === "win32" ? "file" : undefined,
);
});
await expect(
execute(
repo,
tool,
tool === "read"
? { path: "visible.txt" }
: { path: ".", pattern: "blocked marker" },
),
).rejects.toThrow(/protected|authorization|safely opened/u);
expect(filesystemControl.readRequests).toEqual([]);
},
);
it.each(["read", "grep"] as const)(
"rejects an ancestor-directory swap after resolution through %s before reading bytes",
async (tool) => {
const { repo } = await fixture();
const visibleDirectory = path.join(repo, "visible-directory");
const archivedDirectory = path.join(repo, "archived-directory");
const blockedDirectory = path.join(repo, "blocked-directory");
await mkdir(visibleDirectory);
await mkdir(blockedDirectory);
await writeFile(
path.join(visibleDirectory, "document.txt"),
"safe content\n",
);
await writeFile(
path.join(blockedDirectory, "document.txt"),
"blocked marker\n",
);
protectionControl.parts.add(path.basename(blockedDirectory));
const visible = path.join(visibleDirectory, "document.txt");
swapAfterPreOpenResolution(visible, async () => {
await rename(visibleDirectory, archivedDirectory);
await symlink(
blockedDirectory,
visibleDirectory,
process.platform === "win32" ? "junction" : "dir",
);
});
await expect(
execute(
repo,
tool,
tool === "read"
? { path: "visible-directory/document.txt" }
: { path: ".", pattern: "blocked marker" },
),
).rejects.toThrow(/protected|authorization/u);
expect(filesystemControl.readRequests).toEqual([]);
},
);
it("rejects protected lexical ancestors and canonical symlink targets before reads", async () => {
const { repo } = await fixture();
let resolved = false;
await expect(
resolveAuthorizedPath(
repo,
"private/.env.production/../public.txt",
async () => {
resolved = true;
return path.join(repo, "public.txt");
},
),
).rejects.toThrow(/protected path/);
expect(resolved).toBe(false);
await expect(
resolveAuthorizedPath(repo, "ordinary-link", async () => {
return path.join(repo, ".git", "objects", "pretend");
}),
).rejects.toThrow(/protected path/);
});
it("rejects URL-like paths, invalid arguments, protected traversal, and missing paths", async () => {
const { repo } = await fixture();
for (const unsafe of [
"https://example.invalid/file",
"file:///ordinary/file",
"safe/../.git/config",
]) {
await expect(execute(repo, "read", { path: unsafe })).rejects.toThrow(
/invalid filesystem path|protected path/,
);
}
await expect(
execute(repo, "read", { path: "missing.txt" }),
).rejects.toThrow(/does not exist or cannot be resolved/);
await expect(
execute(repo, "grep", { path: ".", pattern: "x", context: 99 }),
).rejects.toThrow();
});
it("bounds bytes read when a regular file grows after opened-handle checks", async () => {
const { repo } = await fixture();
const growing = path.join(repo, "growing.txt");
await writeFile(growing, "small\n");
filesystemControl.beforeRead = async () => {
await writeFile(growing, Buffer.alloc(MAX_FILE_BYTES + 10, 0x61));
};
await expect(
execute(repo, "read", { path: "growing.txt" }),
).rejects.toThrow(/byte limit/u);
expect(
filesystemControl.readRequests.reduce(
(total, request) => total + request.bytes,
0,
),
).toBe(MAX_FILE_BYTES + 1);
});
it("stops grep at the actual total byte budget", async () => {
const { repo } = await fixture();
await Promise.all(
Array.from({ length: 5 }, (_, index) =>
writeFile(
path.join(repo, `budget-${index}.txt`),
Buffer.alloc(MAX_FILE_BYTES, 0x61),
),
),
);
const result = await execute(repo, "grep", {
path: ".",
pattern: "absent",
});
expect(result).toMatchObject({
bytesVisited: 4 * MAX_FILE_BYTES,
filesVisited: 4,
truncated: true,
});
expect(result.limitReasons).toContain("total byte limit");
});
it("rejects directories, non-regular sockets, binary files, and oversized files", async () => {
const { repo } = await fixture();
await writeFile(path.join(repo, "binary.dat"), Buffer.from([0, 1, 2, 3]));
await writeFile(
path.join(repo, "oversized.txt"),
Buffer.alloc(2 * 1024 * 1024 + 1, 0x61),
);
await expect(execute(repo, "read", { path: "." })).rejects.toThrow(
/not a regular file/,
);
await expect(execute(repo, "read", { path: "binary.dat" })).rejects.toThrow(
/binary file/,
);
await expect(
execute(repo, "read", { path: "oversized.txt" }),
).rejects.toThrow(/byte limit/);
const binarySearch = await execute(repo, "grep", {
path: "binary.dat",
pattern: "absent",
});
expect(binarySearch).toMatchObject({
bytesVisited: 4,
skippedBinary: 1,
});
const oversizedSearch = await execute(repo, "grep", {
path: "oversized.txt",
pattern: "absent",
});
expect(oversizedSearch).toMatchObject({
bytesVisited: MAX_FILE_BYTES + 1,
truncated: true,
});
expect(oversizedSearch.limitReasons).toContain("per-file byte limit");
if (process.platform !== "win32") {
const socketPath = path.join(repo, "ordinary.sock");
let server: Server | undefined;
try {
server = createServer();
await new Promise<void>((resolve, reject) => {
server?.once("error", reject);
server?.listen(socketPath, resolve);
});
await expect(
execute(repo, "read", { path: socketPath }),
).rejects.toThrow(/not a regular file/);
} finally {
await new Promise<void>((resolve) => server?.close(() => resolve()));
}
}
});
it("returns bounded ls names with directory indicators", async () => {
const { repo } = await fixture();
await mkdir(path.join(repo, "directory"));
await Promise.all(
Array.from({ length: 12 }, (_, index) =>
writeFile(path.join(repo, `file-${index}.txt`), "ordinary\n"),
),
);
const result = await execute(repo, "ls", { limit: 3 });
expect(result).toMatchObject({ returned: 3, truncated: true });
expect(result.entries).toHaveLength(3);
const complete = await execute(repo, "ls", { limit: 20 });
expect(complete.entries).toContain("directory/");
});
it("reports recursive traversal limits instead of silently losing output", async () => {
const { repo } = await fixture();
let directory = repo;
for (let depth = 0; depth < 27; depth++) {
directory = path.join(directory, `level-${depth}`);
await mkdir(directory);
}
await writeFile(path.join(directory, "deep.txt"), "deep marker\n");
const result = await execute(repo, "grep", {
path: ".",
pattern: "deep marker",
});
expect(result).toMatchObject({ totalMatches: 0, truncated: true });
expect(result.limitReasons).toContain("depth limit");
});
it("honors cancellation before filesystem access", async () => {
const { repo } = await fixture();
await writeFile(path.join(repo, "ordinary.txt"), "content\n");
const controller = new AbortController();
controller.abort(new Error("filesystem cancelled"));
await expect(
execute(repo, "read", { path: "ordinary.txt" }, controller.signal),
).rejects.toThrow("filesystem cancelled");
await expect(
access(path.join(repo, "ordinary.txt")),
).resolves.toBeUndefined();
});
it("treats backslashes according to the native platform", async () => {
const { repo } = await fixture();
if (process.platform === "win32") {
await expect(
resolveAuthorizedPath(repo, ".git\\config", async (value) => value),
).rejects.toThrow(/protected path/);
} else {
const filename = "ordinary\\name.txt";
await writeFile(path.join(repo, filename), "backslash filename\n");
const result = await execute(repo, "read", { path: filename });
expect(result.content).toContain("backslash filename");
}
});
});