diff --git a/src/main/ssh/build-toolchain-diagnosis.ts b/src/main/ssh/build-toolchain-diagnosis.ts index c64a41ead51..cfd14dda25c 100644 --- a/src/main/ssh/build-toolchain-diagnosis.ts +++ b/src/main/ssh/build-toolchain-diagnosis.ts @@ -163,3 +163,40 @@ 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. It is neither -- the host's Node + * ships no headers at `/include/node` (so the local-headers export found nothing) AND it + * cannot reach nodejs.org. Both are things the operator can fix; neither is visible from the log. + */ +export function isNodeHeadersDownloadFailure(message: string): boolean { + return ( + message.toLowerCase().includes('gyp') && + NODE_HEADERS_TARBALL_RE.test(message) && + /\b(ECONNREFUSED|ENOTFOUND|ETIMEDOUT|ECONNRESET|EAI_AGAIN|EHOSTUNREACH|ENETUNREACH|fetch failed|FetchError)\b/i.test( + message + ) + ) +} + +export function formatNodeHeadersDownloadError(underlyingError: string): string { + return [ + 'The remote host could not download the Node.js headers needed to compile node-pty, and its ' + + 'Node install does not ship them locally. node-pty has no prebuilt binary for Linux, so it ' + + 'must be compiled on the remote host, and node-gyp fetches the headers from nodejs.org ' + + 'unless the Node install provides them at /include/node.', + '', + '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 include the headers; or', + ' - Allow outbound HTTPS to nodejs.org, or point npm at a mirror: ' + + 'npm config set disturl https:///dist', + '', + `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..46130f32035 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,47 @@ 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' + ) + ).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) + }) +}) + +describe('formatNodeHeadersDownloadError', () => { + it('names both remedies and keeps the underlying error', () => { + const msg = formatNodeHeadersDownloadError(HEADERS_REFUSED) + expect(msg).toContain('/include/node') + expect(msg).toContain('nodejs.org') + expect(msg).toContain('disturl') + 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..59ba3b9b7d2 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 } 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,11 @@ async function installNativeDeps( return } } + // Why: the local-headers export already found nothing on this host, so what is left is a host + // that is both header-less and offline -- name it, or the log reads as a broken relay. + if (platform.startsWith('linux') && isNodeHeadersDownloadFailure(msg)) { + throw new Error(formatNodeHeadersDownloadError(msg), { cause: err }) + } throw err } @@ -1347,7 +1355,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 +1537,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..76f6b6bf3d6 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,47 @@ 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') + } + }) + + it('names the fix when node-gyp cannot download headers and the host ships none (STA-6674)', async () => { + const conn = makeMockConnection(sftpCapture) + feed( + makeExecResponses({ + npmInstall: { + reject: + 'Command "npm install" failed (exit 1): 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' + } + }) + ) + + 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('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('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..a7b4df8f8b7 --- /dev/null +++ b/src/main/ssh/ssh-relay-node-headers.test.ts @@ -0,0 +1,98 @@ +import { spawnSync } from 'node:child_process' +import { 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 } 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 } { + 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 [nodedir = '', pkgNodedir = ''] = result.stdout.split('\n') + return { nodedir, pkgNodedir } +} + +/** 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 } = runPrefix(process.execPath) + expect(nodedir).toBe(prefix) + expect(pkgNodedir).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') + spawnSync('cp', [process.execPath, copied]) + const { nodedir, pkgNodedir } = runPrefix(copied) + expect(nodedir).toBe('') + expect(pkgNodedir).toBe('') + }) + + 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 }) + spawnSync('cp', [process.execPath, copied]) + 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('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('after:') + }) +}) 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..5dc6637dc6e --- /dev/null +++ b/src/main/ssh/ssh-relay-node-headers.ts @@ -0,0 +1,52 @@ +/** + * 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. A distro `/usr/include/node` left by + * an older headers package would compile a binding for the wrong ABI, which loads and crashes + * instead of failing at install. 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('') + +/** + * 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` + return ( + `${NODEDIR_SHELL_VAR}=$(${probe}); ` + + `if [ -n "$${NODEDIR_SHELL_VAR}" ]; then ` + + `export npm_config_nodedir="$${NODEDIR_SHELL_VAR}" npm_package_config_node_gyp_nodedir="$${NODEDIR_SHELL_VAR}"; ` + + `fi; ` + ) +} 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..7805cb9040c --- /dev/null +++ b/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts @@ -0,0 +1,153 @@ +// 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 { 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) +} + +function startTarget(): TargetFixture { + 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)) + return { containerName, identityFile, port, tempDir } +} + +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(() => { + fixture = 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) + } +)