Files
orca/config/scripts/desktop-distribution/runtime-verification/verify-packaged-daemon-entry.cjs
Vitalii KulishandNeil 4824562f3e fix(packaging): run the terminal host PTY probe in /bin/sh (#39602)
The packaged daemon PTY check types a POSIX arithmetic command and waits
for "OK 42". The daemon picked the PTY shell from the builder's $SHELL, so
a fish builder never printed it and Linux afterPack timed out. Pin the
probe daemon's SHELL to /bin/sh so the check exercises the packaged PTY,
not the builder's login shell.

Fixes #39591

Co-authored-by: Neil <neil@stably.ai>
2026-11-08 03:35:16 -08:00

299 lines
11 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
const { existsSync, mkdtempSync, readFileSync, rmSync, statSync } = require('node:fs')
const { spawn, spawnSync } = require('node:child_process')
const { randomUUID } = require('node:crypto')
const { connect } = require('node:net')
const { tmpdir } = require('node:os')
const { join, relative } = require('node:path')
const { setTimeout: delay } = require('node:timers/promises')
const {
macTerminalHostPaths
} = require('../../macos/terminal-host-bundle/macos-terminal-host-bundle.cjs')
const {
daemonProtocolVersion
} = require('../../../../src/shared/local-build-compatibility-contract.json')
// Require exact asar-unpacked daemon entry on every platform; missing path indicates layout regression and must not silently skip.
function assertPackagedDaemonEntryExists(resourcesDir) {
const entryPath = join(resourcesDir, 'app.asar.unpacked', 'out', 'main', 'daemon-entry.js')
if (!existsSync(entryPath)) {
throw new Error(
`[verify-packaged-daemon-entry] missing unpacked daemon entry at ${entryPath} — ` +
`asarUnpack expects out/main/daemon-entry.js on every platform, so the packaged ` +
`daemon cannot be forked from this layout`
)
}
return entryPath
}
// Boot packaged daemon graph under plain Node: argv Usage proves module loading, not PTYs; missing usage/module fails packaging (rc.1 incident).
// resourcesDir is packaged Resources; default execPath is packaging Node.
function verifyPackagedDaemonEntryBoots(resourcesDir, options = {}) {
const execPath = options.execPath || process.execPath
const entryPath = assertPackagedDaemonEntryExists(resourcesDir)
const result = spawnSync(execPath, [entryPath], { encoding: 'utf8', timeout: 10_000 })
if (result.error) {
throw new Error(
`[verify-packaged-daemon-entry] could not launch daemon-entry.js: ${result.error.message}`
)
}
const stderr = result.stderr || ''
if (/Cannot find module|MODULE_NOT_FOUND/.test(stderr)) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js failed to load under plain Node:\n${stderr}`
)
}
if (!stderr.includes('Usage: daemon-entry')) {
throw new Error(
`[verify-packaged-daemon-entry] packaged daemon-entry.js did not reach argv parsing ` +
`(expected the "Usage: daemon-entry" error). stderr:\n${stderr}`
)
}
console.log('[verify-packaged-daemon-entry] OK — packaged daemon-entry loads under plain Node')
}
const TERMINAL_HOST_BOOT_TIMEOUT_MS = 20_000
// Arithmetic so the shell echoing the typed command cannot satisfy the check.
const TERMINAL_HOST_BOOT_COMMAND = 'echo "OK $((6*7))" && exit'
const TERMINAL_HOST_BOOT_OUTPUT = 'OK 42'
/** The command prefix that runs a `targetArch` Mach-O here, or null when this host cannot. */
function macCommandPrefixForArch(targetArch, run = spawnSync) {
if (targetArch === process.arch) {
return []
}
if (targetArch === 'x64' && process.arch === 'arm64') {
const rosetta = run('/usr/bin/arch', ['-x86_64', '/usr/bin/true'], { timeout: 10_000 })
return rosetta.status === 0 ? ['/usr/bin/arch', '-x86_64'] : null
}
return null
}
/** Settles with `promise`, or rejects once `deadline` passes. */
function beforeDeadline(promise, deadline, what) {
let timer
const timeout = new Promise((_, reject) => {
timer = setTimeout(
() => reject(new Error(`timed out waiting for ${what}`)),
Math.max(0, deadline - Date.now())
)
})
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer))
}
// NDJSON ready settles on hello; broken rejects any later error/close/non-JSON response.
function openDaemonConnection(socketPath, hello) {
const socket = connect(socketPath)
const listeners = []
let fail
const broken = new Promise((_, reject) => {
fail = (error) => {
reject(error)
socket.destroy()
}
})
broken.catch(() => {})
let greet
const greeted = new Promise((resolve) => {
greet = resolve
})
let isGreeted = false
let buffer = ''
socket.setEncoding('utf8')
socket.on('error', (error) => fail(error))
socket.on('close', () => fail(new Error(`the daemon closed the ${hello.role} connection`)))
socket.on('data', (chunk) => {
buffer += chunk.toString()
let newline
while ((newline = buffer.indexOf('\n')) !== -1) {
const line = buffer.slice(0, newline)
buffer = buffer.slice(newline + 1)
let message
try {
message = JSON.parse(line)
} catch {
fail(new Error(`the daemon sent a non-JSON ${hello.role} line: ${line.slice(0, 200)}`))
return
}
if (!isGreeted) {
isGreeted = true
if (message?.ok) {
greet()
} else {
fail(new Error(`daemon rejected ${hello.role} hello: ${message?.error}`))
}
continue
}
for (const listener of listeners) {
listener(message)
}
}
})
socket.write(`${JSON.stringify({ type: 'hello', ...hello })}\n`)
return {
socket,
broken,
ready: Promise.race([greeted, broken]),
onMessage: (listener) => listeners.push(listener)
}
}
async function stopDaemon(daemon, isExited) {
if (isExited()) {
return
}
const exited = new Promise((resolve) => daemon.once('exit', resolve))
const waitForExit = (ms) =>
beforeDeadline(exited, Date.now() + ms, 'the daemon to exit').catch(() => {})
daemon.kill('SIGTERM')
await waitForExit(3_000)
if (!isExited()) {
daemon.kill('SIGKILL')
await waitForExit(1_000)
}
}
async function runTerminalHostRoundTrip({
command,
entry,
deadline,
stderr,
scratchPrefix = '/tmp/oth-'
}) {
// A short path: the socket must fit sun_path.
const scratch = mkdtempSync(scratchPrefix)
const socketPath = join(scratch, 'd.sock')
const tokenPath = join(scratch, 'token')
// POSIX sh so the probe's arithmetic does not depend on the builder's login shell (e.g. fish).
const env = { ...process.env, SHELL: '/bin/sh', ORCA_USER_DATA_PATH: join(scratch, 'user-data') }
delete env.ELECTRON_RUN_AS_NODE
const daemon = spawn(
command[0],
[...command.slice(1), entry, '--socket', socketPath, '--token', tokenPath],
{ env, stdio: ['ignore', 'ignore', 'pipe'] }
)
let exited = false
daemon.on('exit', () => {
exited = true
})
daemon.on('error', (error) => {
exited = true
stderr.push(error.message)
})
daemon.stderr.setEncoding('utf8')
daemon.stderr.on('data', (chunk) => stderr.push(chunk))
// If something exits the process mid-check, still leave no daemon or scratch dir behind.
const cleanupOnExit = () => {
daemon.kill('SIGKILL')
rmSync(scratch, { recursive: true, force: true })
}
process.once('exit', cleanupOnExit)
const connections = []
try {
// The first exec of a freshly signed binary can be slow while the system assesses it.
while (!(existsSync(socketPath) && existsSync(tokenPath) && statSync(tokenPath).size > 0)) {
if (exited || Date.now() > deadline) {
throw new Error(exited ? 'the daemon exited before it was ready' : 'no daemon token')
}
await delay(100)
}
const token = readFileSync(tokenPath, 'utf8').trim()
const clientId = randomUUID()
const hello = { version: daemonProtocolVersion, token, clientId }
const control = openDaemonConnection(socketPath, { ...hello, role: 'control' })
connections.push(control)
await beforeDeadline(control.ready, deadline, 'the control hello reply')
const stream = openDaemonConnection(socketPath, { ...hello, role: 'stream' })
connections.push(stream)
await beforeDeadline(stream.ready, deadline, 'the stream hello reply')
let output = ''
const sawOutput = new Promise((resolve) => {
stream.onMessage((message) => {
output += message?.payload?.data ?? ''
if (output.includes(TERMINAL_HOST_BOOT_OUTPUT)) {
resolve()
}
})
})
control.onMessage((message) => {
if (message?.id === 'boot-1' && message.ok === false) {
stderr.push(`createOrAttach failed: ${message.error}`)
}
})
control.socket.write(
`${JSON.stringify({
id: 'boot-1',
type: 'createOrAttach',
payload: {
sessionId: 'terminal-host-boot',
cols: 80,
rows: 24,
cwd: scratch,
command: TERMINAL_HOST_BOOT_COMMAND
}
})}\n`
)
try {
await beforeDeadline(
Promise.race([sawOutput, control.broken, stream.broken]),
deadline,
`"${TERMINAL_HOST_BOOT_OUTPUT}" from the PTY`
)
} catch (error) {
throw new Error(`${error.message}; output: ${output.slice(-400)}`)
}
} finally {
for (const connection of connections) {
connection.socket.destroy()
}
await stopDaemon(daemon, () => exited)
process.removeListener('exit', cleanupOnExit)
rmSync(scratch, { recursive: true, force: true })
}
}
// Probe helper’s own Node/node-pty round trip under release hardened runtime to exercise allow-jit and same-team library validation before notarization.
async function verifyPackagedMacTerminalHostBoots(appPath, { arch, required }) {
const paths = macTerminalHostPaths(appPath)
const prefix = macCommandPrefixForArch(arch)
if (!prefix) {
const message = `terminal host boot on ${arch} cannot run on this ${process.arch} host`
if (required) {
throw new Error(`[verify-packaged-daemon-entry] the ${message}`)
}
console.log(`[verify-packaged-daemon-entry] skipped ${message}`)
return
}
const stderr = []
// Boot clone outside build tree so ancestor node_modules cannot satisfy missing packaged dependency.
const isolated = mkdtempSync(join(tmpdir(), 'orca-terminal-host-boot-'))
const clone = join(isolated, 'Orca Terminal Host.app')
try {
const cloned = spawnSync('/bin/cp', ['-cR', paths.bundle, clone], { encoding: 'utf8' })
if (cloned.status !== 0) {
throw new Error(`could not clone the helper: ${cloned.stderr.trim()}`)
}
await runTerminalHostRoundTrip({
command: [...prefix, join(clone, relative(paths.bundle, paths.executable))],
entry: join(clone, relative(paths.bundle, paths.entry)),
deadline: Date.now() + TERMINAL_HOST_BOOT_TIMEOUT_MS,
stderr
})
} catch (error) {
throw new Error(
`[verify-packaged-daemon-entry] the ${arch} terminal host did not serve a PTY: ${error.message}\n${stderr.join('').slice(-2000)}`
)
} finally {
rmSync(isolated, { recursive: true, force: true })
}
console.log(`[verify-packaged-daemon-entry] OK — ${arch} terminal host served a PTY`)
}
module.exports = {
assertPackagedDaemonEntryExists,
macCommandPrefixForArch,
runTerminalHostRoundTrip,
verifyPackagedDaemonEntryBoots,
verifyPackagedMacTerminalHostBoots
}