Compare commits

...
10 changed files with 271 additions and 81 deletions
+67 -61
View File
@@ -444,7 +444,7 @@ export function make(options: ClientOptions) {
path: `/api/location`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -469,7 +469,7 @@ export function make(options: ClientOptions) {
path: `/api/agent`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -481,7 +481,7 @@ export function make(options: ClientOptions) {
path: `/api/agent/${encodeURIComponent(input.agentID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -495,7 +495,7 @@ export function make(options: ClientOptions) {
path: `/api/plugin`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -508,7 +508,7 @@ export function make(options: ClientOptions) {
query: { location: input?.["location"] },
body: { target: input?.["target"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -521,7 +521,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { targets: input["targets"] },
successStatus: 204,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: true,
},
requestOptions,
@@ -1109,7 +1109,7 @@ export function make(options: ClientOptions) {
path: `/api/model`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -1121,7 +1121,7 @@ export function make(options: ClientOptions) {
path: `/api/model/default`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -1149,7 +1149,7 @@ export function make(options: ClientOptions) {
path: `/api/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -1161,7 +1161,7 @@ export function make(options: ClientOptions) {
path: `/api/provider/${encodeURIComponent(input.providerID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 503],
declaredStatuses: [400, 401, 403, 404, 503],
empty: false,
},
requestOptions,
@@ -1175,7 +1175,7 @@ export function make(options: ClientOptions) {
path: `/api/integration`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1187,7 +1187,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1201,7 +1201,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { url: input["url"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1216,7 +1216,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { key: input["key"], answer: input["answer"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1231,7 +1231,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], answer: input["answer"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1243,7 +1243,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1256,7 +1256,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1268,7 +1268,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1283,7 +1283,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1295,7 +1295,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1307,7 +1307,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1322,7 +1322,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1335,7 +1335,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1347,7 +1347,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1359,7 +1359,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1371,7 +1371,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1384,7 +1384,7 @@ export function make(options: ClientOptions) {
path: `/api/mcp/resource`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1430,7 +1430,7 @@ export function make(options: ClientOptions) {
project: {
list: (requestOptions?: RequestOptions) =>
request<ProjectListOutput>(
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
{ method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 403], empty: false },
requestOptions,
),
update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) =>
@@ -1445,7 +1445,7 @@ export function make(options: ClientOptions) {
commands: input["commands"],
},
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1459,7 +1459,7 @@ export function make(options: ClientOptions) {
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1474,7 +1474,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1488,7 +1488,7 @@ export function make(options: ClientOptions) {
path: `/api/permission/saved`,
query: { projectID: input?.["projectID"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1499,7 +1499,7 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/permission/saved/${encodeURIComponent(input.id)}`,
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1568,7 +1568,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/read/${encodePath(input.path)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
binary: true,
},
@@ -1581,7 +1581,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/list`,
query: { location: input?.["location"], path: input?.["path"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1593,7 +1593,7 @@ export function make(options: ClientOptions) {
path: `/api/fs/find`,
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1606,7 +1606,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"], path: input["path"] },
body: input["payload"],
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
binaryBody: true,
},
@@ -1621,7 +1621,7 @@ export function make(options: ClientOptions) {
path: `/api/command`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1635,7 +1635,7 @@ export function make(options: ClientOptions) {
path: `/api/skill`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1650,7 +1650,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { input: input["input"] },
successStatus: 200,
declaredStatuses: [400, 401, 500],
declaredStatuses: [400, 401, 403, 500],
empty: false,
},
requestOptions,
@@ -1671,7 +1671,7 @@ export function make(options: ClientOptions) {
path: `/api/pty`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1690,7 +1690,7 @@ export function make(options: ClientOptions) {
env: input?.["env"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1702,7 +1702,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1715,7 +1715,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { title: input["title"], size: input["size"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1727,7 +1727,7 @@ export function make(options: ClientOptions) {
path: `/api/pty/${encodeURIComponent(input.ptyID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: true,
},
requestOptions,
@@ -1881,7 +1881,7 @@ export function make(options: ClientOptions) {
path: `/api/shell`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1899,7 +1899,7 @@ export function make(options: ClientOptions) {
metadata: input["metadata"],
},
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -1911,7 +1911,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1923,7 +1923,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}/output`,
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401, 403, 404],
empty: false,
},
requestOptions,
@@ -1935,7 +1935,7 @@ export function make(options: ClientOptions) {
path: `/api/shell/${encodeURIComponent(input.id)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -1949,7 +1949,7 @@ export function make(options: ClientOptions) {
path: `/api/reference`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -2019,7 +2019,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -2031,7 +2031,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/base`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -2043,7 +2043,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/status`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -2056,7 +2056,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/branch`,
query: { location: input?.["location"], search: input?.["search"], limit: input?.["limit"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
@@ -2069,7 +2069,7 @@ export function make(options: ClientOptions) {
path: `/api/vcs/diff`,
query: { location: input["location"], mode: input["mode"], base: input["base"], context: input["context"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -2125,7 +2125,7 @@ export function make(options: ClientOptions) {
path: `/api/websearch/provider`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -2138,7 +2138,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { query: input["query"], providerID: input["providerID"] },
successStatus: 200,
declaredStatuses: [400, 401, 503],
declaredStatuses: [400, 401, 403, 503],
empty: false,
},
requestOptions,
@@ -2152,14 +2152,20 @@ export function make(options: ClientOptions) {
path: `/api/config`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
),
shells: (requestOptions?: RequestOptions) =>
request<ConfigShellsOutput>(
{ method: "GET", path: `/api/config/shell`, successStatus: 200, declaredStatuses: [400, 401], empty: false },
{
method: "GET",
path: `/api/config/shell`,
successStatus: 200,
declaredStatuses: [400, 401, 403],
empty: false,
},
requestOptions,
),
update: (input: ConfigUpdateInput, requestOptions?: RequestOptions) =>
@@ -2169,7 +2175,7 @@ export function make(options: ClientOptions) {
path: `/api/experimental/config`,
body: { shell: input["shell"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 403],
empty: true,
},
requestOptions,
@@ -2484,6 +2484,22 @@ export type UnauthorizedError = { readonly _tag: "UnauthorizedError"; readonly m
export const isUnauthorizedError = (value: unknown): value is UnauthorizedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "UnauthorizedError"
export type LocationDirectoryNotFoundError = {
readonly _tag: "LocationDirectoryNotFoundError"
readonly directory: string
readonly message: string
}
export const isLocationDirectoryNotFoundError = (value: unknown): value is LocationDirectoryNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationDirectoryNotFoundError"
export type LocationPermissionDeniedError = {
readonly _tag: "LocationPermissionDeniedError"
readonly directory: string
readonly message: string
}
export const isLocationPermissionDeniedError = (value: unknown): value is LocationPermissionDeniedError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "LocationPermissionDeniedError"
export type ServiceUnavailableError = {
readonly _tag: "ServiceUnavailableError"
readonly message: string
+7 -1
View File
@@ -1,4 +1,9 @@
import { InvalidRequestError, SessionNotFoundError } from "./errors.js"
import {
InvalidRequestError,
LocationDirectoryNotFoundError,
LocationPermissionDeniedError,
SessionNotFoundError,
} from "./errors.js"
import { makeDefaultApi } from "./api.js"
import type { Api } from "./api.js"
import type { Context } from "effect"
@@ -7,6 +12,7 @@ import type { EventGroup } from "./groups/event.js"
class LocationMiddleware extends HttpApiMiddleware.Service<LocationMiddleware>()(
"@opencode/client/LocationMiddleware",
{ error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] },
) {}
class SessionLocationMiddleware extends HttpApiMiddleware.Service<SessionLocationMiddleware>()(
+18
View File
@@ -11,6 +11,24 @@ export class InvalidRequestError extends Schema.TaggedError<InvalidRequestError>
{ httpApiStatus: 400 },
) {}
export class LocationDirectoryNotFoundError extends Schema.TaggedError<LocationDirectoryNotFoundError>()(
"LocationDirectoryNotFoundError",
{
directory: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 400 },
) {}
export class LocationPermissionDeniedError extends Schema.TaggedError<LocationPermissionDeniedError>()(
"LocationPermissionDeniedError",
{
directory: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 403 },
) {}
export class RpcError extends Schema.TaggedError<RpcError>()(
"RpcError",
{
+62 -16
View File
@@ -2,7 +2,9 @@ import { Location } from "@opencode/core/location"
import { LocationServiceMap } from "@opencode/core/location-services"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { InvalidRequestError } from "@opencode/protocol/errors"
import { InvalidRequestError, LocationDirectoryNotFoundError, LocationPermissionDeniedError } from "@opencode/protocol/errors"
import { FSUtil } from "@opencode/util/fs-util"
import type { PlatformError } from "effect/PlatformError"
import { Effect, Layer, Schema } from "effect"
import { HttpServerRequest } from "effect/unstable/http"
import { HttpApiMiddleware } from "effect/unstable/httpapi"
@@ -10,9 +12,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",
) {}
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* () {
@@ -54,15 +57,58 @@ function decode(input: string) {
}
}
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))))
}),
)
}),
)
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)))
}),
)
}),
)
// 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"
)
}
+2
View File
@@ -41,6 +41,8 @@ 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),
}),
),
})
+3 -1
View File
@@ -31,6 +31,7 @@ import { PluginUpdate } from "@opencode/core/plugin/update"
import { SdkPlugins } from "@opencode/core/plugin/sdk"
import { WellKnown } from "@opencode/core/wellknown"
import { Workspace } from "@opencode/core/workspace"
import { FSUtil } from "@opencode/util/fs-util"
import { Watcher } from "@opencode/core/filesystem/watcher"
import { HttpRouter } from "effect/unstable/http"
import { HttpApiBuilder } from "effect/unstable/httpapi"
@@ -51,6 +52,7 @@ import type { ServerOptions } from "./options"
const applicationServiceNodes = [
Global.node,
FSUtil.node,
Database.node,
Bus.node,
EventLogger.node,
@@ -177,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),
Layer.provide(layer(options.fs?.directoryCheck !== false && !options.simulation)),
Layer.provide(authorizationLayer),
Layer.provide(schemaErrorLayer),
Layer.provide(auth),
+1 -1
View File
@@ -62,7 +62,7 @@ export function serverOptions(options: Options): ServerOptions {
return {
app: options.app,
password: options.password,
fs: { filewatcher: false, fff: false },
fs: { filewatcher: false, fff: false, directoryCheck: 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.
+55 -1
View File
@@ -6,7 +6,7 @@ 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 { tmpdir } from "../../core/test/fixture/tmpdir"
import { tmpdir, tmpdirScoped } from "../../core/test/fixture/tmpdir"
import { it } from "../../core/test/lib/effect"
import { ServerFetch } from "../src/fetch"
@@ -121,6 +121,60 @@ it.live("activates credentials through the HttpApi", () =>
}),
)
it.live("reports a missing project directory and recovers when it returns", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
const directory = yield* tmpdirScoped()
const missing = path.join(directory.path, "moved-project")
const request = (endpoint: string) =>
handler(
new Request(`http://opencode.local${endpoint}`, {
headers: { "x-opencode-directory": encodeURIComponent(missing) },
}),
)
const response = yield* Effect.promise(() => request("/api/integration"))
expect(response.status).toBe(400)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "LocationDirectoryNotFoundError",
directory: missing,
})
expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(400)
yield* Effect.promise(() => fs.mkdir(missing))
expect((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200)
}),
)
it.live("reports denied project access without breaking other locations", () =>
Effect.gen(function* () {
if (process.platform === "win32") return
const handler = yield* ServerFetch.make(options)
const root = yield* tmpdirScoped()
const parent = path.join(root.path, "private")
const directory = path.join(parent, "project")
yield* Effect.promise(() => fs.mkdir(directory, { recursive: true }))
yield* Effect.addFinalizer(() => Effect.promise(() => fs.chmod(parent, 0o700)))
yield* Effect.promise(() => fs.chmod(parent, 0o000))
const request = () =>
handler(
new Request("http://opencode.local/api/plugin", {
headers: { "x-opencode-directory": encodeURIComponent(directory) },
}),
)
const response = yield* Effect.promise(request)
expect(response.status).toBe(403)
expect(yield* Effect.promise(() => response.json())).toMatchObject({
_tag: "LocationPermissionDeniedError",
directory,
})
yield* Effect.promise(() => fs.chmod(parent, 0o700))
expect((yield* Effect.promise(request)).status).toBe(200)
}),
)
it.live("serves unauthenticated and answers CORS preflight when no password is configured", () =>
Effect.gen(function* () {
const handler = yield* ServerFetch.make(options)
@@ -0,0 +1,40 @@
import { expect, test } from "bun:test"
import { PlatformError } from "effect"
import { isPermissionDenied } from "../src/location"
// Reproduces the error macOS returns when privacy settings block a folder.
const macPrivacyDenied = () =>
PlatformError.systemError({
_tag: "Unknown",
module: "FileSystem",
method: "realPath",
pathOrDescriptor: "/Users/example/Documents",
cause: Object.assign(new Error("EPERM: operation not permitted, lstat '/Users/example/Documents'"), {
code: "EPERM",
}),
})
test("recognizes the macOS privacy denial reported as Unknown with an EPERM cause", () => {
expect(isPermissionDenied(macPrivacyDenied())).toBe(true)
expect(
isPermissionDenied(
PlatformError.systemError({ _tag: "PermissionDenied", module: "FileSystem", method: "realPath" }),
),
).toBe(true)
})
test("does not treat other filesystem failures as permission denied", () => {
expect(
isPermissionDenied(PlatformError.systemError({ _tag: "NotFound", module: "FileSystem", method: "realPath" })),
).toBe(false)
expect(
isPermissionDenied(
PlatformError.systemError({
_tag: "Unknown",
module: "FileSystem",
method: "realPath",
cause: Object.assign(new Error("EIO: i/o error"), { code: "EIO" }),
}),
),
).toBe(false)
})