Files
orca/src/main/ssh/ssh-relay-build-toolchain.ts
T
Brennan Benson 86a993aec4 fix(ssh): connect to Linux hosts that cannot compile node-pty (#10776)
* fix(ssh): connect to Linux hosts that cannot compile node-pty

node-pty ships no Linux prebuilt at any architecture, so it is compiled on
the remote. On a host without a C/C++ toolchain that build fails, and because
both native deps install in one npm command it also took down
@parcel/watcher — which does have a working Linux prebuilt — and failed the
whole connection. Every Linux image without build tools was unusable.

node-pty only backs remote terminals; files, git, and the editor do not need
it, and a missing native dep is already non-fatal further down the deploy. So
when the existing toolchain probe confirms the compiler is missing, reinstall
without node-pty instead of aborting. The manifest has to drop it too — npm
reconciles every dependency in package.json, not just the ones named on the
command line, so naming only @parcel/watcher still rebuilds node-pty.

If that reinstall also fails the actionable build-tools error is rethrown, so
a host broken for some other reason still reports the toolchain gap.

The relay's PTY error now names the fix rather than saying only that node-pty
is unavailable.

Verified on a stock Rocky Linux 10.2 aarch64 container (openssh-server, git,
nodejs, npm, no compiler): connect succeeds, /etc lists over SSH, node-pty is
absent while @parcel/watcher installs its linux-arm64-glibc prebuilt, and
spawning a terminal reports the install hint.

* fix(ssh): keep the node-pty skip path honest about platform and watcher

The PTY unavailable message named build tools unconditionally, but only Linux
compiles node-pty — the deploy-side skip is gated on linux and the toolchain
probe returns null on Windows. A Windows or macOS remote, where node-pty ships
prebuilds, was told to install make/g++/python3. Pick the remedy by the relay's
own platform.

The skip path returned before the install probe, so a @parcel/watcher that
installs but cannot require() (glibc below the floor) connected with dead file
watching and nothing logged. Probe before returning and warn; no rebuild, since
node-pty provably cannot compile on that host, and never fatal.

Also log the pty-less reinstall's own failure and attach it as cause — the
rethrown toolchain message is built from the original npm error, so an
unrelated retry failure (registry, ENOSPC, EACCES) was lost. The reinstall now
keeps the caller's resetDeps as well, so a repair reconnect still clears every
dep the probe found broken.

Tests: the skip-success fixture queued a chmod/probe/rebuild sequence
production never runs, and the surplus slots were absorbed by launchRelay's
readiness poll (1817ms vs 3-9ms for its peers). It now emits exactly the 12
execs production performs, and pins that no rebuild is issued. Adds the missing
negative case: a gyp-shaped failure on a host whose probe reports a complete
toolchain must still hard-fail rather than silently degrade.

* fix(ssh): hedge the node-pty remedy and keep repair resets on the skip path
2026-07-26 15:29:13 -07:00

185 lines
6.8 KiB
TypeScript

import { execCommand } from './ssh-relay-deploy-helpers'
import type { SshConnection } from './ssh-connection'
import { isWindowsRemoteHost, type RemoteHostPlatform } from './ssh-remote-platform'
// Why: node-pty@1.1.0 ships no Linux prebuild, so the remote `npm install` falls
// back to `node-gyp rebuild` and needs a C/C++ toolchain. A missing toolchain is
// the dominant first-connect failure on Linux relays (#1693); node-gyp surfaces
// it as an opaque `not found: make`. We probe for the tools so we can replace
// that with an actionable "install build-essential" message.
const PROBED_TOOLS = [
'make',
'gcc',
'g++',
'cc',
'c++',
'clang',
'clang++',
'python3',
'python'
] as const
// Package managers mapped to the one-liner that installs a C/C++ toolchain on
// the matching distro family. Ordered by detection priority.
const PACKAGE_MANAGER_HINTS: readonly { bin: string; install: string }[] = [
{ bin: 'apt-get', install: 'sudo apt-get install -y build-essential python3' },
{ bin: 'dnf', install: 'sudo dnf install -y make gcc gcc-c++ python3' },
{ bin: 'yum', install: 'sudo yum install -y make gcc gcc-c++ python3' },
{ bin: 'pacman', install: 'sudo pacman -S --needed base-devel python' },
{ bin: 'apk', install: 'sudo apk add build-base python3' },
{ bin: 'zypper', install: 'sudo zypper install -y gcc gcc-c++ make python3' }
]
export type BuildToolchainStatus = {
present: string[]
packageManager: string | null
// node-gyp needs make, Python, and a C++ compiler. The caller only uses this
// verdict after npm/node-gyp output already points at a native-build failure,
// so custom Python paths do not make unrelated npm failures look toolchainy.
toolchainMissing: boolean
}
function hasCxxCompiler(present: ReadonlySet<string>): boolean {
return present.has('g++') || present.has('c++') || present.has('clang++')
}
function hasPython(present: ReadonlySet<string>): boolean {
return present.has('python3') || present.has('python')
}
// POSIX-sh probe: echo a `HAVE <tool>` line per resolvable build tool and a
// single `PKG <manager>` line for the host's package manager. Runs under
// `/bin/sh -c` (see wrapRemoteCommandForPosixShell), so it stays portable.
export function buildToolchainProbeCommand(): string {
const toolLoop = `for t in ${PROBED_TOOLS.join(
' '
)}; do if command -v "$t" >/dev/null 2>&1; then echo "HAVE $t"; fi; done`
const pkgList = PACKAGE_MANAGER_HINTS.map((hint) => hint.bin).join(' ')
const pkgLoop = `for p in ${pkgList}; do if command -v "$p" >/dev/null 2>&1; then echo "PKG $p"; break; fi; done`
return `${toolLoop}; ${pkgLoop}`
}
export function parseBuildToolchainProbe(output: string): BuildToolchainStatus {
const present = new Set<string>()
let packageManager: string | null = null
for (const line of output.split('\n')) {
const haveMatch = line.trim().match(/^HAVE (\S+)$/)
if (haveMatch) {
present.add(haveMatch[1])
continue
}
const pkgMatch = line.trim().match(/^PKG (\S+)$/)
if (pkgMatch && !packageManager) {
packageManager = pkgMatch[1]
}
}
return {
present: PROBED_TOOLS.filter((tool) => present.has(tool)),
packageManager,
toolchainMissing: !present.has('make') || !hasCxxCompiler(present) || !hasPython(present)
}
}
export function shouldProbeBuildToolchainAfterNativeDepsFailure(message: string): boolean {
const lower = message.toLowerCase()
if (!lower.includes('gyp') && !lower.includes('node-gyp')) {
return false
}
return (
/\bnot found:\s*(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b/i.test(
message
) ||
/\b(make|gmake|gcc|g\+\+|cc|c\+\+|clang|clang\+\+|python|python3)\b.*\bnot found\b/i.test(
message
) ||
lower.includes('could not find any python installation') ||
lower.includes('no xcode or clt version detected')
)
}
function missingToolNames(status: BuildToolchainStatus): string[] {
const present = new Set(status.present)
const missing: string[] = []
if (!present.has('make')) {
missing.push('make')
}
if (!hasCxxCompiler(present)) {
missing.push('a C++ compiler (g++ or clang++)')
}
if (!hasPython(present)) {
missing.push('python3')
}
return missing
}
/** Install hint for the host's package manager, or the cross-distro list when it is unknown. */
export function toolchainInstallHintLines(status: BuildToolchainStatus): string[] {
const tailored = status.packageManager
? PACKAGE_MANAGER_HINTS.find((hint) => hint.bin === status.packageManager)?.install
: null
if (tailored) {
return [` ${tailored}`]
}
return [
' Debian/Ubuntu: sudo apt-get install -y build-essential python3',
' Fedora/RHEL: sudo dnf install -y make gcc gcc-c++ python3',
' Arch: sudo pacman -S --needed base-devel python',
' Alpine: sudo apk add build-base python3'
]
}
/** One-line summary for the deploy log when node-pty is skipped rather than compiled. */
export function formatSkippedNodePtyWarning(status: BuildToolchainStatus): string {
// Why: with no package manager detected the hint list is the cross-distro menu, whose first line
// is Debian's — quoting it alone would name the wrong distro, so stay neutral instead.
const hintLines = toolchainInstallHintLines(status)
const hint =
hintLines.length === 1
? hintLines[0].trim()
: 'install a C/C++ toolchain (make, a C++ compiler, python3)'
return (
`missing build tools (${missingToolNames(status).join(', ')}); skipping node-pty so the ` +
`connection still serves files and git. Remote terminals need: ${hint}`
)
}
export function formatMissingToolchainError(
status: BuildToolchainStatus,
underlyingError: string
): string {
const lines = [
`The remote host is missing the C/C++ build tools (${missingToolNames(status).join(', ')}) ` +
`needed to compile Orca's relay native modules (node-pty, @parcel/watcher). node-pty has no ` +
`prebuilt binary for Linux, so they must be compiled on the remote host.`,
'',
'Install the build tools on the remote host, then reconnect:',
...toolchainInstallHintLines(status),
'',
`Underlying install error: ${underlyingError}`
]
return lines.join('\n')
}
// Best-effort: returns null on Windows hosts (node-pty ships win32 prebuilds, so
// a missing toolchain isn't the failure there) or if the probe itself errors —
// callers fall back to the original install error in those cases.
export async function probeBuildToolchain(
conn: SshConnection,
hostPlatform: RemoteHostPlatform,
signal?: AbortSignal
): Promise<BuildToolchainStatus | null> {
if (isWindowsRemoteHost(hostPlatform)) {
return null
}
try {
const output = await execCommand(conn, buildToolchainProbeCommand(), {
wrapCommand: true,
signal
})
return parseBuildToolchainProbe(output)
} catch {
signal?.throwIfAborted()
return null
}
}