mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
* 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
298 lines
11 KiB
TypeScript
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
|
|
)
|
|
}
|