From 471a7431f240bde697c4d69cbdf5b1b354141ec6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 29 Jul 2026 09:16:01 +0000 Subject: [PATCH] fix(projects): prevent project key collisions Preserve platform path semantics and generic remote transports in cross-host keys, and compare legacy checkout roots using filesystem equivalence before materializing ownership. --- .../server/src/server/project-key.test.ts | 36 +++++++++++++++++-- packages/server/src/server/project-key.ts | 21 ++++++++--- .../workspace-registry-bootstrap-legacy.ts | 6 ++-- .../workspace-registry-bootstrap.test.ts | 23 ++++++++++++ 4 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/server/src/server/project-key.test.ts b/packages/server/src/server/project-key.test.ts index 8b67608fa..f8c58b991 100644 --- a/packages/server/src/server/project-key.test.ts +++ b/packages/server/src/server/project-key.test.ts @@ -15,7 +15,22 @@ describe("deriveProjectKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:git.example.com:8443/acme/app.git"); + ).toBe("remote:https://git.example.com:8443/acme/app.git"); + }); + + test("distinguishes transports for generic URL remotes", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("http://git.example.com:80/acme/app.git")).not.toBe( + derive("https://git.example.com:443/acme/app.git"), + ); }); test("normalizes the default SSH port", () => { @@ -43,7 +58,7 @@ describe("deriveProjectKey", () => { worktreeRoot: rootPath, mainRepoRoot: null, }), - ).toBe("remote:git.example.com/repo.git"); + ).toBe("remote:https://git.example.com/repo.git"); }); test("preserves meaningful dot-git suffixes on unknown Git servers", () => { @@ -244,6 +259,23 @@ describe("deriveProjectKey", () => { ).toBe("remote:github.com/getpaseo/paseo#subdir:packages/app"); }); + test.skipIf(process.platform === "win32")( + "preserves backslashes in POSIX selected path segments", + () => { + const worktreeRoot = "/repo"; + const derive = (rootPath: string) => + deriveProjectKey({ + rootPath, + remoteUrl: "git@github.com:getpaseo/paseo.git", + worktreeRoot, + mainRepoRoot: null, + }); + + expect(derive("/repo/foo\\bar")).toBe("remote:github.com/getpaseo/paseo#subdir:foo%5Cbar"); + expect(derive("/repo/foo\\bar")).not.toBe(derive("/repo/foo/bar")); + }, + ); + test("keeps a selected path distinct from remote path syntax", () => { const worktreeRoot = path.resolve("repo"); const selectedKey = deriveProjectKey({ diff --git a/packages/server/src/server/project-key.ts b/packages/server/src/server/project-key.ts index a4f6a3eae..f4c1e384c 100644 --- a/packages/server/src/server/project-key.ts +++ b/packages/server/src/server/project-key.ts @@ -42,7 +42,9 @@ export function deriveProjectKey(input: { : localPath; } - return selectedPath ? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath)}` : remoteKey; + return selectedPath + ? `${remoteKey}#subdir:${encodeSelectedPath(selectedPath, input.worktreeRoot ?? input.rootPath)}` + : remoteKey; } function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): string | null { @@ -50,8 +52,11 @@ function deriveSelectedPath(rootPath: string, worktreeRoot: string | null): stri return getRealpathAwareRelativePath(worktreeRoot, rootPath) || null; } -function encodeSelectedPath(selectedPath: string): string { - return selectedPath.split(/[\\/]/u).map(encodeURIComponent).join("/"); +function encodeSelectedPath(selectedPath: string, sourcePath: string): string { + const segments = looksLikeWindowsPath(sourcePath) + ? selectedPath.split(/[\\/]/u) + : selectedPath.split("/"); + return segments.map(encodeURIComponent).join("/"); } function deriveRemoteProjectKey(remoteUrl: string | null): string | null { @@ -70,12 +75,14 @@ function deriveRemoteProjectKey(remoteUrl: string | null): string | null { : ""; const normalizedHost = remote.host.toLowerCase(); const normalizedPath = normalizedHost === "github.com" ? cleanedPath.toLowerCase() : cleanedPath; - return `remote:${userPrefix}${normalizedHost}/${normalizedPath}`; + const transportPrefix = remote.transport ? `${remote.transport}//` : ""; + return `remote:${transportPrefix}${userPrefix}${normalizedHost}/${normalizedPath}`; } interface RemoteLocation { host: string; path: string; + transport: string | null; relativePathUser: string | null; preserveLeadingSlash: boolean; decodePercentEncoding: boolean; @@ -99,6 +106,7 @@ function parseScpRemote(remoteUrl: string): RemoteLocation | null { return { host: normalizedHost, path: remotePath, + transport: null, relativePathUser: user && user !== "git" && !preserveLeadingSlash ? user : null, preserveLeadingSlash, decodePercentEncoding: false, @@ -119,6 +127,7 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { return { host, path: remotePath, + transport: forgeHost ? null : parsed.protocol.toLowerCase(), relativePathUser: null, preserveLeadingSlash, decodePercentEncoding: true, @@ -212,3 +221,7 @@ function deriveRemoteHost(remoteUrl: URL): string | null { return remoteUrl.hostname || null; return remoteUrl.host || null; } + +function looksLikeWindowsPath(value: string): boolean { + return /^[a-zA-Z]:[\\/]/u.test(value) || /^\\{2}[^\\/]+[\\/][^\\/]+/u.test(value); +} diff --git a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts index c290032c1..f96a2edc7 100644 --- a/packages/server/src/server/workspace-registry-bootstrap-legacy.ts +++ b/packages/server/src/server/workspace-registry-bootstrap-legacy.ts @@ -3,7 +3,7 @@ import { resolve } from "node:path"; import type { ProjectCheckoutLitePayload } from "@getpaseo/protocol/messages"; import { parseGitRevParsePath } from "../utils/git-rev-parse-path.js"; -import { getRealpathAwareRelativePath } from "../utils/path.js"; +import { createRealpathAwarePathMatcher, getRealpathAwareRelativePath } from "../utils/path.js"; import { deriveProjectKey, deriveProjectGroupingDisplayName } from "./project-key.js"; import { deriveProjectKind, @@ -62,7 +62,9 @@ export function classifyDirectoryForProjectMembership(input: { function deriveWorkspaceDirectoryKey(cwd: string, checkout: ProjectCheckoutLitePayload): string { const worktreeRoot = checkout.worktreeRoot ? parseGitRevParsePath(checkout.worktreeRoot) : null; const selectedRoot = resolve(cwd); - return worktreeRoot && resolve(worktreeRoot) === selectedRoot ? worktreeRoot : selectedRoot; + return worktreeRoot && createRealpathAwarePathMatcher(worktreeRoot)(selectedRoot) + ? worktreeRoot + : selectedRoot; } function deriveProjectRootPath(input: { diff --git a/packages/server/src/server/workspace-registry-bootstrap.test.ts b/packages/server/src/server/workspace-registry-bootstrap.test.ts index f0d37249f..5fc2653e9 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.test.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.test.ts @@ -10,6 +10,7 @@ import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-servic import type { WorkspaceGitService } from "./workspace-git-service.js"; import { FileBackedProjectRegistry, FileBackedWorkspaceRegistry } from "./workspace-registry.js"; import { bootstrapWorkspaceRegistries } from "./workspace-registry-bootstrap.js"; +import { classifyDirectoryForProjectMembership } from "./workspace-registry-bootstrap-legacy.js"; let NON_GIT_PROJECT: string; let ARCHIVED_PROJECT: string; @@ -511,6 +512,28 @@ describe("bootstrapWorkspaceRegistries", () => { }, ); + test.skipIf(process.platform === "win32")( + "uses one directory key for symlink-equivalent checkout roots", + () => { + const linkedProject = path.join(tmpDir, "legacy-project-link"); + symlinkSync(GIT_PROJECT, linkedProject, "dir"); + const checkout = { + cwd: linkedProject, + isGit: true as const, + currentBranch: "main", + remoteUrl: "git@github.com:acme/legacy-project.git", + worktreeRoot: GIT_PROJECT, + isPaseoOwnedWorktree: false, + mainRepoRoot: GIT_PROJECT, + }; + + expect( + classifyDirectoryForProjectMembership({ cwd: linkedProject, checkout }) + .workspaceDirectoryKey, + ).toBe(GIT_PROJECT); + }, + ); + test("migrates cwd-only agents to the oldest existing same-cwd workspace", async () => { await projectRegistry.initialize(); await workspaceRegistry.initialize();