Skip to content
48 changes: 48 additions & 0 deletions src/main/ssh/ssh-relay-handshake-mismatch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it } from 'vitest'
import { buildRelayHandshakeRefusalError } from './ssh-relay-handshake-mismatch'
import {
RelayVersionMismatchError,
RELAY_EXIT_CODE_VERSION_MISMATCH
} from './ssh-relay-version-mismatch-error'
import {
RelayCredentialMismatchError,
RELAY_EXIT_CODE_CREDENTIAL_MISMATCH
} from './ssh-relay-credential-mismatch-error'

describe('buildRelayHandshakeRefusalError', () => {
// The bridge's mismatch line now carries `daemonProtocol=` and `ours=` after the version, so a
// `daemon=` capture that merely stops at `;` swallows the separating comma into the version.
it('names the daemon version without the comma that follows it', () => {
const error = buildRelayHandshakeRefusalError(
RELAY_EXIT_CODE_VERSION_MISMATCH,
'[relay-connect] Handshake mismatch: expected=0.1.0+aaa, daemon=0.1.0+bbb, ' +
'daemonProtocol=6, ours=1; exiting 42\n'
)
expect(error).toBeInstanceOf(RelayVersionMismatchError)
expect(error).toMatchObject({ expected: '0.1.0+aaa', got: '0.1.0+bbb' })
})

// A relay from before the protocol fields still emits the short form.
it('still reads the pre-negotiation form of the line', () => {
const error = buildRelayHandshakeRefusalError(
RELAY_EXIT_CODE_VERSION_MISMATCH,
'[relay-connect] Handshake mismatch: expected=0.1.0+aaa, daemon=0.1.0+bbb; exiting 42\n'
)
expect(error).toMatchObject({ expected: '0.1.0+aaa', got: '0.1.0+bbb' })
})

it('reports an unparseable mismatch line without inventing versions', () => {
const error = buildRelayHandshakeRefusalError(RELAY_EXIT_CODE_VERSION_MISMATCH, 'nothing here')
expect(error).toMatchObject({ expected: undefined, got: undefined })
})

it('keeps a refused credential distinct from a version skew', () => {
expect(
buildRelayHandshakeRefusalError(RELAY_EXIT_CODE_CREDENTIAL_MISMATCH, 'refused')
).toBeInstanceOf(RelayCredentialMismatchError)
})

it('returns nothing for an exit code that encodes no refusal', () => {
expect(buildRelayHandshakeRefusalError(1, 'crashed')).toBeNull()
})
})
4 changes: 3 additions & 1 deletion src/main/ssh/ssh-relay-handshake-mismatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,13 @@ export function buildRelayHandshakeRefusalError(

// Why: extract the expected/got version pair from --connect's stderr line
// "Handshake mismatch: expected=<x>, daemon=<y>" so diagnostics name both versions.
// Why a comma is excluded from `daemon`: the line grew trailing `daemonProtocol=`/`ours=`
// fields, so the version is no longer the last thing before the `;`.
function parseHandshakeMismatchStderr(stderr: string): {
expected: string | undefined
got: string | undefined
} {
const match = /expected=([^,\s]+),\s*daemon=([^\s;]+)/.exec(stderr)
const match = /expected=([^,\s]+),\s*daemon=([^\s;,]+)/.exec(stderr)
if (!match) {
return { expected: undefined, got: undefined }
}
Expand Down
41 changes: 38 additions & 3 deletions src/relay/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,27 @@ export {
}
export type { DecodedFrame, FrameDecoderOptions } from './relay-frame-decoder'

import {
MIN_RELAY_PROTOCOL_VERSION,
RELAY_PROTOCOL_VERSION,
describeRelayProtocolVersion,
relayProtocolOffer,
relayProtocolOfferAdmits,
type RelayHandshakeCapabilities,
type RelayProtocolOffer
} from '../shared/relay-protocol-version'

export {
MIN_RELAY_PROTOCOL_VERSION,
RELAY_PROTOCOL_VERSION,
describeRelayProtocolVersion,
relayProtocolOffer,
relayProtocolOfferAdmits
}
export type { RelayHandshakeCapabilities, RelayProtocolOffer }

// Why frozen at 0.1.0: the deploy path carries a content hash of the bundle instead, so nothing
// ever needed this to move. `RELAY_PROTOCOL_VERSION` is the number that describes the wire.
export const RELAY_VERSION = '0.1.0'
export const RELAY_SENTINEL = `ORCA-RELAY v${RELAY_VERSION} READY\n`

Expand All @@ -37,10 +58,24 @@ export const MessageType = {
// reads exactly one Handshake frame before attaching the JSON-RPC dispatcher,
// to refuse mismatched-version --connect bridges that would otherwise drive a
// stale daemon.
// Why optional on every arm: `parseHandshakeMessage` throws on an unknown `type` and the throw
// closes the socket, so this envelope can only ever grow by optional fields on the arms that
// already ship. `endpointCredential` is the precedent. See
// docs/reference/remote-wire-compatibility.md Rule 1.
export type HandshakeMessage =
| { type: 'orca-relay-handshake'; version: string; endpointCredential?: string }
| { type: 'orca-relay-handshake-ok'; version: string }
| { type: 'orca-relay-handshake-mismatch'; expected: string; got: string }
| ({
type: 'orca-relay-handshake'
version: string
endpointCredential?: string
} & RelayProtocolOffer)
| ({
type: 'orca-relay-handshake-ok'
version: string
capabilities?: RelayHandshakeCapabilities
} & RelayProtocolOffer)
// `protocolVersion` here tells a client whose offered range missed *what* it missed, which a
// content hash cannot: the two builds may be wire-compatible and merely differ in bytes.
| ({ type: 'orca-relay-handshake-mismatch'; expected: string; got: string } & RelayProtocolOffer)
// Why a distinct reply: the bridge exits with its own code so the client can tell a refused
// credential from a crashed relay. Old bridges reject the unknown type and exit 1 pre-sentinel.
| { type: 'orca-relay-handshake-credential-mismatch' }
Expand Down
185 changes: 184 additions & 1 deletion src/relay/relay-handshake-roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,14 @@ import {
encodeHandshakeFrame,
encodeJsonRpcFrame,
FrameDecoder,
parseHandshakeMessage,
MIN_RELAY_PROTOCOL_VERSION,
RELAY_PROTOCOL_VERSION,
type DecodedFrame,
type HandshakeMessage,
MessageType
} from './protocol'
import { PTY_CONSUMER_SESSION_PROTOCOL_VERSION } from '../shared/pty-consumer-session-contract'
import { relayTestSocketPath } from './relay-test-socket-path'

// Why: --connect normally calls process.exit on mismatch / fatal handshake
Expand Down Expand Up @@ -210,9 +215,86 @@ describe('handshake round-trip over a real Socket pair', () => {
bridgeSock.destroy()
})

it('exits with EXIT_CODE_VERSION_MISMATCH when the daemon reports a mismatch', async () => {
// Reads the daemon's single handshake reply off a raw socket, so a test can assert what the
// envelope carries rather than only whether the bridge accepted it.
function readDaemonReply(sock: Socket): Promise<HandshakeMessage> {
return new Promise((resolve) => {
const decoder = new FrameDecoder((frame) => {
if (frame.type === MessageType.Handshake) {
resolve(parseHandshakeMessage(frame.payload))
}
})
sock.on('data', (chunk: Buffer) => decoder.feed(chunk))
})
}

it('refuses a cross-build bridge that offers no protocol range at all', async () => {
// Why raw bytes: runConnectHandshake now always offers a range. Every relay already deployed
// sends none, and for those the build hash stays the only gate — this is that fallback.
await startDaemon('0.1.0+server-version')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))
const reply = readDaemonReply(bridgeSock)

bridgeSock.write(
encodeHandshakeFrame({ type: 'orca-relay-handshake', version: '0.1.0+different' })
)

// The refusal now names the daemon's protocol version, which a content hash cannot express.
await expect(reply).resolves.toMatchObject({
type: 'orca-relay-handshake-mismatch',
expected: '0.1.0+server-version',
got: '0.1.0+different',
protocolVersion: RELAY_PROTOCOL_VERSION,
minProtocolVersion: MIN_RELAY_PROTOCOL_VERSION
})

bridgeSock.destroy()
})

it('refuses a cross-build bridge whose offered range excludes this daemon', async () => {
await startDaemon('0.1.0+server-version')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))
const reply = readDaemonReply(bridgeSock)

bridgeSock.write(
encodeHandshakeFrame({
type: 'orca-relay-handshake',
version: '0.1.0+different',
protocolVersion: RELAY_PROTOCOL_VERSION + 5,
minProtocolVersion: RELAY_PROTOCOL_VERSION + 5
})
)

await expect(reply).resolves.toMatchObject({ type: 'orca-relay-handshake-mismatch' })

bridgeSock.destroy()
})

it('exits with EXIT_CODE_VERSION_MISMATCH when the daemon reports a mismatch', async () => {
server = createServer((sock) => {
trackServerSocket(sock)
const decoder = new FrameDecoder((frame) => {
if (frame.type !== MessageType.Handshake) {
return
}
sock.write(
encodeHandshakeFrame({
type: 'orca-relay-handshake-mismatch',
expected: '0.1.0+server-version',
got: '0.1.0+different',
protocolVersion: RELAY_PROTOCOL_VERSION + 5,
minProtocolVersion: RELAY_PROTOCOL_VERSION + 5
})
)
})
sock.on('data', (chunk: Buffer) => decoder.feed(chunk))
})
await new Promise<void>((r) => server.listen(sockPath, () => r()))

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))

Expand All @@ -226,6 +308,107 @@ describe('handshake round-trip over a real Socket pair', () => {
bridgeSock.destroy()
})

// #13852: after an app update the client's bundle hashes differently, so the incumbent relay
// holding every live PTY refused it and that work became permanently unreachable.
it('admits a bridge from a different build once it offers a range this daemon falls in', async () => {
const { accepted } = await startDaemon('0.1.0+incumbent-holding-live-ptys')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))

const acceptedCb = vi.fn<(leftover: Buffer) => void>()
runConnectHandshake(bridgeSock, '0.1.0+freshly-updated-client', { onAccepted: acceptedCb })

await accepted
await vi.waitFor(() => expect(acceptedCb).toHaveBeenCalledTimes(1))
expect(exitSpy).not.toHaveBeenCalled()

bridgeSock.destroy()
})

it('answers a cross-build bridge with the protocol version and capabilities it must speak', async () => {
await startDaemon('0.1.0+incumbent-holding-live-ptys')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))
const reply = readDaemonReply(bridgeSock)

bridgeSock.write(
encodeHandshakeFrame({
type: 'orca-relay-handshake',
version: '0.1.0+freshly-updated-client',
protocolVersion: RELAY_PROTOCOL_VERSION + 3,
minProtocolVersion: MIN_RELAY_PROTOCOL_VERSION
})
)

await expect(reply).resolves.toMatchObject({
type: 'orca-relay-handshake-ok',
// The build the client actually reached, which is no longer its own.
version: '0.1.0+incumbent-holding-live-ptys',
protocolVersion: RELAY_PROTOCOL_VERSION,
minProtocolVersion: MIN_RELAY_PROTOCOL_VERSION,
capabilities: { ptyConsumerSession: PTY_CONSUMER_SESSION_PROTOCOL_VERSION }
})

bridgeSock.destroy()
})

// The refusal path logs the peer's claim, and `JSON.parse` yields objects a template literal
// cannot stringify. A throw there is inside the frame-decoder callback, so it would kill the
// daemon — and every PTY it still holds — on an unauthenticated frame.
it('refuses a hostile protocolVersion claim without taking the daemon down', async () => {
const { accepted } = await startDaemon('0.1.0+server-version')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))
const reply = readDaemonReply(bridgeSock)

bridgeSock.write(
encodeHandshakeFrame(
JSON.parse(
'{"type":"orca-relay-handshake","version":"0.1.0+different","protocolVersion":{"toString":1}}'
) as HandshakeMessage
)
)

await expect(reply).resolves.toMatchObject({ type: 'orca-relay-handshake-mismatch' })

// Still serving: a second, well-formed client is admitted after the hostile one.
const good = connect(sockPath)
await new Promise<void>((r) => good.once('connect', () => r()))
runConnectHandshake(good, '0.1.0+server-version', { onAccepted: vi.fn() })
await accepted

bridgeSock.destroy()
good.destroy()
})

// Tolerance replaces a compatibility gate, never the auth gate: the credential file lives inside
// the incumbent's own install dir, so presenting it is what proves the caller may reach it.
it('still refuses a cross-build bridge that cannot present the endpoint credential', async () => {
const { accepted } = await startDaemon('0.1.0+incumbent', 'secret-credential')

const bridgeSock = connect(sockPath)
await new Promise<void>((r) => bridgeSock.once('connect', () => r()))
const closed = new Promise<void>((r) => bridgeSock.once('close', () => r()))

runConnectHandshake(
bridgeSock,
'0.1.0+freshly-updated-client',
{ onAccepted: vi.fn() },
'wrong-credential'
)

await closed
await expect(
Promise.race([
accepted.then(() => 'accepted'),
new Promise<string>((r) => setTimeout(() => r('closed'), 20))
])
).resolves.toBe('closed')
})

it('does not call onAccepted before any handshake-ok frame arrives', async () => {
// Why: silent server that never replies. acceptedCb must stay
// un-invoked even though the bridge has flushed its handshake frame.
Expand Down
Loading
Loading