fix(github): resolve SSH host aliases for account binding (#29018)

* fix(github): resolve SSH host aliases for account binding

An origin like git@github-personal:owner/repo kept the alias as the
required gh host, so the Settings account list matched nothing.

* fix(github): keep github.com filter for resolved aliases and resolve in the gh runtime

Real canonical keys carry github.com, so an alias resolving to github.com
must filter to github.com like a direct remote. Resolve via the runtime-scoped
resolveSshConfigHostname (WSL distro aware) and keep Enterprise hosts exact.

* fix(github): expand SSH aliases only for SSH-transport remotes

* fix(github): resolve the SSH alias with its original case

---------

Co-authored-by: orchardprior <342342873+orchardprior@users.noreply.github.com>
Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
orchardprior
2026-10-15 23:11:42 -07:00
committed by GitHub
co-authored by orchardprior Neil
parent 0acb4933c0
commit ebd8eff4ce
4 changed files with 203 additions and 16 deletions
@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest'
import { normalizeGitRemoteUrl } from '../../shared/git-remote-identity'
import { resolveGhAccountRequiredHost } from './account-binding-required-host'
function repoFor(remoteUrl: string) {
return {
path: '/repos/sample-app',
gitRemoteIdentity: {
remoteName: 'origin',
remoteUrl,
canonicalKey: normalizeGitRemoteUrl(remoteUrl) ?? ''
}
}
}
describe('resolveGhAccountRequiredHost', () => {
it('keeps a github.com remote filtered to github.com', async () => {
const resolveAlias = vi.fn()
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github.com:example/sample-app.git'),
{},
resolveAlias
)
).resolves.toBe('github.com')
expect(resolveAlias).not.toHaveBeenCalled()
})
it('keeps a real enterprise host without resolving an alias', async () => {
const resolveAlias = vi.fn()
await expect(
resolveGhAccountRequiredHost(
repoFor('git@ghe.example.com:example/sample-app.git'),
{},
resolveAlias
)
).resolves.toBe('ghe.example.com')
expect(resolveAlias).not.toHaveBeenCalled()
})
it('filters an alias that resolves to github.com the same as a direct github.com remote', async () => {
const resolveAlias = vi.fn(async () => ({ hostname: 'github.com' }))
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github-personal:example/sample-app.git'),
{},
resolveAlias
)
).resolves.toBe('github.com')
expect(resolveAlias).toHaveBeenCalledWith('github-personal', {
repoPath: '/repos/sample-app',
connectionId: null
})
})
it('folds ssh.github.com and www.github.com to github.com', async () => {
for (const hostname of ['ssh.github.com', 'www.github.com']) {
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github-work:example/sample-app.git'),
{},
async () => ({ hostname })
)
).resolves.toBe('github.com')
}
})
it('keeps the exact resolved enterprise hostname, including a www prefix', async () => {
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github-work:example/sample-app.git'),
{},
async () => ({ hostname: 'GHE.Example.com' })
)
).resolves.toBe('ghe.example.com')
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github-work:example/sample-app.git'),
{},
async () => ({ hostname: 'www.ghe.example.com' })
)
).resolves.toBe('www.ghe.example.com')
})
it('resolves the alias in the WSL distro that runs gh', async () => {
const resolveAlias = vi.fn(async () => ({ hostname: 'github.com' }))
await resolveGhAccountRequiredHost(
repoFor('git@github-personal:example/sample-app.git'),
{ wslDistro: 'Ubuntu' },
resolveAlias
)
expect(resolveAlias).toHaveBeenCalledWith('github-personal', {
repoPath: '/repos/sample-app',
connectionId: null,
wslDistro: 'Ubuntu'
})
})
it('resolves the alias with its original case', async () => {
const resolveAlias = vi.fn(async () => ({ hostname: 'github.com' }))
await resolveGhAccountRequiredHost(
repoFor('git@GitHub-Work:example/sample-app.git'),
{},
resolveAlias
)
expect(resolveAlias).toHaveBeenCalledWith('GitHub-Work', expect.anything())
})
it('keeps a dotless HTTPS host literal without consulting ssh config', async () => {
const resolveAlias = vi.fn(async () => ({ hostname: 'elsewhere.example.com' }))
await expect(
resolveGhAccountRequiredHost(repoFor('https://ghe/example/sample-app.git'), {}, resolveAlias)
).resolves.toBe('ghe')
expect(resolveAlias).not.toHaveBeenCalled()
})
it('does not filter when the alias cannot be resolved', async () => {
for (const resolveAlias of [
async () => ({ hostname: null }),
async () => ({ hostname: 'github-personal' }),
async () => {
throw new Error('ssh -G failed')
}
]) {
await expect(
resolveGhAccountRequiredHost(
repoFor('git@github-personal:example/sample-app.git'),
{},
resolveAlias
)
).resolves.toBeUndefined()
}
})
})
@@ -0,0 +1,53 @@
import {
isUnresolvedSshHostAlias,
foldComparableGitHubHost
} from '../../shared/git-remote-host-alias'
import { githubHostFromIdentityKey } from '../../shared/github/repository-identity-key'
import { rawSshTransportHost, remoteUrlUsesSshTransport } from './github-remote-identity-parsing'
import {
resolveSshConfigHostname,
type SshConfigResolutionContext
} from './github-ssh-host-alias-resolution'
export type SshAliasHostnameResolver = (
alias: string,
context: SshConfigResolutionContext
) => Promise<{ hostname: string | null }>
/**
* Host filter for Settings GitHub account binding.
* `undefined` means do not filter.
*/
export async function resolveGhAccountRequiredHost(
repo: {
path: string
connectionId?: string | null
gitRemoteIdentity?: { canonicalKey: string; remoteUrl: string } | null
},
localGitOptions: { wslDistro?: string },
resolveAlias: SshAliasHostnameResolver = resolveSshConfigHostname
): Promise<string | undefined> {
const host = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey)
// Why: only SSH transport reads ~/.ssh/config; a dotless HTTPS host is literal.
const remoteUrl = repo.gitRemoteIdentity?.remoteUrl ?? ''
if (!host || !isUnresolvedSshHostAlias(host) || !remoteUrlUsesSshTransport(remoteUrl)) {
return host
}
// Why: `git@github-personal:owner/repo` keeps the OpenSSH alias in the identity key.
// No gh login is named after that alias, so filtering to it empties the dropdown.
// Resolve in the runtime that runs gh (WSL distro vs host) so both see the same ssh config.
const context: SshConfigResolutionContext = {
repoPath: repo.path,
connectionId: repo.connectionId ?? null,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
}
// Why raw: the canonical key is lowercased, but OpenSSH matches Host patterns case-sensitively.
const alias = rawSshTransportHost(remoteUrl) ?? host
const resolved = await resolveAlias(alias, context).catch(() => null)
const hostname = resolved?.hostname?.trim().toLowerCase()
if (!hostname || isUnresolvedSshHostAlias(hostname)) {
return undefined
}
// Why: only github.com's SSH/www aliases fold; gh keys an Enterprise login by its exact host.
return foldComparableGitHubHost(hostname) === 'github.com' ? 'github.com' : hostname
}