From e8d632834c7a14c6d095e885ec5789af4ecf0401 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 21 Jun 2026 13:14:37 -0400 Subject: [PATCH] fix(core): expose filesystem failures --- packages/core/src/filesystem.ts | 27 ++++++------ packages/core/src/filesystem/schema.ts | 5 +++ .../core/test/location-filesystem.test.ts | 43 ++++++++++++++++--- packages/plugin/src/v2/effect/filesystem.ts | 20 ++++++++- packages/plugin/src/v2/effect/index.ts | 2 +- 5 files changed, 76 insertions(+), 21 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 3257fe8840..a7f90ced06 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -7,8 +7,8 @@ import { FSUtil } from "./fs-util" import { Location } from "./location" import { PositiveInt, RelativePath } from "./schema" import { FileSystemSearch } from "./filesystem/search" -import { Entry, Match } from "./filesystem/schema" -export { Entry, Match, Submatch } from "./filesystem/schema" +import { Entry, Match, PathError } from "./filesystem/schema" +export { Entry, Match, PathError, Submatch } from "./filesystem/schema" export const ReadInput = Schema.Struct({ path: RelativePath, @@ -58,8 +58,10 @@ export const Event = { } export interface Interface { - readonly read: (input: ReadInput) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> - readonly list: (input?: ListInput) => Effect.Effect + readonly read: ( + input: ReadInput, + ) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, PathError | FSUtil.Error> + readonly list: (input?: ListInput) => Effect.Effect readonly find: (input: FindInput) => Effect.Effect readonly glob: (input: GlobInput) => Effect.Effect readonly grep: (input: GrepInput) => Effect.Effect @@ -77,9 +79,9 @@ const baseLayer = Layer.effect( 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).pipe(Effect.orDie) - if (!FSUtil.contains(root, real)) return yield* Effect.die(new Error("Path escapes the location")) + return yield* new PathError({ path: input ?? ".", reason: "lexical_escape" }) + const real = yield* fs.realPath(absolute) + if (!FSUtil.contains(root, real)) return yield* new PathError({ path: input ?? ".", reason: "symlink_escape" }) return { absolute, real, directory: location.directory, root } }) return Service.of({ @@ -88,19 +90,18 @@ const baseLayer = Layer.effect( grep: search.grep, read: Effect.fn("FileSystem.read")(function* (input) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "File") return yield* Effect.die(new Error("Path is not a file")) + const info = yield* fs.stat(target.real) + if (info.type !== "File") return yield* new PathError({ path: input.path, reason: "not_file" }) return { - content: yield* fs.readFile(target.real).pipe(Effect.orDie), + content: yield* fs.readFile(target.real), mime: FSUtil.mimeType(target.real), } }), list: Effect.fn("FileSystem.list")(function* (input = {}) { const target = yield* resolve(input.path) - const info = yield* fs.stat(target.real).pipe(Effect.orDie) - if (info.type !== "Directory") return yield* Effect.die(new Error("Path is not a directory")) + const info = yield* fs.stat(target.real) + if (info.type !== "Directory") return yield* new PathError({ path: input.path ?? ".", reason: "not_directory" }) return yield* fs.readDirectoryEntries(target.real).pipe( - Effect.orDie, Effect.map((items) => items .flatMap((item) => { diff --git a/packages/core/src/filesystem/schema.ts b/packages/core/src/filesystem/schema.ts index 6a2cb48413..1eeffa7d37 100644 --- a/packages/core/src/filesystem/schema.ts +++ b/packages/core/src/filesystem/schema.ts @@ -21,3 +21,8 @@ export class Match extends Schema.Class("FileSystem.Match")({ text: Schema.String, submatches: Schema.Array(Submatch), }) {} + +export class PathError extends Schema.TaggedErrorClass()("FileSystem.PathError", { + path: Schema.String, + reason: Schema.Literals(["lexical_escape", "symlink_escape", "not_file", "not_directory"]), +}) {} diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index a3ac24a905..971210404a 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -1,7 +1,7 @@ import fs from "fs/promises" import path from "path" import { describe, expect } from "bun:test" -import { Effect, Exit, Layer } from "effect" +import { Cause, Effect, Exit, Layer } from "effect" import { FileSystem } from "@opencode-ai/core/filesystem" import { FSUtil } from "@opencode-ai/core/fs-util" import { Location } from "@opencode-ai/core/location" @@ -31,6 +31,13 @@ const withTmp = (f: (directory: string) => Effect.Effect) => ).pipe(Effect.flatMap((tmp) => f(tmp.path))) describe("FileSystem", () => { + const expectFail = (exit: Exit.Exit) => { + expect(Exit.isFailure(exit)).toBe(true) + if (Exit.isSuccess(exit)) return + expect(Cause.hasFails(exit.cause)).toBe(true) + expect(Cause.hasDies(exit.cause)).toBe(false) + } + it.live("reads text and binary files", () => withTmp((directory) => Effect.gen(function* () { @@ -60,13 +67,39 @@ describe("FileSystem", () => { ), ) - it.live("rejects lexical escapes", () => + it.live("fails for missing paths", () => withTmp((directory) => Effect.gen(function* () { - const result = yield* (yield* FileSystem.Service) - .read({ path: RelativePath.make("../outside.txt") }) + const exit = yield* (yield* FileSystem.Service) + .read({ path: RelativePath.make("missing.txt") }) .pipe(Effect.exit) - expect(Exit.isFailure(result)).toBe(true) + expectFail(exit) + }).pipe(provide(directory)), + ), + ) + + it.live("fails for wrong path kinds", () => + withTmp((directory) => + Effect.gen(function* () { + yield* Effect.promise(() => fs.mkdir(path.join(directory, "src"))) + yield* Effect.promise(() => fs.writeFile(path.join(directory, "README.md"), "# Test")) + const service = yield* FileSystem.Service + expectFail(yield* service.read({ path: RelativePath.make("src") }).pipe(Effect.exit)) + expectFail(yield* service.list({ path: RelativePath.make("README.md") }).pipe(Effect.exit)) + }).pipe(provide(directory)), + ), + ) + + it.live("fails for lexical and symlink escapes", () => + withTmp((directory) => + Effect.gen(function* () { + const outside = path.join(directory, "..", "outside.txt") + yield* Effect.promise(() => fs.writeFile(outside, "outside")) + yield* Effect.promise(() => fs.symlink(outside, path.join(directory, "linked.txt"))) + const service = yield* FileSystem.Service + expectFail(yield* service.read({ path: RelativePath.make("../outside.txt") }).pipe(Effect.exit)) + expectFail(yield* service.read({ path: RelativePath.make("linked.txt") }).pipe(Effect.exit)) + yield* Effect.promise(() => fs.rm(outside)) }).pipe(provide(directory)), ), ) diff --git a/packages/plugin/src/v2/effect/filesystem.ts b/packages/plugin/src/v2/effect/filesystem.ts index d242b2a692..e530a51d64 100644 --- a/packages/plugin/src/v2/effect/filesystem.ts +++ b/packages/plugin/src/v2/effect/filesystem.ts @@ -1,9 +1,25 @@ import type { FileSystemEntry } from "@opencode-ai/sdk/v2/types" import type { Effect } from "effect" +import type { PlatformError } from "effect/PlatformError" + +export type FileSystemError = + | PlatformError + | { + readonly _tag: "FileSystemError" + readonly method: string + readonly cause?: unknown + } + | { + readonly _tag: "FileSystem.PathError" + readonly path: string + readonly reason: "lexical_escape" | "symlink_escape" | "not_file" | "not_directory" + } export interface FileSystem { - read(input: { readonly path: string }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }> - list(input?: { readonly path?: string }): Effect.Effect + read(input: { + readonly path: string + }): Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, FileSystemError> + list(input?: { readonly path?: string }): Effect.Effect find(input: { readonly query: string readonly type?: "file" | "directory" diff --git a/packages/plugin/src/v2/effect/index.ts b/packages/plugin/src/v2/effect/index.ts index 46c4574515..6a83e01a46 100644 --- a/packages/plugin/src/v2/effect/index.ts +++ b/packages/plugin/src/v2/effect/index.ts @@ -7,7 +7,7 @@ export type { AISDK, AISDKHooks } from "./aisdk.js" export type { Catalog, CatalogDraft, CatalogProviderRecord } from "./catalog.js" export type { Command, CommandDraft } from "./command.js" export type { Event, EventMap } from "./event.js" -export type { FileSystem } from "./filesystem.js" +export type { FileSystem, FileSystemError } from "./filesystem.js" export type { Integration, IntegrationDraft, IntegrationMethod, IntegrationMethodRegistration } from "./integration.js" export type { Location } from "./location.js" export type { Npm } from "./npm.js"