Keep repositories on their known public forge (#40542)

* fix(git): keep public forge ownership ahead of auth evidence

* test(git): exercise forge admission with complete auth fixtures
This commit is contained in:
Brennan Benson
2026-11-10 15:26:25 -08:00
committed by GitHub
parent 8d45e46743
commit bc1427f6ce
12 changed files with 198 additions and 74 deletions
+9 -8
View File
@@ -1,4 +1,5 @@
import { normalizeForgeWebHost } from '../../../shared/git/remotes/git-remote-host-alias'
import { getPublicForgeProviderForHost } from '../../../shared/git/remotes/public-forge-host'
// Scoped local fork of the hosted-git-info@9.0.3 behavior Orca used.
// Keep only the provider parsing and file/commit URL construction needed by source links.
@@ -23,14 +24,14 @@ const shorthandHosts: Record<string, { host: string; provider: HostedRemoteProvi
function providerForHost(host: string): HostedRemoteHost | null {
const normalized = normalizeForgeWebHost(host)
if (normalized === 'github.com') {
return { host: 'github.com', provider: 'github' }
}
if (normalized === 'gitlab.com') {
return { host: 'gitlab.com', provider: 'gitlab' }
}
if (normalized === 'bitbucket.org') {
return { host: 'bitbucket.org', provider: 'bitbucket' }
const provider = getPublicForgeProviderForHost(normalized)
if (
(normalized === 'github.com' ||
normalized === 'gitlab.com' ||
normalized === 'bitbucket.org') &&
(provider === 'github' || provider === 'gitlab' || provider === 'bitbucket')
) {
return { host: normalized, provider }
}
return null
}
+20
View File
@@ -0,0 +1,20 @@
import { expect, it } from 'vitest'
import { parseGiteaRepoRef } from './repository-ref'
it('keeps Gitea endpoints available without claiming canonical foreign hosts', () => {
for (const remote of [
'git@ssh.github.com:team/repo.git',
'ssh://git@altssh.gitlab.com:443/team/repo.git',
'https://acme.ghe.com/team/repo.git'
]) {
expect(parseGiteaRepoRef(remote)).toBeNull()
}
expect(parseGiteaRepoRef('https://codeberg.org/team/repo.git')).toMatchObject({
owner: 'team',
repo: 'repo'
})
expect(parseGiteaRepoRef('https://git.acme.test/team/repo.git')).toMatchObject({
owner: 'team',
repo: 'repo'
})
})
+3 -13
View File
@@ -1,4 +1,5 @@
import { createRemoteRefProbeCache } from '../git/remotes/remote-ref-probe-cache'
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
export type GiteaRepoRef = {
host: string
@@ -12,14 +13,6 @@ type LocalGitExecOptions = {
wslDistro?: string
}
const KNOWN_NON_GITEA_HOSTS = new Set([
'github.com',
'gitlab.com',
'bitbucket.org',
'ssh.bitbucket.org',
'dev.azure.com',
'ssh.dev.azure.com'
])
const repoRefProbeCache = createRemoteRefProbeCache(parseGiteaRepoRef)
/** @internal - exposed for tests only */
@@ -69,11 +62,8 @@ function apiBaseUrlFromWebBase(webBaseUrl: string): string {
function makeRepoRef(host: string, path: string, webOrigin: string): GiteaRepoRef | null {
const normalizedHost = host.toLowerCase()
if (
!normalizedHost ||
KNOWN_NON_GITEA_HOSTS.has(normalizedHost) ||
normalizedHost.endsWith('.visualstudio.com')
) {
const owner = getPublicForgeProviderForHost(normalizedHost)
if (!normalizedHost || (owner && owner !== 'gitea')) {
return null
}
@@ -70,6 +70,24 @@ describe('getEnterpriseGitHubRepoSlug', () => {
_resetOriginGitHubApiRepositoryCache()
})
it.each([
['https://bitbucket.org/team/orca.git', 'bitbucket.org'],
['ssh://git@altssh.gitlab.com:443/team/orca.git', 'altssh.gitlab.com'],
['https://codeberg.org/team/orca.git', 'codeberg.org']
])('does not promote a foreign public remote from gh inventory: %s', async (url, host) => {
mockOriginRemote(url)
mockHostAuthenticated(host)
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeFalsy()
await expect(isGitHubHostAuthenticated(host, '/repo')).resolves.toBe(false)
})
it('uses the effective SSH hostname before admitting an Enterprise identity', async () => {
mockOriginRemote('git@work-forge:team/orca.git')
resolveWithSshGMock.mockResolvedValue({ hostname: 'bitbucket.org' })
mockHostAuthenticated('bitbucket.org')
await expect(getEnterpriseGitHubRepoSlug('/repo')).resolves.toBeNull()
})
it.each([
'https://ghe.acme.com:8443/team/orca.git',
'https://ghe.acme.com:80/team/orca.git',
@@ -22,6 +22,7 @@ import { parseWslPath } from '../wsl/filesystem/unc-path-access'
import { isStableMissingGitRemoteError } from '../git/remotes/stable-missing-git-remote-error'
import { requireReachableGitRoute } from '../providers/execution-host-provider-dispatch'
import { getConnectionExecutionHostId } from '../../shared/execution-host'
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
@@ -131,6 +132,10 @@ async function resolveAuthenticatedGitHubHost(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null | undefined> {
const owner = getPublicForgeProviderForHost(host)
if (owner && owner !== 'github') {
return null
}
const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase()
const execOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
const cacheKey = `${runtimeCacheKey(repoPath, connectionId, localGitOptions.wslDistro)}\0${normalizedHost}\0${githubReadExecutionScope(execOptions)}`
@@ -1,7 +1,6 @@
import { glabExecFileAsync } from '../git/runner'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
import { addWslEnvKeys } from '../../shared/wsl-env'
import { normalizeForgeWebHost } from '../../shared/git/remotes/git-remote-host-alias'
import { isTransientGitProbeError, readRemoteUrl } from '../git/remotes/remote-url-probe'
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remotes/remote-ref-probe-cache'
import { readLocalGitConfigSignature } from '../github/local-git-config-signature'
@@ -19,7 +18,7 @@ import {
DEFAULT_GITLAB_HOSTS,
normalizeGitLabHost,
parseGitLabProjectRef,
parseRemoteProjectRefCandidate,
parseGitLabProjectRefCandidate,
type ProjectRef
} from './project-ref-parser'
@@ -154,19 +153,12 @@ async function resolveProjectRefForRemote(
if (stdout === null) {
return null
}
// Stray glab host entries for GitHub/Bitbucket hosts must not claim their remotes (#40386).
// Match the real host only: `github:team/project` is an scp remote on an SSH alias named github.
const remoteCandidate = parseRemoteProjectRefCandidate(stdout)
const remoteHost = remoteCandidate && normalizeForgeWebHost(remoteCandidate.host)
if (remoteHost === 'github.com' || remoteHost === 'bitbucket.org') {
publish(null)
return null
}
const result = parseGitLabProjectRef(stdout, knownHosts)
if (result) {
publish(result)
return result
}
const remoteCandidate = parseGitLabProjectRefCandidate(stdout)
if (
remoteCandidate &&
(await isGlabConfiguredForRemoteHost(
+28 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { parseGitLabProjectRef } from './project-ref-parser'
import { parseGitLabProjectRef, parseRemoteProjectRefCandidate } from './project-ref-parser'
describe('gitlab project ref parsing', () => {
it.each([
@@ -43,4 +43,31 @@ describe('gitlab project ref parsing', () => {
parseGitLabProjectRef('http://gitea.example.com:3030/team/api.git', knownHosts)
).toBeNull()
})
it('keeps public ownership authoritative without narrowing generic mirror parsing', () => {
for (const [remote, host] of [
['https://github.com:8443/team/project.git', 'github.com:8443'],
['git@SSH.GITHUB.COM:team/project.git', 'ssh.github.com'],
['git@acme.ghe.com:team/project.git', 'acme.ghe.com'],
['git@ssh.bitbucket.org:team/project.git', 'ssh.bitbucket.org'],
['https://dev.azure.com/acme/project/_git/repo', 'dev.azure.com'],
['git@ssh.dev.azure.com:v3/acme/project/repo', 'ssh.dev.azure.com'],
['https://acme.visualstudio.com/project/_git/repo', 'acme.visualstudio.com'],
['https://codeberg.org/team/project.git', 'codeberg.org']
]) {
expect(parseGitLabProjectRef(remote, [host])).toBeNull()
}
expect(parseRemoteProjectRefCandidate('git@github.com:team/project.git')).toEqual({
host: 'github.com',
path: 'team/project'
})
expect(
parseGitLabProjectRef('https://git.bitbucket.acme.com/team/project.git', [
'git.bitbucket.acme.com'
])
).toEqual({
host: 'git.bitbucket.acme.com',
path: 'team/project'
})
})
})
+15 -28
View File
@@ -1,5 +1,6 @@
import type { GitLabProjectRef } from '../../shared/gitlab-types'
import { normalizeGitLabRemoteHost } from '../../shared/git/remotes/git-remote-host-alias'
import { getPublicForgeProviderForHost } from '../../shared/git/remotes/public-forge-host'
export type ProjectRef = GitLabProjectRef
@@ -47,19 +48,6 @@ function knownHostMatches(urlHost: string, knownHost: string): boolean {
return false
}
function makeProjectRef(
host: string,
path: string,
knownHosts: readonly string[]
): ProjectRef | null {
const normalizedHost = normalizeGitLabRemoteHost(host)
const normalizedKnownHosts = knownHosts.map(normalizeGitLabHost)
if (!normalizedKnownHosts.some((knownHost) => knownHostMatches(normalizedHost, knownHost))) {
return null
}
return makeProjectRefForTrustedHost(normalizedHost, path)
}
export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
@@ -80,25 +68,24 @@ export function parseRemoteProjectRefCandidate(remoteUrl: string): ProjectRef |
}
}
export function parseGitLabProjectRefCandidate(remoteUrl: string): ProjectRef | null {
const candidate = parseRemoteProjectRefCandidate(remoteUrl)
if (!candidate) {
return null
}
const owner = getPublicForgeProviderForHost(candidate.host)
return owner && owner !== 'gitlab' ? null : candidate
}
export function parseGitLabProjectRef(
remoteUrl: string,
knownHosts: readonly string[] = DEFAULT_GITLAB_HOSTS
): ProjectRef | null {
const trimmed = remoteUrl.trim()
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) {
const scpLike = trimmed.match(/^(?:[^@/:]+@)?([^:\s/]+):([^\s]+?)(?:\.git)?$/)
if (scpLike) {
return makeProjectRef(scpLike[1], scpLike[2], knownHosts)
}
}
try {
const url = new URL(trimmed)
if (!['http:', 'https:', 'ssh:', 'git:', 'git+ssh:'].includes(url.protocol.toLowerCase())) {
return null
}
return makeProjectRef(hostIdentityFromUrl(url), url.pathname, knownHosts)
} catch {
const candidate = parseGitLabProjectRefCandidate(remoteUrl)
if (!candidate) {
return null
}
return knownHosts.some((host) => knownHostMatches(candidate.host, normalizeGitLabHost(host)))
? candidate
: null
}
@@ -25,6 +25,8 @@ vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({
}))
import { listMergeRequests } from './merge-request-list'
import { isTaskRepositoryIneligible } from '../git/task-repository-eligibility'
import { rememberGlabKnownHosts } from './gitlab-known-host-probe'
import { listIssues } from './issues'
import { _resetProjectRefCache, _resetKnownHostsCache } from './gitlab-project-ref-resolution'
import { _resetRemoteNameListingCache } from '../git/remotes/remote-name-listing'
@@ -132,4 +134,9 @@ describe('GitLab task project admission', () => {
expect(result.items).toEqual([])
expect(glab.mock.calls.filter(([args]) => args[0] !== 'auth')).toEqual([])
})
it('does not exclude GitHub tasks because glab remembers github.com', async () => {
rememberGlabKnownHosts(['github.com'])
await expect(isTaskRepositoryIneligible('/github', 'github')).resolves.toBe(false)
})
})
@@ -35,11 +35,6 @@ vi.mock('../git/command-runner/gh-exec-file', async (importOriginal) => ({
ghExecFileAsync: ghExecFileAsyncMock
}))
vi.mock('../git/exec-error', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
extractExecError: vi.fn()
}))
vi.mock('../github/client', () => ({
createGitHubPullRequest: vi.fn(),
getRepoSlug: getRepoSlugMock,
@@ -71,6 +66,8 @@ vi.mock('./hosted-review', () => ({
import { _resetKnownHostsCache, _resetProjectRefCache } from '../gitlab/gl-utils'
import { diagnoseAuth } from '../gitlab/client'
import { getHostedReviewCreationEligibility } from './hosted-review-creation'
import { detectHostedReviewProvider } from './forge-provider-detection'
import { rememberGlabKnownHosts } from '../gitlab/gitlab-known-host-probe'
function resetMocks(): void {
for (const mock of [
@@ -167,4 +164,49 @@ describe('GitLab self-hosted hosted review creation eligibility', () => {
blockedReason: null
})
})
it.each(['per-host failure', 'global inventory', 'remembered inventory'])(
'keeps GitHub PR identity across repositories despite glab %s',
async (evidence) => {
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'git@github.com:team/orca.git\n' })
getRepoSlugMock.mockResolvedValue({ owner: 'team', repo: 'orca' })
if (evidence === 'remembered inventory') {
rememberGlabKnownHosts(['github.com'])
}
glabExecFileAsyncMock.mockImplementation(async (args: string[]) => {
if (args.includes('--hostname')) {
// Captured glab 1.115 output for a configured host without a token.
throw Object.assign(new Error('glab auth status failed'), {
stdout: '',
stderr:
'github.com\n x github.com: API call failed\n ! No token found (checked config file, keyring, and environment variables).\n'
})
}
return {
stdout:
evidence === 'global inventory'
? 'github.com\n ✓ Logged in to github.com as example-user (config file)\n'
: 'gitlab.com\n ✓ Logged in to gitlab.com as example-user (config file)\n',
stderr: ''
}
})
for (const repoPath of ['/first-repo', '/second-repo']) {
await expect(
detectHostedReviewProvider({ repoPath, executionHostId: 'local' })
).resolves.toBe('github')
}
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'https://forge.acme.test/team/orca.git\n' })
glabExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('glab auth status failed'), {
stdout: '',
stderr:
'forge.acme.test\n x forge.acme.test: API call failed\n ! No token found (checked config file, keyring, and environment variables).\n'
})
)
await expect(
detectHostedReviewProvider({ repoPath: '/custom', executionHostId: 'local' })
).resolves.toBe('gitlab')
}
)
})
@@ -0,0 +1,25 @@
import { foldWwwHostAlias, normalizeForgeWebHost } from './git-remote-host-alias'
type PublicForgeProvider = 'github' | 'gitlab' | 'bitbucket' | 'azure-devops' | 'gitea'
/** Ownership only: callers retain their original endpoint and persisted host identity. */
export function getPublicForgeProviderForHost(host: string): PublicForgeProvider | null {
const hostname = normalizeForgeWebHost(foldWwwHostAlias(host).replace(/:\d+$/, ''))
if (hostname === 'github.com' || hostname === 'ghe.com' || hostname.endsWith('.ghe.com')) {
return 'github'
}
if (hostname === 'gitlab.com') {
return 'gitlab'
}
if (hostname === 'bitbucket.org') {
return 'bitbucket'
}
if (
hostname === 'dev.azure.com' ||
hostname.endsWith('.dev.azure.com') ||
hostname.endsWith('.visualstudio.com')
) {
return 'azure-devops'
}
return hostname === 'codeberg.org' ? 'gitea' : null
}
@@ -2,6 +2,7 @@ import { parseJiraIssueUrl, JIRA_ISSUE_KEY_PATTERN } from '../jira/jira-issue-ur
import { parseLinearIssueInput, parseLinearIssueUrlIntent } from '../linear/links'
import { getTaskSourceCacheScope } from '../work-items/task-source-context'
import type { WorkspaceAttachment } from '../worktree/types'
import { getPublicForgeProviderForHost } from '../git/remotes/public-forge-host'
export type WorkspaceReferenceQuery =
| { kind: 'url'; identity: string }
@@ -9,13 +10,22 @@ export type WorkspaceReferenceQuery =
| { kind: 'issue-url'; route: string }
| { kind: 'issue-key'; identifier: string }
const HOST_PROVIDERS: Readonly<Record<string, WorkspaceAttachment['provider']>> = {
'github.com': 'github',
'gitlab.com': 'gitlab',
'linear.app': 'linear',
'codeberg.org': 'gitea',
'bitbucket.org': 'bitbucket',
'dev.azure.com': 'azure-devops'
// Keep existing saved-reference URL normalization separate from remote-host ownership.
const PUBLIC_REFERENCE_HOSTS = new Set([
'github.com',
'gitlab.com',
'codeberg.org',
'bitbucket.org',
'dev.azure.com'
])
function referenceHostProvider(host: string): WorkspaceAttachment['provider'] | undefined {
if (host === 'linear.app') {
return 'linear'
}
return PUBLIC_REFERENCE_HOSTS.has(host)
? (getPublicForgeProviderForHost(host) ?? undefined)
: undefined
}
const CASE_INSENSITIVE_PATH_PROVIDERS: ReadonlySet<WorkspaceAttachment['provider']> = new Set([
@@ -37,7 +47,7 @@ function referenceUrl(input: string): URL {
url.search = ''
url.hash = ''
url.pathname = url.pathname.replace(/\/+$/, '')
if (url.hostname.startsWith('www.') && HOST_PROVIDERS[url.hostname.slice(4)]) {
if (url.hostname.startsWith('www.') && referenceHostProvider(url.hostname.slice(4))) {
url.hostname = url.hostname.slice(4)
}
return url
@@ -63,7 +73,7 @@ export function parseWorkspaceReferenceUrl(
providerHint?: WorkspaceAttachment['provider']
): WorkspaceAttachment {
const url = referenceUrl(input)
const knownProvider = HOST_PROVIDERS[url.hostname]
const knownProvider = referenceHostProvider(url.hostname)
const item = parseReferenceRoute(url, knownProvider ?? providerHint)
if (
(knownProvider && item.provider !== knownProvider) ||