fix(server): check project directory on location boot

This commit is contained in:
Brendonovich
2026-09-25 06:25:02 +00:00
parent eb15a9b789
commit fdc3eddc8a
8 changed files with 169 additions and 79 deletions
+16 -2
View File
@@ -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<A, E>(root: LayerNode.Graph<A, E>, replacements: LayerNode.Replacements = []) {
export function build<A, E>(
root: LayerNode.Graph<A, E>,
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,
],
})
}
+48 -3
View File
@@ -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<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const owner = yield* Effect.scope
const fs = options.directoryCheck ? yield* Effect.serviceOption(FSUtil.Service) : Option.none<FSUtil.Interface>()
if (options.directoryCheck && Option.isNone(fs)) return yield* Effect.die("Directory check requires FSUtil")
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = 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"
)
}
+33 -61
View File
@@ -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<ReturnType<(typeof LocationServiceMap.Service)["get"]>>
export class LocationMiddleware extends HttpApiMiddleware.Service<
LocationMiddleware,
{ provides: LocationServices }
>()("@opencode/HttpApiLocation", { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }) {}
export class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware, { provides: LocationServices }>()(
"@opencode/HttpApiLocation",
{ error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] },
) {}
export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
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/<user>/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<never, LocationDirectoryNotFoundError | LocationPermissionDeniedError> {
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)
}
@@ -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)),
)
}),
)
@@ -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)),
)
}),
)
+4 -2
View File
@@ -148,7 +148,9 @@ function makeRoutes<AuthError, AuthServices>(
...(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<AuthError, AuthServices>(
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),
+49 -2
View File
@@ -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
@@ -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 = () =>