diff --git a/packages/core/src/environment/index.ts b/packages/core/src/environment/index.ts
index 08463dbc72..5868796a19 100644
--- a/packages/core/src/environment/index.ts
+++ b/packages/core/src/environment/index.ts
@@ -12,6 +12,7 @@ export {
WrongKind,
} from "./files"
export { execDefaults } from "./exec-defaults"
+export { makeLocalDriver } from "./local"
export { makeMemoryDriver, type MemoryDriver } from "./memory"
import type { Driver } from "./driver"
diff --git a/packages/core/src/environment/local.ts b/packages/core/src/environment/local.ts
new file mode 100644
index 0000000000..c78b39942d
--- /dev/null
+++ b/packages/core/src/environment/local.ts
@@ -0,0 +1,103 @@
+import fs from "node:fs/promises"
+import path from "node:path"
+import { Effect } from "effect"
+import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner"
+import type { Driver } from "./driver"
+import { Failed, NotFound, WrongKind, type FileInfo, type FilesImpl, type FileType } from "./files"
+
+/**
+ * The host filesystem binding. Deliberately raw node:fs rather than effect's
+ * FileSystem service or FSUtil: the contract needs lstat semantics (stat
+ * reports "symlink") and typed directory entries, and effect's node
+ * FileSystem provides neither — its stat always follows symlinks and
+ * readDirectory returns names only. FSUtil hits the same gap and its
+ * readDirectoryEntries already bypasses to raw node readdir internally.
+ * Nothing above the environment seam touches node:fs.
+ */
+export const makeLocalDriver = (spawner: ChildProcessSpawner["Service"]): Driver => {
+ const overrides: FilesImpl = {
+ read: (value, range) =>
+ Effect.gen(function* () {
+ const info = yield* stat(value, true)
+ if (info.type !== "file") return yield* new WrongKind({ path: value, actual: info.type })
+ if (range === undefined) {
+ const bytes = yield* attempt(value, () => fs.readFile(value), true)
+ return { info, bytes }
+ }
+ const bytes = yield* attempt(
+ value,
+ async () => {
+ const handle = await fs.open(value, "r")
+ try {
+ const buffer = new Uint8Array(range.length)
+ const result = await handle.read(buffer, 0, range.length, range.offset)
+ return buffer.subarray(0, result.bytesRead)
+ } finally {
+ await handle.close()
+ }
+ },
+ true,
+ )
+ return { info, bytes }
+ }),
+ stat: (value) => stat(value, false),
+ list: (value) =>
+ Effect.gen(function* () {
+ const info = yield* stat(value, false)
+ if (info.type !== "directory") return yield* new WrongKind({ path: value, actual: info.type })
+ const entries = yield* attempt(value, () => fs.readdir(value, { withFileTypes: true }), true)
+ return entries.map((entry) => ({ name: entry.name, type: fileType(entry) }))
+ }),
+ write: (value, bytes) =>
+ attempt(value, async () => {
+ await fs.mkdir(path.dirname(value), { recursive: true })
+ await fs.writeFile(value, bytes)
+ }),
+ remove: (value) => attempt(value, () => fs.rm(value, { recursive: true, force: true })),
+ move: (from, to) =>
+ Effect.gen(function* () {
+ yield* stat(from, false)
+ const destination = yield* stat(to, false).pipe(
+ Effect.map((info) => (info.type === "directory" ? path.join(to, path.basename(from)) : to)),
+ Effect.catchIf(
+ (error) => error instanceof NotFound,
+ () => Effect.succeed(to),
+ ),
+ )
+ yield* attempt(from, () => fs.rename(from, destination))
+ }),
+ mkdir: (value) => attempt(value, () => fs.mkdir(value, { recursive: true }).then(() => undefined)),
+ }
+
+ return { spawner, overrides }
+}
+
+const stat = (value: string, follow: boolean) =>
+ attempt(value, () => (follow ? fs.stat(value) : fs.lstat(value)), true).pipe(
+ Effect.map((stats): FileInfo => ({ type: fileType(stats), size: stats.size, mtimeMs: stats.mtimeMs })),
+ )
+
+const fileType = (entry: { isFile(): boolean; isDirectory(): boolean; isSymbolicLink(): boolean }): FileType => {
+ if (entry.isFile()) return "file"
+ if (entry.isDirectory()) return "directory"
+ if (entry.isSymbolicLink()) return "symlink"
+ return "other"
+}
+
+function attempt(value: string, run: () => Promise): Effect.Effect
+function attempt(value: string, run: () => Promise, missing: true): Effect.Effect
+function attempt(value: string, run: () => Promise, missing = false) {
+ return Effect.tryPromise({
+ try: run,
+ catch: (cause) =>
+ missing && isMissing(cause) ? new NotFound({ path: value }) : new Failed({ path: value, cause }),
+ })
+}
+
+const isMissing = (cause: unknown) =>
+ cause !== null &&
+ typeof cause === "object" &&
+ "code" in cause &&
+ (cause.code === "ENOENT" || cause.code === "ENOTDIR")
+
+export * as EnvironmentLocal from "./local"
diff --git a/packages/core/test/environment.test.ts b/packages/core/test/environment.test.ts
index 0803cb5528..bc7c9960f5 100644
--- a/packages/core/test/environment.test.ts
+++ b/packages/core/test/environment.test.ts
@@ -3,7 +3,7 @@ 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 { execDefaults, Failed, makeFiles, makeLocalDriver, makeMemoryDriver } from "../src/environment/index"
import { tmpdir } from "./fixture/tmpdir"
import { environmentConformance } from "./lib/environment-conformance"
@@ -18,6 +18,27 @@ environmentConformance("memory environment", () =>
}),
)
+environmentConformance("local environment", () =>
+ Effect.gen(function* () {
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner
+ const tmp = yield* Effect.promise(() => tmpdir("opencode-local-environment-"))
+ return {
+ files: makeFiles(makeLocalDriver(spawner)),
+ root: tmp.path,
+ ...(process.platform === "win32"
+ ? {}
+ : {
+ 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))),
+)
+
environmentConformance(
"GNU exec environment",
() =>