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 = () =>