Files
orca/src/main/github/github-enterprise-repository.ts
T
Brennan Benson 8e9b5c908c fix(github): fail closed instead of running client git against a remote repoPath when the SSH provider is unregistered (#14945)
* fix(github): fail closed when the SSH git provider is gone

getCurrentHeadOid and probeTrackedUpstreamBranches only routed through the
SSH provider when one was registered. With connectionId set but the provider
unregistered (dropped connection, not yet reattached) they fell through to
client-side git with cwd pointing at the remote repoPath — on a machine with
a same-named local path that silently answers for the wrong repository.

getCurrentHeadOid feeds shouldHideMergedImplicitPR, so a wrong OID changes
which PR the UI attributes to a worktree.

Both now take their existing unknown path (null / probeFailed) instead,
matching repo-default-branch.ts. Local and WSL routing is unchanged.

* fix(github): preserve PR state when SSH probes fail

* fix(github): keep failed SSH discovery unverifiable

* fix(github): propagate SSH identity failures

* fix(github): scope verified SSH identity probes

* test(github): preserve tolerant resolver calls

* fix(github): preserve indeterminate auth discovery

* fix(github): isolate SSH repository probe generations

* test(github): expose SSH probe generation in mocks
2026-08-17 00:12:14 -07:00

298 lines
11 KiB
TypeScript

import { ghExecFileAsync } from '../git/runner'
import type { GitHubOwnerRepo } from '../../shared/github/pull-request-types'
import {
getHostedReviewLocalGitOptions,
type HostedReviewExecutionOptions
} from '../source-control/hosted-review-git-options'
import { parseAuthStatus } from './auth-diagnose'
import {
ghRepoExecOptions,
getRemoteUrlForRepo,
githubRepoContext,
parseGitHubRemoteIdentity,
type LocalGitExecOptions
} from './github-repository-identity'
import {
effectiveGitHubRemoteHost,
gitHubSshConfigHostAlias
} from './github-remote-identity-parsing'
import { resolveSshConfigHostname } from './github-ssh-host-alias-resolution'
import { parseWslPath } from '../wsl'
import {
getSshGitProvider,
SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE
} from '../providers/ssh-git-dispatch'
import { isStableMissingGitRemoteError } from '../git/stable-missing-git-remote-error'
export type GitHubEnterpriseRepoSlug = GitHubOwnerRepo & { host: string }
// Why: `gh` only ever manages github.com / GitHub Enterprise credentials, so a
// host `gh auth status` reports as logged-in is definitively a GitHub host. This
// mirrors the `glab auth status` signal GitLab self-hosted detection uses, so a
// GHES remote is not left to fall through to Gitea (#8312).
const HOST_AUTH_TTL_MS = 60_000
const HOST_AUTH_CACHE_MAX_ENTRIES = 512
type HostAuthCacheEntry = {
authenticatedHost: string | null
expiresAt: number
}
const hostAuthCache = new Map<string, HostAuthCacheEntry>()
const hostAuthInFlight = new Map<string, Promise<string | null | undefined>>()
// Why: connection-backed Git operations execute remotely, but gh intentionally
// executes on the native host. Only WSL selects a distinct gh config/runtime.
function runtimeCacheKey(repoPath: string, wslDistro?: string): string {
const resolvedDistro = wslDistro ?? parseWslPath(repoPath)?.distro
return `local:${resolvedDistro?.toLowerCase() ?? 'host'}`
}
/** @internal - exposed for tests only */
export function _resetGitHubHostAuthCache(): void {
hostAuthCache.clear()
hostAuthInFlight.clear()
}
function pruneHostAuthCache(now: number): void {
for (const [key, entry] of hostAuthCache) {
if (entry.expiresAt <= now) {
hostAuthCache.delete(key)
}
}
while (hostAuthCache.size > HOST_AUTH_CACHE_MAX_ENTRIES) {
const oldestKey = hostAuthCache.keys().next().value
if (oldestKey === undefined) {
return
}
hostAuthCache.delete(oldestKey)
}
}
// Only gh's own stdout/stderr — not the Error.message — counts as an
// authoritative answer. A spawn failure (gh missing, ENOENT) carries just a
// message and no command output, and must stay indeterminate rather than be
// read as "host not authenticated".
function ghCommandOutput(error: unknown): string {
const execErr = error as { stdout?: unknown; stderr?: unknown }
return [execErr?.stdout, execErr?.stderr]
.filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
.join('\n')
}
type NormalizedGitHubHost = {
hostname: string
port: string | null
authority: string
}
function normalizeGitHubHost(host: string): NormalizedGitHubHost | null {
const match = host
.trim()
.toLowerCase()
.match(/^([a-z0-9][a-z0-9.-]*)(?::(\d+))?$/i)
if (!match) {
return null
}
const hostname = match[1]
// Why: remote URL parsing already removes protocol-default ports; any port left here identifies the endpoint.
const port = match[2] ?? null
return { hostname, port, authority: port ? `${hostname}:${port}` : hostname }
}
function authenticatedHostFromInventory(host: string, output: string): string | null {
const requested = normalizeGitHubHost(host)
if (!requested) {
return null
}
const inventory = Array.from(
new Map(
parseAuthStatus(output)
.map((account) => normalizeGitHubHost(account.host))
.filter((candidate): candidate is NormalizedGitHubHost => candidate !== null)
.map((candidate) => [candidate.authority, candidate])
).values()
)
const exact = inventory.find((candidate) => candidate.authority === requested.authority)
if (exact) {
return exact.authority
}
// Why: a non-default web port identifies the API endpoint; portless credentials target a different server.
if (requested.port) {
return null
}
const compatible = inventory.filter((candidate) => candidate.hostname === requested.hostname)
// Why: an SSH remote has no API port. Only a unique auth-inventory host can
// safely supply it; multiple ported endpoints on one hostname are ambiguous.
return compatible.length === 1 ? compatible[0].authority : null
}
async function resolveAuthenticatedGitHubHost(
host: string,
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<string | null | undefined> {
const normalizedHost = normalizeGitHubHost(host)?.authority ?? host.trim().toLowerCase()
const cacheKey = `${runtimeCacheKey(repoPath, localGitOptions.wslDistro)}\0${normalizedHost}`
const now = Date.now()
pruneHostAuthCache(now)
const cached = hostAuthCache.get(cacheKey)
if (cached && cached.expiresAt > now) {
return cached.authenticatedHost
}
const inFlight = hostAuthInFlight.get(cacheKey)
if (inFlight) {
return inFlight
}
// Why: provider detection and review loading can probe the same runtime at
// once; coalesce them so one host never spawns duplicate auth subprocesses.
const probe = (async () => {
const execOptions = {
...ghRepoExecOptions(githubRepoContext(repoPath, connectionId, localGitOptions))
}
let authenticatedHost: string | null
try {
const { stdout, stderr } = await ghExecFileAsync(['auth', 'status'], execOptions)
authenticatedHost = authenticatedHostFromInventory(host, `${stdout}\n${stderr}`)
} catch (error) {
const output = ghCommandOutput(error)
if (!output) {
// Indeterminate (gh missing / spawn failure) — do not cache so a later
// probe (gh installed, tunnel ready, token added) can recover.
return undefined
}
// gh exits non-zero when a host has a token problem but still prints the
// per-host status; trust only hosts that are actually listed.
authenticatedHost = authenticatedHostFromInventory(host, output)
}
hostAuthCache.set(cacheKey, {
authenticatedHost,
expiresAt: Date.now() + HOST_AUTH_TTL_MS
})
pruneHostAuthCache(Date.now())
return authenticatedHost
})()
hostAuthInFlight.set(cacheKey, probe)
try {
return await probe
} finally {
if (hostAuthInFlight.get(cacheKey) === probe) {
hostAuthInFlight.delete(cacheKey)
}
}
}
/**
* Whether `gh` is authenticated to `host` from the repository's own runtime.
*
* The probe inventories gh's configured hosts and matches `host` locally. It
* deliberately does not pass an untrusted remote host to gh because ambient
* enterprise tokens could otherwise be sent to that host during validation.
* Cached briefly per runtime+host so provider-detection polling stays cheap.
*/
export async function isGitHubHostAuthenticated(
host: string,
repoPath: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<boolean> {
return Boolean(
await resolveAuthenticatedGitHubHost(host, repoPath, connectionId, localGitOptions)
)
}
/** Safely validate a project-selected host without giving the untrusted host
* to gh. Global project calls have no repository cwd, so they use native gh. */
export function isGitHubHostAuthenticatedForGlobalCli(host: string): Promise<boolean> {
return isGitHubHostAuthenticated(host, '', 'project-host-validation')
}
/**
* Resolve owner/repo for a GitHub Enterprise Server remote — a custom host the
* user is gh-authenticated to. Returns null for github.com (already handled by
* {@link getOwnerRepo}) and for hosts gh is not logged in to
* (Gitea/Forgejo/self-hosted GitLab/etc.), so GHES routes to the GitHub provider
* without a GitHub provider stealing another forge's remote.
*/
export async function getEnterpriseGitHubRepoSlugForRemote(
repoPath: string,
remoteName: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {},
requireVerifiedSshProbe = false
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
const localGitOptions = getHostedReviewLocalGitOptions(options)
const context = githubRepoContext(repoPath, connectionId, localGitOptions)
if (requireVerifiedSshProbe && connectionId && !getSshGitProvider(connectionId)) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
let remoteUrl: string | null
try {
remoteUrl = await getRemoteUrlForRepo(context, remoteName)
} catch (error) {
if (requireVerifiedSshProbe && connectionId && !isStableMissingGitRemoteError(error)) {
throw error
}
return null
}
if (requireVerifiedSshProbe && connectionId && !remoteUrl && !getSshGitProvider(connectionId)) {
throw new Error(SSH_GIT_PROVIDER_UNAVAILABLE_MESSAGE)
}
const identity = remoteUrl ? parseGitHubRemoteIdentity(remoteUrl) : null
if (!identity) {
return null
}
// Why: GHES routing needs the effective host behind an SSH alias.
let effectiveHost = identity.host
const aliasHost = remoteUrl ? gitHubSshConfigHostAlias(remoteUrl) : null
if (aliasHost) {
const { hostname, resolved } = await resolveSshConfigHostname(aliasHost, context)
if (!resolved || !hostname) {
if (requireVerifiedSshProbe && connectionId) {
throw new Error('Remote repository identity is unverifiable.')
}
const authenticatedLiteralHost = await resolveAuthenticatedGitHubHost(
identity.host,
repoPath,
connectionId,
localGitOptions
)
return authenticatedLiteralHost
? { owner: identity.owner, repo: identity.repo, host: authenticatedLiteralHost }
: undefined
}
effectiveHost = effectiveGitHubRemoteHost(identity.host, hostname)
}
if (effectiveHost === 'github.com') {
return null
}
const authenticatedHost = await resolveAuthenticatedGitHubHost(
effectiveHost,
repoPath,
connectionId,
localGitOptions
)
if (authenticatedHost === undefined) {
return undefined
}
return authenticatedHost
? { owner: identity.owner, repo: identity.repo, host: authenticatedHost }
: null
}
export async function getEnterpriseGitHubRepoSlug(
repoPath: string,
connectionId?: string | null,
options: HostedReviewExecutionOptions = {},
requireVerifiedSshProbe = false
): Promise<GitHubEnterpriseRepoSlug | null | undefined> {
return getEnterpriseGitHubRepoSlugForRemote(
repoPath,
'origin',
connectionId,
options,
requireVerifiedSshProbe
)
}