Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion packages/core/src/effect/app-node-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -22,7 +23,16 @@ const instances = makeGlobalNode({
export function build<A, E>(root: LayerNode.Graph<A, E>, 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,
],
})
}

Expand Down
46 changes: 43 additions & 3 deletions packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,45 @@ 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<LocationServiceMap.Service> {
return Layer.effect(
LocationServiceMap.Service,
Effect.gen(function* () {
const owner = yield* Effect.scope
const fs = yield* Effect.serviceOption(FSUtil.Service)
const builds = MutableHashMap.empty<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = yield* LayerMap.make(
(ref: Location.Ref) => {
const build: { close?: Effect.Effect<void> } = {}
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(() => {
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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"
)
}
94 changes: 33 additions & 61 deletions packages/server/src/location.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
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"
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* () {
Expand Down Expand Up @@ -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)
}
13 changes: 9 additions & 4 deletions packages/server/src/middleware/form-location.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand All @@ -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)),
)
}),
)
13 changes: 9 additions & 4 deletions packages/server/src/middleware/session-location.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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)),
)
}),
)
2 changes: 0 additions & 2 deletions packages/server/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}),
),
})
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,7 +179,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),
Expand Down
2 changes: 1 addition & 1 deletion packages/server/src/workerd.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
51 changes: 49 additions & 2 deletions packages/server/test/fetch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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")
}),
Expand Down Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/server/test/location-permission.test.ts
Original file line number Diff line number Diff line change
@@ -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 = () =>
Expand Down
Loading