diff --git a/config/scripts/build-orcad-prebuilds.mjs b/config/scripts/build-orcad-prebuilds.mjs index 73dbab5201e..4145edf1b2d 100644 --- a/config/scripts/build-orcad-prebuilds.mjs +++ b/config/scripts/build-orcad-prebuilds.mjs @@ -3,7 +3,8 @@ * Build one node-pty prebuilt for the CURRENT platform/arch/libc and file it in orcad's * prebuilds matrix, so a deployment target needs no C/C++ toolchain. * - * node-pty is the only ABI-sensitive native module orcad requires. It is also PATCHED in + * node-pty is the only ABI-sensitive native module every slot builds; a compat slot also + * builds the addons in COMPAT_SLOT_ADDONS (orcad-prebuild-compat-addons.mjs). node-pty is PATCHED in * this repo (config/patches/node-pty@1.1.0.patch), and that patch is the glibc-floor fix: * `.symver` pins on openpty/forkpty/pthread_sigmask plus the `--no-as-needed` ldflags that * keep libutil/libpthread in DT_NEEDED. An upstream prebuilt has none of it and reproduces @@ -40,13 +41,14 @@ import { highestGlibcNeed, isCompatSlot, mergeManifest, - prebuildCompileGypi, readManifest, sha256Of, slotGlibcFloor, slotSourceFiles, SLOT_NAPI_VERSION } from './orcad-prebuild-slot-contents.mjs' +import { compileCompatAddons } from './orcad-prebuild-compat-addons.mjs' +import { nodeGypRebuild, stageNodeAddonApi } from './orcad-prebuild-node-gyp.mjs' import { ensurePinnedNodeExecutable, preparePinnedNodeDir } from './pinned-node-downloads.mjs' export { readManifest } @@ -203,44 +205,23 @@ async function compileNodePty(sourceDir, slot) { ) const ptySourcePath = join(stagedDir, 'src', 'unix', 'pty.cc') writeFileSync(ptySourcePath, ptySourceForLibc(readFileSync(ptySourcePath, 'utf8'), libc)) - const addonApiDir = dirname( - require.resolve('node-addon-api/package.json', { paths: [sourceDir] }) - ) - cpSync(addonApiDir, join(stagedDir, 'node_modules', 'node-addon-api'), { - recursive: true, - dereference: true - }) + stageNodeAddonApi(sourceDir, stagedDir) if (process.platform === 'win32') { require('./node-pty-job-ownership.cjs').assertNodePtySourceDeniesMsysBreakaway({ nodePtyDir: stagedDir }) } - const compileGypi = join(workDir, 'prebuild-compile.gypi') - writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime: isCompatSlot(slot) })) const nodeDir = await preparePinnedNodeDir({ target: slot, workDir: join(workDir, 'nodedir') }) console.log( `[orcad-prebuilds] compiling patched node-pty for ${slot} against Node ${NODE_RUNTIME_PIN.version} headers, N-API ${SLOT_NAPI_VERSION} ...` ) - const { runProcessSync } = await import('./script-child-process.mjs') - const result = runProcessSync({ - program: process.execPath, - args: [ - join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), - 'rebuild', - `--nodedir=${nodeDir}`, - '--', - '-I', - compileGypi - ], - cwd: stagedDir, - stdio: 'inherit', - timeoutMs: null + const buildDir = await nodeGypRebuild({ + stagedDir, + workDir, + nodeDir, + staticCxxRuntime: isCompatSlot(slot) }) - if (result.code !== 0) { - throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.code})`) - } - const buildDir = join(stagedDir, 'build', 'Release') if (process.platform === 'win32') { require('./node-pty-job-ownership.cjs').assertRebuiltConptyDeniesMsysBreakaway({ nodePtyDir: stagedDir, @@ -248,7 +229,7 @@ async function compileNodePty(sourceDir, slot) { crossHost: false }) } - return buildDir + return { buildDir, nodeDir } } function requireSlots(slots) { @@ -310,16 +291,22 @@ async function build() { const slot = slotName() assertCompatSlotHost(slot, { platform: process.platform, arch: process.arch, libc: detectLibc() }) const slotDir = join(PREBUILDS_DIR, slot) - const buildDir = await compileNodePty(sourceDir, slot) + const { buildDir, nodeDir } = await compileNodePty(sourceDir, slot) + const compatAddons = isCompatSlot(slot) + ? await compileCompatAddons({ slot, workDir: join(WORK_DIR, slot), nodeDir }) + : [] rmSync(slotDir, { recursive: true, force: true }) const files = {} - for (const [relative, source] of slotSourceFiles({ - platform: process.platform, - arch: process.arch, - buildDir, - nodePtyDir: sourceDir - })) { + for (const [relative, source] of [ + ...slotSourceFiles({ + platform: process.platform, + arch: process.arch, + buildDir, + nodePtyDir: sourceDir + }), + ...compatAddons + ]) { if (!existsSync(source)) { throw new Error(`[orcad-prebuilds] ${slot} needs ${relative}, but ${source} is missing`) } diff --git a/config/scripts/build-orcad-template.mjs b/config/scripts/build-orcad-template.mjs index 355f2e2ba95..899116c07f8 100644 --- a/config/scripts/build-orcad-template.mjs +++ b/config/scripts/build-orcad-template.mjs @@ -29,7 +29,12 @@ import { pinnedNodeRuntimeAsset } from '../../src/shared/node-runtime-pin.ts' import { ORCAD_PREBUILDS_DIR } from './build-orcad-prebuilds.mjs' -import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' +import { + COMPAT_SLOT_ADDONS, + findCompatAddonGaps, + findSlotProblems, + readManifest +} from './orcad-prebuild-slot-contents.mjs' import { runProcessSync } from './script-child-process.mjs' import { verifyPackagedOrcadTemplate } from './verify-packaged-orcad-template.cjs' @@ -121,8 +126,8 @@ export function requestedTemplateTargets(argv = process.argv) { } /** - * A compat target (design D6 rung B) is its base target's package with the compat node-pty - * slot and runtime marker swapped in; everything else is target-independent or libc-static. + * A compat target (design D6 rung B) is its base target's package with the compat slot's addons + * and runtime marker swapped in; everything else is target-independent or libc-static. * Omitted, not failed, when this build has no compat slot: rung B then refuses as unavailable. */ function stageCompatTarget(compat, basePackageDir) { @@ -136,12 +141,21 @@ function stageCompatTarget(compat, basePackageDir) { return null } const destination = join(outputDir, ORCAD_TEMPLATE_TARGETS_DIR, compat) - const slotFiles = new Map( - orcadNodePtySlotFiles(compat).map((file) => [ + const slotFiles = new Map([ + ...orcadNodePtySlotFiles(compat).map((file) => [ `${ORCAD_NODE_PTY_DIR}/build/Release/${file}`, join(ORCAD_PREBUILDS_DIR, compat, ...file.split('/')) + ]), + ...Object.entries(COMPAT_SLOT_ADDONS).map(([file, shipped]) => [ + shipped, + join(ORCAD_PREBUILDS_DIR, compat, ...file.split('/')) ]) - ) + ]) + // Why fatal: the base binary would pass every check here and fail only on a compat host. + const gaps = findCompatAddonGaps(orcadTemplateTargetFilenames(compat), slotFiles) + if (gaps.length > 0) { + throw new Error(`compat target ${compat} would ship base-target addons: ${gaps.join(', ')}`) + } const files = {} for (const filename of orcadTemplateTargetFilenames(compat)) { const staged = join(destination, ...filename.split('/')) diff --git a/config/scripts/merge-orcad-prebuilds.test.mjs b/config/scripts/merge-orcad-prebuilds.test.mjs index 430cf1bca1f..4a937810aa8 100644 --- a/config/scripts/merge-orcad-prebuilds.test.mjs +++ b/config/scripts/merge-orcad-prebuilds.test.mjs @@ -1,9 +1,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { mergeOrcadPrebuildTrees } from './merge-orcad-prebuilds.mjs' -import { findSlotProblems, mergeManifest, sha256Of } from './orcad-prebuild-slot-contents.mjs' +import { + COMPAT_SLOT_ADDONS, + findSlotProblems, + isCompatSlot, + mergeManifest, + sha256Of +} from './orcad-prebuild-slot-contents.mjs' const dirs = [] function temp() { @@ -20,15 +26,20 @@ afterEach(() => { /** One CI lane's `out/orcad-prebuilds`: a single slot plus its manifest. */ function laneTree(slot, { version = '1.1.0', nodeHeaders = '24.21.0', bytes = slot } = {}) { const dir = temp() - mkdirSync(join(dir, slot), { recursive: true }) - const binary = join(dir, slot, 'pty.node') - writeFileSync(binary, bytes) + const files = {} + // A compat slot also carries its own addons. + for (const file of ['pty.node', ...(isCompatSlot(slot) ? Object.keys(COMPAT_SLOT_ADDONS) : [])]) { + const binary = join(dir, slot, ...file.split('/')) + mkdirSync(dirname(binary), { recursive: true }) + writeFileSync(binary, bytes) + files[file] = sha256Of(binary) + } const manifest = mergeManifest(null, { slot, version, napi: 8, nodeHeaders, - entry: { napi: 8, files: { 'pty.node': sha256Of(binary) } } + entry: { napi: 8, files } }) writeFileSync(join(dir, 'manifest.json'), JSON.stringify(manifest)) return dir diff --git a/config/scripts/orcad-prebuild-compat-addons.mjs b/config/scripts/orcad-prebuild-compat-addons.mjs new file mode 100644 index 00000000000..b7e35e4e991 --- /dev/null +++ b/config/scripts/orcad-prebuild-compat-addons.mjs @@ -0,0 +1,52 @@ +/** + * The addons a compat slot builds beside node-pty (design D6 rung B). The default slots take + * @parcel/watcher's upstream prebuild, which needs a newer libstdc++ than a glibc 2.17 host has, + * so the compat slot compiles it from the package's own sources with the C++ runtime static. + */ +import { cpSync, mkdirSync, rmSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' +import { nodeGypRebuild, stageNodeAddonApi } from './orcad-prebuild-node-gyp.mjs' +import { COMPAT_SLOT_ADDONS, SLOT_NAPI_VERSION } from './orcad-prebuild-slot-contents.mjs' + +const require = createRequire(import.meta.url) + +const BUILDERS = { + 'parcel-watcher/watcher.node': compileParcelWatcher +} + +/** `[slot-relative path, built file]` for every compat addon, compiled under `workDir`. */ +export async function compileCompatAddons({ slot, workDir, nodeDir }) { + const built = [] + for (const relative of Object.keys(COMPAT_SLOT_ADDONS)) { + const builder = BUILDERS[relative] + if (!builder) { + throw new Error(`[orcad-prebuilds] no builder for compat addon ${relative}`) + } + built.push([relative, await builder({ slot, workDir, nodeDir })]) + } + return built +} + +async function compileParcelWatcher({ slot, workDir, nodeDir }) { + const sourceDir = dirname(require.resolve('@parcel/watcher/package.json')) + const addonWorkDir = join(workDir, 'parcel-watcher') + const stagedDir = join(addonWorkDir, 'watcher') + rmSync(addonWorkDir, { recursive: true, force: true }) + mkdirSync(stagedDir, { recursive: true }) + for (const entry of ['package.json', 'binding.gyp', 'src']) { + cpSync(join(sourceDir, entry), join(stagedDir, entry), { recursive: true }) + } + stageNodeAddonApi(sourceDir, stagedDir) + console.log( + `[orcad-prebuilds] compiling @parcel/watcher for ${slot} against Node ${NODE_RUNTIME_PIN.version} headers, N-API ${SLOT_NAPI_VERSION} ...` + ) + const buildDir = await nodeGypRebuild({ + stagedDir, + workDir: addonWorkDir, + nodeDir, + staticCxxRuntime: true + }) + return join(buildDir, 'watcher.node') +} diff --git a/config/scripts/orcad-prebuild-node-gyp.mjs b/config/scripts/orcad-prebuild-node-gyp.mjs new file mode 100644 index 00000000000..4391b93bbee --- /dev/null +++ b/config/scripts/orcad-prebuild-node-gyp.mjs @@ -0,0 +1,44 @@ +// node-gyp rebuild of one staged addon against the pinned Node headers, shared by every slot addon. +import { cpSync, writeFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' +import process from 'node:process' +import { prebuildCompileGypi } from './orcad-prebuild-slot-contents.mjs' + +const require = createRequire(import.meta.url) +const ROOT = join(import.meta.dirname, '..', '..') + +/** Copies node-addon-api beside a staged addon, since scratch copies leave the pnpm tree behind. */ +export function stageNodeAddonApi(sourceDir, stagedDir) { + const addonApiDir = dirname( + require.resolve('node-addon-api/package.json', { paths: [sourceDir] }) + ) + cpSync(addonApiDir, join(stagedDir, 'node_modules', 'node-addon-api'), { + recursive: true, + dereference: true + }) +} + +export async function nodeGypRebuild({ stagedDir, workDir, nodeDir, staticCxxRuntime }) { + const compileGypi = join(workDir, 'prebuild-compile.gypi') + writeFileSync(compileGypi, prebuildCompileGypi({ staticCxxRuntime })) + const { runProcessSync } = await import('./script-child-process.mjs') + const result = runProcessSync({ + program: process.execPath, + args: [ + join(ROOT, 'node_modules', 'node-gyp', 'bin', 'node-gyp.js'), + 'rebuild', + `--nodedir=${nodeDir}`, + '--', + '-I', + compileGypi + ], + cwd: stagedDir, + stdio: 'inherit', + timeoutMs: null + }) + if (result.code !== 0) { + throw new Error(`[orcad-prebuilds] node-gyp rebuild failed (status ${result.code})`) + } + return join(stagedDir, 'build', 'Release') +} diff --git a/config/scripts/orcad-prebuild-slot-contents.mjs b/config/scripts/orcad-prebuild-slot-contents.mjs index 3f817da251b..4084d9fcc20 100644 --- a/config/scripts/orcad-prebuild-slot-contents.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.mjs @@ -1,5 +1,6 @@ /** - * What goes into one orcad node-pty prebuild slot, and the manifest that records it. + * What goes into one orcad node-pty prebuild slot (plus a compat slot's own addons), and the + * manifest that records it. * * The manifest is the loader's contract (src/main/orcad/node-pty-prebuilt-slot.ts): per-slot * N-API level, libc, the highest glibc symbol version the binaries need, and a sha256 per @@ -33,6 +34,23 @@ export const COMPAT_SLOTS = Object.freeze({ 'linux-x64-glibc217': Object.freeze({ platform: 'linux', arch: 'x64', libc: 'glibc' }) }) +/** + * Native addons a compat slot builds beside node-pty, by slot-relative path, mapped to where an + * orcad slot ships them. A compat target ships no native file without a compat build. + */ +export const COMPAT_SLOT_ADDONS = Object.freeze({ + 'parcel-watcher/watcher.node': 'node_modules/@parcel/watcher/watcher.node' +}) + +/** + * The native files of a compat target with no compat build behind them: each would be the base + * target's binary, built for a newer glibc and libstdc++ than the compat host has. + * `compatSources` maps orcad slot paths to the compat slot files that fill them. + */ +export function findCompatAddonGaps(targetFilenames, compatSources) { + return targetFilenames.filter((file) => file.endsWith('.node') && !compatSources.has(file)) +} + export function isCompatSlot(slot) { return Object.hasOwn(COMPAT_SLOTS, slot) } @@ -217,6 +235,13 @@ export function findSlotProblems(manifest, prebuildsDir, requiredSlots) { problems.push(`${slot}: not built`) continue } + if (isCompatSlot(slot)) { + for (const addon of Object.keys(COMPAT_SLOT_ADDONS)) { + if (!Object.hasOwn(entry.files ?? {}, addon)) { + problems.push(`${slot}/${addon}: not built`) + } + } + } for (const [file, expected] of Object.entries(entry.files ?? {})) { const path = join(prebuildsDir, slot, ...file.split('/')) if (!existsSync(path)) { diff --git a/config/scripts/orcad-prebuild-slot-contents.test.mjs b/config/scripts/orcad-prebuild-slot-contents.test.mjs index a60ddbe817d..720a0436c3e 100644 --- a/config/scripts/orcad-prebuild-slot-contents.test.mjs +++ b/config/scripts/orcad-prebuild-slot-contents.test.mjs @@ -5,7 +5,9 @@ import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' import { assertCompatSlotHost, + COMPAT_SLOT_ADDONS, COMPAT_SLOTS, + findCompatAddonGaps, findPostBaselineNodeApiNames, findSharedCxxRuntimeNeeds, findSlotProblems, @@ -19,7 +21,10 @@ import { SLOT_NAPI_VERSION, windowsConptyRuntimeDir } from './orcad-prebuild-slot-contents.mjs' -import { ORCAD_ADDON_NAPI_VERSION } from '../../src/shared/orcad-artifacts.ts' +import { + ORCAD_ADDON_NAPI_VERSION, + orcadTemplateTargetFilenames +} from '../../src/shared/orcad-artifacts.ts' const floors = createRequire(import.meta.url)('./verify-linux-glibc-floor.cjs') const dirs = [] @@ -232,4 +237,37 @@ describe('findSlotProblems', () => { 'manifest.json is missing or not schema 2' ]) }) + + it('refuses a compat slot missing one of its own addons', () => { + const dir = temp() + const slot = 'linux-x64-glibc217' + mkdirSync(join(dir, slot)) + writeFileSync(join(dir, slot, 'pty.node'), 'binary') + const manifest = mergeManifest( + null, + next(slot, { entry: entry({ 'pty.node': sha256Of(join(dir, slot, 'pty.node')) }) }) + ) + expect(findSlotProblems(manifest, dir, [slot])).toEqual([ + `${slot}/parcel-watcher/watcher.node: not built` + ]) + }) +}) + +describe('compat addon coverage', () => { + const nodePtySources = (target) => + orcadTemplateTargetFilenames(target).filter((file) => file.includes('node-pty/build/Release/')) + + it('gives every native addon a compat target ships a compat build', () => { + for (const compat of Object.keys(COMPAT_SLOTS)) { + const sources = new Set([...nodePtySources(compat), ...Object.values(COMPAT_SLOT_ADDONS)]) + expect(findCompatAddonGaps(orcadTemplateTargetFilenames(compat), sources)).toEqual([]) + } + }) + + it('names a native file that would ship as the base target build', () => { + const target = 'linux-x64-glibc217' + expect( + findCompatAddonGaps(orcadTemplateTargetFilenames(target), new Set(nodePtySources(target))) + ).toEqual(['node_modules/@parcel/watcher/watcher.node']) + }) }) diff --git a/config/scripts/orcad-prebuild-smoke-child.cjs b/config/scripts/orcad-prebuild-smoke-child.cjs index 67f784eaf69..2fbb2e45e45 100644 --- a/config/scripts/orcad-prebuild-smoke-child.cjs +++ b/config/scripts/orcad-prebuild-smoke-child.cjs @@ -2,7 +2,7 @@ const { join } = require('node:path') const { tmpdir } = require('node:os') -const [nodePtyDir, expectedVersion] = process.argv.slice(2) +const [nodePtyDir, expectedVersion, ...addons] = process.argv.slice(2) if (!nodePtyDir || !expectedVersion) { throw new Error('usage: orcad-prebuild-smoke-child.cjs ') } @@ -11,6 +11,10 @@ if (process.version !== `v${expectedVersion}`) { } const pty = require(nodePtyDir) +// A compat slot's own addons: loading proves their glibc and C++ runtime needs resolve here. +for (const addon of addons) { + require(addon) +} if (process.platform === 'win32') { // Loaded only by the non-DLL kill path; prove the shipped module still loads under this Node. const { loadNativeModule } = require(join(nodePtyDir, 'lib', 'utils')) diff --git a/config/scripts/orcad-prebuild-smoke.mjs b/config/scripts/orcad-prebuild-smoke.mjs index 363e3ea7d62..52987a15eb6 100644 --- a/config/scripts/orcad-prebuild-smoke.mjs +++ b/config/scripts/orcad-prebuild-smoke.mjs @@ -3,7 +3,12 @@ import { chmodSync, cpSync, existsSync, mkdirSync, rmSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { NODE_RUNTIME_PIN } from '../../src/shared/node-runtime-pin.ts' -import { findSlotProblems, readManifest } from './orcad-prebuild-slot-contents.mjs' +import { + COMPAT_SLOT_ADDONS, + findSlotProblems, + isCompatSlot, + readManifest +} from './orcad-prebuild-slot-contents.mjs' import { ensurePinnedNodeExecutable } from './pinned-node-downloads.mjs' import { runProcessSync } from './script-child-process.mjs' @@ -28,6 +33,15 @@ export function stageSmokeNodePty({ slotDir, stageDir }) { return nodePtyDir } +/** stageSmokeNodePty copies the whole slot into build/Release, compat addons included. */ +function compatAddonPaths(slot, nodePtyDir) { + return isCompatSlot(slot) + ? Object.keys(COMPAT_SLOT_ADDONS).map((file) => + join(nodePtyDir, 'build', 'Release', ...file.split('/')) + ) + : [] +} + export async function runOrcadPrebuildSmoke({ slot, prebuildsDir }) { const problems = findSlotProblems(readManifest(prebuildsDir), prebuildsDir, [slot]) if (problems.length > 0) { @@ -43,7 +57,8 @@ export async function runOrcadPrebuildSmoke({ slot, prebuildsDir }) { args: [ join(import.meta.dirname, 'orcad-prebuild-smoke-child.cjs'), nodePtyDir, - NODE_RUNTIME_PIN.version + NODE_RUNTIME_PIN.version, + ...compatAddonPaths(slot, nodePtyDir) ], timeoutMs: 60_000 }) diff --git a/src/main/ssh/ssh-hostile-host-cells.ts b/src/main/ssh/ssh-hostile-host-cells.ts index 3c3abb288fa..f15d0daf218 100644 --- a/src/main/ssh/ssh-hostile-host-cells.ts +++ b/src/main/ssh/ssh-hostile-host-cells.ts @@ -185,14 +185,8 @@ export const HOSTILE_HOST_CELLS: readonly HostileHostCell[] = [ runtime: 'linux-x64-glibc217', refusals: [{ step: 'A', reason: 'libc_floor' }] }, - // Managed orcad picks the same compat runtime, but the template's @parcel/watcher needs a newer - // libstdc++ than CentOS 7 ships, so its preflight refuses and the host keeps relay rung B. - managed: { - outcome: 'refused', - runtime: 'linux-x64-glibc217', - code: 'orcad_candidate_preflight_failed', - relayRung: 'B' - } + // Managed orcad runs on the same compat runtime and slot, so an empty CentOS 7 host is managed. + managed: { outcome: 'activated', runtime: 'linux-x64-glibc217' } }, { // The client uploads the runtime over SSH, so a host that cannot reach nodejs.org still runs A. diff --git a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts index 524131b2b83..e5949b83411 100644 --- a/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts +++ b/src/main/ssh/ssh-relay-hostile-hosts.docker.test.ts @@ -15,6 +15,7 @@ vi.mock('electron', () => ({ app: { getAppPath: () => process.cwd() } })) import { posix } from 'node:path' import { NODE_RUNTIME_PIN } from '../../shared/node-runtime-pin' +import { ORCAD_PARCEL_WATCHER_NATIVE } from '../../shared/orcad-artifacts' import type { SshConnection } from './ssh-connection' import { HOSTILE_HOST_CELLS, selectHostileHostCells } from './ssh-hostile-host-cells' import { proveManagedOrcadCell } from './ssh-hostile-host-managed-orcad' @@ -105,6 +106,9 @@ describe('SSH relay hostile-host matrix', () => { if (target.kind === 'local-sshd' && target.cell.runsOn.platform === 'darwin') { await assertRunsWithoutQuarantine(target, launched.nodePath) } + // The relay runs on without its watcher, so only a direct load proves the slot's build fits. + const watcher = `${first.deployed?.remoteRelayDir}/${ORCAD_PARCEL_WATCHER_NATIVE}` + await hostExec(target, `'${launched.nodePath}' -e "require('${watcher}')"`) } else if ( cell.expect.outcome !== 'legacy_opt_out' && cell.expect.refusals[0]?.reason === 'libc_floor'