Files
orca/config/scripts/ssh-relay-node-pty-build.mjs
T
2026-07-14 15:38:24 -04:00

230 lines
8.5 KiB
JavaScript

import { execFile } from 'node:child_process'
import { copyFile, cp, mkdir, readFile, readdir, stat } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { dirname, join, resolve } from 'node:path'
import { promisify } from 'node:util'
import { applyWindowsNodePtySettlement } from './ssh-relay-node-pty-windows-settlement.mjs'
import {
applyWindowsNodePtyBuildDeterminism,
assertWindowsNodePtyGeneratedBuildSettings,
inspectWindowsNodePtyLinkCommandTracking
} from './ssh-relay-node-pty-windows-build-determinism.mjs'
const require = createRequire(import.meta.url)
const execFileAsync = promisify(execFile)
const NODE_GYP_PATH = require.resolve('node-gyp/bin/node-gyp.js')
const NODE_ADDON_API_DIRECTORY = dirname(require.resolve('node-addon-api/package.json'))
const BUILD_TIMEOUT_MS = 10 * 60 * 1000
const COMMAND_TIMEOUT_MS = 60 * 1000
const MAX_COMMAND_OUTPUT_BYTES = 16 * 1024 * 1024
export function nodePtyNativeBuildCommands({ nodePath, nodeRoot, tuple }) {
const nodeGypArguments = ['--release', `--nodedir=${nodeRoot}`]
if (!tuple.startsWith('darwin-')) {
return [{ command: nodePath, args: [NODE_GYP_PATH, 'rebuild', ...nodeGypArguments] }]
}
return [
{ command: nodePath, args: [NODE_GYP_PATH, 'configure', ...nodeGypArguments] },
{
command: 'make',
// Why: a canonical build path plus this flag retains a loadable, reproducible Mach-O UUID.
args: ['-C', 'build', 'BUILDTYPE=Release', 'LDFLAGS.target=-Wl,-reproducible']
}
]
}
async function runCommand(command, args, options = {}) {
// Why: native build tools can be noisy or hang; both cases must settle within explicit bounds.
const result = await execFileAsync(command, args, {
...options,
encoding: 'utf8',
maxBuffer: MAX_COMMAND_OUTPUT_BYTES,
timeout: options.timeout ?? COMMAND_TIMEOUT_MS,
windowsHide: true
})
if (result.stdout) {
process.stdout.write(result.stdout)
}
if (result.stderr) {
process.stderr.write(result.stderr)
}
return result
}
function sourceFilter(source) {
const normalized = source.replaceAll('\\', '/')
return !/(?:^|\/)build(?:\/|$)/.test(normalized) && !/(?:^|\/)prebuilds(?:\/|$)/.test(normalized)
}
async function assertPatchedSource(nodePtyDirectory) {
const [unixTerminal, ptySource, windowsAgent] = await Promise.all([
readFile(join(nodePtyDirectory, 'lib', 'unixTerminal.js'), 'utf8'),
readFile(join(nodePtyDirectory, 'src', 'unix', 'pty.cc'), 'utf8'),
readFile(join(nodePtyDirectory, 'lib', 'conpty_console_list_agent.js'), 'utf8')
])
if (
!unixTerminal.includes("if (!helperPath.includes('app.asar.unpacked'))") ||
!ptySource.includes('pty_format_spawn_error') ||
!windowsAgent.includes('consoleProcessList = [shellPid]')
) {
throw new Error('node-pty source is missing Orca-required patch markers')
}
}
async function assertBuiltArtifacts(buildDirectory, tuple) {
const releaseDirectory = join(buildDirectory, 'build', 'Release')
const nativePaths = tuple.startsWith('win32-')
? [
'conpty.node',
'conpty_console_list.node',
'pty.node',
'conpty/conpty.dll',
'conpty/OpenConsole.exe'
]
: ['pty.node']
for (const path of nativePaths) {
if (!(await stat(join(releaseDirectory, ...path.split('/')))).isFile()) {
throw new Error(`node-pty did not produce build/Release/${path}`)
}
}
if (tuple.startsWith('darwin-')) {
const helperPath = join(releaseDirectory, 'spawn-helper')
const helper = await stat(helperPath)
if (!helper.isFile() || (helper.mode & 0o111) === 0) {
throw new Error('macOS node-pty did not produce executable build/Release/spawn-helper')
}
}
}
async function stageWindowsConptyRuntime(buildDirectory, tuple) {
if (!tuple.startsWith('win32-')) {
return
}
const conptyRoot = join(buildDirectory, 'third_party', 'conpty')
const versions = (await readdir(conptyRoot, { withFileTypes: true })).filter((entry) =>
entry.isDirectory()
)
if (versions.length !== 1) {
throw new Error('node-pty must contain exactly one pinned ConPTY runtime version')
}
const architecture = tuple.endsWith('-arm64') ? 'arm64' : 'x64'
const sourceDirectory = join(conptyRoot, versions[0].name, `win10-${architecture}`)
const destinationDirectory = join(buildDirectory, 'build', 'Release', 'conpty')
await mkdir(destinationDirectory)
// Why: direct node-gyp bypasses node-pty's postinstall, while Orca explicitly uses this DLL path.
for (const name of ['conpty.dll', 'OpenConsole.exe']) {
const sourcePath = join(sourceDirectory, name)
if (!(await stat(sourcePath)).isFile()) {
throw new Error(`node-pty is missing pinned ${architecture} ConPTY runtime file: ${name}`)
}
await copyFile(sourcePath, join(destinationDirectory, name))
}
}
async function stripBuiltArtifacts(buildDirectory, tuple) {
const releaseDirectory = join(buildDirectory, 'build', 'Release')
const ptyPath = join(releaseDirectory, 'pty.node')
if (tuple.startsWith('linux-')) {
await runCommand('strip', ['--strip-unneeded', ptyPath], {
windowsHide: true,
env: process.env
})
} else if (tuple.startsWith('darwin-')) {
await runCommand('strip', ['-S', ptyPath, join(releaseDirectory, 'spawn-helper')], {
windowsHide: true,
env: process.env
})
}
}
export async function buildPatchedSshRelayNodePty({
projectRoot,
nodePath,
nodeRoot,
nodeVersion,
tuple,
buildDirectory
}) {
const sourceDirectory = resolve(projectRoot, 'node_modules', 'node-pty')
await assertPatchedSource(sourceDirectory)
await mkdir(buildDirectory)
await cp(sourceDirectory, buildDirectory, {
recursive: true,
dereference: true,
filter: sourceFilter
})
await applyWindowsNodePtyBuildDeterminism({ nodePtyDirectory: buildDirectory, tuple })
await applyWindowsNodePtySettlement({
nodePtyLibraryDirectory: join(buildDirectory, 'lib'),
tuple
})
await mkdir(join(buildDirectory, 'node_modules'), { recursive: true })
await cp(NODE_ADDON_API_DIRECTORY, join(buildDirectory, 'node_modules', 'node-addon-api'), {
recursive: true,
dereference: true
})
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), BUILD_TIMEOUT_MS)
try {
const buildEnvironment = {
...process.env,
PATH: `${dirname(nodePath)}${process.platform === 'win32' ? ';' : ':'}${process.env.PATH ?? ''}`,
npm_config_arch: tuple.includes('arm64') ? 'arm64' : 'x64',
npm_config_build_from_source: 'true',
npm_config_nodedir: nodeRoot,
// Why: a successful build must prove all Node inputs were staged, never fetched implicitly.
npm_config_offline: 'true',
npm_config_disturl: 'http://127.0.0.1:9',
npm_config_target: nodeVersion
}
for (const buildCommand of nodePtyNativeBuildCommands({ nodePath, nodeRoot, tuple })) {
await runCommand(buildCommand.command, buildCommand.args, {
cwd: buildDirectory,
signal: controller.signal,
timeout: BUILD_TIMEOUT_MS,
windowsHide: true,
env: buildEnvironment
})
}
const generatedSettings = await assertWindowsNodePtyGeneratedBuildSettings({
nodePtyDirectory: buildDirectory,
tuple
})
if (generatedSettings) {
// Why: MSBuild tracking layout differs by runner, so selection is bounded by target content.
const linkCommand = await inspectWindowsNodePtyLinkCommandTracking({
nodePtyDirectory: buildDirectory,
tuple
})
process.stdout.write(
`windows_node_pty_msbuild_settings=${JSON.stringify({
...generatedSettings,
linkCommand
})}\n`
)
}
} finally {
clearTimeout(timeout)
}
await stageWindowsConptyRuntime(buildDirectory, tuple)
await assertBuiltArtifacts(buildDirectory, tuple)
await stripBuiltArtifacts(buildDirectory, tuple)
// Why: loading with the bundled executable catches an accidental host-ABI build immediately.
const nativeNames = tuple.startsWith('win32-') ? ['conpty', 'conpty_console_list'] : ['pty']
await runCommand(
nodePath,
[
'-e',
`const {loadNativeModule}=require(${JSON.stringify(join(buildDirectory, 'lib', 'utils.js'))});` +
`for(const name of ${JSON.stringify(nativeNames)}){` +
`const loaded=loadNativeModule(name);` +
`if(!loaded.dir.replace(/\\\\/g,'/').includes('build/Release/'))process.exit(2);}`
],
{ cwd: buildDirectory, windowsHide: true, env: process.env }
)
return { buildDirectory, releaseDirectory: join(buildDirectory, 'build', 'Release') }
}