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.
This commit is contained in:
Peter Steinberger
2026-08-19 09:32:21 -07:00
committed by GitHub
parent 543b4a85cf
commit 16ff0dd699
2 changed files with 39 additions and 29 deletions
@@ -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);
});
+15 -29
View File
@@ -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();
}