From f77fdf2a2f55823d8e54e4b3a53e240ef2a7a6bd Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Thu, 16 Jul 2026 18:35:17 +0000 Subject: [PATCH] fix(projects): derive exact cwd from matched path identity --- packages/server/src/server/session.ts | 4 +- .../src/server/session.workspaces.test.ts | 5 +-- .../src/server/workspace-archive-service.ts | 8 ++-- packages/server/src/utils/path.test.ts | 29 ++++++++++++- packages/server/src/utils/path.ts | 42 ++++++++++++++----- packages/server/src/utils/worktree.test.ts | 30 ++++++++++++- packages/server/src/utils/worktree.ts | 16 +++---- 7 files changed, 107 insertions(+), 27 deletions(-) diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index bacb96c83..b09c1ce89 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -178,7 +178,7 @@ import { matchesAgentUpdatesFilter, type AgentUpdatesService, } from "./session/agent-updates/agent-updates-service.js"; -import { areEquivalentPaths, expandTilde } from "../utils/path.js"; +import { createRealpathAwarePathMatcher, expandTilde } from "../utils/path.js"; import { searchDirectoryEntries, WORKSPACE_SEARCH_HIDDEN_DIRECTORIES, @@ -4083,7 +4083,7 @@ export class Session { workspaceCwd: workspace.cwd, targetWorktreePath: result.worktreePath, }); - if (!areEquivalentPaths(recreatedWorkspacePath, workspace.cwd)) { + if (!createRealpathAwarePathMatcher(workspace.cwd)(recreatedWorkspacePath)) { throw new WorktreeRequestError({ code: "unknown", message: `Recreated worktree diverged from ${workspace.cwd}: ${recreatedWorkspacePath}`, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 4ab3d1e01..9287e105e 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -33,7 +33,7 @@ import type { AgentStreamEvent, } from "./agent/agent-sdk-types.js"; import { createWorktree } from "../utils/worktree.js"; -import { areEquivalentPaths } from "../utils/path.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; import { readPaseoWorktreeMetadata, writePaseoWorktreeFirstAgentBranchAutoNameMetadata, @@ -1024,9 +1024,8 @@ test("create_agent_request launches from an exact subdirectory in a created work .toString() .trim(); expect( - areEquivalentPaths( + createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))( createdAgent?.cwd ?? "", - path.join(createdWorktreeRoot, "packages", "app"), ), ).toBe(true); expect(findByType(emitted, "status")?.payload).toMatchObject({ diff --git a/packages/server/src/server/workspace-archive-service.ts b/packages/server/src/server/workspace-archive-service.ts index 0ed543d78..6e6c52f84 100644 --- a/packages/server/src/server/workspace-archive-service.ts +++ b/packages/server/src/server/workspace-archive-service.ts @@ -14,7 +14,7 @@ import { } from "../utils/worktree.js"; import type { TerminalManager } from "../terminal/terminal-manager.js"; import type { PersistedWorkspaceRecord, WorkspaceRegistry } from "./workspace-registry.js"; -import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../utils/path.js"; +import { createRealpathAwarePathMatcher } from "../utils/path.js"; export interface ActiveWorkspaceRef { workspaceId: string; @@ -189,6 +189,7 @@ async function resolveArchiveTargets( paseoWorktreesBaseRoot, ); const targetDir = resolve(targetPath); + const matchesTargetDir = createRealpathAwarePathMatcher(targetDir); const targetWorkspaceIds = ( await Promise.all( activeWorkspaces.map(async (workspace) => { @@ -197,7 +198,7 @@ async function resolveArchiveTargets( dependencies, paseoWorktreesBaseRoot, ); - return areEquivalentPaths(backingDirectory, targetDir) ? workspace.workspaceId : null; + return matchesTargetDir(backingDirectory) ? workspace.workspaceId : null; }), ) ).filter((workspaceId): workspaceId is string => workspaceId !== null); @@ -373,6 +374,7 @@ async function isDirectoryUnreferenced( request: Pick, ): Promise { const target = resolve(targetDir); + const matchesTarget = createRealpathAwarePathMatcher(target); for (const workspace of activeWorkspaces) { if (archivedWorkspaceIds.has(workspace.workspaceId)) continue; const backingDirectory = await resolveBackingWorktreeDirectory( @@ -380,7 +382,7 @@ async function isDirectoryUnreferenced( dependencies, request.paseoWorktreesBaseRoot, ); - if (areEquivalentPaths(backingDirectory, target)) return false; + if (matchesTarget(backingDirectory)) return false; } return true; } diff --git a/packages/server/src/utils/path.test.ts b/packages/server/src/utils/path.test.ts index d84a1bf6a..fbdf509f5 100644 --- a/packages/server/src/utils/path.test.ts +++ b/packages/server/src/utils/path.test.ts @@ -1,6 +1,14 @@ +import { mkdtempSync, mkdirSync, rmSync, symlinkSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, expect, test } from "vitest"; -import { areEquivalentPaths, createPathEquivalenceMatcher, isPathInsideRoot } from "./path.js"; +import { + areEquivalentPaths, + createPathEquivalenceMatcher, + getRealpathAwareRelativePath, + isPathInsideRoot, +} from "./path.js"; describe("path equivalence", () => { test.each([ @@ -33,4 +41,23 @@ describe("path equivalence", () => { false, ); }); + + test.skipIf(process.platform === "win32")( + "derives the contained suffix from a realpath-equivalent root", + () => { + const tempDir = mkdtempSync(join(tmpdir(), "paseo-path-")); + try { + const realRoot = join(tempDir, "real-root"); + const nestedPath = join(realRoot, "packages", "app"); + const aliasRoot = join(tempDir, "root-alias"); + mkdirSync(nestedPath, { recursive: true }); + symlinkSync(realRoot, aliasRoot, "dir"); + + expect(getRealpathAwareRelativePath(aliasRoot, nestedPath)).toBe(join("packages", "app")); + expect(getRealpathAwareRelativePath(aliasRoot, tempDir)).toBeNull(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }, + ); }); diff --git a/packages/server/src/utils/path.ts b/packages/server/src/utils/path.ts index f4d87a92a..150bb184d 100644 --- a/packages/server/src/utils/path.ts +++ b/packages/server/src/utils/path.ts @@ -59,22 +59,44 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri } export function isPathInsideRoot(root: string, candidate: string): boolean { + return getRelativePathInsideRoot(root, candidate) !== null; +} + +/** + * Returns the candidate's relative suffix when it is inside root. + * + * The suffix is derived from the same lexical path pair used to prove + * containment. Callers that map an existing filesystem path into a new root + * must keep those two operations coupled. + */ +export function getRealpathAwareRelativePath(root: string, candidate: string): string | null { + const rootVariants = collectPathVariants(root); + const candidateVariants = collectPathVariants(candidate); + + for (const rootVariant of rootVariants) { + for (const candidateVariant of candidateVariants) { + const relativePath = getRelativePathInsideRoot(rootVariant, candidateVariant); + if (relativePath !== null) return relativePath; + } + } + + return null; +} + +export function isRealpathInsideRoot(root: string, candidate: string): boolean { + return getRealpathAwareRelativePath(root, candidate) !== null; +} + +function getRelativePathInsideRoot(root: string, candidate: string): string | null { const compareAsWindows = shouldCompareAsWindows(root, candidate); const platformPath = compareAsWindows ? nodePath.win32 : nodePath.posix; const normalizedRoot = normalizePathForComparison(root, compareAsWindows); const normalizedCandidate = normalizePathForComparison(candidate, compareAsWindows); const relative = platformPath.relative(normalizedRoot, normalizedCandidate); - return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)); -} - -export function isRealpathInsideRoot(root: string, candidate: string): boolean { - const rootVariants = collectPathVariants(root); - const candidateVariants = collectPathVariants(candidate); - - return rootVariants.some((rootVariant) => - candidateVariants.some((candidateVariant) => isPathInsideRoot(rootVariant, candidateVariant)), - ); + return relative === "" || (!relative.startsWith("..") && !platformPath.isAbsolute(relative)) + ? relative + : null; } function collectPathVariants(value: string): string[] { diff --git a/packages/server/src/utils/worktree.test.ts b/packages/server/src/utils/worktree.test.ts index f7ce3d314..17b3e238f 100644 --- a/packages/server/src/utils/worktree.test.ts +++ b/packages/server/src/utils/worktree.test.ts @@ -10,7 +10,15 @@ import { type WorktreeConfig, } from "./worktree"; import { execFileSync } from "child_process"; -import { mkdtempSync, mkdirSync, rmSync, existsSync, realpathSync, writeFileSync } from "fs"; +import { + mkdtempSync, + mkdirSync, + rmSync, + existsSync, + realpathSync, + symlinkSync, + writeFileSync, +} from "fs"; import { join } from "path"; import { tmpdir } from "os"; @@ -132,6 +140,26 @@ describe("paseo worktree manager", () => { ).toThrow("outside its source worktree"); }); + it.skipIf(process.platform === "win32")( + "maps a realpath-equivalent source workspace into the matching target subdirectory", + () => { + const sourceWorktreePath = join(tempDir, "source-worktree"); + const workspaceCwd = join(sourceWorktreePath, "packages", "app"); + const sourceAlias = join(tempDir, "source-alias"); + const targetWorktreePath = join(tempDir, "target-worktree"); + mkdirSync(workspaceCwd, { recursive: true }); + symlinkSync(sourceWorktreePath, sourceAlias, "dir"); + + expect( + mapWorkspaceCwdToWorktree({ + sourceWorktreePath: sourceAlias, + workspaceCwd, + targetWorktreePath, + }), + ).toBe(join(targetWorktreePath, "packages", "app")); + }, + ); + it("rejects the worktrees root itself and the per-repo hash dir", async () => { const projectHash = await deriveWorktreeProjectHash(repoDir); const worktreesRoot = join(paseoHome, "worktrees"); diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index b13e5a762..05ee28d23 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -2,7 +2,7 @@ import { execFile } from "child_process"; import { promisify } from "util"; import { existsSync, mkdirSync, realpathSync, rmSync, statSync } from "fs"; import { copyFile, rm, stat } from "fs/promises"; -import { join, basename, dirname, isAbsolute, relative, resolve, sep } from "path"; +import { join, basename, dirname, isAbsolute, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; import stripAnsi from "strip-ansi"; @@ -34,7 +34,7 @@ import { resolvePaseoHome } from "../server/paseo-home.js"; import { createExternalProcessEnv } from "../server/paseo-env.js"; import { parseGitRevParsePath, resolveGitRevParsePath } from "./git-rev-parse-path.js"; import { validateBranchSlug } from "@getpaseo/protocol/branch-slug"; -import { expandTilde, isRealpathInsideRoot } from "./path.js"; +import { expandTilde, getRealpathAwareRelativePath, isPathInsideRoot } from "./path.js"; export { slugify, validateBranchSlug } from "@getpaseo/protocol/branch-slug"; @@ -830,14 +830,16 @@ export function mapWorkspaceCwdToWorktree(input: { workspaceCwd: string; targetWorktreePath: string; }): string { - if (!isRealpathInsideRoot(input.sourceWorktreePath, input.workspaceCwd)) { + const relativeWorkspaceCwd = getRealpathAwareRelativePath( + input.sourceWorktreePath, + input.workspaceCwd, + ); + if (relativeWorkspaceCwd === null) { throw new Error(`Workspace cwd is outside its source worktree: ${input.workspaceCwd}`); } - const sourceWorktreePath = normalizePathForOwnership(input.sourceWorktreePath); - const workspaceCwd = normalizePathForOwnership(input.workspaceCwd); - const mappedCwd = resolve(input.targetWorktreePath, relative(sourceWorktreePath, workspaceCwd)); - if (!isRealpathInsideRoot(input.targetWorktreePath, mappedCwd)) { + const mappedCwd = resolve(input.targetWorktreePath, relativeWorkspaceCwd); + if (!isPathInsideRoot(input.targetWorktreePath, mappedCwd)) { throw new Error(`Workspace cwd escapes its target worktree: ${input.workspaceCwd}`); } return mappedCwd;