branch:
workspace.test.ts
66449 bytesRaw
import { env } from "cloudflare:workers";
import { describe, expect, it } from "vitest";
import { getAgentByName } from "agents";
import type { FileInfo, FileStat } from "../filesystem";
import { StateBatchOperationError } from "../index";
import { createWorkspaceStateBackend } from "../workspace";
// ── DO agent helpers ──────────────────────────────────────────────────────
async function freshAgent(name: string) {
return getAgentByName(env.TestWorkspaceAgent, name);
}
// ── Mock helpers (for WorkspaceStateBackend unit tests) ───────────────────
function fileInfo(
path: string,
type: "file" | "directory",
size: number
): FileInfo {
return {
path,
name: path.slice(path.lastIndexOf("/") + 1),
type,
mimeType: "text/plain",
size,
createdAt: 0,
updatedAt: 0
};
}
function createWorkspaceLike(
files: Map<string, string>,
options?: { failWritePath?: string }
) {
return {
async readFile(path: string) {
return files.get(path) ?? null;
},
async readFileBytes(path: string) {
const value = files.get(path);
return value === undefined ? null : new TextEncoder().encode(value);
},
async writeFile(path: string, content: string) {
if (path === options?.failWritePath) {
throw new Error(`simulated write failure: ${path}`);
}
files.set(path, content);
},
async writeFileBytes(path: string, content: Uint8Array) {
if (path === options?.failWritePath) {
throw new Error(`simulated write failure: ${path}`);
}
files.set(path, new TextDecoder().decode(content));
},
async appendFile(path: string, content: string) {
files.set(path, (files.get(path) ?? "") + content);
},
async exists(path: string) {
return files.has(path);
},
async stat(_path: string) {
return null;
},
async lstat(path: string) {
const directFile = files.get(path);
if (directFile !== undefined) {
return {
path,
name: path.slice(path.lastIndexOf("/") + 1),
type: "file" as const,
mimeType: "text/plain",
size: directFile.length,
createdAt: 0,
updatedAt: 0
};
}
const prefix = path.endsWith("/") ? path : `${path}/`;
const hasChildren = Array.from(files.keys()).some((filePath) =>
filePath.startsWith(prefix)
);
if (hasChildren) {
return {
path,
name: path.slice(path.lastIndexOf("/") + 1),
type: "directory" as const,
mimeType: "text/plain",
size: 0,
createdAt: 0,
updatedAt: 0
};
}
return null;
},
async mkdir(_path: string) {},
async readDir(path: string) {
const prefix = path.endsWith("/") ? path : `${path}/`;
const directories = new Map<string, ReturnType<typeof fileInfo>>();
const fileEntries: ReturnType<typeof fileInfo>[] = [];
for (const filePath of files.keys()) {
if (!filePath.startsWith(prefix)) continue;
const rest = filePath.slice(prefix.length);
if (!rest.includes("/")) {
fileEntries.push(
fileInfo(filePath, "file", files.get(filePath)?.length ?? 0)
);
continue;
}
const nextDir = rest.slice(0, rest.indexOf("/"));
const dirPath = `${path === "/" ? "" : path}/${nextDir}`.replace(
/\/+/g,
"/"
);
directories.set(dirPath, fileInfo(dirPath, "directory", 0));
}
return [...directories.values(), ...fileEntries];
},
async glob(pattern: string) {
const regex = new RegExp(
"^" +
pattern
.replace(/[.+^$|\\()]/g, "\\$&")
.replace(/\*\*\//g, "(?:.+/)?")
.replace(/\*/g, "[^/]*") +
"$"
);
const result: ReturnType<typeof fileInfo>[] = [];
for (const [filePath, content] of files) {
if (regex.test(filePath)) {
result.push(fileInfo(filePath, "file", content.length));
}
}
return result.sort((a, b) => a.path.localeCompare(b.path));
},
async symlink(_target: string, _linkPath: string) {},
async readlink(_path: string): Promise<string> {
throw new Error("not a symlink");
},
async deleteFile(path: string) {
return files.delete(path);
},
async rm(path: string) {
files.delete(path);
},
async cp(src: string, dest: string) {
const content = files.get(src);
if (content !== undefined) files.set(dest, content);
},
async mv(src: string, dest: string) {
const content = files.get(src);
if (content !== undefined) {
files.set(dest, content);
files.delete(src);
}
},
async diff(_a: string, _b: string) {
return "";
},
async diffContent(_path: string, _content: string) {
return "";
},
async getWorkspaceInfo() {
return { fileCount: 0, directoryCount: 0, totalBytes: 0, r2FileCount: 0 };
}
};
}
// ═══════════════════════════════════════════════════════════════════
// WorkspaceStateBackend — mock-based unit tests
// ═══════════════════════════════════════════════════════════════════
describe("WorkspaceStateBackend", () => {
it("reads and writes JSON through the workspace adapter", async () => {
const files = new Map<string, string>();
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files) as never
);
await backend.writeJson("/settings.json", {
feature: true,
retries: 3
});
await expect(backend.readJson("/settings.json")).resolves.toEqual({
feature: true,
retries: 3
});
expect(files.get("/settings.json")).toBe(
'{\n "feature": true,\n "retries": 3\n}\n'
);
});
it("searches and replaces text through the workspace adapter", async () => {
const files = new Map<string, string>([
["/docs.txt", "alpha beta alpha\n"]
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files) as never
);
await expect(backend.searchText("/docs.txt", "alpha")).resolves.toEqual([
{
line: 1,
column: 1,
match: "alpha",
lineText: "alpha beta alpha"
},
{
line: 1,
column: 12,
match: "alpha",
lineText: "alpha beta alpha"
}
]);
await expect(
backend.replaceInFile("/docs.txt", "alpha", "omega")
).resolves.toEqual({
replaced: 2,
content: "omega beta omega\n"
});
expect(files.get("/docs.txt")).toBe("omega beta omega\n");
});
it("supports multi-file search, replacement, and batched edits", async () => {
const files = new Map<string, string>([
["/src/a.ts", 'export const a = "foo";\n'],
["/src/b.ts", 'export const b = "foo";\n'],
["/src/c.ts", 'export const c = "nope";\n']
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files) as never
);
await expect(backend.searchFiles("/src/*.ts", "foo")).resolves.toEqual([
{
path: "/src/a.ts",
matches: [
{
line: 1,
column: 19,
match: "foo",
lineText: 'export const a = "foo";'
}
]
},
{
path: "/src/b.ts",
matches: [
{
line: 1,
column: 19,
match: "foo",
lineText: 'export const b = "foo";'
}
]
}
]);
const preview = await backend.replaceInFiles("/src/*.ts", "foo", "bar", {
dryRun: true
});
expect(preview.totalFiles).toBe(2);
expect(preview.totalReplacements).toBe(2);
expect(files.get("/src/a.ts")).toBe('export const a = "foo";\n');
const applied = await backend.replaceInFiles("/src/*.ts", "foo", "bar");
expect(applied.totalFiles).toBe(2);
expect(files.get("/src/a.ts")).toBe('export const a = "bar";\n');
expect(files.get("/src/b.ts")).toBe('export const b = "bar";\n');
const editResult = await backend.applyEdits(
[
{ path: "/src/a.ts", content: 'export const a = "baz";\n' },
{ path: "/src/d.ts", content: 'export const d = "new";\n' }
],
{ dryRun: true }
);
expect(editResult.totalChanged).toBe(2);
expect(files.has("/src/d.ts")).toBe(false);
});
it("plans structured edits through the workspace adapter", async () => {
const files = new Map<string, string>([
["/src/a.ts", 'export const a = "foo";\n'],
["/src/data.json", '{ "count": 1 }\n']
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files) as never
);
const plan = await backend.planEdits([
{
kind: "replace",
path: "/src/a.ts",
search: "foo",
replacement: "bar"
},
{
kind: "writeJson",
path: "/src/data.json",
value: { count: 2 }
}
]);
expect(plan.totalInstructions).toBe(2);
expect(plan.totalChanged).toBe(2);
expect(plan.edits[0].content).toBe('export const a = "bar";\n');
expect(plan.edits[1].content).toBe('{\n "count": 2\n}\n');
await backend.applyEditPlan(plan);
expect(files.get("/src/a.ts")).toBe('export const a = "bar";\n');
expect(files.get("/src/data.json")).toBe('{\n "count": 2\n}\n');
});
it("supports find, json query/update, tree, archive, hash, and file detection", async () => {
const files = new Map<string, string>([
["/src/a.ts", 'export const a = "foo";\n'],
["/src/nested/b.json", '{ "count": 1 }\n'],
["/src/nested/c.txt", "plain"]
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files) as never
);
await expect(
backend.find("/src", { type: "file", pathPattern: "/src/**/*.json" })
).resolves.toEqual([
{
path: "/src/nested/b.json",
name: "b.json",
type: "file",
depth: 2,
size: files.get("/src/nested/b.json")!.length,
mtime: expect.any(Date)
}
]);
await expect(
backend.queryJson("/src/nested/b.json", ".count")
).resolves.toBe(1);
await backend.updateJson("/src/nested/b.json", [
{ op: "set", path: ".count", value: 2 }
]);
expect(files.get("/src/nested/b.json")).toBe('{\n "count": 2\n}\n');
await expect(backend.summarizeTree("/src")).resolves.toEqual({
files: 3,
directories: 2,
symlinks: 0,
totalBytes:
files.get("/src/a.ts")!.length +
files.get("/src/nested/b.json")!.length +
files.get("/src/nested/c.txt")!.length,
maxDepth: 2
});
await backend.createArchive("/bundle.tar", ["/src"]);
await expect(backend.listArchive("/bundle.tar")).resolves.toEqual([
{ path: "src", type: "directory", size: 0 },
{ path: "src/a.ts", type: "file", size: files.get("/src/a.ts")!.length },
{ path: "src/nested", type: "directory", size: 0 },
{
path: "src/nested/b.json",
type: "file",
size: files.get("/src/nested/b.json")!.length
},
{
path: "src/nested/c.txt",
type: "file",
size: files.get("/src/nested/c.txt")!.length
}
]);
await backend.extractArchive("/bundle.tar", "/restored");
expect(files.get("/restored/src/nested/c.txt")).toBe("plain");
await expect(backend.hashFile("/src/nested/c.txt")).resolves.toBe(
"a116c9ed46d6207734a43317d30fd88f52ac8634c37d904bbf4e41d865f90475"
);
await expect(backend.detectFile("/src/nested/c.txt")).resolves.toEqual({
mime: "text/plain",
extension: "txt",
binary: false,
description: "text/plain (txt)"
});
});
it("rolls back workspace-backed batch writes on failure", async () => {
const files = new Map<string, string>([
["/src/a.ts", 'export const a = "foo";\n'],
["/src/b.ts", 'export const b = "foo";\n']
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files, { failWritePath: "/src/b.ts" }) as never
);
await expect(
backend.replaceInFiles("/src/*.ts", "foo", "bar")
).rejects.toMatchObject({
name: "StateBatchOperationError",
operation: "replaceInFiles",
rolledBack: true
} satisfies Partial<StateBatchOperationError>);
expect(files.get("/src/a.ts")).toBe('export const a = "foo";\n');
expect(files.get("/src/b.ts")).toBe('export const b = "foo";\n');
});
it("can opt out of rollback for workspace-backed batch writes", async () => {
const files = new Map<string, string>([
["/src/a.ts", 'export const a = "foo";\n'],
["/src/b.ts", 'export const b = "foo";\n']
]);
const backend = createWorkspaceStateBackend(
createWorkspaceLike(files, { failWritePath: "/src/b.ts" }) as never
);
await expect(
backend.applyEdits(
[
{ path: "/src/a.ts", content: 'export const a = "bar";\n' },
{ path: "/src/b.ts", content: 'export const b = "bar";\n' }
],
{ rollbackOnError: false }
)
).rejects.toMatchObject({
name: "StateBatchOperationError",
operation: "applyEdits",
rolledBack: false
} satisfies Partial<StateBatchOperationError>);
expect(files.get("/src/a.ts")).toBe('export const a = "bar";\n');
expect(files.get("/src/b.ts")).toBe('export const b = "foo";\n');
});
});
// ═══════════════════════════════════════════════════════════════════
// Workspace — DO-backed integration tests
// ═══════════════════════════════════════════════════════════════════
// ── File I/O ─────────────────────────────────────────────────────────
describe("workspace — file I/O", () => {
it("write + read roundtrip", async () => {
const agent = await freshAgent("file-roundtrip");
await agent.write("/hello.txt", "world");
const content = (await agent.read("/hello.txt")) as unknown as string;
expect(content).toBe("world");
});
it("read returns null for missing file", async () => {
const agent = await freshAgent("file-missing");
const content = await agent.read("/nope.txt");
expect(content).toBeNull();
});
it("read throws on directory", async () => {
const agent = await freshAgent("file-read-dir");
await agent.mkdirCall("/subdir");
const result = await agent.read("/subdir");
expect((result as { error: string }).error).toContain("EISDIR");
});
it("writeFile auto-creates parent directories", async () => {
const agent = await freshAgent("file-auto-mkdir");
await agent.write("/a/b/c/deep.txt", "deep content");
const content = (await agent.read("/a/b/c/deep.txt")) as unknown as string;
expect(content).toBe("deep content");
});
it("writeFile overwrites existing file", async () => {
const agent = await freshAgent("file-overwrite");
await agent.write("/overwrite.txt", "v1");
await agent.write("/overwrite.txt", "v2");
const content = (await agent.read("/overwrite.txt")) as unknown as string;
expect(content).toBe("v2");
});
it("writeFile rejects writing to root", async () => {
const agent = await freshAgent("file-write-root");
const result = await agent.write("/", "oops");
expect((result as { error: string }).error).toContain("EISDIR");
});
it("writeFile stores custom mime type", async () => {
const agent = await freshAgent("file-mime");
await agent.write("/data.json", '{"a":1}', "application/json");
const stat = (await agent.stat("/data.json")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.mimeType).toBe("application/json");
});
});
// ── exists / fileExists / stat ────────────────────────────────────
describe("workspace — exists & fileExists & stat", () => {
it("fileExists returns true for files, false otherwise", async () => {
const agent = await freshAgent("exists-check");
await agent.write("/present.txt", "yes");
expect((await agent.exists("/present.txt")) as unknown as boolean).toBe(
true
);
expect((await agent.exists("/absent.txt")) as unknown as boolean).toBe(
false
);
});
it("fileExists returns false for directories", async () => {
const agent = await freshAgent("exists-dir");
await agent.mkdirCall("/mydir");
expect((await agent.exists("/mydir")) as unknown as boolean).toBe(false);
});
it("exists returns true for files and directories", async () => {
const agent = await freshAgent("exists-any");
await agent.write("/file.txt", "hi");
await agent.mkdirCall("/dir");
expect((await agent.existsAny("/file.txt")) as unknown as boolean).toBe(
true
);
expect((await agent.existsAny("/dir")) as unknown as boolean).toBe(true);
expect((await agent.existsAny("/nope")) as unknown as boolean).toBe(false);
});
it("exists returns true for symlinks", async () => {
const agent = await freshAgent("exists-any-sym");
await agent.write("/real.txt", "hi");
await agent.symlinkCall("/real.txt", "/link.txt");
expect((await agent.existsAny("/link.txt")) as unknown as boolean).toBe(
true
);
});
it("exists returns true for dangling symlinks", async () => {
const agent = await freshAgent("exists-any-dangling");
await agent.write("/temp.txt", "hi");
await agent.symlinkCall("/temp.txt", "/dangle.txt");
await agent.del("/temp.txt");
expect((await agent.existsAny("/dangle.txt")) as unknown as boolean).toBe(
true
);
});
it("exists returns false after deletion", async () => {
const agent = await freshAgent("exists-any-del");
await agent.write("/gone.txt", "bye");
expect((await agent.existsAny("/gone.txt")) as unknown as boolean).toBe(
true
);
await agent.del("/gone.txt");
expect((await agent.existsAny("/gone.txt")) as unknown as boolean).toBe(
false
);
});
it("stat returns metadata for file", async () => {
const agent = await freshAgent("stat-file");
await agent.write("/stat.txt", "hello");
const stat = (await agent.stat("/stat.txt")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.type).toBe("file");
expect(stat.name).toBe("stat.txt");
expect(stat.size).toBe(5);
expect(stat.mimeType).toBe("text/plain");
expect(stat.createdAt).toBeGreaterThan(0);
expect(stat.updatedAt).toBeGreaterThan(0);
});
it("stat returns metadata for directory", async () => {
const agent = await freshAgent("stat-dir");
await agent.mkdirCall("/statdir");
const stat = (await agent.stat("/statdir")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.type).toBe("directory");
expect(stat.name).toBe("statdir");
});
it("stat returns null for missing path", async () => {
const agent = await freshAgent("stat-missing");
const stat = await agent.stat("/ghost");
expect(stat).toBeNull();
});
});
// ── deleteFile ───────────────────────────────────────────────────────
describe("workspace — deleteFile", () => {
it("deleteFile removes a file and returns true", async () => {
const agent = await freshAgent("del-basic");
await agent.write("/todelete.txt", "bye");
const result = (await agent.del("/todelete.txt")) as unknown as boolean;
expect(result).toBe(true);
expect((await agent.exists("/todelete.txt")) as unknown as boolean).toBe(
false
);
});
it("deleteFile returns false for missing file", async () => {
const agent = await freshAgent("del-missing");
const result = (await agent.del("/nope.txt")) as unknown as boolean;
expect(result).toBe(false);
});
it("deleteFile throws on directory", async () => {
const agent = await freshAgent("del-dir");
await agent.mkdirCall("/cantdel");
const result = await agent.del("/cantdel");
expect((result as { error: string }).error).toContain("EISDIR");
});
});
// ── Directory operations ─────────────────────────────────────────────
describe("workspace — directories", () => {
it("mkdir creates a directory", async () => {
const agent = await freshAgent("mkdir-basic");
await agent.mkdirCall("/newdir");
const stat = (await agent.stat("/newdir")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.type).toBe("directory");
});
it("mkdir recursive creates nested directories", async () => {
const agent = await freshAgent("mkdir-recursive");
await agent.mkdirCall("/x/y/z", { recursive: true });
const stat = (await agent.stat("/x/y/z")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.type).toBe("directory");
});
it("mkdir throws on duplicate without recursive", async () => {
const agent = await freshAgent("mkdir-dup");
await agent.mkdirCall("/dup");
const result = await agent.mkdirCall("/dup");
expect((result as { error: string }).error).toContain("EEXIST");
});
it("mkdir recursive is idempotent", async () => {
const agent = await freshAgent("mkdir-idem");
await agent.mkdirCall("/idem", { recursive: true });
await agent.mkdirCall("/idem", { recursive: true });
const stat = (await agent.stat("/idem")) as unknown as FileStat;
expect(stat.type).toBe("directory");
});
it("mkdir throws if parent missing without recursive", async () => {
const agent = await freshAgent("mkdir-noparent");
const result = await agent.mkdirCall("/no/parent");
expect((result as { error: string }).error).toContain("ENOENT");
});
it("readDir returns children of a directory", async () => {
const agent = await freshAgent("list-basic");
await agent.write("/list/a.txt", "a");
await agent.write("/list/b.txt", "b");
await agent.mkdirCall("/list/sub", { recursive: true });
const items = (await agent.list("/list")) as unknown as FileInfo[];
expect(items.length).toBe(3);
const names = items.map((i: FileInfo) => i.name);
expect(names).toContain("a.txt");
expect(names).toContain("b.txt");
expect(names).toContain("sub");
});
it("readDir with limit and offset", async () => {
const agent = await freshAgent("list-paginate");
await agent.write("/pg/1.txt", "1");
await agent.write("/pg/2.txt", "2");
await agent.write("/pg/3.txt", "3");
const first = (await agent.list("/pg", {
limit: 2,
offset: 0
})) as unknown as FileInfo[];
expect(first.length).toBe(2);
const second = (await agent.list("/pg", {
limit: 2,
offset: 2
})) as unknown as FileInfo[];
expect(second.length).toBe(1);
});
it("readDir returns empty for empty directory", async () => {
const agent = await freshAgent("list-empty");
await agent.mkdirCall("/emptydir");
const items = (await agent.list("/emptydir")) as unknown as FileInfo[];
expect(items.length).toBe(0);
});
});
// ── rm ───────────────────────────────────────────────────────────────
describe("workspace — rm", () => {
it("rm removes a file", async () => {
const agent = await freshAgent("rm-file");
await agent.write("/rmfile.txt", "bye");
await agent.rmCall("/rmfile.txt");
expect((await agent.exists("/rmfile.txt")) as unknown as boolean).toBe(
false
);
});
it("rm removes empty directory", async () => {
const agent = await freshAgent("rm-emptydir");
await agent.mkdirCall("/emptydir");
await agent.rmCall("/emptydir");
const stat = await agent.stat("/emptydir");
expect(stat).toBeNull();
});
it("rm throws on non-empty directory without recursive", async () => {
const agent = await freshAgent("rm-notempty");
await agent.write("/notempty/file.txt", "x");
const result = await agent.rmCall("/notempty");
expect((result as { error: string }).error).toContain("ENOTEMPTY");
});
it("rm recursive removes directory and descendants", async () => {
const agent = await freshAgent("rm-recursive");
await agent.write("/tree/a.txt", "a");
await agent.write("/tree/sub/b.txt", "b");
await agent.rmCall("/tree", { recursive: true });
const stat = await agent.stat("/tree");
expect(stat).toBeNull();
});
it("rm throws on missing path", async () => {
const agent = await freshAgent("rm-missing");
const result = await agent.rmCall("/ghost");
expect((result as { error: string }).error).toContain("ENOENT");
});
it("rm force on missing path is no-op", async () => {
const agent = await freshAgent("rm-force");
await agent.rmCall("/ghost", { force: true });
});
it("rm rejects removing root", async () => {
const agent = await freshAgent("rm-root");
const result = await agent.rmCall("/");
expect((result as { error: string }).error).toContain("EPERM");
});
});
// ── getWorkspaceInfo ─────────────────────────────────────────────────
describe("workspace — getWorkspaceInfo", () => {
it("returns correct counts", async () => {
const agent = await freshAgent("info-counts");
await agent.write("/info/a.txt", "aaa");
await agent.write("/info/b.txt", "bb");
await agent.mkdirCall("/info/sub", { recursive: true });
const info = (await agent.info()) as unknown as {
fileCount: number;
directoryCount: number;
totalBytes: number;
r2FileCount: number;
};
expect(info.directoryCount).toBe(3);
expect(info.fileCount).toBe(2);
expect(info.totalBytes).toBe(5);
expect(info.r2FileCount).toBe(0);
});
});
// ── Path normalization ────────────────────────────────────────────────
describe("workspace — path normalization", () => {
it("handles paths without leading slash", async () => {
const agent = await freshAgent("norm-noslash");
await agent.write("noslash.txt", "works");
const content = (await agent.read("/noslash.txt")) as unknown as string;
expect(content).toBe("works");
});
it("handles trailing slashes", async () => {
const agent = await freshAgent("norm-trailing");
await agent.mkdirCall("/trailing/");
const stat = (await agent.stat("/trailing")) as unknown as FileStat;
expect(stat).not.toBeNull();
expect(stat.type).toBe("directory");
});
it("collapses multiple slashes", async () => {
const agent = await freshAgent("norm-multi");
await agent.write("///multi///slashes///file.txt", "ok");
const content = (await agent.read(
"/multi/slashes/file.txt"
)) as unknown as string;
expect(content).toBe("ok");
});
});
// ── File streaming ──────────────────────────────────────────────────
describe("workspace — file streaming", () => {
it("writeFileStream + readFileStream round-trip", async () => {
const agent = await freshAgent("stream-roundtrip");
await agent.writeStream("/streamed.txt", "streamed content");
const content = (await agent.readStream(
"/streamed.txt"
)) as unknown as string;
expect(content).toBe("streamed content");
});
it("readFileStream returns null for missing file", async () => {
const agent = await freshAgent("stream-missing");
const content = await agent.readStream("/nope.txt");
expect(content).toBeNull();
});
it("writeFileStream creates parent directories", async () => {
const agent = await freshAgent("stream-parents");
await agent.writeStream("/a/b/streamed.txt", "deep stream");
const content = (await agent.readStream(
"/a/b/streamed.txt"
)) as unknown as string;
expect(content).toBe("deep stream");
});
it("writeFileStream file is readable with readFile", async () => {
const agent = await freshAgent("stream-interop-read");
await agent.writeStream("/interop.txt", "via stream");
const content = (await agent.read("/interop.txt")) as unknown as string;
expect(content).toBe("via stream");
});
it("readFileStream works on file written with writeFile", async () => {
const agent = await freshAgent("stream-interop-write");
await agent.write("/normal.txt", "via writeFile");
const content = (await agent.readStream(
"/normal.txt"
)) as unknown as string;
expect(content).toBe("via writeFile");
});
it("writeFileStream overwrites existing file", async () => {
const agent = await freshAgent("stream-overwrite");
await agent.write("/over.txt", "original");
await agent.writeStream("/over.txt", "replaced");
const content = (await agent.read("/over.txt")) as unknown as string;
expect(content).toBe("replaced");
});
});
// ── Symlinks ────────────────────────────────────────────────────────
describe("workspace — symlinks", () => {
it("create and readlink a symlink", async () => {
const agent = await freshAgent("sym-basic");
await agent.write("/target.txt", "hello");
await agent.symlinkCall("/target.txt", "/link.txt");
const target = (await agent.readlinkCall("/link.txt")) as unknown as string;
expect(target).toBe("/target.txt");
});
it("reading through a symlink returns target content", async () => {
const agent = await freshAgent("sym-read");
await agent.write("/real.txt", "symlink content");
await agent.symlinkCall("/real.txt", "/alias.txt");
const content = (await agent.read("/alias.txt")) as unknown as string;
expect(content).toBe("symlink content");
});
it("stat follows symlink to target", async () => {
const agent = await freshAgent("sym-stat");
await agent.write("/data.txt", "12345");
await agent.symlinkCall("/data.txt", "/link.txt");
const s = (await agent.stat("/link.txt")) as unknown as {
type: string;
size: number;
};
expect(s.type).toBe("file");
expect(s.size).toBe(5);
});
it("lstat returns symlink type without following", async () => {
const agent = await freshAgent("sym-lstat");
await agent.write("/orig.txt", "content");
await agent.symlinkCall("/orig.txt", "/sl.txt");
const s = (await agent.lstatCall("/sl.txt")) as unknown as {
type: string;
target: string;
};
expect(s.type).toBe("symlink");
expect(s.target).toBe("/orig.txt");
});
it("symlink with relative target resolves correctly", async () => {
const agent = await freshAgent("sym-relative");
await agent.write("/dir/file.txt", "relative target");
await agent.symlinkCall("file.txt", "/dir/link.txt");
const content = (await agent.read("/dir/link.txt")) as unknown as string;
expect(content).toBe("relative target");
});
it("chained symlinks resolve", async () => {
const agent = await freshAgent("sym-chain");
await agent.write("/real.txt", "chained");
await agent.symlinkCall("/real.txt", "/link1.txt");
await agent.symlinkCall("/link1.txt", "/link2.txt");
const content = (await agent.read("/link2.txt")) as unknown as string;
expect(content).toBe("chained");
});
it("readlink on non-symlink throws EINVAL", async () => {
const agent = await freshAgent("sym-einval");
await agent.write("/regular.txt", "not a link");
const result = await agent.readlinkCall("/regular.txt");
expect((result as { error: string }).error).toContain("EINVAL");
});
it("symlink to existing path throws EEXIST", async () => {
const agent = await freshAgent("sym-eexist");
await agent.write("/a.txt", "a");
await agent.write("/b.txt", "b");
const result = await agent.symlinkCall("/a.txt", "/b.txt");
expect((result as { error: string }).error).toContain("EEXIST");
});
});
// ── Change events ───────────────────────────────────────────────────
describe("workspace — change events", () => {
it("writeFile emits create event for new file", async () => {
const agent = await freshAgent("evt-create");
await agent.clearChangeLog();
await agent.writeWithEvents("/hello.txt", "world");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
const fileEvt = log.find(
(e) => e.path === "/hello.txt" && e.entryType === "file"
);
expect(fileEvt).toBeDefined();
expect(fileEvt!.type).toBe("create");
});
it("writeFile emits update event for overwrite", async () => {
const agent = await freshAgent("evt-update");
await agent.writeWithEvents("/up.txt", "v1");
await agent.clearChangeLog();
await agent.writeWithEvents("/up.txt", "v2");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
expect(log).toHaveLength(1);
expect(log[0].type).toBe("update");
expect(log[0].path).toBe("/up.txt");
});
it("deleteFile emits delete event", async () => {
const agent = await freshAgent("evt-delete");
await agent.writeWithEvents("/del.txt", "bye");
await agent.clearChangeLog();
await agent.deleteWithEvents("/del.txt");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
expect(log).toHaveLength(1);
expect(log[0].type).toBe("delete");
expect(log[0].entryType).toBe("file");
});
it("mkdir emits create event for directory", async () => {
const agent = await freshAgent("evt-mkdir");
await agent.clearChangeLog();
await agent.mkdirWithEvents("/mydir");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
const dirEvt = log.find(
(e) => e.path === "/mydir" && e.entryType === "directory"
);
expect(dirEvt).toBeDefined();
expect(dirEvt!.type).toBe("create");
});
it("rm emits delete event", async () => {
const agent = await freshAgent("evt-rm");
await agent.writeWithEvents("/rmme.txt", "gone");
await agent.clearChangeLog();
await agent.rmWithEvents("/rmme.txt");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
expect(log).toHaveLength(1);
expect(log[0].type).toBe("delete");
});
it("symlink emits create event with symlink entryType", async () => {
const agent = await freshAgent("evt-symlink");
await agent.writeWithEvents("/target.txt", "t");
await agent.clearChangeLog();
await agent.symlinkWithEvents("/target.txt", "/slink.txt");
const log = (await agent.getChangeLog()) as unknown as {
type: string;
path: string;
entryType: string;
}[];
const slEvt = log.find(
(e) => e.path === "/slink.txt" && e.entryType === "symlink"
);
expect(slEvt).toBeDefined();
expect(slEvt!.type).toBe("create");
});
it("no events fire when onChange is not set", async () => {
const agent = await freshAgent("evt-none");
await agent.write("/no-events.txt", "quiet");
const log = (await agent.getChangeLog()) as unknown as unknown[];
expect(log).toHaveLength(0);
});
});
// ── Binary file support ─────────────────────────────────────────────
describe("workspace — binary files", () => {
it("writeFileBytes + readFileBytes round-trip", async () => {
const agent = await freshAgent("bin-roundtrip");
const data = [0, 1, 2, 127, 128, 255];
await agent.writeBytes("/bin.dat", data);
const result = (await agent.readBytes("/bin.dat")) as unknown as number[];
expect(result).toEqual(data);
});
it("readFileBytes on text file returns encoded bytes", async () => {
const agent = await freshAgent("bin-read-text");
await agent.write("/text.txt", "hello");
const result = (await agent.readBytes("/text.txt")) as unknown as number[];
expect(result).toEqual([104, 101, 108, 108, 111]);
});
it("readFile on binary file returns decoded string", async () => {
const agent = await freshAgent("bin-read-as-text");
await agent.writeBytes("/abc.bin", [65, 66, 67]);
const content = (await agent.read("/abc.bin")) as unknown as string;
expect(content).toBe("ABC");
});
it("readFileBytes returns null for missing file", async () => {
const agent = await freshAgent("bin-missing");
const result = await agent.readBytes("/nope.bin");
expect(result).toBeNull();
});
it("writeFileBytes creates parent directories", async () => {
const agent = await freshAgent("bin-parents");
await agent.writeBytes("/a/b/deep.bin", [1, 2, 3]);
const result = (await agent.readBytes(
"/a/b/deep.bin"
)) as unknown as number[];
expect(result).toEqual([1, 2, 3]);
});
it("writeFileBytes with custom mimeType", async () => {
const agent = await freshAgent("bin-mime");
await agent.writeBytes("/img.png", [137, 80, 78, 71], "image/png");
const s = (await agent.stat("/img.png")) as unknown as {
mimeType: string;
};
expect(s.mimeType).toBe("image/png");
});
it("writeFileBytes overwrites existing file", async () => {
const agent = await freshAgent("bin-overwrite");
await agent.writeBytes("/over.bin", [1, 2, 3]);
await agent.writeBytes("/over.bin", [4, 5, 6]);
const result = (await agent.readBytes("/over.bin")) as unknown as number[];
expect(result).toEqual([4, 5, 6]);
});
it("binary file preserves null bytes", async () => {
const agent = await freshAgent("bin-null");
const data = [0, 0, 0, 42, 0, 0];
await agent.writeBytes("/nulls.bin", data);
const result = (await agent.readBytes("/nulls.bin")) as unknown as number[];
expect(result).toEqual(data);
});
});
// ── cp / mv ─────────────────────────────────────────────────────────
describe("workspace — cp", () => {
it("copies a file", async () => {
const agent = await freshAgent("cp-file");
await agent.write("/src.txt", "copy me");
await agent.cpCall("/src.txt", "/dest.txt");
const content = (await agent.read("/dest.txt")) as unknown as string;
expect(content).toBe("copy me");
const src = (await agent.read("/src.txt")) as unknown as string;
expect(src).toBe("copy me");
});
it("copies a directory recursively", async () => {
const agent = await freshAgent("cp-dir");
await agent.write("/dir/a.txt", "aaa");
await agent.write("/dir/b.txt", "bbb");
await agent.cpCall("/dir", "/copy", { recursive: true });
const a = (await agent.read("/copy/a.txt")) as unknown as string;
const b = (await agent.read("/copy/b.txt")) as unknown as string;
expect(a).toBe("aaa");
expect(b).toBe("bbb");
});
it("copies nested directories recursively", async () => {
const agent = await freshAgent("cp-nested");
await agent.write("/d/sub/f.txt", "nested");
await agent.cpCall("/d", "/d2", { recursive: true });
const content = (await agent.read("/d2/sub/f.txt")) as unknown as string;
expect(content).toBe("nested");
});
it("copies a symlink as a symlink", async () => {
const agent = await freshAgent("cp-symlink");
await agent.write("/target.txt", "t");
await agent.symlinkCall("/target.txt", "/link.txt");
await agent.cpCall("/link.txt", "/link2.txt");
const target = (await agent.readlinkCall(
"/link2.txt"
)) as unknown as string;
expect(target).toBe("/target.txt");
});
it("cp without recursive on directory throws EISDIR", async () => {
const agent = await freshAgent("cp-no-recursive");
await agent.write("/dir/f.txt", "x");
const result = await agent.cpCall("/dir", "/dir2");
expect((result as { error: string }).error).toContain("EISDIR");
});
it("cp on missing source throws ENOENT", async () => {
const agent = await freshAgent("cp-enoent");
const result = await agent.cpCall("/ghost", "/dest");
expect((result as { error: string }).error).toContain("ENOENT");
});
});
describe("workspace — mv", () => {
it("moves a file", async () => {
const agent = await freshAgent("mv-file");
await agent.write("/old.txt", "move me");
await agent.mvCall("/old.txt", "/new.txt");
const content = (await agent.read("/new.txt")) as unknown as string;
expect(content).toBe("move me");
const old = await agent.read("/old.txt");
expect(old).toBeNull();
});
it("moves a directory", async () => {
const agent = await freshAgent("mv-dir");
await agent.write("/src/a.txt", "aaa");
await agent.write("/src/b.txt", "bbb");
await agent.mvCall("/src", "/dst");
const a = (await agent.read("/dst/a.txt")) as unknown as string;
expect(a).toBe("aaa");
const srcStat = await agent.stat("/src");
expect(srcStat).toBeNull();
});
it("mv renames a file in place", async () => {
const agent = await freshAgent("mv-rename");
await agent.write("/dir/old.txt", "renamed");
await agent.mvCall("/dir/old.txt", "/dir/new.txt");
const content = (await agent.read("/dir/new.txt")) as unknown as string;
expect(content).toBe("renamed");
const old = await agent.read("/dir/old.txt");
expect(old).toBeNull();
});
});
// ── Diff ─────────────────────────────────────────────────────────────
describe("workspace — diff", () => {
it("diff returns empty string for identical files", async () => {
const agent = await freshAgent("diff-identical");
await agent.write("/a.txt", "same\ncontent");
await agent.write("/b.txt", "same\ncontent");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toBe("");
});
it("diff shows added lines", async () => {
const agent = await freshAgent("diff-add");
await agent.write("/a.txt", "line1\nline2");
await agent.write("/b.txt", "line1\nline2\nline3");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toContain("--- /a.txt");
expect(d).toContain("+++ /b.txt");
expect(d).toContain("+line3");
});
it("diff shows removed lines", async () => {
const agent = await freshAgent("diff-remove");
await agent.write("/a.txt", "line1\nline2\nline3");
await agent.write("/b.txt", "line1\nline3");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toContain("-line2");
});
it("diff shows changed lines", async () => {
const agent = await freshAgent("diff-change");
await agent.write("/a.txt", "hello\nworld");
await agent.write("/b.txt", "hello\nearth");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toContain("-world");
expect(d).toContain("+earth");
});
it("diffContent compares file against string", async () => {
const agent = await freshAgent("diff-content");
await agent.write("/f.txt", "old\ncontent");
const d = (await agent.diffContentCall(
"/f.txt",
"new\ncontent"
)) as unknown as string;
expect(d).toContain("-old");
expect(d).toContain("+new");
expect(d).not.toContain("-content");
});
it("diffContent returns empty for same content", async () => {
const agent = await freshAgent("diff-content-same");
await agent.write("/f.txt", "unchanged");
const d = (await agent.diffContentCall(
"/f.txt",
"unchanged"
)) as unknown as string;
expect(d).toBe("");
});
it("diff on missing file throws ENOENT", async () => {
const agent = await freshAgent("diff-enoent");
await agent.write("/a.txt", "exists");
const result = await agent.diffCall("/a.txt", "/nope.txt");
expect((result as { error: string }).error).toContain("ENOENT");
});
it("diff includes hunk headers", async () => {
const agent = await freshAgent("diff-hunks");
await agent.write("/a.txt", "a\nb\nc\nd\ne");
await agent.write("/b.txt", "a\nb\nX\nd\ne");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toContain("@@");
expect(d).toContain("-c");
expect(d).toContain("+X");
});
});
// ── Regression tests ────────────────────────────────────────────────
describe("workspace — regression: content_encoding reset", () => {
it("writeFile over binary file resets encoding so readFile works", async () => {
const agent = await freshAgent("reg-encoding-reset");
await agent.writeBytes("/mixed.txt", [0, 1, 2, 255]);
await agent.write("/mixed.txt", "now text");
const content = (await agent.read("/mixed.txt")) as unknown as string;
expect(content).toBe("now text");
});
it("writeFileBytes over text file sets encoding to base64", async () => {
const agent = await freshAgent("reg-encoding-to-b64");
await agent.write("/f.txt", "text first");
await agent.writeBytes("/f.txt", [10, 20, 30]);
const bytes = (await agent.readBytes("/f.txt")) as unknown as number[];
expect(bytes).toEqual([10, 20, 30]);
});
});
describe("workspace — regression: writeFileStream binary", () => {
it("writeFileStream preserves binary data", async () => {
const agent = await freshAgent("reg-stream-binary");
const data = [0, 1, 127, 128, 255];
await agent.writeStreamBytes("/stream.bin", data);
const result = (await agent.readBytes(
"/stream.bin"
)) as unknown as number[];
expect(result).toEqual(data);
});
});
describe("workspace — regression: symlink write-through", () => {
it("writeFile through symlink modifies the target", async () => {
const agent = await freshAgent("reg-write-symlink");
await agent.write("/real.txt", "original");
await agent.symlinkCall("/real.txt", "/link.txt");
await agent.write("/link.txt", "updated");
const content = (await agent.read("/real.txt")) as unknown as string;
expect(content).toBe("updated");
const target = (await agent.readlinkCall("/link.txt")) as unknown as string;
expect(target).toBe("/real.txt");
});
it("writeFileBytes through symlink modifies the target", async () => {
const agent = await freshAgent("reg-writebytes-symlink");
await agent.writeBytes("/real.bin", [1, 2, 3]);
await agent.symlinkCall("/real.bin", "/link.bin");
await agent.writeBytes("/link.bin", [4, 5, 6]);
const bytes = (await agent.readBytes("/real.bin")) as unknown as number[];
expect(bytes).toEqual([4, 5, 6]);
});
});
describe("workspace — regression: deleteFile symlink", () => {
it("deleteFile removes a symlink without following it", async () => {
const agent = await freshAgent("reg-delete-symlink");
await agent.write("/target.txt", "keep me");
await agent.symlinkCall("/target.txt", "/link.txt");
const deleted = (await agent.del("/link.txt")) as unknown as boolean;
expect(deleted).toBe(true);
const content = (await agent.read("/target.txt")) as unknown as string;
expect(content).toBe("keep me");
});
it("deleteFile on symlink to directory succeeds (removes link)", async () => {
const agent = await freshAgent("reg-delete-symlink-dir");
await agent.mkdirCall("/mydir");
await agent.symlinkCall("/mydir", "/dirlink");
const deleted = (await agent.del("/dirlink")) as unknown as boolean;
expect(deleted).toBe(true);
const s = await agent.stat("/mydir");
expect(s).not.toBeNull();
});
});
describe("workspace — regression: fileExists through symlink", () => {
it("fileExists returns true through symlink to file", async () => {
const agent = await freshAgent("reg-exists-symlink");
await agent.write("/real.txt", "hi");
await agent.symlinkCall("/real.txt", "/link.txt");
const ex = (await agent.exists("/link.txt")) as unknown as boolean;
expect(ex).toBe(true);
});
it("fileExists returns false through symlink to directory", async () => {
const agent = await freshAgent("reg-exists-symlink-dir");
await agent.mkdirCall("/dir");
await agent.symlinkCall("/dir", "/dirlink");
const ex = (await agent.exists("/dirlink")) as unknown as boolean;
expect(ex).toBe(false);
});
});
// ── Glob ────────────────────────────────────────────────────────────
describe("workspace — glob", () => {
it("matches files by extension", async () => {
const agent = await freshAgent("glob-ext");
await agent.write("/src/a.ts", "a");
await agent.write("/src/b.ts", "b");
await agent.write("/src/c.js", "c");
await agent.write("/src/d.txt", "d");
const results = (await agent.globCall(
"/src/*.ts"
)) as unknown as FileInfo[];
expect(results.length).toBe(2);
expect(results.map((r) => r.name).sort()).toEqual(["a.ts", "b.ts"]);
});
it("** matches across directories", async () => {
const agent = await freshAgent("glob-doublestar");
await agent.write("/src/a.ts", "a");
await agent.write("/src/sub/b.ts", "b");
await agent.write("/src/sub/deep/c.ts", "c");
await agent.write("/src/sub/deep/d.js", "d");
const results = (await agent.globCall(
"/src/**/*.ts"
)) as unknown as FileInfo[];
expect(results.length).toBe(3);
expect(results.map((r) => r.path).sort()).toEqual([
"/src/a.ts",
"/src/sub/b.ts",
"/src/sub/deep/c.ts"
]);
});
it("? matches single character", async () => {
const agent = await freshAgent("glob-question");
await agent.write("/f1.txt", "1");
await agent.write("/f2.txt", "2");
await agent.write("/f10.txt", "10");
const results = (await agent.globCall("/f?.txt")) as unknown as FileInfo[];
expect(results.length).toBe(2);
expect(results.map((r) => r.name).sort()).toEqual(["f1.txt", "f2.txt"]);
});
it("brace expansion {a,b}", async () => {
const agent = await freshAgent("glob-brace");
await agent.write("/app.ts", "ts");
await agent.write("/app.js", "js");
await agent.write("/app.css", "css");
const results = (await agent.globCall(
"/app.{ts,js}"
)) as unknown as FileInfo[];
expect(results.length).toBe(2);
expect(results.map((r) => r.name).sort()).toEqual(["app.js", "app.ts"]);
});
it("character class [abc]", async () => {
const agent = await freshAgent("glob-charclass");
await agent.write("/a.txt", "a");
await agent.write("/b.txt", "b");
await agent.write("/c.txt", "c");
await agent.write("/d.txt", "d");
const results = (await agent.globCall(
"/[ab].txt"
)) as unknown as FileInfo[];
expect(results.length).toBe(2);
expect(results.map((r) => r.name).sort()).toEqual(["a.txt", "b.txt"]);
});
it("matches directories and files", async () => {
const agent = await freshAgent("glob-all-types");
await agent.write("/src/index.ts", "x");
await agent.mkdirCall("/src/utils", { recursive: true });
const results = (await agent.globCall("/src/*")) as unknown as FileInfo[];
expect(results.length).toBe(2);
const types = results.map((r) => r.type).sort();
expect(types).toEqual(["directory", "file"]);
});
it("returns empty for no matches", async () => {
const agent = await freshAgent("glob-nomatch");
await agent.write("/a.txt", "a");
const results = (await agent.globCall("/b.*")) as unknown as FileInfo[];
expect(results.length).toBe(0);
});
it("exact path (no wildcards) returns the entry", async () => {
const agent = await freshAgent("glob-exact");
await agent.write("/exact.txt", "x");
const results = (await agent.globCall(
"/exact.txt"
)) as unknown as FileInfo[];
expect(results.length).toBe(1);
expect(results[0].name).toBe("exact.txt");
});
it("prefix optimization narrows SQL scan", async () => {
const agent = await freshAgent("glob-prefix");
await agent.write("/a/file.ts", "a");
await agent.write("/b/file.ts", "b");
await agent.write("/a/nested/deep.ts", "c");
const results = (await agent.globCall(
"/a/**/*.ts"
)) as unknown as FileInfo[];
expect(results.length).toBe(2);
expect(results.every((r) => r.path.startsWith("/a/"))).toBe(true);
});
});
// ── Security regression tests ───────────────────────────────────────
describe("workspace — security: LIKE injection", () => {
it("rm recursive on dir with % in name does not delete unrelated files", async () => {
const agent = await freshAgent("sec-like-percent");
await agent.write("/a%b/child.txt", "in dir");
await agent.write("/axb/other.txt", "unrelated");
await agent.rmCall("/a%b", { recursive: true });
const content = (await agent.read("/axb/other.txt")) as unknown as string;
expect(content).toBe("unrelated");
});
it("rm recursive on dir with _ in name does not delete unrelated files", async () => {
const agent = await freshAgent("sec-like-underscore");
await agent.write("/a_b/child.txt", "in dir");
await agent.write("/axb/other.txt", "unrelated");
await agent.rmCall("/a_b", { recursive: true });
const content = (await agent.read("/axb/other.txt")) as unknown as string;
expect(content).toBe("unrelated");
});
});
describe("workspace — security: path normalization", () => {
it(".. is resolved so files are reachable via readDir", async () => {
const agent = await freshAgent("sec-dotdot-resolve");
await agent.write("/a/b/../c.txt", "content");
const content = (await agent.read("/a/c.txt")) as unknown as string;
expect(content).toBe("content");
const files = (await agent.list("/a")) as unknown as FileInfo[];
const names = files.map((f: FileInfo) => f.name);
expect(names).toContain("c.txt");
});
it(". is resolved in paths", async () => {
const agent = await freshAgent("sec-dot-resolve");
await agent.write("/a/./b.txt", "content");
const content = (await agent.read("/a/b.txt")) as unknown as string;
expect(content).toBe("content");
});
it(".. at root stays at root", async () => {
const agent = await freshAgent("sec-dotdot-root");
await agent.write("/../../../etc/passwd", "nope");
const content = (await agent.read("/etc/passwd")) as unknown as string;
expect(content).toBe("nope");
});
it("read and write use consistent normalized paths", async () => {
const agent = await freshAgent("sec-norm-consistency");
await agent.write("/x/y/../z.txt", "v1");
await agent.write("/x/z.txt", "v2");
const content = (await agent.read("/x/y/../z.txt")) as unknown as string;
expect(content).toBe("v2");
});
});
describe("workspace — security: writeFileStream size limit", () => {
it("normal-sized stream works fine", async () => {
const agent = await freshAgent("sec-stream-ok");
await agent.writeStream("/ok.txt", "small content");
const content = (await agent.read("/ok.txt")) as unknown as string;
expect(content).toBe("small content");
});
});
describe("workspace — security: diff line limit", () => {
it("rejects diff when files exceed MAX_DIFF_LINES", async () => {
const agent = await freshAgent("sec-diff-limit");
const bigContent = Array.from(
{ length: 10_001 },
(_, i) => `line ${i}`
).join("\n");
await agent.write("/big.txt", bigContent);
await agent.write("/small.txt", "hello");
const result = await agent.diffCall("/big.txt", "/small.txt");
expect((result as { error: string }).error).toContain("EFBIG");
});
it("rejects diffContent when content exceeds MAX_DIFF_LINES", async () => {
const agent = await freshAgent("sec-diffcontent-limit");
await agent.write("/base.txt", "hello");
const bigContent = Array.from(
{ length: 10_001 },
(_, i) => `line ${i}`
).join("\n");
const result = await agent.diffContentCall("/base.txt", bigContent);
expect((result as { error: string }).error).toContain("EFBIG");
});
it("allows diff on files within the line limit", async () => {
const agent = await freshAgent("sec-diff-ok");
await agent.write("/a.txt", "hello\nworld");
await agent.write("/b.txt", "hello\nearth");
const d = (await agent.diffCall("/a.txt", "/b.txt")) as unknown as string;
expect(d).toContain("-world");
expect(d).toContain("+earth");
});
});
describe("workspace — security: symlink target validation", () => {
it("rejects empty symlink target", async () => {
const agent = await freshAgent("sec-symlink-empty");
const result = await agent.symlinkCall("", "/link");
expect((result as { error: string }).error).toContain("EINVAL");
});
it("rejects whitespace-only symlink target", async () => {
const agent = await freshAgent("sec-symlink-space");
const result = await agent.symlinkCall(" ", "/link");
expect((result as { error: string }).error).toContain("EINVAL");
});
it("rejects excessively long symlink target", async () => {
const agent = await freshAgent("sec-symlink-long");
const longTarget = "/" + "a".repeat(5000);
const result = await agent.symlinkCall(longTarget, "/link");
expect((result as { error: string }).error).toContain("ENAMETOOLONG");
});
});
describe("workspace — security: path length limit", () => {
it("rejects paths exceeding MAX_PATH_LENGTH", async () => {
const agent = await freshAgent("sec-path-long");
const longPath = "/" + "a".repeat(5000);
const result = await agent.write(longPath, "nope");
expect((result as { error: string }).error).toContain("ENAMETOOLONG");
});
it("allows paths within the limit", async () => {
const agent = await freshAgent("sec-path-ok");
const okPath = "/" + "a".repeat(100) + "/file.txt";
await agent.write(okPath, "ok");
const content = (await agent.read(okPath)) as unknown as string;
expect(content).toBe("ok");
});
});
// ── Observability ───────────────────────────────────────────────────
describe("workspace — observability", () => {
it("emits workspace:write on file creation", async () => {
const agent = await freshAgent("obs-write-create");
await agent.startObservability();
await agent.write("/hello.txt", "world");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const writeEvents = log.filter((e) => e.type === "workspace:write");
expect(writeEvents).toHaveLength(1);
expect(writeEvents[0].payload).toMatchObject({
path: "/hello.txt",
storage: "inline",
update: false,
namespace: "default"
});
expect(writeEvents[0].payload.size).toBeGreaterThan(0);
});
it("emits workspace:write with update=true on overwrite", async () => {
const agent = await freshAgent("obs-write-update");
await agent.write("/f.txt", "v1");
await agent.startObservability();
await agent.write("/f.txt", "v2");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const writeEvents = log.filter((e) => e.type === "workspace:write");
expect(writeEvents).toHaveLength(1);
expect(writeEvents[0].payload.update).toBe(true);
});
it("emits workspace:read on file read", async () => {
const agent = await freshAgent("obs-read");
await agent.write("/r.txt", "data");
await agent.startObservability();
await agent.read("/r.txt");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const readEvents = log.filter((e) => e.type === "workspace:read");
expect(readEvents).toHaveLength(1);
expect(readEvents[0].payload).toMatchObject({
path: "/r.txt",
storage: "inline",
namespace: "default"
});
});
it("emits workspace:delete on file deletion", async () => {
const agent = await freshAgent("obs-delete");
await agent.write("/d.txt", "gone");
await agent.startObservability();
await agent.del("/d.txt");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const delEvents = log.filter((e) => e.type === "workspace:delete");
expect(delEvents).toHaveLength(1);
expect(delEvents[0].payload).toMatchObject({
path: "/d.txt",
namespace: "default"
});
});
it("emits workspace:mkdir on directory creation", async () => {
const agent = await freshAgent("obs-mkdir");
await agent.startObservability();
await agent.mkdirCall("/mydir");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const mkdirEvents = log.filter((e) => e.type === "workspace:mkdir");
expect(mkdirEvents).toHaveLength(1);
expect(mkdirEvents[0].payload).toMatchObject({
path: "/mydir",
namespace: "default"
});
});
it("emits workspace:rm on removal", async () => {
const agent = await freshAgent("obs-rm");
await agent.write("/rmfile.txt", "x");
await agent.startObservability();
await agent.rmCall("/rmfile.txt");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const rmEvents = log.filter((e) => e.type === "workspace:rm");
expect(rmEvents).toHaveLength(1);
expect(rmEvents[0].payload).toMatchObject({
path: "/rmfile.txt",
namespace: "default"
});
});
it("emits workspace:cp on copy", async () => {
const agent = await freshAgent("obs-cp");
await agent.write("/src.txt", "copy me");
await agent.startObservability();
await agent.cpCall("/src.txt", "/dst.txt");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const cpEvents = log.filter((e) => e.type === "workspace:cp");
expect(cpEvents).toHaveLength(1);
expect(cpEvents[0].payload).toMatchObject({
src: "/src.txt",
dest: "/dst.txt",
namespace: "default"
});
});
it("emits workspace:mv on move", async () => {
const agent = await freshAgent("obs-mv");
await agent.write("/old.txt", "moving");
await agent.startObservability();
await agent.mvCall("/old.txt", "/new.txt");
const log = (await agent.getObservabilityLog()) as {
type: string;
payload: Record<string, unknown>;
}[];
await agent.stopObservability();
const mvEvents = log.filter((e) => e.type === "workspace:mv");
expect(mvEvents).toHaveLength(1);
expect(mvEvents[0].payload).toMatchObject({
src: "/old.txt",
dest: "/new.txt",
namespace: "default"
});
});
it("events include timestamp", async () => {
const agent = await freshAgent("obs-timestamp");
const before = Date.now();
await agent.startObservability();
await agent.write("/ts.txt", "time");
const log = (await agent.getObservabilityLog()) as {
type: string;
timestamp: number;
}[];
await agent.stopObservability();
const writeEvent = log.find((e) => e.type === "workspace:write");
expect(writeEvent).toBeDefined();
expect(writeEvent!.timestamp).toBeGreaterThanOrEqual(before);
expect(writeEvent!.timestamp).toBeLessThanOrEqual(Date.now());
});
it("events include agent name", async () => {
const agent = await freshAgent("obs-name");
await agent.startObservability();
await agent.write("/n.txt", "name");
const log = (await agent.getObservabilityLog()) as {
type: string;
name: string;
}[];
await agent.stopObservability();
const writeEvent = log.find((e) => e.type === "workspace:write");
expect(writeEvent).toBeDefined();
expect(writeEvent!.name).toBe("obs-name");
});
it("no events emitted after unsubscribe", async () => {
const agent = await freshAgent("obs-unsub");
await agent.startObservability();
await agent.stopObservability();
await agent.write("/after.txt", "silent");
const log = (await agent.getObservabilityLog()) as {
type: string;
}[];
expect(log).toHaveLength(0);
});
});