diff --git a/src/main/github/account-binding-required-host.test.ts b/src/main/github/account-binding-required-host.test.ts new file mode 100644 index 00000000000..5d0470317d6 --- /dev/null +++ b/src/main/github/account-binding-required-host.test.ts @@ -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() + } + }) +}) diff --git a/src/main/github/account-binding-required-host.ts b/src/main/github/account-binding-required-host.ts new file mode 100644 index 00000000000..f5ce37dced1 --- /dev/null +++ b/src/main/github/account-binding-required-host.ts @@ -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 { + 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 +} diff --git a/src/main/ipc/github-account-handlers.ts b/src/main/ipc/github-account-handlers.ts index 1b957879811..7d3b7a40625 100644 --- a/src/main/ipc/github-account-handlers.ts +++ b/src/main/ipc/github-account-handlers.ts @@ -1,6 +1,6 @@ import { ipcMain } from 'electron' import { appStarSourceSchema } from '../../shared/gh-star-source' -import { githubHostFromIdentityKey } from '../../shared/github/repository-identity-key' +import { resolveGhAccountRequiredHost } from '../github/account-binding-required-host' import { diagnoseGhAuth } from '../github/auth-diagnose' import { checkOrcaStarred, getAuthenticatedViewer, starOrca } from '../github/client' import { @@ -38,10 +38,11 @@ export function registerGitHubAccountHandlers(store: Store): void { ipcMain.handle( 'gh:listBindableAccounts', - (_event, args: { repoPath: string; repoId?: string; refreshCapability?: boolean }) => { + async (_event, args: { repoPath: string; repoId?: string; refreshCapability?: boolean }) => { const repo = assertRegisteredGitHubRepo(args, store) - const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) - return listGhAccountBindingInventory(getGitHubLocalGitOptionArgs(store, repo)[0] ?? {}, { + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] ?? {} + const requiredHost = await resolveGhAccountRequiredHost(repo, localGitOptions) + return listGhAccountBindingInventory(localGitOptions, { refreshCapability: args.refreshCapability === true, ...(requiredHost ? { requiredHost } : {}) }) @@ -50,12 +51,13 @@ export function registerGitHubAccountHandlers(store: Store): void { ipcMain.handle( 'gh:validateAccountBinding', - (_event, args: { repoPath: string; repoId?: string; host: string; user: string }) => { + async (_event, args: { repoPath: string; repoId?: string; host: string; user: string }) => { const repo = assertRegisteredGitHubRepo(args, store) - const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) + const localGitOptions = getGitHubLocalGitOptionArgs(store, repo)[0] ?? {} + const requiredHost = await resolveGhAccountRequiredHost(repo, localGitOptions) return validateGhAccountBinding( { host: args.host, user: args.user }, - getGitHubLocalGitOptionArgs(store, repo)[0] ?? {}, + localGitOptions, requiredHost ? { requiredHost } : {} ) } diff --git a/src/main/runtime/runtime-github-repository-query-commands.ts b/src/main/runtime/runtime-github-repository-query-commands.ts index 53f1edc8364..fd1e950d15c 100644 --- a/src/main/runtime/runtime-github-repository-query-commands.ts +++ b/src/main/runtime/runtime-github-repository-query-commands.ts @@ -16,7 +16,7 @@ import { validateGhAccountBinding } from '../github/gh-account-binding-inventory' import { getRateLimit } from '../github/rate-limit' -import { githubHostFromIdentityKey } from '../../shared/github/repository-identity-key' +import { resolveGhAccountRequiredHost } from '../github/account-binding-required-host' import { getWorkItemDetails } from '../github/work-item-details' type LocalGitArgs = [] | [LocalProjectGhExecOptions] @@ -157,8 +157,9 @@ export class RuntimeGitHubRepositoryQueryCommands { options?: { refreshCapability?: boolean } ): Promise>> { const repo = await this.deps.resolveRepo(repoSelector) - const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) - return listGhAccountBindingInventory(this.deps.getLocalGitArgs(repo)[0] ?? {}, { + const localGitOptions = this.deps.getLocalGitArgs(repo)[0] ?? {} + const requiredHost = await resolveGhAccountRequiredHost(repo, localGitOptions) + return listGhAccountBindingInventory(localGitOptions, { refreshCapability: options?.refreshCapability === true, ...(requiredHost ? { requiredHost } : {}) }) @@ -169,11 +170,8 @@ export class RuntimeGitHubRepositoryQueryCommands { binding: { host: string; user: string } ): Promise>> { const repo = await this.deps.resolveRepo(repoSelector) - const requiredHost = githubHostFromIdentityKey(repo.gitRemoteIdentity?.canonicalKey) - return validateGhAccountBinding( - binding, - this.deps.getLocalGitArgs(repo)[0] ?? {}, - requiredHost ? { requiredHost } : {} - ) + const localGitOptions = this.deps.getLocalGitArgs(repo)[0] ?? {} + const requiredHost = await resolveGhAccountRequiredHost(repo, localGitOptions) + return validateGhAccountBinding(binding, localGitOptions, requiredHost ? { requiredHost } : {}) } }