From df766e1e2bc12ece25588fe1072daa5758f401b0 Mon Sep 17 00:00:00 2001 From: Wout Stiens Date: Tue, 21 Jul 2026 10:55:36 +0200 Subject: [PATCH 1/2] fix(server): contain workspace writes across symlinks --- .../src/workspace/WorkspaceFileSystem.test.ts | 254 ++++++++++++++- .../src/workspace/WorkspaceFileSystem.ts | 305 ++++++++++++++++-- 2 files changed, 532 insertions(+), 27 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index cecffbc1993..e638754f399 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -1,9 +1,15 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + import * as NodeServices from "@effect/platform-node/NodeServices"; +import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { it, describe, expect } from "@effect/vitest"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import type { TestContext } from "vite-plus/test"; import * as ServerConfig from "../config.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; @@ -51,6 +57,30 @@ const writeTextFile = Effect.fn("writeTextFile")(function* ( yield* fileSystem.writeFileString(absolutePath, contents).pipe(Effect.orDie); }); +const createFileSymlinkOrSkip = Effect.fn("createFileSymlinkOrSkip")(function* ( + context: TestContext, + targetPath: string, + linkPath: string, +) { + const cause = yield* Effect.promise(async () => { + try { + await NodeFSP.symlink(targetPath, linkPath, "file"); + return null; + } catch (cause) { + return cause; + } + }); + if (cause === null) { + return true; + } + const code = (cause as NodeJS.ErrnoException).code; + if (code === "EPERM" || code === "EACCES" || code === "ENOSYS" || code === "ENOTSUP") { + context.skip(`File symlinks are unavailable in this environment (${code})`); + return false; + } + return yield* Effect.die(cause); +}); + it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (it) => { describe("readFile", () => { it.effect("reads UTF-8 files relative to the workspace root", () => @@ -88,7 +118,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); - it.effect("rejects symlinks that resolve outside the workspace root", () => + it.effect("rejects symlinks that resolve outside the workspace root", (context) => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const fileSystem = yield* FileSystem.FileSystem; @@ -96,10 +126,15 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i const cwd = yield* makeTempDir; const outsideDir = yield* makeTempDir; yield* writeTextFile(outsideDir, "secret.txt", "outside\n"); - yield* fileSystem.symlink( - path.join(outsideDir, "secret.txt"), - path.join(cwd, "linked-secret.txt"), - ); + if ( + !(yield* createFileSymlinkOrSkip( + context, + path.join(outsideDir, "secret.txt"), + path.join(cwd, "linked-secret.txt"), + )) + ) { + return; + } const error = yield* workspaceFileSystem .readFile({ cwd, relativePath: "linked-secret.txt" }) @@ -192,6 +227,36 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }); describe("writeFile", () => { + it.effect("terminates ancestor discovery at a filesystem root", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const currentAncestor = NodePath.parse(cwd).root; + const resolvedPath = path.join(cwd, "missing", "file.txt"); + + const error = yield* WorkspaceFileSystem.__testing + .nextAncestorPath({ + path, + workspaceRoot: cwd, + relativePath: "missing/file.txt", + resolvedPath, + currentAncestor, + }) + .pipe(Effect.flip); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "missing/file.txt", + resolvedPath, + operationPath: currentAncestor, + operation: "realpath-target", + }); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); + expect((error.cause as NodeJS.ErrnoException).path).toBe(currentAncestor); + }), + ); + it.effect("writes files relative to the workspace root", () => Effect.gen(function* () { const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; @@ -212,6 +277,26 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i }), ); + it.effect("overwrites existing files within the workspace root", () => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const cwd = yield* makeTempDir; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* writeTextFile(cwd, "plans/existing.md", "before\n"); + + const result = yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "plans/existing.md", + contents: "after\n", + }); + const saved = yield* fileSystem.readFileString(path.join(cwd, "plans/existing.md")); + + expect(result).toEqual({ relativePath: "plans/existing.md" }); + expect(saved).toBe("after\n"); + }), + ); + it.effect("invalidates workspace entry search cache after writes", () => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -264,5 +349,164 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i expect(escapedStat).toBeNull(); }), ); + + it.effect("rejects writes through file symlinks outside the workspace root", (context) => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const outsidePath = path.join(outsideDir, "outside.txt"); + yield* fileSystem.writeFileString(outsidePath, "outside\n"); + if (!(yield* createFileSymlinkOrSkip(context, outsidePath, path.join(cwd, "linked.txt")))) { + return; + } + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: "linked.txt", contents: "overwritten\n" }) + .pipe(Effect.flip); + const saved = yield* fileSystem.readFileString(outsidePath); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(outsidePath); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect(saved).toBe("outside\n"); + }), + ); + + it.effect("rejects writes through dangling file symlinks", (context) => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const outsidePath = path.join(outsideDir, "missing.txt"); + if (!(yield* createFileSymlinkOrSkip(context, outsidePath, path.join(cwd, "linked.txt")))) { + return; + } + + const error = yield* workspaceFileSystem + .writeFile({ cwd, relativePath: "linked.txt", contents: "outside\n" }) + .pipe(Effect.flip); + const escapedStat = yield* fileSystem + .stat(outsidePath) + .pipe(Effect.orElseSucceed(() => null)); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFileSystemOperationError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked.txt", + resolvedPath: path.join(cwd, "linked.txt"), + operationPath: path.join(cwd, "linked.txt"), + operation: "realpath-target", + }); + expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); + expect(escapedStat).toBeNull(); + }), + ); + + it.effect("writes through file symlinks that stay within the workspace root", (context) => + Effect.gen(function* () { + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + yield* writeTextFile(cwd, "actual.txt", "before\n"); + const linkPath = path.join(cwd, "linked.txt"); + if (!(yield* createFileSymlinkOrSkip(context, path.join(cwd, "actual.txt"), linkPath))) { + return; + } + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "linked.txt", + contents: "after\n", + }); + const saved = yield* fileSystem.readFileString(path.join(cwd, "actual.txt")); + const linkStat = yield* Effect.tryPromise(() => NodeFSP.lstat(linkPath)).pipe(Effect.orDie); + + expect(saved).toBe("after\n"); + expect(linkStat.isSymbolicLink()).toBe(true); + }), + ); + + it.effect("rejects writes through directory symlinks outside the workspace root", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const outsideDir = yield* makeTempDir; + const outsidePath = path.join(outsideDir, "nested", "created.txt"); + yield* Effect.tryPromise(() => + NodeFSP.symlink( + outsideDir, + path.join(cwd, "linked-dir"), + platform === "win32" ? "junction" : "dir", + ), + ).pipe(Effect.orDie); + + const error = yield* workspaceFileSystem + .writeFile({ + cwd, + relativePath: "linked-dir/nested/created.txt", + contents: "outside\n", + }) + .pipe(Effect.flip); + const escapedStat = yield* fileSystem + .stat(outsidePath) + .pipe(Effect.orElseSucceed(() => null)); + const resolvedWorkspaceRoot = yield* fileSystem.realPath(cwd); + const resolvedPath = yield* fileSystem.realPath(outsideDir); + + expect(error).toBeInstanceOf(WorkspaceFileSystem.WorkspaceFilePathEscapeError); + expect(error).toMatchObject({ + workspaceRoot: cwd, + relativePath: "linked-dir/nested/created.txt", + resolvedWorkspaceRoot, + resolvedPath, + }); + expect(escapedStat).toBeNull(); + }), + ); + + it.effect("writes through directory symlinks that stay within the workspace root", () => + Effect.gen(function* () { + const platform = yield* HostProcessPlatform; + const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cwd = yield* makeTempDir; + const actualDir = path.join(cwd, "actual-dir"); + yield* fileSystem.makeDirectory(actualDir); + yield* Effect.tryPromise(() => + NodeFSP.symlink( + actualDir, + path.join(cwd, "linked-dir"), + platform === "win32" ? "junction" : "dir", + ), + ).pipe(Effect.orDie); + + yield* workspaceFileSystem.writeFile({ + cwd, + relativePath: "linked-dir/nested/created.txt", + contents: "inside\n", + }); + const saved = yield* fileSystem.readFileString( + path.join(actualDir, "nested", "created.txt"), + ); + + expect(saved).toBe("inside\n"); + }), + ); }); }); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index e2dc9cbbb39..5d549a1f042 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -7,6 +7,7 @@ * * @module WorkspaceFileSystem */ +import * as NodeFS from "node:fs"; import * as NodeFSP from "node:fs/promises"; import type { @@ -27,6 +28,35 @@ import * as WorkspacePaths from "./WorkspacePaths.ts"; const PROJECT_READ_FILE_MAX_BYTES = 1024 * 1024; +const nextAncestorPath = Effect.fn("WorkspaceFileSystem.nextAncestorPath")(function* (input: { + readonly path: Path.Path; + readonly workspaceRoot: string; + readonly relativePath: string; + readonly resolvedPath: string; + readonly currentAncestor: string; +}) { + const parentAncestor = input.path.dirname(input.currentAncestor); + if (parentAncestor === input.currentAncestor) { + const cause = Object.assign( + new Error(`No existing ancestor found for '${input.resolvedPath}'.`), + { + code: "ENOENT", + path: input.currentAncestor, + syscall: "realpath", + }, + ); + return yield* new WorkspaceFileSystemOperationError({ + workspaceRoot: input.workspaceRoot, + relativePath: input.relativePath, + resolvedPath: input.resolvedPath, + operationPath: input.currentAncestor, + operation: "realpath-target", + cause, + }); + } + return parentAncestor; +}); + export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( "WorkspaceFileSystemOperationError", { @@ -52,6 +82,14 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass()( + "WorkspaceRealPathProbeError", + { + operation: Schema.Literals(["realpath-target", "stat"]), + cause: Schema.Defect(), + }, +) {} + export class WorkspaceFilePathEscapeError extends Schema.TaggedErrorClass()( "WorkspaceFilePathEscapeError", { @@ -132,6 +170,69 @@ export const make = Effect.gen(function* () { const workspacePaths = yield* WorkspacePaths.WorkspacePaths; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const assertRealPathWithinWorkspace = Effect.fn( + "WorkspaceFileSystem.assertRealPathWithinWorkspace", + )(function* (input: { + readonly workspaceRoot: string; + readonly relativePath: string; + readonly resolvedWorkspaceRoot: string; + readonly resolvedPath: string; + }) { + const relativeRealPath = path.relative(input.resolvedWorkspaceRoot, input.resolvedPath); + if ( + relativeRealPath.startsWith(`..${path.sep}`) || + relativeRealPath === ".." || + path.isAbsolute(relativeRealPath) + ) { + return yield* new WorkspaceFilePathEscapeError(input); + } + }); + + const realPathOrNull = Effect.fn("WorkspaceFileSystem.realPathOrNull")(function* (input: { + readonly workspaceRoot: string; + readonly relativePath: string; + readonly resolvedPath: string; + readonly operationPath: string; + }) { + const realPathResult = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(input.operationPath), + catch: (cause) => new WorkspaceRealPathProbeError({ operation: "realpath-target", cause }), + }).pipe(Effect.result); + if (realPathResult._tag === "Success") { + return realPathResult.success; + } + if ((realPathResult.failure.cause as NodeJS.ErrnoException).code !== "ENOENT") { + return yield* new WorkspaceFileSystemOperationError({ + ...input, + operation: realPathResult.failure.operation, + cause: realPathResult.failure.cause, + }); + } + + const lstatResult = yield* Effect.tryPromise({ + try: () => NodeFSP.lstat(input.operationPath), + catch: (cause) => new WorkspaceRealPathProbeError({ operation: "stat", cause }), + }).pipe(Effect.result); + if (lstatResult._tag === "Failure") { + if ((lstatResult.failure.cause as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + return yield* new WorkspaceFileSystemOperationError({ + ...input, + operation: lstatResult.failure.operation, + cause: lstatResult.failure.cause, + }); + } + + // The directory entry exists but cannot be canonicalized, such as a + // dangling symlink. It must not be treated as a creatable path. + return yield* new WorkspaceFileSystemOperationError({ + ...input, + operation: "realpath-target", + cause: realPathResult.failure.cause, + }); + }); + const readFile: WorkspaceFileSystem["Service"]["readFile"] = Effect.fn( "WorkspaceFileSystem.readFile", )(function* (input) { @@ -164,19 +265,12 @@ export const make = Effect.gen(function* () { 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, - }); - } + yield* assertRealPathWithinWorkspace({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetPath, + }); return yield* Effect.acquireUseRelease( Effect.tryPromise({ @@ -267,31 +361,196 @@ export const make = Effect.gen(function* () { relativePath: input.relativePath, }); - yield* fileSystem.makeDirectory(path.dirname(target.absolutePath), { recursive: true }).pipe( + 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 targetParent = path.dirname(target.absolutePath); + let existingAncestor = targetParent; + while (true) { + const realExistingAncestor = yield* realPathOrNull({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: existingAncestor, + }); + if (realExistingAncestor !== null) { + yield* assertRealPathWithinWorkspace({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realExistingAncestor, + }); + break; + } + existingAncestor = yield* nextAncestorPath({ + path, + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + currentAncestor: existingAncestor, + }); + } + + yield* fileSystem.makeDirectory(targetParent, { recursive: true }).pipe( Effect.mapError( (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, resolvedPath: target.absolutePath, - operationPath: path.dirname(target.absolutePath), + operationPath: targetParent, operation: "make-directory", cause, }), ), ); - yield* fileSystem.writeFileString(target.absolutePath, input.contents).pipe( - Effect.mapError( - (cause) => + const realTargetParent = yield* Effect.tryPromise({ + try: () => NodeFSP.realpath(targetParent), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: targetParent, + operation: "realpath-target", + cause, + }), + }); + yield* assertRealPathWithinWorkspace({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: realTargetParent, + }); + + const existingRealTarget = yield* realPathOrNull({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: target.absolutePath, + }); + const initialRealTargetPath = + existingRealTarget ?? path.join(realTargetParent, path.basename(target.absolutePath)); + yield* assertRealPathWithinWorkspace({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: initialRealTargetPath, + }); + + const openTarget = Effect.fn("WorkspaceFileSystem.openWriteTarget")(function* ( + resolvedPath: string, + flags: number, + ) { + return yield* Effect.tryPromise({ + try: () => NodeFSP.open(resolvedPath, flags), + catch: (cause) => new WorkspaceFileSystemOperationError({ workspaceRoot: input.cwd, relativePath: input.relativePath, - resolvedPath: target.absolutePath, - operationPath: target.absolutePath, - operation: "write-file", + resolvedPath, + operationPath: resolvedPath, + operation: "open", cause, }), - ), + }); + }); + const noFollowFlag = NodeFS.constants.O_NOFOLLOW ?? 0; + const openExistingFlags = NodeFS.constants.O_WRONLY | noFollowFlag; + const openNewFlags = + NodeFS.constants.O_WRONLY | NodeFS.constants.O_CREAT | NodeFS.constants.O_EXCL | noFollowFlag; + const acquireWriteTarget = Effect.gen(function* () { + if (existingRealTarget !== null) { + const handle = yield* openTarget(initialRealTargetPath, openExistingFlags); + return { handle, realTargetPath: initialRealTargetPath, truncate: true }; + } + + const createResult = yield* openTarget(initialRealTargetPath, openNewFlags).pipe( + Effect.result, + ); + if (createResult._tag === "Success") { + return { + handle: createResult.success, + realTargetPath: initialRealTargetPath, + truncate: false, + }; + } + if ((createResult.failure.cause as NodeJS.ErrnoException).code !== "EEXIST") { + return yield* createResult.failure; + } + + const appearedRealTarget = yield* realPathOrNull({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: target.absolutePath, + operationPath: initialRealTargetPath, + }); + if (appearedRealTarget === null) { + return yield* createResult.failure; + } + yield* assertRealPathWithinWorkspace({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedWorkspaceRoot: realWorkspaceRoot, + resolvedPath: appearedRealTarget, + }); + const handle = yield* openTarget(appearedRealTarget, openExistingFlags); + return { handle, realTargetPath: appearedRealTarget, truncate: true }; + }); + + yield* Effect.acquireUseRelease( + acquireWriteTarget, + ({ handle, realTargetPath, truncate }) => + Effect.gen(function* () { + if (truncate) { + yield* Effect.tryPromise({ + try: () => handle.truncate(0), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "write-file", + cause, + }), + }); + } + yield* Effect.tryPromise({ + try: () => handle.writeFile(input.contents, "utf8"), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "write-file", + cause, + }), + }); + }), + ({ handle, realTargetPath }) => + Effect.tryPromise({ + try: () => handle.close(), + catch: (cause) => + new WorkspaceFileSystemOperationError({ + workspaceRoot: input.cwd, + relativePath: input.relativePath, + resolvedPath: realTargetPath, + operationPath: realTargetPath, + operation: "close", + cause, + }), + }), ); yield* workspaceEntries.refresh(input.cwd); return { relativePath: target.relativePath }; @@ -301,3 +560,5 @@ export const make = Effect.gen(function* () { }); export const layer = Layer.effect(WorkspaceFileSystem, make); + +export const __testing = { nextAncestorPath }; From 2e83a2b4b573c8eddb04c340490ec4f512bb7842 Mon Sep 17 00:00:00 2001 From: Wout Stiens Date: Tue, 21 Jul 2026 11:00:56 +0200 Subject: [PATCH 2/2] fix(server): model ancestor termination without cause --- apps/server/src/workspace/WorkspaceFileSystem.test.ts | 3 +-- apps/server/src/workspace/WorkspaceFileSystem.ts | 11 +---------- 2 files changed, 2 insertions(+), 12 deletions(-) diff --git a/apps/server/src/workspace/WorkspaceFileSystem.test.ts b/apps/server/src/workspace/WorkspaceFileSystem.test.ts index e638754f399..da8bd315f91 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.test.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.test.ts @@ -252,8 +252,7 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceFileSystemLive", (i operationPath: currentAncestor, operation: "realpath-target", }); - expect((error.cause as NodeJS.ErrnoException).code).toBe("ENOENT"); - expect((error.cause as NodeJS.ErrnoException).path).toBe(currentAncestor); + expect("cause" in error).toBe(false); }), ); diff --git a/apps/server/src/workspace/WorkspaceFileSystem.ts b/apps/server/src/workspace/WorkspaceFileSystem.ts index 5d549a1f042..c274adfaf81 100644 --- a/apps/server/src/workspace/WorkspaceFileSystem.ts +++ b/apps/server/src/workspace/WorkspaceFileSystem.ts @@ -37,21 +37,12 @@ const nextAncestorPath = Effect.fn("WorkspaceFileSystem.nextAncestorPath")(funct }) { const parentAncestor = input.path.dirname(input.currentAncestor); if (parentAncestor === input.currentAncestor) { - const cause = Object.assign( - new Error(`No existing ancestor found for '${input.resolvedPath}'.`), - { - code: "ENOENT", - path: input.currentAncestor, - syscall: "realpath", - }, - ); return yield* new WorkspaceFileSystemOperationError({ workspaceRoot: input.workspaceRoot, relativePath: input.relativePath, resolvedPath: input.resolvedPath, operationPath: input.currentAncestor, operation: "realpath-target", - cause, }); } return parentAncestor; @@ -74,7 +65,7 @@ export class WorkspaceFileSystemOperationError extends Schema.TaggedErrorClass