mirror of
https://github.com/stablyai/orca.git
synced 2026-10-09 00:02:39 +00:00
fix(ssh): a connect with no leases asks the host's relay endpoints before converting (#25223)
* fix(ssh): a connect with no leases and no relay session asks the host's relay endpoints before converting * refactor(ssh): one isLiveSshPtyLease for every lease-liveness check * fix(ssh): the host relay census asks a relay its PTYs before reading it as live work An accepting relay whose holders or children the probe could not read (no lsof, an unrecognised service child) read as live work, so a relay-era host that had exited its last shell never converted. The relay's own bridge answers pty.listProcesses without the owner role. * fix(ssh): the host relay census runs each relay's probe and bridge on the runtime it runs on Pinned-ladder hosts often have no Node on PATH, so a PATH Node read every relay as unverifiable. Each daemon's own argv names its pinned runtime, or the host Node a legacy relay started with. * fix(ssh): an older relay's bridge runs on that relay's own runtime --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -1,3 +1,4 @@
|
|||||||
|
import { isLiveSshPtyLease } from '../../shared/ssh-pty-lease-liveness'
|
||||||
import { ipcMain } from 'electron'
|
import { ipcMain } from 'electron'
|
||||||
import type { SshTarget } from '../../shared/ssh-types'
|
import type { SshTarget } from '../../shared/ssh-types'
|
||||||
import { toAppSshPtyId } from '../providers/ssh-pty-id'
|
import { toAppSshPtyId } from '../providers/ssh-pty-id'
|
||||||
@@ -65,7 +66,7 @@ async function doResetRelay(targetId: string, target: SshTarget): Promise<void>
|
|||||||
// (docs/reference/ssh-execution-boundary.md). Nothing here can adopt a stranger either — the
|
// (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
|
// replacement relay namespaces every id under a fresh mint epoch, so an old orphan lease can
|
||||||
// only fail its next reattach.
|
// only fail its next reattach.
|
||||||
if (lease.state !== 'terminated' && lease.state !== 'expired') {
|
if (isLiveSshPtyLease(lease)) {
|
||||||
ptyIds.add(lease.ptyId)
|
ptyIds.add(lease.ptyId)
|
||||||
// Why: only a host-acknowledged force-stop may retire a lease. When it threw we never
|
// 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
|
// observed those shells, so expiring them would record a verdict we do not hold; mirrors
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
retainOrcadMigrationSource
|
retainOrcadMigrationSource
|
||||||
} from '../ssh/orcad-migration-source-retention'
|
} from '../ssh/orcad-migration-source-retention'
|
||||||
import { retireRetainedOrcadSourceChain } from '../ssh/orcad-retained-source-retirement'
|
import { retireRetainedOrcadSourceChain } from '../ssh/orcad-retained-source-retirement'
|
||||||
import { assessOrcadMigrationTerminals } from '../ssh/orcad-migration-terminal-gate'
|
|
||||||
import { hasOrcadTemplate } from '../ssh/orcad-artifact-materializer'
|
import { hasOrcadTemplate } from '../ssh/orcad-artifact-materializer'
|
||||||
import { managedServerUpdateDeps } from '../ssh/managed-server-update-deps'
|
import { managedServerUpdateDeps } from '../ssh/managed-server-update-deps'
|
||||||
import { ensureOrcadManagedTunnel } from '../ssh/orcad-managed-tunnel'
|
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 { orcadMigrationDestinationFor } from '../ssh/orcad-runtime-conversion-wiring'
|
||||||
import { createManagedOrcadEnvironment } from '../ssh/orcad-runtime-deployment'
|
import { createManagedOrcadEnvironment } from '../ssh/orcad-runtime-deployment'
|
||||||
import type { HostServerOnConnectDeps } from '../ssh/ssh-host-server-on-connect'
|
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 { setSshHostServerStatus } from '../ssh/ssh-host-server-status'
|
||||||
import { trackSshHostServerEvent } from '../ssh/ssh-host-server-telemetry'
|
import { trackSshHostServerEvent } from '../ssh/ssh-host-server-telemetry'
|
||||||
import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo'
|
import { knownSshHostPlatform } from '../ssh/ssh-host-platform-memo'
|
||||||
@@ -83,16 +87,16 @@ export function hostServerOnConnectDeps(userDataPath: string): HostServerOnConne
|
|||||||
: undefined
|
: undefined
|
||||||
).claimable
|
).claimable
|
||||||
},
|
},
|
||||||
relayTerminals: async (target) => {
|
relayTerminals: (target) =>
|
||||||
const proof = await assessOrcadMigrationTerminals(
|
relayTerminalsOnConnect({
|
||||||
store,
|
store,
|
||||||
target.id,
|
targetId: target.id,
|
||||||
orcadMigrationRelayPtyLister(target.id)
|
listRelayPtyIds: orcadMigrationRelayPtyLister(target.id),
|
||||||
)
|
censusHost: async () =>
|
||||||
return proof.verdict === 'exited'
|
censusSshHostRelaysBeforeSession(
|
||||||
? { verdict: 'exited', count: 0 }
|
await requireManagedOrcadInfrastructure().connectionManager.connect(target)
|
||||||
: { verdict: proof.verdict, count: proof.ptyIds.length }
|
)
|
||||||
},
|
}),
|
||||||
deploy: (target) =>
|
deploy: (target) =>
|
||||||
createManagedOrcadEnvironment(userDataPath, {
|
createManagedOrcadEnvironment(userDataPath, {
|
||||||
name: target.orcadProvisioning?.name ?? target.label,
|
name: target.orcadProvisioning?.name ?? target.label,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { isLiveSshPtyLease } from '../../../shared/ssh-pty-lease-liveness'
|
||||||
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
|
import type { StoreRuntimeState } from '../loading-store/store-runtime-state'
|
||||||
import type { PersistedState } from '../../../shared/persisted-state-types'
|
import type { PersistedState } from '../../../shared/persisted-state-types'
|
||||||
import type { SshRemotePtyLease } from '../../../shared/ssh-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
|
// 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
|
// 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.
|
// 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.supersededBy
|
||||||
delete next.relayIdRecycled
|
delete next.relayIdRecycled
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
* prove that every terminal it ever leased on the target has exited. Loss of contact is never
|
* 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`.
|
* 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 { SshRemotePtyLease } from '../../shared/ssh-types'
|
||||||
import type { Store } from '../persistence'
|
import type { Store } from '../persistence'
|
||||||
|
|
||||||
@@ -107,7 +108,7 @@ export function confirmOrcadMigrationTerminalsUnderFence(
|
|||||||
}
|
}
|
||||||
const proven = new Set(proof.provenPtyIds)
|
const proven = new Set(proof.provenPtyIds)
|
||||||
const leases = store.getSshRemotePtyLeases(targetId)
|
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) {
|
if (live.length > 0) {
|
||||||
return refuse('live', live, 'a terminal started on this host before the fence took hold')
|
return refuse('live', live, 'a terminal started on this host before the fence took hold')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>()),
|
||||||
|
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>): RelayEndpointIncumbent {
|
||||||
|
return {
|
||||||
|
sockPath: sock(1),
|
||||||
|
verdict: 'live',
|
||||||
|
evidence: 'accepted-connection',
|
||||||
|
socketPresent: true,
|
||||||
|
holders: [working],
|
||||||
|
holdersEnumerable: true,
|
||||||
|
...overrides
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const census = (nodePath: () => Promise<string | null> = 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<string | null>
|
||||||
|
signal?: AbortSignal
|
||||||
|
}
|
||||||
|
): Promise<HostRelayEndpointCensus> {
|
||||||
|
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<string | null> | undefined
|
||||||
|
const runtimeFor = async (endpoint: string): Promise<string | null> =>
|
||||||
|
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<typeof classifySupersededRelay>
|
||||||
|
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'
|
||||||
|
}
|
||||||
@@ -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<SshRemotePtyLease, 'ptyId' | 'state'>[] = []) {
|
||||||
|
const full = leases.map((lease) => ({ ...lease, targetId: 'ssh-1', createdAt: 1, updatedAt: 1 }))
|
||||||
|
return { getSshRemotePtyLeases: () => full }
|
||||||
|
}
|
||||||
|
|
||||||
|
const decide = (
|
||||||
|
census: HostRelayEndpointCensus | Error,
|
||||||
|
options: { leases?: Parameters<typeof store>[0]; lister?: () => Promise<string[]> } = {}
|
||||||
|
) => {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<Store, 'getSshRemotePtyLeases'>
|
||||||
|
targetId: string
|
||||||
|
/** Null when no relay session is connected, as on a connect that has not registered one yet. */
|
||||||
|
listRelayPtyIds: ListRelayPtyIds | null
|
||||||
|
censusHost: () => Promise<HostRelayEndpointCensus>
|
||||||
|
}): Promise<HostServerTerminalVerdict> {
|
||||||
|
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<HostRelayEndpointCensus> {
|
||||||
|
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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 }))
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,7 @@ import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
|
|||||||
import { SshLegacyRelayRoute, type LegacyRelayRouteSink } from './ssh-legacy-relay-route'
|
import { SshLegacyRelayRoute, type LegacyRelayRouteSink } from './ssh-legacy-relay-route'
|
||||||
import { SshLegacyRelayRouter } from './ssh-legacy-relay-router'
|
import { SshLegacyRelayRouter } from './ssh-legacy-relay-router'
|
||||||
import { previousRelayCensus } from './ssh-previous-relay-terminals'
|
import { previousRelayCensus } from './ssh-previous-relay-terminals'
|
||||||
|
import { readRelayDaemonRuntimes } from './ssh-relay-endpoint-runtime'
|
||||||
|
|
||||||
const routersByTarget = new Map<string, SshLegacyRelayRouter>()
|
const routersByTarget = new Map<string, SshLegacyRelayRouter>()
|
||||||
|
|
||||||
@@ -33,9 +34,14 @@ export function createSshLegacyRelayRouter(args: {
|
|||||||
targetId,
|
targetId,
|
||||||
endpoints: async () => (await previousRelayCensus(targetId)).endpoints,
|
endpoints: async () => (await previousRelayCensus(targetId)).endpoints,
|
||||||
openRoute: async (sockPath) => {
|
openRoute: async (sockPath) => {
|
||||||
const { nodePath } = await previousRelayCensus(targetId)
|
const census = await previousRelayCensus(targetId)
|
||||||
const conn = args.connection()
|
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 null
|
||||||
}
|
}
|
||||||
return await SshLegacyRelayRoute.open({
|
return await SshLegacyRelayRoute.open({
|
||||||
|
|||||||
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<number | null> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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']
|
||||||
|
])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
/**
|
||||||
|
* The Node runtime each running relay daemon was started with, read from its own argv.
|
||||||
|
*
|
||||||
|
* A relay launches as `<node> relay.js --detached ... --sock-path <sock> ...`, where `<node>` 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<string, string> {
|
||||||
|
const runtimes = new Map<string, string>()
|
||||||
|
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<Map<string, string>> {
|
||||||
|
try {
|
||||||
|
return parseRelayDaemonRuntimes(
|
||||||
|
await execCommand(conn, RELAY_DAEMON_ARGV_COMMAND, { wrapCommand: true, signal })
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return new Map()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
* What blocks is what cannot move: another owner, live terminal leases, and dependent state the
|
* 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.
|
* 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 type { Store } from '../persistence'
|
||||||
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
|
import { getManagedOrcadFenceEnvironmentId } from '../../shared/managed-orcad-ssh-owner'
|
||||||
import type {
|
import type {
|
||||||
@@ -53,7 +54,7 @@ export function preflightOrcadMigrationExport(
|
|||||||
const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)]
|
const blockers: OrcadMigrationBlocker[] = [...collectTargetCatalogBlockers(store, target)]
|
||||||
const terminalLeases = store
|
const terminalLeases = store
|
||||||
.getSshRemotePtyLeases(targetId)
|
.getSshRemotePtyLeases(targetId)
|
||||||
.filter((lease) => lease.state !== 'terminated' && lease.state !== 'expired')
|
.filter(isLiveSshPtyLease)
|
||||||
.map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({
|
.map(({ ptyId, worktreeId, tabId, leafId, state, updatedAt }) => ({
|
||||||
ptyId,
|
ptyId,
|
||||||
worktreeId,
|
worktreeId,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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<SshRemotePtyLease, 'state'>): boolean {
|
||||||
|
return lease.state === 'attached' || lease.state === 'detached'
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user