Files
orca/src/main/gitlab/gitlab-known-host-probe.ts
T
OrcaWinandm4air 4085e1cf60 fix(memory): release stale session registries (#21734)
* fix(memory): bound session and lifecycle registries

* fix(memory): bound transient filesystem registries

* fix(memory): cap path and locale caches

* fix(memory): bound runtime recovery registries

* fix(memory): bound host mirror gap verdicts

* fix(memory): bound shell startup env cache

* fix(memory): bound gitlab host context cache

* fix(memory): release removed ssh generations

* fix(memory): expire cloud refresh replay guards

* fix(memory): release retired plugin generations

* fix(memory): bound plugin log key retention

* fix(memory): bound automation authority generations

* fix(memory): bound native chat enrichment cache

* fix(memory): bound web session tracking generations

* fix(memory): bound codex credential absence paths

* fix(memory): bound WSL canonical path cache

* fix(memory): bound sparse checkout cache

* fix(memory): bound shared directory cache

* fix(memory): bound advertised URL scan snapshots

* fix(memory): bound automation manager cache

* fix(memory): bound web session reorder intents

* fix(memory): bound web session focus intents

* fix(memory): bound web session handoffs

* fix(memory): bound automation dispatch tokens

* fix(memory): bound host mirror waiters

* fix(memory): bound retained session activity

* fix(memory): bound retained session activity

* fix(memory): bound web session close intents

* fix(memory): bound cloud session cache

* fix(memory): bound WSL home cache

* fix(memory): bound SSH capability cache

* fix(memory): bound trust grant cooldowns

* fix(memory): bound WSL auth drain state

* fix(memory): bound Linear workspace credential cache

* fix(memory): bound local Git capability cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL Git environment cache

* fix(memory): bound WSL preflight cache

* fix(memory): keep hot cache entries warm

* fix(memory): preserve generation fences across eviction

* fix(memory): close remaining eviction fences

* fix(memory): align evicted upstream generations

* fix(memory): trim successful capability probes

* fix(auth): retain expired refresh replay evidence

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
2026-09-20 14:41:50 -07:00

237 lines
8.6 KiB
TypeScript

import { runCoalescedProbe, type CoalescedProbes } from '../git/coalesced-probe'
import { NEGATIVE_ENTRY_TTL_MS } from '../git/remote-ref-probe-cache'
import { glabExecFileAsync } from '../git/runner'
import { getSshGitProviderGeneration } from '../providers/ssh-git-dispatch'
import { DEFAULT_GITLAB_HOSTS, normalizeGitLabHost } from './project-ref-parser'
import type { GitAdmissionTier } from '../git/command-runner/git-exec-options'
export type LocalGitExecOptions = {
wslDistro?: string
admissionTier?: GitAdmissionTier
}
const GLAB_KNOWN_HOSTS_TIMEOUT_MS = 10_000
const UNAUTHENTICATED_HOSTS_MAX_ENTRIES = 128
export const KNOWN_HOSTS_CACHE_MAX_ENTRIES = 128
const knownHostsCacheByExecutionContext = new Map<
string,
{ key: string; hosts: readonly string[] }
>()
const knownHostsInFlightByExecutionContext: CoalescedProbes<readonly string[]> = new Map()
const unauthenticatedHostExpiries = new Map<string, number>()
function knownHostsExecutionKey(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): string {
if (connectionId) {
// Why: reconnecting can replace the SSH/relay execution host under the same id.
return `connection:${connectionId}:${getSshGitProviderGeneration(connectionId)}`
}
return localGitOptions.wslDistro ? `wsl:${localGitOptions.wslDistro}` : 'native'
}
function knownHostsCacheContext(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): { key: string; cacheKey: string } {
const key = knownHostsExecutionKey(connectionId, localGitOptions)
const cacheKey = connectionId ? `connection:${connectionId}` : key
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
if (cached && cached.key !== key) {
knownHostsCacheByExecutionContext.delete(cacheKey)
}
return { key, cacheKey }
}
/** @internal - exposed for tests only */
export function _resetKnownHostsCache(): void {
knownHostsCacheByExecutionContext.clear()
knownHostsInFlightByExecutionContext.clear()
unauthenticatedHostExpiries.clear()
}
/** @internal - exposed for tests only */
export function _resetGlabUnauthenticatedHosts(): void {
unauthenticatedHostExpiries.clear()
}
/** @internal - exposed for cache-bound tests only. */
export function _getKnownHostsCacheSize(): number {
return knownHostsCacheByExecutionContext.size
}
function trimKnownHostsCache(): void {
while (knownHostsCacheByExecutionContext.size > KNOWN_HOSTS_CACHE_MAX_ENTRIES) {
const oldest = knownHostsCacheByExecutionContext.keys().next()
if (oldest.done) {
break
}
knownHostsCacheByExecutionContext.delete(oldest.value)
}
}
function unauthenticatedHostKey(
host: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): string {
return `${knownHostsExecutionKey(connectionId, localGitOptions)}\0${normalizeGitLabHost(host)}`
}
/**
* Why: `glab auth status --hostname` is how a self-hosted instance that plain
* `glab auth status` did not list gets discovered, so a remote that is not
* GitLab at all runs it too. Project-ref negatives expire now, and without this
* that becomes one `glab` spawn per repo per interval on the hosted-review poll.
* The answer is per host, not per repo, and expires on the same clock so a
* login still lands within an interval.
*/
export function isGlabHostKnownUnauthenticated(
host: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): boolean {
const key = unauthenticatedHostKey(host, connectionId, localGitOptions)
const expiresAt = unauthenticatedHostExpiries.get(key)
if (expiresAt === undefined) {
return false
}
if (expiresAt > Date.now()) {
return true
}
unauthenticatedHostExpiries.delete(key)
return false
}
export function rememberGlabHostUnauthenticated(
host: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): void {
unauthenticatedHostExpiries.set(
unauthenticatedHostKey(host, connectionId, localGitOptions),
Date.now() + NEGATIVE_ENTRY_TTL_MS
)
while (unauthenticatedHostExpiries.size > UNAUTHENTICATED_HOSTS_MAX_ENTRIES) {
const oldestKey = unauthenticatedHostExpiries.keys().next().value
if (oldestKey === undefined) {
return
}
unauthenticatedHostExpiries.delete(oldestKey)
}
}
export function rememberGlabKnownHost(
host: string,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): void {
rememberGlabKnownHosts([host], connectionId, localGitOptions)
}
export function rememberGlabKnownHosts(
hosts: readonly string[],
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): void {
const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions)
const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts ?? DEFAULT_GITLAB_HOSTS
const seen = new Set(cached.map(normalizeGitLabHost))
const additions: string[] = []
for (const host of hosts) {
const normalizedHost = normalizeGitLabHost(host)
if (seen.has(normalizedHost)) {
continue
}
seen.add(normalizedHost)
additions.push(normalizedHost)
unauthenticatedHostExpiries.delete(
unauthenticatedHostKey(normalizedHost, connectionId, localGitOptions)
)
}
if (additions.length === 0) {
return
}
knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: [...cached, ...additions] })
trimKnownHostsCache()
}
export async function getGlabKnownHosts(
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<readonly string[]> {
const { key, cacheKey } = knownHostsCacheContext(connectionId, localGitOptions)
const cached = knownHostsCacheByExecutionContext.get(cacheKey)?.hosts
if (cached) {
const entry = knownHostsCacheByExecutionContext.get(cacheKey)
if (entry) {
knownHostsCacheByExecutionContext.delete(cacheKey)
knownHostsCacheByExecutionContext.set(cacheKey, entry)
}
return cached
}
// Why: only join a probe still young enough to answer, so a wedged one cannot
// pin every later retry for the life of the process (P1-D).
return runCoalescedProbe(knownHostsInFlightByExecutionContext, key, (ownsKey) =>
probeGlabKnownHosts(key, cacheKey, ownsKey, connectionId, localGitOptions)
)
}
async function probeGlabKnownHosts(
key: string,
cacheKey: string,
ownsKey: () => boolean,
connectionId?: string | null,
localGitOptions: LocalGitExecOptions = {}
): Promise<readonly string[]> {
try {
// Why: auth config belongs to the executing host; do not share native, WSL,
// or reconnected SSH/relay results, and bound an otherwise global probe.
const { stdout, stderr } = await glabExecFileAsync(['auth', 'status'], {
timeout: GLAB_KNOWN_HOSTS_TIMEOUT_MS,
// Why: a local probe would cache the default distro's auth under 'native'
// and wake an idle VM. A connection-keyed probe keeps the retry — glab never
// runs over SSH/relay, so the calls it gates take that same fallback.
...(connectionId ? {} : { allowDefaultWslFallback: false }),
...(!connectionId && localGitOptions.wslDistro
? { wslDistro: localGitOptions.wslDistro }
: {}),
...(localGitOptions.admissionTier ? { admissionTier: localGitOptions.admissionTier } : {})
})
const hosts = parseGlabAuthStatusHosts(`${stdout}\n${stderr}`)
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
const remembered = cached?.key === key ? cached.hosts : []
const merged = Array.from(new Set([...DEFAULT_GITLAB_HOSTS, ...remembered, ...hosts]))
if (ownsKey() && knownHostsExecutionKey(connectionId, localGitOptions) === key) {
knownHostsCacheByExecutionContext.set(cacheKey, { key, hosts: merged })
trimKnownHostsCache()
}
return merged
} catch {
// Keep failures uncached so auth or tunnel recovery is discovered later.
const cached = knownHostsCacheByExecutionContext.get(cacheKey)
return cached?.key === key ? cached.hosts : [...DEFAULT_GITLAB_HOSTS]
}
}
export function parseGlabAuthStatusHosts(output: string): string[] {
const hosts = new Set<string>()
// Why: self-hosted GitLab can run on a non-default port; preserve it so
// services on the same hostname remain distinct downstream.
for (const match of output.matchAll(/logged in to ([a-zA-Z0-9.-]+(?::\d+)?)/gi)) {
hosts.add(match[1].toLowerCase())
}
for (const line of output.split('\n')) {
const bareLine = line.trim()
const hostLine = bareLine.endsWith(':') ? bareLine.slice(0, -1) : bareLine
if (
line === bareLine &&
/^[a-zA-Z0-9](?:[a-zA-Z0-9.-]*[a-zA-Z0-9])?(?::\d+)?$/.test(hostLine)
) {
hosts.add(hostLine.toLowerCase())
}
}
return Array.from(hosts)
}