From fc0b1002661c7f0c0b1c7971853cc5830c0d4f8f Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Sat, 3 Oct 2026 20:01:07 +0200 Subject: [PATCH 1/2] fix(server): report denied and lost location folders --- .../client/src/promise/generated/client.ts | 192 +++++++++--------- .../client/src/promise/generated/types.ts | 8 + packages/core/src/filesystem.ts | 48 +++-- packages/core/src/instance.ts | 22 +- packages/core/src/session.ts | 8 +- packages/core/src/session/generate.ts | 2 +- packages/core/src/session/runner/index.ts | 2 +- packages/core/src/worktree.ts | 2 +- packages/core/test/fixture/prompt-location.ts | 2 +- packages/core/test/location-activity.test.ts | 2 +- packages/core/test/location-layer.test.ts | 2 +- packages/core/test/session-compact.test.ts | 2 +- packages/core/test/session-execution.test.ts | 2 +- packages/core/test/session-move.test.ts | 4 +- packages/core/test/session-prompt.test.ts | 2 +- packages/core/test/session-skill.test.ts | 2 +- packages/protocol/src/client.ts | 6 +- packages/protocol/src/errors.ts | 6 + packages/protocol/src/groups/generate.ts | 4 +- packages/protocol/src/groups/permission.ts | 4 +- packages/protocol/src/groups/session.ts | 3 +- packages/server/src/handlers/location.ts | 4 + packages/server/src/location.ts | 21 +- .../server/src/middleware/form-location.ts | 4 +- .../server/src/middleware/session-location.ts | 4 +- packages/server/src/workerd.ts | 1 + packages/server/test/fetch.test.ts | 50 +++++ 27 files changed, 255 insertions(+), 154 deletions(-) diff --git a/packages/client/src/promise/generated/client.ts b/packages/client/src/promise/generated/client.ts index 578f04995ce1..2d363be55732 100644 --- a/packages/client/src/promise/generated/client.ts +++ b/packages/client/src/promise/generated/client.ts @@ -449,7 +449,7 @@ export function make(options: ClientOptions) { path: `/api/location`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -474,7 +474,7 @@ export function make(options: ClientOptions) { path: `/api/agent`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -486,7 +486,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, @@ -500,7 +500,7 @@ export function make(options: ClientOptions) { path: `/api/plugin`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -513,7 +513,7 @@ export function make(options: ClientOptions) { query: { location: input?.["location"] }, body: { target: input?.["target"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -526,7 +526,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { targets: input["targets"] }, successStatus: 204, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: true, }, requestOptions, @@ -657,7 +657,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, @@ -669,7 +669,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, @@ -681,7 +681,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, @@ -693,7 +693,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, @@ -726,7 +726,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, @@ -745,7 +745,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, @@ -757,7 +757,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, @@ -776,7 +776,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, @@ -788,7 +788,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, @@ -800,7 +800,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, @@ -811,7 +811,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, @@ -824,7 +824,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, @@ -835,7 +835,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, @@ -846,7 +846,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, @@ -870,7 +870,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/diff`, query: { from: input["from"], to: input["to"], context: input["context"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 500], + declaredStatuses: [400, 401, 403, 404, 500], empty: false, }, requestOptions, @@ -919,7 +919,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, @@ -931,7 +931,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, @@ -942,7 +942,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, @@ -956,7 +956,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, @@ -980,7 +980,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, @@ -991,7 +991,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, @@ -1016,7 +1016,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, @@ -1028,7 +1028,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, @@ -1039,7 +1039,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, @@ -1051,7 +1051,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, @@ -1063,7 +1063,7 @@ export function make(options: ClientOptions) { path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`, query: { message: input["message"] }, successStatus: 204, - declaredStatuses: [400, 401, 404, 409], + declaredStatuses: [400, 401, 403, 404, 409], empty: true, }, requestOptions, @@ -1116,7 +1116,7 @@ export function make(options: ClientOptions) { path: `/api/model`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -1128,7 +1128,7 @@ export function make(options: ClientOptions) { path: `/api/model/default`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -1142,7 +1142,7 @@ export function make(options: ClientOptions) { path: `/api/experimental/generate`, body: { prompt: input["prompt"], model: input["model"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -1156,7 +1156,7 @@ export function make(options: ClientOptions) { path: `/api/provider`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -1168,7 +1168,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, @@ -1182,7 +1182,7 @@ export function make(options: ClientOptions) { path: `/api/integration`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1194,7 +1194,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, @@ -1208,7 +1208,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { url: input["url"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1223,7 +1223,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, @@ -1238,7 +1238,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, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1250,7 +1250,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, @@ -1263,7 +1263,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, @@ -1275,7 +1275,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, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1290,7 +1290,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, @@ -1302,7 +1302,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, @@ -1314,7 +1314,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, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1329,7 +1329,7 @@ export function make(options: ClientOptions) { path: `/api/mcp`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1342,7 +1342,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { config: input["config"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1354,7 +1354,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, @@ -1366,7 +1366,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, @@ -1378,7 +1378,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, @@ -1391,7 +1391,7 @@ export function make(options: ClientOptions) { path: `/api/mcp/resource`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1460,7 +1460,13 @@ export function make(options: ClientOptions) { project: { list: (requestOptions?: RequestOptions) => request( - { method: "GET", path: `/api/project`, successStatus: 200, declaredStatuses: [400, 401, 404], empty: false }, + { + method: "GET", + path: `/api/project`, + successStatus: 200, + declaredStatuses: [400, 401, 403, 404], + empty: false, + }, requestOptions, ), update: (input: ProjectUpdateInput, requestOptions?: RequestOptions) => @@ -1475,7 +1481,7 @@ export function make(options: ClientOptions) { commands: input["commands"], }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1489,7 +1495,7 @@ export function make(options: ClientOptions) { path: `/api/form`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1504,7 +1510,7 @@ export function make(options: ClientOptions) { path: `/api/permission/request`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1518,7 +1524,7 @@ export function make(options: ClientOptions) { path: `/api/permission/saved`, query: { projectID: input?.["projectID"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1529,7 +1535,7 @@ export function make(options: ClientOptions) { method: "DELETE", path: `/api/permission/saved/${encodeURIComponent(input.id)}`, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1550,7 +1556,7 @@ export function make(options: ClientOptions) { agent: input["agent"], }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1561,7 +1567,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, @@ -1572,7 +1578,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, @@ -1584,7 +1590,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, @@ -1598,7 +1604,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, }, @@ -1611,7 +1617,7 @@ export function make(options: ClientOptions) { path: `/api/fs/list`, query: { location: input?.["location"], path: input?.["path"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1623,7 +1629,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, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1636,7 +1642,7 @@ export function make(options: ClientOptions) { query: { location: input["location"], path: input["path"] }, body: input["payload"], successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, binaryBody: true, }, @@ -1651,7 +1657,7 @@ export function make(options: ClientOptions) { path: `/api/command`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1665,7 +1671,7 @@ export function make(options: ClientOptions) { path: `/api/skill`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1680,7 +1686,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { input: input["input"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 500], + declaredStatuses: [400, 401, 403, 404, 500], empty: false, }, requestOptions, @@ -1701,7 +1707,7 @@ export function make(options: ClientOptions) { path: `/api/pty`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1720,7 +1726,7 @@ export function make(options: ClientOptions) { env: input?.["env"], }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1732,7 +1738,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, @@ -1745,7 +1751,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, @@ -1757,7 +1763,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, @@ -1911,7 +1917,7 @@ export function make(options: ClientOptions) { path: `/api/shell`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1929,7 +1935,7 @@ export function make(options: ClientOptions) { metadata: input["metadata"], }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -1941,7 +1947,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, @@ -1953,7 +1959,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, @@ -1965,7 +1971,7 @@ export function make(options: ClientOptions) { path: `/api/shell/${encodeURIComponent(input.id)}`, query: { location: input["location"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, @@ -1979,7 +1985,7 @@ export function make(options: ClientOptions) { path: `/api/reference`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2049,7 +2055,7 @@ export function make(options: ClientOptions) { path: `/api/vcs/init`, query: { location: input?.["location"], provider: input?.["provider"] }, successStatus: 204, - declaredStatuses: [400, 401, 404, 409, 501, 503], + declaredStatuses: [400, 401, 403, 404, 409, 501, 503], empty: true, }, requestOptions, @@ -2061,7 +2067,7 @@ export function make(options: ClientOptions) { path: `/api/vcs`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2073,7 +2079,7 @@ export function make(options: ClientOptions) { path: `/api/vcs/base`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -2085,7 +2091,7 @@ export function make(options: ClientOptions) { path: `/api/vcs/status`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2098,7 +2104,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, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2111,7 +2117,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, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -2167,7 +2173,7 @@ export function make(options: ClientOptions) { path: `/api/websearch/provider`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -2180,7 +2186,7 @@ export function make(options: ClientOptions) { query: { location: input["location"] }, body: { query: input["query"], providerID: input["providerID"] }, successStatus: 200, - declaredStatuses: [400, 401, 404, 503], + declaredStatuses: [400, 401, 403, 404, 503], empty: false, }, requestOptions, @@ -2194,7 +2200,7 @@ export function make(options: ClientOptions) { path: `/api/config`, query: { location: input?.["location"] }, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2205,7 +2211,7 @@ export function make(options: ClientOptions) { method: "GET", path: `/api/config/shell`, successStatus: 200, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: false, }, requestOptions, @@ -2217,7 +2223,7 @@ export function make(options: ClientOptions) { path: `/api/experimental/config`, body: { shell: input["shell"] }, successStatus: 204, - declaredStatuses: [400, 401, 404], + declaredStatuses: [400, 401, 403, 404], empty: true, }, requestOptions, diff --git a/packages/client/src/promise/generated/types.ts b/packages/client/src/promise/generated/types.ts index 62a6395c9d6d..6e7b1fe687b1 100644 --- a/packages/client/src/promise/generated/types.ts +++ b/packages/client/src/promise/generated/types.ts @@ -2531,6 +2531,14 @@ 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 LocationPermissionDeniedError = { + readonly _tag: "LocationPermissionDeniedError" + readonly location: { 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 LocationNotFoundError = { readonly _tag: "LocationNotFoundError" readonly location: { readonly directory: string } diff --git a/packages/core/src/filesystem.ts b/packages/core/src/filesystem.ts index 4c5024d15434..0cc71626b3e0 100644 --- a/packages/core/src/filesystem.ts +++ b/packages/core/src/filesystem.ts @@ -2,7 +2,7 @@ export * as FileSystem from "./filesystem.js" import { makeLocationNode } from "@opencode/util/effect/app-node" import path from "path" -import { Context, Effect, Layer, Schema } from "effect" +import { Context, Effect, Layer, PlatformError, Schema } from "effect" import { FSUtil } from "@opencode/util/fs-util" import { Location } from "./location.js" import { AbsolutePath, PositiveInt, RelativePath } from "./schema.js" @@ -50,6 +50,27 @@ export class DirectoryAccessDeniedError extends Schema.TaggedError("FileSystem.GrepInput")({ export const Event = FileSystem.Event export interface Interface { + readonly check: Effect.Effect readonly read: ( input: ReadInput, ) => Effect.Effect<{ readonly content: Uint8Array; readonly mime: string }, NotFoundError> @@ -111,24 +133,15 @@ const baseLayer = Layer.effect( // configured directory as canonical; local placements keep symlink // canonicalization. This skip is boot-only: resolve/read/list below still // access the host filesystem per operation (tracked in #44568). - const root = location.workspaceID - ? location.directory - : yield* fs.realPath(location.directory).pipe( - Effect.catch((cause): Effect.Effect => { - if (cause.reason._tag === "NotFound") - return Effect.fail(new DirectoryNotFoundError({ directory: location.directory, cause })) - // macOS privacy denials arrive as Unknown with an EPERM cause. - if ( - cause.reason._tag === "PermissionDenied" || - (cause.reason._tag === "Unknown" && - cause.reason.cause instanceof Error && - "code" in cause.reason.cause && - cause.reason.cause.code === "EPERM") - ) - return Effect.fail(new DirectoryAccessDeniedError({ directory: location.directory, cause })) - return Effect.die(cause) + const canonical = location.workspaceID + ? Effect.succeed(location.directory) + : fs.realPath(location.directory).pipe( + Effect.catch((cause) => { + const error = directoryError(cause, location.directory) + return error ? Effect.fail(error) : Effect.die(cause) }), ) + const root = yield* canonical const resolve = Effect.fnUntraced(function* (input?: RelativePath) { const absolute = path.resolve(location.directory, input ?? ".") if (!FSUtil.contains(location.directory, absolute)) @@ -138,6 +151,7 @@ const baseLayer = Layer.effect( return { absolute, real, directory: location.directory } }) return Service.of({ + check: canonical.pipe(Effect.asVoid), find: search.find, read: Effect.fn("FileSystem.read")(function* (input) { const target = yield* resolve(input.path).pipe( diff --git a/packages/core/src/instance.ts b/packages/core/src/instance.ts index 0586fbaf9dc8..4f09bcf6facb 100644 --- a/packages/core/src/instance.ts +++ b/packages/core/src/instance.ts @@ -1,4 +1,4 @@ -import { Context, Effect, Layer } from "effect" +import { Cause, Context, Effect, Layer } from "effect" import { Agent } from "./agent.js" import { AISDK } from "./aisdk.js" import { Model } from "./model.js" @@ -114,7 +114,7 @@ const nodes = [ export const graph = LayerNode.group(nodes) export type Services = LayerNode.Output -export type Error = FileSystem.DirectoryNotFoundError +export type Error = FileSystem.DirectoryError export interface Options { // Plugins this instance is born with; empty and absent are equivalent. @@ -157,15 +157,21 @@ export function layer(ref: Location.Ref, options: Options = {}): Layer.Layer => Layer.unwrap( - Effect.failCause(cause).pipe( - Effect.catch( - (error): Effect.Effect => - error instanceof FileSystem.DirectoryNotFoundError ? Effect.fail(error) : Effect.die(error), - ), + Effect.failCause( + Cause.fromReasons(cause.reasons.map((reason) => { + if (Cause.isInterruptReason(reason)) return reason + const value = Cause.isFailReason(reason) ? reason.error : reason.defect + // Workspace directories are not host paths, so only already-typed failures apply. + const error = ref.workspaceID + ? FileSystem.directoryError(Cause.isFailReason(reason) ? value : undefined, ref.directory) + : FileSystem.directoryError(value, ref.directory) + if (error) return Cause.makeFailReason(error) + return Cause.isDieReason(reason) ? reason : Cause.makeDieReason(value) + })), ), ), ), diff --git a/packages/core/src/session.ts b/packages/core/src/session.ts index 39f5f36d3a56..9715ef4f6f67 100644 --- a/packages/core/src/session.ts +++ b/packages/core/src/session.ts @@ -148,7 +148,7 @@ export interface Interface { readonly context?: number }) => Effect.Effect< readonly FileDiff.Info[], - NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error | FileSystem.DirectoryNotFoundError + NotFoundError | MessageNotFoundError | TurnRangeError | Snapshot.Error | FileSystem.DirectoryError > /** * Durable admitted session work not yet visible in projected history, @@ -201,7 +201,7 @@ export interface Interface { delivery?: SessionInbox.Delivery }) => Effect.Effect< void, - NotFoundError | Command.NotFoundError | Command.ExecutionError | FileSystem.DirectoryNotFoundError + NotFoundError | Command.NotFoundError | Command.ExecutionError | FileSystem.DirectoryError > readonly shell: ( input: Parameters[0] & { sessionID: SessionSchema.ID }, @@ -227,11 +227,11 @@ export interface Interface { files?: boolean }) => Effect.Effect< SessionSchema.Revert, - NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error | FileSystem.DirectoryNotFoundError + NotFoundError | MessageNotFoundError | BusyError | Snapshot.Error | FileSystem.DirectoryError > readonly clear: ( sessionID: SessionSchema.ID, - ) => Effect.Effect + ) => Effect.Effect readonly commit: (sessionID: SessionSchema.ID) => Effect.Effect } } diff --git a/packages/core/src/session/generate.ts b/packages/core/src/session/generate.ts index e2600badb833..ac03ade2495a 100644 --- a/packages/core/src/session/generate.ts +++ b/packages/core/src/session/generate.ts @@ -20,7 +20,7 @@ export type Error = | Instructions.InitializationBlocked | SessionRunnerModel.Error | AIError - | FileSystem.DirectoryNotFoundError + | FileSystem.DirectoryError /** Generates text from current Session context without mutating the Session. */ export const generate = Effect.fn("SessionGenerate.generate")(function* (input: { diff --git a/packages/core/src/session/runner/index.ts b/packages/core/src/session/runner/index.ts index 493713fa6768..2422a1fe01bb 100644 --- a/packages/core/src/session/runner/index.ts +++ b/packages/core/src/session/runner/index.ts @@ -16,7 +16,7 @@ export type RunError = | AgentNotFoundError | StepFailedError | Instructions.InitializationBlocked - | FileSystem.DirectoryNotFoundError + | FileSystem.DirectoryError export type Continuation = { readonly step: number } diff --git a/packages/core/src/worktree.ts b/packages/core/src/worktree.ts index d6d2685e8cfb..78e8c9177d67 100644 --- a/packages/core/src/worktree.ts +++ b/packages/core/src/worktree.ts @@ -68,7 +68,7 @@ export class StrategyUnavailableError extends Schema.TaggedError, + }) as unknown as Layer.Layer, ) }), ), diff --git a/packages/core/test/location-activity.test.ts b/packages/core/test/location-activity.test.ts index a71b0a82f459..0fcaa4a1ceb7 100644 --- a/packages/core/test/location-activity.test.ts +++ b/packages/core/test/location-activity.test.ts @@ -66,7 +66,7 @@ const locations = Layer.effect( Layer.provideMerge(Form.layer), Layer.provide(Layer.succeed(Bus.Service, bus)), Layer.fresh, - ) as unknown as Layer.Layer, + ) as unknown as Layer.Layer, { idleTimeToLive: Duration.infinity }, ) return { diff --git a/packages/core/test/location-layer.test.ts b/packages/core/test/location-layer.test.ts index 4506d0fca4d6..a3b2f4bbd129 100644 --- a/packages/core/test/location-layer.test.ts +++ b/packages/core/test/location-layer.test.ts @@ -68,7 +68,7 @@ const activityLocations = Layer.effect( workspaceID: ref.workspaceID, project: { id: Project.ID.global, directory: ref.directory, canonical: ref.directory }, }), - ) as unknown as Layer.Layer, + ) as unknown as Layer.Layer, { idleTimeToLive: Duration.infinity }, ), ) diff --git a/packages/core/test/session-compact.test.ts b/packages/core/test/session-compact.test.ts index cb9f7b255483..665da2bfe29e 100644 --- a/packages/core/test/session-compact.test.ts +++ b/packages/core/test/session-compact.test.ts @@ -59,7 +59,7 @@ const locations = Layer.effect( Layer.provide(client), Layer.provide(config), Layer.provide(models), - ) as unknown as Layer.Layer, + ) as unknown as Layer.Layer, ), ) const it = testEffect( diff --git a/packages/core/test/session-execution.test.ts b/packages/core/test/session-execution.test.ts index d6cafcf7b5b1..86739d6c99ed 100644 --- a/packages/core/test/session-execution.test.ts +++ b/packages/core/test/session-execution.test.ts @@ -1372,7 +1372,7 @@ function buildExecution( () => // The local execution test only needs the Session runner from the Location graph. // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion - runner as unknown as Layer.Layer, + runner as unknown as Layer.Layer, ), ) return yield* Layer.buildWithScope( diff --git a/packages/core/test/session-move.test.ts b/packages/core/test/session-move.test.ts index 5e7fb7967489..0f77eaac7d6e 100644 --- a/packages/core/test/session-move.test.ts +++ b/packages/core/test/session-move.test.ts @@ -59,7 +59,7 @@ const itWithActiveExecution = testEffect( replacements: [Project.node.replace(globalProjectNode), offlineModels], }), Layer.succeed(SessionRunner.Service, { drain: () => Effect.never }), - ) as unknown as Layer.Layer, + ) as unknown as Layer.Layer, ), ), ), @@ -72,7 +72,7 @@ const unavailableLocations = Layer.effect( () => Layer.effectDiscard(Effect.die(new Error("broken location"))) as unknown as Layer.Layer< LocationServices, - FileSystem.DirectoryNotFoundError + FileSystem.DirectoryError >, ), ) diff --git a/packages/core/test/session-prompt.test.ts b/packages/core/test/session-prompt.test.ts index d6a20188945f..b44a5f7756c1 100644 --- a/packages/core/test/session-prompt.test.ts +++ b/packages/core/test/session-prompt.test.ts @@ -84,7 +84,7 @@ const locations = makeGlobalNode({ restore: () => Effect.void, }), Layer.mock(Plugin.Service, { awaitActivation: Effect.void }), - ).pipe(Layer.fresh) as unknown as Layer.Layer, + ).pipe(Layer.fresh) as unknown as Layer.Layer, ) }), ), diff --git a/packages/core/test/session-skill.test.ts b/packages/core/test/session-skill.test.ts index 8876a55a91ab..22fdd130bc85 100644 --- a/packages/core/test/session-skill.test.ts +++ b/packages/core/test/session-skill.test.ts @@ -50,7 +50,7 @@ const locations = makeGlobalNode({ list: () => Effect.succeed([info]), }), Layer.mock(Plugin.Service, { awaitActivation: Effect.void }), - ) as unknown as Layer.Layer, + ) as unknown as Layer.Layer, ), ), deps: [], diff --git a/packages/protocol/src/client.ts b/packages/protocol/src/client.ts index a01155ac6666..7377406e97ab 100644 --- a/packages/protocol/src/client.ts +++ b/packages/protocol/src/client.ts @@ -1,4 +1,4 @@ -import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "./errors.js" +import { LocationNotFoundError, LocationPermissionDeniedError, InvalidRequestError, SessionNotFoundError } from "./errors.js" import { makeDefaultApi } from "./api.js" import type { Api } from "./api.js" import type { Context } from "effect" @@ -7,12 +7,12 @@ import type { EventGroup } from "./groups/event.js" class LocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/client/LocationMiddleware", - { error: [LocationNotFoundError] }, + { error: [LocationNotFoundError, LocationPermissionDeniedError] }, ) {} class SessionLocationMiddleware extends HttpApiMiddleware.Service()( "@opencode/client/SessionLocationMiddleware", - { error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError] }, + { error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError, LocationPermissionDeniedError] }, ) {} type ClientApiShape = Api< diff --git a/packages/protocol/src/errors.ts b/packages/protocol/src/errors.ts index e793d8d8f5d7..3dff7eb2ee2e 100644 --- a/packages/protocol/src/errors.ts +++ b/packages/protocol/src/errors.ts @@ -8,6 +8,12 @@ export class LocationNotFoundError extends Schema.TaggedError()( + "LocationPermissionDeniedError", + { location: Location.PublicRef, message: Schema.String }, + { httpApiStatus: 403 }, +) {} + export class InvalidRequestError extends Schema.TaggedError()( "InvalidRequestError", { diff --git a/packages/protocol/src/groups/generate.ts b/packages/protocol/src/groups/generate.ts index ab44243578e7..a476d343c70f 100644 --- a/packages/protocol/src/groups/generate.ts +++ b/packages/protocol/src/groups/generate.ts @@ -1,7 +1,7 @@ import { Model } from "@opencode/schema/model" import { Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" -import { LocationNotFoundError, InvalidRequestError, ServiceUnavailableError } from "../errors.js" +import { LocationNotFoundError, LocationPermissionDeniedError, InvalidRequestError, ServiceUnavailableError } from "../errors.js" export const GenerateGroup = HttpApiGroup.make("server.generate") .add( @@ -13,7 +13,7 @@ export const GenerateGroup = HttpApiGroup.make("server.generate") success: Schema.Struct({ data: Schema.Struct({ text: Schema.String }), }).annotate({ identifier: "GenerateTextResponse" }), - error: [InvalidRequestError, ServiceUnavailableError, LocationNotFoundError], + error: [InvalidRequestError, ServiceUnavailableError, LocationNotFoundError, LocationPermissionDeniedError], }).annotateMerge( OpenApi.annotations({ identifier: "experimental.generate.text", diff --git a/packages/protocol/src/groups/permission.ts b/packages/protocol/src/groups/permission.ts index 9085a51f9f89..1ce9bc90a9c8 100644 --- a/packages/protocol/src/groups/permission.ts +++ b/packages/protocol/src/groups/permission.ts @@ -6,7 +6,7 @@ import { Project } from "@opencode/schema/project" import { Session } from "@opencode/schema/session" import { Context, Schema } from "effect" import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi" -import { LocationNotFoundError, PermissionNotFoundError, SessionNotFoundError } from "../errors.js" +import { LocationNotFoundError, LocationPermissionDeniedError, PermissionNotFoundError, SessionNotFoundError } from "../errors.js" import { LocationQuery, locationQueryOpenApi } from "./location.js" export const makePermissionGroup = < @@ -89,7 +89,7 @@ export const makePermissionGroup = < HttpApiEndpoint.get("session.permission.list", "/api/session/:sessionID/permission", { params: { sessionID: Session.ID }, success: Schema.Struct({ data: Schema.Array(Permission.Request) }), - error: [SessionNotFoundError, LocationNotFoundError], + error: [SessionNotFoundError, LocationNotFoundError, LocationPermissionDeniedError], }).annotateMerge( OpenApi.annotations({ identifier: "session.permission.list", diff --git a/packages/protocol/src/groups/session.ts b/packages/protocol/src/groups/session.ts index d5bf2af1db12..f0e4f12662e8 100644 --- a/packages/protocol/src/groups/session.ts +++ b/packages/protocol/src/groups/session.ts @@ -19,6 +19,7 @@ import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenAp import { ConflictError, LocationNotFoundError, + LocationPermissionDeniedError, CommandExecutionError, CommandNotFoundError, FormAlreadySettledError, @@ -591,7 +592,7 @@ export const makeSessionGroup = Effect.gen(function* () { @@ -13,6 +15,8 @@ export const LocationHandler = HttpApiBuilder.group(Api, "server.location", (han "location.get", Effect.fn(function* () { const location = yield* Location.Service + const filesystem = yield* FileSystem.Service + yield* filesystem.check.pipe(locationErrors) return new Location.Info({ directory: location.directory, project: location.project, diff --git a/packages/server/src/location.ts b/packages/server/src/location.ts index 7553fa9adc6f..8c960d9ba6dd 100644 --- a/packages/server/src/location.ts +++ b/packages/server/src/location.ts @@ -3,7 +3,7 @@ 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 { LocationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors" +import { LocationNotFoundError, LocationPermissionDeniedError, InvalidRequestError } from "@opencode/protocol/errors" import { Effect, Layer, Schema } from "effect" import { HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -13,20 +13,25 @@ export type LocationServices = Layer.Success()( "@opencode/HttpApiLocation", - { error: [LocationNotFoundError] }, + { error: [LocationNotFoundError, LocationPermissionDeniedError] }, ) {} export function locationErrors(effect: Effect.Effect) { return effect.pipe( Effect.catchIf( - (error): error is Extract => - error instanceof FileSystem.DirectoryNotFoundError, + (error): error is Extract => + error instanceof FileSystem.DirectoryNotFoundError || error instanceof FileSystem.DirectoryAccessDeniedError, (error) => Effect.fail( - new LocationNotFoundError({ - location: { directory: error.directory }, - message: `Location not found: ${error.directory}`, - }), + error instanceof FileSystem.DirectoryNotFoundError + ? new LocationNotFoundError({ + location: { directory: error.directory }, + message: `Location not found: ${error.directory}`, + }) + : new LocationPermissionDeniedError({ + location: { directory: error.directory }, + message: `Location access denied: ${error.directory}`, + }), ), ), ) diff --git a/packages/server/src/middleware/form-location.ts b/packages/server/src/middleware/form-location.ts index f622c9317da4..4fc9c9d0f3fc 100644 --- a/packages/server/src/middleware/form-location.ts +++ b/packages/server/src/middleware/form-location.ts @@ -1,7 +1,7 @@ import { Instance } from "@opencode/core/instance/service" import { LocationServiceMap } from "@opencode/core/location-services" import { Session } from "@opencode/core/session" -import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" +import { LocationNotFoundError, LocationPermissionDeniedError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" import { Effect, Layer } from "effect" import { HttpRouter, HttpServerRequest } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" @@ -11,7 +11,7 @@ export class FormLocationMiddleware extends HttpApiMiddleware.Service< FormLocationMiddleware, { provides: LocationServices } >()("@opencode/HttpApiFormLocation", { - error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError], + error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError, LocationPermissionDeniedError], }) {} export const formLocationLayer = Layer.effect( diff --git a/packages/server/src/middleware/session-location.ts b/packages/server/src/middleware/session-location.ts index 341ff73e6067..52a8114118b6 100644 --- a/packages/server/src/middleware/session-location.ts +++ b/packages/server/src/middleware/session-location.ts @@ -3,14 +3,14 @@ import { Session } from "@opencode/core/session" import { Effect, Layer } from "effect" import { HttpRouter } from "effect/unstable/http" import { HttpApiMiddleware } from "effect/unstable/httpapi" -import { LocationNotFoundError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" +import { LocationNotFoundError, LocationPermissionDeniedError, InvalidRequestError, SessionNotFoundError } from "@opencode/protocol/errors" import { locationErrors, sessionInfo, type LocationServices } from "../location" export class SessionLocationMiddleware extends HttpApiMiddleware.Service< SessionLocationMiddleware, { provides: LocationServices } >()("@opencode/HttpApiSessionLocation", { - error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError], + error: [InvalidRequestError, SessionNotFoundError, LocationNotFoundError, LocationPermissionDeniedError], }) {} export const sessionLocationLayer = Layer.effect( diff --git a/packages/server/src/workerd.ts b/packages/server/src/workerd.ts index fca076b8b913..6a59b0056356 100644 --- a/packages/server/src/workerd.ts +++ b/packages/server/src/workerd.ts @@ -111,6 +111,7 @@ const vcsLayer = Layer.succeed( const fileSystemLayer = Layer.succeed( FileSystem.Service, FileSystem.Service.of({ + check: Effect.void, read: () => unavailable("FileSystem.read"), list: () => unavailable("FileSystem.list"), find: () => unavailable("FileSystem.find"), diff --git a/packages/server/test/fetch.test.ts b/packages/server/test/fetch.test.ts index 9a59cd2afc9f..dad6ad823ae7 100644 --- a/packages/server/test/fetch.test.ts +++ b/packages/server/test/fetch.test.ts @@ -73,6 +73,56 @@ it.live("returns LocationNotFoundError for a missing folder and recovers once it }), ) +if (process.platform !== "win32") + it.live("returns LocationPermissionDeniedError for a denied folder and recovers once access returns", () => + Effect.gen(function* () { + const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-directory-denied-"))) + const parent = path.join(config.path, "protected") + const directory = path.join(parent, "project") + yield* Effect.promise(() => fs.mkdir(directory, { recursive: true })) + yield* Effect.acquireRelease( + Effect.promise(() => fs.chmod(parent, 0o000)), + () => Effect.promise(() => fs.chmod(parent, 0o700)), + ) + const handler = yield* ServerFetch.make({ ...options, config: { directory: config.path } }) + const request = () => + handler( + new Request("http://opencode.local/api/model", { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }), + ) + const denied = yield* Effect.promise(request) + expect(denied.status).toBe(403) + expect(yield* Effect.promise(() => denied.json())).toEqual({ + _tag: "LocationPermissionDeniedError", + location: { directory }, + message: `Location access denied: ${directory}`, + }) + yield* Effect.promise(() => fs.chmod(parent, 0o700)) + expect((yield* Effect.promise(request)).status).toBe(200) + }), + ) + +it.live("location.get reports a folder removed after its Location booted", () => + Effect.gen(function* () { + const config = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-directory-removed-"))) + const directory = path.join(config.path, "project") + yield* Effect.promise(() => fs.mkdir(directory)) + const handler = yield* ServerFetch.make({ ...options, config: { directory: config.path } }) + const request = () => + handler( + new Request("http://opencode.local/api/location", { + headers: { "x-opencode-directory": encodeURIComponent(directory) }, + }), + ) + expect((yield* Effect.promise(request)).status).toBe(200) + yield* Effect.promise(() => fs.rm(directory, { recursive: true })) + const removed = yield* Effect.promise(request) + expect(removed.status).toBe(404) + expect(yield* Effect.promise(() => removed.json())).toMatchObject({ _tag: "LocationNotFoundError" }) + }), +) + type Handler = (request: Request) => Promise function occupy(port: number, cancel = false) { From 504075c1c46c3cb41dc3fcaa8af80c1a1e06f5fa Mon Sep 17 00:00:00 2001 From: OpeOginni Date: Sat, 3 Oct 2026 20:01:05 +0200 Subject: [PATCH 2/2] fix(desktop): recover from missing or inaccessible project folders --- packages/app/src/app.tsx | 2 + packages/app/src/desktop.ts | 3 +- packages/app/src/home/model.ts | 4 +- packages/app/src/home/projects/add.ts | 8 +- packages/app/src/home/projects/controller.tsx | 51 ++++++-- packages/app/src/new-session/route.tsx | 46 ++++++- packages/app/src/runtime/i18n/en.ts | 15 +++ .../app/src/runtime/server/errors.test.ts | 41 ++++++- packages/app/src/runtime/server/errors.ts | 27 +++++ .../app/src/session/location-unavailable.tsx | 112 ++++++++++++++++++ packages/app/src/session/route.tsx | 45 +++++-- packages/app/src/shell/errors/error.tsx | 10 +- packages/app/src/workspaces/location.tsx | 51 +++++++- .../main/lifecycle/default-project.test.ts | 34 ++++++ .../src/main/lifecycle/default-project.ts | 21 ++++ .../desktop/src/main/lifecycle/onboarding.ts | 9 +- packages/desktop/src/renderer/api-types.ts | 2 +- packages/desktop/src/renderer/desktop-app.tsx | 7 +- .../desktop/src/renderer/onboarding/index.tsx | 54 ++++++++- packages/desktop/src/shared/ipc-rpc/app.ts | 2 +- 20 files changed, 489 insertions(+), 55 deletions(-) create mode 100644 packages/app/src/session/location-unavailable.tsx 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 626ff8585945..3e8d2aab4d32 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -48,6 +48,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 }>, @@ -64,6 +65,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 e80b2c7aed62..063de25bed5f 100644 --- a/packages/app/src/desktop.ts +++ b/packages/app/src/desktop.ts @@ -2,9 +2,10 @@ export { AppBaseProviders, AppInterface, preloadRoute } from "./app" export { useCommand } from "./shell/commands/command" export { currentRoute, type LayoutRoute, useCurrentRoute } from "./shell/state/layout" export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./runtime/i18n/language" -export { type FatalRendererErrorLog, type Platform, PlatformProvider } from "./runtime/platform/platform" +export { type FatalRendererErrorLog, type Platform, PlatformProvider, usePlatform } from "./runtime/platform/platform" export { ServerConnection, useServers } from "./runtime/server/registry" export { useGlobal } from "./runtime/server/runtime" +export { formatProjectLocationError, formatServerError, projectLocationError } 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/model.ts b/packages/app/src/home/model.ts index f93e4193c3db..9974adffc9ea 100644 --- a/packages/app/src/home/model.ts +++ b/packages/app/src/home/model.ts @@ -118,8 +118,8 @@ export function createHomeController() { return setSelection(toggleHomeProjectSelection(selection(), key, directory)) }, - add: (conn: ServerConnection.Any, directories: string[]) => { - const directory = addProjects(global.ensureServerCtx(conn), directories) + add: (conn: ServerConnection.Any, directories: string[], onError?: (directory: string, error: unknown) => void) => { + const directory = addProjects(global.ensureServerCtx(conn), directories, onError) if (!directory) return setSelection({ server: ServerConnection.key(conn), directory }) }, diff --git a/packages/app/src/home/projects/add.ts b/packages/app/src/home/projects/add.ts index f088eb2e9855..29f2f2090876 100644 --- a/packages/app/src/home/projects/add.ts +++ b/packages/app/src/home/projects/add.ts @@ -1,6 +1,10 @@ import type { ServerCtx } from "@/runtime/server/runtime" -export function addProjects(context: ServerCtx, directories: string[]) { +export function addProjects( + context: ServerCtx, + directories: string[], + onError?: (directory: string, error: unknown) => void, +) { const directory = directories[0] if (!directory) return @@ -11,7 +15,7 @@ export function addProjects(context: ServerCtx, directories: string[]) { .list({ path: ".", location }) .then(() => context.sdk.api.location.get({ location })) .then((value) => context.sync.child(item, { bootstrap: false })[1]("project", value.project.id)) - .catch(() => undefined) + .catch((error: unknown) => onError?.(item, error)) context.projects.open(item) }) context.projects.touch(directory) diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx index 79363e5cf475..834674ba782a 100644 --- a/packages/app/src/home/projects/controller.tsx +++ b/packages/app/src/home/projects/controller.tsx @@ -16,6 +16,7 @@ import { Persistence } from "@/runtime/persistence/schema" import type { HomeController } from "../model" import { useGlobal } from "@/runtime/server/runtime" import { SessionTransfer } from "@opencode/schema/session-transfer" +import { formatProjectLocationError, projectLocationError } from "@/runtime/server/errors" import { useRevealProject } from "./reveal" export const HomeServersSchema = Schema.Struct({ @@ -43,12 +44,48 @@ 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) + } + + // Only a typed folder error is actionable here; other failures keep the existing quiet behavior. + function showUnavailable(conn: ServerConnection.Any, directory: string, error: unknown) { + const location = projectLocationError(error) + if (!location) return + showToast({ + variant: "error", + persistent: true, + title: language.t( + location.type === "missing" ? "home.project.missing.title" : "toast.project.permissionDenied.title", + ), + description: formatProjectLocationError( + location, + language.t, + ServerConnection.local(conn) && platform.platform === "desktop" && platform.os === "macos", + ), + actions: + location.type === "missing" + ? [{ label: language.t("toast.project.missing.remove"), onClick: () => closeProject(conn, directory) }] + : undefined, + }) + } + + function add(conn: ServerConnection.Any, directories: string[]) { + home.project.add(conn, directories, (directory, error) => showUnavailable(conn, directory, error)) + } + 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)), }) } @@ -95,7 +132,7 @@ export function createHomeProjectsController(home: HomeController) { if (authenticate(conn, () => home.project.select(conn, directory))) return home.project.select(conn, directory) }, - 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) @@ -150,15 +187,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/new-session/route.tsx b/packages/app/src/new-session/route.tsx index 963b1f9640fa..bee705203574 100644 --- a/packages/app/src/new-session/route.tsx +++ b/packages/app/src/new-session/route.tsx @@ -3,7 +3,7 @@ import { createMemo, Show, type ParentProps } from "solid-js" import { CommentsProvider } from "@/composer/comments" import { FileProvider } from "@/workspaces/files/model" import { useGlobal } from "@/runtime/server/runtime" -import { LocationProvider } from "@/workspaces/location" +import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location" import { ModelsProvider } from "@/providers/models/models" import { ComposerPersistenceProvider } from "@/composer/persistence" import { ServerProvider, useServer } from "@/runtime/server/current" @@ -13,6 +13,8 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider" import NewSession from "@/new-session/screen" import { IncompatibleServerPanel } from "@/session/incompatible-server-panel" import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" +import { LocationUnavailable } from "@/session/location-unavailable" +import { useLanguage } from "@/runtime/i18n/language" export function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() @@ -67,17 +69,49 @@ function ResolvedDraftContent(props: { draft: DraftTab }) { > - - - - - + ) } +function DraftPage(props: { draft: DraftTab }) { + const server = useServer() + const tabs = useTabs() + const language = useLanguage() + const location = useWorkspaceLocation() + return ( + + + + + + } + > + + + { + server.ctx.projects.open(directory) + server.ctx.projects.touch(directory) + tabs.updateDraft(props.draft.draftID, { directory, worktree: undefined, branch: undefined }) + }} + onCloseTab={() => { + const index = tabs.store.findIndex((tab) => tab.type === "draft" && tab.draftID === props.draft.draftID) + if (index !== -1) tabs.closeTab(index) + }} + /> + + + + ) +} + // The draft page only renders the prompt composer. // FileProvider and CommentsProvider stay because Composer uses file search and comment context. function DraftProviders(props: ParentProps) { diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index fbb3e6994f94..beba5b7b088d 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -393,6 +393,21 @@ export const dict = { "toast.project.copyID.failed.description": "An error occurred while copying the project ID", "toast.project.reloadFailed.title": "Failed to reload {{project}}", + "toast.project.defaultUnavailable.title": "Cannot open default project", + "toast.project.missing.remove": "Remove project", + "session.location.move": "Move session", + "session.location.choose": "Choose directory", + "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.", + "error.project.permissionDenied": + "OpenCode doesn't have permission to open {{directory}}. Make sure your user account can read the folder, then try again.", + "error.project.permissionDenied.macos": + "macOS is blocking OpenCode from opening {{directory}}. In System Settings > Privacy & Security > Files & Folders (or Full Disk Access), allow OpenCode. If you started OpenCode from a terminal, allow that terminal app instead. If it still fails, run `opencode service restart` or restart your Mac.", + "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 c70de04c4f2e..929dfa6b9bb7 100644 --- a/packages/app/src/runtime/server/errors.test.ts +++ b/packages/app/src/runtime/server/errors.test.ts @@ -1,7 +1,18 @@ import { describe, expect, test } from "bun:test" -import type { FileNotFoundError, SessionNotFoundError } from "@opencode/client/promise" +import type { + FileNotFoundError, + LocationNotFoundError, + LocationPermissionDeniedError, + SessionNotFoundError, +} from "@opencode/client/promise" import type { ConfigInvalidError, ProviderModelNotFoundError } from "./errors" -import { formatServerError, isSessionNotFoundError, parseReadableConfigInvalidError } from "./errors" +import { + formatProjectLocationError, + formatServerError, + isSessionNotFoundError, + parseReadableConfigInvalidError, + projectLocationError, +} from "./errors" function fill(text: string, vars?: Record) { if (!vars) return text @@ -54,6 +65,32 @@ describe("parseReadableConfigInvalidError", () => { }) describe("formatServerError", () => { + test("explains missing and denied project folders without misclassifying other failures", () => { + const missing = { + _tag: "LocationNotFoundError", + location: { directory: "C:\\Users\\Test User\\Projects\\moved-project" }, + message: "Location not found", + } satisfies LocationNotFoundError + const denied = { + _tag: "LocationPermissionDeniedError", + location: { directory: "/Users/example/Documents/private-project" }, + message: "Location access denied", + } satisfies LocationPermissionDeniedError + for (const error of [missing, new Error("Request failed", { cause: { body: missing, status: 404 } })]) { + expect(projectLocationError(error)).toEqual({ type: "missing", directory: missing.location.directory }) + expect(formatServerError(error)).toContain(`${missing.location.directory} was moved`) + } + const info = projectLocationError(new Error("Request failed", { cause: { body: denied, status: 403 } })) + expect(info).toEqual({ type: "denied", directory: denied.location.directory }) + expect(formatServerError(denied)).toContain(`permission to open ${denied.location.directory}`) + expect(formatServerError(denied)).not.toContain("Privacy & Security") + if (info) expect(formatProjectLocationError(info, undefined, true)).toContain("Privacy & Security") + expect( + projectLocationError({ _tag: "FileNotFoundError", path: "file.txt", message: "File not found" }), + ).toBeUndefined() + expect(projectLocationError(new Error("HTTP 500"))).toBeUndefined() + }) + test.each([ { name: "trimmed config message without issues", diff --git a/packages/app/src/runtime/server/errors.ts b/packages/app/src/runtime/server/errors.ts index 631e6a55fe86..d657271758bf 100644 --- a/packages/app/src/runtime/server/errors.ts +++ b/packages/app/src/runtime/server/errors.ts @@ -1,3 +1,6 @@ +import { isLocationNotFoundError, isLocationPermissionDeniedError } from "@opencode/client/promise" +import { dict } from "../i18n/en" + export type ConfigInvalidError = { name: "ConfigInvalidError" data: { @@ -25,7 +28,31 @@ function tr(translator: Translator | undefined, key: string, text: string, vars? return out } +export function projectLocationError(error: unknown) { + const unwrapped = unwrapNamedError(error) + if (isLocationNotFoundError(unwrapped)) return { type: "missing" as const, directory: unwrapped.location.directory } + if (isLocationPermissionDeniedError(unwrapped)) + return { type: "denied" as const, directory: unwrapped.location.directory } +} + +// Pass `macos` only when the server runs on this Mac; its privacy settings then explain the denial. +export function formatProjectLocationError( + info: NonNullable>, + translate?: Translator, + macos?: boolean, +) { + const key = + info.type === "missing" + ? "error.project.missing" + : macos + ? "error.project.permissionDenied.macos" + : "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/session/location-unavailable.tsx b/packages/app/src/session/location-unavailable.tsx new file mode 100644 index 000000000000..a70e5f040da0 --- /dev/null +++ b/packages/app/src/session/location-unavailable.tsx @@ -0,0 +1,112 @@ +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 { usePlatform } from "@/runtime/platform/platform" +import { useServer } from "@/runtime/server/current" +import { formatProjectLocationError, formatServerError } from "@/runtime/server/errors" +import { showToast } from "@/shell/notifications/toast" +import { closeHomeProject } from "@/shell/layout/helpers" +import { useLayout } from "@/shell/state/layout" +import { useDirectoryPicker } from "@/workspaces/selection/picker" +import { useWorkspaceLocation } from "@/workspaces/location" +import { sameDirectory } from "@opencode/util/path" + +// Recovery for a session or draft tab whose folder is missing or blocked. The caller supplies how the tab +// moves to another folder and how it closes. +export function LocationUnavailable(props: { + moveLabel: string + onMove: (directory: string) => Promise + onCloseTab: () => void +}) { + const language = useLanguage() + const platform = usePlatform() + const server = useServer() + const layout = useLayout() + const pickDirectory = useDirectoryPicker() + const location = useWorkspaceLocation() + const [busy, setBusy] = createSignal(false) + // A missing worktree/subdirectory must not close its still-accessible parent project. + const project = createMemo(() => { + const unavailable = location().error + if (!unavailable) return + return server.ctx.projects.list().find((item) => sameDirectory(item.worktree, unavailable.directory)) + }) + + const move = (directory: string) => { + if (busy()) return + setBusy(true) + void props + .onMove(directory) + .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) + } + props.onCloseTab() + } + + return ( +
+
+ +
+
+ {language.t( + location().error?.type === "denied" + ? "toast.project.permissionDenied.title" + : "home.project.missing.title", + )} +
+
+ + {(error) => + formatProjectLocationError( + error(), + language.t, + server.isLocal && platform.platform === "desktop" && platform.os === "macos", + ) + } + +
+
+
+ + + +
+
+
+ ) +} diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index e0aaddb44411..95b810fe13b6 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -6,7 +6,7 @@ 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" @@ -25,6 +25,7 @@ import { IncompatibleServerPanel } from "./incompatible-server-panel" import { SessionErrorFallback } from "./route-error" import { createSessionResolution } from "./session-resolution" import { SessionScreenView } from "./screen" +import { LocationUnavailable } from "./location-unavailable" import { PreparingComposer } from "./preparing-composer" export function TargetSessionRouteContent() { @@ -167,16 +168,40 @@ function SessionStatePanel(props: ParentProps) { } function TargetSessionPage() { + const params = useParams<{ id: string }>() + const server = useServer() + const data = useData() + const tabs = useTabs() + const language = useLanguage() + 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. - - - - - - - + + + + + + + + } + > + + + server.ctx.sdk.api.session + .move({ sessionID: params.id, directory }) + .then(() => data.session.sync(params.id)) + } + onCloseTab={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })} + /> + + ) } diff --git a/packages/app/src/shell/errors/error.tsx b/packages/app/src/shell/errors/error.tsx index f62712d80de6..d3328e98ed00 100644 --- a/packages/app/src/shell/errors/error.tsx +++ b/packages/app/src/shell/errors/error.tsx @@ -7,6 +7,7 @@ import { createStore } from "solid-js/store" import { Predicate } from "effect" import { Updater } from "@opencode/gui-extensions/updater" import { usePlatform } from "@/runtime/platform/platform" +import { formatProjectLocationError, projectLocationError } from "@/runtime/server/errors" import { useLanguage } from "@/runtime/i18n/language" import { createIpcClients } from "@/runtime/extension/ipc" import { Icon } from "@opencode/ui/icon" @@ -257,6 +258,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<{ @@ -339,9 +341,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))}

+ readonly retry: () => Promise } const context = createSimpleContext({ @@ -28,6 +31,31 @@ 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 load = async (location: LocationRef) => { + // The server keeps a booted Location cached after its folder disappears, and so does the client + // sync. location.get re-checks the folder, so ask it again whenever a cached Location is entered. + if (!location.workspaceID && data.location.info(location)) + await serverSDK.api.location.get({ location: { directory: location.directory } }) + await data.location.sync(location) + } + const sync = (location: LocationRef) => + load(location).then( + () => setFailure(undefined), + (cause: unknown) => { + const unavailable = projectLocationError(cause) + if (unavailable) setFailure({ ref: location, error: unavailable }) + }, + ) createEffect(() => { const location = ref() @@ -36,14 +64,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. - void retry(() => (stale ? Promise.resolve() : data.location.sync(location)), { - retryIf: () => !stale, - }).catch(() => undefined) + // 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() : load(location)), { + 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 +90,8 @@ const context = createSimpleContext({ ...location(), ref: ref(), current: current(), + error: error(), + retry: () => sync(ref()), })) }, }) diff --git a/packages/desktop/src/main/lifecycle/default-project.test.ts b/packages/desktop/src/main/lifecycle/default-project.test.ts new file mode 100644 index 000000000000..889240cb5d99 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/default-project.test.ts @@ -0,0 +1,34 @@ +import { expect } from "bun:test" +import { NodeServices } from "@effect/platform-node" +import { Effect, FileSystem, Path } from "effect" +import { testEffect } from "../../../../core/test/lib/effect" +import { makeDefaultProject } from "./default-project" + +const it = testEffect(NodeServices.layer) + +it.live("creates an accessible default project", Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.makeTempDirectoryScoped({ prefix: "onboarding-project-" }) + const directory = path.join(root, "Default Project") + + expect(yield* makeDefaultProject(directory)).toBe(directory) + expect(yield* fs.exists(directory)).toBe(true) +})) + +if (process.platform !== "win32") + it.live("reports denied access to the default project and succeeds after access returns", Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem + const path = yield* Path.Path + const root = yield* fs.makeTempDirectoryScoped({ prefix: "onboarding-denied-" }) + const parent = path.join(root, "protected") + const directory = path.join(parent, "Default Project") + yield* fs.makeDirectory(parent) + yield* Effect.addFinalizer(() => 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..421b34774441 --- /dev/null +++ b/packages/desktop/src/main/lifecycle/default-project.ts @@ -0,0 +1,21 @@ +import { Effect, FileSystem } from "effect" + +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) => { + // macOS privacy denials are reported as Unknown with an EPERM cause. + if ( + error.reason._tag === "PermissionDenied" || + (error.reason._tag === "Unknown" && + error.reason.cause instanceof Error && + "code" in error.reason.cause && + error.reason.cause.code === "EPERM") + ) + return Effect.succeed(true) + return Effect.fail(error) + }), + ) + return denied ? { permissionDenied: directory } : directory +}) diff --git a/packages/desktop/src/main/lifecycle/onboarding.ts b/packages/desktop/src/main/lifecycle/onboarding.ts index 91e9fabf1e6e..c3bc820fe779 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 }) + // A denied default project still completes onboarding, so a refused privacy prompt is not repeated every launch. + const result = defaultProject ? yield* makeDefaultProject(defaultProject) : null 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 a3f0d1bd6e00..8dac0801a23b 100644 --- a/packages/desktop/src/renderer/api-types.ts +++ b/packages/desktop/src/renderer/api-types.ts @@ -19,7 +19,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 c23de5736a9b..e28aa0e94872 100644 --- a/packages/desktop/src/renderer/desktop-app.tsx +++ b/packages/desktop/src/renderer/desktop-app.tsx @@ -7,6 +7,7 @@ import { currentRoute, PlatformProvider, preloadRoute, + projectLocationError, ServerConnection, useCommand, useCurrentRoute, @@ -26,7 +27,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" const MigrationStatus = lazy(() => import("./migration-status").then((module) => ({ default: module.MigrationStatus }))) @@ -143,6 +144,10 @@ export function DesktopApp(props: { api: ElectronAPI; version: string }) { return ( { + if (projectLocationError(error)) 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..b7e40afba909 100644 --- a/packages/desktop/src/renderer/onboarding/index.tsx +++ b/packages/desktop/src/renderer/onboarding/index.tsx @@ -1,4 +1,16 @@ -import { ServerConnection, useCurrentRoute, useGlobal, useServers, useTabs } from "@opencode/app/desktop" +import { + formatProjectLocationError, + formatServerError, + projectLocationError, + ServerConnection, + useCurrentRoute, + useGlobal, + useLanguage, + usePlatform, + 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 +25,8 @@ export function DesktopFirstLaunchOnboarding(props: { const global = useGlobal() const tabs = useTabs() const route = useCurrentRoute() + const language = useLanguage() + const macos = usePlatform().os === "macos" const [completed] = createResource(async () => { await runFirstLaunchOnboarding() @@ -40,18 +54,48 @@ 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: formatProjectLocationError( + { type: "denied", directory: directory.permissionDenied }, + language.t, + macos, + ), + }) + 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) { + const unavailable = projectLocationError(failure.error) + showToast({ + variant: "error", + persistent: true, + title: language.t("toast.project.defaultUnavailable.title"), + description: unavailable + ? formatProjectLocationError(unavailable, language.t, macos) + : 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 30496fb13a4b..8ed1a7064513 100644 --- a/packages/desktop/src/shared/ipc-rpc/app.ts +++ b/packages/desktop/src/shared/ipc-rpc/app.ts @@ -27,7 +27,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", {