fix: support windows ssh hosts (#5004)

* feat: add windows ssh relay base support

* feat: support windows ssh relay runtime services

* fix: default windows ssh pty cwd to user profile

* fix: support windows hosts over system ssh

* fix: preserve degraded windows relay native deps

* fix: gate windows shell args by relay platform

* fix: preserve windows relay fallback pipes

* test: align windows native deps relay fixture

* fix: build valid windows install lock command

* fix: address windows SSH relay review findings

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

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

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

* Support cancellation and timeouts in Windows port scanning

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

---------

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