diff --git a/src/main/ipc/ssh-connection-handlers.ts b/src/main/ipc/ssh-connection-handlers.ts index 7d38fb1859f..bd40d305719 100644 --- a/src/main/ipc/ssh-connection-handlers.ts +++ b/src/main/ipc/ssh-connection-handlers.ts @@ -1,3 +1,4 @@ +import { isLiveSshPtyLease } from '../../shared/ssh-pty-lease-liveness' import { ipcMain } from 'electron' import type { SshTarget } from '../../shared/ssh-types' import { toAppSshPtyId } from '../providers/ssh-pty-id' @@ -65,7 +66,7 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise // (docs/reference/ssh-execution-boundary.md). Nothing here can adopt a stranger either — the // replacement relay namespaces every id under a fresh mint epoch, so an old orphan lease can // only fail its next reattach. - if (lease.state !== 'terminated' && lease.state !== 'expired') { + if (isLiveSshPtyLease(lease)) { ptyIds.add(lease.ptyId) // Why: only a host-acknowledged force-stop may retire a lease. When it threw we never // observed those shells, so expiring them would record a verdict we do not hold; mirrors diff --git a/src/main/ipc/ssh-host-server-on-connect-wiring.ts b/src/main/ipc/ssh-host-server-on-connect-wiring.ts index bbd72437f31..ec639e73914 100644 --- a/src/main/ipc/ssh-host-server-on-connect-wiring.ts +++ b/src/main/ipc/ssh-host-server-on-connect-wiring.ts @@ -10,7 +10,6 @@ import { retainOrcadMigrationSource } from '../ssh/orcad-migration-source-retention' import { retireRetainedOrcadSourceChain } from '../ssh/orcad-retained-source-retirement' -import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate' import { hasOrcadTemplate } from '../ssh/orcad-artifact-materializer' import { managedServerUpdateDeps } from '../ssh/managed-server-update-deps' import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel' @@ -19,6 +18,11 @@ import { convertSshTargetToManagedOrcad } from '../ssh/orcad-runtime-conversion' import { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring' import { createManagedOrcadEnvironment } from '../ssh/orcad-runtime-deployment' import type { HostServerOnConnectDeps } from '../ssh/ssh-host-server-on-connect' +import { + censusSshHostRelaysBeforeSession, + relayTerminalsOnConnect +} from '../ssh/ssh-host-relay-terminals-on-connect' +import { requireManagedOrcadInfrastructure } from '../ssh/orcad-managed-runtime-context' import { setSshHostServerStatus } from '../ssh/ssh-host-server-status' import { trackSshHostServerEvent } from '../ssh/ssh-host-server-telemetry' import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo' @@ -83,16 +87,16 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne : undefined ).claimable }, - relayTerminals: async (target) => { - const proof = await assessOrcadMigrationTerminals( + relayTerminals: (target) => + relayTerminalsOnConnect({ store, - target.id, - orcadMigrationRelayPtyLister(target.id) - ) - return proof.verdict === 'exited' - ? { verdict: 'exited', count: 0 } - : { verdict: proof.verdict, count: proof.ptyIds.length } - }, + targetId: target.id, + listRelayPtyIds: orcadMigrationRelayPtyLister(target.id), + censusHost: async () => + censusSshHostRelaysBeforeSession( + await requireManagedOrcadInfrastructure().connectionManager.connect(target) + ) + }), deploy: (target) => createManagedOrcadEnvironment(userDataPath, { name: target.orcadProvisioning?.name ?? target.label, diff --git a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts index b2e3cef030b..9eb5870c19c 100644 --- a/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts +++ b/src/main/persistence/leasing-ssh-ptys/ssh-pty-lease-operations.ts @@ -1,3 +1,4 @@ +import { isLiveSshPtyLease } from '../../../shared/ssh-pty-lease-liveness' import type { StoreRuntimeState } from '../loading-store/store-runtime-state' import type { PersistedState } from '../../../shared/persisted-state-types' import type { SshRemotePtyLease } from '../../../shared/ssh-types' @@ -77,7 +78,7 @@ export function upsertSshRemotePtyLease( // A relay renumbers from `pty-1` on every start, so `existing` can be a RECYCLED id. Route // retirement belongs to the shell that lost, never to whatever claims the id next — drop both // marks the moment this id is claimed live again, and let supersession re-derive them below. - if (next.state === 'attached' || next.state === 'detached') { + if (isLiveSshPtyLease(next)) { delete next.supersededBy delete next.relayIdRecycled } diff --git a/src/main/ssh/orcad-migration-terminal-gate.ts b/src/main/ssh/orcad-migration-terminal-gate.ts index b3a72f7121c..fceb14e3569 100644 --- a/src/main/ssh/orcad-migration-terminal-gate.ts +++ b/src/main/ssh/orcad-migration-terminal-gate.ts @@ -3,6 +3,7 @@ * prove that every terminal it ever leased on the target has exited. Loss of contact is never * exit: an unanswered relay, or a lease the relay cannot account for, blocks as `unverifiable`. */ +import { isLiveSshPtyLease } from '../../shared/ssh-pty-lease-liveness' import type { SshRemotePtyLease } from '../../shared/ssh-types' import type { Store } from '../persistence' @@ -107,7 +108,7 @@ export function confirmOrcadMigrationTerminalsUnderFence( } const proven = new Set(proof.provenPtyIds) const leases = store.getSshRemotePtyLeases(targetId) - const live = leases.filter((lease) => lease.state === 'attached' || lease.state === 'detached') + const live = leases.filter(isLiveSshPtyLease) if (live.length > 0) { return refuse('live', live, 'a terminal started on this host before the fence took hold') } diff --git a/src/main/ssh/ssh-host-relay-endpoint-census.test.ts b/src/main/ssh/ssh-host-relay-endpoint-census.test.ts new file mode 100644 index 00000000000..1c81bddec10 --- /dev/null +++ b/src/main/ssh/ssh-host-relay-endpoint-census.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' +import type { RelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' +import { getRemoteHostPlatform } from './ssh-remote-platform' + +const { execCommand, probeRelayEndpointIncumbent, countRelayEndpointPtys } = vi.hoisted(() => ({ + execCommand: vi.fn(), + probeRelayEndpointIncumbent: vi.fn(), + countRelayEndpointPtys: vi.fn() +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ execCommand })) +vi.mock('./ssh-relay-endpoint-pty-count', () => ({ countRelayEndpointPtys })) +vi.mock('./ssh-relay-endpoint-incumbent', async (importOriginal) => ({ + ...(await importOriginal>()), + probeRelayEndpointIncumbent +})) + +import { censusHostRelayEndpoints } from './ssh-host-relay-endpoint-census' +import { RELAY_DAEMON_ARGV_COMMAND } from './ssh-relay-endpoint-runtime' + +let daemonArgv = '' +/** The endpoint listing answers `listing`; the daemon argv read answers `daemonArgv`. */ +function hostAnswers(listing: string): void { + execCommand.mockImplementation(async (_conn: unknown, command: string) => + command === RELAY_DAEMON_ARGV_COMMAND ? daemonArgv : listing + ) +} + +// The census only hands the connection to the mocked exec and probe. +const conn: SshConnection = Object.create(null) +const linux = getRemoteHostPlatform('linux-x64') +const sock = (n: number) => `/home/dev/.orca-remote/relay-1.4.${n}/relay-abc.sock` + +const husk = { pid: 10, matchesRelayArgv: true, childCount: 0, unrecognizedChildCount: 0 } +const working = { ...husk, childCount: 1, unrecognizedChildCount: 1 } + +function incumbent(overrides: Partial): RelayEndpointIncumbent { + return { + sockPath: sock(1), + verdict: 'live', + evidence: 'accepted-connection', + socketPresent: true, + holders: [working], + holdersEnumerable: true, + ...overrides + } +} + +const census = (nodePath: () => Promise = async () => '/usr/bin/node') => + censusHostRelayEndpoints(conn, { + host: linux, + remoteHome: '/home/dev', + fallbackNodePath: nodePath + }) + +describe('the host-side relay endpoint census', () => { + beforeEach(() => { + execCommand.mockReset() + probeRelayEndpointIncumbent.mockReset() + // By default the relay itself cannot be asked, so the probe's reading stands. + countRelayEndpointPtys.mockReset().mockResolvedValue(null) + daemonArgv = '' + }) + + it('finds none when no relay endpoint exists, without resolving node', async () => { + hostAnswers('') + const nodePath = vi.fn(async () => '/usr/bin/node') + + await expect(census(nodePath)).resolves.toEqual({ verdict: 'none', count: 0 }) + expect(nodePath).not.toHaveBeenCalled() + }) + + it('reads endpoints that hold no live work as idle', async () => { + hostAnswers(`${sock(1)}\n${sock(2)}\n`) + probeRelayEndpointIncumbent + .mockResolvedValueOnce(incumbent({ verdict: 'exited', socketPresent: true, holders: [] })) + // A live relay holding no shell of its own is a husk. + .mockResolvedValueOnce(incumbent({ holders: [husk] })) + + await expect(census()).resolves.toMatchObject({ verdict: 'idle' }) + }) + + it('reports live work any endpoint still runs, including another desktop\u2019s', async () => { + hostAnswers(`${sock(1)}\n/home/dev/.orca-remote/relay-1.4.1/relay-other.sock\n`) + probeRelayEndpointIncumbent + .mockResolvedValueOnce(incumbent({ verdict: 'exited', holders: [] })) + .mockResolvedValueOnce(incumbent({ holders: [working] })) + + await expect(census()).resolves.toEqual({ verdict: 'live', count: 1 }) + }) + + it('asks a relay the probe could not prove idle, and trusts its empty answer', async () => { + hostAnswers(`${sock(1)}\n${sock(2)}\n`) + // Holders not enumerable (no lsof): an accepting relay reads as live work to the probe. + probeRelayEndpointIncumbent + .mockResolvedValueOnce(incumbent({ holdersEnumerable: false })) + .mockResolvedValueOnce(incumbent({ verdict: 'unverifiable' })) + countRelayEndpointPtys.mockResolvedValue(0) + + await expect(census()).resolves.toEqual({ verdict: 'idle', count: 0 }) + expect(countRelayEndpointPtys).toHaveBeenCalledWith(conn, '/usr/bin/node', sock(1), undefined) + }) + + it('reports live work when the relay itself lists PTYs', async () => { + hostAnswers(`${sock(1)}\n`) + probeRelayEndpointIncumbent.mockResolvedValue(incumbent({})) + countRelayEndpointPtys.mockResolvedValue(2) + + await expect(census()).resolves.toEqual({ verdict: 'live', count: 1 }) + }) + + it.each([ + ['the listing failed', () => execCommand.mockRejectedValue(new Error('channel closed'))], + [ + 'an endpoint could not be classified', + () => { + hostAnswers(`${sock(1)}\n`) + probeRelayEndpointIncumbent.mockResolvedValue(incumbent({ verdict: 'unverifiable' })) + } + ], + [ + 'a probe threw', + () => { + hostAnswers(`${sock(1)}\n`) + probeRelayEndpointIncumbent.mockRejectedValue(new Error('timeout')) + } + ], + [ + 'there were more endpoints than it probes', + () => hostAnswers(Array.from({ length: 33 }, (_, i) => sock(i)).join('\n')) + ] + ])('is unverifiable when %s', async (_label, arrange) => { + arrange() + await expect(census()).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('is unverifiable when endpoints exist but the host has no node to probe them', async () => { + hostAnswers(`${sock(1)}\n`) + await expect(census(async () => null)).resolves.toEqual({ verdict: 'unverifiable', count: 1 }) + }) + + it('cannot enumerate Windows named pipes', async () => { + await expect( + censusHostRelayEndpoints(conn, { + host: getRemoteHostPlatform('win32-x64'), + remoteHome: 'C:\\Users\\dev', + fallbackNodePath: async () => 'node.exe' + }) + ).resolves.toEqual({ verdict: 'unenumerable', count: 0 }) + expect(execCommand).not.toHaveBeenCalled() + }) + + it('probes and asks each relay with its own pinned runtime on a host with no Node on PATH', async () => { + const pinned = '/home/dev/.orca-remote/node-runtimes/v24.21.0-linux-x64/bin/node' + daemonArgv = `${pinned} relay.js --detached --grace-time 300 --sock-path ${sock(1)} --credential-file ${sock(1)}.credential\n` + hostAnswers(`${sock(1)}\n`) + probeRelayEndpointIncumbent.mockResolvedValue(incumbent({ holdersEnumerable: false })) + countRelayEndpointPtys.mockResolvedValue(0) + const noPathNode = vi.fn(async () => null) + + await expect(census(noPathNode)).resolves.toEqual({ verdict: 'idle', count: 0 }) + expect(probeRelayEndpointIncumbent).toHaveBeenCalledWith(conn, linux, pinned, sock(1), { + signal: undefined + }) + expect(countRelayEndpointPtys).toHaveBeenCalledWith(conn, pinned, sock(1), undefined) + expect(noPathNode).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-host-relay-endpoint-census.ts b/src/main/ssh/ssh-host-relay-endpoint-census.ts new file mode 100644 index 00000000000..48d52ee2045 --- /dev/null +++ b/src/main/ssh/ssh-host-relay-endpoint-census.ts @@ -0,0 +1,130 @@ +/** + * Whether any Orca relay on an SSH host still runs work, asked over the bootstrap connection + * before a relay session exists. Local leases only cover this desktop's terminals; the host's own + * relay endpoints also show terminals another desktop opened there. + * + * Loss of contact is never evidence of exit (docs/reference/ssh-execution-boundary.md): a listing + * that failed, ran out of room, or an endpoint the probe could not classify is `unverifiable`. + */ +import type { SshConnection } from './ssh-connection' +import { shellEscape } from './ssh-connection-utils' +import { RELAY_REMOTE_DIR } from './relay-protocol' +import { SHORT_RELAY_SOCKET_DIR_PREFIX } from './relay-socket-path-limit' +import { execCommand } from './ssh-relay-deploy-helpers' +import { probeRelayEndpointIncumbent } from './ssh-relay-endpoint-incumbent' +import { classifySupersededRelay } from './ssh-relay-superseded-endpoints' +import { countRelayEndpointPtys } from './ssh-relay-endpoint-pty-count' +import { readRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime' +import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform' + +/** `unenumerable`: Windows named pipes cannot be listed, so the caller keeps today's path. */ +export type HostRelayEndpointVerdict = 'none' | 'idle' | 'live' | 'unverifiable' | 'unenumerable' + +export type HostRelayEndpointCensus = { verdict: HostRelayEndpointVerdict; count: number } + +const MAX_CENSUS_ENDPOINTS = 32 + +/** + * Every relay socket under this user's relay directories, whichever target or desktop bound it: + * the socket name hashes the target id, and another desktop's target id is not ours to know. + */ +export function hostRelayEndpointListCommand(remoteHome: string): string { + return [ + `base=${shellEscape(`${remoteHome}/${RELAY_REMOTE_DIR}`)}`, + `short_base="${SHORT_RELAY_SOCKET_DIR_PREFIX}$(id -u 2>/dev/null)"`, + 'for sock in "$base"/relay-*/relay*.sock "$short_base"/relay-*/relay*.sock; do', + ' [ -S "$sock" ] && printf \'%s\\n\' "$sock"', + 'done', + 'true' + ].join('\n') +} + +export async function censusHostRelayEndpoints( + conn: SshConnection, + args: { + host: RemoteHostPlatform + remoteHome: string + /** A Node for endpoints whose daemon is not running; null when the host has none. */ + fallbackNodePath: () => Promise + signal?: AbortSignal + } +): Promise { + if (isWindowsRemoteHost(args.host)) { + return { verdict: 'unenumerable', count: 0 } + } + let listing: string + try { + listing = await execCommand(conn, hostRelayEndpointListCommand(args.remoteHome), { + wrapCommand: true, + signal: args.signal + }) + } catch { + return { verdict: 'unverifiable', count: 0 } + } + const endpoints = listing + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.startsWith('/')) + if (endpoints.length === 0) { + return { verdict: 'none', count: 0 } + } + if (endpoints.length > MAX_CENSUS_ENDPOINTS) { + return { verdict: 'unverifiable', count: endpoints.length } + } + // Each relay is asked with the runtime it runs on; a dead daemon's socket with any runtime here. + const runtimes = await readRelayDaemonRuntimes(conn, args.signal) + const anyRuntime = runtimes.values().next().value ?? null + let fallback: Promise | undefined + const runtimeFor = async (endpoint: string): Promise => + runtimes.get(endpoint) ?? + anyRuntime ?? + (await (fallback ??= args.fallbackNodePath().catch(() => null))) + let live = 0 + let unverifiable = 0 + for (const endpoint of endpoints) { + const nodePath = await runtimeFor(endpoint) + const outcome = nodePath + ? await classifyEndpoint(conn, args.host, nodePath, endpoint, args.signal) + : 'unverifiable' + if (outcome === 'live') { + live += 1 + } else if (outcome === 'unverifiable') { + unverifiable += 1 + } + } + if (live > 0) { + return { verdict: 'live', count: live } + } + return unverifiable > 0 + ? { verdict: 'unverifiable', count: unverifiable } + : { verdict: 'idle', count: 0 } +} + +/** + * The probe proves a relay idle only when it can read its whole process tree; otherwise the relay + * itself is asked, and only when it cannot answer does the probe's conservative reading stand. + */ +async function classifyEndpoint( + conn: SshConnection, + host: RemoteHostPlatform, + nodePath: string, + endpoint: string, + signal: AbortSignal | undefined +): Promise<'idle' | 'live' | 'unverifiable'> { + let outcome: ReturnType + try { + outcome = classifySupersededRelay( + await probeRelayEndpointIncumbent(conn, host, nodePath, endpoint, { signal }) + ) + } catch { + outcome = 'unverifiable' + } + if (outcome === 'reap-candidate' || outcome === 'stale-endpoint-removed') { + return 'idle' + } + const ptys = await countRelayEndpointPtys(conn, nodePath, endpoint, signal) + if (ptys !== null) { + return ptys > 0 ? 'live' : 'idle' + } + return outcome === 'retained-live-work' ? 'live' : 'unverifiable' +} diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts new file mode 100644 index 00000000000..173569300fa --- /dev/null +++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, it, vi } from 'vitest' +import type { SshRemotePtyLease } from '../../shared/ssh-types' +import type { HostRelayEndpointCensus } from './ssh-host-relay-endpoint-census' +import { relayTerminalsOnConnect } from './ssh-host-relay-terminals-on-connect' + +function store(leases: Pick[] = []) { + const full = leases.map((lease) => ({ ...lease, targetId: 'ssh-1', createdAt: 1, updatedAt: 1 })) + return { getSshRemotePtyLeases: () => full } +} + +const decide = ( + census: HostRelayEndpointCensus | Error, + options: { leases?: Parameters[0]; lister?: () => Promise } = {} +) => { + const censusHost = vi.fn(async () => { + if (census instanceof Error) { + throw census + } + return census + }) + return { + censusHost, + verdict: relayTerminalsOnConnect({ + store: store(options.leases), + targetId: 'ssh-1', + listRelayPtyIds: options.lister ?? null, + censusHost + }) + } +} + +describe('the connect-time relay terminal verdict', () => { + it.each([ + ['no relay endpoints at all', { verdict: 'none', count: 0 }], + ['endpoints with no live work', { verdict: 'idle', count: 0 }], + // Windows pipes cannot be listed: today's lease-only decision stands. + ['a host whose endpoints cannot be listed', { verdict: 'unenumerable', count: 0 }] + ] as const)('converts with %s', async (_label, census) => { + await expect(decide(census).verdict).resolves.toEqual({ verdict: 'exited', count: 0 }) + }) + + it('stays on the relay while any endpoint, even another desktop’s, runs live work', async () => { + await expect(decide({ verdict: 'live', count: 2 }).verdict).resolves.toEqual({ + verdict: 'live', + count: 2 + }) + }) + + it.each([ + ['incomplete', { verdict: 'unverifiable', count: 1 } as const], + ['failed', new Error('connect refused')] + ])('refuses as unverifiable when the census %s', async (_label, census) => { + await expect(decide(census).verdict).resolves.toMatchObject({ verdict: 'unverifiable' }) + }) + + it('keeps a lease-backed verdict without asking the host', async () => { + const { censusHost, verdict } = decide( + { verdict: 'none', count: 0 }, + { leases: [{ ptyId: 'a', state: 'attached' }] } + ) + await expect(verdict).resolves.toEqual({ verdict: 'live', count: 1 }) + expect(censusHost).not.toHaveBeenCalled() + }) + + it('trusts a connected relay session that answered, without asking the host', async () => { + const { censusHost, verdict } = decide( + { verdict: 'live', count: 1 }, + { lister: async () => [] } + ) + await expect(verdict).resolves.toEqual({ verdict: 'exited', count: 0 }) + expect(censusHost).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-host-relay-terminals-on-connect.ts b/src/main/ssh/ssh-host-relay-terminals-on-connect.ts new file mode 100644 index 00000000000..990fad2466d --- /dev/null +++ b/src/main/ssh/ssh-host-relay-terminals-on-connect.ts @@ -0,0 +1,74 @@ +/** + * The connect path's terminal verdict for a host that may convert to a managed server. This + * desktop's leases answer first; when they claim nothing and no relay session can be asked, the + * host's own relay endpoints decide, so terminals another desktop opened there still block. + */ +import type { Store } from '../persistence' +import type { HostServerTerminalVerdict } from './ssh-host-server-on-connect' +import { + censusHostRelayEndpoints, + type HostRelayEndpointCensus +} from './ssh-host-relay-endpoint-census' +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' +import { readRemoteHomeCommand } from './ssh-remote-commands' +import { resolveRemoteNodePath } from './ssh-remote-node-resolution' +import { detectRemoteHostPlatform } from './ssh-remote-platform-detection' +import { isWindowsRemoteHost, normalizeRemoteHome, validateRemoteHome } from './ssh-remote-platform' +import { + assessOrcadMigrationTerminals, + type ListRelayPtyIds +} from './orcad-migration-terminal-gate' + +export async function relayTerminalsOnConnect(args: { + store: Pick + targetId: string + /** Null when no relay session is connected, as on a connect that has not registered one yet. */ + listRelayPtyIds: ListRelayPtyIds | null + censusHost: () => Promise +}): Promise { + const proof = await assessOrcadMigrationTerminals(args.store, args.targetId, args.listRelayPtyIds) + if (proof.verdict !== 'exited') { + return { verdict: proof.verdict, count: proof.ptyIds.length } + } + if (args.listRelayPtyIds) { + return { verdict: 'exited', count: 0 } + } + let census: HostRelayEndpointCensus + try { + census = await args.censusHost() + } catch { + return { verdict: 'unverifiable', count: 0 } + } + // Windows pipes cannot be listed (`unenumerable`); those hosts keep deciding from the leases. + return census.verdict === 'live' || census.verdict === 'unverifiable' + ? { verdict: census.verdict, count: census.count } + : { verdict: 'exited', count: 0 } +} + +/** The census over the connect's bootstrap connection, before any relay session exists. */ +export async function censusSshHostRelaysBeforeSession( + conn: SshConnection, + signal?: AbortSignal +): Promise { + const host = await detectRemoteHostPlatform(conn, { signal }) + if (!host) { + return { verdict: 'unverifiable', count: 0 } + } + if (isWindowsRemoteHost(host)) { + return { verdict: 'unenumerable', count: 0 } + } + const remoteHome = normalizeRemoteHome( + await execCommand(conn, readRemoteHomeCommand(host), { signal }), + host + ) + if (!validateRemoteHome(remoteHome, host)) { + return { verdict: 'unverifiable', count: 0 } + } + return censusHostRelayEndpoints(conn, { + host, + remoteHome, + fallbackNodePath: () => resolveRemoteNodePath(conn, host, { signal }), + signal + }) +} diff --git a/src/main/ssh/ssh-legacy-relay-routing.test.ts b/src/main/ssh/ssh-legacy-relay-routing.test.ts new file mode 100644 index 00000000000..b399d4634d1 --- /dev/null +++ b/src/main/ssh/ssh-legacy-relay-routing.test.ts @@ -0,0 +1,60 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +const { previousRelayCensus, readRelayDaemonRuntimes, open } = vi.hoisted(() => ({ + previousRelayCensus: vi.fn(), + readRelayDaemonRuntimes: vi.fn(), + open: vi.fn() +})) + +vi.mock('./ssh-previous-relay-terminals', () => ({ previousRelayCensus })) +vi.mock('./ssh-relay-endpoint-runtime', () => ({ readRelayDaemonRuntimes })) +vi.mock('./ssh-legacy-relay-route', () => ({ SshLegacyRelayRoute: { open } })) + +import { createSshLegacyRelayRouter } from './ssh-legacy-relay-routing' + +const OLD_SOCK = '/home/dev/.orca-remote/relay-1.4.0/relay-abc.sock' +const CURRENT_NODE = '/home/dev/.orca-remote/node-runtimes/v24/bin/node' +// The routing only hands the connection to the mocked runtime read and route. +const conn: SshConnection = Object.create(null) + +function router() { + return createSshLegacyRelayRouter({ + targetId: 'target-1', + connection: () => conn, + clientInstanceId: 'client-1', + sink: { data: vi.fn(), exit: vi.fn(), replay: vi.fn() } + }) +} + +describe('the legacy relay route runtime', () => { + beforeEach(() => { + previousRelayCensus.mockReset().mockResolvedValue({ + endpoints: [OLD_SOCK], + nodePath: CURRENT_NODE, + complete: true, + unverifiable: false + }) + readRelayDaemonRuntimes.mockReset() + open.mockReset().mockResolvedValue(null) + }) + + it('opens an older relay’s bridge on the runtime that relay runs on', async () => { + const olderPin = '/home/dev/.orca-remote/node-runtimes/v22/bin/node' + readRelayDaemonRuntimes.mockResolvedValue(new Map([[OLD_SOCK, olderPin]])) + + await router().listHeld() + + expect(open).toHaveBeenCalledWith( + expect.objectContaining({ sockPath: OLD_SOCK, nodePath: olderPin }) + ) + }) + + it('falls back to the current deploy’s runtime when the daemon’s argv is unreadable', async () => { + readRelayDaemonRuntimes.mockResolvedValue(new Map()) + + await router().listHeld() + + expect(open).toHaveBeenCalledWith(expect.objectContaining({ nodePath: CURRENT_NODE })) + }) +}) diff --git a/src/main/ssh/ssh-legacy-relay-routing.ts b/src/main/ssh/ssh-legacy-relay-routing.ts index 614ad72e73d..3a9d9c59ee4 100644 --- a/src/main/ssh/ssh-legacy-relay-routing.ts +++ b/src/main/ssh/ssh-legacy-relay-routing.ts @@ -3,6 +3,7 @@ import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers' import { SshLegacyRelayRoute, type LegacyRelayRouteSink } from './ssh-legacy-relay-route' import { SshLegacyRelayRouter } from './ssh-legacy-relay-router' import { previousRelayCensus } from './ssh-previous-relay-terminals' +import { readRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime' const routersByTarget = new Map() @@ -33,9 +34,14 @@ export function createSshLegacyRelayRouter(args: { targetId, endpoints: async () => (await previousRelayCensus(targetId)).endpoints, openRoute: async (sockPath) => { - const { nodePath } = await previousRelayCensus(targetId) + const census = await previousRelayCensus(targetId) const conn = args.connection() - if (!nodePath || !conn) { + if (!conn) { + return null + } + // An older relay may run on an older Node pin or host Node; its bridge must use that runtime. + const nodePath = (await readRelayDaemonRuntimes(conn)).get(sockPath) ?? census.nodePath + if (!nodePath) { return null } return await SshLegacyRelayRoute.open({ diff --git a/src/main/ssh/ssh-relay-endpoint-pty-count.test.ts b/src/main/ssh/ssh-relay-endpoint-pty-count.test.ts new file mode 100644 index 00000000000..b7c0a6a275f --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-pty-count.test.ts @@ -0,0 +1,62 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { SshConnection } from './ssh-connection' + +const { waitForSentinel, request, dispose } = vi.hoisted(() => ({ + waitForSentinel: vi.fn(), + request: vi.fn(), + dispose: vi.fn() +})) + +vi.mock('./ssh-relay-deploy-helpers', () => ({ waitForSentinel })) +vi.mock('./ssh-channel-multiplexer', () => ({ + SshChannelMultiplexer: class { + request = request + dispose = dispose + } +})) + +import { countRelayEndpointPtys } from './ssh-relay-endpoint-pty-count' + +const exec = vi.fn(async () => ({})) +// Only exec is read; the bridge transport itself is mocked above. +const conn: SshConnection = Object.assign(Object.create(null), { exec }) +const SOCK = '/home/dev/.orca-remote/relay-1.4.0/relay-abc.sock' + +describe('asking a relay how many PTYs it runs', () => { + beforeEach(() => { + exec.mockClear() + waitForSentinel.mockReset().mockResolvedValue({}) + request.mockReset() + dispose.mockReset() + }) + + it('lists through the relay’s own bridge without taking the owner role, then hangs up', async () => { + request.mockResolvedValue([{ id: 'pty-1' }, { id: 'pty-2' }]) + + await expect(countRelayEndpointPtys(conn, '/usr/bin/node', SOCK)).resolves.toBe(2) + expect(exec).toHaveBeenCalledWith(expect.stringContaining('relay.js --connect')) + expect(request).toHaveBeenCalledTimes(1) + expect(request).toHaveBeenCalledWith( + 'pty.listProcesses', + { includeForegroundProcessEvidence: false }, + expect.objectContaining({ timeoutMs: expect.any(Number) }) + ) + expect(dispose).toHaveBeenCalled() + }) + + it.each([ + ['the bridge could not start', () => waitForSentinel.mockRejectedValue(new Error('exit 1'))], + ['the request failed', () => request.mockRejectedValue(new Error('timeout'))], + ['the answer is not a listing', () => request.mockResolvedValue({ nope: true })] + ])('answers null when %s', async (_label, arrange) => { + arrange() + await expect(countRelayEndpointPtys(conn, '/usr/bin/node', SOCK)).resolves.toBeNull() + }) + + it('never guesses a relay whose socket was relocated outside its version directory', async () => { + await expect( + countRelayEndpointPtys(conn, '/usr/bin/node', '/tmp/.orca-relay-1000/relay-x/relay-abc.sock') + ).resolves.toBeNull() + expect(exec).not.toHaveBeenCalled() + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-pty-count.ts b/src/main/ssh/ssh-relay-endpoint-pty-count.ts new file mode 100644 index 00000000000..9333a1c6be4 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-pty-count.ts @@ -0,0 +1,43 @@ +/** + * Asks one relay on the host how many PTYs it runs, through that relay's own bridge. + * + * The incumbent probe can only guess from the process table, and an accepting relay whose + * holders or children it cannot read looks the same whether it runs shells or none. The relay + * itself knows: its own `relay.js --connect` presents its own build hash, so any client reaches it, + * and `pty.listProcesses` needs no PTY owner role, so asking never takes over another desktop's. + */ +import type { SshConnection } from './ssh-connection' +import { SshChannelMultiplexer } from './ssh-channel-multiplexer' +import { legacyRelayBridge } from './ssh-legacy-relay-route' +import { waitForSentinel } from './ssh-relay-deploy-helpers' + +const RELAY_PTY_COUNT_TIMEOUT_MS = 10_000 + +/** Null when the relay could not be asked or answered something that is not a listing. */ +export async function countRelayEndpointPtys( + conn: SshConnection, + nodePath: string, + sockPath: string, + signal?: AbortSignal +): Promise { + const bridge = legacyRelayBridge(nodePath, sockPath) + if (!bridge) { + return null + } + let mux: SshChannelMultiplexer | null = null + try { + mux = new SshChannelMultiplexer( + await waitForSentinel(await conn.exec(bridge.connectCommand), signal) + ) + const rows = await mux.request( + 'pty.listProcesses', + { includeForegroundProcessEvidence: false }, + { timeoutMs: RELAY_PTY_COUNT_TIMEOUT_MS, signal } + ) + return Array.isArray(rows) ? rows.length : null + } catch { + return null + } finally { + mux?.dispose() + } +} diff --git a/src/main/ssh/ssh-relay-endpoint-runtime.test.ts b/src/main/ssh/ssh-relay-endpoint-runtime.test.ts new file mode 100644 index 00000000000..fd22247584a --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-runtime.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' +import { parseRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime' + +describe('the runtime each relay daemon runs on', () => { + it('reads a pinned and a legacy host-Node daemon from their argv', () => { + const pinned = '/home/dev/.orca-remote/node-runtimes/v24/bin/node' + const runtimes = parseRelayDaemonRuntimes( + [ + `${pinned} relay.js --detached --grace-time 0 --sock-path /home/dev/.orca-remote/relay-2/relay-a.sock --credential-file x`, + '/usr/local/bin/node relay.js --detached --grace-time 300 --sock-path /home/dev/.orca-remote/relay-1/relay-b.sock --log-file y', + // A bridge client is not a daemon, and a line without a socket is skipped. + '/usr/bin/node relay.js --connect --sock-path /home/dev/.orca-remote/relay-1/relay-b.sock', + 'grep -F relay.js --detached' + ].join('\n') + ) + expect([...runtimes]).toEqual([ + ['/home/dev/.orca-remote/relay-2/relay-a.sock', pinned], + ['/home/dev/.orca-remote/relay-1/relay-b.sock', '/usr/local/bin/node'] + ]) + }) +}) diff --git a/src/main/ssh/ssh-relay-endpoint-runtime.ts b/src/main/ssh/ssh-relay-endpoint-runtime.ts new file mode 100644 index 00000000000..7c9a216f632 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoint-runtime.ts @@ -0,0 +1,47 @@ +/** + * The Node runtime each running relay daemon was started with, read from its own argv. + * + * A relay launches as ` relay.js --detached ... --sock-path ...`, where `` is + * its pinned runtime on ladder hosts (which often have no Node on PATH) or the host Node a legacy + * relay resolved. Asking that relay, or probing its socket, with the same runtime is what keeps a + * host with no PATH Node from reading every relay as unverifiable. + */ +import type { SshConnection } from './ssh-connection' +import { execCommand } from './ssh-relay-deploy-helpers' + +const RELAY_DAEMON_MARKER = ' relay.js --detached ' + +/** + * `-ww` keeps long argv untruncated on procps and BSD `ps`; BusyBox (Alpine) rejects those flags + * but lists every process for plain `-o args`. No daemon prints nothing. + */ +export const RELAY_DAEMON_ARGV_COMMAND = + `{ ps -eww -o args= 2>/dev/null || ps -o args= 2>/dev/null; } | ` + + `grep -F -- '${RELAY_DAEMON_MARKER.trim()}' || true` + +/** Socket path to the runtime its daemon runs on; a daemon whose argv does not parse is skipped. */ +export function parseRelayDaemonRuntimes(output: string): Map { + const runtimes = new Map() + for (const line of output.split('\n')) { + const marker = line.indexOf(RELAY_DAEMON_MARKER) + const sock = /\s--sock-path\s+(\/\S+)/.exec(line)?.[1] + const node = marker > 0 ? line.slice(0, marker).trim() : '' + if (sock && node && !runtimes.has(sock)) { + runtimes.set(sock, node) + } + } + return runtimes +} + +export async function readRelayDaemonRuntimes( + conn: SshConnection, + signal?: AbortSignal +): Promise> { + try { + return parseRelayDaemonRuntimes( + await execCommand(conn, RELAY_DAEMON_ARGV_COMMAND, { wrapCommand: true, signal }) + ) + } catch { + return new Map() + } +} diff --git a/src/main/ssh/ssh-target-orcad-preflight.ts b/src/main/ssh/ssh-target-orcad-preflight.ts index 642b9359559..708e5a7424c 100644 --- a/src/main/ssh/ssh-target-orcad-preflight.ts +++ b/src/main/ssh/ssh-target-orcad-preflight.ts @@ -6,6 +6,7 @@ * What blocks is what cannot move: another owner, live terminal leases, and dependent state the * manifest cannot carry. Read-only: building the manifest here exports nothing. */ +import { isLiveSshPtyLease } from '../../shared/ssh-pty-lease-liveness' import type { Store } from '../persistence' import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner' import type { @@ -53,7 +54,7 @@ export function preflightOrcadMigrationExport( const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)] const terminalLeases = store .getSshRemotePtyLeases(targetId) - .filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired') + .filter(isLiveSshPtyLease) .map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({ ptyId, worktreeId, diff --git a/src/shared/ssh-pty-lease-liveness.test.ts b/src/shared/ssh-pty-lease-liveness.test.ts new file mode 100644 index 00000000000..09b2ba27d26 --- /dev/null +++ b/src/shared/ssh-pty-lease-liveness.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest' +import { isLiveSshPtyLease } from './ssh-pty-lease-liveness' + +describe('isLiveSshPtyLease', () => { + it.each([ + ['attached', true], + ['detached', true], + ['expired', false], + ['terminated', false] + ] as const)('reads a %s lease as live: %s', (state, live) => { + expect(isLiveSshPtyLease({ state })).toBe(live) + }) +}) diff --git a/src/shared/ssh-pty-lease-liveness.ts b/src/shared/ssh-pty-lease-liveness.ts new file mode 100644 index 00000000000..afa0a6dc5c5 --- /dev/null +++ b/src/shared/ssh-pty-lease-liveness.ts @@ -0,0 +1,10 @@ +import type { SshRemotePtyLease } from './ssh-types' + +/** + * A lease that still claims a running terminal: a client holds it (`attached`) or let it run + * (`detached`). `expired` lost its owner without an exit record and `terminated` ended; neither is + * a claim, though only `terminated` is evidence the terminal exited. + */ +export function isLiveSshPtyLease(lease: Pick): boolean { + return lease.state === 'attached' || lease.state === 'detached' +}