From 16ff0dd699d5d239e14e77f3427949e7596670bb Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 19 Aug 2026 09:32:21 -0700 Subject: [PATCH] fix(config): reuse canonical git URL parser for cloud project identity (#126343) normalizeCloudRepo hand-rolled an scp-form regex that also matches scheme URLs, so ssh://git@host:22/owner/repo put the port in the repository path (github.com/22/owner/repo). Operators with a ported ssh remote silently lost their cloudWorkers.projectProfiles mapping: dispatch fell back to no-mapping behavior with no visible outcome. Delegate to parseGitUrl, the parser project memory scope already uses for the same host/path identity derivation, which folds userinfo, ports, and scp-vs-scheme forms and rejects traversal. Only the config-specific lowercase folding stays local. --- .../cloud-worker-project-profiles.test.ts | 24 ++++++++++ src/config/cloud-worker-project-profiles.ts | 44 +++++++------------ 2 files changed, 39 insertions(+), 29 deletions(-) diff --git a/src/config/cloud-worker-project-profiles.test.ts b/src/config/cloud-worker-project-profiles.test.ts index d9b712eba9ec..037b3c9cc4cd 100644 --- a/src/config/cloud-worker-project-profiles.test.ts +++ b/src/config/cloud-worker-project-profiles.test.ts @@ -9,6 +9,30 @@ describe("normalizeCloudRepo", () => { ["trailing .git", "https://github.com/acme/app.git", "github.com/acme/app"], ["missing owner and repo", "https://github.com", undefined], ["missing repo", "https://github.com/acme", undefined], + ["ssh scheme origin", "ssh://git@github.com/acme/app.git", "github.com/acme/app"], + [ + "ssh scheme origin with default port", + "ssh://git@github.com:22/acme/app.git", + "github.com/acme/app", + ], + [ + "ssh scheme origin with custom port", + "ssh://git@github.com:2222/acme/app.git", + "github.com/acme/app", + ], + [ + "https origin with userinfo", + "https://user:token@github.com/acme/app.git", + "github.com/acme/app", + ], + [ + "self-hosted nested path", + "https://gitlab.example.com/group/sub/app.git", + "gitlab.example.com/group/sub/app", + ], + ["path traversal", "https://github.com/acme/../app.git", undefined], + ["unsupported scheme", "file:///tmp/repo.git", undefined], + ["empty origin", " ", undefined], ])("normalizes %s", (_label, originUrl, expected) => { expect(normalizeCloudRepo(originUrl)).toBe(expected); }); diff --git a/src/config/cloud-worker-project-profiles.ts b/src/config/cloud-worker-project-profiles.ts index 6a332407c1b6..914a1c64ac52 100644 --- a/src/config/cloud-worker-project-profiles.ts +++ b/src/config/cloud-worker-project-profiles.ts @@ -1,40 +1,26 @@ // Normalizes repository remotes for cloud-worker project profile selection. +import { parseGitUrl } from "../agents/utils/git.js"; + /** Normalize a Git origin URL to a lowercase host/path repository identity. */ export function normalizeCloudRepo(originUrl: string): string | undefined { const value = originUrl.trim(); if (!value) { return undefined; } - - let host: string; - let repoPath: string; - const scpLike = value.match(/^[^@\s]+@([^:\s]+):(.+)$/u); - if (scpLike) { - host = scpLike[1] ?? ""; - repoPath = scpLike[2] ?? ""; - } else { - let parsed: URL; - try { - parsed = new URL(value); - } catch { - return undefined; - } - if (!["git:", "http:", "https:", "ssh:"].includes(parsed.protocol)) { - return undefined; - } - host = parsed.hostname; - repoPath = parsed.pathname; - } - - const normalizedPath = repoPath.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, ""); - const segments = normalizedPath.split("/"); - if ( - !host || - segments.length < 2 || - segments.some((segment) => !segment || segment === "." || segment === "..") - ) { + // The canonical parser owns remote-form handling: scp-vs-scheme detection, userinfo, + // ports, and `.git`/traversal rules. A local regex mis-parses `ssh://git@host:22/o/r` + // into the path and silently drops the operator's mapping. + const source = parseGitUrl(`git:${value}`); + if (!source) { return undefined; } - return `${host}/${segments.join("/")}`.toLowerCase(); + const segments = source.path.split("/").filter(Boolean); + if (!source.host || segments.length < 2) { + return undefined; + } + // Unlike project memory scope, the whole key folds to lowercase: these are + // operator-typed config keys selecting a machine profile, so a casing variant must + // not silently miss its mapping. A case-only collision still selects one profile. + return `${source.host}/${segments.join("/")}`.toLowerCase(); }