mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
fix(server): check project directory on location boot
This commit is contained in:
@@ -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,
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
Reference in New Issue
Block a user