fix: support windows ssh hosts (#5004)

* feat: add windows ssh relay base support

* feat: support windows ssh relay runtime services

* fix: default windows ssh pty cwd to user profile

* fix: support windows hosts over system ssh

* fix: preserve degraded windows relay native deps

* fix: gate windows shell args by relay platform

* fix: preserve windows relay fallback pipes

* test: align windows native deps relay fixture

* fix: build valid windows install lock command

* fix: address windows SSH relay review findings

Resolve correctness, efficiency, and reuse issues found reviewing the
Windows SSH native-host support:

- GC liveness on Windows now probes the actual named pipe (via node
  net.connect against markers + deterministic candidates) instead of
  substring-matching Win32_Process command lines, which could remove a
  live relay dir. Reports ALIVE conservatively only when there is no
  liveness signal at all (no markers and no seed pipes).
- Resolve the remote node path once per deploy and thread it through
  install/repair/launch instead of re-resolving 3-7x.
- Replace the 200ms node -e poll loop with a single long-lived remote
  wait process during Windows relay startup.
- Skip the no-op executable command on Windows in uploadRelay.
- Make the Windows fallback pipe name deterministic and recoverable
  (drop the global counter), with an extra reconnect attempt.
- Normalize the prepended node bin dir to backslashes on Windows PATH.
- Batch the system-SSH Windows directory upload into a single streamed
  JSON package instead of one ssh process per file.
- Extract relay endpoint/marker helpers into ssh-relay-endpoints.ts and
  consolidate the PowerShell EncodedCommand encoding into the shared
  powershell-command-encoding module.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* Support cancellation and timeouts in Windows port scanning

- Propagate the request AbortSignal and a 5-second timeout to both
  PowerShell and netstat child processes during Windows port scanning.
- Avoid spawning the netstat fallback process if the port scan has
  already been aborted.
- Wrap the .NET OSArchitecture check in a try/catch block during SSH
  Windows platform detection to robustly fall back to environment
  variables if needed.

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
This commit is contained in:
Leynier Gutiérrez González
2026-06-09 01:17:34 -07:00
committed by GitHub
co-authored by Claude Opus 4.8 Jinjing
parent ce66cee37b
commit 98d02bca47
49 changed files with 3055 additions and 314 deletions
+16
View File
@@ -208,6 +208,22 @@ describe('parseUnameToRelayPlatform', () => {
expect(parseUnameToRelayPlatform('Linux', 'amd64')).toBe('linux-x64')
})
it('maps Windows amd64', () => {
expect(parseUnameToRelayPlatform('Windows', 'AMD64')).toBe('win32-x64')
})
it('maps Windows X64 runtime architecture', () => {
expect(parseUnameToRelayPlatform('Windows', 'X64')).toBe('win32-x64')
})
it('maps Windows arm64', () => {
expect(parseUnameToRelayPlatform('win32', 'ARM64')).toBe('win32-arm64')
})
it('maps MSYS/MINGW uname output as Windows', () => {
expect(parseUnameToRelayPlatform('MINGW64_NT-10.0', 'x86_64')).toBe('win32-x64')
})
it('returns null for unsupported OS', () => {
expect(parseUnameToRelayPlatform('FreeBSD', 'x86_64')).toBeNull()
})
+15 -2
View File
@@ -192,7 +192,13 @@ export function parseJsonRpcMessage(payload: Buffer): JsonRpcMessage {
// ── Supported platforms ─────────────────────────────────────────────
export type RelayPlatform = 'linux-x64' | 'linux-arm64' | 'darwin-x64' | 'darwin-arm64'
export type RelayPlatform =
| 'linux-x64'
| 'linux-arm64'
| 'darwin-x64'
| 'darwin-arm64'
| 'win32-x64'
| 'win32-arm64'
export function parseUnameToRelayPlatform(os: string, arch: string): RelayPlatform | null {
const normalizedOs = os.toLowerCase().trim()
@@ -203,10 +209,17 @@ export function parseUnameToRelayPlatform(os: string, arch: string): RelayPlatfo
relayOs = 'linux'
} else if (normalizedOs === 'darwin') {
relayOs = 'darwin'
} else if (
normalizedOs === 'windows' ||
normalizedOs === 'win32' ||
normalizedOs.startsWith('mingw') ||
normalizedOs.startsWith('msys')
) {
relayOs = 'win32'
}
let relayArch: string | null = null
if (normalizedArch === 'x86_64' || normalizedArch === 'amd64') {
if (normalizedArch === 'x86_64' || normalizedArch === 'amd64' || normalizedArch === 'x64') {
relayArch = 'x64'
} else if (normalizedArch === 'aarch64' || normalizedArch === 'arm64') {
relayArch = 'arm64'
+4
View File
@@ -88,6 +88,10 @@ export function wrapRemoteCommandForPosixShell(command: string): string {
return `exec /bin/sh -c ${shellEscape(command)}`
}
export type SshExecOptions = {
wrapCommand?: boolean
}
function cmdEscape(s: string): string {
return `"${s.replace(/"/g, '""')}"`
}
+55 -2
View File
@@ -140,6 +140,8 @@ import {
type SshConnectionCallbacks
} from './ssh-connection'
import { resolveWithSshG } from './ssh-config-parser'
import { uploadDirectoryViaSystemSsh, writeFileViaSystemSsh } from './ssh-system-fallback'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshTarget } from '../../shared/ssh-types'
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
@@ -194,6 +196,10 @@ describe('SshConnection', () => {
clientInstances = []
spawnSystemSshCommandMock.mockReset()
spawnSystemSshCommandMock.mockImplementation(() => createSystemCommandChannel())
vi.mocked(uploadDirectoryViaSystemSsh).mockReset()
vi.mocked(uploadDirectoryViaSystemSsh).mockResolvedValue(undefined)
vi.mocked(writeFileViaSystemSsh).mockReset()
vi.mocked(writeFileViaSystemSsh).mockResolvedValue(undefined)
vi.mocked(resolveWithSshG).mockReset()
vi.mocked(resolveWithSshG).mockResolvedValue(null)
vi.unstubAllEnvs()
@@ -562,6 +568,17 @@ describe('SshConnection', () => {
)
})
it('can execute native remote commands without the POSIX shell wrapper', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
await conn.exec('powershell.exe -NoProfile -EncodedCommand AAAA', { wrapCommand: false })
expect(clientInstances[0].lastExecCommand).toBe(
'powershell.exe -NoProfile -EncodedCommand AAAA'
)
})
it('times out when ssh2 never opens an exec channel', async () => {
const conn = new SshConnection(createTarget(), createCallbacks())
await conn.connect()
@@ -668,7 +685,8 @@ describe('SshConnection', () => {
expect(clientInstances).toHaveLength(0)
expect(spawnSystemSshCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'fdpass-host' }),
'printf ORCA-SYSTEM-SSH-OK'
'echo ORCA-SYSTEM-SSH-OK',
{ wrapCommand: false }
)
})
@@ -685,7 +703,42 @@ describe('SshConnection', () => {
expect(clientInstances).toHaveLength(0)
expect(spawnSystemSshCommandMock).toHaveBeenCalledWith(
expect.objectContaining({ proxyCommand: 'ssh -W %h:%p bastion.example.com' }),
'printf ORCA-SYSTEM-SSH-OK'
'echo ORCA-SYSTEM-SSH-OK',
{ wrapCommand: false }
)
})
it('passes the detected host platform to system SSH file operations', async () => {
vi.mocked(resolveWithSshG).mockResolvedValueOnce({
hostname: 'example.com',
port: 22,
identityFile: [],
forwardAgent: false,
identitiesOnly: false,
proxyUseFdpass: true
})
const conn = new SshConnection(createTarget({ configHost: 'fdpass-host' }), createCallbacks())
const hostPlatform = getRemoteHostPlatform('win32-x64')
await conn.connect()
await conn.uploadDirectory('/tmp/local-relay', 'C:/Users/me/.orca-remote/relay', {
hostPlatform
})
await conn.writeFile('C:/Users/me/.orca-remote/relay/.version', '0.1.0', {
hostPlatform
})
expect(uploadDirectoryViaSystemSsh).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'fdpass-host' }),
'/tmp/local-relay',
'C:/Users/me/.orca-remote/relay',
expect.objectContaining({ hostPlatform })
)
expect(writeFileViaSystemSsh).toHaveBeenCalledWith(
expect.objectContaining({ configHost: 'fdpass-host' }),
'C:/Users/me/.orca-remote/relay/.version',
'0.1.0',
expect.objectContaining({ hostPlatform })
)
})
+34 -10
View File
@@ -26,10 +26,16 @@ import {
resolveEffectiveProxy,
spawnProxyCommand,
wrapRemoteCommandForPosixShell,
type SshExecOptions,
type SshConnectionCallbacks
} from './ssh-connection-utils'
import type { RemoteHostPlatform } from './ssh-remote-platform'
export type { SshConnectionCallbacks } from './ssh-connection-utils'
type SshRemoteFileOptions = {
hostPlatform?: RemoteHostPlatform
}
export class SshConnection {
private client: SshClient | null = null
private proxyProcess: ChildProcess | null = null
@@ -84,20 +90,21 @@ export class SshConnection {
return this.cachedPassphrase != null || this.cachedPassword != null
}
async exec(cmd: string): Promise<ClientChannel> {
async exec(cmd: string, options?: SshExecOptions): Promise<ClientChannel> {
if (this.useSystemSshTransport) {
if (this.disposed || this.state.status !== 'connected') {
throw new Error('Not connected')
}
return this.spawnTrackedSystemSshCommand(cmd)
return this.spawnTrackedSystemSshCommand(cmd, options)
}
if (!this.client) {
throw new Error('Not connected')
}
const client = this.client
const remoteCommand = options?.wrapCommand === false ? cmd : wrapRemoteCommandForPosixShell(cmd)
return this.waitForSshCallback(
'SSH exec channel timed out',
(callback) => client.exec(wrapRemoteCommandForPosixShell(cmd), callback),
(callback) => client.exec(remoteCommand, callback),
(channel) => channel.close()
)
}
@@ -161,7 +168,11 @@ export class SshConnection {
})
}
async uploadDirectory(localDir: string, remoteDir: string): Promise<void> {
async uploadDirectory(
localDir: string,
remoteDir: string,
options?: SshRemoteFileOptions
): Promise<void> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
try {
@@ -173,11 +184,16 @@ export class SshConnection {
return
}
await uploadDirectoryViaSystemSsh(this.target, localDir, remoteDir, {
signal: this.systemOperationAbortController.signal
signal: this.systemOperationAbortController.signal,
hostPlatform: options?.hostPlatform
})
}
async writeFile(remotePath: string, contents: string): Promise<void> {
async writeFile(
remotePath: string,
contents: string,
options?: SshRemoteFileOptions
): Promise<void> {
if (!this.useSystemSshTransport) {
const sftp = await this.sftp()
const swallowLateSftpError = (): void => {}
@@ -221,7 +237,8 @@ export class SshConnection {
return
}
await writeFileViaSystemSsh(this.target, remotePath, contents, {
signal: this.systemOperationAbortController.signal
signal: this.systemOperationAbortController.signal,
hostPlatform: options?.hostPlatform
})
}
@@ -418,7 +435,11 @@ export class SshConnection {
this.proxyProcess?.kill()
this.proxyProcess = null
const channel = this.spawnTrackedSystemSshCommand('printf ORCA-SYSTEM-SSH-OK')
// Why: this probe runs before remote platform detection. A raw echo works
// under POSIX shells, cmd.exe, and PowerShell; `/bin/sh` wrapping does not.
const channel = this.spawnTrackedSystemSshCommand('echo ORCA-SYSTEM-SSH-OK', {
wrapCommand: false
})
try {
await new Promise<void>((resolve, reject) => {
let stdout = ''
@@ -485,8 +506,11 @@ export class SshConnection {
}
}
private spawnTrackedSystemSshCommand(command: string): ClientChannel {
const channel = spawnSystemSshCommand(this.target, command)
private spawnTrackedSystemSshCommand(command: string, options?: SshExecOptions): ClientChannel {
const channel =
options === undefined
? spawnSystemSshCommand(this.target, command)
: spawnSystemSshCommand(this.target, command, options)
this.systemCommandChannels.add(channel)
const cleanup = (): void => {
this.systemCommandChannels.delete(channel)
@@ -33,7 +33,10 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn(),
execCommand: vi.fn()
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
+11 -79
View File
@@ -1,11 +1,9 @@
import type { ClientChannel } from 'ssh2'
import type { SshConnection } from './ssh-connection'
import type { SshExecOptions } from './ssh-connection-utils'
import { RELAY_SENTINEL, RELAY_SENTINEL_TIMEOUT_MS } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import {
RelayVersionMismatchError,
RELAY_EXIT_CODE_VERSION_MISMATCH
} from './ssh-relay-version-mismatch-error'
import { buildRelayVersionMismatchError } from './ssh-relay-handshake-mismatch'
export { uploadFile, uploadDirectory, mkdirSftp } from './sftp-upload'
@@ -126,9 +124,9 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
// condition and skip backoff. The check still wins over a fired
// timeout because the timeout handler defers settling for a small
// grace window so the close handler can deliver the exit code.
if (lastExitCode === RELAY_EXIT_CODE_VERSION_MISMATCH) {
const { expected, got } = parseHandshakeMismatchStderr(stderrOutput)
reject(new RelayVersionMismatchError(expected, got, stderrOutput.trim()))
const versionMismatchError = buildRelayVersionMismatchError(lastExitCode, stderrOutput)
if (versionMismatchError) {
reject(versionMismatchError)
return
}
const timeoutSuffix = timeoutFired
@@ -240,8 +238,12 @@ export function waitForSentinel(channel: ClientChannel): Promise<MultiplexerTran
const EXEC_TIMEOUT_MS = 30_000
export async function execCommand(conn: SshConnection, command: string): Promise<string> {
const channel = await conn.exec(command)
export async function execCommand(
conn: SshConnection,
command: string,
options?: SshExecOptions
): Promise<string> {
const channel = await conn.exec(command, options)
return new Promise((resolve, reject) => {
let stdout = ''
let stderr = ''
@@ -293,73 +295,3 @@ export async function execCommand(conn: SshConnection, command: string): Promise
channel.on('close', onClose)
})
}
// ── Remote Node.js resolution ─────────────────────────────────────────
// Why: non-login SSH shells (the default for `exec`) don't source
// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH.
// We try common locations and fall back to a login-shell `which`.
export async function resolveRemoteNodePath(conn: SshConnection): Promise<string> {
// Why: non-login SSH exec channels don't source .bashrc/.zshrc, so node
// installed via nvm/fnm/Homebrew may not be in PATH. We probe common
// locations directly, then fall back to sourcing the profile explicitly.
// The glob in $HOME/.nvm/... is expanded by the shell, not by `command -v`.
const script = [
'command -v node 2>/dev/null',
'command -v /usr/local/bin/node 2>/dev/null',
'command -v /opt/homebrew/bin/node 2>/dev/null',
// Why: nvm installs into a versioned directory. `ls -1` sorts
// alphabetically, which misorders versions (e.g. v9 > v18). Pipe
// through `sort -V` (version sort) so we pick the highest version.
'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1',
'command -v $HOME/.local/bin/node 2>/dev/null',
'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null'
].join(' || ')
try {
const result = await execCommand(conn, script)
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node at: ${nodePath}`)
return nodePath
}
} catch {
// Fall through to login shell attempt
}
// Why: last resort — source the full login profile. This is separated into
// its own exec because `bash -lc` can hang on remotes with interactive
// shell configs (conda prompts, etc.). If this times out, the error message
// from execCommand will tell us it was the login shell attempt.
try {
console.log('[ssh-relay] Trying login shell to find node...')
const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null")
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node via login shell: ${nodePath}`)
return nodePath
}
} catch {
// Fall through
}
throw new Error(
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
'Install Node.js on the remote and try again.'
)
}
// Why: extract the expected/got version pair from --connect's stderr line
// "Handshake mismatch: expected=<x>, daemon=<y>" so the typed error carries
// actionable detail. Best-effort: returns undefined fields if the regex
// doesn't match, preserving the raw stderr verbatim for diagnostics.
function parseHandshakeMismatchStderr(stderr: string): {
expected: string | undefined
got: string | undefined
} {
const match = /expected=([^,\s]+),\s*daemon=([^\s;]+)/.exec(stderr)
if (!match) {
return { expected: undefined, got: undefined }
}
return { expected: match[1], got: match[2] }
}
+207 -3
View File
@@ -16,7 +16,21 @@ vi.mock('fs', () => ({
vi.mock('./relay-protocol', () => ({
RELAY_VERSION: '0.1.0',
RELAY_REMOTE_DIR: '.orca-remote',
parseUnameToRelayPlatform: vi.fn().mockReturnValue('linux-x64'),
parseUnameToRelayPlatform: vi.fn((os: string, arch: string) => {
const normalizedOs = os.toLowerCase()
const normalizedArch = arch.toLowerCase()
const relayArch = normalizedArch === 'arm64' || normalizedArch === 'aarch64' ? 'arm64' : 'x64'
if (normalizedOs === 'windows' || normalizedOs === 'win32') {
return `win32-${relayArch}`
}
if (normalizedOs === 'darwin') {
return `darwin-${relayArch}`
}
if (normalizedOs === 'linux') {
return `linux-${relayArch}`
}
return null
}),
RELAY_SENTINEL: 'ORCA-RELAY v0.1.0 READY\n',
RELAY_SENTINEL_TIMEOUT_MS: 10_000
}))
@@ -28,7 +42,10 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn().mockResolvedValue('Linux x86_64'),
execCommand: vi.fn().mockResolvedValue('Linux x86_64')
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
@@ -50,13 +67,27 @@ vi.mock('./ssh-connection-utils', () => ({
}))
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { execCommand, waitForSentinel } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import type { SshConnection } from './ssh-connection'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS
} from '../../shared/ssh-types'
function decodePowerShellCommand(command: string): string | null {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null
}
function extractWindowsSockPath(script: string): string {
return /--sock-path\s+'([^']+)'/.exec(script)?.[1] ?? ''
}
function extractWindowsMarkerPath(script: string): string {
return /-LiteralPath\s+'([^']*\.windows-active-pipe[^']*)'/.exec(script)?.[1] ?? ''
}
function makeMockConnection(): SshConnection {
return {
exec: vi.fn().mockResolvedValue({
@@ -116,6 +147,20 @@ describe('deployAndLaunchRelay', () => {
expect(progress).toContain('Starting relay...')
})
it('resolves the remote node path once per deploy', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
mockExecCommand.mockResolvedValueOnce('Linux x86_64')
mockExecCommand.mockResolvedValueOnce('/home/user')
mockExecCommand.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
mockExecCommand.mockResolvedValueOnce('DEAD')
mockExecCommand.mockResolvedValueOnce('READY')
await deployAndLaunchRelay(conn)
expect(resolveRemoteNodePath).toHaveBeenCalledTimes(1)
})
it('defaults fresh relays to the three-hour SSH disconnect grace window', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
@@ -254,4 +299,163 @@ describe('deployAndLaunchRelay', () => {
expect(launchB).toContain('--sock-path')
expect(launchA).not.toEqual(launchB)
})
it('launches Windows remotes via a named pipe endpoint', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe')
mockExecCommand
.mockRejectedValueOnce(new Error('uname not found')) // uname -sm
.mockResolvedValueOnce('Windows X64') // PowerShell platform probe
.mockResolvedValueOnce('C:\\Users\\me user') // remote home
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe
.mockResolvedValueOnce('') // no persisted active pipe
.mockResolvedValueOnce('WAITING') // named pipe probe
.mockResolvedValueOnce('') // Start-Process launch
.mockResolvedValueOnce('READY') // named pipe poll
.mockResolvedValueOnce('') // persist active pipe marker
const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a')
expect(result.platform).toBe('win32-x64')
expect(result.remoteHome).toBe('C:/Users/me user')
expect(result.sockPath).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/)
const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string)
expect(execCommands).toHaveLength(1)
expect(execCommands[0]).toContain('powershell.exe')
const decodedScripts = mockExecCommand.mock.calls
.map(([, command]) => decodePowerShellCommand(command))
.filter((script): script is string => script !== null)
const launchScript = decodedScripts.find((script) => script.includes('Start-Process')) ?? ''
expect(launchScript).toContain(
'"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/relay.js"'
)
expect(launchScript).toContain('--endpoint-dir')
expect(launchScript).toContain(
'"C:/Users/me user/.orca-remote/relay-0.1.0+abcdef012345/agent-hooks/orca-relay-'
)
expect(launchScript).not.toContain('\\\\.\\pipe\\agent-hooks')
const waitScript = decodedScripts.find((script) => script.includes('deadline=Date.now()')) ?? ''
expect(waitScript).toContain('setTimeout(attempt,intervalMs)')
})
it('relaunches Windows remotes on a fallback pipe when reconnecting the occupied pipe fails', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe')
vi.mocked(waitForSentinel)
.mockRejectedValueOnce(new Error('stale daemon handshake failed'))
.mockResolvedValueOnce({
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
})
mockExecCommand
.mockRejectedValueOnce(new Error('uname not found')) // uname -sm
.mockResolvedValueOnce('Windows X64') // PowerShell platform probe
.mockResolvedValueOnce('C:\\Users\\me user') // remote home
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe
.mockResolvedValueOnce('') // no persisted active pipe yet
.mockResolvedValueOnce('READY') // existing named pipe probe
.mockResolvedValueOnce('WAITING') // deterministic fallback pipe is not already running
.mockResolvedValueOnce('') // Start-Process launch on fallback pipe
.mockResolvedValueOnce('READY') // fallback pipe poll
.mockResolvedValueOnce('') // persist fallback active pipe marker
const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a')
const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string)
expect(execCommands).toHaveLength(2)
const firstConnectScript = decodePowerShellCommand(execCommands[0]) ?? ''
const secondConnectScript = decodePowerShellCommand(execCommands[1]) ?? ''
const primaryPipe = extractWindowsSockPath(firstConnectScript)
const fallbackPipe = extractWindowsSockPath(secondConnectScript)
expect(primaryPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/)
expect(fallbackPipe).toMatch(/^\\\\\.\\pipe\\orca-relay-[0-9a-f]{20}$/)
expect(fallbackPipe).not.toBe(primaryPipe)
expect(result.sockPath).toBe(fallbackPipe)
const launchScript =
mockExecCommand.mock.calls
.map(([, command]) => decodePowerShellCommand(command))
.find((script) => script?.includes('Start-Process')) ?? ''
expect(launchScript).toContain(fallbackPipe)
expect(launchScript).not.toContain(primaryPipe)
const markerWriteScript =
mockExecCommand.mock.calls
.map(([, command]) => decodePowerShellCommand(command))
.find(
(script) => script?.includes('Set-Content') && script.includes('.windows-active-pipe')
) ?? ''
expect(markerWriteScript).toContain(fallbackPipe)
expect(markerWriteScript).not.toContain(primaryPipe)
})
it('prefers a persisted Windows fallback pipe on later reconnects', async () => {
const conn = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
const persistedPipe = '\\\\.\\pipe\\orca-relay-1234567890abcdef1234'
vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe')
mockExecCommand
.mockRejectedValueOnce(new Error('uname not found')) // uname -sm
.mockResolvedValueOnce('Windows X64') // PowerShell platform probe
.mockResolvedValueOnce('C:\\Users\\me user') // remote home
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK') // native deps probe
.mockResolvedValueOnce(`${persistedPipe}\n`) // persisted active pipe marker
.mockResolvedValueOnce('READY') // persisted named pipe probe
.mockResolvedValueOnce('') // refresh active pipe marker
const result = await deployAndLaunchRelay(conn, undefined, 300, 'target-a')
const execCommands = vi.mocked(conn.exec).mock.calls.map(([cmd]) => cmd as string)
expect(execCommands).toHaveLength(1)
const connectScript = decodePowerShellCommand(execCommands[0]) ?? ''
expect(extractWindowsSockPath(connectScript)).toBe(persistedPipe)
expect(result.sockPath).toBe(persistedPipe)
const decodedExecScripts = mockExecCommand.mock.calls
.map(([, command]) => decodePowerShellCommand(command))
.filter((script): script is string => script !== null)
expect(decodedExecScripts.some((script) => script.includes('Start-Process'))).toBe(false)
})
it('scopes persisted Windows active pipe markers by relay target', async () => {
const connA = makeMockConnection()
const connB = makeMockConnection()
const mockExecCommand = vi.mocked(execCommand)
vi.mocked(resolveRemoteNodePath).mockResolvedValue('C:/Program Files/nodejs/node.exe')
mockExecCommand
.mockRejectedValueOnce(new Error('uname not found')) // uname A
.mockResolvedValueOnce('Windows X64')
.mockResolvedValueOnce('C:\\Users\\me user')
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
.mockResolvedValueOnce('') // no persisted active pipe A
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('READY')
.mockResolvedValueOnce('') // persist active pipe A
.mockRejectedValueOnce(new Error('uname not found')) // uname B
.mockResolvedValueOnce('Windows X64')
.mockResolvedValueOnce('C:\\Users\\me user')
.mockResolvedValueOnce('ORCA-NATIVE-DEPS-OK')
.mockResolvedValueOnce('') // no persisted active pipe B
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('')
.mockResolvedValueOnce('READY')
.mockResolvedValueOnce('') // persist active pipe B
await deployAndLaunchRelay(connA, undefined, 300, 'target-a')
await deployAndLaunchRelay(connB, undefined, 300, 'target-b')
const markerPaths = mockExecCommand.mock.calls
.map(([, command]) => decodePowerShellCommand(command))
.filter((script): script is string => Boolean(script?.includes('Get-Content')))
.map(extractWindowsMarkerPath)
expect(markerPaths).toHaveLength(2)
expect(markerPaths[0]).toContain('.windows-active-pipe-relay-')
expect(markerPaths[1]).toContain('.windows-active-pipe-relay-')
expect(markerPaths[0]).not.toBe(markerPaths[1])
})
})
+554 -87
View File
@@ -6,14 +6,10 @@ import { join } from 'path'
import { existsSync } from 'fs'
import { app } from 'electron'
import type { SshConnection } from './ssh-connection'
import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol'
import type { RelayPlatform } from './relay-protocol'
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
import {
uploadDirectory,
waitForSentinel,
execCommand,
resolveRemoteNodePath
} from './ssh-relay-deploy-helpers'
import { uploadDirectory, waitForSentinel, execCommand } from './ssh-relay-deploy-helpers'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import {
readLocalFullVersion,
computeRemoteRelayDir,
@@ -24,7 +20,30 @@ import {
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { shellEscape } from './ssh-connection-utils'
import {
commandWithNodePath,
makeRemoteDirectoryCommand,
makeRemoteExecutableCommand,
readRemoteHomeCommand,
removeRemoteFileCommand
} from './ssh-remote-commands'
import {
isWindowsRemoteHost,
joinRemotePath,
normalizeRemoteHome,
validateRemoteHome,
type RemoteHostPlatform
} from './ssh-remote-platform'
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import { relaySocketNameForInstanceId } from './ssh-relay-instance-id'
import {
isWindowsRelayPipePath,
relayEndpointForHost,
relayHookEndpointDirForHost,
windowsActivePipeMarkerPath,
windowsRelayFallbackSocketName
} from './ssh-relay-endpoints'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
MAX_SSH_RELAY_GRACE_PERIOD_SECONDS,
@@ -34,6 +53,7 @@ import {
export type RelayDeployResult = {
transport: MultiplexerTransport
platform: RelayPlatform
hostPlatform?: RemoteHostPlatform
remoteHome?: string
remoteRelayDir?: string
nodePath?: string
@@ -46,6 +66,14 @@ export type RelayDeployResult = {
// upload could block the connection indefinitely.
const RELAY_DEPLOY_TIMEOUT_MS = 120_000
function execHostCommand(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
command: string
): Promise<string> {
return execCommand(conn, command, { wrapCommand: !isWindowsRemoteHost(hostPlatform) })
}
/**
* Deploy the relay to the remote host and launch it.
*
@@ -88,12 +116,13 @@ async function deployAndLaunchRelayInner(
): Promise<RelayDeployResult> {
onProgress?.('Detecting remote platform...')
console.log('[ssh-relay] Detecting remote platform...')
const platform = await detectRemotePlatform(conn)
if (!platform) {
const hostPlatform = await detectRemoteHostPlatform(conn)
if (!hostPlatform) {
throw new Error(
'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64.'
'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64, win32-x64, win32-arm64.'
)
}
const platform = hostPlatform.relayPlatform
console.log(`[ssh-relay] Platform: ${platform}`)
const localRelayDir = getLocalRelayPath(platform)
@@ -109,73 +138,87 @@ async function deployAndLaunchRelayInner(
// docs/ssh-relay-versioned-install-dirs.md "Data Flow: Upstream Error".
const fullVersion = readLocalFullVersion(localRelayDir)
// Why: SFTP does not expand `~`, so we must resolve the remote home directory
// explicitly. `echo $HOME` over exec gives us the absolute path.
const remoteHome = (await execCommand(conn, 'echo $HOME')).trim()
// Why: SFTP does not expand `~`, so we must resolve the remote home
// explicitly with the host's native shell and normalize it before use.
const remoteHome = normalizeRemoteHome(
await execHostCommand(conn, hostPlatform, readRemoteHomeCommand(hostPlatform)),
hostPlatform
)
// Why: we only interpolate $HOME into single-quoted shell strings later, so
// this validation only needs to reject obviously unsafe control characters.
// Allow spaces and non-ASCII so valid home directories are not rejected.
// oxlint-disable-next-line no-control-regex
if (!remoteHome || !remoteHome.startsWith('/') || /[\u0000\r\n]/.test(remoteHome)) {
throw new Error(`Remote $HOME is not a valid path: ${remoteHome.slice(0, 100)}`)
if (!validateRemoteHome(remoteHome, hostPlatform)) {
throw new Error(`Remote home is not a valid path: ${remoteHome.slice(0, 100)}`)
}
const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion)
const remoteRelayDir = computeRemoteRelayDir(remoteHome, fullVersion, hostPlatform.pathFlavor)
console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`)
onProgress?.('Checking existing relay...')
const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir)
const alreadyInstalled = await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform)
console.log(`[ssh-relay] Already installed at ${fullVersion}: ${alreadyInstalled}`)
const nodePath = await resolveRemoteNodePath(conn, hostPlatform)
if (alreadyInstalled) {
await repairInstalledNativeDeps(conn, remoteRelayDir, platform)
await repairInstalledNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath)
} else {
// Why: serialize concurrent first-installs of the same version against
// each other via an atomic mkdir lock. The losing caller polls and either
// re-checks `alreadyInstalled` (now true) or steals a stale lock.
await acquireInstallLock(conn, remoteRelayDir)
await acquireInstallLock(conn, remoteRelayDir, hostPlatform)
try {
// Re-probe after acquiring the lock — a sibling installer may have
// finished while we were waiting.
if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir))) {
if (!(await isRelayAlreadyInstalled(conn, remoteRelayDir, hostPlatform))) {
onProgress?.('Uploading relay...')
console.log('[ssh-relay] Uploading relay...')
await uploadRelay(conn, platform, remoteRelayDir, fullVersion)
await uploadRelay(conn, platform, remoteRelayDir, fullVersion, hostPlatform)
console.log('[ssh-relay] Upload complete')
onProgress?.('Installing native dependencies...')
console.log('[ssh-relay] Installing native dependencies...')
await installNativeDeps(conn, remoteRelayDir, platform)
await installNativeDeps(conn, remoteRelayDir, platform, hostPlatform, nodePath)
console.log('[ssh-relay] Native deps installed')
// Why: write `.install-complete` BEFORE releasing the lock so a
// sibling never observes the dir as "complete but locked", which
// would lead GC to skip a recoverable dir indefinitely.
await finalizeInstall(conn, remoteRelayDir)
await finalizeInstall(conn, remoteRelayDir, hostPlatform)
} else {
await abandonInstall(conn, remoteRelayDir)
await abandonInstall(conn, remoteRelayDir, hostPlatform)
}
} catch (err) {
// Why: leave a partial install dir in place (no `.install-complete`)
// so the next deploy detects the partial and re-runs upload + install.
// Just release the lock so a concurrent caller can retry.
await abandonInstall(conn, remoteRelayDir)
await abandonInstall(conn, remoteRelayDir, hostPlatform)
throw err
}
}
onProgress?.('Starting relay...')
console.log('[ssh-relay] Launching relay...')
const launched = await launchRelay(conn, remoteRelayDir, graceTimeSeconds, relayInstanceId)
const launched = await launchRelay(
conn,
remoteRelayDir,
hostPlatform,
nodePath,
graceTimeSeconds,
relayInstanceId
)
console.log('[ssh-relay] Relay started successfully')
// Why: best-effort cleanup of unreferenced sibling version dirs. Errors
// are logged inside gcOldRelayVersions and never propagate, so a GC failure
// can never block the user from connecting.
void gcOldRelayVersions(conn, remoteHome, remoteRelayDir).catch(() => {})
void gcOldRelayVersions(conn, remoteHome, remoteRelayDir, hostPlatform, {
windowsNodePath: launched.nodePath,
windowsSockNames: [relaySocketNameForInstanceId(relayInstanceId)]
}).catch(() => {})
return {
transport: launched.transport,
platform,
hostPlatform,
remoteHome,
remoteRelayDir,
nodePath: launched.nodePath,
@@ -183,20 +226,12 @@ async function deployAndLaunchRelayInner(
}
}
async function detectRemotePlatform(conn: SshConnection): Promise<RelayPlatform | null> {
const output = await execCommand(conn, 'uname -sm')
const parts = output.trim().split(/\s+/)
if (parts.length < 2) {
return null
}
return parseUnameToRelayPlatform(parts[0], parts[1])
}
async function uploadRelay(
conn: SshConnection,
platform: RelayPlatform,
remoteDir: string,
fullVersion: string
fullVersion: string,
hostPlatform: RemoteHostPlatform
): Promise<void> {
const localRelayDir = getLocalRelayPath(platform)
if (!localRelayDir || !existsSync(localRelayDir)) {
@@ -207,26 +242,38 @@ async function uploadRelay(
}
// Create remote directory
await execCommand(conn, `mkdir -p ${shellEscape(remoteDir)}`)
await execHostCommand(conn, hostPlatform, makeRemoteDirectoryCommand(hostPlatform, remoteDir))
await uploadDirectoryForConnection(conn, localRelayDir, remoteDir)
await uploadDirectoryForConnection(conn, localRelayDir, remoteDir, hostPlatform)
// Make the node binary executable
await execCommand(conn, `chmod +x ${shellEscape(`${remoteDir}/node`)} 2>/dev/null; true`)
if (!isWindowsRemoteHost(hostPlatform)) {
await execHostCommand(
conn,
hostPlatform,
makeRemoteExecutableCommand(hostPlatform, joinRemotePath(hostPlatform, remoteDir, 'node'))
)
}
// Why: write `.version` via SFTP rather than shell to avoid quoting issues
// with content-hashed version strings. The remote daemon reads this same
// file on startup so the wire-handshake validates against it.
await writeRemoteFile(conn, `${remoteDir}/.version`, fullVersion)
await writeRemoteFile(
conn,
hostPlatform,
joinRemotePath(hostPlatform, remoteDir, '.version'),
fullVersion
)
}
async function uploadDirectoryForConnection(
conn: SshConnection,
localRelayDir: string,
remoteDir: string
remoteDir: string,
hostPlatform: RemoteHostPlatform
): Promise<void> {
if (typeof conn.uploadDirectory === 'function') {
await conn.uploadDirectory(localRelayDir, remoteDir)
await conn.uploadDirectory(localRelayDir, remoteDir, { hostPlatform })
return
}
@@ -240,11 +287,12 @@ async function uploadDirectoryForConnection(
async function writeRemoteFile(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
remotePath: string,
contents: string
): Promise<void> {
if (typeof conn.writeFile === 'function') {
await conn.writeFile(remotePath, contents)
await conn.writeFile(remotePath, contents, { hostPlatform })
return
}
@@ -269,17 +317,28 @@ const RELAY_NATIVE_DEPS = {
'@parcel/watcher': '2.5.6'
} as const
async function hasRequiredNativeDeps(conn: SshConnection, remoteDir: string): Promise<boolean> {
const nodePath = await resolveRemoteNodePath(conn)
const nodeBinDir = nodePath.replace(/\/node$/, '')
const escapedDir = shellEscape(remoteDir)
const escapedBinDir = shellEscape(nodeBinDir)
async function hasRequiredNativeDeps(
conn: SshConnection,
remoteDir: string,
hostPlatform: RemoteHostPlatform,
nodePath: string
): Promise<boolean> {
const escapedNode = shellEscape(nodePath)
try {
const probe = await execCommand(
conn,
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && (${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)`
)
const command = isWindowsRemoteHost(hostPlatform)
? commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`try { & ${powerShellLiteral(nodePath)} -e ${powerShellLiteral('require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")')} } catch { 'MISSING' }`
)
: commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`(${escapedNode} -e 'require.resolve("node-pty"); require.resolve("@parcel/watcher"); console.log("ORCA-NATIVE-DEPS-OK")' 2>/dev/null || echo MISSING)`
)
const probe = await execHostCommand(conn, hostPlatform, command)
return probe.includes('ORCA-NATIVE-DEPS-OK')
} catch {
return false
@@ -289,25 +348,27 @@ async function hasRequiredNativeDeps(conn: SshConnection, remoteDir: string): Pr
async function repairInstalledNativeDeps(
conn: SshConnection,
remoteDir: string,
platform: RelayPlatform
platform: RelayPlatform,
hostPlatform: RemoteHostPlatform,
nodePath: string
): Promise<void> {
if (await hasRequiredNativeDeps(conn, remoteDir)) {
if (await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath)) {
return
}
console.warn(`[ssh-relay] Repairing missing native deps at ${remoteDir}`)
await acquireInstallLock(conn, remoteDir)
await acquireInstallLock(conn, remoteDir, hostPlatform)
try {
// Why: older complete relay dirs were created before @parcel/watcher was
// installed. Re-probe under the lock so only one reconnect mutates the dir.
if (!(await hasRequiredNativeDeps(conn, remoteDir))) {
await installNativeDeps(conn, remoteDir, platform)
await finalizeInstall(conn, remoteDir)
if (!(await hasRequiredNativeDeps(conn, remoteDir, hostPlatform, nodePath))) {
await installNativeDeps(conn, remoteDir, platform, hostPlatform, nodePath)
await finalizeInstall(conn, remoteDir, hostPlatform)
} else {
await abandonInstall(conn, remoteDir)
await abandonInstall(conn, remoteDir, hostPlatform)
}
} catch (err) {
await abandonInstall(conn, remoteDir)
await abandonInstall(conn, remoteDir, hostPlatform)
throw err
}
}
@@ -323,16 +384,14 @@ async function repairInstalledNativeDeps(
async function installNativeDeps(
conn: SshConnection,
remoteDir: string,
platform: RelayPlatform
platform: RelayPlatform,
hostPlatform: RemoteHostPlatform,
nodePath: string
): Promise<void> {
const nodePath = await resolveRemoteNodePath(conn)
// Why: node's bin directory must be in PATH for npm's child processes.
// npm install runs node-pty's prebuild script (`node scripts/prebuild.js`)
// which spawns `node` as a child — if node isn't in PATH, that child
// fails with exit 127 even though we invoked npm via its full path.
const nodeBinDir = nodePath.replace(/\/node$/, '')
const escapedDir = shellEscape(remoteDir)
const escapedBinDir = shellEscape(nodeBinDir)
const escapedNode = shellEscape(nodePath)
// npm init -y rejects '+' in derived package names (content-hashed dir
@@ -346,16 +405,33 @@ async function installNativeDeps(
type: 'commonjs',
dependencies: RELAY_NATIVE_DEPS
})}\n`
await writeRemoteFile(conn, `${remoteDir}/package.json`, pkgJson)
await writeRemoteFile(
conn,
hostPlatform,
joinRemotePath(hostPlatform, remoteDir, 'package.json'),
pkgJson
)
try {
const installArgs = Object.entries(RELAY_NATIVE_DEPS)
.map(([dep, version]) => shellEscape(`${dep}@${version}`))
.join(' ')
await execCommand(
conn,
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm install --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
)
const command = isWindowsRemoteHost(hostPlatform)
? commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`npm install --omit=dev --no-audit --no-fund ${Object.entries(RELAY_NATIVE_DEPS)
.map(([dep, version]) => powerShellLiteral(`${dep}@${version}`))
.join(' ')}`
)
: commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`npm install --omit=dev --no-audit --no-fund ${installArgs} 2>&1`
)
await execHostCommand(conn, hostPlatform, command)
} catch (err) {
// Don't write .install-complete on hard fail; reconnect retries on a
// partial install. Greppable token so user bug reports paste something
@@ -369,10 +445,13 @@ async function installNativeDeps(
// SFTP doesn't preserve execute bits; node-pty's spawn-helper prebuild
// must be +x for posix_spawnp.
await execCommand(
conn,
`find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`
)
if (!isWindowsRemoteHost(hostPlatform)) {
await execHostCommand(
conn,
hostPlatform,
`find ${shellEscape(joinRemotePath(hostPlatform, remoteDir, 'node_modules/node-pty/prebuilds'))} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`
)
}
// node -e require() catches unloadable installs (wrong arch, missing
// prebuild, broken native binding) that test -d cannot. Stderr → file
@@ -381,21 +460,37 @@ async function installNativeDeps(
// docs/ssh-relay-versioned-install-dirs.md (relay still serves
// fs/git/preflight; only pty.spawn fails at runtime).
const PROBE_OK = 'ORCA-NPTY-PROBE-OK'
const stderrFile = `${remoteDir}/.npty-probe.stderr`
const stderrFile = joinRemotePath(hostPlatform, remoteDir, '.npty-probe.stderr')
const escapedStderr = shellEscape(stderrFile)
const probeOutput = await execCommand(
conn,
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && (${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)`
)
const probeCommand = isWindowsRemoteHost(hostPlatform)
? commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`try { & ${powerShellLiteral(nodePath)} -e ${powerShellLiteral('require("node-pty"); console.log(process.argv[1])')} ${powerShellLiteral(PROBE_OK)}; if ($LASTEXITCODE -ne 0) { 'MISSING' } } catch { 'MISSING' }`
)
: commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`(${escapedNode} -e 'require("node-pty"); console.log(process.argv[1])' ${shellEscape(PROBE_OK)} 2>${escapedStderr} || echo MISSING)`
)
const probeOutput = await execHostCommand(conn, hostPlatform, probeCommand)
if (!probeOutput.includes(PROBE_OK)) {
const remoteStderr = await execCommand(conn, `cat ${escapedStderr} 2>/dev/null; true`).catch(
() => ''
)
const remoteStderr = isWindowsRemoteHost(hostPlatform)
? ''
: await execHostCommand(conn, hostPlatform, `cat ${escapedStderr} 2>/dev/null; true`).catch(
() => ''
)
console.warn(
`[ssh-relay][NPTY-MISSING] node-pty installed but require() failed at ${remoteDir} (${platform}). stdout=${probeOutput.trim().slice(-200)} stderr=${remoteStderr.trim().slice(-500)}`
)
}
await execCommand(conn, `rm -f ${escapedStderr} 2>/dev/null; true`).catch(() => {})
await execHostCommand(
conn,
hostPlatform,
removeRemoteFileCommand(hostPlatform, stderrFile)
).catch(() => {})
}
function getLocalRelayPath(platform: RelayPlatform): string | null {
@@ -432,6 +527,8 @@ export function getLocalRelayCandidates(platform: RelayPlatform): string[] {
async function launchRelay(
conn: SshConnection,
remoteDir: string,
hostPlatform: RemoteHostPlatform,
nodePath: string,
graceTimeSeconds?: number,
relayInstanceId?: string
): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> {
@@ -440,7 +537,6 @@ async function launchRelay(
// package small (~100KB JS vs ~60MB with embedded node).
// Non-login SSH shells may not have node in PATH, so we source the
// user's profile to pick up nvm/fnm/brew PATH entries.
const nodePath = await resolveRemoteNodePath(conn)
// Why: graceTimeSeconds originates from user-editable SshTarget config.
// Clamping to integer prevents shell injection if the type ever loosened.
const requestedGraceTime = Math.floor(graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS)
@@ -457,7 +553,31 @@ async function launchRelay(
// account. Hashing the target ID into the socket name prevents one target
// from attaching to another target's live relay.
const sockName = relaySocketNameForInstanceId(relayInstanceId)
const sockFile = `${remoteDir}/${sockName}`
const sockFile = relayEndpointForHost(hostPlatform, remoteDir, sockName)
const endpointDir = relayHookEndpointDirForHost(hostPlatform, remoteDir, sockFile)
if (isWindowsRemoteHost(hostPlatform)) {
const activePipeMarkerPath = windowsActivePipeMarkerPath(hostPlatform, remoteDir, sockName)
const activeEndpoint = (await readWindowsActiveRelayEndpoint(
conn,
hostPlatform,
remoteDir,
activePipeMarkerPath
)) ?? {
sockPath: sockFile,
endpointDir
}
const fallbackEndpoint = buildWindowsRelayFallbackEndpoint(hostPlatform, remoteDir, sockName)
return launchWindowsRelay(conn, hostPlatform, {
remoteDir,
nodePath,
sockPath: activeEndpoint.sockPath,
endpointDir: activeEndpoint.endpointDir,
graceTime,
activePipeMarkerPath,
reconnectFallback: fallbackEndpoint
})
}
// Why: after an app restart a relay may still be running in its grace
// period with live PTY sessions. We check for its Unix socket and
@@ -568,3 +688,350 @@ async function launchRelay(
)
return { transport: await waitForSentinel(channel), nodePath, sockPath: sockFile }
}
function buildWindowsRelayFallbackEndpoint(
hostPlatform: RemoteHostPlatform,
remoteDir: string,
sockName: string
): WindowsRelayEndpoint {
const fallbackSockName = windowsRelayFallbackSocketName(sockName)
const sockPath = relayEndpointForHost(hostPlatform, remoteDir, fallbackSockName)
return {
sockPath,
endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath)
}
}
async function readWindowsActiveRelayEndpoint(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
remoteDir: string,
markerPath: string
): Promise<WindowsRelayEndpoint | null> {
const output = await execHostCommand(
conn,
hostPlatform,
powerShellCommand(
`if (Test-Path -LiteralPath ${powerShellLiteral(markerPath)} -PathType Leaf) { Get-Content -LiteralPath ${powerShellLiteral(markerPath)} -Raw -ErrorAction SilentlyContinue }`
)
).catch(() => '')
const sockPath = output.trim()
if (!isWindowsRelayPipePath(sockPath)) {
return null
}
return {
sockPath,
endpointDir: relayHookEndpointDirForHost(hostPlatform, remoteDir, sockPath)
}
}
async function rememberWindowsActiveRelayEndpoint(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
markerPath: string,
sockPath: string
): Promise<void> {
await execHostCommand(
conn,
hostPlatform,
powerShellCommand(
`Set-Content -LiteralPath ${powerShellLiteral(markerPath)} -Value ${powerShellLiteral(sockPath)} -NoNewline`
)
).catch((err) => {
// Why: fallback pipe names are deterministic, so losing this marker does
// not force the next deploy to orphan an undiscoverable relay.
console.warn(
`[ssh-relay] Failed to persist Windows active relay pipe at ${markerPath}: ${err instanceof Error ? err.message : String(err)}`
)
})
}
type WindowsRelayEndpoint = {
sockPath: string
endpointDir: string
}
type WindowsRelayLaunchOptions = {
remoteDir: string
nodePath: string
graceTime: number
activePipeMarkerPath: string
} & WindowsRelayEndpoint & {
reconnectFallback?: WindowsRelayEndpoint
}
async function launchWindowsRelay(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
opts: WindowsRelayLaunchOptions
): Promise<{ transport: MultiplexerTransport; nodePath: string; sockPath: string }> {
let launchOpts = opts
if ((await probeWindowsRelayPipe(conn, hostPlatform, opts)) === 'READY') {
try {
const transport = await connectWindowsRelay(conn, hostPlatform, opts)
await rememberWindowsActiveRelayEndpoint(
conn,
hostPlatform,
opts.activePipeMarkerPath,
opts.sockPath
)
return {
transport,
nodePath: opts.nodePath,
sockPath: opts.sockPath
}
} catch (err) {
console.warn(
'[ssh-relay] Windows named pipe reconnect failed, launching fresh relay:',
err instanceof Error ? err.message : String(err)
)
if (opts.reconnectFallback) {
// Why: an existing Windows named pipe cannot be unlinked like a Unix
// socket; use a deterministic fallback pipe so marker write failures
// remain recoverable on the next deploy.
// Keep activePipeMarkerPath keyed by the original target sock name;
// the marker records the active pipe for that target, fallback or not.
launchOpts = { ...opts, ...opts.reconnectFallback }
}
}
}
if (
launchOpts !== opts &&
(await probeWindowsRelayPipe(conn, hostPlatform, launchOpts)) === 'READY'
) {
try {
const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts)
await rememberWindowsActiveRelayEndpoint(
conn,
hostPlatform,
launchOpts.activePipeMarkerPath,
launchOpts.sockPath
)
return {
transport,
nodePath: launchOpts.nodePath,
sockPath: launchOpts.sockPath
}
} catch (err) {
console.warn(
'[ssh-relay] Windows fallback pipe reconnect failed, relaunching relay:',
err instanceof Error ? err.message : String(err)
)
}
}
const logFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.log')
const errFile = joinRemotePath(hostPlatform, launchOpts.remoteDir, 'relay.err.log')
await execHostCommand(
conn,
hostPlatform,
windowsRelayLaunchCommand(
hostPlatform,
launchOpts.nodePath,
launchOpts.remoteDir,
launchOpts.sockPath,
launchOpts.endpointDir,
launchOpts.graceTime,
logFile,
errFile
)
)
const POLL_INTERVAL_MS = 200
const POLL_TIMEOUT_MS = 10_000
if (
await waitForWindowsRelayPipe(conn, hostPlatform, launchOpts, POLL_TIMEOUT_MS, POLL_INTERVAL_MS)
) {
const transport = await connectWindowsRelay(conn, hostPlatform, launchOpts)
await rememberWindowsActiveRelayEndpoint(
conn,
hostPlatform,
launchOpts.activePipeMarkerPath,
launchOpts.sockPath
)
return {
transport,
nodePath: launchOpts.nodePath,
sockPath: launchOpts.sockPath
}
}
const logOutput = await execHostCommand(
conn,
hostPlatform,
windowsRelayTailLogCommand(logFile, errFile)
).catch(() => '(could not read log)')
throw new Error(`Relay failed to start within ${POLL_TIMEOUT_MS / 1000}s. Log:\n${logOutput}`)
}
async function connectWindowsRelay(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
opts: {
remoteDir: string
nodePath: string
sockPath: string
}
): Promise<MultiplexerTransport> {
const channel = await conn.exec(
windowsRelayConnectCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath),
{ wrapCommand: false }
)
return waitForSentinel(channel)
}
function windowsRelayConnectCommand(
hostPlatform: RemoteHostPlatform,
nodePath: string,
remoteDir: string,
sockPath: string
): string {
return commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`& ${powerShellLiteral(nodePath)} relay.js --connect --sock-path ${powerShellLiteral(sockPath)}`
)
}
function windowsRelayLaunchCommand(
hostPlatform: RemoteHostPlatform,
nodePath: string,
remoteDir: string,
sockPath: string,
endpointDir: string,
graceTime: number,
logFile: string,
errFile: string
): string {
const relayScript = joinRemotePath(hostPlatform, remoteDir, 'relay.js')
return commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
[
`$args = @(${windowsStartProcessArgumentLiteral(relayScript)}, '--detached', '--grace-time', ${powerShellLiteral(String(graceTime))}, '--sock-path', ${windowsStartProcessArgumentLiteral(sockPath)}, '--endpoint-dir', ${windowsStartProcessArgumentLiteral(endpointDir)})`,
`Start-Process -FilePath ${powerShellLiteral(nodePath)} -ArgumentList $args -WorkingDirectory ${powerShellLiteral(remoteDir)} -RedirectStandardOutput ${powerShellLiteral(logFile)} -RedirectStandardError ${powerShellLiteral(errFile)} -WindowStyle Hidden`
].join('; ')
)
}
function windowsStartProcessArgumentLiteral(value: string): string {
return powerShellLiteral(`"${value.replace(/"/g, '\\"')}"`)
}
async function probeWindowsRelayPipe(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
opts: {
remoteDir: string
nodePath: string
sockPath: string
}
): Promise<'READY' | 'WAITING'> {
const result = await execHostCommand(
conn,
hostPlatform,
windowsRelayProbeCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath)
)
return result.trim() === 'READY' ? 'READY' : 'WAITING'
}
async function waitForWindowsRelayPipe(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
opts: {
remoteDir: string
nodePath: string
sockPath: string
},
timeoutMs: number,
intervalMs: number
): Promise<boolean> {
try {
const result = await execHostCommand(
conn,
hostPlatform,
windowsRelayWaitCommand(hostPlatform, opts.nodePath, opts.remoteDir, opts.sockPath, {
timeoutMs,
intervalMs
})
)
return result.trim() === 'READY'
} catch {
return false
}
}
function windowsRelayProbeCommand(
hostPlatform: RemoteHostPlatform,
nodePath: string,
remoteDir: string,
sockPath: string
): string {
const js = [
'const net=require("net");',
'const s=net.connect(process.argv[1]);',
's.on("connect",()=>{s.destroy();process.stdout.write("READY")});',
's.on("error",()=>{process.stdout.write("WAITING")});'
].join('')
return commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
`& ${powerShellLiteral(nodePath)} -e ${powerShellLiteral(js)} ${powerShellLiteral(sockPath)}`
)
}
function windowsRelayWaitCommand(
hostPlatform: RemoteHostPlatform,
nodePath: string,
remoteDir: string,
sockPath: string,
opts: { timeoutMs: number; intervalMs: number }
): string {
const js = [
'const net=require("net");',
'const pipe=process.argv[1];',
'const timeoutMs=Number(process.argv[2]);',
'const intervalMs=Number(process.argv[3]);',
'const deadline=Date.now()+timeoutMs;',
'function finish(value){process.stdout.write(value);process.exit(0)}',
'function attempt(){',
'const s=net.connect(pipe);',
'let settled=false;',
'function retry(){if(settled)return;settled=true;s.destroy();',
'if(Date.now()>=deadline)finish("WAITING");else setTimeout(attempt,intervalMs)}',
's.setTimeout(Math.min(intervalMs,500));',
's.on("connect",()=>{if(settled)return;settled=true;s.destroy();finish("READY")});',
's.on("timeout",retry);',
's.on("error",retry);',
'}',
'attempt();'
].join('')
return commandWithNodePath(
hostPlatform,
nodePath,
remoteDir,
[
`& ${powerShellLiteral(nodePath)}`,
'-e',
powerShellLiteral(js),
powerShellLiteral(sockPath),
powerShellLiteral(String(opts.timeoutMs)),
powerShellLiteral(String(opts.intervalMs))
].join(' ')
)
}
function windowsRelayTailLogCommand(logFile: string, errFile: string): string {
const script = [
`$out = if (Test-Path -LiteralPath ${powerShellLiteral(logFile)}) { Get-Content -LiteralPath ${powerShellLiteral(logFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stdout log)' }`,
`$err = if (Test-Path -LiteralPath ${powerShellLiteral(errFile)}) { Get-Content -LiteralPath ${powerShellLiteral(errFile)} -Tail 20 -ErrorAction SilentlyContinue } else { '(no stderr log)' }`,
'Write-Output $out',
"Write-Output '--- stderr ---'",
'Write-Output $err'
].join('; ')
return powerShellCommand(script)
}
+68
View File
@@ -0,0 +1,68 @@
import { createHash } from 'crypto'
import {
isWindowsRemoteHost,
joinRemotePath,
remoteBasename,
type RemoteHostPlatform
} from './ssh-remote-platform'
export const WINDOWS_ACTIVE_PIPE_MARKER_PREFIX = '.windows-active-pipe-'
export function relayEndpointForHost(
hostPlatform: RemoteHostPlatform,
remoteDir: string,
sockName: string
): string {
if (!isWindowsRemoteHost(hostPlatform)) {
return joinRemotePath(hostPlatform, remoteDir, sockName)
}
const endpointHash = createHash('sha256')
.update(`${remoteDir}\0${sockName}`)
.digest('hex')
.slice(0, 20)
return `\\\\.\\pipe\\orca-relay-${endpointHash}`
}
export function relayHookEndpointDirForHost(
hostPlatform: RemoteHostPlatform,
remoteDir: string,
sockPath: string
): string {
return joinRemotePath(
hostPlatform,
remoteDir,
'agent-hooks',
remoteBasename(sockPath, hostPlatform)
)
}
export function windowsRelayFallbackSocketName(sockName: string): string {
return `${sockName}-fallback`
}
export function windowsRelayPipePathsForSocketName(
hostPlatform: RemoteHostPlatform,
remoteDir: string,
sockName: string
): string[] {
return [
relayEndpointForHost(hostPlatform, remoteDir, sockName),
relayEndpointForHost(hostPlatform, remoteDir, windowsRelayFallbackSocketName(sockName))
]
}
export function windowsActivePipeMarkerPath(
hostPlatform: RemoteHostPlatform,
remoteDir: string,
sockName: string
): string {
return joinRemotePath(
hostPlatform,
remoteDir,
`${WINDOWS_ACTIVE_PIPE_MARKER_PREFIX}${sockName.replace(/[^a-zA-Z0-9.-]/g, '_')}`
)
}
export function isWindowsRelayPipePath(value: string): boolean {
return /^\\\\[.?]\\pipe\\orca-relay-[0-9a-f]{20}$/i.test(value)
}
@@ -0,0 +1,28 @@
import {
RelayVersionMismatchError,
RELAY_EXIT_CODE_VERSION_MISMATCH
} from './ssh-relay-version-mismatch-error'
export function buildRelayVersionMismatchError(
exitCode: number | null,
stderr: string
): RelayVersionMismatchError | null {
if (exitCode !== RELAY_EXIT_CODE_VERSION_MISMATCH) {
return null
}
const { expected, got } = parseHandshakeMismatchStderr(stderr)
return new RelayVersionMismatchError(expected, got, stderr.trim())
}
// Why: extract the expected/got version pair from --connect's stderr line
// "Handshake mismatch: expected=<x>, daemon=<y>" so diagnostics name both versions.
function parseHandshakeMismatchStderr(stderr: string): {
expected: string | undefined
got: string | undefined
} {
const match = /expected=([^,\s]+),\s*daemon=([^\s;]+)/.exec(stderr)
if (!match) {
return { expected: undefined, got: undefined }
}
return { expected: match[1], got: match[2] }
}
@@ -35,7 +35,10 @@ vi.mock('./ssh-relay-deploy-helpers', () => ({
onData: vi.fn(),
onClose: vi.fn()
}),
execCommand: vi.fn(),
execCommand: vi.fn()
}))
vi.mock('./ssh-remote-node-resolution', () => ({
resolveRemoteNodePath: vi.fn().mockResolvedValue('/usr/bin/node')
}))
@@ -56,6 +59,7 @@ vi.mock('./ssh-connection-utils', () => ({
import { deployAndLaunchRelay } from './ssh-relay-deploy'
import { execCommand } from './ssh-relay-deploy-helpers'
import { parseUnameToRelayPlatform } from './relay-protocol'
import { resolveRemoteNodePath } from './ssh-remote-node-resolution'
import {
acquireInstallLock,
abandonInstall,
@@ -118,6 +122,11 @@ function makeMockConnection(capture: SftpWriteCapture): SshConnection {
type ExecResponse = string | { reject: string }
function decodePowerShellCommand(command: string): string | null {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : null
}
// Exec call order under our mocks (deploy happy path):
// 1: uname 2: $HOME 3: mkdir remoteDir (uploadRelay)
// 4: chmod +x node 5: npm install 6: chmod prebuilds
@@ -432,6 +441,41 @@ describe('installNativeDeps (via deployAndLaunchRelay)', () => {
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
})
it('keeps Windows node-pty probe failures non-fatal by checking LASTEXITCODE', async () => {
vi.mocked(parseUnameToRelayPlatform).mockReturnValueOnce('win32-x64')
vi.mocked(resolveRemoteNodePath).mockResolvedValueOnce('C:/Program Files/nodejs/node.exe')
const conn = makeMockConnection(sftpCapture)
feed([
'Windows AMD64',
'C:\\Users\\u',
'', // mkdir remoteDir
'', // npm install native deps
'MISSING\n', // native process exit normalized by PowerShell command
'', // remove probe stderr file
'', // no persisted active pipe marker
'WAITING',
'', // Start-Process launch
'READY',
'' // persist active pipe marker
])
await deployAndLaunchRelay(conn)
const probeCommand =
vi
.mocked(execCommand)
.mock.calls.map(([, c]) => c)
.find((command) => decodePowerShellCommand(command)?.includes('require("node-pty")')) ?? ''
const probeScript = decodePowerShellCommand(probeCommand) ?? ''
expect(probeScript).toContain('$LASTEXITCODE -ne 0')
expect(probeScript).toContain("'MISSING'")
const warnMessages = warnSpy.mock.calls.map((args) => String(args[0] ?? ''))
expect(warnMessages.some((m) => m.includes('[ssh-relay][NPTY-MISSING]'))).toBe(true)
expect(vi.mocked(finalizeInstall)).toHaveBeenCalledTimes(1)
expect(vi.mocked(abandonInstall)).not.toHaveBeenCalled()
})
it('includes the platform tuple in NPTY-MISSING and native install failure logs', async () => {
// Platform tuple lets bug reports be triaged for prebuild availability
// without asking the user to dig out their arch.
+77
View File
@@ -18,6 +18,10 @@ vi.mock('./ssh-relay-deploy', () => ({
deployAndLaunchRelay: vi.fn()
}))
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn().mockResolvedValue('')
}))
vi.mock('./ssh-channel-multiplexer', () => {
return {
SshChannelMultiplexer: class MockSshChannelMultiplexer {
@@ -84,6 +88,8 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
}))
const { deployAndLaunchRelay } = await import('./ssh-relay-deploy')
const { execCommand } = await import('./ssh-relay-deploy-helpers')
const { getRemoteHostPlatform } = await import('./ssh-remote-platform')
const {
registerSshPtyProvider,
unregisterSshPtyProvider,
@@ -197,6 +203,35 @@ describe('SshRelaySession', () => {
)
})
it('does not run POSIX managed hook installers on Windows remotes', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {
writeFile: vi.fn().mockResolvedValue(undefined)
} as unknown as SshConnection
vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({
transport: {
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
},
platform: 'win32-x64',
hostPlatform: getRemoteHostPlatform('win32-x64'),
remoteHome: 'C:/Users/me',
remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1',
nodePath: 'C:/Program Files/nodejs/node.exe',
sockPath: '\\\\.\\pipe\\orca-relay-123'
})
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
expect(installRemoteManagedAgentHooksMock).not.toHaveBeenCalled()
expect(
muxRequestMock.mock.calls.some(([method]) => method === AGENT_HOOK_INSTALL_PLUGINS_METHOD)
).toBe(true)
})
it('does not register providers if dispose wins during initial plugin sync', async () => {
process.env.ORCA_FEATURE_REMOTE_AGENT_HOOKS = '1'
let resolvePluginInstall!: () => void
@@ -266,6 +301,48 @@ describe('SshRelaySession', () => {
expect(registerSshPtyProvider).toHaveBeenCalledWith('target-1', expect.anything())
})
it('installs a native Windows Orca CLI bridge without POSIX shell commands', async () => {
const { mockStore, mockPortForward, getMainWindow } = createMockDeps()
const mockConn = {
writeFile: vi.fn().mockResolvedValue(undefined)
} as unknown as SshConnection
vi.mocked(deployAndLaunchRelay).mockResolvedValueOnce({
transport: {
write: vi.fn(),
onData: vi.fn(),
onClose: vi.fn()
},
platform: 'win32-x64',
hostPlatform: getRemoteHostPlatform('win32-x64'),
remoteHome: 'C:/Users/me',
remoteRelayDir: 'C:/Users/me/.orca-remote/relay-v1',
nodePath: 'C:/Program Files/nodejs/node.exe',
sockPath: '\\\\.\\pipe\\orca-relay-123'
})
const session = new SshRelaySession('target-1', getMainWindow, mockStore, mockPortForward)
await session.establish(mockConn)
expect(execCommand).toHaveBeenCalledTimes(1)
expect(vi.mocked(execCommand).mock.calls[0]?.[1]).toContain('powershell.exe')
expect(vi.mocked(execCommand).mock.calls[0]?.[2]).toEqual({ wrapCommand: false })
expect(mockConn.writeFile).toHaveBeenCalledWith(
'C:/Users/me/.orca-relay/bin/orca.cmd',
expect.stringContaining('@echo off'),
{ hostPlatform: getRemoteHostPlatform('win32-x64') }
)
const shim = vi.mocked(mockConn.writeFile).mock.calls[0]?.[1] as string
expect(shim).toContain('C:/Users/me/.orca-remote/relay-v1')
expect(shim).toContain('\\\\.\\pipe\\orca-relay-123')
expect(shim).not.toContain('if not exist "%ORCA_RELAY_SOCKET_PATH%"')
expect(shim).not.toContain('Orca SSH CLI bridge cannot find the relay socket')
expect(shim).not.toContain('#!/usr/bin/env sh')
expect(vi.mocked(execCommand).mock.calls.some(([, command]) => command.includes('chmod'))).toBe(
false
)
})
it('reconnect re-attaches live PTYs', async () => {
const { mockConn, mockStore, mockPortForward, getMainWindow } = createMockDeps()
vi.mocked(getPtyIdsForConnection).mockReturnValue(['pty-1', 'pty-2'])
+83 -36
View File
@@ -49,7 +49,8 @@ import { notifyRemoteWorkspaceHandlers } from '../ipc/remote-workspace-events'
import { PortScanner } from './ssh-port-scanner'
import type { SshPortForwardManager } from './ssh-port-forward'
import type { SshConnection } from './ssh-connection'
import { shellEscape } from './ssh-connection-utils'
import { joinRemotePath, isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
import { makeRemoteDirectoryCommand, makeRemoteExecutableCommand } from './ssh-remote-commands'
import {
DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS,
type DetectedPort,
@@ -63,6 +64,15 @@ import { runRemoteOrcaCli } from './ssh-remote-orca-cli'
export type RelaySessionState = 'idle' | 'deploying' | 'ready' | 'reconnecting' | 'disposed'
type RemoteCliBridgeEnv = {
binDir: string
relayDir: string
nodePath: string
sockPath: string
hostPlatform: RemoteHostPlatform
pathDelimiter?: ':' | ';'
}
function normalizeRelayGracePeriodSeconds(graceTimeSeconds: number | undefined): number {
const raw = graceTimeSeconds ?? DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
const requested = Number.isFinite(raw) ? Math.floor(raw) : DEFAULT_SSH_RELAY_GRACE_PERIOD_SECONDS
@@ -99,12 +109,7 @@ export class SshRelaySession {
private _onReady: ((targetId: string) => void) | null = null
private portScanner: PortScanner | null = null
private currentConnection: SshConnection | null = null
private remoteCliBridgeEnv: {
binDir: string
relayDir: string
nodePath: string
sockPath: string
} | null = null
private remoteCliBridgeEnv: RemoteCliBridgeEnv | null = null
constructor(
readonly targetId: string,
@@ -192,15 +197,17 @@ export class SshRelaySession {
this.currentConnection = conn
try {
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } =
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
binDir: `${remoteHome}/.orca-relay/bin`,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
sockPath
sockPath,
hostPlatform,
pathDelimiter: hostPlatform.pathDelimiter
}
: null
@@ -316,15 +323,17 @@ export class SshRelaySession {
this.teardownProviders('connection_lost')
try {
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath } =
const { transport, remoteHome, remoteRelayDir, nodePath, sockPath, hostPlatform } =
await deployAndLaunchRelay(conn, undefined, graceTimeSeconds, this.targetId)
this.remoteCliBridgeEnv =
remoteHome && remoteRelayDir && nodePath && sockPath
remoteHome && remoteRelayDir && nodePath && sockPath && hostPlatform
? {
binDir: `${remoteHome}/.orca-relay/bin`,
binDir: joinRemotePath(hostPlatform, remoteHome, '.orca-relay', 'bin'),
relayDir: remoteRelayDir,
nodePath,
sockPath
sockPath,
hostPlatform,
pathDelimiter: hostPlatform.pathDelimiter
}
: null
@@ -547,6 +556,14 @@ export class SshRelaySession {
if (!isRemoteAgentHooksEnabled() || !this.areAgentStatusHooksEnabled()) {
return
}
if (
this.remoteCliBridgeEnv?.hostPlatform &&
isWindowsRemoteHost(this.remoteCliBridgeEnv.hostPlatform)
) {
// Why: managed hook installers currently emit POSIX hook scripts and paths.
// Windows remotes still get relay-injected env plus plugin overlays.
return
}
let remoteHome: string
try {
@@ -588,41 +605,31 @@ export class SshRelaySession {
if (!this.remoteCliBridgeEnv) {
return
}
const { binDir, relayDir, nodePath, sockPath } = this.remoteCliBridgeEnv
const shimPath = `${binDir}/orca`
const shim = [
'#!/usr/bin/env sh',
'set -eu',
`ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(nodePath)}}`,
`ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(relayDir)}}`,
`ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(sockPath)}}`,
'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then',
' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2',
' exit 1',
'fi',
'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"',
''
].join('\n')
await execCommand(this.requireReadyConnection(), `mkdir -p ${shellEscape(binDir)}`)
const { binDir, hostPlatform } = this.remoteCliBridgeEnv
const shim = buildRemoteCliShim(this.remoteCliBridgeEnv)
const conn = this.requireReadyConnection()
await execCommand(conn, makeRemoteDirectoryCommand(hostPlatform, binDir), {
wrapCommand: !isWindowsRemoteHost(hostPlatform)
})
if (typeof conn.writeFile === 'function') {
await conn.writeFile(shimPath, shim)
await conn.writeFile(shim.path, shim.contents, { hostPlatform })
} else {
const sftp = await conn.sftp()
try {
await new Promise<void>((resolve, reject) => {
const ws = sftp.createWriteStream(shimPath)
const ws = sftp.createWriteStream(shim.path)
sftp.once('error', reject)
ws.once('close', resolve)
ws.once('error', reject)
ws.end(shim)
ws.end(shim.contents)
})
} finally {
sftp.end()
}
}
await execCommand(conn, `chmod 755 ${shellEscape(shimPath)}`)
if (!isWindowsRemoteHost(hostPlatform)) {
await execCommand(conn, makeRemoteExecutableCommand(hostPlatform, shim.path))
}
}
private wireUpRemoteOrcaCli(mux: SshChannelMultiplexer): void {
@@ -992,3 +999,43 @@ export class SshRelaySession {
function quoteSh(value: string): string {
return `'${value.replaceAll("'", `'\\''`)}'`
}
function buildRemoteCliShim(env: RemoteCliBridgeEnv): {
path: string
contents: string
} {
if (isWindowsRemoteHost(env.hostPlatform)) {
const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca.cmd')
return {
path: shimPath,
contents: [
'@echo off',
'setlocal',
`if not defined ORCA_RELAY_NODE_PATH set "ORCA_RELAY_NODE_PATH=${env.nodePath}"`,
`if not defined ORCA_RELAY_DIR set "ORCA_RELAY_DIR=${env.relayDir}"`,
`if not defined ORCA_RELAY_SOCKET_PATH set "ORCA_RELAY_SOCKET_PATH=${env.sockPath}"`,
'"%ORCA_RELAY_NODE_PATH%" "%ORCA_RELAY_DIR%/relay.js" --sock-path "%ORCA_RELAY_SOCKET_PATH%" --orca-cli %*',
'exit /b %ERRORLEVEL%',
''
].join('\r\n')
}
}
const shimPath = joinRemotePath(env.hostPlatform, env.binDir, 'orca')
return {
path: shimPath,
contents: [
'#!/usr/bin/env sh',
'set -eu',
`ORCA_RELAY_NODE_PATH=\${ORCA_RELAY_NODE_PATH:-${quoteSh(env.nodePath)}}`,
`ORCA_RELAY_DIR=\${ORCA_RELAY_DIR:-${quoteSh(env.relayDir)}}`,
`ORCA_RELAY_SOCKET_PATH=\${ORCA_RELAY_SOCKET_PATH:-${quoteSh(env.sockPath)}}`,
'if [ ! -S "$ORCA_RELAY_SOCKET_PATH" ]; then',
' echo "Orca SSH CLI bridge cannot find the relay socket: $ORCA_RELAY_SOCKET_PATH" >&2',
' exit 1',
'fi',
'exec "$ORCA_RELAY_NODE_PATH" "$ORCA_RELAY_DIR/relay.js" --sock-path "$ORCA_RELAY_SOCKET_PATH" --orca-cli "$@"',
''
].join('\n')
}
}
@@ -24,6 +24,7 @@ import {
gcOldRelayVersions
} from './ssh-relay-versioned-install'
import { execCommand } from './ssh-relay-deploy-helpers'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshConnection } from './ssh-connection'
const conn = {} as SshConnection
@@ -31,6 +32,11 @@ const mockExec = vi.mocked(execCommand)
const mockExists = vi.mocked(existsSync)
const mockRead = vi.mocked(readFileSync)
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
}
describe('readLocalFullVersion', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -300,6 +306,34 @@ describe('gcOldRelayVersions', () => {
expect(cmds.some((c) => c.includes('rm -rf'))).toBe(false)
})
it('probes Windows GC liveness by connecting to named pipes, not process command lines', async () => {
const windows = getRemoteHostPlatform('win32-x64')
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
mockExec
.mockResolvedValueOnce('OPEN')
.mockResolvedValueOnce('COMPLETE')
.mockResolvedValueOnce('WAITING')
.mockResolvedValueOnce('')
await gcOldRelayVersions(
conn,
'C:/Users/u',
'C:/Users/u/.orca-remote/relay-0.1.0+bbb',
windows,
{
windowsNodePath: 'C:/Program Files/nodejs/node.exe',
windowsSockNames: ['relay-target.sock']
}
)
const livenessCommand = mockExec.mock.calls[3]?.[1] ?? ''
const script = decodePowerShellCommand(livenessCommand ?? '')
expect(script).toContain('net.connect(pipe)')
expect(script).toContain('.windows-active-pipe-')
expect(script).toContain('\\\\.\\pipe\\orca-relay-')
expect(script).not.toContain('Win32_Process')
})
it('does not consider the current dir as a GC candidate', async () => {
mockExec.mockResolvedValueOnce('relay-0.1.0+aaa\n')
await gcOldRelayVersions(conn, '/home/u', '/home/u/.orca-remote/relay-0.1.0+aaa')
+123 -51
View File
@@ -15,7 +15,27 @@ import { existsSync, readFileSync } from 'fs'
import type { SshConnection } from './ssh-connection'
import { RELAY_REMOTE_DIR } from './relay-protocol'
import { execCommand } from './ssh-relay-deploy-helpers'
import { shellEscape } from './ssh-connection-utils'
import {
acquireInstallLockParentCommand,
listRelayBaseDirsCommand,
lockMtimeEpochCommand,
probeDirectoryExistsCommand,
probeFileExistsCommand,
probeRelayInstalledCommand,
relayLivenessProbeCommand,
removeRemoteTreeCommand,
tryCreateInstallLockCommand,
writeRemoteEmptyFileCommand
} from './ssh-remote-commands'
import {
getRemoteHostPlatform,
isWindowsRemoteHost,
joinRemotePath,
remoteBasename,
type RemoteHostPlatform,
type RemotePathFlavor
} from './ssh-remote-platform'
import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints'
// Why: the GC pass and the version-dir parser must agree on what counts as a
// relay install dir. Single source of truth for both. The pattern matches the
@@ -42,6 +62,15 @@ const INSTALL_LOCK_TIMEOUT_MS = 120_000
// (10–60s on slow hosts) so a slow concurrent installer is not falsely
// declared dead.
const INSTALL_LOCK_STALE_MS = 120_000
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
function execHostCommand(
conn: SshConnection,
host: RemoteHostPlatform,
command: string
): Promise<string> {
return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' })
}
/**
* Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020")
@@ -72,8 +101,16 @@ export function readLocalFullVersion(localRelayDir: string): string {
* Compute the absolute remote install directory for a given content-hashed
* version. The format is `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`.
*/
export function computeRemoteRelayDir(remoteHome: string, fullVersion: string): string {
return `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`
export function computeRemoteRelayDir(
remoteHome: string,
fullVersion: string,
pathFlavor: RemotePathFlavor = 'posix'
): string {
const host =
pathFlavor === 'windows'
? getRemoteHostPlatform('win32-x64')
: getRemoteHostPlatform('linux-x64')
return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, `relay-${fullVersion}`)
}
/**
@@ -86,15 +123,14 @@ export function computeRemoteRelayDir(remoteHome: string, fullVersion: string):
*/
export async function isRelayAlreadyInstalled(
conn: SshConnection,
remoteRelayDir: string
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<boolean> {
try {
const probe = await execCommand(
const probe = await execHostCommand(
conn,
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(`${remoteRelayDir}/relay.js`)} ` +
`&& test -f ${shellEscape(`${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`)} ` +
`&& echo OK || echo MISSING`
host,
probeRelayInstalledCommand(host, remoteRelayDir)
)
return probe.trim() === 'OK'
} catch {
@@ -113,21 +149,19 @@ export async function isRelayAlreadyInstalled(
*/
export async function acquireInstallLock(
conn: SshConnection,
remoteRelayDir: string
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
const lockDir = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
// Why: the parent dir may not exist yet on a first install. mkdir -p is
// safe to run multiple times — it's a no-op if the dir already exists.
await execCommand(conn, `mkdir -p ${shellEscape(remoteRelayDir)}`)
await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir))
let start = Date.now()
let recoveredOnce = false
while (true) {
try {
const result = await execCommand(
conn,
`mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
)
const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir))
if (result.trim().endsWith('OK')) {
return
}
@@ -146,10 +180,10 @@ export async function acquireInstallLock(
// window, the previous installer crashed. Steal it and retry once,
// resetting the timeout window so a single post-recovery race doesn't
// immediately exhaust the budget.
const ageOk = await isLockStale(conn, lockDir)
const ageOk = await isLockStale(conn, lockDir, host)
if (ageOk) {
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
await execCommand(conn, `rm -rf ${shellEscape(lockDir)}`).catch(() => {})
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
recoveredOnce = true
start = Date.now()
continue
@@ -164,15 +198,16 @@ export async function acquireInstallLock(
}
}
async function isLockStale(conn: SshConnection, lockDir: string): Promise<boolean> {
async function isLockStale(
conn: SshConnection,
lockDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<boolean> {
try {
// Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS).
// We try GNU first, then BSD; both produce a Unix epoch in seconds on
// stdout. If both fail we conservatively treat the lock as not stale.
const out = await execCommand(
conn,
`stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo`
)
const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir))
const mtimeSec = parseInt(out.trim(), 10)
if (!Number.isFinite(mtimeSec)) {
return false
@@ -190,11 +225,15 @@ async function isLockStale(conn: SshConnection, lockDir: string): Promise<boolea
* a sibling dir is never observed by GC as "complete but locked", which
* would lead GC to skip a recoverable dir indefinitely.
*/
export async function finalizeInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
const sentinel = `${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
await execCommand(conn, `touch ${shellEscape(sentinel)}`)
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
export async function finalizeInstall(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME)
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel))
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
}
/**
@@ -202,9 +241,13 @@ export async function finalizeInstall(conn: SshConnection, remoteRelayDir: strin
* from the failure path so the dir remains a recoverable partial that the
* next deploy detects (alreadyInstalled=false) and re-runs upload+install.
*/
export async function abandonInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
export async function abandonInstall(
conn: SshConnection,
remoteRelayDir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
): Promise<void> {
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
}
/**
@@ -223,13 +266,18 @@ export async function abandonInstall(conn: SshConnection, remoteRelayDir: string
export async function gcOldRelayVersions(
conn: SshConnection,
remoteHome: string,
currentDirAbsPath: string
currentDirAbsPath: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: {
windowsNodePath?: string
windowsSockNames?: string[]
}
): Promise<void> {
const baseDir = `${remoteHome}/${RELAY_REMOTE_DIR}`
const currentDirName = currentDirAbsPath.split('/').filter(Boolean).pop() ?? ''
const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)
const currentDirName = remoteBasename(currentDirAbsPath, host)
let listing: string
try {
listing = await execCommand(conn, `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`)
listing = await execHostCommand(conn, host, listRelayBaseDirsCommand(host, baseDir))
} catch {
return
}
@@ -247,14 +295,14 @@ export async function gcOldRelayVersions(
const removed: string[] = []
const kept: string[] = []
for (const name of candidates) {
const dir = `${baseDir}/${name}`
const dir = joinRemotePath(host, baseDir, name)
try {
const safe = await isCandidateSafeToRemove(conn, dir, name)
const safe = await isCandidateSafeToRemove(conn, dir, name, host, options)
if (!safe) {
kept.push(name)
continue
}
await execCommand(conn, `rm -rf ${shellEscape(dir)}`)
await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir))
removed.push(name)
} catch (err) {
console.warn(
@@ -275,13 +323,20 @@ export async function gcOldRelayVersions(
async function isCandidateSafeToRemove(
conn: SshConnection,
dir: string,
name: string
name: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: {
windowsNodePath?: string
windowsSockNames?: string[]
}
): Promise<boolean> {
const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name)
const lockProbe = await execCommand(
const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME)
const lockProbe = await execHostCommand(
conn,
`test -d ${shellEscape(`${dir}/${INSTALL_LOCK_NAME}`)} && echo LOCKED || echo OPEN`
host,
probeDirectoryExistsCommand(host, lockDir)
).catch(() => 'OPEN')
const locked = lockProbe.trim() === 'LOCKED'
@@ -293,8 +348,7 @@ async function isCandidateSafeToRemove(
// end of finalizeInstall failed), removing the dir is safe — no
// installer is racing us, and the daemon (if any) keeps running off
// its already-loaded code regardless of disk state.
const lockDir = `${dir}/${INSTALL_LOCK_NAME}`
if (!(await isLockStale(conn, lockDir))) {
if (!(await isLockStale(conn, lockDir, host))) {
return false
}
process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`)
@@ -304,9 +358,11 @@ async function isCandidateSafeToRemove(
// check for them and rely solely on the live-socket probe — that's the
// only signal we have that a legacy daemon is still serving clients.
if (!isLegacy) {
const completeProbe = await execCommand(
const completePath = joinRemotePath(host, dir, INSTALL_COMPLETE_NAME)
const completeProbe = await execHostCommand(
conn,
`test -f ${shellEscape(`${dir}/${INSTALL_COMPLETE_NAME}`)} && echo COMPLETE || echo PARTIAL`
host,
probeFileExistsCommand(host, completePath)
).catch(() => 'PARTIAL')
if (completeProbe.trim() !== 'COMPLETE') {
// Crashed-install partial; leave for the next deploy to recover.
@@ -314,24 +370,40 @@ async function isCandidateSafeToRemove(
}
}
const sockAlive = await hasLiveRelaySocket(conn, dir)
const sockAlive = await hasLiveRelaySocket(conn, dir, host, options)
if (sockAlive) {
return false
}
return true
}
async function hasLiveRelaySocket(conn: SshConnection, dir: string): Promise<boolean> {
async function hasLiveRelaySocket(
conn: SshConnection,
dir: string,
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
options?: {
windowsNodePath?: string
windowsSockNames?: string[]
}
): Promise<boolean> {
try {
// Why: `ls -1 dir/relay-*.sock 2>/dev/null` lists socket files. For each,
// we test -S to confirm it's a socket inode. We do NOT attempt to open
// the socket here — `test -S` is sufficient for the GC decision and a
// connect-and-close probe would race with a daemon that's about to idle.
const out = await execCommand(
const windowsOptions =
isWindowsRemoteHost(host) && options?.windowsNodePath
? {
nodePath: options.windowsNodePath,
pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) =>
windowsRelayPipePathsForSocketName(host, dir, sockName)
)
}
: undefined
const out = await execHostCommand(
conn,
`for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
`[ -S "$f" ] && echo ALIVE && break; ` +
`done; true`
host,
relayLivenessProbeCommand(host, dir, windowsOptions)
)
return out.includes('ALIVE')
} catch {
+98
View File
@@ -0,0 +1,98 @@
import { describe, expect, it } from 'vitest'
import {
commandInRemoteDirectory,
commandWithNodePath,
listRelayBaseDirsCommand,
makeRemoteDirectoryCommand,
probeRelayInstalledCommand,
readRemoteHomeCommand,
relayLivenessProbeCommand,
tryCreateInstallLockCommand
} from './ssh-remote-commands'
import { getRemoteHostPlatform } from './ssh-remote-platform'
const posix = getRemoteHostPlatform('linux-x64')
const windows = getRemoteHostPlatform('win32-x64')
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
}
describe('ssh remote command builders', () => {
it('keeps POSIX deploy commands POSIX-native', () => {
expect(readRemoteHomeCommand(posix)).toBe('echo $HOME')
expect(makeRemoteDirectoryCommand(posix, '/home/me/.orca-remote')).toContain('mkdir -p')
expect(probeRelayInstalledCommand(posix, '/home/me/relay')).toContain('test -d')
})
it('uses encoded PowerShell for Windows deploy commands', () => {
expect(readRemoteHomeCommand(windows)).toContain('powershell.exe')
expect(makeRemoteDirectoryCommand(windows, 'C:/Users/me/.orca-remote')).toContain(
'-EncodedCommand'
)
expect(probeRelayInstalledCommand(windows, 'C:/Users/me/relay')).toContain('-EncodedCommand')
})
it('uses named pipe try-connect liveness for Windows GC', () => {
const command = relayLivenessProbeCommand(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', {
nodePath: 'C:/Program Files/nodejs/node.exe',
pipePaths: ['\\\\.\\pipe\\orca-relay-1234567890abcdef1234']
})
const script = decodePowerShellCommand(command)
expect(command).toContain('powershell.exe')
expect(script).toContain('net.connect(pipe)')
expect(script).toContain('.windows-active-pipe-')
expect(script).toContain('markerCount===0&&pipes.length===0')
expect(script).toContain('C:\\Program Files\\nodejs')
expect(script).not.toContain('Win32_Process')
expect(listRelayBaseDirsCommand(windows, 'C:/Users/me/.orca-remote')).toContain(
'-EncodedCommand'
)
})
it('prepends the Windows node bin directory to PATH with native separators', () => {
const script = decodePowerShellCommand(
commandWithNodePath(
windows,
'C:/Program Files/nodejs/node.exe',
'C:/Users/me/.orca-remote/relay-0.1.0',
"'READY'"
)
)
expect(script).toContain("$env:PATH = 'C:\\Program Files\\nodejs' + ';' + $env:PATH")
})
it('keeps the Windows install-lock try/catch parseable', () => {
const script = decodePowerShellCommand(
tryCreateInstallLockCommand(windows, 'C:/Users/me/.orca-remote/relay/.install-lock')
)
expect(script).toContain('$ErrorActionPreference = "Stop"; try {')
expect(script).toContain("} catch { 'BUSY' }")
expect(script).not.toContain('}; catch')
})
it('makes Windows remote directory changes fail before running scoped commands', () => {
const scopedCommand = decodePowerShellCommand(
commandInRemoteDirectory(windows, 'C:/Users/me/.orca-remote/relay-0.1.0', "'READY'")
)
const nodeScopedCommand = decodePowerShellCommand(
commandWithNodePath(
windows,
'C:/Program Files/nodejs/node.exe',
'C:/Users/me/.orca-remote/relay-0.1.0',
"'READY'"
)
)
expect(scopedCommand).toContain(
"Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'"
)
expect(nodeScopedCommand).toContain(
"Set-Location -ErrorAction Stop -LiteralPath 'C:/Users/me/.orca-remote/relay-0.1.0'"
)
})
})
+235
View File
@@ -0,0 +1,235 @@
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, joinRemotePath, remoteDirname } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
import { shellEscape } from './ssh-connection-utils'
export function readRemoteHomeCommand(host: RemoteHostPlatform): string {
if (!isWindowsRemoteHost(host)) {
return 'echo $HOME'
}
return powerShellCommand("Write-Output ([Environment]::GetFolderPath('UserProfile'))")
}
export function makeRemoteDirectoryCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `mkdir -p ${shellEscape(remotePath)}`
}
return powerShellCommand(
`$null = New-Item -ItemType Directory -Force -LiteralPath ${powerShellLiteral(remotePath)}`
)
}
export function makeRemoteExecutableCommand(host: RemoteHostPlatform, remotePath: string): string {
if (isWindowsRemoteHost(host)) {
return powerShellCommand(`if (Test-Path -LiteralPath ${powerShellLiteral(remotePath)}) { }`)
}
return `chmod +x ${shellEscape(remotePath)} 2>/dev/null; true`
}
export function removeRemoteFileCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `rm -f ${shellEscape(remotePath)} 2>/dev/null; true`
}
return powerShellCommand(
`Remove-Item -LiteralPath ${powerShellLiteral(remotePath)} -Force -ErrorAction SilentlyContinue`
)
}
export function removeRemoteTreeCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `rm -rf ${shellEscape(remotePath)}`
}
return powerShellCommand(
`Remove-Item -LiteralPath ${powerShellLiteral(remotePath)} -Recurse -Force -ErrorAction SilentlyContinue`
)
}
export function writeRemoteEmptyFileCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `touch ${shellEscape(remotePath)}`
}
return powerShellCommand(
`Set-Content -LiteralPath ${powerShellLiteral(remotePath)} -Value '' -NoNewline`
)
}
export function probeRelayInstalledCommand(
host: RemoteHostPlatform,
remoteRelayDir: string
): string {
const relayJs = joinRemotePath(host, remoteRelayDir, 'relay.js')
const installComplete = joinRemotePath(host, remoteRelayDir, '.install-complete')
if (!isWindowsRemoteHost(host)) {
return (
`test -d ${shellEscape(remoteRelayDir)} ` +
`&& test -f ${shellEscape(relayJs)} ` +
`&& test -f ${shellEscape(installComplete)} ` +
`&& echo OK || echo MISSING`
)
}
return powerShellCommand(
[
`$dir = ${powerShellLiteral(remoteRelayDir)}`,
`$relay = ${powerShellLiteral(relayJs)}`,
`$complete = ${powerShellLiteral(installComplete)}`,
"if ((Test-Path -LiteralPath $dir -PathType Container) -and (Test-Path -LiteralPath $relay -PathType Leaf) -and (Test-Path -LiteralPath $complete -PathType Leaf)) { 'OK' } else { 'MISSING' }"
].join('; ')
)
}
export function acquireInstallLockParentCommand(
host: RemoteHostPlatform,
remoteRelayDir: string
): string {
return makeRemoteDirectoryCommand(host, remoteRelayDir)
}
export function tryCreateInstallLockCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
}
return powerShellCommand(
`$ErrorActionPreference = "Stop"; try { $null = New-Item -ItemType Directory -LiteralPath ${powerShellLiteral(lockDir)}; 'OK' } catch { 'BUSY' }`
)
}
export function lockMtimeEpochCommand(host: RemoteHostPlatform, lockDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo`
}
return powerShellCommand(
[
`$item = Get-Item -LiteralPath ${powerShellLiteral(lockDir)} -ErrorAction Stop`,
'$dto = [DateTimeOffset]$item.LastWriteTimeUtc',
'Write-Output $dto.ToUnixTimeSeconds()'
].join('; ')
)
}
export function listRelayBaseDirsCommand(host: RemoteHostPlatform, baseDir: string): string {
if (!isWindowsRemoteHost(host)) {
return `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`
}
return powerShellCommand(
[
`$base = ${powerShellLiteral(baseDir)}`,
'if (Test-Path -LiteralPath $base -PathType Container) {',
'Get-ChildItem -LiteralPath $base -Directory | ForEach-Object { $_.Name }',
'}'
].join(' ')
)
}
export function probeDirectoryExistsCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `test -d ${shellEscape(remotePath)} && echo LOCKED || echo OPEN`
}
return powerShellCommand(
`if (Test-Path -LiteralPath ${powerShellLiteral(remotePath)} -PathType Container) { 'LOCKED' } else { 'OPEN' }`
)
}
export function probeFileExistsCommand(host: RemoteHostPlatform, remotePath: string): string {
if (!isWindowsRemoteHost(host)) {
return `test -f ${shellEscape(remotePath)} && echo COMPLETE || echo PARTIAL`
}
return powerShellCommand(
`if (Test-Path -LiteralPath ${powerShellLiteral(remotePath)} -PathType Leaf) { 'COMPLETE' } else { 'PARTIAL' }`
)
}
type WindowsRelayLivenessOptions = {
nodePath: string
pipePaths: string[]
}
export function relayLivenessProbeCommand(
host: RemoteHostPlatform,
dir: string,
windowsOptions?: WindowsRelayLivenessOptions
): string {
if (!isWindowsRemoteHost(host)) {
return (
`for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
`[ -S "$f" ] && echo ALIVE && break; ` +
'done; true'
)
}
if (!windowsOptions) {
return powerShellCommand("'ALIVE'")
}
const js = [
'const fs=require("fs"),path=require("path"),net=require("net");',
'const [dir,...seed]=process.argv.slice(1);',
'const valid=/^\\\\\\\\[.?]\\\\pipe\\\\orca-relay-[0-9a-f]{20}$/i;',
'const pipes=[];',
'let markerCount=0;',
'for(const p of seed){if(valid.test(p)&&!pipes.includes(p))pipes.push(p)}',
'try{for(const name of fs.readdirSync(dir)){',
'if(!name.startsWith(".windows-active-pipe-"))continue;',
'markerCount++;',
'const p=fs.readFileSync(path.join(dir,name),"utf8").trim();',
'if(valid.test(p)&&!pipes.includes(p))pipes.push(p)',
'}}catch{}',
'if(markerCount===0&&pipes.length===0){process.stdout.write("ALIVE");process.exit(0)}',
'let i=0;',
'function done(ok){process.stdout.write(ok?"ALIVE":"WAITING")}',
'function next(){',
'const pipe=pipes[i++];',
'if(!pipe)return done(false);',
'const s=net.connect(pipe);',
'let settled=false;',
'function finish(ok){if(settled)return;settled=true;s.destroy();if(ok)done(true);else next()}',
's.setTimeout(200);',
's.on("connect",()=>finish(true));',
's.on("timeout",()=>finish(false));',
's.on("error",()=>finish(false));',
'}',
'next();'
].join('')
return commandWithNodePath(
host,
windowsOptions.nodePath,
dir,
[
`& ${powerShellLiteral(windowsOptions.nodePath)}`,
'-e',
powerShellLiteral(js),
powerShellLiteral(dir),
...windowsOptions.pipePaths.map((pipePath) => powerShellLiteral(pipePath))
].join(' ')
)
}
export function commandInRemoteDirectory(
host: RemoteHostPlatform,
remoteDir: string,
command: string
): string {
if (!isWindowsRemoteHost(host)) {
return `cd ${shellEscape(remoteDir)} && ${command}`
}
return powerShellCommand(
`Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(remoteDir)}; ${command}`
)
}
export function commandWithNodePath(
host: RemoteHostPlatform,
nodePath: string,
remoteDir: string,
command: string
): string {
const nodeBinDir = remoteDirname(nodePath, host)
if (!isWindowsRemoteHost(host)) {
return `export PATH=${shellEscape(nodeBinDir)}:$PATH && cd ${shellEscape(remoteDir)} && ${command}`
}
const windowsNodeBinDir = nodeBinDir.replace(/\//g, '\\')
return powerShellCommand(
[
`$env:PATH = ${powerShellLiteral(windowsNodeBinDir)} + ';' + $env:PATH`,
`Set-Location -ErrorAction Stop -LiteralPath ${powerShellLiteral(remoteDir)}`,
command
].join('; ')
)
}
@@ -0,0 +1,98 @@
import type { SshConnection } from './ssh-connection'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { RemoteHostPlatform } from './ssh-remote-platform'
import { isWindowsRemoteHost, normalizeWindowsRemotePath } from './ssh-remote-platform'
import { powerShellCommand } from './ssh-remote-powershell'
// Why: non-login SSH shells (the default for `exec`) don't source
// .bashrc/.zshrc, so node installed via nvm/fnm/Homebrew isn't in PATH.
// We try common locations and fall back to a login-shell `which`.
export async function resolveRemoteNodePath(
conn: SshConnection,
host?: RemoteHostPlatform
): Promise<string> {
if (host && isWindowsRemoteHost(host)) {
return resolveRemoteWindowsNodePath(conn)
}
const script = [
'command -v node 2>/dev/null',
'command -v /usr/local/bin/node 2>/dev/null',
'command -v /opt/homebrew/bin/node 2>/dev/null',
// Why: nvm installs into a versioned directory. `ls -1` sorts
// alphabetically, which misorders versions (e.g. v9 > v18). Pipe
// through `sort -V` (version sort) so we pick the highest version.
'ls -1 $HOME/.nvm/versions/node/*/bin/node 2>/dev/null | sort -V | tail -1',
'command -v $HOME/.local/bin/node 2>/dev/null',
'command -v $HOME/.fnm/aliases/default/bin/node 2>/dev/null'
].join(' || ')
try {
const result = await execCommand(conn, script)
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node at: ${nodePath}`)
return nodePath
}
} catch {
// Fall through to login shell attempt
}
// Why: last resort — source the full login profile. This is separated into
// its own exec because `bash -lc` can hang on remotes with interactive
// shell configs (conda prompts, etc.). If this times out, the error message
// from execCommand will tell us it was the login shell attempt.
try {
console.log('[ssh-relay] Trying login shell to find node...')
const result = await execCommand(conn, "bash -lc 'command -v node' 2>/dev/null")
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
console.log(`[ssh-relay] Found node via login shell: ${nodePath}`)
return nodePath
}
} catch {
// Fall through
}
throwNodeNotFound()
}
async function resolveRemoteWindowsNodePath(conn: SshConnection): Promise<string> {
const script = [
'$paths = @()',
'$cmd = Get-Command node.exe -ErrorAction SilentlyContinue',
'if ($cmd -and $cmd.Source) { $paths += $cmd.Source }',
'if ($env:ProgramFiles) { $paths += (Join-Path $env:ProgramFiles "nodejs/node.exe") }',
'if (${env:ProgramFiles(x86)}) { $paths += (Join-Path ${env:ProgramFiles(x86)} "nodejs/node.exe") }',
'if ($env:LOCALAPPDATA) { $paths += (Join-Path $env:LOCALAPPDATA "Programs/nodejs/node.exe") }',
'foreach ($path in $paths) {',
' if ($path -and (Test-Path -LiteralPath $path -PathType Leaf)) {',
' Write-Output $path',
' exit 0',
' }',
'}',
"Write-Error 'Node.js not found'",
'exit 1'
].join('\n')
try {
const result = await execCommand(conn, powerShellCommand(script), { wrapCommand: false })
const nodePath = result.trim().split('\n')[0]
if (nodePath) {
const normalized = normalizeWindowsRemotePath(nodePath)
console.log(`[ssh-relay] Found Windows node at: ${normalized}`)
return normalized
}
} catch {
// Fall through to the shared error below.
}
throwNodeNotFound()
}
function throwNodeNotFound(): never {
throw new Error(
'Node.js not found on remote host. Orca relay requires Node.js 18+. ' +
'Install Node.js on the remote and try again.'
)
}
@@ -0,0 +1,48 @@
import type { SshConnection } from './ssh-connection'
import { parseUnameToRelayPlatform, type RelayPlatform } from './relay-protocol'
import { execCommand } from './ssh-relay-deploy-helpers'
import { getRemoteHostPlatform, type RemoteHostPlatform } from './ssh-remote-platform'
import { powerShellCommand } from './ssh-remote-powershell'
export async function detectRemoteHostPlatform(
conn: SshConnection
): Promise<RemoteHostPlatform | null> {
const unamePlatform = await detectUnamePlatform(conn)
if (unamePlatform) {
return getRemoteHostPlatform(unamePlatform)
}
const windowsPlatform = await detectWindowsPlatform(conn)
return windowsPlatform ? getRemoteHostPlatform(windowsPlatform) : null
}
async function detectUnamePlatform(conn: SshConnection): Promise<RelayPlatform | null> {
try {
const output = await execCommand(conn, 'uname -sm')
const parts = output.trim().split(/\s+/)
if (parts.length < 2) {
return null
}
return parseUnameToRelayPlatform(parts[0], parts[1])
} catch {
return null
}
}
async function detectWindowsPlatform(conn: SshConnection): Promise<RelayPlatform | null> {
try {
const script = [
'$arch = $env:PROCESSOR_ARCHITECTURE',
'try { $runtimeArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString(); if ($runtimeArch) { $arch = $runtimeArch } } catch {}',
'if (-not $arch) { $arch = $env:PROCESSOR_ARCHITECTURE }',
'Write-Output ("Windows " + $arch)'
].join('; ')
const output = await execCommand(conn, powerShellCommand(script), { wrapCommand: false })
const parts = output.trim().split(/\s+/)
if (parts.length < 2 || parts[0].toLowerCase() !== 'windows') {
return null
}
return parseUnameToRelayPlatform('Windows', parts[1])
} catch {
return null
}
}
+63
View File
@@ -0,0 +1,63 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { getRemoteHostPlatform, joinRemotePath } from './ssh-remote-platform'
import { detectRemoteHostPlatform } from './ssh-remote-platform-detection'
import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
vi.mock('./ssh-relay-deploy-helpers', () => ({
execCommand: vi.fn()
}))
const conn = {} as SshConnection
function decodePowerShellCommand(command: string): string {
const match = command.match(/-EncodedCommand\s+([A-Za-z0-9+/=]+)/)
return match ? Buffer.from(match[1], 'base64').toString('utf16le') : ''
}
beforeEach(() => {
vi.clearAllMocks()
})
describe('joinRemotePath', () => {
it('joins POSIX remote paths', () => {
expect(joinRemotePath(getRemoteHostPlatform('linux-x64'), '/home/me', '.orca-remote')).toBe(
'/home/me/.orca-remote'
)
})
it('normalizes and joins Windows remote paths with forward slashes for SFTP and Node', () => {
expect(
joinRemotePath(getRemoteHostPlatform('win32-x64'), 'C:\\Users\\me', '.orca-remote', 'relay')
).toBe('C:/Users/me/.orca-remote/relay')
})
})
describe('detectRemoteHostPlatform', () => {
it('uses uname when the remote is POSIX', async () => {
vi.mocked(execCommand).mockResolvedValueOnce('Darwin arm64')
await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({
relayPlatform: 'darwin-arm64',
commandDialect: 'posix'
})
})
it('falls back to PowerShell when uname is unavailable on Windows', async () => {
vi.mocked(execCommand)
.mockRejectedValueOnce(new Error('uname not recognized'))
.mockResolvedValueOnce('Windows AMD64')
await expect(detectRemoteHostPlatform(conn)).resolves.toMatchObject({
relayPlatform: 'win32-x64',
commandDialect: 'powershell',
pathFlavor: 'windows'
})
expect(vi.mocked(execCommand).mock.calls[1]?.[1]).toContain('powershell.exe')
const script = decodePowerShellCommand(vi.mocked(execCommand).mock.calls[1]?.[1] ?? '')
expect(script).toContain('$arch = $env:PROCESSOR_ARCHITECTURE')
expect(script).toContain('try { $runtimeArch =')
expect(script).toContain('catch {}')
})
})
+147
View File
@@ -0,0 +1,147 @@
import type { RelayPlatform } from './relay-protocol'
export type RemotePathFlavor = 'posix' | 'windows'
export type RemoteCommandDialect = 'posix' | 'powershell'
export type RemoteOperatingSystem = 'linux' | 'darwin' | 'win32'
export type RemoteArchitecture = 'x64' | 'arm64'
export type RemoteHostPlatform = {
relayPlatform: RelayPlatform
os: RemoteOperatingSystem
arch: RemoteArchitecture
pathFlavor: RemotePathFlavor
commandDialect: RemoteCommandDialect
pathSeparator: '/' | '\\'
pathDelimiter: ':' | ';'
}
const PLATFORM_INFO: Record<RelayPlatform, RemoteHostPlatform> = {
'linux-x64': {
relayPlatform: 'linux-x64',
os: 'linux',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
},
'linux-arm64': {
relayPlatform: 'linux-arm64',
os: 'linux',
arch: 'arm64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
},
'darwin-x64': {
relayPlatform: 'darwin-x64',
os: 'darwin',
arch: 'x64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
},
'darwin-arm64': {
relayPlatform: 'darwin-arm64',
os: 'darwin',
arch: 'arm64',
pathFlavor: 'posix',
commandDialect: 'posix',
pathSeparator: '/',
pathDelimiter: ':'
},
'win32-x64': {
relayPlatform: 'win32-x64',
os: 'win32',
arch: 'x64',
pathFlavor: 'windows',
commandDialect: 'powershell',
pathSeparator: '\\',
pathDelimiter: ';'
},
'win32-arm64': {
relayPlatform: 'win32-arm64',
os: 'win32',
arch: 'arm64',
pathFlavor: 'windows',
commandDialect: 'powershell',
pathSeparator: '\\',
pathDelimiter: ';'
}
}
export function getRemoteHostPlatform(platform: RelayPlatform): RemoteHostPlatform {
return PLATFORM_INFO[platform]
}
export function isWindowsRemoteHost(host: RemoteHostPlatform): boolean {
return host.os === 'win32'
}
export function normalizeWindowsRemotePath(path: string): string {
return path.replace(/\\/g, '/')
}
export function normalizeRemoteHome(rawHome: string, host: RemoteHostPlatform): string {
const home = rawHome.trim()
return isWindowsRemoteHost(host) ? normalizeWindowsRemotePath(home).replace(/\/+$/, '') : home
}
function hasUnsafeRemotePathChar(value: string): boolean {
for (let i = 0; i < value.length; i += 1) {
const code = value.charCodeAt(i)
if (code === 0 || code === 10 || code === 13) {
return true
}
}
return false
}
export function validateRemoteHome(home: string, host: RemoteHostPlatform): boolean {
if (!home || hasUnsafeRemotePathChar(home)) {
return false
}
if (host.pathFlavor === 'windows') {
return /^[a-zA-Z]:\//.test(home) || home.startsWith('//')
}
return home.startsWith('/')
}
export function joinRemotePath(host: RemoteHostPlatform, ...segments: string[]): string {
const cleaned = segments.filter(Boolean)
if (cleaned.length === 0) {
return ''
}
if (host.pathFlavor === 'windows') {
const [first, ...rest] = cleaned.map((segment) => normalizeWindowsRemotePath(segment))
return rest.reduce((acc, segment) => {
const left = acc.replace(/\/+$/, '')
const right = segment.replace(/^\/+/, '')
return `${left}/${right}`
}, first)
}
const [first, ...rest] = cleaned
return rest.reduce((acc, segment) => {
const left = acc.replace(/\/+$/, '')
const right = segment.replace(/^\/+/, '')
return `${left}/${right}`
}, first)
}
export function remoteBasename(path: string, host: RemoteHostPlatform): string {
const normalized = host.pathFlavor === 'windows' ? normalizeWindowsRemotePath(path) : path
return normalized.split('/').filter(Boolean).pop() ?? ''
}
export function remoteDirname(path: string, host: RemoteHostPlatform): string {
const normalized = host.pathFlavor === 'windows' ? normalizeWindowsRemotePath(path) : path
const parts = normalized.split('/')
parts.pop()
const joined = parts.join('/')
if (host.pathFlavor === 'windows') {
return joined
}
return joined || '/'
}
+9
View File
@@ -0,0 +1,9 @@
import { encodePowerShellCommand } from '../../shared/powershell-command-encoding'
export function powerShellLiteral(value: string): string {
return `'${value.replace(/'/g, "''")}'`
}
export function powerShellCommand(script: string): string {
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`
}
+88 -3
View File
@@ -1,3 +1,6 @@
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { EventEmitter } from 'node:events'
import { PassThrough } from 'node:stream'
import { describe, expect, it, vi, beforeEach } from 'vitest'
@@ -7,9 +10,13 @@ const { existsSyncMock, spawnMock } = vi.hoisted(() => ({
spawnMock: vi.fn()
}))
vi.mock('fs', () => ({
existsSync: existsSyncMock
}))
vi.mock('fs', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
existsSync: existsSyncMock
}
})
vi.mock('child_process', () => ({
spawn: spawnMock
@@ -23,6 +30,7 @@ import {
uploadDirectoryViaSystemSsh,
writeFileViaSystemSsh
} from './ssh-system-fallback'
import { getRemoteHostPlatform } from './ssh-remote-platform'
import type { SshTarget } from '../../shared/ssh-types'
function createTarget(overrides?: Partial<SshTarget>): SshTarget {
@@ -237,6 +245,18 @@ describe('spawnSystemSsh', () => {
)
})
it('can spawn a native remote command without the POSIX shell wrapper', () => {
spawnSystemSshCommand(createTarget({ configHost: 'fdpass-host' }), 'echo hello', {
wrapCommand: false
})
expect(spawnMock).toHaveBeenCalledWith(
'/usr/bin/ssh',
expect.arrayContaining(['--', 'deploy@fdpass-host', 'echo hello']),
expect.objectContaining({ stdio: ['pipe', 'pipe', 'pipe'] })
)
})
it('exposes child stdin so remote commands receive EOF', () => {
const channel = spawnSystemSshCommand(createTarget(), 'cat > /tmp/file')
@@ -276,6 +296,71 @@ describe('spawnSystemSsh', () => {
expect(proc.stderr.listenerCount('data')).toBe(0)
})
it('writes files to Windows system SSH targets with PowerShell stdin bytes', async () => {
const proc = createEventedProcess()
spawnMock.mockReturnValue(proc)
const hostPlatform = getRemoteHostPlatform('win32-x64')
const promise = writeFileViaSystemSsh(
createTarget(),
'C:/Users/me/.orca-remote/relay/.version',
'0.1.0',
{ hostPlatform }
)
proc.emit('close', 0, null)
await expect(promise).resolves.toBeUndefined()
const args = spawnMock.mock.calls[0][1] as string[]
const remoteCommand = args.at(-1) ?? ''
expect(remoteCommand).toContain('powershell.exe')
expect(remoteCommand).not.toContain('/bin/sh')
expect(proc.stdin.end).toHaveBeenCalledWith(Buffer.from('0.1.0', 'utf-8'))
})
it('uploads directories to Windows system SSH targets in one PowerShell batch', async () => {
const localDir = mkdtempSync(join(tmpdir(), 'orca-system-ssh-upload-'))
writeFileSync(join(localDir, 'relay.js'), 'console.log("relay")')
const spawned: EventedProcess[] = []
spawnMock.mockImplementation(() => {
const proc = createEventedProcess()
spawned.push(proc)
queueMicrotask(() => proc.emit('close', 0, null))
return proc
})
try {
await uploadDirectoryViaSystemSsh(
createTarget(),
localDir,
'C:/Users/me/.orca-remote/relay',
{ hostPlatform: getRemoteHostPlatform('win32-x64') }
)
} finally {
rmSync(localDir, { recursive: true, force: true })
}
const commands = spawnMock.mock.calls.map((call) => (call[1] as string[]).at(-1) ?? '')
expect(commands).toHaveLength(1)
expect(commands.every((command) => command.includes('powershell.exe'))).toBe(true)
expect(commands.every((command) => !command.includes('/bin/sh'))).toBe(true)
expect(commands.join('\n')).not.toContain('tar -xzf')
const payload = JSON.parse(spawned[0].stdin.end.mock.calls[0]?.[0] as string) as {
kind: string
path: string
contentsBase64?: string
}[]
expect(payload).toEqual(
expect.arrayContaining([
{ kind: 'directory', path: 'C:/Users/me/.orca-remote/relay' },
{
kind: 'file',
path: 'C:/Users/me/.orca-remote/relay/relay.js',
contentsBase64: Buffer.from('console.log("relay")').toString('base64')
}
])
)
})
it('throws when no system ssh is found', () => {
existsSyncMock.mockReturnValue(false)
expect(() => spawnSystemSsh(createTarget())).toThrow('No system ssh binary found')
+185 -2
View File
@@ -1,11 +1,17 @@
/* eslint-disable max-lines -- Why: system-ssh process wrapping and fallback file operations share cleanup contracts. */
import { spawn, type ChildProcess } from 'child_process'
import { constants } from 'node:fs'
import { existsSync } from 'fs'
import { lstat, open, readdir } from 'fs/promises'
import { join as pathJoin } from 'path'
import { Duplex } from 'stream'
import { pipeline } from 'stream/promises'
import type { ClientChannel } from 'ssh2'
import type { SshTarget } from '../../shared/ssh-types'
import { wrapRemoteCommandForPosixShell, shellEscape } from './ssh-connection-utils'
import type { SshExecOptions } from './ssh-connection-utils'
import { isWindowsRemoteHost, joinRemotePath, type RemoteHostPlatform } from './ssh-remote-platform'
import { powerShellCommand, powerShellLiteral } from './ssh-remote-powershell'
const SYSTEM_SSH_PATHS =
process.platform === 'win32'
@@ -27,6 +33,7 @@ type SystemSshCommandChannel = ClientChannel & {
type SystemSshOperationOptions = {
signal?: AbortSignal
hostPlatform?: RemoteHostPlatform
}
/**
@@ -68,7 +75,11 @@ export function spawnSystemSsh(target: SshTarget): SystemSshProcess {
return wrapChildProcess(proc)
}
export function spawnSystemSshCommand(target: SshTarget, command: string): ClientChannel {
export function spawnSystemSshCommand(
target: SshTarget,
command: string,
options?: SshExecOptions
): ClientChannel {
const sshPath = findSystemSsh()
if (!sshPath) {
throw new Error(
@@ -76,7 +87,9 @@ export function spawnSystemSshCommand(target: SshTarget, command: string): Clien
)
}
const proc = spawn(sshPath, [...buildSshArgs(target), wrapRemoteCommandForPosixShell(command)], {
const remoteCommand =
options?.wrapCommand === false ? command : wrapRemoteCommandForPosixShell(command)
const proc = spawn(sshPath, [...buildSshArgs(target), remoteCommand], {
stdio: ['pipe', 'pipe', 'pipe'],
windowsHide: true
})
@@ -90,6 +103,11 @@ export async function uploadDirectoryViaSystemSsh(
options?: SystemSshOperationOptions
): Promise<void> {
throwIfAborted(options?.signal)
if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) {
await uploadDirectoryViaSystemSshWindows(target, localDir, remoteDir, options)
return
}
const sshPath = findSystemSsh()
if (!sshPath) {
throw new Error('No system ssh binary found. Install OpenSSH to use system SSH transport.')
@@ -145,6 +163,16 @@ export async function writeFileViaSystemSsh(
options?: SystemSshOperationOptions
): Promise<void> {
throwIfAborted(options?.signal)
if (options?.hostPlatform && isWindowsRemoteHost(options.hostPlatform)) {
await writeBufferViaSystemSshWindows(
target,
remotePath,
Buffer.from(contents, 'utf-8'),
options
)
return
}
const channel = spawnSystemSshCommand(target, `cat > ${shellEscape(remotePath)}`)
const closePromise = awaitWithSystemSshAbort(
options?.signal,
@@ -157,6 +185,161 @@ export async function writeFileViaSystemSsh(
await closePromise
}
async function uploadDirectoryViaSystemSshWindows(
target: SshTarget,
localDir: string,
remoteDir: string,
options: SystemSshOperationOptions
): Promise<void> {
const hostPlatform = options.hostPlatform
if (!hostPlatform) {
throw new Error('Windows system SSH upload requires a remote host platform')
}
const entries = await collectWindowsUploadEntries(
localDir,
remoteDir,
hostPlatform,
options.signal
)
await writeWindowsUploadPackageViaSystemSsh(target, entries, options)
}
type WindowsUploadEntry =
| {
kind: 'directory'
path: string
}
| {
kind: 'file'
path: string
contentsBase64: string
}
async function collectWindowsUploadEntries(
localDir: string,
remoteDir: string,
hostPlatform: RemoteHostPlatform,
signal: AbortSignal | undefined
): Promise<WindowsUploadEntry[]> {
const entries: WindowsUploadEntry[] = [{ kind: 'directory', path: remoteDir }]
const dirEntries = await readdir(localDir, { withFileTypes: true })
for (const entry of dirEntries) {
throwIfAborted(signal)
const localPath = pathJoin(localDir, entry.name)
const remotePath = joinRemotePath(hostPlatform, remoteDir, entry.name)
const statResult = await lstat(localPath)
if (statResult.isSymbolicLink() || (!statResult.isFile() && !statResult.isDirectory())) {
continue
}
if (statResult.isDirectory()) {
entries.push(
...(await collectWindowsUploadEntries(localPath, remotePath, hostPlatform, signal))
)
continue
}
const buffer = await readLocalUploadFile(localPath, statResult)
entries.push({ kind: 'file', path: remotePath, contentsBase64: buffer.toString('base64') })
}
return entries
}
async function writeWindowsUploadPackageViaSystemSsh(
target: SshTarget,
entries: WindowsUploadEntry[],
options: SystemSshOperationOptions
): Promise<void> {
throwIfAborted(options.signal)
const channel = spawnSystemSshCommand(target, makeWindowsUploadPackageCommand(), {
wrapCommand: false
})
const closePromise = awaitWithSystemSshAbort(
options.signal,
() => channel.close(),
waitForChannelClose(channel, 'windows relay upload')
)
if (!options.signal?.aborted) {
channel.stdin.end(JSON.stringify(entries))
}
await closePromise
}
async function readLocalUploadFile(
localPath: string,
statResult: Awaited<ReturnType<typeof lstat>>
): Promise<Buffer> {
const handle = await open(localPath, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
try {
const openedStat = await handle.stat()
if (
!openedStat.isFile() ||
openedStat.size !== statResult.size ||
(statResult.ino !== 0 && openedStat.ino !== 0 && openedStat.ino !== statResult.ino) ||
(statResult.dev !== 0 && openedStat.dev !== 0 && openedStat.dev !== statResult.dev)
) {
throw new Error(`File changed during upload: ${localPath}`)
}
return await handle.readFile()
} finally {
await handle.close()
}
}
async function writeBufferViaSystemSshWindows(
target: SshTarget,
remotePath: string,
contents: Buffer,
options: SystemSshOperationOptions
): Promise<void> {
throwIfAborted(options.signal)
const channel = spawnSystemSshCommand(target, makeWindowsWriteFileCommand(remotePath), {
wrapCommand: false
})
const closePromise = awaitWithSystemSshAbort(
options.signal,
() => channel.close(),
waitForChannelClose(channel, `write ${remotePath}`)
)
if (!options.signal?.aborted) {
channel.stdin.end(contents)
}
await closePromise
}
function makeWindowsWriteFileCommand(remotePath: string): string {
return powerShellCommand(
[
'$ErrorActionPreference = "Stop"',
`$path = ${powerShellLiteral(remotePath)}`,
'$parent = [System.IO.Path]::GetDirectoryName($path)',
'if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }',
'$inputStream = [Console]::OpenStandardInput()',
'$outputStream = [System.IO.File]::Open($path, [System.IO.FileMode]::Create, [System.IO.FileAccess]::Write, [System.IO.FileShare]::None)',
'try { $inputStream.CopyTo($outputStream) } finally { $outputStream.Dispose() }'
].join('; ')
)
}
function makeWindowsUploadPackageCommand(): string {
return powerShellCommand(
[
'$ErrorActionPreference = "Stop"',
'$json = [Console]::In.ReadToEnd()',
'if ([string]::IsNullOrWhiteSpace($json)) { return }',
'$items = $json | ConvertFrom-Json',
'foreach ($item in @($items)) {',
' $path = [string]$item.path',
' if ($item.kind -eq "directory") {',
' $null = [System.IO.Directory]::CreateDirectory($path)',
' continue',
' }',
' $parent = [System.IO.Path]::GetDirectoryName($path)',
' if ($parent) { $null = [System.IO.Directory]::CreateDirectory($parent) }',
' [System.IO.File]::WriteAllBytes($path, [Convert]::FromBase64String([string]$item.contentsBase64))',
'}'
].join('; ')
)
}
export function buildSshArgs(target: SshTarget): string[] {
const args: string[] = []
@@ -121,7 +121,14 @@ if (process.argv.includes('--detached')) {
}
function createRelayTree(root: string, remoteHome: string): void {
const platforms = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64']
const platforms = [
'linux-x64',
'linux-arm64',
'darwin-x64',
'darwin-arm64',
'win32-x64',
'win32-arm64'
]
for (const platform of platforms) {
const localDir = join(root, platform)
mkdirSync(localDir, { recursive: true })