mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
282 lines
10 KiB
TypeScript
282 lines
10 KiB
TypeScript
import { join } from 'path'
|
|
import { existsSync } from 'fs'
|
|
import { app } from 'electron'
|
|
import type { SshConnection } from './ssh-connection'
|
|
import {
|
|
RELAY_VERSION,
|
|
RELAY_REMOTE_DIR,
|
|
parseUnameToRelayPlatform,
|
|
type RelayPlatform
|
|
} from './relay-protocol'
|
|
import type { MultiplexerTransport } from './ssh-channel-multiplexer'
|
|
import {
|
|
uploadDirectory,
|
|
waitForSentinel,
|
|
execCommand,
|
|
resolveRemoteNodePath
|
|
} from './ssh-relay-deploy-helpers'
|
|
import { shellEscape } from './ssh-connection-utils'
|
|
|
|
export type RelayDeployResult = {
|
|
transport: MultiplexerTransport
|
|
platform: RelayPlatform
|
|
}
|
|
|
|
// Why: individual exec commands have 30s timeouts, but the full deploy
|
|
// pipeline (detect platform → check existing → upload → npm install →
|
|
// launch) has no overall bound. A hanging `npm install` or slow SFTP
|
|
// upload could block the connection indefinitely.
|
|
const RELAY_DEPLOY_TIMEOUT_MS = 120_000
|
|
|
|
/**
|
|
* Deploy the relay to the remote host and launch it.
|
|
*
|
|
* Steps:
|
|
* 1. Detect remote OS/arch via `uname -sm`
|
|
* 2. Check if correct relay version is already deployed
|
|
* 3. If not, SCP the relay package
|
|
* 4. Launch relay via exec channel
|
|
* 5. Wait for ORCA-RELAY sentinel on stdout
|
|
* 6. Return the transport (relay's stdin/stdout) for multiplexer use
|
|
*/
|
|
export async function deployAndLaunchRelay(
|
|
conn: SshConnection,
|
|
onProgress?: (status: string) => void
|
|
): Promise<RelayDeployResult> {
|
|
let timeoutHandle: ReturnType<typeof setTimeout>
|
|
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
timeoutHandle = setTimeout(() => {
|
|
reject(new Error(`Relay deployment timed out after ${RELAY_DEPLOY_TIMEOUT_MS / 1000}s`))
|
|
}, RELAY_DEPLOY_TIMEOUT_MS)
|
|
})
|
|
|
|
try {
|
|
return await Promise.race([deployAndLaunchRelayInner(conn, onProgress), timeoutPromise])
|
|
} finally {
|
|
clearTimeout(timeoutHandle!)
|
|
}
|
|
}
|
|
|
|
async function deployAndLaunchRelayInner(
|
|
conn: SshConnection,
|
|
onProgress?: (status: string) => void
|
|
): Promise<RelayDeployResult> {
|
|
onProgress?.('Detecting remote platform...')
|
|
console.log('[ssh-relay] Detecting remote platform...')
|
|
const platform = await detectRemotePlatform(conn)
|
|
if (!platform) {
|
|
throw new Error(
|
|
'Unsupported remote platform. Orca relay supports: linux-x64, linux-arm64, darwin-x64, darwin-arm64.'
|
|
)
|
|
}
|
|
console.log(`[ssh-relay] Platform: ${platform}`)
|
|
|
|
// 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: 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)}`)
|
|
}
|
|
const remoteRelayDir = `${remoteHome}/${RELAY_REMOTE_DIR}/relay-v${RELAY_VERSION}`
|
|
console.log(`[ssh-relay] Remote dir: ${remoteRelayDir}`)
|
|
|
|
onProgress?.('Checking existing relay...')
|
|
const localRelayDir = getLocalRelayPath(platform)
|
|
const alreadyDeployed = await checkRelayExists(conn, remoteRelayDir, localRelayDir)
|
|
console.log(`[ssh-relay] Already deployed: ${alreadyDeployed}`)
|
|
|
|
if (!alreadyDeployed) {
|
|
onProgress?.('Uploading relay...')
|
|
console.log('[ssh-relay] Uploading relay...')
|
|
await uploadRelay(conn, platform, remoteRelayDir)
|
|
console.log('[ssh-relay] Upload complete')
|
|
|
|
onProgress?.('Installing native dependencies...')
|
|
console.log('[ssh-relay] Installing node-pty...')
|
|
await installNativeDeps(conn, remoteRelayDir)
|
|
console.log('[ssh-relay] Native deps installed')
|
|
}
|
|
|
|
onProgress?.('Starting relay...')
|
|
console.log('[ssh-relay] Launching relay...')
|
|
const transport = await launchRelay(conn, remoteRelayDir)
|
|
console.log('[ssh-relay] Relay started successfully')
|
|
|
|
return { transport, platform }
|
|
}
|
|
|
|
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 checkRelayExists(
|
|
conn: SshConnection,
|
|
remoteDir: string,
|
|
localRelayDir: string | null
|
|
): Promise<boolean> {
|
|
try {
|
|
const output = await execCommand(
|
|
conn,
|
|
`test -f ${shellEscape(`${remoteDir}/relay.js`)} && echo OK || echo MISSING`
|
|
)
|
|
if (output.trim() !== 'OK') {
|
|
return false
|
|
}
|
|
|
|
// Why: compare against the local .version file content (which includes a
|
|
// content hash) so any code change triggers re-deploy, even without bumping
|
|
// RELAY_VERSION. Falls back to the bare RELAY_VERSION for safety.
|
|
let expectedVersion = RELAY_VERSION
|
|
if (localRelayDir) {
|
|
try {
|
|
const { readFileSync } = await import('fs')
|
|
expectedVersion = readFileSync(join(localRelayDir, '.version'), 'utf-8').trim()
|
|
} catch {
|
|
/* fall back to RELAY_VERSION */
|
|
}
|
|
}
|
|
|
|
const versionOutput = await execCommand(
|
|
conn,
|
|
`cat ${shellEscape(`${remoteDir}/.version`)} 2>/dev/null || echo MISSING`
|
|
)
|
|
return versionOutput.trim() === expectedVersion
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
async function uploadRelay(
|
|
conn: SshConnection,
|
|
platform: RelayPlatform,
|
|
remoteDir: string
|
|
): Promise<void> {
|
|
const localRelayDir = getLocalRelayPath(platform)
|
|
if (!localRelayDir || !existsSync(localRelayDir)) {
|
|
throw new Error(
|
|
`Relay package for ${platform} not found at ${localRelayDir}. ` +
|
|
`This may be a packaging issue — try reinstalling Orca.`
|
|
)
|
|
}
|
|
|
|
// Create remote directory
|
|
await execCommand(conn, `mkdir -p ${shellEscape(remoteDir)}`)
|
|
|
|
// Upload via SFTP
|
|
const sftp = await conn.sftp()
|
|
|
|
try {
|
|
await uploadDirectory(sftp, localRelayDir, remoteDir)
|
|
} finally {
|
|
sftp.end()
|
|
}
|
|
|
|
// Make the node binary executable
|
|
await execCommand(conn, `chmod +x ${shellEscape(`${remoteDir}/node`)} 2>/dev/null; true`)
|
|
|
|
// Why: version marker includes a content hash so code changes trigger
|
|
// re-deploy even without bumping RELAY_VERSION. Read from the local build
|
|
// output so the remote marker matches exactly what checkRelayExists expects.
|
|
// Why: we write the version file via SFTP instead of a shell command to
|
|
// avoid shell injection — the version string could contain characters
|
|
// that break or escape single-quoted shell interpolation.
|
|
let versionString = RELAY_VERSION
|
|
const localVersionFile = join(localRelayDir, '.version')
|
|
if (existsSync(localVersionFile)) {
|
|
const { readFileSync } = await import('fs')
|
|
versionString = readFileSync(localVersionFile, 'utf-8').trim()
|
|
}
|
|
const versionSftp = await conn.sftp()
|
|
try {
|
|
await new Promise<void>((resolve, reject) => {
|
|
const ws = versionSftp.createWriteStream(`${remoteDir}/.version`)
|
|
ws.on('close', resolve)
|
|
ws.on('error', reject)
|
|
ws.end(versionString)
|
|
})
|
|
} finally {
|
|
versionSftp.end()
|
|
}
|
|
}
|
|
|
|
// Why: node-pty is a native addon that can't be bundled by esbuild. It must
|
|
// be compiled on the remote host against its Node.js version and OS. We run
|
|
// `npm init -y && npm install node-pty` in the relay directory so
|
|
// `require('node-pty')` resolves to the local node_modules.
|
|
async function installNativeDeps(conn: SshConnection, remoteDir: 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)
|
|
|
|
try {
|
|
await execCommand(
|
|
conn,
|
|
`export PATH=${escapedBinDir}:$PATH && cd ${escapedDir} && npm init -y --silent 2>/dev/null && npm install node-pty 2>&1`
|
|
)
|
|
// Why: SFTP uploads preserve file content but not Unix execute bits.
|
|
// node-pty ships a prebuilt `spawn-helper` binary that must be executable
|
|
// for posix_spawnp to fork the PTY process.
|
|
await execCommand(
|
|
conn,
|
|
`find ${shellEscape(`${remoteDir}/node_modules/node-pty/prebuilds`)} -name spawn-helper -exec chmod +x {} + 2>/dev/null; true`
|
|
)
|
|
} catch (err) {
|
|
// Why: node-pty install can fail if build tools (python, make, g++) are
|
|
// missing on the remote. Log the error but don't block relay startup —
|
|
// the relay will degrade gracefully (pty.spawn returns an error).
|
|
console.warn('[ssh-relay] Failed to install node-pty:', (err as Error).message)
|
|
}
|
|
}
|
|
|
|
function getLocalRelayPath(platform: RelayPlatform): string | null {
|
|
if (process.env.ORCA_RELAY_PATH) {
|
|
const override = join(process.env.ORCA_RELAY_PATH, platform)
|
|
if (existsSync(override)) {
|
|
return override
|
|
}
|
|
}
|
|
|
|
// Production: bundled alongside the app
|
|
const prodPath = join(app.getAppPath(), 'resources', 'relay', platform)
|
|
if (existsSync(prodPath)) {
|
|
return prodPath
|
|
}
|
|
|
|
// Development: built by `pnpm build:relay` into out/relay/{platform}/
|
|
const devPath = join(app.getAppPath(), 'out', 'relay', platform)
|
|
if (existsSync(devPath)) {
|
|
return devPath
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
async function launchRelay(conn: SshConnection, remoteDir: string): Promise<MultiplexerTransport> {
|
|
// Why: Phase 1 of the plan requires Node.js on the remote. We use the
|
|
// system `node` rather than bundling a node binary, keeping the relay
|
|
// 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: both remoteDir and nodePath come from the remote host and could
|
|
// contain shell metacharacters. Single-quote escaping prevents injection.
|
|
const channel = await conn.exec(
|
|
`cd ${shellEscape(remoteDir)} && ${shellEscape(nodePath)} relay.js --grace-time 60`
|
|
)
|
|
return waitForSentinel(channel)
|
|
}
|