import { describe, expect, test } from "bun:test"; import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { LocalWorkspaceFiles, LocalWorkspaceResolver } from "."; describe("LocalWorkspaceResolver", () => { test("只接受实际存在且可读写的绝对目录", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-workspace-")); try { const resolver = new LocalWorkspaceResolver(); expect(await resolver.resolveForCreation(root)).toContain( "great-agent-workspace-", ); expect(await resolver.isAvailable(root)).toBe(true); expect( resolver.resolveForCreation("relative/path"), ).rejects.toMatchObject({ code: "WORKSPACE_PATH_NOT_ABSOLUTE" }); } finally { await rm(root, { recursive: true }); } }); }); describe("LocalWorkspaceFiles", () => { test("列出、搜索并读取工作区文本", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-files-")); try { await mkdir(join(root, "docs")); await writeFile( join(root, "docs", "说明.md"), "第一行\n需要搜索的内容\n", ); const files = new LocalWorkspaceFiles(); expect(await files.list(root)).toEqual([ { path: "docs", name: "docs", kind: "directory" }, ]); expect((await files.search(root, "搜索"))[0]).toMatchObject({ path: "docs/说明.md", line: 2, matchedBy: "content", }); expect((await files.readText(root, "docs/说明.md")).content).toContain( "第一行", ); } finally { await rm(root, { recursive: true }); } }); test("拒绝绝对路径、父目录跳转和符号链接逃逸", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-safe-")); const outside = await mkdtemp(join(tmpdir(), "great-agent-outside-")); try { await writeFile(join(outside, "secret.txt"), "secret"); await symlink(join(outside, "secret.txt"), join(root, "shortcut.txt")); await symlink(outside, join(root, "outside-directory")); const files = new LocalWorkspaceFiles(); await expect(files.readText(root, "../secret.txt")).rejects.toMatchObject( { code: "WORKSPACE_PATH_FORBIDDEN", }, ); await expect( files.readText(root, join(outside, "secret.txt")), ).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN", }); await expect(files.readText(root, "shortcut.txt")).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN", }); await expect( files.createText(root, "outside-directory/new.txt", "escape"), ).rejects.toMatchObject({ code: "WORKSPACE_PATH_FORBIDDEN" }); } finally { await rm(root, { recursive: true }); await rm(outside, { recursive: true }); } }); test("二进制文件返回明确的不支持状态", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-binary-")); try { await writeFile(join(root, "image.bin"), new Uint8Array([1, 0, 2])); const files = new LocalWorkspaceFiles(); expect((await files.inspect(root, "image.bin")).readableAsText).toBe( false, ); await expect(files.readText(root, "image.bin")).rejects.toMatchObject({ code: "FILE_TYPE_UNSUPPORTED", }); } finally { await rm(root, { recursive: true }); } }); test("排他创建并使用内容哈希安全修改文本", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-write-")); try { const files = new LocalWorkspaceFiles(); const created = await files.createText(root, "notes.txt", "旧内容\n"); expect(created.path).toBe("notes.txt"); expect(created.hash).toHaveLength(64); await expect( files.createText(root, "notes.txt", "不能覆盖"), ).rejects.toMatchObject({ code: "FILE_ALREADY_EXISTS" }); const changed = await files.applyTextPatch( root, "notes.txt", created.hash, [{ oldText: "旧内容", newText: "新内容" }], ); expect(changed.hash).not.toBe(created.hash); expect((await files.readText(root, "notes.txt")).content).toBe( "新内容\n", ); await expect( files.applyTextPatch(root, "notes.txt", created.hash, [ { oldText: "新内容", newText: "危险覆盖" }, ]), ).rejects.toMatchObject({ code: "FILE_CHANGED" }); } finally { await rm(root, { recursive: true }); } }); test("修改目标缺失或不唯一时不写文件", async () => { const root = await mkdtemp(join(tmpdir(), "great-agent-patch-")); try { await writeFile(join(root, "repeat.txt"), "相同\n相同\n"); const files = new LocalWorkspaceFiles(); const current = await files.readText(root, "repeat.txt"); await expect( files.applyTextPatch(root, "repeat.txt", current.hash, [ { oldText: "不存在", newText: "内容" }, ]), ).rejects.toMatchObject({ code: "PATCH_TARGET_NOT_FOUND" }); await expect( files.applyTextPatch(root, "repeat.txt", current.hash, [ { oldText: "相同", newText: "内容" }, ]), ).rejects.toMatchObject({ code: "PATCH_TARGET_AMBIGUOUS" }); expect((await files.readText(root, "repeat.txt")).content).toBe( "相同\n相同\n", ); } finally { await rm(root, { recursive: true }); } }); });