Files
orca/src/main/ssh/ssh-relay-offline-node-headers.docker.test.ts
T
Neil 36a826ff48 fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org (STA-6674) (#18774)
* fix(ssh): compile node-pty from the host's own Node headers instead of nodejs.org

STA-6674: a Linux SSH host that cannot reach nodejs.org never came up. node-pty
ships no Linux prebuild, so npm hands it to node-gyp, and node-gyp's default is
to download node-v<ver>-headers.tar.gz before configuring. The host refused
that connection (ECONNREFUSED) and the relay deploy failed inside npm install,
which the UI showed only as "Disconnected".

Every official Node build and every version manager that unpacks one already
has those exact headers at <prefix>/include/node. Export node-gyp's nodedir to
that prefix, on every command that can compile node-pty (npm install, npm
rebuild, the cloexec patch's rebuild), when the shipped node_version.h matches
the running Node. Both npm_config_nodedir (node-gyp 10, Node 20) and
npm_package_config_node_gyp_nodedir (node-gyp >= 11.4) are set so every Node
the relay runs on reads it. A version mismatch leaves it unset, which is the
existing behaviour.

When a host is both header-less and offline, name that in the deploy error
instead of forty lines of gyp http output, with the two remedies.

Reproduced and verified with a Docker sshd whose nodejs.org resolves to
127.0.0.1, on node:24.12.0 (the user's version), node:20 and node:26:
ssh-relay-offline-node-headers.docker.test.ts.

* fix(ssh): fail loudly when node-gyp ignores the exported Node headers dir

The headers export relies on npm forwarding npm_config_nodedir /
npm_package_config_node_gyp_nodedir into lifecycle scripts. If a future npm
drops that, node-gyp would silently fall back to downloading, and an offline
host would fail with the same "install an official Node" diagnosis -- wrong,
since the host did ship headers.

The prefix now echoes ORCA-NODE-HEADERS:<dir|none> into the command's output
before the compile, and the download-failure diagnosis reads it back: an
exported dir plus a download attempt is reported as an Orca defect naming
the dir, not as a host problem. Nothing else changes when it works.

* fix(ssh): address review on the relay node-headers export

- Unset any inherited npm_config_nodedir / npm_package_config_node_gyp_nodedir
  before the conditional export, so a stale header dir from the remote profile
  cannot bypass the version check and build a wrong-ABI binding (CodeRabbit).
- Require `gyp ERR! configure error` and a real network errno in the
  headers-download matcher; node-gyp's fetch client logs retried attempts it
  recovers from, and a FetchError can be a non-2xx mirror answer (pullfrog).
- Say "no local headers matching its own version", since the probe also
  rejects a version mismatch, not only absent headers (CodeRabbit).
- Log the same diagnosis from the non-fatal `npm rebuild` fallback (CodeRabbit).
- Docker test waits for the SSH banner on the mapped port before connecting
  instead of trusting `docker run -d` (CodeRabbit).

* fix(ssh): read the node-headers marker from the host output, not the quoted command

execCommand rejects with `Command "<command>" failed (exit N): <output>`, and
<command> quotes the whole prefix, marker echo included. The first-match
parser hit that copy and returned `${ORCA_NODE_HEADERS_DIR:-none}"; ...` as a
"dir", so every real no-headers failure was misreported as an Orca defect
(measured by an independent Docker exercise of 609685e). Strip the exec-
failure head before scanning; keep first-match so gyp output cannot spoof it.

The unit fixture hid this by rejecting with `Command "npm install" failed`,
a string production never builds. It now rejects from the command the mock
actually received, and the Docker test gains a no-headers failure case on the
same offline fixture that asserts the host-remedy message.

Also unset NPM_CONFIG_NODEDIR (npm accepts either case), and narrow the claim:
a ~/.npmrc nodedir= is not overridable from the env (measured: empty env
override is ignored on npm 10 and 11), so it stays the operator's setting.
Copy the node binary via fs in the unit test so a failed copy fails the test.

* docs(ssh): state the header-mismatch refusal as a conservative default, not an observed crash

* docs(ssh): note why the exec-failure head regex may match lazily
2026-09-04 23:47:32 -07:00

205 lines
8.1 KiB
TypeScript

// 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<ver>-headers.tar.gz` unless told the host already has the headers -- which
// every official Node install does, at `<prefix>/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<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))
// `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<void> {
const deadline = Date.now() + deadlineMs
for (;;) {
const gotBanner = await new Promise<boolean>((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)
}
)