diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts index c64a41ead51..77d20ce475a 100644 --- a/src/main/ssh/build-toolchain-diagnosis.ts +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -163,3 +163,66 @@ export function formatMissingToolchainError( ] return lines.join('\n') } + +const NODE_HEADERS_TARBALL_RE = /node-v[0-9.]+-headers\.tar\.gz/i + +/** + * Whether a native-deps failure is node-gyp failing to download Node headers from nodejs.org. + * + * Why it needs naming: the raw output is forty lines of `gyp http` and stack frames around one + * `ECONNREFUSED`, and it reads as a broken host or a broken Orca. Which of two things it is + * depends on what the local-headers export found first, so the formatter takes that answer. + */ +export function isNodeHeadersDownloadFailure(message: string): boolean { + // Why `configure error` is required: node-gyp's fetch client logs `attempt N failed with ` + // on retries it then recovers from, so a network token alone also matches a build that got its + // headers and died later for an unrelated reason. Only the configure step downloads headers. + return ( + /gyp ERR! configure error/i.test(message) && + NODE_HEADERS_TARBALL_RE.test(message) && + /\b(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|EAI_AGAIN|ECONNRESET)\b/.test( + message + ) + ) +} + +const NODE_HEADERS_CONTEXT = + 'node-pty has no prebuilt binary for Linux, so it must be compiled on the remote host, and ' + + 'node-gyp fetches the Node.js headers from nodejs.org unless the Node install provides them ' + + 'at /include/node.' + +/** + * @param localHeadersDir what the local-headers export found: a dir it exported, `null` when + * the host's Node ships no matching headers, `undefined` when the answer never came back. + * + * Why the exported-dir case is its own message: the export is the fix, so node-gyp downloading + * anyway means its `nodedir` env keys were not honoured (a future npm dropping the passthrough, + * a wrapper scrubbing the env). That is an Orca defect, not a host problem, and must not be + * reported as one -- it names the dir so the report is checkable. + */ +export function formatNodeHeadersDownloadError( + underlyingError: string, + localHeadersDir: string | null | undefined +): string { + const lines = localHeadersDir + ? [ + `The remote host could not download the Node.js headers needed to compile node-pty, even ` + + `though its Node install ships matching headers at ${localHeadersDir}/include/node and ` + + `Orca pointed node-gyp at them. node-gyp ignored that setting; this is an Orca defect, ` + + `please report it with the log below.`, + '', + 'Workaround on the remote host until then: allow outbound HTTPS to nodejs.org, or point ' + + 'npm at a mirror: npm config set disturl https:///dist' + ] + : [ + 'The remote host could not download the Node.js headers needed to compile node-pty, and ' + + `its Node install has no local headers matching its own version. ${NODE_HEADERS_CONTEXT}`, + '', + 'Fix one of the following on the remote host, then reconnect:', + ' - Install Node.js from an official build or a version manager (nvm, fnm, volta, n), ' + + 'which ship headers for exactly the Node they run; or', + ' - Allow outbound HTTPS to nodejs.org, or point npm at a mirror: ' + + 'npm config set disturl https:///dist' + ] + return [...lines, '', `Underlying install error: ${underlyingError}`].join('\n') +} diff --git a/src/main/ssh/ssh-relay-build-toolchain.test.ts b/src/main/ssh/ssh-relay-build-toolchain.test.ts index b216b599f5a..6fcc4f5321d 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.test.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.test.ts @@ -3,7 +3,9 @@ import { buildToolchainProbeCommand, parseBuildToolchainProbe, formatMissingToolchainError, + formatNodeHeadersDownloadError, formatSkippedNodePtyWarning, + isNodeHeadersDownloadFailure, shouldProbeBuildToolchainAfterNativeDepsFailure } from './ssh-relay-build-toolchain' @@ -125,3 +127,71 @@ describe('formatSkippedNodePtyWarning', () => { expect(warning).toContain('install a C/C++ toolchain') }) }) + +// Verbatim shape of the STA-6674 failure: node-gyp on a host whose nodejs.org is refused. +const HEADERS_REFUSED = + 'npm error gyp http GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + + 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\n' + + 'npm error gyp ERR! configure error\n' + + 'npm error gyp ERR! stack FetchError: request to https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz failed, reason: connect ECONNREFUSED 127.0.0.1:443' + +describe('isNodeHeadersDownloadFailure', () => { + it('matches node-gyp failing to fetch the Node headers tarball', () => { + expect(isNodeHeadersDownloadFailure(HEADERS_REFUSED)).toBe(true) + expect( + isNodeHeadersDownloadFailure( + 'gyp http fetch GET https://nodejs.org/download/release/v20.19.0/node-v20.19.0-headers.tar.gz attempt 1 failed with ENOTFOUND\ngyp ERR! configure error' + ) + ).toBe(true) + }) + + it('is not the toolchain diagnosis, and does not fire on other network failures', () => { + expect(shouldProbeBuildToolchainAfterNativeDepsFailure(HEADERS_REFUSED)).toBe(false) + // The registry, not nodejs.org: a different remedy. + expect( + isNodeHeadersDownloadFailure( + 'npm error network request to https://registry.npmjs.org/node-pty failed, reason: connect ECONNREFUSED' + ) + ).toBe(false) + // Headers named but the build failed for another reason. + expect( + isNodeHeadersDownloadFailure( + 'gyp info using node-v24.12.0-headers.tar.gz\ngyp ERR! build error make failed with exit code: 2' + ) + ).toBe(false) + // A retried attempt that recovered, then a compile failure: not a download failure. + expect( + isNodeHeadersDownloadFailure( + 'gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNRESET\n' + + 'gyp http 200 https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz\n' + + 'gyp ERR! build error\ngyp ERR! stack Error: `make` failed with exit code: 2' + ) + ).toBe(false) + // A mirror answering non-2xx is a FetchError without a network code: a different remedy. + expect( + isNodeHeadersDownloadFailure( + 'gyp ERR! configure error\ngyp ERR! stack FetchError: 404 Not Found https://mirror/dist/v24.12.0/node-v24.12.0-headers.tar.gz' + ) + ).toBe(false) + }) +}) + +describe('formatNodeHeadersDownloadError', () => { + it('names both host remedies when the host ships no headers', () => { + const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, null) + expect(msg).toContain('no local headers matching its own version') + expect(msg).toContain('/include/node') + expect(msg).toContain('nvm, fnm, volta, n') + expect(msg).toContain('disturl') + expect(msg).toContain('ECONNREFUSED') + }) + + it('reports an Orca defect, not a host problem, when headers were exported and ignored', () => { + const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED, '/usr/local') + expect(msg).toContain('/usr/local/include/node') + expect(msg).toContain('Orca defect') + expect(msg).not.toContain('no local headers matching its own version') + expect(msg).not.toContain('nvm, fnm, volta, n') + expect(msg).toContain('ECONNREFUSED') + }) +}) diff --git a/src/main/ssh/ssh-relay-build-toolchain.ts b/src/main/ssh/ssh-relay-build-toolchain.ts index bcb64d3bdf9..db7b6353697 100644 --- a/src/main/ssh/ssh-relay-build-toolchain.ts +++ b/src/main/ssh/ssh-relay-build-toolchain.ts @@ -16,7 +16,9 @@ export { shouldProbeBuildToolchainAfterNativeDepsFailure, toolchainInstallHintLines, formatSkippedNodePtyWarning, - formatMissingToolchainError + formatMissingToolchainError, + formatNodeHeadersDownloadError, + isNodeHeadersDownloadFailure } from './build-toolchain-diagnosis' export type { BuildToolchainStatus } from './build-toolchain-diagnosis' diff --git a/src/main/ssh/ssh-relay-deploy.ts b/src/main/ssh/ssh-relay-deploy.ts index e7450478d92..5d8101361c6 100644 --- a/src/main/ssh/ssh-relay-deploy.ts +++ b/src/main/ssh/ssh-relay-deploy.ts @@ -53,11 +53,14 @@ import { } from './ssh-relay-deploy-timing' import { createSshOperationAbortError, shellEscape } from './ssh-connection-utils' import { isWindowsRelayPlatform } from '../../shared/relay-artifacts' +import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' import { probeBuildToolchain, formatMissingToolchainError, formatSkippedNodePtyWarning, - shouldProbeBuildToolchainAfterNativeDepsFailure + shouldProbeBuildToolchainAfterNativeDepsFailure, + formatNodeHeadersDownloadError, + isNodeHeadersDownloadFailure } from './ssh-relay-build-toolchain' import { commandWithNodePath, @@ -1174,7 +1177,7 @@ async function installNativeDeps( hostPlatform, nodePath, remoteDir, - `${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` + `${exportLocalNodeHeadersPrefix(nodePath)}${resetPrefix}npm install --ignore-scripts=false --omit=dev --no-audit --no-fund ${installArgs} 2>&1` ) await execHostCommand(conn, hostPlatform, command, { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, @@ -1236,6 +1239,14 @@ async function installNativeDeps( return } } + // Why: either the local-headers export found nothing (a host both header-less and offline) or + // it did and node-gyp downloaded anyway (the export is broken) -- name which, or the log reads + // as a broken relay either way. + if (platform.startsWith('linux') && isNodeHeadersDownloadFailure(msg)) { + throw new Error(formatNodeHeadersDownloadError(msg, localNodeHeadersFromOutput(msg)), { + cause: err + }) + } throw err } @@ -1254,8 +1265,15 @@ async function installNativeDeps( throw err } signal?.throwIfAborted() + // Same diagnosis as the install catch: this fallback is non-fatal, so the log is the only + // place the offline-headers cause can reach anyone. + const rebuildMsg = (err as Error).message console.warn( - `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${(err as Error).message}` + `[ssh-relay][NATIVE-DEPS-REBUILD-FAIL] npm rebuild native deps failed at ${remoteDir} (${platform}): ${ + platform.startsWith('linux') && isNodeHeadersDownloadFailure(rebuildMsg) + ? formatNodeHeadersDownloadError(rebuildMsg, localNodeHeadersFromOutput(rebuildMsg)) + : rebuildMsg + }` ) } signal?.throwIfAborted() @@ -1347,7 +1365,7 @@ async function applyNodePtyMasterCloexecPatch( hostPlatform, nodePath, remoteDir, - `${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` + `${exportLocalNodeHeadersPrefix(nodePath)}${shellEscape(nodePath)} ${shellEscape(NODE_PTY_MASTER_CLOEXEC_PATCH_FILENAME)} 2>&1` ) const output = await execHostCommand(conn, hostPlatform, command, { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, @@ -1529,7 +1547,7 @@ async function rebuildNativeDeps( hostPlatform, nodePath, remoteDir, - `npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` + `${exportLocalNodeHeadersPrefix(nodePath)}npm rebuild --ignore-scripts=false ${depNames.map(shellEscape).join(' ')} 2>&1` ) await execHostCommand(conn, hostPlatform, command, { timeoutMs: NATIVE_DEPS_COMMAND_TIMEOUT_MS, diff --git a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts index 4d90a7c8289..e8616e7c230 100644 --- a/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts +++ b/src/main/ssh/ssh-relay-native-deps-install-staged-upload.test.ts @@ -154,6 +154,69 @@ describe('installNativeDeps staged uploads', () => { expect(writeObservedAt).toBeLessThanOrEqual(npmInstallIdx) }) + it('exports the host Node headers dir to node-gyp on every command that can compile node-pty (STA-6674)', async () => { + const conn = makeMockConnection(sftpCapture) + // Install succeeds, the probe fails, the rebuild repairs it, then the cloexec patch rebuilds again. + feed(makeExecResponses({ npmInstall: 'ok', probe: 'missing', repairProbe: 'ok' })) + + await deployAndLaunchRelay(conn) + + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + const compiling = ['npm install', 'npm rebuild', 'node-pty-1.1.0-master-cloexec-patch.cjs'] + for (const compileStep of compiling) { + const command = commands.find((candidate) => candidate.includes(compileStep)) + expect(command, compileStep).toBeDefined() + // Both spellings: node-gyp 10 (Node 20) reads only npm_config_, node-gyp >= 11.4 prefers the other. + expect(command).toContain('export npm_config_nodedir=') + expect(command).toContain('npm_package_config_node_gyp_nodedir=') + // The export precedes the compile on the same command line, and only when the probe found headers. + expect(command!.indexOf('npm_config_nodedir')).toBeLessThan(command!.indexOf(compileStep)) + expect(command).toContain('node_version.h') + // The marker lands in the captured output, so a failure after it can say what was exported. + expect(command).toContain('echo "ORCA-NODE-HEADERS:${ORCA_NODE_HEADERS_DIR:-none}"') + } + }) + + // What execCommand actually rejects with: the whole command line (marker echo included) quoted + // ahead of the host's output. A fixture that omits the command hides the marker-parsing bug. + function rejectNpmInstallLikeExecCommand(hostOutput: string): void { + vi.mocked(execCommand).mockImplementationOnce(async (_conn, command) => { + throw new Error(`Command "${command}" failed (exit 1): ${hostOutput}`) + }) + } + const HEADERS_REFUSED = + 'npm error gyp http fetch GET https://nodejs.org/download/release/v24.12.0/node-v24.12.0-headers.tar.gz attempt 1 failed with ECONNREFUSED\nnpm error gyp ERR! configure error' + + it('names the fix when node-gyp cannot download headers and the host ships none (STA-6674)', async () => { + const conn = makeMockConnection(sftpCapture) + feed(makeStagedFirstInstallExecPrefix()) + rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:none\n${HEADERS_REFUSED}`) + feed(['']) // clean stage root + + const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) + expect((error as Error).message).toContain('could not download the Node.js headers') + expect((error as Error).message).toContain('no local headers matching its own version') + expect((error as Error).message).not.toContain('Orca defect') + expect((error as Error).message).toContain('ECONNREFUSED') + // A full toolchain: the toolchain probe must not run, and this is not a "build tools" error. + expect((error as Error).message).not.toContain('build tools') + const commands = vi.mocked(execCommand).mock.calls.map(([, command]) => command) + expect(commands.some((command) => command.includes('command -v "$t"'))).toBe(false) + }) + + it('reports an Orca defect when headers were exported but node-gyp downloaded anyway', async () => { + // The marker says the export happened; a download after it means node-gyp never read the env. + const conn = makeMockConnection(sftpCapture) + feed(makeStagedFirstInstallExecPrefix()) + rejectNpmInstallLikeExecCommand(`ORCA-NODE-HEADERS:/usr/local\n${HEADERS_REFUSED}`) + feed(['']) // clean stage root + + const error = await deployAndLaunchRelay(conn).catch((e: Error) => e) + expect((error as Error).message).toContain('/usr/local/include/node') + expect((error as Error).message).toContain('Orca defect') + expect((error as Error).message).not.toContain('no local headers matching its own version') + }) + it('promotes only after the first-install lock is acquired', async () => { const conn = makeMockConnection(sftpCapture) feed(makeExecResponses({ npmInstall: 'ok', probe: 'ok' })) diff --git a/src/main/ssh/ssh-relay-node-headers.test.ts b/src/main/ssh/ssh-relay-node-headers.test.ts new file mode 100644 index 00000000000..84e9016738b --- /dev/null +++ b/src/main/ssh/ssh-relay-node-headers.test.ts @@ -0,0 +1,164 @@ +import { spawnSync } from 'node:child_process' +import { + chmodSync, + copyFileSync, + mkdtempSync, + mkdirSync, + rmSync, + symlinkSync, + writeFileSync +} from 'node:fs' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { afterEach, describe, expect, it } from 'vitest' +import { exportLocalNodeHeadersPrefix, localNodeHeadersFromOutput } from './ssh-relay-node-headers' + +const POSIX = process.platform !== 'win32' + +/** Runs the prefix under /bin/sh exactly as the relay does, then prints what node-gyp would see. */ +function runPrefix(nodePath: string): { + nodedir: string + pkgNodedir: string + marker: string | null | undefined +} { + const script = `${exportLocalNodeHeadersPrefix(nodePath)}printf '%s\\n%s\\n' "$npm_config_nodedir" "$npm_package_config_node_gyp_nodedir"` + const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) + expect(result.status).toBe(0) + const marker = localNodeHeadersFromOutput(result.stdout) + const [nodedir = '', pkgNodedir = ''] = result.stdout + .split('\n') + .filter((line) => !line.startsWith('ORCA-NODE-HEADERS:')) + return { nodedir, pkgNodedir, marker } +} + +/** A fake `/bin/node` whose `include/node/node_version.h` claims `version`. */ +function fakeNodePrefix(root: string, version: string): string { + const prefix = join(root, 'prefix') + mkdirSync(join(prefix, 'bin'), { recursive: true }) + mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) + const [major, minor, patch] = version.split('.') + writeFileSync( + join(prefix, 'include', 'node', 'node_version.h'), + `#define NODE_MAJOR_VERSION ${major}\n#define NODE_MINOR_VERSION ${minor}\n#define NODE_PATCH_VERSION ${patch}\n` + ) + // Why a symlink to the real binary: the probe reads process.execPath, which Node resolves + // through symlinks -- so this stands in for `/usr/bin/node -> /opt/node/bin/node` shims too. + symlinkSync(process.execPath, join(prefix, 'bin', 'node')) + return join(prefix, 'bin', 'node') +} + +describe.skipIf(!POSIX)('exportLocalNodeHeadersPrefix', () => { + const roots: string[] = [] + afterEach(() => { + for (const root of roots.splice(0)) { + rmSync(root, { recursive: true, force: true }) + } + }) + + it('exports nodedir when the running Node ships headers for its own version', () => { + // The test runner's Node is an official build, so its prefix has include/node. + const prefix = dirname(dirname(process.execPath)) + const { nodedir, pkgNodedir, marker } = runPrefix(process.execPath) + expect(nodedir).toBe(prefix) + expect(pkgNodedir).toBe(prefix) + expect(marker).toBe(prefix) + }) + + it('leaves nodedir unset when the shipped headers are for another Node version', () => { + // A symlinked node resolves execPath to the real binary, whose prefix is the real one; so + // to stage a mismatch the probe must run a node whose execPath lands in the fake prefix. + // A copy does that. + const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) + roots.push(root) + const prefix = join(root, 'prefix') + mkdirSync(join(prefix, 'bin'), { recursive: true }) + mkdirSync(join(prefix, 'include', 'node'), { recursive: true }) + writeFileSync( + join(prefix, 'include', 'node', 'node_version.h'), + '#define NODE_MAJOR_VERSION 1\n#define NODE_MINOR_VERSION 0\n#define NODE_PATCH_VERSION 0\n' + ) + const copied = join(prefix, 'bin', 'node') + copyFileSync(process.execPath, copied) + chmodSync(copied, 0o755) + const { nodedir, pkgNodedir, marker } = runPrefix(copied) + expect(nodedir).toBe('') + expect(pkgNodedir).toBe('') + expect(marker).toBeNull() + }) + + it('leaves nodedir unset when the prefix has no headers at all', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) + roots.push(root) + const copied = join(root, 'bin', 'node') + mkdirSync(dirname(copied), { recursive: true }) + copyFileSync(process.execPath, copied) + chmodSync(copied, 0o755) + const { nodedir } = runPrefix(copied) + expect(nodedir).toBe('') + }) + + it('follows a symlinked node to the install that owns the headers', () => { + const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) + roots.push(root) + const shim = fakeNodePrefix(root, '0.0.0') + // The shim's own fake headers are ignored: execPath resolves to the real binary, and the + // real prefix's headers are the ones that match. + const { nodedir } = runPrefix(shim) + expect(nodedir).toBe(dirname(dirname(process.execPath))) + }) + + it('clears an inherited nodedir when the probe finds no matching headers', () => { + // A remote profile's stale nodedir must not survive past the version check. + const root = mkdtempSync(join(tmpdir(), 'orca-node-headers-')) + roots.push(root) + const copied = join(root, 'bin', 'node') + mkdirSync(dirname(copied), { recursive: true }) + copyFileSync(process.execPath, copied) + chmodSync(copied, 0o755) + const script = `${exportLocalNodeHeadersPrefix(copied)}printf '%s|%s|%s' "$npm_config_nodedir" "$NPM_CONFIG_NODEDIR" "$npm_package_config_node_gyp_nodedir"` + const result = spawnSync('/bin/sh', ['-c', script], { + encoding: 'utf8', + env: { + ...process.env, + npm_config_nodedir: '/usr/stale-headers', + NPM_CONFIG_NODEDIR: '/usr/stale-headers', + npm_package_config_node_gyp_nodedir: '/usr/stale-headers' + } + }) + expect(result.status).toBe(0) + expect(result.stdout.split('\n').at(-1)).toBe('||') + }) + + it('does not fail the command line when node itself cannot run', () => { + const script = `${exportLocalNodeHeadersPrefix('/nonexistent/node')}echo "after:$npm_config_nodedir"` + const result = spawnSync('/bin/sh', ['-c', script], { encoding: 'utf8' }) + expect(result.status).toBe(0) + expect(result.stdout.trim()).toBe('ORCA-NODE-HEADERS:none\nafter:') + }) +}) + +describe('localNodeHeadersFromOutput', () => { + it('reads the host answer, not the copy of the marker echo quoted in an exec-failure head', () => { + // The real shape: execCommand quotes the whole command line, prefix included, before the output. + const command = `export PATH='/usr/local/bin':$PATH && cd '/root/.orca-remote/relay-x' && ${exportLocalNodeHeadersPrefix('/usr/local/bin/node')}npm install node-pty 2>&1` + const failed = (hostOutput: string): string => + `Command "${command}" failed (exit 1): ${hostOutput}` + expect( + localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:none\ngyp ERR! configure error')) + ).toBeNull() + expect( + localNodeHeadersFromOutput(failed('ORCA-NODE-HEADERS:/usr/local\ngyp ERR! configure error')) + ).toBe('/usr/local') + // No host output at all after the head: the command copy alone must not count as a marker. + expect(localNodeHeadersFromOutput(failed(''))).toBeUndefined() + }) + + it('distinguishes an exported dir, an explicit none, and no marker at all', () => { + expect(localNodeHeadersFromOutput('x\nORCA-NODE-HEADERS:/usr/local\ngyp ERR!')).toBe( + '/usr/local' + ) + expect(localNodeHeadersFromOutput('ORCA-NODE-HEADERS:none\ngyp ERR!')).toBeNull() + expect(localNodeHeadersFromOutput('gyp ERR! only')).toBeUndefined() + }) +}) diff --git a/src/main/ssh/ssh-relay-node-headers.ts b/src/main/ssh/ssh-relay-node-headers.ts new file mode 100644 index 00000000000..a590bd40fca --- /dev/null +++ b/src/main/ssh/ssh-relay-node-headers.ts @@ -0,0 +1,97 @@ +/** + * Point node-gyp at the headers the host's Node install already ships, so compiling node-pty + * needs nothing from nodejs.org. + * + * Why: node-pty has no Linux prebuild, so every Linux relay compiles it, and node-gyp's default + * is to download `node-v-headers.tar.gz` before configuring. Every official Node build, and + * every version manager that unpacks one (nvm, fnm, volta, mise, n), already has those exact + * headers at `/include/node`. The download was the only step that needed the internet, + * so a firewalled host failed with ECONNREFUSED on work that never had to happen (STA-6674). + * + * Why both variables: node-gyp >= 11.4 prefers `npm_package_config_node_gyp_` and npm 11+ + * warns that arbitrary `npm_config_` is deprecated, but node-gyp 10 (bundled with Node 20) + * reads only `npm_config_`. Both together cover every Node the relay runs on. + * + * Why the version check: node-gyp trusts `nodedir` blindly, so a distro `/usr/include/node` left + * by an older headers package would be compiled against as-is. Whether that binding then misbehaves + * is not established (one measured run loaded a node-20-header build under node 24); refusing is + * the conservative default. A mismatch leaves the variables unset, which is today's path. + */ +import { shellEscape } from './ssh-connection-utils' + +/** Shell variable the probe answers into; namespaced so it cannot collide with npm's own. */ +const NODEDIR_SHELL_VAR = 'ORCA_NODE_HEADERS_DIR' + +/** + * Prints the running Node's install prefix when `/include/node/node_version.h` matches + * `process.versions.node`, and nothing otherwise. `process.execPath` is symlink-resolved, so a + * `/usr/bin/node` -> `/opt/node/bin/node` shim still finds `/opt/node/include`. + */ +export const LOCAL_NODE_HEADERS_PROBE_JS = [ + 'const p=require("path"),f=require("fs");', + 'const d=p.dirname(p.dirname(process.execPath));', + 'try{', + 'const h=f.readFileSync(p.join(d,"include","node","node_version.h"),"utf8");', + 'const v=["MAJOR","MINOR","PATCH"].map(k=>(h.match(new RegExp("#define NODE_"+k+"_VERSION ([0-9]+)"))||[])[1]).join(".");', + 'if(v===process.versions.node)process.stdout.write(d)', + '}catch{}' +].join('') + +/** + * Stdout marker naming what the probe found, printed before the compile so the answer is in the + * captured output of any failure that follows. `none` means no matching local headers. + */ +export const LOCAL_NODE_HEADERS_MARKER_PREFIX = 'ORCA-NODE-HEADERS:' + +/** + * POSIX-sh prefix (`...; `) that exports node-gyp's `nodedir` for the rest of the command line + * when the host's Node ships matching headers. Prepend to any command that may compile node-pty: + * `npm install`, `npm rebuild`, and the cloexec patch (its `npm rebuild` inherits the env). + */ +export function exportLocalNodeHeadersPrefix(nodePath: string): string { + const probe = `${shellEscape(nodePath)} -e ${shellEscape(LOCAL_NODE_HEADERS_PROBE_JS)} 2>/dev/null` + // Why the unset: a remote profile can already export a nodedir (a stale distro header dir), in + // either case npm accepts. Left alone it would bypass the version check above and compile + // against those headers. Deliberately env only: a `nodedir=` in ~/.npmrc is not reachable from here + // -- npm ignores an empty env override, and a CLI `--nodedir=` would also override the good + // export -- so an npmrc setting stays the operator's, as it was before this prefix existed. + return ( + `${NODEDIR_SHELL_VAR}=$(${probe}); ` + + `unset npm_config_nodedir NPM_CONFIG_NODEDIR npm_package_config_node_gyp_nodedir; ` + + `if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` + + `export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` + + `fi; ` + + `echo "${LOCAL_NODE_HEADERS_MARKER_PREFIX}\${${NODEDIR_SHELL_VAR}:-none}"; ` + ) +} + +/** + * The headers dir the prefix exported, `null` when it found none, or `undefined` when the + * marker is absent (output truncated, or the command never reached the prefix). + */ +export function localNodeHeadersFromOutput(output: string): string | null | undefined { + // Why the head is stripped first: a failed exec's message is `Command "" failed + // (exit N): `, and quotes this prefix verbatim -- including the marker's + // `echo`. Scanning from the start would match that copy and return `${ORCA_NODE_HEADERS_DIR:- + // none}"...` as a "dir". Only what follows the head is the host's answer. + const head = output.match(EXEC_FAILURE_HEAD_RE) + const hostOutput = head ? output.slice(head[0].length) : output + // First match, not last: the host's own line comes first, and later lines are npm/gyp output + // that must not be able to spoof it. + for (const line of hostOutput.split(/\r?\n/)) { + const at = line.indexOf(LOCAL_NODE_HEADERS_MARKER_PREFIX) + if (at === -1) { + continue + } + const dir = line.slice(at + LOCAL_NODE_HEADERS_MARKER_PREFIX.length).trim() + return dir === 'none' || dir === '' ? null : dir + } + return undefined +} + +/** + * `Command "" failed (exit N): ` -- see ssh-relay-exec-command.ts. + * Lazy `[\s\S]*?` is safe: it stops at the first `" failed (exit N): `, and no command this + * module builds contains that literal, so the match cannot end early inside the command. + */ +const EXEC_FAILURE_HEAD_RE = /^Command "[\s\S]*?" failed \(exit -?\d+\): / diff --git a/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts new file mode 100644 index 00000000000..c5700c991c1 --- /dev/null +++ b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts @@ -0,0 +1,204 @@ +// Why this exists (STA-6674): a Linux host whose only unreachable endpoint is nodejs.org could +// not run a relay. node-pty ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp +// downloads `node-v-headers.tar.gz` unless told the host already has the headers -- which +// every official Node install does, at `/include/node`. This drives the real deploy at a +// Docker sshd whose nodejs.org resolves to 127.0.0.1 (ECONNREFUSED, exactly what the user saw). +// +// Run: ORCA_REVIEW_SSH_OFFLINE_HEADERS=1 pnpm test src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts +// Needs Docker and `pnpm build:relay`. ORCA_REVIEW_SSH_NODE_IMAGE picks the Node image +// (default node:24.12.0-bookworm, the user's version); ORCA_REVIEW_SSH_TARGET_HOST overrides +// the address the app connects to (default 127.0.0.1). +import { execFileSync, spawnSync } from 'node:child_process' +import { randomUUID } from 'node:crypto' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { connect } from 'node:net' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' + +vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) + +import { SshConnection } from './ssh-connection' +import { deployAndLaunchRelay } from './ssh-relay-deploy' +import type { SshTarget } from '../../shared/ssh-types' + +const RUN_REVIEW_ORACLE = process.env.ORCA_REVIEW_SSH_OFFLINE_HEADERS === '1' +const NODE_IMAGE = process.env.ORCA_REVIEW_SSH_NODE_IMAGE ?? 'node:24.12.0-bookworm' +const TARGET_HOST = process.env.ORCA_REVIEW_SSH_TARGET_HOST ?? '127.0.0.1' + +type TargetFixture = { + containerName: string + identityFile: string + port: number + tempDir: string +} + +function run(command: string, args: string[], timeout = 30_000, input?: string): string { + return execFileSync(command, args, { + encoding: 'utf8', + stdio: [input === undefined ? 'ignore' : 'pipe', 'pipe', 'pipe'], + timeout, + input + }).trim() +} + +function dockerExec(fixture: TargetFixture, command: string): string { + return run('docker', ['exec', fixture.containerName, 'bash', '-lc', command], 60_000) +} + +async function startTarget(): Promise { + const image = `orca-review-offline-headers:${NODE_IMAGE.replace(/[^A-Za-z0-9_.-]/g, '-')}` + run( + 'docker', + ['build', '-q', '-t', image, '-'], + 600_000, + [ + `FROM ${NODE_IMAGE}`, + 'RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server git && rm -rf /var/lib/apt/lists/* && mkdir -p /run/sshd /root/.ssh && chmod 700 /root/.ssh', + '' + ].join('\n') + ) + const tempDir = mkdtempSync(join(tmpdir(), 'orca-offline-headers-ssh-')) + const identityFile = join(tempDir, 'id_ed25519') + run('ssh-keygen', ['-t', 'ed25519', '-N', '', '-f', identityFile, '-q']) + const publicKey = readFileSync(`${identityFile}.pub`, 'utf8').trim() + const containerName = `orca-offline-headers-${randomUUID().slice(0, 12)}` + // Why a refused connection and not a dropped one: a timeout takes node-gyp's retry path and + // burns the deploy budget; the user's host refused, and that is the path under test. + run( + 'docker', + [ + 'run', + '-d', + '--name', + containerName, + '--add-host', + 'nodejs.org:127.0.0.1', + '-p', + '0.0.0.0::22', + '-e', + `AUTHORIZED_KEY=${publicKey}`, + image, + 'bash', + '-lc', + 'printf "%s\\n" "$AUTHORIZED_KEY" > /root/.ssh/authorized_keys && chmod 600 /root/.ssh/authorized_keys && exec /usr/sbin/sshd -D -e' + ], + 120_000 + ) + const port = Number(run('docker', ['port', containerName, '22/tcp']).split(':').at(-1)) + // `docker run -d` returns before sshd binds; connect() against a closed port is a flake. + await waitForSshBanner(port) + return { containerName, identityFile, port, tempDir } +} + +/** Resolves once sshd answers with its banner on the mapped port, or throws after the deadline. */ +async function waitForSshBanner(port: number, deadlineMs = 60_000): Promise { + const deadline = Date.now() + deadlineMs + for (;;) { + const gotBanner = await new Promise((resolve) => { + const socket = connect({ host: TARGET_HOST, port }) + const done = (value: boolean): void => { + socket.destroy() + resolve(value) + } + socket.setTimeout(2_000, () => done(false)) + socket.once('data', (chunk) => done(chunk.toString('utf8').startsWith('SSH-'))) + socket.once('error', () => done(false)) + }) + if (gotBanner) { + return + } + if (Date.now() > deadline) { + throw new Error(`sshd on port ${port} did not answer within ${deadlineMs / 1000}s`) + } + await new Promise((resolve) => setTimeout(resolve, 500)) + } +} + +function stopTarget(fixture: TargetFixture | null): void { + if (!fixture) { + return + } + spawnSync('docker', ['rm', '-f', fixture.containerName], { stdio: 'ignore', timeout: 30_000 }) + rmSync(fixture.tempDir, { recursive: true, force: true }) +} + +function createConnection(fixture: TargetFixture): SshConnection { + const target: SshTarget = { + id: `offline-headers-${randomUUID()}`, + label: 'Offline node headers Docker SSH target', + source: 'manual', + host: TARGET_HOST, + port: fixture.port, + username: 'root', + identityFile: fixture.identityFile, + identitiesOnly: true + } + return new SshConnection(target, { onStateChange: vi.fn() }) +} + +describe.skipIf(!RUN_REVIEW_ORACLE)( + 'SSH relay deploy on a host that cannot reach nodejs.org', + () => { + let fixture: TargetFixture | null = null + + beforeAll(async () => { + fixture = await startTarget() + }, 900_000) + + afterAll(() => { + stopTarget(fixture) + }) + + it('compiles node-pty from the host Node install headers instead of downloading them', async () => { + const activeFixture = fixture as TargetFixture + expect(dockerExec(activeFixture, 'getent hosts nodejs.org')).toContain('127.0.0.1') + const connection = createConnection(activeFixture) + await connection.connect() + try { + const result = await deployAndLaunchRelay(connection, undefined, 60) + expect(result.remoteRelayDir).toBeTruthy() + + const evidence = dockerExec( + activeFixture, + [ + `cd '${result.remoteRelayDir}'`, + 'test -f node_modules/node-pty/build/Release/pty.node && echo PTY_NODE=built', + 'test -d /root/.cache/node-gyp && echo HEADERS=downloaded || echo HEADERS=local', + `node -e "require('node-pty'); require('@parcel/watcher'); console.log('NATIVE=loadable')"` + ].join('; ') + ) + console.log(`[offline-node-headers] ${NODE_IMAGE}: ${evidence.replace(/\n/g, ' ')}`) + expect(evidence).toContain('PTY_NODE=built') + expect(evidence).toContain('HEADERS=local') + expect(evidence).toContain('NATIVE=loadable') + } finally { + await connection.disconnect() + } + }, 600_000) + + it('names the missing-local-headers cause, not an Orca defect, when the host ships no headers', async () => { + // Same offline host, headers removed and the relay uninstalled so the deploy compiles again. + // This is the shape a review found misreported: the exec-failure message quotes the whole + // command (marker echo included) ahead of the output, and the parser must not read that copy. + const activeFixture = fixture as TargetFixture + dockerExec( + activeFixture, + 'rm -rf /usr/local/include/node /root/.orca-remote /root/.cache/node-gyp' + ) + const connection = createConnection(activeFixture) + await connection.connect() + try { + const error = await deployAndLaunchRelay(connection, undefined, 60).catch((e: Error) => e) + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + console.log(`[offline-node-headers] ${NODE_IMAGE} no-headers: ${message.split('\n')[0]}`) + expect(message).toContain('no local headers matching its own version') + expect(message).not.toContain('Orca defect') + expect(message).toContain('ECONNREFUSED') + } finally { + await connection.disconnect() + } + }, 600_000) + } +)