diff --git a/src/main/git/remotes/hosted-remote-url.ts b/src/main/git/remotes/hosted-remote-url.ts index 9f10984fa05..5361b6ffac4 100644 --- a/src/main/git/remotes/hosted-remote-url.ts +++ b/src/main/git/remotes/hosted-remote-url.ts @@ -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 { + 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' + }) +}) diff --git a/src/main/gitea/repository-ref.ts b/src/main/gitea/repository-ref.ts index 89162701eda..d5ed49c716b 100644 --- a/src/main/gitea/repository-ref.ts +++ b/src/main/gitea/repository-ref.ts @@ -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 } diff --git a/src/main/github/github-enterprise-repository.test.ts b/src/main/github/github-enterprise-repository.test.ts index 99885cc2254..6a97404c2b7 100644 --- a/src/main/github/github-enterprise-repository.test.ts +++ b/src/main/github/github-enterprise-repository.test.ts @@ -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', diff --git a/src/main/github/github-enterprise-repository.ts b/src/main/github/github-enterprise-repository.ts index a0d590a6fb7..89a50c28ef2 100644 --- a/src/main/github/github-enterprise-repository.ts +++ b/src/main/github/github-enterprise-repository.ts @@ -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 { + 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)}` diff --git a/src/main/gitlab/gitlab-project-ref-resolution.ts b/src/main/gitlab/gitlab-project-ref-resolution.ts index 3e3b57a90df..90d69bf6052 100644 --- a/src/main/gitlab/gitlab-project-ref-resolution.ts +++ b/src/main/gitlab/gitlab-project-ref-resolution.ts @@ -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( diff --git a/src/main/gitlab/project-ref-parser.test.ts b/src/main/gitlab/project-ref-parser.test.ts index 6550aa5e402..c83ef13fe26 100644 --- a/src/main/gitlab/project-ref-parser.test.ts +++ b/src/main/gitlab/project-ref-parser.test.ts @@ -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' + }) + }) }) diff --git a/src/main/gitlab/project-ref-parser.ts b/src/main/gitlab/project-ref-parser.ts index 051fdf1d062..96f083a6783 100644 --- a/src/main/gitlab/project-ref-parser.ts +++ b/src/main/gitlab/project-ref-parser.ts @@ -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 } diff --git a/src/main/gitlab/task-project-eligibility.test.ts b/src/main/gitlab/task-project-eligibility.test.ts index 894a356e3d7..1844b499abb 100644 --- a/src/main/gitlab/task-project-eligibility.test.ts +++ b/src/main/gitlab/task-project-eligibility.test.ts @@ -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) + }) }) diff --git a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts index bdd3a654baa..ab4d4387e4e 100644 --- a/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts +++ b/src/main/source-control/hosted-review-creation-gitlab-self-hosted.test.ts @@ -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>()), - 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') + } + ) }) diff --git a/src/shared/git/remotes/public-forge-host.ts b/src/shared/git/remotes/public-forge-host.ts new file mode 100644 index 00000000000..800f9430025 --- /dev/null +++ b/src/shared/git/remotes/public-forge-host.ts @@ -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 +} diff --git a/src/shared/workspace-attachments/workspace-reference-identity.ts b/src/shared/workspace-attachments/workspace-reference-identity.ts index 179e355c7fd..408a047d1e1 100644 --- a/src/shared/workspace-attachments/workspace-reference-identity.ts +++ b/src/shared/workspace-attachments/workspace-reference-identity.ts @@ -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> = { - '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 = 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) ||