export * as FileSystem from "./filesystem.js" import { makeLocationNode } from "@opencode/util/effect/app-node" import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "@opencode/util/fs-util" import { Location } from "./location.js" import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js" import { FileSystemSearch } from "./filesystem/search.js" import { Entry, FileSystem, FindInput, Write } from "@opencode/schema/filesystem" export { Entry, Match, Submatch } from "@opencode/schema/filesystem" export const ReadInput = Schema.Struct({ path: RelativePath, }) export type ReadInput = typeof ReadInput.Type export const WriteInput = Schema.Struct({ /** Absolute, or relative to the location directory. */ path: Schema.String, data: Schema.Uint8Array, }) export type WriteInput = typeof WriteInput.Type export class NotFoundError extends Schema.TaggedError()("FileSystem.NotFoundError", { path: RelativePath, }) {} export const Content = Schema.Struct({ uri: Schema.String, name: Schema.String.pipe(Schema.optional), content: Schema.String, encoding: Schema.Literals(["utf8", "base64"]), mime: Schema.String, }).annotate({ identifier: "FileSystem.Content" }) export type Content = typeof Content.Type export const ListInput = Schema.Struct({ path: Schema.String.pipe(Schema.optional), }) export type ListInput = typeof ListInput.Type export { FindInput, Write } export const DEFAULT_SEARCH_LIMIT = 100 export const DEFAULT_SEARCH_TIMEOUT_MS = 30_000 export class GlobInput extends Schema.Class("FileSystem.GlobInput")({ pattern: Schema.String, path: Schema.optionalKey(RelativePath), hidden: Schema.optionalKey(Schema.Boolean), limit: Schema.optionalKey(PositiveInt), }) {} export class GrepInput extends Schema.Class("FileSystem.GrepInput")({ pattern: Schema.String, path: Schema.optionalKey(RelativePath), include: Schema.optionalKey(Schema.String), literal: Schema.optionalKey(Schema.Boolean), caseSensitive: Schema.optionalKey(Schema.Boolean), limit: Schema.optionalKey(PositiveInt), }) {} export const Event = FileSystem.Event export interface Interface { readonly read: ( input: ReadInput, ) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError> readonly list: (input?: ListInput) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect /** Writes a file at an absolute path or one relative to the location; not confined to it. */ readonly write: (input: WriteInput) => Effect.Effect } export class Service extends Context.Service()("@opencode/FileSystem") {} const baseLayer = Layer.effect( Service, Effect.gen(function* () { const fs = yield* FSUtil.Service const location = yield* Location.Service const search = yield* FileSystemSearch.Service // Workspace-placed directories exist only inside the workspace, so a host // realpath probe at boot consults the wrong filesystem and would block // construction on servers without a matching local directory. Treat the // configured directory as canonical; local placements keep symlink // canonicalization. This skip is boot-only: resolve/read/list below still // access the host filesystem per operation (tracked in #44568). const root = location.workspaceID ? location.directory : yield* fs.realPath(location.directory).pipe(Effect.orDie) const resolve = Effect.fnUntraced(function* (input?: RelativePath) { const absolute = path.resolve(location.directory, input ?? ".") if (!FSUtil.contains(location.directory, absolute)) return yield* Effect.die(new Error("Path escapes the location")) const real = yield* fs.realPath(absolute) if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) return { absolute, real, directory: location.directory } }) return Service.of({ find: search.find, read: Effect.fn("FileSystem.read")(function* (input) { const target = yield* resolve(input.path).pipe( Effect.catchReason( "PlatformError", "NotFound", () => Effect.fail(new NotFoundError({ path: input.path })), (_, error) => Effect.die(error), ), ) const info = yield* fs.stat(target.real).pipe( Effect.catchReason( "PlatformError", "NotFound", () => Effect.fail(new NotFoundError({ path: input.path })), (_, error) => Effect.die(error), ), ) if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) return { content: yield* fs.readFile(target.real).pipe( Effect.catchReason( "PlatformError", "NotFound", () => Effect.fail(new NotFoundError({ path: input.path })), (_, error) => Effect.die(error), ), ), mime: FSUtil.mimeType(target.real), } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { // Navigation can leave the cwd without activating another Location. const directory = path.resolve(location.directory, input.path ?? ".") const info = yield* fs.stat(directory).pipe(Effect.orDie) if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) return yield* fs.readDirectoryEntries(directory).pipe( Effect.orDie, Effect.map((items) => items .flatMap((item) => { if (item.type !== "file" && item.type !== "directory") return [] const absolute = path.join(directory, item.name) const relative = path.relative(location.directory, absolute) || "." return [ Entry.make({ path: RelativePath.make(relative + (item.type === "directory" ? path.sep : "")), type: item.type, }), ] }) .sort((a, b) => (a.type === b.type ? a.path.localeCompare(b.path) : a.type === "directory" ? -1 : 1)), ), ) }), // Unlike read, write reaches outside the location so clients can stage files in the // server tmp directory, which the model is already told to prefer and permitted to access. write: Effect.fn("FileSystem.write")(function* (input) { const target = path.resolve(location.directory, input.path) yield* fs.writeWithDirs(target, input.data).pipe(Effect.orDie) return Write.make({ path: AbsolutePath.make(target) }) }), }) }), ) export const node = makeLocationNode({ service: Service, layer: baseLayer, deps: [FSUtil.node, Location.node, FileSystemSearch.node], })