Преглед изворни кода

feat: Add centralized filesystem module for Bun.file migration (#14117)

Dax пре 6 месеци
родитељ
комит
6b29896a35

+ 5 - 0
bun.lock

@@ -14,6 +14,7 @@
       "devDependencies": {
         "@actions/artifact": "5.0.1",
         "@tsconfig/bun": "catalog:",
+        "@types/mime-types": "3.0.1",
         "husky": "9.1.7",
         "prettier": "3.6.2",
         "semver": "^7.6.0",
@@ -324,6 +325,7 @@
         "hono-openapi": "catalog:",
         "ignore": "7.0.5",
         "jsonc-parser": "3.3.1",
+        "mime-types": "3.0.2",
         "minimatch": "10.0.3",
         "open": "10.1.2",
         "opentui-spinner": "0.0.6",
@@ -356,6 +358,7 @@
         "@tsconfig/bun": "catalog:",
         "@types/babel__core": "7.20.5",
         "@types/bun": "catalog:",
+        "@types/mime-types": "3.0.1",
         "@types/turndown": "5.0.5",
         "@types/yargs": "17.0.33",
         "@typescript/native-preview": "catalog:",
@@ -1917,6 +1920,8 @@
 
     "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="],
 
+    "@types/mime-types": ["@types/mime-types@3.0.1", "", {}, "sha512-xRMsfuQbnRq1Ef+C+RKaENOxXX87Ygl38W1vDfPHRku02TgQr+Qd8iivLtAMcR0KF5/29xlnFihkTlbqFrGOVQ=="],
+
     "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="],
 
     "@types/mssql": ["@types/mssql@9.1.9", "", { "dependencies": { "@types/node": "*", "tarn": "^3.0.1", "tedious": "*" } }, "sha512-P0nCgw6vzY23UxZMnbI4N7fnLGANt4LI4yvxze1paPj+LuN28cFv5EI+QidP8udnId/BKhkcRhm/BleNsjK65A=="],

+ 1 - 0
package.json

@@ -69,6 +69,7 @@
   "devDependencies": {
     "@actions/artifact": "5.0.1",
     "@tsconfig/bun": "catalog:",
+    "@types/mime-types": "3.0.1",
     "husky": "9.1.7",
     "prettier": "3.6.2",
     "semver": "^7.6.0",

+ 2 - 0
packages/opencode/package.json

@@ -40,6 +40,7 @@
     "@tsconfig/bun": "catalog:",
     "@types/babel__core": "7.20.5",
     "@types/bun": "catalog:",
+    "@types/mime-types": "3.0.1",
     "@types/turndown": "5.0.5",
     "@types/yargs": "17.0.33",
     "@typescript/native-preview": "catalog:",
@@ -110,6 +111,7 @@
     "hono-openapi": "catalog:",
     "ignore": "7.0.5",
     "jsonc-parser": "3.3.1",
+    "mime-types": "3.0.2",
     "minimatch": "10.0.3",
     "open": "10.1.2",
     "opentui-spinner": "0.0.6",

+ 70 - 11
packages/opencode/src/util/filesystem.ts

@@ -1,18 +1,76 @@
+import { mkdir, readFile, writeFile } from "fs/promises"
+import { existsSync, statSync } from "fs"
+import { lookup } from "mime-types"
 import { realpathSync } from "fs"
 import { dirname, join, relative } from "path"
 
 export namespace Filesystem {
-  export const exists = (p: string) =>
-    Bun.file(p)
-      .stat()
-      .then(() => true)
-      .catch(() => false)
-
-  export const isDir = (p: string) =>
-    Bun.file(p)
-      .stat()
-      .then((s) => s.isDirectory())
-      .catch(() => false)
+  // Fast sync version for metadata checks
+  export async function exists(p: string): Promise<boolean> {
+    return existsSync(p)
+  }
+
+  export async function isDir(p: string): Promise<boolean> {
+    try {
+      return statSync(p).isDirectory()
+    } catch {
+      return false
+    }
+  }
+
+  export async function size(p: string): Promise<number> {
+    try {
+      return statSync(p).size
+    } catch {
+      return 0
+    }
+  }
+
+  export async function readText(p: string): Promise<string> {
+    return readFile(p, "utf-8")
+  }
+
+  export async function readJson<T>(p: string): Promise<T> {
+    return JSON.parse(await readFile(p, "utf-8"))
+  }
+
+  export async function readBytes(p: string): Promise<Buffer> {
+    return readFile(p)
+  }
+
+  function isEnoent(e: unknown): e is { code: "ENOENT" } {
+    return typeof e === "object" && e !== null && "code" in e && (e as { code: string }).code === "ENOENT"
+  }
+
+  export async function write(p: string, content: string | Buffer, mode?: number): Promise<void> {
+    try {
+      if (mode) {
+        await writeFile(p, content, { mode })
+      } else {
+        await writeFile(p, content)
+      }
+    } catch (e) {
+      if (isEnoent(e)) {
+        await mkdir(dirname(p), { recursive: true })
+        if (mode) {
+          await writeFile(p, content, { mode })
+        } else {
+          await writeFile(p, content)
+        }
+        return
+      }
+      throw e
+    }
+  }
+
+  export async function writeJson(p: string, data: unknown, mode?: number): Promise<void> {
+    return write(p, JSON.stringify(data, null, 2), mode)
+  }
+
+  export function mimeType(p: string): string {
+    return lookup(p) || "application/octet-stream"
+  }
+
   /**
    * On Windows, normalize a path to its canonical casing using the filesystem.
    * This is needed because Windows paths are case-insensitive but LSP servers
@@ -26,6 +84,7 @@ export namespace Filesystem {
       return p
     }
   }
+
   export function overlaps(a: string, b: string) {
     const relA = relative(a, b)
     const relB = relative(b, a)

+ 340 - 0
packages/opencode/test/file/index.test.ts

@@ -0,0 +1,340 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { File } from "../../src/file"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("file/index Bun.file patterns", () => {
+  describe("File.read() - text content", () => {
+    test("reads text file via Bun.file().text()", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      await fs.writeFile(filepath, "Hello World", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("test.txt")
+          expect(result.type).toBe("text")
+          expect(result.content).toBe("Hello World")
+        },
+      })
+    })
+
+    test("reads with Bun.file().exists() check", async () => {
+      await using tmp = await tmpdir()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          // Non-existent file should return empty content
+          const result = await File.read("nonexistent.txt")
+          expect(result.type).toBe("text")
+          expect(result.content).toBe("")
+        },
+      })
+    })
+
+    test("trims whitespace from text content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      await fs.writeFile(filepath, "  content with spaces  \n\n", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("test.txt")
+          expect(result.content).toBe("content with spaces")
+        },
+      })
+    })
+
+    test("handles empty text file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "empty.txt")
+      await fs.writeFile(filepath, "", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("empty.txt")
+          expect(result.type).toBe("text")
+          expect(result.content).toBe("")
+        },
+      })
+    })
+
+    test("handles multi-line text files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "multiline.txt")
+      await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("multiline.txt")
+          expect(result.content).toBe("line1\nline2\nline3")
+        },
+      })
+    })
+  })
+
+  describe("File.read() - binary content", () => {
+    test("reads binary file via Bun.file().arrayBuffer()", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "image.png")
+      const binaryContent = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
+      await fs.writeFile(filepath, binaryContent)
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("image.png")
+          expect(result.type).toBe("text") // Images return as text with base64 encoding
+          expect(result.encoding).toBe("base64")
+          expect(result.mimeType).toBe("image/png")
+          expect(result.content).toBe(binaryContent.toString("base64"))
+        },
+      })
+    })
+
+    test("returns empty for binary non-image files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "binary.so")
+      await fs.writeFile(filepath, Buffer.from([0x7f, 0x45, 0x4c, 0x46]), "binary")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("binary.so")
+          expect(result.type).toBe("binary")
+          expect(result.content).toBe("")
+        },
+      })
+    })
+  })
+
+  describe("File.read() - Bun.file().type", () => {
+    test("detects MIME type via Bun.file().type", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.json")
+      await fs.writeFile(filepath, '{"key": "value"}', "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const bunFile = Bun.file(filepath)
+          expect(bunFile.type).toContain("application/json")
+
+          const result = await File.read("test.json")
+          expect(result.type).toBe("text")
+        },
+      })
+    })
+
+    test("handles various image MIME types", async () => {
+      await using tmp = await tmpdir()
+      const testCases = [
+        { ext: "jpg", mime: "image/jpeg" },
+        { ext: "png", mime: "image/png" },
+        { ext: "gif", mime: "image/gif" },
+        { ext: "webp", mime: "image/webp" },
+      ]
+
+      for (const { ext, mime } of testCases) {
+        const filepath = path.join(tmp.path, `test.${ext}`)
+        await fs.writeFile(filepath, Buffer.from([0x00, 0x00, 0x00, 0x00]), "binary")
+
+        await Instance.provide({
+          directory: tmp.path,
+          fn: async () => {
+            const bunFile = Bun.file(filepath)
+            expect(bunFile.type).toContain(mime)
+          },
+        })
+      }
+    })
+  })
+
+  describe("File.list() - Bun.file().exists() and .text()", () => {
+    test("reads .gitignore via Bun.file().exists() and .text()", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const gitignorePath = path.join(tmp.path, ".gitignore")
+          await fs.writeFile(gitignorePath, "node_modules\ndist\n", "utf-8")
+
+          // This is used internally in File.list()
+          const bunFile = Bun.file(gitignorePath)
+          expect(await bunFile.exists()).toBe(true)
+
+          const content = await bunFile.text()
+          expect(content).toContain("node_modules")
+        },
+      })
+    })
+
+    test("reads .ignore file similarly", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const ignorePath = path.join(tmp.path, ".ignore")
+          await fs.writeFile(ignorePath, "*.log\n.env\n", "utf-8")
+
+          const bunFile = Bun.file(ignorePath)
+          expect(await bunFile.exists()).toBe(true)
+          expect(await bunFile.text()).toContain("*.log")
+        },
+      })
+    })
+
+    test("handles missing .gitignore gracefully", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const gitignorePath = path.join(tmp.path, ".gitignore")
+          const bunFile = Bun.file(gitignorePath)
+          expect(await bunFile.exists()).toBe(false)
+
+          // File.list() should still work
+          const nodes = await File.list()
+          expect(Array.isArray(nodes)).toBe(true)
+        },
+      })
+    })
+  })
+
+  describe("File.changed() - Bun.file().text() for untracked files", () => {
+    test("reads untracked files via Bun.file().text()", async () => {
+      await using tmp = await tmpdir({ git: true })
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const untrackedPath = path.join(tmp.path, "untracked.txt")
+          await fs.writeFile(untrackedPath, "new content\nwith multiple lines", "utf-8")
+
+          // This is how File.changed() reads untracked files
+          const bunFile = Bun.file(untrackedPath)
+          const content = await bunFile.text()
+          const lines = content.split("\n").length
+          expect(lines).toBe(2)
+        },
+      })
+    })
+  })
+
+  describe("Error handling", () => {
+    test("handles errors gracefully in Bun.file().text()", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "readonly.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.txt"))
+          // Bun.file().text() on non-existent file throws
+          await expect(nonExistentFile.text()).rejects.toThrow()
+
+          // But File.read() handles this gracefully
+          const result = await File.read("does-not-exist.txt")
+          expect(result.content).toBe("")
+        },
+      })
+    })
+
+    test("handles errors in Bun.file().arrayBuffer()", async () => {
+      await using tmp = await tmpdir()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const nonExistentFile = Bun.file(path.join(tmp.path, "does-not-exist.bin"))
+          const buffer = await nonExistentFile.arrayBuffer().catch(() => new ArrayBuffer(0))
+          expect(buffer.byteLength).toBe(0)
+        },
+      })
+    })
+
+    test("returns empty array buffer on error for images", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "broken.png")
+      // Don't create the file
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const bunFile = Bun.file(filepath)
+          // File.read() handles missing images gracefully
+          const result = await File.read("broken.png")
+          expect(result.type).toBe("text")
+          expect(result.content).toBe("")
+        },
+      })
+    })
+  })
+
+  describe("shouldEncode() logic", () => {
+    test("returns encoding info for text files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      await fs.writeFile(filepath, "simple text", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("test.txt")
+          expect(result.encoding).toBeUndefined()
+          expect(result.type).toBe("text")
+        },
+      })
+    })
+
+    test("returns base64 encoding for images", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.jpg")
+      await fs.writeFile(filepath, Buffer.from([0xff, 0xd8, 0xff, 0xe0]), "binary")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await File.read("test.jpg")
+          expect(result.encoding).toBe("base64")
+          expect(result.mimeType).toBe("image/jpeg")
+        },
+      })
+    })
+  })
+
+  describe("Path security", () => {
+    test("throws for paths outside project directory", async () => {
+      await using tmp = await tmpdir()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+        },
+      })
+    })
+
+    test("throws for paths outside project directory", async () => {
+      await using tmp = await tmpdir()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await expect(File.read("../outside.txt")).rejects.toThrow("Access denied")
+        },
+      })
+    })
+  })
+})

+ 360 - 0
packages/opencode/test/file/time.test.ts

@@ -0,0 +1,360 @@
+import { describe, test, expect, beforeEach } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { FileTime } from "../../src/file/time"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+describe("file/time", () => {
+  const sessionID = "test-session-123"
+
+  describe("read() and get()", () => {
+    test("stores read timestamp", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const before = FileTime.get(sessionID, filepath)
+          expect(before).toBeUndefined()
+
+          FileTime.read(sessionID, filepath)
+
+          const after = FileTime.get(sessionID, filepath)
+          expect(after).toBeInstanceOf(Date)
+          expect(after!.getTime()).toBeGreaterThan(0)
+        },
+      })
+    })
+
+    test("tracks separate timestamps per session", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read("session1", filepath)
+          FileTime.read("session2", filepath)
+
+          const time1 = FileTime.get("session1", filepath)
+          const time2 = FileTime.get("session2", filepath)
+
+          expect(time1).toBeDefined()
+          expect(time2).toBeDefined()
+        },
+      })
+    })
+
+    test("updates timestamp on subsequent reads", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+          const first = FileTime.get(sessionID, filepath)!
+
+          await new Promise((resolve) => setTimeout(resolve, 10))
+
+          FileTime.read(sessionID, filepath)
+          const second = FileTime.get(sessionID, filepath)!
+
+          expect(second.getTime()).toBeGreaterThanOrEqual(first.getTime())
+        },
+      })
+    })
+  })
+
+  describe("assert()", () => {
+    test("passes when file has not been modified", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+
+          // Should not throw
+          await FileTime.assert(sessionID, filepath)
+        },
+      })
+    })
+
+    test("throws when file was not read first", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("You must read file")
+        },
+      })
+    })
+
+    test("throws when file was modified after read", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+
+          // Wait to ensure different timestamps
+          await new Promise((resolve) => setTimeout(resolve, 100))
+
+          // Modify file after reading
+          await fs.writeFile(filepath, "modified content", "utf-8")
+
+          await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow("modified since it was last read")
+        },
+      })
+    })
+
+    test("includes timestamps in error message", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+          await new Promise((resolve) => setTimeout(resolve, 100))
+          await fs.writeFile(filepath, "modified", "utf-8")
+
+          let error: Error | undefined
+          try {
+            await FileTime.assert(sessionID, filepath)
+          } catch (e) {
+            error = e as Error
+          }
+          expect(error).toBeDefined()
+          expect(error!.message).toContain("Last modification:")
+          expect(error!.message).toContain("Last read:")
+        },
+      })
+    })
+
+    test("skips check when OPENCODE_DISABLE_FILETIME_CHECK is true", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const { Flag } = await import("../../src/flag/flag")
+          const original = Flag.OPENCODE_DISABLE_FILETIME_CHECK
+          ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = true
+
+          try {
+            // Should not throw even though file wasn't read
+            await FileTime.assert(sessionID, filepath)
+          } finally {
+            ;(Flag as { OPENCODE_DISABLE_FILETIME_CHECK: boolean }).OPENCODE_DISABLE_FILETIME_CHECK = original
+          }
+        },
+      })
+    })
+  })
+
+  describe("withLock()", () => {
+    test("executes function within lock", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          let executed = false
+          await FileTime.withLock(filepath, async () => {
+            executed = true
+            return "result"
+          })
+          expect(executed).toBe(true)
+        },
+      })
+    })
+
+    test("returns function result", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const result = await FileTime.withLock(filepath, async () => {
+            return "success"
+          })
+          expect(result).toBe("success")
+        },
+      })
+    })
+
+    test("serializes concurrent operations on same file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const order: number[] = []
+
+          const op1 = FileTime.withLock(filepath, async () => {
+            order.push(1)
+            await new Promise((resolve) => setTimeout(resolve, 10))
+            order.push(2)
+          })
+
+          const op2 = FileTime.withLock(filepath, async () => {
+            order.push(3)
+            order.push(4)
+          })
+
+          await Promise.all([op1, op2])
+
+          // Operations should be serialized
+          expect(order).toContain(1)
+          expect(order).toContain(2)
+          expect(order).toContain(3)
+          expect(order).toContain(4)
+        },
+      })
+    })
+
+    test("allows concurrent operations on different files", async () => {
+      await using tmp = await tmpdir()
+      const filepath1 = path.join(tmp.path, "file1.txt")
+      const filepath2 = path.join(tmp.path, "file2.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          let started1 = false
+          let started2 = false
+
+          const op1 = FileTime.withLock(filepath1, async () => {
+            started1 = true
+            await new Promise((resolve) => setTimeout(resolve, 50))
+            expect(started2).toBe(true) // op2 should have started while op1 is running
+          })
+
+          const op2 = FileTime.withLock(filepath2, async () => {
+            started2 = true
+          })
+
+          await Promise.all([op1, op2])
+
+          expect(started1).toBe(true)
+          expect(started2).toBe(true)
+        },
+      })
+    })
+
+    test("releases lock even if function throws", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          await expect(
+            FileTime.withLock(filepath, async () => {
+              throw new Error("Test error")
+            }),
+          ).rejects.toThrow("Test error")
+
+          // Lock should be released, subsequent operations should work
+          let executed = false
+          await FileTime.withLock(filepath, async () => {
+            executed = true
+          })
+          expect(executed).toBe(true)
+        },
+      })
+    })
+
+    test("deadlocks on nested locks (expected behavior)", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          // Nested locks on same file cause deadlock - this is expected
+          // The outer lock waits for inner to complete, but inner waits for outer to release
+          const timeout = new Promise<never>((_, reject) =>
+            setTimeout(() => reject(new Error("Deadlock detected")), 100),
+          )
+
+          const nestedLock = FileTime.withLock(filepath, async () => {
+            return FileTime.withLock(filepath, async () => {
+              return "inner"
+            })
+          })
+
+          // Should timeout due to deadlock
+          await expect(Promise.race([nestedLock, timeout])).rejects.toThrow("Deadlock detected")
+        },
+      })
+    })
+  })
+
+  describe("stat() Bun.file pattern", () => {
+    test("reads file modification time via Bun.file().stat()", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+
+          const stats = await Bun.file(filepath).stat()
+          expect(stats.mtime).toBeInstanceOf(Date)
+          expect(stats.mtime.getTime()).toBeGreaterThan(0)
+
+          // FileTime.assert uses this stat internally
+          await FileTime.assert(sessionID, filepath)
+        },
+      })
+    })
+
+    test("detects modification via stat mtime", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "original", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(sessionID, filepath)
+
+          const originalStat = await Bun.file(filepath).stat()
+
+          // Wait and modify
+          await new Promise((resolve) => setTimeout(resolve, 100))
+          await fs.writeFile(filepath, "modified", "utf-8")
+
+          const newStat = await Bun.file(filepath).stat()
+          expect(newStat.mtime.getTime()).toBeGreaterThan(originalStat.mtime.getTime())
+
+          await expect(FileTime.assert(sessionID, filepath)).rejects.toThrow()
+        },
+      })
+    })
+  })
+})

+ 496 - 0
packages/opencode/test/tool/edit.test.ts

@@ -0,0 +1,496 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { EditTool } from "../../src/tool/edit"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+import { FileTime } from "../../src/file/time"
+
+const ctx = {
+  sessionID: "test-edit-session",
+  messageID: "",
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => {},
+  ask: async () => {},
+}
+
+describe("tool.edit", () => {
+  describe("creating new files", () => {
+    test("creates new file when oldString is empty", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "newfile.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const edit = await EditTool.init()
+          const result = await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "",
+              newString: "new content",
+            },
+            ctx,
+          )
+
+          expect(result.metadata.diff).toContain("new content")
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("new content")
+        },
+      })
+    })
+
+    test("creates new file with nested directories", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "nested", "dir", "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "",
+              newString: "nested file",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("nested file")
+        },
+      })
+    })
+
+    test("emits add event for new files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "new.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const { Bus } = await import("../../src/bus")
+          const { File } = await import("../../src/file")
+          const { FileWatcher } = await import("../../src/file/watcher")
+
+          const events: string[] = []
+          const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
+          const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "",
+              newString: "content",
+            },
+            ctx,
+          )
+
+          expect(events).toContain("edited")
+          expect(events).toContain("updated")
+          unsubEdited()
+          unsubUpdated()
+        },
+      })
+    })
+  })
+
+  describe("editing existing files", () => {
+    test("replaces text in existing file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "existing.txt")
+      await fs.writeFile(filepath, "old content here", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          const result = await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "old content",
+              newString: "new content",
+            },
+            ctx,
+          )
+
+          expect(result.output).toContain("Edit applied successfully")
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("new content here")
+        },
+      })
+    })
+
+    test("throws error when file does not exist", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "nonexistent.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "old",
+                newString: "new",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("not found")
+        },
+      })
+    })
+
+    test("throws error when oldString equals newString", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "same",
+                newString: "same",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("identical")
+        },
+      })
+    })
+
+    test("throws error when oldString not found in file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "actual content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "not in file",
+                newString: "replacement",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow()
+        },
+      })
+    })
+
+    test("throws error when file was not read first (FileTime)", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "content",
+                newString: "modified",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("You must read file")
+        },
+      })
+    })
+
+    test("throws error when file has been modified since read", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "original content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          // Read first
+          FileTime.read(ctx.sessionID, filepath)
+
+          // Wait a bit to ensure different timestamps
+          await new Promise((resolve) => setTimeout(resolve, 100))
+
+          // Simulate external modification
+          await fs.writeFile(filepath, "modified externally", "utf-8")
+
+          // Try to edit with the new content
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "modified externally",
+                newString: "edited",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("modified since it was last read")
+        },
+      })
+    })
+
+    test("replaces all occurrences with replaceAll option", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "foo bar foo baz foo", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "foo",
+              newString: "qux",
+              replaceAll: true,
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("qux bar qux baz qux")
+        },
+      })
+    })
+
+    test("emits change event for existing files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "original", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const { Bus } = await import("../../src/bus")
+          const { File } = await import("../../src/file")
+          const { FileWatcher } = await import("../../src/file/watcher")
+
+          const events: string[] = []
+          const unsubEdited = Bus.subscribe(File.Event.Edited, () => events.push("edited"))
+          const unsubUpdated = Bus.subscribe(FileWatcher.Event.Updated, () => events.push("updated"))
+
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "original",
+              newString: "modified",
+            },
+            ctx,
+          )
+
+          expect(events).toContain("edited")
+          expect(events).toContain("updated")
+          unsubEdited()
+          unsubUpdated()
+        },
+      })
+    })
+  })
+
+  describe("edge cases", () => {
+    test("handles multiline replacements", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "line2",
+              newString: "new line 2\nextra line",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("line1\nnew line 2\nextra line\nline3")
+        },
+      })
+    })
+
+    test("handles CRLF line endings", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "line1\r\nold\r\nline3", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "old",
+              newString: "new",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("line1\r\nnew\r\nline3")
+        },
+      })
+    })
+
+    test("throws error when oldString equals newString", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: filepath,
+                oldString: "",
+                newString: "",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("identical")
+        },
+      })
+    })
+
+    test("throws error when path is directory", async () => {
+      await using tmp = await tmpdir()
+      const dirpath = path.join(tmp.path, "adir")
+      await fs.mkdir(dirpath)
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, dirpath)
+
+          const edit = await EditTool.init()
+          await expect(
+            edit.execute(
+              {
+                filePath: dirpath,
+                oldString: "old",
+                newString: "new",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow("directory")
+        },
+      })
+    })
+
+    test("tracks file diff statistics", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "line1\nline2\nline3", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+          const result = await edit.execute(
+            {
+              filePath: filepath,
+              oldString: "line2",
+              newString: "new line a\nnew line b",
+            },
+            ctx,
+          )
+
+          expect(result.metadata.filediff).toBeDefined()
+          expect(result.metadata.filediff.file).toBe(filepath)
+          expect(result.metadata.filediff.additions).toBeGreaterThan(0)
+        },
+      })
+    })
+  })
+
+  describe("concurrent editing", () => {
+    test("serializes concurrent edits to same file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "0", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          FileTime.read(ctx.sessionID, filepath)
+
+          const edit = await EditTool.init()
+
+          // Two concurrent edits
+          const promise1 = edit.execute(
+            {
+              filePath: filepath,
+              oldString: "0",
+              newString: "1",
+            },
+            ctx,
+          )
+
+          // Need to read again since FileTime tracks per-session
+          FileTime.read(ctx.sessionID, filepath)
+
+          const promise2 = edit.execute(
+            {
+              filePath: filepath,
+              oldString: "0",
+              newString: "2",
+            },
+            ctx,
+          )
+
+          // Both should complete without error (though one might fail due to content mismatch)
+          const results = await Promise.allSettled([promise1, promise2])
+          expect(results.some((r) => r.status === "fulfilled")).toBe(true)
+        },
+      })
+    })
+  })
+})

+ 341 - 0
packages/opencode/test/tool/write.test.ts

@@ -0,0 +1,341 @@
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
+import { WriteTool } from "../../src/tool/write"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+const ctx = {
+  sessionID: "test-write-session",
+  messageID: "",
+  callID: "",
+  agent: "build",
+  abort: AbortSignal.any([]),
+  messages: [],
+  metadata: () => {},
+  ask: async () => {},
+}
+
+describe("tool.write", () => {
+  describe("new file creation", () => {
+    test("writes content to new file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "newfile.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          const result = await write.execute(
+            {
+              filePath: filepath,
+              content: "Hello, World!",
+            },
+            ctx,
+          )
+
+          expect(result.output).toContain("Wrote file successfully")
+          expect(result.metadata.exists).toBe(false)
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("Hello, World!")
+        },
+      })
+    })
+
+    test("creates parent directories if needed", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content: "nested content",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("nested content")
+        },
+      })
+    })
+
+    test("handles relative paths by resolving to instance directory", async () => {
+      await using tmp = await tmpdir()
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: "relative.txt",
+              content: "relative content",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(path.join(tmp.path, "relative.txt"), "utf-8")
+          expect(content).toBe("relative content")
+        },
+      })
+    })
+  })
+
+  describe("existing file overwrite", () => {
+    test("overwrites existing file content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "existing.txt")
+      await fs.writeFile(filepath, "old content", "utf-8")
+
+      // First read the file to satisfy FileTime requirement
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const { FileTime } = await import("../../src/file/time")
+          FileTime.read(ctx.sessionID, filepath)
+
+          const write = await WriteTool.init()
+          const result = await write.execute(
+            {
+              filePath: filepath,
+              content: "new content",
+            },
+            ctx,
+          )
+
+          expect(result.output).toContain("Wrote file successfully")
+          expect(result.metadata.exists).toBe(true)
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("new content")
+        },
+      })
+    })
+
+    test("returns diff in metadata for existing files", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "file.txt")
+      await fs.writeFile(filepath, "old", "utf-8")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const { FileTime } = await import("../../src/file/time")
+          FileTime.read(ctx.sessionID, filepath)
+
+          const write = await WriteTool.init()
+          const result = await write.execute(
+            {
+              filePath: filepath,
+              content: "new",
+            },
+            ctx,
+          )
+
+          // Diff should be in metadata
+          expect(result.metadata).toHaveProperty("filepath", filepath)
+          expect(result.metadata).toHaveProperty("exists", true)
+        },
+      })
+    })
+  })
+
+  describe("file permissions", () => {
+    test("sets file permissions when writing sensitive data", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "sensitive.json")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content: JSON.stringify({ secret: "data" }),
+            },
+            ctx,
+          )
+
+          // On Unix systems, check permissions
+          if (process.platform !== "win32") {
+            const stats = await fs.stat(filepath)
+            expect(stats.mode & 0o777).toBe(0o644)
+          }
+        },
+      })
+    })
+  })
+
+  describe("content types", () => {
+    test("writes JSON content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "data.json")
+      const data = { key: "value", nested: { array: [1, 2, 3] } }
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content: JSON.stringify(data, null, 2),
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(JSON.parse(content)).toEqual(data)
+        },
+      })
+    })
+
+    test("writes binary-safe content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "binary.bin")
+      const content = "Hello\x00World\x01\x02\x03"
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content,
+            },
+            ctx,
+          )
+
+          const buf = await fs.readFile(filepath)
+          expect(buf.toString()).toBe(content)
+        },
+      })
+    })
+
+    test("writes empty content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "empty.txt")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content: "",
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe("")
+
+          const stats = await fs.stat(filepath)
+          expect(stats.size).toBe(0)
+        },
+      })
+    })
+
+    test("writes multi-line content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "multiline.txt")
+      const lines = ["Line 1", "Line 2", "Line 3", ""].join("\n")
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content: lines,
+            },
+            ctx,
+          )
+
+          const content = await fs.readFile(filepath, "utf-8")
+          expect(content).toBe(lines)
+        },
+      })
+    })
+
+    test("handles different line endings", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "crlf.txt")
+      const content = "Line 1\r\nLine 2\r\nLine 3"
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await write.execute(
+            {
+              filePath: filepath,
+              content,
+            },
+            ctx,
+          )
+
+          const buf = await fs.readFile(filepath)
+          expect(buf.toString()).toBe(content)
+        },
+      })
+    })
+  })
+
+  describe("error handling", () => {
+    test("throws error for paths outside project", async () => {
+      await using tmp = await tmpdir()
+      const outsidePath = "/etc/passwd"
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          await expect(
+            write.execute(
+              {
+                filePath: outsidePath,
+                content: "test",
+              },
+              ctx,
+            ),
+          ).rejects.toThrow()
+        },
+      })
+    })
+  })
+
+  describe("title generation", () => {
+    test("returns relative path as title", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "src", "components", "Button.tsx")
+      await fs.mkdir(path.dirname(filepath), { recursive: true })
+
+      await Instance.provide({
+        directory: tmp.path,
+        fn: async () => {
+          const write = await WriteTool.init()
+          const result = await write.execute(
+            {
+              filePath: filepath,
+              content: "export const Button = () => {}",
+            },
+            ctx,
+          )
+
+          expect(result.title).toEndWith(path.join("src", "components", "Button.tsx"))
+        },
+      })
+    })
+  })
+})

+ 274 - 25
packages/opencode/test/util/filesystem.test.ts

@@ -1,39 +1,288 @@
-import { describe, expect, test } from "bun:test"
-import os from "node:os"
-import path from "node:path"
-import { mkdtemp, mkdir, rm } from "node:fs/promises"
+import { describe, test, expect } from "bun:test"
+import path from "path"
+import fs from "fs/promises"
 import { Filesystem } from "../../src/util/filesystem"
+import { tmpdir } from "../fixture/fixture"
 
-describe("util.filesystem", () => {
-  test("exists() is true for files and directories", async () => {
-    const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
-    const dir = path.join(tmp, "dir")
-    const file = path.join(tmp, "file.txt")
-    const missing = path.join(tmp, "missing")
+describe("filesystem", () => {
+  describe("exists()", () => {
+    test("returns true for existing file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
 
-    await mkdir(dir, { recursive: true })
-    await Bun.write(file, "hello")
+      expect(await Filesystem.exists(filepath)).toBe(true)
+    })
 
-    const cases = await Promise.all([Filesystem.exists(dir), Filesystem.exists(file), Filesystem.exists(missing)])
+    test("returns false for non-existent file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist.txt")
 
-    expect(cases).toEqual([true, true, false])
+      expect(await Filesystem.exists(filepath)).toBe(false)
+    })
 
-    await rm(tmp, { recursive: true, force: true })
+    test("returns true for existing directory", async () => {
+      await using tmp = await tmpdir()
+      const dirpath = path.join(tmp.path, "subdir")
+      await fs.mkdir(dirpath)
+
+      expect(await Filesystem.exists(dirpath)).toBe(true)
+    })
+  })
+
+  describe("isDir()", () => {
+    test("returns true for directory", async () => {
+      await using tmp = await tmpdir()
+      const dirpath = path.join(tmp.path, "testdir")
+      await fs.mkdir(dirpath)
+
+      expect(await Filesystem.isDir(dirpath)).toBe(true)
+    })
+
+    test("returns false for file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      await fs.writeFile(filepath, "content", "utf-8")
+
+      expect(await Filesystem.isDir(filepath)).toBe(false)
+    })
+
+    test("returns false for non-existent path", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist")
+
+      expect(await Filesystem.isDir(filepath)).toBe(false)
+    })
+  })
+
+  describe("size()", () => {
+    test("returns file size", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      const content = "Hello, World!"
+      await fs.writeFile(filepath, content, "utf-8")
+
+      expect(await Filesystem.size(filepath)).toBe(content.length)
+    })
+
+    test("returns 0 for non-existent file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist.txt")
+
+      expect(await Filesystem.size(filepath)).toBe(0)
+    })
+
+    test("returns directory size", async () => {
+      await using tmp = await tmpdir()
+      const dirpath = path.join(tmp.path, "testdir")
+      await fs.mkdir(dirpath)
+
+      // Directories have size on some systems
+      const size = await Filesystem.size(dirpath)
+      expect(typeof size).toBe("number")
+    })
+  })
+
+  describe("readText()", () => {
+    test("reads file content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      const content = "Hello, World!"
+      await fs.writeFile(filepath, content, "utf-8")
+
+      expect(await Filesystem.readText(filepath)).toBe(content)
+    })
+
+    test("throws for non-existent file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist.txt")
+
+      await expect(Filesystem.readText(filepath)).rejects.toThrow()
+    })
+
+    test("reads UTF-8 content correctly", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "unicode.txt")
+      const content = "Hello 世界 🌍"
+      await fs.writeFile(filepath, content, "utf-8")
+
+      expect(await Filesystem.readText(filepath)).toBe(content)
+    })
+  })
+
+  describe("readJson()", () => {
+    test("reads and parses JSON", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.json")
+      const data = { key: "value", nested: { array: [1, 2, 3] } }
+      await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
+
+      const result: typeof data = await Filesystem.readJson(filepath)
+      expect(result).toEqual(data)
+    })
+
+    test("throws for invalid JSON", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "invalid.json")
+      await fs.writeFile(filepath, "{ invalid json", "utf-8")
+
+      await expect(Filesystem.readJson(filepath)).rejects.toThrow()
+    })
+
+    test("throws for non-existent file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist.json")
+
+      await expect(Filesystem.readJson(filepath)).rejects.toThrow()
+    })
+
+    test("returns typed data", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "typed.json")
+      interface Config {
+        name: string
+        version: number
+      }
+      const data: Config = { name: "test", version: 1 }
+      await fs.writeFile(filepath, JSON.stringify(data), "utf-8")
+
+      const result = await Filesystem.readJson<Config>(filepath)
+      expect(result.name).toBe("test")
+      expect(result.version).toBe(1)
+    })
+  })
+
+  describe("readBytes()", () => {
+    test("reads file as buffer", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      const content = "Hello, World!"
+      await fs.writeFile(filepath, content, "utf-8")
+
+      const buffer = await Filesystem.readBytes(filepath)
+      expect(buffer).toBeInstanceOf(Buffer)
+      expect(buffer.toString("utf-8")).toBe(content)
+    })
+
+    test("throws for non-existent file", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "does-not-exist.bin")
+
+      await expect(Filesystem.readBytes(filepath)).rejects.toThrow()
+    })
+  })
+
+  describe("write()", () => {
+    test("writes text content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.txt")
+      const content = "Hello, World!"
+
+      await Filesystem.write(filepath, content)
+
+      expect(await fs.readFile(filepath, "utf-8")).toBe(content)
+    })
+
+    test("writes buffer content", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "test.bin")
+      const content = Buffer.from([0x00, 0x01, 0x02, 0x03])
+
+      await Filesystem.write(filepath, content)
+
+      const read = await fs.readFile(filepath)
+      expect(read).toEqual(content)
+    })
+
+    test("writes with permissions", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "protected.txt")
+      const content = "secret"
+
+      await Filesystem.write(filepath, content, 0o600)
+
+      const stats = await fs.stat(filepath)
+      // Check permissions on Unix
+      if (process.platform !== "win32") {
+        expect(stats.mode & 0o777).toBe(0o600)
+      }
+    })
+
+    test("creates parent directories", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "nested", "deep", "file.txt")
+      const content = "nested content"
+
+      await Filesystem.write(filepath, content)
+
+      expect(await fs.readFile(filepath, "utf-8")).toBe(content)
+    })
   })
 
-  test("isDir() is true only for directories", async () => {
-    const tmp = await mkdtemp(path.join(os.tmpdir(), "opencode-filesystem-"))
-    const dir = path.join(tmp, "dir")
-    const file = path.join(tmp, "file.txt")
-    const missing = path.join(tmp, "missing")
+  describe("writeJson()", () => {
+    test("writes JSON data", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "data.json")
+      const data = { key: "value", number: 42 }
+
+      await Filesystem.writeJson(filepath, data)
+
+      const content = await fs.readFile(filepath, "utf-8")
+      expect(JSON.parse(content)).toEqual(data)
+    })
+
+    test("writes formatted JSON", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "pretty.json")
+      const data = { key: "value" }
+
+      await Filesystem.writeJson(filepath, data)
+
+      const content = await fs.readFile(filepath, "utf-8")
+      expect(content).toContain("\n")
+      expect(content).toContain("  ")
+    })
+
+    test("writes with permissions", async () => {
+      await using tmp = await tmpdir()
+      const filepath = path.join(tmp.path, "config.json")
+      const data = { secret: "data" }
+
+      await Filesystem.writeJson(filepath, data, 0o600)
+
+      const stats = await fs.stat(filepath)
+      if (process.platform !== "win32") {
+        expect(stats.mode & 0o777).toBe(0o600)
+      }
+    })
+  })
+
+  describe("mimeType()", () => {
+    test("returns correct MIME type for JSON", () => {
+      expect(Filesystem.mimeType("test.json")).toContain("application/json")
+    })
+
+    test("returns correct MIME type for JavaScript", () => {
+      expect(Filesystem.mimeType("test.js")).toContain("javascript")
+    })
 
-    await mkdir(dir, { recursive: true })
-    await Bun.write(file, "hello")
+    test("returns MIME type for TypeScript (or video/mp2t due to extension conflict)", () => {
+      const mime = Filesystem.mimeType("test.ts")
+      // .ts is ambiguous: TypeScript vs MPEG-2 TS video
+      expect(mime === "video/mp2t" || mime === "application/typescript" || mime === "text/typescript").toBe(true)
+    })
 
-    const cases = await Promise.all([Filesystem.isDir(dir), Filesystem.isDir(file), Filesystem.isDir(missing)])
+    test("returns correct MIME type for images", () => {
+      expect(Filesystem.mimeType("test.png")).toContain("image/png")
+      expect(Filesystem.mimeType("test.jpg")).toContain("image/jpeg")
+    })
 
-    expect(cases).toEqual([true, false, false])
+    test("returns default for unknown extension", () => {
+      expect(Filesystem.mimeType("test.unknown")).toBe("application/octet-stream")
+    })
 
-    await rm(tmp, { recursive: true, force: true })
+    test("handles files without extension", () => {
+      expect(Filesystem.mimeType("Makefile")).toBe("application/octet-stream")
+    })
   })
 })