| author | |
| committer | |
| log | cdfecb36a623816f8d032408247e29b007b043f1 |
| tree | a88965b719f940f8e4afebd1985139c27541e8e1 |
| parent | f4ef356215271b47dd2acdd93590f919f548ffd6 |
| signature | Signed by PGP key B5690EEEBB952194 |
Co-authored-by: codex <codex@users.noreply.github.com>8 files changed, 707 insertions(+), 119 deletions(-)
apps/server/src/server.test.ts+73-20| ... | @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4430,7 +4430,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4430 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), | 4430 | }).pipe(Effect.provide(NodeHttpServer.layerTest), TestClock.withLive), |
| 4431 | ); | 4431 | ); |
| 4432 | 4432 | ||
| 4433 | it.effect("preserves workspace rpc failure messages", () => | 4433 | it.effect("preserves structured workspace rpc failures", () => |
| 4434 | Effect.gen(function* () { | 4434 | Effect.gen(function* () { |
| 4435 | const fs = yield* FileSystem.FileSystem; | 4435 | const fs = yield* FileSystem.FileSystem; |
| 4436 | const path = yield* Path.Path; | 4436 | const path = yield* Path.Path; |
| ... | @@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4443,18 +4443,20 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4443 | const outsideFile = path.join(outsideDir, "outside.txt"); | 4443 | const outsideFile = path.join(outsideDir, "outside.txt"); |
| 4444 | yield* fs.writeFileString(outsideFile, "outside\n"); | 4444 | yield* fs.writeFileString(outsideFile, "outside\n"); |
| 4445 | yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); | 4445 | yield* fs.symlink(outsideFile, path.join(workspaceDir, "linked-outside.txt")); |
| 4446 | const resolvedOutsideFile = yield* fs.realPath(outsideFile); | ||
| 4446 | 4447 | ||
| 4447 | yield* buildAppUnderTest(); | 4448 | yield* buildAppUnderTest(); |
| 4448 | 4449 | ||
| 4449 | const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); | 4450 | const invalidWorkspace = path.join(workspaceDir, "missing-workspace"); |
| 4450 | const missingBrowseParent = path.join(workspaceDir, "missing-browse"); | 4451 | const missingBrowseParent = path.join(workspaceDir, "missing-browse"); |
| 4452 | const sensitiveQuery = "authorization: Bearer secret-token"; | ||
| 4451 | const wsUrl = yield* getWsServerUrl("/ws"); | 4453 | const wsUrl = yield* getWsServerUrl("/ws"); |
| 4452 | const results = yield* Effect.scoped( | 4454 | const results = yield* Effect.scoped( |
| 4453 | withWsRpcClient(wsUrl, (client) => | 4455 | withWsRpcClient(wsUrl, (client) => |
| 4454 | Effect.all({ | 4456 | Effect.all({ |
| 4455 | search: client[WS_METHODS.projectsSearchEntries]({ | 4457 | search: client[WS_METHODS.projectsSearchEntries]({ |
| 4456 | cwd: invalidWorkspace, | 4458 | cwd: invalidWorkspace, |
| 4457 | query: "needle", | 4459 | query: sensitiveQuery, |
| 4458 | limit: 10, | 4460 | limit: 10, |
| 4459 | }).pipe(Effect.result), | 4461 | }).pipe(Effect.result), |
| 4460 | list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( | 4462 | list: client[WS_METHODS.projectsListEntries]({ cwd: invalidWorkspace }).pipe( |
| ... | @@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4472,26 +4474,70 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4472 | ), | 4474 | ), |
| 4473 | ); | 4475 | ); |
| 4474 | 4476 | ||
| 4475 | assertTrue(results.search._tag === "Failure"); | 4477 | if ( |
| 4476 | assert.equal( | 4478 | results.search._tag !== "Failure" || |
| 4477 | results.search.failure.message, | 4479 | results.search.failure._tag !== "ProjectSearchEntriesError" |
| 4478 | `Failed to search workspace entries: Workspace root does not exist: ${invalidWorkspace}`, | 4480 | ) { |
| 4479 | ); | 4481 | assert.fail("Expected a ProjectSearchEntriesError"); |
| 4480 | assertTrue(results.list._tag === "Failure"); | 4482 | } |
| 4483 | const searchError = results.search.failure; | ||
| 4481 | assert.equal( | 4484 | assert.equal( |
| 4482 | results.list.failure.message, | 4485 | searchError.message, |
| 4483 | `Failed to list workspace entries: Workspace root does not exist: ${invalidWorkspace}`, | 4486 | `Failed to search workspace entries in '${invalidWorkspace}'.`, |
| 4484 | ); | 4487 | ); |
| 4485 | assertTrue(results.read._tag === "Failure"); | 4488 | assert.equal(searchError.cwd, invalidWorkspace); |
| 4489 | assert.equal(searchError.queryLength, sensitiveQuery.length); | ||
| 4490 | assert.notProperty(searchError, "query"); | ||
| 4491 | assert.notInclude(searchError.message, "Bearer"); | ||
| 4492 | assert.notInclude(searchError.message, "secret-token"); | ||
| 4493 | assert.equal(searchError.limit, 10); | ||
| 4494 | assert.equal(searchError.failure, "workspace_root_not_found"); | ||
| 4495 | assert.equal(searchError.normalizedCwd, invalidWorkspace); | ||
| 4496 | assert.isDefined(searchError.cause); | ||
| 4497 | |||
| 4498 | if ( | ||
| 4499 | results.list._tag !== "Failure" || | ||
| 4500 | results.list.failure._tag !== "ProjectListEntriesError" | ||
| 4501 | ) { | ||
| 4502 | assert.fail("Expected a ProjectListEntriesError"); | ||
| 4503 | } | ||
| 4504 | const listError = results.list.failure; | ||
| 4505 | assert.equal(listError.message, `Failed to list workspace entries in '${invalidWorkspace}'.`); | ||
| 4506 | assert.equal(listError.cwd, invalidWorkspace); | ||
| 4507 | assert.equal(listError.failure, "workspace_root_not_found"); | ||
| 4508 | assert.equal(listError.normalizedCwd, invalidWorkspace); | ||
| 4509 | assert.isDefined(listError.cause); | ||
| 4510 | |||
| 4511 | if (results.read._tag !== "Failure" || results.read.failure._tag !== "ProjectReadFileError") { | ||
| 4512 | assert.fail("Expected a ProjectReadFileError"); | ||
| 4513 | } | ||
| 4514 | const readError = results.read.failure; | ||
| 4486 | assert.equal( | 4515 | assert.equal( |
| 4487 | results.read.failure.message, | 4516 | readError.message, |
| 4488 | "Failed to read workspace file: Workspace file path resolves outside the project root.", | 4517 | `Failed to read workspace file 'linked-outside.txt' in '${workspaceDir}'.`, |
| 4489 | ); | 4518 | ); |
| 4490 | assertTrue(results.browse._tag === "Failure"); | 4519 | assert.equal(readError.cwd, workspaceDir); |
| 4520 | assert.equal(readError.relativePath, "linked-outside.txt"); | ||
| 4521 | assert.equal(readError.failure, "resolved_path_outside_root"); | ||
| 4522 | assert.equal(readError.resolvedPath, resolvedOutsideFile); | ||
| 4523 | assert.isDefined(readError.cause); | ||
| 4524 | |||
| 4525 | if ( | ||
| 4526 | results.browse._tag !== "Failure" || | ||
| 4527 | results.browse.failure._tag !== "FilesystemBrowseError" | ||
| 4528 | ) { | ||
| 4529 | assert.fail("Expected a FilesystemBrowseError"); | ||
| 4530 | } | ||
| 4531 | const browseError = results.browse.failure; | ||
| 4491 | assert.equal( | 4532 | assert.equal( |
| 4492 | results.browse.failure.message, | 4533 | browseError.message, |
| 4493 | `Unable to browse '${missingBrowseParent}': ENOENT: no such file or directory, scandir '${missingBrowseParent}'`, | 4534 | `Failed to browse filesystem path './missing-browse/child' from '${workspaceDir}'.`, |
| 4494 | ); | 4535 | ); |
| 4536 | assert.equal(browseError.cwd, workspaceDir); | ||
| 4537 | assert.equal(browseError.partialPath, "./missing-browse/child"); | ||
| 4538 | assert.equal(browseError.failure, "read_directory_failed"); | ||
| 4539 | assert.equal(browseError.parentPath, missingBrowseParent); | ||
| 4540 | assert.isDefined(browseError.cause); | ||
| 4495 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4541 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4496 | ); | 4542 | ); |
| 4497 | 4543 | ||
| ... | @@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -4572,12 +4618,19 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 4572 | ).pipe(Effect.result), | 4618 | ).pipe(Effect.result), |
| 4573 | ); | 4619 | ); |
| 4574 | 4620 | ||
| 4575 | assertTrue(result._tag === "Failure"); | 4621 | if (result._tag !== "Failure" || result.failure._tag !== "ProjectWriteFileError") { |
| 4576 | assertTrue(result.failure._tag === "ProjectWriteFileError"); | 4622 | assert.fail("Expected a ProjectWriteFileError"); |
| 4623 | } | ||
| 4624 | const writeError = result.failure; | ||
| 4577 | assert.equal( | 4625 | assert.equal( |
| 4578 | result.failure.message, | 4626 | writeError.message, |
| 4579 | "Workspace file path must stay within the project root.", | 4627 | `Failed to write workspace file '../escape.txt' in '${workspaceDir}'.`, |
| 4580 | ); | 4628 | ); |
| 4629 | assert.equal(writeError.cwd, workspaceDir); | ||
| 4630 | assert.equal(writeError.relativePath, "../escape.txt"); | ||
| 4631 | assert.equal(writeError.failure, "workspace_path_outside_root"); | ||
| 4632 | assert.isDefined(writeError.cause); | ||
| 4633 | assert.notProperty(writeError, "contents"); | ||
| 4581 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 4634 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 4582 | ); | 4635 | ); |
| 4583 | 4636 |
apps/server/src/workspace/WorkspaceFileSystem.test.ts+81-6| ... | @@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i | ... | @@ -104,14 +104,89 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i |
| 104 | const error = yield* workspaceFileSystem | 104 | const error = yield* workspaceFileSystem |
| 105 | .readFile({ cwd, relativePath: "linked-secret.txt" }) | 105 | .readFile({ cwd, relativePath: "linked-secret.txt" }) |
| 106 | .pipe(Effect.flip); | 106 | .pipe(Effect.flip); |
| 107 | const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); | ||
| 108 | const resolvedPath = yield* fileSystem.realPath(path.join(outsideDir, "secret.txt")); | ||
| 107 | 109 | ||
| 108 | expect(error.message).toBe( | 110 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); |
| 109 | `Workspace file operation 'workspaceFileSystem.readFile' failed for 'linked-secret.txt' in '${cwd}'.`, | 111 | expect(error).toMatchObject({ |
| 110 | ); | 112 | workspaceRoot: cwd, |
| 113 | relativePath: "linked-secret.txt", | ||
| 114 | resolvedWorkspaceRoot, | ||
| 115 | resolvedPath, | ||
| 116 | }); | ||
| 117 | expect("cause" in error).toBe(false); | ||
| 118 | }), | ||
| 119 | ); | ||
| 120 | |||
| 121 | it.effect("rejects directories without manufacturing an I/O cause", () => | ||
| 122 | Effect.gen(function* () { | ||
| 123 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 124 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 125 | const path = yield* Path.Path; | ||
| 126 | const cwd = yield* makeTempDir; | ||
| 127 | yield* fileSystem.makeDirectory(path.join(cwd, "src")); | ||
| 128 | |||
| 129 | const error = yield* workspaceFileSystem | ||
| 130 | .readFile({ cwd, relativePath: "src" }) | ||
| 131 | .pipe(Effect.flip); | ||
| 132 | const resolvedPath = yield* fileSystem.realPath(path.join(cwd, "src")); | ||
| 133 | |||
| 134 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspacePathNotFileError); | ||
| 135 | expect(error).toMatchObject({ | ||
| 136 | workspaceRoot: cwd, | ||
| 137 | relativePath: "src", | ||
| 138 | resolvedPath, | ||
| 139 | }); | ||
| 140 | expect("cause" in error).toBe(false); | ||
| 141 | }), | ||
| 142 | ); | ||
| 143 | |||
| 144 | it.effect("rejects binary files without leaking their contents into the error", () => | ||
| 145 | Effect.gen(function* () { | ||
| 146 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 147 | const fileSystem = yield* FileSystem.FileSystem; | ||
| 148 | const path = yield* Path.Path; | ||
| 149 | const cwd = yield* makeTempDir; | ||
| 150 | const absolutePath = path.join(cwd, "asset.bin"); | ||
| 151 | yield* fileSystem.writeFile(absolutePath, Uint8Array.from([0x61, 0, 0x62])); | ||
| 152 | |||
| 153 | const error = yield* workspaceFileSystem | ||
| 154 | .readFile({ cwd, relativePath: "asset.bin" }) | ||
| 155 | .pipe(Effect.flip); | ||
| 156 | const resolvedPath = yield* fileSystem.realPath(absolutePath); | ||
| 157 | |||
| 158 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceBinaryFileError); | ||
| 159 | expect(error).toMatchObject({ | ||
| 160 | workspaceRoot: cwd, | ||
| 161 | relativePath: "asset.bin", | ||
| 162 | resolvedPath, | ||
| 163 | }); | ||
| 164 | expect("cause" in error).toBe(false); | ||
| 165 | expect("contents" in error).toBe(false); | ||
| 166 | }), | ||
| 167 | ); | ||
| 168 | |||
| 169 | it.effect("preserves the real cause and path for I/O failures", () => | ||
| 170 | Effect.gen(function* () { | ||
| 171 | const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; | ||
| 172 | const path = yield* Path.Path; | ||
| 173 | const cwd = yield* makeTempDir; | ||
| 174 | const resolvedPath = path.join(cwd, "missing.txt"); | ||
| 175 | |||
| 176 | const error = yield* workspaceFileSystem | ||
| 177 | .readFile({ cwd, relativePath: "missing.txt" }) | ||
| 178 | .pipe(Effect.flip); | ||
| 179 | |||
| 180 | expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); | ||
| 181 | expect(error).toMatchObject({ | ||
| 182 | workspaceRoot: cwd, | ||
| 183 | relativePath: "missing.txt", | ||
| 184 | resolvedPath, | ||
| 185 | operationPath: resolvedPath, | ||
| 186 | operation: "realpath-target", | ||
| 187 | }); | ||
| 111 | expect(error.cause).toBeInstanceOf(Error); | 188 | expect(error.cause).toBeInstanceOf(Error); |
| 112 | expect((error.cause as Error).message).toBe( | 189 | expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); |
| 113 | "Workspace file path resolves outside the project root.", | ||
| 114 | ); | ||
| 115 | }), | 190 | }), |
| 116 | ); | 191 | ); |
| 117 | }); | 192 | }); |
apps/server/src/workspace/WorkspaceFileSystem.ts+177-49| ... | @@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; | ... | @@ -27,25 +27,79 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; |
| 27 | 27 | ||
| 28 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; | 28 | const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; |
| 29 | 29 | ||
| 30 | export class WorkspaceFileSystemError extends Schema.TaggedErrorClass<WorkspaceFileSystemError>()( | 30 | export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass<WorkspaceFileSystemOperationError>()( |
| 31 | "WorkspaceFileSystemError", | 31 | "WorkspaceFileSystemOperationError", |
| 32 | { | 32 | { |
| 33 | cwd: Schema.String, | 33 | workspaceRoot: Schema.String, |
| 34 | relativePath: Schema.optional(Schema.String), | 34 | relativePath: Schema.String, |
| 35 | resolvedPath: Schema.String, | ||
| 36 | operationPath: Schema.String, | ||
| 35 | operation: Schema.Literals([ | 37 | operation: Schema.Literals([ |
| 36 | "workspaceFileSystem.readFile", | 38 | "realpath-workspace-root", |
| 37 | "workspaceFileSystem.makeDirectory", | 39 | "realpath-target", |
| 38 | "workspaceFileSystem.writeFile", | 40 | "open", |
| 41 | "stat", | ||
| 42 | "read", | ||
| 43 | "close", | ||
| 44 | "make-directory", | ||
| 45 | "write-file", | ||
| 39 | ]), | 46 | ]), |
| 40 | cause: Schema.Defect(), | 47 | cause: Schema.Defect(), |
| 41 | }, | 48 | }, |
| 42 | ) { | 49 | ) { |
| 43 | override get message(): string { | 50 | override get message(): string { |
| 44 | const target = this.relativePath ? `'${this.relativePath}' in '${this.cwd}'` : `'${this.cwd}'`; | 51 | return `Workspace file operation '${this.operation}' failed at '${this.operationPath}' for resolved path '${this.resolvedPath}' (requested as '${this.relativePath}' in '${this.workspaceRoot}').`; |
| 45 | return `Workspace file operation '${this.operation}' failed for ${target}.`; | ||
| 46 | } | 52 | } |
| 47 | } | 53 | } |
| 48 | 54 | ||
| 55 | export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass<WorkspaceFilePathEscapeError>()( | ||
| 56 | "WorkspaceFilePathEscapeError", | ||
| 57 | { | ||
| 58 | workspaceRoot: Schema.String, | ||
| 59 | relativePath: Schema.String, | ||
| 60 | resolvedWorkspaceRoot: Schema.String, | ||
| 61 | resolvedPath: Schema.String, | ||
| 62 | }, | ||
| 63 | ) { | ||
| 64 | override get message(): string { | ||
| 65 | return `Workspace file '${this.relativePath}' resolves outside workspace root '${this.workspaceRoot}': ${this.resolvedPath}`; | ||
| 66 | } | ||
| 67 | } | ||
| 68 | |||
| 69 | export class WorkspacePathNotFileError extends Schema.TaggedErrorClass<WorkspacePathNotFileError>()( | ||
| 70 | "WorkspacePathNotFileError", | ||
| 71 | { | ||
| 72 | workspaceRoot: Schema.String, | ||
| 73 | relativePath: Schema.String, | ||
| 74 | resolvedPath: Schema.String, | ||
| 75 | }, | ||
| 76 | ) { | ||
| 77 | override get message(): string { | ||
| 78 | return `Workspace path '${this.relativePath}' in '${this.workspaceRoot}' is not a file: ${this.resolvedPath}`; | ||
| 79 | } | ||
| 80 | } | ||
| 81 | |||
| 82 | export class WorkspaceBinaryFileError extends Schema.TaggedErrorClass<WorkspaceBinaryFileError>()( | ||
| 83 | "WorkspaceBinaryFileError", | ||
| 84 | { | ||
| 85 | workspaceRoot: Schema.String, | ||
| 86 | relativePath: Schema.String, | ||
| 87 | resolvedPath: Schema.String, | ||
| 88 | }, | ||
| 89 | ) { | ||
| 90 | override get message(): string { | ||
| 91 | return `Workspace file '${this.relativePath}' in '${this.workspaceRoot}' is binary and cannot be previewed as text.`; | ||
| 92 | } | ||
| 93 | } | ||
| 94 | |||
| 95 | export const WorkspaceFileSystemError = Schema.Union([ | ||
| 96 | WorkspaceFileSystemOperationError, | ||
| 97 | WorkspaceFilePathEscapeError, | ||
| 98 | WorkspacePathNotFileError, | ||
| 99 | WorkspaceBinaryFileError, | ||
| 100 | ]); | ||
| 101 | export type WorkspaceFileSystemError = typeof WorkspaceFileSystemError.Type; | ||
| 102 | |||
| 49 | /** Service tag for workspace file operations. */ | 103 | /** Service tag for workspace file operations. */ |
| 50 | export class WorkspaceFileSystem extends Context.Service< | 104 | export class WorkspaceFileSystem extends Context.Service< |
| 51 | WorkspaceFileSystem, | 105 | WorkspaceFileSystem, |
| ... | @@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { | ... | @@ -86,53 +140,123 @@ export const make = Effect.gen(function* () { |
| 86 | relativePath: input.relativePath, | 140 | relativePath: input.relativePath, |
| 87 | }); | 141 | }); |
| 88 | 142 | ||
| 89 | return yield* Effect.tryPromise({ | 143 | const realWorkspaceRoot = yield* Effect.tryPromise({ |
| 90 | try: async () => { | 144 | try: () => NodeFSP.realpath(input.cwd), |
| 91 | const [realWorkspaceRoot, realTargetPath] = await Promise.all([ | 145 | catch: (cause) => |
| 92 | NodeFSP.realpath(input.cwd), | 146 | new WorkspaceFileSystemOperationError({ |
| 93 | NodeFSP.realpath(target.absolutePath), | 147 | workspaceRoot: input.cwd, |
| 94 | ]); | 148 | relativePath: input.relativePath, |
| 95 | const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | 149 | resolvedPath: target.absolutePath, |
| 96 | if ( | 150 | operationPath: input.cwd, |
| 97 | relativeRealPath.startsWith(`..${path.sep}`) || | 151 | operation: "realpath-workspace-root", |
| 98 | relativeRealPath === ".." || | 152 | cause, |
| 99 | path.isAbsolute(relativeRealPath) | 153 | }), |
| 100 | ) { | 154 | }); |
| 101 | throw new Error("Workspace file path resolves outside the project root."); | 155 | const realTargetPath = yield* Effect.tryPromise({ |
| 102 | } | 156 | try: () => NodeFSP.realpath(target.absolutePath), |
| 103 | 157 | catch: (cause) => | |
| 104 | const handle = await NodeFSP.open(realTargetPath, "r"); | 158 | new WorkspaceFileSystemOperationError({ |
| 105 | try { | 159 | workspaceRoot: input.cwd, |
| 106 | const stat = await handle.stat(); | 160 | relativePath: input.relativePath, |
| 161 | resolvedPath: target.absolutePath, | ||
| 162 | operationPath: target.absolutePath, | ||
| 163 | operation: "realpath-target", | ||
| 164 | cause, | ||
| 165 | }), | ||
| 166 | }); | ||
| 167 | const relativeRealPath = path.relative(realWorkspaceRoot, realTargetPath); | ||
| 168 | if ( | ||
| 169 | relativeRealPath.startsWith(`..${path.sep}`) || | ||
| 170 | relativeRealPath === ".." || | ||
| 171 | path.isAbsolute(relativeRealPath) | ||
| 172 | ) { | ||
| 173 | return yield* new WorkspaceFilePathEscapeError({ | ||
| 174 | workspaceRoot: input.cwd, | ||
| 175 | relativePath: input.relativePath, | ||
| 176 | resolvedWorkspaceRoot: realWorkspaceRoot, | ||
| 177 | resolvedPath: realTargetPath, | ||
| 178 | }); | ||
| 179 | } | ||
| 180 | |||
| 181 | return yield* Effect.acquireUseRelease( | ||
| 182 | Effect.tryPromise({ | ||
| 183 | try: () => NodeFSP.open(realTargetPath, "r"), | ||
| 184 | catch: (cause) => | ||
| 185 | new WorkspaceFileSystemOperationError({ | ||
| 186 | workspaceRoot: input.cwd, | ||
| 187 | relativePath: input.relativePath, | ||
| 188 | resolvedPath: realTargetPath, | ||
| 189 | operationPath: realTargetPath, | ||
| 190 | operation: "open", | ||
| 191 | cause, | ||
| 192 | }), | ||
| 193 | }), | ||
| 194 | (handle) => | ||
| 195 | Effect.gen(function* () { | ||
| 196 | const stat = yield* Effect.tryPromise({ | ||
| 197 | try: () => handle.stat(), | ||
| 198 | catch: (cause) => | ||
| 199 | new WorkspaceFileSystemOperationError({ | ||
| 200 | workspaceRoot: input.cwd, | ||
| 201 | relativePath: input.relativePath, | ||
| 202 | resolvedPath: realTargetPath, | ||
| 203 | operationPath: realTargetPath, | ||
| 204 | operation: "stat", | ||
| 205 | cause, | ||
| 206 | }), | ||
| 207 | }); | ||
| 107 | if (!stat.isFile()) { | 208 | if (!stat.isFile()) { |
| 108 | throw new Error("Workspace path is not a file."); | 209 | return yield* new WorkspacePathNotFileError({ |
| 210 | workspaceRoot: input.cwd, | ||
| 211 | relativePath: input.relativePath, | ||
| 212 | resolvedPath: realTargetPath, | ||
| 213 | }); | ||
| 109 | } | 214 | } |
| 215 | |||
| 110 | const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); | 216 | const bytesToRead = Math.min(stat.size, PROJECT_READ_FILE_MAX_BYTES); |
| 111 | const buffer = Buffer.alloc(bytesToRead); | 217 | const buffer = Buffer.alloc(bytesToRead); |
| 112 | const { bytesRead } = await handle.read(buffer, 0, bytesToRead, 0); | 218 | const { bytesRead } = yield* Effect.tryPromise({ |
| 219 | try: () => handle.read(buffer, 0, bytesToRead, 0), | ||
| 220 | catch: (cause) => | ||
| 221 | new WorkspaceFileSystemOperationError({ | ||
| 222 | workspaceRoot: input.cwd, | ||
| 223 | relativePath: input.relativePath, | ||
| 224 | resolvedPath: realTargetPath, | ||
| 225 | operationPath: realTargetPath, | ||
| 226 | operation: "read", | ||
| 227 | cause, | ||
| 228 | }), | ||
| 229 | }); | ||
| 113 | const fileBytes = buffer.subarray(0, bytesRead); | 230 | const fileBytes = buffer.subarray(0, bytesRead); |
| 114 | if (fileBytes.includes(0)) { | 231 | if (fileBytes.includes(0)) { |
| 115 | throw new Error("Binary files cannot be previewed as text."); | 232 | return yield* new WorkspaceBinaryFileError({ |
| 233 | workspaceRoot: input.cwd, | ||
| 234 | relativePath: input.relativePath, | ||
| 235 | resolvedPath: realTargetPath, | ||
| 236 | }); | ||
| 116 | } | 237 | } |
| 117 | const contents = new TextDecoder("utf-8").decode(fileBytes); | 238 | |
| 118 | return { | 239 | return { |
| 119 | relativePath: target.relativePath, | 240 | relativePath: target.relativePath, |
| 120 | contents, | 241 | contents: new TextDecoder("utf-8").decode(fileBytes), |
| 121 | byteLength: stat.size, | 242 | byteLength: stat.size, |
| 122 | truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, | 243 | truncated: stat.size > PROJECT_READ_FILE_MAX_BYTES, |
| 123 | }; | 244 | }; |
| 124 | } finally { | ||
| 125 | await handle.close(); | ||
| 126 | } | ||
| 127 | }, | ||
| 128 | catch: (cause) => | ||
| 129 | new WorkspaceFileSystemError({ | ||
| 130 | cwd: input.cwd, | ||
| 131 | relativePath: input.relativePath, | ||
| 132 | operation: "workspaceFileSystem.readFile", | ||
| 133 | cause, | ||
| 134 | }), | 245 | }), |
| 135 | }); | 246 | (handle) => |
| 247 | Effect.tryPromise({ | ||
| 248 | try: () => handle.close(), | ||
| 249 | catch: (cause) => | ||
| 250 | new WorkspaceFileSystemOperationError({ | ||
| 251 | workspaceRoot: input.cwd, | ||
| 252 | relativePath: input.relativePath, | ||
| 253 | resolvedPath: realTargetPath, | ||
| 254 | operationPath: realTargetPath, | ||
| 255 | operation: "close", | ||
| 256 | cause, | ||
| 257 | }), | ||
| 258 | }), | ||
| 259 | ); | ||
| 136 | }); | 260 | }); |
| 137 | 261 | ||
| 138 | const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( | 262 | const writeFile: WorkspaceFileSystem["Service"]["writeFile"] = Effect.fn( |
| ... | @@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { | ... | @@ -146,10 +270,12 @@ export const make = Effect.gen(function* () { |
| 146 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( | 270 | yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( |
| 147 | Effect.mapError( | 271 | Effect.mapError( |
| 148 | (cause) => | 272 | (cause) => |
| 149 | new WorkspaceFileSystemError({ | 273 | new WorkspaceFileSystemOperationError({ |
| 150 | cwd: input.cwd, | 274 | workspaceRoot: input.cwd, |
| 151 | relativePath: input.relativePath, | 275 | relativePath: input.relativePath, |
| 152 | operation: "workspaceFileSystem.makeDirectory", | 276 | resolvedPath: target.absolutePath, |
| 277 | operationPath: path.dirname(target.absolutePath), | ||
| 278 | operation: "make-directory", | ||
| 153 | cause, | 279 | cause, |
| 154 | }), | 280 | }), |
| 155 | ), | 281 | ), |
| ... | @@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { | ... | @@ -157,10 +283,12 @@ export const make = Effect.gen(function* () { |
| 157 | yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( | 283 | yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( |
| 158 | Effect.mapError( | 284 | Effect.mapError( |
| 159 | (cause) => | 285 | (cause) => |
| 160 | new WorkspaceFileSystemError({ | 286 | new WorkspaceFileSystemOperationError({ |
| 161 | cwd: input.cwd, | 287 | workspaceRoot: input.cwd, |
| 162 | relativePath: input.relativePath, | 288 | relativePath: input.relativePath, |
| 163 | operation: "workspaceFileSystem.writeFile", | 289 | resolvedPath: target.absolutePath, |
| 290 | operationPath: target.absolutePath, | ||
| 291 | operation: "write-file", | ||
| 164 | cause, | 292 | cause, |
| 165 | }), | 293 | }), |
| 166 | ), | 294 | ), |
apps/server/src/ws.ts+106-39| ... | @@ -34,6 +34,9 @@ import { | ... | @@ -34,6 +34,9 @@ import { |
| 34 | OrchestrationGetSnapshotError, | 34 | OrchestrationGetSnapshotError, |
| 35 | OrchestrationGetTurnDiffError, | 35 | OrchestrationGetTurnDiffError, |
| 36 | ORCHESTRATION_WS_METHODS, | 36 | ORCHESTRATION_WS_METHODS, |
| 37 | type ProjectEntriesFailure, | ||
| 38 | type ProjectFileFailure, | ||
| 39 | type ProjectFileOperation, | ||
| 37 | ProjectListEntriesError, | 40 | ProjectListEntriesError, |
| 38 | ProjectReadFileError, | 41 | ProjectReadFileError, |
| 39 | ProjectSearchEntriesError, | 42 | ProjectSearchEntriesError, |
| ... | @@ -41,6 +44,7 @@ import { | ... | @@ -41,6 +44,7 @@ import { |
| 41 | RelayClientInstallFailedError, | 44 | RelayClientInstallFailedError, |
| 42 | type RelayClientInstallProgressEvent, | 45 | type RelayClientInstallProgressEvent, |
| 43 | OrchestrationReplayEventsError, | 46 | OrchestrationReplayEventsError, |
| 47 | type FilesystemBrowseFailure, | ||
| 44 | FilesystemBrowseError, | 48 | FilesystemBrowseError, |
| 45 | AssetAccessError, | 49 | AssetAccessError, |
| 46 | EnvironmentAuthorizationError, | 50 | EnvironmentAuthorizationError, |
| ... | @@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts"; | ... | @@ -108,7 +112,6 @@ import * as SessionStore from "./auth/SessionStore.ts"; |
| 108 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; | 112 | import { failEnvironmentAuthInvalid, failEnvironmentInternal } from "./auth/http.ts"; |
| 109 | import * as RelayClient from "@t3tools/shared/relayClient"; | 113 | import * as RelayClient from "@t3tools/shared/relayClient"; |
| 110 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); | 114 | const isOrchestrationDispatchCommandError = Schema.is(OrchestrationDispatchCommandError); |
| 111 | const isWorkspacePathOutsideRootError = Schema.is(WorkspacePaths.WorkspacePathOutsideRootError); | ||
| 112 | 115 | ||
| 113 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 116 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 114 | 117 | ||
| ... | @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { | ... | @@ -116,11 +119,6 @@ function unexpectedCompatibilityError(error: never): never { |
| 116 | throw new Error(`Unhandled compatibility error: ${String(error)}`); | 119 | throw new Error(`Unhandled compatibility error: ${String(error)}`); |
| 117 | } | 120 | } |
| 118 | 121 | ||
| 119 | /** Preserve pre-structured-error display behavior at the RPC boundary. */ | ||
| 120 | function legacyPlatformFailureDescription(cause: unknown): string { | ||
| 121 | return cause instanceof Error ? cause.message : String(cause); | ||
| 122 | } | ||
| 123 | |||
| 124 | /** Preserve the setup runner's broader pre-refactor message normalization. */ | 122 | /** Preserve the setup runner's broader pre-refactor message normalization. */ |
| 125 | function legacySetupFailureDescription(cause: unknown): string { | 123 | function legacySetupFailureDescription(cause: unknown): string { |
| 126 | if ( | 124 | if ( |
| ... | @@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { | ... | @@ -134,37 +132,99 @@ function legacySetupFailureDescription(cause: unknown): string { |
| 134 | return String(cause); | 132 | return String(cause); |
| 135 | } | 133 | } |
| 136 | 134 | ||
| 137 | function workspaceEntriesCompatibilityDetail( | 135 | function projectEntriesFailureContext(error: WorkspaceEntries.WorkspaceEntriesError): { |
| 138 | error: WorkspaceEntries.WorkspaceEntriesError, | 136 | readonly failure: ProjectEntriesFailure; |
| 139 | ): string { | 137 | readonly normalizedCwd?: string; |
| 138 | readonly timeout?: string; | ||
| 139 | readonly detail?: string; | ||
| 140 | } { | ||
| 140 | switch (error._tag) { | 141 | switch (error._tag) { |
| 141 | case "WorkspaceRootNotExistsError": | 142 | case "WorkspaceRootNotExistsError": |
| 142 | return `Workspace root does not exist: ${error.normalizedWorkspaceRoot}`; | 143 | return { |
| 144 | failure: "workspace_root_not_found", | ||
| 145 | normalizedCwd: error.normalizedWorkspaceRoot, | ||
| 146 | }; | ||
| 143 | case "WorkspaceRootCreateFailedError": | 147 | case "WorkspaceRootCreateFailedError": |
| 144 | return `Failed to create workspace root: ${error.normalizedWorkspaceRoot}`; | 148 | return { |
| 149 | failure: "workspace_root_create_failed", | ||
| 150 | normalizedCwd: error.normalizedWorkspaceRoot, | ||
| 151 | }; | ||
| 145 | case "WorkspaceRootNotDirectoryError": | 152 | case "WorkspaceRootNotDirectoryError": |
| 146 | return `Workspace root is not a directory: ${error.normalizedWorkspaceRoot}`; | 153 | return { |
| 154 | failure: "workspace_root_not_directory", | ||
| 155 | normalizedCwd: error.normalizedWorkspaceRoot, | ||
| 156 | }; | ||
| 147 | case "WorkspaceSearchIndexCreateFailed": | 157 | case "WorkspaceSearchIndexCreateFailed": |
| 148 | return `Failed to create the workspace search index for '${error.cwd}': ${error.reason}`; | 158 | return { |
| 159 | failure: "search_index_create_failed", | ||
| 160 | normalizedCwd: error.cwd, | ||
| 161 | detail: error.reason, | ||
| 162 | }; | ||
| 149 | case "WorkspaceSearchIndexScanTimedOut": | 163 | case "WorkspaceSearchIndexScanTimedOut": |
| 150 | return `Workspace search index for '${error.cwd}' did not finish scanning within ${error.timeout}`; | 164 | return { |
| 165 | failure: "search_index_scan_timed_out", | ||
| 166 | normalizedCwd: error.cwd, | ||
| 167 | timeout: error.timeout, | ||
| 168 | }; | ||
| 151 | case "WorkspaceSearchIndexSearchFailed": | 169 | case "WorkspaceSearchIndexSearchFailed": |
| 152 | return `Workspace search failed for '${error.cwd}': ${error.reason}`; | 170 | return { |
| 171 | failure: "search_index_search_failed", | ||
| 172 | normalizedCwd: error.cwd, | ||
| 173 | detail: error.reason, | ||
| 174 | }; | ||
| 153 | default: | 175 | default: |
| 154 | return unexpectedCompatibilityError(error); | 176 | return unexpectedCompatibilityError(error); |
| 155 | } | 177 | } |
| 156 | } | 178 | } |
| 157 | 179 | ||
| 158 | function workspaceBrowseCompatibilityDetail( | 180 | function filesystemBrowseFailureContext(error: WorkspaceEntries.WorkspaceEntriesBrowseError): { |
| 159 | error: WorkspaceEntries.WorkspaceEntriesBrowseError, | 181 | readonly failure: FilesystemBrowseFailure; |
| 160 | ): string { | 182 | readonly parentPath?: string; |
| 183 | readonly platform?: string; | ||
| 184 | } { | ||
| 161 | switch (error._tag) { | 185 | switch (error._tag) { |
| 162 | case "WorkspaceEntriesWindowsPathUnsupportedError": | 186 | case "WorkspaceEntriesWindowsPathUnsupportedError": |
| 163 | return "Windows-style paths are only supported on Windows."; | 187 | return { failure: "windows_path_unsupported", platform: error.platform }; |
| 164 | case "WorkspaceEntriesCurrentProjectRequiredError": | 188 | case "WorkspaceEntriesCurrentProjectRequiredError": |
| 165 | return "Relative filesystem browse paths require a current project."; | 189 | return { failure: "current_project_required" }; |
| 166 | case "WorkspaceEntriesReadDirectoryError": | 190 | case "WorkspaceEntriesReadDirectoryError": |
| 167 | return `Unable to browse '${error.parentPath}': ${legacyPlatformFailureDescription(error.cause)}`; | 191 | return { failure: "read_directory_failed", parentPath: error.parentPath }; |
| 192 | default: | ||
| 193 | return unexpectedCompatibilityError(error); | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | function projectFileFailureContext( | ||
| 198 | error: | ||
| 199 | | WorkspaceFileSystem.WorkspaceFileSystemError | ||
| 200 | | WorkspacePaths.WorkspacePathOutsideRootError, | ||
| 201 | ): { | ||
| 202 | readonly failure: ProjectFileFailure; | ||
| 203 | readonly resolvedPath?: string; | ||
| 204 | readonly resolvedWorkspaceRoot?: string; | ||
| 205 | readonly operation?: ProjectFileOperation; | ||
| 206 | readonly operationPath?: string; | ||
| 207 | } { | ||
| 208 | switch (error._tag) { | ||
| 209 | case "WorkspacePathOutsideRootError": | ||
| 210 | return { failure: "workspace_path_outside_root" }; | ||
| 211 | case "WorkspaceFileSystemOperationError": | ||
| 212 | return { | ||
| 213 | failure: "operation_failed", | ||
| 214 | resolvedPath: error.resolvedPath, | ||
| 215 | operation: error.operation, | ||
| 216 | operationPath: error.operationPath, | ||
| 217 | }; | ||
| 218 | case "WorkspaceFilePathEscapeError": | ||
| 219 | return { | ||
| 220 | failure: "resolved_path_outside_root", | ||
| 221 | resolvedPath: error.resolvedPath, | ||
| 222 | resolvedWorkspaceRoot: error.resolvedWorkspaceRoot, | ||
| 223 | }; | ||
| 224 | case "WorkspacePathNotFileError": | ||
| 225 | return { failure: "path_not_file", resolvedPath: error.resolvedPath }; | ||
| 226 | case "WorkspaceBinaryFileError": | ||
| 227 | return { failure: "binary_file", resolvedPath: error.resolvedPath }; | ||
| 168 | default: | 228 | default: |
| 169 | return unexpectedCompatibilityError(error); | 229 | return unexpectedCompatibilityError(error); |
| 170 | } | 230 | } |
| ... | @@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1260,7 +1320,10 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1260 | Effect.mapError( | 1320 | Effect.mapError( |
| 1261 | (cause) => | 1321 | (cause) => |
| 1262 | new ProjectSearchEntriesError({ | 1322 | new ProjectSearchEntriesError({ |
| 1263 | message: `Failed to search workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, | 1323 | cwd: input.cwd, |
| 1324 | queryLength: input.query.length, | ||
| 1325 | limit: input.limit, | ||
| 1326 | ...projectEntriesFailureContext(cause), | ||
| 1264 | cause, | 1327 | cause, |
| 1265 | }), | 1328 | }), |
| 1266 | ), | 1329 | ), |
| ... | @@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1274,7 +1337,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1274 | Effect.mapError( | 1337 | Effect.mapError( |
| 1275 | (cause) => | 1338 | (cause) => |
| 1276 | new ProjectListEntriesError({ | 1339 | new ProjectListEntriesError({ |
| 1277 | message: `Failed to list workspace entries: ${workspaceEntriesCompatibilityDetail(cause)}`, | 1340 | ...input, |
| 1341 | ...projectEntriesFailureContext(cause), | ||
| 1278 | cause, | 1342 | cause, |
| 1279 | }), | 1343 | }), |
| 1280 | ), | 1344 | ), |
| ... | @@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1285,12 +1349,14 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1285 | observeRpcEffect( | 1349 | observeRpcEffect( |
| 1286 | WS_METHODS.projectsReadFile, | 1350 | WS_METHODS.projectsReadFile, |
| 1287 | workspaceFileSystem.readFile(input).pipe( | 1351 | workspaceFileSystem.readFile(input).pipe( |
| 1288 | Effect.mapError((cause) => { | 1352 | Effect.mapError( |
| 1289 | const message = isWorkspacePathOutsideRootError(cause) | 1353 | (cause) => |
| 1290 | ? "Workspace file path must stay within the project root." | 1354 | new ProjectReadFileError({ |
| 1291 | : `Failed to read workspace file: ${legacyPlatformFailureDescription(cause.cause)}`; | 1355 | ...input, |
| 1292 | return new ProjectReadFileError({ message, cause }); | 1356 | ...projectFileFailureContext(cause), |
| 1293 | }), | 1357 | cause, |
| 1358 | }), | ||
| 1359 | ), | ||
| 1294 | ), | 1360 | ), |
| 1295 | { "rpc.aggregate": "workspace" }, | 1361 | { "rpc.aggregate": "workspace" }, |
| 1296 | ), | 1362 | ), |
| ... | @@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1298,15 +1364,15 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1298 | observeRpcEffect( | 1364 | observeRpcEffect( |
| 1299 | WS_METHODS.projectsWriteFile, | 1365 | WS_METHODS.projectsWriteFile, |
| 1300 | workspaceFileSystem.writeFile(input).pipe( | 1366 | workspaceFileSystem.writeFile(input).pipe( |
| 1301 | Effect.mapError((cause) => { | 1367 | Effect.mapError( |
| 1302 | const message = isWorkspacePathOutsideRootError(cause) | 1368 | (cause) => |
| 1303 | ? "Workspace file path must stay within the project root." | 1369 | new ProjectWriteFileError({ |
| 1304 | : "Failed to write workspace file"; | 1370 | cwd: input.cwd, |
| 1305 | return new ProjectWriteFileError({ | 1371 | relativePath: input.relativePath, |
| 1306 | message, | 1372 | ...projectFileFailureContext(cause), |
| 1307 | cause, | 1373 | cause, |
| 1308 | }); | 1374 | }), |
| 1309 | }), | 1375 | ), |
| 1310 | ), | 1376 | ), |
| 1311 | { "rpc.aggregate": "workspace" }, | 1377 | { "rpc.aggregate": "workspace" }, |
| 1312 | ), | 1378 | ), |
| ... | @@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => | ... | @@ -1321,7 +1387,8 @@ const makeWsRpcLayer = (currentSession: EnvironmentAuth.AuthenticatedSession) => |
| 1321 | Effect.mapError( | 1387 | Effect.mapError( |
| 1322 | (cause) => | 1388 | (cause) => |
| 1323 | new FilesystemBrowseError({ | 1389 | new FilesystemBrowseError({ |
| 1324 | message: workspaceBrowseCompatibilityDetail(cause), | 1390 | ...input, |
| 1391 | ...filesystemBrowseFailureContext(cause), | ||
| 1325 | cause, | 1392 | cause, |
| 1326 | }), | 1393 | }), |
| 1327 | ), | 1394 | ), |
packages/contracts/src/filesystem.test.ts created+33| ... | @@ -0,0 +1,33 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { FilesystemBrowseError } from "./filesystem.ts"; | ||
| 5 | |||
| 6 | describe("FilesystemBrowseError", () => { | ||
| 7 | it("derives a stable message from browse context while retaining the cause", () => { | ||
| 8 | const cause = new Error("sensitive filesystem detail"); | ||
| 9 | const error = new FilesystemBrowseError({ | ||
| 10 | cwd: "/workspace", | ||
| 11 | partialPath: "./src/mai", | ||
| 12 | failure: "read_directory_failed", | ||
| 13 | parentPath: "/workspace/src", | ||
| 14 | cause, | ||
| 15 | }); | ||
| 16 | |||
| 17 | expect(error.message).toBe("Failed to browse filesystem path './src/mai' from '/workspace'."); | ||
| 18 | expect(error.message).not.toContain(cause.message); | ||
| 19 | expect(error.cause).toBe(cause); | ||
| 20 | }); | ||
| 21 | |||
| 22 | it("decodes legacy message-only errors during rolling upgrades", () => { | ||
| 23 | const decodeError = Schema.decodeUnknownSync(FilesystemBrowseError); | ||
| 24 | const error = decodeError({ | ||
| 25 | _tag: "FilesystemBrowseError", | ||
| 26 | message: "Legacy filesystem browse failure.", | ||
| 27 | }); | ||
| 28 | |||
| 29 | expect(error.message).toBe("Legacy filesystem browse failure."); | ||
| 30 | expect(error.partialPath).toBeUndefined(); | ||
| 31 | expect(error.failure).toBeUndefined(); | ||
| 32 | }); | ||
| 33 | }); | ||
packages/contracts/src/filesystem.ts+38-1| ... | @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ | ... | @@ -21,10 +21,47 @@ export const FilesystemBrowseResult = Schema.Struct({ |
| 21 | }); | 21 | }); |
| 22 | export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; | 22 | export type FilesystemBrowseResult = typeof FilesystemBrowseResult.Type; |
| 23 | 23 | ||
| 24 | export const FilesystemBrowseFailure = Schema.Literals([ | ||
| 25 | "windows_path_unsupported", | ||
| 26 | "current_project_required", | ||
| 27 | "read_directory_failed", | ||
| 28 | ]); | ||
| 29 | export type FilesystemBrowseFailure = typeof FilesystemBrowseFailure.Type; | ||
| 30 | |||
| 31 | function decodedFilesystemBrowseErrorMessage(props: object): string | undefined { | ||
| 32 | if (!("message" in props)) return undefined; | ||
| 33 | return typeof props.message === "string" ? props.message : undefined; | ||
| 34 | } | ||
| 35 | |||
| 24 | export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()( | 36 | export class FilesystemBrowseError extends Schema.TaggedErrorClass<FilesystemBrowseError>()( |
| 25 | "FilesystemBrowseError", | 37 | "FilesystemBrowseError", |
| 26 | { | 38 | { |
| 39 | partialPath: Schema.optional(TrimmedNonEmptyString), | ||
| 40 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 41 | failure: Schema.optional(FilesystemBrowseFailure), | ||
| 42 | parentPath: Schema.optional(TrimmedNonEmptyString), | ||
| 43 | platform: Schema.optional(TrimmedNonEmptyString), | ||
| 27 | message: TrimmedNonEmptyString, | 44 | message: TrimmedNonEmptyString, |
| 28 | cause: Schema.optional(Schema.Defect()), | 45 | cause: Schema.optional(Schema.Defect()), |
| 29 | }, | 46 | }, |
| 30 | ) {} | 47 | ) { |
| 48 | // Structured diagnostics stay optional for rolling compatibility with legacy message-only | ||
| 49 | // payloads, while new call sites must provide the request context and failure classification. | ||
| 50 | // @effect-diagnostics-next-line overriddenSchemaConstructor:off | ||
| 51 | constructor(props: { | ||
| 52 | readonly partialPath: string; | ||
| 53 | readonly cwd?: string | undefined; | ||
| 54 | readonly failure: FilesystemBrowseFailure; | ||
| 55 | readonly parentPath?: string; | ||
| 56 | readonly platform?: string; | ||
| 57 | readonly cause?: unknown; | ||
| 58 | }) { | ||
| 59 | const cwd = props.cwd === undefined ? "" : ` from '${props.cwd}'`; | ||
| 60 | super({ | ||
| 61 | ...props, | ||
| 62 | message: | ||
| 63 | decodedFilesystemBrowseErrorMessage(props) ?? | ||
| 64 | `Failed to browse filesystem path '${props.partialPath}'${cwd}.`, | ||
| 65 | } as any); | ||
| 66 | } | ||
| 67 | } |
packages/contracts/src/project.test.ts created+67| ... | @@ -0,0 +1,67 @@ | ||
| 1 | import * as Schema from "effect/Schema"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { | ||
| 5 | ProjectReadFileError, | ||
| 6 | ProjectSearchEntriesError, | ||
| 7 | ProjectWriteFileError, | ||
| 8 | } from "./project.ts"; | ||
| 9 | |||
| 10 | describe("project RPC errors", () => { | ||
| 11 | it("derives stable messages from structured request context while retaining causes", () => { | ||
| 12 | const cause = new Error("sensitive platform detail"); | ||
| 13 | const searchError = new ProjectSearchEntriesError({ | ||
| 14 | cwd: "/workspace", | ||
| 15 | queryLength: "authorization: Bearer secret-token".length, | ||
| 16 | limit: 20, | ||
| 17 | failure: "search_index_search_failed", | ||
| 18 | normalizedCwd: "/workspace", | ||
| 19 | detail: "index unavailable", | ||
| 20 | cause, | ||
| 21 | }); | ||
| 22 | const readError = new ProjectReadFileError({ | ||
| 23 | cwd: "/workspace", | ||
| 24 | relativePath: "src/index.ts", | ||
| 25 | failure: "operation_failed", | ||
| 26 | operation: "read", | ||
| 27 | operationPath: "/workspace/src/index.ts", | ||
| 28 | resolvedPath: "/workspace/src/index.ts", | ||
| 29 | cause, | ||
| 30 | }); | ||
| 31 | |||
| 32 | expect(searchError.message).toBe("Failed to search workspace entries in '/workspace'."); | ||
| 33 | expect(searchError.message).not.toContain(cause.message); | ||
| 34 | expect(searchError.normalizedCwd).toBe("/workspace"); | ||
| 35 | expect(searchError.queryLength).toBe("authorization: Bearer secret-token".length); | ||
| 36 | expect(searchError).not.toHaveProperty("query"); | ||
| 37 | expect(searchError.message).not.toMatch(/Bearer|secret-token/); | ||
| 38 | expect(searchError.cause).toBe(cause); | ||
| 39 | expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); | ||
| 40 | expect(readError.message).not.toContain(cause.message); | ||
| 41 | expect(readError.cause).toBe(cause); | ||
| 42 | }); | ||
| 43 | |||
| 44 | it("decodes legacy message-only errors during rolling upgrades", () => { | ||
| 45 | const decodeSearchError = Schema.decodeUnknownSync(ProjectSearchEntriesError); | ||
| 46 | const decodeWriteError = Schema.decodeUnknownSync(ProjectWriteFileError); | ||
| 47 | |||
| 48 | const searchError = decodeSearchError({ | ||
| 49 | _tag: "ProjectSearchEntriesError", | ||
| 50 | message: "Legacy project search failure.", | ||
| 51 | query: "legacy sensitive query", | ||
| 52 | }); | ||
| 53 | const writeError = decodeWriteError({ | ||
| 54 | _tag: "ProjectWriteFileError", | ||
| 55 | message: "Legacy project write failure.", | ||
| 56 | }); | ||
| 57 | |||
| 58 | expect(searchError.message).toBe("Legacy project search failure."); | ||
| 59 | expect(searchError.cwd).toBeUndefined(); | ||
| 60 | expect(searchError.queryLength).toBeUndefined(); | ||
| 61 | expect(searchError).not.toHaveProperty("query"); | ||
| 62 | expect(searchError.failure).toBeUndefined(); | ||
| 63 | expect(writeError.message).toBe("Legacy project write failure."); | ||
| 64 | expect(writeError.relativePath).toBeUndefined(); | ||
| 65 | expect(writeError.failure).toBeUndefined(); | ||
| 66 | }); | ||
| 67 | }); | ||
packages/contracts/src/project.ts+132-4| ... | @@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({ | ... | @@ -37,21 +37,83 @@ export const ProjectListEntriesResult = Schema.Struct({ |
| 37 | }); | 37 | }); |
| 38 | export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; | 38 | export type ProjectListEntriesResult = typeof ProjectListEntriesResult.Type; |
| 39 | 39 | ||
| 40 | export const ProjectEntriesFailure = Schema.Literals([ | ||
| 41 | "workspace_root_not_found", | ||
| 42 | "workspace_root_create_failed", | ||
| 43 | "workspace_root_not_directory", | ||
| 44 | "search_index_create_failed", | ||
| 45 | "search_index_scan_timed_out", | ||
| 46 | "search_index_search_failed", | ||
| 47 | ]); | ||
| 48 | export type ProjectEntriesFailure = typeof ProjectEntriesFailure.Type; | ||
| 49 | |||
| 50 | type ProjectEntriesFailureContext = { | ||
| 51 | readonly failure: ProjectEntriesFailure; | ||
| 52 | readonly normalizedCwd?: string; | ||
| 53 | readonly timeout?: string; | ||
| 54 | readonly detail?: string; | ||
| 55 | readonly cause?: unknown; | ||
| 56 | }; | ||
| 57 | |||
| 58 | function decodedProjectErrorMessage(props: object): string | undefined { | ||
| 59 | if (!("message" in props)) return undefined; | ||
| 60 | return typeof props.message === "string" ? props.message : undefined; | ||
| 61 | } | ||
| 62 | |||
| 40 | export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()( | 63 | export class ProjectSearchEntriesError extends Schema.TaggedErrorClass<ProjectSearchEntriesError>()( |
| 41 | "ProjectSearchEntriesError", | 64 | "ProjectSearchEntriesError", |
| 42 | { | 65 | { |
| 66 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 67 | queryLength: Schema.optional(NonNegativeInt), | ||
| 68 | limit: Schema.optional(PositiveInt), | ||
| 69 | failure: Schema.optional(ProjectEntriesFailure), | ||
| 70 | normalizedCwd: Schema.optional(TrimmedNonEmptyString), | ||
| 71 | timeout: Schema.optional(TrimmedNonEmptyString), | ||
| 72 | detail: Schema.optional(TrimmedNonEmptyString), | ||
| 43 | message: TrimmedNonEmptyString, | 73 | message: TrimmedNonEmptyString, |
| 44 | cause: Schema.optional(Schema.Defect()), | 74 | cause: Schema.optional(Schema.Defect()), |
| 45 | }, | 75 | }, |
| 46 | ) {} | 76 | ) { |
| 77 | // The structured fields are optional on the wire so newer peers can decode legacy message-only | ||
| 78 | // failures. New application code must provide them through this constructor. | ||
| 79 | // @effect-diagnostics-next-line overriddenSchemaConstructor:off | ||
| 80 | constructor( | ||
| 81 | props: ProjectEntriesFailureContext & { | ||
| 82 | readonly cwd: string; | ||
| 83 | readonly queryLength: number; | ||
| 84 | readonly limit: number; | ||
| 85 | }, | ||
| 86 | ) { | ||
| 87 | super({ | ||
| 88 | ...props, | ||
| 89 | message: | ||
| 90 | decodedProjectErrorMessage(props) ?? | ||
| 91 | `Failed to search workspace entries in '${props.cwd}'.`, | ||
| 92 | } as any); | ||
| 93 | } | ||
| 94 | } | ||
| 47 | 95 | ||
| 48 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( | 96 | export class ProjectListEntriesError extends Schema.TaggedErrorClass<ProjectListEntriesError>()( |
| 49 | "ProjectListEntriesError", | 97 | "ProjectListEntriesError", |
| 50 | { | 98 | { |
| 99 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 100 | failure: Schema.optional(ProjectEntriesFailure), | ||
| 101 | normalizedCwd: Schema.optional(TrimmedNonEmptyString), | ||
| 102 | timeout: Schema.optional(TrimmedNonEmptyString), | ||
| 103 | detail: Schema.optional(TrimmedNonEmptyString), | ||
| 51 | message: TrimmedNonEmptyString, | 104 | message: TrimmedNonEmptyString, |
| 52 | cause: Schema.optional(Schema.Defect()), | 105 | cause: Schema.optional(Schema.Defect()), |
| 53 | }, | 106 | }, |
| 54 | ) {} | 107 | ) { |
| 108 | // @effect-diagnostics-next-line overriddenSchemaConstructor:off | ||
| 109 | constructor(props: ProjectEntriesFailureContext & { readonly cwd: string }) { | ||
| 110 | super({ | ||
| 111 | ...props, | ||
| 112 | message: | ||
| 113 | decodedProjectErrorMessage(props) ?? `Failed to list workspace entries in '${props.cwd}'.`, | ||
| 114 | } as any); | ||
| 115 | } | ||
| 116 | } | ||
| 55 | 117 | ||
| 56 | export const ProjectReadFileInput = Schema.Struct({ | 118 | export const ProjectReadFileInput = Schema.Struct({ |
| 57 | cwd: TrimmedNonEmptyString, | 119 | cwd: TrimmedNonEmptyString, |
| ... | @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ | ... | @@ -67,13 +129,62 @@ export const ProjectReadFileResult = Schema.Struct({ |
| 67 | }); | 129 | }); |
| 68 | export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; | 130 | export type ProjectReadFileResult = typeof ProjectReadFileResult.Type; |
| 69 | 131 | ||
| 132 | export const ProjectFileFailure = Schema.Literals([ | ||
| 133 | "workspace_path_outside_root", | ||
| 134 | "resolved_path_outside_root", | ||
| 135 | "path_not_file", | ||
| 136 | "binary_file", | ||
| 137 | "operation_failed", | ||
| 138 | ]); | ||
| 139 | export type ProjectFileFailure = typeof ProjectFileFailure.Type; | ||
| 140 | |||
| 141 | export const ProjectFileOperation = Schema.Literals([ | ||
| 142 | "realpath-workspace-root", | ||
| 143 | "realpath-target", | ||
| 144 | "open", | ||
| 145 | "stat", | ||
| 146 | "read", | ||
| 147 | "close", | ||
| 148 | "make-directory", | ||
| 149 | "write-file", | ||
| 150 | ]); | ||
| 151 | export type ProjectFileOperation = typeof ProjectFileOperation.Type; | ||
| 152 | |||
| 153 | type ProjectFileFailureContext = { | ||
| 154 | readonly cwd: string; | ||
| 155 | readonly relativePath: string; | ||
| 156 | readonly failure: ProjectFileFailure; | ||
| 157 | readonly resolvedPath?: string; | ||
| 158 | readonly resolvedWorkspaceRoot?: string; | ||
| 159 | readonly operation?: ProjectFileOperation; | ||
| 160 | readonly operationPath?: string; | ||
| 161 | readonly cause?: unknown; | ||
| 162 | }; | ||
| 163 | |||
| 70 | export class ProjectReadFileError extends Schema.TaggedErrorClass<ProjectReadFileError>()( | 164 | export class ProjectReadFileError extends Schema.TaggedErrorClass<ProjectReadFileError>()( |
| 71 | "ProjectReadFileError", | 165 | "ProjectReadFileError", |
| 72 | { | 166 | { |
| 167 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 168 | relativePath: Schema.optional(TrimmedNonEmptyString), | ||
| 169 | failure: Schema.optional(ProjectFileFailure), | ||
| 170 | resolvedPath: Schema.optional(TrimmedNonEmptyString), | ||
| 171 | resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), | ||
| 172 | operation: Schema.optional(ProjectFileOperation), | ||
| 173 | operationPath: Schema.optional(TrimmedNonEmptyString), | ||
| 73 | message: TrimmedNonEmptyString, | 174 | message: TrimmedNonEmptyString, |
| 74 | cause: Schema.optional(Schema.Defect()), | 175 | cause: Schema.optional(Schema.Defect()), |
| 75 | }, | 176 | }, |
| 76 | ) {} | 177 | ) { |
| 178 | // @effect-diagnostics-next-line overriddenSchemaConstructor:off | ||
| 179 | constructor(props: ProjectFileFailureContext) { | ||
| 180 | super({ | ||
| 181 | ...props, | ||
| 182 | message: | ||
| 183 | decodedProjectErrorMessage(props) ?? | ||
| 184 | `Failed to read workspace file '${props.relativePath}' in '${props.cwd}'.`, | ||
| 185 | } as any); | ||
| 186 | } | ||
| 187 | } | ||
| 77 | 188 | ||
| 78 | export const ProjectWriteFileInput = Schema.Struct({ | 189 | export const ProjectWriteFileInput = Schema.Struct({ |
| 79 | cwd: TrimmedNonEmptyString, | 190 | cwd: TrimmedNonEmptyString, |
| ... | @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; | ... | @@ -90,7 +201,24 @@ export type ProjectWriteFileResult = typeof ProjectWriteFileResult.Type; |
| 90 | export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()( | 201 | export class ProjectWriteFileError extends Schema.TaggedErrorClass<ProjectWriteFileError>()( |
| 91 | "ProjectWriteFileError", | 202 | "ProjectWriteFileError", |
| 92 | { | 203 | { |
| 204 | cwd: Schema.optional(TrimmedNonEmptyString), | ||
| 205 | relativePath: Schema.optional(TrimmedNonEmptyString), | ||
| 206 | failure: Schema.optional(ProjectFileFailure), | ||
| 207 | resolvedPath: Schema.optional(TrimmedNonEmptyString), | ||
| 208 | resolvedWorkspaceRoot: Schema.optional(TrimmedNonEmptyString), | ||
| 209 | operation: Schema.optional(ProjectFileOperation), | ||
| 210 | operationPath: Schema.optional(TrimmedNonEmptyString), | ||
| 93 | message: TrimmedNonEmptyString, | 211 | message: TrimmedNonEmptyString, |
| 94 | cause: Schema.optional(Schema.Defect()), | 212 | cause: Schema.optional(Schema.Defect()), |
| 95 | }, | 213 | }, |
| 96 | ) {} | 214 | ) { |
| 215 | // @effect-diagnostics-next-line overriddenSchemaConstructor:off | ||
| 216 | constructor(props: ProjectFileFailureContext) { | ||
| 217 | super({ | ||
| 218 | ...props, | ||
| 219 | message: | ||
| 220 | decodedProjectErrorMessage(props) ?? | ||
| 221 | `Failed to write workspace file '${props.relativePath}' in '${props.cwd}'.`, | ||
| 222 | } as any); | ||
| 223 | } | ||
| 224 | } |