diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index dfefbf8d6620..0f4f8961e834 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" @@ -22,7 +23,16 @@ const instances = makeGlobalNode({ export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { 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), + deps: [FSUtil.node], + }), + ), + ...bindings, + ], }) } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 095811cc39a7..7df80b63933d 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -4,12 +4,26 @@ 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 = [], ): Layer.Layer { @@ -17,6 +31,7 @@ export function buildLocationServiceMap( LocationServiceMap.Service, Effect.gen(function* () { const owner = yield* Effect.scope + const fs = yield* Effect.serviceOption(FSUtil.Service) const builds = MutableHashMap.empty }>() const inner: LayerMap.LayerMap = yield* LayerMap.make( (ref: Location.Ref) => { @@ -24,7 +39,10 @@ export function buildLocationServiceMap( MutableHashMap.set(builds, ref, build) return Layer.fromBuild((memoMap, scope) => Effect.suspend(() => - Layer.buildWithMemoMap(Instance.layer(ref, { replacements: bindings }), memoMap, scope), + (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 +70,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 +103,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 05b52b956704..0bf98b1227a7 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 dc18de75fbda..fae264dde166 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 f3a9a2234483..49d7e8e151bd 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/options.ts b/packages/server/src/options.ts index 77038e3f761a..489006a36233 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -41,8 +41,6 @@ export const ServerOptions = Schema.Struct({ Schema.Struct({ filewatcher: Schema.optional(Schema.Boolean), fff: Schema.optional(Schema.Boolean), - // Hosts without a local project filesystem (e.g. workerd) opt out of the request-time directory probe. - directoryCheck: Schema.optional(Schema.Boolean), }), ), }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c2245a4d9ffc..eea50e1f61e7 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -179,7 +179,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/src/workerd.ts b/packages/server/src/workerd.ts index bb3e3193a1a3..949ee3fd4d06 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -62,7 +62,7 @@ export function serverOptions(options: Options): ServerOptions { return { app: options.app, password: options.password, - fs: { filewatcher: false, fff: false, directoryCheck: false }, + fs: { filewatcher: false, fff: false }, // Durable event history is how a turn orphaned by eviction is recovered: // the boot-time resume replays it. A runtime that dies without teardown // cannot opt out of it, so this is not exposed as an option. diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 9920673f4f30..171a510d50bb 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 5bb43d15c857..4c27dfa0fb13 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 = () =>