mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
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
This commit is contained in:
@@ -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 <code>`
|
||||
// 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 <prefix>/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://<mirror>/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://<mirror>/dist'
|
||||
]
|
||||
return [...lines, '', `Underlying install error: ${underlyingError}`].join('\n')
|
||||
}
|
||||
|
||||
@@ -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('<prefix>/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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,9 @@ export {
|
||||
shouldProbeBuildToolchainAfterNativeDepsFailure,
|
||||
toolchainInstallHintLines,
|
||||
formatSkippedNodePtyWarning,
|
||||
formatMissingToolchainError
|
||||
formatMissingToolchainError,
|
||||
formatNodeHeadersDownloadError,
|
||||
isNodeHeadersDownloadFailure
|
||||
} from './build-toolchain-diagnosis'
|
||||
export type { BuildToolchainStatus } from './build-toolchain-diagnosis'
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' }))
|
||||
|
||||
@@ -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 `<prefix>/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()
|
||||
})
|
||||
})
|
||||
@@ -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<ver>-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 `<prefix>/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_<key>` and npm 11+
|
||||
* warns that arbitrary `npm_config_<key>` is deprecated, but node-gyp 10 (bundled with Node 20)
|
||||
* reads only `npm_config_<key>`. 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 `<prefix>/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 "<command>" failed
|
||||
// (exit N): <output>`, and <command> 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 "<anything, quotes included>" 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+\): /
|
||||
@@ -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<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)
|
||||
}
|
||||
)
|
||||
Reference in New Issue
Block a user