From a7c5cce24db5a1dbf7e7d6db94bb6e8d51b6f88d Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:28:25 -0700 Subject: [PATCH 1/3] Structure workspace file system errors Co-authored-by: codex --- .../src/workspace/WorkspaceFileSystem.test.ts | 87 ++++++- .../src/workspace/WorkspaceFileSystem.ts | 226 ++++++++++++++---- 2 files changed, 258 insertions(+), 55 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index aa2dabb3337..cecffbc1993 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const error = yield* workspaceFileSystem .readFile({ cwd, relativePath: "linked-secret.txt" }) .pipe(Effect.flip); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); - expect(error.message).toBe( - `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, - ); + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-secret.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects directories without manufacturing an I/O cause", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* fileSystem.makeDirectory(path.join(cwd, "src")); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "src" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "src", + resolvedPath, + }); + expect("cause" in error).toBe(false); + }), + ); + + it.effect("rejects binary files without leaking their contents into the error", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const absolutePath = path.join(cwd, "asset.bin"); + yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "asset.bin" }) + .pipe(Effect.flip); + const resolvedPath = yield* fileSystem.realPath(absolutePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "asset.bin", + resolvedPath, + }); + expect("cause" in error).toBe(false); + expect("contents" in error).toBe(false); + }), + ); + + it.effect("preserves the real cause and path for I/O failures", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const resolvedPath = path.join(cwd, "missing.txt"); + + const error = yield* workspaceFileSystem + .readFile({ cwd, relativePath: "missing.txt" }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "missing.txt", + resolvedPath, + operationPath: resolvedPath, + operation: "realpath-target", + }); expect(error.cause).toBeInstanceOf(Error); - expect((error.cause as Error).message).toBe( - "Workspace file path resolves outside the project root.", - ); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); }), ); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 8cd176db3dd..e2dc9cbbb39 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; -export class WorkspaceFileSystemError extends Schema.TaggedErrorClass()( - "WorkspaceFileSystemError", +export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceFileSystemOperationError", { - cwd: Schema.String, - relativePath: Schema.optional(Schema.String), + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + operationPath: Schema.String, operation: Schema.Literals([ - "workspaceFileSystem.readFile", - "workspaceFileSystem.makeDirectory", - "workspaceFileSystem.writeFile", + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", ]), cause: Schema.Defect(), }, ) { override get message(): string { - const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; - return `Workspace file operation '${this.operation}' failed for ${target}.`; + return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; } } +export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass()( + "WorkspaceFilePathEscapeError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedWorkspaceRoot: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`; + } +} + +export class WorkspacePathNotFileError extends Schema.TaggedErrorClass()( + "WorkspacePathNotFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`; + } +} + +export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass()( + "WorkspaceBinaryFileError", + { + workspaceRoot: Schema.String, + relativePath: Schema.String, + resolvedPath: Schema.String, + }, +) { + override get message(): string { + return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`; + } +} + +export const WorkspaceFileSystemError = Schema.Union([ + WorkspaceFileSystemOperationError, + WorkspaceFilePathEscapeError, + WorkspacePathNotFileError, + WorkspaceBinaryFileError, +]); +export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; + /** Service tag for workspace file operations. */ export class WorkspaceFileSystem extends Context.Service< WorkspaceFileSystem, @@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - return yield* Effect.tryPromise({ - try: async () => { - const [realWorkspaceRoot, realTargetPath] = await Promise.all([ - NodeFSP.realpath(input.cwd), - NodeFSP.realpath(target.absolutePath), - ]); - const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); - if ( - relativeRealPath.startsWith(`..${path.sep}`) || - relativeRealPath === ".." || - path.isAbsolute(relativeRealPath) - ) { - throw new Error("Workspace file path resolves outside the project root."); - } - - const handle = await NodeFSP.open(realTargetPath, "r"); - try { - const stat = await handle.stat(); + const realWorkspaceRoot = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.cwd), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: input.cwd, + operation: "realpath-workspace-root", + cause, + }), + }); + const realTargetPath = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(target.absolutePath), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "realpath-target", + cause, + }), + }); + const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); + } + + return yield* Effect.acquireUseRelease( + Effect.tryPromise({ + try: () => NodeFSP.open(realTargetPath, "r"), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "open", + cause, + }), + }), + (handle) => + Effect.gen(function* () { + const stat = yield* Effect.tryPromise({ + try: () => handle.stat(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "stat", + cause, + }), + }); if (!stat.isFile()) { - throw new Error("Workspace path is not a file."); + return yield* new WorkspacePathNotFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); } + const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); const buffer = Buffer.alloc(bytesToRead); - const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); + const { bytesRead } = yield* Effect.tryPromise({ + try: () => handle.read(buffer, 0, bytesToRead, 0), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "read", + cause, + }), + }); const fileBytes = buffer.subarray(0, bytesRead); if (fileBytes.includes(0)) { - throw new Error("Binary files cannot be previewed as text."); + return yield* new WorkspaceBinaryFileError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + }); } - const contents = new TextDecoder("utf-8").decode(fileBytes); + return { relativePath: target.relativePath, - contents, + contents: new TextDecoder("utf-8").decode(fileBytes), byteLength: stat.size, truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, }; - } finally { - await handle.close(); - } - }, - catch: (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, - relativePath: input.relativePath, - operation: "workspaceFileSystem.readFile", - cause, }), - }); + (handle) => + Effect.tryPromise({ + try: () => handle.close(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "close", + cause, + }), + }), + ); }); const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( @@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( Effect.mapError( (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, relativePath: input.relativePath, - operation: "workspaceFileSystem.makeDirectory", + resolvedPath: target.absolutePath, + operationPath: path.dirname(target.absolutePath), + operation: "make-directory", cause, }), ), @@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( Effect.mapError( (cause) => - new WorkspaceFileSystemError({ - cwd: input.cwd, + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, relativePath: input.relativePath, - operation: "workspaceFileSystem.writeFile", + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + operation: "write-file", cause, }), ), From 9584417f21ce2338c79140f320c0600307927f47 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 03:40:44 -0700 Subject: [PATCH 2/3] fix(server): preserve workspace read error messages Co-authored-by: codex --- apps/server/src/server.test.ts | 3 ++- apps/server/src/ws.ts | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 2202d30b837..3bc430db956 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4443,6 +4443,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const outsideFile = path.join(outsideDir, "outside.txt"); yield* fs.writeFileString(outsideFile, "outside\n"); yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); + const resolvedOutsideFile = yield* fs.realPath(outsideFile); yield* buildAppUnderTest(); @@ -4485,7 +4486,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { assertTrue(results.read._tag === "Failure"); assert.equal( results.read.failure.message, - "Failed to read workspace file: Workspace file path resolves outside the project root.", + `Failed to read workspace file: Workspace file 'linked-outside.txt' resolves outside workspace root '${workspaceDir}': ${resolvedOutsideFile}`, ); assertTrue(results.browse._tag === "Failure"); assert.equal( diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 03b609ddcfe..9e0a08b7ca0 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -170,6 +170,21 @@ function workspaceBrowseCompatibilityDetail( } } +function workspaceFileReadCompatibilityDetail( + error: WorkspaceFileSystem.WorkspaceFileSystemError, +): string { + switch (error._tag) { + case "WorkspaceFileSystemOperationError": + return legacyPlatformFailureDescription(error.cause); + case "WorkspaceFilePathEscapeError": + case "WorkspacePathNotFileError": + case "WorkspaceBinaryFileError": + return error.message; + default: + return unexpectedCompatibilityError(error); + } +} + function projectSetupScriptCompatibilityDetail( error: ProjectSetupScriptRunner.ProjectSetupScriptRunnerError, ): string { @@ -1288,7 +1303,7 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError((cause) => { const message = isWorkspacePathOutsideRootError(cause) ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; + : `Failed to read workspace file: ${workspaceFileReadCompatibilityDetail(cause)}`; return new ProjectReadFileError({ message, cause }); }), ), From d2e14715133161fa8f6cf6885de2e554d16ec67b Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 11:35:13 -0700 Subject: [PATCH 3/3] [codex] Structure project and filesystem RPC errors (#3400) Co-authored-by: codex --- apps/server/src/server.test.ts | 92 ++++++++++---- apps/server/src/ws.ts | 140 +++++++++++++++------- packages/contracts/src/filesystem.test.ts | 33 +++++ packages/contracts/src/filesystem.ts | 39 +++++- packages/contracts/src/project.test.ts | 67 +++++++++++ packages/contracts/src/project.ts | 136 ++++++++++++++++++++- 6 files changed, 438 insertions(+), 69 deletions(-) create mode 100644 packages/contracts/src/filesystem.test.ts create mode 100644 packages/contracts/src/project.test.ts diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 3bc430db956..32a7cc17944 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), ); - it.effect("preserves workspace rpc failure messages", () => + it.effect("preserves structured workspace rpc failures", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -4449,13 +4449,14 @@ it.layer(NodeServices.layer)("server router seam", (it) => { const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); const missingBrowseParent = path.join(workspaceDir, "missing-browse"); + const sensitiveQuery = "authorization: Bearer secret-token"; const wsUrl = yield* getWsServerUrl("/ws"); const results = yield* Effect.scoped( withWsRpcClient(wsUrl, (client) => Effect.all({ search: client[WS_METHODS.projectsSearchEntries]({ cwd: invalidWorkspace, - query: "needle", + query: sensitiveQuery, limit: 10, }).pipe(Effect.result), list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( @@ -4473,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ), ); - assertTrue(results.search._tag === "Failure"); - assert.equal( - results.search.failure.message, - `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, - ); - assertTrue(results.list._tag === "Failure"); + if ( + results.search._tag !== "Failure" || + results.search.failure._tag !== "ProjectSearchEntriesError" + ) { + assert.fail("Expected a ProjectSearchEntriesError"); + } + const searchError = results.search.failure; assert.equal( - results.list.failure.message, - `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, + searchError.message, + `Failed to search workspace entries in '${invalidWorkspace}'.`, ); - assertTrue(results.read._tag === "Failure"); + assert.equal(searchError.cwd, invalidWorkspace); + assert.equal(searchError.queryLength, sensitiveQuery.length); + assert.notProperty(searchError, "query"); + assert.notInclude(searchError.message, "Bearer"); + assert.notInclude(searchError.message, "secret-token"); + assert.equal(searchError.limit, 10); + assert.equal(searchError.failure, "workspace_root_not_found"); + assert.equal(searchError.normalizedCwd, invalidWorkspace); + assert.isDefined(searchError.cause); + + if ( + results.list._tag !== "Failure" || + results.list.failure._tag !== "ProjectListEntriesError" + ) { + assert.fail("Expected a ProjectListEntriesError"); + } + const listError = results.list.failure; + assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`); + assert.equal(listError.cwd, invalidWorkspace); + assert.equal(listError.failure, "workspace_root_not_found"); + assert.equal(listError.normalizedCwd, invalidWorkspace); + assert.isDefined(listError.cause); + + if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") { + assert.fail("Expected a ProjectReadFileError"); + } + const readError = results.read.failure; assert.equal( - results.read.failure.message, - `Failed to read workspace file: Workspace file 'linked-outside.txt' resolves outside workspace root '${workspaceDir}': ${resolvedOutsideFile}`, + readError.message, + `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, ); - assertTrue(results.browse._tag === "Failure"); + assert.equal(readError.cwd, workspaceDir); + assert.equal(readError.relativePath, "linked-outside.txt"); + assert.equal(readError.failure, "resolved_path_outside_root"); + assert.equal(readError.resolvedPath, resolvedOutsideFile); + assert.isDefined(readError.cause); + + if ( + results.browse._tag !== "Failure" || + results.browse.failure._tag !== "FilesystemBrowseError" + ) { + assert.fail("Expected a FilesystemBrowseError"); + } + const browseError = results.browse.failure; assert.equal( - results.browse.failure.message, - `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, + browseError.message, + `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`, ); + assert.equal(browseError.cwd, workspaceDir); + assert.equal(browseError.partialPath, "./missing-browse/child"); + assert.equal(browseError.failure, "read_directory_failed"); + assert.equal(browseError.parentPath, missingBrowseParent); + assert.isDefined(browseError.cause); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); @@ -4573,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { ).pipe(Effect.result), ); - assertTrue(result._tag === "Failure"); - assertTrue(result.failure._tag === "ProjectWriteFileError"); + if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") { + assert.fail("Expected a ProjectWriteFileError"); + } + const writeError = result.failure; assert.equal( - result.failure.message, - "Workspace file path must stay within the project root.", + writeError.message, + `Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`, ); + assert.equal(writeError.cwd, workspaceDir); + assert.equal(writeError.relativePath, "../escape.txt"); + assert.equal(writeError.failure, "workspace_path_outside_root"); + assert.isDefined(writeError.cause); + assert.notProperty(writeError, "contents"); }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 9e0a08b7ca0..7c45d0b58b8 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -34,6 +34,9 @@ import { OrchestrationGetSnapshotError, OrchestrationGetTurnDiffError, ORCHESTRATION_WS_METHODS, + type ProjectEntriesFailure, + type ProjectFileFailure, + type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, ProjectSearchEntriesError, @@ -41,6 +44,7 @@ import { RelayClientInstallFailedError, type RelayClientInstallProgressEvent, OrchestrationReplayEventsError, + type FilesystemBrowseFailure, FilesystemBrowseError, AssetAccessError, EnvironmentAuthorizationError, @@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts"; import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; import * as RelayClient from "@t3tools/shared/relayClient"; const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); -const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { throw new Error(`Unhandled compatibility error: ${String(error)}`); } -/** Preserve pre-structured-error display behavior at the RPC boundary. */ -function legacyPlatformFailureDescription(cause: unknown): string { - return cause instanceof Error ? cause.message : String(cause); -} - /** Preserve the setup runner's broader pre-refactor message normalization. */ function legacySetupFailureDescription(cause: unknown): string { if ( @@ -134,52 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { return String(cause); } -function workspaceEntriesCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesError, -): string { +function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; +} { switch (error._tag) { case "WorkspaceRootNotExistsError": - return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_found", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootCreateFailedError": - return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_create_failed", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceRootNotDirectoryError": - return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; + return { + failure: "workspace_root_not_directory", + normalizedCwd: error.normalizedWorkspaceRoot, + }; case "WorkspaceSearchIndexCreateFailed": - return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_create_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; case "WorkspaceSearchIndexScanTimedOut": - return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; + return { + failure: "search_index_scan_timed_out", + normalizedCwd: error.cwd, + timeout: error.timeout, + }; case "WorkspaceSearchIndexSearchFailed": - return `Workspace search failed for '${error.cwd}': ${error.reason}`; + return { + failure: "search_index_search_failed", + normalizedCwd: error.cwd, + detail: error.reason, + }; default: return unexpectedCompatibilityError(error); } } -function workspaceBrowseCompatibilityDetail( - error: WorkspaceEntries.WorkspaceEntriesBrowseError, -): string { +function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; +} { switch (error._tag) { case "WorkspaceEntriesWindowsPathUnsupportedError": - return "Windows-style paths are only supported on Windows."; + return { failure: "windows_path_unsupported", platform: error.platform }; case "WorkspaceEntriesCurrentProjectRequiredError": - return "Relative filesystem browse paths require a current project."; + return { failure: "current_project_required" }; case "WorkspaceEntriesReadDirectoryError": - return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; + return { failure: "read_directory_failed", parentPath: error.parentPath }; default: return unexpectedCompatibilityError(error); } } -function workspaceFileReadCompatibilityDetail( - error: WorkspaceFileSystem.WorkspaceFileSystemError, -): string { +function projectFileFailureContext( + error: + | WorkspaceFileSystem.WorkspaceFileSystemError + | WorkspacePaths.WorkspacePathOutsideRootError, +): { + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; +} { switch (error._tag) { + case "WorkspacePathOutsideRootError": + return { failure: "workspace_path_outside_root" }; case "WorkspaceFileSystemOperationError": - return legacyPlatformFailureDescription(error.cause); + return { + failure: "operation_failed", + resolvedPath: error.resolvedPath, + operation: error.operation, + operationPath: error.operationPath, + }; case "WorkspaceFilePathEscapeError": + return { + failure: "resolved_path_outside_root", + resolvedPath: error.resolvedPath, + resolvedWorkspaceRoot: error.resolvedWorkspaceRoot, + }; case "WorkspacePathNotFileError": + return { failure: "path_not_file", resolvedPath: error.resolvedPath }; case "WorkspaceBinaryFileError": - return error.message; + return { failure: "binary_file", resolvedPath: error.resolvedPath }; default: return unexpectedCompatibilityError(error); } @@ -1275,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectSearchEntriesError({ - message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1289,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new ProjectListEntriesError({ - message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, + ...input, + ...projectEntriesFailureContext(cause), cause, }), ), @@ -1300,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsReadFile, workspaceFileSystem.readFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : `Failed to read workspace file: ${workspaceFileReadCompatibilityDetail(cause)}`; - return new ProjectReadFileError({ message, cause }); - }), + Effect.mapError( + (cause) => + new ProjectReadFileError({ + ...input, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1313,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => observeRpcEffect( WS_METHODS.projectsWriteFile, workspaceFileSystem.writeFile(input).pipe( - Effect.mapError((cause) => { - const message = isWorkspacePathOutsideRootError(cause) - ? "Workspace file path must stay within the project root." - : "Failed to write workspace file"; - return new ProjectWriteFileError({ - message, - cause, - }); - }), + Effect.mapError( + (cause) => + new ProjectWriteFileError({ + cwd: input.cwd, + relativePath: input.relativePath, + ...projectFileFailureContext(cause), + cause, + }), + ), ), { "rpc.aggregate": "workspace" }, ), @@ -1336,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => Effect.mapError( (cause) => new FilesystemBrowseError({ - message: workspaceBrowseCompatibilityDetail(cause), + ...input, + ...filesystemBrowseFailureContext(cause), cause, }), ), diff --git a/packages/contracts/src/filesystem.test.ts b/packages/contracts/src/filesystem.test.ts new file mode 100644 index 00000000000..45355b73edc --- /dev/null +++ b/packages/contracts/src/filesystem.test.ts @@ -0,0 +1,33 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { FilesystemBrowseError } from "./filesystem.ts"; + +describe("FilesystemBrowseError", () => { + it("derives a stable message from browse context while retaining the cause", () => { + const cause = new Error("sensitive filesystem detail"); + const error = new FilesystemBrowseError({ + cwd: "/workspace", + partialPath: "./src/mai", + failure: "read_directory_failed", + parentPath: "/workspace/src", + cause, + }); + + expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'."); + expect(error.message).not.toContain(cause.message); + expect(error.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError); + const error = decodeError({ + _tag: "FilesystemBrowseError", + message: "Legacy filesystem browse failure.", + }); + + expect(error.message).toBe("Legacy filesystem browse failure."); + expect(error.partialPath).toBeUndefined(); + expect(error.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/filesystem.ts b/packages/contracts/src/filesystem.ts index 511f8ee19a3..ca4519b4c8b 100644 --- a/packages/contracts/src/filesystem.ts +++ b/packages/contracts/src/filesystem.ts @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ }); export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; +export const FilesystemBrowseFailure = Schema.Literals([ + "windows_path_unsupported", + "current_project_required", + "read_directory_failed", +]); +export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; + +function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class FilesystemBrowseError extends Schema.TaggedErrorClass()( "FilesystemBrowseError", { + partialPath: Schema.optional(TrimmedNonEmptyString), + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(FilesystemBrowseFailure), + parentPath: Schema.optional(TrimmedNonEmptyString), + platform: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // Structured diagnostics stay optional for rolling compatibility with legacy message-only + // payloads, while new call sites must provide the request context and failure classification. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: { + readonly partialPath: string; + readonly cwd?: string | undefined; + readonly failure: FilesystemBrowseFailure; + readonly parentPath?: string; + readonly platform?: string; + readonly cause?: unknown; + }) { + const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; + super({ + ...props, + message: + decodedFilesystemBrowseErrorMessage(props) ?? + `Failed to browse filesystem path '${props.partialPath}'${cwd}.`, + } as any); + } +} diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts new file mode 100644 index 00000000000..ea9d5a90e7c --- /dev/null +++ b/packages/contracts/src/project.test.ts @@ -0,0 +1,67 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + ProjectReadFileError, + ProjectSearchEntriesError, + ProjectWriteFileError, +} from "./project.ts"; + +describe("project RPC errors", () => { + it("derives stable messages from structured request context while retaining causes", () => { + const cause = new Error("sensitive platform detail"); + const searchError = new ProjectSearchEntriesError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 20, + failure: "search_index_search_failed", + normalizedCwd: "/workspace", + detail: "index unavailable", + cause, + }); + const readError = new ProjectReadFileError({ + cwd: "/workspace", + relativePath: "src/index.ts", + failure: "operation_failed", + operation: "read", + operationPath: "/workspace/src/index.ts", + resolvedPath: "/workspace/src/index.ts", + cause, + }); + + expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); + expect(searchError.message).not.toContain(cause.message); + expect(searchError.normalizedCwd).toBe("/workspace"); + expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(searchError.cause).toBe(cause); + expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); + expect(readError.message).not.toContain(cause.message); + expect(readError.cause).toBe(cause); + }); + + it("decodes legacy message-only errors during rolling upgrades", () => { + const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError); + const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError); + + const searchError = decodeSearchError({ + _tag: "ProjectSearchEntriesError", + message: "Legacy project search failure.", + query: "legacy sensitive query", + }); + const writeError = decodeWriteError({ + _tag: "ProjectWriteFileError", + message: "Legacy project write failure.", + }); + + expect(searchError.message).toBe("Legacy project search failure."); + expect(searchError.cwd).toBeUndefined(); + expect(searchError.queryLength).toBeUndefined(); + expect(searchError).not.toHaveProperty("query"); + expect(searchError.failure).toBeUndefined(); + expect(writeError.message).toBe("Legacy project write failure."); + expect(writeError.relativePath).toBeUndefined(); + expect(writeError.failure).toBeUndefined(); + }); +}); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 29610845288..338b87096d9 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({ }); export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; +export const ProjectEntriesFailure = Schema.Literals([ + "workspace_root_not_found", + "workspace_root_create_failed", + "workspace_root_not_directory", + "search_index_create_failed", + "search_index_scan_timed_out", + "search_index_search_failed", +]); +export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; + +type ProjectEntriesFailureContext = { + readonly failure: ProjectEntriesFailure; + readonly normalizedCwd?: string; + readonly timeout?: string; + readonly detail?: string; + readonly cause?: unknown; +}; + +function decodedProjectErrorMessage(props: object): string | undefined { + if (!("message" in props)) return undefined; + return typeof props.message === "string" ? props.message : undefined; +} + export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( "ProjectSearchEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // The structured fields are optional on the wire so newer peers can decode legacy message-only + // failures. New application code must provide them through this constructor. + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace entries in '${props.cwd}'.`, + } as any); + } +} export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { + cwd: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`, + } as any); + } +} export const ProjectReadFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ }); export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; +export const ProjectFileFailure = Schema.Literals([ + "workspace_path_outside_root", + "resolved_path_outside_root", + "path_not_file", + "binary_file", + "operation_failed", +]); +export type ProjectFileFailure = typeof ProjectFileFailure.Type; + +export const ProjectFileOperation = Schema.Literals([ + "realpath-workspace-root", + "realpath-target", + "open", + "stat", + "read", + "close", + "make-directory", + "write-file", +]); +export type ProjectFileOperation = typeof ProjectFileOperation.Type; + +type ProjectFileFailureContext = { + readonly cwd: string; + readonly relativePath: string; + readonly failure: ProjectFileFailure; + readonly resolvedPath?: string; + readonly resolvedWorkspaceRoot?: string; + readonly operation?: ProjectFileOperation; + readonly operationPath?: string; + readonly cause?: unknown; +}; + export class ProjectReadFileError extends Schema.TaggedErrorClass()( "ProjectReadFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +} export const ProjectWriteFileInput = Schema.Struct({ cwd: TrimmedNonEmptyString, @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; export class ProjectWriteFileError extends Schema.TaggedErrorClass()( "ProjectWriteFileError", { + cwd: Schema.optional(TrimmedNonEmptyString), + relativePath: Schema.optional(TrimmedNonEmptyString), + failure: Schema.optional(ProjectFileFailure), + resolvedPath: Schema.optional(TrimmedNonEmptyString), + resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), + operation: Schema.optional(ProjectFileOperation), + operationPath: Schema.optional(TrimmedNonEmptyString), message: TrimmedNonEmptyString, cause: Schema.optional(Schema.Defect()), }, -) {} +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor(props: ProjectFileFailureContext) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`, + } as any); + } +}