mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
* 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>
136 lines
3.8 KiB
TypeScript
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
|
|
}
|