mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
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:
co-authored by
Claude Opus 4.8
Jinjing
parent
ce66cee37b
commit
98d02bca47
@@ -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'
|
||||
|
||||
|
||||
@@ -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 +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' })
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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, '""')}"`
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
}))
|
||||
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -15,7 +15,27 @@ import { existsSync, readFileSync } from 'fs'
|
||||
import type { SshConnection } from './ssh-connection'
|
||||
import { RELAY_REMOTE_DIR } from './relay-protocol'
|
||||
import { execCommand } from './ssh-relay-deploy-helpers'
|
||||
import { shellEscape } from './ssh-connection-utils'
|
||||
import {
|
||||
acquireInstallLockParentCommand,
|
||||
listRelayBaseDirsCommand,
|
||||
lockMtimeEpochCommand,
|
||||
probeDirectoryExistsCommand,
|
||||
probeFileExistsCommand,
|
||||
probeRelayInstalledCommand,
|
||||
relayLivenessProbeCommand,
|
||||
removeRemoteTreeCommand,
|
||||
tryCreateInstallLockCommand,
|
||||
writeRemoteEmptyFileCommand
|
||||
} from './ssh-remote-commands'
|
||||
import {
|
||||
getRemoteHostPlatform,
|
||||
isWindowsRemoteHost,
|
||||
joinRemotePath,
|
||||
remoteBasename,
|
||||
type RemoteHostPlatform,
|
||||
type RemotePathFlavor
|
||||
} from './ssh-remote-platform'
|
||||
import { windowsRelayPipePathsForSocketName } from './ssh-relay-endpoints'
|
||||
|
||||
// Why: the GC pass and the version-dir parser must agree on what counts as a
|
||||
// relay install dir. Single source of truth for both. The pattern matches the
|
||||
@@ -42,6 +62,15 @@ const INSTALL_LOCK_TIMEOUT_MS = 120_000
|
||||
// (10–60s on slow hosts) so a slow concurrent installer is not falsely
|
||||
// declared dead.
|
||||
const INSTALL_LOCK_STALE_MS = 120_000
|
||||
const DEFAULT_REMOTE_HOST = getRemoteHostPlatform('linux-x64')
|
||||
|
||||
function execHostCommand(
|
||||
conn: SshConnection,
|
||||
host: RemoteHostPlatform,
|
||||
command: string
|
||||
): Promise<string> {
|
||||
return execCommand(conn, command, { wrapCommand: host.commandDialect !== 'powershell' })
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the local relay's content-hashed version (e.g. "0.1.0+0a5fe134d020")
|
||||
@@ -72,8 +101,16 @@ export function readLocalFullVersion(localRelayDir: string): string {
|
||||
* Compute the absolute remote install directory for a given content-hashed
|
||||
* version. The format is `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`.
|
||||
*/
|
||||
export function computeRemoteRelayDir(remoteHome: string, fullVersion: string): string {
|
||||
return `${remoteHome}/${RELAY_REMOTE_DIR}/relay-${fullVersion}`
|
||||
export function computeRemoteRelayDir(
|
||||
remoteHome: string,
|
||||
fullVersion: string,
|
||||
pathFlavor: RemotePathFlavor = 'posix'
|
||||
): string {
|
||||
const host =
|
||||
pathFlavor === 'windows'
|
||||
? getRemoteHostPlatform('win32-x64')
|
||||
: getRemoteHostPlatform('linux-x64')
|
||||
return joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR, `relay-${fullVersion}`)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -86,15 +123,14 @@ export function computeRemoteRelayDir(remoteHome: string, fullVersion: string):
|
||||
*/
|
||||
export async function isRelayAlreadyInstalled(
|
||||
conn: SshConnection,
|
||||
remoteRelayDir: string
|
||||
remoteRelayDir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const probe = await execCommand(
|
||||
const probe = await execHostCommand(
|
||||
conn,
|
||||
`test -d ${shellEscape(remoteRelayDir)} ` +
|
||||
`&& test -f ${shellEscape(`${remoteRelayDir}/relay.js`)} ` +
|
||||
`&& test -f ${shellEscape(`${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`)} ` +
|
||||
`&& echo OK || echo MISSING`
|
||||
host,
|
||||
probeRelayInstalledCommand(host, remoteRelayDir)
|
||||
)
|
||||
return probe.trim() === 'OK'
|
||||
} catch {
|
||||
@@ -113,21 +149,19 @@ export async function isRelayAlreadyInstalled(
|
||||
*/
|
||||
export async function acquireInstallLock(
|
||||
conn: SshConnection,
|
||||
remoteRelayDir: string
|
||||
remoteRelayDir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
|
||||
): Promise<void> {
|
||||
const lockDir = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
|
||||
const lockDir = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
|
||||
// Why: the parent dir may not exist yet on a first install. mkdir -p is
|
||||
// safe to run multiple times — it's a no-op if the dir already exists.
|
||||
await execCommand(conn, `mkdir -p ${shellEscape(remoteRelayDir)}`)
|
||||
await execHostCommand(conn, host, acquireInstallLockParentCommand(host, remoteRelayDir))
|
||||
|
||||
let start = Date.now()
|
||||
let recoveredOnce = false
|
||||
while (true) {
|
||||
try {
|
||||
const result = await execCommand(
|
||||
conn,
|
||||
`mkdir ${shellEscape(lockDir)} 2>&1 && echo OK || echo BUSY`
|
||||
)
|
||||
const result = await execHostCommand(conn, host, tryCreateInstallLockCommand(host, lockDir))
|
||||
if (result.trim().endsWith('OK')) {
|
||||
return
|
||||
}
|
||||
@@ -146,10 +180,10 @@ export async function acquireInstallLock(
|
||||
// window, the previous installer crashed. Steal it and retry once,
|
||||
// resetting the timeout window so a single post-recovery race doesn't
|
||||
// immediately exhaust the budget.
|
||||
const ageOk = await isLockStale(conn, lockDir)
|
||||
const ageOk = await isLockStale(conn, lockDir, host)
|
||||
if (ageOk) {
|
||||
console.warn(`[ssh-relay] Stealing stale install lock at ${lockDir}`)
|
||||
await execCommand(conn, `rm -rf ${shellEscape(lockDir)}`).catch(() => {})
|
||||
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lockDir)).catch(() => {})
|
||||
recoveredOnce = true
|
||||
start = Date.now()
|
||||
continue
|
||||
@@ -164,15 +198,16 @@ export async function acquireInstallLock(
|
||||
}
|
||||
}
|
||||
|
||||
async function isLockStale(conn: SshConnection, lockDir: string): Promise<boolean> {
|
||||
async function isLockStale(
|
||||
conn: SshConnection,
|
||||
lockDir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Why: `stat` flags differ between GNU coreutils (Linux) and BSD (macOS).
|
||||
// We try GNU first, then BSD; both produce a Unix epoch in seconds on
|
||||
// stdout. If both fail we conservatively treat the lock as not stale.
|
||||
const out = await execCommand(
|
||||
conn,
|
||||
`stat -c %Y ${shellEscape(lockDir)} 2>/dev/null || stat -f %m ${shellEscape(lockDir)} 2>/dev/null || echo`
|
||||
)
|
||||
const out = await execHostCommand(conn, host, lockMtimeEpochCommand(host, lockDir))
|
||||
const mtimeSec = parseInt(out.trim(), 10)
|
||||
if (!Number.isFinite(mtimeSec)) {
|
||||
return false
|
||||
@@ -190,11 +225,15 @@ async function isLockStale(conn: SshConnection, lockDir: string): Promise<boolea
|
||||
* a sibling dir is never observed by GC as "complete but locked", which
|
||||
* would lead GC to skip a recoverable dir indefinitely.
|
||||
*/
|
||||
export async function finalizeInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
|
||||
const sentinel = `${remoteRelayDir}/${INSTALL_COMPLETE_NAME}`
|
||||
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
|
||||
await execCommand(conn, `touch ${shellEscape(sentinel)}`)
|
||||
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
|
||||
export async function finalizeInstall(
|
||||
conn: SshConnection,
|
||||
remoteRelayDir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
|
||||
): Promise<void> {
|
||||
const sentinel = joinRemotePath(host, remoteRelayDir, INSTALL_COMPLETE_NAME)
|
||||
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
|
||||
await execHostCommand(conn, host, writeRemoteEmptyFileCommand(host, sentinel))
|
||||
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -202,9 +241,13 @@ export async function finalizeInstall(conn: SshConnection, remoteRelayDir: strin
|
||||
* from the failure path so the dir remains a recoverable partial that the
|
||||
* next deploy detects (alreadyInstalled=false) and re-runs upload+install.
|
||||
*/
|
||||
export async function abandonInstall(conn: SshConnection, remoteRelayDir: string): Promise<void> {
|
||||
const lock = `${remoteRelayDir}/${INSTALL_LOCK_NAME}`
|
||||
await execCommand(conn, `rm -rf ${shellEscape(lock)}`).catch(() => {})
|
||||
export async function abandonInstall(
|
||||
conn: SshConnection,
|
||||
remoteRelayDir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST
|
||||
): Promise<void> {
|
||||
const lock = joinRemotePath(host, remoteRelayDir, INSTALL_LOCK_NAME)
|
||||
await execHostCommand(conn, host, removeRemoteTreeCommand(host, lock)).catch(() => {})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,13 +266,18 @@ export async function abandonInstall(conn: SshConnection, remoteRelayDir: string
|
||||
export async function gcOldRelayVersions(
|
||||
conn: SshConnection,
|
||||
remoteHome: string,
|
||||
currentDirAbsPath: string
|
||||
currentDirAbsPath: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
|
||||
options?: {
|
||||
windowsNodePath?: string
|
||||
windowsSockNames?: string[]
|
||||
}
|
||||
): Promise<void> {
|
||||
const baseDir = `${remoteHome}/${RELAY_REMOTE_DIR}`
|
||||
const currentDirName = currentDirAbsPath.split('/').filter(Boolean).pop() ?? ''
|
||||
const baseDir = joinRemotePath(host, remoteHome, RELAY_REMOTE_DIR)
|
||||
const currentDirName = remoteBasename(currentDirAbsPath, host)
|
||||
let listing: string
|
||||
try {
|
||||
listing = await execCommand(conn, `ls -1 ${shellEscape(baseDir)} 2>/dev/null || true`)
|
||||
listing = await execHostCommand(conn, host, listRelayBaseDirsCommand(host, baseDir))
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
@@ -247,14 +295,14 @@ export async function gcOldRelayVersions(
|
||||
const removed: string[] = []
|
||||
const kept: string[] = []
|
||||
for (const name of candidates) {
|
||||
const dir = `${baseDir}/${name}`
|
||||
const dir = joinRemotePath(host, baseDir, name)
|
||||
try {
|
||||
const safe = await isCandidateSafeToRemove(conn, dir, name)
|
||||
const safe = await isCandidateSafeToRemove(conn, dir, name, host, options)
|
||||
if (!safe) {
|
||||
kept.push(name)
|
||||
continue
|
||||
}
|
||||
await execCommand(conn, `rm -rf ${shellEscape(dir)}`)
|
||||
await execHostCommand(conn, host, removeRemoteTreeCommand(host, dir))
|
||||
removed.push(name)
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
@@ -275,13 +323,20 @@ export async function gcOldRelayVersions(
|
||||
async function isCandidateSafeToRemove(
|
||||
conn: SshConnection,
|
||||
dir: string,
|
||||
name: string
|
||||
name: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
|
||||
options?: {
|
||||
windowsNodePath?: string
|
||||
windowsSockNames?: string[]
|
||||
}
|
||||
): Promise<boolean> {
|
||||
const isLegacy = LEGACY_RELAY_DIR_REGEX.test(name)
|
||||
|
||||
const lockProbe = await execCommand(
|
||||
const lockDir = joinRemotePath(host, dir, INSTALL_LOCK_NAME)
|
||||
const lockProbe = await execHostCommand(
|
||||
conn,
|
||||
`test -d ${shellEscape(`${dir}/${INSTALL_LOCK_NAME}`)} && echo LOCKED || echo OPEN`
|
||||
host,
|
||||
probeDirectoryExistsCommand(host, lockDir)
|
||||
).catch(() => 'OPEN')
|
||||
const locked = lockProbe.trim() === 'LOCKED'
|
||||
|
||||
@@ -293,8 +348,7 @@ async function isCandidateSafeToRemove(
|
||||
// end of finalizeInstall failed), removing the dir is safe — no
|
||||
// installer is racing us, and the daemon (if any) keeps running off
|
||||
// its already-loaded code regardless of disk state.
|
||||
const lockDir = `${dir}/${INSTALL_LOCK_NAME}`
|
||||
if (!(await isLockStale(conn, lockDir))) {
|
||||
if (!(await isLockStale(conn, lockDir, host))) {
|
||||
return false
|
||||
}
|
||||
process.stderr.write?.(`[ssh-relay] GC: lock at ${lockDir} is stale; treating as recoverable\n`)
|
||||
@@ -304,9 +358,11 @@ async function isCandidateSafeToRemove(
|
||||
// check for them and rely solely on the live-socket probe — that's the
|
||||
// only signal we have that a legacy daemon is still serving clients.
|
||||
if (!isLegacy) {
|
||||
const completeProbe = await execCommand(
|
||||
const completePath = joinRemotePath(host, dir, INSTALL_COMPLETE_NAME)
|
||||
const completeProbe = await execHostCommand(
|
||||
conn,
|
||||
`test -f ${shellEscape(`${dir}/${INSTALL_COMPLETE_NAME}`)} && echo COMPLETE || echo PARTIAL`
|
||||
host,
|
||||
probeFileExistsCommand(host, completePath)
|
||||
).catch(() => 'PARTIAL')
|
||||
if (completeProbe.trim() !== 'COMPLETE') {
|
||||
// Crashed-install partial; leave for the next deploy to recover.
|
||||
@@ -314,24 +370,40 @@ async function isCandidateSafeToRemove(
|
||||
}
|
||||
}
|
||||
|
||||
const sockAlive = await hasLiveRelaySocket(conn, dir)
|
||||
const sockAlive = await hasLiveRelaySocket(conn, dir, host, options)
|
||||
if (sockAlive) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
async function hasLiveRelaySocket(conn: SshConnection, dir: string): Promise<boolean> {
|
||||
async function hasLiveRelaySocket(
|
||||
conn: SshConnection,
|
||||
dir: string,
|
||||
host: RemoteHostPlatform = DEFAULT_REMOTE_HOST,
|
||||
options?: {
|
||||
windowsNodePath?: string
|
||||
windowsSockNames?: string[]
|
||||
}
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
// Why: `ls -1 dir/relay-*.sock 2>/dev/null` lists socket files. For each,
|
||||
// we test -S to confirm it's a socket inode. We do NOT attempt to open
|
||||
// the socket here — `test -S` is sufficient for the GC decision and a
|
||||
// connect-and-close probe would race with a daemon that's about to idle.
|
||||
const out = await execCommand(
|
||||
const windowsOptions =
|
||||
isWindowsRemoteHost(host) && options?.windowsNodePath
|
||||
? {
|
||||
nodePath: options.windowsNodePath,
|
||||
pipePaths: (options.windowsSockNames ?? []).flatMap((sockName) =>
|
||||
windowsRelayPipePathsForSocketName(host, dir, sockName)
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
const out = await execHostCommand(
|
||||
conn,
|
||||
`for f in ${shellEscape(dir)}/relay-*.sock ${shellEscape(dir)}/relay.sock; do ` +
|
||||
`[ -S "$f" ] && echo ALIVE && break; ` +
|
||||
`done; true`
|
||||
host,
|
||||
relayLivenessProbeCommand(host, dir, windowsOptions)
|
||||
)
|
||||
return out.includes('ALIVE')
|
||||
} catch {
|
||||
|
||||
@@ -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'"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}')
|
||||
})
|
||||
})
|
||||
@@ -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 || '/'
|
||||
}
|
||||
@@ -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)}`
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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`)
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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++}`
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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: {} }
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user