diff --git a/packages/server/src/server/agent/mcp-server.test.ts b/packages/server/src/server/agent/mcp-server.test.ts index 84c9df422f..b29b0a39f5 100644 --- a/packages/server/src/server/agent/mcp-server.test.ts +++ b/packages/server/src/server/agent/mcp-server.test.ts @@ -2564,6 +2564,7 @@ describe("create_agent MCP tool", () => { providerSnapshotManager: createOpenCodeManager().manager, projectRegistry: { get: async (projectId) => (projectId === project.projectId ? project : null), + list: async () => [project], }, createPaseoWorktree, logger, diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 906fd16a23..e681eb521f 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -108,7 +108,7 @@ export interface PaseoToolHostDependencies { archiveWorkspaceRecord?: ArchiveDependencies["archiveWorkspaceRecord"]; emitWorkspaceUpdatesForWorkspaceIds?: ArchiveDependencies["emitWorkspaceUpdatesForWorkspaceIds"]; workspaceRegistry?: Pick; - projectRegistry?: Pick; + projectRegistry?: Pick; createDirectoryWorkspace?: ( cwd: string, title?: string | null, diff --git a/packages/server/src/server/project-key.test.ts b/packages/server/src/server/project-key.test.ts index f8c58b9913..68fc39bf34 100644 --- a/packages/server/src/server/project-key.test.ts +++ b/packages/server/src/server/project-key.test.ts @@ -174,6 +174,51 @@ describe("deriveProjectKey", () => { ); }); + test("distinguishes SSH users for generic URL remotes", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("ssh://alice@git.example.com/srv/repo.git")).not.toBe( + derive("ssh://bob@git.example.com/srv/repo.git"), + ); + }); + + test("normalizes equivalent absolute SSH remote forms", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("git@example.com:/srv/repo.git")).toBe( + derive("ssh://git@example.com/srv/repo.git"), + ); + }); + + test("preserves query and fragment text in SSH repository paths", () => { + const rootPath = path.resolve("repo"); + const derive = (remoteUrl: string) => + deriveProjectKey({ + rootPath, + remoteUrl, + worktreeRoot: rootPath, + mainRepoRoot: null, + }); + + expect(derive("ssh://git@example.com/repo.git?x=1#one")).not.toBe( + derive("ssh://git@example.com/repo.git?x=2#two"), + ); + }); + test("keeps percent sequences literal in SCP paths", () => { const rootPath = path.resolve("repo"); const derive = (remoteUrl: string) => diff --git a/packages/server/src/server/project-key.ts b/packages/server/src/server/project-key.ts index f4c1e384cb..672ccd8ccd 100644 --- a/packages/server/src/server/project-key.ts +++ b/packages/server/src/server/project-key.ts @@ -122,13 +122,17 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { const host = forgeHost ?? deriveRemoteHost(parsed); const preserveLeadingSlash = isSsh && !forgeHost; let remotePath = parsed.pathname || null; + if (remotePath && isSsh) remotePath += `${parsed.search}${parsed.hash}`; if (remotePath && !preserveLeadingSlash) remotePath = remotePath.replace(/^\/+/, ""); if (!host || !remotePath) return null; return { host, path: remotePath, - transport: forgeHost ? null : parsed.protocol.toLowerCase(), - relativePathUser: null, + transport: forgeHost || isSsh ? null : parsed.protocol.toLowerCase(), + relativePathUser: + isSsh && !forgeHost && parsed.username && parsed.username !== "git" + ? decodeUrlComponent(parsed.username) + : null, preserveLeadingSlash, decodePercentEncoding: true, stripDotGitSuffix: forgeHost !== null, @@ -138,6 +142,14 @@ function parseUrlRemote(remoteUrl: string): RemoteLocation | null { } } +function decodeUrlComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + function normalizeCloudForgeHost(host: string): string { return CANONICAL_CLOUD_FORGE_HOSTS.get(host.toLowerCase()) ?? host; } diff --git a/packages/server/src/server/project-reference.ts b/packages/server/src/server/project-reference.ts new file mode 100644 index 0000000000..707ff91f8f --- /dev/null +++ b/packages/server/src/server/project-reference.ts @@ -0,0 +1,24 @@ +import { createRealpathAwarePathMatcher } from "../utils/path.js"; +import type { PersistedProjectRecord, ProjectRegistry } from "./workspace-registry.js"; + +export async function resolveProjectReference( + reference: string, + projectRegistry: Pick, + pathHints: readonly string[] = [], +): Promise { + const direct = await projectRegistry.get(reference); + if (direct) return direct; + + // COMPAT(projectKeyAsProjectId): added in v0.2.4 on 2026-07-29; remove after 2027-01-29. + // Older clients return ProjectPlacementPayload.projectKey in workspace source.projectId. + const candidates = (await projectRegistry.list()).filter( + (project) => !project.archivedAt && project.projectKey === reference, + ); + for (const pathHint of pathHints) { + const matches = candidates.filter((project) => + createRealpathAwarePathMatcher(project.rootPath)(pathHint), + ); + if (matches.length === 1) return matches[0] ?? null; + } + return candidates.length === 1 ? (candidates[0] ?? null) : null; +} diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 8c08093f22..a436f05a55 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -457,6 +457,26 @@ test("createWorkspaceForDirectory refreshes an explicit project's stale Git kind }); }); +test("createWorkspaceForDirectory accepts a legacy project-key reference", async () => { + const rootPath = path.join(tmpDir, "repo"); + gitRoots.add(rootPath); + const project = await projectRegistry.getOrCreateActiveByRoot({ + rootPath, + kind: "git", + displayName: "repo", + projectKey: "remote:github.com/acme/repo", + timestamp: ARCHIVED_AT, + }); + + const workspace = await provisioning.createWorkspaceForDirectory( + rootPath, + null, + "remote:github.com/acme/repo", + ); + + expect(workspace.projectId).toBe(project.projectId); +}); + test("createWorkspaceForDirectory classifies unknown and archived explicit projects", async () => { await expect( provisioning.createWorkspaceForDirectory(path.join(tmpDir, "directory"), null, "missing"), diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 61a25b96bd..98e9141652 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -15,6 +15,7 @@ import { import type { WorkspaceGitService } from "../../workspace-git-service.js"; import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.js"; import { deriveProjectKey } from "../../project-key.js"; +import { resolveProjectReference } from "../../project-reference.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; export interface ResolveOrCreateWorkspaceIdInput { @@ -175,8 +176,11 @@ export function createWorkspaceProvisioningService(deps: { }); } - async function requireActiveProject(projectId: string): Promise { - const project = await projectRegistry.get(projectId); + async function requireActiveProject( + projectId: string, + pathHints: readonly string[] = [], + ): Promise { + const project = await resolveProjectReference(projectId, projectRegistry, pathHints); if (!project) throw new WorkspaceProvisioningError("unknown_project", projectId); if (project.archivedAt) throw new WorkspaceProvisioningError("archived_project", projectId); return project; @@ -191,7 +195,11 @@ export function createWorkspaceProvisioningService(deps: { const normalizedCwd = resolve(cwd); const checkout = await workspaceGitService.getCheckout(normalizedCwd); const project = projectId - ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) + ? await refreshProjectKind( + await requireActiveProject(projectId, [normalizedCwd]), + normalizedCwd, + checkout, + ) : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. await findOrCreateProjectForDirectory(normalizedCwd); const timestamp = new Date().toISOString(); @@ -247,7 +255,9 @@ export function createWorkspaceProvisioningService(deps: { repoRoot: string; }): Promise { if (input.projectId) { - return refreshProjectKind(await requireActiveProject(input.projectId)); + return refreshProjectKind( + await requireActiveProject(input.projectId, [input.sourceCwd, input.repoRoot]), + ); } const workspaces = await workspaceRegistry.list(); diff --git a/packages/server/src/server/workspace-source.ts b/packages/server/src/server/workspace-source.ts index 21a53e6271..41dd1a4afb 100644 --- a/packages/server/src/server/workspace-source.ts +++ b/packages/server/src/server/workspace-source.ts @@ -1,4 +1,5 @@ import { expandTilde } from "../utils/path.js"; +import { resolveProjectReference } from "./project-reference.js"; import type { ProjectRegistry } from "./workspace-registry.js"; export interface WorktreeWorkspaceSource { @@ -8,7 +9,7 @@ export interface WorktreeWorkspaceSource { export async function resolveWorktreeSourceCwd( source: WorktreeWorkspaceSource, - projectRegistry: Pick, + projectRegistry: Pick, ): Promise { if (source.cwd) { return expandTilde(source.cwd); @@ -17,7 +18,7 @@ export async function resolveWorktreeSourceCwd( throw new Error("cwd or projectId is required for a worktree-backed workspace"); } - const project = await projectRegistry.get(source.projectId); + const project = await resolveProjectReference(source.projectId, projectRegistry); if (!project || project.archivedAt) { throw new Error(`Project not found: ${source.projectId}`); }