fix(ssh): isolate relay versions via per-version install dirs and wire handshake

The relay's previous single-dir layout (~/.orca-remote/relay-v0.1.0/) let
the deploy step rewrite relay.js in place while a daemon was still loaded
in memory at the previous version. New clients then drove that stale
daemon, surfacing as a reconnect loop (issue #1660 follow-up) and the
field failure observed against an 8-day-old daemon on openclaw.

Switch to a VS Code-style versioned layout where each (RELAY_VERSION +
content-hash) bundle installs into its own directory and is never
mutated after install. A v2 client's --connect socket path is rooted in
relay-${v2-hash}/ and structurally cannot reach a v1 daemon's socket.

Defense-in-depth: the daemon now reads exactly one Handshake-typed frame
on each newly-accepted Unix socket before attaching the JSON-RPC
dispatcher (mirrors VS Code's remoteExtensionHostAgentServer.ts:340).
Mismatch closes the socket; the bridge exits with code 42; client maps
that to a typed RelayVersionMismatchError and skips the relay-lost
backoff loop instead of retrying through 6 attempts.

Other deploy hardening:
- atomic mkdir-based install lock with stale-lock recovery serialises
  concurrent first-installs of the same version
- .install-complete sentinel distinguishes a finished install from a
  crashed-mid-install partial that should be retried
- gcOldRelayVersions removes unreferenced sibling dirs (allowlist regex,
  skips locked or incomplete dirs, skips dirs with a live socket)
- readLocalFullVersion fails fast on a missing/empty local .version
  rather than silently falling back to a path where a daemon from a
  different code generation may already be running

Includes a cross-version isolation test that fails any future refactor
which collapses the per-version layout back to a shared dir.

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-11 00:48:21 -04:00
co-authored by Orca
parent 7a5c6bc127
commit 26d1666ea1
13 changed files with 1267 additions and 140 deletions
+23
View File
@@ -388,6 +388,29 @@ export function registerSshHandlers(
// triggers session.reconnect() using the live SSH connection.
// Set before establish() so the callback is in place if the relay
// dies during the deploy/connect sequence.
// Why: a wire-handshake mismatch (typed RelayVersionMismatchError) means
// the local client and remote daemon are at different code versions —
// no amount of backoff will reconcile them. Skip the relay-lost loop
// entirely and surface a terminal "please reconnect manually" error.
session.setOnTerminalRelayError((tid, err) => {
clearRelayLostBackoff(tid)
console.warn(
`[ssh] Terminal relay error for ${tid}: ${err.message}; skipping reconnect backoff.`
)
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send('ssh:state-changed', {
targetId: tid,
state: {
targetId: tid,
status: 'error',
error: err.message,
reconnectAttempt: 0
}
})
}
})
session.setOnRelayLost((tid) => {
const s = activeSessions.get(tid)
if (!s) {
@@ -0,0 +1,141 @@
// Cross-version isolation guard.
//
// Why: this test is the executable form of the "Pattern Note" in
// docs/ssh-relay-versioned-install-dirs.md — it asserts that a v2 deploy
// targeting a remote where a v1 daemon is already running NEVER touches
// v1's install dir or socket. Without this test a future refactor that
// collapses to a shared dir passes every other unit test and re-introduces
// the original "stale daemon serves new client" bug.
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+v2hash')
}))
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
uploadDirectory: vi.fn().mockResolvedValue(undefined),
waitForSentinel: vi.fn().mockResolvedValue({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn(),
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
function makeMockConnection(): SshConnection {
return {
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
end: vi.fn()
}),
end: vi.fn()
})
} as unknown as SshConnection
}
describe('cross-version isolation', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('a v2 deploy never references the v1 install dir or v1 socket path', async () => {
const conn = makeMockConnection()
const mockExec = vi.mocked(execCommand)
// Simulated remote where:
// v1 dir = ~/.orca-remote/relay-0.1.0+v1hash/ (live daemon, listening)
// v2 dir = ~/.orca-remote/relay-0.1.0+v2hash/ (does not yet exist)
// The v2 client has fullVersion='0.1.0+v2hash' (from the fs mock above).
//
// We feed enough exec results to walk through the deploy: platform,
// $HOME, isRelayAlreadyInstalled probe, lock acquire, upload (no exec),
// npm install, finalize, socket probe, socket poll, then GC scan.
const responses: string[] = [
'Linux x86_64', // uname -sm
'/home/u', // echo $HOME
'MISSING', // isRelayAlreadyInstalled (v2 dir doesn't exist)
'', // mkdir -p remoteRelayDir (v2)
'OK', // mkdir lock OK
'MISSING', // re-probe after lock → still missing → proceed with install
'', // mkdir remoteDir (uploadRelay)
'', // chmod +x node
'', // npm install
'', // chmod prebuilds
'', // touch .install-complete (finalizeInstall)
'', // rm -rf .install-lock
'DEAD', // launch socket probe
'READY', // socket poll
// GC scan begins here
'relay-0.1.0+v1hash\nrelay-0.1.0+v2hash\n', // ls listing
'OPEN', // v1 lock probe (siblings only — current dir is v2)
'COMPLETE', // v1 .install-complete probe
'ALIVE' // v1 socket probe → live → SKIP (don't GC v1)
]
for (const r of responses) {
mockExec.mockResolvedValueOnce(r)
}
await deployAndLaunchRelay(conn)
const allCmds = [
...mockExec.mock.calls.map(([, c]) => c),
...vi.mocked(conn.exec).mock.calls.map(([c]) => c as string)
]
// (a) the v2 deploy creates dirs/files under relay-0.1.0+v2hash
expect(allCmds.some((c) => c.includes('relay-0.1.0+v2hash'))).toBe(true)
// (b) the v2 launch and connect socket paths are rooted in v2 dir, never v1
const launchAndConnectCmds = vi
.mocked(conn.exec)
.mock.calls.map(([c]) => c as string)
.filter((c) => c.includes('--sock-path'))
expect(launchAndConnectCmds.length).toBeGreaterThan(0)
for (const cmd of launchAndConnectCmds) {
expect(cmd).toContain('relay-0.1.0+v2hash')
expect(cmd).not.toContain('relay-0.1.0+v1hash')
}
// (c) GC observes v1 has a live socket and never issues an rm -rf for it
const v1RemoveCmds = allCmds.filter(
(c) => c.includes('rm -rf') && c.includes('relay-0.1.0+v1hash')
)
expect(v1RemoveCmds).toHaveLength(0)
})
})
+41
View File
@@ -2,6 +2,10 @@ import type { ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import {
RelayVersionMismatchError,
RELAY_EXIT_CODE_VERSION_MISMATCH
} from './ssh-relay-version-mismatch-error'
export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload'
@@ -14,6 +18,11 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
let stderrOutput = ''
let bufferedStdout = Buffer.alloc(0)
let closedAfterSentinel = false
// Why: ssh2 fires 'exit' BEFORE 'close' with the remote exit code.
// Capturing it here lets us translate exit-42 (relay handshake mismatch)
// into a typed RelayVersionMismatchError so the relay-lost retry loop
// can skip backoff for this terminal condition.
let lastExitCode: number | null = null
const timeout = setTimeout(() => {
if (!settled) {
@@ -27,6 +36,12 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
}
}, RELAY_SENTINEL_TIMEOUT_MS)
channel.on('exit', (code: number | null) => {
if (typeof code === 'number') {
lastExitCode = code
}
})
const MAX_BUFFER_CAP = 64 * 1024
channel.stderr.on('data', (data: Buffer) => {
stderrOutput += data.toString('utf-8')
@@ -71,6 +86,17 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
clearTimeout(timeout)
if (!settled) {
settled = true
// Why: a wire-handshake mismatch on the daemon side closes the
// socket; --connect prints the mismatch detail to stderr and exits
// with code 42 BEFORE writing the sentinel. Translate that into a
// typed RelayVersionMismatchError so the retry loop in ssh.ts can
// distinguish a recoverable transport drop from this terminal
// condition and skip backoff.
if (lastExitCode === RELAY_EXIT_CODE_VERSION_MISMATCH) {
const { expected, got } = parseHandshakeMismatchStderr(stderrOutput)
reject(new RelayVersionMismatchError(expected, got, stderrOutput.trim()))
return
}
reject(
new Error(
`Relay process exited before ready.${stderrOutput ? ` stderr: ${stderrOutput.trim()}` : ''}`
@@ -258,3 +284,18 @@ export async function resolveRemoteNodePath(conn: SshConnection): Promise<string
'Install Node.js on the remote and try again.'
)
}
// Why: extract the expected/got version pair from --connect's stderr line
// "Handshake mismatch: expected=<x>, daemon=<y>" so the typed error carries
// actionable detail. Best-effort: returns undefined fields if the regex
// doesn't match, preserving the raw stderr verbatim for diagnostics.
function parseHandshakeMismatchStderr(stderr: string): {
expected: string | undefined
got: string | undefined
} {
const match = /expected=([^,\s]+),\s*daemon=([^\s;]+)/.exec(stderr)
if (!match) {
return { expected: undefined, got: undefined }
}
return { expected: match[1], got: match[2] }
}
+40 -10
View File
@@ -4,9 +4,13 @@ vi.mock('electron', () => ({
app: { getAppPath: () => '/mock/app' }
}))
// Why: deployAndLaunchRelay now reads `${localRelayDir}/.version` upfront
// (per docs/ssh-relay-versioned-install-dirs.md). The fs mock must report
// the local relay package as existing AND return a content-hashed version
// string so readLocalFullVersion succeeds.
vi.mock('fs', () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn()
existsSync: vi.fn().mockReturnValue(true),
readFileSync: vi.fn().mockReturnValue('0.1.0+abcdef012345')
}))
vi.mock('./relay-protocol', () => ({
@@ -28,6 +32,19 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
// Why: the versioned-install module shells out to the remote for install
// state, lock acquisition, and GC. Tests stub these to no-ops so the deploy
// happy-path is exercised without a real SSH connection.
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+abcdef012345'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(true),
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
@@ -70,8 +87,6 @@ describe('deployAndLaunchRelay', () => {
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64') // uname -sm
mockExecCommand.mockResolvedValueOnce('/home/user') // echo $HOME
mockExecCommand.mockResolvedValueOnce('OK') // check relay exists
mockExecCommand.mockResolvedValueOnce('0.1.0') // version check
mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe
mockExecCommand.mockResolvedValueOnce('READY') // socket poll
@@ -85,8 +100,6 @@ describe('deployAndLaunchRelay', () => {
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
mockExecCommand.mockResolvedValueOnce('/home/user')
mockExecCommand.mockResolvedValueOnce('OK')
mockExecCommand.mockResolvedValueOnce('0.1.0')
mockExecCommand.mockResolvedValueOnce('DEAD') // socket probe
mockExecCommand.mockResolvedValueOnce('READY') // socket poll
@@ -97,6 +110,27 @@ describe('deployAndLaunchRelay', () => {
expect(progress).toContain('Starting relay...')
})
it('uses a content-hashed versioned remote install directory', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
mockExecCommand.mockResolvedValueOnce('/home/user')
mockExecCommand.mockResolvedValueOnce('DEAD')
mockExecCommand.mockResolvedValueOnce('READY')
await deployAndLaunchRelay(conn)
// The launch + connect commands include the versioned dir path.
const execArgs = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string)
const allCmds = [...execArgs, ...mockExecCommand.mock.calls.map(([, cmd]) => cmd)]
const sawVersionedDir = allCmds.some((cmd) =>
cmd.includes('/.orca-remote/relay-0.1.0+abcdef012345')
)
expect(sawVersionedDir).toBe(true)
const sawLegacyDir = allCmds.some((cmd) => cmd.includes('relay-v0.1.0'))
expect(sawLegacyDir).toBe(false)
})
it('has a 120-second overall timeout', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
@@ -125,14 +159,10 @@ describe('deployAndLaunchRelay', () => {
mockExecCommand
.mockResolvedValueOnce('Linux x86_64') // uname A
.mockResolvedValueOnce('/home/user') // $HOME A
.mockResolvedValueOnce('OK') // exists A
.mockResolvedValueOnce('0.1.0') // version A
.mockResolvedValueOnce('DEAD') // probe A
.mockResolvedValueOnce('READY') // poll A
.mockResolvedValueOnce('Linux x86_64') // uname B
.mockResolvedValueOnce('/home/user') // $HOME B
.mockResolvedValueOnce('OK') // exists B
.mockResolvedValueOnce('0.1.0') // version B
.mockResolvedValueOnce('DEAD') // probe B
.mockResolvedValueOnce('READY') // poll B
+69 -70
View File
@@ -3,12 +3,7 @@ import { existsSync } from 'fs'
import { app } from 'electron'
import { createHash } from 'crypto'
import type { SshConnection } from './ssh-connection'
import {
RELAY_VERSION,
RELAY_REMOTE_DIR,
parseUnameToRelayPlatform,
type RelayPlatform
} from './relay-protocol'
import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import {
uploadDirectory,
@@ -16,6 +11,15 @@ import {
execCommand,
resolveRemoteNodePath
} from './ssh-relay-deploy-helpers'
import {
readLocalFullVersion,
computeRemoteRelayDir,
isRelayAlreadyInstalled,
acquireInstallLock,
finalizeInstall,
abandonInstall,
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { shellEscape } from './ssh-connection-utils'
export type RelayDeployResult = {
@@ -79,6 +83,19 @@ async function deployAndLaunchRelayInner(
}
console.log(`[ssh-relay] Platform: ${platform}`)
const localRelayDir = getLocalRelayPath(platform)
if (!localRelayDir) {
throw new Error(
`Relay package for ${platform} not found locally. ` +
`This may be a packaging issue — try reinstalling Orca.`
)
}
// Why: read the content-hashed full version from the local build's .version
// file. Used as both the remote dir name and the wire-handshake version.
// Throws on missing/empty rather than silently falling back — see
// docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error".
const fullVersion = readLocalFullVersion(localRelayDir)
// Why: SFTP does not expand `~`, so we must resolve the remote home directory
// explicitly. `echo $HOME` over exec gives us the absolute path.
const remoteHome = (await execCommand(conn, 'echo $HOME')).trim()
@@ -89,24 +106,46 @@ async function deployAndLaunchRelayInner(
if (!remoteHome || !remoteHome.startsWith('/') || /[\u0000\r\n]/.test(remoteHome)) {
throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`)
}
const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}`
const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion)
console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`)
onProgress?.('Checking existing relay...')
const localRelayDir = getLocalRelayPath(platform)
const alreadyDeployed = await checkRelayExists(conn, remoteRelayDir, localRelayDir)
console.log(`[ssh-relay] Already deployed: ${alreadyDeployed}`)
const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir)
console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`)
if (!alreadyDeployed) {
onProgress?.('Uploading relay...')
console.log('[ssh-relay] Uploading relay...')
await uploadRelay(conn, platform, remoteRelayDir)
console.log('[ssh-relay] Upload complete')
if (!alreadyInstalled) {
// Why: serialize concurrent first-installs of the same version against
// each other via an atomic mkdir lock. The losing caller polls and either
// re-checks `alreadyInstalled` (now true) or steals a stale lock.
await acquireInstallLock(conn, remoteRelayDir)
try {
// Re-probe after acquiring the lock — a sibling installer may have
// finished while we were waiting.
if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir))) {
onProgress?.('Uploading relay...')
console.log('[ssh-relay] Uploading relay...')
await uploadRelay(conn, platform, remoteRelayDir, fullVersion)
console.log('[ssh-relay] Upload complete')
onProgress?.('Installing native dependencies...')
console.log('[ssh-relay] Installing node-pty...')
await installNativeDeps(conn, remoteRelayDir)
console.log('[ssh-relay] Native deps installed')
onProgress?.('Installing native dependencies...')
console.log('[ssh-relay] Installing node-pty...')
await installNativeDeps(conn, remoteRelayDir)
console.log('[ssh-relay] Native deps installed')
// Why: write `.install-complete` BEFORE releasing the lock so a
// sibling never observes the dir as "complete but locked", which
// would lead GC to skip a recoverable dir indefinitely.
await finalizeInstall(conn, remoteRelayDir)
} else {
await abandonInstall(conn, remoteRelayDir)
}
} catch (err) {
// Why: leave a partial install dir in place (no `.install-complete`)
// so the next deploy detects the partial and re-runs upload + install.
// Just release the lock so a concurrent caller can retry.
await abandonInstall(conn, remoteRelayDir)
throw err
}
}
onProgress?.('Starting relay...')
@@ -114,6 +153,11 @@ async function deployAndLaunchRelayInner(
const transport = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId)
console.log('[ssh-relay] Relay started successfully')
// Why: best-effort cleanup of unreferenced sibling version dirs. Errors
// are logged inside gcOldRelayVersions and never propagate, so a GC failure
// can never block the user from connecting.
void gcOldRelayVersions(conn, remoteHome, remoteRelayDir).catch(() => {})
return { transport, platform }
}
@@ -126,47 +170,11 @@ async function detectRemotePlatform(conn: SshConnection): Promise<RelayPlatform
return parseUnameToRelayPlatform(parts[0], parts[1])
}
async function checkRelayExists(
conn: SshConnection,
remoteDir: string,
localRelayDir: string | null
): Promise<boolean> {
try {
const output = await execCommand(
conn,
`test -f ${shellEscape(`${remoteDir}/relay.js`)} && echo OK || echo MISSING`
)
if (output.trim() !== 'OK') {
return false
}
// Why: compare against the local .version file content (which includes a
// content hash) so any code change triggers re-deploy, even without bumping
// RELAY_VERSION. Falls back to the bare RELAY_VERSION for safety.
let expectedVersion = RELAY_VERSION
if (localRelayDir) {
try {
const { readFileSync } = await import('fs')
expectedVersion = readFileSync(join(localRelayDir, '.version'), 'utf-8').trim()
} catch {
/* fall back to RELAY_VERSION */
}
}
const versionOutput = await execCommand(
conn,
`cat ${shellEscape(`${remoteDir}/.version`)} 2>/dev/null || echo MISSING`
)
return versionOutput.trim() === expectedVersion
} catch {
return false
}
}
async function uploadRelay(
conn: SshConnection,
platform: RelayPlatform,
remoteDir: string
remoteDir: string,
fullVersion: string
): Promise<void> {
const localRelayDir = getLocalRelayPath(platform)
if (!localRelayDir || !existsSync(localRelayDir)) {
@@ -191,25 +199,16 @@ async function uploadRelay(
// Make the node binary executable
await execCommand(conn, `chmod +x ${shellEscape(`${remoteDir}/node`)} 2>/dev/null; true`)
// Why: version marker includes a content hash so code changes trigger
// re-deploy even without bumping RELAY_VERSION. Read from the local build
// output so the remote marker matches exactly what checkRelayExists expects.
// Why: we write the version file via SFTP instead of a shell command to
// avoid shell injection — the version string could contain characters
// that break or escape single-quoted shell interpolation.
let versionString = RELAY_VERSION
const localVersionFile = join(localRelayDir, '.version')
if (existsSync(localVersionFile)) {
const { readFileSync } = await import('fs')
versionString = readFileSync(localVersionFile, 'utf-8').trim()
}
// Why: write `.version` via SFTP rather than shell to avoid quoting issues
// with content-hashed version strings. The remote daemon reads this same
// file on startup so the wire-handshake validates against it.
const versionSftp = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = versionSftp.createWriteStream(`${remoteDir}/.version`)
ws.on('close', resolve)
ws.on('error', reject)
ws.end(versionString)
ws.end(fullVersion)
})
} finally {
versionSftp.end()
+25
View File
@@ -10,6 +10,8 @@
import type { BrowserWindow } from 'electron'
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { isRelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import type { RelayVersionMismatchError } from './ssh-relay-version-mismatch-error'
import { SshChannelMultiplexer } from './ssh-channel-multiplexer'
import { SshPtyProvider } from '../providers/ssh-pty-provider'
import { SshFilesystemProvider } from '../providers/ssh-filesystem-provider'
@@ -47,6 +49,14 @@ export class SshRelaySession {
// up, the onStateChange reconnect path never fires. This callback lets
// ssh.ts wire up relay-level reconnect from outside the session.
private _onRelayLost: ((targetId: string) => void) | null = null
// Why: a wire-handshake mismatch is terminal — the daemon and client are at
// different versions, no amount of backoff retry will reconcile them. This
// separate callback lets ssh.ts surface the failure to the user and skip
// the relay-lost backoff loop entirely. Distinct from _onRelayLost because
// _onRelayLost expects a recoverable transport drop.
private _onTerminalRelayError:
| ((targetId: string, err: RelayVersionMismatchError) => void)
| null = null
private _onReady: ((targetId: string) => void) | null = null
private portScanner: PortScanner | null = null
@@ -67,6 +77,10 @@ export class SshRelaySession {
this._onRelayLost = cb
}
setOnTerminalRelayError(cb: (targetId: string, err: RelayVersionMismatchError) => void): void {
this._onTerminalRelayError = cb
}
setOnReady(cb: (targetId: string) => void): void {
this._onReady = cb
}
@@ -294,6 +308,17 @@ export class SshRelaySession {
if (this.abortController === abortController && !this.isDisposed()) {
this.teardownProviders('connection_lost')
}
// Why: a version-mismatch is terminal. Fire the typed callback so
// ssh.ts can surface a "please reconnect manually" notice and skip the
// relay-lost backoff loop entirely. We do NOT keep state at
// 'reconnecting' — there's no transient drop to recover from.
if (isRelayVersionMismatchError(err)) {
console.warn(
`[ssh-relay-session] Terminal relay version mismatch for ${this.targetId}: ${err.message}`
)
this._onTerminalRelayError?.(this.targetId, err)
return
}
// Why: stay in 'reconnecting' rather than reverting to 'ready', because
// the provider stack is already torn down. The SSH connection manager
// will fire another onStateChange when it reconnects again.
@@ -0,0 +1,34 @@
// Why: a unique error class so callers (in particular the relay-lost retry
// loop in src/main/ipc/ssh.ts) can branch on `instanceof
// RelayVersionMismatchError` and treat the failure as terminal — i.e. skip
// the exponential-backoff retry and surface a user-visible "please reconnect
// manually" error. Any other transport failure remains transiently retryable.
//
// Trigger: the remote `--connect` process exits with code 42 after the
// daemon's wire-level handshake reports a version mismatch. See
// docs/ssh-relay-versioned-install-dirs.md.
export class RelayVersionMismatchError extends Error {
readonly name = 'RelayVersionMismatchError'
constructor(
readonly expected: string | undefined,
readonly got: string | undefined,
readonly stderr?: string
) {
super(
`Remote relay version mismatch — expected=${expected ?? 'unknown'}, ` +
`daemon=${got ?? 'unknown'}. The remote daemon was launched against a different ` +
`relay binary than the local client expects. Please reconnect manually.`
)
}
}
export function isRelayVersionMismatchError(err: unknown): err is RelayVersionMismatchError {
return err instanceof RelayVersionMismatchError
}
// Why: the remote --connect process uses this exit code to signal the wire
// handshake failed because of a version mismatch. The mapping daemon ⇄ exit
// code 42 lives in src/relay/relay-handshake.ts (EXIT_CODE_VERSION_MISMATCH).
export const RELAY_EXIT_CODE_VERSION_MISMATCH = 42
@@ -0,0 +1,199 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('fs', () => ({
existsSync: vi.fn(),
readFileSync: vi.fn()
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn()
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`
}))
import { existsSync, readFileSync } from 'fs'
import {
readLocalFullVersion,
computeRemoteRelayDir,
isRelayAlreadyInstalled,
acquireInstallLock,
finalizeInstall,
abandonInstall,
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
const conn = {} as SshConnection
const mockExec = vi.mocked(execCommand)
const mockExists = vi.mocked(existsSync)
const mockRead = vi.mocked(readFileSync)
describe('readLocalFullVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns trimmed contents of the .version file', () => {
mockExists.mockReturnValue(true)
mockRead.mockReturnValue('0.1.0+deadbeef\n')
expect(readLocalFullVersion('/local/relay')).toBe('0.1.0+deadbeef')
})
it('throws an actionable error when the .version file is missing', () => {
mockExists.mockReturnValue(false)
expect(() => readLocalFullVersion('/local/relay')).toThrow(/missing its version marker/)
})
it('throws when the .version file is empty', () => {
mockExists.mockReturnValue(true)
mockRead.mockReturnValue(' \n')
expect(() => readLocalFullVersion('/local/relay')).toThrow(/is empty/)
})
})
describe('computeRemoteRelayDir', () => {
it('joins remoteHome with .orca-remote and the version-keyed dir name', () => {
expect(computeRemoteRelayDir('/home/u', '0.1.0+abc')).toBe(
'/home/u/.orca-remote/relay-0.1.0+abc'
)
})
})
describe('isRelayAlreadyInstalled', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns true only when the OK probe succeeds', async () => {
mockExec.mockResolvedValueOnce('OK')
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(true)
})
it('returns false when the probe reports MISSING', async () => {
mockExec.mockResolvedValueOnce('MISSING')
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
})
it('returns false on exec error', async () => {
mockExec.mockRejectedValueOnce(new Error('boom'))
expect(await isRelayAlreadyInstalled(conn, '/r')).toBe(false)
})
it('checks for relay.js AND .install-complete in addition to the dir', async () => {
mockExec.mockResolvedValueOnce('OK')
await isRelayAlreadyInstalled(conn, '/r')
const cmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(cmd).toContain('relay.js')
expect(cmd).toContain('.install-complete')
})
})
describe('acquireInstallLock', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns when mkdir reports OK', async () => {
// 1st call: mkdir -p remoteRelayDir
// 2nd call: mkdir lockDir → OK
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('OK')
await acquireInstallLock(conn, '/r')
expect(mockExec).toHaveBeenCalledTimes(2)
})
it('finalizeInstall writes .install-complete then removes the lock', async () => {
mockExec.mockResolvedValueOnce('').mockResolvedValueOnce('')
await finalizeInstall(conn, '/r')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds[0]).toContain('touch')
expect(cmds[0]).toContain('.install-complete')
expect(cmds[1]).toContain('rm -rf')
expect(cmds[1]).toContain('.install-lock')
})
it('abandonInstall removes the lock without writing the sentinel', async () => {
mockExec.mockResolvedValueOnce('')
await abandonInstall(conn, '/r')
const cmd = mockExec.mock.calls[0]?.[1] ?? ''
expect(cmd).toContain('rm -rf')
expect(cmd).toContain('.install-lock')
expect(cmd).not.toContain('.install-complete')
})
})
describe('gcOldRelayVersions', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('removes a sibling that is complete, unlocked, and has no live socket', async () => {
// ls listing
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\nrelay-0.1.0+bbb\n')
// For sibling "aaa": LOCKED probe → OPEN, COMPLETE probe → COMPLETE, sock probe → empty (no ALIVE), then rm -rf
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const lastCmd = mockExec.mock.calls.at(-1)?.[1] ?? ''
expect(lastCmd).toContain('rm -rf')
expect(lastCmd).toContain('relay-0.1.0+aaa')
})
it('skips siblings that are missing .install-complete (mid-install or partial)', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN') // not locked
.mockResolvedValueOnce('PARTIAL') // missing .install-complete
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('skips siblings whose .install-lock is held', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec.mockResolvedValueOnce('LOCKED')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('skips siblings with a live relay-*.sock', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('ALIVE')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('does not consider the current dir as a GC candidate', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa')
expect(mockExec.mock.calls.length).toBe(1) // only the listing
})
it('ignores entries that do not match the relay version dir regex (allowlist)', async () => {
mockExec.mockResolvedValueOnce('logs\nbackup\nrelay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+bbb')
const cmds = mockExec.mock.calls.map(([, c]) => c)
const rmCmds = cmds.filter((c) => c.includes('rm -rf'))
expect(rmCmds).toHaveLength(1)
expect(rmCmds[0]).toContain('relay-0.1.0+aaa')
expect(rmCmds[0]).not.toContain('logs')
expect(rmCmds[0]).not.toContain('backup')
})
})
+317
View File
@@ -0,0 +1,317 @@
// Versioned-install plumbing for the remote relay.
//
// Why this exists: the relay used to install into a single shared directory
// (~/.orca-remote/relay-v0.1.0) which the deploy step would overwrite in place
// on every cross-version push. A daemon already loaded into memory then served
// new clients off rewritten on-disk code, producing protocol drift and a
// reconnect loop. We now install each (RELAY_VERSION + content-hash) bundle
// into its own directory and never mutate it after the install finishes,
// matching VS Code's `~/.vscode-server/bin/<commit>/` layout.
//
// See: docs/ssh-relay-versioned-install-dirs.md
import { join } from 'path'
import { existsSync, readFileSync } from 'fs'
import type { SshConnection } from './ssh-connection'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { execCommand } from './ssh-relay-deploy-helpers'
import { shellEscape } from './ssh-connection-utils'
// Why: the GC pass and the version-dir parser must agree on what counts as a
// relay install dir. Single source of truth for both. The pattern matches the
// new layout `relay-${RELAY_VERSION}+${hash}` and the legacy `relay-v${VERSION}`
// so the GC eventually drains the old layout once its daemons idle out.
const RELAY_VERSION_DIR_REGEX = /^relay-(v?\d+\.\d+\.\d+(\+[0-9a-f]+)?)$/
const INSTALL_LOCK_NAME = '.install-lock'
const INSTALL_COMPLETE_NAME = '.install-complete'
const INSTALL_LOCK_POLL_MS = 1_000
const INSTALL_LOCK_TIMEOUT_MS = 120_000
// Why: a stale lock dir from a crashed installer must be recoverable without
// user intervention. After the timeout we check the lock's mtime; if it's
// older than this window the previous installer is assumed dead and we steal
// the lock. 2 minutes is well above a normal `npm install node-pty` runtime
// (1060s on slow hosts) so a slow concurrent installer is not falsely
// declared dead.
const INSTALL_LOCK_STALE_MS = 120_000
/**
* Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020")
* from `${localRelayDir}/.version`. Throws on missing/empty so the caller
* never silently falls back to a path where a daemon from a different code
* generation may already be running — that fallback is the failure mode the
* versioned-install design exists to prevent.
*/
export function readLocalFullVersion(localRelayDir: string): string {
const versionFile = join(localRelayDir, '.version')
if (!existsSync(versionFile)) {
throw new Error(
`Orca's local relay build is missing its version marker at ${versionFile}. ` +
`This usually indicates a packaging or build problem; reinstall Orca.`
)
}
const v = readFileSync(versionFile, 'utf-8').trim()
if (!v) {
throw new Error(
`Orca's local relay version marker at ${versionFile} is empty. ` +
`This usually indicates a packaging or build problem; reinstall Orca.`
)
}
return v
}
/**
* Compute the absolute remote install directory for a given content-hashed
* version. The format is `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`.
*/
export function computeRemoteRelayDir(remoteHome: string, fullVersion: string): string {
return `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`
}
/**
* Probe whether a fully-installed relay already exists at remoteRelayDir.
*
* "Fully installed" means: the directory exists, contains relay.js, AND
* contains the .install-complete sentinel written at the end of a successful
* install. A directory missing .install-complete is either mid-install (lock
* held) or a crashed-install partial — either way we re-run the deploy.
*/
export async function isRelayAlreadyInstalled(
conn: SshConnection,
remoteRelayDir: string
): Promise<boolean> {
try {
const probe = await execCommand(
conn,
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(`${remoteRelayDir}/relay.js`)} ` +
`&& test -f ${shellEscape(`${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`)} ` +
`&& echo OK || echo MISSING`
)
return probe.trim() === 'OK'
} catch {
return false
}
}
/**
* Acquire the per-version install lock via atomic `mkdir`. Returns when the
* caller owns the lock; throws if the lock could not be acquired within
* INSTALL_LOCK_TIMEOUT_MS even after one stale-lock recovery attempt.
*
* Why mkdir: POSIX `mkdir` is atomic and fails with EEXIST if the dir already
* exists, giving us a free mutex. A second concurrent caller polls and
* eventually either acquires the lock or steals it after the stale window.
*/
export async function acquireInstallLock(
conn: SshConnection,
remoteRelayDir: string
): Promise<void> {
const lockDir = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
// Why: the parent dir may not exist yet on a first install. mkdir -p is
// safe to run multiple times — it's a no-op if the dir already exists.
await execCommand(conn, `mkdir -p ${shellEscape(remoteRelayDir)}`)
const start = Date.now()
let recoveredOnce = false
while (true) {
try {
const result = await execCommand(
conn,
`mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
)
if (result.trim().endsWith('OK')) {
return
}
} catch {
/* mkdir failed with non-zero — fall through to BUSY treatment */
}
if (Date.now() - start >= INSTALL_LOCK_TIMEOUT_MS) {
if (recoveredOnce) {
throw new Error(
`Could not acquire relay install lock at ${lockDir} after ${
INSTALL_LOCK_TIMEOUT_MS / 1000
}s; another install is in progress or the lock is wedged.`
)
}
// Stale-lock recovery: if the lock dir's mtime is older than the stale
// window, the previous installer crashed. Steal it and retry once.
const ageOk = await isLockStale(conn, lockDir)
if (ageOk) {
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
await execCommand(conn, `rm -rf ${shellEscape(lockDir)}`).catch(() => {})
recoveredOnce = true
continue
}
throw new Error(
`Could not acquire relay install lock at ${lockDir} after ${
INSTALL_LOCK_TIMEOUT_MS / 1000
}s and the lock is not yet stale.`
)
}
await new Promise((r) => setTimeout(r, INSTALL_LOCK_POLL_MS))
}
}
async function isLockStale(conn: SshConnection, lockDir: string): Promise<boolean> {
try {
// Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS).
// We try GNU first, then BSD; both produce a Unix epoch in seconds on
// stdout. If both fail we conservatively treat the lock as not stale.
const out = await execCommand(
conn,
`stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo`
)
const mtimeSec = parseInt(out.trim(), 10)
if (!Number.isFinite(mtimeSec)) {
return false
}
const ageMs = Date.now() - mtimeSec * 1000
return ageMs > INSTALL_LOCK_STALE_MS
} catch {
return false
}
}
/**
* Mark the install as complete and release the lock. Sentinel ordering is:
* write `.install-complete` FIRST, then remove `.install-lock`. This ensures
* a sibling dir is never observed by GC as "complete but locked", which
* would lead GC to skip a recoverable dir indefinitely.
*/
export async function finalizeInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
const sentinel = `${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
await execCommand(conn, `touch ${shellEscape(sentinel)}`)
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
}
/**
* Release the install lock without writing the completion sentinel. Called
* from the failure path so the dir remains a recoverable partial that the
* next deploy detects (alreadyInstalled=false) and re-runs upload+install.
*/
export async function abandonInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
}
/**
* Garbage-collect old version directories. Removes a sibling dir under
* `${remoteHome}/${RELAY_REMOTE_DIR}/` only if ALL of:
*
* - it matches the relay-version-dir regex (allowlist)
* - it is NOT the current version dir
* - it has no live `relay-*.sock` (pgrep + connectability probe)
* - it contains `.install-complete` (a fully-installed dir, not a partial)
* - it does NOT contain `.install-lock` (no in-progress install)
*
* Best-effort: any error is logged and swallowed; GC must never block the
* user from connecting.
*/
export async function gcOldRelayVersions(
conn: SshConnection,
remoteHome: string,
currentDirAbsPath: string
): Promise<void> {
const baseDir = `${remoteHome}/${RELAY_REMOTE_DIR}`
const currentDirName = currentDirAbsPath.split('/').filter(Boolean).pop() ?? ''
let listing: string
try {
listing = await execCommand(conn, `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`)
} catch {
return
}
const candidates = listing
.split('\n')
.map((s) => s.trim())
.filter(Boolean)
.filter((name) => RELAY_VERSION_DIR_REGEX.test(name))
.filter((name) => name !== currentDirName)
if (candidates.length === 0) {
return
}
const removed: string[] = []
const kept: string[] = []
for (const name of candidates) {
const dir = `${baseDir}/${name}`
try {
const safe = await isCandidateSafeToRemove(conn, dir)
if (!safe) {
kept.push(name)
continue
}
await execCommand(conn, `rm -rf ${shellEscape(dir)}`)
removed.push(name)
} catch (err) {
console.warn(
`[ssh-relay] GC failed for ${dir}: ${err instanceof Error ? err.message : String(err)}`
)
kept.push(name)
}
}
if (removed.length > 0) {
const keptSuffix = kept.length > 0 ? ` (kept: ${kept.join(', ')})` : ''
console.log(
`[ssh-relay] GC: removed ${removed.length} stale version dir(s): ${removed.join(', ')}${keptSuffix}`
)
}
}
async function isCandidateSafeToRemove(conn: SshConnection, dir: string): Promise<boolean> {
// Why: skip mid-install or crashed-install partial dirs. A locked dir is
// unsafe because removing it would corrupt a concurrent installer; a dir
// missing .install-complete is either locked (handled above) or a crashed
// partial that the next deploy will recover.
const lockProbe = await execCommand(
conn,
`test -d ${shellEscape(`${dir}/${INSTALL_LOCK_NAME}`)} && echo LOCKED || echo OPEN`
).catch(() => 'OPEN')
if (lockProbe.trim() === 'LOCKED') {
return false
}
const completeProbe = await execCommand(
conn,
`test -f ${shellEscape(`${dir}/${INSTALL_COMPLETE_NAME}`)} && echo COMPLETE || echo PARTIAL`
).catch(() => 'PARTIAL')
if (completeProbe.trim() !== 'COMPLETE') {
// Why: legacy dirs from before `.install-complete` was introduced are
// missing the sentinel. Treat them as recoverable partials (the new
// deploy targeting their version dir would re-run install). The legacy
// dir name (`relay-v0.1.0`) is NOT considered safe to remove here unless
// the legacy daemon has died — and the dead-daemon check below also
// gates on `.install-complete`. Net effect: legacy dirs persist until a
// future migration explicitly drains them. Acceptable: no daemon there
// is reachable by the new client, just disk usage.
return false
}
const sockAlive = await hasLiveRelaySocket(conn, dir)
if (sockAlive) {
return false
}
return true
}
async function hasLiveRelaySocket(conn: SshConnection, dir: string): Promise<boolean> {
try {
// Why: `ls -1 dir/relay-*.sock 2>/dev/null` lists socket files. For each,
// we test -S to confirm it's a socket inode. We do NOT attempt to open
// the socket here — `test -S` is sufficient for the GC decision and a
// connect-and-close probe would race with a daemon that's about to idle.
const out = await execCommand(
conn,
`for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
`[ -S "$f" ] && echo ALIVE && break; ` +
`done; true`
)
return out.includes('ALIVE')
} catch {
return false
}
}
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest'
import {
MessageType,
HEADER_LENGTH,
FrameDecoder,
encodeHandshakeFrame,
parseHandshakeMessage,
type DecodedFrame
} from './protocol'
describe('handshake framing', () => {
it('round-trips an orca-relay-handshake envelope through the existing framing', () => {
const sent = encodeHandshakeFrame({
type: 'orca-relay-handshake',
version: '0.1.0+deadbeef'
})
expect(sent[0]).toBe(MessageType.Handshake)
expect(sent.length).toBeGreaterThan(HEADER_LENGTH)
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder((f) => frames.push(f))
decoder.feed(sent)
expect(frames).toHaveLength(1)
expect(frames[0].type).toBe(MessageType.Handshake)
const msg = parseHandshakeMessage(frames[0].payload)
expect(msg).toEqual({ type: 'orca-relay-handshake', version: '0.1.0+deadbeef' })
})
it('round-trips an orca-relay-handshake-ok reply', () => {
const sent = encodeHandshakeFrame({
type: 'orca-relay-handshake-ok',
version: '0.1.0+deadbeef'
})
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder((f) => frames.push(f))
decoder.feed(sent)
const msg = parseHandshakeMessage(frames[0].payload)
expect(msg).toEqual({ type: 'orca-relay-handshake-ok', version: '0.1.0+deadbeef' })
})
it('round-trips an orca-relay-handshake-mismatch reply', () => {
const sent = encodeHandshakeFrame({
type: 'orca-relay-handshake-mismatch',
expected: '0.1.0+aaa',
got: '0.1.0+bbb'
})
const frames: DecodedFrame[] = []
const decoder = new FrameDecoder((f) => frames.push(f))
decoder.feed(sent)
const msg = parseHandshakeMessage(frames[0].payload)
expect(msg).toEqual({
type: 'orca-relay-handshake-mismatch',
expected: '0.1.0+aaa',
got: '0.1.0+bbb'
})
})
it('rejects payloads with unknown type', () => {
const bogus = Buffer.from(JSON.stringify({ type: 'orca-something-else', version: 'x' }))
expect(() => parseHandshakeMessage(bogus)).toThrow(/Unknown handshake type/)
})
it('handshake frames use a distinct MessageType from Regular and KeepAlive', () => {
expect(MessageType.Handshake).not.toBe(MessageType.Regular)
expect(MessageType.Handshake).not.toBe(MessageType.KeepAlive)
})
})
+28
View File
@@ -9,9 +9,37 @@ export const MAX_MESSAGE_SIZE = 16 * 1024 * 1024
export const MessageType = {
Regular: 1,
Handshake: 2,
KeepAlive: 9
} as const
// Why: a pre-dispatcher envelope on a freshly-accepted Unix socket. The daemon
// reads exactly one Handshake frame before attaching the JSON-RPC dispatcher,
// to refuse mismatched-version --connect bridges that would otherwise drive a
// stale daemon.
export type HandshakeMessage =
| { type: 'orca-relay-handshake'; version: string }
| { type: 'orca-relay-handshake-ok'; version: string }
| { type: 'orca-relay-handshake-mismatch'; expected: string; got: string }
export function encodeHandshakeFrame(msg: HandshakeMessage): Buffer {
const payload = Buffer.from(JSON.stringify(msg), 'utf-8')
return encodeFrame(MessageType.Handshake, 0, 0, payload)
}
export function parseHandshakeMessage(payload: Buffer): HandshakeMessage {
const msg = JSON.parse(payload.toString('utf-8')) as HandshakeMessage
const t = (msg as { type?: string }).type
if (
t !== 'orca-relay-handshake' &&
t !== 'orca-relay-handshake-ok' &&
t !== 'orca-relay-handshake-mismatch'
) {
throw new Error(`Unknown handshake type: ${t}`)
}
return msg
}
export const KEEPALIVE_SEND_MS = 5_000
export const TIMEOUT_MS = 20_000
+218
View File
@@ -0,0 +1,218 @@
// Wire-level handshake helpers for the Orca relay.
//
// Why this lives in its own module: oxlint enforces a 300-line limit (with
// blanks/comments stripped) on .ts files, and relay.ts already runs near that
// limit. Splitting the version-handshake plumbing into a sibling module keeps
// relay.ts focused on the daemon-lifecycle wiring and makes the handshake
// independently unit-testable.
import { dirname, join } from 'path'
import { existsSync, readFileSync } from 'fs'
import type { Socket } from 'net'
import {
RELAY_VERSION,
MessageType,
FrameDecoder,
encodeHandshakeFrame,
parseHandshakeMessage,
type DecodedFrame
} from './protocol'
// Why: a unique exit code reserved for the wire-level version-mismatch terminal
// condition. The client (waitForSentinel + ssh.ts) maps this exit code to a
// non-retryable RelayVersionMismatchError so _onRelayLost skips the backoff
// loop. Any other non-zero exit is treated as a transient transport error.
export const EXIT_CODE_VERSION_MISMATCH = 42
// Why: the deploy step writes a content-hashed version marker (e.g.
// "0.1.0+0a5fe134d020") into ${remoteDir}/.version next to relay.js. Read it
// from the directory the running script lives in (NOT process.cwd()) so test
// spawns from arbitrary working dirs still report a coherent version. Falls
// back to bare RELAY_VERSION if the marker is missing — the wire handshake
// will then refuse a fresh client whose .version differs.
export function readLaunchVersion(): string {
try {
const entry = process.argv[1]
const dir = entry ? dirname(entry) : process.cwd()
const versionFile = join(dir, '.version')
if (existsSync(versionFile)) {
const v = readFileSync(versionFile, 'utf-8').trim()
if (v) {
return v
}
}
} catch {
/* fall through */
}
return RELAY_VERSION
}
// ── Daemon side ─────────────────────────────────────────────────────
export type DaemonHandshakeCallbacks = {
onAccepted: (sock: Socket) => void
launchVersion: string
}
// Why: pre-dispatcher version handshake. The daemon reads exactly one
// Handshake-typed frame off this freshly-accepted socket BEFORE the JSON-RPC
// dispatcher pipe is attached. Mismatch means the connecting bridge was
// launched against a different relay.js version than the daemon was; we close
// the socket so the bridge exits 42 and the client surfaces a typed error
// instead of looping over the dispatcher.
export function setupDaemonHandshake(sock: Socket, cb: DaemonHandshakeCallbacks): void {
const decoder = new FrameDecoder(
(frame: DecodedFrame) => {
handleDaemonHandshakeFrame(sock, frame, cb)
},
(err) => {
process.stderr.write(`[relay] Handshake decode error: ${err.message}\n`)
sock.destroy()
}
)
const onHandshakeData = (chunk: Buffer): void => {
decoder.feed(chunk)
}
sock.on('data', onHandshakeData)
;(sock as Socket & { __orcaOnHandshake?: typeof onHandshakeData }).__orcaOnHandshake =
onHandshakeData
}
export function detachHandshakeListener(sock: Socket): void {
const tagged = sock as Socket & { __orcaOnHandshake?: (chunk: Buffer) => void }
if (tagged.__orcaOnHandshake) {
sock.removeListener('data', tagged.__orcaOnHandshake)
delete tagged.__orcaOnHandshake
}
}
function handleDaemonHandshakeFrame(
sock: Socket,
frame: DecodedFrame,
cb: DaemonHandshakeCallbacks
): void {
if (frame.type !== MessageType.Handshake) {
process.stderr.write(
`[relay] Protocol violation pre-handshake: type=${frame.type}; closing socket\n`
)
sock.destroy()
return
}
let msg: ReturnType<typeof parseHandshakeMessage>
try {
msg = parseHandshakeMessage(frame.payload)
} catch (err) {
process.stderr.write(
`[relay] Could not parse handshake: ${(err as Error).message}; closing socket\n`
)
sock.destroy()
return
}
if (msg.type !== 'orca-relay-handshake') {
process.stderr.write(
`[relay] Unexpected handshake type from client: ${msg.type}; closing socket\n`
)
sock.destroy()
return
}
if (msg.version !== cb.launchVersion) {
process.stderr.write(
`[relay] Handshake mismatch: own=${cb.launchVersion}, client=${msg.version}; closing socket\n`
)
try {
sock.write(
encodeHandshakeFrame({
type: 'orca-relay-handshake-mismatch',
expected: cb.launchVersion,
got: msg.version
})
)
} catch {
/* best-effort — close+exit-42 still wins */
}
sock.end()
return
}
process.stderr.write(`[relay] Handshake OK from version=${msg.version}\n`)
sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake-ok', version: cb.launchVersion }))
detachHandshakeListener(sock)
cb.onAccepted(sock)
}
// ── --connect side ──────────────────────────────────────────────────
export type ConnectHandshakeCallbacks = {
onAccepted: () => void
}
// Why: the wire-level version handshake from the bridge side. Before we attach
// the bidirectional pipe (and before we write RELAY_SENTINEL to stdout to
// unblock the client), we send a Handshake-typed frame carrying our version
// and wait for the daemon's Handshake response. This is defense-in-depth on
// top of the versioned-install-dir layout: a corrupt/missing .version, hash
// collision, or legacy-fallback path would otherwise let a v2 bridge drive a
// v1 daemon. VS Code's remoteExtensionHostAgentServer.ts:340 does the same
// check.
export function runConnectHandshake(
sock: Socket,
myVersion: string,
cb: ConnectHandshakeCallbacks
): void {
let handshakeDone = false
const decoder = new FrameDecoder(
(frame: DecodedFrame) => {
if (handshakeDone) {
return
}
if (frame.type !== MessageType.Handshake) {
process.stderr.write(
`[relay-connect] Protocol violation: expected Handshake frame, got type=${frame.type}\n`
)
sock.destroy()
process.exit(1)
}
let msg: ReturnType<typeof parseHandshakeMessage>
try {
msg = parseHandshakeMessage(frame.payload)
} catch (err) {
process.stderr.write(
`[relay-connect] Could not parse handshake reply: ${(err as Error).message}\n`
)
sock.destroy()
process.exit(1)
}
if (msg.type === 'orca-relay-handshake-ok') {
process.stderr.write(`[relay-connect] Handshake OK at version=${msg.version}\n`)
handshakeDone = true
sock.removeAllListeners('data')
cb.onAccepted()
return
}
if (msg.type === 'orca-relay-handshake-mismatch') {
process.stderr.write(
`[relay-connect] Handshake mismatch: expected=${msg.expected}, daemon=${msg.got}; exiting ${EXIT_CODE_VERSION_MISMATCH}\n`
)
sock.destroy()
process.exit(EXIT_CODE_VERSION_MISMATCH)
}
process.stderr.write(`[relay-connect] Unexpected handshake type: ${msg.type}\n`)
sock.destroy()
process.exit(1)
},
(err) => {
process.stderr.write(`[relay-connect] Handshake decode error: ${err.message}\n`)
sock.destroy()
process.exit(1)
}
)
sock.on('data', (chunk: Buffer) => {
if (!handshakeDone) {
decoder.feed(chunk)
}
})
sock.write(encodeHandshakeFrame({ type: 'orca-relay-handshake', version: myVersion }))
}
+64 -60
View File
@@ -15,6 +15,7 @@ import { homedir } from 'os'
import { resolve, join } from 'path'
import { unlinkSync, existsSync } from 'fs'
import { RELAY_SENTINEL } from './protocol'
import { readLaunchVersion, runConnectHandshake, setupDaemonHandshake } from './relay-handshake'
import { RelayDispatcher } from './dispatcher'
import { RelayContext } from './context'
import { PtyHandler } from './pty-handler'
@@ -68,6 +69,7 @@ function parseArgs(argv: string[]): {
// that owns the PTY sessions.
function runConnectMode(sockPath: string): void {
const myVersion = readLaunchVersion()
const sock = createConnection({ path: sockPath })
const connectTimeout = setTimeout(() => {
@@ -78,13 +80,19 @@ function runConnectMode(sockPath: string): void {
sock.on('connect', () => {
clearTimeout(connectTimeout)
// Why: the client-side waitForSentinel expects this exact string
// before it starts sending framed data. Emitting it here lets the
// deploy code use the same sentinel-detection path for both fresh
// launches and reconnects.
process.stdout.write(RELAY_SENTINEL)
process.stdin.pipe(sock)
sock.pipe(process.stdout)
runConnectHandshake(sock, myVersion, {
onAccepted: () => {
// Why: RELAY_SENTINEL must be written AFTER the handshake passes; if it
// were written earlier, waitForSentinel on the client would resolve
// and start sending JSON-RPC over a socket the daemon was about to
// close on mismatch — surfacing as a generic channel drop and
// re-entering the backoff loop. Sequencing it post-handshake makes
// mismatch a clean exit-42 path with no false-positive sentinel.
process.stdout.write(RELAY_SENTINEL)
process.stdin.pipe(sock)
sock.pipe(process.stdout)
}
})
})
// Why: when the SSH channel closes, stdout becomes a broken pipe.
@@ -210,77 +218,73 @@ function main(): void {
let activeSocket: Socket | null = null
let socketServer: Server | null = null
const launchVersion = readLaunchVersion()
function attachAcceptedSocket(sock: Socket): void {
// Why: only one client at a time. If a second reconnect arrives (e.g.
// user restarts again quickly), close the stale bridge so the new one
// takes over cleanly. We null activeSocket BEFORE destroying so the old
// socket's close handler sees it's been replaced and skips starting the
// grace timer.
if (activeSocket) {
process.stderr.write('[relay] Replacing existing socket client with new connection\n')
const replaced = activeSocket
activeSocket = null
replaced.destroy()
}
activeSocket = sock
// Why: stdin's data listener is still registered from the initial
// connection. If the old SSH channel hasn't fully closed yet (TCP FIN
// delayed), buffered stdin data would interleave with the new socket
// client's frames, corrupting the frame decoder.
process.stdin.pause()
process.stdin.removeAllListeners('data')
ptyHandler.cancelGraceTimer()
dispatcher.setWrite((data) => {
if (!sock.destroyed) {
sock.write(data)
}
})
sock.on('data', (chunk: Buffer) => {
if (activeSocket !== sock) {
return
}
ptyHandler.cancelGraceTimer()
dispatcher.feed(chunk)
})
}
function startSocketServer(): Server {
cleanupSocket(sockPath)
const server = createServer((sock) => {
// Why: only one client at a time. If a second reconnect arrives
// (e.g. user restarts again quickly), close the stale bridge so the
// new one takes over cleanly. We null activeSocket BEFORE destroying
// so the old socket's close handler sees it's been replaced and
// skips starting the grace timer.
if (activeSocket) {
process.stderr.write('[relay] Replacing existing socket client with new connection\n')
const replaced = activeSocket
activeSocket = null
replaced.destroy()
}
activeSocket = sock
// Why: pre-dispatcher version handshake — see relay-handshake.ts.
setupDaemonHandshake(sock, { launchVersion, onAccepted: attachAcceptedSocket })
// Why: stdin's data listener is still registered from the initial
// connection. If the old SSH channel hasn't fully closed yet (TCP
// FIN delayed), buffered stdin data would interleave with the new
// socket client's frames, corrupting the frame decoder.
process.stdin.pause()
process.stdin.removeAllListeners('data')
ptyHandler.cancelGraceTimer()
dispatcher.setWrite((data) => {
if (!sock.destroyed) {
sock.write(data)
}
})
sock.on('data', (chunk: Buffer) => {
if (activeSocket !== sock) {
return
}
ptyHandler.cancelGraceTimer()
dispatcher.feed(chunk)
})
// Why: when the --connect bridge's SSH channel dies, stdin.pipe(sock)
// calls sock.end(), sending FIN to the relay. Without this handler
// the relay-side socket stays half-open — the relay keeps writing
// pty.data frames that the bridge can no longer forward, silently
// dropping output until the next --connect replaces the socket.
// Destroying on 'end' ensures the 'close' handler fires promptly.
// Why: when --connect's SSH channel dies, stdin.pipe(sock) calls
// sock.end(), sending FIN to the relay. Destroying on 'end' ensures
// the 'close' handler fires promptly so the daemon can enter grace.
sock.on('end', () => {
if (!sock.destroyed) {
sock.destroy()
}
})
sock.on('error', () => {
// Why: Node emits 'error' then 'close'. The close handler owns
// activeSocket cleanup and grace startup.
})
sock.on('close', () => {
// Why: only start the grace timer if THIS socket is still the
// active one. If it was replaced by a newer connection (see
// above), activeSocket was already nulled and reassigned — starting
// the grace timer here would incorrectly begin shutdown while a
// live client is connected.
if (activeSocket === sock) {
activeSocket = null
dispatcher.invalidateClient()
startGrace()
}
})
sock.on('error', () => {
// Why: Node emits 'error' then 'close'. The close handler owns
// activeSocket cleanup and grace startup; clearing activeSocket here
// would make close skip the grace timer and leave the relay alive
// indefinitely with no client.
})
})
// Why: setting umask to 0o177 BEFORE listen ensures the socket is