mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
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:
co-authored by
orchardprior
Neil
parent
0acb4933c0
commit
ebd8eff4ce
@@ -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
|
||||
}
|
||||
@@ -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 } : {}
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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<Awaited<ReturnType<typeof listGhAccountBindingInventory>>> {
|
||||
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<Awaited<ReturnType<typeof validateGhAccountBinding>>> {
|
||||
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 } : {})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user