Files
orca/src/main/ssh/sftp-stream-late-error.test.ts
T
Neil 7c6c8ef85e fix(ssh): stop a late SFTP stream error crashing main, and keep the relay socket inside sun_path (#17862)
* fix(ssh): stop SFTP stream errors crashing main and bound the relay socket path

inside the protocol parser. Every transfer removed its listener on settle, so a
STATUS reply that arrived late - the normal case behind a jump host that chroots
its SFTP subsystem - threw synchronously out of Socket.emit('data') and killed
the main process. Keep one durable listener per stream, and report a sandboxed
SFTP namespace with an actionable message instead of a bare 'file does not
exist'.

104 macOS) and bind failed with a bare 'listen EINVAL'. Fall back to a per-uid
base whose length does not depend on $HOME, keeping the hashed socket name
intact.

* fix(ssh): validate the short socket dir before mutating it

* fix(ssh): keep the SFTP session guarded, scope the relocated socket, narrow the chroot verdict

Three review findings.

The CLI-launcher install ran writeStringViaSftp in a loop over a bare conn.sftp().
That helper removes its own session 'error' listener at each settle, so between
files and after the last one the emitter carried none -- and ssh2 raises a late
STATUS reply synchronously out of Protocol.parse, which is the uncaught exception
that kills main (#15479). The inline loop it replaced leaked one listener per file
and covered this by accident. Extract writeStringsViaSftp, which owns the session
latch, and share that latch with runSftpFallbackTransfer.

SSH_FX_PERMISSION_DENIED is a mode/ownership refusal on a path the subsystem can
see, not evidence of a chroot; sftp-namespace-resolution already treats only
NO_SUCH_FILE as conclusive. Narrow the predicate to code 2 so a read-only home
stops being reported as a bastion misconfiguration.

The relocated socket had no version dimension. relaySocketNameForInstanceId hashes
the target, not the build, and under $HOME the enclosing relay-<fullVersion> dir
supplied the rest -- so the short form made the path stable across updates. The
next build would bind the path the previous relay still holds, the handshake would
mismatch, and a relay holding live work would raise RelayEndpointHeldError with no
way through. Add a hashed version segment under the short base, mirroring the
relay-*/<sock> shape so one pattern serves both, and teach the superseded sweep and
force-stop about that base. The relocated tree now also gets reclaimed: nothing
else walks it.

* fix(i18n): restore the activity-options key the rebase dropped

* fix(i18n): union en.json with main so the rebase cannot drop keys
2026-09-02 15:14:17 -07:00

173 lines
6.3 KiB
TypeScript

import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import type { SFTPWrapper } from 'ssh2'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { uploadBuffer, uploadFile, writeStringViaSftp, writeStringsViaSftp } from './sftp-upload'
import { writeRelayFile } from './ssh-relay-install-transfers'
import type { SshConnection } from './ssh-connection'
import { getRemoteHostPlatform } from './ssh-remote-platform'
/** The exact error ssh2 builds from a STATUS reply of SSH_FX_NO_SUCH_FILE. */
function sftpNoSuchFileError(): Error {
return Object.assign(new Error('file does not exist'), { code: 2 })
}
let tempDir = ''
let localFile = ''
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'orca-sftp-late-'))
localFile = join(tempDir, 'relay.js')
await writeFile(localFile, 'console.log(1)\n')
})
afterEach(async () => {
await rm(tempDir, { recursive: true, force: true })
})
function sftpDoubleReturning(stream: PassThrough): SFTPWrapper {
return Object.assign(new EventEmitter(), {
createWriteStream: () => stream
}) as unknown as SFTPWrapper
}
describe('late SFTP stream errors', () => {
// ssh2 emits the OPEN failure from inside the protocol parser. If no listener is left,
// Node throws it synchronously up through Socket.emit('data') and the main process dies
// (#15479) — uncaught exceptions are re-thrown by installUncaughtPipeErrorGuard, unlike
// rejections, which are only logged.
it('does not throw when a write stream fails after uploadFile settles', async () => {
const stream = new PassThrough()
stream.resume()
await uploadFile(sftpDoubleReturning(stream), localFile, '/home/user/.orca-remote/relay.js')
expect(() => stream.emit('error', sftpNoSuchFileError())).not.toThrow()
})
it('does not throw when a write stream fails after writeStringViaSftp settles', async () => {
const stream = new PassThrough()
stream.resume()
await writeStringViaSftp(sftpDoubleReturning(stream), '/home/user/.orca-remote/.version', 'v1')
expect(() => stream.emit('error', sftpNoSuchFileError())).not.toThrow()
})
it('does not throw when a write stream fails after uploadBuffer settles', async () => {
const stream = new PassThrough()
stream.resume()
await uploadBuffer(sftpDoubleReturning(stream), Buffer.from('x'), '/home/user/x')
expect(() => stream.emit('error', sftpNoSuchFileError())).not.toThrow()
})
// The failure mode a per-file loop reintroduces: writeStringViaSftp removes its own
// session listener at each settle, so a session that ran N transfers ends up with zero
// listeners while it is still open and still able to deliver a STATUS reply.
it('does not throw when a session error arrives after a multi-file write settles', async () => {
const sftp = Object.assign(new EventEmitter(), {
createWriteStream: () => {
const stream = new PassThrough()
stream.resume()
return stream
},
end: () => {}
}) as unknown as SFTPWrapper
await writeStringsViaSftp({ sftp: () => Promise.resolve(sftp) }, [
{ path: '/home/user/.local/bin/orca', contents: '#!/bin/sh\n' },
{ path: '/home/user/.local/bin/orca.mjs', contents: 'export {}\n' }
])
expect(() => sftp.emit('error', sftpNoSuchFileError())).not.toThrow()
})
it('still rejects a multi-file write with a session error raised during it', async () => {
const sftp = Object.assign(new EventEmitter(), {
createWriteStream: () => {
const stream = new PassThrough()
queueMicrotask(() => sftp.emit('error', sftpNoSuchFileError()))
return stream
},
end: () => {}
}) as unknown as SFTPWrapper
// The latch must sit behind the transfer's own prepended listener, or a real
// mid-transfer failure would be swallowed into a hang.
await expect(
writeStringsViaSftp({ sftp: () => Promise.resolve(sftp) }, [
{ path: '/home/user/.local/bin/orca', contents: '#!/bin/sh\n' }
])
).rejects.toThrow('file does not exist')
})
it('still rejects with the SFTP error when it arrives during the transfer', async () => {
const stream = new PassThrough()
stream.resume()
const failing = Object.assign(new EventEmitter(), {
createWriteStream: () => {
queueMicrotask(() => stream.emit('error', sftpNoSuchFileError()))
return stream
}
}) as unknown as SFTPWrapper
await expect(writeStringViaSftp(failing, '/home/user/x', 'v1')).rejects.toThrow(
'file does not exist'
)
})
})
describe('sandboxed SFTP subsystem diagnosis', () => {
it('leaves a permission refusal as itself rather than blaming a chroot', async () => {
// SSH_FX_PERMISSION_DENIED is a mode/ownership refusal on a path the subsystem can
// see -- a read-only home, a root-owned parent, a quota. Rewriting it into "your
// bastion chroots SFTP" sends the user to fix ProxyJump for a chmod.
const conn = {
writeFile: () => Promise.reject(Object.assign(new Error('permission denied'), { code: 3 }))
} as unknown as SshConnection
const failure: unknown = await writeRelayFile(
conn,
getRemoteHostPlatform('linux-x64'),
'/home/user/.orca-remote/relay-1/.version',
'v1'
).then(
() => null,
(err: unknown) => err
)
expect((failure as Error).message).toBe('permission denied')
expect(failure).not.toHaveProperty('sandboxedSftpNamespace')
})
it('replaces the bare SFTP status with an actionable relay-install message', async () => {
const conn = {
writeFile: () => Promise.reject(sftpNoSuchFileError())
} as unknown as SshConnection
await expect(
writeRelayFile(
conn,
getRemoteHostPlatform('linux-x64'),
'/home/user/.orca-remote/relay-1/.version',
'v1'
)
).rejects.toThrow(/SFTP subsystem sees a different filesystem/)
})
it('leaves unrelated transfer failures untouched', async () => {
const conn = {
writeFile: () => Promise.reject(new Error('Connection lost'))
} as unknown as SshConnection
await expect(
writeRelayFile(conn, getRemoteHostPlatform('linux-x64'), '/home/user/x', 'v1')
).rejects.toThrow('Connection lost')
})
})