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, 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>(); const fileEntries: ReturnType[] = []; 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[] = []; 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 { 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(); 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([ ["/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([ ["/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([ ["/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([ ["/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([ ["/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); 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([ ["/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); 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; }[]; 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; }[]; 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; }[]; 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; }[]; 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; }[]; 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; }[]; 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; }[]; 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; }[]; 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); }); });