From 98d02bca472c913d488036dfc141d6cb58505e44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Leynier=20Guti=C3=A9rrez=20Gonz=C3=A1lez?= Date: Tue, 9 Jun 2026 02:17:34 -0600 Subject: [PATCH] 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 * 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 Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com> --- config/scripts/build-relay.mjs | 9 +- src/main/ipc/preflight.ts | 4 +- src/main/powershell-osc133-bootstrap.ts | 5 +- src/main/providers/ssh-pty-provider.test.ts | 30 + src/main/providers/ssh-pty-provider.ts | 6 +- src/main/ssh/relay-protocol.test.ts | 16 + src/main/ssh/relay-protocol.ts | 17 +- src/main/ssh/ssh-connection-utils.ts | 4 + src/main/ssh/ssh-connection.test.ts | 57 +- src/main/ssh/ssh-connection.ts | 44 +- .../ssh-relay-cross-version-isolation.test.ts | 5 +- src/main/ssh/ssh-relay-deploy-helpers.ts | 90 +-- src/main/ssh/ssh-relay-deploy.test.ts | 210 +++++- src/main/ssh/ssh-relay-deploy.ts | 641 +++++++++++++++--- src/main/ssh/ssh-relay-endpoints.ts | 68 ++ src/main/ssh/ssh-relay-handshake-mismatch.ts | 28 + .../ssh/ssh-relay-native-deps-install.test.ts | 46 +- src/main/ssh/ssh-relay-session.test.ts | 77 +++ src/main/ssh/ssh-relay-session.ts | 119 +++- .../ssh/ssh-relay-versioned-install.test.ts | 34 + src/main/ssh/ssh-relay-versioned-install.ts | 174 +++-- src/main/ssh/ssh-remote-commands.test.ts | 98 +++ src/main/ssh/ssh-remote-commands.ts | 235 +++++++ src/main/ssh/ssh-remote-node-resolution.ts | 98 +++ src/main/ssh/ssh-remote-platform-detection.ts | 48 ++ src/main/ssh/ssh-remote-platform.test.ts | 63 ++ src/main/ssh/ssh-remote-platform.ts | 147 ++++ src/main/ssh/ssh-remote-powershell.ts | 9 + src/main/ssh/ssh-system-fallback.test.ts | 91 ++- src/main/ssh/ssh-system-fallback.ts | 187 ++++- .../ssh-system-transport.integration.test.ts | 9 +- src/relay/agent-hook-server.test.ts | 9 +- src/relay/agent-hook-server.ts | 17 + src/relay/context.test.ts | 14 + src/relay/context.ts | 5 +- src/relay/port-scan-handler.test.ts | 53 ++ src/relay/port-scan-handler.ts | 22 +- src/relay/preflight-handler.test.ts | 27 + src/relay/preflight-handler.ts | 40 +- src/relay/pty-handler.ts | 3 +- src/relay/pty-shell-launch.test.ts | 26 + src/relay/pty-shell-launch.ts | 30 +- src/relay/pty-shell-utils.test.ts | 75 ++ src/relay/pty-shell-utils.ts | 48 ++ src/relay/relay.ts | 35 +- src/relay/windows-port-scan.test.ts | 87 +++ src/relay/windows-port-scan.ts | 204 ++++++ src/shared/powershell-command-encoding.ts | 3 + src/shared/ssh-types.ts | 2 +- 49 files changed, 3055 insertions(+), 314 deletions(-) create mode 100644 src/main/ssh/ssh-relay-endpoints.ts create mode 100644 src/main/ssh/ssh-relay-handshake-mismatch.ts create mode 100644 src/main/ssh/ssh-remote-commands.test.ts create mode 100644 src/main/ssh/ssh-remote-commands.ts create mode 100644 src/main/ssh/ssh-remote-node-resolution.ts create mode 100644 src/main/ssh/ssh-remote-platform-detection.ts create mode 100644 src/main/ssh/ssh-remote-platform.test.ts create mode 100644 src/main/ssh/ssh-remote-platform.ts create mode 100644 src/main/ssh/ssh-remote-powershell.ts create mode 100644 src/relay/context.test.ts create mode 100644 src/relay/preflight-handler.test.ts create mode 100644 src/relay/pty-shell-utils.test.ts create mode 100644 src/relay/windows-port-scan.test.ts create mode 100644 src/relay/windows-port-scan.ts create mode 100644 src/shared/powershell-command-encoding.ts diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index 968901f50d6..b303f6d88bb 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -19,7 +19,14 @@ const __dirname = dirname(fileURLToPath(import.meta.url)) const ROOT = join(__dirname, '..', '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') -const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] +const PLATFORMS = [ + 'linux-x64', + 'linux-arm64', + 'darwin-x64', + 'darwin-arm64', + 'win32-x64', + 'win32-arm64' +] const RELAY_VERSION = '0.1.0' diff --git a/src/main/ipc/preflight.ts b/src/main/ipc/preflight.ts index 74a46bc13c8..3f7e4877ce5 100644 --- a/src/main/ipc/preflight.ts +++ b/src/main/ipc/preflight.ts @@ -367,8 +367,8 @@ export function registerPreflightHandlers(): void { // Why: remote worktrees need agent detection on the SSH host, not the local // machine. This handler forwards the same KNOWN_AGENT_COMMANDS list to the - // relay's preflight.detectAgents RPC, which runs `which` inside a login shell - // on the remote host to match the PATH users see in PTY sessions. + // relay's preflight.detectAgents RPC, whose lookup command is selected on + // the remote host so native Windows OpenSSH does not require a POSIX shell. ipcMain.handle( 'preflight:detectRemoteAgents', async (_event, args: { connectionId: string }): Promise => { diff --git a/src/main/powershell-osc133-bootstrap.ts b/src/main/powershell-osc133-bootstrap.ts index 32af1d823ce..ff84103bc83 100644 --- a/src/main/powershell-osc133-bootstrap.ts +++ b/src/main/powershell-osc133-bootstrap.ts @@ -1,4 +1,5 @@ import { getPowerShellOmpShellWrapper } from './pty/omp-shell-wrapper' +export { encodePowerShellCommand } from '../shared/powershell-command-encoding' const POWERSHELL_OSC133_BOOTSTRAP = `# Orca OSC 133 shell integration for PowerShell. if ((Test-Path variable:global:__OrcaOsc133State) -and @@ -74,10 +75,6 @@ export function getPowerShellOsc133Bootstrap(): string { return POWERSHELL_OSC133_BOOTSTRAP } -export function encodePowerShellCommand(command: string): string { - return Buffer.from(command, 'utf16le').toString('base64') -} - export function isPowerShellExecutableName(shellName: string): boolean { const normalized = shellName.toLowerCase() return ( diff --git a/src/main/providers/ssh-pty-provider.test.ts b/src/main/providers/ssh-pty-provider.test.ts index a37b1fe551c..ba4a47be8e3 100644 --- a/src/main/providers/ssh-pty-provider.test.ts +++ b/src/main/providers/ssh-pty-provider.test.ts @@ -125,6 +125,36 @@ describe('SshPtyProvider', () => { }) }) + it('uses Windows PATH delimiters for native Windows SSH bridge env', async () => { + mux.request.mockResolvedValue({ id: 'pty-bridge' }) + provider = new SshPtyProvider('conn-1', mux as never, { + binDir: 'C:/Users/me/.orca-relay/bin', + relayDir: 'C:/Users/me/.orca-remote/relay-v1', + nodePath: 'C:/Program Files/nodejs/node.exe', + sockPath: '\\\\.\\pipe\\orca-relay-123', + pathDelimiter: ';' + }) + + await provider.spawn({ + cols: 120, + rows: 40, + env: { Path: 'C:/Windows/System32;C:/Tools' } + }) + + expect(mux.request).toHaveBeenCalledWith('pty.spawn', { + cols: 120, + rows: 40, + cwd: undefined, + env: { + Path: 'C:/Users/me/.orca-relay/bin;C:/Windows/System32;C:/Tools', + ORCA_REMOTE_CLI_BIN_DIR: 'C:/Users/me/.orca-relay/bin', + ORCA_RELAY_DIR: 'C:/Users/me/.orca-remote/relay-v1', + ORCA_RELAY_NODE_PATH: 'C:/Program Files/nodejs/node.exe', + ORCA_RELAY_SOCKET_PATH: '\\\\.\\pipe\\orca-relay-123' + } + }) + }) + it('reattaches an existing session and returns attach replay separately from snapshot', async () => { mux.request.mockResolvedValue({ replay: 'buffered-output' }) diff --git a/src/main/providers/ssh-pty-provider.ts b/src/main/providers/ssh-pty-provider.ts index db91c9cc4e1..8883b88a374 100644 --- a/src/main/providers/ssh-pty-provider.ts +++ b/src/main/providers/ssh-pty-provider.ts @@ -10,6 +10,7 @@ type RemoteCliBridgeEnv = { relayDir: string nodePath: string sockPath: string + pathDelimiter?: ':' | ';' } export const SSH_SESSION_EXPIRED_ERROR = 'SSH_SESSION_EXPIRED' @@ -152,13 +153,14 @@ export class SshPtyProvider implements IPtyProvider { return env } const merged = { ...env } + const pathDelimiter = this.remoteCliBridgeEnv.pathDelimiter ?? ':' const pathKey = merged.PATH !== undefined ? 'PATH' : merged.Path !== undefined ? 'Path' : null if (pathKey) { const pathValue = merged[pathKey] ?? '' - merged[pathKey] = pathValue.split(':').includes(this.remoteCliBridgeEnv.binDir) + merged[pathKey] = pathValue.split(pathDelimiter).includes(this.remoteCliBridgeEnv.binDir) ? pathValue : pathValue - ? `${this.remoteCliBridgeEnv.binDir}:${pathValue}` + ? `${this.remoteCliBridgeEnv.binDir}${pathDelimiter}${pathValue}` : this.remoteCliBridgeEnv.binDir } merged.ORCA_REMOTE_CLI_BIN_DIR = this.remoteCliBridgeEnv.binDir diff --git a/src/main/ssh/relay-protocol.test.ts b/src/main/ssh/relay-protocol.test.ts index 827b5a09b13..8369c3c02cd 100644 --- a/src/main/ssh/relay-protocol.test.ts +++ b/src/main/ssh/relay-protocol.test.ts @@ -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() }) diff --git a/src/main/ssh/relay-protocol.ts b/src/main/ssh/relay-protocol.ts index e3927e41924..d275c05f49f 100644 --- a/src/main/ssh/relay-protocol.ts +++ b/src/main/ssh/relay-protocol.ts @@ -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' diff --git a/src/main/ssh/ssh-connection-utils.ts b/src/main/ssh/ssh-connection-utils.ts index 0e22dede82d..aa0a7cc6db4 100644 --- a/src/main/ssh/ssh-connection-utils.ts +++ b/src/main/ssh/ssh-connection-utils.ts @@ -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, '""')}"` } diff --git a/src/main/ssh/ssh-connection.test.ts b/src/main/ssh/ssh-connection.test.ts index f97b1c94c44..1191e0a5335 100644 --- a/src/main/ssh/ssh-connection.test.ts +++ b/src/main/ssh/ssh-connection.test.ts @@ -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 { @@ -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 }) ) }) diff --git a/src/main/ssh/ssh-connection.ts b/src/main/ssh/ssh-connection.ts index 31871268351..d14db4d26bf 100644 --- a/src/main/ssh/ssh-connection.ts +++ b/src/main/ssh/ssh-connection.ts @@ -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 { + async exec(cmd: string, options?: SshExecOptions): Promise { 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 { + async uploadDirectory( + localDir: string, + remoteDir: string, + options?: SshRemoteFileOptions + ): Promise { 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 { + async writeFile( + remotePath: string, + contents: string, + options?: SshRemoteFileOptions + ): Promise { 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((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) diff --git a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts index d8623267769..e1f93d8ec6d 100644 --- a/src/main/ssh/ssh-relay-cross-version-isolation.test.ts +++ b/src/main/ssh/ssh-relay-cross-version-isolation.test.ts @@ -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') })) diff --git a/src/main/ssh/ssh-relay-deploy-helpers.ts b/src/main/ssh/ssh-relay-deploy-helpers.ts index 9e2be8c15a4..261e3b83f7d 100644 --- a/src/main/ssh/ssh-relay-deploy-helpers.ts +++ b/src/main/ssh/ssh-relay-deploy-helpers.ts @@ -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 { - const channel = await conn.exec(command) +export async function execCommand( + conn: SshConnection, + command: string, + options?: SshExecOptions +): Promise { + 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 { - // 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=, daemon=" 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] } -} diff --git a/src/main/ssh/ssh-relay-deploy.test.ts b/src/main/ssh/ssh-relay-deploy.test.ts index 39ef185da46..bb6ceabadb8 100644 --- a/src/main/ssh/ssh-relay-deploy.test.ts +++ b/src/main/ssh/ssh-relay-deploy.test.ts @@ -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]) + }) }) diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index d88ceddc624..6a3d15d6a60 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -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 { + 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 { 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 { - 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 { 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 { 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 { 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 { - 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 { 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 { - 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 { - 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 { + 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 { + 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 { + 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 { + 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) +} diff --git a/src/main/ssh/ssh-relay-endpoints.ts b/src/main/ssh/ssh-relay-endpoints.ts new file mode 100644 index 00000000000..54238a450e4 --- /dev/null +++ b/src/main/ssh/ssh-relay-endpoints.ts @@ -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) +} diff --git a/src/main/ssh/ssh-relay-handshake-mismatch.ts b/src/main/ssh/ssh-relay-handshake-mismatch.ts new file mode 100644 index 00000000000..62b39c48bee --- /dev/null +++ b/src/main/ssh/ssh-relay-handshake-mismatch.ts @@ -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=, daemon=" 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] } +} diff --git a/src/main/ssh/ssh-relay-native-deps-install.test.ts b/src/main/ssh/ssh-relay-native-deps-install.test.ts index 0a09308d6d7..355fa4c85d2 100644 --- a/src/main/ssh/ssh-relay-native-deps-install.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install.test.ts @@ -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. diff --git a/src/main/ssh/ssh-relay-session.test.ts b/src/main/ssh/ssh-relay-session.test.ts index c68f78d0267..66c8650221e 100644 --- a/src/main/ssh/ssh-relay-session.test.ts +++ b/src/main/ssh/ssh-relay-session.test.ts @@ -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']) diff --git a/src/main/ssh/ssh-relay-session.ts b/src/main/ssh/ssh-relay-session.ts index d9568a3335a..deeb8270067 100644 --- a/src/main/ssh/ssh-relay-session.ts +++ b/src/main/ssh/ssh-relay-session.ts @@ -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((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') + } +} diff --git a/src/main/ssh/ssh-relay-versioned-install.test.ts b/src/main/ssh/ssh-relay-versioned-install.test.ts index a7bae19e9cf..c33e52a0008 100644 --- a/src/main/ssh/ssh-relay-versioned-install.test.ts +++ b/src/main/ssh/ssh-relay-versioned-install.test.ts @@ -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') diff --git a/src/main/ssh/ssh-relay-versioned-install.ts b/src/main/ssh/ssh-relay-versioned-install.ts index 07aaa28c2d0..2b0233073ee 100644 --- a/src/main/ssh/ssh-relay-versioned-install.ts +++ b/src/main/ssh/ssh-relay-versioned-install.ts @@ -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 { + 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 { 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 { - 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 { +async function isLockStale( + conn: SshConnection, + lockDir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST +): Promise { 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 { - 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 { + 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 { - 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 { + 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 { - 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 { 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 { +async function hasLiveRelaySocket( + conn: SshConnection, + dir: string, + host: RemoteHostPlatform = DEFAULT_REMOTE_HOST, + options?: { + windowsNodePath?: string + windowsSockNames?: string[] + } +): Promise { 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 { diff --git a/src/main/ssh/ssh-remote-commands.test.ts b/src/main/ssh/ssh-remote-commands.test.ts new file mode 100644 index 00000000000..21e92987afd --- /dev/null +++ b/src/main/ssh/ssh-remote-commands.test.ts @@ -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'" + ) + }) +}) diff --git a/src/main/ssh/ssh-remote-commands.ts b/src/main/ssh/ssh-remote-commands.ts new file mode 100644 index 00000000000..89bd89eec80 --- /dev/null +++ b/src/main/ssh/ssh-remote-commands.ts @@ -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('; ') + ) +} diff --git a/src/main/ssh/ssh-remote-node-resolution.ts b/src/main/ssh/ssh-remote-node-resolution.ts new file mode 100644 index 00000000000..e655894e924 --- /dev/null +++ b/src/main/ssh/ssh-remote-node-resolution.ts @@ -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 { + 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 { + 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.' + ) +} diff --git a/src/main/ssh/ssh-remote-platform-detection.ts b/src/main/ssh/ssh-remote-platform-detection.ts new file mode 100644 index 00000000000..6742100564f --- /dev/null +++ b/src/main/ssh/ssh-remote-platform-detection.ts @@ -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 { + 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 { + 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 { + 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 + } +} diff --git a/src/main/ssh/ssh-remote-platform.test.ts b/src/main/ssh/ssh-remote-platform.test.ts new file mode 100644 index 00000000000..d065cecd62e --- /dev/null +++ b/src/main/ssh/ssh-remote-platform.test.ts @@ -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 {}') + }) +}) diff --git a/src/main/ssh/ssh-remote-platform.ts b/src/main/ssh/ssh-remote-platform.ts new file mode 100644 index 00000000000..3532b825539 --- /dev/null +++ b/src/main/ssh/ssh-remote-platform.ts @@ -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 = { + '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 || '/' +} diff --git a/src/main/ssh/ssh-remote-powershell.ts b/src/main/ssh/ssh-remote-powershell.ts new file mode 100644 index 00000000000..9cbd2df486e --- /dev/null +++ b/src/main/ssh/ssh-remote-powershell.ts @@ -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)}` +} diff --git a/src/main/ssh/ssh-system-fallback.test.ts b/src/main/ssh/ssh-system-fallback.test.ts index 9c7f46fd562..5a79e07cce0 100644 --- a/src/main/ssh/ssh-system-fallback.test.ts +++ b/src/main/ssh/ssh-system-fallback.test.ts @@ -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 + 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 { @@ -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') diff --git a/src/main/ssh/ssh-system-fallback.ts b/src/main/ssh/ssh-system-fallback.ts index 4be22fc5553..06727c45fc1 100644 --- a/src/main/ssh/ssh-system-fallback.ts +++ b/src/main/ssh/ssh-system-fallback.ts @@ -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 { 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 { 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 { + 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 { + 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 { + 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> +): Promise { + 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 { + 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[] = [] diff --git a/src/main/ssh/ssh-system-transport.integration.test.ts b/src/main/ssh/ssh-system-transport.integration.test.ts index 0af378d7ef9..396b4fe64c4 100644 --- a/src/main/ssh/ssh-system-transport.integration.test.ts +++ b/src/main/ssh/ssh-system-transport.integration.test.ts @@ -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 }) diff --git a/src/relay/agent-hook-server.test.ts b/src/relay/agent-hook-server.test.ts index 3860ff805ad..40583b64335 100644 --- a/src/relay/agent-hook-server.test.ts +++ b/src/relay/agent-hook-server.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs' -import { tmpdir } from 'os' +import { homedir, tmpdir } from 'os' import { join } from 'path' import { endpointDirForRelaySocket, RelayAgentHookServer } from './agent-hook-server' import type { AgentHookRelayEnvelope } from '../shared/agent-hook-relay' @@ -27,6 +27,13 @@ describe('RelayAgentHookServer', () => { expect(first).not.toBe(second) }) + it('keeps named-pipe endpoint files on a real filesystem path', () => { + const endpointDir = endpointDirForRelaySocket('\\\\.\\pipe\\orca-relay-abc123') + + expect(endpointDir).toBe(join(homedir(), '.orca-relay', 'agent-hooks', 'orca-relay-abc123')) + expect(endpointDir).not.toContain('\\\\.\\pipe') + }) + it('forwards a parsed Claude UserPromptSubmit POST as a normalized envelope', async () => { const forward = vi.fn<(envelope: AgentHookRelayEnvelope) => void>() const server = new RelayAgentHookServer({ endpointDir: dir, forward }) diff --git a/src/relay/agent-hook-server.ts b/src/relay/agent-hook-server.ts index fa404b88f3f..eb9df3348b3 100644 --- a/src/relay/agent-hook-server.ts +++ b/src/relay/agent-hook-server.ts @@ -58,7 +58,24 @@ function defaultEndpointDir(): string { return join(homedir(), RELAY_HOOKS_DIR_NAME, RELAY_HOOKS_SUBDIR) } +function isWindowsNamedPipePath(sockPath: string): boolean { + return /^\\\\[.?]\\pipe\\/i.test(sockPath) +} + +function windowsNamedPipeEndpointName(sockPath: string): string { + return ( + sockPath + .replace(/^\\\\[.?]\\pipe\\/i, '') + .split(/[\\/]/) + .filter(Boolean) + .pop() ?? 'relay' + ) +} + export function endpointDirForRelaySocket(sockPath: string): string { + if (isWindowsNamedPipePath(sockPath)) { + return join(defaultEndpointDir(), windowsNamedPipeEndpointName(sockPath)) + } return join(dirname(sockPath), RELAY_HOOKS_SUBDIR, basename(sockPath)) } diff --git a/src/relay/context.test.ts b/src/relay/context.test.ts new file mode 100644 index 00000000000..68adda98377 --- /dev/null +++ b/src/relay/context.test.ts @@ -0,0 +1,14 @@ +import { homedir } from 'os' +import { resolve } from 'path' +import { describe, expect, it } from 'vitest' +import { expandTilde } from './context' + +describe('expandTilde', () => { + it('expands POSIX-style home paths', () => { + expect(expandTilde('~/projects')).toBe(resolve(homedir(), 'projects')) + }) + + it('expands Windows-style home paths without forcing POSIX separators', () => { + expect(expandTilde('~\\projects')).toBe(`${homedir()}\\projects`) + }) +}) diff --git a/src/relay/context.ts b/src/relay/context.ts index 856ad382a27..7d07c9278b9 100644 --- a/src/relay/context.ts +++ b/src/relay/context.ts @@ -5,12 +5,15 @@ import { homedir } from 'os' // have been stored with `~` or `~/…` paths before the client-side fix, so the // relay must expand them to absolute paths as a safety net. export function expandTilde(p: string): string { - if (p === '~' || p === '~/') { + if (p === '~' || p === '~/' || p === '~\\') { return homedir() } if (p.startsWith('~/')) { return resolve(homedir(), p.slice(2)) } + if (p.startsWith('~\\')) { + return `${homedir()}\\${p.slice(2)}` + } return p } diff --git a/src/relay/port-scan-handler.test.ts b/src/relay/port-scan-handler.test.ts index d366b0bc3f6..a8648cf281a 100644 --- a/src/relay/port-scan-handler.test.ts +++ b/src/relay/port-scan-handler.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { parseHexAddress } from './port-scan-handler' +import { parseWindowsNetstatOutput, parseWindowsPowerShellPortRows } from './windows-port-scan' describe('parseHexAddress', () => { it('parses IPv4 localhost (127.0.0.1)', () => { @@ -66,3 +67,55 @@ describe('parseHexAddress', () => { expect(result).toEqual({ host: '0.0.0.0', port: 3306 }) }) }) + +describe('parseWindowsPowerShellPortRows', () => { + it('parses PowerShell JSON arrays', () => { + expect( + parseWindowsPowerShellPortRows( + JSON.stringify([ + { host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' }, + { host: '0.0.0.0', port: 8080, pid: 5678, processName: 'dotnet' } + ]) + ) + ).toEqual([ + { host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' }, + { host: '0.0.0.0', port: 8080, pid: 5678, processName: 'dotnet' } + ]) + }) + + it('parses single-object PowerShell JSON', () => { + expect( + parseWindowsPowerShellPortRows( + JSON.stringify({ host: '::1', port: '3000', pid: '4321', processName: 'node' }) + ) + ).toEqual([{ host: '::1', port: 3000, pid: 4321, processName: 'node' }]) + }) + + it('ignores malformed rows', () => { + expect( + parseWindowsPowerShellPortRows( + JSON.stringify([ + { host: '127.0.0.1', port: 5173, pid: 1234 }, + { host: '127.0.0.1', port: 'nan', pid: 1234 }, + { port: 8080, pid: 5678 } + ]) + ) + ).toEqual([{ host: '127.0.0.1', port: 5173, pid: 1234 }]) + }) +}) + +describe('parseWindowsNetstatOutput', () => { + it('parses Windows netstat listening rows', () => { + const output = [ + ' Proto Local Address Foreign Address State PID', + ' TCP 0.0.0.0:5173 0.0.0.0:0 LISTENING 1234', + ' TCP 127.0.0.1:9229 0.0.0.0:0 ESTABLISHED 1234', + ' TCP [::1]:3000 [::]:0 LISTENING 5678' + ].join('\r\n') + + expect(parseWindowsNetstatOutput(output)).toEqual([ + { host: '0.0.0.0', port: 5173, pid: 1234 }, + { host: '::1', port: 3000, pid: 5678 } + ]) + }) +}) diff --git a/src/relay/port-scan-handler.ts b/src/relay/port-scan-handler.ts index 79541c33e26..c347b6bacf8 100644 --- a/src/relay/port-scan-handler.ts +++ b/src/relay/port-scan-handler.ts @@ -1,5 +1,6 @@ import { readFile, readdir, readlink } from 'fs/promises' -import type { RelayDispatcher } from './dispatcher' +import type { RelayDispatcher, RequestContext } from './dispatcher' +import { scanWindowsListeningPorts } from './windows-port-scan' // Keep in sync with src/shared/ssh-types.ts — DetectedPort export type DetectedPort = { @@ -15,18 +16,27 @@ const MAX_DETECTED_PORTS = 50 export class PortScanHandler { constructor(dispatcher: RelayDispatcher) { - dispatcher.onRequest('ports.detect', async () => { - if (process.platform !== 'linux') { - return { ports: [], platform: process.platform } + dispatcher.onRequest('ports.detect', async (_params, context: RequestContext) => { + if (process.platform === 'linux') { + return { + ports: await this.scanLinuxListeningPorts(), + platform: process.platform + } + } + if (process.platform === 'win32') { + return { + ports: await scanWindowsListeningPorts(context.signal), + platform: process.platform + } } return { - ports: await this.scanListeningPorts(), + ports: [], platform: process.platform } }) } - private async scanListeningPorts(): Promise { + private async scanLinuxListeningPorts(): Promise { const [tcp4, tcp6] = await Promise.all([ this.readProcNet('/proc/net/tcp'), this.readProcNet('/proc/net/tcp6') diff --git a/src/relay/preflight-handler.test.ts b/src/relay/preflight-handler.test.ts new file mode 100644 index 00000000000..b2dfffe8ed2 --- /dev/null +++ b/src/relay/preflight-handler.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' +import { buildCommandLookupSpec, hasAbsoluteCommandPath } from './preflight-handler' + +describe('buildCommandLookupSpec', () => { + it('uses where.exe on native Windows SSH hosts', () => { + expect(buildCommandLookupSpec('codex', 'win32')).toEqual({ + file: 'where.exe', + args: ['codex'], + windowsHide: true + }) + }) + + it('passes the command as an argument to the POSIX login-shell probe', () => { + expect(buildCommandLookupSpec('codex', 'linux')).toEqual({ + file: '/bin/sh', + args: ['-lc', 'command -v "$1"', 'sh', 'codex'] + }) + }) +}) + +describe('hasAbsoluteCommandPath', () => { + it('recognizes Windows absolute command paths', () => { + expect( + hasAbsoluteCommandPath('C:\\Users\\alice\\AppData\\Roaming\\npm\\codex.cmd\r\n', 'win32') + ).toBe(true) + }) +}) diff --git a/src/relay/preflight-handler.ts b/src/relay/preflight-handler.ts index a889d0d85ca..fb47248323d 100644 --- a/src/relay/preflight-handler.ts +++ b/src/relay/preflight-handler.ts @@ -1,10 +1,17 @@ import { execFile } from 'child_process' import { promisify } from 'util' -import path from 'path' +import path, { win32 } from 'path' import type { RelayDispatcher } from './dispatcher' +import { buildRelayCommandEnv } from './relay-command-env' const execFileAsync = promisify(execFile) +type CommandLookupSpec = { + file: string + args: string[] + windowsHide?: true +} + export class PreflightHandler { private dispatcher: RelayDispatcher @@ -40,18 +47,37 @@ export class PreflightHandler { // .zprofile/.bash_profile sourced. Running `which` directly would miss // agents installed via Homebrew, nvm, cargo, pipx, etc. Spawning a login // shell (`-lc`) ensures PATH matches what the user's PTY sessions see. + // Windows has no /bin/sh on native OpenSSH hosts, so use where.exe there. private async isCommandOnPath(command: string): Promise { try { - const { stdout } = await execFileAsync('/bin/sh', ['-lc', `which ${command}`], { + const spec = buildCommandLookupSpec(command, process.platform) + const { stdout } = await execFileAsync(spec.file, spec.args, { encoding: 'utf-8', - timeout: 5000 + env: buildRelayCommandEnv(), + timeout: 5000, + ...(spec.windowsHide ? { windowsHide: true } : {}) }) - return stdout - .split(/\r?\n/) - .map((line) => line.trim()) - .some((line) => path.isAbsolute(line)) + return hasAbsoluteCommandPath(stdout, process.platform) } catch { return false } } } + +export function buildCommandLookupSpec( + command: string, + platform: NodeJS.Platform +): CommandLookupSpec { + if (platform === 'win32') { + return { file: 'where.exe', args: [command], windowsHide: true } + } + return { file: '/bin/sh', args: ['-lc', 'command -v "$1"', 'sh', command] } +} + +export function hasAbsoluteCommandPath(output: string, platform: NodeJS.Platform): boolean { + const pathOps = platform === 'win32' ? win32 : path + return output + .split(/\r?\n/) + .map((line) => line.trim()) + .some((line) => pathOps.isAbsolute(line)) +} diff --git a/src/relay/pty-handler.ts b/src/relay/pty-handler.ts index 9384967a4db..56c4b8a4761 100644 --- a/src/relay/pty-handler.ts +++ b/src/relay/pty-handler.ts @@ -4,6 +4,7 @@ import type * as NodePty from 'node-pty' import type { RelayDispatcher, RequestContext } from './dispatcher' import { resolveDefaultShell, + resolveDefaultCwd, resolveProcessCwd, processHasChildren, getForegroundProcessName, @@ -417,7 +418,7 @@ export class PtyHandler { const cols = (params.cols as number) || 80 const rows = (params.rows as number) || 24 - const cwd = (params.cwd as string) || process.env.HOME || '/' + const cwd = (params.cwd as string) || resolveDefaultCwd() const env = params.env as Record | undefined const shell = resolveDefaultShell() const id = `pty-${this.nextId++}` diff --git a/src/relay/pty-shell-launch.test.ts b/src/relay/pty-shell-launch.test.ts index d6a942210d0..775845ca151 100644 --- a/src/relay/pty-shell-launch.test.ts +++ b/src/relay/pty-shell-launch.test.ts @@ -96,6 +96,32 @@ describe('getRelayShellLaunchConfig', () => { } ) + it('does not pass POSIX login flags to Windows shells', () => { + expect( + getRelayShellLaunchConfig('C:\\Windows\\System32\\cmd.exe', { HOME: homeDir }, 'win32') + ).toEqual({ + args: [], + env: {} + }) + expect( + getRelayShellLaunchConfig( + 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe', + { HOME: homeDir }, + 'win32' + ) + ).toEqual({ + args: ['-NoLogo'], + env: {} + }) + }) + + it('keeps PowerShell Core on POSIX remotes as a login shell', () => { + expect(getRelayShellLaunchConfig('/usr/bin/pwsh', { HOME: homeDir }, 'linux')).toEqual({ + args: ['-l'], + env: {} + }) + }) + it.skipIf(process.platform === 'win32')('rewrites stale persistent wrapper files', () => { const zshRoot = join(homeDir, '.orca-relay', 'shell-ready', 'zsh') mkdirSync(zshRoot, { recursive: true }) diff --git a/src/relay/pty-shell-launch.ts b/src/relay/pty-shell-launch.ts index 90d85e99da6..f2603d0bed4 100644 --- a/src/relay/pty-shell-launch.ts +++ b/src/relay/pty-shell-launch.ts @@ -1,6 +1,6 @@ import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'fs' import { homedir } from 'os' -import { basename, dirname, join } from 'path' +import { dirname, join } from 'path' import { getPosixOmpShellWrapper } from '../main/pty/omp-shell-wrapper' import { getZshFinalZdotdirRestoreBlock, @@ -19,6 +19,23 @@ function quotePosixSingle(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'` } +function shellBasename(shellPath: string): string { + return shellPath.replace(/\\/g, '/').split('/').pop()?.toLowerCase() ?? '' +} + +function windowsShellArgs(shellName: string): string[] | null { + if (shellName === 'powershell.exe' || shellName === 'powershell') { + return ['-NoLogo'] + } + if (shellName === 'pwsh.exe' || shellName === 'pwsh') { + return ['-NoLogo'] + } + if (shellName === 'cmd.exe' || shellName === 'cmd') { + return [] + } + return null +} + function hasOverlayRestoreEnv(env: Record): boolean { return Boolean( env.ORCA_OPENCODE_CONFIG_DIR || @@ -226,13 +243,16 @@ trap '__orca_osc133_preexec' DEBUG export function getRelayShellLaunchConfig( shellPath: string, - env: Record + env: Record, + platform: NodeJS.Platform = process.platform ): RelayShellLaunchConfig { - if (process.platform === 'win32') { - return { args: POSIX_LOGIN_ARGS, env: {} } + const shellName = shellBasename(shellPath) + if (platform === 'win32') { + // Why: pwsh also exists on POSIX remotes; Windows-specific shell args must + // only apply when the relay itself is running on native Windows. + return { args: windowsShellArgs(shellName) ?? [], env: {} } } - const shellName = basename(shellPath).toLowerCase() if (shellName !== 'zsh' && shellName !== 'bash') { return { args: POSIX_LOGIN_ARGS, env: {} } } diff --git a/src/relay/pty-shell-utils.test.ts b/src/relay/pty-shell-utils.test.ts new file mode 100644 index 00000000000..dcc8ab5901f --- /dev/null +++ b/src/relay/pty-shell-utils.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest' +import { resolveDefaultCwd, resolveWindowsDefaultShell } from './pty-shell-utils' + +describe('resolveWindowsDefaultShell', () => { + it('uses an existing SHELL override when one is provided', () => { + expect( + resolveWindowsDefaultShell( + { + SHELL: 'C:\\Tools\\pwsh.exe', + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe' + }, + (path) => path === 'C:\\Tools\\pwsh.exe' + ) + ).toBe('C:\\Tools\\pwsh.exe') + }) + + it('prefers inbox PowerShell before ComSpec for an interactive Windows PTY', () => { + const powershell = 'C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe' + + expect( + resolveWindowsDefaultShell( + { + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe' + }, + (path) => path === powershell || path === 'C:\\Windows\\System32\\cmd.exe' + ) + ).toBe(powershell) + }) + + it('falls back to ComSpec when PowerShell cannot be found by path', () => { + expect( + resolveWindowsDefaultShell( + { + SystemRoot: 'C:\\Windows', + ComSpec: 'C:\\Windows\\System32\\cmd.exe' + }, + (path) => path === 'C:\\Windows\\System32\\cmd.exe' + ) + ).toBe('C:\\Windows\\System32\\cmd.exe') + }) +}) + +describe('resolveDefaultCwd', () => { + it('uses USERPROFILE for Windows PTYs without an explicit cwd', () => { + expect( + resolveDefaultCwd( + { + USERPROFILE: 'C:\\Users\\alice', + HOME: '/not/a/windows/cwd' + }, + 'win32', + 'C:\\Users\\fallback' + ) + ).toBe('C:\\Users\\alice') + }) + + it('falls back to HOMEDRIVE plus HOMEPATH on Windows when USERPROFILE is missing', () => { + expect( + resolveDefaultCwd( + { + HOMEDRIVE: 'D:', + HOMEPATH: '\\Users\\bob' + }, + 'win32', + 'C:\\Users\\fallback' + ) + ).toBe('D:\\Users\\bob') + }) + + it('keeps POSIX HOME fallback behavior', () => { + expect(resolveDefaultCwd({ HOME: '/home/alice' }, 'linux', '/fallback')).toBe('/home/alice') + }) +}) diff --git a/src/relay/pty-shell-utils.ts b/src/relay/pty-shell-utils.ts index 72e26e1ec11..596b6e2b8ba 100644 --- a/src/relay/pty-shell-utils.ts +++ b/src/relay/pty-shell-utils.ts @@ -1,14 +1,49 @@ import { execFile as execFileCb } from 'child_process' import { existsSync, readFileSync } from 'fs' +import { homedir } from 'os' +import { win32 as pathWin32 } from 'path' import { promisify } from 'util' const execFile = promisify(execFileCb) +export function resolveWindowsDefaultShell( + env: NodeJS.ProcessEnv = process.env, + existsPath: (path: string) => boolean = existsSync +): string { + const envShell = env.SHELL + if (envShell && existsPath(envShell)) { + return envShell + } + + const systemRoot = env.SystemRoot || env.WINDIR || env.windir || 'C:\\Windows' + const windowsPowerShell = pathWin32.join( + systemRoot, + 'System32', + 'WindowsPowerShell', + 'v1.0', + 'powershell.exe' + ) + if (existsPath(windowsPowerShell)) { + return windowsPowerShell + } + + const comspec = env.ComSpec || env.COMSPEC + if (comspec && existsPath(comspec)) { + return comspec + } + + return comspec || 'powershell.exe' +} + /** * Resolve the default shell for PTY spawning. * Prefers $SHELL, then common fallbacks. */ export function resolveDefaultShell(): string { + if (process.platform === 'win32') { + return resolveWindowsDefaultShell() + } + const envShell = process.env.SHELL if (envShell && existsSync(envShell)) { return envShell @@ -22,6 +57,19 @@ export function resolveDefaultShell(): string { return '/bin/sh' } +export function resolveDefaultCwd( + env: NodeJS.ProcessEnv = process.env, + platform: NodeJS.Platform = process.platform, + homeDir = homedir() +): string { + if (platform === 'win32') { + const driveHome = env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined + return env.USERPROFILE || env.HOME || driveHome || homeDir || `${env.SystemDrive || 'C:'}\\` + } + + return env.HOME || homeDir || '/' +} + /** * Resolve the current working directory of a process by pid. * Tries /proc on Linux and lsof on macOS before falling back to `fallbackCwd`. diff --git a/src/relay/relay.ts b/src/relay/relay.ts index ae62a4fb7a8..6dbf71b7e66 100644 --- a/src/relay/relay.ts +++ b/src/relay/relay.ts @@ -87,6 +87,9 @@ function parseNonNegativeIntEnv(name: string, fallback: number): number { } function readSocketIdentity(sockPath: string): SocketIdentity | null { + if (isWindowsNamedPipePath(sockPath)) { + return null + } try { const stat = statSync(sockPath, { bigint: true }) return { dev: stat.dev, ino: stat.ino, ctimeNs: stat.ctimeNs } @@ -95,18 +98,24 @@ function readSocketIdentity(sockPath: string): SocketIdentity | null { } } +function isWindowsNamedPipePath(sockPath: string): boolean { + return process.platform === 'win32' && /^\\\\[.?]\\pipe\\/i.test(sockPath) +} + function parseArgs(argv: string[]): { graceTimeMs: number connectMode: boolean detached: boolean cliMode: boolean sockPath: string + endpointDir?: string } { let graceTimeMs = DEFAULT_GRACE_MS let connectMode = false let detached = false let cliMode = false let sockPath = '' + let endpointDir: string | undefined for (let i = 2; i < argv.length; i++) { if (argv[i] === '--grace-time' && argv[i + 1]) { const parsed = parseInt(argv[i + 1], 10) @@ -126,12 +135,15 @@ function parseArgs(argv: string[]): { } else if (argv[i] === '--sock-path' && argv[i + 1]) { sockPath = argv[i + 1] i++ + } else if (argv[i] === '--endpoint-dir' && argv[i + 1]) { + endpointDir = argv[i + 1] + i++ } } if (!sockPath) { sockPath = join(process.cwd(), SOCK_NAME) } - return { graceTimeMs, connectMode, detached, cliMode, sockPath } + return { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } } // ── Connect mode ───────────────────────────────────────────────────── @@ -299,7 +311,9 @@ function pickRemoteCliEnv(env: NodeJS.ProcessEnv): Record { // ── Normal mode ────────────────────────────────────────────────────── async function main(): Promise { - const { graceTimeMs, connectMode, detached, cliMode, sockPath } = parseArgs(process.argv) + const { graceTimeMs, connectMode, detached, cliMode, sockPath, endpointDir } = parseArgs( + process.argv + ) if (connectMode) { runConnectMode(sockPath) @@ -314,6 +328,9 @@ async function main(): Promise { let ownsSocketPath = false let ownedSocketIdentity: SocketIdentity | null = null const ownsCurrentSocketPath = (): boolean => { + if (isWindowsNamedPipePath(sockPath)) { + return ownsSocketPath + } const currentIdentity = readSocketIdentity(sockPath) return ( ownsSocketPath && @@ -453,7 +470,7 @@ async function main(): Promise { // Why: a remote account can host multiple target-specific relay daemons. // Scope endpoint.env/cmd by the daemon socket path so their hook tokens // cannot overwrite each other. - endpointDir: endpointDirForRelaySocket(sockPath), + endpointDir: endpointDir ?? endpointDirForRelaySocket(sockPath), forward: (envelope) => { // Why: dispatcher.notify is fire-and-forget — when the SSH channel is // mid-reconnect the write callback no-ops and the notification is @@ -730,10 +747,11 @@ async function main(): Promise { // created with 0o600 permissions atomically. The previous approach // (chmod after listen) had a TOCTOU window where another local user // could connect to the socket before chmod ran. - const prevUmask = process.umask(0o177) + const shouldSetSocketUmask = !isWindowsNamedPipePath(sockPath) + const prevUmask = shouldSetSocketUmask ? process.umask(0o177) : 0 let umaskRestored = false const restoreUmask = (): void => { - if (!umaskRestored) { + if (shouldSetSocketUmask && !umaskRestored) { process.umask(prevUmask) umaskRestored = true } @@ -807,6 +825,10 @@ async function main(): Promise { failInitial(err) return } + if (isWindowsNamedPipePath(sockPath)) { + failInitial(err) + return + } staleRetryAttempted = true const blockedIdentity = readSocketIdentity(sockPath) const probe = createConnection({ path: sockPath }) @@ -977,6 +999,9 @@ async function main(): Promise { } function cleanupSocket(sockPath: string): void { + if (isWindowsNamedPipePath(sockPath)) { + return + } try { if (existsSync(sockPath)) { unlinkSync(sockPath) diff --git a/src/relay/windows-port-scan.test.ts b/src/relay/windows-port-scan.test.ts new file mode 100644 index 00000000000..0abd9bf1e97 --- /dev/null +++ b/src/relay/windows-port-scan.test.ts @@ -0,0 +1,87 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { execFileAsyncMock, execFileMock, promisifyCustom } = vi.hoisted(() => ({ + execFileAsyncMock: vi.fn(), + execFileMock: vi.fn(), + promisifyCustom: Symbol.for('nodejs.util.promisify.custom') +})) + +vi.mock('child_process', () => ({ + execFile: Object.assign(execFileMock, { + [promisifyCustom]: execFileAsyncMock + }) +})) + +vi.mock('./relay-command-env', () => ({ + buildRelayCommandEnv: () => ({ PATH: 'C:\\Windows\\System32' }) +})) + +const { scanWindowsListeningPorts } = await import('./windows-port-scan') + +describe('scanWindowsListeningPorts', () => { + beforeEach(() => { + execFileAsyncMock.mockReset() + }) + + it('bounds the PowerShell scan with the caller abort signal and timeout', async () => { + const controller = new AbortController() + execFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' }), + stderr: '' + }) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ + { host: '127.0.0.1', port: 5173, pid: 1234, processName: 'node' } + ]) + + expect(execFileAsyncMock).toHaveBeenCalledWith( + 'powershell.exe', + expect.arrayContaining(['-EncodedCommand', expect.any(String)]), + expect.objectContaining({ + signal: controller.signal, + timeout: 5000, + windowsHide: true + }) + ) + }) + + it('bounds the netstat fallback with the same abort signal and timeout', async () => { + const controller = new AbortController() + execFileAsyncMock + .mockRejectedValueOnce(new Error('powershell unavailable')) + .mockRejectedValueOnce(new Error('pwsh unavailable')) + .mockResolvedValueOnce({ + stdout: [ + ' Proto Local Address Foreign Address State PID', + ' TCP 0.0.0.0:3000 0.0.0.0:0 LISTENING 2468' + ].join('\r\n'), + stderr: '' + }) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([ + { host: '0.0.0.0', port: 3000, pid: 2468 } + ]) + + expect(execFileAsyncMock).toHaveBeenLastCalledWith( + 'netstat.exe', + ['-ano', '-p', 'tcp'], + expect.objectContaining({ + signal: controller.signal, + timeout: 5000, + windowsHide: true + }) + ) + }) + + it('does not start the netstat fallback after the scan is cancelled', async () => { + const controller = new AbortController() + controller.abort() + execFileAsyncMock.mockRejectedValueOnce( + Object.assign(new Error('cancelled'), { name: 'AbortError' }) + ) + + await expect(scanWindowsListeningPorts(controller.signal)).resolves.toEqual([]) + + expect(execFileAsyncMock).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/relay/windows-port-scan.ts b/src/relay/windows-port-scan.ts new file mode 100644 index 00000000000..0c5c407b4cd --- /dev/null +++ b/src/relay/windows-port-scan.ts @@ -0,0 +1,204 @@ +import { execFile } from 'child_process' +import { promisify } from 'util' +import { encodePowerShellCommand } from '../shared/powershell-command-encoding' +import type { DetectedPort } from './port-scan-handler' +import { buildRelayCommandEnv } from './relay-command-env' + +const SYSTEM_PORTS_TO_EXCLUDE = new Set([22]) +const MAX_DETECTED_PORTS = 50 +const WINDOWS_PORT_SCAN_TIMEOUT_MS = 5_000 +const execFileAsync = promisify(execFile) + +export async function scanWindowsListeningPorts(signal?: AbortSignal): Promise { + try { + const json = await runWindowsPortScanPowerShell(signal) + return normalizeWindowsDetectedPorts(parseWindowsPowerShellPortRows(json)) + } catch { + if (signal?.aborted) { + return [] + } + try { + const { stdout } = await execFileAsync('netstat.exe', ['-ano', '-p', 'tcp'], { + env: buildRelayCommandEnv(), + encoding: 'utf-8', + signal, + timeout: WINDOWS_PORT_SCAN_TIMEOUT_MS, + windowsHide: true + }) + return normalizeWindowsDetectedPorts(parseWindowsNetstatOutput(stdout)) + } catch { + return [] + } + } +} + +async function runWindowsPortScanPowerShell(signal?: AbortSignal): Promise { + const script = [ + "$ErrorActionPreference = 'Stop'", + '$connections = Get-NetTCPConnection -State Listen -ErrorAction Stop', + '$items = foreach ($connection in $connections) {', + ' $name = $null', + ' try {', + ' $process = Get-Process -Id $connection.OwningProcess -ErrorAction Stop', + ' $name = $process.ProcessName', + ' } catch {}', + ' [pscustomobject]@{', + ' host = [string]$connection.LocalAddress', + ' port = [int]$connection.LocalPort', + ' pid = [int]$connection.OwningProcess', + ' processName = $name', + ' }', + '}', + '$items | ConvertTo-Json -Compress -Depth 3' + ].join('\n') + const encoded = encodePowerShellCommand(script) + const lastError: unknown[] = [] + + for (const binary of ['powershell.exe', 'pwsh.exe']) { + try { + const { stdout } = await execFileAsync( + binary, + ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-EncodedCommand', encoded], + { + env: buildRelayCommandEnv(), + encoding: 'utf-8', + maxBuffer: 1024 * 1024, + signal, + timeout: WINDOWS_PORT_SCAN_TIMEOUT_MS, + windowsHide: true + } + ) + return stdout + } catch (error) { + if (signal?.aborted) { + throw error + } + lastError.push(error) + } + } + + throw lastError[0] ?? new Error('PowerShell unavailable') +} + +export function parseWindowsPowerShellPortRows(json: string): DetectedPort[] { + const trimmed = json.trim() + if (!trimmed) { + return [] + } + + let parsed: unknown + try { + parsed = JSON.parse(trimmed) + } catch { + return [] + } + + const rows = Array.isArray(parsed) ? parsed : [parsed] + return rows.flatMap((row) => parseWindowsPortRow(row)) +} + +export function parseWindowsNetstatOutput(output: string): DetectedPort[] { + const rows: DetectedPort[] = [] + + for (const line of output.split(/\r?\n/)) { + const fields = line.trim().split(/\s+/) + if (fields.length < 5 || fields[0].toUpperCase() !== 'TCP') { + continue + } + if (fields[3].toUpperCase() !== 'LISTENING') { + continue + } + const hostPort = parseWindowsNetstatAddress(fields[1]) + const pid = Number.parseInt(fields[4], 10) + if (!hostPort || !Number.isSafeInteger(pid) || pid <= 0) { + continue + } + rows.push({ ...hostPort, pid }) + } + + return rows +} + +function parseWindowsPortRow(row: unknown): DetectedPort[] { + if (!row || typeof row !== 'object') { + return [] + } + const value = row as { + host?: unknown + LocalAddress?: unknown + port?: unknown + LocalPort?: unknown + pid?: unknown + OwningProcess?: unknown + processName?: unknown + ProcessName?: unknown + } + const host = readString(value.host ?? value.LocalAddress) + const port = readInteger(value.port ?? value.LocalPort) + const pid = readInteger(value.pid ?? value.OwningProcess) + const processName = readString(value.processName ?? value.ProcessName) + if (!host || port == null || pid == null) { + return [] + } + return [ + { + host, + port, + pid, + ...(processName ? { processName } : {}) + } + ] +} + +function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined +} + +function readInteger(value: unknown): number | undefined { + const parsed = + typeof value === 'number' ? value : typeof value === 'string' ? Number.parseInt(value, 10) : NaN + return Number.isSafeInteger(parsed) ? parsed : undefined +} + +function parseWindowsNetstatAddress(value: string): { host: string; port: number } | null { + const ipv6Match = /^\[(.*)\]:(\d+)$/.exec(value) + const portText = ipv6Match?.[2] ?? value.slice(value.lastIndexOf(':') + 1) + const port = Number.parseInt(portText, 10) + if (!Number.isSafeInteger(port) || port <= 0) { + return null + } + if (ipv6Match) { + return { host: ipv6Match[1], port } + } + const idx = value.lastIndexOf(':') + if (idx <= 0) { + return null + } + return { host: value.slice(0, idx), port } +} + +function normalizeWindowsDetectedPorts(ports: DetectedPort[]): DetectedPort[] { + const seen = new Set() + const relayPid = process.pid + const relayParentPid = process.ppid + const normalized: DetectedPort[] = [] + + for (const port of ports) { + const processName = port.processName?.toLowerCase() + const key = `${port.host}:${port.port}:${port.pid ?? ''}` + if ( + seen.has(key) || + SYSTEM_PORTS_TO_EXCLUDE.has(port.port) || + port.pid === relayPid || + port.pid === relayParentPid || + processName === 'sshd' + ) { + continue + } + seen.add(key) + normalized.push(port) + } + + normalized.sort((a, b) => a.port - b.port || a.host.localeCompare(b.host)) + return normalized.slice(0, MAX_DETECTED_PORTS) +} diff --git a/src/shared/powershell-command-encoding.ts b/src/shared/powershell-command-encoding.ts new file mode 100644 index 00000000000..3d8e36aa470 --- /dev/null +++ b/src/shared/powershell-command-encoding.ts @@ -0,0 +1,3 @@ +export function encodePowerShellCommand(command: string): string { + return Buffer.from(command, 'utf16le').toString('base64') +} diff --git a/src/shared/ssh-types.ts b/src/shared/ssh-types.ts index c5f0957a678..2d7b57af3e5 100644 --- a/src/shared/ssh-types.ts +++ b/src/shared/ssh-types.ts @@ -101,7 +101,7 @@ export type PortForwardEntry = { advertisedProtocol?: 'http' | 'https' } -/** A listening port detected on the remote host via /proc/net/tcp scanning. +/** A listening port detected on the remote host by the relay. * Keep in sync with src/relay/port-scan-handler.ts — DetectedPort. * The relay is deployed as a standalone bundle and cannot import from shared. */ export type DetectedPort = {