Compare commits

...
12 changed files with 347 additions and 80 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
+11 -1
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"
@@ -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,
],
})
}
+43 -3
View File
@@ -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<LocationServiceMap.Service> {
@@ -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<Location.Ref, { close?: Effect.Effect<void> }>()
const inner: LayerMap.LayerMap<Location.Ref, LocationServices> = 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"
)
}
+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",
{
+21 -3
View File
@@ -1,8 +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 } from "@opencode/protocol/errors"
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,6 +16,7 @@ export type LocationServices = Layer.Success<ReturnType<(typeof LocationServiceM
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>) {
@@ -61,8 +66,21 @@ export const layer = Layer.effect(
return LocationMiddleware.of((effect) =>
Effect.gen(function* () {
const request = yield* HttpServerRequest.HttpServerRequest
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
return yield* effect.pipe(
Effect.provide(locations.get(requestRef(request))),
Effect.catchDefect(locationFailure),
)
}),
)
}),
)
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)),
)
}),
)
+2
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,
+104 -3
View File
@@ -5,8 +5,10 @@ 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 { tmpdir } from "../../core/test/fixture/tmpdir"
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")
}),
@@ -121,6 +125,103 @@ 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("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
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 "../../core/src/location-services"
// 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)
})