From d74adc2beb2628162fb09e6a1f818f749a98ddd5 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Thu, 24 Sep 2026 18:47:08 +0200 Subject: [PATCH 1/7] fix(server): return typed errors for unavailable project folders --- .../client/src/promise/generated/client.ts | 128 +++++++++--------- .../client/src/promise/generated/types.ts | 16 +++ packages/protocol/src/client.ts | 8 +- packages/protocol/src/errors.ts | 18 +++ packages/server/src/location.ts | 76 ++++++++--- packages/server/src/options.ts | 2 + packages/server/src/routes.ts | 4 +- packages/server/src/workerd.ts | 2 +- packages/server/test/fetch.test.ts | 56 +++++++- .../server/test/location-permission.test.ts | 21 +++ 10 files changed, 250 insertions(+), 81 deletions(-) create mode 100644 packages/server/test/location-permission.test.ts diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 60653cc73335..c0b9e9501d79 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -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( - { 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( - { 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, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 4d51de939f71..a75e71faa010 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -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 diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 2d6cdb9c50e1..32626655b2f8 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -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()( "@opencode/client/LocationMiddleware", + { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }, ) {} class SessionLocationMiddleware extends HttpApiMiddleware.Service()( diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index 5e2ee1705c8a..c9dff9aac184 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -11,6 +11,24 @@ export class InvalidRequestError extends Schema.TaggedError { httpApiStatus: 400 }, ) {} +export class LocationDirectoryNotFoundError extends Schema.TaggedError()( + "LocationDirectoryNotFoundError", + { + directory: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 400 }, +) {} + +export class LocationPermissionDeniedError extends Schema.TaggedError()( + "LocationPermissionDeniedError", + { + directory: Schema.String, + message: Schema.String, + }, + { httpApiStatus: 403 }, +) {} + export class RpcError extends Schema.TaggedError()( "RpcError", { diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 83e66df301dd..dcf83456faef 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -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> -export class LocationMiddleware extends HttpApiMiddleware.Service()( - "@opencode/HttpApiLocation", -) {} +export class LocationMiddleware extends HttpApiMiddleware.Service< + LocationMiddleware, + { provides: LocationServices } +>()("@opencode/HttpApiLocation", { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }) {} export function response(data: Effect.Effect) { return Effect.gen(function* () { @@ -54,15 +57,56 @@ 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))) + }), + ) + }), + ) + +// macOS can report EPERM as a PlatformError with reason Unknown instead of PermissionDenied. +export function isPermissionDenied(error: PlatformError) { + if (error.reason._tag === "PermissionDenied") return true + const cause = error.cause + return ( + typeof cause === "object" && + cause !== null && + "code" in cause && + (cause.code === "EPERM" || cause.code === "EACCES") + ) +} diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 489006a36233..77038e3f761a 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -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), }), ), }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c4f746f1d82b..c2245a4d9ffc 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -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( 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), diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index 949ee3fd4d06..bb3e3193a1a3 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -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. diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 3bdfa1d1769a..9920673f4f30 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -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) diff --git a/packages/server/test/location-permission.test.ts b/packages/server/test/location-permission.test.ts new file mode 100644 index 000000000000..6c9179fd72e4 --- /dev/null +++ b/packages/server/test/location-permission.test.ts @@ -0,0 +1,21 @@ +import { expect, test } from "bun:test" +import { PlatformError } from "effect" +import { isPermissionDenied } from "../src/location" + +test("recognizes macOS EPERM even when the platform reason is Unknown", () => { + expect( + isPermissionDenied( + PlatformError.systemError({ + _tag: "Unknown", + module: "FileSystem", + method: "realPath", + cause: Object.assign(new Error("operation not permitted"), { code: "EPERM" }), + }), + ), + ).toBe(true) + expect( + isPermissionDenied( + PlatformError.systemError({ _tag: "NotFound", module: "FileSystem", method: "realPath" }), + ), + ).toBe(false) +}) From eb15a9b7891754fb2189d3a1b1c5ae15a4bffa30 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Thu, 24 Sep 2026 19:21:50 +0200 Subject: [PATCH 2/7] fix(server): match the macOS EPERM privacy denial exactly --- packages/server/src/location.ts | 6 ++-- .../server/test/location-permission.test.ts | 33 +++++++++++++++---- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index dcf83456faef..05b52b956704 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -99,14 +99,16 @@ export const layer = (directoryCheck = true) => }), ) -// macOS can report EPERM as a PlatformError with reason Unknown instead of PermissionDenied. +// 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//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" || cause.code === "EACCES") + cause.code === "EPERM" ) } diff --git a/packages/server/test/location-permission.test.ts b/packages/server/test/location-permission.test.ts index 6c9179fd72e4..5bb43d15c857 100644 --- a/packages/server/test/location-permission.test.ts +++ b/packages/server/test/location-permission.test.ts @@ -2,20 +2,39 @@ import { expect, test } from "bun:test" import { PlatformError } from "effect" import { isPermissionDenied } from "../src/location" -test("recognizes macOS EPERM even when the platform reason is Unknown", () => { +// 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("operation not permitted"), { code: "EPERM" }), + cause: Object.assign(new Error("EIO: i/o error"), { code: "EIO" }), }), ), - ).toBe(true) - expect( - isPermissionDenied( - PlatformError.systemError({ _tag: "NotFound", module: "FileSystem", method: "realPath" }), - ), ).toBe(false) }) From d26b7ec3b0d608bd7b36f174300af093b850fd26 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:25:02 +0000 Subject: [PATCH 3/7] fix(server): check project directory on location boot --- packages/core/src/effect/app-node-builder.ts | 18 +++- packages/core/src/location-services.ts | 51 +++++++++- packages/server/src/location.ts | 94 +++++++------------ .../server/src/middleware/form-location.ts | 13 ++- .../server/src/middleware/session-location.ts | 13 ++- packages/server/src/routes.ts | 6 +- packages/server/test/fetch.test.ts | 51 +++++++++- .../server/test/location-permission.test.ts | 2 +- 8 files changed, 169 insertions(+), 79 deletions(-) diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index dfefbf8d6620..d716ad1a4f3a 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -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(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { +export function build( + root: LayerNode.Graph, + 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, + ], }) } diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 095811cc39a7..4ed94331d506 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -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 { return Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const owner = yield* Effect.scope + const fs = options.directoryCheck ? yield* Effect.serviceOption(FSUtil.Service) : Option.none() + if (options.directoryCheck && Option.isNone(fs)) return yield* Effect.die("Directory check requires FSUtil") const builds = MutableHashMap.empty }>() const inner: LayerMap.LayerMap = 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" + ) +} diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 05b52b956704..0bf98b1227a7 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -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> -export class LocationMiddleware extends HttpApiMiddleware.Service< - LocationMiddleware, - { provides: LocationServices } ->()("@opencode/HttpApiLocation", { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }) {} +export class LocationMiddleware extends HttpApiMiddleware.Service()( + "@opencode/HttpApiLocation", + { error: [LocationDirectoryNotFoundError, LocationPermissionDeniedError] }, +) {} export function response(data: Effect.Effect) { 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//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 { + 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) } diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts index dc18de75fbda..fae264dde166 100644 --- a/packages/server/src/middleware/form-location.ts +++ b/packages/server/src/middleware/form-location.ts @@ -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)), ) }), ) diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index f3a9a2234483..49d7e8e151bd 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -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)), ) }), ) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index c2245a4d9ffc..b443f3375d54 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -148,7 +148,9 @@ function makeRoutes( ...(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( 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), diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 9920673f4f30..171a510d50bb 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -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 diff --git a/packages/server/test/location-permission.test.ts b/packages/server/test/location-permission.test.ts index 5bb43d15c857..4c27dfa0fb13 100644 --- a/packages/server/test/location-permission.test.ts +++ b/packages/server/test/location-permission.test.ts @@ -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 = () => From 6e5e68f243b9befe3756ea507bc704f69f6c06e3 Mon Sep 17 00:00:00 2001 From: Brendonovich <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:39:10 +0000 Subject: [PATCH 4/7] refactor(server): remove directory check option --- packages/core/src/effect/app-node-builder.ts | 10 +++------- packages/core/src/location-services.ts | 9 ++------- packages/server/src/options.ts | 2 -- packages/server/src/routes.ts | 4 +--- packages/server/src/workerd.ts | 2 +- 5 files changed, 7 insertions(+), 20 deletions(-) diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index d716ad1a4f3a..0f4f8961e834 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -20,19 +20,15 @@ const instances = makeGlobalNode({ deps: [LocationServiceMap.node], }) -export function build( - root: LayerNode.Graph, - replacements: LayerNode.Replacements = [], - options: { directoryCheck?: boolean } = {}, -) { +export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { const bindings = [Instance.node.replace(instances), ...replacements] return LayerNode.compile(root, { replacements: [ LocationServiceMap.node.replace( makeGlobalNode({ service: LocationServiceMap.Service, - layer: buildLocationServiceMap(bindings, options), - deps: options.directoryCheck ? [FSUtil.node] : [], + layer: buildLocationServiceMap(bindings), + deps: [FSUtil.node], }), ), ...bindings, diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 4ed94331d506..7df80b63933d 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -26,14 +26,12 @@ export class PermissionDeniedError extends Error { export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], - options: { directoryCheck?: boolean } = {}, ): Layer.Layer { return Layer.effect( LocationServiceMap.Service, Effect.gen(function* () { const owner = yield* Effect.scope - const fs = options.directoryCheck ? yield* Effect.serviceOption(FSUtil.Service) : Option.none() - if (options.directoryCheck && Option.isNone(fs)) return yield* Effect.die("Directory check requires FSUtil") + const fs = yield* Effect.serviceOption(FSUtil.Service) const builds = MutableHashMap.empty }>() const inner: LayerMap.LayerMap = yield* LayerMap.make( (ref: Location.Ref) => { @@ -41,10 +39,7 @@ export function buildLocationServiceMap( MutableHashMap.set(builds, ref, build) return Layer.fromBuild((memoMap, scope) => Effect.suspend(() => - (!options.directoryCheck || ref.workspaceID || Option.isNone(fs) - ? Effect.void - : checkDirectory(fs.value, ref) - ).pipe( + (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)), ), diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 77038e3f761a..489006a36233 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -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), }), ), }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index b443f3375d54..eea50e1f61e7 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -148,9 +148,7 @@ function makeRoutes( ...(instances ? [Instance.node.replace(instances(() => replacements))] : []), ...overrides, ] - return AppNodeBuilder.build(applicationServices, replacements, { - directoryCheck: options.fs?.directoryCheck !== false && !options.simulation, - }) + return AppNodeBuilder.build(applicationServices, replacements) } const serviceLayer = options.simulation ? Layer.unwrap( diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index bb3e3193a1a3..949ee3fd4d06 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -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. From 137e02ea0a6cdfc7be731bcae234e0057bc85261 Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Fri, 25 Sep 2026 13:48:57 +0200 Subject: [PATCH 5/7] fix(server): retain explicit access checks and runtime opt-outs Use Location boot probing for non-HTTP execution and reload, while checking location.get to catch folders lost after boot. Preserve workerd and simulation opt-outs, expose errors on session routes, and keep direct session moves available for recovery. --- .../client/src/promise/generated/client.ts | 58 ++++++------ packages/core/src/effect/app-node-builder.ts | 8 +- packages/core/src/location-services.ts | 8 +- packages/protocol/src/client.ts | 2 +- packages/protocol/src/groups/permission.ts | 16 ++-- packages/server/src/handlers/permission.ts | 9 +- packages/server/src/location.ts | 42 +++++---- packages/server/src/options.ts | 2 + packages/server/src/routes.ts | 5 +- packages/server/src/workerd.ts | 2 +- packages/server/test/fetch.test.ts | 92 ++++++++++++++++++- packages/server/test/workerd.test.ts | 15 +++ 12 files changed, 189 insertions(+), 70 deletions(-) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index c0b9e9501d79..fe759d7c4221 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -651,7 +651,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`, body: { before: input["before"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -663,7 +663,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/agent`, body: { agent: input["agent"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -675,7 +675,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/model`, body: { model: input["model"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -687,7 +687,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}`, body: { title: input["title"], metadata: input["metadata"], permissions: input["permissions"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -720,7 +720,7 @@ export function make(options: ClientOptions) { resume: input["resume"], }, successStatus: 200, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: false, }, requestOptions, @@ -739,7 +739,7 @@ export function make(options: ClientOptions) { delivery: input["delivery"], }, successStatus: 204, - declaredStatuses: [400, 401, 404, 500], + declaredStatuses: [400, 401, 403, 404, 500], empty: true, }, requestOptions, @@ -751,7 +751,7 @@ export function make(options: ClientOptions) { path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/skill`, body: { id: input["id"], resume: input["resume"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -770,7 +770,7 @@ export function make(options: ClientOptions) { resume: input["resume"], }, successStatus: 200, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: false, }, requestOptions, @@ -782,7 +782,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/shell`, body: { id: input["id"], command: input["command"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -794,7 +794,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/compact`, body: { id: input["id"], delivery: input["delivery"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: false, }, requestOptions, @@ -805,7 +805,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/wait`, successStatus: 204, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: true, }, requestOptions, @@ -818,7 +818,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/stage`, body: { messageID: input["messageID"], files: input["files"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 409, 500], + declaredStatuses: [400, 401, 403, 404, 409, 500], empty: false, }, requestOptions, @@ -829,7 +829,7 @@ export function make(options: ClientOptions) { method: "DELETE", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert`, successStatus: 204, - declaredStatuses: [400, 401, 404, 409, 500], + declaredStatuses: [400, 401, 403, 404, 409, 500], empty: true, }, requestOptions, @@ -840,7 +840,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/revert/commit`, successStatus: 204, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: true, }, requestOptions, @@ -913,7 +913,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/instructions/entries`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -925,7 +925,7 @@ export function make(options: ClientOptions) { path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`, body: { value: input["value"] }, successStatus: 204, - declaredStatuses: [400, 401, 404, 413], + declaredStatuses: [400, 401, 403, 404, 413], empty: true, }, requestOptions, @@ -936,7 +936,7 @@ export function make(options: ClientOptions) { method: "DELETE", path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/instructions/entries/${encodeURIComponent(input.key)}`, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -950,7 +950,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/generate`, body: { prompt: input["prompt"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -974,7 +974,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/interrupt`, query: { resume: input["resume"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -985,7 +985,7 @@ export function make(options: ClientOptions) { method: "POST", path: `/api/session/${encodeURIComponent(input.sessionID)}/background`, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1010,7 +1010,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/form`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1022,7 +1022,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/form`, body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: false, }, requestOptions, @@ -1033,7 +1033,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1045,7 +1045,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`, body: { answer: input["answer"] }, successStatus: 204, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: true, }, requestOptions, @@ -1056,7 +1056,7 @@ export function make(options: ClientOptions) { method: "DELETE", path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`, successStatus: 204, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: true, }, requestOptions, @@ -1520,7 +1520,7 @@ export function make(options: ClientOptions) { agent: input["agent"], }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1531,7 +1531,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1542,7 +1542,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1554,7 +1554,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`, body: { decision: input["decision"], message: input["message"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, diff --git a/packages/core/src/effect/app-node-builder.ts b/packages/core/src/effect/app-node-builder.ts index 0f4f8961e834..029aa3322a6c 100644 --- a/packages/core/src/effect/app-node-builder.ts +++ b/packages/core/src/effect/app-node-builder.ts @@ -20,14 +20,18 @@ const instances = makeGlobalNode({ deps: [LocationServiceMap.node], }) -export function build(root: LayerNode.Graph, replacements: LayerNode.Replacements = []) { +export function build( + root: LayerNode.Graph, + replacements: LayerNode.Replacements = [], + options: { readonly directoryCheck?: boolean } = {}, +) { const bindings = [Instance.node.replace(instances), ...replacements] return LayerNode.compile(root, { replacements: [ LocationServiceMap.node.replace( makeGlobalNode({ service: LocationServiceMap.Service, - layer: buildLocationServiceMap(bindings), + layer: buildLocationServiceMap(bindings, options), deps: [FSUtil.node], }), ), diff --git a/packages/core/src/location-services.ts b/packages/core/src/location-services.ts index 7df80b63933d..f4054ff0936d 100644 --- a/packages/core/src/location-services.ts +++ b/packages/core/src/location-services.ts @@ -26,6 +26,7 @@ export class PermissionDeniedError extends Error { export function buildLocationServiceMap( replacements: LayerNode.Replacements = [], + options: { readonly directoryCheck?: boolean } = {}, ): Layer.Layer { return Layer.effect( LocationServiceMap.Service, @@ -39,7 +40,10 @@ export function buildLocationServiceMap( MutableHashMap.set(builds, ref, build) return Layer.fromBuild((memoMap, scope) => Effect.suspend(() => - (ref.workspaceID || Option.isNone(fs) ? Effect.void : checkDirectory(fs.value, ref)).pipe( + (options.directoryCheck === false || 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)), ), @@ -104,7 +108,7 @@ export function buildLocationServiceMap( ) } -function checkDirectory(fs: FSUtil.Interface, ref: Location.Ref) { +export 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))), diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index 32626655b2f8..c57297c247f5 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -17,7 +17,7 @@ class LocationMiddleware extends HttpApiMiddleware.Service() class SessionLocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/client/SessionLocationMiddleware", - { error: [InvalidRequestError, SessionNotFoundError] }, + { error: [InvalidRequestError, SessionNotFoundError, LocationDirectoryNotFoundError, LocationPermissionDeniedError] }, ) {} type ClientApiShape = Api< diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts index b35a71c8f9cb..53bf76a08036 100644 --- a/packages/protocol/src/groups/permission.ts +++ b/packages/protocol/src/groups/permission.ts @@ -90,13 +90,15 @@ export const makePermissionGroup = < params: { sessionID: Session.ID }, success: Schema.Struct({ data: Schema.Array(Permission.Request) }), error: SessionNotFoundError, - }).annotateMerge( - OpenApi.annotations({ - identifier: "session.permission.list", - summary: "List session permission requests", - description: "Retrieve pending permission requests owned by a session.", - }), - ), + }) + .middleware(sessionLocationMiddleware) + .annotateMerge( + OpenApi.annotations({ + identifier: "session.permission.list", + summary: "List session permission requests", + description: "Retrieve pending permission requests owned by a session.", + }), + ), ) .add( HttpApiEndpoint.get("session.permission.get", "/api/session/:sessionID/permission/:requestID", { diff --git a/packages/server/src/handlers/permission.ts b/packages/server/src/handlers/permission.ts index cec7bdb9bd9e..37b55648b970 100644 --- a/packages/server/src/handlers/permission.ts +++ b/packages/server/src/handlers/permission.ts @@ -1,13 +1,11 @@ -import { Instance } from "@opencode/core/instance/service" import { Location } from "@opencode/core/location" import { Permission } from "@opencode/core/permission" import { PermissionSaved } from "@opencode/core/permission/saved" -import { Session } from "@opencode/core/session" import { Effect } from "effect" import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi" import { Api } from "../api" import { PermissionNotFoundError } from "@opencode/protocol/errors" -import { response, sessionInfo } from "../location" +import { response } from "../location" import { missingSession } from "./session-error" function missingRequest(id: Permission.ID) { @@ -16,8 +14,6 @@ function missingRequest(id: Permission.ID) { export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", (handlers) => Effect.gen(function* () { - const instances = yield* Instance.Service - const sessions = yield* Session.Service const requireOwnedRequest = Effect.fnUntraced(function* ( sessionID: Permission.Request["sessionID"], requestID: Permission.ID, @@ -59,10 +55,9 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission", .handle( "session.permission.list", Effect.fn(function* (ctx) { - const session = yield* sessionInfo(sessions, ctx.params.sessionID) const requests = yield* Permission.Service.use((permission) => permission.forSession(ctx.params.sessionID), - ).pipe(instances.provide(session)) + ) return { data: requests } }), ) diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 0bf98b1227a7..fc00664192c4 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -1,5 +1,10 @@ import { Location } from "@opencode/core/location" -import { DirectoryNotFoundError, LocationServiceMap, PermissionDeniedError } from "@opencode/core/location-services" +import { + checkDirectory, + DirectoryNotFoundError, + LocationServiceMap, + PermissionDeniedError, +} from "@opencode/core/location-services" import { AbsolutePath } from "@opencode/core/schema" import { Session } from "@opencode/core/session" import { @@ -7,6 +12,7 @@ import { LocationDirectoryNotFoundError, LocationPermissionDeniedError, } from "@opencode/protocol/errors" +import { FSUtil } from "@opencode/util/fs-util" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -59,21 +65,25 @@ 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))), - Effect.catchDefect(locationFailure), - ) - }), - ) - }), -) +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, options) => + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest + const ref = requestRef(request) + // An explicit location.get is the client's access check. A Location may have booted + // before the directory was removed; cached Location services do not probe again. + if (directoryCheck && !ref.workspaceID && options.endpoint.identifier === "location.get") + yield* checkDirectory(fs, ref).pipe(Effect.catch(locationFailure)) + return yield* effect.pipe(Effect.provide(locations.get(ref)), Effect.catchDefect(locationFailure)) + }), + ) + }), + ) export function locationFailure( defect: unknown, diff --git a/packages/server/src/options.ts b/packages/server/src/options.ts index 489006a36233..66d647538e42 100644 --- a/packages/server/src/options.ts +++ b/packages/server/src/options.ts @@ -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 (workerd, simulation) opt out of the boot probe. + directoryCheck: Schema.optional(Schema.Boolean), }), ), }) diff --git a/packages/server/src/routes.ts b/packages/server/src/routes.ts index eea50e1f61e7..e8b4ed3801b4 100644 --- a/packages/server/src/routes.ts +++ b/packages/server/src/routes.ts @@ -141,6 +141,7 @@ function makeRoutes( }), ), ] + const directoryCheck = options.fs?.directoryCheck !== false && !options.simulation const build = (overrides: LayerNode.Replacements) => { const replacements: LayerNode.Replacements = [ ...standard, @@ -148,7 +149,7 @@ function makeRoutes( ...(instances ? [Instance.node.replace(instances(() => replacements))] : []), ...overrides, ] - return AppNodeBuilder.build(applicationServices, replacements) + return AppNodeBuilder.build(applicationServices, replacements, { directoryCheck }) } const serviceLayer = options.simulation ? Layer.unwrap( @@ -179,7 +180,7 @@ function makeRoutes( 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(directoryCheck)), Layer.provide(authorizationLayer), Layer.provide(schemaErrorLayer), Layer.provide(auth), diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index 949ee3fd4d06..bb3e3193a1a3 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -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. diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 171a510d50bb..4be2f6c697cf 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -179,20 +179,106 @@ it.live("checks a directory once per location boot, not on every request", () => }), ) - expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + expect((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200) const afterBoot = checked.length expect(afterBoot).toBeGreaterThan(0) - expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + expect((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200) expect(checked).toHaveLength(afterBoot) + expect((yield* Effect.promise(() => request("/api/location"))).status).toBe(200) + expect(checked).toHaveLength(afterBoot + 1) 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((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200) expect(checked).toHaveLength(afterReload) }), ) +it.live("re-checks a booted project directory on location.get", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make(options) + const root = yield* tmpdirScoped() + const directory = path.join(root.path, "project") + yield* Effect.promise(() => fs.mkdir(directory)) + const request = (endpoint: string) => + handler( + new Request(`http://opencode.local${endpoint}`, { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }), + ) + expect((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200) + + yield* Effect.promise(() => fs.rm(directory, { recursive: true })) + // Warm requests reuse the booted Location without probing the directory. + expect((yield* Effect.promise(() => request("/api/integration"))).status).toBe(200) + const response = yield* Effect.promise(() => request("/api/location")) + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + _tag: "LocationDirectoryNotFoundError", + directory, + }) + }), +) + +it.live("reports a missing session directory and still moves the session", () => + Effect.gen(function* () { + const handler = yield* ServerFetch.make(options) + const root = yield* tmpdirScoped() + const source = path.join(root.path, "source") + const destination = path.join(root.path, "destination") + yield* Effect.promise(() => Promise.all([fs.mkdir(source), fs.mkdir(destination)])) + const created = (yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/session", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ location: { directory: source } }), + }), + ).then((response) => response.json()), + )) as { data: { id: string } } + const forms = () => + Effect.promise(() => handler(new Request(`http://opencode.local/api/session/${created.data.id}/form`))) + + yield* Effect.promise(() => fs.rm(source, { recursive: true })) + const response = yield* forms() + expect(response.status).toBe(400) + expect(yield* Effect.promise(() => response.json())).toMatchObject({ + _tag: "LocationDirectoryNotFoundError", + directory: source, + }) + // Session execution routes use a different middleware from form routes. + const action = yield* Effect.promise(() => + handler(new Request(`http://opencode.local/api/session/${created.data.id}/background`, { method: "POST" })), + ) + expect(action.status).toBe(400) + expect(yield* Effect.promise(() => action.json())).toMatchObject({ + _tag: "LocationDirectoryNotFoundError", + directory: source, + }) + const pending = yield* Effect.promise(() => + handler(new Request(`http://opencode.local/api/session/${created.data.id}/permission`)), + ) + expect(pending.status).toBe(400) + expect(yield* Effect.promise(() => pending.json())).toMatchObject({ + _tag: "LocationDirectoryNotFoundError", + directory: source, + }) + + const moved = yield* Effect.promise(() => + handler( + new Request(`http://opencode.local/api/session/${created.data.id}/move`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ directory: destination }), + }), + ), + ) + expect(moved.status).toBe(204) + expect((yield* forms()).status).toBe(200) + }), +) + it.live("reports denied project access without breaking other locations", () => Effect.gen(function* () { if (process.platform === "win32") return diff --git a/packages/server/test/workerd.test.ts b/packages/server/test/workerd.test.ts index ed2099473c74..bbecbaf49020 100644 --- a/packages/server/test/workerd.test.ts +++ b/packages/server/test/workerd.test.ts @@ -13,6 +13,7 @@ it.live("boots the workerd profile over durable object storage", () => password: "secret", app: { version: "workerd-test" }, config: { content: "{}" }, + models: { fetch: false }, }) const unauthorized = yield* Effect.promise(() => handler(new Request("http://opencode.local/api/info"))) @@ -29,5 +30,19 @@ it.live("boots the workerd profile over durable object storage", () => const body: unknown = yield* Effect.promise(() => status.json()) expect(body).toMatchObject({ version: "workerd-test" }) + + // The host does not have this project directory; Location boot must not probe its host filesystem. + const directory = "/workerd-project-only" + const location = yield* Effect.promise(() => + handler( + new Request("http://opencode.local/api/location", { + headers: { + authorization: `Basic ${btoa("opencode:secret")}`, + "x-opencode-directory": encodeURIComponent(directory), + }, + }), + ), + ) + expect(location.status).toBe(200) }), ) From 74cf924c0a5b0f556f191a9d7abef99488f5705e Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Thu, 24 Sep 2026 18:48:23 +0200 Subject: [PATCH 6/7] fix(desktop): recover from missing or inaccessible project folders --- packages/app/src/app.tsx | 2 + packages/app/src/desktop.ts | 1 + packages/app/src/home/projects/controller.tsx | 109 +++++++++++++++--- packages/app/src/runtime/i18n/en.ts | 10 ++ .../app/src/runtime/server/errors.test.ts | 30 ++++- packages/app/src/runtime/server/errors.ts | 19 +++ packages/app/src/shell/errors/error.tsx | 10 +- .../main/lifecycle/default-project.test.ts | 34 ++++++ .../src/main/lifecycle/default-project.ts | 20 ++++ .../desktop/src/main/lifecycle/onboarding.ts | 9 +- packages/desktop/src/renderer/api-types.ts | 2 +- packages/desktop/src/renderer/desktop-app.tsx | 3 +- .../desktop/src/renderer/onboarding/index.tsx | 47 +++++++- packages/desktop/src/shared/ipc-rpc/app.ts | 2 +- 14 files changed, 264 insertions(+), 34 deletions(-) create mode 100644 packages/desktop/src/main/lifecycle/default-project.test.ts create mode 100644 packages/desktop/src/main/lifecycle/default-project.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 8e0286a75222..0871e0d2cae7 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -59,6 +59,7 @@ function BodyTypography() { export function AppBaseProviders( props: ParentProps<{ locale?: Locale + onError?: (error: unknown) => void onNativeTranslations?: Parameters[0]["onNativeTranslations"] onThemeApplied?: (mode: "light" | "dark", scheme: "system" | "light" | "dark") => void }>, @@ -76,6 +77,7 @@ export function AppBaseProviders( { + props.onError?.(error) void import("@sentry/solid").then(({ captureException }) => captureException(error)) return }} diff --git a/packages/app/src/desktop.ts b/packages/app/src/desktop.ts index 1f79856633e4..fbbb28306f78 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -15,6 +15,7 @@ export type { } from "./runtime/platform/browser-pane" export { ServerConnection, useServers } from "./runtime/server/registry" export { useGlobal } from "./runtime/server/runtime" +export { formatServerError } from "./runtime/server/errors" export { useTabs } from "./shell/tabs/tabs" export { createDraftStore } from "./runtime/persistence/drafts" export { createNamespaceStorage, type NamespaceStorage } from "./runtime/persistence/namespace" diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 4e1d0cb696d9..67a3a1d80fa0 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -17,6 +17,8 @@ import type { HomeController } from "../model" import { useGlobal } from "@/runtime/server/runtime" import { SessionTransfer } from "@opencode/schema/session-transfer" import { useSshAuthenticate } from "@/servers/ssh/authenticate" +import { getFilename } from "@opencode/util/path" +import { formatProjectLocationError, projectLocationError } from "@/runtime/server/errors" import { useRevealProject } from "./reveal" export const HomeServersSchema = Schema.Struct({ @@ -44,12 +46,73 @@ export function createHomeProjectsController(home: HomeController) { return [project.worktree, ...(project.sandboxes ?? [])] } + function closeProject(conn: ServerConnection.Any, directory: string) { + const next = closeHomeProject( + home.selection.value(), + ServerConnection.key(conn), + home.server.context(conn).projects, + directory, + ) + if (next) home.selection.set(next) + } + + function accessible(conn: ServerConnection.Any, directory: string) { + return home.server + .context(conn) + .sdk.api.location.get({ location: { directory } }) + .then( + () => true, + (error: unknown) => { + showUnavailable(conn, directory, error) + return false + }, + ) + } + + function showUnavailable(conn: ServerConnection.Any, directory: string, error: unknown) { + const location = projectLocationError(error) + if (!location) { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: language.t("error.project.unavailable", { directory }), + }) + return + } + const saved = home.server + .context(conn) + .projects.list() + .some((project) => project.worktree === directory) + showToast({ + variant: "error", + persistent: true, + title: language.t( + location.type === "missing" ? "toast.project.missing.title" : "toast.project.permissionDenied.title", + ), + description: formatProjectLocationError(location, language.t), + actions: + location.type === "missing" && saved + ? [{ label: language.t("toast.project.missing.remove"), onClick: () => closeProject(conn, directory) }] + : undefined, + }) + } + + function add(conn: ServerConnection.Any, directories: string[]) { + if (platform.platform !== "desktop" || !ServerConnection.local(conn)) return home.project.add(conn, directories) + void Promise.all(directories.map((directory) => accessible(conn, directory))).then((available) => + home.project.add( + conn, + directories.filter((_, index) => available[index]), + ), + ) + } + function choose(conn: ServerConnection.Any) { pickDirectory({ server: conn, title: language.t("command.project.open"), multiple: true, - onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), + onSelect: (result) => add(conn, homeProjectDirectories(result)), }) } @@ -93,13 +156,29 @@ export function createHomeProjectsController(home: HomeController) { recentlyClosed: home.project.recentlyClosed, homedir: home.project.homedir, select: (conn: ServerConnection.Any, directory: string) => { - if (authenticate(conn, () => home.project.select(conn, directory))) return - home.project.select(conn, directory) + const select = () => { + const selected = home.selection.value() + if (selected.server === ServerConnection.key(conn) && selected.directory === directory) + return home.project.select(conn, directory) + if (platform.platform !== "desktop" || !ServerConnection.local(conn)) return home.project.select(conn, directory) + void accessible(conn, directory).then((ok) => { + if (ok) home.project.select(conn, directory) + }) + } + if (authenticate(conn, select)) return + select() }, - add: home.project.add, + add, openNewSession: (conn: ServerConnection.Any, directory: string) => { - if (authenticate(conn, () => home.project.openProjectNewSession(conn, directory))) return - home.project.openProjectNewSession(conn, directory) + const open = () => { + if (platform.platform !== "desktop" || !ServerConnection.local(conn)) + return home.project.openProjectNewSession(conn, directory) + void accessible(conn, directory).then((ok) => { + if (ok) home.project.openProjectNewSession(conn, directory) + }) + } + if (authenticate(conn, open)) return + open() }, canImportSession: !!platform.openAttachmentPickerDialog, importSession: (conn: ServerConnection.Any, project: LocalProject) => { @@ -131,9 +210,11 @@ export function createHomeProjectsController(home: HomeController) { }) }, edit: (conn: ServerConnection.Any, project: LocalProject) => { - settings.openProject({ - server: ServerConnection.key(conn), - project: project.worktree, + if (platform.platform !== "desktop" || !ServerConnection.local(conn)) + return settings.openProject({ server: ServerConnection.key(conn), project: project.worktree }) + void accessible(conn, project.worktree).then((ok) => { + if (!ok) return + settings.openProject({ server: ServerConnection.key(conn), project: project.worktree }) }) }, unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { @@ -151,15 +232,7 @@ export function createHomeProjectsController(home: HomeController) { if (home.server.health(conn)?.healthy === false) return choose(conn) }, - close: (conn: ServerConnection.Any, directory: string) => { - const next = closeHomeProject( - home.selection.value(), - ServerConnection.key(conn), - home.server.context(conn).projects, - directory, - ) - if (next) home.selection.set(next) - }, + close: closeProject, move: (conn: ServerConnection.Any, worktree: string, index: number) => { home.server.context(conn).projects.move(worktree, index) }, diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 55170f5669c9..52fea52945d1 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -734,6 +734,16 @@ export const dict = { "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", + "toast.project.defaultUnavailable.title": "Cannot open default project", + "toast.project.missing.title": "Project folder not found", + "toast.project.missing.remove": "Remove project", + "toast.project.permissionDenied.title": "OpenCode can't access this folder", + "error.project.missing": + "{{directory}} was moved, renamed, or deleted. Restore the folder at that path, or remove this project from OpenCode and open it from its new location.", + "error.project.permissionDenied": + "OpenCode doesn't have permission to open {{directory}}. On macOS, open System Settings > Privacy & Security > Files & Folders (or Full Disk Access) and allow OpenCode, then try again.", + "error.project.unavailable": + "Couldn't open the project at {{directory}}. Check that the folder exists and OpenCode has access to it.", "toast.migration.failed.title": "Data migration failed", "toast.migration.progress.clearingOldEvents": "Clearing old events", "toast.migration.progress.migratingSessions": "Migrating sessions", diff --git a/packages/app/src/runtime/server/errors.test.ts b/packages/app/src/runtime/server/errors.test.ts index ac06b6ed3088..af222892d69d 100644 --- a/packages/app/src/runtime/server/errors.test.ts +++ b/packages/app/src/runtime/server/errors.test.ts @@ -1,7 +1,12 @@ import { describe, expect, test } from "bun:test" import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors" -import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors" +import { + formatServerError, + isSessionNotFoundError, + parseReadableConfigInvalidError, + projectLocationError, +} from "./errors" function fill(text: string, vars?: Record) { if (!vars) return text @@ -97,6 +102,29 @@ describe("formatServerError", () => { expect(formatServerError(error, language.t)).toBe("File not found: deleted.txt") }) + test("explains missing and inaccessible project folders", () => { + const sdkError = (body: unknown) => new Error("Request failed", { cause: { body } }) + const missing = sdkError({ + _tag: "LocationDirectoryNotFoundError", + directory: "C:\\Users\\Test User\\Projects\\moved-project", + message: "Project directory not found", + }) + const denied = sdkError({ + _tag: "LocationPermissionDeniedError", + directory: "/Users/example/Documents/private-project", + message: "Cannot access project directory", + }) + + expect(projectLocationError(missing)).toEqual({ + type: "missing", + directory: "C:\\Users\\Test User\\Projects\\moved-project", + }) + expect(formatServerError(missing)).toContain("C:\\Users\\Test User\\Projects\\moved-project was moved") + expect(projectLocationError(denied)?.type).toBe("denied") + expect(formatServerError(denied)).toContain("Privacy & Security") + expect(projectLocationError(new Error("Request failed"))).toBeUndefined() + }) + test("returns provided string errors", () => { expect(formatServerError("Failed to connect to server", language.t)).toBe("Failed to connect to server") }) diff --git a/packages/app/src/runtime/server/errors.ts b/packages/app/src/runtime/server/errors.ts index 631e6a55fe86..2494cf417d7e 100644 --- a/packages/app/src/runtime/server/errors.ts +++ b/packages/app/src/runtime/server/errors.ts @@ -1,3 +1,6 @@ +import { isLocationDirectoryNotFoundError, isLocationPermissionDeniedError } from "@opencode/client/promise" +import { dict } from "../i18n/en" + export type ConfigInvalidError = { name: "ConfigInvalidError" data: { @@ -25,7 +28,23 @@ function tr(translator: Translator | undefined, key: string, text: string, vars? return out } +export function projectLocationError(error: unknown) { + const unwrapped = unwrapNamedError(error) + if (isLocationDirectoryNotFoundError(unwrapped)) return { type: "missing" as const, directory: unwrapped.directory } + if (isLocationPermissionDeniedError(unwrapped)) return { type: "denied" as const, directory: unwrapped.directory } +} + +export function formatProjectLocationError( + info: NonNullable>, + translate?: Translator, +) { + const key = info.type === "missing" ? "error.project.missing" : "error.project.permissionDenied" + return tr(translate, key, dict[key].replace("{{directory}}", info.directory), { directory: info.directory }) +} + export function formatServerError(error: unknown, translate?: Translator, fallback?: string) { + const location = projectLocationError(error) + if (location) return formatProjectLocationError(location, translate) const unwrapped = unwrapNamedError(error) if (isConfigInvalidErrorLike(unwrapped)) return parseReadableConfigInvalidError(unwrapped, translate) if (isProviderModelNotFoundErrorLike(unwrapped)) return parseReadableProviderModelNotFoundError(unwrapped, translate) diff --git a/packages/app/src/shell/errors/error.tsx b/packages/app/src/shell/errors/error.tsx index 2312bb9248a5..bbae041fbad9 100644 --- a/packages/app/src/shell/errors/error.tsx +++ b/packages/app/src/shell/errors/error.tsx @@ -8,6 +8,7 @@ import { usePlatform } from "@/runtime/platform/platform" import { useLanguage } from "@/runtime/i18n/language" import { Icon } from "@opencode/ui/icon" import { errorDescriptionKey, errorStatus } from "./description" +import { formatProjectLocationError, projectLocationError } from "@/runtime/server/errors" export type InitError = { name: string @@ -224,6 +225,7 @@ export const ErrorPage: Component = (props) => { const language = useLanguage() const formattedError = () => formatError(props.error, language.t) const status = () => errorStatus(props.error) + const projectLocation = () => projectLocationError(props.error) let recordedFatalError: Promise | undefined const [store, setStore] = createStore({ actionError: undefined as string | undefined, @@ -293,9 +295,11 @@ export const ErrorPage: Component = (props) => { {language.t(status() ? "error.page.title.status" : "error.page.title")}

- {status() - ? language.t("error.page.description.status", { status: status()! }) - : language.t(errorDescriptionKey(props.error))} + {projectLocation() + ? formatProjectLocationError(projectLocation()!, language.t) + : status() + ? language.t("error.page.description.status", { status: status()! }) + : language.t(errorDescriptionKey(props.error))}

fs.chmod(parent, 0o700)) + yield* fs.chmod(parent, 0o000) + + expect(yield* makeDefaultProject(directory)).toEqual({ permissionDenied: directory }) + + yield* fs.chmod(parent, 0o700) + expect(yield* makeDefaultProject(directory)).toBe(directory) + })) diff --git a/packages/desktop/src/main/lifecycle/default-project.ts b/packages/desktop/src/main/lifecycle/default-project.ts new file mode 100644 index 000000000000..57de189f4577 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/default-project.ts @@ -0,0 +1,20 @@ +import { Effect, FileSystem } from "effect" +import type { PlatformError } from "effect/PlatformError" + +export const makeDefaultProject = Effect.fn("Onboarding.makeDefaultProject")(function* (directory: string) { + const fs = yield* FileSystem.FileSystem + const denied = yield* fs.makeDirectory(directory, { recursive: true }).pipe( + Effect.as(false), + Effect.catchTag("PlatformError", (error) => { + if (isPermissionDenied(error)) return Effect.succeed(true) + return Effect.fail(error) + }), + ) + return denied ? { permissionDenied: directory } : directory +}) + +function isPermissionDenied(error: PlatformError) { + if (error.reason._tag === "PermissionDenied") return true + const cause = error.cause + return typeof cause === "object" && cause !== null && "code" in cause && (cause.code === "EPERM" || cause.code === "EACCES") +} diff --git a/packages/desktop/src/main/lifecycle/onboarding.ts b/packages/desktop/src/main/lifecycle/onboarding.ts index 91e9fabf1e6e..bbf3a19a1f4f 100644 --- a/packages/desktop/src/main/lifecycle/onboarding.ts +++ b/packages/desktop/src/main/lifecycle/onboarding.ts @@ -5,6 +5,7 @@ import { hasExistingAppState } from "../storage/install-state" import { FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY } from "../storage/keys" import { getStore } from "../storage/store" import { nativeT } from "../native/translations" +import { makeDefaultProject } from "./default-project" export const initializeFirstLaunchOnboarding = Effect.fn("Onboarding.initialize")(function* (userDataPath: string) { const store = getStore() @@ -39,17 +40,17 @@ export const finishFirstLaunchOnboarding = Effect.fn("Onboarding.finish")(functi return null } - const fs = yield* FileSystem.FileSystem const path = yield* Path.Path const defaultProject = createDefaultProject ? path.join(app.getPath("documents"), nativeT("desktop.onboarding.defaultProject")) : null - if (defaultProject) yield* fs.makeDirectory(defaultProject, { recursive: true }) + const result = defaultProject ? yield* makeDefaultProject(defaultProject) : null + if (result && typeof result !== "string") return result getStore().set(FIRST_LAUNCH_ONBOARDING_COMPLETE_KEY, true) yield* scoped( "onboarding", - Effect.logInfo("first launch onboarding completed", { createDefaultProject, defaultProject }), + Effect.logInfo("first launch onboarding completed", { createDefaultProject, defaultProject: result }), ) - return defaultProject + return result }) diff --git a/packages/desktop/src/renderer/api-types.ts b/packages/desktop/src/renderer/api-types.ts index ec2dbdfc1ad2..ac553c9b53e4 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -40,7 +40,7 @@ export type ElectronAPI = { getDefaultServerUrl(): Promise setDefaultServerUrl(url: string | null): Promise isFirstLaunchOnboardingPending(): Promise - finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise + finishFirstLaunchOnboarding(createDefaultProject: boolean): Promise checkAppExists(appName: string): Promise resolveAppPath(appName: string): Promise storeItems(name: string): Promise<{ items: Record; revision: number }> diff --git a/packages/desktop/src/renderer/desktop-app.tsx b/packages/desktop/src/renderer/desktop-app.tsx index a0182b77064f..bc568ffa55c6 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -28,7 +28,7 @@ import { bindDesktopMenu } from "./platform/menu" import { createSidecarResolver, initializationData, sidecarHttp } from "./startup/initialization" import { preloadStoredLocale } from "./startup/locale" import { LoadingSplash } from "./startup/splash" -import { getLastActiveUrl } from "./window/route-storage" +import { getLastActiveUrl, setLastActiveUrl } from "./window/route-storage" import { DesktopMemoryRouter } from "./window/router" import { availableStartupServer, readyWslConnections } from "./wsl/connections" import { createSshConnections } from "./ssh/connections" @@ -148,6 +148,7 @@ export function DesktopApp(props: { api: ElectronAPI; updater: UpdaterPlatform; return ( setLastActiveUrl(windowState.id, "/")} locale={locale.latest} onNativeTranslations={(bundle) => void props.api.setNativeTranslations(bundle).catch(() => undefined)} onThemeApplied={(mode, scheme) => { diff --git a/packages/desktop/src/renderer/onboarding/index.tsx b/packages/desktop/src/renderer/onboarding/index.tsx index 100d98d21012..163bd6f0a6c1 100644 --- a/packages/desktop/src/renderer/onboarding/index.tsx +++ b/packages/desktop/src/renderer/onboarding/index.tsx @@ -1,4 +1,13 @@ -import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop" +import { + formatServerError, + ServerConnection, + useCurrentRoute, + useGlobal, + useLanguage, + useServers, + useTabs, +} from "@opencode/app/desktop" +import { showToast } from "@opencode/ui/toast" import { createResource } from "solid-js" import type { ElectronAPI } from "../api-types" @@ -13,6 +22,7 @@ export function DesktopFirstLaunchOnboarding(props: { const global = useGlobal() const tabs = useTabs() const route = useCurrentRoute() + const language = useLanguage() const [completed] = createResource(async () => { await runFirstLaunchOnboarding() @@ -40,18 +50,45 @@ export function DesktopFirstLaunchOnboarding(props: { }) const directory = await props.api.finishFirstLaunchOnboarding(shouldTrigger) + if (directory && typeof directory !== "string") { + showToast({ + variant: "error", + persistent: true, + title: language.t("toast.project.defaultUnavailable.title"), + description: language.t("error.project.permissionDenied", { directory: directory.permissionDenied }), + }) + return + } if (!shouldTrigger || !directory) return console.info("[desktop-onboarding] starting first launch draft", { directory }) - const projects = server.projects.forServer(props.serverKey) - projects.open(directory) - projects.touch(directory) const connection = server.list.find((connection) => ServerConnection.key(connection) === props.serverKey) if (connection) { - const data = global.ensureServerCtx(connection).data + const context = global.ensureServerCtx(connection) + const failure = await context.sdk.api.location.get({ location: { directory } }).then( + () => undefined, + (error: unknown) => ({ error }), + ) + if (failure) { + showToast({ + variant: "error", + persistent: true, + title: language.t("toast.project.defaultUnavailable.title"), + description: formatServerError( + failure.error, + language.t, + language.t("error.project.unavailable", { directory }), + ), + }) + return + } + const data = context.data // Load the initial provider/model state before the draft transition exposes the composer. await Promise.all([data.location.provider.sync({ directory }), data.location.model.sync({ directory })]) } + const projects = server.projects.forServer(props.serverKey) + projects.open(directory) + projects.touch(directory) tabs.select(await tabs.newDraft({ server: props.serverKey, directory })) } finally { props.onReady() diff --git a/packages/desktop/src/shared/ipc-rpc/app.ts b/packages/desktop/src/shared/ipc-rpc/app.ts index 1f9b62bdf9c8..e31e37738df2 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -25,7 +25,7 @@ export const AppIsFirstLaunchOnboardingPending = Rpc.make("AppIsFirstLaunchOnboa }) export const AppFinishFirstLaunchOnboarding = Rpc.make("AppFinishFirstLaunchOnboarding", { payload: { createDefaultProject: Schema.Boolean }, - success: Schema.NullOr(Schema.String), + success: Schema.NullOr(Schema.Union([Schema.String, Schema.Struct({ permissionDenied: Schema.String })])), }) export const AppCheckAppExists = Rpc.make("AppCheckAppExists", { payload: { appName: Schema.String }, From 0a6ca866fe57b9b723cf5c650077be0ac80b247e Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Fri, 25 Sep 2026 13:36:44 +0200 Subject: [PATCH 7/7] fix(desktop): recover unavailable session tabs Show typed folder failures on restored session tabs with move, retry, and close actions. Keep the project listing untouched when only a worktree is missing. --- packages/app/src/runtime/i18n/en.ts | 3 + .../app/src/session/location-unavailable.tsx | 98 +++++++++++++++++++ packages/app/src/session/route.tsx | 39 +++++--- packages/app/src/workspaces/location.tsx | 42 +++++++- 4 files changed, 165 insertions(+), 17 deletions(-) create mode 100644 packages/app/src/session/location-unavailable.tsx diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index 52fea52945d1..dc67e8823540 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -737,6 +737,9 @@ export const dict = { "toast.project.defaultUnavailable.title": "Cannot open default project", "toast.project.missing.title": "Project folder not found", "toast.project.missing.remove": "Remove project", + "session.location.move": "Move session", + "session.location.retry": "Try again", + "session.location.closeProject": "Close project", "toast.project.permissionDenied.title": "OpenCode can't access this folder", "error.project.missing": "{{directory}} was moved, renamed, or deleted. Restore the folder at that path, or remove this project from OpenCode and open it from its new location.", diff --git a/packages/app/src/session/location-unavailable.tsx b/packages/app/src/session/location-unavailable.tsx new file mode 100644 index 000000000000..34d05bd24303 --- /dev/null +++ b/packages/app/src/session/location-unavailable.tsx @@ -0,0 +1,98 @@ +import { Button } from "@opencode/ui/button" +import { Icon } from "@opencode/ui/icon" +import { createMemo, createSignal, Show } from "solid-js" +import { useLanguage } from "@/runtime/i18n/language" +import { useServer, useData } from "@/runtime/server/current" +import { formatProjectLocationError, formatServerError } from "@/runtime/server/errors" +import { showToast } from "@/shell/notifications/toast" +import { closeHomeProject, projectForSession } from "@/shell/layout/helpers" +import { useLayout } from "@/shell/state/layout" +import { useTabs } from "@/shell/tabs/tabs" +import { useDirectoryPicker } from "@/workspaces/selection/picker" +import { useWorkspaceLocation } from "@/workspaces/location" +import { sameDirectory } from "@/workspaces/paths" + +export function SessionLocationUnavailable(props: { sessionID: string }) { + const language = useLanguage() + const server = useServer() + const data = useData() + const layout = useLayout() + const tabs = useTabs() + const pickDirectory = useDirectoryPicker() + const location = useWorkspaceLocation() + const [busy, setBusy] = createSignal(false) + const project = createMemo(() => { + const session = data.session.get(props.sessionID) + const saved = session ? projectForSession(session, server.ctx.projects.list()) : undefined + const unavailable = location().error + // A missing worktree/subdirectory must not close its still-accessible parent project. + return saved && unavailable && sameDirectory(saved.worktree, unavailable.directory) ? saved : undefined + }) + + const move = (directory: string) => { + if (busy()) return + setBusy(true) + void server.ctx.sdk.api.session + .move({ sessionID: props.sessionID, directory }) + .then(() => data.session.sync(props.sessionID)) + .catch((error: unknown) => + showToast({ + variant: "error", + title: language.t("workspace.move.failed"), + description: formatServerError(error, language.t), + }), + ) + .finally(() => setBusy(false)) + } + + const close = () => { + const saved = project() + if (saved) { + const next = closeHomeProject(layout.home.selection(), server.key, server.ctx.projects, saved.worktree) + if (next) layout.home.setSelection(next) + } + tabs.removeSessionTab({ server: server.key, sessionId: props.sessionID }) + } + + return ( +
+
+ +
+
+ {language.t( + location().error?.type === "missing" ? "toast.project.missing.title" : "toast.project.permissionDenied.title", + )} +
+
+ {(error) => formatProjectLocationError(error(), language.t)} +
+
+
+ + + +
+
+
+ ) +} diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 0900b868de3a..18481bf3bdf4 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -6,10 +6,11 @@ import { TextShimmer } from "@opencode/ui/text-shimmer" import { CommentsProvider } from "@/composer/comments" import { readPromptPresentation } from "@/composer/comment-note" import { FileProvider } from "@/workspaces/files/model" -import { LocationProvider } from "@/workspaces/location" +import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location" import { ModelsProvider } from "@/providers/models/models" import { useProviders } from "@/providers/catalog/providers" import { useLanguage } from "@/runtime/i18n/language" +import { usePlatform } from "@/runtime/platform/platform" import { useNotification } from "@/shell/notifications/notification" import { ComposerPersistenceProvider } from "@/composer/persistence" import { useData, useServer } from "@/runtime/server/current" @@ -26,6 +27,7 @@ import { IncompatibleServerPanel } from "./incompatible-server-panel" import { SessionErrorFallback } from "./route-error" import { createSessionResolution } from "./session-resolution" import { SessionScreen } from "./screen" +import { SessionLocationUnavailable } from "./location-unavailable" import { PreparingComposer } from "./preparing-composer" export function TargetSessionRouteContent() { @@ -164,18 +166,31 @@ function SessionStatePanel(props: ParentProps) { } function TargetSessionPage() { + const params = useParams<{ id: string }>() + const platform = usePlatform() + const server = useServer() + const location = useWorkspaceLocation() return ( - // These providers select their scoped state reactively and retain bounded caches, - // so keep their owners alive while navigating between workspaces on this server. - - - - - - - - - + + + + + + + + + + } + > + + + + ) } diff --git a/packages/app/src/workspaces/location.tsx b/packages/app/src/workspaces/location.tsx index 4c09b2130317..2d76523e2541 100644 --- a/packages/app/src/workspaces/location.tsx +++ b/packages/app/src/workspaces/location.tsx @@ -1,14 +1,17 @@ import { createSimpleContext } from "@opencode/ui/context" import type { LocationGetOutput, LocationRef } from "@opencode/client/promise" import { retry } from "@opencode/util/retry" -import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { type LocationContext, useServerSDK } from "@/runtime/server/client" import { useData, useServer } from "@/runtime/server/current" +import { projectLocationError } from "@/runtime/server/errors" export type { LocationContext } from "@/runtime/server/client" export type WorkspaceLocation = LocationContext & { readonly ref: LocationRef readonly current: LocationGetOutput | undefined + readonly error: ReturnType + readonly retry: () => Promise } const context = createSimpleContext({ @@ -28,6 +31,24 @@ const context = createSimpleContext({ }, ) const current = createMemo(() => data.location.info(ref())) + const [failure, setFailure] = createSignal<{ + ref: LocationRef + error: NonNullable> + }>() + const error = createMemo(() => { + const failed = failure() + const location = ref() + if (failed?.ref.directory !== location.directory || failed.ref.workspaceID !== location.workspaceID) return + return failed.error + }) + const sync = (location: LocationRef) => + data.location.sync(location).then( + () => setFailure(undefined), + (cause: unknown) => { + const unavailable = projectLocationError(cause) + if (unavailable) setFailure({ ref: location, error: unavailable }) + }, + ) createEffect(() => { const location = ref() @@ -36,14 +57,23 @@ const context = createSimpleContext({ stale = true }) if (serverSDK.connection.status() !== "connected") return - // A failed sync does not prove the directory is missing. Keep recovery local to reads. + // Only a typed directory failure proves this Location is unavailable. Transient failures + // keep their existing retries; a successful retry or a session move clears the state. void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { - retryIf: () => !stale, - }).catch(() => undefined) + retryIf: (cause) => !stale && !projectLocationError(cause), + }).then( + () => { + if (!stale) setFailure(undefined) + }, + (cause: unknown) => { + const unavailable = projectLocationError(cause) + if (!stale && unavailable) setFailure({ ref: location, error: unavailable }) + }, + ) }) createEffect(() => { const id = current()?.project.id - if (!id || serverSDK.connection.status() !== "connected") return + if (!id || error() || serverSDK.connection.status() !== "connected") return // Showing a Location is the demand for its project's worktree inventory (workspace styling, picker). void server.ctx.sync.worktrees.list(id).then(() => server.ctx.sync.worktrees.refresh(id)) }) @@ -53,6 +83,8 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), + error: error(), + retry: () => sync(ref()), })) }, })