diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index dfefbf8d662..d716ad1a4f3 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -2,6 +2,7 @@ import { buildLocationServiceMap } from "../location-services.js" import { LocationServiceMap } from "../location-service-map.js" import { LayerNode } from "@opencode/util/effect/layer-node" import { makeGlobalNode } from "@opencode/util/effect/app-node" +import { FSUtil } from "@opencode/util/fs-util" import { Effect, Layer } from "effect" import { Instance } from "../instance/service.js" @@ -19,10 +20,23 @@ const instances = makeGlobalNode({ deps: [LocationServiceMap.node], }) -export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { +export function build( + root: LayerNode.Graph, + replacements: LayerNode.Replacements = [], + options: { directoryCheck?: boolean } = {}, +) { const bindings = [Instance.node.replace(instances), ...replacements] return LayerNode.compile(root, { - replacements: [LocationServiceMap.node.replace(buildLocationServiceMap(bindings)), ...bindings], + replacements: [ + LocationServiceMap.node.replace( + makeGlobalNode({ + service: LocationServiceMap.Service, + layer: buildLocationServiceMap(bindings, options), + deps: options.directoryCheck ? [FSUtil.node] : [], + }), + ), + ...bindings, + ], }) } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 095811cc39a..4ed94331d50 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -4,19 +4,36 @@ import { Instance } from "./instance.js" import { Location } from "./location.js" import { LocationLifecycle } from "./location-lifecycle.js" import { LocationServiceMap } from "./location-service-map.js" +import { FSUtil } from "@opencode/util/fs-util" +import type { PlatformError } from "effect/PlatformError" export { LocationServiceMap } from "./location-service-map.js" export type LocationServices = Instance.Services export type LocationError = Instance.Error +export class DirectoryNotFoundError extends Error { + constructor(readonly directory: string) { + super(`Project directory not found: ${directory}`) + } +} + +export class PermissionDeniedError extends Error { + constructor(readonly directory: string) { + super(`Cannot access project directory: ${directory}`) + } +} + export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], + options: { directoryCheck?: boolean } = {}, ): Layer.Layer { return Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const owner = yield* Effect.scope + const fs = options.directoryCheck ? yield* Effect.serviceOption(FSUtil.Service) : Option.none() + if (options.directoryCheck && Option.isNone(fs)) return yield* Effect.die("Directory check requires FSUtil") const builds = MutableHashMap.empty }>() const inner: LayerMap.LayerMap = yield* LayerMap.make( (ref: Location.Ref) => { @@ -24,7 +41,13 @@ export function buildLocationServiceMap( MutableHashMap.set(builds, ref, build) return Layer.fromBuild((memoMap, scope) => Effect.suspend(() => - Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope), + (!options.directoryCheck || ref.workspaceID || Option.isNone(fs) + ? Effect.void + : checkDirectory(fs.value, ref) + ).pipe( + Effect.orDie, + Effect.andThen(Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope)), + ), ).pipe( Effect.onExit((exit) => { const finish = Effect.suspend(() => { @@ -52,8 +75,7 @@ export function buildLocationServiceMap( ), ) }, - // Retain healthy graphs. Boot failures, not local filesystem probes, - // decide whether a location (including workspace placement) can retry. + // Retain healthy graphs; failed checks and boot failures retry on the next use. { idleTimeToLive: Duration.infinity }, ) const map = { @@ -86,3 +108,26 @@ export function buildLocationServiceMap( }), ) } + +function checkDirectory(fs: FSUtil.Interface, ref: Location.Ref) { + return fs.realPath(ref.directory).pipe( + Effect.asVoid, + Effect.catchReason("PlatformError", "NotFound", () => Effect.fail(new DirectoryNotFoundError(ref.directory))), + Effect.catchTag("PlatformError", (error) => + isPermissionDenied(error) ? Effect.fail(new PermissionDeniedError(ref.directory)) : Effect.die(error), + ), + ) +} + +// Effect maps EACCES to PermissionDenied, but macOS privacy blocks report EPERM as Unknown. +export function isPermissionDenied(error: PlatformError) { + if (error.reason._tag === "PermissionDenied") return true + const cause = error.cause + return ( + error.reason._tag === "Unknown" && + typeof cause === "object" && + cause !== null && + "code" in cause && + cause.code === "EPERM" + ) +} diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 05b52b95670..0bf98b1227a 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,10 +1,12 @@ import { Location } from "@opencode/core/location" -import { LocationServiceMap } from "@opencode/core/location-services" +import { DirectoryNotFoundError, LocationServiceMap, PermissionDeniedError } from "@opencode/core/location-services" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" -import { InvalidRequestError, LocationDirectoryNotFoundError, LocationPermissionDeniedError } from "@opencode/protocol/errors" -import { FSUtil } from "@opencode/util/fs-util" -import type { PlatformError } from "effect/PlatformError" +import { + InvalidRequestError, + LocationDirectoryNotFoundError, + LocationPermissionDeniedError, +} from "@opencode/protocol/errors" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -12,10 +14,10 @@ import { missingSession } from "./handlers/session-error" export type LocationServices = Layer.Success> -export class LocationMiddleware extends HttpApiMiddleware.Service< - LocationMiddleware, - { provides: LocationServices } ->()("@opencode/HttpApiLocation", { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }) {} +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", + { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }, +) {} export function response(data: Effect.Effect) { return Effect.gen(function* () { @@ -57,58 +59,28 @@ function decode(input: string) { } } -export const layer = (directoryCheck = true) => - Layer.effect( - LocationMiddleware, - Effect.gen(function* () { - const locations = yield* LocationServiceMap.Service - const fs = yield* FSUtil.Service - return LocationMiddleware.of((effect) => - Effect.gen(function* () { - const request = yield* HttpServerRequest.HttpServerRequest - const ref = requestRef(request) - // Resolve once before booting the Location graph; otherwise both a missing folder and - // macOS privacy denial become defects during Project/FileSystem startup. - if (directoryCheck) - yield* fs.realPath(ref.directory).pipe( - Effect.catchReason( - "PlatformError", - "NotFound", - () => - Effect.fail( - new LocationDirectoryNotFoundError({ - directory: ref.directory, - message: `Project directory not found: ${ref.directory}`, - }), - ), - ), - Effect.catchTag("PlatformError", (error) => { - if (isPermissionDenied(error)) - return Effect.fail( - new LocationPermissionDeniedError({ - directory: ref.directory, - message: `Cannot access project directory: ${ref.directory}`, - }), - ) - return Effect.die(error) - }), - ) - return yield* effect.pipe(Effect.provide(locations.get(ref))) - }), - ) - }), - ) +export const layer = Layer.effect( + LocationMiddleware, + Effect.gen(function* () { + const locations = yield* LocationServiceMap.Service + return LocationMiddleware.of((effect) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + return yield* effect.pipe( + Effect.provide(locations.get(requestRef(request))), + Effect.catchDefect(locationFailure), + ) + }), + ) + }), +) -// Effect maps EACCES to PermissionDenied but leaves EPERM as Unknown, which is how macOS reports a -// folder blocked by privacy settings (e.g. `EPERM: operation not permitted, lstat '/Users//Documents'`). -export function isPermissionDenied(error: PlatformError) { - if (error.reason._tag === "PermissionDenied") return true - const cause = error.cause - return ( - error.reason._tag === "Unknown" && - typeof cause === "object" && - cause !== null && - "code" in cause && - cause.code === "EPERM" - ) +export function locationFailure( + defect: unknown, +): Effect.Effect { + if (defect instanceof DirectoryNotFoundError) + return Effect.fail(new LocationDirectoryNotFoundError({ directory: defect.directory, message: defect.message })) + if (defect instanceof PermissionDeniedError) + return Effect.fail(new LocationPermissionDeniedError({ directory: defect.directory, message: defect.message })) + return Effect.die(defect) } diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts index dc18de75fbd..fae264dde16 100644 --- a/packages/server/src/middleware/form-location.ts +++ b/packages/server/src/middleware/form-location.ts @@ -1,17 +1,22 @@ import { Instance } from "@opencode/core/instance/service" import { LocationServiceMap } from "@opencode/core/location-services" import { Session } from "@opencode/core/session" -import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" +import { + InvalidRequestError, + LocationDirectoryNotFoundError, + LocationPermissionDeniedError, + SessionNotFoundError, +} from "@opencode/protocol/errors" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { requestRef, sessionInfo, type LocationServices } from "../location" +import { locationFailure, requestRef, sessionInfo, type LocationServices } from "../location" export class FormLocationMiddleware extends HttpApiMiddleware.Service< FormLocationMiddleware, { provides: LocationServices } >()("@opencode/HttpApiFormLocation", { - error: [InvalidRequestError, SessionNotFoundError], + error: [InvalidRequestError, SessionNotFoundError, LocationDirectoryNotFoundError, LocationPermissionDeniedError], }) {} export const formLocationLayer = Layer.effect( @@ -34,7 +39,7 @@ export const formLocationLayer = Layer.effect( const session = yield* sessionInfo(sessions, route.params.sessionID) return yield* effect.pipe(instances.provide(session)) - }), + }).pipe(Effect.catchDefect(locationFailure)), ) }), ) diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index f3a9a223448..49d7e8e151b 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -3,14 +3,19 @@ import { Session } from "@opencode/core/session" import { Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" -import { sessionInfo, type LocationServices } from "../location" +import { + InvalidRequestError, + LocationDirectoryNotFoundError, + LocationPermissionDeniedError, + SessionNotFoundError, +} from "@opencode/protocol/errors" +import { locationFailure, sessionInfo, type LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, { provides: LocationServices } >()("@opencode/HttpApiSessionLocation", { - error: [InvalidRequestError, SessionNotFoundError], + error: [InvalidRequestError, SessionNotFoundError, LocationDirectoryNotFoundError, LocationPermissionDeniedError], }) {} export const sessionLocationLayer = Layer.effect( @@ -24,7 +29,7 @@ export const sessionLocationLayer = Layer.effect( const route = yield* HttpRouter.RouteContext const session = yield* sessionInfo(sessions, route.params.sessionID) return yield* effect.pipe(instances.provide(session)) - }), + }).pipe(Effect.catchDefect(locationFailure)), ) }), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c2245a4d9ff..b443f3375d5 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -148,7 +148,9 @@ function makeRoutes( ...(instances ? [Instance.node.replace(instances(() => replacements))] : []), ...overrides, ] - return AppNodeBuilder.build(applicationServices, replacements) + return AppNodeBuilder.build(applicationServices, replacements, { + directoryCheck: options.fs?.directoryCheck !== false && !options.simulation, + }) } const serviceLayer = options.simulation ? Layer.unwrap( @@ -179,7 +181,7 @@ function makeRoutes( Layer.provide(handlers.pipe(Layer.provide(services), Layer.provide(Layer.succeed(CorsConfig, options)))), Layer.provide(formLocationLayer), Layer.provide(sessionLocationLayer), - Layer.provide(layer(options.fs?.directoryCheck !== false && !options.simulation)), + Layer.provide(layer), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 9920673f4f3..171a510d50b 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -5,7 +5,9 @@ import path from "node:path" import { Agent } from "@opencode/schema/agent" import { Integration } from "@opencode/schema/integration" import { ServerInfo } from "@opencode/protocol/groups/server" -import { Effect, Schedule, Schema } from "effect" +import { FSUtil } from "@opencode/util/fs-util" +import { NodeFileSystem } from "@effect/platform-node" +import { Context, Effect, Layer, Schedule, Schema } from "effect" import { tmpdir, tmpdirScoped } from "../../core/test/fixture/tmpdir" import { it } from "../../core/test/lib/effect" import { ServerFetch } from "../src/fetch" @@ -105,7 +107,9 @@ it.live("serves the HttpApi and enforces Basic auth like the Node server", () => ), ) expect(response.status).toBe(200) - const body = yield* Effect.promise(() => response.json()).pipe(Effect.flatMap(Schema.decodeUnknownEffect(ServerInfo))) + const body = yield* Effect.promise(() => response.json()).pipe( + Effect.flatMap(Schema.decodeUnknownEffect(ServerInfo)), + ) expect(body.version).toBe("test-version") expect(body.paths.tmp).toEndWith("opencode") }), @@ -146,6 +150,49 @@ it.live("reports a missing project directory and recovers when it returns", () = }), ) +it.live("checks a directory once per location boot, not on every request", () => + Effect.gen(function* () { + const directory = yield* tmpdirScoped() + const checked: string[] = [] + const filesystem = FSUtil.layer.pipe( + Layer.provide(NodeFileSystem.layer), + Layer.flatMap((context) => { + const fs = Context.get(context, FSUtil.Service) + return Layer.succeed( + FSUtil.Service, + FSUtil.Service.of({ + ...fs, + realPath: (input) => { + if (input === directory.path) checked.push(input) + return fs.realPath(input) + }, + }), + ) + }), + ) + const handler = yield* ServerFetch.make(options, { overrides: [FSUtil.node.replace(filesystem)] }) + const request = (endpoint: string, method = "GET") => + handler( + new Request(`http://opencode.local${endpoint}`, { + method, + headers: { "x-opencode-directory": encodeURIComponent(directory.path) }, + }), + ) + + expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + const afterBoot = checked.length + expect(afterBoot).toBeGreaterThan(0) + expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + expect(checked).toHaveLength(afterBoot) + + expect((yield* Effect.promise(() => request("/api/location/reload", "POST"))).status).toBe(204) + const afterReload = checked.length + expect(afterReload).toBeGreaterThan(afterBoot) + expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + expect(checked).toHaveLength(afterReload) + }), +) + it.live("reports denied project access without breaking other locations", () => Effect.gen(function* () { if (process.platform === "win32") return diff --git a/packages/server/test/location-permission.test.ts b/packages/server/test/location-permission.test.ts index 5bb43d15c85..4c27dfa0fb1 100644 --- a/packages/server/test/location-permission.test.ts +++ b/packages/server/test/location-permission.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test" import { PlatformError } from "effect" -import { isPermissionDenied } from "../src/location" +import { isPermissionDenied } from "../../core/src/location-services" // Reproduces the error macOS returns when privacy settings block a folder. const macPrivacyDenied = () =>