Explorar o código

feat(core): add workspace environment foundation (#40967)

Kit Langton hai 2 días
pai
achega
3bb0d7fda0

+ 1 - 0
packages/core/package.json

@@ -17,6 +17,7 @@
     "opencode": "./bin/opencode"
   },
   "exports": {
+    "./environment": "./src/environment/index.ts",
     "./session/runner": "./src/session/runner/index.ts",
     "./instructions": "./src/instructions/index.ts",
     "./*": "./src/*.ts"

+ 9 - 0
packages/core/src/environment/driver.ts

@@ -0,0 +1,9 @@
+import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import type { FilesImpl } from "./files"
+
+export interface Driver {
+  readonly spawner: ChildProcessSpawner["Service"]
+  readonly overrides?: Partial<FilesImpl>
+}
+
+export * as EnvironmentDriver from "./driver"

+ 192 - 0
packages/core/src/environment/exec-defaults.ts

@@ -0,0 +1,192 @@
+import { Effect, Stream } from "effect"
+import { ChildProcess } from "effect/unstable/process"
+import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import { collectStream } from "@opencode-ai/util/process"
+import { Failed, NotFound, WrongKind, type FileInfo, type FileType, type FilesImpl } from "./files"
+
+/**
+ * Files derived from spawning processes: one process per intent, "$1" is
+ * always the target path. Scripts report classification through an exit-code
+ * protocol (44/45/46) so failures never require parsing localized error text;
+ * LC_ALL=C pins the one stderr match that remains. Requires GNU coreutils and
+ * findutils in the target image — BSD and busybox userlands will not work.
+ * Malformed output from these scripts is our own bug and dies as a defect.
+ */
+
+const MAX_DATA_BYTES = 64 * 1024 * 1024
+const MAX_ERROR_BYTES = 64 * 1024
+const NOT_FOUND = 44
+const WRONG_KIND = 45
+const FAILED = 46
+const TAB = "\t"
+
+const loadMetadata = (flags = "") => `
+metadata=$(stat ${flags} -c '%F${TAB}%s${TAB}%Y' -- "$1" 2>&1) || {
+  case "$metadata" in
+    *'No such file or directory'*|*'Not a directory'*) exit ${NOT_FOUND} ;;
+    *) printf '%s' "$metadata" >&2; exit ${FAILED} ;;
+  esac
+}
+`
+
+const statScript = `
+${loadMetadata()}
+printf '%s\n' "$metadata"
+`
+
+const readScript = `
+${loadMetadata("-L")}
+kind=\${metadata%%${TAB}*}
+if [ "$kind" != 'regular file' ] && [ "$kind" != 'regular empty file' ]; then
+  printf '%s' "$kind" >&2
+  exit ${WRONG_KIND}
+fi
+printf '%s\n' "$metadata"
+if [ "$2" = range ]; then
+  dd if="$1" iflag=skip_bytes,count_bytes skip="$3" count="$4" status=none
+else
+  cat -- "$1"
+fi
+`
+
+const listScript = `
+${loadMetadata()}
+kind=\${metadata%%${TAB}*}
+if [ "$kind" != directory ]; then
+  printf '%s' "$kind" >&2
+  exit ${WRONG_KIND}
+fi
+find "$1" -mindepth 1 -maxdepth 1 -printf '%y\\0%f\\0'
+`
+
+const moveScript = `
+${loadMetadata()}
+mv -- "$1" "$2"
+`
+
+interface Result {
+  readonly exitCode: number
+  readonly stdout: Uint8Array
+  readonly stderr: Uint8Array
+}
+
+export const execDefaults = (spawner: ChildProcessSpawner["Service"]): FilesImpl => {
+  const run = (
+    path: string,
+    script: string,
+    args: ReadonlyArray<string> = [],
+    stdin?: Uint8Array,
+  ): Effect.Effect<Result, Failed> =>
+    Effect.scoped(
+      Effect.gen(function* () {
+        const command = ChildProcess.make("sh", ["-c", script, "sh", path, ...args], {
+          env: { LC_ALL: "C" },
+          extendEnv: true,
+          stdin: stdin === undefined ? undefined : Stream.make(stdin),
+        })
+        const handle = yield* spawner.spawn(command).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
+        const [stdout, stderr, exitCode] = yield* Effect.all(
+          [
+            collectStream(handle.stdout, MAX_DATA_BYTES),
+            collectStream(handle.stderr, MAX_ERROR_BYTES),
+            handle.exitCode,
+          ],
+          { concurrency: "unbounded" },
+        ).pipe(Effect.mapError((cause) => new Failed({ path, cause })))
+        if (stdout.truncated || stderr.truncated) {
+          return yield* new Failed({ path, cause: new Error("Process output exceeded its collection limit") })
+        }
+        return { exitCode, stdout: stdout.buffer, stderr: stderr.buffer }
+      }),
+    )
+
+  const classify = <A>(
+    path: string,
+    result: Result,
+    success: (stdout: Uint8Array) => A,
+  ): Effect.Effect<A, NotFound | WrongKind | Failed> => {
+    if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
+    if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
+    if (result.exitCode === WRONG_KIND) {
+      return Effect.fail(new WrongKind({ path, actual: parseType(new TextDecoder().decode(result.stderr)) }))
+    }
+    return Effect.fail(processFailure(path, result))
+  }
+
+  const complete = (path: string, result: Result) =>
+    result.exitCode === 0 ? Effect.void : Effect.fail(processFailure(path, result))
+
+  return {
+    stat: (path) => run(path, statScript).pipe(Effect.flatMap((result) => classifyPlain(path, result, parseInfo))),
+    read: (path, range) =>
+      run(
+        path,
+        readScript,
+        range === undefined ? ["whole"] : ["range", String(range.offset), String(range.length)],
+      ).pipe(
+        Effect.flatMap((result) =>
+          classify(path, result, (stdout) => {
+            const newline = stdout.indexOf(10)
+            if (newline < 0) throw new Error("Missing read metadata header")
+            return {
+              info: parseInfo(stdout.slice(0, newline)),
+              bytes: stdout.slice(newline + 1),
+            }
+          }),
+        ),
+      ),
+    write: (path, bytes) =>
+      run(path, `mkdir -p "$(dirname "$1")" && cat > "$1"`, [], bytes).pipe(
+        Effect.flatMap((result) => complete(path, result)),
+      ),
+    list: (path) => run(path, listScript).pipe(Effect.flatMap((result) => classify(path, result, parseList))),
+    remove: (path) => run(path, `rm -rf -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
+    move: (from, to) =>
+      run(from, moveScript, [to]).pipe(Effect.flatMap((result) => classifyPlain(from, result, () => undefined))),
+    mkdir: (path) => run(path, `mkdir -p -- "$1"`).pipe(Effect.flatMap((result) => complete(path, result))),
+  }
+}
+
+/** `classify` for scripts whose protocol never reports WrongKind. */
+const classifyPlain = <A>(
+  path: string,
+  result: Result,
+  success: (stdout: Uint8Array) => A,
+): Effect.Effect<A, NotFound | Failed> => {
+  if (result.exitCode === 0) return Effect.sync(() => success(result.stdout))
+  if (result.exitCode === NOT_FOUND) return Effect.fail(new NotFound({ path }))
+  return Effect.fail(processFailure(path, result))
+}
+
+const processFailure = (path: string, result: Result) =>
+  new Failed({
+    path,
+    cause: new Error(new TextDecoder().decode(result.stderr).trim() || `Process exited with code ${result.exitCode}`),
+  })
+
+const parseInfo = (bytes: Uint8Array): FileInfo => {
+  const [rawType, rawSize, rawMtime] = new TextDecoder().decode(bytes).trim().split(TAB)
+  const size = Number(rawSize)
+  const mtimeMs = Number(rawMtime) * 1_000
+  if (!rawType || !Number.isFinite(size) || !Number.isFinite(mtimeMs)) throw new Error("Invalid stat output")
+  return { type: parseType(rawType), size, mtimeMs }
+}
+
+const parseType = (value: string): FileType => {
+  if (value === "regular file" || value === "regular empty file" || value === "f") return "file"
+  if (value === "directory" || value === "d") return "directory"
+  if (value === "symbolic link" || value === "l") return "symlink"
+  return "other"
+}
+
+const parseList = (bytes: Uint8Array) => {
+  const fields = new TextDecoder().decode(bytes).split("\0")
+  fields.pop()
+  if (fields.length % 2 !== 0) throw new Error("Invalid find output")
+  return Array.from({ length: fields.length / 2 }, (_, index) => ({
+    name: fields[index * 2 + 1],
+    type: parseType(fields[index * 2]),
+  }))
+}
+
+export * as EnvironmentExecDefaults from "./exec-defaults"

+ 53 - 0
packages/core/src/environment/files.ts

@@ -0,0 +1,53 @@
+import { Effect, Schema } from "effect"
+
+export const FileType = Schema.Literals(["file", "directory", "symlink", "other"])
+export type FileType = typeof FileType.Type
+
+export interface FileInfo {
+  readonly type: FileType
+  readonly size: number
+  readonly mtimeMs: number
+}
+
+export interface DirEntry {
+  readonly name: string
+  readonly type: FileType
+}
+
+export class NotFound extends Schema.TaggedErrorClass<NotFound>()("Environment.NotFound", {
+  path: Schema.String,
+}) {}
+
+export class WrongKind extends Schema.TaggedErrorClass<WrongKind>()("Environment.WrongKind", {
+  path: Schema.String,
+  actual: FileType,
+}) {}
+
+export class Failed extends Schema.TaggedErrorClass<Failed>()("Environment.Failed", {
+  path: Schema.String,
+  cause: Schema.Defect(),
+}) {}
+
+export interface FilesImpl {
+  /**
+   * Reads a file, following a final symlink so `info` describes the target whose bytes are returned.
+   * The process-backed default caps collected output at 64 MiB; larger whole-file reads fail with
+   * `Failed`, so callers must use ranges for larger files.
+   */
+  readonly read: (
+    path: string,
+    range?: { readonly offset: number; readonly length: number },
+  ) => Effect.Effect<{ readonly info: FileInfo; readonly bytes: Uint8Array }, NotFound | WrongKind | Failed>
+  readonly write: (path: string, bytes: Uint8Array) => Effect.Effect<void, Failed>
+  /** Describes the path entry itself, so a final symlink is reported as `symlink` rather than followed. */
+  readonly stat: (path: string) => Effect.Effect<FileInfo, NotFound | Failed>
+  /** Lists a directory entry without following a final symlink; intermediate symlinks are traversed. */
+  readonly list: (path: string) => Effect.Effect<ReadonlyArray<DirEntry>, NotFound | WrongKind | Failed>
+  readonly remove: (path: string) => Effect.Effect<void, Failed>
+  readonly move: (from: string, to: string) => Effect.Effect<void, NotFound | Failed>
+  readonly mkdir: (path: string) => Effect.Effect<void, Failed>
+}
+
+export interface Files extends FilesImpl {}
+
+export * as EnvironmentFiles from "./files"

+ 24 - 0
packages/core/src/environment/index.ts

@@ -0,0 +1,24 @@
+export * as Environment from "./index"
+
+export { type Driver } from "./driver"
+export {
+  type DirEntry,
+  Failed,
+  type FileInfo,
+  type Files,
+  type FilesImpl,
+  type FileType,
+  NotFound,
+  WrongKind,
+} from "./files"
+export { execDefaults } from "./exec-defaults"
+export { makeMemoryDriver, type MemoryDriver } from "./memory"
+
+import type { Driver } from "./driver"
+import { execDefaults } from "./exec-defaults"
+import type { Files } from "./files"
+
+export const makeFiles = (driver: Driver): Files => ({
+  ...execDefaults(driver.spawner),
+  ...driver.overrides,
+})

+ 168 - 0
packages/core/src/environment/memory.ts

@@ -0,0 +1,168 @@
+import path from "node:path"
+import { Effect, PlatformError } from "effect"
+import { make } from "effect/unstable/process/ChildProcessSpawner"
+import type { Driver } from "./driver"
+import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
+
+type Node =
+  | { readonly type: "file"; readonly bytes: Uint8Array; readonly mtimeMs: number }
+  | { readonly type: "directory"; readonly mtimeMs: number }
+  | { readonly type: "symlink"; readonly target: string; readonly mtimeMs: number }
+
+export interface MemoryDriver extends Driver {
+  readonly symlink: (target: string, path: string) => Effect.Effect<void, Failed>
+}
+
+export const makeMemoryDriver = (): MemoryDriver => {
+  const nodes = new Map<string, Node>([["/", { type: "directory", mtimeMs: Date.now() }]])
+  const key = (value: string) => path.posix.resolve("/", value)
+  const info = (node: Node): FileInfo => ({
+    type: node.type,
+    size:
+      node.type === "file"
+        ? node.bytes.length
+        : node.type === "symlink"
+          ? new TextEncoder().encode(node.target).length
+          : 0,
+    mtimeMs: node.mtimeMs,
+  })
+  const resolveKey = (value: string, followFinal: boolean, seen = new Set<string>()): string | undefined => {
+    const normalized = key(value)
+    const parts = normalized.split("/").filter(Boolean)
+    const base = "/"
+    const walk = (current: string, index: number): string | undefined => {
+      if (index === parts.length) return current
+      const part = parts[index]
+      const candidate = path.posix.join(current, part)
+      const node = nodes.get(candidate)
+      if (node?.type !== "symlink" || (!followFinal && index === parts.length - 1)) return walk(candidate, index + 1)
+      if (seen.has(candidate)) return undefined
+      seen.add(candidate)
+      const target = path.posix.resolve(path.posix.dirname(candidate), node.target)
+      return resolveKey(path.posix.join(target, ...parts.slice(index + 1)), followFinal, seen)
+    }
+    return walk(base, 0)
+  }
+  const lookup = (value: string) => nodes.get(resolveKey(value, false) ?? key(value))
+  const requireParent = (value: string) => {
+    const parentPath = path.posix.dirname(key(value))
+    const parent = nodes.get(resolveKey(parentPath, true) ?? parentPath)
+    if (!parent) throw new Error(`Parent directory does not exist: ${path.posix.dirname(value)}`)
+    if (parent.type !== "directory") throw new Error(`Parent is not a directory: ${path.posix.dirname(value)}`)
+  }
+  const mkdirSync = (value: string) => {
+    const target = resolveKey(value, false) ?? key(value)
+    const existing = nodes.get(target)
+    if (existing?.type === "directory") return
+    if (existing) throw new Error(`Path is not a directory: ${value}`)
+    const parent = path.posix.dirname(target)
+    if (parent !== target) mkdirSync(parent)
+    nodes.set(target, { type: "directory", mtimeMs: Date.now() })
+  }
+  const failed = (value: string, cause: unknown) => new Failed({ path: value, cause })
+  const overrides: FilesImpl = {
+    stat: (value) => {
+      const node = lookup(value)
+      return node ? Effect.succeed(info(node)) : Effect.fail(new NotFound({ path: value }))
+    },
+    read: (value, range) => {
+      const original = lookup(value)
+      if (!original) return Effect.fail(new NotFound({ path: value }))
+      if (original.type === "directory") return Effect.fail(new WrongKind({ path: value, actual: "directory" }))
+      const resolved = resolveKey(value, true)
+      const node = resolved === undefined ? undefined : nodes.get(resolved)
+      if (!node) return Effect.fail(new NotFound({ path: value }))
+      if (node.type !== "file") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
+      const bytes = range === undefined ? node.bytes : node.bytes.subarray(range.offset, range.offset + range.length)
+      return Effect.succeed({ info: info(node), bytes: bytes.slice() })
+    },
+    write: (value, bytes) =>
+      Effect.try({
+        try: () => {
+          mkdirSync(path.posix.dirname(key(value)))
+          const existing = lookup(value)
+          if (existing?.type === "directory") throw new Error(`Path is a directory: ${value}`)
+          const target = existing?.type === "symlink" ? resolveKey(value, true) : resolveKey(value, false)
+          if (!target) throw new Error(`Cannot resolve symlink: ${value}`)
+          requireParent(target)
+          nodes.set(target, { type: "file", bytes: bytes.slice(), mtimeMs: Date.now() })
+        },
+        catch: (cause) => failed(value, cause),
+      }),
+    list: (value) => {
+      const target = resolveKey(value, false) ?? key(value)
+      const node = nodes.get(target)
+      if (!node) return Effect.fail(new NotFound({ path: value }))
+      if (node.type !== "directory") return Effect.fail(new WrongKind({ path: value, actual: node.type }))
+      const entries = [...nodes.entries()]
+        .filter(([entry]) => entry !== target && path.posix.dirname(entry) === target)
+        .map(([entry, child]) => ({ name: path.posix.basename(entry), type: child.type satisfies FileType }))
+        .sort((a, b) => a.name.localeCompare(b.name))
+      return Effect.succeed(entries)
+    },
+    remove: (value) =>
+      Effect.sync(() => {
+        const target = resolveKey(value, false) ?? key(value)
+        for (const entry of nodes.keys()) {
+          if (entry === target || entry.startsWith(`${target}/`)) nodes.delete(entry)
+        }
+      }),
+    move: (from, to) => {
+      const source = resolveKey(from, false) ?? key(from)
+      const node = nodes.get(source)
+      if (!node) return Effect.fail(new NotFound({ path: from }))
+      return Effect.try({
+        try: () => {
+          const requested = resolveKey(to, false) ?? key(to)
+          const destination =
+            nodes.get(requested)?.type === "directory"
+              ? path.posix.join(requested, path.posix.basename(source))
+              : requested
+          if (node.type === "directory" && destination.startsWith(`${source}/`)) {
+            throw new Error(`Cannot move a directory into itself: ${from}`)
+          }
+          const existing = nodes.get(destination)
+          if (node.type === "directory" && existing && existing.type !== "directory") {
+            throw new Error(`Cannot overwrite a non-directory with a directory: ${to}`)
+          }
+          requireParent(destination)
+          const moved = [...nodes.entries()].filter(([entry]) => entry === source || entry.startsWith(`${source}/`))
+          for (const [entry] of moved) nodes.delete(entry)
+          for (const [entry, child] of moved) nodes.set(`${destination}${entry.slice(source.length)}`, child)
+        },
+        catch: (cause) => failed(from, cause),
+      })
+    },
+    mkdir: (value) => Effect.try({ try: () => mkdirSync(value), catch: (cause) => failed(value, cause) }),
+  }
+
+  const spawner = make((command) =>
+    Effect.suspend(() => {
+      const description = command._tag === "StandardCommand" ? command.command : "pipeline"
+      return Effect.fail(
+        PlatformError.systemError({
+          _tag: "Unknown",
+          module: "EnvironmentMemory",
+          method: "spawn",
+          pathOrDescriptor: description,
+          cause: failed(description, new Error("The memory driver cannot spawn processes")),
+        }),
+      )
+    }),
+  )
+
+  return {
+    spawner,
+    overrides,
+    symlink: (target, value) =>
+      Effect.try({
+        try: () => {
+          requireParent(value)
+          nodes.set(resolveKey(value, false) ?? key(value), { type: "symlink", target, mtimeMs: Date.now() })
+        },
+        catch: (cause) => failed(value, cause),
+      }),
+  }
+}
+
+export * as EnvironmentMemory from "./memory"

+ 39 - 0
packages/core/test/environment.test.ts

@@ -0,0 +1,39 @@
+import fs from "node:fs/promises"
+import { Effect } from "effect"
+import { ChildProcessSpawner } from "effect/unstable/process"
+import { CrossSpawnSpawner } from "@opencode-ai/util/cross-spawn-spawner"
+import { LayerNode } from "@opencode-ai/util/effect/layer-node"
+import { execDefaults, Failed, makeFiles, makeMemoryDriver } from "../src/environment/index"
+import { tmpdir } from "./fixture/tmpdir"
+import { environmentConformance } from "./lib/environment-conformance"
+
+environmentConformance("memory environment", () =>
+  Effect.sync(() => {
+    const driver = makeMemoryDriver()
+    return {
+      files: makeFiles(driver),
+      root: `/workspace-${crypto.randomUUID()}`,
+      symlink: driver.symlink,
+    }
+  }),
+)
+
+environmentConformance(
+  "GNU exec environment",
+  () =>
+    Effect.gen(function* () {
+      const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+      const tmp = yield* Effect.promise(() => tmpdir("opencode-environment-"))
+      return {
+        files: execDefaults(spawner),
+        root: tmp.path,
+        symlink: (target: string, link: string) =>
+          Effect.tryPromise({
+            try: () => fs.symlink(target, link),
+            catch: (cause) => new Failed({ path: link, cause }),
+          }),
+        dispose: Effect.promise(() => tmp[Symbol.asyncDispose]()),
+      }
+    }).pipe(Effect.provide(LayerNode.compile(CrossSpawnSpawner.node))),
+  process.platform !== "linux",
+)

+ 159 - 0
packages/core/test/lib/environment-conformance.ts

@@ -0,0 +1,159 @@
+import { describe, expect } from "bun:test"
+import { Effect } from "effect"
+import { Failed, NotFound, WrongKind, type Files } from "../../src/environment/index"
+import { it } from "./effect"
+
+export interface EnvironmentHarness {
+  readonly files: Files
+  readonly root: string
+  readonly symlink?: (target: string, path: string) => Effect.Effect<void, Failed>
+  readonly dispose?: Effect.Effect<void>
+}
+
+export const environmentConformance = <E>(
+  name: string,
+  makeHarness: () => Effect.Effect<EnvironmentHarness, E>,
+  skip = false,
+) => {
+  const check = <A, E2>(title: string, body: (harness: EnvironmentHarness) => Effect.Effect<A, E2>) =>
+    it.live(title, () =>
+      Effect.gen(function* () {
+        const harness = yield* Effect.acquireRelease(makeHarness(), (harness) =>
+          Effect.gen(function* () {
+            yield* Effect.ignore(harness.files.remove(harness.root))
+            if (harness.dispose) yield* harness.dispose
+          }),
+        )
+        yield* harness.files.mkdir(harness.root)
+        return yield* body(harness)
+      }),
+    )
+
+  const bytes = (value: string) => new TextEncoder().encode(value)
+  const text = (value: Uint8Array) => new TextDecoder().decode(value)
+  const suite = skip ? describe.skip : describe
+
+  suite(name, () => {
+    check("writes, stats, and reads a file with its info", (harness) =>
+      Effect.gen(function* () {
+        const target = `${harness.root}/hello.txt`
+        yield* harness.files.write(target, bytes("hello"))
+        const result = yield* harness.files.read(target)
+        expect(text(result.bytes)).toBe("hello")
+        expect(result.info.type).toBe("file")
+        expect(result.info.size).toBe(5)
+        expect(yield* harness.files.stat(target)).toEqual(result.info)
+      }),
+    )
+
+    check("reports missing paths", (harness) =>
+      Effect.gen(function* () {
+        const target = `${harness.root}/missing`
+        expect(yield* Effect.flip(harness.files.read(target))).toBeInstanceOf(NotFound)
+        expect(yield* Effect.flip(harness.files.stat(target))).toBeInstanceOf(NotFound)
+        expect(yield* Effect.flip(harness.files.list(target))).toBeInstanceOf(NotFound)
+        expect(yield* Effect.flip(harness.files.move(target, `${harness.root}/other`))).toBeInstanceOf(NotFound)
+      }),
+    )
+
+    check("reports the actual kind", (harness) =>
+      Effect.gen(function* () {
+        const directory = `${harness.root}/directory`
+        const file = `${harness.root}/file`
+        yield* harness.files.mkdir(directory)
+        yield* harness.files.write(file, bytes("data"))
+        const readError = yield* Effect.flip(harness.files.read(directory))
+        const listError = yield* Effect.flip(harness.files.list(file))
+        expect(readError).toBeInstanceOf(WrongKind)
+        expect((readError as WrongKind).actual).toBe("directory")
+        expect(listError).toBeInstanceOf(WrongKind)
+        expect((listError as WrongKind).actual).toBe("file")
+      }),
+    )
+
+    check("write creates parent directories", (harness) =>
+      Effect.gen(function* () {
+        const target = `${harness.root}/one/two/file`
+        yield* harness.files.write(target, bytes("nested"))
+        yield* harness.files.write(`${harness.root}/empty`, new Uint8Array())
+        expect((yield* harness.files.stat(`${harness.root}/one/two`)).type).toBe("directory")
+        expect(yield* harness.files.stat(`${harness.root}/empty`)).toMatchObject({ type: "file", size: 0 })
+        expect(text((yield* harness.files.read(target)).bytes)).toBe("nested")
+      }),
+    )
+
+    check("reads byte ranges", (harness) =>
+      Effect.gen(function* () {
+        const target = `${harness.root}/range`
+        yield* harness.files.write(target, bytes("0123456789"))
+        expect(text((yield* harness.files.read(target, { offset: 2, length: 4 })).bytes)).toBe("2345")
+        expect(text((yield* harness.files.read(target, { offset: 8, length: 8 })).bytes)).toBe("89")
+        expect(text((yield* harness.files.read(target, { offset: 20, length: 4 })).bytes)).toBe("")
+      }),
+    )
+
+    check("lists immediate entries with their kinds", (harness) =>
+      Effect.gen(function* () {
+        yield* harness.files.write(`${harness.root}/file name`, bytes("data"))
+        yield* harness.files.mkdir(`${harness.root}/directory`)
+        yield* harness.files.write(`${harness.root}/directory/nested`, bytes("nested"))
+        const entries = yield* harness.files.list(harness.root)
+        expect(entries.toSorted((a, b) => a.name.localeCompare(b.name))).toEqual([
+          { name: "directory", type: "directory" },
+          { name: "file name", type: "file" },
+        ])
+      }),
+    )
+
+    check("reports symlinks without resolving them", (harness) =>
+      Effect.gen(function* () {
+        if (!harness.symlink) return
+        yield* harness.files.write(`${harness.root}/target`, bytes("target"))
+        yield* harness.files.write(`${harness.root}/target-dir/file`, bytes("through link"))
+        yield* harness.symlink("target", `${harness.root}/link`)
+        yield* harness.symlink("target-dir", `${harness.root}/link-dir`)
+        expect((yield* harness.files.stat(`${harness.root}/link`)).type).toBe("symlink")
+        expect(yield* harness.files.list(harness.root)).toContainEqual({ name: "link", type: "symlink" })
+        expect(text((yield* harness.files.read(`${harness.root}/link-dir/file`)).bytes)).toBe("through link")
+        const listError = yield* Effect.flip(harness.files.list(`${harness.root}/link-dir`))
+        expect(listError).toBeInstanceOf(WrongKind)
+        expect((listError as WrongKind).actual).toBe("symlink")
+      }),
+    )
+
+    check("follows symlinks when reading", (harness) =>
+      Effect.gen(function* () {
+        if (!harness.symlink) return
+        yield* harness.files.write(`${harness.root}/target`, bytes("target content"))
+        yield* harness.files.mkdir(`${harness.root}/directory`)
+        yield* harness.symlink("target", `${harness.root}/file-link`)
+        yield* harness.symlink("directory", `${harness.root}/directory-link`)
+        yield* harness.symlink("missing", `${harness.root}/dangling-link`)
+
+        const result = yield* harness.files.read(`${harness.root}/file-link`)
+        expect(text(result.bytes)).toBe("target content")
+        expect(result.info.type).toBe("file")
+        expect(result.info.size).toBe(bytes("target content").length)
+
+        const directoryError = yield* Effect.flip(harness.files.read(`${harness.root}/directory-link`))
+        expect(directoryError).toBeInstanceOf(WrongKind)
+        expect((directoryError as WrongKind).actual).toBe("directory")
+        expect(yield* Effect.flip(harness.files.read(`${harness.root}/dangling-link`))).toBeInstanceOf(NotFound)
+      }),
+    )
+
+    check("moves files and removes trees idempotently", (harness) =>
+      Effect.gen(function* () {
+        const source = `${harness.root}/source/file`
+        const destination = `${harness.root}/destination`
+        yield* harness.files.write(source, bytes("moved"))
+        yield* harness.files.move(source, destination)
+        expect(text((yield* harness.files.read(destination)).bytes)).toBe("moved")
+        expect(yield* Effect.flip(harness.files.stat(source))).toBeInstanceOf(NotFound)
+        yield* harness.files.remove(`${harness.root}/source`)
+        yield* harness.files.remove(`${harness.root}/source`)
+        expect(yield* Effect.flip(harness.files.stat(`${harness.root}/source`))).toBeInstanceOf(NotFound)
+      }),
+    )
+  })
+}