From a0c946155e44f39b519ddaa11db42d6ffdbb4c14 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:42:45 -0700 Subject: [PATCH] fix(server): preserve workspace root stat failures Co-authored-by: codex --- apps/server/src/server.test.ts | 34 +++++++++ apps/server/src/workspace/WorkspaceEntries.ts | 1 + .../src/workspace/WorkspacePaths.test.ts | 74 +++++++++++++++++++ apps/server/src/workspace/WorkspacePaths.ts | 59 +++++++++++++-- apps/server/src/ws.ts | 6 ++ packages/contracts/src/project.ts | 1 + 6 files changed, 168 insertions(+), 7 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 32a7cc17944..e1daf20ed57 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -2,6 +2,7 @@ import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; import * as NodeSocket from "@effect/platform-node/NodeSocket"; import * as NodeServices from "@effect/platform-node/NodeServices"; import * as NodeCrypto from "node:crypto"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { AuthAccessTokenType, @@ -4541,6 +4542,39 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("reports workspace root stat failures without relabeling them as missing", () => + Effect.gen(function* () { + if ((yield* HostProcessPlatform) === "win32") return; + + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const blockedRoot = yield* fs.makeTempDirectoryScoped({ + prefix: "t3-ws-workspace-stat-error-", + }); + const workspaceRoot = path.join(blockedRoot, "workspace"); + yield* fs.makeDirectory(workspaceRoot); + yield* fs.chmod(blockedRoot, 0o000); + + const result = yield* Effect.gen(function* () { + yield* buildAppUnderTest(); + const wsUrl = yield* getWsServerUrl("/ws"); + return yield* Effect.scoped( + withWsRpcClient(wsUrl, (client) => + client[WS_METHODS.projectsListEntries]({ cwd: workspaceRoot }).pipe(Effect.result), + ), + ); + }).pipe(Effect.ensuring(fs.chmod(blockedRoot, 0o700).pipe(Effect.ignore))); + + if (result._tag !== "Failure" || result.failure._tag !== "ProjectListEntriesError") { + assert.fail("Expected a ProjectListEntriesError"); + } + const error = result.failure; + assert.equal(error.failure, "workspace_root_stat_failed"); + assert.equal(error.normalizedCwd, workspaceRoot); + assert.equal(error.detail, "validate-existing"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("routes websocket rpc projects.writeFile", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index cdb26a38bc7..81fb735ea2e 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -73,6 +73,7 @@ export type WorkspaceEntriesBrowseError = typeof WorkspaceEntriesBrowseError.Typ export const WorkspaceEntriesError = Schema.Union([ WorkspacePaths.WorkspaceRootNotExistsError, WorkspacePaths.WorkspaceRootCreateFailedError, + WorkspacePaths.WorkspaceRootStatFailedError, WorkspacePaths.WorkspaceRootNotDirectoryError, WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed, WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut, diff --git a/apps/server/src/workspace/WorkspacePaths.test.ts b/apps/server/src/workspace/WorkspacePaths.test.ts index ecce54b67d6..4f3bc833b4c 100644 --- a/apps/server/src/workspace/WorkspacePaths.test.ts +++ b/apps/server/src/workspace/WorkspacePaths.test.ts @@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as WorkspacePaths from "./WorkspacePaths.ts"; @@ -91,6 +92,79 @@ it.layer(TestLayer)("WorkspacePathsLive", (it) => { expect(error.message).toContain("Workspace root is not a directory:"); }), ); + + it.effect("preserves non-NotFound stat failures while validating the root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => + Effect.fail( + PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: "Test PermissionDenied stat failure.", + }), + ), + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./permission-denied "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot).pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "validate-existing", + }); + }), + ); + + it.effect("preserves stat failures while verifying a newly created root", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + let statCalls = 0; + const workspacePaths = yield* WorkspacePaths.make.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fileSystem, + stat: (path) => { + statCalls += 1; + const reason = statCalls === 1 ? "NotFound" : "PermissionDenied"; + return Effect.fail( + PlatformError.systemError({ + _tag: reason, + module: "FileSystem", + method: "stat", + pathOrDescriptor: String(path), + description: `Test ${reason} stat failure.`, + }), + ); + }, + makeDirectory: () => Effect.void, + }), + ); + const path = yield* Path.Path; + const workspaceRoot = " ./created-then-unreadable "; + const normalizedWorkspaceRoot = path.resolve(workspaceRoot.trim()); + + const error = yield* workspacePaths + .normalizeWorkspaceRoot(workspaceRoot, { createIfMissing: true }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspacePaths.WorkspaceRootStatFailedError); + expect(error).toMatchObject({ + workspaceRoot, + normalizedWorkspaceRoot, + phase: "verify-created", + }); + }), + ); }); describe("resolveRelativePathWithinRoot", () => { diff --git a/apps/server/src/workspace/WorkspacePaths.ts b/apps/server/src/workspace/WorkspacePaths.ts index 85e3db561c4..5acf6677cde 100644 --- a/apps/server/src/workspace/WorkspacePaths.ts +++ b/apps/server/src/workspace/WorkspacePaths.ts @@ -40,6 +40,20 @@ export class WorkspaceRootCreateFailedError extends Schema.TaggedErrorClass()( + "WorkspaceRootStatFailedError", + { + workspaceRoot: Schema.String, + normalizedWorkspaceRoot: Schema.String, + phase: Schema.Literals(["validate-existing", "verify-created"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to stat workspace root '${this.normalizedWorkspaceRoot}' during '${this.phase}'.`; + } +} + export class WorkspaceRootNotDirectoryError extends Schema.TaggedErrorClass()( "WorkspaceRootNotDirectoryError", { @@ -67,6 +81,7 @@ export class WorkspacePathOutsideRootError extends Schema.TaggedErrorClass Effect.Effect< string, - WorkspaceRootNotExistsError | WorkspaceRootCreateFailedError | WorkspaceRootNotDirectoryError + | WorkspaceRootNotExistsError + | WorkspaceRootCreateFailedError + | WorkspaceRootStatFailedError + | WorkspaceRootNotDirectoryError >; /** * Resolve a relative path within a validated workspace root. @@ -117,13 +135,38 @@ export const make = Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; + const statWorkspaceRoot = Effect.fn("WorkspacePaths.statWorkspaceRoot")(function* ( + workspaceRoot: string, + normalizedWorkspaceRoot: string, + phase: WorkspaceRootStatFailedError["phase"], + ) { + return yield* fileSystem.stat(normalizedWorkspaceRoot).pipe( + Effect.matchEffect({ + onFailure: (cause) => + cause.reason._tag === "NotFound" + ? Effect.succeed(null) + : Effect.fail( + new WorkspaceRootStatFailedError({ + workspaceRoot, + normalizedWorkspaceRoot, + phase, + cause, + }), + ), + onSuccess: Effect.succeed, + }), + ); + }); + const normalizeWorkspaceRoot: WorkspacePaths["Service"]["normalizeWorkspaceRoot"] = Effect.fn( "WorkspacePaths.normalizeWorkspaceRoot", )(function* (workspaceRoot, options) { const normalizedWorkspaceRoot = path.resolve(expandHomePath(workspaceRoot.trim(), path)); - let workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + let workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "validate-existing", + ); if (!workspaceStat && options?.createIfMissing) { yield* fileSystem.makeDirectory(normalizedWorkspaceRoot, { recursive: true }).pipe( Effect.mapError( @@ -135,9 +178,11 @@ export const make = Effect.gen(function* () { }), ), ); - workspaceStat = yield* fileSystem - .stat(normalizedWorkspaceRoot) - .pipe(Effect.orElseSucceed(() => null)); + workspaceStat = yield* statWorkspaceRoot( + workspaceRoot, + normalizedWorkspaceRoot, + "verify-created", + ); } if (!workspaceStat) { return yield* new WorkspaceRootNotExistsError({ diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 7c45d0b58b8..05e78de476c 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -149,6 +149,12 @@ function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesEr failure: "workspace_root_create_failed", normalizedCwd: error.normalizedWorkspaceRoot, }; + case "WorkspaceRootStatFailedError": + return { + failure: "workspace_root_stat_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + detail: error.phase, + }; case "WorkspaceRootNotDirectoryError": return { failure: "workspace_root_not_directory", diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 338b87096d9..d59b9770ad3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -40,6 +40,7 @@ export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; export const ProjectEntriesFailure = Schema.Literals([ "workspace_root_not_found", "workspace_root_create_failed", + "workspace_root_stat_failed", "workspace_root_not_directory", "search_index_create_failed", "search_index_scan_timed_out",