Files
orca/src/main/ssh/system-ssh-operation-lifecycle.ts
T
4dbc9f3817 feat(ssh): add ControlMaster multiplexing for system SSH transport (#6922)
* feat(ssh): add ControlMaster multiplexing for system SSH transport

System SSH transport spawns a new OpenSSH process per exec command
(platform detect, relay install check, node resolution, relay launch,
socket probe). Each process pays the full SSH handshake cost — ~9s on
Uber devpods — making a typical relay connect take 54s+ and reliably
exceeding the 15s startup reconnect budget.

Add SSH ControlMaster multiplexing via a per-target socket in
$TMPDIR/orca-ssh-ctl/<hash>.sock. The first command establishes the
master; subsequent commands reuse it at ~100ms per exec instead of ~9s.
ControlPersist=300 keeps the master alive after commands exit so rapid
reconnects (e.g. on tab focus) also benefit. Windows is excluded since
OpenSSH's ControlMaster support there is limited.

* fix(ssh): address ControlMaster key collision and directory permission risks

- Use target.id in the socket key so distinct SSH targets can never
  collide even when configHost/port/user happen to match
- Switch from SHA1 to SHA256 and extend hash slice from 12 to 16 chars
- Stat the control-socket directory after mkdirSync to reject pre-existing
  dirs that are symlinks, foreign-owned, or have group/other write bits
  (mkdirSync mode is ignored on pre-existing dirs)
- Update two tests that used exact spawn-arg arrays; replace with
  ordering assertions (forward flags before --) that stay correct
  regardless of which extra ControlMaster options are injected

* fix(ssh): bind ControlPath identity to route and reject symlinked ctl dir

Fold proxyCommand/jumpHost/identity fields into the ControlPath hash so a
target whose route is edited no longer reuses a still-alive master built on
the old route. Switch the control-socket dir check from statSync to lstatSync
so a planted symlink fails the directory validation outright.

* test(ssh): drop tautological argv re-assertion in spawn checks

The toHaveBeenCalledWith re-passed the args array extracted from the same
mock call, making that argument position always pass. argv content is
already verified by the index-ordering assertions above; use expect.any(Array)
so the spawn check only claims what it actually verifies (binary path, stdio).

* fix(ssh): harden system ssh connection reuse

Co-authored-by: Orca <help@stably.ai>

* test(ssh): isolate control socket runtime dir

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: Test <test@example.com>
Co-authored-by: Jinwoo-H <jinwoo0825@gmail.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-01 21:29:48 -07:00

136 lines
3.8 KiB
TypeScript

import type { ChildProcess } from 'node:child_process'
import type { SystemSshCommandChannel } from './system-ssh-command'
export type ProcessResult = { label: string; stderr: string }
export function waitForChannelClose(
channel: SystemSshCommandChannel,
label: string
): Promise<void> {
return new Promise((resolve, reject) => {
let stderr = ''
const cleanup = (): void => {
channel.stderr.off('data', onStderrData)
channel.off('error', onError)
channel.off('close', onClose)
}
const settle = (fn: typeof resolve | typeof reject, val?: unknown): void => {
cleanup()
fn(val as never)
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString('utf-8')
}
const onError = (err: Error): void => {
settle(reject, err)
}
const onClose = (code: number | null, signal?: NodeJS.Signals | null): void => {
if (code !== 0) {
const detail = code === null ? `signal ${signal ?? 'unknown'}` : `exit ${code}`
settle(reject, new Error(`${label} failed (${detail}): ${stderr.trim()}`))
return
}
settle(resolve)
}
channel.stderr.on('data', onStderrData)
channel.on('error', onError)
channel.on('close', onClose)
})
}
export function waitForProcess(proc: ChildProcess, label: string): Promise<ProcessResult> {
return new Promise((resolve, reject) => {
let stderr = ''
const cleanup = (): void => {
proc.stderr?.off('data', onStderrData)
proc.off('error', onError)
proc.off('close', onClose)
}
const settle = (fn: typeof resolve | typeof reject, val: ProcessResult | Error): void => {
cleanup()
fn(val as never)
}
const onStderrData = (data: Buffer): void => {
stderr += data.toString('utf-8')
}
const onError = (err: Error): void => {
settle(reject, err)
}
const onClose = (code: number | null): void => {
if (code !== 0) {
settle(reject, new Error(`${label} failed (exit ${code}): ${stderr.trim()}`))
return
}
settle(resolve, { label, stderr })
}
proc.stderr?.on('data', onStderrData)
proc.on('error', onError)
proc.on('close', onClose)
})
}
export function killProcess(proc: ChildProcess): void {
if (proc.exitCode !== null || proc.killed) {
return
}
try {
proc.kill('SIGTERM')
} catch {
// Process may already be dead
}
}
export async function awaitWithSystemSshAbort<T>(
signal: AbortSignal | undefined,
abortChildren: () => void,
operation: Promise<T>
): Promise<T> {
if (!signal) {
return operation
}
let abortReject: ((error: Error) => void) | null = null
let suppressLateOperationError = false
const abortPromise = new Promise<never>((_resolve, reject) => {
abortReject = reject
})
const abort = (): void => {
// Why: abort is connection teardown; do not wait for stubborn system ssh/tar
// children to emit close after we've already signaled them.
abortChildren()
suppressLateOperationError = true
abortReject?.(createAbortError())
}
signal.addEventListener('abort', abort, { once: true })
if (signal.aborted) {
abort()
}
try {
return await Promise.race([
operation.catch((error: unknown) => {
if (suppressLateOperationError) {
return new Promise<never>(() => {})
}
throw error
}),
abortPromise
])
} finally {
signal.removeEventListener('abort', abort)
}
}
export function throwIfAborted(signal: AbortSignal | undefined): void {
if (!signal?.aborted) {
return
}
throw createAbortError()
}
function createAbortError(): Error & { name: string } {
const error = new Error('System SSH operation was cancelled') as Error & { name: string }
error.name = 'AbortError'
return error
}