Files
orca/src/main/ipc/ssh-connect-flow.ts
T
Neil 27cfee6f7e fix(ssh): a Disconnect stays disconnected, and Settings and the status bar agree (#30130)
A user's Disconnect of an SSH host is now recorded for the session, and background
dials (a managed server's tunnel and status checks, census, host-resume recovery,
pane and automation restores) refuse it until an explicit Connect. Disconnecting a
managed host from Settings also stops its server's status checks, and the status
bar's Disconnect of a managed server also disconnects the SSH host it runs on, so
both surfaces read the same verdict.

Fixes #30086
Fixes #30085
2026-10-17 16:25:42 -07:00

193 lines
7.6 KiB
TypeScript

import { appendFileSync } from 'node:fs'
import type { SshConnectionState } from '../../shared/ssh-types'
import { createCancelledConnectAttemptError } from '../ssh/ssh-connect-attempt-cancellation'
import {
getSshProviderAuthority,
isCurrentSshProviderAuthority,
rotateSshProviderAuthority
} from '../ssh/ssh-provider-authority'
import { adoptSshConnection, runAttributedToSshOwner } from '../ssh/ssh-connection-attribution'
import { getSshTargetRegistryStore } from '../ssh/ssh-target-registry'
import {
decideHostServer,
recheckWhenManagedFenceClears,
publishHostServerDecisionFailure,
publishBridgedUnservedHostServer,
publishManagedServerConnect,
publishUnservedHostServer
} from './ssh-host-server-connect'
import {
assertSshConnectsNotFenced,
connectInFlight,
isCurrentConnectAttempt
} from './ssh-connect-attempt-registry'
import { connectionManager } from './ssh-ipc-context'
import { abandonDecisionTransport } from './ssh-session-teardown'
import { awaitTargetLifecycle } from './ssh-target-lifecycle-queue'
import { startLegacyRelayBridge } from '../ssh/legacy-relay-bridge/legacy-relay-bridge-registry'
import { clearSshCredentialDeclined } from '../ssh/ssh-credential-decline'
import { createSshUserDisconnectedError, isSshUserDisconnected } from '../ssh/ssh-user-disconnect'
import {
liftSshHostUserDisconnect,
resumeRuntimeEnvironmentStatus
} from './ssh-host-user-disconnect'
/** `automatic`: no user asked for this connect, so a cancelled sign-in or a Disconnect stays. */
export async function connectTarget(
targetId: string,
options: { automatic?: boolean } = {}
): Promise<SshConnectionState> {
const e2eProbePath = process.env.ORCA_E2E_FORBID_LOCAL_SSH_CONNECT_PROBE
if (e2eProbePath) {
appendFileSync(e2eProbePath, `${JSON.stringify(targetId)}\n`)
throw new Error('e2e_forbidden_local_ssh_connect')
}
if (options.automatic) {
// Why before any publish: the host must keep reading "disconnected", not a failed connect.
if (isSshUserDisconnected(targetId)) {
const label = getSshTargetRegistryStore()?.getTarget(targetId)?.label ?? targetId
throw createSshUserDisconnectedError(label)
}
return admitConnect(targetId)
}
clearSshCredentialDeclined(targetId)
const liftedEnvironments = liftSshHostUserDisconnect(targetId)
try {
return await admitConnect(targetId)
} finally {
resumeRuntimeEnvironmentStatus(liftedEnvironments)
}
}
async function admitConnect(targetId: string): Promise<SshConnectionState> {
// Why: fence callers that entered before a same-turn disconnect/reset but resume after its cleanup.
const admissionAuthority = getSshProviderAuthority(targetId)
await awaitTargetLifecycle(targetId)
// Why: serialize concurrent ssh:connect for the same target; interleaved connects otherwise leak the first session.
const existing = connectInFlight.get(targetId)
let replacePendingTransport = false
if (existing) {
if (isCurrentConnectAttempt(targetId, existing.authority)) {
return existing.promise
}
}
if (!isCurrentConnectAttempt(targetId, admissionAuthority)) {
throw createCancelledConnectAttemptError()
}
const observedAuthority = admissionAuthority
if (existing) {
if (connectInFlight.get(targetId) === existing) {
connectInFlight.delete(targetId)
replacePendingTransport = true
}
}
if (!isCurrentSshProviderAuthority(observedAuthority)) {
throw createCancelledConnectAttemptError()
}
// Why: the shutdown drain fences and snapshots synchronously, so a connect either registers in
// connectInFlight below (and gets joined) or fails here — it can never slip between the two.
assertSshConnectsNotFenced()
const promise = doConnect(targetId, replacePendingTransport)
const attempt = { authority: getSshProviderAuthority(targetId), promise }
connectInFlight.set(targetId, attempt)
try {
return await promise
} finally {
if (connectInFlight.get(targetId) === attempt) {
connectInFlight.delete(targetId)
}
}
}
async function doConnect(
targetId: string,
replacePendingTransport = false
): Promise<SshConnectionState> {
const target = getSshTargetRegistryStore()!.getTarget(targetId)
if (!target) {
throw new Error(`SSH target "${targetId}" not found`)
}
const authority = rotateSshProviderAuthority(targetId)
if (replacePendingTransport) {
await connectionManager!.disconnect(targetId)
if (!isCurrentConnectAttempt(targetId, authority)) {
throw createCancelledConnectAttemptError()
}
}
// A transport the decision's census, deploy or conversion opens is attributed to this attempt,
// so a cancelled attempt closes exactly that one and nothing a newer owner took over.
const owner = Symbol(targetId)
// Why after the teardown above: deploy and conversion refuse while a direct session or transport
// exists, and the authority rotated synchronously so concurrent connects still join this one.
const server = await runAttributedToSshOwner(owner, () => decideHostServer(target)).catch(
async (error: unknown) => {
if (!isCurrentConnectAttempt(targetId, authority)) {
await abandonDecisionTransport(targetId, owner, authority)
throw createCancelledConnectAttemptError()
}
// A failed setup leaves no relay to own the transport its decision dialed.
await abandonDecisionTransport(targetId, owner, authority)
publishHostServerDecisionFailure(targetId, error)
throw error
}
)
// A shutdown that began during the decision is the actionable reason, ahead of the rotation.
assertSshConnectsNotFenced()
if (!isCurrentConnectAttempt(targetId, authority)) {
await abandonDecisionTransport(targetId, owner, authority)
throw createCancelledConnectAttemptError()
}
adoptCurrentTransport(targetId, owner)
if (server.route === 'managed') {
if (server.fenceHeld) {
recheckWhenManagedFenceClears(target, server.environmentId)
}
// COMPAT(legacy-relay-bridge): panes an older relay still runs are known before the host is.
await startLegacyRelayBridge(targetId)
// A disconnect or shutdown during the bridge wait already published; "connected" would be stale.
assertSshConnectsNotFenced()
if (!isCurrentConnectAttempt(targetId, authority)) {
await abandonDecisionTransport(targetId, owner, authority)
throw createCancelledConnectAttemptError()
}
const published = publishManagedServerConnect(
targetId,
server.environmentId,
server.update,
server.serving,
server.linked
)
// Why after publishing: the check costs round trips, and only a macOS host shows a result.
void import('./ssh-host-launch-session-note')
.then(({ noteEndedLaunchSshSession }) =>
noteEndedLaunchSshSession(targetId, server.environmentId)
)
.catch(() => {})
return published
}
if (server.bridged) {
// COMPAT(legacy-relay-bridge): the move waits for relay terminals the bridge keeps serving.
await startLegacyRelayBridge(targetId)
assertSshConnectsNotFenced()
if (!isCurrentConnectAttempt(targetId, authority)) {
await abandonDecisionTransport(targetId, owner, authority)
throw createCancelledConnectAttemptError()
}
return publishBridgedUnservedHostServer(target, server)
}
// This version has no relay to fall back to: a host its managed server doesn't serve fails here.
await abandonDecisionTransport(targetId, owner, authority)
throw publishUnservedHostServer(target, server)
}
function adoptCurrentTransport(targetId: string, owner: symbol): void {
const current = connectionManager!.getConnection(targetId)
if (current) {
adoptSshConnection(current, owner)
}
}