From 14045661412e80963f885a63199115f4d64d1a8e Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 1 Oct 2026 22:45:08 -0500 Subject: [PATCH] refactor(core): type directory initialization errors (#52649) --- packages/core/src/filesystem.ts | 43 +++++++++++- .../core/test/location-filesystem.test.ts | 69 ++++++++++++++++++- 2 files changed, 110 insertions(+), 2 deletions(-) diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index b6b1bb3402f..4c5024d1543 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -26,6 +26,30 @@ export class NotFoundError extends Schema.TaggedError()("FileSyst path: RelativePath, }) {} +export class DirectoryNotFoundError extends Schema.TaggedError()( + "FileSystem.DirectoryNotFoundError", + { + directory: AbsolutePath, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Directory not found: ${this.directory}` + } +} + +export class DirectoryAccessDeniedError extends Schema.TaggedError()( + "FileSystem.DirectoryAccessDeniedError", + { + directory: AbsolutePath, + cause: Schema.Defect(), + }, +) { + override get message() { + return `Access denied to directory: ${this.directory}` + } +} + export const Content = Schema.Struct({ uri: Schema.String, name: Schema.String.pipe(Schema.optional), @@ -87,7 +111,24 @@ const baseLayer = Layer.effect( // 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 root = location.workspaceID + ? location.directory + : yield* fs.realPath(location.directory).pipe( + Effect.catch((cause): Effect.Effect => { + if (cause.reason._tag === "NotFound") + return Effect.fail(new DirectoryNotFoundError({ directory: location.directory, cause })) + // macOS privacy denials arrive as Unknown with an EPERM cause. + if ( + cause.reason._tag === "PermissionDenied" || + (cause.reason._tag === "Unknown" && + cause.reason.cause instanceof Error && + "code" in cause.reason.cause && + cause.reason.cause.code === "EPERM") + ) + return Effect.fail(new DirectoryAccessDeniedError({ directory: location.directory, cause })) + return Effect.die(cause) + }), + ) const resolve = Effect.fnUntraced(function* (input?: RelativePath) { const absolute = path.resolve(location.directory, input ?? ".") if (!FSUtil.contains(location.directory, absolute)) diff --git a/packages/core/test/location-filesystem.test.ts b/packages/core/test/location-filesystem.test.ts index 8ce5a27b02e..4e75fbf4e54 100644 --- a/packages/core/test/location-filesystem.test.ts +++ b/packages/core/test/location-filesystem.test.ts @@ -1,7 +1,8 @@ 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, PlatformError } from "effect" +import { FSUtil } from "@opencode/util/fs-util" import { LayerNode } from "@opencode/util/effect/layer-node" import { FileSystem } from "@opencode/core/filesystem" import { Location } from "@opencode/core/location" @@ -79,10 +80,76 @@ describe("FileSystem", () => { // host realpath canonicalization stays load-bearing for local placements. const local = yield* FileSystem.Service.pipe(provide(missing), Effect.exit) expect(Exit.isFailure(local)).toBe(true) + if (Exit.isFailure(local)) { + const error = Cause.findErrorOption(local.cause) + expect(error).toMatchObject({ + _tag: "Some", + value: { + _tag: "FileSystem.DirectoryNotFoundError", + directory: missing, + message: `Directory not found: ${missing}`, + }, + }) + } }), ), ) + for (const input of [ + { reason: "PermissionDenied", code: "EACCES", denied: true }, + { reason: "Unknown", code: "EPERM", denied: true }, + { reason: "Unknown", code: "EIO", denied: false }, + ] as const) { + it.live(`classifies directory initialization failure ${input.code}`, () => + withTmp((directory) => + Effect.gen(function* () { + const filesystem = yield* FSUtil.Service + const cause = PlatformError.systemError({ + _tag: input.reason, + module: "FileSystem", + method: "realPath", + pathOrDescriptor: directory, + cause: Object.assign(new Error(input.code), { code: input.code }), + }) + const result = yield* FileSystem.Service.pipe( + Effect.provide( + LayerNode.compile(FileSystem.node, { + replacements: [ + Location.node.replace( + Layer.succeed(Location.Service, location({ directory: AbsolutePath.make(directory) })), + ), + FSUtil.node.replace( + Layer.succeed(FSUtil.Service, { + ...filesystem, + realPath: (target) => (target === directory ? Effect.fail(cause) : filesystem.realPath(target)), + }), + ), + ], + }), + ), + Effect.exit, + ) + expect(Exit.isFailure(result)).toBe(true) + if (Exit.isFailure(result)) { + if (input.denied) { + expect(Cause.findErrorOption(result.cause)).toMatchObject({ + _tag: "Some", + value: { + _tag: "FileSystem.DirectoryAccessDeniedError", + directory, + cause, + message: `Access denied to directory: ${directory}`, + }, + }) + return + } + expect(result.cause.reasons.filter(Cause.isDieReason)).toMatchObject([{ defect: cause }]) + } + }).pipe(Effect.provide(LayerNode.compile(FSUtil.node))), + ), + ) + } + it.live("lists parents and siblings with paths relative to the current location", () => withTmp((directory) => Effect.gen(function* () {