From c9cbd2b1f4db409275564e56ddbbbe03263c2f2a Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 7 Aug 2026 10:53:35 -0400 Subject: [PATCH] feat(core): add local environment driver (#41076) --- packages/core/src/environment/index.ts | 1 + packages/core/src/environment/local.ts | 103 +++++++++++++++++++++++++ packages/core/test/environment.test.ts | 23 +++++- 3 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 packages/core/src/environment/local.ts diff --git a/packages/core/src/environment/index.ts b/packages/core/src/environment/index.ts index 08463dbc729..5868796a19d 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 00000000000..c78b39942de --- /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 0803cb5528e..bc7c9960f5b 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", () =>