Fix SSH relay installs on split shell/SFTP namespaces (#10645)

* Fix ssh-relay install on hosts with split shell/SFTP namespaces

On Synology DSM and similar hosts, the SSH shell and SFTP subsystem expose
different absolute paths for the same directory (e.g., /var/services/homes/alice
vs /homes/alice). The relay installer silently picked the wrong path and failed
discovery. This fix implements SFTP namespace detection: each install creates an
unguessable ownership marker and probes both namespaces to detect divergence.
When paths differ, SFTP writes redirect to the candidate namespace while shell
commands keep the canonical path. Markers are random tokens redacted from logs.

* Fix ssh-relay install on hosts with split shell/SFTP namespaces

Strengthen path validation to catch traversal and empty segments in
absolute POSIX paths, preventing security issues. Improve split-namespace
handling with comprehensive wire tests for uploads and file writes.
Ensure system SSH connections bypass namespace mapping entirely rather
than attempting incorrect retargeting.
This commit is contained in:
Jinjing 2026-07-25 19:20:17 -07:00 committed by GitHub
parent 16482623b3
commit 9f81f97a0c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 2755 additions and 151 deletions

View File

@ -0,0 +1,361 @@
// Why: picking the wrong SFTP path silently installs the relay somewhere the shell
// will never launch it, so every discovery outcome needs a pinned decision.
import type { SFTPWrapper } from 'ssh2'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
resolveSftpTransferPath,
resolveSftpTransferPathIfMapped,
type SftpNamespacePathMapping
} from './sftp-namespace-resolution'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const SHELL_HOME = '/var/services/homes/alice'
const RELAY_DIR = '.orca-remote/relay-0.1.0+hash'
const MARKER = '.install-lock/.sftp-namespace-deadbeef'
const mapping: SftpNamespacePathMapping = {
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${MARKER}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER}`
}
type LstatOutcome = 'present' | { code: number } | { message: string }
function statusError(code: number): Error {
return Object.assign(new Error(`SFTP status ${code}`), { code })
}
// A marker probe must care only about "the call succeeded", never about the reported type or size.
const MARKER_STATS = {
isDirectory: () => false,
isSymbolicLink: () => true,
mode: 0o120_777,
size: 0
}
function makeSftp(options: {
startPath?: string | Error | unknown
lstat?: (path: string) => LstatOutcome
}): {
sftp: SFTPWrapper
realpathCalls: string[]
lstatCalls: string[]
} {
const realpathCalls: string[] = []
const lstatCalls: string[] = []
const sftp = {
realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: unknown) => void) => {
realpathCalls.push(path)
if (options.startPath instanceof Error) {
cb(options.startPath)
return
}
cb(null, options.startPath)
}),
lstat: vi.fn((path: string, cb: (err: Error | null, stats?: unknown) => void) => {
lstatCalls.push(path)
const outcome = options.lstat?.(path) ?? { code: 2 }
if (outcome === 'present') {
cb(null, MARKER_STATS)
return
}
cb('code' in outcome ? statusError(outcome.code) : new Error(outcome.message))
})
}
return { sftp: sftp as unknown as SFTPWrapper, realpathCalls, lstatCalls }
}
// The marker lives under the SFTP start directory but not under the shell path.
function divergentLstat(startPath: string) {
return (path: string): LstatOutcome =>
path === `${startPath}/${RELAY_DIR}/${MARKER}` ? 'present' : { code: 2 }
}
describe('resolveSftpTransferPath', () => {
beforeEach(() => {
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
it('keeps the shell path and skips probing when both namespaces agree', async () => {
const { sftp, lstatCalls } = makeSftp({ startPath: SHELL_HOME })
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(lstatCalls).toEqual([])
})
it('redirects to the SFTP namespace when only that side carries our marker', async () => {
const { sftp, lstatCalls } = makeSftp({
startPath: '/homes/alice',
lstat: divergentLstat('/homes/alice')
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`)
expect(lstatCalls).toEqual([
`${SHELL_HOME}/${RELAY_DIR}/${MARKER}`,
`/homes/alice/${RELAY_DIR}/${MARKER}`
])
})
it('handles a start directory that is not a home directory at all', async () => {
const { sftp } = makeSftp({
startPath: '/volume1/shared',
lstat: divergentLstat('/volume1/shared')
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`/volume1/shared/${RELAY_DIR}`)
})
it('normalizes a trailing slash on the reported start directory', async () => {
const { sftp } = makeSftp({
startPath: '/homes/alice/',
lstat: divergentLstat('/homes/alice')
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`)
})
it('resolves a nested file path, not just the relay directory', async () => {
const fileMapping: SftpNamespacePathMapping = {
...mapping,
homeRelativePath: `${RELAY_DIR}/package.json`
}
const { sftp } = makeSftp({
startPath: '/homes/alice',
lstat: divergentLstat('/homes/alice')
})
const resolved = await resolveSftpTransferPath(
sftp,
`${SHELL_HOME}/${RELAY_DIR}/package.json`,
fileMapping
)
expect(resolved).toBe(`/homes/alice/${RELAY_DIR}/package.json`)
})
it('refuses an unrelated same-version directory that lacks our marker', async () => {
const { sftp, lstatCalls } = makeSftp({ startPath: '/homes/alice' })
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(lstatCalls).toHaveLength(2)
})
it('keeps the shell path when the marker is already visible there', async () => {
const { sftp, lstatCalls } = makeSftp({
startPath: '/homes/alice',
lstat: (path) => (path === mapping.shellProbePath ? 'present' : { code: 2 })
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(lstatCalls).toEqual([mapping.shellProbePath])
})
// Why: only SSH_FX_NO_SUCH_FILE proves absence; anything else must not license a redirect.
it.each([
['generic failure', { code: 4 } as LstatOutcome],
['permission denied', { code: 3 } as LstatOutcome],
['a code-less transport error', { message: 'socket hang up' } as LstatOutcome]
])('never probes the candidate after %s on the shell marker', async (_label, outcome) => {
const { sftp, lstatCalls } = makeSftp({
startPath: '/homes/alice',
lstat: (path) => (path === mapping.shellProbePath ? outcome : 'present')
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(lstatCalls).toEqual([mapping.shellProbePath])
})
it('keeps the shell path when the candidate probe is inconclusive', async () => {
const { sftp } = makeSftp({
startPath: '/homes/alice',
lstat: (path) => (path === mapping.shellProbePath ? { code: 2 } : { code: 4 })
})
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path'))
})
it.each([
['REALPATH fails', new Error('permission denied')],
['REALPATH returns a relative path', 'homes/alice'],
['REALPATH returns a non-string', 42],
['REALPATH smuggles a line break', '/homes/alice\nrm -rf /'],
['REALPATH contains an empty component', '/homes//alice'],
['REALPATH contains a dot component', '/homes/./alice'],
['REALPATH contains a traversal component', '/homes/archive/../alice']
])('keeps the shell path and skips LSTAT when %s', async (_label, startPath) => {
const { sftp, lstatCalls } = makeSftp({ startPath })
const resolved = await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, mapping)
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(lstatCalls).toEqual([])
})
it.each([
['a relative shell path', { shell: `${SHELL_HOME}/${RELAY_DIR}`.slice(1) }, 'SFTP namespace'],
['an absolute home-relative path', { homeRelativePath: `/${RELAY_DIR}` }, 'SFTP namespace'],
['a traversal segment', { homeRelativePath: `${RELAY_DIR}/../escape` }, 'Unsafe remote path'],
[
'a traversal segment in the absolute shell path',
{ shell: `${SHELL_HOME}/archive/../${RELAY_DIR}` },
'Unsafe remote path'
],
[
'an empty component in the absolute shell path',
{ shell: `${SHELL_HOME}//${RELAY_DIR}` },
'Unsafe remote path'
],
[
'a dot component in the absolute marker path',
{
shellProbePath: `${SHELL_HOME}/./${RELAY_DIR}/${MARKER}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER}`
},
'Unsafe remote path'
],
['a line break in the marker path', { shellProbePath: `${SHELL_HOME}/a\nb` }, 'SFTP namespace'],
[
'a mismatched transfer suffix',
{ shell: `${SHELL_HOME}/${RELAY_DIR}/other` },
'share one home-relative suffix'
],
[
'a mismatched shell namespace prefix',
{ shellProbePath: `/other/home/${RELAY_DIR}/${MARKER}` },
'share one shell namespace prefix'
],
[
'a mismatched marker basename',
{ homeRelativeProbePath: `${RELAY_DIR}/.install-lock/.sftp-namespace-other` },
'marker paths must share one marker basename'
],
[
'a marker outside the install lock',
{
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`,
homeRelativeProbePath: `${RELAY_DIR}/other-lock/.sftp-namespace-deadbeef`
},
'inside the transfer relay install lock'
],
[
'a marker under another relay tree',
{
shellProbePath: `${SHELL_HOME}/other/.install-lock/.sftp-namespace-deadbeef`,
homeRelativeProbePath: 'other/.install-lock/.sftp-namespace-deadbeef'
},
'inside the transfer relay install lock'
]
])('rejects %s before issuing any SFTP request', async (_label, overrides, expected) => {
const { shell, ...mappingOverrides } = overrides as Record<string, string>
const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: SHELL_HOME })
await expect(
resolveSftpTransferPath(sftp, shell ?? `${SHELL_HOME}/${RELAY_DIR}`, {
...mapping,
...mappingOverrides
})
).rejects.toThrow(expected)
expect(realpathCalls).toEqual([])
expect(lstatCalls).toEqual([])
})
it('redacts marker tokens from discovery diagnostics', async () => {
const token = 'a'.repeat(32)
const secretMarker = `.install-lock/.sftp-namespace-${token}`
const secretMapping: SftpNamespacePathMapping = {
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_HOME}/${RELAY_DIR}/${secretMarker}`,
homeRelativeProbePath: `${RELAY_DIR}/${secretMarker}`
}
const { sftp } = makeSftp({
startPath: '/homes/alice',
lstat: () => ({ message: `failure at ${secretMapping.shellProbePath}` })
})
await resolveSftpTransferPath(sftp, `${SHELL_HOME}/${RELAY_DIR}`, secretMapping)
const warnings = vi.mocked(console.warn).mock.calls.flat().join('\n')
expect(warnings).toContain('.sftp-namespace-[redacted]')
expect(warnings).not.toContain(token)
})
})
describe('resolveSftpTransferPathIfMapped', () => {
it('issues no discovery requests when no mapping was supplied', async () => {
const { sftp, realpathCalls, lstatCalls } = makeSftp({ startPath: '/homes/alice' })
const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, {
hostPlatform: getRemoteHostPlatform('linux-x64')
})
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(realpathCalls).toEqual([])
expect(lstatCalls).toEqual([])
})
// Why: Windows SFTP reports drive paths like /C:/Users/alice, which break the POSIX prefix contract.
it('ignores a mapping on a Windows host', async () => {
const { sftp, realpathCalls } = makeSftp({
startPath: '/homes/alice',
lstat: divergentLstat('/homes/alice')
})
const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, {
hostPlatform: getRemoteHostPlatform('win32-x64'),
sftpNamespace: mapping
})
expect(resolved).toBe(`${SHELL_HOME}/${RELAY_DIR}`)
expect(realpathCalls).toEqual([])
})
it('conservatively ignores a Windows path when platform metadata is absent', async () => {
const { sftp, realpathCalls, lstatCalls } = makeSftp({
startPath: '/homes/alice',
lstat: divergentLstat('/homes/alice')
})
const windowsPath = 'C:\\Users\\alice\\relay\\.version'
const resolved = await resolveSftpTransferPathIfMapped(sftp, windowsPath, {
sftpNamespace: mapping
})
expect(resolved).toBe(windowsPath)
expect(realpathCalls).toEqual([])
expect(lstatCalls).toEqual([])
})
it('resolves on a POSIX host with a mapping', async () => {
vi.spyOn(console, 'log').mockImplementation(() => {})
const { sftp } = makeSftp({
startPath: '/homes/alice',
lstat: divergentLstat('/homes/alice')
})
const resolved = await resolveSftpTransferPathIfMapped(sftp, `${SHELL_HOME}/${RELAY_DIR}`, {
hostPlatform: getRemoteHostPlatform('linux-x64'),
sftpNamespace: mapping
})
expect(resolved).toBe(`/homes/alice/${RELAY_DIR}`)
})
})

View File

@ -0,0 +1,245 @@
// Resolve an SFTP transfer path when the SSH shell and the SFTP subsystem expose
// different absolute namespaces for the same directory (e.g. Synology DSM's
// /var/services/homes/alice shell home vs. /homes/alice SFTP start directory).
//
// See: docs/ssh-relay-sftp-namespace.md
import type { SFTPWrapper } from 'ssh2'
import { assertSafeRemotePathSegment, isWindowsRemoteHost } from './ssh-remote-platform'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { redactRelayInstallMarkerTokens } from './ssh-relay-install-marker'
export type SftpNamespacePathMapping = {
homeRelativePath: string
shellProbePath: string
homeRelativeProbePath: string
}
// SSH_FX_NO_SUCH_FILE. The only status that definitively proves a path is absent;
// permission denied, generic failure, and code-less errors are inconclusive.
const SFTP_STATUS_NO_SUCH_FILE = 2
type MarkerProbe =
| { kind: 'present' }
| { kind: 'absent' }
| { kind: 'inconclusive'; detail: string }
function hasNulOrLineBreak(value: string): boolean {
return value.includes('\0') || value.includes('\r') || value.includes('\n')
}
function assertAbsolutePosixPath(label: string, value: string): void {
if (!value.startsWith('/') || hasNulOrLineBreak(value)) {
throw new Error(
`SFTP namespace ${label} must be an absolute POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}`
)
}
// Same segment hygiene as REALPATH start paths — empty/`.`/`..` are programming errors.
if (value === '/') {
return
}
for (const segment of value.slice(1).split('/')) {
assertSafeRemotePathSegment(segment, 'posix')
}
}
function assertHomeRelativePosixPath(label: string, value: string): void {
if (!value || value.startsWith('/') || hasNulOrLineBreak(value)) {
throw new Error(
`SFTP namespace ${label} must be a relative POSIX path: ${JSON.stringify(redactRelayInstallMarkerTokens(value))}`
)
}
for (const segment of value.split('/')) {
assertSafeRemotePathSegment(segment, 'posix')
}
}
function assertMappingIdentity(shellAbsolutePath: string, mapping: SftpNamespacePathMapping): void {
if (!shellAbsolutePath.endsWith(`/${mapping.homeRelativePath}`)) {
throw new Error('SFTP namespace transfer paths must share one home-relative suffix')
}
const probeSegments = mapping.homeRelativeProbePath.split('/')
const markerFileName = probeSegments.at(-1)
const shellMarkerFileName = mapping.shellProbePath.slice(
mapping.shellProbePath.lastIndexOf('/') + 1
)
if (!markerFileName || shellMarkerFileName !== markerFileName) {
throw new Error('SFTP namespace marker paths must share one marker basename')
}
const shellNamespacePrefix = shellAbsolutePath.slice(0, -mapping.homeRelativePath.length)
if (mapping.shellProbePath !== `${shellNamespacePrefix}${mapping.homeRelativeProbePath}`) {
throw new Error('SFTP namespace transfer and marker must share one shell namespace prefix')
}
const lockSegmentIndex = probeSegments.length - 2
const relayDir = probeSegments.slice(0, lockSegmentIndex).join('/')
if (
lockSegmentIndex < 1 ||
probeSegments[lockSegmentIndex] !== '.install-lock' ||
(mapping.homeRelativePath !== relayDir && !mapping.homeRelativePath.startsWith(`${relayDir}/`))
) {
throw new Error('SFTP namespace marker must be inside the transfer relay install lock')
}
}
function normalizeSftpStartPath(value: unknown): string | null {
if (typeof value !== 'string' || !value.startsWith('/') || hasNulOrLineBreak(value)) {
return null
}
if (value === '/') {
return value
}
const normalized = value.replace(/\/+$/, '')
if (!normalized.startsWith('/')) {
return null
}
const segments = normalized.slice(1).split('/')
return segments.some((segment) => !segment || segment === '.' || segment === '..')
? null
: normalized
}
function joinSftpStartPath(startPath: string, homeRelativePath: string): string {
return `${startPath.replace(/\/+$/, '')}/${homeRelativePath}`
}
function realpathSftp(sftp: SFTPWrapper, remotePath: string): Promise<string> {
return new Promise((resolve, reject) => {
sftp.realpath(remotePath, (err, resolved) => {
if (err) {
reject(err)
return
}
resolve(resolved)
})
})
}
// Why: sftpPathExists collapses "absent" and "could not tell" into one boolean;
// namespace selection must never treat an inconclusive probe as absence.
function probeMarkerPath(sftp: SFTPWrapper, remotePath: string): Promise<MarkerProbe> {
return new Promise((resolve) => {
sftp.lstat(remotePath, (err) => {
if (!err) {
resolve({ kind: 'present' })
return
}
const code = (err as { code?: unknown }).code
if (code === SFTP_STATUS_NO_SUCH_FILE) {
resolve({ kind: 'absent' })
return
}
resolve({
kind: 'inconclusive',
detail:
typeof code === 'number' ? `status ${code}` : redactRelayInstallMarkerTokens(err.message)
})
})
})
}
function logRetainedShellPath(operation: string, detail: string, shellAbsolutePath: string): void {
console.warn(
`[ssh-relay] SFTP namespace discovery inconclusive (${operation}: ${redactRelayInstallMarkerTokens(detail)}); retaining shell path ${redactRelayInstallMarkerTokens(shellAbsolutePath)}`
)
}
/**
* Pick the path this SFTP session should transfer to.
*
* Returns `shellAbsolutePath` unless the session both fails to see the install
* owner's marker there and does see it under its own start directory. Discovery
* failures degrade to the shell path rather than inventing a namespace error.
*/
export async function resolveSftpTransferPath(
sftp: SFTPWrapper,
shellAbsolutePath: string,
mapping: SftpNamespacePathMapping
): Promise<string> {
assertAbsolutePosixPath('transfer path', shellAbsolutePath)
assertAbsolutePosixPath('marker path', mapping.shellProbePath)
assertHomeRelativePosixPath('relative transfer path', mapping.homeRelativePath)
assertHomeRelativePosixPath('relative marker path', mapping.homeRelativeProbePath)
assertMappingIdentity(shellAbsolutePath, mapping)
let reportedStartPath: unknown
try {
reportedStartPath = await realpathSftp(sftp, '.')
} catch (err) {
logRetainedShellPath(
'REALPATH',
err instanceof Error ? err.message : String(err),
shellAbsolutePath
)
return shellAbsolutePath
}
const startPath = normalizeSftpStartPath(reportedStartPath)
if (!startPath) {
logRetainedShellPath('REALPATH', 'unusable start directory', shellAbsolutePath)
return shellAbsolutePath
}
const candidatePath = joinSftpStartPath(startPath, mapping.homeRelativePath)
if (candidatePath === shellAbsolutePath) {
return shellAbsolutePath
}
const shellMarker = await probeMarkerPath(sftp, mapping.shellProbePath)
if (shellMarker.kind !== 'absent') {
if (shellMarker.kind === 'inconclusive') {
logRetainedShellPath('LSTAT', shellMarker.detail, shellAbsolutePath)
}
return shellAbsolutePath
}
const candidateMarker = await probeMarkerPath(
sftp,
joinSftpStartPath(startPath, mapping.homeRelativeProbePath)
)
if (candidateMarker.kind === 'present') {
console.log(
`[ssh-relay] SFTP namespace differs; transfer path: ${redactRelayInstallMarkerTokens(candidatePath)}`
)
return candidatePath
}
if (candidateMarker.kind === 'inconclusive') {
logRetainedShellPath('LSTAT', candidateMarker.detail, shellAbsolutePath)
}
return shellAbsolutePath
}
export type SftpTransferPathOptions = {
hostPlatform?: RemoteHostPlatform
sftpNamespace?: SftpNamespacePathMapping
}
/**
* Namespace-resolve a transfer path only when a mapping was supplied and the host
* is not Windows, whose SFTP drive paths (`/C:/Users/...`) break the POSIX prefix
* contract. Callers without a mapping issue no REALPATH or LSTAT.
*/
export function resolveSftpTransferPathIfMapped(
sftp: SFTPWrapper,
shellAbsolutePath: string,
options?: SftpTransferPathOptions
): Promise<string> {
const mapping = options?.sftpNamespace
if (
!mapping ||
(options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) ||
(!options?.hostPlatform && isRecognizableWindowsAbsolutePath(shellAbsolutePath))
) {
return Promise.resolve(shellAbsolutePath)
}
return resolveSftpTransferPath(sftp, shellAbsolutePath, mapping)
}
function isRecognizableWindowsAbsolutePath(value: string): boolean {
return (
/^[A-Za-z]:[\\/]/u.test(value) ||
value.startsWith('\\\\') ||
/^\/[A-Za-z]:\//u.test(value) ||
/^\/\/[^/]/u.test(value)
)
}

View File

@ -125,6 +125,43 @@ export function uploadBuffer(
})
}
export function writeStringViaSftp(
sftp: SFTPWrapper,
remotePath: string,
contents: string
): Promise<void> {
return new Promise((resolve, reject) => {
const ws = sftp.createWriteStream(remotePath)
let settled = false
const cleanup = (): void => {
sftp.removeListener('error', onError)
ws.removeListener('close', onClose)
ws.removeListener('error', onError)
}
const onClose = (): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve()
}
const onError = (err: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(err)
}
// Why: prepend so a session error settles this write before a late-error swallower sees it.
sftp.prependOnceListener('error', onError)
ws.once('close', onClose)
ws.once('error', onError)
ws.end(contents)
})
}
export async function uploadDirectory(
sftp: SFTPWrapper,
localDir: string,

View File

@ -0,0 +1,471 @@
// Why: namespace resolution has to happen on the very session that transfers, and
// only for the two relay-install writes — a leak into other transfers or into the
// system-SSH/Windows branches would silently retarget unrelated file operations.
import { EventEmitter } from 'node:events'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('ssh2', () => ({
BaseAgent: class {},
Client: class {},
createAgent: vi.fn(),
utils: { parseKey: vi.fn() }
}))
vi.mock('./ssh-system-fallback', () => ({
getOrcaControlSocketPath: vi.fn().mockReturnValue(null),
spawnSystemSsh: vi.fn(),
spawnSystemSshCommand: vi.fn(),
downloadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined),
uploadDirectoryViaSystemSsh: vi.fn().mockResolvedValue(undefined),
uploadFileViaSystemSsh: vi.fn().mockResolvedValue(undefined),
writeBufferViaSystemSsh: vi.fn().mockResolvedValue(undefined),
writeFileViaSystemSsh: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-control-socket', () => ({ removeControlSocketPath: vi.fn() }))
vi.mock('./ssh-config-parser', () => ({ resolveWithSshG: vi.fn().mockResolvedValue(null) }))
import { SshConnection } from './ssh-connection'
import type { SftpNamespacePathMapping } from './sftp-namespace-resolution'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { isSshSessionLimitError } from './ssh-session-limit-error'
import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback'
import type { SshTarget } from '../../shared/ssh-types'
const SHELL_HOME = '/var/services/homes/alice'
const SFTP_HOME = '/homes/alice'
const RELAY_DIR = '.orca-remote/relay-0.1.0+hash'
const MARKER = '.install-lock/.sftp-namespace-cafebabe'
const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}`
const namespace: SftpNamespacePathMapping = {
homeRelativePath: RELAY_DIR,
shellProbePath: `${SHELL_RELAY_DIR}/${MARKER}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER}`
}
function fileNamespace(fileName: string): SftpNamespacePathMapping {
return { ...namespace, homeRelativePath: `${RELAY_DIR}/${fileName}` }
}
type FakeSftp = EventEmitter & {
realpathCalls: string[]
lstatCalls: string[]
writtenPaths: string[]
mkdirPaths: string[]
endCalls: number
emitCloseOnEnd: boolean
pendingRealpathCallbacks: RealpathCallback[]
pendingLstatCallbacks: LstatCallback[]
realpath: ReturnType<typeof vi.fn>
lstat: ReturnType<typeof vi.fn>
mkdir: ReturnType<typeof vi.fn>
createWriteStream: ReturnType<typeof vi.fn>
end: ReturnType<typeof vi.fn>
}
type RealpathCallback = (err: Error | null, resolved?: string) => void
type LstatCallback = (err: Error | null) => void
function createFakeSftp(options?: { pendingRealpath?: boolean; pendingLstat?: boolean }): FakeSftp {
const sftp = new EventEmitter() as FakeSftp
sftp.realpathCalls = []
sftp.lstatCalls = []
sftp.writtenPaths = []
sftp.mkdirPaths = []
sftp.endCalls = 0
sftp.emitCloseOnEnd = true
sftp.pendingRealpathCallbacks = []
sftp.pendingLstatCallbacks = []
sftp.realpath = vi.fn((path: string, cb: RealpathCallback) => {
sftp.realpathCalls.push(path)
if (options?.pendingRealpath) {
sftp.pendingRealpathCallbacks.push(cb)
return
}
cb(null, SFTP_HOME)
})
// The install-owner marker exists only under the SFTP start directory.
sftp.lstat = vi.fn((path: string, cb: LstatCallback) => {
sftp.lstatCalls.push(path)
if (options?.pendingLstat) {
sftp.pendingLstatCallbacks.push(cb)
return
}
if (path === `${SFTP_HOME}/${RELAY_DIR}/${MARKER}`) {
cb(null)
return
}
cb(Object.assign(new Error('No such file'), { code: 2 }))
})
sftp.mkdir = vi.fn((path: string, cb: (err: Error | null) => void) => {
sftp.mkdirPaths.push(path)
cb(null)
})
sftp.createWriteStream = vi.fn((path: string) => {
sftp.writtenPaths.push(path)
const ws = new EventEmitter()
return Object.assign(ws, {
end: vi.fn(() => setTimeout(() => ws.emit('close'), 0)),
destroy: vi.fn(),
off: ws.removeListener.bind(ws),
write: vi.fn(),
on: ws.on.bind(ws)
})
})
sftp.end = vi.fn(() => {
sftp.endCalls += 1
if (sftp.emitCloseOnEnd) {
setTimeout(() => sftp.emit('close'), 0)
}
})
return sftp
}
function createTarget(): SshTarget {
return {
id: 'target-1',
label: 'Synology',
host: 'nas.local',
port: 22,
username: 'alice',
authMethod: 'agent'
} as SshTarget
}
function connectedTo(
sftpSessions: FakeSftp[],
options?: { sftpError?: Error; useSystemSsh?: boolean }
): SshConnection {
const conn = new SshConnection(createTarget(), {
onStateChange: vi.fn(),
onLog: vi.fn()
} as never)
let handed = 0
const client = {
sftp: (cb: (err: Error | undefined, sftp: unknown) => void) => {
if (options?.sftpError) {
cb(options.sftpError, undefined)
return
}
cb(undefined, sftpSessions[handed++] ?? sftpSessions.at(-1))
}
}
// Why: the transfer branches are the unit under test; skip the connect handshake.
Object.assign(conn as unknown as Record<string, unknown>, {
client,
useSystemSshTransport: options?.useSystemSsh ?? false
})
return conn
}
describe('SshConnection SFTP namespace resolution', () => {
const linux = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
let localDir: string
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'log').mockImplementation(() => {})
vi.spyOn(console, 'warn').mockImplementation(() => {})
// realpath: macOS /var is a symlink and uploadDirectory rejects a root that resolves elsewhere.
localDir = realpathSync(mkdtempSync(join(tmpdir(), 'orca-relay-')))
writeFileSync(join(localDir, 'relay.js'), 'console.log(1)')
})
afterEach(() => {
rmSync(localDir, { recursive: true, force: true })
})
it('writes to the SFTP namespace path discovered on the same session', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp])
await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, '0.1.0+hash', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version')
})
expect(sftp.realpathCalls).toEqual(['.'])
expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/.version`])
expect(sftp.endCalls).toBe(1)
})
it('uploads the bundle into the SFTP namespace directory', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp])
await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, {
hostPlatform: linux,
sftpNamespace: namespace
})
expect(sftp.writtenPaths).toEqual([`${SFTP_HOME}/${RELAY_DIR}/relay.js`])
expect(sftp.endCalls).toBe(1)
})
it('issues no discovery requests when the caller supplies no mapping', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp])
await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', { hostPlatform: linux })
expect(sftp.realpathCalls).toEqual([])
expect(sftp.lstatCalls).toEqual([])
expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`])
})
it('ignores a mapping on a Windows host', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp])
await conn.writeFile('C:\\Users\\alice\\relay\\.version', 'v', {
hostPlatform: windows,
sftpNamespace: fileNamespace('.version')
})
expect(sftp.realpathCalls).toEqual([])
expect(sftp.writtenPaths).toEqual(['C:\\Users\\alice\\relay\\.version'])
})
it('never resolves on the system-SSH transport', async () => {
const conn = connectedTo([], { useSystemSsh: true })
await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version')
})
await conn.uploadDirectory(localDir, SHELL_RELAY_DIR, {
hostPlatform: linux,
sftpNamespace: namespace
})
expect(vi.mocked(writeFileViaSystemSsh)).toHaveBeenCalledWith(
expect.anything(),
`${SHELL_RELAY_DIR}/.version`,
'v',
expect.anything()
)
expect(vi.mocked(uploadDirectoryViaSystemSsh)).toHaveBeenCalledWith(
expect.anything(),
localDir,
SHELL_RELAY_DIR,
expect.anything()
)
})
// Why: only the relay-install writes carry a marker; other transfers have no owner to verify.
it('leaves writeBuffer and openFileUploadSession on the shell path', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp, sftp])
await conn.writeBuffer(`${SHELL_RELAY_DIR}/blob.bin`, Buffer.from('x'), {
hostPlatform: linux,
sftpNamespace: fileNamespace('blob.bin')
})
const session = await conn.openFileUploadSession({
hostPlatform: linux,
sftpNamespace: namespace
})
session.close()
expect(sftp.realpathCalls).toEqual([])
expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/blob.bin`])
})
it('keeps the shell path when discovery is inconclusive', async () => {
const sftp = createFakeSftp()
sftp.lstat = vi.fn((path: string, cb: (err: Error | null) => void) => {
sftp.lstatCalls.push(path)
cb(Object.assign(new Error('permission denied'), { code: 3 }))
})
const conn = connectedTo([sftp])
await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version')
})
expect(sftp.writtenPaths).toEqual([`${SHELL_RELAY_DIR}/.version`])
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining('retaining shell path'))
})
it('aborts a transfer stuck in discovery and reports a confirmed channel close', async () => {
const sftp = createFakeSftp({ pendingRealpath: true })
const conn = connectedTo([sftp])
const controller = new AbortController()
const write = conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version'),
signal: controller.signal
})
await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1))
controller.abort()
const error = (await write.catch((err: Error) => err)) as Error & {
sshChannelCloseConfirmed?: boolean
}
expect(error.name).toBe('AbortError')
expect(error.sshChannelCloseConfirmed).toBe(true)
expect(sftp.endCalls).toBe(1)
expect(sftp.writtenPaths).toEqual([])
})
it('reports an unconfirmed close when the aborted session never closes', async () => {
vi.useFakeTimers()
try {
const sftp = createFakeSftp({ pendingRealpath: true })
sftp.emitCloseOnEnd = false
const conn = connectedTo([sftp])
const controller = new AbortController()
const write = conn
.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version'),
signal: controller.signal
})
.catch((err: Error) => err)
await vi.waitFor(() => expect(sftp.realpathCalls).toHaveLength(1))
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean }
expect(error.name).toBe('AbortError')
expect(error.sshChannelCloseConfirmed).toBe(false)
expect(sftp.endCalls).toBe(1)
} finally {
vi.useRealTimers()
}
})
// Why: relay deploy classifies MaxSessions to back off; wrapping it would hide the retry signal.
it('preserves a session-limit channel-open error unchanged', async () => {
const original = Object.assign(
new Error('Channel open failure: open failed reason 4: MaxSessions'),
{ reason: 4 }
)
const sftp = createFakeSftp()
const conn = connectedTo([sftp], {
sftpError: original
})
const error = await conn
.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version')
})
.catch((err: Error) => err)
expect(error).toBe(original)
expect(isSshSessionLimitError(error)).toBe(true)
expect(sftp.realpathCalls).toEqual([])
expect(sftp.writtenPaths).toEqual([])
})
it.each([
['succeeds', (callback: RealpathCallback) => callback(null, SFTP_HOME)],
['rejects', (callback: RealpathCallback) => callback(new Error('late REALPATH failure'))]
])(
'ignores a late REALPATH callback that %s after a confirmed abort',
async (_outcome, completeRealpath) => {
const sftp = createFakeSftp({ pendingRealpath: true })
const conn = connectedTo([sftp])
const controller = new AbortController()
const unhandledRejection = vi.fn()
process.on('unhandledRejection', unhandledRejection)
try {
const write = conn
.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version'),
signal: controller.signal
})
.catch((err: Error) => err)
await vi.waitFor(() => expect(sftp.pendingRealpathCallbacks).toHaveLength(1))
controller.abort()
const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean }
expect(error).toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: true
})
completeRealpath(sftp.pendingRealpathCallbacks[0]!)
await new Promise<void>((resolve) => setImmediate(resolve))
expect(unhandledRejection).not.toHaveBeenCalled()
expect(sftp.writtenPaths).toEqual([])
expect(sftp.endCalls).toBe(1)
} finally {
process.off('unhandledRejection', unhandledRejection)
}
}
)
it.each([
['succeeds', (callback: LstatCallback) => callback(null)],
[
'rejects',
(callback: LstatCallback) =>
callback(Object.assign(new Error('late LSTAT failure'), { code: 3 }))
]
])(
'ignores a late LSTAT callback that %s after an unconfirmed abort',
async (_outcome, completeLstat) => {
vi.useFakeTimers()
const unhandledRejection = vi.fn()
process.on('unhandledRejection', unhandledRejection)
try {
const sftp = createFakeSftp({ pendingLstat: true })
sftp.emitCloseOnEnd = false
const conn = connectedTo([sftp])
const controller = new AbortController()
const write = conn
.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version'),
signal: controller.signal
})
.catch((err: Error) => err)
await vi.waitFor(() => expect(sftp.pendingLstatCallbacks).toHaveLength(1))
controller.abort()
await vi.advanceTimersByTimeAsync(5_000)
const error = (await write) as Error & { sshChannelCloseConfirmed?: boolean }
expect(error).toMatchObject({
name: 'AbortError',
sshChannelCloseConfirmed: false
})
completeLstat(sftp.pendingLstatCallbacks[0]!)
await Promise.resolve()
await Promise.resolve()
expect(unhandledRejection).not.toHaveBeenCalled()
expect(sftp.writtenPaths).toEqual([])
expect(sftp.endCalls).toBe(1)
} finally {
process.off('unhandledRejection', unhandledRejection)
vi.useRealTimers()
}
}
)
it('swallows a late session error after the transfer settled', async () => {
const sftp = createFakeSftp()
const conn = connectedTo([sftp])
await conn.writeFile(`${SHELL_RELAY_DIR}/.version`, 'v', {
hostPlatform: linux,
sftpNamespace: fileNamespace('.version')
})
expect(() => sftp.emit('error', new Error('late channel reset'))).not.toThrow()
})
})

View File

@ -0,0 +1,361 @@
import { lstat, mkdir, mkdtemp, open, readFile, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, posix } from 'node:path'
import { Client, Server as Ssh2Server, utils, type Connection, type SFTPWrapper } from 'ssh2'
import { expect, it, vi } from 'vitest'
import type { SshTarget } from '../../shared/ssh-types'
import { SshConnection } from './ssh-connection'
import type { SftpNamespacePathMapping } from './sftp-namespace-resolution'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const SHELL_HOME = '/var/services/homes/alice'
const SFTP_HOME = '/homes/alice'
const RELAY_DIR = '.orca-remote/relay-0.1.0+wire'
const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_DIR}`
const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_DIR}`
const MARKER_FILE = `.sftp-namespace-${'a'.repeat(32)}`
const MARKER_PATH = `.install-lock/${MARKER_FILE}`
const SFTP_OPEN_WRITE = 2
const SFTP_STATUS_OK = 0
const SFTP_STATUS_NO_SUCH_FILE = 2
const SFTP_STATUS_FAILURE = 4
type SftpWireServer = {
port: number
operations: string[]
close: () => Promise<void>
}
type OpenFile = Awaited<ReturnType<typeof open>>
function backingPath(backingRoot: string, remotePath: string): string | null {
if (remotePath === SFTP_HOME) {
return backingRoot
}
if (!remotePath.startsWith(`${SFTP_HOME}/`)) {
return null
}
const relativePath = posix.relative(SFTP_HOME, remotePath)
if (!relativePath || relativePath.startsWith('../') || posix.isAbsolute(relativePath)) {
return null
}
return join(backingRoot, ...relativePath.split('/'))
}
function sendFsError(sftp: SFTPWrapper, requestId: number, error: unknown): void {
const code =
error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT'
? SFTP_STATUS_NO_SUCH_FILE
: SFTP_STATUS_FAILURE
sftp.status(requestId, code)
}
function fileId(handle: Buffer, files: Map<number, OpenFile>): number | null {
if (handle.length !== 4) {
return null
}
const id = handle.readUInt32BE(0)
return files.has(id) ? id : null
}
function installSftpHandlers(sftp: SFTPWrapper, backingRoot: string, operations: string[]): void {
const files = new Map<number, OpenFile>()
let nextFileId = 0
sftp.on('error', () => {})
sftp.on('REALPATH', (requestId, remotePath) => {
operations.push(`REALPATH:${remotePath}`)
if (remotePath !== '.') {
sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE)
return
}
sftp.name(requestId, [
{
filename: SFTP_HOME,
longname: SFTP_HOME,
attrs: { mode: 0o40_755, uid: 0, gid: 0, size: 0, atime: 0, mtime: 0 }
}
])
})
sftp.on('LSTAT', (requestId, remotePath) => {
operations.push(`LSTAT:${remotePath}`)
const localPath = backingPath(backingRoot, remotePath)
if (!localPath) {
sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE)
return
}
void lstat(localPath).then(
(stats) =>
sftp.attrs(requestId, {
mode: stats.mode,
size: stats.size,
uid: stats.uid,
gid: stats.gid,
atime: Math.floor(stats.atimeMs / 1000),
mtime: Math.floor(stats.mtimeMs / 1000)
}),
(error: unknown) => sendFsError(sftp, requestId, error)
)
})
sftp.on('MKDIR', (requestId, remotePath) => {
operations.push(`MKDIR:${remotePath}`)
const localPath = backingPath(backingRoot, remotePath)
if (!localPath) {
sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE)
return
}
void mkdir(localPath).then(
() => sftp.status(requestId, SFTP_STATUS_OK),
(error: unknown) => sendFsError(sftp, requestId, error)
)
})
sftp.on('OPEN', (requestId, remotePath, flags) => {
operations.push(`OPEN:${remotePath}`)
const localPath = backingPath(backingRoot, remotePath)
if (!localPath || !(flags & SFTP_OPEN_WRITE)) {
sftp.status(requestId, SFTP_STATUS_NO_SUCH_FILE)
return
}
void open(localPath, 'w').then(
(file) => {
const id = nextFileId++
files.set(id, file)
const handle = Buffer.alloc(4)
handle.writeUInt32BE(id)
sftp.handle(requestId, handle)
},
(error: unknown) => sendFsError(sftp, requestId, error)
)
})
sftp.on('WRITE', (requestId, handle, offset, data) => {
operations.push('WRITE')
const id = fileId(handle, files)
if (id === null) {
sftp.status(requestId, SFTP_STATUS_FAILURE)
return
}
void files
.get(id)!
.write(data, 0, data.length, offset)
.then(
() => sftp.status(requestId, SFTP_STATUS_OK),
(error: unknown) => sendFsError(sftp, requestId, error)
)
})
sftp.on('CLOSE', (requestId, handle) => {
operations.push('CLOSE')
const id = fileId(handle, files)
if (id === null) {
sftp.status(requestId, SFTP_STATUS_FAILURE)
return
}
const file = files.get(id)!
files.delete(id)
void file.close().then(
() => sftp.status(requestId, SFTP_STATUS_OK),
(error: unknown) => sendFsError(sftp, requestId, error)
)
})
}
async function startSftpWireServer(backingRoot: string): Promise<SftpWireServer> {
const operations: string[] = []
const connections = new Set<Connection>()
const hostKey = utils.generateKeyPairSync('ed25519').private
const server = new Ssh2Server({ hostKeys: [hostKey] }, (connection) => {
connections.add(connection)
connection.on('error', () => {})
connection.on('close', () => connections.delete(connection))
connection.on('authentication', (context) => {
if (
context.method === 'password' &&
context.username === 'fixture' &&
context.password === 'secret'
) {
context.accept()
} else {
context.reject()
}
})
connection.on('ready', () => {
connection.on('session', (accept) => {
const session = accept()
session.on('sftp', (acceptSftp) => {
installSftpHandlers(acceptSftp(), backingRoot, operations)
})
})
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, '127.0.0.1', () => {
server.removeListener('error', reject)
resolve()
})
})
const address = server.address()
if (!address || typeof address === 'string') {
throw new Error('SSH fixture did not bind a TCP port')
}
return {
port: address.port,
operations,
close: async () => {
for (const connection of connections) {
connection.end()
}
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()))
})
}
}
}
function connectSshClient(port: number): Promise<Client> {
return new Promise((resolve, reject) => {
const client = new Client()
client.once('ready', () => resolve(client))
client.once('error', reject)
client.connect({
host: '127.0.0.1',
port,
username: 'fixture',
password: 'secret',
hostVerifier: () => true,
readyTimeout: 5_000
})
})
}
function connectionWithClient(client: Client): SshConnection {
const target = {
id: 'sftp-wire',
label: 'SFTP wire fixture',
host: '127.0.0.1',
port: 22,
username: 'fixture',
authMethod: 'password'
} as SshTarget
const connection = new SshConnection(target, { onStateChange: vi.fn() })
Object.assign(connection as unknown as Record<string, unknown>, {
client,
useSystemSshTransport: false
})
return connection
}
async function boundedTransfer(
operation: Promise<void>,
operations: string[],
label: string
): Promise<void> {
let timeout: ReturnType<typeof setTimeout> | undefined
try {
await Promise.race([
operation,
new Promise<never>((_resolve, reject) => {
timeout = setTimeout(
() => reject(new Error(`SFTP wire ${label} timed out: ${operations.join(', ')}`)),
5_000
)
})
])
} finally {
clearTimeout(timeout)
}
}
function splitNamespaceMapping(homeRelativePath: string): SftpNamespacePathMapping {
return {
homeRelativePath,
shellProbePath: `${SHELL_RELAY_DIR}/${MARKER_PATH}`,
homeRelativeProbePath: `${RELAY_DIR}/${MARKER_PATH}`
}
}
async function withSplitNamespaceFixture(
run: (args: {
connection: SshConnection
fixture: SftpWireServer
backingRelayDir: string
}) => Promise<void>
): Promise<void> {
const backingRoot = await mkdtemp(join(tmpdir(), 'orca-sftp-wire-remote-'))
const backingRelayDir = join(backingRoot, ...RELAY_DIR.split('/'))
await mkdir(join(backingRelayDir, '.install-lock'), { recursive: true })
await writeFile(join(backingRelayDir, '.install-lock', MARKER_FILE), '')
let fixture: SftpWireServer | undefined
let client: Client | undefined
try {
fixture = await startSftpWireServer(backingRoot)
client = await connectSshClient(fixture.port)
await run({
connection: connectionWithClient(client),
fixture,
backingRelayDir
})
} finally {
client?.end()
await fixture?.close()
await rm(backingRoot, { recursive: true, force: true })
}
}
it('uploads through a verified split namespace over a real ssh2 SFTP session', async () => {
const localDir = await realpath(await mkdtemp(join(tmpdir(), 'orca-sftp-wire-local-')))
await mkdir(join(localDir, 'nested'))
await writeFile(join(localDir, 'nested', 'payload.bin'), Buffer.from([0, 1, 2, 255]))
try {
await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => {
const mapping = splitNamespaceMapping(RELAY_DIR)
await boundedTransfer(
connection.uploadDirectory(localDir, SHELL_RELAY_DIR, {
hostPlatform: getRemoteHostPlatform('linux-x64'),
sftpNamespace: mapping
}),
fixture.operations,
'upload'
)
expect(await readFile(join(backingRelayDir, 'nested', 'payload.bin'))).toEqual(
Buffer.from([0, 1, 2, 255])
)
expect(fixture.operations).toContain(`REALPATH:.`)
expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`)
expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`)
expect(fixture.operations).toContain(`MKDIR:${SFTP_RELAY_DIR}/nested`)
expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/nested/payload.bin`)
expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE']))
})
} finally {
await rm(localDir, { recursive: true, force: true })
}
}, 15_000)
it('writes a mapped file through a verified split namespace over a real ssh2 SFTP session', async () => {
await withSplitNamespaceFixture(async ({ connection, fixture, backingRelayDir }) => {
const mapping = splitNamespaceMapping(`${RELAY_DIR}/package.json`)
const shellFilePath = `${SHELL_RELAY_DIR}/package.json`
const contents = '{"name":"orca-relay"}\n'
await boundedTransfer(
connection.writeFile(shellFilePath, contents, {
hostPlatform: getRemoteHostPlatform('linux-x64'),
sftpNamespace: mapping
}),
fixture.operations,
'writeFile'
)
expect(await readFile(join(backingRelayDir, 'package.json'), 'utf8')).toBe(contents)
expect(fixture.operations).toContain(`REALPATH:.`)
expect(fixture.operations).toContain(`LSTAT:${mapping.shellProbePath}`)
expect(fixture.operations).toContain(`LSTAT:${SFTP_RELAY_DIR}/${MARKER_PATH}`)
expect(fixture.operations).toContain(`OPEN:${SFTP_RELAY_DIR}/package.json`)
expect(fixture.operations).toEqual(expect.arrayContaining(['WRITE', 'CLOSE']))
// Shell-namespace path must never be opened for a confirmed split write.
expect(fixture.operations).not.toContain(`OPEN:${shellFilePath}`)
})
}, 15_000)

View File

@ -40,6 +40,10 @@ import {
type SshConnectionCallbacks
} from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import {
resolveSftpTransferPathIfMapped,
type SftpNamespacePathMapping
} from './sftp-namespace-resolution'
import type { FileUploadSession } from '../providers/types'
import { isSshSessionLimitError } from './ssh-session-limit-error'
import {
@ -50,6 +54,8 @@ export type { SshConnectionCallbacks } from './ssh-connection-utils'
type SshRemoteFileOptions = {
hostPlatform?: RemoteHostPlatform
// Only uploadDirectory and writeFile honor this, and only on the non-Windows ssh2 branch.
sftpNamespace?: SftpNamespacePathMapping
}
// Upper bound on waiting for an aborted channel's open/close to settle before rejecting anyway.
@ -423,15 +429,17 @@ export class SshConnection {
sftp.on('error', swallowLateSftpError)
sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError))
try {
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
await raceSftpFileTransferWithAbort(
uploadDirectory(sftp, localDir, remoteDir),
linkedSignal.signal,
(onClose) => {
sftp.once('close', onClose)
endSftp()
}
)
// Why: resolve on the same session that transfers — a later session is not authoritative for this one's namespace.
const transfer = (async (): Promise<void> => {
const targetDir = await resolveSftpTransferPathIfMapped(sftp, remoteDir, options)
linkedSignal.signal.throwIfAborted()
const { uploadDirectory } = await import('./ssh-relay-deploy-helpers')
await uploadDirectory(sftp, localDir, targetDir)
})()
await raceSftpFileTransferWithAbort(transfer, linkedSignal.signal, (onClose) => {
sftp.once('close', onClose)
endSftp()
})
} finally {
endSftp()
}
@ -519,35 +527,13 @@ export class SshConnection {
sftp.on('error', swallowLateSftpError)
sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError))
try {
const write = new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(remotePath)
let settled = false
const cleanup = (): void => {
sftp.removeListener('error', onError)
ws.removeListener('close', onClose)
ws.removeListener('error', onError)
}
const onClose = (): void => {
if (settled) {
return
}
settled = true
cleanup()
resolve()
}
const onError = (err: Error): void => {
if (settled) {
return
}
settled = true
cleanup()
reject(err)
}
sftp.prependOnceListener('error', onError)
ws.once('close', onClose)
ws.once('error', onError)
ws.end(contents)
})
// Why: resolve on the same session that writes — a later session is not authoritative for this one's namespace.
const write = (async (): Promise<void> => {
const targetPath = await resolveSftpTransferPathIfMapped(sftp, remotePath, options)
linkedSignal.signal.throwIfAborted()
const { writeStringViaSftp } = await import('./sftp-upload')
await writeStringViaSftp(sftp, targetPath, contents)
})()
await raceSftpFileTransferWithAbort(write, linkedSignal.signal, (onClose) => {
sftp.once('close', onClose)
endSftp()

View File

@ -7,6 +7,7 @@
// collapses to a shared dir passes every other unit test and re-introduces
// the original "stale daemon serves new client" bug.
import { EventEmitter } from 'node:events'
import { beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
@ -61,24 +62,28 @@ function makeMockConnection(): SshConnection {
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockResolvedValue({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
on: vi.fn(),
once: vi.fn(),
createWriteStream: vi.fn().mockReturnValue({
on: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
once: vi.fn((_event: string, cb: () => void) => {
if (_event === 'close') {
setTimeout(cb, 0)
}
}),
end: vi.fn()
}),
end: vi.fn()
// Why: production attaches and removes real SFTP/write-stream listeners, so the fake must be an emitter.
sftp: vi.fn().mockImplementation(() => {
const sftp = new EventEmitter()
return Promise.resolve(
Object.assign(sftp, {
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
// Shell home and SFTP start directory agree here, so no namespace redirect applies.
realpath: vi.fn((_p: string, cb: (err: Error | null, resolved: string) => void) =>
cb(null, '/home/u')
),
lstat: vi.fn((_p: string, cb: (err: Error | null) => void) =>
cb(Object.assign(new Error('No such file'), { code: 2 }))
),
createWriteStream: vi.fn().mockImplementation(() => {
const ws = new EventEmitter()
return Object.assign(ws, {
end: vi.fn(() => setTimeout(() => ws.emit('close'), 0))
})
}),
end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0))
})
)
})
} as unknown as SshConnection
}

View File

@ -207,6 +207,9 @@ describe('waitForSentinel', () => {
})
describe('execCommand', () => {
const installMarkerToken = 'a'.repeat(32)
const installMarkerPath = `/home/u/.install-lock/.sftp-namespace-${installMarkerToken}`
it('waits for channel close before rejecting a timed-out remote command', async () => {
vi.useFakeTimers()
try {
@ -271,6 +274,42 @@ describe('execCommand', () => {
expect(channel.stderr.listenerCount('data')).toBe(0)
})
it('redacts install-owner marker tokens from command failures', async () => {
const channel = createMockChannel()
const conn = {
exec: vi.fn().mockResolvedValue(channel)
}
const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`)
await Promise.resolve()
channel.emit('data', Buffer.from(`touch failed for ${installMarkerPath}\n`))
channel.emit('close', 1)
const error = await execCommandRejection(commandPromise)
expect(error.message).toContain('.sftp-namespace-[redacted]')
expect(error.message).not.toContain(installMarkerToken)
})
it('redacts install-owner marker tokens from timeout errors', async () => {
vi.useFakeTimers()
try {
const channel = createMockChannel()
const conn = { exec: vi.fn().mockResolvedValue(channel) }
const commandPromise = execCommand(conn as never, `touch '${installMarkerPath}'`, {
timeoutMs: 1_000
})
await vi.advanceTimersByTimeAsync(1_000)
channel.emit('close', 0)
const error = await execCommandRejection(commandPromise)
expect(error.message).toContain('.sftp-namespace-[redacted]')
expect(error.message).not.toContain(installMarkerToken)
} finally {
vi.useRealTimers()
}
})
it('surfaces stdout alongside stderr on nonzero exit instead of masking it', async () => {
const channel = createMockChannel()
const conn = {

View File

@ -64,7 +64,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined)
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./ssh-relay-repair-lock', () => ({

View File

@ -6,11 +6,19 @@ import type { SshConnection } from './ssh-connection'
import type { RelayPlatform } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import {
uploadDirectory,
waitForSentinel,
execCommand,
isUnconfirmedSshCommandTermination
} from './ssh-relay-deploy-helpers'
import { uploadRelayDirectory, writeRelayFile } from './ssh-relay-install-transfers'
import {
createRelayInstallMarkerCommand,
createRelayInstallNamespace,
makeRelayInstallDirectoryCommand,
relayHomeRelativeDir,
relaySftpNamespaceMapping,
type RelayInstallNamespace
} from './ssh-relay-install-namespace'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import {
readLocalFullVersion,
@ -36,7 +44,6 @@ import {
} from './ssh-relay-build-toolchain'
import {
commandWithNodePath,
makeRemoteDirectoryCommand,
makeRemoteExecutableCommand,
readRemoteHomeCommand,
removeRemoteFileCommand
@ -311,6 +318,9 @@ async function deployAndLaunchRelayAttempt(
console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`)
console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`)
// Why: derive the home-relative suffix once — recomputing it by stripping the shell home breaks on a split namespace.
const homeRelativeRelayDir = relayHomeRelativeDir(fullVersion)
let ownsInstallLock = false
let launchGcClaimToken: string | undefined
if (alreadyInstalled) {
@ -320,6 +330,7 @@ async function deployAndLaunchRelayAttempt(
platform,
hostPlatform,
nodePath,
homeRelativeRelayDir,
deploySignal
)
ownsInstallLock = launchFence.ownsInstallLock
@ -336,9 +347,23 @@ async function deployAndLaunchRelayAttempt(
signal: deploySignal
}))
) {
const installNamespace = createInstallNamespaceIfSupported(
conn,
hostPlatform,
homeRelativeRelayDir
)
onProgress?.('Uploading relay...')
console.log('[ssh-relay] Uploading relay...')
await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform, deploySignal)
await uploadRelay(
conn,
platform,
remoteRelayDir,
fullVersion,
hostPlatform,
deploySignal,
installNamespace
)
console.log('[ssh-relay] Upload complete')
onProgress?.('Installing native dependencies...')
@ -349,7 +374,9 @@ async function deployAndLaunchRelayAttempt(
platform,
hostPlatform,
nodePath,
deploySignal
deploySignal,
[],
installNamespace
)
console.log('[ssh-relay] Native deps installed')
@ -421,7 +448,8 @@ async function uploadRelay(
remoteDir: string,
fullVersion: string,
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
signal?: AbortSignal,
namespace?: RelayInstallNamespace
): Promise<void> {
const localRelayDir = getLocalRelayPath(platform)
if (!localRelayDir || !existsSync(localRelayDir)) {
@ -431,11 +459,20 @@ async function uploadRelay(
)
}
await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir), {
signal
})
// Why: the install-owner marker rides along with mkdir, so a standard install spends no extra exec channel on it.
await execHostCommand(
conn,
hostPlatform,
makeRelayInstallDirectoryCommand(hostPlatform, remoteDir, namespace),
{ signal }
)
await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform, signal)
await uploadRelayDirectory(conn, localRelayDir, remoteDir, hostPlatform, {
signal,
sftpNamespace: namespace
? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir)
: undefined
})
if (!isWindowsRemoteHost(hostPlatform)) {
await execHostCommand(
@ -447,60 +484,36 @@ async function uploadRelay(
}
// Why: write .version via SFTP not shell to avoid quoting content-hashed versions; the daemon reads it to validate the wire handshake.
await writeRemoteFile(
await writeRelayFile(
conn,
hostPlatform,
joinRemotePath(hostPlatform, remoteDir, '.version'),
fullVersion,
signal
{
signal,
sftpNamespace: namespace
? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, '.version')
: undefined
}
)
}
async function uploadDirectoryForConnection(
conn: SshConnection,
localRelayDir: string,
remoteDir: string,
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
): Promise<void> {
if (typeof conn.uploadDirectory === 'function') {
await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform, signal })
return
}
const sftp = await conn.sftp()
try {
await uploadDirectory(sftp, localRelayDir, remoteDir)
} finally {
sftp.end()
}
}
async function writeRemoteFile(
/**
* A marker is only meaningful where a split namespace can occur and where Orca
* owns the SFTP session: POSIX hosts reached over the bundled ssh2 transport.
*/
function createInstallNamespaceIfSupported(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
remotePath: string,
contents: string,
signal?: AbortSignal
): Promise<void> {
if (typeof conn.writeFile === 'function') {
await conn.writeFile(remotePath, contents, { hostPlatform, signal })
return
}
const sftp = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(remotePath)
// .once: a late session 'error' after resolve/reject would otherwise be unhandled and crash main.
sftp.once('error', reject)
ws.once('close', resolve)
ws.once('error', reject)
ws.end(contents)
})
} finally {
sftp.end()
homeRelativeRelayDir: string
): RelayInstallNamespace | undefined {
if (isWindowsRemoteHost(hostPlatform)) {
return undefined
}
// A connection double without the transport accessor is an ssh2 connection.
const usesSystemSsh =
typeof conn.usesSystemSshTransport === 'function' ? conn.usesSystemSshTransport() : false
return usesSystemSsh ? undefined : createRelayInstallNamespace(homeRelativeRelayDir)
}
const NODE_PTY_VERSION = '1.1.0'
@ -576,6 +589,7 @@ async function repairInstalledNativeDeps(
platform: RelayPlatform,
hostPlatform: RemoteHostPlatform,
nodePath: string,
homeRelativeRelayDir: string,
signal?: AbortSignal
): Promise<{ ownsInstallLock: boolean; gcClaimToken?: string }> {
const initialProbe = await probeRequiredNativeDeps(
@ -627,6 +641,14 @@ async function repairInstalledNativeDeps(
// Why: older complete relay dirs predate @parcel/watcher; re-probe under the lock so only one reconnect mutates the dir.
const probe = await probeRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath, signal)
if (!probe.available) {
// Why: only stamp ownership once the locked recheck proves this connection is the one about to write.
const repairNamespace = await createRepairInstallMarker(
conn,
hostPlatform,
remoteDir,
homeRelativeRelayDir,
signal
)
await installNativeDeps(
conn,
remoteDir,
@ -634,7 +656,8 @@ async function repairInstalledNativeDeps(
hostPlatform,
nodePath,
signal,
probe.missing
probe.missing,
repairNamespace
)
await finalizeInstall(conn, remoteDir, hostPlatform, { signal, releaseLock: false })
}
@ -652,6 +675,42 @@ async function repairInstalledNativeDeps(
}
}
/**
* Stamp this connection as the install owner during repair. Marker creation is
* best-effort: without it the writes simply keep using the shell path.
*/
async function createRepairInstallMarker(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
remoteDir: string,
homeRelativeRelayDir: string,
signal?: AbortSignal
): Promise<RelayInstallNamespace | undefined> {
const namespace = createInstallNamespaceIfSupported(conn, hostPlatform, homeRelativeRelayDir)
if (!namespace) {
return undefined
}
try {
await execHostCommand(
conn,
hostPlatform,
createRelayInstallMarkerCommand(namespace, hostPlatform, remoteDir),
{ signal }
)
return namespace
} catch (err) {
// Why: an unconfirmed termination still owes the caller its lock semantics; only a confirmed failure degrades to shell paths.
if (isUnconfirmedSshCommandTermination(err)) {
throw err
}
signal?.throwIfAborted()
console.warn(
`[ssh-relay] SFTP namespace marker unavailable at ${remoteDir}; retaining shell paths`
)
return undefined
}
}
async function acquireRelayLaunchGcFence(
conn: SshConnection,
remoteDir: string,
@ -689,7 +748,8 @@ async function installNativeDeps(
hostPlatform: RemoteHostPlatform,
nodePath: string,
signal?: AbortSignal,
resetDeps: RelayNativeDepName[] = []
resetDeps: RelayNativeDepName[] = [],
namespace?: RelayInstallNamespace
): Promise<void> {
// Why: node-pty's prebuild spawns `node` as a child, so node must be in PATH (commandWithNodePath) or it fails exit 127.
// Why: npm init -y rejects '+' in content-hashed dir names, so write a fixed minimal package.json instead.
@ -702,12 +762,17 @@ async function installNativeDeps(
dependencies: RELAY_NATIVE_DEPS,
allowScripts: RELAY_NATIVE_DEP_SCRIPT_ALLOWLIST
})}\n`
await writeRemoteFile(
await writeRelayFile(
conn,
hostPlatform,
joinRemotePath(hostPlatform, remoteDir, 'package.json'),
pkgJson,
signal
{
signal,
sftpNamespace: namespace
? relaySftpNamespaceMapping(namespace, hostPlatform, remoteDir, 'package.json')
: undefined
}
)
try {

View File

@ -1,5 +1,10 @@
import type { ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { createSshOperationAbortError, type SshExecOptions } from './ssh-connection-utils'
import {
redactRelayInstallMarkerError,
redactRelayInstallMarkerTokens
} from './ssh-relay-install-marker'
import type { SystemSshCommandChannel } from './system-ssh-command'
const EXEC_TIMEOUT_MS = 30_000
@ -36,7 +41,14 @@ export async function execCommand(
// Why: reconnect/disconnect can flip the connection back to ssh2 before a
// killed local OpenSSH child emits close; the channel's transport is immutable.
const openedWithSystemSsh = conn.usesSystemSshTransport?.() === true
const channel = await conn.exec(command, execOptions)
let channel: ClientChannel
try {
channel = await conn.exec(command, execOptions)
} catch (error) {
// Preserve identity/classifier fields while removing install-owner tokens.
redactRelayInstallMarkerError(error)
throw error
}
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
@ -98,7 +110,10 @@ export async function execCommand(
}, COMMAND_CLOSE_GRACE_MS)
channel.close()
}
const fail = (err: Error): void => requestTermination(err)
const fail = (err: Error): void => {
redactRelayInstallMarkerError(err)
requestTermination(err)
}
const onAbort = (): void => requestTermination(createSshOperationAbortError())
const onStdoutData = (data: Buffer): void => {
stdout = appendExecOutputTail(stdout, data.toString('utf-8'))
@ -126,14 +141,25 @@ export async function execCommand(
} else if (code !== 0) {
// Why: on the system-ssh transport channel.stderr carries local OpenSSH
// client noise; preferring it masks the real failure in stdout (2>&1).
const output = [stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
settle(reject, new Error(`Command "${command}" failed (exit ${code}): ${output}`))
const output = redactRelayInstallMarkerTokens(
[stderr.trim(), stdout.trim()].filter(Boolean).join('\n')
)
settle(
reject,
new Error(
`Command "${redactRelayInstallMarkerTokens(command)}" failed (exit ${code}): ${output}`
)
)
} else {
settle(resolve, stdout)
}
}
const timeout = setTimeout(() => {
requestTermination(new Error(`Command "${command}" timed out after ${timeoutMs / 1000}s`))
requestTermination(
new Error(
`Command "${redactRelayInstallMarkerTokens(command)}" timed out after ${timeoutMs / 1000}s`
)
)
}, timeoutMs)
// Why: remote reboot tears down exec channels with stream errors. Without

View File

@ -33,7 +33,10 @@ vi.mock('./ssh-relay-versioned-install', () => ({
abandonInstall: vi.fn(),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({ acquireInstallLock: vi.fn() }))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn(),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))

View File

@ -0,0 +1,29 @@
import { randomBytes } from 'node:crypto'
const SFTP_NAMESPACE_MARKER_PREFIX = '.sftp-namespace-'
const SFTP_NAMESPACE_MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/giu
const MARKER_TOKEN_BYTES = 16
export function createRelayInstallMarkerFileName(): string {
return `${SFTP_NAMESPACE_MARKER_PREFIX}${randomBytes(MARKER_TOKEN_BYTES).toString('hex')}`
}
export function redactRelayInstallMarkerTokens(value: string): string {
return value.replace(SFTP_NAMESPACE_MARKER_PATTERN, `${SFTP_NAMESPACE_MARKER_PREFIX}[redacted]`)
}
export function redactRelayInstallMarkerError(error: unknown): void {
if (!(error instanceof Error)) {
return
}
const redactedMessage = redactRelayInstallMarkerTokens(error.message)
if (redactedMessage !== error.message) {
error.message = redactedMessage
}
if (error.stack) {
const redactedStack = redactRelayInstallMarkerTokens(error.stack)
if (redactedStack !== error.stack) {
error.stack = redactedStack
}
}
}

View File

@ -0,0 +1,151 @@
// Why: the shell path and the SFTP-relative path must be built from the same
// validated segments, and the install-owner marker is what proves a redirected
// directory belongs to this install rather than an unrelated same-version one.
import { describe, expect, it } from 'vitest'
import {
createRelayInstallMarkerCommand,
createRelayInstallNamespace,
makeRelayInstallDirectoryCommand,
relayHomeRelativeDir,
relayInstallMarkerShellPath,
relayRemoteDirSegments,
relaySftpNamespaceMapping
} from './ssh-relay-install-namespace'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import { computeRemoteRelayDir } from './ssh-relay-versioned-install'
const LINUX = getRemoteHostPlatform('linux-x64')
const WINDOWS = getRemoteHostPlatform('win32-x64')
const VERSION = '0.1.0+abc123'
const SHELL_RELAY_DIR = `/var/services/homes/alice/.orca-remote/relay-${VERSION}`
describe('relayRemoteDirSegments', () => {
it('builds the two segments every relay path shares', () => {
expect(relayRemoteDirSegments(VERSION, 'posix')).toEqual(['.orca-remote', `relay-${VERSION}`])
})
it('produces a home-relative dir that matches the shell dir suffix', () => {
expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true)
})
it.each([
['a path separator', '1.0/../etc'],
['a NUL byte', '1.0\0'],
['a carriage return', '1.0\rmalicious'],
['a line feed', '1.0\nmalicious']
])('rejects %s in the version segment', (_label, version) => {
expect(() => relayRemoteDirSegments(version, 'posix')).toThrow('Unsafe remote path segment')
})
it('applies Windows-specific segment rules on the windows flavor', () => {
expect(() => relayRemoteDirSegments('1.0 ', 'windows')).toThrow('Unsafe remote path segment')
expect(() => relayRemoteDirSegments('1.0 ', 'posix')).not.toThrow()
})
})
describe('computeRemoteRelayDir agreement', () => {
it('ends with the suffix the SFTP-relative builder produces', () => {
// Why: a split namespace rebuilds the path from the home-relative suffix, so the two must agree.
expect(computeRemoteRelayDir('/var/services/homes/alice', VERSION)).toBe(SHELL_RELAY_DIR)
expect(SHELL_RELAY_DIR.endsWith(`/${relayHomeRelativeDir(VERSION)}`)).toBe(true)
})
it.each([
['a path separator', '0.1.0/../etc'],
['a NUL byte', '0.1.0\0'],
['a line feed', '0.1.0\nrm -rf /']
])('rejects %s in the version rather than building a path', (_label, version) => {
expect(() => computeRemoteRelayDir('/home/u', version)).toThrow('Unsafe remote path segment')
})
it('applies Windows segment rules on the windows flavor', () => {
expect(computeRemoteRelayDir('C:\\Users\\u', VERSION, 'windows')).toBe(
`C:/Users/u/.orca-remote/relay-${VERSION}`
)
expect(() => computeRemoteRelayDir('C:\\Users\\u', '0.1.0 ', 'windows')).toThrow(
'Unsafe remote path segment'
)
})
})
describe('createRelayInstallNamespace', () => {
it('mints an unguessable 128-bit marker name per install', () => {
const first = createRelayInstallNamespace(relayHomeRelativeDir(VERSION))
const second = createRelayInstallNamespace(relayHomeRelativeDir(VERSION))
expect(first.markerFileName).toMatch(/^\.sftp-namespace-[0-9a-f]{32}$/)
expect(first.markerFileName).not.toBe(second.markerFileName)
expect(first.homeRelativeRelayDir).toBe(`.orca-remote/relay-${VERSION}`)
})
})
describe('relaySftpNamespaceMapping', () => {
const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION))
it('maps the bundle directory itself when no file name is given', () => {
const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR)
expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}`)
expect(mapping.homeRelativeProbePath).toBe(
`.orca-remote/relay-${VERSION}/.install-lock/${namespace.markerFileName}`
)
expect(mapping.shellProbePath).toBe(
`${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}`
)
})
it('maps a file inside the bundle directory', () => {
const mapping = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, 'package.json')
expect(mapping.homeRelativePath).toBe(`.orca-remote/relay-${VERSION}/package.json`)
})
it('shares one marker across every write of an install', () => {
const bundle = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR)
const version = relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, '.version')
expect(version.shellProbePath).toBe(bundle.shellProbePath)
expect(version.homeRelativeProbePath).toBe(bundle.homeRelativeProbePath)
})
it.each(['nested/file', '..', '', 'line\nbreak'])(
'rejects an unsafe relative file name %j at mapping construction',
(relativeFileName) => {
expect(() =>
relaySftpNamespaceMapping(namespace, LINUX, SHELL_RELAY_DIR, relativeFileName)
).toThrow('Unsafe remote path segment')
}
)
})
describe('install directory command', () => {
const namespace = createRelayInstallNamespace(relayHomeRelativeDir(VERSION))
it('folds marker creation into the first-install mkdir', () => {
const command = makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR, namespace)
expect(command).toContain(SHELL_RELAY_DIR)
expect(command).toContain(`${SHELL_RELAY_DIR}/.install-lock`)
expect(command).toContain('umask 077')
expect(command).toContain(`touch `)
expect(command).toContain(namespace.markerFileName)
})
it('is the plain directory command when no namespace applies', () => {
expect(makeRelayInstallDirectoryCommand(WINDOWS, 'C:\\Users\\alice\\relay')).not.toContain(
'.sftp-namespace-'
)
expect(makeRelayInstallDirectoryCommand(LINUX, SHELL_RELAY_DIR)).not.toContain('touch ')
})
it('creates the lock directory before touching the marker inside it', () => {
const command = createRelayInstallMarkerCommand(namespace, LINUX, SHELL_RELAY_DIR)
const markerPath = relayInstallMarkerShellPath(namespace, LINUX, SHELL_RELAY_DIR)
expect(command.indexOf('.install-lock')).toBeLessThan(command.indexOf('touch '))
expect(command.indexOf('umask 077')).toBeLessThan(command.indexOf('touch '))
expect(markerPath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${namespace.markerFileName}`)
expect(command).toContain(markerPath)
})
})

View File

@ -0,0 +1,122 @@
// Install-owner identity for relay uploads that cross a split shell/SFTP namespace.
//
// The shell and SFTP paths share one validated home-relative suffix
// (`.orca-remote/relay-<fullVersion>`); a random marker inside the install lock
// lets each SFTP session prove it is looking at THIS install's directory.
//
// See: docs/ssh-relay-sftp-namespace.md
import { RELAY_REMOTE_DIR } from './relay-protocol'
import type { SftpNamespacePathMapping } from './sftp-namespace-resolution'
import { shellEscape } from './ssh-connection-utils'
import { RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock'
import { createRelayInstallMarkerFileName } from './ssh-relay-install-marker'
import { makeRemoteDirectoryCommand } from './ssh-remote-commands'
import {
assertSafeRemotePathSegment,
joinRemotePath,
type RemoteHostPlatform,
type RemotePathFlavor
} from './ssh-remote-platform'
export type RelayInstallNamespace = {
homeRelativeRelayDir: string
markerFileName: string
}
/**
* The two validated segments every relay path is built from. Both the shell
* builder and the SFTP-relative builder go through here so they cannot drift.
*/
export function relayRemoteDirSegments(
fullVersion: string,
pathFlavor: RemotePathFlavor
): string[] {
const segments = [RELAY_REMOTE_DIR, `relay-${fullVersion}`]
for (const segment of segments) {
assertSafeRemotePathSegment(segment, pathFlavor)
// Why: the version reaches logs and diagnostics, where an embedded CR/LF can forge lines.
if (segment.includes('\r') || segment.includes('\n')) {
throw new Error(`Unsafe remote path segment: ${JSON.stringify(segment)}`)
}
}
return segments
}
export function relayHomeRelativeDir(fullVersion: string): string {
return relayRemoteDirSegments(fullVersion, 'posix').join('/')
}
export function createRelayInstallNamespace(homeRelativeRelayDir: string): RelayInstallNamespace {
// Why: an unguessable token, not just a unique suffix — a same-version directory
// under an unrelated SFTP start directory must not qualify as ours.
return {
homeRelativeRelayDir,
markerFileName: createRelayInstallMarkerFileName()
}
}
/**
* Describe one relay transfer to the SFTP namespace resolver. `relativeFileName`
* is omitted for the bundle directory upload itself.
*/
export function relaySftpNamespaceMapping(
namespace: RelayInstallNamespace,
host: RemoteHostPlatform,
shellRelayDir: string,
relativeFileName?: string
): SftpNamespacePathMapping {
if (relativeFileName !== undefined) {
assertSafeRemotePathSegment(relativeFileName, 'posix')
if (relativeFileName.includes('\r') || relativeFileName.includes('\n')) {
throw new Error('Unsafe remote path segment in relay SFTP mapping')
}
}
const homeRelativeLockDir = `${namespace.homeRelativeRelayDir}/${RELAY_INSTALL_LOCK_NAME}`
return {
homeRelativePath:
relativeFileName !== undefined
? `${namespace.homeRelativeRelayDir}/${relativeFileName}`
: namespace.homeRelativeRelayDir,
shellProbePath: relayInstallMarkerShellPath(namespace, host, shellRelayDir),
homeRelativeProbePath: `${homeRelativeLockDir}/${namespace.markerFileName}`
}
}
export function relayInstallMarkerShellPath(
namespace: RelayInstallNamespace,
host: RemoteHostPlatform,
shellRelayDir: string
): string {
return joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME, namespace.markerFileName)
}
/**
* Create the install-owner marker inside the lock this caller already holds.
* POSIX-only: no marker is created for Windows or the system-SSH transport.
*/
export function createRelayInstallMarkerCommand(
namespace: RelayInstallNamespace,
host: RemoteHostPlatform,
shellRelayDir: string
): string {
const lockDir = joinRemotePath(host, shellRelayDir, RELAY_INSTALL_LOCK_NAME)
const markerPath = relayInstallMarkerShellPath(namespace, host, shellRelayDir)
return `${makeRemoteDirectoryCommand(host, lockDir)} && umask 077 && touch ${shellEscape(markerPath)}`
}
/**
* First-install directory creation, folded together with marker creation so a
* standard install spends no extra exec channel on namespace discovery.
*/
export function makeRelayInstallDirectoryCommand(
host: RemoteHostPlatform,
shellRelayDir: string,
namespace?: RelayInstallNamespace
): string {
const makeDir = makeRemoteDirectoryCommand(host, shellRelayDir)
if (!namespace) {
return makeDir
}
return `${makeDir} && ${createRelayInstallMarkerCommand(namespace, host, shellRelayDir)}`
}

View File

@ -0,0 +1,101 @@
// Relay-install SFTP writes. Each helper prefers the SshConnection transfer
// method and otherwise drives one SFTP session itself, because deploy and
// native-dependency tests pass partial connection doubles. Both routes share the
// same namespace resolution, abort race, and one-shot session teardown.
import type { SFTPWrapper } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import { writeStringViaSftp } from './sftp-upload'
import { uploadDirectory } from './ssh-relay-deploy-helpers'
import { raceSftpFileTransferWithAbort } from './ssh-file-transfer-abort'
import {
resolveSftpTransferPathIfMapped,
type SftpNamespacePathMapping
} from './sftp-namespace-resolution'
import type { RemoteHostPlatform } from './ssh-remote-platform'
export type RelayTransferOptions = {
signal?: AbortSignal
sftpNamespace?: SftpNamespacePathMapping
}
export async function uploadRelayDirectory(
conn: SshConnection,
localRelayDir: string,
shellRemoteDir: string,
hostPlatform: RemoteHostPlatform,
options?: RelayTransferOptions
): Promise<void> {
if (typeof conn.uploadDirectory === 'function') {
await conn.uploadDirectory(localRelayDir, shellRemoteDir, {
hostPlatform,
signal: options?.signal,
sftpNamespace: options?.sftpNamespace
})
return
}
await runSftpFallbackTransfer(conn, options, async (sftp) => {
const targetDir = await resolveSftpTransferPathIfMapped(sftp, shellRemoteDir, {
hostPlatform,
sftpNamespace: options?.sftpNamespace
})
options?.signal?.throwIfAborted()
await uploadDirectory(sftp, localRelayDir, targetDir)
})
}
export async function writeRelayFile(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
shellRemotePath: string,
contents: string,
options?: RelayTransferOptions
): Promise<void> {
if (typeof conn.writeFile === 'function') {
await conn.writeFile(shellRemotePath, contents, {
hostPlatform,
signal: options?.signal,
sftpNamespace: options?.sftpNamespace
})
return
}
await runSftpFallbackTransfer(conn, options, async (sftp) => {
const targetPath = await resolveSftpTransferPathIfMapped(sftp, shellRemotePath, {
hostPlatform,
sftpNamespace: options?.sftpNamespace
})
options?.signal?.throwIfAborted()
await writeStringViaSftp(sftp, targetPath, contents)
})
}
async function runSftpFallbackTransfer(
conn: SshConnection,
options: RelayTransferOptions | undefined,
transfer: (sftp: SFTPWrapper) => Promise<void>
): Promise<void> {
const sftp = await conn.sftp(options?.signal)
const swallowLateSftpError = (): void => {}
let sftpEndRequested = false
const endSftp = (): void => {
if (!sftpEndRequested) {
sftpEndRequested = true
sftp.end()
}
}
// A late session 'error' after settle would otherwise be unhandled and crash main.
sftp.on('error', swallowLateSftpError)
sftp.once('close', () => sftp.removeListener('error', swallowLateSftpError))
try {
await raceSftpFileTransferWithAbort(
transfer(sftp),
options?.signal ?? new AbortController().signal,
(onClose) => {
sftp.once('close', onClose)
endSftp()
}
)
} finally {
endSftp()
}
}

View File

@ -1,5 +1,6 @@
// Why: regression coverage for the install-probe contract — the "node-pty is not available" bug shipped because every guard layer was silent.
import { EventEmitter } from 'node:events'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
vi.mock('electron', () => ({
@ -46,7 +47,8 @@ vi.mock('./ssh-relay-versioned-install', () => ({
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined)
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./ssh-relay-repair-lock', () => ({
@ -84,37 +86,32 @@ type SftpWriteCapture = {
execCallCountAtWrite: Record<string, number>
}
type SftpCallback = (err: Error | null, resolved?: string) => void
const NO_SUCH_SFTP_FILE = Object.assign(new Error('No such file'), { code: 2 })
function makeMockConnection(capture: SftpWriteCapture): SshConnection {
const sftpCreate = (): unknown => ({
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
on: vi.fn(),
once: vi.fn(),
createWriteStream: vi.fn().mockImplementation((path: string) => {
capture.paths.push(path)
let buf = ''
let closeCb: (() => void) | undefined
const stub = {
on: vi.fn((event: string, cb: () => void) => {
if (event === 'close') {
closeCb = cb
}
}),
end: vi.fn((data?: string) => {
if (typeof data === 'string') {
buf += data
}
capture.contents[path] = buf
capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length
if (closeCb) {
setTimeout(closeCb, 0)
}
// Why: production attaches/removes real listeners (including prependOnceListener), so the fake must be an emitter.
const sftpCreate = (): unknown => {
const sftp = new EventEmitter()
return Object.assign(sftp, {
mkdir: vi.fn((_p: string, cb: SftpCallback) => cb(null)),
// This host's shell home and SFTP start directory agree, so no namespace redirect is possible.
realpath: vi.fn((_p: string, cb: SftpCallback) => cb(null, '/home/u')),
lstat: vi.fn((_p: string, cb: SftpCallback) => cb(NO_SUCH_SFTP_FILE)),
createWriteStream: vi.fn().mockImplementation((path: string) => {
capture.paths.push(path)
const ws = new EventEmitter()
return Object.assign(ws, {
end: vi.fn((data?: string) => {
capture.contents[path] = `${capture.contents[path] ?? ''}${data ?? ''}`
capture.execCallCountAtWrite[path] = vi.mocked(execCommand).mock.calls.length
setTimeout(() => ws.emit('close'), 0)
})
})
}
// Why: production uses ws.once('close'); the mock delegates 'once' to the same handler table as 'on'.
return Object.assign(stub, { once: stub.on })
}),
end: vi.fn()
})
}),
end: vi.fn(() => setTimeout(() => sftp.emit('close'), 0))
})
}
return {
canRunConcurrentExecCommands: vi.fn().mockReturnValue(false),
exec: vi.fn().mockResolvedValue({
@ -694,6 +691,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
'/home/u',
'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // first probe before lock
'ORCA-NATIVE-DEPS-MISSING:@parcel/watcher\nMISSING', // re-probe after lock
'', // SFTP-namespace install-owner marker (repair)
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
@ -730,6 +728,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
'/home/u',
'MISSING', // health probe: require() fails
'MISSING', // re-probe after lock
'', // SFTP-namespace install-owner marker (repair)
{ reject: 'npm ERR! network ETIMEDOUT' }, // npm install fails (offline)
'DEAD',
'READY'
@ -752,6 +751,7 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
.mockResolvedValueOnce('/home/u')
.mockResolvedValueOnce('MISSING')
.mockResolvedValueOnce('MISSING')
.mockResolvedValueOnce('') // SFTP-namespace install-owner marker (repair)
.mockRejectedValueOnce(
Object.assign(new Error('npm termination was not confirmed'), {
sshChannelCloseConfirmed: false

View File

@ -0,0 +1,599 @@
// Why: on a split-namespace host (Synology DSM) the shell path and the SFTP path
// name the same directory differently, so the deploy must keep issuing shell
// commands against the canonical path while every SFTP write is redirected — and
// only when this connection's own install marker proves the candidate is ours.
import { EventEmitter } from 'node:events'
import { afterEach, 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+testhash')
}))
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()
}),
isUnconfirmedSshCommandTermination: (error: unknown) =>
error instanceof Error &&
(error as Error & { sshChannelCloseConfirmed?: boolean }).sshChannelCloseConfirmed === false,
execCommand: vi.fn()
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
vi.mock('./ssh-relay-versioned-install', () => ({
readLocalFullVersion: vi.fn().mockReturnValue('0.1.0+testhash'),
computeRemoteRelayDir: (home: string, v: string) => `${home}/.orca-remote/relay-${v}`,
isRelayAlreadyInstalled: vi.fn().mockResolvedValue(false),
finalizeInstall: vi.fn().mockResolvedValue(undefined),
abandonInstall: vi.fn().mockResolvedValue(undefined),
gcOldRelayVersions: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-relay-install-lock', () => ({
acquireInstallLock: vi.fn().mockResolvedValue(undefined),
RELAY_INSTALL_LOCK_NAME: '.install-lock'
}))
vi.mock('./ssh-relay-repair-lock', () => ({
tryAcquireRelayRepairLock: vi.fn().mockResolvedValue('acquired')
}))
vi.mock('./ssh-relay-gc-claim', () => ({
releaseRelayGcClaimWithRetry: vi.fn().mockResolvedValue('released'),
tryAcquireRelayGcClaim: vi.fn().mockResolvedValue('launch-token'),
waitForRelayGcClaimRelease: vi.fn().mockResolvedValue(undefined)
}))
vi.mock('./ssh-connection-utils', () => ({
shellEscape: (s: string) => `'${s}'`,
createSshOperationAbortError: () =>
Object.assign(new Error('SSH operation was cancelled'), {
name: 'AbortError'
})
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand, uploadDirectory } from './ssh-relay-deploy-helpers'
import { RELAY_DEPLOY_TIMEOUT_MS } from './ssh-relay-deploy-timing'
import { parseUnameToRelayPlatform } from './relay-protocol'
import {
abandonInstall,
finalizeInstall,
isRelayAlreadyInstalled
} from './ssh-relay-versioned-install'
import { tryAcquireRelayRepairLock } from './ssh-relay-repair-lock'
import type { SshConnection } from './ssh-connection'
import type { SftpNamespacePathMapping } from './sftp-namespace-resolution'
// The transfer-option slice these tests inspect; SshConnection keeps its own options type internal.
type TransferOptions = { sftpNamespace?: SftpNamespacePathMapping }
const SHELL_HOME = '/home/u'
const SFTP_HOME = '/homes/u'
const RELAY_SUFFIX = '.orca-remote/relay-0.1.0+testhash'
const SHELL_RELAY_DIR = `${SHELL_HOME}/${RELAY_SUFFIX}`
const SFTP_RELAY_DIR = `${SFTP_HOME}/${RELAY_SUFFIX}`
const MARKER_PATTERN = /\.sftp-namespace-[0-9a-f]{32}/
type ConnectionOptions = {
// '/homes/u' models a DSM host whose SFTP subsystem starts outside the shell home.
sftpStartPath?: string
lstatPresent?: (path: string) => boolean
hangRealpath?: boolean
// Models an SFTP session that never confirms close, so teardown stays unconfirmed.
neverCloses?: boolean
systemSsh?: boolean
// Present only on the shipping path; absent doubles exercise the deploy's own SFTP fallback.
transferMethods?: boolean
}
type Capture = {
writePaths: string[]
uploadTargets: string[]
realpathCalls: string[]
lstatCalls: string[]
sftpEndCalls: number
uploadOptions: (TransferOptions | undefined)[]
writeOptions: (TransferOptions | undefined)[]
}
function newCapture(): Capture {
return {
writePaths: [],
uploadTargets: [],
realpathCalls: [],
lstatCalls: [],
sftpEndCalls: 0,
uploadOptions: [],
writeOptions: []
}
}
// The marker this run created, read back from the shell command that made it.
function issuedMarkerName(): string | undefined {
for (const [, command] of vi.mocked(execCommand).mock.calls) {
const match = decodeCommand(command).match(MARKER_PATTERN)
if (match) {
return match[0]
}
}
return undefined
}
function decodeCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : command
}
function execCommands(): string[] {
return vi.mocked(execCommand).mock.calls.map(([, command]) => decodeCommand(command))
}
function makeConnection(capture: Capture, options: ConnectionOptions = {}): SshConnection {
const startPath = options.sftpStartPath ?? SFTP_HOME
// Default: the marker is visible only through the SFTP namespace, and only under this install's token.
const lstatPresent =
options.lstatPresent ??
((path: string) =>
path.startsWith(`${SFTP_HOME}/`) && path.includes(issuedMarkerName() ?? '\0'))
const makeSftp = (): unknown => {
const sftp = new EventEmitter()
return Object.assign(sftp, {
mkdir: vi.fn((_p: string, cb: (err: Error | null) => void) => cb(null)),
realpath: vi.fn((path: string, cb: (err: Error | null, resolved?: string) => void) => {
capture.realpathCalls.push(path)
if (options.hangRealpath) {
return
}
cb(null, startPath)
}),
lstat: vi.fn((path: string, cb: (err: Error | null) => void) => {
capture.lstatCalls.push(path)
cb(lstatPresent(path) ? null : Object.assign(new Error('No such file'), { code: 2 }))
}),
createWriteStream: vi.fn().mockImplementation((path: string) => {
capture.writePaths.push(path)
const ws = new EventEmitter()
return Object.assign(ws, {
end: vi.fn(() => setTimeout(() => ws.emit('close'), 0))
})
}),
end: vi.fn(() => {
capture.sftpEndCalls += 1
if (!options.neverCloses) {
setTimeout(() => sftp.emit('close'), 0)
}
})
})
}
const conn: Record<string, unknown> = {
canRunConcurrentExecCommands: vi.fn().mockReturnValue(false),
exec: vi.fn().mockResolvedValue({
on: vi.fn(),
stderr: { on: vi.fn() },
stdin: {},
stdout: { on: vi.fn() },
close: vi.fn()
}),
sftp: vi.fn().mockImplementation(() => Promise.resolve(makeSftp()))
}
if (options.systemSsh) {
conn.usesSystemSshTransport = vi.fn().mockReturnValue(true)
}
if (options.transferMethods) {
conn.uploadDirectory = vi
.fn()
.mockImplementation((_local: string, remote: string, opts?: TransferOptions) => {
capture.uploadTargets.push(remote)
capture.uploadOptions.push(opts)
return Promise.resolve()
})
conn.writeFile = vi
.fn()
.mockImplementation((remote: string, _contents: string, opts?: TransferOptions) => {
capture.writePaths.push(remote)
capture.writeOptions.push(opts)
return Promise.resolve()
})
}
return conn as unknown as SshConnection
}
function feed(responses: string[]): void {
for (const response of responses) {
vi.mocked(execCommand).mockResolvedValueOnce(response)
}
}
// POSIX first install, healthy npm install and node-pty probe.
const POSIX_FIRST_INSTALL = [
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
SHELL_HOME,
'', // mkdir remoteDir (+ install-owner marker)
'', // chmod +x node
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
'', // rm probe stderr
'DEAD',
'READY'
]
// POSIX repair of an installed dir whose native deps are missing.
const POSIX_REPAIR = [
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
SHELL_HOME,
'MISSING', // probe before the repair lock
'MISSING', // re-probe under the lock
'', // install-owner marker
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
'', // rm probe stderr
'DEAD',
'READY'
]
describe('relay install writes on a split SFTP namespace', () => {
let capture: Capture
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(execCommand).mockReset()
vi.mocked(uploadDirectory).mockImplementation((_sftp, _local, remote: string) => {
capture.uploadTargets.push(remote)
return Promise.resolve()
})
vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64')
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(false)
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired')
capture = newCapture()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
vi.restoreAllMocks()
})
it('redirects every first-install write while shell commands keep the canonical path', async () => {
const conn = makeConnection(capture)
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
expect(capture.uploadTargets).toEqual([SFTP_RELAY_DIR])
expect(capture.writePaths).toEqual([
`${SFTP_RELAY_DIR}/.version`,
`${SFTP_RELAY_DIR}/package.json`
])
// Every shell command — mkdir, chmod, npm, launch — still names the shell path.
for (const command of execCommands()) {
expect(command).not.toContain(SFTP_RELAY_DIR)
}
expect(execCommands().some((command) => command.includes(SHELL_RELAY_DIR))).toBe(true)
})
it('folds the install-owner marker into the first-install mkdir', async () => {
const conn = makeConnection(capture)
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
const markerCommands = execCommands().filter((command) => MARKER_PATTERN.test(command))
expect(markerCommands).toHaveLength(1)
expect(markerCommands[0]).toContain('mkdir')
expect(markerCommands[0]).toContain(`${SHELL_RELAY_DIR}/.install-lock`)
})
it('probes one shared marker for every write of an install', async () => {
const conn = makeConnection(capture)
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
const marker = issuedMarkerName()
expect(marker).toMatch(MARKER_PATTERN)
expect(capture.lstatCalls).toEqual([
`${SHELL_RELAY_DIR}/.install-lock/${marker}`,
`${SFTP_RELAY_DIR}/.install-lock/${marker}`,
`${SHELL_RELAY_DIR}/.install-lock/${marker}`,
`${SFTP_RELAY_DIR}/.install-lock/${marker}`,
`${SHELL_RELAY_DIR}/.install-lock/${marker}`,
`${SFTP_RELAY_DIR}/.install-lock/${marker}`
])
})
it('refuses a same-version candidate dir that carries another install marker', async () => {
const foreign = `.sftp-namespace-${'f'.repeat(32)}`
const conn = makeConnection(capture, {
lstatPresent: (path) => path.startsWith(`${SFTP_HOME}/`) && path.includes(foreign)
})
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR])
expect(capture.writePaths).toEqual([
`${SHELL_RELAY_DIR}/.version`,
`${SHELL_RELAY_DIR}/package.json`
])
})
it('keeps the shell path and skips probing when both namespaces agree', async () => {
const conn = makeConnection(capture, { sftpStartPath: SHELL_HOME })
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR])
expect(capture.lstatCalls).toEqual([])
})
it('resolves against a start directory outside any home', async () => {
const conn = makeConnection(capture, {
sftpStartPath: '/volume1/shared',
lstatPresent: (path) => path.startsWith('/volume1/shared/')
})
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
expect(capture.uploadTargets).toEqual([`/volume1/shared/${RELAY_SUFFIX}`])
})
it('passes the same mapping to a connection that owns its own transfer methods', async () => {
const conn = makeConnection(capture, { transferMethods: true })
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
// The shipping path hands over shell paths plus a mapping; resolution happens on the write session.
expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR])
expect(capture.writePaths).toEqual([
`${SHELL_RELAY_DIR}/.version`,
`${SHELL_RELAY_DIR}/package.json`
])
const marker = issuedMarkerName()
const mappings = [...capture.uploadOptions, ...capture.writeOptions].map(
(options) => options?.sftpNamespace
)
expect(mappings).toHaveLength(3)
for (const mapping of mappings) {
expect(mapping?.shellProbePath).toBe(`${SHELL_RELAY_DIR}/.install-lock/${marker}`)
expect(mapping?.homeRelativeProbePath).toBe(`${RELAY_SUFFIX}/.install-lock/${marker}`)
}
expect(mappings.map((mapping) => mapping?.homeRelativePath)).toEqual([
RELAY_SUFFIX,
`${RELAY_SUFFIX}/.version`,
`${RELAY_SUFFIX}/package.json`
])
expect(capture.realpathCalls).toEqual([])
})
it('leaves system-SSH connections unmapped and unprobed', async () => {
// transferMethods models the shipping SshConnection path (uploadDirectory/writeFile → system SSH helpers).
const conn = makeConnection(capture, { systemSsh: true, transferMethods: true })
feed(POSIX_FIRST_INSTALL)
await deployAndLaunchRelay(conn)
expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false)
expect(capture.realpathCalls).toEqual([])
expect(capture.lstatCalls).toEqual([])
expect(conn.sftp).not.toHaveBeenCalled()
// System SSH never retargets: shell absolute paths, no mapping, no SFTP session.
expect(capture.uploadTargets).toEqual([SHELL_RELAY_DIR])
expect(capture.writePaths).toEqual([
`${SHELL_RELAY_DIR}/.version`,
`${SHELL_RELAY_DIR}/package.json`
])
const transferOptions = [...capture.uploadOptions, ...capture.writeOptions]
expect(transferOptions).toHaveLength(3)
for (const options of transferOptions) {
expect(options?.sftpNamespace).toBeUndefined()
}
})
it('leaves Windows hosts unmapped and unprobed', async () => {
vi.mocked(parseUnameToRelayPlatform).mockReturnValue('win32-x64')
const conn = makeConnection(capture)
feed([
'__ORCA_REMOTE_PLATFORM__ Windows AMD64',
'C:\\Users\\u',
'' // mkdir remoteDir
])
// Fail the install right after the package.json write; the launch path is not what this asserts.
vi.mocked(execCommand).mockRejectedValueOnce(new Error('npm install failed'))
await expect(deployAndLaunchRelay(conn)).rejects.toThrow('npm install failed')
expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false)
expect(capture.realpathCalls).toEqual([])
expect(capture.writePaths).toEqual([
'C:/Users/u/.orca-remote/relay-0.1.0+testhash/.version',
'C:/Users/u/.orca-remote/relay-0.1.0+testhash/package.json'
])
})
it('releases the first-install lock when a redirected upload fails', async () => {
const conn = makeConnection(capture)
feed(POSIX_FIRST_INSTALL)
vi.mocked(uploadDirectory).mockRejectedValueOnce(new Error('sftp write failed'))
await expect(deployAndLaunchRelay(conn)).rejects.toThrow('sftp write failed')
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
})
it('ends the SFTP session once when a deploy abort strands namespace discovery', async () => {
vi.useFakeTimers()
try {
const conn = makeConnection(capture, { hangRealpath: true })
feed(POSIX_FIRST_INSTALL)
const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1))
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
const result = await deploy
expect((result as Error).message).toContain('Relay deployment timed out')
await vi.advanceTimersByTimeAsync(5_000)
expect(capture.sftpEndCalls).toBe(1)
// A confirmed close releases the first-install lock and leaves the dir incomplete.
expect(vi.mocked(abandonInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('retains the first-install lock when the aborted SFTP session never closes', async () => {
vi.useFakeTimers()
try {
const conn = makeConnection(capture, { hangRealpath: true, neverCloses: true })
feed(POSIX_FIRST_INSTALL)
const deploy = deployAndLaunchRelay(conn).catch((err: Error) => err)
await vi.waitFor(() => expect(capture.realpathCalls).toHaveLength(1))
await vi.advanceTimersByTimeAsync(RELAY_DEPLOY_TIMEOUT_MS)
await deploy
await vi.advanceTimersByTimeAsync(5_000)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
})
describe('relay repair writes on a split SFTP namespace', () => {
let capture: Capture
let warnSpy: ReturnType<typeof vi.spyOn>
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(execCommand).mockReset()
vi.mocked(uploadDirectory).mockResolvedValue(undefined)
vi.mocked(parseUnameToRelayPlatform).mockReturnValue('linux-x64')
vi.mocked(isRelayAlreadyInstalled).mockResolvedValue(true)
vi.mocked(tryAcquireRelayRepairLock).mockResolvedValue('acquired')
capture = newCapture()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
vi.spyOn(console, 'log').mockImplementation(() => {})
})
afterEach(() => {
warnSpy.mockRestore()
vi.restoreAllMocks()
})
it('stamps the marker only after the locked recheck, then redirects package.json', async () => {
const conn = makeConnection(capture)
let execCountAtLock = -1
vi.mocked(tryAcquireRelayRepairLock).mockImplementation(() => {
execCountAtLock = vi.mocked(execCommand).mock.calls.length
return Promise.resolve('acquired')
})
feed(POSIX_REPAIR)
await deployAndLaunchRelay(conn)
const commands = execCommands()
const markerIndex = commands.findIndex((command) => MARKER_PATTERN.test(command))
expect(markerIndex).toBeGreaterThan(execCountAtLock)
// The re-probe under the lock is the last exec before the marker.
expect(commands[markerIndex - 1]).toContain('loadNativeModule')
expect(capture.writePaths).toEqual([`${SFTP_RELAY_DIR}/package.json`])
})
it('does not stamp a marker when repairing over system SSH', async () => {
const conn = makeConnection(capture, { systemSsh: true, transferMethods: true })
feed([
'__ORCA_REMOTE_PLATFORM__ Linux x86_64',
SHELL_HOME,
'MISSING',
'MISSING',
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
'', // rm probe stderr
'DEAD',
'READY'
])
await deployAndLaunchRelay(conn)
expect(execCommands().some((command) => MARKER_PATTERN.test(command))).toBe(false)
expect(conn.sftp).not.toHaveBeenCalled()
expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`])
expect(capture.writeOptions).toEqual([expect.objectContaining({ sftpNamespace: undefined })])
})
it('degrades to shell paths when marker creation fails outright', async () => {
const conn = makeConnection(capture)
feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING'])
vi.mocked(execCommand).mockRejectedValueOnce(new Error('read-only file system'))
feed([
'', // npm install native deps
'', // chmod prebuilds
'ORCA-NPTY-PROBE-OK\n',
'', // rm probe stderr
'DEAD',
'READY'
])
await deployAndLaunchRelay(conn)
expect(capture.writePaths).toEqual([`${SHELL_RELAY_DIR}/package.json`])
expect(capture.realpathCalls).toEqual([])
expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual(
expect.stringContaining('SFTP namespace marker unavailable')
)
})
it('keeps the repair lock when marker creation has unconfirmed termination', async () => {
const conn = makeConnection(capture)
feed(['__ORCA_REMOTE_PLATFORM__ Linux x86_64', SHELL_HOME, 'MISSING', 'MISSING'])
vi.mocked(execCommand).mockRejectedValueOnce(
Object.assign(new Error('marker teardown unconfirmed'), { sshChannelCloseConfirmed: false })
)
feed(['DEAD', 'READY'])
await deployAndLaunchRelay(conn)
// Repair is best-effort: the relay still launches, but nothing was written and no lock was released.
expect(capture.writePaths).toEqual([])
expect(vi.mocked(finalizeInstall)).not.toHaveBeenCalled()
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
expect(warnSpy.mock.calls.map((args) => String(args[0]))).toContainEqual(
expect.stringContaining('launching degraded')
)
})
})

View File

@ -11,6 +11,7 @@ import { RELAY_REMOTE_DIR } from './relay-protocol'
import { execCommand } from './ssh-relay-deploy-helpers'
import { probeInstallLockExistsCommand } from './ssh-relay-install-lock-commands'
import { isRelayInstallLockStale, RELAY_INSTALL_LOCK_NAME } from './ssh-relay-install-lock'
import { relayRemoteDirSegments } from './ssh-relay-install-namespace'
import {
isRelayGcClaimOwned,
releaseRelayGcClaimWithRetry,
@ -100,7 +101,8 @@ export function computeRemoteRelayDir(
pathFlavor === 'windows'
? getRemoteHostPlatform('win32-x64')
: getRemoteHostPlatform('linux-x64')
return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, `relay-${fullVersion}`)
// Why: shell and SFTP-relative builders must derive the same validated segments or the namespaces diverge.
return joinRemotePath(host, remoteHome, ...relayRemoteDirSegments(fullVersion, pathFlavor))
}
/**