diff --git a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts index 6f2924c86bfc..7665823cce0b 100644 --- a/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/AntigravityAdapterV2.test.ts @@ -221,6 +221,17 @@ describe("AntigravityAdapterV2 client file system", () => { context("fs/write_text_file"), ); assert.equal(yield* fileSystem.readFileString(insidePath), "inside"); + const workspaceAlias = path.join(outside, "workspace-alias"); + yield* fileSystem.symlink(workspace, workspaceAlias); + const nestedPath = path.join(workspaceAlias, "new", "nested", "inside.ts"); + yield* writeTextFile( + { sessionId: "mock-session-1", path: nestedPath, content: "nested" }, + context("fs/write_text_file"), + ); + assert.equal( + yield* fileSystem.readFileString(path.join(workspace, "new", "nested", "inside.ts")), + "nested", + ); const pasted = yield* readTextFile( { sessionId: "mock-session-1", path: attachment }, context("fs/read_text_file"), diff --git a/apps/server/src/provider/acp/AntigravityClientFiles.ts b/apps/server/src/provider/acp/AntigravityClientFiles.ts index 7c5265934f03..6679564d35c1 100644 --- a/apps/server/src/provider/acp/AntigravityClientFiles.ts +++ b/apps/server/src/provider/acp/AntigravityClientFiles.ts @@ -35,24 +35,28 @@ const resolveClientFilePath = Effect.fn("AntigravityClientFiles.resolveClientFil const outside = EffectAcpErrors.AcpRequestError.invalidParams( `Path '${input.requestPath}' is outside the session workspace.`, ); - const real = yield* input.fileSystem.realPath(resolved).pipe( - Effect.catch(() => - Effect.gen(function* () { - // Only a missing file (a new write) falls back to its parent; a - // dangling or unreadable link must not be followed on write. - const entryExists = yield* input.fileSystem.readLink(resolved).pipe( - Effect.as(true), - Effect.catch(() => input.fileSystem.exists(resolved)), - Effect.orElseSucceed(() => true), - ); - if (entryExists) return yield* outside; - const parent = yield* input.fileSystem - .realPath(path.dirname(resolved)) - .pipe(Effect.orElseSucceed(() => path.dirname(resolved))); - return path.join(parent, path.basename(resolved)); - }), - ), - ); + let candidate = resolved; + const missing: Array = []; + const real = yield* Effect.gen(function* () { + while (true) { + const canonical = yield* input.fileSystem.realPath(candidate).pipe(Effect.option); + if (canonical._tag === "Some") { + return path.join(canonical.value, ...missing); + } + // Resolve the nearest existing ancestor before appending missing + // directories. A dangling or unreadable link must not be followed. + const entryExists = yield* input.fileSystem.readLink(candidate).pipe( + Effect.as(true), + Effect.catch(() => input.fileSystem.exists(candidate)), + Effect.orElseSucceed(() => true), + ); + if (entryExists) return yield* outside; + const parent = path.dirname(candidate); + if (parent === candidate) return yield* outside; + missing.unshift(path.basename(candidate)); + candidate = parent; + } + }); const roots = yield* Effect.forEach(input.allowedRoots, (root) => input.fileSystem.realPath(root).pipe(Effect.orElseSucceed(() => root)), );